@pyric/cli 0.1.0-alpha.11 → 0.1.0-alpha.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/conformance/.generated/can-i-use-browser.d.ts.map +1 -1
- package/dist/conformance/.generated/can-i-use-browser.js +1 -1
- package/dist/conformance/.generated/can-i-use-browser.js.map +1 -1
- package/dist/conformance/.generated/can-i-use.d.ts.map +1 -1
- package/dist/conformance/.generated/can-i-use.js +2 -2
- package/dist/conformance/.generated/can-i-use.js.map +1 -1
- package/dist/serve/docs-ui/docs/_rhythm/index.html +1 -1
- package/dist/serve/docs-ui/docs/agent/set-up-your-agent/index.html +1 -1
- package/dist/serve/docs-ui/docs/agent/watch-and-review/index.html +1 -1
- package/dist/serve/docs-ui/docs/agent/work-with-an-agent/index.html +1 -1
- package/dist/serve/docs-ui/docs/ai-compat/index.html +1 -1
- package/dist/serve/docs-ui/docs/api-reference/index.html +2 -2
- package/dist/serve/docs-ui/docs/api-reference.md +1 -1
- package/dist/serve/docs-ui/docs/app-compat/index.html +1 -1
- package/dist/serve/docs-ui/docs/auth-compat/index.html +1 -1
- package/dist/serve/docs-ui/docs/build/ai-logic/index.html +1 -1
- package/dist/serve/docs-ui/docs/build/authentication/index.html +1 -1
- package/dist/serve/docs-ui/docs/build/cloud-firestore/index.html +1 -1
- package/dist/serve/docs-ui/docs/build/cloud-messaging/index.html +1 -1
- package/dist/serve/docs-ui/docs/build/cloud-storage/index.html +1 -1
- package/dist/serve/docs-ui/docs/build/realtime-database/index.html +1 -1
- package/dist/serve/docs-ui/docs/conformance-scores/index.html +1 -1
- package/dist/serve/docs-ui/docs/create-pyric-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/database-compat/index.html +1 -1
- package/dist/serve/docs-ui/docs/firestore-compat/index.html +1 -1
- package/dist/serve/docs-ui/docs/functions-rtdb-compat/index.html +1 -1
- package/dist/serve/docs-ui/docs/get-started/how-the-swap-works/index.html +1 -1
- package/dist/serve/docs-ui/docs/get-started/start-building/index.html +1 -1
- package/dist/serve/docs-ui/docs/index.json +200 -25
- package/dist/serve/docs-ui/docs/messaging-compat/index.html +1 -1
- package/dist/serve/docs-ui/docs/observe/see-whats-happening/index.html +1 -1
- package/dist/serve/docs-ui/docs/observe/shape-your-data/index.html +1 -1
- package/dist/serve/docs-ui/docs/overview/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-admin-app-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-admin-auth-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-admin-database-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-admin-firestore-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-admin-messaging-reference-api/index.html +916 -0
- package/dist/serve/docs-ui/docs/pyric-admin-messaging-reference-api.md +706 -0
- package/dist/serve/docs-ui/docs/pyric-admin-storage-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-ai-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-ai-scripting-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-app-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-auth-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-cli-assurance-browser-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-cli-assurance-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-cli-bridge-client-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-cli-bridge-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-cli-conformance-browser-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-cli-conformance-docs-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-cli-conformance-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-cli-credentials-node-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-cli-discover-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-cli-register-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-cli-remote-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-cli-serve-worker-reference-api/index.html +131 -66
- package/dist/serve/docs-ui/docs/pyric-cli-serve-worker-reference-api.md +130 -65
- package/dist/serve/docs-ui/docs/pyric-cli-verify-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-cli-vite-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-database-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-firestore-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-messaging-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-messaging-sw-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-rules-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-sandbox-database-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-sandbox-firestore-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-sandbox-reference-api/index.html +37 -14
- package/dist/serve/docs-ui/docs/pyric-sandbox-reference-api.md +28 -8
- package/dist/serve/docs-ui/docs/pyric-storage-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-ui-agents-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-ui-auth-hooks-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-ui-auth-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-ui-events-hooks-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-ui-events-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-ui-firestore-hooks-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-ui-firestore-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-ui-primitives-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-ui-rtdb-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-ui-rules-hooks-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-ui-rules-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-ui-storage-hooks-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-ui-storage-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-ui-traffic-hooks-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-ui-traffic-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/rules-compat/index.html +1 -1
- package/dist/serve/docs-ui/docs/secure/audit-your-rules/index.html +1 -1
- package/dist/serve/docs-ui/docs/secure/firestore-rules-limits/index.html +1 -1
- package/dist/serve/docs-ui/docs/secure/read-a-denial/index.html +1 -1
- package/dist/serve/docs-ui/docs/secure/rtdb-rules-in-typescript/index.html +1 -1
- package/dist/serve/docs-ui/docs/secure/rules-standard-library/index.html +1 -1
- package/dist/serve/docs-ui/docs/secure/secure-it-with-rules/index.html +1 -1
- package/dist/serve/docs-ui/docs/secure/simulate-and-lint/index.html +1 -1
- package/dist/serve/docs-ui/docs/secure/write-a-rules-test-suite/index.html +1 -1
- package/dist/serve/docs-ui/docs/ship/ship-to-production/index.html +1 -1
- package/dist/serve/docs-ui/docs/ship/test-in-node/index.html +1 -1
- package/dist/serve/docs-ui/docs/storage-compat/index.html +1 -1
- package/dist/serve/docs-ui/docs/trust/how-we-know-it-matches-firebase/index.html +1 -1
- package/dist/serve/docs-ui/docs/trust/versioning-and-compatibility/index.html +1 -1
- package/dist/serve/docs-ui/llms.txt +1 -1
- package/dist/serve/worker/host/core.js +1 -1
- package/dist/serve/worker/host/core.js.map +1 -1
- package/dist/serve/worker/host/firestore-writes.d.ts.map +1 -1
- package/dist/serve/worker/host/firestore-writes.js +3 -2
- package/dist/serve/worker/host/firestore-writes.js.map +1 -1
- package/dist/serve/worker/index.d.ts +2 -1
- package/dist/serve/worker/index.d.ts.map +1 -1
- package/dist/serve/worker/index.js +2 -1
- package/dist/serve/worker/index.js.map +1 -1
- package/dist/serve/worker/protocol.js +2 -2
- package/dist/serve/worker/protocol.js.map +1 -1
- package/package.json +5 -4
- package/src/assurance/.generated/conformance-verdicts.ts +1090 -0
- package/src/assurance/attachment.ts +211 -0
- package/src/assurance/browser.ts +80 -0
- package/src/assurance/campaign.ts +496 -0
- package/src/assurance/capabilities.ts +446 -0
- package/src/assurance/cases.ts +43 -0
- package/src/assurance/index.ts +75 -0
- package/src/assurance/runner.ts +885 -0
- package/src/assurance/tool-names.ts +15 -0
- package/src/assurance/tools.ts +830 -0
- package/src/assurance/types.ts +352 -0
- package/src/assurance/validation.ts +434 -0
- package/src/bridge/client/bridge.ts +550 -0
- package/src/bridge/client/dispatch.ts +137 -0
- package/src/bridge/client.ts +45 -0
- package/src/bridge/protocol.ts +351 -0
- package/src/bridge/server/audit.ts +53 -0
- package/src/bridge/server/bridge.ts +597 -0
- package/src/bridge/server/headless.ts +160 -0
- package/src/bridge/server/json-schema-to-zod.ts +108 -0
- package/src/bridge/server/local-bridge.ts +66 -0
- package/src/bridge/server/logger.ts +50 -0
- package/src/bridge/server/mcp-contract.ts +93 -0
- package/src/bridge/server/mcp.ts +129 -0
- package/src/bridge/server/peer.ts +232 -0
- package/src/bridge/server/standalone.ts +347 -0
- package/src/bridge/server/tool-metadata.ts +99 -0
- package/src/bridge/server.ts +28 -0
- package/src/cli/can-i-use.ts +50 -0
- package/src/cli/cli.test.ts +493 -0
- package/src/cli/database-rules.ts +329 -0
- package/src/cli/dev-runner.ts +282 -0
- package/src/cli/firebase-json.ts +100 -0
- package/src/cli/firestore-indexes.ts +70 -0
- package/src/cli/index.ts +425 -0
- package/src/cli/init.ts +260 -0
- package/src/cli/mcp-proxy.ts +196 -0
- package/src/cli/parse-args.ts +80 -0
- package/src/cli/rules.ts +259 -0
- package/src/cli/scope.ts +67 -0
- package/src/cli/serve.ts +996 -0
- package/src/cli/service-commands.ts +73 -0
- package/src/cli/snapshot.ts +164 -0
- package/src/cli/storage-rules.ts +172 -0
- package/src/cli/verify.ts +416 -0
- package/src/conformance/.generated/can-i-use-browser.ts +113 -0
- package/src/conformance/.generated/can-i-use.ts +117 -0
- package/src/conformance/.generated/conformance-docs.ts +14 -0
- package/src/conformance/browser.ts +24 -0
- package/src/conformance/can-i-use-tool.ts +51 -0
- package/src/conformance/can-i-use.ts +18 -0
- package/src/conformance/docs.ts +8 -0
- package/src/conformance/index.ts +20 -0
- package/src/conformance/tools.ts +16 -0
- package/src/credentials/core/memoize-ttl.ts +142 -0
- package/src/credentials/core/types.ts +10 -0
- package/src/credentials/node/from-adc.ts +96 -0
- package/src/credentials/node/from-service-account.ts +133 -0
- package/src/credentials/node/index.ts +6 -0
- package/src/discover/concurrency.ts +127 -0
- package/src/discover/crawler-adapter.ts +142 -0
- package/src/discover/crawler.ts +1127 -0
- package/src/discover/credential-free.ts +27 -0
- package/src/discover/findCollectionGroup.ts +131 -0
- package/src/discover/firestore-source.ts +59 -0
- package/src/discover/index.ts +5 -0
- package/src/discover/merge.ts +523 -0
- package/src/discover/session.ts +402 -0
- package/src/discover/tools.ts +201 -0
- package/src/discover/types.ts +187 -0
- package/src/discover/wire.ts +324 -0
- package/src/functions-rtdb/child.ts +382 -0
- package/src/functions-rtdb/delivery.ts +7 -0
- package/src/functions-rtdb/discovery.ts +136 -0
- package/src/functions-rtdb/event.ts +64 -0
- package/src/functions-rtdb/execution.ts +111 -0
- package/src/functions-rtdb/in-memory-delivery.ts +40 -0
- package/src/functions-rtdb/project.ts +74 -0
- package/src/functions-rtdb/projection.ts +104 -0
- package/src/functions-rtdb/reference-pattern.ts +20 -0
- package/src/functions-rtdb/remote-delivery.ts +23 -0
- package/src/pkg-version.ts +49 -0
- package/src/register/esm-exports.ts +63 -0
- package/src/register/hooks.ts +30 -0
- package/src/register/index.ts +150 -0
- package/src/register/mapping.ts +33 -0
- package/src/remote/index.ts +1050 -0
- package/src/rtdb/crawl-snapshot.ts +118 -0
- package/src/rtdb/inspection.ts +137 -0
- package/src/rtdb/load-rules-document.ts +56 -0
- package/src/rtdb/rules-generation-tool.ts +38 -0
- package/src/rtdb/rules-json.ts +29 -0
- package/src/serve/activity-guard.ts +29 -0
- package/src/serve/activity-route.ts +164 -0
- package/src/serve/activity-warning.ts +26 -0
- package/src/serve/bridge-mount.ts +200 -0
- package/src/serve/bundler.ts +493 -0
- package/src/serve/capture-store.ts +65 -0
- package/src/serve/discovery.ts +199 -0
- package/src/serve/entries/ai.ts +292 -0
- package/src/serve/entries/app-backend.ts +4 -0
- package/src/serve/entries/app-client.ts +26 -0
- package/src/serve/entries/app-session-store.ts +47 -0
- package/src/serve/entries/app.ts +11 -0
- package/src/serve/entries/auth-helper-core.ts +181 -0
- package/src/serve/entries/auth-helper-dom.ts +134 -0
- package/src/serve/entries/auth-helper-runtime.ts +20 -0
- package/src/serve/entries/auth.ts +255 -0
- package/src/serve/entries/bridge-url.ts +31 -0
- package/src/serve/entries/database.ts +129 -0
- package/src/serve/entries/firestore.ts +232 -0
- package/src/serve/entries/init.ts +46 -0
- package/src/serve/entries/keepalive.ts +46 -0
- package/src/serve/entries/messaging-sw.ts +75 -0
- package/src/serve/entries/messaging.ts +96 -0
- package/src/serve/entries/runtime.ts +540 -0
- package/src/serve/entries/session-store.ts +97 -0
- package/src/serve/entries/storage.ts +92 -0
- package/src/serve/entries/tab-sync-wiring.ts +274 -0
- package/src/serve/entries/worker-runtime.ts +75 -0
- package/src/serve/init-payload.ts +42 -0
- package/src/serve/namespace.ts +675 -0
- package/src/serve/open-browser.ts +68 -0
- package/src/serve/rules.ts +272 -0
- package/src/serve/sandbox-marker.ts +31 -0
- package/src/serve/server.ts +428 -0
- package/src/serve/standalone-assets.ts +180 -0
- package/src/serve/state-store.ts +166 -0
- package/src/serve/studio/disk-project-store.ts +185 -0
- package/src/serve/studio/disk-workspace.ts +162 -0
- package/src/serve/studio/index.ts +20 -0
- package/src/serve/studio/routes.ts +237 -0
- package/src/serve/studio/store-types.ts +48 -0
- package/src/serve/studio/studio-storage.test.ts +289 -0
- package/src/serve/vite-plugin.ts +1102 -0
- package/src/serve/worker/activity-bootstrap.ts +24 -0
- package/src/serve/worker/client/admin-firestore.ts +38 -0
- package/src/serve/worker/client/ai.ts +124 -0
- package/src/serve/worker/client/auth.ts +489 -0
- package/src/serve/worker/client/connection.ts +202 -0
- package/src/serve/worker/client/core.ts +350 -0
- package/src/serve/worker/client/disconnect.ts +45 -0
- package/src/serve/worker/client/firestore-reads.ts +181 -0
- package/src/serve/worker/client/firestore-refs.ts +226 -0
- package/src/serve/worker/client/firestore-writes.ts +232 -0
- package/src/serve/worker/client/handles.ts +91 -0
- package/src/serve/worker/client/messaging.ts +119 -0
- package/src/serve/worker/client/presence.ts +174 -0
- package/src/serve/worker/client/rtdb.ts +311 -0
- package/src/serve/worker/client/rules.ts +63 -0
- package/src/serve/worker/client/service-worker-connection.ts +59 -0
- package/src/serve/worker/client/snapshots.ts +86 -0
- package/src/serve/worker/client/storage.ts +195 -0
- package/src/serve/worker/client/studio.ts +88 -0
- package/src/serve/worker/client.ts +57 -0
- package/src/serve/worker/durable-persistence.ts +137 -0
- package/src/serve/worker/entry.ts +179 -0
- package/src/serve/worker/host/admin-firestore.ts +88 -0
- package/src/serve/worker/host/connection.ts +179 -0
- package/src/serve/worker/host/core.ts +387 -0
- package/src/serve/worker/host/dispatch.ts +316 -0
- package/src/serve/worker/host/firestore-reads.ts +117 -0
- package/src/serve/worker/host/firestore-writes.ts +456 -0
- package/src/serve/worker/host/presence.ts +312 -0
- package/src/serve/worker/host/rtdb.ts +136 -0
- package/src/serve/worker/host/rules.ts +128 -0
- package/src/serve/worker/host/storage.ts +312 -0
- package/src/serve/worker/host/studio.ts +75 -0
- package/src/serve/worker/host/subscriptions.ts +238 -0
- package/src/serve/worker/host-ai.ts +165 -0
- package/src/serve/worker/host-auth.ts +469 -0
- package/src/serve/worker/host-context.ts +273 -0
- package/src/serve/worker/host-events.ts +94 -0
- package/src/serve/worker/host-messaging.ts +240 -0
- package/src/serve/worker/host.ts +47 -0
- package/src/serve/worker/index.ts +196 -0
- package/src/serve/worker/presence-timing.ts +15 -0
- package/src/serve/worker/protocol.ts +1112 -0
- package/src/serve/worker/serve-init.ts +606 -0
- package/src/serve/worker/service-worker-channel.ts +33 -0
- package/src/serve/worker/service-worker-relay.ts +86 -0
- package/src/serve/writer-lock.ts +53 -0
- package/src/verify/cases.ts +233 -0
- package/src/verify/fixture.ts +258 -0
- package/src/verify/index.ts +519 -0
- package/src/verify/tools.ts +108 -0
- package/src/version/compat-target.ts +17 -0
- package/src/vite.ts +17 -0
- package/dist/serve/docs-ui/docs/pyric-firestore-values-reference-api/index.html +0 -31
- package/dist/serve/docs-ui/docs/pyric-firestore-values-reference-api.md +0 -29
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser-side bridge client. Connects an in-page `Sandbox` to a
|
|
3
|
+
* running pyric bridge process over WebSocket. Once connected,
|
|
4
|
+
* MCP tool calls reaching the bridge are forwarded here and
|
|
5
|
+
* dispatched against the in-page `Sandbox`; results flow back
|
|
6
|
+
* over the same WS.
|
|
7
|
+
*
|
|
8
|
+
* Discovery order for the bridge URL:
|
|
9
|
+
* 1. Explicit `opts.url`.
|
|
10
|
+
* 2. `window.__PYRIC_BRIDGE_URL__` (set by the Vite plugin).
|
|
11
|
+
* 3. `ws://${location.hostname}:5174/sandbox` (sidecar default).
|
|
12
|
+
*
|
|
13
|
+
* Reconnect strategy: simple exponential backoff up to 30s. The
|
|
14
|
+
* connection is best-effort — if the bridge is down the host app
|
|
15
|
+
* keeps working; tool calls just won't reach an external agent.
|
|
16
|
+
*
|
|
17
|
+
* Standby: when the bridge closes this connection with the REPLACED code
|
|
18
|
+
* (another tab registered and won the last-connection-wins slot), the client
|
|
19
|
+
* does NOT re-hello — that would kick the winner right back out and the two
|
|
20
|
+
* tabs would fight forever. It health-polls the serve origin at a gentle
|
|
21
|
+
* jittered cadence and reconnects only once `sandboxConnected` is false
|
|
22
|
+
* (the winning tab closed or refreshed). Every other close keeps the
|
|
23
|
+
* immediate reconnect loop — that path is what makes tab-refresh takeover
|
|
24
|
+
* work.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import type { LocalSandbox } from 'pyric/sandbox';
|
|
28
|
+
import type {
|
|
29
|
+
BridgeMessage,
|
|
30
|
+
HelloFromClient,
|
|
31
|
+
ToolCallRequest,
|
|
32
|
+
ToolCallResponse,
|
|
33
|
+
WorkerOpFrame,
|
|
34
|
+
WorkerSubFrame,
|
|
35
|
+
WorkerOpPayload,
|
|
36
|
+
WorkerSubPayload,
|
|
37
|
+
} from '../protocol.js';
|
|
38
|
+
import {
|
|
39
|
+
isBridgeMessage,
|
|
40
|
+
assertJsonSafeRelayValue,
|
|
41
|
+
DEFAULT_BRIDGE_PORT,
|
|
42
|
+
DEFAULT_SANDBOX_PATH,
|
|
43
|
+
PEER_REPLACED_CLOSE_CODE,
|
|
44
|
+
WORKER_RELAY_CAPABILITY,
|
|
45
|
+
} from '../protocol.js';
|
|
46
|
+
import { dispatchSandboxTool, SANDBOX_TOOL_NAMES } from './dispatch.js';
|
|
47
|
+
|
|
48
|
+
export interface ConnectBridgeOptions {
|
|
49
|
+
/**
|
|
50
|
+
* Bridge WebSocket URL. Overrides the discovery chain.
|
|
51
|
+
* Example: `ws://localhost:5174/sandbox`.
|
|
52
|
+
*/
|
|
53
|
+
url?: string;
|
|
54
|
+
/**
|
|
55
|
+
* Stable identifier for this sandbox session — surfaces in the
|
|
56
|
+
* bridge's audit log entries. Default: a random UUID per page load.
|
|
57
|
+
*/
|
|
58
|
+
sandboxId?: string;
|
|
59
|
+
/**
|
|
60
|
+
* Custom tool dispatcher. Defaults to the built-in sandbox tool
|
|
61
|
+
* dispatcher (see `./dispatch.ts`). Hosts can replace this to
|
|
62
|
+
* extend the tool surface.
|
|
63
|
+
*/
|
|
64
|
+
dispatcher?: SandboxToolDispatcher;
|
|
65
|
+
/**
|
|
66
|
+
* Tool names to advertise to the bridge in the hello message.
|
|
67
|
+
* Defaults to `SANDBOX_TOOL_NAMES`. Override when supplying a
|
|
68
|
+
* custom dispatcher.
|
|
69
|
+
*/
|
|
70
|
+
toolNames?: string[];
|
|
71
|
+
/** Initial reconnect delay in ms (default 500). */
|
|
72
|
+
initialReconnectDelayMs?: number;
|
|
73
|
+
/** Max reconnect delay in ms (default 30_000). */
|
|
74
|
+
maxReconnectDelayMs?: number;
|
|
75
|
+
/**
|
|
76
|
+
* Standby poll interval in ms (default 2_000). When the bridge closes this
|
|
77
|
+
* connection with the REPLACED code (another tab took the peer slot), the
|
|
78
|
+
* client polls the health endpoint at this cadence — plus per-poll jitter,
|
|
79
|
+
* so two standby tabs don't stampede a freshly vacant slot — and only
|
|
80
|
+
* reconnects when `sandboxConnected` is false.
|
|
81
|
+
*/
|
|
82
|
+
standbyPollMs?: number;
|
|
83
|
+
/** Injectable fetch for the standby health poll (tests). Default: global. */
|
|
84
|
+
fetchImpl?: typeof fetch;
|
|
85
|
+
/**
|
|
86
|
+
* Disable the auto-reconnect loop (useful in tests where the test
|
|
87
|
+
* harness explicitly controls connection lifecycle).
|
|
88
|
+
*/
|
|
89
|
+
noReconnect?: boolean;
|
|
90
|
+
/** Called whenever the client transitions connection state. */
|
|
91
|
+
onStateChange?: (state: ConnectedBridgeState) => void;
|
|
92
|
+
/**
|
|
93
|
+
* Generic worker relay (remote sandbox, slice 1). When supplied, the
|
|
94
|
+
* client advertises the `worker-relay` capability in its hello and routes
|
|
95
|
+
* `worker-op` / `worker-sub` / `worker-unsub` frames through it — on the
|
|
96
|
+
* worker path this forwards them into the SharedWorker port
|
|
97
|
+
* (`relayWorkerOp` / `relayWorkerSub` in `serve/worker/client.ts`).
|
|
98
|
+
*/
|
|
99
|
+
workerRelay?: WorkerRelay;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Host-supplied handlers that forward relay frames into the SharedWorker. */
|
|
103
|
+
export interface WorkerRelay {
|
|
104
|
+
/** Dispatch one worker op; resolves with the worker's `res.value`. */
|
|
105
|
+
op(op: WorkerOpPayload): Promise<unknown>;
|
|
106
|
+
/**
|
|
107
|
+
* Register a worker subscription; `onValue` receives every snap value
|
|
108
|
+
* (including the `{ __error }` establishment-failure convention).
|
|
109
|
+
* Returns the unsubscribe function.
|
|
110
|
+
*/
|
|
111
|
+
subscribe(sub: WorkerSubPayload, onValue: (value: unknown) => void): () => void;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export type ConnectedBridgeState =
|
|
115
|
+
| { kind: 'connecting' }
|
|
116
|
+
| { kind: 'connected'; bridgeVersion: string }
|
|
117
|
+
| { kind: 'disconnected'; reason: string }
|
|
118
|
+
| { kind: 'reconnecting'; attempt: number; delayMs: number }
|
|
119
|
+
/**
|
|
120
|
+
* Another tab holds the peer slot (this connection was closed with the
|
|
121
|
+
* REPLACED code). The client health-polls until the slot is vacant, then
|
|
122
|
+
* reconnects. Distinct from `reconnecting`: the bridge is healthy and
|
|
123
|
+
* deliberately serving a different tab.
|
|
124
|
+
*/
|
|
125
|
+
| { kind: 'standby' };
|
|
126
|
+
|
|
127
|
+
export interface SandboxToolDispatcher {
|
|
128
|
+
/**
|
|
129
|
+
* Dispatch a tool call. Throws if the tool isn't recognised
|
|
130
|
+
* (the bridge advertises only what this dispatcher reports it
|
|
131
|
+
* can handle, so unknowns indicate wire-level drift).
|
|
132
|
+
*/
|
|
133
|
+
(sandbox: LocalSandbox, name: string, args: Record<string, unknown>): Promise<{
|
|
134
|
+
ok: boolean;
|
|
135
|
+
summary: string;
|
|
136
|
+
data?: unknown;
|
|
137
|
+
}>;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export interface ConnectedBridge {
|
|
141
|
+
/** Close the bridge connection. Does NOT close the underlying sandbox. */
|
|
142
|
+
disconnect(): void;
|
|
143
|
+
/** Current connection state. */
|
|
144
|
+
state(): ConnectedBridgeState;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const DEFAULT_INITIAL_DELAY = 500;
|
|
148
|
+
const DEFAULT_MAX_DELAY = 30_000;
|
|
149
|
+
const DEFAULT_STANDBY_POLL_MS = 2_000;
|
|
150
|
+
/** Per-poll jitter as a fraction of the poll interval (0..50% added), so two
|
|
151
|
+
* standby tabs don't race a freshly vacant slot in lockstep. */
|
|
152
|
+
const STANDBY_POLL_JITTER_RATIO = 0.5;
|
|
153
|
+
/** Consecutive unreachable/unparseable health polls before standby gives up
|
|
154
|
+
* and falls back to the plain reconnect loop (a restarted server has a
|
|
155
|
+
* vacant slot but may briefly serve nothing). */
|
|
156
|
+
const STANDBY_MAX_POLL_FAILURES = 3;
|
|
157
|
+
|
|
158
|
+
export function connectBridge(
|
|
159
|
+
sandbox: LocalSandbox,
|
|
160
|
+
opts: ConnectBridgeOptions = {},
|
|
161
|
+
): ConnectedBridge {
|
|
162
|
+
const url = resolveBridgeUrl(opts.url);
|
|
163
|
+
const sandboxId = opts.sandboxId ?? randomId();
|
|
164
|
+
const dispatcher = opts.dispatcher ?? dispatchSandboxTool;
|
|
165
|
+
const toolNames = opts.toolNames ?? [...SANDBOX_TOOL_NAMES];
|
|
166
|
+
const initialDelay = opts.initialReconnectDelayMs ?? DEFAULT_INITIAL_DELAY;
|
|
167
|
+
const maxDelay = opts.maxReconnectDelayMs ?? DEFAULT_MAX_DELAY;
|
|
168
|
+
const noReconnect = opts.noReconnect ?? false;
|
|
169
|
+
const onStateChange = opts.onStateChange ?? (() => {});
|
|
170
|
+
const standbyPollMs = opts.standbyPollMs ?? DEFAULT_STANDBY_POLL_MS;
|
|
171
|
+
// Wrap rather than alias: an unbound `window.fetch` reference throws
|
|
172
|
+
// "Illegal invocation" in browsers.
|
|
173
|
+
const fetchImpl = opts.fetchImpl ?? ((input: Parameters<typeof fetch>[0]) => fetch(input));
|
|
174
|
+
|
|
175
|
+
const workerRelay = opts.workerRelay ?? null;
|
|
176
|
+
|
|
177
|
+
let ws: WebSocket | null = null;
|
|
178
|
+
let closed = false;
|
|
179
|
+
let attempt = 0;
|
|
180
|
+
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
181
|
+
let standbyTimer: ReturnType<typeof setTimeout> | null = null;
|
|
182
|
+
let standbyPollFailures = 0;
|
|
183
|
+
let currentState: ConnectedBridgeState = { kind: 'connecting' };
|
|
184
|
+
/** Live relay subscriptions (bridge subId → worker unsubscribe). Torn down
|
|
185
|
+
* on every socket close: the bridge re-issues its registry to the NEXT
|
|
186
|
+
* registered peer, so keeping these would double-deliver. */
|
|
187
|
+
const relaySubs = new Map<string, () => void>();
|
|
188
|
+
|
|
189
|
+
function teardownRelaySubs() {
|
|
190
|
+
for (const unsubscribe of relaySubs.values()) {
|
|
191
|
+
try {
|
|
192
|
+
unsubscribe();
|
|
193
|
+
} catch {}
|
|
194
|
+
}
|
|
195
|
+
relaySubs.clear();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function setState(next: ConnectedBridgeState) {
|
|
199
|
+
currentState = next;
|
|
200
|
+
try {
|
|
201
|
+
onStateChange(next);
|
|
202
|
+
} catch {
|
|
203
|
+
// host callback failures must not interrupt the loop.
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function send(msg: BridgeMessage) {
|
|
208
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
209
|
+
try {
|
|
210
|
+
ws.send(JSON.stringify(msg));
|
|
211
|
+
} catch {
|
|
212
|
+
// socket likely closing; reconnect will pick up after close event
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function connect() {
|
|
218
|
+
if (closed) return;
|
|
219
|
+
setState({ kind: 'connecting' });
|
|
220
|
+
let socket: WebSocket;
|
|
221
|
+
try {
|
|
222
|
+
socket = new WebSocket(url);
|
|
223
|
+
} catch (err) {
|
|
224
|
+
scheduleReconnect(`failed to open WebSocket: ${err instanceof Error ? err.message : String(err)}`);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
ws = socket;
|
|
228
|
+
|
|
229
|
+
socket.onopen = () => {
|
|
230
|
+
attempt = 0;
|
|
231
|
+
const hello: HelloFromClient = {
|
|
232
|
+
type: 'hello',
|
|
233
|
+
protocol: 1,
|
|
234
|
+
tools: toolNames,
|
|
235
|
+
sandboxId,
|
|
236
|
+
...(workerRelay ? { capabilities: [WORKER_RELAY_CAPABILITY] } : {}),
|
|
237
|
+
};
|
|
238
|
+
send(hello);
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
socket.onmessage = (event: MessageEvent) => {
|
|
242
|
+
let parsed: unknown;
|
|
243
|
+
try {
|
|
244
|
+
parsed = JSON.parse(typeof event.data === 'string' ? event.data : '');
|
|
245
|
+
} catch {
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
if (!isBridgeMessage(parsed)) return;
|
|
249
|
+
|
|
250
|
+
if (parsed.type === 'hello-ack') {
|
|
251
|
+
setState({ kind: 'connected', bridgeVersion: parsed.bridgeVersion });
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
if (parsed.type === 'tool-call') {
|
|
255
|
+
void handleToolCall(parsed);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
if (parsed.type === 'worker-op') {
|
|
259
|
+
void handleWorkerOp(parsed);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
if (parsed.type === 'worker-sub') {
|
|
263
|
+
handleWorkerSub(parsed);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
if (parsed.type === 'worker-unsub') {
|
|
267
|
+
const unsubscribe = relaySubs.get(parsed.subId);
|
|
268
|
+
if (unsubscribe) {
|
|
269
|
+
relaySubs.delete(parsed.subId);
|
|
270
|
+
try {
|
|
271
|
+
unsubscribe();
|
|
272
|
+
} catch {}
|
|
273
|
+
}
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
if (parsed.type === 'ping') {
|
|
277
|
+
send({ type: 'pong', id: parsed.id });
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
// Other message types (tool-result, pong, hello) are peer-only; ignore.
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
socket.onclose = (event: CloseEvent) => {
|
|
284
|
+
const reason = event.reason || `closed (code ${event.code})`;
|
|
285
|
+
ws = null;
|
|
286
|
+
// Relay subs are per-connection: the bridge owns the durable registry
|
|
287
|
+
// and re-issues it after the next hello, so live worker listeners from
|
|
288
|
+
// THIS connection must go now (or the next connection double-delivers).
|
|
289
|
+
teardownRelaySubs();
|
|
290
|
+
if (event.code === PEER_REPLACED_CLOSE_CODE) {
|
|
291
|
+
// Another tab won the peer slot. Re-helloing now would kick it right
|
|
292
|
+
// back out (last-connection-wins) and the two tabs would fight over
|
|
293
|
+
// the slot forever — re-firing every relayed subscription and
|
|
294
|
+
// failing all in-flight worker ops once per cycle. Go standby:
|
|
295
|
+
// health-poll until the slot is genuinely vacant.
|
|
296
|
+
enterStandby();
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
scheduleReconnect(reason);
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
socket.onerror = () => {
|
|
303
|
+
// The browser doesn't provide error detail; rely on onclose
|
|
304
|
+
// for the reconnect trigger.
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async function handleToolCall(req: ToolCallRequest) {
|
|
309
|
+
let response: ToolCallResponse;
|
|
310
|
+
try {
|
|
311
|
+
const result = await dispatcher(sandbox, req.name, req.args ?? {});
|
|
312
|
+
response = {
|
|
313
|
+
type: 'tool-result',
|
|
314
|
+
id: req.id,
|
|
315
|
+
ok: true,
|
|
316
|
+
result,
|
|
317
|
+
};
|
|
318
|
+
} catch (err) {
|
|
319
|
+
response = {
|
|
320
|
+
type: 'tool-result',
|
|
321
|
+
id: req.id,
|
|
322
|
+
ok: false,
|
|
323
|
+
error: {
|
|
324
|
+
code: err instanceof Error ? err.name : 'Error',
|
|
325
|
+
message: err instanceof Error ? err.message : String(err),
|
|
326
|
+
},
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
send(response);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async function handleWorkerOp(req: WorkerOpFrame) {
|
|
333
|
+
if (!workerRelay) return; // capability not advertised — drop (wire drift)
|
|
334
|
+
try {
|
|
335
|
+
const value = await workerRelay.op(req.op);
|
|
336
|
+
// Anti-corruption guard: a Blob/ArrayBuffer/TypedArray result would be
|
|
337
|
+
// SILENTLY mangled by the JSON WS legs ({} / index-keyed object) — turn
|
|
338
|
+
// it into a loud error naming the base64 storage ops instead.
|
|
339
|
+
assertJsonSafeRelayValue(`op '${req.op.method}'`, value);
|
|
340
|
+
send({ type: 'worker-res', id: req.id, ok: true, value });
|
|
341
|
+
} catch (err) {
|
|
342
|
+
const denialContext = (err as { denialContext?: unknown }).denialContext;
|
|
343
|
+
send({
|
|
344
|
+
type: 'worker-res',
|
|
345
|
+
id: req.id,
|
|
346
|
+
ok: false,
|
|
347
|
+
error: {
|
|
348
|
+
code: (err as { code?: string }).code ?? 'unknown',
|
|
349
|
+
message: err instanceof Error ? err.message : String(err),
|
|
350
|
+
// Structured denial context (spike gap 6) — plain JSON, relayed
|
|
351
|
+
// verbatim so the Node side re-attaches it.
|
|
352
|
+
...(denialContext !== undefined ? { denialContext } : {}),
|
|
353
|
+
},
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function handleWorkerSub(req: WorkerSubFrame) {
|
|
359
|
+
if (!workerRelay) return; // capability not advertised — drop (wire drift)
|
|
360
|
+
if (relaySubs.has(req.subId)) return; // idempotent
|
|
361
|
+
try {
|
|
362
|
+
const unsubscribe = workerRelay.subscribe(req.sub, (value) => {
|
|
363
|
+
// Same anti-corruption guard as handleWorkerOp: a binary snap value
|
|
364
|
+
// would be silently mangled by the JSON WS legs — fail the sub with
|
|
365
|
+
// the snap-error convention (routes to onError on the Node side)
|
|
366
|
+
// instead of delivering garbage.
|
|
367
|
+
try {
|
|
368
|
+
assertJsonSafeRelayValue(`subscription '${req.subId}'`, value);
|
|
369
|
+
} catch (err) {
|
|
370
|
+
send({
|
|
371
|
+
type: 'worker-snap',
|
|
372
|
+
subId: req.subId,
|
|
373
|
+
value: {
|
|
374
|
+
__error: {
|
|
375
|
+
code: (err as { code?: string }).code ?? 'invalid-argument',
|
|
376
|
+
message: err instanceof Error ? err.message : String(err),
|
|
377
|
+
},
|
|
378
|
+
},
|
|
379
|
+
});
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
send({ type: 'worker-snap', subId: req.subId, value });
|
|
383
|
+
});
|
|
384
|
+
relaySubs.set(req.subId, unsubscribe);
|
|
385
|
+
} catch (err) {
|
|
386
|
+
// Synchronous establishment failure — relay it via the worker host's
|
|
387
|
+
// snap-error convention so the far side's subscribe can reject.
|
|
388
|
+
const denialContext = (err as { denialContext?: unknown }).denialContext;
|
|
389
|
+
send({
|
|
390
|
+
type: 'worker-snap',
|
|
391
|
+
subId: req.subId,
|
|
392
|
+
value: {
|
|
393
|
+
__error: {
|
|
394
|
+
code: (err as { code?: string }).code ?? 'unknown',
|
|
395
|
+
message: err instanceof Error ? err.message : String(err),
|
|
396
|
+
...(denialContext !== undefined ? { denialContext } : {}),
|
|
397
|
+
},
|
|
398
|
+
},
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// ── Standby: another tab holds the peer slot ─────────────────────────────
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Health endpoints to poll while in standby, derived from the WS url —
|
|
407
|
+
* same host, http(s) for ws(s). `pyric dev` mounts `/__pyric/health`
|
|
408
|
+
* (bridge-mount.ts); the standalone bridge serves `/health` — try both.
|
|
409
|
+
*/
|
|
410
|
+
function healthUrls(): string[] {
|
|
411
|
+
try {
|
|
412
|
+
const u = new URL(url);
|
|
413
|
+
u.protocol = u.protocol === 'wss:' ? 'https:' : 'http:';
|
|
414
|
+
const base = u.origin;
|
|
415
|
+
return [`${base}/__pyric/health`, `${base}/health`];
|
|
416
|
+
} catch {
|
|
417
|
+
return [];
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function enterStandby() {
|
|
422
|
+
if (closed) return;
|
|
423
|
+
if (noReconnect) {
|
|
424
|
+
setState({ kind: 'disconnected', reason: 'replaced by a newer sandbox peer' });
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
attempt = 0;
|
|
428
|
+
standbyPollFailures = 0;
|
|
429
|
+
setState({ kind: 'standby' });
|
|
430
|
+
scheduleStandbyPoll();
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function scheduleStandbyPoll() {
|
|
434
|
+
if (closed) return;
|
|
435
|
+
const delay = standbyPollMs * (1 + Math.random() * STANDBY_POLL_JITTER_RATIO);
|
|
436
|
+
standbyTimer = setTimeout(() => {
|
|
437
|
+
standbyTimer = null;
|
|
438
|
+
void pollForVacantSlot();
|
|
439
|
+
}, delay);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
async function pollForVacantSlot(): Promise<void> {
|
|
443
|
+
if (closed) return;
|
|
444
|
+
// null = health unreadable (unreachable / non-JSON / shape drift).
|
|
445
|
+
let vacant: boolean | null = null;
|
|
446
|
+
for (const target of healthUrls()) {
|
|
447
|
+
try {
|
|
448
|
+
const res = await fetchImpl(target);
|
|
449
|
+
if (!res.ok) continue;
|
|
450
|
+
const body = (await res.json()) as { sandboxConnected?: unknown };
|
|
451
|
+
if (typeof body.sandboxConnected === 'boolean') {
|
|
452
|
+
vacant = !body.sandboxConnected;
|
|
453
|
+
break;
|
|
454
|
+
}
|
|
455
|
+
} catch {
|
|
456
|
+
// try the next candidate
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
if (closed) return;
|
|
460
|
+
if (vacant === true) {
|
|
461
|
+
// The winning tab is gone (refresh / close) — claim the slot. The
|
|
462
|
+
// bridge replays its sub registry to us on hello; its re-issue dedup
|
|
463
|
+
// keeps unchanged listeners quiet.
|
|
464
|
+
connect();
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
if (vacant === false) {
|
|
468
|
+
standbyPollFailures = 0;
|
|
469
|
+
scheduleStandbyPoll();
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
// Health unreadable: a restarting server has a vacant slot but may
|
|
473
|
+
// briefly serve nothing. After a few consecutive failures fall back to
|
|
474
|
+
// the plain reconnect loop rather than idling in standby forever.
|
|
475
|
+
standbyPollFailures += 1;
|
|
476
|
+
if (standbyPollFailures >= STANDBY_MAX_POLL_FAILURES) {
|
|
477
|
+
scheduleReconnect('standby health poll unreachable');
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
scheduleStandbyPoll();
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function scheduleReconnect(reason: string) {
|
|
484
|
+
if (closed) return;
|
|
485
|
+
if (noReconnect) {
|
|
486
|
+
setState({ kind: 'disconnected', reason });
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
attempt += 1;
|
|
490
|
+
const delay = Math.min(initialDelay * 2 ** (attempt - 1), maxDelay);
|
|
491
|
+
setState({ kind: 'reconnecting', attempt, delayMs: delay });
|
|
492
|
+
reconnectTimer = setTimeout(() => {
|
|
493
|
+
reconnectTimer = null;
|
|
494
|
+
connect();
|
|
495
|
+
}, delay);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// Boot the loop.
|
|
499
|
+
connect();
|
|
500
|
+
|
|
501
|
+
return {
|
|
502
|
+
disconnect() {
|
|
503
|
+
closed = true;
|
|
504
|
+
teardownRelaySubs();
|
|
505
|
+
if (reconnectTimer) {
|
|
506
|
+
clearTimeout(reconnectTimer);
|
|
507
|
+
reconnectTimer = null;
|
|
508
|
+
}
|
|
509
|
+
if (standbyTimer) {
|
|
510
|
+
clearTimeout(standbyTimer);
|
|
511
|
+
standbyTimer = null;
|
|
512
|
+
}
|
|
513
|
+
if (ws) {
|
|
514
|
+
try {
|
|
515
|
+
ws.close();
|
|
516
|
+
} catch {
|
|
517
|
+
// ignore
|
|
518
|
+
}
|
|
519
|
+
ws = null;
|
|
520
|
+
}
|
|
521
|
+
setState({ kind: 'disconnected', reason: 'client closed' });
|
|
522
|
+
},
|
|
523
|
+
state() {
|
|
524
|
+
return currentState;
|
|
525
|
+
},
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// ── Internal helpers ─────────────────────────────────────────────────
|
|
530
|
+
|
|
531
|
+
function resolveBridgeUrl(explicit?: string): string {
|
|
532
|
+
if (explicit) return explicit;
|
|
533
|
+
if (typeof window !== 'undefined') {
|
|
534
|
+
const injected = (window as unknown as { __PYRIC_BRIDGE_URL__?: string })
|
|
535
|
+
.__PYRIC_BRIDGE_URL__;
|
|
536
|
+
if (injected) return injected;
|
|
537
|
+
const host = window.location.hostname || 'localhost';
|
|
538
|
+
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
539
|
+
return `${protocol}//${host}:${DEFAULT_BRIDGE_PORT}${DEFAULT_SANDBOX_PATH}`;
|
|
540
|
+
}
|
|
541
|
+
return `ws://localhost:${DEFAULT_BRIDGE_PORT}${DEFAULT_SANDBOX_PATH}`;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function randomId(): string {
|
|
545
|
+
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
|
546
|
+
return crypto.randomUUID();
|
|
547
|
+
}
|
|
548
|
+
// Fallback for ancient environments.
|
|
549
|
+
return `sb-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
550
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser-side tool dispatcher. Consumes the SAME tool factories the
|
|
3
|
+
* bridge advertises over MCP (`getSandboxToolMetadata`), so the set the
|
|
4
|
+
* bridge LISTS and the set the page EXECUTES are identical. There is one
|
|
5
|
+
* source of truth: the factories in `buildSandboxHandlers`.
|
|
6
|
+
*
|
|
7
|
+
* History of the trap this guards against: earlier this file delegated
|
|
8
|
+
* to ONLY `createFirestoreSimulatorTools`, while `getSandboxToolMetadata`
|
|
9
|
+
* advertised the simulator + data-plane + inspect families. The bridge
|
|
10
|
+
* therefore LISTED `firestore_create_document` / `sandbox_inspect`
|
|
11
|
+
* etc., but a `callTool` failed at dispatch with "tool 'X' is not
|
|
12
|
+
* registered with the connected sandbox peer" — succeed-at-list,
|
|
13
|
+
* fail-at-dispatch. Registering every family here (and deriving
|
|
14
|
+
* `SANDBOX_TOOL_NAMES` from the same set) closes the gap. The parity is
|
|
15
|
+
* pinned by `test/bridge/tool-parity.test.ts`.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { createFirestoreSimulatorTools } from 'pyric/rules/internal/node';
|
|
19
|
+
import {
|
|
20
|
+
createFirestoreDataTools,
|
|
21
|
+
createFirestoreInspectTools,
|
|
22
|
+
getFirestore,
|
|
23
|
+
getAdminFirestore,
|
|
24
|
+
type As,
|
|
25
|
+
} from 'pyric/firestore';
|
|
26
|
+
import { getInternalEnv } from 'pyric/sandbox/internal';
|
|
27
|
+
import type { LocalSandbox } from 'pyric/sandbox';
|
|
28
|
+
import { createRtdbInspectionTools } from '../../rtdb/inspection.js';
|
|
29
|
+
|
|
30
|
+
export interface DispatchResult {
|
|
31
|
+
ok: boolean;
|
|
32
|
+
summary: string;
|
|
33
|
+
data?: unknown;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The full handler set for a sandbox: the simulator family (bound to the
|
|
38
|
+
* internal env) plus the modular data-plane and inspect families (bound
|
|
39
|
+
* to a per-call `resolveDb`). This is the single source of truth shared
|
|
40
|
+
* by `buildSandboxDispatcher` and `SANDBOX_TOOL_NAMES`, and it MUST stay
|
|
41
|
+
* aligned with `getSandboxToolMetadata` (asserted by tool-parity.test.ts).
|
|
42
|
+
*
|
|
43
|
+
* Auth: `resolveDb('admin')` / `resolveDb(undefined)` is an admin-bypass handle
|
|
44
|
+
* (no rules); `resolveDb({ uid, claims })` is a rules-enforcing client acting as
|
|
45
|
+
* that user — `claims` ride the auth token, matching the rules
|
|
46
|
+
* `request.auth.token` shape. This is a SANDBOX dispatcher, so the admin default
|
|
47
|
+
* is intended; a dispatcher wired to a real backend must reject `'admin'`.
|
|
48
|
+
*/
|
|
49
|
+
function buildSandboxHandlers(sandbox: LocalSandbox) {
|
|
50
|
+
const env = getInternalEnv(sandbox);
|
|
51
|
+
const resolveDb = (actor?: As) =>
|
|
52
|
+
actor && actor !== 'admin'
|
|
53
|
+
? getFirestore(sandbox.withAuth({ uid: actor.uid, token: actor.claims }))
|
|
54
|
+
: getAdminFirestore(sandbox);
|
|
55
|
+
return [
|
|
56
|
+
...createFirestoreSimulatorTools({ resolveSandbox: () => env }),
|
|
57
|
+
...createFirestoreDataTools({ resolveDb }),
|
|
58
|
+
...createFirestoreInspectTools({ resolveSandbox: () => sandbox }),
|
|
59
|
+
...createRtdbInspectionTools({ resolveSandbox: () => sandbox }),
|
|
60
|
+
];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Build a dispatcher for the supplied `Sandbox`. The returned function
|
|
65
|
+
* looks up tools by name across every factory and invokes the
|
|
66
|
+
* canonical handler. Throws `UnknownToolError` on unknown names.
|
|
67
|
+
*/
|
|
68
|
+
export function buildSandboxDispatcher(
|
|
69
|
+
sandbox: LocalSandbox,
|
|
70
|
+
): (name: string, args: Record<string, unknown>) => Promise<DispatchResult> {
|
|
71
|
+
const byName = new Map(buildSandboxHandlers(sandbox).map((h) => [h.name, h]));
|
|
72
|
+
return async (name, args) => {
|
|
73
|
+
const handler = byName.get(name);
|
|
74
|
+
if (!handler) throw new UnknownToolError(name);
|
|
75
|
+
// ToolContext is supplied minimally; these handlers only read the
|
|
76
|
+
// signal field (and our factory handlers don't use it).
|
|
77
|
+
const ctx = {
|
|
78
|
+
signal: new AbortController().signal,
|
|
79
|
+
} as never;
|
|
80
|
+
const result = await handler.execute(args, ctx);
|
|
81
|
+
return {
|
|
82
|
+
ok: result.ok,
|
|
83
|
+
summary: result.summary,
|
|
84
|
+
data: result.data,
|
|
85
|
+
};
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Convenience: build a dispatcher and immediately invoke it. Same shape
|
|
91
|
+
* `connectBridge` expects as its `dispatcher` option.
|
|
92
|
+
*
|
|
93
|
+
* Dispatchers are cached per sandbox so repeated bridge requests reuse the
|
|
94
|
+
* same bound handler set.
|
|
95
|
+
*/
|
|
96
|
+
const sandboxDispatchers = new WeakMap<
|
|
97
|
+
LocalSandbox,
|
|
98
|
+
ReturnType<typeof buildSandboxDispatcher>
|
|
99
|
+
>();
|
|
100
|
+
|
|
101
|
+
export async function dispatchSandboxTool(
|
|
102
|
+
sandbox: LocalSandbox,
|
|
103
|
+
name: string,
|
|
104
|
+
args: Record<string, unknown>,
|
|
105
|
+
): Promise<DispatchResult> {
|
|
106
|
+
let dispatcher = sandboxDispatchers.get(sandbox);
|
|
107
|
+
if (!dispatcher) {
|
|
108
|
+
dispatcher = buildSandboxDispatcher(sandbox);
|
|
109
|
+
sandboxDispatchers.set(sandbox, dispatcher);
|
|
110
|
+
}
|
|
111
|
+
return dispatcher(name, args);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Tool names this dispatcher recognises, derived at load time from the
|
|
116
|
+
* SAME factories — so it equals what the page executes AND what the
|
|
117
|
+
* bridge advertises. `connectBridge` sends this as the `hello.tools`
|
|
118
|
+
* payload so the bridge advertises exactly the executable set.
|
|
119
|
+
*/
|
|
120
|
+
export const SANDBOX_TOOL_NAMES: string[] = (() => {
|
|
121
|
+
const stub = async () => {
|
|
122
|
+
throw new Error('SANDBOX_TOOL_NAMES: stub resolver should never be invoked');
|
|
123
|
+
};
|
|
124
|
+
return [
|
|
125
|
+
...createFirestoreSimulatorTools({ resolveSandbox: stub as never }),
|
|
126
|
+
...createFirestoreDataTools({ resolveDb: stub as never }),
|
|
127
|
+
...createFirestoreInspectTools({ resolveSandbox: stub as never }),
|
|
128
|
+
...createRtdbInspectionTools({ resolveSandbox: stub as never }),
|
|
129
|
+
].map((h) => h.name);
|
|
130
|
+
})();
|
|
131
|
+
|
|
132
|
+
export class UnknownToolError extends Error {
|
|
133
|
+
constructor(public readonly tool: string) {
|
|
134
|
+
super(`unknown sandbox tool: ${tool}`);
|
|
135
|
+
this.name = 'UnknownToolError';
|
|
136
|
+
}
|
|
137
|
+
}
|