refactor(watch-history): extract pure codec helpers from watchHistory (5/10)

File-size decomposition (no behavior change). Move the dependency-free encode/
decode helpers (sanitizeWatchHistoryMetadata, serializeWatchHistoryItems,
bytesToHex, looksLikeJsonStructure, hexToBytesCompat) into a new
js/nostr/watchHistoryCodec.js and import them back.

watchHistory.js: 2512 -> 2435 lines (back under its 2438 limit).

Build + full unit suite green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-19 11:35:44 -04:00
1 parent 37a675392f
commit 8871c4e3a1
2 files changed
+95 -83

No files matched your search

+7 -83
View File
@@ -14,6 +14,13 @@ import {
NOTE_TYPES,
} from "../nostrEventSchemas.js";
import { CACHE_POLICIES } from "./cachePolicies.js";
import {
sanitizeWatchHistoryMetadata,
serializeWatchHistoryItems,
bytesToHex,
looksLikeJsonStructure,
hexToBytesCompat,
} from "./watchHistoryCodec.js";
import { publishEventToRelays } from "../nostrPublish.js";
import {
RELAY_URLS,
@@ -368,55 +375,6 @@ function canonicalizeWatchHistoryItems(rawItems, maxItems = WATCH_HISTORY_MAX_IT
return buckets;
}
function sanitizeWatchHistoryMetadata(metadata) {
return {};
}
function serializeWatchHistoryItems(items) {
if (!Array.isArray(items) || items.length === 0) {
return "[]";
}
const normalized = items
.map((item) => {
const type = item?.type === "a" ? "a" : "e";
const value = typeof item?.value === "string" ? item.value : "";
if (!type || !value) {
return null;
}
const relay =
typeof item?.relay === "string" && item.relay.trim()
? item.relay.trim()
: undefined;
const watchedAt = Number.isFinite(item?.watchedAt)
? Math.max(0, Math.floor(item.watchedAt))
: undefined;
const payload = { type, value };
if (relay) {
payload.relay = relay;
}
if (watchedAt !== undefined) {
payload.watchedAt = watchedAt;
}
const resumeAt = Number.isFinite(item?.resumeAt)
? Math.max(0, Math.floor(item.resumeAt))
: undefined;
if (resumeAt !== undefined) {
payload.resumeAt = resumeAt;
}
if (item?.completed === true) {
payload.completed = true;
}
return payload;
})
.filter(Boolean);
return JSON.stringify(normalized);
}
function bytesToHex(bytes) {
return Array.from(bytes)
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
}
async function computeWatchHistoryFingerprintForItems(itemsOrBuckets) {
// Check if buckets
@@ -454,40 +412,6 @@ async function computeWatchHistoryFingerprintForItems(itemsOrBuckets) {
return `fallback:${serialized}`;
}
function looksLikeJsonStructure(content) {
if (typeof content !== "string") {
return false;
}
const trimmed = content.trim();
if (!trimmed) {
return false;
}
const first = trimmed[0];
return first === "{" || first === "[";
}
function hexToBytesCompat(hex, tools = null) {
if (typeof hex !== "string") {
throw new Error("Invalid hex input.");
}
const trimmed = hex.trim();
if (!trimmed || trimmed.length % 2 !== 0) {
throw new Error("Invalid hex input.");
}
if (tools?.utils && typeof tools.utils.hexToBytes === "function") {
return tools.utils.hexToBytes(trimmed);
}
const bytes = new Uint8Array(trimmed.length / 2);
for (let index = 0; index < trimmed.length; index += 2) {
const byte = Number.parseInt(trimmed.slice(index, index + 2), 16);
if (Number.isNaN(byte)) {
throw new Error("Invalid hex input.");
}
bytes[index / 2] = byte;
}
return bytes;
}
// Memoizes NIP-44 conversation keys across an entire load. Deriving a
// conversation key is an ECDH operation (~12ms each in-bundle); without this
// cache the watch-history decrypt path re-derived the *same* key once per
+88
View File
@@ -0,0 +1,88 @@
// js/nostr/watchHistoryCodec.js
//
// Pure, dependency-free encode/decode helpers extracted from watchHistory.js to
// keep that module under the file-size budget. No behavior change.
export function sanitizeWatchHistoryMetadata(metadata) {
return {};
}
export function serializeWatchHistoryItems(items) {
if (!Array.isArray(items) || items.length === 0) {
return "[]";
}
const normalized = items
.map((item) => {
const type = item?.type === "a" ? "a" : "e";
const value = typeof item?.value === "string" ? item.value : "";
if (!type || !value) {
return null;
}
const relay =
typeof item?.relay === "string" && item.relay.trim()
? item.relay.trim()
: undefined;
const watchedAt = Number.isFinite(item?.watchedAt)
? Math.max(0, Math.floor(item.watchedAt))
: undefined;
const payload = { type, value };
if (relay) {
payload.relay = relay;
}
if (watchedAt !== undefined) {
payload.watchedAt = watchedAt;
}
const resumeAt = Number.isFinite(item?.resumeAt)
? Math.max(0, Math.floor(item.resumeAt))
: undefined;
if (resumeAt !== undefined) {
payload.resumeAt = resumeAt;
}
if (item?.completed === true) {
payload.completed = true;
}
return payload;
})
.filter(Boolean);
return JSON.stringify(normalized);
}
export function bytesToHex(bytes) {
return Array.from(bytes)
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
}
export function looksLikeJsonStructure(content) {
if (typeof content !== "string") {
return false;
}
const trimmed = content.trim();
if (!trimmed) {
return false;
}
const first = trimmed[0];
return first === "{" || first === "[";
}
export function hexToBytesCompat(hex, tools = null) {
if (typeof hex !== "string") {
throw new Error("Invalid hex input.");
}
const trimmed = hex.trim();
if (!trimmed || trimmed.length % 2 !== 0) {
throw new Error("Invalid hex input.");
}
if (tools?.utils && typeof tools.utils.hexToBytes === "function") {
return tools.utils.hexToBytes(trimmed);
}
const bytes = new Uint8Array(trimmed.length / 2);
for (let index = 0; index < trimmed.length; index += 2) {
const byte = Number.parseInt(trimmed.slice(index, index + 2), 16);
if (Number.isNaN(byte)) {
throw new Error("Invalid hex input.");
}
bytes[index / 2] = byte;
}
return bytes;
}