feat(libs): r2-idb-sync bridge — sync-wire protocol, FE/BE packages, apps/server resolve+origin serving, GPL adapter

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergő Törcsvári 2026-06-17 09:40:57 +02:00
commit dca8e41390
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
7 changed files with 145 additions and 5 deletions

@ -1 +1 @@
Subproject commit f1240eaa02154311b9b6ca9d9cc30e054894d387
Subproject commit cb402cfac1a88cb6507c0b8fdfd4a5906ad9978c

16
web/pnpm-lock.yaml generated
View file

@ -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)

View file

@ -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"

View file

@ -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",

View file

@ -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/<libId>/<tool>`). 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;
}

View file

@ -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 (
<PreflightGate>

View file

@ -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 `"<kind>/<name>"` 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<LibInfo[]> {
const { info } = await ensure();
return [info];
},
async listItems(): Promise<LibItemInfo[]> {
const { stack } = await ensure();
return (await stack.list()).map((e) => splitPath(e.path));
},
async getItemBody(_id, kind, name): Promise<string | null> {
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<boolean> {
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<string, string> = {
...(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 `"<kind>/<name>"` 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) };
}