@pyric/cli 0.1.0-alpha.12 → 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.
Files changed (230) hide show
  1. package/dist/cli/serve.js +4 -4
  2. package/dist/cli/serve.js.map +1 -1
  3. package/dist/serve/bundler.d.ts +13 -8
  4. package/dist/serve/bundler.d.ts.map +1 -1
  5. package/dist/serve/bundler.js +31 -36
  6. package/dist/serve/bundler.js.map +1 -1
  7. package/dist/serve/docs-ui/docs/_rhythm/index.html +1 -1
  8. package/dist/serve/docs-ui/docs/agent/set-up-your-agent/index.html +1 -1
  9. package/dist/serve/docs-ui/docs/agent/watch-and-review/index.html +1 -1
  10. package/dist/serve/docs-ui/docs/agent/work-with-an-agent/index.html +1 -1
  11. package/dist/serve/docs-ui/docs/ai-compat/index.html +1 -1
  12. package/dist/serve/docs-ui/docs/api-reference/index.html +1 -1
  13. package/dist/serve/docs-ui/docs/app-compat/index.html +1 -1
  14. package/dist/serve/docs-ui/docs/auth-compat/index.html +1 -1
  15. package/dist/serve/docs-ui/docs/build/ai-logic/index.html +44 -22
  16. package/dist/serve/docs-ui/docs/build/ai-logic.md +69 -21
  17. package/dist/serve/docs-ui/docs/build/authentication/index.html +1 -1
  18. package/dist/serve/docs-ui/docs/build/cloud-firestore/index.html +1 -1
  19. package/dist/serve/docs-ui/docs/build/cloud-messaging/index.html +1 -1
  20. package/dist/serve/docs-ui/docs/build/cloud-storage/index.html +1 -1
  21. package/dist/serve/docs-ui/docs/build/realtime-database/index.html +1 -1
  22. package/dist/serve/docs-ui/docs/conformance-scores/index.html +1 -1
  23. package/dist/serve/docs-ui/docs/create-pyric-reference-api/index.html +1 -1
  24. package/dist/serve/docs-ui/docs/database-compat/index.html +1 -1
  25. package/dist/serve/docs-ui/docs/firestore-compat/index.html +1 -1
  26. package/dist/serve/docs-ui/docs/functions-rtdb-compat/index.html +1 -1
  27. package/dist/serve/docs-ui/docs/get-started/how-the-swap-works/index.html +2 -1
  28. package/dist/serve/docs-ui/docs/get-started/how-the-swap-works.md +2 -0
  29. package/dist/serve/docs-ui/docs/get-started/start-building/index.html +3 -2
  30. package/dist/serve/docs-ui/docs/get-started/start-building.md +3 -1
  31. package/dist/serve/docs-ui/docs/index.json +149 -4
  32. package/dist/serve/docs-ui/docs/messaging-compat/index.html +1 -1
  33. package/dist/serve/docs-ui/docs/observe/resolve-runtime-status/index.html +28 -0
  34. package/dist/serve/docs-ui/docs/observe/resolve-runtime-status.md +47 -0
  35. package/dist/serve/docs-ui/docs/observe/see-whats-happening/index.html +1 -1
  36. package/dist/serve/docs-ui/docs/observe/shape-your-data/index.html +1 -1
  37. package/dist/serve/docs-ui/docs/overview/index.html +1 -1
  38. package/dist/serve/docs-ui/docs/pyric-admin-app-reference-api/index.html +1 -1
  39. package/dist/serve/docs-ui/docs/pyric-admin-auth-reference-api/index.html +1 -1
  40. package/dist/serve/docs-ui/docs/pyric-admin-database-reference-api/index.html +1 -1
  41. package/dist/serve/docs-ui/docs/pyric-admin-firestore-reference-api/index.html +1 -1
  42. package/dist/serve/docs-ui/docs/pyric-admin-messaging-reference-api/index.html +1 -1
  43. package/dist/serve/docs-ui/docs/pyric-admin-storage-reference-api/index.html +1 -1
  44. package/dist/serve/docs-ui/docs/pyric-ai-reference-api/index.html +1 -1
  45. package/dist/serve/docs-ui/docs/pyric-ai-scripting-reference-api/index.html +1 -1
  46. package/dist/serve/docs-ui/docs/pyric-app-reference-api/index.html +1 -1
  47. package/dist/serve/docs-ui/docs/pyric-auth-reference-api/index.html +1 -1
  48. package/dist/serve/docs-ui/docs/pyric-cli-assurance-browser-reference-api/index.html +1 -1
  49. package/dist/serve/docs-ui/docs/pyric-cli-assurance-reference-api/index.html +1 -1
  50. package/dist/serve/docs-ui/docs/pyric-cli-bridge-client-reference-api/index.html +1 -1
  51. package/dist/serve/docs-ui/docs/pyric-cli-bridge-reference-api/index.html +1 -1
  52. package/dist/serve/docs-ui/docs/pyric-cli-conformance-browser-reference-api/index.html +1 -1
  53. package/dist/serve/docs-ui/docs/pyric-cli-conformance-docs-reference-api/index.html +1 -1
  54. package/dist/serve/docs-ui/docs/pyric-cli-conformance-reference-api/index.html +1 -1
  55. package/dist/serve/docs-ui/docs/pyric-cli-credentials-node-reference-api/index.html +1 -1
  56. package/dist/serve/docs-ui/docs/pyric-cli-discover-reference-api/index.html +1 -1
  57. package/dist/serve/docs-ui/docs/pyric-cli-register-reference-api/index.html +1 -1
  58. package/dist/serve/docs-ui/docs/pyric-cli-remote-reference-api/index.html +1 -1
  59. package/dist/serve/docs-ui/docs/pyric-cli-serve-worker-reference-api/index.html +38 -11
  60. package/dist/serve/docs-ui/docs/pyric-cli-serve-worker-reference-api.md +26 -8
  61. package/dist/serve/docs-ui/docs/pyric-cli-verify-reference-api/index.html +1 -1
  62. package/dist/serve/docs-ui/docs/pyric-cli-vite-reference-api/index.html +11 -8
  63. package/dist/serve/docs-ui/docs/pyric-cli-vite-reference-api.md +17 -4
  64. package/dist/serve/docs-ui/docs/pyric-database-reference-api/index.html +1 -1
  65. package/dist/serve/docs-ui/docs/pyric-firestore-reference-api/index.html +1 -1
  66. package/dist/serve/docs-ui/docs/pyric-messaging-reference-api/index.html +1 -1
  67. package/dist/serve/docs-ui/docs/pyric-messaging-sw-reference-api/index.html +1 -1
  68. package/dist/serve/docs-ui/docs/pyric-rules-reference-api/index.html +1 -1
  69. package/dist/serve/docs-ui/docs/pyric-sandbox-database-reference-api/index.html +1 -1
  70. package/dist/serve/docs-ui/docs/pyric-sandbox-firestore-reference-api/index.html +1 -1
  71. package/dist/serve/docs-ui/docs/pyric-sandbox-reference-api/index.html +1 -1
  72. package/dist/serve/docs-ui/docs/pyric-storage-reference-api/index.html +1 -1
  73. package/dist/serve/docs-ui/docs/pyric-ui-agents-reference-api/index.html +1 -1
  74. package/dist/serve/docs-ui/docs/pyric-ui-auth-hooks-reference-api/index.html +1 -1
  75. package/dist/serve/docs-ui/docs/pyric-ui-auth-reference-api/index.html +1 -1
  76. package/dist/serve/docs-ui/docs/pyric-ui-events-hooks-reference-api/index.html +1 -1
  77. package/dist/serve/docs-ui/docs/pyric-ui-events-reference-api/index.html +1 -1
  78. package/dist/serve/docs-ui/docs/pyric-ui-firestore-hooks-reference-api/index.html +1 -1
  79. package/dist/serve/docs-ui/docs/pyric-ui-firestore-reference-api/index.html +1 -1
  80. package/dist/serve/docs-ui/docs/pyric-ui-primitives-reference-api/index.html +1 -1
  81. package/dist/serve/docs-ui/docs/pyric-ui-rtdb-reference-api/index.html +1 -1
  82. package/dist/serve/docs-ui/docs/pyric-ui-rules-hooks-reference-api/index.html +1 -1
  83. package/dist/serve/docs-ui/docs/pyric-ui-rules-reference-api/index.html +1 -1
  84. package/dist/serve/docs-ui/docs/pyric-ui-storage-hooks-reference-api/index.html +1 -1
  85. package/dist/serve/docs-ui/docs/pyric-ui-storage-reference-api/index.html +1 -1
  86. package/dist/serve/docs-ui/docs/pyric-ui-traffic-hooks-reference-api/index.html +1 -1
  87. package/dist/serve/docs-ui/docs/pyric-ui-traffic-reference-api/index.html +1 -1
  88. package/dist/serve/docs-ui/docs/rules-compat/index.html +1 -1
  89. package/dist/serve/docs-ui/docs/secure/audit-your-rules/index.html +1 -1
  90. package/dist/serve/docs-ui/docs/secure/firestore-rules-limits/index.html +1 -1
  91. package/dist/serve/docs-ui/docs/secure/read-a-denial/index.html +1 -1
  92. package/dist/serve/docs-ui/docs/secure/rtdb-rules-in-typescript/index.html +1 -1
  93. package/dist/serve/docs-ui/docs/secure/rules-standard-library/index.html +1 -1
  94. package/dist/serve/docs-ui/docs/secure/secure-it-with-rules/index.html +1 -1
  95. package/dist/serve/docs-ui/docs/secure/simulate-and-lint/index.html +1 -1
  96. package/dist/serve/docs-ui/docs/secure/write-a-rules-test-suite/index.html +1 -1
  97. package/dist/serve/docs-ui/docs/ship/ship-to-production/index.html +1 -1
  98. package/dist/serve/docs-ui/docs/ship/test-in-node/index.html +1 -1
  99. package/dist/serve/docs-ui/docs/storage-compat/index.html +1 -1
  100. package/dist/serve/docs-ui/docs/trust/how-we-know-it-matches-firebase/index.html +1 -1
  101. package/dist/serve/docs-ui/docs/trust/versioning-and-compatibility/index.html +1 -1
  102. package/dist/serve/docs-ui/docs/tutorial/index.html +429 -0
  103. package/dist/serve/docs-ui/docs/tutorial.md +701 -0
  104. package/dist/serve/docs-ui/llms.txt +2 -0
  105. package/dist/serve/entries/init.d.ts.map +1 -1
  106. package/dist/serve/entries/init.js +3 -0
  107. package/dist/serve/entries/init.js.map +1 -1
  108. package/dist/serve/entries/runtime.d.ts.map +1 -1
  109. package/dist/serve/entries/runtime.js +20 -5
  110. package/dist/serve/entries/runtime.js.map +1 -1
  111. package/dist/serve/entries/worker-runtime.d.ts +2 -2
  112. package/dist/serve/entries/worker-runtime.d.ts.map +1 -1
  113. package/dist/serve/entries/worker-runtime.js +45 -10
  114. package/dist/serve/entries/worker-runtime.js.map +1 -1
  115. package/dist/serve/runtime/chip-config.d.ts +19 -0
  116. package/dist/serve/runtime/chip-config.d.ts.map +1 -0
  117. package/dist/serve/runtime/chip-config.js +19 -0
  118. package/dist/serve/runtime/chip-config.js.map +1 -0
  119. package/dist/serve/runtime/chip-install.d.ts +10 -0
  120. package/dist/serve/runtime/chip-install.d.ts.map +1 -0
  121. package/dist/serve/runtime/chip-install.js +15 -0
  122. package/dist/serve/runtime/chip-install.js.map +1 -0
  123. package/dist/serve/runtime/chip.d.ts +17 -0
  124. package/dist/serve/runtime/chip.d.ts.map +1 -0
  125. package/dist/serve/runtime/chip.js +257 -0
  126. package/dist/serve/runtime/chip.js.map +1 -0
  127. package/dist/serve/runtime/manifest.d.ts +22 -0
  128. package/dist/serve/runtime/manifest.d.ts.map +1 -0
  129. package/dist/serve/runtime/manifest.js +20 -0
  130. package/dist/serve/runtime/manifest.js.map +1 -0
  131. package/dist/serve/runtime/status.d.ts +49 -0
  132. package/dist/serve/runtime/status.d.ts.map +1 -0
  133. package/dist/serve/runtime/status.js +164 -0
  134. package/dist/serve/runtime/status.js.map +1 -0
  135. package/dist/serve/runtime/worker-connection.d.ts +4 -0
  136. package/dist/serve/runtime/worker-connection.d.ts.map +1 -0
  137. package/dist/serve/runtime/worker-connection.js +11 -0
  138. package/dist/serve/runtime/worker-connection.js.map +1 -0
  139. package/dist/serve/runtime/worker-generation.d.ts +14 -0
  140. package/dist/serve/runtime/worker-generation.d.ts.map +1 -0
  141. package/dist/serve/runtime/worker-generation.js +56 -0
  142. package/dist/serve/runtime/worker-generation.js.map +1 -0
  143. package/dist/serve/runtime/worker-replacement.d.ts +18 -0
  144. package/dist/serve/runtime/worker-replacement.d.ts.map +1 -0
  145. package/dist/serve/runtime/worker-replacement.js +29 -0
  146. package/dist/serve/runtime/worker-replacement.js.map +1 -0
  147. package/dist/serve/standalone-assets.d.ts +2 -2
  148. package/dist/serve/standalone-assets.d.ts.map +1 -1
  149. package/dist/serve/standalone-assets.js +1 -1
  150. package/dist/serve/standalone-assets.js.map +1 -1
  151. package/dist/serve/studio-ui/assets/{RulesCodeEditor-CxVYcZXw.js → RulesCodeEditor-BDlH-5lp.js} +1 -1
  152. package/dist/serve/studio-ui/assets/{index-cpSfgQ7H.js → index-Bf9SSE8L.js} +5 -5
  153. package/dist/serve/studio-ui/assets/{seed-DeuFKi4J.js → seed-D_UjxfOU.js} +1 -1
  154. package/dist/serve/studio-ui/index.html +1 -1
  155. package/dist/serve/vite-ai-config.d.ts +25 -0
  156. package/dist/serve/vite-ai-config.d.ts.map +1 -0
  157. package/dist/serve/vite-ai-config.js +45 -0
  158. package/dist/serve/vite-ai-config.js.map +1 -0
  159. package/dist/serve/vite-plugin.d.ts +17 -36
  160. package/dist/serve/vite-plugin.d.ts.map +1 -1
  161. package/dist/serve/vite-plugin.js +38 -57
  162. package/dist/serve/vite-plugin.js.map +1 -1
  163. package/dist/serve/vite-rules-source.d.ts +8 -0
  164. package/dist/serve/vite-rules-source.d.ts.map +1 -0
  165. package/dist/serve/vite-rules-source.js +23 -0
  166. package/dist/serve/vite-rules-source.js.map +1 -0
  167. package/dist/serve/vite-worker-runtime.d.ts +20 -0
  168. package/dist/serve/vite-worker-runtime.d.ts.map +1 -0
  169. package/dist/serve/vite-worker-runtime.js +25 -0
  170. package/dist/serve/vite-worker-runtime.js.map +1 -0
  171. package/dist/serve/worker/client/connection.d.ts +7 -2
  172. package/dist/serve/worker/client/connection.d.ts.map +1 -1
  173. package/dist/serve/worker/client/connection.js +13 -16
  174. package/dist/serve/worker/client/connection.js.map +1 -1
  175. package/dist/serve/worker/client/core.d.ts +7 -0
  176. package/dist/serve/worker/client/core.d.ts.map +1 -1
  177. package/dist/serve/worker/client/core.js +28 -0
  178. package/dist/serve/worker/client/core.js.map +1 -1
  179. package/dist/serve/worker/client/runtime-control.d.ts +8 -0
  180. package/dist/serve/worker/client/runtime-control.d.ts.map +1 -0
  181. package/dist/serve/worker/client/runtime-control.js +11 -0
  182. package/dist/serve/worker/client/runtime-control.js.map +1 -0
  183. package/dist/serve/worker/entry.js +44 -2
  184. package/dist/serve/worker/entry.js.map +1 -1
  185. package/dist/serve/worker/host/studio.js +1 -1
  186. package/dist/serve/worker/host/studio.js.map +1 -1
  187. package/dist/serve/worker/host-context.d.ts +1 -1
  188. package/dist/serve/worker/host-context.d.ts.map +1 -1
  189. package/dist/serve/worker/protocol.d.ts +15 -1
  190. package/dist/serve/worker/protocol.d.ts.map +1 -1
  191. package/dist/serve/worker/protocol.js.map +1 -1
  192. package/dist/serve/worker/retirement.d.ts +21 -0
  193. package/dist/serve/worker/retirement.d.ts.map +1 -0
  194. package/dist/serve/worker/retirement.js +97 -0
  195. package/dist/serve/worker/retirement.js.map +1 -0
  196. package/dist/serve/worker/serve-init.d.ts.map +1 -1
  197. package/dist/serve/worker/serve-init.js +5 -6
  198. package/dist/serve/worker/serve-init.js.map +1 -1
  199. package/dist/vite.d.ts +1 -0
  200. package/dist/vite.d.ts.map +1 -1
  201. package/dist/vite.js.map +1 -1
  202. package/package.json +5 -4
  203. package/src/cli/serve.ts +4 -4
  204. package/src/serve/bundler.ts +41 -15
  205. package/src/serve/entries/init.ts +3 -0
  206. package/src/serve/entries/runtime.ts +23 -7
  207. package/src/serve/entries/worker-runtime.ts +69 -9
  208. package/src/serve/runtime/chip-config.ts +30 -0
  209. package/src/serve/runtime/chip-install.ts +27 -0
  210. package/src/serve/runtime/chip.ts +282 -0
  211. package/src/serve/runtime/manifest.ts +37 -0
  212. package/src/serve/runtime/status.ts +216 -0
  213. package/src/serve/runtime/worker-connection.ts +14 -0
  214. package/src/serve/runtime/worker-generation.ts +75 -0
  215. package/src/serve/runtime/worker-replacement.ts +42 -0
  216. package/src/serve/standalone-assets.ts +2 -2
  217. package/src/serve/vite-ai-config.ts +77 -0
  218. package/src/serve/vite-plugin.ts +60 -87
  219. package/src/serve/vite-rules-source.ts +28 -0
  220. package/src/serve/vite-worker-runtime.ts +51 -0
  221. package/src/serve/worker/client/connection.ts +29 -4
  222. package/src/serve/worker/client/core.ts +34 -0
  223. package/src/serve/worker/client/runtime-control.ts +22 -0
  224. package/src/serve/worker/entry.ts +46 -2
  225. package/src/serve/worker/host/studio.ts +1 -1
  226. package/src/serve/worker/host-context.ts +1 -1
  227. package/src/serve/worker/protocol.ts +13 -1
  228. package/src/serve/worker/retirement.ts +125 -0
  229. package/src/serve/worker/serve-init.ts +5 -6
  230. package/src/vite.ts +1 -0
@@ -41,7 +41,6 @@
41
41
  */
42
42
  import { readFile } from 'node:fs/promises';
43
43
  import { existsSync, readFileSync, rmSync, writeFileSync, mkdirSync } from 'node:fs';
44
- import { homedir } from 'node:os';
45
44
  import path from 'node:path';
46
45
  import type { IncomingMessage, ServerResponse, Server as HttpServer } from 'node:http';
47
46
  import type { Plugin, UserConfig, ConfigEnv } from 'vite';
@@ -51,19 +50,16 @@ import {
51
50
  defaultSdkEntries,
52
51
  resolveStudioUiDir,
53
52
  pyricPackageRoot,
54
- bundleWorker,
55
- workerSourceHash,
56
53
  NODE_BUILTIN_RE,
57
54
  NODE_BUILTIN_SHIMS,
58
55
  } from './bundler.js';
56
+ import { createViteWorkerRuntime } from './vite-worker-runtime.js';
59
57
  import {
60
58
  createEventHub,
61
59
  createPyricNamespace,
62
60
  type InitPayload,
63
61
  } from './namespace.js';
64
62
  import { formatActivityWarning } from './activity-warning.js';
65
- import type { AiEngineConfigWire } from './worker/protocol.js';
66
- import type { AIOptions } from 'pyric/ai';
67
63
  import { diskWorkspace, diskProjectStore } from './studio/index.js';
68
64
  import { createBridgeMount } from './bridge-mount.js';
69
65
  import {
@@ -91,6 +87,17 @@ import {
91
87
  createLinePrefixer,
92
88
  registerModuleUrl,
93
89
  } from '../cli/dev-runner.js';
90
+ import {
91
+ loadViteAiEnv,
92
+ resolveViteAiConfig,
93
+ type PyricAiOptions,
94
+ } from './vite-ai-config.js';
95
+ import { resolveViteRulesConfig } from './vite-rules-source.js';
96
+ import {
97
+ PYRIC_RUNTIME_CHIP_META,
98
+ runtimeChipMetaValue,
99
+ type PyricRuntimeChipOption,
100
+ } from './runtime/chip-config.js';
94
101
 
95
102
  /**
96
103
  * Whether a `vite build` should run the firebase→pyric swap (produce a SANDBOX
@@ -109,41 +116,6 @@ function swapsInBuild(env: ConfigEnv, swapInBuild: boolean | undefined): boolean
109
116
  /** Any `firebase/<sub>` specifier. */
110
117
  const FB_ANY = /^firebase\/([a-z-]+(?:\/[a-z-]+)*)$/;
111
118
 
112
- /** The serve proxy route the browser openai engine defaults to (#98.2). */
113
- const AI_PROXY_PATH = '/__pyric/ai-proxy';
114
-
115
- /**
116
- * The plugin-level engine config surface = `pyric/ai`'s `EngineConfig` (what an
117
- * app passes to `getAI`), minus the custom `AnswerEngine` object variant — a
118
- * live object cannot cross to the SharedWorker host or serialize into the page,
119
- * so the plugin's declarative surface is the JSON-able `scripted` / `openai`
120
- * configs only (the same restriction the worker path already enforces).
121
- */
122
- export type PyricAiEngineConfig = Extract<NonNullable<AIOptions['engine']>, { kind: string }>;
123
-
124
- /**
125
- * Node-side `EngineConfig` → JSON-safe {@link AiEngineConfigWire}. Mirrors the
126
- * browser `toEngineWire` (entries/ai.ts): an openai config with no `baseUrl`
127
- * targets the same-origin proxy; scripted `script` entries pass through (plain
128
- * authoring shapes — function/RegExp matchers can't survive JSON and are not a
129
- * plugin-config use case).
130
- */
131
- export function engineConfigToWire(engine: PyricAiEngineConfig): AiEngineConfigWire {
132
- if (engine.kind === 'openai') {
133
- return {
134
- kind: 'openai',
135
- baseUrl: engine.baseUrl ?? AI_PROXY_PATH,
136
- ...(engine.model !== undefined ? { model: engine.model } : {}),
137
- ...(engine.modelMap !== undefined ? { modelMap: engine.modelMap } : {}),
138
- };
139
- }
140
- return {
141
- kind: 'scripted',
142
- ...(engine.script !== undefined
143
- ? { script: engine.script as unknown as Array<Record<string, unknown>> }
144
- : {}),
145
- };
146
- }
147
119
  /** The firebase subpaths with swap entries. */
148
120
  const SERVED = new Set(SDK_MODULES.map((specifier) => specifier.slice('firebase/'.length)));
149
121
  const entryKey = (subpath: string): string => subpath.replaceAll('/', '-');
@@ -159,8 +131,9 @@ function packageRootOf(file: string): string {
159
131
  }
160
132
 
161
133
  export interface PyricOptions {
162
- /** firestore.rules path (relative to `root`). Default: `firebase.json`'s
163
- * `firestore.rules`, else `firestore.rules` in the project root. */
134
+ /** Firestore rules path (relative to `root`). Default discovery prefers an
135
+ * authored `firestore.modules.rules`, then `firebase.json`, then
136
+ * `firestore.rules` in the project root. */
164
137
  rules?: string;
165
138
  /** Project dir for `firebase.json` / rules discovery. Default: Vite's `root`. */
166
139
  root?: string;
@@ -193,6 +166,10 @@ export interface PyricOptions {
193
166
  * so app, Studio, and agent all observe the one sandbox); pass `ui: false`
194
167
  * to disable. */
195
168
  ui?: boolean;
169
+ /** Inject the collapsed Pyric runtime chip into the app during sandbox Vite
170
+ * dev/builds. On by default. Pass `false` to hide it, or
171
+ * `{ initiallyOpen: true }` when actively debugging runtime errors. */
172
+ runtimeChip?: PyricRuntimeChipOption;
196
173
  /** RTDB-triggered Cloud Functions under this dev server (the `pyric dev`
197
174
  * parity fold). By default a `functions` block in `firebase.json` is
198
175
  * discovered automatically: its `onValueCreated` triggers run in an isolated
@@ -229,11 +206,14 @@ export interface PyricOptions {
229
206
  *
230
207
  * pyric({
231
208
  * ai: {
232
- * engine: { kind: 'openai', model: 'llama3.2', baseUrl: '/__pyric/ai-proxy' },
209
+ * model: 'llama3.2',
233
210
  * proxyUpstream: 'http://localhost:11434/v1', // your Ollama
234
211
  * },
235
212
  * })
236
213
  *
214
+ * - `model` is the simple OpenAI-compatible path. It uses the same-origin
215
+ * proxy and becomes the catch-all upstream model. `PYRIC_AI_MODEL` selects
216
+ * the same path when neither `model` nor `engine` is explicit.
237
217
  * - `engine` is `pyric/ai`'s `EngineConfig` (scripted | openai), applied on
238
218
  * both the SharedWorker and in-page paths. An openai `baseUrl` of
239
219
  * `/__pyric/ai-proxy` (or omitted) routes through the same-origin proxy so a
@@ -241,20 +221,11 @@ export interface PyricOptions {
241
221
  * - `proxyUpstream` sets what `/__pyric/ai-proxy` forwards to (beats the
242
222
  * `PYRIC_AI_PROXY_UPSTREAM` env var; default `http://localhost:11434/v1`).
243
223
  *
244
- * Precedence: a plugin-level `engine`, when set, always wins over an engine an
245
- * app's own `getAI()` passes (host-side via `ctx.aiEngine` on the worker path,
246
- * page-side via an injected global on the in-page fallback); with no plugin
247
- * engine the first `getAI()` call's engine is honored (first-call-wins), and
248
- * with neither the zero-config scripted default applies.
224
+ * Precedence: explicit `engine` or `model`, then `PYRIC_AI_MODEL`, then an
225
+ * engine passed by the app's first `getAI()` call; with none, the zero-config
226
+ * scripted default applies. `model` and `engine` are mutually exclusive.
249
227
  */
250
- ai?: {
251
- /** Engine config (scripted | openai). Custom `AnswerEngine` objects are not
252
- * supported at the plugin level — they can't cross to the worker/page. */
253
- engine?: PyricAiEngineConfig;
254
- /** OpenAI-compatible upstream `/__pyric/ai-proxy` forwards to. Beats
255
- * `PYRIC_AI_PROXY_UPSTREAM`; default `http://localhost:11434/v1`. */
256
- proxyUpstream?: string;
257
- };
228
+ ai?: PyricAiOptions;
258
229
  }
259
230
 
260
231
  /**
@@ -264,6 +235,7 @@ export interface PyricOptions {
264
235
  * export default defineConfig({ plugins: [pyric()] });
265
236
  */
266
237
  export function pyric(options: PyricOptions = {}): Plugin {
238
+ let resolvedAi = resolveViteAiConfig(options.ai, {});
267
239
  // Resolved once. `defaultSdkEntries()` prefers compiled dist `.js` and falls
268
240
  // back to source `.ts` in the workspace.
269
241
  const entries = defaultSdkEntries(); // { app, auth, firestore, init } → abs paths
@@ -335,11 +307,10 @@ export function pyric(options: PyricOptions = {}): Plugin {
335
307
  };
336
308
 
337
309
  // M2: the SharedWorker bundle's content hash (sync) — stamped into the page so
338
- // a still-running OLD worker is detected as stale. `workerReady` flips true once
339
- // `bundleWorker` succeeds in configureServer; until then (or on bundle failure)
340
- // the page is forced onto the in-page sandbox path. transformIndexHtml reads it.
341
- const workerVersion = workerSourceHash();
342
- let workerReady = false;
310
+ // a still-running OLD worker is detected as stale. The collaborator becomes
311
+ // ready once its bundle succeeds; until then (or on bundle failure) the page
312
+ // is forced onto the in-page sandbox path. transformIndexHtml reads its tag.
313
+ const workerRuntime = createViteWorkerRuntime();
343
314
 
344
315
  // Set by the `config` hook. When the plugin runs under `vite build` at all it
345
316
  // is a SANDBOX build (the `apply` gate below only lets build through under the
@@ -364,12 +335,12 @@ export function pyric(options: PyricOptions = {}): Plugin {
364
335
  // so multi-tab is disabled under bridge to keep agent + app on one backend.
365
336
  const bridgeOpts = options.bridge === true ? {} : options.bridge || null;
366
337
 
367
- // Plugin-level AI engine, normalized once to the JSON-safe wire shape. Travels
338
+ // Plugin-level AI engine, normalized to the JSON-safe wire shape. Travels
368
339
  // to the worker host via the init payload (→ ctx.aiEngine) AND to the in-page
369
- // fallback via an injected synchronous global (see transformIndexHtml). Undefined
370
- // when the `ai.engine` option is unset.
371
- const aiEngineWire = options.ai?.engine ? engineConfigToWire(options.ai.engine) : undefined;
372
-
340
+ // fallback via an injected synchronous global (see transformIndexHtml).
341
+ // An explicit engine is available immediately (some hook tests call the HTML
342
+ // transform directly); Vite's config hook may otherwise select the simple
343
+ // PYRIC_AI_MODEL environment path for the active mode/root.
373
344
  return {
374
345
  name: 'pyric:sandbox',
375
346
  // Active for `vite dev` ALWAYS, and for `vite build` only when it is a
@@ -378,14 +349,17 @@ export function pyric(options: PyricOptions = {}): Plugin {
378
349
  // production output. A sandbox build applies the same swap so the output
379
350
  // bundles pyric's in-page adapters (self-contained; preview it under
380
351
  // `pyric dev`, never deploy it).
381
- apply(_config, env) {
352
+ apply(config, env) {
353
+ void config;
382
354
  if (env.command === 'serve') return true;
383
355
  return swapsInBuild(env, options.swapInBuild);
384
356
  },
385
357
  enforce: 'pre',
386
358
 
387
- config(_config, env) {
359
+ config(config, env) {
388
360
  sandboxBuild = env.command === 'build';
361
+ const loadedEnv = loadViteAiEnv(env.mode, config.root, config.envDir);
362
+ resolvedAi = resolveViteAiConfig(options.ai, loadedEnv);
389
363
  // Cast: the `esbuild` package's `Plugin` type skews slightly from Vite's
390
364
  // bundled esbuild types (benign — the Plugin shape is stable across the
391
365
  // versions in range).
@@ -457,10 +431,11 @@ export function pyric(options: PyricOptions = {}): Plugin {
457
431
  } catch {
458
432
  /* optional — serve without a firebase.json */
459
433
  }
460
- // Honor an explicit `rules` option by overriding the resolved config.
461
- const config: FirebaseJson | null = options.rules
462
- ? { ...(fbJson ?? {}), firestore: { ...(fbJson?.firestore ?? {}), rules: options.rules } }
463
- : fbJson;
434
+ // Convention-first development source: an explicit option wins; otherwise
435
+ // an authored 2+modules file wins over firebase.json's generated deployment
436
+ // target. Projects without that convention retain the normal Firebase
437
+ // discovery path.
438
+ const config = resolveViteRulesConfig(cwd, options.rules, fbJson);
464
439
  const loaded = await loadProjectRules(cwd, config);
465
440
  const loadedDatabase = await loadProjectDatabaseRules(cwd, config);
466
441
  const loadedStorage = await loadProjectStorageRules(cwd, config);
@@ -530,12 +505,10 @@ export function pyric(options: PyricOptions = {}): Plugin {
530
505
 
531
506
  // ── M2 SharedWorker host: bundle it (cached per version) and serve it at
532
507
  // /__pyric/sdk/worker.js. This is what flips runtime.ts to the worker path.
533
- // On bundle failure, fall back to the in-page sandbox (workerReady stays
534
- // false transformIndexHtml forces in-page).
535
- const sdkDir = path.join(homedir(), '.pyric', 'vite-worker', workerVersion);
508
+ // On bundle failure, the collaborator stays unready and its HTML tag
509
+ // forces the in-page sandbox.
536
510
  try {
537
- await bundleWorker({ outDir: sdkDir });
538
- workerReady = true;
511
+ await workerRuntime.prepare();
539
512
  } catch (e) {
540
513
  server.config.logger.warn(
541
514
  ` ⚠ [pyric] SharedWorker bundle failed — using the in-page sandbox (single-tab, ephemeral): ${e instanceof Error ? e.message : String(e)}`,
@@ -629,7 +602,7 @@ export function pyric(options: PyricOptions = {}): Plugin {
629
602
  messaging: true,
630
603
  // Plugin-level engine → the worker host's ctx.aiEngine (host-ai.ts),
631
604
  // which wins over any op-carried engine. Null when unset.
632
- ai: aiEngineWire ? { engine: aiEngineWire } : null,
605
+ ai: resolvedAi.engineWire ? { engine: resolvedAi.engineWire } : null,
633
606
  });
634
607
  // Pyric Studio: mount the disk-backed workspace/project routes that
635
608
  // Studio's `local` mode talks to + serve the built Studio app at
@@ -657,6 +630,7 @@ export function pyric(options: PyricOptions = {}): Plugin {
657
630
  );
658
631
  }
659
632
  }
633
+ const { sdkDir } = workerRuntime.status();
660
634
  const namespace = createPyricNamespace({
661
635
  sdkDir,
662
636
  initPayload,
@@ -668,7 +642,7 @@ export function pyric(options: PyricOptions = {}): Plugin {
668
642
  studioUiDir,
669
643
  // `ai.proxyUpstream`: what `/__pyric/ai-proxy` forwards to (beats the
670
644
  // PYRIC_AI_PROXY_UPSTREAM env var; falls back to the default when unset).
671
- aiProxyUpstream: options.ai?.proxyUpstream,
645
+ aiProxyUpstream: resolvedAi.proxyUpstream,
672
646
  // Adapt Vite's logger to the plain info/note shape the namespace's
673
647
  // diagnostics (denial relay, future hot-reload lines) expect —
674
648
  // matches the `↻`/`⚠ [pyric]` lines already logged elsewhere in this
@@ -1047,6 +1021,7 @@ export function pyric(options: PyricOptions = {}): Plugin {
1047
1021
  },
1048
1022
 
1049
1023
  transformIndexHtml(html) {
1024
+ const runtimeChipTag = `<meta name="${PYRIC_RUNTIME_CHIP_META}" content="${runtimeChipMetaValue(options.runtimeChip)}" data-studio="${options.ui === false ? 'off' : 'on'}" data-pyric-sandbox>`;
1050
1025
  // Sandbox BUILD: the app's own `firebase/*` imports were already swapped
1051
1026
  // (resolveId, above) to pyric's in-page adapters and BUNDLED into the app
1052
1027
  // chunk, and the emitted init chunk (script-tagged here) carries the
@@ -1063,7 +1038,7 @@ export function pyric(options: PyricOptions = {}): Plugin {
1063
1038
  const initTag = initChunkFile
1064
1039
  ? `<script type="module" crossorigin src="/${initChunkFile}" data-pyric-sandbox-init></script>`
1065
1040
  : '';
1066
- const tags = SANDBOX_BUILD_META + initTag;
1041
+ const tags = SANDBOX_BUILD_META + runtimeChipTag + initTag;
1067
1042
  return html.includes('</head>')
1068
1043
  ? html.replace('</head>', `${tags}</head>`)
1069
1044
  : tags + html;
@@ -1076,26 +1051,24 @@ export function pyric(options: PyricOptions = {}): Plugin {
1076
1051
  // module evaluates — a classic inline script runs before the deferred module.
1077
1052
  // The flag (not nulling `window.SharedWorker`) leaves the user's own
1078
1053
  // SharedWorker usage intact. We force in-page ONLY when the worker bundle
1079
- // failed (workerReady false) — the ephemeral fallback. `bridge` no longer
1054
+ // failed (worker runtime unready) — the ephemeral fallback. `bridge` no longer
1080
1055
  // forces in-page: the bridge peer routes agent tool-calls THROUGH the
1081
1056
  // worker (see `connectBridgePeer`), so the agent shares the one sandbox the
1082
1057
  // app + Studio use.
1083
- const head = workerReady
1084
- ? `<meta name="pyric-worker-v" content="${workerVersion}" ${MARKER}>`
1085
- : `<script ${MARKER}>globalThis.__PYRIC_FORCE_INPAGE__=true;</script>`;
1058
+ const head = workerRuntime.headTag(MARKER);
1086
1059
  // Plugin-level engine for the IN-PAGE path: a classic inline script runs
1087
1060
  // before the deferred init module AND before app code's `getAI`, so the
1088
1061
  // served `getAI` (entries/ai.ts) reads it synchronously — init.json can't
1089
1062
  // be awaited there. Harmless on the worker path (that branch ignores the
1090
1063
  // global; the worker reads ctx.aiEngine from init.json). `<` is escaped so
1091
1064
  // an engine value can never break out of the script tag.
1092
- const aiEngineTag = aiEngineWire
1093
- ? `<script ${MARKER}>globalThis.__PYRIC_AI_ENGINE__=${JSON.stringify(aiEngineWire).replace(/</g, '\\u003c')};</script>`
1065
+ const aiEngineTag = resolvedAi.engineWire
1066
+ ? `<script ${MARKER}>globalThis.__PYRIC_AI_ENGINE__=${JSON.stringify(resolvedAi.engineWire).replace(/</g, '\\u003c')};</script>`
1094
1067
  : '';
1095
1068
  // Boot the sandbox by loading the real init entry as a module (Vite
1096
1069
  // serves + transforms it). The init module's top-level await deploys rules
1097
1070
  // before app code runs. Mirrors serve's injectServeTags.
1098
- const tag = head + aiEngineTag + `<script type="module" src="/@fs/${entries.init}" ${MARKER}></script>`;
1071
+ const tag = head + aiEngineTag + runtimeChipTag + `<script type="module" src="/@fs/${entries.init}" ${MARKER}></script>`;
1099
1072
  return html.includes('</head>') ? html.replace('</head>', `${tag}</head>`) : tag + html;
1100
1073
  },
1101
1074
  };
@@ -0,0 +1,28 @@
1
+ import { existsSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import type { FirebaseJson } from '../cli/firebase-json.js';
4
+
5
+ /**
6
+ * Apply the Vite authoring convention without replacing unrelated Firebase
7
+ * configuration. The normal rules loader retains responsibility for its
8
+ * `firebase.json` and root `firestore.rules` fallbacks.
9
+ */
10
+ export function resolveViteRulesConfig(
11
+ root: string,
12
+ explicitRules: string | undefined,
13
+ firebaseConfig: FirebaseJson | null,
14
+ ): FirebaseJson | null {
15
+ const rulesSource = explicitRules
16
+ ?? (existsSync(path.join(root, 'firestore.modules.rules'))
17
+ ? 'firestore.modules.rules'
18
+ : undefined);
19
+
20
+ if (!rulesSource) return firebaseConfig;
21
+ return {
22
+ ...(firebaseConfig ?? {}),
23
+ firestore: {
24
+ ...(firebaseConfig?.firestore ?? {}),
25
+ rules: rulesSource,
26
+ },
27
+ };
28
+ }
@@ -0,0 +1,51 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+ import {
4
+ bundleWorker,
5
+ workerSourceHash,
6
+ type WorkerBundleOptions,
7
+ type WorkerBundleResult,
8
+ } from './bundler.js';
9
+
10
+ export interface ViteWorkerRuntimeStatus {
11
+ sdkDir: string;
12
+ ready: boolean;
13
+ epoch: string | null;
14
+ }
15
+
16
+ export interface ViteWorkerRuntime {
17
+ prepare(): Promise<void>;
18
+ status(): ViteWorkerRuntimeStatus;
19
+ headTag(marker: string): string;
20
+ }
21
+
22
+ interface ViteWorkerRuntimeOptions {
23
+ cacheRoot?: string;
24
+ cacheKey?: string;
25
+ bundle?: (options: WorkerBundleOptions) => Promise<WorkerBundleResult>;
26
+ }
27
+
28
+ /** Own the Vite plugin's worker build state and its one HTML projection. */
29
+ export function createViteWorkerRuntime(
30
+ options: ViteWorkerRuntimeOptions = {},
31
+ ): ViteWorkerRuntime {
32
+ const cacheRoot = options.cacheRoot ?? join(homedir(), '.pyric', 'vite-worker');
33
+ const cacheKey = options.cacheKey ?? workerSourceHash();
34
+ const sdkDir = join(cacheRoot, cacheKey);
35
+ const bundle = options.bundle ?? bundleWorker;
36
+ let current: ViteWorkerRuntimeStatus = { sdkDir, ready: false, epoch: null };
37
+
38
+ return {
39
+ async prepare() {
40
+ current = { sdkDir, ready: false, epoch: null };
41
+ const result = await bundle({ outDir: sdkDir });
42
+ current = { sdkDir, ready: true, epoch: result.epoch };
43
+ },
44
+ status: () => current,
45
+ headTag(marker) {
46
+ return current.ready && current.epoch
47
+ ? `<meta name="pyric-worker-v" content="${current.epoch}" ${marker}>`
48
+ : `<script ${marker}>globalThis.__PYRIC_FORCE_INPAGE__=true;</script>`;
49
+ },
50
+ };
51
+ }
@@ -11,6 +11,7 @@ import {
11
11
  nextSubId,
12
12
  wirePort,
13
13
  rpc,
14
+ rpcWithTimeout,
14
15
  rawRpc,
15
16
  openSnapshotSubscription,
16
17
  closeSubscription,
@@ -29,7 +30,15 @@ import type { ClientDb } from './handles.js';
29
30
  * `getFirestore` mirrors `pyric/firestore`'s `getFirestore(sandbox)` shape
30
31
  * but returns a `ClientDb` backed by a `MessagePort` instead of a sandbox.
31
32
  */
32
- export function getFirestore(workerUrl: string | URL, name?: string): ClientDb {
33
+ export interface SharedWorkerConnectionOptions {
34
+ onError?: (error: Error) => void;
35
+ }
36
+
37
+ export function getFirestore(
38
+ workerUrl: string | URL,
39
+ name?: string,
40
+ options: SharedWorkerConnectionOptions = {},
41
+ ): ClientDb {
33
42
  if (typeof SharedWorker === 'undefined') {
34
43
  throw new Error(
35
44
  'SharedWorker is not available. ' +
@@ -41,9 +50,15 @@ export function getFirestore(workerUrl: string | URL, name?: string): ClientDb {
41
50
  type: 'classic',
42
51
  name: name ?? 'pyric-shared-worker',
43
52
  });
53
+ if (options.onError) {
54
+ worker.addEventListener('error', (event) => {
55
+ const detail = event.message || `failed to load ${String(workerUrl)}`;
56
+ options.onError?.(new Error(`Pyric SharedWorker error: ${detail}`));
57
+ });
58
+ }
44
59
  const port = worker.port;
45
- port.start();
46
60
  wirePort(port);
61
+ port.start();
47
62
  return { __kind: 'client-db', port };
48
63
  }
49
64
 
@@ -52,8 +67,18 @@ export function getFirestore(workerUrl: string | URL, name?: string): ClientDb {
52
67
  * compares it to the served bundle version and warns when a still-running OLD
53
68
  * worker is older than what's served (a SharedWorker can't hot-update).
54
69
  */
55
- export async function getWorkerVersion(db: ClientDb): Promise<string> {
56
- const r = (await rpc(db.port, { t: 'op', id: nextId(), method: 'getVersion' })) as { version: string };
70
+ export async function getWorkerVersion(
71
+ db: ClientDb,
72
+ options: { timeoutMs?: number } = {},
73
+ ): Promise<string> {
74
+ const id = nextId();
75
+ const timeoutMs = options.timeoutMs ?? 2_000;
76
+ const r = (await rpcWithTimeout(
77
+ db.port,
78
+ { t: 'op', id, method: 'getRuntimeEpoch' },
79
+ timeoutMs,
80
+ `Timed out waiting for the Pyric SharedWorker version handshake after ${timeoutMs}ms.`,
81
+ )) as { version: string };
57
82
  return r.version;
58
83
  }
59
84
 
@@ -10,6 +10,7 @@
10
10
  * they did when all of this lived inline in `client.ts`.
11
11
  */
12
12
  import type { InboundMessage, OutboundMessage } from '../protocol.js';
13
+ import type { RuntimeReloadMessage } from '../protocol.js';
13
14
  // TYPE-ONLY — the auth-lens contract + the cross-service event envelope, shared
14
15
  // with the worker host + the sandbox's event provenance. Erased at build, so the
15
16
  // leaf client bundle stays engine-free.
@@ -58,6 +59,14 @@ export const _eventSubs = new Map<string, {
58
59
  next: (events: readonly SandboxEvent[]) => void;
59
60
  }>();
60
61
  const disconnectedPorts = new WeakSet<ClientPort>();
62
+ const runtimeReloadListeners = new Set<(message: RuntimeReloadMessage) => void>();
63
+
64
+ export function subscribeRuntimeReload(
65
+ listener: (message: RuntimeReloadMessage) => void,
66
+ ): () => void {
67
+ runtimeReloadListeners.add(listener);
68
+ return () => runtimeReloadListeners.delete(listener);
69
+ }
61
70
 
62
71
  function appDeletedError(): Error & { code: string } {
63
72
  return Object.assign(new Error('Firebase App was deleted'), { code: 'app/app-deleted' });
@@ -231,6 +240,8 @@ export function wirePort(port: ClientPort): void {
231
240
  // no rehydration. Deliver the whole batch to the registered subscriber.
232
241
  const subscription = _eventSubs.get(msg.subId);
233
242
  if (subscription) subscription.next(msg.events);
243
+ } else if (msg.t === 'runtime-reload') {
244
+ for (const listener of runtimeReloadListeners) listener(msg);
234
245
  }
235
246
  };
236
247
  }
@@ -330,6 +341,29 @@ export function rpc(port: ClientPort, msg: InboundMessage): Promise<unknown> {
330
341
  return rawRpc(port, stampIssuer(msg));
331
342
  }
332
343
 
344
+ /**
345
+ * Send a client RPC with a bounded wait, while keeping correlation cleanup
346
+ * inside the transport module that owns the pending-request registry.
347
+ */
348
+ export function rpcWithTimeout(
349
+ port: ClientPort,
350
+ msg: InboundMessage,
351
+ timeoutMs: number,
352
+ timeoutMessage: string,
353
+ ): Promise<unknown> {
354
+ const id = (msg as { id: string }).id;
355
+ let timer: ReturnType<typeof setTimeout> | undefined;
356
+ const timeout = new Promise<never>((_, reject) => {
357
+ timer = setTimeout(() => {
358
+ if (!_pending.delete(id)) return;
359
+ reject(new Error(timeoutMessage));
360
+ }, timeoutMs);
361
+ });
362
+ return Promise.race([rpc(port, msg), timeout]).finally(() => {
363
+ if (timer) clearTimeout(timer);
364
+ });
365
+ }
366
+
333
367
  /**
334
368
  * Like {@link rpc} but stamps the active default auth lens onto the op message
335
369
  * (Pyric Studio). Used by data-service ops so a `setLens(...)` choice
@@ -0,0 +1,22 @@
1
+ import { nextId, rpcWithTimeout, subscribeRuntimeReload } from './core.js';
2
+ import type { ClientDb } from './handles.js';
3
+
4
+ /** Ask the current SharedWorker to drain accepted work and retire. */
5
+ export async function retireWorkerRuntime(
6
+ db: ClientDb,
7
+ targetEpoch: string,
8
+ options: { timeoutMs?: number } = {},
9
+ ): Promise<void> {
10
+ const timeoutMs = options.timeoutMs ?? 12_000;
11
+ await rpcWithTimeout(
12
+ db.port,
13
+ { t: 'op', id: nextId(), method: 'retireRuntime', targetEpoch },
14
+ timeoutMs,
15
+ `Timed out waiting for the Pyric worker to retire after ${timeoutMs}ms.`,
16
+ );
17
+ }
18
+
19
+ /** Observe the worker's all-pages reload signal. */
20
+ export function onWorkerRuntimeReload(listener: (epoch: string) => void): () => void {
21
+ return subscribeRuntimeReload((message) => listener(message.epoch));
22
+ }
@@ -48,6 +48,7 @@
48
48
  // without enabling the full webworker lib.
49
49
  interface SharedWorkerGlobalScope {
50
50
  onconnect: ((e: MessageEvent) => void) | null;
51
+ close(): void;
51
52
  }
52
53
 
53
54
  import { createIndexedDBBackend } from 'pyric/sandbox';
@@ -65,6 +66,17 @@ import {
65
66
  type ServiceWorkerChannelMessage,
66
67
  } from './service-worker-channel.js';
67
68
  import { createServiceWorkerRelay } from './service-worker-relay.js';
69
+ import { createWorkerRetirement } from './retirement.js';
70
+
71
+ declare const __PYRIC_WORKER_VERSION__: string;
72
+ const workerEpoch = typeof __PYRIC_WORKER_VERSION__ !== 'undefined'
73
+ ? __PYRIC_WORKER_VERSION__
74
+ : 'dev';
75
+ const workerScope = self as unknown as SharedWorkerGlobalScope;
76
+ const retirement = createWorkerRetirement({
77
+ closeWorker: () => workerScope.close(),
78
+ beforeAnnounce: async () => { await _ctx?.captureFlush?.(); },
79
+ });
68
80
 
69
81
  // ─── Singleton context ────────────────────────────────────────────────────
70
82
 
@@ -125,12 +137,37 @@ async function buildCtx(): Promise<HostCtx> {
125
137
  * Each tab gets its own `MessagePort`; we start() it (required for
126
138
  * classic-mode workers to un-pause the message queue) and wire onmessage.
127
139
  */
128
- (self as unknown as SharedWorkerGlobalScope).onconnect = (e: MessageEvent) => {
140
+ workerScope.onconnect = (e: MessageEvent) => {
129
141
  const port = e.ports[0];
130
142
  port.start();
143
+ retirement.connect(port);
131
144
 
132
145
  let messageQueue = Promise.resolve();
133
146
  port.onmessage = (ev: MessageEvent<InboundMessage>) => {
147
+ if (ev.data.t === 'op' && ev.data.method === 'getRuntimeEpoch') {
148
+ port.postMessage({
149
+ t: 'res',
150
+ id: ev.data.id,
151
+ ok: true,
152
+ value: {
153
+ version: workerEpoch,
154
+ },
155
+ });
156
+ return;
157
+ }
158
+ if (ev.data.t === 'op' && ev.data.method === 'retireRuntime') {
159
+ void retirement.retire(port, ev.data.id, ev.data.targetEpoch);
160
+ return;
161
+ }
162
+ if (!retirement.accepting()) {
163
+ if ('id' in ev.data) {
164
+ port.postMessage({
165
+ t: 'res', id: ev.data.id, ok: false,
166
+ error: { code: 'pyric/worker-retiring', message: 'The Pyric worker is restarting.' },
167
+ });
168
+ }
169
+ return;
170
+ }
134
171
  // Serialize each port's frames. A disconnect acknowledgement therefore
135
172
  // cannot overtake an already-posted mutation, and later frames see the
136
173
  // disconnected-port tombstone instead of touching the shared backend.
@@ -143,12 +180,14 @@ async function buildCtx(): Promise<HostCtx> {
143
180
  console.error('[pyric worker] message handler error:', (e as Error)?.stack ?? e, 'msg:', ev.data);
144
181
  }
145
182
  });
183
+ retirement.track(port, messageQueue);
146
184
  };
147
185
 
148
186
  // Best-effort port cleanup on tab close/navigation.
149
187
  // Chrome doesn't reliably fire 'close' on MessagePort — handled as
150
188
  // best-effort; subscriptions also GC when the worker itself dies.
151
189
  port.addEventListener('close', () => {
190
+ retirement.disconnect(port);
152
191
  if (_ctx) cleanupPort(_ctx, port as unknown as PortLike);
153
192
  });
154
193
  };
@@ -174,6 +213,11 @@ if (typeof BroadcastChannel !== 'undefined') {
174
213
  channel.onmessage = (event: MessageEvent<ServiceWorkerChannelMessage>) => {
175
214
  const envelope = event.data;
176
215
  if (envelope.direction !== 'host') return;
177
- void relay.handle(envelope);
216
+ // A generation-targeted replacement can briefly overlap this old worker
217
+ // with its successor. Only the accepting generation may answer the shared
218
+ // service-worker relay channel, otherwise both workers would reply.
219
+ if (!retirement.accepting()) return;
220
+ const work = relay.handle(envelope);
221
+ retirement.trackDetached(work);
178
222
  };
179
223
  }
@@ -60,7 +60,7 @@ export async function handleStudioOp(
60
60
  // reset just emptied `sandbox.history()`, and waiting out the
61
61
  // capture debounce leaves a window where a worker death resurrects
62
62
  // the wiped session's events on the next boot.
63
- ctx.captureFlush?.();
63
+ await ctx.captureFlush?.();
64
64
  ok(port, msg.id, { errors });
65
65
  } catch (e) {
66
66
  fail(port, msg.id, e instanceof Error ? e : new Error(String(e)));
@@ -169,7 +169,7 @@ export interface HostCtx {
169
169
  * worker would otherwise re-prime via `hydrateEventHistory` (issue #359
170
170
  * extension). Absent when capture is off.
171
171
  */
172
- captureFlush?: () => void;
172
+ captureFlush?: () => Promise<void>;
173
173
  /**
174
174
  * Per-uid impersonation Firestore handles (Pyric Studio auth lens, T2).
175
175
  * Keyed by the impersonated uid. Each is a FROZEN-identity