web_lib

Common web application libraries
git clone https://radroots.dev/git/web_lib.git
Log | Files | Refs | LICENSE

commit c8033ebd84c75a950f7352c4594f5efbdce42e33
parent 45c56db5ce7b80378d4bd8b9768c609ba601ef23
Author: triesap <triesap@radroots.dev>
Date:   Sun, 28 Dec 2025 19:56:32 +0000

tangle: add Nostr sync + tar.gz export pipeline

- Add tangle-events-wasm dependency and init in wasm bootstrap
- Expose nostr_sync_all API with bundle parsing, signing, and relay publish
- Switch database export format from .zip to .tar.gz with tar writer + gzip
- Harden export/share flows with user activation and permission error handling

Diffstat:
Mclient/package.json | 1+
Mclient/src/tangle/types.ts | 5++++-
Mclient/src/tangle/web.ts | 403++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
3 files changed, 375 insertions(+), 34 deletions(-)

diff --git a/client/package.json b/client/package.json @@ -90,6 +90,7 @@ "@radroots/http": "workspace:*", "@radroots/tangle-db-schema-bindings": "workspace:*", "@radroots/tangle-db-wasm": "workspace:*", + "@radroots/tangle-events-wasm": "workspace:*", "@radroots/types-bindings": "workspace:*", "@radroots/utils": "workspace:*", "@radroots/nostr": "workspace:*", diff --git a/client/src/tangle/types.ts b/client/src/tangle/types.ts @@ -160,7 +160,9 @@ import { type SqlJsMigrationState } from "../sql/types.js"; import type { IError } from "@radroots/types-bindings"; import type { TangleDatabaseExportOptions, - TangleDatabaseJsonExport + TangleDatabaseJsonExport, + TangleNostrSyncOptions, + TangleNostrSyncSummary } from "./web.js"; export interface IClientTangleDatabase { @@ -173,6 +175,7 @@ export interface IClientTangleDatabase { export_json(): Promise<TangleDatabaseJsonExport | IError<string>>; import_json(backup: TangleDatabaseJsonExport): Promise<void | IError<string>>; export_database(opts: TangleDatabaseExportOptions): Promise<void | IError<string>>; + nostr_sync_all(opts: TangleNostrSyncOptions): Promise<TangleNostrSyncSummary | IError<string>>; farm_create(opts: IFarmCreate): Promise<IFarmCreateResolve | IError<string>>; farm_find_one(opts: IFarmFindOne): Promise<IFarmFindOneResolve | IError<string>>; farm_find_many(opts?: IFarmFindMany): Promise<IFarmFindManyResolve | IError<string>>; diff --git a/client/src/tangle/web.ts b/client/src/tangle/web.ts @@ -246,6 +246,19 @@ import init_wasm, { tangle_db_export_json, tangle_db_import_json } from "@radroots/tangle-db-wasm"; +import init_tangle_events_wasm, { + tangle_events_ingest_event, + tangle_events_sync_all +} from "@radroots/tangle-events-wasm"; +import { + nostr_context_create, + nostr_event_sign, + nostr_public_key_from_secret, + nostr_publish, + nostr_relays_clear, + nostr_relays_open, + type NostrContext +} from "@radroots/nostr"; import type { IError } from "@radroots/types-bindings"; import { err_msg, handle_err, type IdbClientConfig } from "@radroots/utils"; import { IDB_CONFIG_TANGLE } from "../idb/config.js"; @@ -313,8 +326,8 @@ export type TangleDatabaseExportManifestTs = { }; export type TangleDatabaseExportManifest = { - rs: TangleDatabaseExportManifestRs; - ts: TangleDatabaseExportManifestTs; + rust: TangleDatabaseExportManifestRs; + client: TangleDatabaseExportManifestTs; }; export type TangleDatabaseExportSnapshot = { @@ -336,6 +349,37 @@ export type TangleDatabaseExportOptions = { signer?: TangleDatabaseExportSigner; }; +export type TangleNostrSyncSigner = { + secret_key: string; +}; + +export type TangleNostrEventDraft = { + kind: number; + author: string; + content: string; + tags: string[][]; +}; + +export type TangleNostrSyncBundle = { + version: number; + events: TangleNostrEventDraft[]; +}; + +export type TangleNostrSyncOptions = { + relays: string[]; + signers: TangleNostrSyncSigner[]; + publish_timeout_ms?: number; + context?: NostrContext; +}; + +export type TangleNostrSyncSummary = { + events_total: number; + events_published: number; + events_failed: number; + events_skipped: number; + missing_signers: string[]; +}; + export type WebTangleDatabaseConfig = { store_key?: string; idb_config?: IdbClientConfig; @@ -417,6 +461,67 @@ const is_export_snapshot = (value: unknown): value is TangleDatabaseExportSnapsh return true; }; +const is_string_list = (value: unknown): value is string[] => + Array.isArray(value) && value.every((item) => typeof item === "string"); + +const is_tag_list = (value: unknown): value is string[][] => + Array.isArray(value) && value.every(is_string_list); + +const is_tangle_nostr_event_draft = (value: unknown): value is TangleNostrEventDraft => { + if (!is_record(value)) return false; + if (typeof value.kind !== "number" || !Number.isFinite(value.kind)) return false; + if (typeof value.author !== "string") return false; + if (typeof value.content !== "string") return false; + if (!is_tag_list(value.tags)) return false; + return true; +}; + +const is_tangle_nostr_sync_bundle = (value: unknown): value is TangleNostrSyncBundle => { + if (!is_record(value)) return false; + if (typeof value.version !== "number" || !Number.isFinite(value.version)) return false; + if (!Array.isArray(value.events) || !value.events.every(is_tangle_nostr_event_draft)) return false; + return true; +}; + +const parse_tangle_nostr_sync_bundle = (value: unknown): TangleNostrSyncBundle | IError<string> => { + let parsed: unknown = value; + if (typeof value === "string") { + try { + parsed = JSON.parse(value); + } catch { + return err_msg(cl_tangle_error.parse_failure); + } + } + if (!is_tangle_nostr_sync_bundle(parsed)) return err_msg(cl_tangle_error.invalid_response); + return parsed; +}; + +const is_ingest_outcome = (value: unknown): value is "applied" | "skipped" => + value === "applied" || value === "skipped"; + +const tangle_sync_event_d_tag = (tags: string[][]): string => { + const match = tags.find((tag) => tag[0] === "d"); + const value = match?.[1]; + return typeof value === "string" ? value : ""; +}; + +const tangle_sync_event_key = (draft: TangleNostrEventDraft): string => + `${draft.kind}:${draft.author}:${tangle_sync_event_d_tag(draft.tags)}`; + +const build_signer_map = (signers: TangleNostrSyncSigner[]): Record<string, string> => { + const map: Record<string, string> = {}; + for (const signer of signers) { + const secret_key = signer.secret_key; + if (!secret_key || typeof secret_key !== "string") continue; + const pubkey = nostr_public_key_from_secret(secret_key); + map[pubkey] = secret_key; + } + return map; +}; + +const publish_results_has_success = (results: Record<string, { status?: string }>): boolean => + Object.values(results).some((result) => result.status === "success"); + type ZipEntry = { name: string; data: Uint8Array; @@ -612,6 +717,107 @@ const zip_write_stream = async (stream: ZipFileWritable, entries: ZipEntry[]): P await stream.close(); }; +type TarEntry = { + name: string; + data: Uint8Array; +}; + +const TAR_BLOCK_SIZE = 512; + +const tar_pad_size = (size: number): number => { + const rem = size % TAR_BLOCK_SIZE; + return rem === 0 ? 0 : TAR_BLOCK_SIZE - rem; +}; + +const tar_write_string = (buf: Uint8Array, offset: number, length: number, value: string): void => { + const enc = new TextEncoder(); + const bytes = enc.encode(value); + const slice = bytes.length > length ? bytes.slice(0, length) : bytes; + buf.set(slice, offset); +}; + +const tar_write_octal = (buf: Uint8Array, offset: number, length: number, value: number): void => { + const str = value.toString(8).padStart(length - 1, "0"); + tar_write_string(buf, offset, length - 1, str); + buf[offset + length - 1] = 0; +}; + +const tar_header = (entry: TarEntry, mtime: number): Uint8Array => { + if (entry.name.length > 100) throw new Error("tar entry name too long"); + const header = new Uint8Array(TAR_BLOCK_SIZE); + tar_write_string(header, 0, 100, entry.name); + tar_write_octal(header, 100, 8, 0o644); + tar_write_octal(header, 108, 8, 0); + tar_write_octal(header, 116, 8, 0); + tar_write_octal(header, 124, 12, entry.data.length); + tar_write_octal(header, 136, 12, mtime); + for (let i = 148; i < 156; i++) header[i] = 32; + header[156] = 48; + tar_write_string(header, 257, 6, "ustar"); + tar_write_string(header, 263, 2, "00"); + let checksum = 0; + for (let i = 0; i < header.length; i++) checksum += header[i]; + const chk = checksum.toString(8).padStart(6, "0"); + tar_write_string(header, 148, 6, chk); + header[154] = 0; + header[155] = 32; + return header; +}; + +const tar_build_bytes = (entries: TarEntry[], mtime: number): Uint8Array => { + const parts: Uint8Array[] = []; + let total = 0; + for (const entry of entries) { + const header = tar_header(entry, mtime); + const pad = tar_pad_size(entry.data.length); + parts.push(header, entry.data); + total += header.length + entry.data.length; + if (pad) { + const padding = new Uint8Array(pad); + parts.push(padding); + total += padding.length; + } + } + const end = new Uint8Array(TAR_BLOCK_SIZE * 2); + parts.push(end); + total += end.length; + const out = new Uint8Array(total); + let offset = 0; + for (const part of parts) { + out.set(part, offset); + offset += part.length; + } + return out; +}; + +const tar_stream = (entries: TarEntry[], mtime: number): ReadableStream<Uint8Array> => { + return new ReadableStream({ + start(controller) { + for (const entry of entries) { + const header = tar_header(entry, mtime); + controller.enqueue(header); + controller.enqueue(entry.data); + const pad = tar_pad_size(entry.data.length); + if (pad) controller.enqueue(new Uint8Array(pad)); + } + controller.enqueue(new Uint8Array(TAR_BLOCK_SIZE * 2)); + controller.close(); + } + }); +}; + +const gzip_bytes = async (bytes: Uint8Array): Promise<Uint8Array> => { + if (typeof CompressionStream === "undefined") { + throw new Error("tangle export requires gzip support"); + } + const stream = new CompressionStream("gzip"); + const writer = stream.writable.getWriter(); + writer.write(bytes); + await writer.close(); + const buffer = await new Response(stream.readable).arrayBuffer(); + return new Uint8Array(buffer); +}; + const bytes_to_hex = (bytes: Uint8Array): string => { const hex: string[] = []; for (let i = 0; i < bytes.length; i++) { @@ -635,10 +841,9 @@ const filename_slug = (value: string): string => { return `radroots-${slug}`; }; -const export_filename = (app_name: string, app_version: string, exported_at: string): string => { - const ts = exported_at.replace(/[:.]/g, "-"); +const export_filename = (app_name: string, app_version: string): string => { const base = filename_slug(app_name); - return `${base}-${app_version}-backup-${ts}.zip`; + return `${base}-${app_version}-backup.tar.gz`; }; const get_zip_file_picker = (): ZipFilePicker | undefined => { @@ -647,6 +852,26 @@ const get_zip_file_picker = (): ZipFilePicker | undefined => { return picker; }; +const user_activation_is_active = (): boolean => { + if (typeof navigator === "undefined") return false; + const nav = navigator as Navigator & { userActivation?: { isActive?: boolean } }; + if (!nav.userActivation) return true; + return nav.userActivation.isActive === true; +}; + +const is_permission_error = (err: unknown): boolean => { + if (!err) return false; + if (typeof err === "string") { + const msg = err.toLowerCase(); + return msg.includes("permission") || msg.includes("denied") || msg.includes("not allowed"); + } + if (!is_record(err)) return false; + const name = typeof err.name === "string" ? err.name.toLowerCase() : ""; + const message = typeof err.message === "string" ? err.message.toLowerCase() : ""; + if (name.includes("notallowed") || name.includes("abort")) return true; + return message.includes("permission") || message.includes("denied") || message.includes("not allowed"); +}; + const can_share_file = (file: File): boolean => { if (typeof navigator === "undefined") return false; const nav = navigator as Navigator & { canShare?: (data: { files?: File[] }) => boolean }; @@ -672,28 +897,39 @@ const download_blob = (blob: Blob, filename: string): void => { URL.revokeObjectURL(url); }; -const export_zip = async (filename: string, entries: ZipEntry[]): Promise<void> => { +const export_tar_gz = async (filename: string, entries: TarEntry[], mtime: number): Promise<void> => { const picker = get_zip_file_picker(); - if (picker) { - const handle = await picker({ - suggestedName: filename, - types: [ - { - description: "Radroots tangle export", - accept: { "application/zip": [".zip"] } - } - ] - }); - const stream = await handle.createWritable(); - await zip_write_stream(stream, entries); - return; - } - const zip_bytes = zip_build_bytes(entries); - const blob = new Blob([zip_bytes], { type: "application/zip" }); - const file = new File([blob], filename, { type: "application/zip" }); - if (can_share_file(file)) { - const shared = await share_file(file); - if (shared) return; + if (picker && user_activation_is_active() && typeof CompressionStream !== "undefined") { + try { + const handle = await picker({ + suggestedName: filename, + types: [ + { + description: "Radroots tangle export", + accept: { "application/gzip": [".tar.gz"] } + } + ] + }); + const stream = await handle.createWritable(); + await tar_stream(entries, mtime) + .pipeThrough(new CompressionStream("gzip")) + .pipeTo(stream); + return; + } catch (e) { + if (!is_permission_error(e)) throw e; + } + } + const tar_bytes = tar_build_bytes(entries, mtime); + const gz_bytes = await gzip_bytes(tar_bytes); + const blob = new Blob([gz_bytes], { type: "application/gzip" }); + const file = new File([blob], filename, { type: "application/gzip" }); + if (can_share_file(file) && user_activation_is_active()) { + try { + const shared = await share_file(file); + if (shared) return; + } catch (e) { + if (!is_permission_error(e)) throw e; + } } download_blob(blob, filename); }; @@ -710,6 +946,7 @@ const wasm_init_once = async (): Promise<void> => { if (!wasm_init_promise) { wasm_init_promise = (async () => { await init_wasm(); + await init_tangle_events_wasm(); })(); } try { @@ -887,6 +1124,105 @@ export class WebTangleDatabase implements IWebTangleDatabase { } } + async nostr_sync_all(opts: TangleNostrSyncOptions): Promise<TangleNostrSyncSummary | IError<string>> { + try { + await this.ensure_ready(); + const relays = Array.from(new Set(opts.relays.map((relay) => relay.trim()).filter((relay) => relay.length))); + if (!relays.length) return err_msg(`tangle sync requires relays`); + if (!opts.signers.length) return err_msg(`tangle sync requires signers`); + const signer_map = build_signer_map(opts.signers); + if (!Object.keys(signer_map).length) return err_msg(`tangle sync requires valid signers`); + + const farms = await this.farm_find_many(); + if ("err" in farms) return farms; + const event_map: Record<string, TangleNostrEventDraft> = {}; + for (const farm of farms.results) { + const bundle_raw = tangle_events_sync_all(this.serialize({ + farm: { id: farm.id }, + options: null + })); + const bundle = parse_tangle_nostr_sync_bundle(bundle_raw); + if ("err" in bundle) return bundle; + for (const draft of bundle.events) { + const key = tangle_sync_event_key(draft); + if (!event_map[key]) event_map[key] = draft; + } + } + + const event_keys = Object.keys(event_map); + event_keys.sort(); + if (!event_keys.length) { + return { + events_total: 0, + events_published: 0, + events_failed: 0, + events_skipped: 0, + missing_signers: [] + }; + } + + const context = opts.context ?? nostr_context_create(); + const context_owned = !opts.context; + let events_published = 0; + let events_failed = 0; + let events_skipped = 0; + const missing_signers = new Set<string>(); + + try { + nostr_relays_open(context, relays); + for (const key of event_keys) { + const draft = event_map[key]; + const secret_key = signer_map[draft.author]; + if (!secret_key) { + missing_signers.add(draft.author); + events_skipped += 1; + continue; + } + const event = nostr_event_sign({ + secret_key, + event: { + kind: draft.kind, + created_at: Math.floor(Date.now() / 1000), + tags: draft.tags, + content: draft.content + } + }); + const publish_results = await nostr_publish({ + event, + relays, + context, + timeout: opts.publish_timeout_ms + }); + if (!publish_results_has_success(publish_results)) { + events_failed += 1; + continue; + } + const ingest_result = tangle_events_ingest_event(this.serialize(event)); + if (!is_ingest_outcome(ingest_result)) { + events_failed += 1; + continue; + } + events_published += 1; + } + } finally { + if (context_owned) nostr_relays_clear(context); + } + + const summary: TangleNostrSyncSummary = { + events_total: event_keys.length, + events_published, + events_failed, + events_skipped, + missing_signers: Array.from(missing_signers) + }; + if (summary.missing_signers.length) return err_msg(`tangle sync missing signers: ${summary.missing_signers.join(", ")}`); + if (summary.events_failed) return err_msg(`tangle sync publish failed (${summary.events_failed}/${summary.events_total})`); + return summary; + } catch (e) { + return handle_err(e); + } + } + private async export_database_inner(opts: TangleDatabaseExportOptions): Promise<void> { await this.ensure_ready(); const app_name = opts.app_name; @@ -913,16 +1249,16 @@ export class WebTangleDatabase implements IWebTangleDatabase { }; let manifest: TangleDatabaseExportManifest = { - rs: manifest_rs, - ts: manifest_ts_base + rust: manifest_rs, + client: manifest_ts_base }; if (opts.signer) { const nostr_event = await opts.signer({ db_sha256, manifest }); if (nostr_event) { manifest = { - rs: manifest_rs, - ts: { + rust: manifest_rs, + client: { ...manifest_ts_base, nostr_event } @@ -932,11 +1268,12 @@ export class WebTangleDatabase implements IWebTangleDatabase { const manifest_json = JSON.stringify(manifest, null, 2); const manifest_bytes = new TextEncoder().encode(manifest_json); - const filename = export_filename(app_name, app_version, exported_at); - await export_zip(filename, [ + const filename = export_filename(app_name, app_version); + const mtime = Math.floor(Date.parse(exported_at) / 1000); + await export_tar_gz(filename, [ { name: "manifest.json", data: manifest_bytes }, { name: "tangle.db", data: db_bytes } - ]); + ], mtime); } finally { if (export_active) tangle_db_export_finish(); }