feat: standalone download-consent gate + truthful loading states (standalone-load-ux 0001/0002)

Cold loads on versioned CDN deploys now show a consent card (editor MB +
symbol/footprint lib figures, downloaded-now vs on-demand) and wait for OK
before any big fetch; warm loads skip it and show truthful stages (loading
from cache / Compiling / Starting KiCad) instead of the first-download line.

- wasm-assets: resolveWasmMeta (bundle/ver/sizes), download-completion marker
  keyed by content-addressed bundle/ver, update wording, auto-download opt-out,
  HEAD size fallback
- boot: manifest raw size as the progress total (fixes the br/gzip
  Content-Length mismatch), marker written after download+instantiate succeed
- cdn-source: syncState() — IDB warmth peek + sizes.json cold sums
- synced-source: syncState() from the backend envelope's sync refs (private
  platform); remote-source passes libSchema.sync through
- publish-wasm: manifest schema 2 with per-bundle sizes (registry-persisted,
  reuse + snapshot modes); publish-libs: sizes.json sibling key + top-up mode
- fixed 4 stale unit tests (bundle mapping, session-identity email)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011CC8aAnUUHcnHy3QCJtUwb
This commit is contained in:
Gergő Törcsvári 2026-07-29 07:48:25 +02:00
commit 8ee8db69e6
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
14 changed files with 972 additions and 73 deletions

View file

@ -21,9 +21,15 @@
// — the publish-time footprint index: unique electrical pad count per footprint,
// so the editor's symbol-chooser footprint selector can filter EVERY footprint
// lib without fat-loading a single body (kicad pcbnew.cpp `filterFootprints`).
// + `<prefix>/<libTag>/sizes.json` { schema, tag, libs: { <libId>: <bundleBytes> } }
// — per-lib bundle byte counts for the standalone's download-consent dialog
// (standalone-load-ux 0001). A SEPARATE key (not a manifest.json field) on
// purpose: manifest.json is stored IMMUTABLE, so re-putting it to add sizes
// could serve stale from edge caches; a new key can't.
// All immutable (content is pinned by the tag).
// A tag published before fp-index.json existed gets an INDEX-ONLY top-up run:
// bundles/manifests are skipped (immutable + present), only the index is put.
// A tag published before fp-index.json / sizes.json existed gets a TOP-UP run:
// bundles/manifests are skipped (immutable + present), only the missing index
// and/or sizes files are computed (pure local work) and put.
import { execFileSync } from "node:child_process";
import { existsSync, mkdirSync } from "node:fs";
@ -107,19 +113,23 @@ async function main(): Promise<void> {
const enc = new TextEncoder();
const topKey = `${a.prefix}/${a.libTag}/manifest.json`;
const indexKey = `${a.prefix}/${a.libTag}/fp-index.json`;
const sizesKey = `${a.prefix}/${a.libTag}/sizes.json`;
// Skip-if-exists: the snapshot is immutable + content-pinned by the tag.
// A tag published before fp-index.json existed drops into INDEX-ONLY mode:
// recompute + put just the footprint index (pure local work; no bundle puts).
const indexOnly = !a.force && !!store.getJSON(topKey);
if (indexOnly && store.getJSON(indexKey)) {
// A tag published before fp-index.json / sizes.json existed drops into a
// TOP-UP run: recompute + put just the missing derived files (pure local
// work; no bundle/manifest puts).
const published = !a.force && !!store.getJSON(topKey);
const haveIndex = published && !!store.getJSON(indexKey);
const haveSizes = published && !!store.getJSON(sizesKey);
if (published && haveIndex && haveSizes) {
console.log(`publish-libs: ${topKey} already published — skipping (use --force)`);
return;
}
console.log(
`publish-libs: tag=${a.libTag} driver=${store.kind} → ${a.prefix}/${a.libTag}/` +
(indexOnly ? " (index-only top-up)" : ""),
(published ? " (top-up: derived files only)" : ""),
);
// Source the full set from upstream when asked (CI path); --symbols-src /
@ -134,19 +144,24 @@ async function main(): Promise<void> {
}
const libs = await extractAllLibs({
// Index-only: the index covers footprints only, so skip symbol extraction.
symbolsSrc: indexOnly ? undefined : (a.symbolsSrc ?? undefined),
// Symbols are only needed for bundles (fresh publish) or sizes (top-up);
// the fp-index alone covers footprints only.
symbolsSrc: published && haveSizes ? undefined : (a.symbolsSrc ?? undefined),
footprintsSrc: a.footprintsSrc ?? undefined,
});
const topLibs: Array<{ id: string; name: string; kind: string; itemCount: number }> = [];
const fpIndexLibs: Record<string, Array<[string, number]>> = {};
const sizesLibs: Record<string, number> = {};
let totalItems = 0;
for (const { lib, kind, items } of libs) {
if (kind === "footprint") {
if (kind === "footprint" && !haveIndex) {
fpIndexLibs[lib] = items.map((it) => [it.name, countUniquePads(it.body)]);
}
if (indexOnly) continue; // bundles/manifests already live under this tag
// bundles/manifests already live under a published tag; a sizes top-up
// still re-encodes each bundle LOCALLY (deterministic from the same tag's
// sources) to measure it — nothing is re-put.
if (published && haveSizes) continue;
const bodies = items.map(
(it): [string, Uint8Array] => [`${it.kind}/${it.name}`, enc.encode(it.body)],
@ -156,9 +171,13 @@ async function main(): Promise<void> {
entries[path] = { hash: sha256hex(body), size: body.length, mtime: 0 };
}
const manifest: SyncManifest = { version: 1, entries };
const bundle = encodeBundle(manifest, bodies);
sizesLibs[lib] = bundle.byteLength;
if (published) continue;
const base = `${a.prefix}/${a.libTag}/${lib}`;
putJSON(store, `${base}/manifest`, manifest, IMMUTABLE);
store.put(`${base}/bundle`, encodeBundle(manifest, bodies), {
store.put(`${base}/bundle`, bundle, {
contentType: "application/octet-stream",
contentEncoding: null,
cacheControl: IMMUTABLE,
@ -167,18 +186,25 @@ async function main(): Promise<void> {
totalItems += items.length;
}
if (!indexOnly) {
if (!published) {
topLibs.sort((x, y) => x.id.localeCompare(y.id));
putJSON(store, topKey, { schema: 1, tag: a.libTag, libs: topLibs }, IMMUTABLE);
}
putJSON(store, indexKey, { schema: 1, tag: a.libTag, libs: fpIndexLibs }, IMMUTABLE);
if (!haveIndex) {
putJSON(store, indexKey, { schema: 1, tag: a.libTag, libs: fpIndexLibs }, IMMUTABLE);
}
if (!haveSizes) {
putJSON(store, sizesKey, { schema: 1, tag: a.libTag, libs: sizesLibs }, IMMUTABLE);
}
const fpIndexCount = Object.values(fpIndexLibs).reduce((n, v) => n + v.length, 0);
console.log(
indexOnly
? `publish-libs: done — fp-index only (${fpIndexCount} footprints) → ${indexKey}`
published
? `publish-libs: done — top-up (${haveIndex ? "" : `fp-index: ${fpIndexCount} footprints`}` +
`${!haveIndex && !haveSizes ? ", " : ""}` +
`${haveSizes ? "" : `sizes: ${Object.keys(sizesLibs).length} libs`})`
: `publish-libs: done — ${topLibs.length} libs, ${totalItems} items ` +
`(+fp-index: ${fpIndexCount} footprints) → ${topKey}`,
`(+fp-index: ${fpIndexCount} footprints, +sizes) → ${topKey}`,
);
if (store.kind === "local") console.log(`local layout under: ${a.out}`);
}

View file

@ -179,7 +179,13 @@ async function main() {
registry.index ||= {};
registry.tools ||= {};
const manifest = { schema: 1, tag: a.tag, builtAt, tools: {} };
// schema 2: adds `sizes` — per-tool byte counts the standalone's download
// consent dialog + progress bar read (standalone-load-ux 0001). `wasm` is the
// RAW (decoded) size (matches the byte-counting progress stream), `wasmStored`
// / `totalStored` are over-the-wire (post-br/gzip) sizes. Additive: old
// clients read only `tools`; new clients tolerate a missing/partial `sizes`
// (tools published before this schema carry none in the registry).
const manifest = { schema: 2, tag: a.tag, builtAt, tools: {}, sizes: {} };
// Snapshot mode (the tag deploy): pin manifest-<tag> to the CURRENT published
// per-tool versions and STOP — no build, no upload. Honors "reuse prebuilt":
@ -193,6 +199,7 @@ async function main() {
`mode) before snapshotting a release manifest`,
);
manifest.tools[tool] = entry.version;
if (entry.sizes) manifest.sizes[tool] = entry.sizes;
}
putJSON(store, `${P}/manifest-${a.tag}.json`, manifest, NO_STORE);
console.log(
@ -207,16 +214,27 @@ async function main() {
let reused = 0;
// Per-tool sizes destined for manifest.sizes + the registry entry. Reused
// tools inherit the registry's stored sizes when they match this version
// (compression is skipped on reuse, so stored sizes can't be recomputed);
// pre-schema-2 registry entries yield a raw-only partial the client treats
// as "stored size unknown".
const sizesByTool = {};
// Plan: hash every tool, decide reuse vs upload (moved-tag guard included).
const plan = [];
for (const tool of a.tools) {
const files = gather(tool, a.src);
const hash = toolContentHash(files);
const idx = (registry.index[tool] ||= {});
const rawWasm = files.find((f) => f.name === `${tool}.wasm`).bytes.length;
let ver = idx[hash];
if (ver) {
reused++;
const prev = registry.tools[tool];
sizesByTool[tool] =
prev?.version === ver && prev.sizes ? prev.sizes : { wasm: rawWasm };
console.log(` ${tool}: reuse ${ver} (${hash.slice(0, 19)}…)`);
} else {
ver = a.tag;
@ -244,6 +262,12 @@ async function main() {
for (const { tool, files, hash, ver } of plan) {
const sizes = files.map((f) => putFile(store, `${P}/${tool}/${ver}/${f.name}`, f));
const wasmFile = sizes.find((s) => s.name === `${tool}.wasm`);
sizesByTool[tool] = {
wasm: wasmFile.raw,
wasmStored: wasmFile.stored,
totalStored: sizes.reduce((s, x) => s + x.stored, 0),
};
putJSON(
store,
`${P}/${tool}/${ver}/meta.json`,
@ -264,6 +288,13 @@ async function main() {
}
const uploaded = plan.length;
for (const tool of a.tools) {
const s = sizesByTool[tool];
if (!s) continue;
manifest.sizes[tool] = s;
registry.tools[tool].sizes = s;
}
// Browser-facing manifest + convenience pointer, both uncached.
putJSON(store, `${P}/manifest-${a.tag}.json`, manifest, NO_STORE);
putJSON(store, `${P}/manifest-latest.json`, { tag: a.tag }, NO_STORE);

@ -1 +1 @@
Subproject commit 6a25a549a6d4315217f6ef359ef5346fdeeedf88
Subproject commit 6189307bf50a3fc1d477f0616434e8c9e2f980b9

View file

@ -15,7 +15,7 @@ import {
type KicadDoc,
type Tool,
} from "@pcbjam/shared";
import { ChevronDown, ChevronUp, Eye, EyeOff, Loader2, Moon, PanelsTopLeft, Sun } from "lucide-react";
import { ChevronDown, ChevronUp, Download, Eye, EyeOff, Loader2, Moon, PanelsTopLeft, Sun } from "lucide-react";
import {
API_BASE_URL,
APP_URL,
@ -33,7 +33,16 @@ import { redirectTargetFor } from "@/lib/redirect";
import { loadSessionIdentity } from "@/lib/session-identity";
import { setTheme, useThemeValue } from "@/lib/theme";
import { bootKicadTool } from "@/wasm/boot";
import { resolveWasmBase } from "@/wasm/wasm-assets";
import {
autoDownloadEnabled,
fetchWasmStoredSize,
hasAnyWasmDownload,
isWasmDownloaded,
markWasmDownloaded,
resolveWasmMeta,
setAutoDownloadEnabled,
type WasmMeta,
} from "@/wasm/wasm-assets";
import {
LIB_BUSY_EVENT,
LIB_ERROR_EVENT,
@ -44,12 +53,13 @@ import {
type LibItemUpdatedDetail,
type LibLoadingDetail,
type LibsSource,
type LibsSyncState,
} from "@/wasm/libs/source";
import {
MODELS_LOADING_EVENT,
type ModelsLoadingDetail,
} from "@/wasm/libs/models-bridge";
import { memfsFilePath, memfsProjectDir, TOOL_FRAME } from "@/wasm/constants";
import { memfsFilePath, memfsProjectDir, TOOL_BUNDLE, TOOL_FRAME } from "@/wasm/constants";
import { driveProjectIntoTool, type ToolFile } from "@/wasm/kicad-runner";
import { registerSaveHook, type SaveBytes } from "@/wasm/save-flow";
import type {
@ -143,6 +153,64 @@ const LIB_KIND_FOR_TOOL: Partial<Record<Tool, "symbol" | "footprint">> = {
footprint_editor: "footprint",
pcbnew: "footprint",
};
/** What the download-consent dialog quotes (standalone-load-ux 0001). */
interface ConsentInfo {
/** Over-the-wire bytes for the editor bundle — null when the CDN carries no
* size info and HEAD yielded none ("large download" wording then). */
toolBytes: number | null;
/** A previous version of this bundle was downloaded → word it as an update. */
update: boolean;
/** The lib kind pre-synced at boot for this tool ("downloaded now"). */
libNowKind: "symbol" | "footprint" | null;
libNow: LibsSyncState | null;
/** The other kind a merged-bundle session can pull lazily ("only if used"). */
libLaterKind: "symbol" | "footprint" | null;
libLater: LibsSyncState | null;
}
/**
* Gather the consent dialog's figures. Everything is best-effort: only small
* JSON/HEAD requests run here (never a bundle or the wasm), and any missing
* piece degrades to vaguer wording rather than blocking the dialog.
*/
async function gatherConsentInfo(
meta: WasmMeta,
source: LibsSource | null,
tool: Tool,
): Promise<ConsentInfo> {
let toolBytes = meta.sizes?.totalStored ?? null;
if (toolBytes === null) {
toolBytes = await fetchWasmStoredSize(meta.base, meta.bundle);
}
const libNowKind = LIB_KIND_FOR_TOOL[tool] ?? null;
// The merged kicad_editor bundle seeds BOTH lib tables — the other kind loads
// lazily per-lib when a cross-face feature reaches it (see boot.ts libKinds).
const libLaterKind =
TOOL_BUNDLE[tool] === "kicad_editor" && libNowKind
? libNowKind === "symbol"
? ("footprint" as const)
: ("symbol" as const)
: null;
const state = async (
kind: "symbol" | "footprint" | null,
): Promise<LibsSyncState | null> => {
if (!kind || !source?.syncState) return null;
try {
return await source.syncState(kind);
} catch {
return null;
}
};
return {
toolBytes,
update: hasAnyWasmDownload(meta.bundle),
libNowKind,
libNow: await state(libNowKind),
libLaterKind,
libLater: await state(libLaterKind),
};
}
const LEGACY_EXTENSION_TOOL: Record<string, Tool> = {
".sch": "eeschema",
".brd": "pcbnew",
@ -858,6 +926,13 @@ export function WasmTool({
total: number;
} | null>(null);
const [slow, setSlow] = React.useState(false);
// Download-consent gate (standalone-load-ux 0001): non-null while the boot
// waits on the user's OK before pulling the (large) cold wasm + lib bundles.
const [consent, setConsent] = React.useState<ConsentInfo | null>(null);
const consentResolveRef = React.useRef<((ok: boolean) => void) | null>(null);
// This bundle+version finished downloading before (completion marker) — the
// load overlay says "from cache" instead of the first-download excuse.
const [warmBoot, setWarmBoot] = React.useState(false);
// A library item currently being fetched (open/save), for a transient spinner.
const [libBusy, setLibBusy] = React.useState<string | null>(null);
// Load-screen pre-sync progress: warming the project's lib bundles into IDB in
@ -1041,12 +1116,13 @@ export function WasmTool({
// "Taking too long": once the tool has been loading for a while without
// becoming ready, surface a hint (slow link / something may be wrong) + a
// reload, so a stalled boot doesn't look like a frozen blank screen.
// reload, so a stalled boot doesn't look like a frozen blank screen. Paused
// while the consent dialog is up — waiting on the user isn't "slow".
React.useEffect(() => {
if (ready) return;
if (ready || consent) return;
const t = setTimeout(() => setSlow(true), 60_000);
return () => clearTimeout(t);
}, [ready]);
}, [ready, consent]);
React.useEffect(() => {
const win = window as ToolWindow;
@ -1242,12 +1318,32 @@ export function WasmTool({
const identityReady = loadSessionIdentity(API_BASE_URL);
// Resolve the per-tool asset base at runtime (CDN manifest → versioned
// folder, or the flat local /wasm in dev). See wasm/wasm-assets.ts.
const base = await resolveWasmBase(tool, assetBaseUrl);
const meta = await resolveWasmMeta(tool, assetBaseUrl);
const base = meta.base;
// One source instance, shared by the wasm provider AND the pre-sync below
// (libsSourceConfig builds a fresh one each call — their SyncStack caches
// must be the same object for the warm-up to benefit the editor).
const source =
libsSource !== undefined ? libsSource : libsSourceConfig(projectId);
// Download-consent gate (standalone-load-ux 0001): before pulling the
// (large) cold wasm + lib bundles, say how many MB and wait for the OK.
// Runs only on versioned CDN deploys (`meta.ver` — flat dev roots and
// e2e assetBaseUrl overrides have no version and skip it), when this
// exact bundle+version hasn't finished downloading before, and the user
// hasn't opted into silent downloads. Only small JSON/HEAD requests
// happen before consent.
const warm = isWasmDownloaded(meta.bundle, meta.ver);
setWarmBoot(warm);
if (meta.ver && !warm && !autoDownloadEnabled()) {
const info = await gatherConsentInfo(meta, source, tool);
const ok = await new Promise<boolean>((resolve) => {
consentResolveRef.current = resolve;
setConsent(info);
});
consentResolveRef.current = null;
setConsent(null);
if (!ok) return; // unmounted while waiting — never start the download
}
// Pre-warm the lib bundles into IDB in PARALLEL with the wasm download, so
// the editor's first enumerate reads a warm cache instead of freezing on N
// cold bundle fetches. Non-blocking + best-effort; the SyncStack dedups, so
@ -1276,6 +1372,13 @@ export function WasmTool({
onStatus: setStatus,
onAbort: oom.onAbort,
onProgress: (loaded, total) => setProgress({ loaded, total }),
// Truthful loading states (standalone-load-ux 0001): the manifest's
// raw wasm size fixes the progress total under br/gzip; the marker
// flips the label to "Loading" (HTTP cache) vs "Downloading"; the
// completion marker is recorded once download + compile succeeded.
expectedWasmBytes: meta.sizes?.wasm ?? null,
warmStart: warm,
onWasmInstantiated: () => markWasmDownloaded(meta.bundle, meta.ver),
libsSource: source,
// 3D models: lazy per-board source (null unless the CDN manifest is
// configured) — feeds the board prescan + the viewer's ensure fallback.
@ -1490,6 +1593,10 @@ export function WasmTool({
})();
return () => {
// A consent dialog pending at unmount resolves false — the boot IIFE
// bails without ever starting the download.
consentResolveRef.current?.(false);
consentResolveRef.current = null;
win.removeEventListener("keydown", swallowBrowserSave, true);
win.removeEventListener("keydown", chromeHotkey, true);
commentsRef.current?.destroy();
@ -1596,6 +1703,14 @@ export function WasmTool({
Reload
</button>
</>
) : consent ? (
<DownloadConsent
info={consent}
onAccept={(always) => {
if (always) setAutoDownloadEnabled(true);
consentResolveRef.current?.(true);
}}
/>
) : (
<>
<Loader2 className="animate-spin" size={32} />
@ -1609,7 +1724,9 @@ export function WasmTool({
</p>
)}
<p className="font-mono text-xs text-white/40">
First load downloads the tool (large) — this can take a moment.
{warmBoot
? "Loading from your browser's cache — no download needed."
: "Downloading the editor — it's cached for future visits."}
</p>
{slow && (
<>
@ -1902,6 +2019,115 @@ export function WasmTool({
);
}
/** "~173 MB" — coarse on purpose; these are quotes, not meters. */
function approxMB(bytes: number): string {
const mb = bytes / 1e6;
return `~${mb >= 10 ? Math.round(mb) : Math.max(0.1, mb).toFixed(1)} MB`;
}
/** One consent row's size figure, from a (possibly partial) LibsSyncState. */
function libStateLabel(s: LibsSyncState | null): string | null {
if (!s) return null;
if (s.total > 0 && s.warm >= s.total) return "already cached";
const cold = s.total - s.warm;
const count = `${cold} librar${cold === 1 ? "y" : "ies"}`;
if (!s.sizesKnown) return count;
return s.warm > 0 ? `${approxMB(s.coldBytes)} (${count})` : approxMB(s.coldBytes);
}
/**
* The download-consent card (standalone-load-ux 0001): what's about to be
* pulled onto this device — the editor bundle now, the tool's lib kind now,
* the other kind later on demand — and an OK that actually gates the fetches.
*/
function DownloadConsent({
info,
onAccept,
}: {
info: ConsentInfo;
onAccept: (always: boolean) => void;
}) {
const [always, setAlways] = React.useState(false);
const kindTitle = (k: "symbol" | "footprint") =>
k === "symbol" ? "Symbol libraries" : "Footprint libraries";
const row = (
title: string,
detail: string,
figure: string | null,
testid: string,
) => (
<li
data-testid={testid}
className="flex items-baseline gap-3 border-t border-white/10 py-2 first:border-t-0"
>
<div className="min-w-0 flex-1">
<p className="text-sm text-white/90">{title}</p>
<p className="text-xs text-white/50">{detail}</p>
</div>
{figure && (
<span className="whitespace-nowrap font-mono text-xs text-white/70">
{figure}
</span>
)}
</li>
);
return (
<div
data-testid="download-consent"
className="flex w-full max-w-md flex-col items-center gap-4 px-6"
>
<Download size={32} className="text-white/70" />
<h2 className="text-base font-semibold">
{info.update ? "Editor update available" : "One-time download needed"}
</h2>
<p className="text-center text-sm text-white/70">
PCBJam runs KiCad fully in your browser.{" "}
{info.update
? "This release ships a new editor build, so it needs downloading again — it's cached after that."
: "Opening this tool downloads it once — repeat visits load from your browser's cache."}
</p>
<ul className="w-full rounded-lg bg-white/5 px-4 py-1">
{row(
"Editor engine",
"the KiCad build, downloaded now",
info.toolBytes !== null ? approxMB(info.toolBytes) : "large (hundreds of MB)",
"consent-row-tool",
)}
{info.libNowKind &&
row(
kindTitle(info.libNowKind),
"downloaded now — this editor browses them",
libStateLabel(info.libNow),
"consent-row-now",
)}
{info.libLaterKind &&
row(
kindTitle(info.libLaterKind),
"downloaded later, only if you use them",
libStateLabel(info.libLater),
"consent-row-later",
)}
</ul>
<label className="flex cursor-pointer items-center gap-2 text-xs text-white/60">
<input
type="checkbox"
checked={always}
onChange={(e) => setAlways(e.target.checked)}
className="accent-white/80"
/>
Always download without asking
</label>
<button
data-testid="consent-accept"
className="rounded bg-white/90 px-4 py-1.5 text-sm font-medium text-[#1a1a2e] hover:bg-white"
onClick={() => onAccept(always)}
>
Download &amp; open
</button>
</div>
);
}
/**
* Fixed-width lib pre-sync line, e.g. "Syncing symbol libraries — 42/208".
* The prefix is constant and `done` is space-padded to `total`'s digit count,

View file

@ -23,8 +23,13 @@ describe("loadSessionIdentity", () => {
expect(await loadSessionIdentity("http://api")).toEqual({
slug: "alice",
name: "Alice A",
email: "a@x.y",
});
expect(sessionIdentity()).toEqual({
slug: "alice",
name: "Alice A",
email: "a@x.y",
});
expect(sessionIdentity()).toEqual({ slug: "alice", name: "Alice A" });
expect(f).toHaveBeenCalledWith("http://api/api/me", {
credentials: "include",
});
@ -35,6 +40,7 @@ describe("loadSessionIdentity", () => {
expect(await loadSessionIdentity("http://api")).toEqual({
slug: "bob",
name: "bob@x.y",
email: "bob@x.y",
});
});

View file

@ -69,6 +69,18 @@ export interface BootOptions {
* real bar. `total` is 0 when the server sends no usable Content-Length (or it
* disagrees with the decoded stream under gzip/br) — then show bytes, not a %. */
onProgress?: (loaded: number, total: number) => void;
/** RAW (decoded) `.wasm` byte count from the release manifest (schema 2), when
* known. The progress stream counts DECODED bytes, so this — unlike a
* compressed Content-Length — is the correct bar total. */
expectedWasmBytes?: number | null;
/** The download-completion marker was this bundle's — a previous load finished
* downloading it, so the fetch should ride the HTTP cache. Only flips the
* status label ("Loading" vs "Downloading"); the mechanics are identical. */
warmStart?: boolean;
/** The `.wasm` finished downloading AND instantiated — the moment the
* download-completion marker is safe to record (a truncated/failed stream
* never gets here). */
onWasmInstantiated?: () => void;
/** Library source backing `window.kicadLibs`. Null/omitted disables libs
* (empty lib-tables are seeded). Its libs become sym-lib-table and/or
* fp-lib-table rows depending on the tool (see `libKinds` in doBoot). */
@ -172,21 +184,30 @@ function pthreadWorkerScript(
* `Content-Length` is the COMPRESSED size when the CDN gzip/br-encodes the wasm,
* while the stream yields DECODED bytes — so `loaded` can exceed `total`. The
* caller treats `total` as unknown in that case and shows MB rather than a %.
* A manifest-provided `expectedBytes` (the RAW size, schema 2) wins over
* Content-Length: it's in the same units the stream counts.
*
* `onDone` fires when the LAST byte has arrived (the compile tail is still
* running inside instantiateStreaming) — the boot flips the status to
* "Compiling…" there so the post-download freeze reads truthfully.
*/
async function fetchWasmWithProgress(
url: string,
onProgress?: (loaded: number, total: number) => void,
expectedBytes?: number | null,
onDone?: () => void,
): Promise<Response> {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${url}`);
if (!res.body || !onProgress) return res;
const total = Number(res.headers.get("content-length")) || 0;
const total = expectedBytes || Number(res.headers.get("content-length")) || 0;
let loaded = 0;
const reader = res.body.getReader();
const stream = new ReadableStream<Uint8Array>({
async pull(controller) {
const { done, value } = await reader.read();
if (done) {
onDone?.();
controller.close();
return;
}
@ -211,9 +232,15 @@ async function doBoot(opts: BootOptions): Promise<void> {
onStatus,
onAbort,
onProgress,
expectedWasmBytes,
warmStart,
onWasmInstantiated,
libsSource,
modelsSource,
} = opts;
// Truthful fetch label: a warm start (download-completion marker present)
// reads from the HTTP cache, so "Downloading" would be a lie — and vice versa.
const fetchLabel = warmStart ? "Loading the editor…" : "Downloading the editor…";
// The deployed bundle backing this tool. footprint_editor/symbol_editor share
// the pcbnew/eeschema engine, so their `.wasm`/`.js`/pthread-worker files are the
// parent's; `tool` still drives identity (thisProgram), config-seed and lib-kind.
@ -315,7 +342,7 @@ async function doBoot(opts: BootOptions): Promise<void> {
}
}
onStatus("Downloading…");
onStatus(fetchLabel);
// Prefetch images.tar.gz in parallel with the (much larger) wasm download —
// exactly as the harness does. writeResources (in preRun) writes it once ready.
@ -511,7 +538,9 @@ async function doBoot(opts: BootOptions): Promise<void> {
mod.kicadSetDarkChrome?.(currentTheme() === "dark");
const canvas = (w.Module as { canvas?: HTMLCanvasElement }).canvas;
if (canvas) canvas.style.display = "block";
onStatus("");
// The runtime is up; main() is booting the wx UI next (the open-flow's
// own "Opening…" statuses take over from there).
onStatus("Starting the editor…");
},
// Resolve wasm + pthread worker against the asset base, not the SPA route.
locateFile: (path: string) => `${base}/${path}`,
@ -527,8 +556,14 @@ async function doBoot(opts: BootOptions): Promise<void> {
): Record<string, never> => {
void (async () => {
try {
onStatus("Downloading…");
const resp = await fetchWasmWithProgress(`${base}/${bundle}.wasm`, onProgress);
onStatus(fetchLabel);
const resp = await fetchWasmWithProgress(
`${base}/${bundle}.wasm`,
onProgress,
expectedWasmBytes,
// Last byte in — only the compile tail is left running.
() => onStatus("Compiling…"),
);
const ct = resp.headers.get("content-type") ?? "";
if (ct.includes("application/wasm") && WebAssembly.instantiateStreaming) {
const { instance, module } = await WebAssembly.instantiateStreaming(
@ -540,9 +575,14 @@ async function doBoot(opts: BootOptions): Promise<void> {
// Fallback (no streaming, or a server that mislabels the MIME type):
// buffer then compile. Costs peak memory but always works.
const bytes = await resp.arrayBuffer();
onStatus("Compiling…");
const { instance, module } = await WebAssembly.instantiate(bytes, imports);
success(instance, module);
}
// Download + compile both succeeded — safe to record "downloaded"
// (a truncated stream would have thrown above).
onWasmInstantiated?.();
onStatus("Starting KiCad…");
} catch (e) {
log(`[boot] wasm instantiate failed: ${String(e)}`);
onStatus(`Error: ${String(e)}`);

View file

@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { encodeBundle, sha256Hex, type SyncManifest } from "@pcbjam/shared";
import { memStore } from "@pcbjam/sync-client";
import { memStore, type LayerStore } from "@pcbjam/sync-client";
import { cdnLibsSource } from "./cdn-source";
const MANIFEST_URL = "https://cdn.test/libs/kicad/9.0.0/manifest.json";
@ -23,7 +23,7 @@ async function makeLib(items: Record<string, string>) {
return { manifest, bundle: encodeBundle(manifest, bodies) };
}
async function fakeCdn() {
async function fakeCdn(opts?: { sizes?: boolean }) {
const device = await makeLib({
"symbol/R": "(kicad_symbol_lib (symbol R))",
"symbol/C": "(kicad_symbol_lib (symbol C))",
@ -39,6 +39,13 @@ async function fakeCdn() {
{ id: "Resistor_SMD", name: "Resistor_SMD", kind: "footprint", itemCount: 1 },
],
};
// Publish-time bundle byte counts (sizes.json) — served unless disabled to
// model a tag published before the file existed.
const sizes = {
schema: 1,
tag: "9.0.0",
libs: { Device: device.bundle.byteLength, Resistor_SMD: resistors.bundle.byteLength },
};
const json = (obj: unknown) => ({ ok: true, json: async () => obj });
const bin = (bytes: Uint8Array) => ({
ok: true,
@ -46,16 +53,25 @@ async function fakeCdn() {
});
const fetchImpl = (async (url: string) => {
if (url === MANIFEST_URL) return json(top);
if (url === `${BASE}/sizes.json` && opts?.sizes !== false) return json(sizes);
if (url === `${BASE}/Device/manifest`) return json(device.manifest);
if (url === `${BASE}/Device/bundle`) return bin(device.bundle);
if (url === `${BASE}/Resistor_SMD/manifest`) return json(resistors.manifest);
if (url === `${BASE}/Resistor_SMD/bundle`) return bin(resistors.bundle);
return { ok: false, status: 404 };
}) as unknown as typeof fetch;
return cdnLibsSource(MANIFEST_URL, {
// Namespace-keyed persistent stores — the "browser's IDB": syncState's
// read-only peek must observe the same cache presync/openStack warmed.
const stores = new Map<string, LayerStore>();
const src = cdnLibsSource(MANIFEST_URL, {
fetchImpl,
storeFactory: () => memStore(),
storeFactory: (ns) => {
let s = stores.get(ns);
if (!s) stores.set(ns, (s = memStore()));
return s;
},
});
return Object.assign(src, { __sizes: sizes });
}
describe("cdn libs source", () => {
@ -137,6 +153,46 @@ describe("cdn libs source", () => {
expect(src.saveItemBody).toBeUndefined();
});
it("syncState reports cold bytes from sizes.json without downloading", async () => {
const src = await fakeCdn();
const cold = await src.syncState!("symbol");
expect(cold).toEqual({
total: 1,
warm: 0,
coldBytes: src.__sizes.libs.Device,
sizesKnown: true,
});
// The probe itself must not have warmed anything (no bundle fetch).
expect((await src.syncState!("symbol"))!.warm).toBe(0);
});
it("syncState sees presync's warmth; the other kind stays cold", async () => {
const src = await fakeCdn();
await src.presync!({ kind: "symbol" });
expect(await src.syncState!("symbol")).toEqual({
total: 1,
warm: 1,
coldBytes: 0,
sizesKnown: true,
});
expect(await src.syncState!("footprint")).toEqual({
total: 1,
warm: 0,
coldBytes: src.__sizes.libs.Resistor_SMD,
sizesKnown: true,
});
});
it("syncState on a tag without sizes.json: counts right, sizesKnown false", async () => {
const src = await fakeCdn({ sizes: false });
expect(await src.syncState!("footprint")).toEqual({
total: 1,
warm: 0,
coldBytes: 0,
sizesKnown: false,
});
});
it("getFpIndex fetches fp-index.json as raw text, null on 404", async () => {
const INDEX = { schema: 1, tag: "9.0.0", libs: { Resistor_SMD: [["R_0402_1005Metric", 2]] } };
let indexFetches = 0;

View file

@ -1,6 +1,6 @@
import { SyncStack, type SyncStackOptions } from "@pcbjam/sync-client";
import { peekNamespaces, SyncStack, type SyncStackOptions } from "@pcbjam/sync-client";
import { asyncMap } from "@/lib/async-map";
import type { LibInfo, LibItemInfo, LibsSource } from "./source";
import type { LibInfo, LibItemInfo, LibsSource, LibsSyncState } from "./source";
/**
* A read-only, no-backend `LibsSource` that serves the FULL default KiCad symbol
@ -14,6 +14,8 @@ import type { LibInfo, LibItemInfo, LibsSource } from "./source";
* Layout under the manifest's directory (`<cdn>/libs/kicad/<tag>/`):
* manifest.json top index: every lib (id, name, kind, itemCount)
* fp-index.json footprint index: per-lib [name, uniquePadCount]
* sizes.json per-lib bundle byte counts (consent dialog; may
* be absent on tags published before it existed)
* <libId>/manifest per-lib SyncManifest (GET .../manifest)
* <libId>/bundle per-lib bundle: manifest + all bodies (cold init)
* Item paths inside a lib follow the `"<kind>/<name>"` scheme.
@ -31,6 +33,11 @@ interface CdnLibsManifest {
tag: string;
libs: CdnLibEntry[];
}
interface CdnLibsSizes {
schema: number;
tag: string;
libs: Record<string, number>;
}
export function cdnLibsSource(
manifestUrl: string,
@ -74,6 +81,22 @@ export function cdnLibsSource(
return manifestP;
};
// Per-lib bundle byte counts (sizes.json). Null when the tag predates the
// file — syncState then reports sizesKnown: false. A resolved null is cached:
// the consent gate reads this once, and a 404 is a stable answer.
let sizesP: Promise<CdnLibsSizes | null> | null = null;
const loadSizes = (): Promise<CdnLibsSizes | null> => {
return (sizesP ??= (async () => {
try {
const r = await fetchImpl(`${baseDir}/sizes.json`, { cache: "no-store" });
if (!r.ok) return null;
return (await r.json()) as CdnLibsSizes;
} catch {
return null;
}
})());
};
// One lazily-opened SyncStack per lib (its IDB store is keyed by namespace, so
// a lib is cached once and reused across opens).
const stacks = new Map<string, Promise<SyncStack>>();
@ -122,6 +145,26 @@ export function cdnLibsSource(
const stack = await openStack(libId);
return (await stack.list()).map((e) => splitPath(e.path));
},
async syncState(kind): Promise<LibsSyncState> {
const m = await loadManifest();
const libs = m.libs.filter((l) => !kind || l.kind === kind);
const [sizes, peeked] = await Promise.all([
loadSizes(),
// Read-only probe (never downloads); same namespaces openStack uses,
// same default "sync:" IDB prefix as the SyncStack it fronts.
peekNamespaces(
libs.map((l) => `kicad:${m.tag}:${l.id}`),
opts?.storeFactory ? { storeFactory: opts.storeFactory } : undefined,
),
]);
let warm = 0;
let coldBytes = 0;
for (const l of libs) {
if (peeked.get(`kicad:${m.tag}:${l.id}`)) warm++;
else coldBytes += sizes?.libs[l.id] ?? 0;
}
return { total: libs.length, warm, coldBytes, sizesKnown: sizes !== null };
},
async presync(opts): Promise<void> {
const { kind, concurrency = 8, onProgress, signal } = opts ?? {};
let m: CdnLibsManifest;

View file

@ -48,6 +48,9 @@ export function remoteLibsSource(
description: l.description ?? null,
type: l.type,
itemCount: l.itemCount,
// Cache identity + cold-download bytes (standalone-load-ux 0002) —
// absent on backends predating the field.
sync: l.sync ?? null,
}));
},
@ -93,6 +96,7 @@ export function remoteLibsSource(
name: res.body.name,
description: res.body.description ?? null,
type: res.body.type,
sync: res.body.sync ?? null,
};
},
};

View file

@ -18,6 +18,12 @@ export interface LibInfo {
/** Item count, when the source knows it cheaply (manifest/backend) — shown as a
* badge in the home-page library list. Omitted ⇒ no badge. */
itemCount?: number;
/** The lib's r2-idb-sync cache identity from the backend (wire `libSchema.sync`,
* standalone-load-ux 0002): the primary sync layer's namespace (= the local
* IDB cache key a cold open hydrates) + expected cold-download bytes (null =
* unknown). Powers {@link LibsSource.syncState} on the synced source without
* resolving N stacks. Omitted by sources without a client-side sync cache. */
sync?: { namespace: string; bytes?: number | null } | null;
}
export interface LibItemInfo {
@ -33,6 +39,19 @@ export interface LibPresyncProgress {
current: string;
}
/** What {@link LibsSource.syncState} reports (drives the consent dialog). */
export interface LibsSyncState {
/** Libs of the requested kind this source serves. */
total: number;
/** How many of them are already cached locally. */
warm: number;
/** Bytes still to download to warm the rest (publish-time bundle sizes). */
coldBytes: number;
/** False when the CDN carries no size info (old tag) — coldBytes is then 0
* and meaningless even though cold libs exist. */
sizesKnown: boolean;
}
export interface LibsSource {
/**
* Libraries to expose to the editor (one lib-table row each). `kind` (the
@ -76,6 +95,17 @@ export interface LibsSource {
onProgress?: (p: LibPresyncProgress) => void;
signal?: AbortSignal;
}): Promise<void>;
/**
* How warm this source's local cache is for `kind`, WITHOUT downloading
* anything (a read-only probe — presync's dry-run counterpart). Feeds the
* download-consent dialog (standalone-load-ux 0001): `coldBytes` sums the
* publish-time bundle sizes of the not-yet-cached libs (0 for libs whose size
* the source doesn't know — `sizesKnown` says whether sizes were available at
* all). Resolves null when the source can't tell (e.g. a backend that doesn't
* expose cache identities) — the dialog then shows the row without figures.
* Optional: sources without a client-side cache omit it.
*/
syncState?(kind?: string): Promise<LibsSyncState | null>;
/**
* Persist one item body into a writable (user) lib. Optional: read-only
* sources omit it (a save into a non-writable source resolves false).

View file

@ -6,8 +6,13 @@ import {
type ServerMsg,
type SyncManifest,
} from "@pcbjam/shared";
import { memStore, type RealtimeChannel } from "@pcbjam/sync-client";
import { syncedLibsSource } from "./synced-source";
import {
memStore,
type LayerStore,
type RealtimeChannel,
} from "@pcbjam/sync-client";
import { syncedLibsSource, syncedScopeLibsSource } from "./synced-source";
import type { LibInfo, LibsSource } from "./source";
/**
* The subscribe → editor-reload bridge (r2-idb-sync task E): a REMOTE change
@ -228,3 +233,88 @@ describe("syncedLibsSource → editor reload bridge", () => {
expect(reload).not.toHaveBeenCalled();
});
});
/**
* syncedScopeLibsSource.syncState (standalone-load-ux 0002): warmth from the
* LOCAL caches (peekNamespaces on the backend-named namespaces) + cold-byte
* sums from the list envelope — no stack resolves, no bundle fetches.
*/
describe("syncedScopeLibsSource.syncState", () => {
function scoped(libs: LibInfo[]) {
const remote: LibsSource = {
listLibs: async () => libs,
listItems: async () => [],
getItemBody: async () => null,
};
const stores = new Map<string, LayerStore>();
const storeFactory = (ns: string): LayerStore => {
let s = stores.get(ns);
if (!s) stores.set(ns, (s = memStore()));
return s;
};
const src = syncedScopeLibsSource(remote, {
apiBase: API,
scope: "s",
storeFactory,
});
return { src, storeFactory };
}
/** Seed a namespace's store so the peek sees it warm. */
async function warmUp(storeFactory: (ns: string) => LayerStore, ns: string) {
await storeFactory(ns).setManifest({
version: 1,
entries: { "symbol/R": { hash: "h", size: 5, mtime: 0 } },
});
}
it("counts local warmth + sums the cold libs' envelope bytes", async () => {
const { src, storeFactory } = scoped([
{ id: "a", name: "A", sync: { namespace: "origin:a@v1", bytes: 1000 } },
{ id: "b", name: "B", sync: { namespace: "origin:b@v1", bytes: 2000 } },
{ id: "c", name: "C", sync: { namespace: "origin:c@v1", bytes: 4000 } },
]);
await warmUp(storeFactory, "origin:a@v1");
expect(await src.syncState!("symbol")).toEqual({
total: 3,
warm: 1,
coldBytes: 6000,
sizesKnown: true,
});
});
it("a cold lib without a byte figure degrades to sizesKnown: false", async () => {
const { src, storeFactory } = scoped([
{ id: "a", name: "A", sync: { namespace: "origin:a@v1", bytes: 1000 } },
// live org layer: namespace but no stamped size
{ id: "u", name: "User", type: "user", sync: { namespace: "org:u", bytes: null } },
]);
expect(await src.syncState!()).toEqual({
total: 2,
warm: 0,
coldBytes: 1000,
sizesKnown: false,
});
// …but once the unknown-size lib is the WARM one, sizes are known again.
await warmUp(storeFactory, "org:u");
expect(await src.syncState!()).toEqual({
total: 2,
warm: 1,
coldBytes: 1000,
sizesKnown: true,
});
});
it("returns null (unknown) when the backend names no namespaces at all", async () => {
const { src } = scoped([{ id: "a", name: "A" }]); // pre-`sync`-field backend
expect(await src.syncState!()).toBeNull();
});
it("probing never initializes a cold cache", async () => {
const { src, storeFactory } = scoped([
{ id: "a", name: "A", sync: { namespace: "origin:a@v1", bytes: 1000 } },
]);
await src.syncState!();
expect(await storeFactory("origin:a@v1").getManifest()).toBeNull();
});
});

View file

@ -1,5 +1,6 @@
import { PROJECT_HEADER, SCOPE_HEADER, USER_HEADER } from "@pcbjam/shared";
import {
peekNamespaces,
SyncStack,
type ChannelFactory,
type LayerDescriptor,
@ -11,6 +12,7 @@ import {
type LibItemInfo,
type LibItemUpdatedDetail,
type LibsSource,
type LibsSyncState,
} from "./source";
/**
@ -290,6 +292,44 @@ export function syncedScopeLibsSource(
listLibs: (kind) => remote.listLibs(kind),
createLib: remote.createLib?.bind(remote),
getFpIndex: remote.getFpIndex?.bind(remote),
async syncState(kind): Promise<LibsSyncState | null> {
// Read-only warmth probe for the download-consent gate (standalone-
// load-ux 0002): the backend's list envelope names each lib's primary
// sync namespace (= its IDB cache key) + expected cold bytes, so warmth
// is answerable from the local cache alone — no stack resolves, no
// bundle fetches. A backend predating the `sync` field yields no
// namespaces at all → null (unknown), never a fake all-cold answer.
const libs = await remote.listLibs(kind);
const withNs = libs.filter(
(l): l is LibInfo & { sync: { namespace: string; bytes?: number | null } } =>
!!l.sync?.namespace,
);
if (withNs.length === 0) return null;
const peeked = await peekNamespaces(
withNs.map((l) => l.sync.namespace),
opts.storeFactory ? { storeFactory: opts.storeFactory } : undefined,
);
let warm = 0;
let coldBytes = 0;
// Libs with no namespace at all are cold-with-unknown-size by definition
// (they can't be probed and carry no byte figure).
let coldUnknown = libs.length - withNs.length;
for (const l of withNs) {
if (peeked.get(l.sync.namespace)) warm++;
else if (typeof l.sync.bytes === "number") coldBytes += l.sync.bytes;
else coldUnknown++;
}
const cold = libs.length - warm;
return {
total: libs.length,
warm,
coldBytes,
// "Sizes known" = every cold lib had a byte figure. Cold libs WITHOUT
// one (live org/mirror layers, unstamped versions) make the MB figure
// an undercount, so degrade to the count-only wording instead.
sizesKnown: cold > 0 ? coldUnknown === 0 : true,
};
},
listItems: (libId) => forLib(libId).listItems(libId),
getAllItems: (libId) => forLib(libId).getAllItems!(libId),
getItemBody: (libId, kind, name) =>

View file

@ -1,15 +1,15 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Tool } from "@pcbjam/shared";
// resolveWasmBase reads WASM_ROOT / WASM_MANIFEST_FILE from config at import
// resolveWasmBase/Meta read WASM_ROOT / WASM_MANIFEST_FILE from config at import
// time, so each case mocks config fresh then dynamically imports the module.
async function loadResolver(cfg: {
async function loadModule(cfg: {
WASM_ROOT: string;
WASM_MANIFEST_FILE: string | null;
}) {
vi.resetModules();
vi.doMock("@/lib/config", () => cfg);
return (await import("./wasm-assets")).resolveWasmBase;
return await import("./wasm-assets");
}
const PCBNEW = "pcbnew" as Tool;
@ -23,11 +23,11 @@ describe("resolveWasmBase", () => {
it("uses an explicit override verbatim (trailing slash stripped), no fetch", async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const resolve = await loadResolver({
const { resolveWasmBase } = await loadModule({
WASM_ROOT: "/wasm",
WASM_MANIFEST_FILE: "manifest-1.json",
});
expect(await resolve(PCBNEW, "https://cdn.example/wasm/pcbnew/9/")).toBe(
expect(await resolveWasmBase(PCBNEW, "https://cdn.example/wasm/pcbnew/9/")).toBe(
"https://cdn.example/wasm/pcbnew/9",
);
expect(fetchMock).not.toHaveBeenCalled();
@ -36,32 +36,34 @@ describe("resolveWasmBase", () => {
it("returns the flat root when no manifest is configured (dev), no fetch", async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const resolve = await loadResolver({
const { resolveWasmBase } = await loadModule({
WASM_ROOT: "/wasm",
WASM_MANIFEST_FILE: null,
});
expect(await resolve(PCBNEW)).toBe("/wasm");
expect(await resolveWasmBase(PCBNEW)).toBe("/wasm");
expect(fetchMock).not.toHaveBeenCalled();
});
it("resolves the per-tool versioned folder from the manifest", async () => {
it("resolves the tool's BUNDLE folder from the manifest (pcbnew → kicad_editor)", async () => {
const fetchMock = vi.fn(async () => ({
ok: true,
json: async () => ({
schema: 1,
schema: 2,
tag: "2.7.7",
tools: { pcbnew: "2.7.5", eeschema: "2.7.1" },
tools: { kicad_editor: "2.7.5", gerbview: "2.7.1" },
}),
}));
vi.stubGlobal("fetch", fetchMock);
const resolve = await loadResolver({
const { resolveWasmBase } = await loadModule({
WASM_ROOT: "https://cdn.pcbjam.com/wasm",
WASM_MANIFEST_FILE: "manifest-2.7.7.json",
});
expect(await resolve(PCBNEW)).toBe("https://cdn.pcbjam.com/wasm/pcbnew/2.7.5");
expect(await resolveWasmBase(PCBNEW)).toBe(
"https://cdn.pcbjam.com/wasm/kicad_editor/2.7.5",
);
// Manifest is fetched uncached, and only ONCE across calls (in-memory cached).
expect(await resolve("eeschema" as Tool)).toBe(
"https://cdn.pcbjam.com/wasm/eeschema/2.7.1",
expect(await resolveWasmBase("gerbview" as Tool)).toBe(
"https://cdn.pcbjam.com/wasm/gerbview/2.7.1",
);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith(
@ -70,24 +72,175 @@ describe("resolveWasmBase", () => {
);
});
it("throws when the manifest omits the tool", async () => {
it("throws when the manifest omits the tool's bundle", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({ ok: true, json: async () => ({ tools: {} }) })),
);
const resolve = await loadResolver({
const { resolveWasmBase } = await loadModule({
WASM_ROOT: "https://cdn.pcbjam.com/wasm",
WASM_MANIFEST_FILE: "manifest-2.7.7.json",
});
await expect(resolve(PCBNEW)).rejects.toThrow(/no WASM version for "pcbnew"/);
await expect(resolveWasmBase(PCBNEW)).rejects.toThrow(
/no WASM version for "kicad_editor"/,
);
});
it("throws when the manifest fetch fails", async () => {
vi.stubGlobal("fetch", vi.fn(async () => ({ ok: false, status: 404 })));
const resolve = await loadResolver({
const { resolveWasmBase } = await loadModule({
WASM_ROOT: "https://cdn.pcbjam.com/wasm",
WASM_MANIFEST_FILE: "manifest-2.7.7.json",
});
await expect(resolve(PCBNEW)).rejects.toThrow(/WASM manifest 404/);
await expect(resolveWasmBase(PCBNEW)).rejects.toThrow(/WASM manifest 404/);
});
});
describe("resolveWasmMeta", () => {
it("carries the bundle's version + sizes from a schema-2 manifest", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({
ok: true,
json: async () => ({
schema: 2,
tag: "2.7.7",
tools: { kicad_editor: "2.7.5" },
sizes: {
kicad_editor: { wasm: 350_000_000, wasmStored: 90_000_000, totalStored: 95_000_000 },
},
}),
})),
);
const { resolveWasmMeta } = await loadModule({
WASM_ROOT: "https://cdn.pcbjam.com/wasm",
WASM_MANIFEST_FILE: "manifest-2.7.7.json",
});
expect(await resolveWasmMeta(PCBNEW)).toEqual({
base: "https://cdn.pcbjam.com/wasm/kicad_editor/2.7.5",
bundle: "kicad_editor",
ver: "2.7.5",
sizes: { wasm: 350_000_000, wasmStored: 90_000_000, totalStored: 95_000_000 },
});
});
it("has null ver/sizes for schema-1 manifests, overrides and flat dev roots", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({
ok: true,
json: async () => ({ schema: 1, tag: "2.7.7", tools: { kicad_editor: "2.7.5" } }),
})),
);
const { resolveWasmMeta } = await loadModule({
WASM_ROOT: "https://cdn.pcbjam.com/wasm",
WASM_MANIFEST_FILE: "manifest-2.7.7.json",
});
// schema 1: version resolves, sizes absent
expect((await resolveWasmMeta(PCBNEW)).sizes).toBeNull();
// override / flat: no version → the consent gate never runs there
expect(await resolveWasmMeta(PCBNEW, "https://x/y")).toMatchObject({
ver: null,
sizes: null,
});
const flat = await loadModule({ WASM_ROOT: "/wasm", WASM_MANIFEST_FILE: null });
expect(await flat.resolveWasmMeta(PCBNEW)).toMatchObject({ ver: null, sizes: null });
});
});
describe("download-completion marker", () => {
function stubLocalStorage() {
const map = new Map<string, string>();
const ls = {
getItem: (k: string) => map.get(k) ?? null,
setItem: (k: string, v: string) => void map.set(k, v),
removeItem: (k: string) => void map.delete(k),
key: (i: number) => [...map.keys()][i] ?? null,
get length() {
return map.size;
},
};
vi.stubGlobal("localStorage", ls);
return map;
}
it("marks + reads back one bundle/version; a version bump reads cold", async () => {
stubLocalStorage();
const { isWasmDownloaded, markWasmDownloaded } = await loadModule({
WASM_ROOT: "/wasm",
WASM_MANIFEST_FILE: null,
});
expect(isWasmDownloaded("kicad_editor", "2.7.5")).toBe(false);
markWasmDownloaded("kicad_editor", "2.7.5");
expect(isWasmDownloaded("kicad_editor", "2.7.5")).toBe(true);
// content-addressed: a new release is honestly "not downloaded yet"
expect(isWasmDownloaded("kicad_editor", "2.8.0")).toBe(false);
// flat/override layouts (ver null) never read warm and never write
expect(isWasmDownloaded("kicad_editor", null)).toBe(false);
markWasmDownloaded("gerbview", null);
expect(isWasmDownloaded("gerbview", "any")).toBe(false);
});
it("keeps ONE marker per bundle and flags prior versions as an update", async () => {
const map = stubLocalStorage();
const { hasAnyWasmDownload, isWasmDownloaded, markWasmDownloaded } =
await loadModule({ WASM_ROOT: "/wasm", WASM_MANIFEST_FILE: null });
expect(hasAnyWasmDownload("kicad_editor")).toBe(false);
markWasmDownloaded("kicad_editor", "2.7.5");
markWasmDownloaded("gerbview", "2.7.5");
// Before re-downloading, the OLD marker makes the consent an "update".
expect(hasAnyWasmDownload("kicad_editor")).toBe(true);
markWasmDownloaded("kicad_editor", "2.8.0");
expect(isWasmDownloaded("kicad_editor", "2.8.0")).toBe(true);
expect(isWasmDownloaded("kicad_editor", "2.7.5")).toBe(false); // superseded, dropped
expect(isWasmDownloaded("gerbview", "2.7.5")).toBe(true); // other bundles untouched
expect([...map.keys()].filter((k) => k.includes("kicad_editor"))).toHaveLength(1);
});
it("treats blocked/absent storage as cold and never throws", async () => {
// node env: no localStorage global at all
const { hasAnyWasmDownload, isWasmDownloaded, markWasmDownloaded } =
await loadModule({ WASM_ROOT: "/wasm", WASM_MANIFEST_FILE: null });
expect(isWasmDownloaded("kicad_editor", "2.7.5")).toBe(false);
expect(hasAnyWasmDownload("kicad_editor")).toBe(false);
expect(() => markWasmDownloaded("kicad_editor", "2.7.5")).not.toThrow();
});
it("auto-download preference round-trips and defaults off", async () => {
stubLocalStorage();
const { autoDownloadEnabled, setAutoDownloadEnabled } = await loadModule({
WASM_ROOT: "/wasm",
WASM_MANIFEST_FILE: null,
});
expect(autoDownloadEnabled()).toBe(false);
setAutoDownloadEnabled(true);
expect(autoDownloadEnabled()).toBe(true);
setAutoDownloadEnabled(false);
expect(autoDownloadEnabled()).toBe(false);
});
});
describe("fetchWasmStoredSize", () => {
it("HEADs the wasm and returns Content-Length; null on failure", async () => {
const fetchMock = vi.fn(async () => ({
ok: true,
headers: { get: () => "12345678" },
}));
vi.stubGlobal("fetch", fetchMock);
const { fetchWasmStoredSize } = await loadModule({
WASM_ROOT: "/wasm",
WASM_MANIFEST_FILE: null,
});
expect(await fetchWasmStoredSize("https://cdn/x/1", "kicad_editor")).toBe(12345678);
expect(fetchMock).toHaveBeenCalledWith("https://cdn/x/1/kicad_editor.wasm", {
method: "HEAD",
});
vi.stubGlobal("fetch", vi.fn(async () => ({ ok: false, status: 405 })));
expect(await fetchWasmStoredSize("https://cdn/x/1", "kicad_editor")).toBeNull();
vi.stubGlobal("fetch", vi.fn(async () => ({ ok: true, headers: { get: () => null } })));
expect(await fetchWasmStoredSize("https://cdn/x/1", "kicad_editor")).toBeNull();
vi.stubGlobal("fetch", vi.fn(async () => Promise.reject(new Error("net"))));
expect(await fetchWasmStoredSize("https://cdn/x/1", "kicad_editor")).toBeNull();
});
});

View file

@ -13,11 +13,27 @@ import { TOOL_BUNDLE, type Bundle } from "./constants";
* load with no app rebuild). See docs/features/demo-deploy/0001-*.
*
* No manifest configured (dev / same-origin) ⇒ the flat `WASM_ROOT` layout.
*
* schema 2 manifests also carry per-bundle `sizes` (standalone-load-ux 0001):
* `wasm` = RAW/decoded bytes (matches the boot progress stream's counting),
* `wasmStored`/`totalStored` = over-the-wire bytes (post br/gzip) — what the
* download consent dialog quotes. Both optional: schema-1 manifests and tools
* published before schema 2 carry none/partials.
*/
export interface WasmBundleSizes {
/** Raw (decoded) `.wasm` bytes — the progress bar's true total. */
wasm?: number;
/** Over-the-wire `.wasm` bytes (compressed). */
wasmStored?: number;
/** Over-the-wire bytes for the whole bundle (wasm + glue + resources). */
totalStored?: number;
}
export interface WasmManifest {
schema: number;
tag: string;
tools: Record<string, string>;
sizes?: Record<string, WasmBundleSizes>;
}
let manifestPromise: Promise<WasmManifest> | null = null;
@ -31,6 +47,52 @@ function loadManifest(): Promise<WasmManifest> {
})());
}
/**
* Everything the boot + the download-consent gate need to know about a tool's
* deployed bundle:
* - `base`: asset base (no trailing slash) for `bootKicadTool({ base })`.
* - `bundle`: the deployed bundle backing the tool (see TOOL_BUNDLE).
* - `ver`: the bundle's content-addressed version — null in the flat/override
* layouts, where there's no versioning (and the gate doesn't run).
* - `sizes`: byte counts when the manifest carries them (schema 2), else null.
*/
export interface WasmMeta {
base: string;
bundle: Bundle;
ver: string | null;
sizes: WasmBundleSizes | null;
}
/** A tool may be served by a shared bundle (all four editors → kicad_editor);
* a caller may also name a bundle directly (occ_service). */
function bundleOf(tool: Tool | Bundle): Bundle {
return (TOOL_BUNDLE as Partial<Record<string, Bundle>>)[tool] ?? (tool as Bundle);
}
export async function resolveWasmMeta(
tool: Tool | Bundle,
override?: string,
): Promise<WasmMeta> {
const bundle = bundleOf(tool);
if (override) {
return { base: override.replace(/\/+$/, ""), bundle, ver: null, sizes: null };
}
if (!WASM_MANIFEST_FILE) {
return { base: WASM_ROOT, bundle, ver: null, sizes: null }; // flat (dev)
}
const manifest = await loadManifest();
const ver = manifest.tools?.[bundle];
if (!ver) {
throw new Error(`no WASM version for "${bundle}" in ${WASM_MANIFEST_FILE}`);
}
return {
base: `${WASM_ROOT}/${bundle}/${ver}`,
bundle,
ver,
sizes: manifest.sizes?.[bundle] ?? null,
};
}
/**
* Asset base (no trailing slash) for `bootKicadTool({ base })`.
* - `override` (e.g. an e2e fixture) wins, used verbatim.
@ -41,18 +103,110 @@ export async function resolveWasmBase(
tool: Tool | Bundle,
override?: string,
): Promise<string> {
if (override) return override.replace(/\/+$/, "");
if (!WASM_MANIFEST_FILE) return WASM_ROOT; // flat (dev / same-origin)
// A tool may be served by a shared bundle (all four editors → kicad_editor);
// resolve the folder/version of the bundle, not the logical tool (bundles are
// the only thing published/listed in the manifest). A caller may also name a
// bundle directly (occ_service — a delivery artifact backing no tool).
const bundle: Bundle =
(TOOL_BUNDLE as Partial<Record<string, Bundle>>)[tool] ?? (tool as Bundle);
const manifest = await loadManifest();
const ver = manifest.tools?.[bundle];
if (!ver) {
throw new Error(`no WASM version for "${bundle}" in ${WASM_MANIFEST_FILE}`);
}
return `${WASM_ROOT}/${bundle}/${ver}`;
return (await resolveWasmMeta(tool, override)).base;
}
/* --------------------------------------------- download-completion marker -- */
// "Have we downloaded this exact bundle before?" The wasm has no explicit cache
// — a plain fetch rides the browser HTTP cache, which is unqueryable cross-
// origin — so completed downloads are recorded here, keyed by the content-
// addressed bundle/version (a release bump changes the key, so a new version
// honestly reads as not-downloaded). If the HTTP cache was evicted anyway, the
// only cost is a fast-labeled load that shows a real progress bar.
const MARKER_PREFIX = "pcbjam:wasm-downloaded:";
function markerKey(bundle: string, ver: string): string {
return `${MARKER_PREFIX}${bundle}/${ver}`;
}
export function isWasmDownloaded(bundle: string, ver: string | null): boolean {
if (!ver) return false;
try {
return localStorage.getItem(markerKey(bundle, ver)) !== null;
} catch {
return false; // storage blocked ⇒ treat as cold (the safe direction)
}
}
export function markWasmDownloaded(bundle: string, ver: string | null): void {
if (!ver) return;
try {
// One marker per bundle: drop superseded versions' markers (their HTTP
// cache entries are dead weight anyway once a new version is in use).
for (const key of staleMarkerKeys(bundle, ver)) localStorage.removeItem(key);
localStorage.setItem(
markerKey(bundle, ver),
JSON.stringify({ at: new Date().toISOString() }),
);
} catch {
/* storage blocked — next load just re-consents */
}
}
function staleMarkerKeys(bundle: string, keepVer: string): string[] {
const prefix = `${MARKER_PREFIX}${bundle}/`;
const stale: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key?.startsWith(prefix) && key !== markerKey(bundle, keepVer)) {
stale.push(key);
}
}
return stale;
}
/** A PREVIOUS version of this bundle was downloaded — the consent dialog words
* the download as an update rather than a first-time setup. */
export function hasAnyWasmDownload(bundle: string): boolean {
try {
const prefix = `${MARKER_PREFIX}${bundle}/`;
for (let i = 0; i < localStorage.length; i++) {
if (localStorage.key(i)?.startsWith(prefix)) return true;
}
} catch {
/* storage blocked */
}
return false;
}
/** Over-the-wire `.wasm` size via HEAD, for manifests without `sizes` (schema
* 1 / pre-schema-2 tools). Best-effort: null when the CDN rejects HEAD or
* sends no usable Content-Length. */
export async function fetchWasmStoredSize(
base: string,
bundle: string,
): Promise<number | null> {
try {
const r = await fetch(`${base}/${bundle}.wasm`, { method: "HEAD" });
if (!r.ok) return null;
const n = Number(r.headers.get("content-length"));
return n > 0 ? n : null;
} catch {
return null;
}
}
/* ------------------------------------------------ auto-download preference -- */
// "Always download without asking" (the consent dialog's checkbox): a device-
// wide opt out of the gate — new versions then download silently, as before
// the gate existed.
const AUTO_DOWNLOAD_KEY = "pcbjam:wasm-autodownload";
export function autoDownloadEnabled(): boolean {
try {
return localStorage.getItem(AUTO_DOWNLOAD_KEY) === "1";
} catch {
return false;
}
}
export function setAutoDownloadEnabled(on: boolean): void {
try {
if (on) localStorage.setItem(AUTO_DOWNLOAD_KEY, "1");
else localStorage.removeItem(AUTO_DOWNLOAD_KEY);
} catch {
/* storage blocked */
}
}