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:
parent
3ad33a10ca
commit
dca8e41390
7 changed files with 145 additions and 5 deletions
|
|
@ -1 +1 @@
|
||||||
Subproject commit f1240eaa02154311b9b6ca9d9cc30e054894d387
|
Subproject commit cb402cfac1a88cb6507c0b8fdfd4a5906ad9978c
|
||||||
16
web/pnpm-lock.yaml
generated
16
web/pnpm-lock.yaml
generated
|
|
@ -59,6 +59,19 @@ importers:
|
||||||
specifier: ^3.0.0
|
specifier: ^3.0.0
|
||||||
version: 3.2.6(@types/node@22.19.19)(jiti@1.21.7)(tsx@4.22.4)
|
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:
|
standalone:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@hocuspocus/provider':
|
'@hocuspocus/provider':
|
||||||
|
|
@ -67,6 +80,9 @@ importers:
|
||||||
'@pcbjam/shared':
|
'@pcbjam/shared':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../pcbjam-shared
|
version: link:../pcbjam-shared
|
||||||
|
'@pcbjam/sync-client':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../pcbjam-shared/sync-client
|
||||||
'@radix-ui/react-dialog':
|
'@radix-ui/react-dialog':
|
||||||
specifier: ^1.1.4
|
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)
|
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)
|
||||||
|
|
|
||||||
|
|
@ -2,3 +2,5 @@ packages:
|
||||||
- "standalone"
|
- "standalone"
|
||||||
- "backend"
|
- "backend"
|
||||||
- "pcbjam-shared"
|
- "pcbjam-shared"
|
||||||
|
# Generic R2⇄IndexedDB sync client (MIT), nested in the shared repo; movable.
|
||||||
|
- "pcbjam-shared/sync-client"
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hocuspocus/provider": "^4.1.1",
|
"@hocuspocus/provider": "^4.1.1",
|
||||||
"@pcbjam/shared": "workspace:*",
|
"@pcbjam/shared": "workspace:*",
|
||||||
|
"@pcbjam/sync-client": "workspace:*",
|
||||||
"@radix-ui/react-dialog": "^1.1.4",
|
"@radix-ui/react-dialog": "^1.1.4",
|
||||||
"@radix-ui/react-label": "^2.1.1",
|
"@radix-ui/react-label": "^2.1.1",
|
||||||
"@radix-ui/react-slot": "^1.1.1",
|
"@radix-ui/react-slot": "^1.1.1",
|
||||||
|
|
|
||||||
|
|
@ -10,12 +10,14 @@ export const WASM_ASSET_BASE_URL =
|
||||||
|
|
||||||
import type { ProviderConfig, ProviderKind } from "@/wasm/collab";
|
import type { ProviderConfig, ProviderKind } from "@/wasm/collab";
|
||||||
import { remoteLibsSource } from "@/wasm/libs/remote-source";
|
import { remoteLibsSource } from "@/wasm/libs/remote-source";
|
||||||
|
import { scopedLibsSource } from "@/wasm/libs/scoped-source";
|
||||||
import type { LibsSource } from "@/wasm/libs/source";
|
import type { LibsSource } from "@/wasm/libs/source";
|
||||||
import {
|
import {
|
||||||
withSpikeWritableFpLib,
|
withSpikeWritableFpLib,
|
||||||
withSpikeWritableLib,
|
withSpikeWritableLib,
|
||||||
} from "@/wasm/libs/spike-writable";
|
} from "@/wasm/libs/spike-writable";
|
||||||
import { staticLibsSource } from "@/wasm/libs/static-source";
|
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
|
* 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;
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { toolSchema } from "@pcbjam/shared";
|
import { toolSchema } from "@pcbjam/shared";
|
||||||
import { libsSourceConfig } from "@/lib/config";
|
import { libsSourceForLib } from "@/lib/config";
|
||||||
import { scopedLibsSource } from "@/wasm/libs/scoped-source";
|
|
||||||
import { WasmTool } from "@/components/WasmTool";
|
import { WasmTool } from "@/components/WasmTool";
|
||||||
import { PreflightGate } from "@/preflight/PreflightGate";
|
import { PreflightGate } from "@/preflight/PreflightGate";
|
||||||
|
|
||||||
|
|
@ -27,8 +26,7 @@ export function LibToolPage() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const base = libsSourceConfig("local");
|
const libsSource = libsSourceForLib(libId, "local");
|
||||||
const libsSource = base ? scopedLibsSource(base, libId) : null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PreflightGate>
|
<PreflightGate>
|
||||||
|
|
|
||||||
97
web/standalone/src/wasm/libs/synced-source.ts
Normal file
97
web/standalone/src/wasm/libs/synced-source.ts
Normal 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) };
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue