diff --git a/web/pcbjam-shared b/web/pcbjam-shared index f1240ea..cb402cf 160000 --- a/web/pcbjam-shared +++ b/web/pcbjam-shared @@ -1 +1 @@ -Subproject commit f1240eaa02154311b9b6ca9d9cc30e054894d387 +Subproject commit cb402cfac1a88cb6507c0b8fdfd4a5906ad9978c diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 45d0bde..64bf70d 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -59,6 +59,19 @@ importers: specifier: ^3.0.0 version: 3.2.6(@types/node@22.19.19)(jiti@1.21.7)(tsx@4.22.4) + pcbjam-shared/sync-client: + dependencies: + '@pcbjam/shared': + specifier: workspace:* + version: link:.. + devDependencies: + typescript: + specifier: ^5.7.3 + version: 5.9.3 + vitest: + specifier: ^3.0.0 + version: 3.2.6(@types/node@22.19.19)(jiti@1.21.7)(tsx@4.22.4) + standalone: dependencies: '@hocuspocus/provider': @@ -67,6 +80,9 @@ importers: '@pcbjam/shared': specifier: workspace:* version: link:../pcbjam-shared + '@pcbjam/sync-client': + specifier: workspace:* + version: link:../pcbjam-shared/sync-client '@radix-ui/react-dialog': specifier: ^1.1.4 version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) diff --git a/web/pnpm-workspace.yaml b/web/pnpm-workspace.yaml index ec771fe..c34635d 100644 --- a/web/pnpm-workspace.yaml +++ b/web/pnpm-workspace.yaml @@ -2,3 +2,5 @@ packages: - "standalone" - "backend" - "pcbjam-shared" + # Generic R2⇄IndexedDB sync client (MIT), nested in the shared repo; movable. + - "pcbjam-shared/sync-client" diff --git a/web/standalone/package.json b/web/standalone/package.json index a8ce6a7..c6ee8b2 100644 --- a/web/standalone/package.json +++ b/web/standalone/package.json @@ -15,6 +15,7 @@ "dependencies": { "@hocuspocus/provider": "^4.1.1", "@pcbjam/shared": "workspace:*", + "@pcbjam/sync-client": "workspace:*", "@radix-ui/react-dialog": "^1.1.4", "@radix-ui/react-label": "^2.1.1", "@radix-ui/react-slot": "^1.1.1", diff --git a/web/standalone/src/lib/config.ts b/web/standalone/src/lib/config.ts index 70a8b32..f56e9cd 100644 --- a/web/standalone/src/lib/config.ts +++ b/web/standalone/src/lib/config.ts @@ -10,12 +10,14 @@ export const WASM_ASSET_BASE_URL = import type { ProviderConfig, ProviderKind } from "@/wasm/collab"; import { remoteLibsSource } from "@/wasm/libs/remote-source"; +import { scopedLibsSource } from "@/wasm/libs/scoped-source"; import type { LibsSource } from "@/wasm/libs/source"; import { withSpikeWritableFpLib, withSpikeWritableLib, } from "@/wasm/libs/spike-writable"; import { staticLibsSource } from "@/wasm/libs/static-source"; +import { syncedLibsSource } from "@/wasm/libs/synced-source"; /** * Which Yjs collab provider this deployment uses (one active per env), and its @@ -103,3 +105,27 @@ export function libsSourceConfig(projectId?: string): LibsSource | null { return base; } + +/** + * The libs source for a single backend library opened scoped to itself + * (`/l//`). With `VITE_LIBS_SOURCE=synced` this is the r2-idb-sync + * bridge (`syncedLibsSource`, per-lib IDB cache + realtime); otherwise it's the + * existing per-item network path wrapped in `scopedLibsSource`. Falls back to the + * network path when the lib can't be synced. + */ +export function libsSourceForLib( + libId: string, + projectId?: string, +): LibsSource | null { + const project = projectId && projectId !== "local" ? projectId : undefined; + if (import.meta.env.VITE_LIBS_SOURCE === "synced") { + return syncedLibsSource(libId, { + apiBase: API_BASE_URL, + owner: libsOwner(), + project, + log: (m) => console.log(m), + }); + } + const base = libsSourceConfig(projectId); + return base ? scopedLibsSource(base, libId) : null; +} diff --git a/web/standalone/src/pages/LibToolPage.tsx b/web/standalone/src/pages/LibToolPage.tsx index c7b00d3..27133ff 100644 --- a/web/standalone/src/pages/LibToolPage.tsx +++ b/web/standalone/src/pages/LibToolPage.tsx @@ -1,7 +1,6 @@ import { useParams } from "react-router-dom"; import { toolSchema } from "@pcbjam/shared"; -import { libsSourceConfig } from "@/lib/config"; -import { scopedLibsSource } from "@/wasm/libs/scoped-source"; +import { libsSourceForLib } from "@/lib/config"; import { WasmTool } from "@/components/WasmTool"; import { PreflightGate } from "@/preflight/PreflightGate"; @@ -27,8 +26,7 @@ export function LibToolPage() { ); } - const base = libsSourceConfig("local"); - const libsSource = base ? scopedLibsSource(base, libId) : null; + const libsSource = libsSourceForLib(libId, "local"); return ( diff --git a/web/standalone/src/wasm/libs/synced-source.ts b/web/standalone/src/wasm/libs/synced-source.ts new file mode 100644 index 0000000..3ee59f0 --- /dev/null +++ b/web/standalone/src/wasm/libs/synced-source.ts @@ -0,0 +1,97 @@ +import { OWNER_HEADER, PROJECT_HEADER } from "@pcbjam/shared"; +import { SyncStack, type LayerDescriptor } from "@pcbjam/sync-client"; +import type { LibInfo, LibItemInfo, LibsSource } from "./source"; + +/** + * A one-lib `LibsSource` backed by the r2-idb-sync bridge + * (docs/features/r2-idb-sync). On first use it resolves the lib's **layer stack** + * from the backend (`POST /api/libs/:lib/sync-stack`), opens a `SyncStack` + * (hydrating a per-lib IndexedDB cache once, then serving locally + realtime), and + * serves the editor's list/get/save from it — replacing the per-item network + * round-trips of `remoteLibsSource`. + * + * The adapter consumes an OPAQUE stack: it never knows which layer is the shared + * read-only origin and which is the writable overlay (that's the backend's call). + * Its only domain knowledge is the `"/"` path scheme. + */ +export function syncedLibsSource( + libId: string, + opts: { + apiBase: string; + owner?: string; + project?: string; + log?: (msg: string) => void; + }, +): LibsSource { + const log = opts.log ?? (() => {}); + let opened: Promise<{ stack: SyncStack; info: LibInfo }> | null = null; + + async function ensure(): Promise<{ stack: SyncStack; info: LibInfo }> { + if (!opened) opened = resolveAndOpen(libId, opts, log); + return opened; + } + + const pathOf = (kind: string, name: string) => `${kind}/${name}`; + + return { + async listLibs(): Promise { + const { info } = await ensure(); + return [info]; + }, + async listItems(): Promise { + const { stack } = await ensure(); + return (await stack.list()).map((e) => splitPath(e.path)); + }, + async getItemBody(_id, kind, name): Promise { + const { stack } = await ensure(); + const bytes = await stack.read(pathOf(kind, name)); + return bytes ? new TextDecoder().decode(bytes) : null; + }, + async saveItemBody(_id, kind, name, body): Promise { + const { stack } = await ensure(); + try { + await stack.push(pathOf(kind, name), new TextEncoder().encode(body)); + return true; + } catch (e) { + log(`[synced] save failed for ${kind}/${name}: ${String(e)}`); + return false; + } + }, + }; +} + +async function resolveAndOpen( + libId: string, + opts: { apiBase: string; owner?: string; project?: string }, + log: (msg: string) => void, +): Promise<{ stack: SyncStack; info: LibInfo }> { + const headers: Record = { + ...(opts.owner ? { [OWNER_HEADER]: opts.owner } : {}), + ...(opts.project ? { [PROJECT_HEADER]: opts.project } : {}), + }; + const res = await fetch( + `${opts.apiBase}/api/libs/${encodeURIComponent(libId)}/sync-stack`, + { method: "POST", headers }, + ); + if (!res.ok) throw new Error(`sync-stack resolve failed: HTTP ${res.status}`); + const body = (await res.json()) as { + lib: { id: string; name: string }; + layers: LayerDescriptor[]; + }; + log(`[synced] resolved ${body.layers.length} layer(s) for lib ${libId}`); + + const stack = new SyncStack({ layers: body.layers }); + await stack.open(); + return { + stack, + info: { id: body.lib.id, name: body.lib.name, description: null }, + }; +} + +/** Decode a `"/"` namespace path back into editor item terms. */ +function splitPath(path: string): LibItemInfo { + const i = path.indexOf("/"); + return i < 0 + ? { kind: path, name: "" } + : { kind: path.slice(0, i), name: path.slice(i + 1) }; +}