@pyric/cli 0.1.0-alpha.12 → 0.1.0-alpha.14

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 +5 -5
  2. package/dist/cli/serve.js.map +1 -1
  3. package/dist/serve/bundler.d.ts +15 -8
  4. package/dist/serve/bundler.d.ts.map +1 -1
  5. package/dist/serve/bundler.js +38 -37
  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 +22 -0
  140. package/dist/serve/runtime/worker-generation.d.ts.map +1 -0
  141. package/dist/serve/runtime/worker-generation.js +77 -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 +27 -0
  156. package/dist/serve/vite-ai-config.d.ts.map +1 -0
  157. package/dist/serve/vite-ai-config.js +49 -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 +29 -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 +5 -5
  204. package/src/serve/bundler.ts +49 -16
  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 +97 -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 +85 -0
  218. package/src/serve/vite-plugin.ts +61 -87
  219. package/src/serve/vite-rules-source.ts +28 -0
  220. package/src/serve/vite-worker-runtime.ts +58 -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,18 @@ import {
91
87
  createLinePrefixer,
92
88
  registerModuleUrl,
93
89
  } from '../cli/dev-runner.js';
90
+ import {
91
+ loadViteAiEnv,
92
+ resolveViteAiConfig,
93
+ viteWorkerEpochSalt,
94
+ type PyricAiOptions,
95
+ } from './vite-ai-config.js';
96
+ import { resolveViteRulesConfig } from './vite-rules-source.js';
97
+ import {
98
+ PYRIC_RUNTIME_CHIP_META,
99
+ runtimeChipMetaValue,
100
+ type PyricRuntimeChipOption,
101
+ } from './runtime/chip-config.js';
94
102
 
95
103
  /**
96
104
  * Whether a `vite build` should run the firebase→pyric swap (produce a SANDBOX
@@ -109,41 +117,6 @@ function swapsInBuild(env: ConfigEnv, swapInBuild: boolean | undefined): boolean
109
117
  /** Any `firebase/<sub>` specifier. */
110
118
  const FB_ANY = /^firebase\/([a-z-]+(?:\/[a-z-]+)*)$/;
111
119
 
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
120
  /** The firebase subpaths with swap entries. */
148
121
  const SERVED = new Set(SDK_MODULES.map((specifier) => specifier.slice('firebase/'.length)));
149
122
  const entryKey = (subpath: string): string => subpath.replaceAll('/', '-');
@@ -159,8 +132,9 @@ function packageRootOf(file: string): string {
159
132
  }
160
133
 
161
134
  export interface PyricOptions {
162
- /** firestore.rules path (relative to `root`). Default: `firebase.json`'s
163
- * `firestore.rules`, else `firestore.rules` in the project root. */
135
+ /** Firestore rules path (relative to `root`). Default discovery prefers an
136
+ * authored `firestore.modules.rules`, then `firebase.json`, then
137
+ * `firestore.rules` in the project root. */
164
138
  rules?: string;
165
139
  /** Project dir for `firebase.json` / rules discovery. Default: Vite's `root`. */
166
140
  root?: string;
@@ -193,6 +167,10 @@ export interface PyricOptions {
193
167
  * so app, Studio, and agent all observe the one sandbox); pass `ui: false`
194
168
  * to disable. */
195
169
  ui?: boolean;
170
+ /** Inject the collapsed Pyric runtime chip into the app during sandbox Vite
171
+ * dev/builds. On by default. Pass `false` to hide it, or
172
+ * `{ initiallyOpen: true }` when actively debugging runtime errors. */
173
+ runtimeChip?: PyricRuntimeChipOption;
196
174
  /** RTDB-triggered Cloud Functions under this dev server (the `pyric dev`
197
175
  * parity fold). By default a `functions` block in `firebase.json` is
198
176
  * discovered automatically: its `onValueCreated` triggers run in an isolated
@@ -229,11 +207,14 @@ export interface PyricOptions {
229
207
  *
230
208
  * pyric({
231
209
  * ai: {
232
- * engine: { kind: 'openai', model: 'llama3.2', baseUrl: '/__pyric/ai-proxy' },
210
+ * model: 'llama3.2',
233
211
  * proxyUpstream: 'http://localhost:11434/v1', // your Ollama
234
212
  * },
235
213
  * })
236
214
  *
215
+ * - `model` is the simple OpenAI-compatible path. It uses the same-origin
216
+ * proxy and becomes the catch-all upstream model. `PYRIC_AI_MODEL` selects
217
+ * the same path when neither `model` nor `engine` is explicit.
237
218
  * - `engine` is `pyric/ai`'s `EngineConfig` (scripted | openai), applied on
238
219
  * both the SharedWorker and in-page paths. An openai `baseUrl` of
239
220
  * `/__pyric/ai-proxy` (or omitted) routes through the same-origin proxy so a
@@ -241,20 +222,11 @@ export interface PyricOptions {
241
222
  * - `proxyUpstream` sets what `/__pyric/ai-proxy` forwards to (beats the
242
223
  * `PYRIC_AI_PROXY_UPSTREAM` env var; default `http://localhost:11434/v1`).
243
224
  *
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.
225
+ * Precedence: explicit `engine` or `model`, then `PYRIC_AI_MODEL`, then an
226
+ * engine passed by the app's first `getAI()` call; with none, the zero-config
227
+ * scripted default applies. `model` and `engine` are mutually exclusive.
249
228
  */
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
- };
229
+ ai?: PyricAiOptions;
258
230
  }
259
231
 
260
232
  /**
@@ -264,6 +236,7 @@ export interface PyricOptions {
264
236
  * export default defineConfig({ plugins: [pyric()] });
265
237
  */
266
238
  export function pyric(options: PyricOptions = {}): Plugin {
239
+ let resolvedAi = resolveViteAiConfig(options.ai, {});
267
240
  // Resolved once. `defaultSdkEntries()` prefers compiled dist `.js` and falls
268
241
  // back to source `.ts` in the workspace.
269
242
  const entries = defaultSdkEntries(); // { app, auth, firestore, init } → abs paths
@@ -335,11 +308,10 @@ export function pyric(options: PyricOptions = {}): Plugin {
335
308
  };
336
309
 
337
310
  // 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;
311
+ // a still-running OLD worker is detected as stale. The collaborator becomes
312
+ // ready once its bundle succeeds; until then (or on bundle failure) the page
313
+ // is forced onto the in-page sandbox path. transformIndexHtml reads its tag.
314
+ const workerRuntime = createViteWorkerRuntime();
343
315
 
344
316
  // Set by the `config` hook. When the plugin runs under `vite build` at all it
345
317
  // is a SANDBOX build (the `apply` gate below only lets build through under the
@@ -364,12 +336,12 @@ export function pyric(options: PyricOptions = {}): Plugin {
364
336
  // so multi-tab is disabled under bridge to keep agent + app on one backend.
365
337
  const bridgeOpts = options.bridge === true ? {} : options.bridge || null;
366
338
 
367
- // Plugin-level AI engine, normalized once to the JSON-safe wire shape. Travels
339
+ // Plugin-level AI engine, normalized to the JSON-safe wire shape. Travels
368
340
  // 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
-
341
+ // fallback via an injected synchronous global (see transformIndexHtml).
342
+ // An explicit engine is available immediately (some hook tests call the HTML
343
+ // transform directly); Vite's config hook may otherwise select the simple
344
+ // PYRIC_AI_MODEL environment path for the active mode/root.
373
345
  return {
374
346
  name: 'pyric:sandbox',
375
347
  // Active for `vite dev` ALWAYS, and for `vite build` only when it is a
@@ -378,14 +350,17 @@ export function pyric(options: PyricOptions = {}): Plugin {
378
350
  // production output. A sandbox build applies the same swap so the output
379
351
  // bundles pyric's in-page adapters (self-contained; preview it under
380
352
  // `pyric dev`, never deploy it).
381
- apply(_config, env) {
353
+ apply(config, env) {
354
+ void config;
382
355
  if (env.command === 'serve') return true;
383
356
  return swapsInBuild(env, options.swapInBuild);
384
357
  },
385
358
  enforce: 'pre',
386
359
 
387
- config(_config, env) {
360
+ config(config, env) {
388
361
  sandboxBuild = env.command === 'build';
362
+ const loadedEnv = loadViteAiEnv(env.mode, config.root, config.envDir);
363
+ resolvedAi = resolveViteAiConfig(options.ai, loadedEnv);
389
364
  // Cast: the `esbuild` package's `Plugin` type skews slightly from Vite's
390
365
  // bundled esbuild types (benign — the Plugin shape is stable across the
391
366
  // versions in range).
@@ -457,10 +432,11 @@ export function pyric(options: PyricOptions = {}): Plugin {
457
432
  } catch {
458
433
  /* optional — serve without a firebase.json */
459
434
  }
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;
435
+ // Convention-first development source: an explicit option wins; otherwise
436
+ // an authored 2+modules file wins over firebase.json's generated deployment
437
+ // target. Projects without that convention retain the normal Firebase
438
+ // discovery path.
439
+ const config = resolveViteRulesConfig(cwd, options.rules, fbJson);
464
440
  const loaded = await loadProjectRules(cwd, config);
465
441
  const loadedDatabase = await loadProjectDatabaseRules(cwd, config);
466
442
  const loadedStorage = await loadProjectStorageRules(cwd, config);
@@ -530,12 +506,10 @@ export function pyric(options: PyricOptions = {}): Plugin {
530
506
 
531
507
  // ── M2 SharedWorker host: bundle it (cached per version) and serve it at
532
508
  // /__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);
509
+ // On bundle failure, the collaborator stays unready and its HTML tag
510
+ // forces the in-page sandbox.
536
511
  try {
537
- await bundleWorker({ outDir: sdkDir });
538
- workerReady = true;
512
+ await workerRuntime.prepare(viteWorkerEpochSalt(cwd, resolvedAi.engineWire));
539
513
  } catch (e) {
540
514
  server.config.logger.warn(
541
515
  ` ⚠ [pyric] SharedWorker bundle failed — using the in-page sandbox (single-tab, ephemeral): ${e instanceof Error ? e.message : String(e)}`,
@@ -629,7 +603,7 @@ export function pyric(options: PyricOptions = {}): Plugin {
629
603
  messaging: true,
630
604
  // Plugin-level engine → the worker host's ctx.aiEngine (host-ai.ts),
631
605
  // which wins over any op-carried engine. Null when unset.
632
- ai: aiEngineWire ? { engine: aiEngineWire } : null,
606
+ ai: resolvedAi.engineWire ? { engine: resolvedAi.engineWire } : null,
633
607
  });
634
608
  // Pyric Studio: mount the disk-backed workspace/project routes that
635
609
  // Studio's `local` mode talks to + serve the built Studio app at
@@ -657,6 +631,7 @@ export function pyric(options: PyricOptions = {}): Plugin {
657
631
  );
658
632
  }
659
633
  }
634
+ const { sdkDir } = workerRuntime.status();
660
635
  const namespace = createPyricNamespace({
661
636
  sdkDir,
662
637
  initPayload,
@@ -668,7 +643,7 @@ export function pyric(options: PyricOptions = {}): Plugin {
668
643
  studioUiDir,
669
644
  // `ai.proxyUpstream`: what `/__pyric/ai-proxy` forwards to (beats the
670
645
  // PYRIC_AI_PROXY_UPSTREAM env var; falls back to the default when unset).
671
- aiProxyUpstream: options.ai?.proxyUpstream,
646
+ aiProxyUpstream: resolvedAi.proxyUpstream,
672
647
  // Adapt Vite's logger to the plain info/note shape the namespace's
673
648
  // diagnostics (denial relay, future hot-reload lines) expect —
674
649
  // matches the `↻`/`⚠ [pyric]` lines already logged elsewhere in this
@@ -1047,6 +1022,7 @@ export function pyric(options: PyricOptions = {}): Plugin {
1047
1022
  },
1048
1023
 
1049
1024
  transformIndexHtml(html) {
1025
+ const runtimeChipTag = `<meta name="${PYRIC_RUNTIME_CHIP_META}" content="${runtimeChipMetaValue(options.runtimeChip)}" data-studio="${options.ui === false ? 'off' : 'on'}" data-pyric-sandbox>`;
1050
1026
  // Sandbox BUILD: the app's own `firebase/*` imports were already swapped
1051
1027
  // (resolveId, above) to pyric's in-page adapters and BUNDLED into the app
1052
1028
  // chunk, and the emitted init chunk (script-tagged here) carries the
@@ -1063,7 +1039,7 @@ export function pyric(options: PyricOptions = {}): Plugin {
1063
1039
  const initTag = initChunkFile
1064
1040
  ? `<script type="module" crossorigin src="/${initChunkFile}" data-pyric-sandbox-init></script>`
1065
1041
  : '';
1066
- const tags = SANDBOX_BUILD_META + initTag;
1042
+ const tags = SANDBOX_BUILD_META + runtimeChipTag + initTag;
1067
1043
  return html.includes('</head>')
1068
1044
  ? html.replace('</head>', `${tags}</head>`)
1069
1045
  : tags + html;
@@ -1076,26 +1052,24 @@ export function pyric(options: PyricOptions = {}): Plugin {
1076
1052
  // module evaluates — a classic inline script runs before the deferred module.
1077
1053
  // The flag (not nulling `window.SharedWorker`) leaves the user's own
1078
1054
  // SharedWorker usage intact. We force in-page ONLY when the worker bundle
1079
- // failed (workerReady false) — the ephemeral fallback. `bridge` no longer
1055
+ // failed (worker runtime unready) — the ephemeral fallback. `bridge` no longer
1080
1056
  // forces in-page: the bridge peer routes agent tool-calls THROUGH the
1081
1057
  // worker (see `connectBridgePeer`), so the agent shares the one sandbox the
1082
1058
  // 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>`;
1059
+ const head = workerRuntime.headTag(MARKER);
1086
1060
  // Plugin-level engine for the IN-PAGE path: a classic inline script runs
1087
1061
  // before the deferred init module AND before app code's `getAI`, so the
1088
1062
  // served `getAI` (entries/ai.ts) reads it synchronously — init.json can't
1089
1063
  // be awaited there. Harmless on the worker path (that branch ignores the
1090
1064
  // global; the worker reads ctx.aiEngine from init.json). `<` is escaped so
1091
1065
  // 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>`
1066
+ const aiEngineTag = resolvedAi.engineWire
1067
+ ? `<script ${MARKER}>globalThis.__PYRIC_AI_ENGINE__=${JSON.stringify(resolvedAi.engineWire).replace(/</g, '\\u003c')};</script>`
1094
1068
  : '';
1095
1069
  // Boot the sandbox by loading the real init entry as a module (Vite
1096
1070
  // serves + transforms it). The init module's top-level await deploys rules
1097
1071
  // before app code runs. Mirrors serve's injectServeTags.
1098
- const tag = head + aiEngineTag + `<script type="module" src="/@fs/${entries.init}" ${MARKER}></script>`;
1072
+ const tag = head + aiEngineTag + runtimeChipTag + `<script type="module" src="/@fs/${entries.init}" ${MARKER}></script>`;
1099
1073
  return html.includes('</head>') ? html.replace('</head>', `${tag}</head>`) : tag + html;
1100
1074
  },
1101
1075
  };
@@ -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,58 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+ import { createHash } from 'node:crypto';
4
+ import {
5
+ bundleWorker,
6
+ workerSourceHash,
7
+ type WorkerBundleOptions,
8
+ type WorkerBundleResult,
9
+ } from './bundler.js';
10
+
11
+ export interface ViteWorkerRuntimeStatus {
12
+ sdkDir: string;
13
+ ready: boolean;
14
+ epoch: string | null;
15
+ }
16
+
17
+ export interface ViteWorkerRuntime {
18
+ prepare(epochSalt?: string): Promise<void>;
19
+ status(): ViteWorkerRuntimeStatus;
20
+ headTag(marker: string): string;
21
+ }
22
+
23
+ interface ViteWorkerRuntimeOptions {
24
+ cacheRoot?: string;
25
+ cacheKey?: string;
26
+ bundle?: (options: WorkerBundleOptions) => Promise<WorkerBundleResult>;
27
+ }
28
+
29
+ /** Own the Vite plugin's worker build state and its one HTML projection. */
30
+ export function createViteWorkerRuntime(
31
+ options: ViteWorkerRuntimeOptions = {},
32
+ ): ViteWorkerRuntime {
33
+ const cacheRoot = options.cacheRoot ?? join(homedir(), '.pyric', 'vite-worker');
34
+ const cacheKey = options.cacheKey ?? workerSourceHash();
35
+ const defaultSdkDir = join(cacheRoot, cacheKey);
36
+ const bundle = options.bundle ?? bundleWorker;
37
+ let current: ViteWorkerRuntimeStatus = { sdkDir: defaultSdkDir, ready: false, epoch: null };
38
+
39
+ return {
40
+ async prepare(epochSalt) {
41
+ const sdkDir = epochSalt
42
+ ? join(
43
+ cacheRoot,
44
+ `${cacheKey}-${createHash('sha256').update(epochSalt).digest('hex').slice(0, 12)}`,
45
+ )
46
+ : defaultSdkDir;
47
+ current = { sdkDir, ready: false, epoch: null };
48
+ const result = await bundle({ outDir: sdkDir, ...(epochSalt ? { epochSalt } : {}) });
49
+ current = { sdkDir, ready: true, epoch: result.epoch };
50
+ },
51
+ status: () => current,
52
+ headTag(marker) {
53
+ return current.ready && current.epoch
54
+ ? `<meta name="pyric-worker-v" content="${current.epoch}" ${marker}>`
55
+ : `<script ${marker}>globalThis.__PYRIC_FORCE_INPAGE__=true;</script>`;
56
+ },
57
+ };
58
+ }
@@ -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