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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (301) hide show
  1. package/dist/conformance/.generated/can-i-use-browser.d.ts.map +1 -1
  2. package/dist/conformance/.generated/can-i-use-browser.js +1 -1
  3. package/dist/conformance/.generated/can-i-use-browser.js.map +1 -1
  4. package/dist/conformance/.generated/can-i-use.d.ts.map +1 -1
  5. package/dist/conformance/.generated/can-i-use.js +2 -2
  6. package/dist/conformance/.generated/can-i-use.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 +2 -2
  13. package/dist/serve/docs-ui/docs/api-reference.md +1 -1
  14. package/dist/serve/docs-ui/docs/app-compat/index.html +1 -1
  15. package/dist/serve/docs-ui/docs/auth-compat/index.html +1 -1
  16. package/dist/serve/docs-ui/docs/build/ai-logic/index.html +1 -1
  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 +1 -1
  28. package/dist/serve/docs-ui/docs/get-started/start-building/index.html +1 -1
  29. package/dist/serve/docs-ui/docs/index.json +200 -25
  30. package/dist/serve/docs-ui/docs/messaging-compat/index.html +1 -1
  31. package/dist/serve/docs-ui/docs/observe/see-whats-happening/index.html +1 -1
  32. package/dist/serve/docs-ui/docs/observe/shape-your-data/index.html +1 -1
  33. package/dist/serve/docs-ui/docs/overview/index.html +1 -1
  34. package/dist/serve/docs-ui/docs/pyric-admin-app-reference-api/index.html +1 -1
  35. package/dist/serve/docs-ui/docs/pyric-admin-auth-reference-api/index.html +1 -1
  36. package/dist/serve/docs-ui/docs/pyric-admin-database-reference-api/index.html +1 -1
  37. package/dist/serve/docs-ui/docs/pyric-admin-firestore-reference-api/index.html +1 -1
  38. package/dist/serve/docs-ui/docs/pyric-admin-messaging-reference-api/index.html +916 -0
  39. package/dist/serve/docs-ui/docs/pyric-admin-messaging-reference-api.md +706 -0
  40. package/dist/serve/docs-ui/docs/pyric-admin-storage-reference-api/index.html +1 -1
  41. package/dist/serve/docs-ui/docs/pyric-ai-reference-api/index.html +1 -1
  42. package/dist/serve/docs-ui/docs/pyric-ai-scripting-reference-api/index.html +1 -1
  43. package/dist/serve/docs-ui/docs/pyric-app-reference-api/index.html +1 -1
  44. package/dist/serve/docs-ui/docs/pyric-auth-reference-api/index.html +1 -1
  45. package/dist/serve/docs-ui/docs/pyric-cli-assurance-browser-reference-api/index.html +1 -1
  46. package/dist/serve/docs-ui/docs/pyric-cli-assurance-reference-api/index.html +1 -1
  47. package/dist/serve/docs-ui/docs/pyric-cli-bridge-client-reference-api/index.html +1 -1
  48. package/dist/serve/docs-ui/docs/pyric-cli-bridge-reference-api/index.html +1 -1
  49. package/dist/serve/docs-ui/docs/pyric-cli-conformance-browser-reference-api/index.html +1 -1
  50. package/dist/serve/docs-ui/docs/pyric-cli-conformance-docs-reference-api/index.html +1 -1
  51. package/dist/serve/docs-ui/docs/pyric-cli-conformance-reference-api/index.html +1 -1
  52. package/dist/serve/docs-ui/docs/pyric-cli-credentials-node-reference-api/index.html +1 -1
  53. package/dist/serve/docs-ui/docs/pyric-cli-discover-reference-api/index.html +1 -1
  54. package/dist/serve/docs-ui/docs/pyric-cli-register-reference-api/index.html +1 -1
  55. package/dist/serve/docs-ui/docs/pyric-cli-remote-reference-api/index.html +1 -1
  56. package/dist/serve/docs-ui/docs/pyric-cli-serve-worker-reference-api/index.html +131 -66
  57. package/dist/serve/docs-ui/docs/pyric-cli-serve-worker-reference-api.md +130 -65
  58. package/dist/serve/docs-ui/docs/pyric-cli-verify-reference-api/index.html +1 -1
  59. package/dist/serve/docs-ui/docs/pyric-cli-vite-reference-api/index.html +1 -1
  60. package/dist/serve/docs-ui/docs/pyric-database-reference-api/index.html +1 -1
  61. package/dist/serve/docs-ui/docs/pyric-firestore-reference-api/index.html +1 -1
  62. package/dist/serve/docs-ui/docs/pyric-messaging-reference-api/index.html +1 -1
  63. package/dist/serve/docs-ui/docs/pyric-messaging-sw-reference-api/index.html +1 -1
  64. package/dist/serve/docs-ui/docs/pyric-rules-reference-api/index.html +1 -1
  65. package/dist/serve/docs-ui/docs/pyric-sandbox-database-reference-api/index.html +1 -1
  66. package/dist/serve/docs-ui/docs/pyric-sandbox-firestore-reference-api/index.html +1 -1
  67. package/dist/serve/docs-ui/docs/pyric-sandbox-reference-api/index.html +37 -14
  68. package/dist/serve/docs-ui/docs/pyric-sandbox-reference-api.md +28 -8
  69. package/dist/serve/docs-ui/docs/pyric-storage-reference-api/index.html +1 -1
  70. package/dist/serve/docs-ui/docs/pyric-ui-agents-reference-api/index.html +1 -1
  71. package/dist/serve/docs-ui/docs/pyric-ui-auth-hooks-reference-api/index.html +1 -1
  72. package/dist/serve/docs-ui/docs/pyric-ui-auth-reference-api/index.html +1 -1
  73. package/dist/serve/docs-ui/docs/pyric-ui-events-hooks-reference-api/index.html +1 -1
  74. package/dist/serve/docs-ui/docs/pyric-ui-events-reference-api/index.html +1 -1
  75. package/dist/serve/docs-ui/docs/pyric-ui-firestore-hooks-reference-api/index.html +1 -1
  76. package/dist/serve/docs-ui/docs/pyric-ui-firestore-reference-api/index.html +1 -1
  77. package/dist/serve/docs-ui/docs/pyric-ui-primitives-reference-api/index.html +1 -1
  78. package/dist/serve/docs-ui/docs/pyric-ui-rtdb-reference-api/index.html +1 -1
  79. package/dist/serve/docs-ui/docs/pyric-ui-rules-hooks-reference-api/index.html +1 -1
  80. package/dist/serve/docs-ui/docs/pyric-ui-rules-reference-api/index.html +1 -1
  81. package/dist/serve/docs-ui/docs/pyric-ui-storage-hooks-reference-api/index.html +1 -1
  82. package/dist/serve/docs-ui/docs/pyric-ui-storage-reference-api/index.html +1 -1
  83. package/dist/serve/docs-ui/docs/pyric-ui-traffic-hooks-reference-api/index.html +1 -1
  84. package/dist/serve/docs-ui/docs/pyric-ui-traffic-reference-api/index.html +1 -1
  85. package/dist/serve/docs-ui/docs/rules-compat/index.html +1 -1
  86. package/dist/serve/docs-ui/docs/secure/audit-your-rules/index.html +1 -1
  87. package/dist/serve/docs-ui/docs/secure/firestore-rules-limits/index.html +1 -1
  88. package/dist/serve/docs-ui/docs/secure/read-a-denial/index.html +1 -1
  89. package/dist/serve/docs-ui/docs/secure/rtdb-rules-in-typescript/index.html +1 -1
  90. package/dist/serve/docs-ui/docs/secure/rules-standard-library/index.html +1 -1
  91. package/dist/serve/docs-ui/docs/secure/secure-it-with-rules/index.html +1 -1
  92. package/dist/serve/docs-ui/docs/secure/simulate-and-lint/index.html +1 -1
  93. package/dist/serve/docs-ui/docs/secure/write-a-rules-test-suite/index.html +1 -1
  94. package/dist/serve/docs-ui/docs/ship/ship-to-production/index.html +1 -1
  95. package/dist/serve/docs-ui/docs/ship/test-in-node/index.html +1 -1
  96. package/dist/serve/docs-ui/docs/storage-compat/index.html +1 -1
  97. package/dist/serve/docs-ui/docs/trust/how-we-know-it-matches-firebase/index.html +1 -1
  98. package/dist/serve/docs-ui/docs/trust/versioning-and-compatibility/index.html +1 -1
  99. package/dist/serve/docs-ui/llms.txt +1 -1
  100. package/dist/serve/worker/host/core.js +1 -1
  101. package/dist/serve/worker/host/core.js.map +1 -1
  102. package/dist/serve/worker/host/firestore-writes.d.ts.map +1 -1
  103. package/dist/serve/worker/host/firestore-writes.js +3 -2
  104. package/dist/serve/worker/host/firestore-writes.js.map +1 -1
  105. package/dist/serve/worker/index.d.ts +2 -1
  106. package/dist/serve/worker/index.d.ts.map +1 -1
  107. package/dist/serve/worker/index.js +2 -1
  108. package/dist/serve/worker/index.js.map +1 -1
  109. package/dist/serve/worker/protocol.js +2 -2
  110. package/dist/serve/worker/protocol.js.map +1 -1
  111. package/package.json +5 -4
  112. package/src/assurance/.generated/conformance-verdicts.ts +1090 -0
  113. package/src/assurance/attachment.ts +211 -0
  114. package/src/assurance/browser.ts +80 -0
  115. package/src/assurance/campaign.ts +496 -0
  116. package/src/assurance/capabilities.ts +446 -0
  117. package/src/assurance/cases.ts +43 -0
  118. package/src/assurance/index.ts +75 -0
  119. package/src/assurance/runner.ts +885 -0
  120. package/src/assurance/tool-names.ts +15 -0
  121. package/src/assurance/tools.ts +830 -0
  122. package/src/assurance/types.ts +352 -0
  123. package/src/assurance/validation.ts +434 -0
  124. package/src/bridge/client/bridge.ts +550 -0
  125. package/src/bridge/client/dispatch.ts +137 -0
  126. package/src/bridge/client.ts +45 -0
  127. package/src/bridge/protocol.ts +351 -0
  128. package/src/bridge/server/audit.ts +53 -0
  129. package/src/bridge/server/bridge.ts +597 -0
  130. package/src/bridge/server/headless.ts +160 -0
  131. package/src/bridge/server/json-schema-to-zod.ts +108 -0
  132. package/src/bridge/server/local-bridge.ts +66 -0
  133. package/src/bridge/server/logger.ts +50 -0
  134. package/src/bridge/server/mcp-contract.ts +93 -0
  135. package/src/bridge/server/mcp.ts +129 -0
  136. package/src/bridge/server/peer.ts +232 -0
  137. package/src/bridge/server/standalone.ts +347 -0
  138. package/src/bridge/server/tool-metadata.ts +99 -0
  139. package/src/bridge/server.ts +28 -0
  140. package/src/cli/can-i-use.ts +50 -0
  141. package/src/cli/cli.test.ts +493 -0
  142. package/src/cli/database-rules.ts +329 -0
  143. package/src/cli/dev-runner.ts +282 -0
  144. package/src/cli/firebase-json.ts +100 -0
  145. package/src/cli/firestore-indexes.ts +70 -0
  146. package/src/cli/index.ts +425 -0
  147. package/src/cli/init.ts +260 -0
  148. package/src/cli/mcp-proxy.ts +196 -0
  149. package/src/cli/parse-args.ts +80 -0
  150. package/src/cli/rules.ts +259 -0
  151. package/src/cli/scope.ts +67 -0
  152. package/src/cli/serve.ts +996 -0
  153. package/src/cli/service-commands.ts +73 -0
  154. package/src/cli/snapshot.ts +164 -0
  155. package/src/cli/storage-rules.ts +172 -0
  156. package/src/cli/verify.ts +416 -0
  157. package/src/conformance/.generated/can-i-use-browser.ts +113 -0
  158. package/src/conformance/.generated/can-i-use.ts +117 -0
  159. package/src/conformance/.generated/conformance-docs.ts +14 -0
  160. package/src/conformance/browser.ts +24 -0
  161. package/src/conformance/can-i-use-tool.ts +51 -0
  162. package/src/conformance/can-i-use.ts +18 -0
  163. package/src/conformance/docs.ts +8 -0
  164. package/src/conformance/index.ts +20 -0
  165. package/src/conformance/tools.ts +16 -0
  166. package/src/credentials/core/memoize-ttl.ts +142 -0
  167. package/src/credentials/core/types.ts +10 -0
  168. package/src/credentials/node/from-adc.ts +96 -0
  169. package/src/credentials/node/from-service-account.ts +133 -0
  170. package/src/credentials/node/index.ts +6 -0
  171. package/src/discover/concurrency.ts +127 -0
  172. package/src/discover/crawler-adapter.ts +142 -0
  173. package/src/discover/crawler.ts +1127 -0
  174. package/src/discover/credential-free.ts +27 -0
  175. package/src/discover/findCollectionGroup.ts +131 -0
  176. package/src/discover/firestore-source.ts +59 -0
  177. package/src/discover/index.ts +5 -0
  178. package/src/discover/merge.ts +523 -0
  179. package/src/discover/session.ts +402 -0
  180. package/src/discover/tools.ts +201 -0
  181. package/src/discover/types.ts +187 -0
  182. package/src/discover/wire.ts +324 -0
  183. package/src/functions-rtdb/child.ts +382 -0
  184. package/src/functions-rtdb/delivery.ts +7 -0
  185. package/src/functions-rtdb/discovery.ts +136 -0
  186. package/src/functions-rtdb/event.ts +64 -0
  187. package/src/functions-rtdb/execution.ts +111 -0
  188. package/src/functions-rtdb/in-memory-delivery.ts +40 -0
  189. package/src/functions-rtdb/project.ts +74 -0
  190. package/src/functions-rtdb/projection.ts +104 -0
  191. package/src/functions-rtdb/reference-pattern.ts +20 -0
  192. package/src/functions-rtdb/remote-delivery.ts +23 -0
  193. package/src/pkg-version.ts +49 -0
  194. package/src/register/esm-exports.ts +63 -0
  195. package/src/register/hooks.ts +30 -0
  196. package/src/register/index.ts +150 -0
  197. package/src/register/mapping.ts +33 -0
  198. package/src/remote/index.ts +1050 -0
  199. package/src/rtdb/crawl-snapshot.ts +118 -0
  200. package/src/rtdb/inspection.ts +137 -0
  201. package/src/rtdb/load-rules-document.ts +56 -0
  202. package/src/rtdb/rules-generation-tool.ts +38 -0
  203. package/src/rtdb/rules-json.ts +29 -0
  204. package/src/serve/activity-guard.ts +29 -0
  205. package/src/serve/activity-route.ts +164 -0
  206. package/src/serve/activity-warning.ts +26 -0
  207. package/src/serve/bridge-mount.ts +200 -0
  208. package/src/serve/bundler.ts +493 -0
  209. package/src/serve/capture-store.ts +65 -0
  210. package/src/serve/discovery.ts +199 -0
  211. package/src/serve/entries/ai.ts +292 -0
  212. package/src/serve/entries/app-backend.ts +4 -0
  213. package/src/serve/entries/app-client.ts +26 -0
  214. package/src/serve/entries/app-session-store.ts +47 -0
  215. package/src/serve/entries/app.ts +11 -0
  216. package/src/serve/entries/auth-helper-core.ts +181 -0
  217. package/src/serve/entries/auth-helper-dom.ts +134 -0
  218. package/src/serve/entries/auth-helper-runtime.ts +20 -0
  219. package/src/serve/entries/auth.ts +255 -0
  220. package/src/serve/entries/bridge-url.ts +31 -0
  221. package/src/serve/entries/database.ts +129 -0
  222. package/src/serve/entries/firestore.ts +232 -0
  223. package/src/serve/entries/init.ts +46 -0
  224. package/src/serve/entries/keepalive.ts +46 -0
  225. package/src/serve/entries/messaging-sw.ts +75 -0
  226. package/src/serve/entries/messaging.ts +96 -0
  227. package/src/serve/entries/runtime.ts +540 -0
  228. package/src/serve/entries/session-store.ts +97 -0
  229. package/src/serve/entries/storage.ts +92 -0
  230. package/src/serve/entries/tab-sync-wiring.ts +274 -0
  231. package/src/serve/entries/worker-runtime.ts +75 -0
  232. package/src/serve/init-payload.ts +42 -0
  233. package/src/serve/namespace.ts +675 -0
  234. package/src/serve/open-browser.ts +68 -0
  235. package/src/serve/rules.ts +272 -0
  236. package/src/serve/sandbox-marker.ts +31 -0
  237. package/src/serve/server.ts +428 -0
  238. package/src/serve/standalone-assets.ts +180 -0
  239. package/src/serve/state-store.ts +166 -0
  240. package/src/serve/studio/disk-project-store.ts +185 -0
  241. package/src/serve/studio/disk-workspace.ts +162 -0
  242. package/src/serve/studio/index.ts +20 -0
  243. package/src/serve/studio/routes.ts +237 -0
  244. package/src/serve/studio/store-types.ts +48 -0
  245. package/src/serve/studio/studio-storage.test.ts +289 -0
  246. package/src/serve/vite-plugin.ts +1102 -0
  247. package/src/serve/worker/activity-bootstrap.ts +24 -0
  248. package/src/serve/worker/client/admin-firestore.ts +38 -0
  249. package/src/serve/worker/client/ai.ts +124 -0
  250. package/src/serve/worker/client/auth.ts +489 -0
  251. package/src/serve/worker/client/connection.ts +202 -0
  252. package/src/serve/worker/client/core.ts +350 -0
  253. package/src/serve/worker/client/disconnect.ts +45 -0
  254. package/src/serve/worker/client/firestore-reads.ts +181 -0
  255. package/src/serve/worker/client/firestore-refs.ts +226 -0
  256. package/src/serve/worker/client/firestore-writes.ts +232 -0
  257. package/src/serve/worker/client/handles.ts +91 -0
  258. package/src/serve/worker/client/messaging.ts +119 -0
  259. package/src/serve/worker/client/presence.ts +174 -0
  260. package/src/serve/worker/client/rtdb.ts +311 -0
  261. package/src/serve/worker/client/rules.ts +63 -0
  262. package/src/serve/worker/client/service-worker-connection.ts +59 -0
  263. package/src/serve/worker/client/snapshots.ts +86 -0
  264. package/src/serve/worker/client/storage.ts +195 -0
  265. package/src/serve/worker/client/studio.ts +88 -0
  266. package/src/serve/worker/client.ts +57 -0
  267. package/src/serve/worker/durable-persistence.ts +137 -0
  268. package/src/serve/worker/entry.ts +179 -0
  269. package/src/serve/worker/host/admin-firestore.ts +88 -0
  270. package/src/serve/worker/host/connection.ts +179 -0
  271. package/src/serve/worker/host/core.ts +387 -0
  272. package/src/serve/worker/host/dispatch.ts +316 -0
  273. package/src/serve/worker/host/firestore-reads.ts +117 -0
  274. package/src/serve/worker/host/firestore-writes.ts +456 -0
  275. package/src/serve/worker/host/presence.ts +312 -0
  276. package/src/serve/worker/host/rtdb.ts +136 -0
  277. package/src/serve/worker/host/rules.ts +128 -0
  278. package/src/serve/worker/host/storage.ts +312 -0
  279. package/src/serve/worker/host/studio.ts +75 -0
  280. package/src/serve/worker/host/subscriptions.ts +238 -0
  281. package/src/serve/worker/host-ai.ts +165 -0
  282. package/src/serve/worker/host-auth.ts +469 -0
  283. package/src/serve/worker/host-context.ts +273 -0
  284. package/src/serve/worker/host-events.ts +94 -0
  285. package/src/serve/worker/host-messaging.ts +240 -0
  286. package/src/serve/worker/host.ts +47 -0
  287. package/src/serve/worker/index.ts +196 -0
  288. package/src/serve/worker/presence-timing.ts +15 -0
  289. package/src/serve/worker/protocol.ts +1112 -0
  290. package/src/serve/worker/serve-init.ts +606 -0
  291. package/src/serve/worker/service-worker-channel.ts +33 -0
  292. package/src/serve/worker/service-worker-relay.ts +86 -0
  293. package/src/serve/writer-lock.ts +53 -0
  294. package/src/verify/cases.ts +233 -0
  295. package/src/verify/fixture.ts +258 -0
  296. package/src/verify/index.ts +519 -0
  297. package/src/verify/tools.ts +108 -0
  298. package/src/version/compat-target.ts +17 -0
  299. package/src/vite.ts +17 -0
  300. package/dist/serve/docs-ui/docs/pyric-firestore-values-reference-api/index.html +0 -31
  301. package/dist/serve/docs-ui/docs/pyric-firestore-values-reference-api.md +0 -29
@@ -0,0 +1,675 @@
1
+ /**
2
+ * The `/__pyric/` reserved namespace — pyric's analog of firebase serve's
3
+ * `/__/firebase/` namespace:
4
+ *
5
+ * /__pyric/sdk/<file>.js the bundled SDK files (import-map targets +
6
+ * the injected init script + shared chunks)
7
+ * /__pyric/init.json the page init payload (rules, bridge URL) —
8
+ * fetched by the runtime chunk at module init
9
+ *
10
+ * Bridge routes (`/__pyric/mcp`, `/__pyric/sandbox`) mount here in P2.
11
+ */
12
+ import { randomBytes } from 'node:crypto';
13
+ import { existsSync, statSync } from 'node:fs';
14
+ import { basename, extname, join } from 'node:path';
15
+ import type { IncomingMessage, ServerResponse } from 'node:http';
16
+ import type { ActivityIncident } from 'pyric/firestore/internal';
17
+ import { collectBody } from '../bridge/server/peer.js';
18
+ import { StateFileError, type StateSection, type StateStore } from './state-store.js';
19
+ import { createWriterLock, type WriterLock } from './writer-lock.js';
20
+ import { createStudioRoutes, type StudioRouteOptions } from './studio/index.js';
21
+ import { contentTypeFor, pipeFileToResponse, resolveStaticFile, type ServeLogger } from './server.js';
22
+ import { SANDBOX_BUILD_MARKER } from './sandbox-marker.js';
23
+ import type { InitPayload } from './init-payload.js';
24
+ import { handleActivity } from './activity-route.js';
25
+ export type { InitPayload } from './init-payload.js';
26
+
27
+ /**
28
+ * Server-sent-events hub for `/__pyric/events` — the hot-reload channel
29
+ * (P3). The runtime opens an EventSource; the rules watcher broadcasts
30
+ * `rules-changed` and connected pages re-deploy without a refresh.
31
+ */
32
+ export interface ServeEventHub {
33
+ /** Mounted inside the namespace handler. */
34
+ handle(req: IncomingMessage, res: ServerResponse): void;
35
+ broadcast(event: string, data: unknown): void;
36
+ clientCount(): number;
37
+ }
38
+
39
+ export function createEventHub(): ServeEventHub {
40
+ const clients = new Set<ServerResponse>();
41
+ return {
42
+ handle(req, res) {
43
+ res.writeHead(200, {
44
+ 'content-type': 'text/event-stream',
45
+ 'cache-control': 'no-store',
46
+ connection: 'keep-alive',
47
+ });
48
+ res.write(': connected\n\n');
49
+ clients.add(res);
50
+ req.on('close', () => clients.delete(res));
51
+ // A dying SSE socket must never surface an unhandled 'error' event
52
+ // (which would kill the whole serve process) — drop the client instead.
53
+ res.on('error', () => clients.delete(res));
54
+ },
55
+ broadcast(event, data) {
56
+ const frame = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
57
+ for (const res of clients) {
58
+ try {
59
+ res.write(frame);
60
+ } catch {
61
+ clients.delete(res); // half-closed socket in the close race window
62
+ }
63
+ }
64
+ },
65
+ clientCount: () => clients.size,
66
+ };
67
+ }
68
+
69
+ export interface NamespaceOptions {
70
+ /** The bundle output dir (`BundleResult.outDir`). */
71
+ sdkDir: string;
72
+ /** Producer for `/__pyric/init.json` — a function so hot-reload serves
73
+ * the latest rules without server restart. */
74
+ initPayload: () => InitPayload;
75
+ /** Hot-reload channel; serves `/__pyric/events` when present. */
76
+ events?: ServeEventHub;
77
+ /** Default-on Firebase Activity Guard sink. The served worker posts bounded,
78
+ * deduplicated incidents here so the host can surface them in its terminal. */
79
+ activity?: (incident: ActivityIncident) => void;
80
+ /** `--persist`: mounts GET/POST /__pyric/state (the state channel). */
81
+ state?: StateStore;
82
+ /** `--capture`: mounts GET/POST /__pyric/capture. POST — the page/worker
83
+ * pushes its session fixture here; the handler writes it verbatim to
84
+ * `.pyric/last-session.json` for `pyric verify` to replay. GET — returns the
85
+ * current fixture JSON (200) or 404 when nothing is captured yet, so the
86
+ * served worker can re-hydrate its event history on boot after a death. */
87
+ capture?: { write(json: string): void; read(): string | null };
88
+ /** `--ui` (Pyric Studio): mounts `/__pyric/workspace` + `/__pyric/projects`
89
+ * (disk-backed `WorkspaceStore`/`ProjectStore`, plus the SSE watch stream)
90
+ * that `@pyric/studio`'s `local` mode talks to. */
91
+ studio?: StudioRouteOptions;
92
+ /** `--ui` (Pyric Studio): the dir of the built Studio app, served under
93
+ * `/__pyric/ui/`. Resolved by file path in the CLI (@pyric/cli never
94
+ * imports `@pyric/studio`). Absent when `--ui` is off or the build is
95
+ * missing. */
96
+ studioUiDir?: string;
97
+ /** `--ui` (Pyric Studio): the built docs site (site-docs), served so the
98
+ * Studio Docs tab has local docs without the hosted site. Built with base
99
+ * `/__pyric/ui/`, so its output straddles two subtrees under the mount:
100
+ * the pages/twins/index.json live under `/__pyric/ui/docs/`, but the shared
101
+ * assets live at `/__pyric/ui/_astro/` (Astro's asset dir, at the base
102
+ * root — NOT under `docs/`). Both are served from this one dir. */
103
+ docsUiDir?: string;
104
+ /** The OpenAI-compatible upstream `/__pyric/ai-proxy` forwards to
105
+ * (pyric/ai — cdd-deltas #98.2). Falls back to the
106
+ * `PYRIC_AI_PROXY_UPSTREAM` env var, then `http://localhost:11434/v1`
107
+ * (local Ollama). Always mounted — the route only touches the network
108
+ * when a request arrives. */
109
+ aiProxyUpstream?: string;
110
+ /** Where hot-reload/diagnostic lines print (rules reload, denial relay).
111
+ * Absent ⇒ diagnostics are dropped (a caller that wires no logger opts out
112
+ * silently rather than falling back to raw `console`). */
113
+ logger?: ServeLogger;
114
+ }
115
+
116
+ // ─── AI proxy (`/__pyric/ai-proxy` — pyric/ai, cdd-deltas #98.2) ───────────
117
+
118
+ /** Default upstream: local Ollama's OpenAI-compatible endpoint. */
119
+ export const AI_PROXY_DEFAULT_UPSTREAM = 'http://localhost:11434/v1';
120
+
121
+ /**
122
+ * Request headers that must NOT be forwarded upstream: origin-sensitive
123
+ * browser context (`origin`/`referer`/`cookie` — the upstream is a different
124
+ * origin and must never see the page's), hop-by-hop headers, and the two the
125
+ * proxy re-derives (`host`, `content-length`). `accept-encoding` is dropped
126
+ * so fetch negotiates its own compression and the streamed body needs no
127
+ * length fixups.
128
+ */
129
+ const AI_PROXY_STRIPPED_HEADERS = new Set([
130
+ 'host',
131
+ 'origin',
132
+ 'referer',
133
+ 'cookie',
134
+ 'connection',
135
+ 'content-length',
136
+ 'accept-encoding',
137
+ 'keep-alive',
138
+ 'proxy-authorization',
139
+ 'te',
140
+ 'trailer',
141
+ 'transfer-encoding',
142
+ 'upgrade',
143
+ ]);
144
+
145
+ /**
146
+ * Handle `POST /__pyric/ai-proxy/<suffix>` — a same-origin passthrough to the
147
+ * configured OpenAI-compatible upstream, so the browser openai engine
148
+ * (running in the served page or the SharedWorker host) reaches a localhost
149
+ * upstream like Ollama with ZERO CORS setup (no `OLLAMA_ORIGINS`, ever).
150
+ *
151
+ * Behavior:
152
+ * - POST only (the OpenAI chat-completions surface is POST); 405 otherwise.
153
+ * - the path suffix + query ride through verbatim
154
+ * (`/__pyric/ai-proxy/chat/completions` → `<upstream>/chat/completions`).
155
+ * - the request body is forwarded verbatim; headers minus the
156
+ * origin-sensitive/hop-by-hop set above (`authorization` DOES forward —
157
+ * upstreams may require a key).
158
+ * - the response body is STREAMED through chunk-by-chunk — SSE passthrough
159
+ * must never buffer, or `stream: true` completions would arrive all at
160
+ * once at the end.
161
+ * - an unreachable upstream answers 502 with a plain-text explanation.
162
+ */
163
+ async function handleAiProxy(
164
+ configuredUpstream: string | undefined,
165
+ req: IncomingMessage,
166
+ res: ServerResponse,
167
+ url: URL,
168
+ ): Promise<void> {
169
+ if (req.method !== 'POST') {
170
+ res.writeHead(405, { allow: 'POST' }).end('method not allowed');
171
+ return;
172
+ }
173
+ const upstreamBase = (
174
+ configuredUpstream ??
175
+ process.env.PYRIC_AI_PROXY_UPSTREAM ??
176
+ AI_PROXY_DEFAULT_UPSTREAM
177
+ ).replace(/\/$/, '');
178
+ const suffix = url.pathname.slice('/__pyric/ai-proxy'.length);
179
+ const target = `${upstreamBase}${suffix}${url.search}`;
180
+
181
+ // Buffer the REQUEST body (small JSON payloads); the RESPONSE streams.
182
+ const raw = await new Promise<Buffer>((resolve, reject) => {
183
+ const chunks: Buffer[] = [];
184
+ req.on('data', (c: Buffer) => chunks.push(c));
185
+ req.on('end', () => resolve(Buffer.concat(chunks)));
186
+ req.on('error', reject);
187
+ });
188
+ // Copy into a plain-ArrayBuffer view — fetch's BodyInit typing rejects
189
+ // Buffer's ArrayBufferLike backing.
190
+ const body = new Uint8Array(raw.byteLength);
191
+ body.set(raw);
192
+
193
+ const headers: Record<string, string> = {};
194
+ for (const [key, value] of Object.entries(req.headers)) {
195
+ if (value === undefined) continue;
196
+ if (AI_PROXY_STRIPPED_HEADERS.has(key.toLowerCase())) continue;
197
+ headers[key] = Array.isArray(value) ? value.join(', ') : value;
198
+ }
199
+
200
+ let upstream: Response;
201
+ try {
202
+ upstream = await fetch(target, { method: 'POST', headers, body });
203
+ } catch (e) {
204
+ res.writeHead(502, { 'content-type': 'text/plain' });
205
+ res.end(
206
+ `pyric dev ai-proxy: upstream ${target} unreachable: ` +
207
+ `${e instanceof Error ? e.message : String(e)}\n` +
208
+ 'Set PYRIC_AI_PROXY_UPSTREAM to an OpenAI-compatible base URL ' +
209
+ `(default ${AI_PROXY_DEFAULT_UPSTREAM}).`,
210
+ );
211
+ return;
212
+ }
213
+
214
+ const responseHeaders: Record<string, string> = { 'cache-control': 'no-store' };
215
+ const contentType = upstream.headers.get('content-type');
216
+ if (contentType) responseHeaders['content-type'] = contentType;
217
+ res.writeHead(upstream.status, responseHeaders);
218
+ if (!upstream.body) {
219
+ res.end();
220
+ return;
221
+ }
222
+ (res as ServerResponse & { flushHeaders?: () => void }).flushHeaders?.();
223
+
224
+ // Chunk-by-chunk passthrough. A dropped client cancels the upstream read
225
+ // so an abandoned SSE stream doesn't keep the upstream generating.
226
+ const reader = upstream.body.getReader();
227
+ res.on('close', () => {
228
+ void reader.cancel().catch(() => {});
229
+ });
230
+ try {
231
+ for (;;) {
232
+ const { done, value } = await reader.read();
233
+ if (done) break;
234
+ res.write(value);
235
+ }
236
+ } catch {
237
+ // Upstream or client dropped mid-stream — nothing to salvage.
238
+ }
239
+ res.end();
240
+ }
241
+
242
+ /** The page's persistence backend speaks this route:
243
+ * GET /__pyric/state → whole envelope (404 when absent)
244
+ * GET /__pyric/state?section=firestore → that section (404 when null)
245
+ * POST /__pyric/state?section=... → write the section (204; 423 if
246
+ * another tab holds the writer lock)
247
+ * DELETE /__pyric/state → release the writer lock (beacon)
248
+ * Writes carry `x-pyric-writer: <id>`; the first writer wins, others get 423
249
+ * and drop to read-only (pre-mortem #3). The firestore body is the sandbox
250
+ * persistence controller's own blob — stored verbatim, never interpreted. */
251
+ async function handleState(
252
+ state: StateStore,
253
+ lock: WriterLock,
254
+ req: IncomingMessage,
255
+ res: ServerResponse,
256
+ url: URL,
257
+ ): Promise<void> {
258
+ const section = url.searchParams.get('section');
259
+ const writerId = (req.headers['x-pyric-writer'] as string | undefined) ?? '';
260
+ try {
261
+ if (req.method === 'GET') {
262
+ const value = section ? state.readSection(section as StateSection) : state.load();
263
+ if (value === null) {
264
+ res.writeHead(404, { 'content-type': 'application/json' }).end('null');
265
+ return;
266
+ }
267
+ res.writeHead(200, {
268
+ 'content-type': 'application/json; charset=utf-8',
269
+ 'cache-control': 'no-store',
270
+ // Lets `pyric snapshot` detect it scanned into a NEIGHBOR project's
271
+ // serve (pre-mortem #4) and warn instead of promoting the wrong DB.
272
+ 'x-pyric-project-dir': state.projectDir,
273
+ });
274
+ res.end(JSON.stringify(value));
275
+ return;
276
+ }
277
+ if (req.method === 'DELETE') {
278
+ if (writerId) lock.release(writerId);
279
+ res.writeHead(204).end();
280
+ return;
281
+ }
282
+ if (req.method === 'PUT') {
283
+ // Lock heartbeat — refresh/claim WITHOUT writing state. 423 if another
284
+ // live tab holds it.
285
+ res.writeHead(lock.claim(writerId || 'anon', Date.now()) ? 204 : 423).end();
286
+ return;
287
+ }
288
+ if (req.method === 'POST') {
289
+ if (section !== 'firestore' && section !== 'auth') {
290
+ res.writeHead(400).end('section must be firestore|auth');
291
+ return;
292
+ }
293
+ // Single-writer: the first page to flush claims the lock; a different
294
+ // live page is refused (423) so it can't erase the writer's world.
295
+ if (!lock.claim(writerId || 'anon', Date.now())) {
296
+ res.writeHead(423, { 'content-type': 'text/plain' }).end(
297
+ `another tab holds the persist writer lock (held by ${lock.holder()})`,
298
+ );
299
+ return;
300
+ }
301
+ const body = await collectBody(req);
302
+ state.writeSection(section, body);
303
+ res.writeHead(204).end();
304
+ return;
305
+ }
306
+ res.writeHead(405, { allow: 'GET, POST, DELETE' }).end('method not allowed');
307
+ } catch (e) {
308
+ // StateFileError (corrupt/version) or bad body — surface, don't clobber.
309
+ res.writeHead(e instanceof StateFileError ? 409 : 400, { 'content-type': 'text/plain' });
310
+ res.end(e instanceof Error ? e.message : String(e));
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Handle POST /__pyric/capture — the page pushes its full session fixture
316
+ * (JSON-serialized history + snapshot + rules) whenever the sandbox changes.
317
+ * The server writes it verbatim to `.pyric/last-session.json` so
318
+ * `pyric verify` can pick it up without any extra arguments.
319
+ *
320
+ * GET returns the current fixture JSON verbatim (200), or 404 when nothing
321
+ * has been captured yet. The served SharedWorker fetches this on boot to
322
+ * re-hydrate its in-memory event history after a worker death (Traffic /
323
+ * activity / metrics survive the refresh even though the worker restarted).
324
+ * Cheap + read-only.
325
+ *
326
+ * POST fails fast rather than silently swallowing write errors so the
327
+ * developer knows the capture is broken.
328
+ *
329
+ * We collect the RAW request body as a string rather than using
330
+ * `collectBody` (which parses JSON). The capture must be stored verbatim
331
+ * — re-serializing a parsed object would round-trip through JS values and
332
+ * could alter key order or lose whitespace, making the file differ from
333
+ * what the page intended to write.
334
+ */
335
+ async function handleCapture(
336
+ capture: { write(json: string): void; read(): string | null },
337
+ req: IncomingMessage,
338
+ res: ServerResponse,
339
+ ): Promise<void> {
340
+ if (req.method === 'GET') {
341
+ const body = capture.read();
342
+ if (body === null) {
343
+ res.writeHead(404, { 'content-type': 'application/json' }).end('null');
344
+ return;
345
+ }
346
+ res.writeHead(200, {
347
+ 'content-type': 'application/json; charset=utf-8',
348
+ 'cache-control': 'no-store',
349
+ });
350
+ res.end(body);
351
+ return;
352
+ }
353
+ if (req.method !== 'POST') {
354
+ res.writeHead(405, { allow: 'GET, POST' }).end('method not allowed');
355
+ return;
356
+ }
357
+ try {
358
+ const body = await new Promise<string>((resolve, reject) => {
359
+ let raw = '';
360
+ req.setEncoding('utf8');
361
+ req.on('data', (chunk: string) => { raw += chunk; });
362
+ req.on('end', () => resolve(raw));
363
+ req.on('error', reject);
364
+ });
365
+ capture.write(body);
366
+ res.writeHead(204).end();
367
+ } catch (e) {
368
+ res.writeHead(400, { 'content-type': 'text/plain' });
369
+ res.end(e instanceof Error ? e.message : String(e));
370
+ }
371
+ }
372
+
373
+ // ─── Denial relay (`POST /__pyric/denials` — headless dev visibility) ─────
374
+ //
375
+ // The worker client (`serve/worker/client/core.ts`) fire-and-forget POSTs
376
+ // here whenever it reconstructs an error carrying `denialContext` (a rules
377
+ // denial) — from a one-shot rejection or an unobserved listener error alike.
378
+ // An agent driving `pyric dev` headlessly has no browser console to watch;
379
+ // this is that visibility, printed to the terminal instead.
380
+ //
381
+ // A denied listener re-fires on every auth/rules change, so printing is
382
+ // throttled per (path, message) pair — at most one line per window, and
383
+ // silent (no output at all) on suppression rather than a "suppressed N more"
384
+ // line, to keep this a diagnostics side channel, not its own log noise.
385
+
386
+ const DENIAL_THROTTLE_MS = 5_000;
387
+
388
+ /** Loosely-typed mirror of the client's relay payload — JSON from the served
389
+ * page, read defensively since nothing here enforces the wire shape. */
390
+ interface DenialRelayPayload {
391
+ kind?: unknown;
392
+ code?: unknown;
393
+ message?: unknown;
394
+ /** Sibling of `denialContext` on the sandbox error — query-proof denials
395
+ * attach the suggested `where()` fix there; the client forwards it. */
396
+ remediation?: unknown;
397
+ denialContext?: {
398
+ auth?: { uid?: string } | null;
399
+ request?: { method?: string; path?: string };
400
+ /** Fallback position, in case a denial path nests it in the context. */
401
+ remediation?: string;
402
+ } | null;
403
+ }
404
+
405
+ /** Per-dev-server-instance throttle: at most one printed line per (path,
406
+ * message) pair within {@link DENIAL_THROTTLE_MS}. Exported for unit tests. */
407
+ export function createDenialThrottle(windowMs: number = DENIAL_THROTTLE_MS): {
408
+ shouldPrint(key: string, now: number): boolean;
409
+ } {
410
+ const lastPrinted = new Map<string, number>();
411
+ return {
412
+ shouldPrint(key, now) {
413
+ const last = lastPrinted.get(key);
414
+ if (last !== undefined && now - last < windowMs) return false;
415
+ lastPrinted.set(key, now);
416
+ return true;
417
+ },
418
+ };
419
+ }
420
+ type DenialThrottle = ReturnType<typeof createDenialThrottle>;
421
+
422
+ /** Format a relayed denial into the compact terminal block: the message,
423
+ * then (when present) the request method/path, the auth uid, and any
424
+ * remediation guidance. A few lines, never a JSON dump. */
425
+ export function formatDenialBlock(payload: DenialRelayPayload): string {
426
+ const message = typeof payload.message === 'string' ? payload.message : 'permission denied';
427
+ const lines = [` ⚠ [pyric] denied: ${message}`];
428
+ const ctx = payload.denialContext;
429
+ const request = ctx?.request;
430
+ if (request?.method && request?.path) {
431
+ lines.push(` ${request.method} ${request.path}`);
432
+ }
433
+ const uid = ctx?.auth?.uid;
434
+ lines.push(` auth: ${uid ?? 'anonymous'}`);
435
+ const remediation = typeof payload.remediation === 'string' ? payload.remediation : ctx?.remediation;
436
+ if (remediation) lines.push(` ${remediation}`);
437
+ return lines.join('\n');
438
+ }
439
+
440
+ /** Handle `POST /__pyric/denials`. Always 204s (best-effort diagnostics —
441
+ * a malformed body or a caller with no logger wired must never fail the
442
+ * page that reported the denial). */
443
+ async function handleDenials(
444
+ throttle: DenialThrottle,
445
+ logger: ServeLogger | undefined,
446
+ req: IncomingMessage,
447
+ res: ServerResponse,
448
+ ): Promise<void> {
449
+ if (req.method !== 'POST') {
450
+ res.writeHead(405, { allow: 'POST' }).end('method not allowed');
451
+ return;
452
+ }
453
+ try {
454
+ const payload = (await collectBody(req)) as DenialRelayPayload;
455
+ const message = typeof payload.message === 'string' ? payload.message : 'permission denied';
456
+ const path = payload.denialContext?.request?.path ?? '';
457
+ const key = `${path}${message}`;
458
+ if (logger && throttle.shouldPrint(key, Date.now())) {
459
+ logger.note(formatDenialBlock(payload));
460
+ }
461
+ } catch {
462
+ /* malformed body — drop it; this is a diagnostics side channel */
463
+ }
464
+ res.writeHead(204).end();
465
+ }
466
+
467
+ export function createPyricNamespace(opts: NamespaceOptions) {
468
+ const writerLock = createWriterLock();
469
+ const studioRoutes = opts.studio ? createStudioRoutes(opts.studio) : null;
470
+ const denialThrottle = createDenialThrottle();
471
+ // Issued once per server boot. The outer static/Vite host guard protects
472
+ // init.json before this capability is disclosed to the served runtime.
473
+ const activityToken = opts.activity ? randomBytes(24).toString('base64url') : undefined;
474
+ return (req: IncomingMessage, res: ServerResponse, url: URL): boolean | Promise<boolean> => {
475
+ if (
476
+ studioRoutes &&
477
+ (url.pathname.startsWith('/__pyric/workspace') ||
478
+ url.pathname.startsWith('/__pyric/projects'))
479
+ ) {
480
+ return studioRoutes(req, res, url);
481
+ }
482
+ if (opts.state && url.pathname === '/__pyric/state') {
483
+ return handleState(opts.state!, writerLock, req, res, url).then(() => true);
484
+ }
485
+ if (opts.capture && url.pathname === '/__pyric/capture') {
486
+ return handleCapture(opts.capture, req, res).then(() => true);
487
+ }
488
+ if (opts.activity && url.pathname === '/__pyric/activity') {
489
+ return handleActivity(opts.activity, req, res, activityToken!).then(() => true);
490
+ }
491
+ if (opts.events && url.pathname === '/__pyric/events') {
492
+ opts.events.handle(req, res);
493
+ return true;
494
+ }
495
+ if (url.pathname === '/__pyric/ai-proxy' || url.pathname.startsWith('/__pyric/ai-proxy/')) {
496
+ return handleAiProxy(opts.aiProxyUpstream, req, res, url).then(() => true);
497
+ }
498
+ if (url.pathname === '/__pyric/denials') {
499
+ return handleDenials(denialThrottle, opts.logger, req, res).then(() => true);
500
+ }
501
+ if (url.pathname === '/__pyric/init.json') {
502
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
503
+ res.end(JSON.stringify({ ...opts.initPayload(), ...(activityToken ? { activityToken } : {}) }));
504
+ return true;
505
+ }
506
+ if (url.pathname.startsWith('/__pyric/sdk/')) {
507
+ // basename() flattens any traversal attempt — the sdk dir is flat.
508
+ const file = join(opts.sdkDir, basename(url.pathname));
509
+ if (!existsSync(file)) {
510
+ res.writeHead(404).end('not found');
511
+ return true;
512
+ }
513
+ const type = file.endsWith('.map') ? 'application/json' : 'text/javascript; charset=utf-8';
514
+ // Immutable-friendly: bundle filenames are content-hashed chunks or
515
+ // cache-keyed outputs; still no-store in dev for simplicity.
516
+ res.writeHead(200, { 'content-type': type, 'cache-control': 'no-store' });
517
+ pipeFileToResponse(file, res);
518
+ return true;
519
+ }
520
+ // Embedded docs site (site-docs). MUST run BEFORE the general
521
+ // `/__pyric/ui/` studio handler below: that handler's SPA fallback answers
522
+ // every miss with Studio's index.html, which would swallow docs pages and
523
+ // (worse) return HTML for a missing docs asset. Built with base
524
+ // `/__pyric/ui/`, the docs output lives in TWO subtrees under the mount —
525
+ // pages/twins/index.json under `/__pyric/ui/docs/`, and shared assets at
526
+ // `/__pyric/ui/_astro/` (Astro's asset dir sits at the base root, not under
527
+ // `docs/`). We claim both; `_astro` is Astro-specific and never collides
528
+ // with Studio (Vite emits `/__pyric/ui/assets/`). No SPA fallback here — a
529
+ // genuinely missing docs page 404s (a broken doc link must fail loudly, not
530
+ // masquerade as another page). Directory-format pages (`<slug>/index.html`)
531
+ // and `bunx serve`-style extensionless→trailing-slash redirects are handled
532
+ // by resolveStaticFile + the directory redirect below.
533
+ if (
534
+ opts.docsUiDir &&
535
+ (url.pathname === '/__pyric/ui/docs' ||
536
+ url.pathname.startsWith('/__pyric/ui/docs/') ||
537
+ url.pathname.startsWith('/__pyric/ui/_astro/'))
538
+ ) {
539
+ const rel = url.pathname.slice('/__pyric/ui'.length) || '/';
540
+ // `bunx serve` parity: an extensionless path that names a real directory
541
+ // (e.g. `/__pyric/ui/docs` or `/__pyric/ui/docs/<slug>`) redirects to the
542
+ // trailing-slash form so the directory's index.html loads with correct
543
+ // relative-URL resolution.
544
+ if (!rel.endsWith('/') && !extname(rel)) {
545
+ const dir = join(opts.docsUiDir, decodeURIComponent(rel));
546
+ if (existsSync(dir) && statSync(dir).isDirectory()) {
547
+ res.writeHead(301, { location: `${url.pathname}/` }).end();
548
+ return true;
549
+ }
550
+ }
551
+ const file = resolveStaticFile(opts.docsUiDir, rel);
552
+ if (!file) {
553
+ res.writeHead(404).end('not found');
554
+ return true;
555
+ }
556
+ res.writeHead(200, { 'content-type': contentTypeFor(file), 'cache-control': 'no-store' });
557
+ pipeFileToResponse(file, res);
558
+ return true;
559
+ }
560
+ if (
561
+ opts.studioUiDir &&
562
+ (url.pathname === '/__pyric/ui' || url.pathname.startsWith('/__pyric/ui/'))
563
+ ) {
564
+ // The built Pyric Studio app. Served verbatim (NOT through
565
+ // injectServeTags: that import-map/init injection is for sandbox pages,
566
+ // not Studio). Studio uses History-API routing under this mount, so any
567
+ // path that doesn't resolve to a real file falls back to index.html —
568
+ // INCLUDING paths with dots (deep links like /storage/uploads/logo.png).
569
+ // Only misses under Vite's content-hashed asset dir stay hard 404s, so a
570
+ // broken script/style URL fails loudly instead of returning HTML.
571
+ if (url.pathname === '/__pyric/ui') {
572
+ res.writeHead(301, { location: '/__pyric/ui/' }).end();
573
+ return true;
574
+ }
575
+ const rel = url.pathname.slice('/__pyric/ui'.length) || '/';
576
+ let file = resolveStaticFile(opts.studioUiDir, rel);
577
+ if (!file && !rel.startsWith('/assets/')) {
578
+ file = resolveStaticFile(opts.studioUiDir, '/index.html');
579
+ }
580
+ if (!file) {
581
+ res.writeHead(404).end('not found');
582
+ return true;
583
+ }
584
+ res.writeHead(200, { 'content-type': contentTypeFor(file), 'cache-control': 'no-store' });
585
+ pipeFileToResponse(file, res);
586
+ return true;
587
+ }
588
+ return false; // unknown /__pyric/* → caller 404s
589
+ };
590
+ }
591
+
592
+ // ─── HTML injection ───────────────────────────────────────────────────
593
+
594
+ /** The import-map targets. Spec → served URL. */
595
+ export function sdkImportMap(): Record<string, string> {
596
+ return {
597
+ 'firebase/ai': '/__pyric/sdk/ai.js',
598
+ 'firebase/app': '/__pyric/sdk/app.js',
599
+ 'firebase/auth': '/__pyric/sdk/auth.js',
600
+ 'firebase/database': '/__pyric/sdk/database.js',
601
+ 'firebase/firestore': '/__pyric/sdk/firestore.js',
602
+ 'firebase/messaging': '/__pyric/sdk/messaging.js',
603
+ 'firebase/messaging/sw': '/__pyric/sdk/messaging-sw.js',
604
+ 'firebase/storage': '/__pyric/sdk/storage.js',
605
+ };
606
+ }
607
+
608
+ /**
609
+ * Inject the import map + init script into an HTML document. The import map
610
+ * MUST precede any module script that imports a mapped specifier, so both
611
+ * tags go at the very start of `<head>` (fallbacks: after `<html>`, else
612
+ * prepended). Idempotent — a page already carrying the marker is untouched
613
+ * (matters when transformHtml runs over an SPA fallback repeatedly).
614
+ */
615
+ export function injectServeTags(
616
+ html: string,
617
+ importMap: Record<string, string> = sdkImportMap(),
618
+ workerVersion?: string,
619
+ forceInPage = false,
620
+ ): string {
621
+ const MARKER = 'data-pyric-serve';
622
+ if (html.includes(MARKER)) return html;
623
+ // A pyric SANDBOX BUILD (`vite build` under the pyricSandbox plugin's sandbox
624
+ // mode) already BUNDLES its own runtime + init chunk — injecting the import
625
+ // map + /__pyric/sdk/init.js on top would boot a SECOND runtime instance on
626
+ // the page (two banners, two bridge peer registrations, races between them).
627
+ // The bundle owns the sandbox for a marked page: it fetches
628
+ // /__pyric/init.json itself (worker path: the SharedWorker does; in-page
629
+ // path: the runtime does) and owns rules hot-reload the same way. The ONLY
630
+ // serve-time contribution left is the worker-version staleness stamp, which
631
+ // the bundled runtime reads from this meta to warn about a stale
632
+ // still-running SharedWorker.
633
+ if (html.includes(SANDBOX_BUILD_MARKER)) {
634
+ if (!workerVersion || html.includes('pyric-worker-v')) return html;
635
+ const meta = `<meta name="pyric-worker-v" content="${workerVersion}" ${MARKER}>`;
636
+ const headTag = html.match(/<head[^>]*>/i);
637
+ if (headTag && headTag.index !== undefined) {
638
+ const at = headTag.index + headTag[0].length;
639
+ return html.slice(0, at) + meta + html.slice(at);
640
+ }
641
+ return meta + html;
642
+ }
643
+ // Stamp the worker's content hash so the page can DETECT staleness: a live
644
+ // SharedWorker survives serve restarts + reloads and can't hot-update, so it
645
+ // keeps running old code until every tab of the origin closes. The worker
646
+ // name is intentionally STABLE (one backend shared by all tabs), so rather
647
+ // than split tabs across versions, `runtime.ts` compares this served hash to
648
+ // the running worker's baked hash and warns the user to close all tabs.
649
+ const versionMeta = workerVersion
650
+ ? `<meta name="pyric-worker-v" content="${workerVersion}" ${MARKER}>`
651
+ : '';
652
+ // `--bridge`: the MCP bridge peers with the IN-PAGE sandbox, so force the page
653
+ // off the default SharedWorker path. Otherwise the agent drives an empty
654
+ // in-page sandbox while the app's data lives in the worker. Set before any app
655
+ // code runs; mirrors the Vite plugin's transformIndexHtml.
656
+ const forceTag = forceInPage
657
+ ? `<script ${MARKER}>globalThis.__PYRIC_FORCE_INPAGE__=true;</script>`
658
+ : '';
659
+ const tags =
660
+ versionMeta +
661
+ forceTag +
662
+ `<script type="importmap" ${MARKER}>${JSON.stringify({ imports: importMap })}</script>` +
663
+ `<script type="module" src="/__pyric/sdk/init.js" ${MARKER}></script>`;
664
+ const head = html.match(/<head[^>]*>/i);
665
+ if (head && head.index !== undefined) {
666
+ const at = head.index + head[0].length;
667
+ return html.slice(0, at) + tags + html.slice(at);
668
+ }
669
+ const htmlTag = html.match(/<html[^>]*>/i);
670
+ if (htmlTag && htmlTag.index !== undefined) {
671
+ const at = htmlTag.index + htmlTag[0].length;
672
+ return html.slice(0, at) + tags + html.slice(at);
673
+ }
674
+ return tags + html;
675
+ }