feat: consume the boot payload (load-path-rework 0001 §6, client)

useProjectBoot: the tool page tries the ONE composed boot round-trip first —
its project half is the getProject shape — and falls back to the active
source's getProject on any miss (older backend, local-store slug, static
gallery), so downstream behavior without a payload is exactly pre-boot.

With a payload: seedSessionIdentity makes the /api/me flight a resolved
no-op; the synced libs source is constructed with the preloaded listing
(kind-filtered client-side by kindCounts — the same org/mirror-always +
libHasKind rule the server applies) and the batch-resolved stacks, and every
INTERNAL listing (syncState, presync, enableRealtime name-mapping) rides the
preload too; the fresh project-sync digest threads into staging so a warm
restage affirms with zero HTTP.

Measured warm load (Arduino repo-as-project): total API traffic = 1 boot
request + the ydoc-backed per-file set — /api/me, /libs, sync-stacks and
/sync/manifest all at zero.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLSht9cadprtT2mhynawWu
This commit is contained in:
Gergő Törcsvári 2026-08-18 12:17:29 +02:00
commit 7c15f14bb8
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
11 changed files with 366 additions and 29 deletions

View file

@ -30,7 +30,7 @@ import {
} from "@/lib/config";
import { defaultFileName, newFileTemplate, withExtension } from "@/lib/new-file";
import { redirectTargetFor } from "@/lib/redirect";
import { loadSessionIdentity } from "@/lib/session-identity";
import { loadSessionIdentity, seedSessionIdentity } from "@/lib/session-identity";
import { setTheme, useThemeValue } from "@/lib/theme";
import { bootKicadTool } from "@/wasm/boot";
import {
@ -954,6 +954,7 @@ export function WasmTool({
libsSource,
sourceDescriptor,
readOnly = false,
boot = null,
}: {
tool: Tool;
slug: string;
@ -972,6 +973,13 @@ export function WasmTool({
* a specific backend lib, or a local `.kicad_sym`/`.kicad_mod` file.
*/
libsSource?: LibsSource | null;
/**
* The composed boot payload (load-path-rework 0001 §6), when the page's ONE
* boot round-trip answered: seeds identity, the lib listing + stack
* resolves, and the project sync digest. Null each consumer uses its
* individual endpoint the pre-boot behavior.
*/
boot?: import("@/lib/boot-payload").BootPayload | null;
/** Fetch one project-relative file's bytes (contract loader or local folder). */
fetchBytes: (relPath: string) => Promise<Uint8Array>;
/**
@ -1639,7 +1647,9 @@ export function WasmTool({
// parallel with the WASM download; awaited after boot, before anything
// binds presence/comments, so presenceUser()/userSlug() speak for the
// authenticated user (anonymous/example backends resolve to null and
// the pre-auth slug fallback stays).
// the pre-auth slug fallback stays). A boot payload already carries
// the /api/me shape — seeding it makes this a resolved no-op flight.
if (boot?.me) seedSessionIdentity(boot.me);
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.
@ -1647,9 +1657,15 @@ export function WasmTool({
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).
// must be the same object for the warm-up to benefit the editor). A boot
// payload pre-seeds its lib listing + stack resolves (zero lib HTTP).
const source =
libsSource !== undefined ? libsSource : libsSourceConfig(projectId);
libsSource !== undefined
? libsSource
: libsSourceConfig(
projectId,
boot ? { libs: boot.libs, stacks: boot.stacks } : undefined,
);
if (libsSource === undefined) ownedLibsSource = source;
// Download-consent gate (standalone-load-ux 0001): before pulling the
// (large) cold wasm + lib bundles, say how many MB and wait for the OK.
@ -1897,6 +1913,8 @@ export function WasmTool({
scope: currentScope(),
scopeId,
projectId,
// Boot's fresh digest: a warm match stages with ZERO HTTP.
digest: boot?.projectSync.digest,
}
: null,
log: append,

View file

@ -1,6 +1,17 @@
import type { DriftReportBody, Project, ProjectFile } from "@pcbjam/shared";
import type {
DriftReportBody,
Project,
ProjectFile,
ProjectWithFiles,
} from "@pcbjam/shared";
import { useQuery } from "@tanstack/react-query";
import { API_BASE_URL, currentScope, libsSourceConfig } from "./config";
import {
API_BASE_URL,
currentScope,
libsSourceConfig,
PROJECT_SOURCE_KIND,
} from "./config";
import { fetchBootPayload, type BootPayload } from "./boot-payload";
import { client } from "./contract-client";
import type { LibInfo } from "@/wasm/libs/source";
import { downloadBytes } from "./download";
@ -72,6 +83,31 @@ export function useProject(slug: string) {
});
}
/**
* The tool page's project query, boot-endpoint first (load-path-rework 0001
* §6): remote deployments try the ONE composed boot round-trip its project
* half is exactly the getProject shape, and the extras (identity, libs,
* stacks, project sync digest) ride along for WasmTool. Any miss (older
* backend, a local-store slug answering 404, the static gallery) falls back
* to the active source's getProject with `boot: null`, which is exactly the
* pre-boot behavior everywhere downstream.
*/
export function useProjectBoot(slug: string) {
return useQuery({
queryKey: ["project-boot", slug],
queryFn: async (): Promise<{
data: ProjectWithFiles & { access?: "read" | "write" };
boot: BootPayload | null;
}> => {
if (PROJECT_SOURCE_KIND !== "static") {
const boot = await fetchBootPayload(currentScope(), slug);
if (boot) return { data: boot, boot };
}
return { data: await projectSource().getProject(slug), boot: null };
},
});
}
/**
* File bytes from the active source (backend stream, or the static CDN
* gallery). Pass the file's row from the current listing as `meta` when you

View file

@ -0,0 +1,65 @@
import {
USER_HEADER,
type ProjectWithFiles,
type SyncManifest,
type SyncStackDescriptor,
} from "@pcbjam/shared";
import { API_BASE_URL, userSlug } from "./config";
import { fileCacheValidator, pruneProjectFileCache } from "./project-file-cache";
/**
* The ONE editor boot round-trip (load-path-rework 0001 §6):
* `GET /api/scopes/:scope/projects/:project/boot` composes everything the
* editor otherwise collects across four serial API calls identity, the
* project + files + access, the visible-lib DTOs, the batch-resolved sync
* stacks, and the project sync-namespace manifest with its digest. Every
* piece is OPTIONAL downstream: an absent payload (older backend, demo
* gallery, local store) leaves each consumer on its individual endpoint,
* so this can only ever remove requests, never break a boot.
*/
/** Wire lib DTO — the `GET /libs` shape, structurally (no contract import). */
export interface BootLibDto {
id: string;
name: string;
description?: string | null;
type: string;
itemCount?: number;
sync?: { namespace: string; bytes: number | null } | null;
}
export interface BootPayload extends ProjectWithFiles {
me: unknown;
access?: "read" | "write";
libs: BootLibDto[];
stacks: Record<string, SyncStackDescriptor | null>;
projectSync: { manifest: SyncManifest; digest: string };
}
/** Null on ANY failure (404 = older backend / non-boot source) callers fall
* back to the individual endpoints. */
export async function fetchBootPayload(
scope: string,
slug: string,
): Promise<BootPayload | null> {
try {
const res = await fetch(
`${API_BASE_URL}/api/scopes/${encodeURIComponent(scope)}/projects/${encodeURIComponent(slug)}/boot`,
{ credentials: "include", headers: { [USER_HEADER]: userSlug() } },
);
if (!res.ok) return null;
const body = (await res.json()) as BootPayload;
if (!body?.project || !Array.isArray(body.files)) return null;
// Fresh listing = fresh cache truth (same prune the remote source's
// getProject performs) — the boot payload replaces that call.
const valid = new Map<string, string>();
for (const f of body.files) {
const v = fileCacheValidator(f);
if (v) valid.set(f.path, v);
}
void pruneProjectFileCache(body.project.id, valid);
return body;
} catch {
return null;
}
}

View file

@ -311,7 +311,19 @@ export function modelsSourceConfig(): Model3dSource | null {
: null;
}
export function libsSourceConfig(projectId?: string): LibsSource | null {
/** The boot payload's lib hand-over (see wasm/libs/synced-source.ts). */
export interface LibsBootPreload {
libs: import("@/wasm/libs/synced-source").PreloadedLibDto[];
stacks: Record<
string,
import("@pcbjam/shared").SyncStackDescriptor | null
>;
}
export function libsSourceConfig(
projectId?: string,
preload?: LibsBootPreload,
): LibsSource | null {
const kind = import.meta.env.VITE_LIBS_SOURCE ?? "remote";
// "local" is the placeholder id for launches with no real backend project
// (local folder, tool grid, lib-scoped open). It is NOT a project on the
@ -338,6 +350,7 @@ export function libsSourceConfig(projectId?: string): LibsSource | null {
scope: currentScope(),
user: userSlug(),
project,
preloaded: preload,
log: (m) => console.log(m),
},
)

View file

@ -1,6 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
loadSessionIdentity,
seedSessionIdentity,
resetSessionIdentityForTest,
sessionIdentity,
} from "./session-identity";
@ -69,4 +70,18 @@ describe("loadSessionIdentity", () => {
await loadSessionIdentity("http://api");
expect(f).toHaveBeenCalledTimes(1);
});
it("a seeded boot payload makes loadSessionIdentity a no-fetch resolved flight", async () => {
const f = vi
.spyOn(globalThis, "fetch")
.mockRejectedValue(new Error("must not fetch"));
seedSessionIdentity({ user: { slug: "boots", name: "Boot User" } });
expect(await loadSessionIdentity("http://api")).toEqual({
slug: "boots",
name: "Boot User",
email: undefined,
});
expect(sessionIdentity()?.slug).toBe("boots");
expect(f).not.toHaveBeenCalled();
});
});

View file

@ -34,19 +34,7 @@ export function loadSessionIdentity(
pending = fetch(`${apiBase}/api/me`, { credentials: "include" })
.then((r) => (r.ok ? r.json() : null))
.then((body: unknown) => {
const u = (
body as {
user?: { slug?: unknown; name?: unknown; email?: unknown } | null;
} | null
)?.user;
if (u && typeof u.slug === "string" && u.slug) {
const email = typeof u.email === "string" && u.email ? u.email : undefined;
identity = {
slug: u.slug,
name: (typeof u.name === "string" && u.name) || email || u.slug,
email,
};
}
adoptMePayload(body);
return identity;
})
.catch(() => null);
@ -54,6 +42,32 @@ export function loadSessionIdentity(
return pending;
}
/**
* Seed the identity from an already-fetched `/api/me`-shaped payload (the boot
* endpoint's `me` — load-path-rework 0001 §6), making `loadSessionIdentity`'s
* own fetch a no-op resolved flight. Must run BEFORE it is first called.
*/
export function seedSessionIdentity(me: unknown): void {
adoptMePayload(me);
pending = Promise.resolve(identity);
}
function adoptMePayload(body: unknown): void {
const u = (
body as {
user?: { slug?: unknown; name?: unknown; email?: unknown } | null;
} | null
)?.user;
if (u && typeof u.slug === "string" && u.slug) {
const email = typeof u.email === "string" && u.email ? u.email : undefined;
identity = {
slug: u.slug,
name: (typeof u.name === "string" && u.name) || email || u.slug,
email,
};
}
}
/** Test-only: forget the cached identity + in-flight fetch. */
export function resetSessionIdentityForTest(): void {
identity = null;

View file

@ -5,7 +5,7 @@ import {
createProjectFileIfMissing,
fetchFileBytes,
uploadFileBytes,
useProject,
useProjectBoot,
useSourceDescriptor,
} from "@/lib/api";
import { docSourceConfig } from "@/lib/config";
@ -30,7 +30,9 @@ export function ToolPage() {
: (parseToolParam(search.get("tool")) ?? (splat ? toolForFile(splat) : null));
const targetPath = params.tool ? undefined : splat;
const { data, isLoading, error } = useProject(slug);
// Boot-endpoint first (one composed round-trip); getProject fallback inside.
const { data: bootData, isLoading, error } = useProjectBoot(slug);
const data = bootData?.data;
const { data: sourceDescriptor } = useSourceDescriptor(slug);
// Listing rows by path, handed to fetchFileBytes so the remote source can
// serve unchanged files from the local body cache (project-file-cache.ts).
@ -102,6 +104,7 @@ export function ToolPage() {
docSource={docSource}
sourceDescriptor={sourceDescriptor}
readOnly={readOnly}
boot={bootData?.boot ?? null}
/>
</PreflightGate>
);

View file

@ -33,6 +33,13 @@ export interface ProjectSyncConfig {
scope: string;
scopeId: string;
projectId: string;
/**
* The boot payload's fresh manifest digest (load-path-rework 0001 §6). It
* was computed from the same listing this boot just fetched, so trusting it
* is exactly as current as GETting /sync/manifest ourselves a warm match
* makes project staging ZERO-request. Absent one manifest GET, as before.
*/
digest?: string;
/** Test seams (default: credentialed global fetch / IndexedDB stores). */
fetchImpl?: typeof fetch;
storeFactory?: ConstructorParameters<typeof SyncStack>[0]["storeFactory"];
@ -220,6 +227,7 @@ export async function stageViaProjectSync(
namespace: `project:${sync.scopeId}:${sync.projectId}`,
kind: "static",
url: `${sync.apiBase}/api/scopes/${encodeURIComponent(sync.scope)}/projects/${encodeURIComponent(opts.slug)}/sync`,
digest: sync.digest,
},
],
fetchImpl: credentialed,

View file

@ -310,6 +310,77 @@ describe("cutover 409 → re-resolve + retry (load-path-rework 0002)", () => {
});
});
describe("boot preload (load-path-rework 0001 §6)", () => {
it("serves listLibs locally (server's kind rule) and skips the stack resolve", async () => {
const server = await fakeServer({ "symbol/R": "(r)" });
const failingResolve = ((input: unknown, init?: RequestInit) => {
if (String(input).includes("sync-stack")) {
throw new Error("resolve must not be called with a preloaded stack");
}
return (server.fetchImpl as (i: unknown, x?: RequestInit) => unknown)(
input,
init,
);
}) as typeof fetch;
const remoteStub = {
listLibs: () => {
throw new Error("remote listLibs must not be called");
},
} as unknown as LibsSource;
const source = syncedScopeLibsSource(remoteStub, {
apiBase: API,
scope: "s",
user: "u",
fetchImpl: failingResolve,
storeFactory: () => memStore(),
channelFactory: () => server.channel,
preloaded: {
libs: [
{ id: LIB_ID, name: "My Lib", type: "org" },
{
id: "lib-sym",
name: "Symbols Only",
type: "origin",
kindCounts: { symbol: 12 },
sync: { namespace: "origin:lib-sym@v1", bytes: 42 },
},
{
id: "lib-fp",
name: "Footprints Only",
type: "origin",
kindCounts: { footprint: 7 },
},
],
stacks: {
[LIB_ID]: {
lib: { id: LIB_ID, name: "My Lib" },
layers: [
{ namespace: `org:${LIB_ID}`, kind: "live", url: ROOM, writable: true },
],
},
},
},
});
// Kind filter matches the server: org libs always; origins by kindCounts.
const symbols = await source.listLibs!("symbol");
expect(symbols.map((l) => l.id).sort()).toEqual([LIB_ID, "lib-sym"]);
expect(symbols.find((l) => l.id === "lib-sym")!.sync).toEqual({
namespace: "origin:lib-sym@v1",
bytes: 42,
});
const footprints = await source.listLibs!("footprint");
expect(footprints.map((l) => l.id).sort()).toEqual([LIB_ID, "lib-fp"]);
// The preloaded stack answers the per-lib resolve: a save works with the
// resolve endpoint hard-failing.
const ok = await source.saveItemBody!(LIB_ID, "symbol", "Mine", "(body)");
expect(ok).toBe(true);
source.dispose?.();
});
});
describe("syncedScopeLibsSource.syncState", () => {
function scoped(libs: LibInfo[]) {
const remote: LibsSource = {

View file

@ -333,6 +333,20 @@ function splitPath(path: string): LibItemInfo {
* "lazy materialization" deferred note) same trade the lib-editor pages
* already make.
*/
/** A lib DTO handed over from the boot payload (structural the wire shape
* of `GET /libs`, which the boot endpoint embeds unfiltered). */
export interface PreloadedLibDto {
id: string;
name: string;
description?: string | null;
type: string;
itemCount?: number;
/** Per-kind item counts the client-side equivalent of the server's
* `libHasKind` filter for `listLibs(kind)`. */
kindCounts?: Record<string, number>;
sync?: { namespace: string; bytes: number | null } | null;
}
export function syncedScopeLibsSource(
remote: LibsSource,
opts: {
@ -341,6 +355,17 @@ export function syncedScopeLibsSource(
user?: string;
project?: string;
log?: (msg: string) => void;
/**
* The boot payload's lib listing + batch-resolved stacks (load-path-rework
* 0001 §6): `listLibs` answers locally (kind-filtered the same way the
* server would) and the stack prefetch is already satisfied a boot with
* this present makes ZERO lib listing/resolve requests. Absent the
* individual endpoints, exactly as before.
*/
preloaded?: {
libs: PreloadedLibDto[];
stacks: Record<string, SyncStackDescriptor | null>;
};
fetchImpl?: typeof fetch;
storeFactory?: (namespace: string) => LayerStore;
channelFactory?: ChannelFactory;
@ -352,6 +377,30 @@ export function syncedScopeLibsSource(
// stale pin isn't retried one-by-one. Misses simply fall back per-lib, which
// is also what happens against a backend predating the batch route.
const batchedStacks = new Map<string, SyncStackDescriptor | null>();
if (opts.preloaded) {
for (const [id, stack] of Object.entries(opts.preloaded.stacks)) {
batchedStacks.set(id, stack);
}
}
const preloadedLibs = (kind?: string): LibInfo[] =>
(opts.preloaded?.libs ?? [])
.filter(
(l) =>
!kind ||
// Same rule the server applies: org libs and mirrors are
// kind-agnostic containers; origins/pins need >=1 item of the kind.
l.type === "org" ||
l.type === "mirror" ||
(l.kindCounts?.[kind] ?? 0) > 0,
)
.map((l) => ({
id: l.id,
name: l.name,
description: l.description ?? null,
type: l.type,
itemCount: l.itemCount,
sync: l.sync ?? null,
}));
const forLib = (libId: string): LibsSource => {
let src = perLib.get(libId);
if (!src) {
@ -408,8 +457,14 @@ export function syncedScopeLibsSource(
}
}
// Every internal listing goes through the preload too — syncState, presync
// and the realtime name-mapping otherwise each re-fetch the listing the
// boot payload already carried.
const listLibsPreferred = (kind?: string): Promise<LibInfo[]> =>
opts.preloaded ? Promise.resolve(preloadedLibs(kind)) : remote.listLibs(kind);
return {
listLibs: (kind) => remote.listLibs(kind),
listLibs: (kind) => listLibsPreferred(kind),
createLib: remote.createLib?.bind(remote),
getFpIndex: remote.getFpIndex?.bind(remote),
async syncState(kind): Promise<LibsSyncState | null> {
@ -419,7 +474,7 @@ export function syncedScopeLibsSource(
// 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 libs = await listLibsPreferred(kind);
const withNs = libs.filter(
(l): l is LibInfo & { sync: { namespace: string; bytes?: number | null } } =>
!!l.sync?.namespace,
@ -457,7 +512,7 @@ export function syncedScopeLibsSource(
saveItemBody: (libId, kind, name, body) =>
forLib(libId).saveItemBody!(libId, kind, name, body),
async presync(presyncOpts): Promise<void> {
const libs = await remote.listLibs(presyncOpts?.kind);
const libs = await listLibsPreferred(presyncOpts?.kind);
const total = libs.length;
let done = 0;
presyncOpts?.onProgress?.({ done, total, current: "libraries" });
@ -493,7 +548,7 @@ export function syncedScopeLibsSource(
// has usually made the same call already). Names that don't resolve —
// project-local table rows, stale nicknames — are simply not ours.
const wanted = new Set(libNames);
const libs = (await remote.listLibs()).filter((l) => wanted.has(l.name));
const libs = (await listLibsPreferred()).filter((l) => wanted.has(l.name));
opts.log?.(
`[synced] realtime upgrade for ${libs.length}/${libNames.length} referenced lib(s)`,
);

View file

@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { encodeBundle, type SyncManifest } from "@pcbjam/shared";
import { encodeBundle, manifestDigest, type SyncManifest } from "@pcbjam/shared";
import { memStore } from "@pcbjam/sync-client";
import {
stageViaProjectSync,
@ -25,16 +25,27 @@ function fakeSyncServer(bodies: Record<string, string>) {
frames.push([path, b]);
}
let bundleFetches = 0;
let manifestFetches = 0;
const fetchImpl = (async (input: unknown) => {
const url = String(input);
if (url.endsWith("/sync/manifest")) return Response.json(manifest);
if (url.endsWith("/sync/manifest")) {
manifestFetches += 1;
return Response.json(manifest);
}
if (url.endsWith("/sync/bundle")) {
bundleFetches += 1;
return new Response(encodeBundle(manifest, frames) as BodyInit);
}
return new Response(null, { status: 404 });
}) as typeof fetch;
return { fetchImpl, counters: { get bundleFetches() { return bundleFetches; } } };
return {
fetchImpl,
manifest,
counters: {
get bundleFetches() { return bundleFetches; },
get manifestFetches() { return manifestFetches; },
},
};
}
function syncConfig(fetchImpl: typeof fetch): ProjectSyncConfig {
@ -118,6 +129,34 @@ describe("stageViaProjectSync", () => {
expect(rest).toEqual(files);
});
it("a boot digest makes the WARM restage zero-request (load-path 0001 §6)", async () => {
const server = fakeSyncServer({ "a.txt": "A", "b.txt": "BB" });
const files: ToolFile[] = [
{ path: "a.txt", revision: 1 },
{ path: "b.txt", revision: 1 },
];
const config = syncConfig(server.fetchImpl); // shared stores across runs
const cold: string[] = [];
await stageViaProjectSync(
{ slug: "p", files, projectSync: config, log: () => {} },
(p) => cold.push(p),
);
expect(cold.sort()).toEqual(["a.txt", "b.txt"]);
expect(server.counters.bundleFetches).toBe(1);
// Warm, with the boot payload's fresh digest: the stored snapshot is
// affirmed without even the manifest GET.
const digest = await manifestDigest(server.manifest);
const warm: string[] = [];
await stageViaProjectSync(
{ slug: "p", files, projectSync: { ...config, digest }, log: () => {} },
(p) => warm.push(p),
);
expect(warm.sort()).toEqual(["a.txt", "b.txt"]);
expect(server.counters.bundleFetches).toBe(1);
expect(server.counters.manifestFetches).toBe(0);
});
it("no sync config means the whole set is per-file (demo/local sources)", async () => {
const files: ToolFile[] = [{ path: "a.txt", revision: 1 }];
const rest = await stageViaProjectSync(