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:
parent
cf2fd424b5
commit
8ee8db69e6
14 changed files with 972 additions and 73 deletions
|
|
@ -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}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue