feat(standalone): env-selected Yjs provider abstraction (ysync 0004)
Generalize the collab transport into a provider registry (none |
broadcastchannel | partykit | hocuspocus), one active per env. startCollab
takes { provider, room } and awaits provider.whenSynced() before seed/adopt.
Network libs lazily imported; room id from @pcbjam/shared collabRoomId.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
cf18efcf66
commit
285efe7a06
9 changed files with 308 additions and 28 deletions
87
web/pnpm-lock.yaml
generated
87
web/pnpm-lock.yaml
generated
|
|
@ -55,6 +55,9 @@ importers:
|
||||||
|
|
||||||
standalone:
|
standalone:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@hocuspocus/provider':
|
||||||
|
specifier: ^4.1.1
|
||||||
|
version: 4.1.1(y-protocols@1.0.7(yjs@13.6.31))(yjs@13.6.31)
|
||||||
'@pcbjam/shared':
|
'@pcbjam/shared':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../pcbjam-shared
|
version: link:../pcbjam-shared
|
||||||
|
|
@ -97,6 +100,9 @@ importers:
|
||||||
tailwindcss-animate:
|
tailwindcss-animate:
|
||||||
specifier: ^1.0.7
|
specifier: ^1.0.7
|
||||||
version: 1.0.7(tailwindcss@3.4.19(tsx@4.22.4))
|
version: 1.0.7(tailwindcss@3.4.19(tsx@4.22.4))
|
||||||
|
y-partyserver:
|
||||||
|
specifier: ^2.2.0
|
||||||
|
version: 2.2.0(@cloudflare/workers-types@4.20260610.1)(partyserver@0.5.6(@cloudflare/workers-types@4.20260610.1))(yjs@13.6.31)
|
||||||
yjs:
|
yjs:
|
||||||
specifier: ^13.6.18
|
specifier: ^13.6.18
|
||||||
version: 13.6.31
|
version: 13.6.31
|
||||||
|
|
@ -218,6 +224,9 @@ packages:
|
||||||
resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==}
|
resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
|
'@cloudflare/workers-types@4.20260610.1':
|
||||||
|
resolution: {integrity: sha512-Mk/f3lUygeIHzQ4HnJjU/JvGg/kllgp9gISty9nylHE/2M2MFeKO+hgAKSgiPpmwUbuhewdYGgqFGgT/ADK0/g==}
|
||||||
|
|
||||||
'@esbuild/aix-ppc64@0.25.12':
|
'@esbuild/aix-ppc64@0.25.12':
|
||||||
resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==}
|
resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
@ -545,6 +554,15 @@ packages:
|
||||||
'@fastify/merge-json-schemas@0.1.1':
|
'@fastify/merge-json-schemas@0.1.1':
|
||||||
resolution: {integrity: sha512-fERDVz7topgNjtXsJTTW1JKLy0rhuLRcquYqNR9rF7OcVpCa2OVW49ZPDIhaRRCaUuvVxI+N416xUoF76HNSXA==}
|
resolution: {integrity: sha512-fERDVz7topgNjtXsJTTW1JKLy0rhuLRcquYqNR9rF7OcVpCa2OVW49ZPDIhaRRCaUuvVxI+N416xUoF76HNSXA==}
|
||||||
|
|
||||||
|
'@hocuspocus/common@4.1.1':
|
||||||
|
resolution: {integrity: sha512-7jvCti57eYp6Yf7MHzPI4QI75o4zHg4G2f2HC3jbAdpTb+aKxAEPpnDxxpl2Lj4XzyDcisZfwB0Rvf7lbPXAog==}
|
||||||
|
|
||||||
|
'@hocuspocus/provider@4.1.1':
|
||||||
|
resolution: {integrity: sha512-LSbr50a1+1yoyO7tiWBkBxZZQzSRkW6xQRfEx6phNhQDzn5E4lL24mvfkUIyVFrr+TzPDGMFz5CBx6cXSaFYzw==}
|
||||||
|
peerDependencies:
|
||||||
|
y-protocols: ^1.0.6
|
||||||
|
yjs: ^13.6.8
|
||||||
|
|
||||||
'@jridgewell/gen-mapping@0.3.13':
|
'@jridgewell/gen-mapping@0.3.13':
|
||||||
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
|
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
|
||||||
|
|
||||||
|
|
@ -561,6 +579,9 @@ packages:
|
||||||
'@jridgewell/trace-mapping@0.3.31':
|
'@jridgewell/trace-mapping@0.3.31':
|
||||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||||
|
|
||||||
|
'@lifeomic/attempt@3.1.0':
|
||||||
|
resolution: {integrity: sha512-QZqem4QuAnAyzfz+Gj5/+SLxqwCAw2qmt7732ZXodr6VDWGeYLG6w1i/vYLa55JQM9wRuBKLmXmiZ2P0LtE5rw==}
|
||||||
|
|
||||||
'@nodelib/fs.scandir@2.1.5':
|
'@nodelib/fs.scandir@2.1.5':
|
||||||
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
|
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
|
|
@ -1316,6 +1337,9 @@ packages:
|
||||||
lines-and-columns@1.2.4:
|
lines-and-columns@1.2.4:
|
||||||
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
|
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
|
||||||
|
|
||||||
|
lodash.debounce@4.0.8:
|
||||||
|
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
|
||||||
|
|
||||||
loose-envify@1.4.0:
|
loose-envify@1.4.0:
|
||||||
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
|
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
@ -1350,6 +1374,11 @@ packages:
|
||||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
nanoid@5.1.11:
|
||||||
|
resolution: {integrity: sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg==}
|
||||||
|
engines: {node: ^18 || >=20}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
node-releases@2.0.46:
|
node-releases@2.0.46:
|
||||||
resolution: {integrity: sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==}
|
resolution: {integrity: sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
@ -1373,6 +1402,11 @@ packages:
|
||||||
resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==}
|
resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==}
|
||||||
engines: {node: '>=14.0.0'}
|
engines: {node: '>=14.0.0'}
|
||||||
|
|
||||||
|
partyserver@0.5.6:
|
||||||
|
resolution: {integrity: sha512-/LKCqlq9nWzNXA8UXZFO/Xz15QDCjJnGAgRQVLnXJO9bA0HKt5J8VM8wLnGc814WatzuQgeG17tqzI//y5WFGA==}
|
||||||
|
peerDependencies:
|
||||||
|
'@cloudflare/workers-types': ^4.20260424.1
|
||||||
|
|
||||||
path-parse@1.0.7:
|
path-parse@1.0.7:
|
||||||
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
|
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
|
||||||
|
|
||||||
|
|
@ -1735,6 +1769,19 @@ packages:
|
||||||
yaml:
|
yaml:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
y-partyserver@2.2.0:
|
||||||
|
resolution: {integrity: sha512-AIsTEZ3jPicBe3VHO5c/VdSGSaFr0J9cAGj6+sgRL/RnXLTNSkP3oQXyXf1y8hQNeXv/j9zYDT5NnRv2k1eMGA==}
|
||||||
|
peerDependencies:
|
||||||
|
'@cloudflare/workers-types': ^4.20260424.1
|
||||||
|
partyserver: '>=0.2.0 <1.0.0'
|
||||||
|
yjs: ^13.6.14
|
||||||
|
|
||||||
|
y-protocols@1.0.7:
|
||||||
|
resolution: {integrity: sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw==}
|
||||||
|
engines: {node: '>=16.0.0', npm: '>=8.0.0'}
|
||||||
|
peerDependencies:
|
||||||
|
yjs: ^13.0.0
|
||||||
|
|
||||||
yallist@3.1.1:
|
yallist@3.1.1:
|
||||||
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
|
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
|
||||||
|
|
||||||
|
|
@ -1861,6 +1908,8 @@ snapshots:
|
||||||
'@babel/helper-string-parser': 7.29.7
|
'@babel/helper-string-parser': 7.29.7
|
||||||
'@babel/helper-validator-identifier': 7.29.7
|
'@babel/helper-validator-identifier': 7.29.7
|
||||||
|
|
||||||
|
'@cloudflare/workers-types@4.20260610.1': {}
|
||||||
|
|
||||||
'@esbuild/aix-ppc64@0.25.12':
|
'@esbuild/aix-ppc64@0.25.12':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
|
@ -2038,6 +2087,18 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
fast-deep-equal: 3.1.3
|
fast-deep-equal: 3.1.3
|
||||||
|
|
||||||
|
'@hocuspocus/common@4.1.1':
|
||||||
|
dependencies:
|
||||||
|
lib0: 0.2.117
|
||||||
|
|
||||||
|
'@hocuspocus/provider@4.1.1(y-protocols@1.0.7(yjs@13.6.31))(yjs@13.6.31)':
|
||||||
|
dependencies:
|
||||||
|
'@hocuspocus/common': 4.1.1
|
||||||
|
'@lifeomic/attempt': 3.1.0
|
||||||
|
lib0: 0.2.117
|
||||||
|
y-protocols: 1.0.7(yjs@13.6.31)
|
||||||
|
yjs: 13.6.31
|
||||||
|
|
||||||
'@jridgewell/gen-mapping@0.3.13':
|
'@jridgewell/gen-mapping@0.3.13':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
|
|
@ -2057,6 +2118,8 @@ snapshots:
|
||||||
'@jridgewell/resolve-uri': 3.1.2
|
'@jridgewell/resolve-uri': 3.1.2
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
|
|
||||||
|
'@lifeomic/attempt@3.1.0': {}
|
||||||
|
|
||||||
'@nodelib/fs.scandir@2.1.5':
|
'@nodelib/fs.scandir@2.1.5':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@nodelib/fs.stat': 2.0.5
|
'@nodelib/fs.stat': 2.0.5
|
||||||
|
|
@ -2717,6 +2780,8 @@ snapshots:
|
||||||
|
|
||||||
lines-and-columns@1.2.4: {}
|
lines-and-columns@1.2.4: {}
|
||||||
|
|
||||||
|
lodash.debounce@4.0.8: {}
|
||||||
|
|
||||||
loose-envify@1.4.0:
|
loose-envify@1.4.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
js-tokens: 4.0.0
|
js-tokens: 4.0.0
|
||||||
|
|
@ -2750,6 +2815,8 @@ snapshots:
|
||||||
|
|
||||||
nanoid@3.3.12: {}
|
nanoid@3.3.12: {}
|
||||||
|
|
||||||
|
nanoid@5.1.11: {}
|
||||||
|
|
||||||
node-releases@2.0.46: {}
|
node-releases@2.0.46: {}
|
||||||
|
|
||||||
normalize-path@3.0.0: {}
|
normalize-path@3.0.0: {}
|
||||||
|
|
@ -2762,6 +2829,11 @@ snapshots:
|
||||||
|
|
||||||
on-exit-leak-free@2.1.2: {}
|
on-exit-leak-free@2.1.2: {}
|
||||||
|
|
||||||
|
partyserver@0.5.6(@cloudflare/workers-types@4.20260610.1):
|
||||||
|
dependencies:
|
||||||
|
'@cloudflare/workers-types': 4.20260610.1
|
||||||
|
nanoid: 5.1.11
|
||||||
|
|
||||||
path-parse@1.0.7: {}
|
path-parse@1.0.7: {}
|
||||||
|
|
||||||
picocolors@1.1.1: {}
|
picocolors@1.1.1: {}
|
||||||
|
|
@ -3111,6 +3183,21 @@ snapshots:
|
||||||
jiti: 1.21.7
|
jiti: 1.21.7
|
||||||
tsx: 4.22.4
|
tsx: 4.22.4
|
||||||
|
|
||||||
|
y-partyserver@2.2.0(@cloudflare/workers-types@4.20260610.1)(partyserver@0.5.6(@cloudflare/workers-types@4.20260610.1))(yjs@13.6.31):
|
||||||
|
dependencies:
|
||||||
|
'@cloudflare/workers-types': 4.20260610.1
|
||||||
|
lib0: 0.2.117
|
||||||
|
lodash.debounce: 4.0.8
|
||||||
|
nanoid: 5.1.11
|
||||||
|
partyserver: 0.5.6(@cloudflare/workers-types@4.20260610.1)
|
||||||
|
y-protocols: 1.0.7(yjs@13.6.31)
|
||||||
|
yjs: 13.6.31
|
||||||
|
|
||||||
|
y-protocols@1.0.7(yjs@13.6.31):
|
||||||
|
dependencies:
|
||||||
|
lib0: 0.2.117
|
||||||
|
yjs: 13.6.31
|
||||||
|
|
||||||
yallist@3.1.1: {}
|
yallist@3.1.1: {}
|
||||||
|
|
||||||
yjs@13.6.31:
|
yjs@13.6.31:
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@hocuspocus/provider": "^4.1.1",
|
||||||
"@pcbjam/shared": "workspace:*",
|
"@pcbjam/shared": "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",
|
||||||
|
|
@ -25,6 +26,7 @@
|
||||||
"react-router-dom": "^6.28.1",
|
"react-router-dom": "^6.28.1",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"y-partyserver": "^2.2.0",
|
||||||
"yjs": "^13.6.18",
|
"yjs": "^13.6.18",
|
||||||
"zod": "^3.24.1"
|
"zod": "^3.24.1"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import type { Tool } from "@pcbjam/shared";
|
import { collabRoomId, type Tool } from "@pcbjam/shared";
|
||||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||||
import { WASM_ASSET_BASE_URL } from "@/lib/config";
|
import { WASM_ASSET_BASE_URL, yjsProviderConfig } from "@/lib/config";
|
||||||
import { bootKicadTool } from "@/wasm/boot";
|
import { bootKicadTool } from "@/wasm/boot";
|
||||||
import { driveProjectIntoTool, type ToolFile } from "@/wasm/kicad-runner";
|
import { driveProjectIntoTool, type ToolFile } from "@/wasm/kicad-runner";
|
||||||
import type { CollabWindow } from "@/wasm/collab";
|
import type { CollabWindow } from "@/wasm/collab";
|
||||||
|
|
@ -22,7 +22,7 @@ async function maybeStartCollab(
|
||||||
win: ToolWindow,
|
win: ToolWindow,
|
||||||
opts: {
|
opts: {
|
||||||
tool: Tool;
|
tool: Tool;
|
||||||
slug: string;
|
projectId: string;
|
||||||
targetPath?: string;
|
targetPath?: string;
|
||||||
log: (m: string) => void;
|
log: (m: string) => void;
|
||||||
onStatus: (t: string) => void;
|
onStatus: (t: string) => void;
|
||||||
|
|
@ -58,12 +58,16 @@ async function maybeStartCollab(
|
||||||
}
|
}
|
||||||
|
|
||||||
const { startCollab } = await import("@/wasm/collab");
|
const { startCollab } = await import("@/wasm/collab");
|
||||||
const channel = `kicad-collab:${opts.slug}:${opts.targetPath ?? ""}`;
|
const provider = yjsProviderConfig();
|
||||||
clog("starting on channel", channel);
|
// One room per (project, document). Two tabs of the same build compute the
|
||||||
await startCollab(mod, win as unknown as CollabWindow, { channel });
|
// same id, so cross-tab BroadcastChannel still works; network providers use it
|
||||||
opts.log(`[collab] connected on ${channel}`);
|
// verbatim to namespace + persist (see @pcbjam/shared collabRoomId).
|
||||||
|
const room = collabRoomId(opts.projectId, opts.targetPath ?? opts.tool);
|
||||||
|
clog("starting collab", provider.kind, "room", room);
|
||||||
|
await startCollab(mod, win as unknown as CollabWindow, { provider, room });
|
||||||
|
opts.log(`[collab] ${provider.kind} connected on ${room}`);
|
||||||
opts.onStatus("Collab: connected");
|
opts.onStatus("Collab: connected");
|
||||||
clog("connected ✓ — edit in one tab, watch the other");
|
clog("connected ✓");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -76,6 +80,7 @@ async function maybeStartCollab(
|
||||||
export function WasmTool({
|
export function WasmTool({
|
||||||
tool,
|
tool,
|
||||||
slug,
|
slug,
|
||||||
|
projectId,
|
||||||
files,
|
files,
|
||||||
targetPath,
|
targetPath,
|
||||||
fetchBytes,
|
fetchBytes,
|
||||||
|
|
@ -83,6 +88,8 @@ export function WasmTool({
|
||||||
}: {
|
}: {
|
||||||
tool: Tool;
|
tool: Tool;
|
||||||
slug: string;
|
slug: string;
|
||||||
|
/** Stable project id — used to key the collab room (see @pcbjam/shared). */
|
||||||
|
projectId: string;
|
||||||
files: ToolFile[];
|
files: ToolFile[];
|
||||||
targetPath?: string;
|
targetPath?: string;
|
||||||
/** Fetch one project-relative file's bytes (contract loader or local folder). */
|
/** Fetch one project-relative file's bytes (contract loader or local folder). */
|
||||||
|
|
@ -126,7 +133,7 @@ export function WasmTool({
|
||||||
log: append,
|
log: append,
|
||||||
onStatus: setStatus,
|
onStatus: setStatus,
|
||||||
});
|
});
|
||||||
await maybeStartCollab(win, { tool, slug, targetPath, log: append, onStatus: setStatus });
|
await maybeStartCollab(win, { tool, projectId, targetPath, log: append, onStatus: setStatus });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
append(`[fatal] ${String(err)}`);
|
append(`[fatal] ${String(err)}`);
|
||||||
setStatus(`Error: ${String(err)}`);
|
setStatus(`Error: ${String(err)}`);
|
||||||
|
|
|
||||||
|
|
@ -7,3 +7,21 @@ export const API_BASE_URL =
|
||||||
// to also satisfy the worker/COEP constraints.
|
// to also satisfy the worker/COEP constraints.
|
||||||
export const WASM_ASSET_BASE_URL =
|
export const WASM_ASSET_BASE_URL =
|
||||||
import.meta.env.VITE_WASM_ASSET_BASE_URL ?? "/wasm";
|
import.meta.env.VITE_WASM_ASSET_BASE_URL ?? "/wasm";
|
||||||
|
|
||||||
|
import type { ProviderConfig, ProviderKind } from "@/wasm/collab";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which Yjs collab provider this deployment uses (one active per env), and its
|
||||||
|
* endpoint/token. Defaults to `broadcastchannel` so a vanilla checkout keeps
|
||||||
|
* the cross-tab-only behavior with no backend. Built here at the composition
|
||||||
|
* root and passed into `startCollab`, so `wasm/collab` stays env-agnostic.
|
||||||
|
*/
|
||||||
|
export function yjsProviderConfig(): ProviderConfig {
|
||||||
|
const kind = (import.meta.env.VITE_YJS_PROVIDER ?? "broadcastchannel") as ProviderKind;
|
||||||
|
const token = import.meta.env.VITE_YJS_TOKEN;
|
||||||
|
return {
|
||||||
|
kind,
|
||||||
|
endpoint: import.meta.env.VITE_YJS_ENDPOINT,
|
||||||
|
params: token ? { token } : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,7 @@ export function HomePage() {
|
||||||
<WasmTool
|
<WasmTool
|
||||||
tool={tool}
|
tool={tool}
|
||||||
slug="local"
|
slug="local"
|
||||||
|
projectId="local"
|
||||||
files={local.files}
|
files={local.files}
|
||||||
targetPath={FILELESS_TOOLS.has(tool) ? undefined : target}
|
targetPath={FILELESS_TOOLS.has(tool) ? undefined : target}
|
||||||
fetchBytes={local.fetchBytes}
|
fetchBytes={local.fetchBytes}
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ export function ToolPage() {
|
||||||
<WasmTool
|
<WasmTool
|
||||||
tool={parsedTool.data}
|
tool={parsedTool.data}
|
||||||
slug={slug}
|
slug={slug}
|
||||||
|
projectId={data.project.id}
|
||||||
files={data.files}
|
files={data.files}
|
||||||
targetPath={targetPath}
|
targetPath={targetPath}
|
||||||
fetchBytes={(relPath) => fetchFileBytes(slug, relPath)}
|
fetchBytes={(relPath) => fetchFileBytes(slug, relPath)}
|
||||||
|
|
|
||||||
6
web/standalone/src/vite-env.d.ts
vendored
6
web/standalone/src/vite-env.d.ts
vendored
|
|
@ -3,6 +3,12 @@
|
||||||
interface ImportMetaEnv {
|
interface ImportMetaEnv {
|
||||||
readonly VITE_API_BASE_URL?: string;
|
readonly VITE_API_BASE_URL?: string;
|
||||||
readonly VITE_WASM_ASSET_BASE_URL?: string;
|
readonly VITE_WASM_ASSET_BASE_URL?: string;
|
||||||
|
/** Yjs collab provider: none | broadcastchannel | partykit | hocuspocus. */
|
||||||
|
readonly VITE_YJS_PROVIDER?: string;
|
||||||
|
/** Host/URL for network collab providers (partykit, hocuspocus). */
|
||||||
|
readonly VITE_YJS_ENDPOINT?: string;
|
||||||
|
/** Optional connection token for the collab provider. */
|
||||||
|
readonly VITE_YJS_TOKEN?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,22 @@
|
||||||
import * as Y from "yjs";
|
import * as Y from "yjs";
|
||||||
import { connectBroadcastChannel, type Transport } from "./broadcast-transport";
|
|
||||||
import { clog } from "./debug";
|
import { clog } from "./debug";
|
||||||
|
import {
|
||||||
|
connectProvider,
|
||||||
|
type ProviderConfig,
|
||||||
|
type YjsProvider,
|
||||||
|
} from "./provider";
|
||||||
import { createReconciler, type Reconciler } from "./reconciler";
|
import { createReconciler, type Reconciler } from "./reconciler";
|
||||||
import type { CollabBridge } from "./types";
|
import type { CollabBridge } from "./types";
|
||||||
|
|
||||||
export type { CollabBridge, CollabDelta, CollabItem } from "./types";
|
export type { CollabBridge, CollabDelta, CollabItem } from "./types";
|
||||||
export { createReconciler } from "./reconciler";
|
export { createReconciler } from "./reconciler";
|
||||||
export { connectBroadcastChannel } from "./broadcast-transport";
|
export { connectBroadcastChannel } from "./broadcast-transport";
|
||||||
|
export {
|
||||||
|
connectProvider,
|
||||||
|
type ProviderConfig,
|
||||||
|
type ProviderKind,
|
||||||
|
type YjsProvider,
|
||||||
|
} from "./provider";
|
||||||
|
|
||||||
/** The subset of the Emscripten Module the collab bridge needs (embind functions). */
|
/** The subset of the Emscripten Module the collab bridge needs (embind functions). */
|
||||||
export interface CollabModule {
|
export interface CollabModule {
|
||||||
|
|
@ -31,51 +41,51 @@ export function moduleBridge(mod: CollabModule, win: CollabWindow): CollabBridge
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StartCollabOptions {
|
export interface StartCollabOptions {
|
||||||
/** BroadcastChannel name — tabs sharing this name share the document. */
|
/** Which Yjs provider to use + its endpoint/params (env-selected upstream). */
|
||||||
channel: string;
|
provider: ProviderConfig;
|
||||||
/**
|
/** Room id — see @pcbjam/shared `collabRoomId`. Identifies one shared doc. */
|
||||||
* How long to wait for an existing tab's state before deciding seed-vs-adopt
|
room: string;
|
||||||
* (seed-once rule). First tab: no reply, seeds from its local model. Later tab:
|
|
||||||
* receives state within this window, then adopts it. Default 300ms.
|
|
||||||
*/
|
|
||||||
settleMs?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CollabHandle {
|
export interface CollabHandle {
|
||||||
doc: Y.Doc;
|
doc: Y.Doc;
|
||||||
reconciler: Reconciler;
|
reconciler: Reconciler;
|
||||||
transport: Transport;
|
provider: YjsProvider;
|
||||||
destroy(): void;
|
destroy(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wire a running pl_editor wasm Module into a collaborative session: Module ⇄ Y.Doc ⇄
|
* Wire a running KiCad wasm Module into a collaborative session:
|
||||||
* BroadcastChannel. Returns once the initial seed/adopt has run. The editor must
|
* Module ⇄ Y.Doc ⇄ provider. Returns once the initial seed/adopt has run. The
|
||||||
* already have its document loaded (so kicadCollabSnapshot reflects it).
|
* editor must already have its document loaded (so kicadCollabSnapshot reflects
|
||||||
|
* it), so that — if this is the first/only client — `seed()` captures it.
|
||||||
|
*
|
||||||
|
* Seed-vs-adopt: after `provider.whenSynced()` the Y.Doc holds the authoritative
|
||||||
|
* state (the server's, a peer tab's, or — first ever — empty). `seed()` then
|
||||||
|
* seeds from the local model if the doc is empty, else adopts the shared doc.
|
||||||
*/
|
*/
|
||||||
export async function startCollab(
|
export async function startCollab(
|
||||||
mod: CollabModule,
|
mod: CollabModule,
|
||||||
win: CollabWindow,
|
win: CollabWindow,
|
||||||
opts: StartCollabOptions,
|
opts: StartCollabOptions,
|
||||||
): Promise<CollabHandle> {
|
): Promise<CollabHandle> {
|
||||||
clog("startCollab: channel =", opts.channel);
|
clog("startCollab:", opts.provider.kind, "room =", opts.room);
|
||||||
const doc = new Y.Doc();
|
const doc = new Y.Doc();
|
||||||
const bridge = moduleBridge(mod, win);
|
const bridge = moduleBridge(mod, win);
|
||||||
const reconciler = createReconciler(doc, bridge);
|
const reconciler = createReconciler(doc, bridge);
|
||||||
const transport = connectBroadcastChannel(doc, opts.channel);
|
const provider = await connectProvider(doc, opts.provider, { room: opts.room });
|
||||||
|
|
||||||
// Let any existing tab answer our state query before we decide to seed.
|
await provider.whenSynced();
|
||||||
await new Promise((r) => setTimeout(r, opts.settleMs ?? 300));
|
|
||||||
reconciler.seed();
|
reconciler.seed();
|
||||||
clog("startCollab: ready; doc items =", reconciler.items.size);
|
clog("startCollab: ready; doc items =", reconciler.items.size);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
doc,
|
doc,
|
||||||
reconciler,
|
reconciler,
|
||||||
transport,
|
provider,
|
||||||
destroy() {
|
destroy() {
|
||||||
reconciler.destroy();
|
reconciler.destroy();
|
||||||
transport.destroy();
|
provider.destroy();
|
||||||
doc.destroy();
|
doc.destroy();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
148
web/standalone/src/wasm/collab/provider.ts
Normal file
148
web/standalone/src/wasm/collab/provider.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
import type * as Y from "yjs";
|
||||||
|
import { connectBroadcastChannel } from "./broadcast-transport";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Y.Doc transport, normalized across backends. The reconciler/seed logic only
|
||||||
|
* needs to know when the initial state has arrived (`whenSynced`) and how to
|
||||||
|
* tear down (`destroy`); everything else is provider-specific.
|
||||||
|
*
|
||||||
|
* Providers are network libraries (MIT/ISC) linked by the GPL editor — fine, and
|
||||||
|
* the closed PartyKit server is reached only over WSS (no linking).
|
||||||
|
*/
|
||||||
|
export interface YjsProvider {
|
||||||
|
/**
|
||||||
|
* Resolves once the doc holds the authoritative initial state: for network
|
||||||
|
* providers when the server has synced; for BroadcastChannel after a short
|
||||||
|
* settle window (a peer tab may have answered). The seed-vs-adopt decision
|
||||||
|
* runs after this.
|
||||||
|
*/
|
||||||
|
whenSynced(): Promise<void>;
|
||||||
|
destroy(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProviderKind =
|
||||||
|
| "none"
|
||||||
|
| "broadcastchannel"
|
||||||
|
| "partykit"
|
||||||
|
| "hocuspocus";
|
||||||
|
|
||||||
|
export interface ProviderConfig {
|
||||||
|
kind: ProviderKind;
|
||||||
|
/** Host/URL for network providers (partykit, hocuspocus). */
|
||||||
|
endpoint?: string;
|
||||||
|
/** Connection params (e.g. `{ token }`); see @pcbjam/shared `collabConnectParams`. */
|
||||||
|
params?: { token?: string };
|
||||||
|
/** BroadcastChannel only: how long to wait for a peer tab before seeding. */
|
||||||
|
settleMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The closed sync server (apps/sync) binds the room DO as `BoardRoom`;
|
||||||
|
// partyserver routes it under the kebab-cased party name.
|
||||||
|
const PARTYKIT_PARTY = "board-room";
|
||||||
|
|
||||||
|
function requireEndpoint(config: ProviderConfig): string {
|
||||||
|
if (!config.endpoint) {
|
||||||
|
throw new Error(
|
||||||
|
`collab provider "${config.kind}" requires an endpoint (VITE_YJS_ENDPOINT)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return config.endpoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
function noopProvider(): YjsProvider {
|
||||||
|
return { whenSynced: () => Promise.resolve(), destroy: () => {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
function broadcastChannelProvider(
|
||||||
|
doc: Y.Doc,
|
||||||
|
room: string,
|
||||||
|
settleMs: number,
|
||||||
|
): YjsProvider {
|
||||||
|
const transport = connectBroadcastChannel(doc, room);
|
||||||
|
return {
|
||||||
|
// No server to sync with — keep the original seed-once heuristic: give a
|
||||||
|
// peer tab a moment to answer the state query before deciding seed/adopt.
|
||||||
|
whenSynced: () => new Promise((resolve) => setTimeout(resolve, settleMs)),
|
||||||
|
destroy: () => transport.destroy(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function partyKitProvider(
|
||||||
|
doc: Y.Doc,
|
||||||
|
endpoint: string,
|
||||||
|
room: string,
|
||||||
|
params: ProviderConfig["params"],
|
||||||
|
): Promise<YjsProvider> {
|
||||||
|
const { default: YProvider } = await import("y-partyserver/provider");
|
||||||
|
const provider = new YProvider(endpoint, room, doc, {
|
||||||
|
party: PARTYKIT_PARTY,
|
||||||
|
connect: true,
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
whenSynced: () =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
if (provider.synced) return resolve();
|
||||||
|
const onSync = (state: boolean) => {
|
||||||
|
if (state) {
|
||||||
|
provider.off("sync", onSync);
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
provider.on("sync", onSync);
|
||||||
|
}),
|
||||||
|
destroy: () => provider.destroy(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hocuspocusProvider(
|
||||||
|
doc: Y.Doc,
|
||||||
|
endpoint: string,
|
||||||
|
room: string,
|
||||||
|
params: ProviderConfig["params"],
|
||||||
|
): Promise<YjsProvider> {
|
||||||
|
const { HocuspocusProvider } = await import("@hocuspocus/provider");
|
||||||
|
const provider = new HocuspocusProvider({
|
||||||
|
url: endpoint,
|
||||||
|
name: room,
|
||||||
|
document: doc,
|
||||||
|
...(params?.token ? { token: params.token } : {}),
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
whenSynced: () =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
if (provider.synced) return resolve();
|
||||||
|
const onSync = () => {
|
||||||
|
provider.off("synced", onSync);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
provider.on("synced", onSync);
|
||||||
|
}),
|
||||||
|
destroy: () => provider.destroy(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connect a Y.Doc to the env-selected provider. Network libraries are imported
|
||||||
|
* lazily so a deployment only ships the one it uses.
|
||||||
|
*/
|
||||||
|
export async function connectProvider(
|
||||||
|
doc: Y.Doc,
|
||||||
|
config: ProviderConfig,
|
||||||
|
opts: { room: string },
|
||||||
|
): Promise<YjsProvider> {
|
||||||
|
switch (config.kind) {
|
||||||
|
case "broadcastchannel":
|
||||||
|
return broadcastChannelProvider(doc, opts.room, config.settleMs ?? 300);
|
||||||
|
case "partykit":
|
||||||
|
return partyKitProvider(doc, requireEndpoint(config), opts.room, config.params);
|
||||||
|
case "hocuspocus":
|
||||||
|
return hocuspocusProvider(doc, requireEndpoint(config), opts.room, config.params);
|
||||||
|
case "none":
|
||||||
|
return noopProvider();
|
||||||
|
default: {
|
||||||
|
const _exhaustive: never = config.kind;
|
||||||
|
return _exhaustive ?? noopProvider();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue