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
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
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