@pyric/cli 0.1.0-alpha.11 → 0.1.0-alpha.13
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/cli/serve.js +4 -4
- package/dist/cli/serve.js.map +1 -1
- 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/bundler.d.ts +13 -8
- package/dist/serve/bundler.d.ts.map +1 -1
- package/dist/serve/bundler.js +31 -36
- package/dist/serve/bundler.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 +44 -22
- package/dist/serve/docs-ui/docs/build/ai-logic.md +69 -21
- 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 +2 -1
- package/dist/serve/docs-ui/docs/get-started/how-the-swap-works.md +2 -0
- package/dist/serve/docs-ui/docs/get-started/start-building/index.html +3 -2
- package/dist/serve/docs-ui/docs/get-started/start-building.md +3 -1
- package/dist/serve/docs-ui/docs/index.json +349 -29
- package/dist/serve/docs-ui/docs/messaging-compat/index.html +1 -1
- package/dist/serve/docs-ui/docs/observe/resolve-runtime-status/index.html +28 -0
- package/dist/serve/docs-ui/docs/observe/resolve-runtime-status.md +47 -0
- 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 +168 -76
- package/dist/serve/docs-ui/docs/pyric-cli-serve-worker-reference-api.md +156 -73
- 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 +11 -8
- package/dist/serve/docs-ui/docs/pyric-cli-vite-reference-api.md +17 -4
- 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/docs/tutorial/index.html +429 -0
- package/dist/serve/docs-ui/docs/tutorial.md +701 -0
- package/dist/serve/docs-ui/llms.txt +3 -1
- package/dist/serve/entries/init.d.ts.map +1 -1
- package/dist/serve/entries/init.js +3 -0
- package/dist/serve/entries/init.js.map +1 -1
- package/dist/serve/entries/runtime.d.ts.map +1 -1
- package/dist/serve/entries/runtime.js +20 -5
- package/dist/serve/entries/runtime.js.map +1 -1
- package/dist/serve/entries/worker-runtime.d.ts +2 -2
- package/dist/serve/entries/worker-runtime.d.ts.map +1 -1
- package/dist/serve/entries/worker-runtime.js +45 -10
- package/dist/serve/entries/worker-runtime.js.map +1 -1
- package/dist/serve/runtime/chip-config.d.ts +19 -0
- package/dist/serve/runtime/chip-config.d.ts.map +1 -0
- package/dist/serve/runtime/chip-config.js +19 -0
- package/dist/serve/runtime/chip-config.js.map +1 -0
- package/dist/serve/runtime/chip-install.d.ts +10 -0
- package/dist/serve/runtime/chip-install.d.ts.map +1 -0
- package/dist/serve/runtime/chip-install.js +15 -0
- package/dist/serve/runtime/chip-install.js.map +1 -0
- package/dist/serve/runtime/chip.d.ts +17 -0
- package/dist/serve/runtime/chip.d.ts.map +1 -0
- package/dist/serve/runtime/chip.js +257 -0
- package/dist/serve/runtime/chip.js.map +1 -0
- package/dist/serve/runtime/manifest.d.ts +22 -0
- package/dist/serve/runtime/manifest.d.ts.map +1 -0
- package/dist/serve/runtime/manifest.js +20 -0
- package/dist/serve/runtime/manifest.js.map +1 -0
- package/dist/serve/runtime/status.d.ts +49 -0
- package/dist/serve/runtime/status.d.ts.map +1 -0
- package/dist/serve/runtime/status.js +164 -0
- package/dist/serve/runtime/status.js.map +1 -0
- package/dist/serve/runtime/worker-connection.d.ts +4 -0
- package/dist/serve/runtime/worker-connection.d.ts.map +1 -0
- package/dist/serve/runtime/worker-connection.js +11 -0
- package/dist/serve/runtime/worker-connection.js.map +1 -0
- package/dist/serve/runtime/worker-generation.d.ts +14 -0
- package/dist/serve/runtime/worker-generation.d.ts.map +1 -0
- package/dist/serve/runtime/worker-generation.js +56 -0
- package/dist/serve/runtime/worker-generation.js.map +1 -0
- package/dist/serve/runtime/worker-replacement.d.ts +18 -0
- package/dist/serve/runtime/worker-replacement.d.ts.map +1 -0
- package/dist/serve/runtime/worker-replacement.js +29 -0
- package/dist/serve/runtime/worker-replacement.js.map +1 -0
- package/dist/serve/standalone-assets.d.ts +2 -2
- package/dist/serve/standalone-assets.d.ts.map +1 -1
- package/dist/serve/standalone-assets.js +1 -1
- package/dist/serve/standalone-assets.js.map +1 -1
- package/dist/serve/studio-ui/assets/{RulesCodeEditor-CxVYcZXw.js → RulesCodeEditor-BDlH-5lp.js} +1 -1
- package/dist/serve/studio-ui/assets/{index-cpSfgQ7H.js → index-Bf9SSE8L.js} +5 -5
- package/dist/serve/studio-ui/assets/{seed-DeuFKi4J.js → seed-D_UjxfOU.js} +1 -1
- package/dist/serve/studio-ui/index.html +1 -1
- package/dist/serve/vite-ai-config.d.ts +25 -0
- package/dist/serve/vite-ai-config.d.ts.map +1 -0
- package/dist/serve/vite-ai-config.js +45 -0
- package/dist/serve/vite-ai-config.js.map +1 -0
- package/dist/serve/vite-plugin.d.ts +17 -36
- package/dist/serve/vite-plugin.d.ts.map +1 -1
- package/dist/serve/vite-plugin.js +38 -57
- package/dist/serve/vite-plugin.js.map +1 -1
- package/dist/serve/vite-rules-source.d.ts +8 -0
- package/dist/serve/vite-rules-source.d.ts.map +1 -0
- package/dist/serve/vite-rules-source.js +23 -0
- package/dist/serve/vite-rules-source.js.map +1 -0
- package/dist/serve/vite-worker-runtime.d.ts +20 -0
- package/dist/serve/vite-worker-runtime.d.ts.map +1 -0
- package/dist/serve/vite-worker-runtime.js +25 -0
- package/dist/serve/vite-worker-runtime.js.map +1 -0
- package/dist/serve/worker/client/connection.d.ts +7 -2
- package/dist/serve/worker/client/connection.d.ts.map +1 -1
- package/dist/serve/worker/client/connection.js +13 -16
- package/dist/serve/worker/client/connection.js.map +1 -1
- package/dist/serve/worker/client/core.d.ts +7 -0
- package/dist/serve/worker/client/core.d.ts.map +1 -1
- package/dist/serve/worker/client/core.js +28 -0
- package/dist/serve/worker/client/core.js.map +1 -1
- package/dist/serve/worker/client/runtime-control.d.ts +8 -0
- package/dist/serve/worker/client/runtime-control.d.ts.map +1 -0
- package/dist/serve/worker/client/runtime-control.js +11 -0
- package/dist/serve/worker/client/runtime-control.js.map +1 -0
- package/dist/serve/worker/entry.js +44 -2
- package/dist/serve/worker/entry.js.map +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/host/studio.js +1 -1
- package/dist/serve/worker/host/studio.js.map +1 -1
- package/dist/serve/worker/host-context.d.ts +1 -1
- package/dist/serve/worker/host-context.d.ts.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.d.ts +15 -1
- package/dist/serve/worker/protocol.d.ts.map +1 -1
- package/dist/serve/worker/protocol.js +2 -2
- package/dist/serve/worker/protocol.js.map +1 -1
- package/dist/serve/worker/retirement.d.ts +21 -0
- package/dist/serve/worker/retirement.d.ts.map +1 -0
- package/dist/serve/worker/retirement.js +97 -0
- package/dist/serve/worker/retirement.js.map +1 -0
- package/dist/serve/worker/serve-init.d.ts.map +1 -1
- package/dist/serve/worker/serve-init.js +5 -6
- package/dist/serve/worker/serve-init.js.map +1 -1
- package/dist/vite.d.ts +1 -0
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js.map +1 -1
- package/package.json +6 -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 +519 -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 +49 -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 +556 -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 +135 -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/runtime/chip-config.ts +30 -0
- package/src/serve/runtime/chip-install.ts +27 -0
- package/src/serve/runtime/chip.ts +282 -0
- package/src/serve/runtime/manifest.ts +37 -0
- package/src/serve/runtime/status.ts +216 -0
- package/src/serve/runtime/worker-connection.ts +14 -0
- package/src/serve/runtime/worker-generation.ts +75 -0
- package/src/serve/runtime/worker-replacement.ts +42 -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-ai-config.ts +77 -0
- package/src/serve/vite-plugin.ts +1075 -0
- package/src/serve/vite-rules-source.ts +28 -0
- package/src/serve/vite-worker-runtime.ts +51 -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 +227 -0
- package/src/serve/worker/client/core.ts +384 -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/runtime-control.ts +22 -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 +223 -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 +1124 -0
- package/src/serve/worker/retirement.ts +125 -0
- package/src/serve/worker/serve-init.ts +605 -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 +18 -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,1050 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `connectRemoteSandbox()` — Node-side client for the browser-hosted
|
|
3
|
+
* SharedWorker sandbox (remote sandbox, slice 1 / checkpoint 1).
|
|
4
|
+
*
|
|
5
|
+
* Server-side Node code (ultimately `pyric-admin`'s remote dispatch arm)
|
|
6
|
+
* reaches the ONE sandbox the app + Studio + agent share:
|
|
7
|
+
*
|
|
8
|
+
* Node process ──ws──> `pyric dev --bridge` ──ws──> browser tab
|
|
9
|
+
* (attach/consumer leg) (peer leg)
|
|
10
|
+
* ──MessagePort──> SharedWorker
|
|
11
|
+
*
|
|
12
|
+
* DISCOVERY mirrors `pyric mcp-proxy` (`serve/discovery.ts`): the
|
|
13
|
+
* `.pyric/serve.json` pointer in cwd, health-probed across both loopback
|
|
14
|
+
* families with the pointer's `instanceId` pinned so a cross-family
|
|
15
|
+
* port-squatter can't split-brain the connection.
|
|
16
|
+
*
|
|
17
|
+
* FAIL-FAST, NEVER FALL BACK: when no browser tab is connected the connect
|
|
18
|
+
* (and every later op that races a closing tab) fails with a clear
|
|
19
|
+
* "open <serve url>" error. There is deliberately NO silent headless
|
|
20
|
+
* fallback — a silently split backend is exactly the failure this client
|
|
21
|
+
* exists to avoid.
|
|
22
|
+
*
|
|
23
|
+
* SUBSCRIPTION OWNERSHIP: the bridge process owns the durable sub registry
|
|
24
|
+
* and re-issues it to every newly registered peer (tab refresh/replacement),
|
|
25
|
+
* so a subscription made here survives peer churn — RTDB value semantics
|
|
26
|
+
* make the replay safe (each resubscribe re-delivers a fresh snapshot;
|
|
27
|
+
* consumers are last-value-wins).
|
|
28
|
+
*
|
|
29
|
+
* NODE-ONLY: uses `ws` and `node:fs` — exported via a `node`-conditional
|
|
30
|
+
* subpath (`@pyric/cli/remote`), never bundled for the browser.
|
|
31
|
+
*/
|
|
32
|
+
import { WebSocket } from 'ws';
|
|
33
|
+
import type { AuthUserRecord, CreateUserRequest, UpdateUserRequest } from 'pyric/auth';
|
|
34
|
+
import type { FullMetadata } from 'pyric/storage';
|
|
35
|
+
import {
|
|
36
|
+
REMOTE_SANDBOX,
|
|
37
|
+
SandboxContextImpl,
|
|
38
|
+
type RemoteSandbox as RemoteSandboxBase,
|
|
39
|
+
type AuthState,
|
|
40
|
+
type SandboxContext,
|
|
41
|
+
} from 'pyric/sandbox';
|
|
42
|
+
import type {
|
|
43
|
+
BridgeMessage,
|
|
44
|
+
WorkerOpPayload,
|
|
45
|
+
WorkerSubPayload,
|
|
46
|
+
} from '../bridge/protocol.js';
|
|
47
|
+
import { isBridgeMessage, NO_SANDBOX_ERROR_MESSAGE } from '../bridge/protocol.js';
|
|
48
|
+
import { cliVersion } from '../pkg-version.js';
|
|
49
|
+
import { MAX_STORAGE_OP_BYTES, storagePayloadTooLarge } from '../serve/worker/protocol.js';
|
|
50
|
+
import { discoverServe } from '../serve/discovery.js';
|
|
51
|
+
|
|
52
|
+
/** Sits just above the bridge's own 30s `callTimeoutMs` so a legitimately
|
|
53
|
+
* slow worker op still completes (same layering as `mcp-proxy`'s 35s). */
|
|
54
|
+
const DEFAULT_OP_TIMEOUT_MS = 35_000;
|
|
55
|
+
|
|
56
|
+
/** How long the attach handshake may take before connect() fails. */
|
|
57
|
+
const ATTACH_TIMEOUT_MS = 5_000;
|
|
58
|
+
|
|
59
|
+
function remoteError(
|
|
60
|
+
code: string,
|
|
61
|
+
message: string,
|
|
62
|
+
denialContext?: unknown,
|
|
63
|
+
): Error & { code: string; denialContext?: unknown } {
|
|
64
|
+
const err = new Error(message) as Error & { code: string; denialContext?: unknown };
|
|
65
|
+
err.code = code;
|
|
66
|
+
// Structured denial context (spike gap 6): a relayed permission-denied
|
|
67
|
+
// carries the same "why did this deny" frame a local SandboxError has —
|
|
68
|
+
// plain JSON off the wire, re-attached here.
|
|
69
|
+
if (denialContext !== undefined) err.denialContext = denialContext;
|
|
70
|
+
return err;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function noTabError(serveUrl: string): Error & { code: string } {
|
|
74
|
+
return remoteError(
|
|
75
|
+
'unavailable',
|
|
76
|
+
`no browser tab is connected to the sandbox — open ${serveUrl} in a browser and retry.`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ─── Public types ──────────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
export interface ConnectRemoteSandboxOptions {
|
|
83
|
+
/** Project root for `.pyric/serve.json` discovery. Default: `process.cwd()`. */
|
|
84
|
+
cwd?: string;
|
|
85
|
+
/** Explicit serve base URL (e.g. `http://127.0.0.1:5000`) — skips discovery. */
|
|
86
|
+
url?: string;
|
|
87
|
+
/** Per-op timeout in ms on the Node side (default 35s, above the bridge's 30s). */
|
|
88
|
+
opTimeoutMs?: number;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The raw relay channel: any worker-protocol op or snap-delivering
|
|
93
|
+
* subscription, verbatim. The typed conveniences below are built on this;
|
|
94
|
+
* checkpoint 2's `pyric-admin` remote-dispatch arm consumes it directly.
|
|
95
|
+
*/
|
|
96
|
+
export interface RemoteSandboxChannel {
|
|
97
|
+
/** Dispatch one worker op. Resolves with the worker's `res.value`;
|
|
98
|
+
* rejects with an Error carrying `.code`. NOTE: callers choose their own
|
|
99
|
+
* `actAs` lens — nothing is pinned here. */
|
|
100
|
+
op(op: WorkerOpPayload): Promise<unknown>;
|
|
101
|
+
/**
|
|
102
|
+
* Register a worker subscription. `onSnap` receives each snap value;
|
|
103
|
+
* an establishment failure (the worker host's `{ __error }` snap) routes
|
|
104
|
+
* to `onError` instead. Returns the unsubscribe function.
|
|
105
|
+
*/
|
|
106
|
+
subscribe(
|
|
107
|
+
sub: WorkerSubPayload,
|
|
108
|
+
onSnap: (value: unknown) => void,
|
|
109
|
+
onError?: (err: Error & { code: string }) => void,
|
|
110
|
+
): () => void;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Wire shape of an RTDB snapshot as the worker host serializes it. */
|
|
114
|
+
export interface RemoteRtdbSnapshot {
|
|
115
|
+
key: string | null;
|
|
116
|
+
exists: boolean;
|
|
117
|
+
value: unknown;
|
|
118
|
+
size: number;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Thin RTDB conveniences over the channel. Every call pins
|
|
123
|
+
* `actAs: { mode: 'admin' }` — firebase-admin's rules-bypass semantics,
|
|
124
|
+
* matching what `pyric-admin`'s database backend needs. Use the raw
|
|
125
|
+
* `channel` for lensed (rules-evaluated) access.
|
|
126
|
+
*/
|
|
127
|
+
export interface RemoteRtdb {
|
|
128
|
+
/** Read the value at `path` (null when absent). */
|
|
129
|
+
get(path: string): Promise<unknown>;
|
|
130
|
+
set(path: string, value: unknown): Promise<void>;
|
|
131
|
+
update(path: string, values: Record<string, unknown>): Promise<void>;
|
|
132
|
+
remove(path: string): Promise<void>;
|
|
133
|
+
/** Push `value` under a CLIENT-minted 20-char push id (the worker-protocol
|
|
134
|
+
* contract: `rtdb.push` carries the key, so `.key` is known synchronously
|
|
135
|
+
* on the pyric-admin side). Resolves with the minted key + full path. */
|
|
136
|
+
push(path: string, value?: unknown): Promise<{ key: string; path: string }>;
|
|
137
|
+
/** Subscribe to the value at `path` (initial snapshot + every change). */
|
|
138
|
+
onValue(
|
|
139
|
+
path: string,
|
|
140
|
+
callback: (snapshot: RemoteRtdbSnapshot) => void,
|
|
141
|
+
onError?: (err: Error & { code: string }) => void,
|
|
142
|
+
): () => void;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Thin Storage conveniences over the channel — the byte-carrying base64 ops
|
|
147
|
+
* plus browse/metadata. Every call pins `actAs: { mode: 'admin' }`
|
|
148
|
+
* (firebase-admin's rules-bypass semantics, matching {@link RemoteRtdb});
|
|
149
|
+
* use the raw `channel` for lensed (rules-evaluated) access. Bytes are
|
|
150
|
+
* capped at 8 MiB raw ({@link MAX_STORAGE_OP_BYTES}) on both ends —
|
|
151
|
+
* streaming transfers are not supported on the sandbox backend.
|
|
152
|
+
*/
|
|
153
|
+
export interface RemoteStorage {
|
|
154
|
+
/** Upload `data` at `path` (replaces any existing object). Resolves with
|
|
155
|
+
* the stored object's `FullMetadata`. */
|
|
156
|
+
putBytes(
|
|
157
|
+
path: string,
|
|
158
|
+
data: Uint8Array,
|
|
159
|
+
options?: { contentType?: string; metadata?: Record<string, unknown> },
|
|
160
|
+
): Promise<FullMetadata>;
|
|
161
|
+
/** Download the object's bytes. Rejects `storage/object-not-found` when
|
|
162
|
+
* absent, `payload-too-large` when over the op cap. */
|
|
163
|
+
getBytes(path: string): Promise<Buffer>;
|
|
164
|
+
/** Delete the object at `path`. Idempotent (missing = no-op). */
|
|
165
|
+
deleteObject(path: string): Promise<void>;
|
|
166
|
+
/** Read the object's `FullMetadata`. */
|
|
167
|
+
getMetadata(path: string): Promise<FullMetadata>;
|
|
168
|
+
/** Does an object exist at `path`? (`getMetadata` with not-found → false.) */
|
|
169
|
+
exists(path: string): Promise<boolean>;
|
|
170
|
+
/** Enumerate immediate child items + prefixes under `path`. */
|
|
171
|
+
listAll(path: string): Promise<{
|
|
172
|
+
items: Array<{ fullPath: string; name: string }>;
|
|
173
|
+
prefixes: Array<{ fullPath: string; name: string }>;
|
|
174
|
+
}>;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Admin auth user-CRUD passthrough (never lensed — auth ops operate the
|
|
178
|
+
* worker's user pool directly, mirroring `pyric/auth`'s sandbox ops). */
|
|
179
|
+
export interface RemoteAuthAdmin {
|
|
180
|
+
listUsers(): Promise<AuthUserRecord[]>;
|
|
181
|
+
createUser(request: CreateUserRequest): Promise<AuthUserRecord>;
|
|
182
|
+
updateUser(uid: string, request: UpdateUserRequest): Promise<AuthUserRecord>;
|
|
183
|
+
deleteUser(uid: string): Promise<void>;
|
|
184
|
+
clearUsers(): Promise<void>;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The Node-side remote sandbox handle. Extends `pyric/sandbox`'s branded
|
|
189
|
+
* {@link RemoteSandboxBase} — structurally a full `Sandbox`, so it can be
|
|
190
|
+
* passed to `pyric-admin/app`'s `initializeApp({ sandbox })`, whose RTDB and
|
|
191
|
+
* Auth backends dispatch on the brand and route through {@link channel}.
|
|
192
|
+
*
|
|
193
|
+
* Sandbox members that are genuinely sync-only (`admin`, `snapshot()`,
|
|
194
|
+
* `history()`, `onEvent`, `currentUser`, …) cannot be mirrored over the
|
|
195
|
+
* wire in slice 1 and throw a remediating `unimplemented` error naming
|
|
196
|
+
* what to do instead. Implemented members: `withAuth` (pure local pair
|
|
197
|
+
* construction) and `dispose` (aliases {@link close}).
|
|
198
|
+
*/
|
|
199
|
+
export interface RemoteSandbox extends RemoteSandboxBase {
|
|
200
|
+
/** The raw worker op/sub relay channel (narrowed to the wire payload types). */
|
|
201
|
+
readonly channel: RemoteSandboxChannel;
|
|
202
|
+
/** RTDB conveniences (admin lens pinned). */
|
|
203
|
+
readonly rtdb: RemoteRtdb;
|
|
204
|
+
/** Storage conveniences (admin lens pinned; 8 MiB per-op byte cap). */
|
|
205
|
+
readonly storage: RemoteStorage;
|
|
206
|
+
/** Admin auth user CRUD. */
|
|
207
|
+
readonly auth: RemoteAuthAdmin;
|
|
208
|
+
/** Close the connection. In-flight ops reject; subscriptions stop. */
|
|
209
|
+
close(): void;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ─── Core (transport-injected — the test seam) ─────────────────────────────
|
|
213
|
+
|
|
214
|
+
/** Minimal transport the core writes to. `connectRemoteSandbox` adapts a
|
|
215
|
+
* `ws` socket; tests inject an in-process pipe to a `ConsumerSession`. */
|
|
216
|
+
export interface RemoteTransport {
|
|
217
|
+
send(msg: BridgeMessage): void;
|
|
218
|
+
/**
|
|
219
|
+
* OPTIONAL event-loop hold hooks (exit-hang fix). The WS adapter unrefs
|
|
220
|
+
* its socket once connected so an IDLE remote client never pins the Node
|
|
221
|
+
* event loop (a finished script exits); the core calls `ref()` when work
|
|
222
|
+
* becomes outstanding (first pending op / live subscription) and
|
|
223
|
+
* `unref()` when the last one settles, so in-flight delivery keeps the
|
|
224
|
+
* process alive. Pure in-process transports (tests) may omit both.
|
|
225
|
+
*/
|
|
226
|
+
ref?(): void;
|
|
227
|
+
unref?(): void;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export interface RemoteSandboxCore {
|
|
231
|
+
/** Feed one parsed message from the transport into the core. */
|
|
232
|
+
handleMessage(msg: BridgeMessage): void;
|
|
233
|
+
/** Send the attach handshake. `ready` settles on the ack. */
|
|
234
|
+
start(): void;
|
|
235
|
+
/** Resolves on `attach-ack`; rejects when no browser tab is connected. */
|
|
236
|
+
ready: Promise<void>;
|
|
237
|
+
channel: RemoteSandboxChannel;
|
|
238
|
+
/** Fail everything in flight (transport closed). Idempotent. */
|
|
239
|
+
dispose(reason?: string): void;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Transport-agnostic client core: correlation ids/subIds are minted HERE
|
|
244
|
+
* (this leg's id space; the bridge re-mints for the peer leg), pending ops
|
|
245
|
+
* carry a Node-side timeout above the bridge's 30s, and `{ __error }` snap
|
|
246
|
+
* values are routed to the subscription's error handler.
|
|
247
|
+
*/
|
|
248
|
+
export function createRemoteSandboxCore(
|
|
249
|
+
transport: RemoteTransport,
|
|
250
|
+
opts: { serveUrl: string; opTimeoutMs?: number },
|
|
251
|
+
): RemoteSandboxCore {
|
|
252
|
+
const serveUrl = opts.serveUrl;
|
|
253
|
+
const opTimeoutMs = opts.opTimeoutMs ?? DEFAULT_OP_TIMEOUT_MS;
|
|
254
|
+
|
|
255
|
+
let opCounter = 0;
|
|
256
|
+
let subCounter = 0;
|
|
257
|
+
let disposed: string | null = null;
|
|
258
|
+
|
|
259
|
+
const pending = new Map<
|
|
260
|
+
string,
|
|
261
|
+
{ resolve: (v: unknown) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout> }
|
|
262
|
+
>();
|
|
263
|
+
const subs = new Map<
|
|
264
|
+
string,
|
|
265
|
+
{ onSnap: (value: unknown) => void; onError?: (err: Error & { code: string }) => void }
|
|
266
|
+
>();
|
|
267
|
+
|
|
268
|
+
let readyResolve!: () => void;
|
|
269
|
+
let readyReject!: (e: Error) => void;
|
|
270
|
+
const ready = new Promise<void>((resolve, reject) => {
|
|
271
|
+
readyResolve = resolve;
|
|
272
|
+
readyReject = reject;
|
|
273
|
+
});
|
|
274
|
+
// `ready` may legitimately go unobserved after dispose — never unhandled.
|
|
275
|
+
ready.catch(() => {});
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Version-skew guidance (integration-smoke fix). Set once when the
|
|
279
|
+
* `attach-ack`'s `serveVersion` stamp is present AND differs from this
|
|
280
|
+
* client's own @pyric/cli version: an old worker can accept a newer op
|
|
281
|
+
* frame and die mid-handling, which surfaces as a bare timeout — so the
|
|
282
|
+
* mismatch warns ONCE on stderr at attach, and op-timeout errors append
|
|
283
|
+
* the same guidance. Old servers omit the stamp → stays null → silent.
|
|
284
|
+
*/
|
|
285
|
+
let versionSkewGuidance: string | null = null;
|
|
286
|
+
|
|
287
|
+
function send(msg: BridgeMessage): void {
|
|
288
|
+
transport.send(msg);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Event-loop hold accounting (exit-hang fix): ref the transport while ANY
|
|
293
|
+
* op or subscription is outstanding, unref when the last one settles.
|
|
294
|
+
* Transition-edged so the hooks fire once per busy/idle flip.
|
|
295
|
+
*/
|
|
296
|
+
let holdingLoop = false;
|
|
297
|
+
function updateLoopHold(): void {
|
|
298
|
+
const busy = pending.size + subs.size > 0;
|
|
299
|
+
if (busy && !holdingLoop) {
|
|
300
|
+
holdingLoop = true;
|
|
301
|
+
transport.ref?.();
|
|
302
|
+
} else if (!busy && holdingLoop) {
|
|
303
|
+
holdingLoop = false;
|
|
304
|
+
transport.unref?.();
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function op(payload: WorkerOpPayload): Promise<unknown> {
|
|
309
|
+
if (disposed) {
|
|
310
|
+
return Promise.reject(remoteError('unavailable', disposed));
|
|
311
|
+
}
|
|
312
|
+
return new Promise<unknown>((resolve, reject) => {
|
|
313
|
+
const id = `rop-${++opCounter}`;
|
|
314
|
+
const timer = setTimeout(() => {
|
|
315
|
+
if (pending.has(id)) {
|
|
316
|
+
pending.delete(id);
|
|
317
|
+
updateLoopHold();
|
|
318
|
+
reject(
|
|
319
|
+
remoteError(
|
|
320
|
+
'deadline-exceeded',
|
|
321
|
+
`remote sandbox op timed out after ${opTimeoutMs}ms (op: ${payload.method}) — is pyric dev still running?` +
|
|
322
|
+
// A version-skewed old worker accepts frames it cannot
|
|
323
|
+
// handle and never responds — a timeout is its signature.
|
|
324
|
+
(versionSkewGuidance ? ` ${versionSkewGuidance}` : ''),
|
|
325
|
+
),
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
}, opTimeoutMs);
|
|
329
|
+
pending.set(id, { resolve, reject, timer });
|
|
330
|
+
updateLoopHold();
|
|
331
|
+
try {
|
|
332
|
+
send({ type: 'worker-op', id, op: payload });
|
|
333
|
+
} catch (err) {
|
|
334
|
+
clearTimeout(timer);
|
|
335
|
+
pending.delete(id);
|
|
336
|
+
updateLoopHold();
|
|
337
|
+
reject(
|
|
338
|
+
remoteError(
|
|
339
|
+
'unavailable',
|
|
340
|
+
`failed to send op to serve: ${err instanceof Error ? err.message : String(err)}`,
|
|
341
|
+
),
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function subscribe(
|
|
348
|
+
sub: WorkerSubPayload,
|
|
349
|
+
onSnap: (value: unknown) => void,
|
|
350
|
+
onError?: (err: Error & { code: string }) => void,
|
|
351
|
+
): () => void {
|
|
352
|
+
if (disposed) throw remoteError('unavailable', disposed);
|
|
353
|
+
const subId = `rsub-${++subCounter}`;
|
|
354
|
+
subs.set(subId, { onSnap, onError });
|
|
355
|
+
updateLoopHold();
|
|
356
|
+
send({ type: 'worker-sub', subId, sub });
|
|
357
|
+
return () => {
|
|
358
|
+
if (!subs.delete(subId)) return;
|
|
359
|
+
updateLoopHold();
|
|
360
|
+
if (!disposed) {
|
|
361
|
+
try {
|
|
362
|
+
send({ type: 'worker-unsub', subId });
|
|
363
|
+
} catch {}
|
|
364
|
+
}
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function handleMessage(msg: BridgeMessage): void {
|
|
369
|
+
if (!isBridgeMessage(msg)) return;
|
|
370
|
+
switch (msg.type) {
|
|
371
|
+
case 'attach-ack': {
|
|
372
|
+
// Version-skew stamp: warn ONCE when the serve process runs a
|
|
373
|
+
// different @pyric/cli version (absent stamp = old server = silent).
|
|
374
|
+
if (
|
|
375
|
+
versionSkewGuidance === null &&
|
|
376
|
+
typeof msg.serveVersion === 'string' &&
|
|
377
|
+
msg.serveVersion !== cliVersion()
|
|
378
|
+
) {
|
|
379
|
+
versionSkewGuidance =
|
|
380
|
+
`pyric dev is running version ${msg.serveVersion}, this client is ` +
|
|
381
|
+
`${cliVersion()} — restart pyric dev and reload the browser tab.`;
|
|
382
|
+
process.stderr.write(`pyric: ${versionSkewGuidance}\n`);
|
|
383
|
+
}
|
|
384
|
+
if (msg.peerConnected) readyResolve();
|
|
385
|
+
else readyReject(noTabError(serveUrl));
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
case 'worker-res': {
|
|
389
|
+
const call = pending.get(msg.id);
|
|
390
|
+
if (!call) return; // late (already timed out) — drop
|
|
391
|
+
clearTimeout(call.timer);
|
|
392
|
+
pending.delete(msg.id);
|
|
393
|
+
updateLoopHold();
|
|
394
|
+
if (msg.ok) {
|
|
395
|
+
call.resolve(msg.value);
|
|
396
|
+
} else {
|
|
397
|
+
const code = msg.error?.code ?? 'unknown';
|
|
398
|
+
let message = msg.error?.message ?? 'unknown sandbox error';
|
|
399
|
+
// Enrich the bridge's generic no-peer error with actionable guidance.
|
|
400
|
+
if (message === NO_SANDBOX_ERROR_MESSAGE) {
|
|
401
|
+
message = noTabError(serveUrl).message;
|
|
402
|
+
} else if (/^Unknown method:/.test(message)) {
|
|
403
|
+
// Version skew: a live tab whose SharedWorker predates this op.
|
|
404
|
+
// Other open pages of this origin keep the old worker alive.
|
|
405
|
+
message +=
|
|
406
|
+
' — the running sandbox may predate this feature; restart pyric dev ' +
|
|
407
|
+
'and close other open pages of this origin, then reload.';
|
|
408
|
+
}
|
|
409
|
+
call.reject(remoteError(code, message, msg.error?.denialContext));
|
|
410
|
+
}
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
case 'worker-snap': {
|
|
414
|
+
const sub = subs.get(msg.subId);
|
|
415
|
+
if (!sub) return; // unsubscribed — drop
|
|
416
|
+
const value = (msg.value ?? {}) as Record<string, unknown>;
|
|
417
|
+
if (value.__error) {
|
|
418
|
+
// A listener error is TERMINAL (Firestore's onSnapshot contract:
|
|
419
|
+
// after onError, no further snapshots and the listener is dead).
|
|
420
|
+
// Auto-unsubscribe BEFORE delivering: drop the local record (so a
|
|
421
|
+
// consumer's own unsubscribe becomes an idempotent no-op and the
|
|
422
|
+
// event-loop hold releases — an errored sub must not pin the
|
|
423
|
+
// process forever) and tell the worker to tear down whatever
|
|
424
|
+
// listener it may have registered (harmless no-op for a
|
|
425
|
+
// registration failure that never registered one).
|
|
426
|
+
subs.delete(msg.subId);
|
|
427
|
+
updateLoopHold();
|
|
428
|
+
if (!disposed) {
|
|
429
|
+
try {
|
|
430
|
+
send({ type: 'worker-unsub', subId: msg.subId });
|
|
431
|
+
} catch {}
|
|
432
|
+
}
|
|
433
|
+
const payload = value.__error as { code: string; message: string; denialContext?: unknown };
|
|
434
|
+
const err = remoteError(payload.code, payload.message, payload.denialContext);
|
|
435
|
+
if (sub.onError) sub.onError(err);
|
|
436
|
+
else console.error('pyric remote sandbox: uncaught error in subscription:', err);
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
try {
|
|
440
|
+
sub.onSnap(msg.value);
|
|
441
|
+
} catch {
|
|
442
|
+
// Consumer callback failures must not break the relay loop.
|
|
443
|
+
}
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
case 'ping': {
|
|
447
|
+
send({ type: 'pong', id: msg.id });
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
default:
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function dispose(reason?: string): void {
|
|
456
|
+
if (disposed) return;
|
|
457
|
+
disposed = reason ?? 'remote sandbox connection closed';
|
|
458
|
+
const err = remoteError('unavailable', disposed);
|
|
459
|
+
for (const call of pending.values()) {
|
|
460
|
+
clearTimeout(call.timer);
|
|
461
|
+
call.reject(err);
|
|
462
|
+
}
|
|
463
|
+
pending.clear();
|
|
464
|
+
subs.clear();
|
|
465
|
+
updateLoopHold();
|
|
466
|
+
readyReject(err); // no-op if already settled
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
return {
|
|
470
|
+
handleMessage,
|
|
471
|
+
start: () => send({ type: 'attach', protocol: 1 }),
|
|
472
|
+
ready,
|
|
473
|
+
channel: { op, subscribe },
|
|
474
|
+
dispose,
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// ─── Typed conveniences over the raw channel ───────────────────────────────
|
|
479
|
+
|
|
480
|
+
/** firebase-admin's rules-bypass lens, pinned on every RTDB convenience op. */
|
|
481
|
+
const ADMIN_LENS = { mode: 'admin' } as const;
|
|
482
|
+
|
|
483
|
+
export function buildRemoteRtdb(channel: RemoteSandboxChannel): RemoteRtdb {
|
|
484
|
+
return {
|
|
485
|
+
async get(path) {
|
|
486
|
+
const snap = (await channel.op({
|
|
487
|
+
method: 'rtdb.get',
|
|
488
|
+
path,
|
|
489
|
+
actAs: ADMIN_LENS,
|
|
490
|
+
})) as RemoteRtdbSnapshot;
|
|
491
|
+
return snap.value ?? null;
|
|
492
|
+
},
|
|
493
|
+
async set(path, value) {
|
|
494
|
+
await channel.op({ method: 'rtdb.set', path, value, actAs: ADMIN_LENS });
|
|
495
|
+
},
|
|
496
|
+
async update(path, values) {
|
|
497
|
+
await channel.op({ method: 'rtdb.update', path, values, actAs: ADMIN_LENS });
|
|
498
|
+
},
|
|
499
|
+
async remove(path) {
|
|
500
|
+
await channel.op({ method: 'rtdb.remove', path, actAs: ADMIN_LENS });
|
|
501
|
+
},
|
|
502
|
+
async push(path, value) {
|
|
503
|
+
const key = generatePushId();
|
|
504
|
+
const res = (await channel.op({
|
|
505
|
+
method: 'rtdb.push',
|
|
506
|
+
path,
|
|
507
|
+
key,
|
|
508
|
+
...(value !== undefined ? { value } : {}),
|
|
509
|
+
actAs: ADMIN_LENS,
|
|
510
|
+
})) as { key: string; path: string };
|
|
511
|
+
return res;
|
|
512
|
+
},
|
|
513
|
+
onValue(path, callback, onError) {
|
|
514
|
+
return channel.subscribe(
|
|
515
|
+
{ target: { service: 'rtdb', path }, actAs: ADMIN_LENS },
|
|
516
|
+
(value) => callback(value as RemoteRtdbSnapshot),
|
|
517
|
+
onError,
|
|
518
|
+
);
|
|
519
|
+
},
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
export function buildRemoteStorage(channel: RemoteSandboxChannel): RemoteStorage {
|
|
524
|
+
return {
|
|
525
|
+
async putBytes(path, data, options) {
|
|
526
|
+
// Client-side cap: reject BEFORE encoding/sending so an oversized
|
|
527
|
+
// payload never hits the wire (the host enforces the same cap on
|
|
528
|
+
// decode — belt and braces across the relay).
|
|
529
|
+
if (data.byteLength > MAX_STORAGE_OP_BYTES) {
|
|
530
|
+
throw storagePayloadTooLarge(data.byteLength, `storage payload for '${path}'`);
|
|
531
|
+
}
|
|
532
|
+
return (await channel.op({
|
|
533
|
+
method: 'storage.putBytes',
|
|
534
|
+
path,
|
|
535
|
+
dataB64: Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString('base64'),
|
|
536
|
+
...(options?.contentType !== undefined ? { contentType: options.contentType } : {}),
|
|
537
|
+
...(options?.metadata !== undefined ? { metadata: options.metadata } : {}),
|
|
538
|
+
actAs: ADMIN_LENS,
|
|
539
|
+
})) as FullMetadata;
|
|
540
|
+
},
|
|
541
|
+
async getBytes(path) {
|
|
542
|
+
const res = (await channel.op({
|
|
543
|
+
method: 'storage.getBytes',
|
|
544
|
+
path,
|
|
545
|
+
actAs: ADMIN_LENS,
|
|
546
|
+
})) as { dataB64: string };
|
|
547
|
+
return Buffer.from(res.dataB64, 'base64');
|
|
548
|
+
},
|
|
549
|
+
async deleteObject(path) {
|
|
550
|
+
await channel.op({ method: 'storage.deleteObject', path, actAs: ADMIN_LENS });
|
|
551
|
+
},
|
|
552
|
+
async getMetadata(path) {
|
|
553
|
+
return (await channel.op({
|
|
554
|
+
method: 'storage.getMetadata',
|
|
555
|
+
path,
|
|
556
|
+
actAs: ADMIN_LENS,
|
|
557
|
+
})) as FullMetadata;
|
|
558
|
+
},
|
|
559
|
+
async exists(path) {
|
|
560
|
+
try {
|
|
561
|
+
await this.getMetadata(path);
|
|
562
|
+
return true;
|
|
563
|
+
} catch (err) {
|
|
564
|
+
if ((err as { code?: string }).code === 'storage/object-not-found') return false;
|
|
565
|
+
throw err;
|
|
566
|
+
}
|
|
567
|
+
},
|
|
568
|
+
async listAll(path) {
|
|
569
|
+
return (await channel.op({
|
|
570
|
+
method: 'storage.listAll',
|
|
571
|
+
path,
|
|
572
|
+
actAs: ADMIN_LENS,
|
|
573
|
+
})) as {
|
|
574
|
+
items: Array<{ fullPath: string; name: string }>;
|
|
575
|
+
prefixes: Array<{ fullPath: string; name: string }>;
|
|
576
|
+
};
|
|
577
|
+
},
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
export function buildRemoteAuthAdmin(channel: RemoteSandboxChannel): RemoteAuthAdmin {
|
|
582
|
+
return {
|
|
583
|
+
async listUsers() {
|
|
584
|
+
return (await channel.op({ method: 'auth.listUsers' })) as AuthUserRecord[];
|
|
585
|
+
},
|
|
586
|
+
async createUser(request) {
|
|
587
|
+
return (await channel.op({
|
|
588
|
+
method: 'auth.adminCreateUser',
|
|
589
|
+
request: request as Record<string, unknown>,
|
|
590
|
+
})) as AuthUserRecord;
|
|
591
|
+
},
|
|
592
|
+
async updateUser(uid, request) {
|
|
593
|
+
return (await channel.op({
|
|
594
|
+
method: 'auth.adminUpdateUser',
|
|
595
|
+
uid,
|
|
596
|
+
request: request as Record<string, unknown>,
|
|
597
|
+
})) as AuthUserRecord;
|
|
598
|
+
},
|
|
599
|
+
async deleteUser(uid) {
|
|
600
|
+
await channel.op({ method: 'auth.adminDeleteUser', uid });
|
|
601
|
+
},
|
|
602
|
+
async clearUsers() {
|
|
603
|
+
await channel.op({ method: 'auth.adminClearUsers' });
|
|
604
|
+
},
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// ─── Handle construction ────────────────────────────────────────────────────
|
|
609
|
+
|
|
610
|
+
/** A member of `Sandbox` that slice 1 cannot express over the wire throws
|
|
611
|
+
* this branded, remediating error instead of silently misbehaving. */
|
|
612
|
+
function notAvailableRemotely(member: string, remedy: string): Error & { code: string } {
|
|
613
|
+
return remoteError(
|
|
614
|
+
'unimplemented',
|
|
615
|
+
`${member} is not available on a remote sandbox — ${remedy}`,
|
|
616
|
+
);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Build the branded remote sandbox handle over an established channel.
|
|
621
|
+
*
|
|
622
|
+
* Split out of {@link connectRemoteSandbox} so the in-process test harness
|
|
623
|
+
* (fake ports + `createConsumerSession`, no WS) constructs the EXACT handle
|
|
624
|
+
* production hands to `pyric-admin`.
|
|
625
|
+
*
|
|
626
|
+
* The handle satisfies `Sandbox` structurally:
|
|
627
|
+
* - `withAuth` / `dispose` are real (pure local construction / teardown).
|
|
628
|
+
* - Everything whose contract is sync-only or worker-owned (`admin`,
|
|
629
|
+
* `snapshot`, `loadSnapshot`, `history`, `onEvent`, `reset`,
|
|
630
|
+
* `currentUser`, `onCurrentUserChanged`, tab sync, persistence) throws
|
|
631
|
+
* a remediating `unimplemented` error. Notably `onEvent`: the unified
|
|
632
|
+
* event stream (`target: 'events'`) is not relayable until slice 2's
|
|
633
|
+
* bounded backpressure lands, and no remote dispatch arm may depend on
|
|
634
|
+
* it — a throw (not a silent no-op) keeps a subscriber from believing
|
|
635
|
+
* it is observing events that will never arrive.
|
|
636
|
+
*/
|
|
637
|
+
export function createRemoteSandboxHandle(opts: {
|
|
638
|
+
channel: RemoteSandboxChannel;
|
|
639
|
+
serveUrl: string;
|
|
640
|
+
close: () => void;
|
|
641
|
+
}): RemoteSandbox {
|
|
642
|
+
const { channel, serveUrl, close } = opts;
|
|
643
|
+
const throwMember = (member: string, remedy: string): never => {
|
|
644
|
+
throw notAvailableRemotely(member, remedy);
|
|
645
|
+
};
|
|
646
|
+
const handle: RemoteSandbox = {
|
|
647
|
+
[REMOTE_SANDBOX]: true as const,
|
|
648
|
+
serveUrl,
|
|
649
|
+
channel,
|
|
650
|
+
rtdb: buildRemoteRtdb(channel),
|
|
651
|
+
storage: buildRemoteStorage(channel),
|
|
652
|
+
auth: buildRemoteAuthAdmin(channel),
|
|
653
|
+
close,
|
|
654
|
+
|
|
655
|
+
// ── Implemented Sandbox members ─────────────────────────────────────
|
|
656
|
+
withAuth(auth: AuthState): SandboxContext {
|
|
657
|
+
// Pure local pair construction — contexts carry identity, the data
|
|
658
|
+
// stays behind the channel.
|
|
659
|
+
return new SandboxContextImpl(handle, auth);
|
|
660
|
+
},
|
|
661
|
+
dispose(): void {
|
|
662
|
+
close();
|
|
663
|
+
},
|
|
664
|
+
|
|
665
|
+
// ── Sync-only / worker-owned members: remediating throws ───────────
|
|
666
|
+
onEvent: () =>
|
|
667
|
+
throwMember(
|
|
668
|
+
'onEvent',
|
|
669
|
+
'the unified event stream is not relayed over the bridge yet (slice 2); ' +
|
|
670
|
+
`observe activity in the browser tab at ${serveUrl} or in Pyric Studio instead`,
|
|
671
|
+
),
|
|
672
|
+
history: () =>
|
|
673
|
+
throwMember(
|
|
674
|
+
'history()',
|
|
675
|
+
'the event log lives in the browser worker; inspect it in Pyric Studio ' +
|
|
676
|
+
'or subscribe from the page that hosts the sandbox',
|
|
677
|
+
),
|
|
678
|
+
get admin(): never {
|
|
679
|
+
return throwMember(
|
|
680
|
+
'admin',
|
|
681
|
+
'the sync Firestore admin plane cannot span the wire; dispatch async worker ops ' +
|
|
682
|
+
"through the handle's channel instead (e.g. channel.op({ method: 'admin.getDocument', path }))",
|
|
683
|
+
);
|
|
684
|
+
},
|
|
685
|
+
reset: () =>
|
|
686
|
+
throwMember(
|
|
687
|
+
'reset()',
|
|
688
|
+
`reset the sandbox from the browser tab at ${serveUrl} or from Pyric Studio`,
|
|
689
|
+
),
|
|
690
|
+
resetAll: () =>
|
|
691
|
+
throwMember(
|
|
692
|
+
'resetAll()',
|
|
693
|
+
"use the async worker op instead: channel.op({ method: 'resetAll' })",
|
|
694
|
+
),
|
|
695
|
+
snapshot: () =>
|
|
696
|
+
throwMember(
|
|
697
|
+
'snapshot()',
|
|
698
|
+
"use the async worker op instead: channel.op({ method: 'getSnapshot' })",
|
|
699
|
+
),
|
|
700
|
+
loadSnapshot: () =>
|
|
701
|
+
throwMember(
|
|
702
|
+
'loadSnapshot()',
|
|
703
|
+
"use the async worker ops instead: channel.op({ method: 'importState', bundle })",
|
|
704
|
+
),
|
|
705
|
+
get currentUser(): never {
|
|
706
|
+
return throwMember(
|
|
707
|
+
'currentUser',
|
|
708
|
+
'the browser tab owns the auth session; ' +
|
|
709
|
+
"read it asynchronously via channel.op({ method: 'auth.getCurrentUser' })",
|
|
710
|
+
);
|
|
711
|
+
},
|
|
712
|
+
onCurrentUserChanged: () =>
|
|
713
|
+
throwMember(
|
|
714
|
+
'onCurrentUserChanged',
|
|
715
|
+
'the browser tab owns the auth session; subscribe there instead',
|
|
716
|
+
),
|
|
717
|
+
enableTabSync: () =>
|
|
718
|
+
throwMember('enableTabSync', 'tab sync is a browser concern; enable it in the page'),
|
|
719
|
+
enablePersistence: () =>
|
|
720
|
+
throwMember(
|
|
721
|
+
'enablePersistence',
|
|
722
|
+
'the browser worker owns persistence; it is already enabled by `pyric dev`',
|
|
723
|
+
),
|
|
724
|
+
flush: () =>
|
|
725
|
+
throwMember('flush()', 'the browser worker owns persistence and flushes itself'),
|
|
726
|
+
clearPersistence: () =>
|
|
727
|
+
throwMember(
|
|
728
|
+
'clearPersistence()',
|
|
729
|
+
'clear the persisted state from the browser tab or Pyric Studio',
|
|
730
|
+
),
|
|
731
|
+
registerPersistableService: () =>
|
|
732
|
+
throwMember(
|
|
733
|
+
'registerPersistableService',
|
|
734
|
+
'the browser worker owns persistence; services register there',
|
|
735
|
+
),
|
|
736
|
+
};
|
|
737
|
+
return handle;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// ─── connect ───────────────────────────────────────────────────────────────
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* Discover the running `pyric dev --bridge`, attach to its bridge WS as a
|
|
744
|
+
* worker-relay CONSUMER (never a peer — attaching cannot kick the browser
|
|
745
|
+
* tab out of last-connection-wins), and return the typed remote handle.
|
|
746
|
+
*
|
|
747
|
+
* Fails fast when no serve is discoverable or no browser tab is connected —
|
|
748
|
+
* there is deliberately no headless fallback (see module doc).
|
|
749
|
+
*/
|
|
750
|
+
export async function connectRemoteSandbox(
|
|
751
|
+
options: ConnectRemoteSandboxOptions = {},
|
|
752
|
+
): Promise<RemoteSandbox> {
|
|
753
|
+
const cwd = options.cwd ?? process.cwd();
|
|
754
|
+
|
|
755
|
+
// Two URLs on purpose: `serveUrl` is the CANONICAL display URL that lands in
|
|
756
|
+
// "open <url>" guidance — it MUST match the origin serve's banner/auto-open
|
|
757
|
+
// use (`http://localhost:<port>` for the default host), or a user following
|
|
758
|
+
// the guidance opens a DIFFERENT browser origin (127.0.0.1 vs localhost →
|
|
759
|
+
// separate SharedWorkers → split sandboxes). `wsBase` is connectivity: the
|
|
760
|
+
// literal loopback family the health probe actually reached.
|
|
761
|
+
let serveUrl: string;
|
|
762
|
+
let wsBase: string;
|
|
763
|
+
if (options.url) {
|
|
764
|
+
serveUrl = options.url.replace(/\/$/, '');
|
|
765
|
+
wsBase = serveUrl;
|
|
766
|
+
} else {
|
|
767
|
+
const found = await discoverServe(cwd);
|
|
768
|
+
if (!found) {
|
|
769
|
+
throw remoteError(
|
|
770
|
+
'not-found',
|
|
771
|
+
'no running `pyric dev --bridge` found (looked for .pyric/serve.json in ' +
|
|
772
|
+
`${cwd} and the default ports) — start your dev server with the bridge enabled and retry.`,
|
|
773
|
+
);
|
|
774
|
+
}
|
|
775
|
+
serveUrl = found.url;
|
|
776
|
+
wsBase = found.base;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
const wsUrl = `${wsBase.replace(/^http/, 'ws')}/__pyric/sandbox`;
|
|
780
|
+
const ws = new WebSocket(wsUrl);
|
|
781
|
+
|
|
782
|
+
// Event-loop hold (exit-hang fix): `ws` exposes no ref/unref of its own —
|
|
783
|
+
// reach the underlying net.Socket (present once connected). Unref'ing only
|
|
784
|
+
// changes loop-exit accounting, never delivery: while ANY pending op or
|
|
785
|
+
// live subscription holds a ref (the core's updateLoopHold), frames flow
|
|
786
|
+
// normally; when idle, a finished script exits instead of hanging.
|
|
787
|
+
const wsSocket = (): { ref(): void; unref(): void } | undefined =>
|
|
788
|
+
(ws as unknown as { _socket?: { ref(): void; unref(): void } })._socket;
|
|
789
|
+
|
|
790
|
+
const core = createRemoteSandboxCore(
|
|
791
|
+
{
|
|
792
|
+
send: (msg) => ws.send(JSON.stringify(msg)),
|
|
793
|
+
ref: () => wsSocket()?.ref(),
|
|
794
|
+
unref: () => wsSocket()?.unref(),
|
|
795
|
+
},
|
|
796
|
+
{ serveUrl, opTimeoutMs: options.opTimeoutMs },
|
|
797
|
+
);
|
|
798
|
+
|
|
799
|
+
ws.on('message', (raw) => {
|
|
800
|
+
let msg: unknown;
|
|
801
|
+
try {
|
|
802
|
+
msg = JSON.parse(raw.toString());
|
|
803
|
+
} catch {
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
if (isBridgeMessage(msg)) core.handleMessage(msg);
|
|
807
|
+
});
|
|
808
|
+
ws.on('close', () => core.dispose('remote sandbox connection closed (serve stopped or connection lost)'));
|
|
809
|
+
ws.on('error', (err) => core.dispose(`remote sandbox connection failed: ${err.message}`));
|
|
810
|
+
|
|
811
|
+
await new Promise<void>((resolve, reject) => {
|
|
812
|
+
const timer = setTimeout(
|
|
813
|
+
() => reject(remoteError('deadline-exceeded', `timed out connecting to ${wsUrl}`)),
|
|
814
|
+
ATTACH_TIMEOUT_MS,
|
|
815
|
+
);
|
|
816
|
+
ws.once('open', () => {
|
|
817
|
+
clearTimeout(timer);
|
|
818
|
+
// Idle default: an open-but-idle connection must not pin the event
|
|
819
|
+
// loop (both the eager and lazy `remoteSandbox` paths come through
|
|
820
|
+
// here). The core re-refs while ops/subs are outstanding.
|
|
821
|
+
wsSocket()?.unref();
|
|
822
|
+
resolve();
|
|
823
|
+
});
|
|
824
|
+
ws.once('error', (err) => {
|
|
825
|
+
clearTimeout(timer);
|
|
826
|
+
reject(remoteError('unavailable', `failed to connect to ${wsUrl}: ${err.message}`));
|
|
827
|
+
});
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
core.start();
|
|
831
|
+
try {
|
|
832
|
+
await withTimeout(core.ready, ATTACH_TIMEOUT_MS, `timed out attaching to the bridge at ${wsUrl}`);
|
|
833
|
+
} catch (err) {
|
|
834
|
+
try {
|
|
835
|
+
ws.close();
|
|
836
|
+
} catch {}
|
|
837
|
+
throw err;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
return createRemoteSandboxHandle({
|
|
841
|
+
serveUrl,
|
|
842
|
+
channel: core.channel,
|
|
843
|
+
close() {
|
|
844
|
+
core.dispose('remote sandbox connection closed by the client');
|
|
845
|
+
try {
|
|
846
|
+
ws.close();
|
|
847
|
+
} catch {}
|
|
848
|
+
},
|
|
849
|
+
});
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// ─── Lazy connect (`remoteSandbox`) ────────────────────────────────────────
|
|
853
|
+
|
|
854
|
+
/**
|
|
855
|
+
* {@link remoteSandbox}'s return type: the branded handle plus `ready` for
|
|
856
|
+
* eager checkers. `ready` kicks off the connection when first accessed and
|
|
857
|
+
* settles with the same fail-fast errors {@link connectRemoteSandbox} throws
|
|
858
|
+
* (no serve discovered / no browser tab connected).
|
|
859
|
+
*/
|
|
860
|
+
export interface LazyRemoteSandbox extends RemoteSandbox {
|
|
861
|
+
readonly ready: Promise<void>;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/**
|
|
865
|
+
* Synchronous construction, lazy connection — the ambient-init seam.
|
|
866
|
+
*
|
|
867
|
+
* `@pyric/cli/register` installs this behind the
|
|
868
|
+
* `Symbol.for('pyric.remote.sandboxFactory')` global so `pyric-admin`'s bare
|
|
869
|
+
* `initializeApp()` can mint a full branded handle without awaiting anything.
|
|
870
|
+
* The wire connection (discovery → WS attach) happens on the FIRST op (or
|
|
871
|
+
* `ready` access), so the existing fail-fast — "no browser tab is connected —
|
|
872
|
+
* open <url>" — surfaces on first use instead of at construction. A failed
|
|
873
|
+
* connect is NOT latched: the next op retries, matching the error's own
|
|
874
|
+
* "…and retry" guidance. `connectRemoteSandbox` (eager) is unchanged.
|
|
875
|
+
*/
|
|
876
|
+
export function remoteSandbox(options: ConnectRemoteSandboxOptions = {}): LazyRemoteSandbox {
|
|
877
|
+
return createLazyRemoteSandbox(() => connectRemoteSandbox(options), options);
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
/**
|
|
881
|
+
* The lazy wrapper with the connect function injected — the test seam
|
|
882
|
+
* (tests inject a fake connect; production injects `connectRemoteSandbox`).
|
|
883
|
+
*/
|
|
884
|
+
export function createLazyRemoteSandbox(
|
|
885
|
+
connect: () => Promise<RemoteSandbox>,
|
|
886
|
+
options: { url?: string } = {},
|
|
887
|
+
): LazyRemoteSandbox {
|
|
888
|
+
let inner: Promise<RemoteSandbox> | null = null;
|
|
889
|
+
let closed = false;
|
|
890
|
+
|
|
891
|
+
function ensure(): Promise<RemoteSandbox> {
|
|
892
|
+
if (closed) {
|
|
893
|
+
return Promise.reject(
|
|
894
|
+
remoteError('unavailable', 'remote sandbox connection closed by the client'),
|
|
895
|
+
);
|
|
896
|
+
}
|
|
897
|
+
if (!inner) {
|
|
898
|
+
inner = connect().then(
|
|
899
|
+
(h) => {
|
|
900
|
+
if (closed) {
|
|
901
|
+
h.close();
|
|
902
|
+
throw remoteError('unavailable', 'remote sandbox connection closed by the client');
|
|
903
|
+
}
|
|
904
|
+
// Discovery resolved the real URL — reflect it on the outer handle.
|
|
905
|
+
(handle as { serveUrl: string }).serveUrl = h.serveUrl;
|
|
906
|
+
return h;
|
|
907
|
+
},
|
|
908
|
+
(err) => {
|
|
909
|
+
// Fail-fast surfaces on this op, but don't latch: the remediation
|
|
910
|
+
// is "open <url> and retry", so the next op re-attempts the connect.
|
|
911
|
+
inner = null;
|
|
912
|
+
throw err;
|
|
913
|
+
},
|
|
914
|
+
);
|
|
915
|
+
}
|
|
916
|
+
return inner;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
const channel: RemoteSandboxChannel = {
|
|
920
|
+
op: (payload) => ensure().then((h) => h.channel.op(payload)),
|
|
921
|
+
subscribe(sub, onSnap, onError) {
|
|
922
|
+
let cancelled = false;
|
|
923
|
+
let innerUnsub: (() => void) | null = null;
|
|
924
|
+
ensure().then(
|
|
925
|
+
(h) => {
|
|
926
|
+
if (cancelled) return;
|
|
927
|
+
innerUnsub = h.channel.subscribe(sub, onSnap, onError);
|
|
928
|
+
},
|
|
929
|
+
(err) => {
|
|
930
|
+
if (cancelled) return;
|
|
931
|
+
const e =
|
|
932
|
+
err instanceof Error && 'code' in err
|
|
933
|
+
? (err as Error & { code: string })
|
|
934
|
+
: remoteError('unavailable', String(err));
|
|
935
|
+
if (onError) onError(e);
|
|
936
|
+
else console.error('pyric remote sandbox: subscription failed to connect:', e);
|
|
937
|
+
},
|
|
938
|
+
);
|
|
939
|
+
return () => {
|
|
940
|
+
cancelled = true;
|
|
941
|
+
if (innerUnsub) {
|
|
942
|
+
innerUnsub();
|
|
943
|
+
innerUnsub = null;
|
|
944
|
+
}
|
|
945
|
+
};
|
|
946
|
+
},
|
|
947
|
+
};
|
|
948
|
+
|
|
949
|
+
const handle = createRemoteSandboxHandle({
|
|
950
|
+
channel,
|
|
951
|
+
// Before discovery the URL is unknown; op-level errors always carry the
|
|
952
|
+
// real URL (they come from the inner connect), and `serveUrl` is patched
|
|
953
|
+
// to the discovered value as soon as the first connect succeeds.
|
|
954
|
+
serveUrl: options.url?.replace(/\/$/, '') ?? '<pyric dev url (pending discovery)>',
|
|
955
|
+
close() {
|
|
956
|
+
if (closed) return;
|
|
957
|
+
closed = true;
|
|
958
|
+
const settled = inner;
|
|
959
|
+
inner = null;
|
|
960
|
+
if (settled) {
|
|
961
|
+
settled.then(
|
|
962
|
+
(h) => h.close(),
|
|
963
|
+
() => {},
|
|
964
|
+
);
|
|
965
|
+
}
|
|
966
|
+
},
|
|
967
|
+
}) as RemoteSandbox & { ready: Promise<void> };
|
|
968
|
+
|
|
969
|
+
// `ready` — the eager checker: accessing it starts the connection. Marked
|
|
970
|
+
// handled (same pattern as the core's `ready`) so a mere existence check
|
|
971
|
+
// (`handle.ready instanceof Promise`) never surfaces an unhandled rejection.
|
|
972
|
+
Object.defineProperty(handle, 'ready', {
|
|
973
|
+
get: (): Promise<void> => {
|
|
974
|
+
const p = ensure().then(() => undefined);
|
|
975
|
+
p.catch(() => {});
|
|
976
|
+
return p;
|
|
977
|
+
},
|
|
978
|
+
enumerable: true,
|
|
979
|
+
configurable: true,
|
|
980
|
+
});
|
|
981
|
+
|
|
982
|
+
return handle as LazyRemoteSandbox;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
function withTimeout<T>(p: Promise<T>, ms: number, message: string): Promise<T> {
|
|
986
|
+
return new Promise<T>((resolve, reject) => {
|
|
987
|
+
const timer = setTimeout(() => reject(remoteError('deadline-exceeded', message)), ms);
|
|
988
|
+
p.then(
|
|
989
|
+
(v) => {
|
|
990
|
+
clearTimeout(timer);
|
|
991
|
+
resolve(v);
|
|
992
|
+
},
|
|
993
|
+
(e) => {
|
|
994
|
+
clearTimeout(timer);
|
|
995
|
+
reject(e);
|
|
996
|
+
},
|
|
997
|
+
);
|
|
998
|
+
});
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
// ─── RTDB push-id generator ────────────────────────────────────────────────
|
|
1002
|
+
// Client-minted push ids (the worker-protocol contract: `rtdb.push` carries
|
|
1003
|
+
// the key so `.key` is synchronously known to callers). Standard Firebase
|
|
1004
|
+
// algorithm — 8 chars of timestamp + 12 random, monotonic within one ms.
|
|
1005
|
+
// Inlined (like pyric-admin does) because pyric doesn't export its generator.
|
|
1006
|
+
|
|
1007
|
+
const PUSH_CHARS =
|
|
1008
|
+
'-0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz';
|
|
1009
|
+
|
|
1010
|
+
let lastPushTime = 0;
|
|
1011
|
+
const lastRandChars: number[] = new Array(12).fill(0);
|
|
1012
|
+
|
|
1013
|
+
function generatePushId(now: number = Date.now()): string {
|
|
1014
|
+
const duplicateTime = now === lastPushTime;
|
|
1015
|
+
lastPushTime = now;
|
|
1016
|
+
|
|
1017
|
+
const timeStampChars: string[] = new Array(8);
|
|
1018
|
+
let ts = now;
|
|
1019
|
+
for (let i = 7; i >= 0; i--) {
|
|
1020
|
+
timeStampChars[i] = PUSH_CHARS.charAt(ts % 64);
|
|
1021
|
+
ts = Math.floor(ts / 64);
|
|
1022
|
+
}
|
|
1023
|
+
if (ts !== 0) {
|
|
1024
|
+
throw new Error('RTDB push-id: timestamp overflow.');
|
|
1025
|
+
}
|
|
1026
|
+
let id = timeStampChars.join('');
|
|
1027
|
+
|
|
1028
|
+
if (!duplicateTime) {
|
|
1029
|
+
for (let i = 0; i < 12; i++) {
|
|
1030
|
+
lastRandChars[i] = Math.floor(Math.random() * 64);
|
|
1031
|
+
}
|
|
1032
|
+
} else {
|
|
1033
|
+
let i: number;
|
|
1034
|
+
for (i = 11; i >= 0 && lastRandChars[i] === 63; i--) {
|
|
1035
|
+
lastRandChars[i] = 0;
|
|
1036
|
+
}
|
|
1037
|
+
if (i < 0) {
|
|
1038
|
+
for (let j = 0; j < 12; j++) {
|
|
1039
|
+
lastRandChars[j] = Math.floor(Math.random() * 64);
|
|
1040
|
+
}
|
|
1041
|
+
} else {
|
|
1042
|
+
lastRandChars[i] = (lastRandChars[i] ?? 0) + 1;
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
for (let i = 0; i < 12; i++) {
|
|
1047
|
+
id += PUSH_CHARS.charAt(lastRandChars[i]!);
|
|
1048
|
+
}
|
|
1049
|
+
return id;
|
|
1050
|
+
}
|