@yourrealm/sdk 0.0.0-alpha.1378.ce03c8dc

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/LICENSE-EXCEPTION +26 -0
  2. package/README.md +34 -0
  3. package/dist/chunk-3R6KEFOI.js +22 -0
  4. package/dist/chunk-67BDAMYX.js +376 -0
  5. package/dist/chunk-AITGS27R.js +139 -0
  6. package/dist/chunk-CMAEHQ4F.js +6 -0
  7. package/dist/chunk-MLKGABMK.js +9 -0
  8. package/dist/chunk-O3UIK34B.js +1430 -0
  9. package/dist/chunk-VHTUSZUX.js +176 -0
  10. package/dist/cli.js +9006 -0
  11. package/dist/docker.js +54 -0
  12. package/dist/index.js +1053 -0
  13. package/dist/ingress.js +436 -0
  14. package/dist/jsx-runtime.js +2 -0
  15. package/dist/preact.js +22 -0
  16. package/dist/service-url.js +11 -0
  17. package/dist/services.js +213 -0
  18. package/dist/test.js +5069 -0
  19. package/dist/ui.js +254 -0
  20. package/dist/worker-protocol.js +89 -0
  21. package/package.json +112 -0
  22. package/src/app-icon.ts +74 -0
  23. package/src/apply.ts +52 -0
  24. package/src/bundle-ui.ts +211 -0
  25. package/src/bundle.ts +546 -0
  26. package/src/cli/build-dev-landing.ts +84 -0
  27. package/src/cli/build-install-panel.ts +90 -0
  28. package/src/cli/build-runtime.ts +133 -0
  29. package/src/cli/build.ts +63 -0
  30. package/src/cli/dev.ts +107 -0
  31. package/src/cli/down.ts +60 -0
  32. package/src/cli/main.ts +56 -0
  33. package/src/cli/test.ts +61 -0
  34. package/src/cli/validate.ts +37 -0
  35. package/src/compile.ts +100 -0
  36. package/src/create-app.ts +982 -0
  37. package/src/deep-merge.ts +33 -0
  38. package/src/dev/fixture.ts +48 -0
  39. package/src/dev/install-handler.ts +383 -0
  40. package/src/dev/server.ts +968 -0
  41. package/src/dev/stack.ts +108 -0
  42. package/src/dev/state.ts +159 -0
  43. package/src/docker.ts +96 -0
  44. package/src/errors.ts +61 -0
  45. package/src/factory.ts +481 -0
  46. package/src/garage.ts +56 -0
  47. package/src/generator/hash.ts +6 -0
  48. package/src/generator/resolve-refs.ts +207 -0
  49. package/src/grants-builder.ts +100 -0
  50. package/src/grants.ts +260 -0
  51. package/src/home-api.ts +172 -0
  52. package/src/home-cookie.ts +10 -0
  53. package/src/home-registry.ts +120 -0
  54. package/src/home.ts +187 -0
  55. package/src/host-registry.ts +143 -0
  56. package/src/http-header.ts +20 -0
  57. package/src/identifiers.ts +49 -0
  58. package/src/index.ts +66 -0
  59. package/src/info.ts +64 -0
  60. package/src/ini.ts +50 -0
  61. package/src/json.ts +14 -0
  62. package/src/jsx-runtime/index.ts +5 -0
  63. package/src/keys.ts +29 -0
  64. package/src/mysql.ts +56 -0
  65. package/src/pack-build.ts +108 -0
  66. package/src/pack.ts +115 -0
  67. package/src/peer-registry.ts +121 -0
  68. package/src/peer-world.ts +117 -0
  69. package/src/permission-meta.ts +116 -0
  70. package/src/permissions.ts +108 -0
  71. package/src/pkg-root.ts +34 -0
  72. package/src/postgres.ts +79 -0
  73. package/src/preact/index.ts +27 -0
  74. package/src/redis.ts +43 -0
  75. package/src/ref.ts +271 -0
  76. package/src/remote-registry.ts +33 -0
  77. package/src/required-host-kinds.ts +110 -0
  78. package/src/resolve-context.ts +111 -0
  79. package/src/resolve.ts +471 -0
  80. package/src/runtime/docker/compose/compose-schema.ts +118 -0
  81. package/src/runtime/docker/compose/convert-to-docker-compose.ts +278 -0
  82. package/src/runtime/docker/compose/default-hardening.ts +21 -0
  83. package/src/runtime/docker/compose/effects.ts +107 -0
  84. package/src/runtime/docker/compose/index.ts +17 -0
  85. package/src/runtime/docker/compose/labels.ts +39 -0
  86. package/src/runtime/docker/compose/types.ts +78 -0
  87. package/src/runtime/docker/home-proxy-server/index.ts +177 -0
  88. package/src/runtime/docker/home-proxy-server/protocol.ts +53 -0
  89. package/src/runtime/docker/ingress/build-config.ts +819 -0
  90. package/src/runtime/docker/ingress/index.ts +16 -0
  91. package/src/runtime/docker/ingress/types.ts +182 -0
  92. package/src/runtime/docker/log-frames.ts +121 -0
  93. package/src/runtime/docker/logs-server/index.ts +366 -0
  94. package/src/runtime/docker/spawn-server/context-key.ts +82 -0
  95. package/src/runtime/docker/spawn-server/index.ts +492 -0
  96. package/src/runtime/docker/spawn-server/protocol.ts +70 -0
  97. package/src/runtime/docker/spawn-server/spec.ts +122 -0
  98. package/src/runtime/docker/stats-server/disks.ts +161 -0
  99. package/src/runtime/docker/stats-server/index.ts +322 -0
  100. package/src/runtime/home-client/index.ts +71 -0
  101. package/src/runtime/log-line.ts +24 -0
  102. package/src/runtime/logs-client/index.ts +175 -0
  103. package/src/runtime/spawn-client/index.ts +93 -0
  104. package/src/runtime/spawn-client/kaniko.ts +79 -0
  105. package/src/runtime/stats-client/index.ts +65 -0
  106. package/src/runtime/stats-sample.ts +95 -0
  107. package/src/safe-json.ts +37 -0
  108. package/src/sandbox.ts +1209 -0
  109. package/src/schema/build-apps.ts +38 -0
  110. package/src/schema/home.ts +217 -0
  111. package/src/schema/host.ts +792 -0
  112. package/src/schema/inputs.ts +26 -0
  113. package/src/schema/nodes.ts +75 -0
  114. package/src/schema/peer.ts +400 -0
  115. package/src/schema/public-domain.ts +116 -0
  116. package/src/schema/remote.ts +74 -0
  117. package/src/security.ts +594 -0
  118. package/src/serialize.ts +21 -0
  119. package/src/service-definition.ts +284 -0
  120. package/src/service-url.ts +64 -0
  121. package/src/service.ts +39 -0
  122. package/src/services/index.ts +5 -0
  123. package/src/shared.ts +65 -0
  124. package/src/speculative-silo.ts +96 -0
  125. package/src/stable-secret.ts +14 -0
  126. package/src/surfaces.ts +264 -0
  127. package/src/tailwind-bin.ts +56 -0
  128. package/src/test/boot.ts +192 -0
  129. package/src/test/compile.ts +501 -0
  130. package/src/test/describe.ts +117 -0
  131. package/src/test/index.ts +11 -0
  132. package/src/test/ingress-fixture.ts +37 -0
  133. package/src/test/types.ts +318 -0
  134. package/src/trusted-headers.ts +79 -0
  135. package/src/types.ts +126 -0
  136. package/src/ui/components/aside.tsx +96 -0
  137. package/src/ui/components/badge.tsx +42 -0
  138. package/src/ui/components/button.tsx +59 -0
  139. package/src/ui/components/card.tsx +106 -0
  140. package/src/ui/components/checkbox.tsx +37 -0
  141. package/src/ui/components/code-block.tsx +29 -0
  142. package/src/ui/components/combobox.tsx +119 -0
  143. package/src/ui/components/config-field.tsx +284 -0
  144. package/src/ui/components/dialog.tsx +137 -0
  145. package/src/ui/components/domain-editor.tsx +317 -0
  146. package/src/ui/components/domain-list.ts +205 -0
  147. package/src/ui/components/dropdown-menu.tsx +73 -0
  148. package/src/ui/components/empty-state.tsx +51 -0
  149. package/src/ui/components/highlight.ts +59 -0
  150. package/src/ui/components/icons.tsx +101 -0
  151. package/src/ui/components/image-posture.tsx +85 -0
  152. package/src/ui/components/input.tsx +28 -0
  153. package/src/ui/components/label.tsx +16 -0
  154. package/src/ui/components/line-chart.tsx +113 -0
  155. package/src/ui/components/page.tsx +39 -0
  156. package/src/ui/components/permission-row.tsx +262 -0
  157. package/src/ui/components/permission-section.tsx +51 -0
  158. package/src/ui/components/pie-chart.tsx +245 -0
  159. package/src/ui/components/schema-block.tsx +60 -0
  160. package/src/ui/components/separator.tsx +42 -0
  161. package/src/ui/components/sheet.tsx +130 -0
  162. package/src/ui/components/sidebar.tsx +650 -0
  163. package/src/ui/components/silo-picker.tsx +510 -0
  164. package/src/ui/components/skeleton.tsx +13 -0
  165. package/src/ui/components/sonner.tsx +33 -0
  166. package/src/ui/components/switch.tsx +58 -0
  167. package/src/ui/components/table-view.ts +219 -0
  168. package/src/ui/components/table.tsx +506 -0
  169. package/src/ui/components/tabs.tsx +59 -0
  170. package/src/ui/components/timeago.tsx +84 -0
  171. package/src/ui/components/tooltip.tsx +56 -0
  172. package/src/ui/components.json +21 -0
  173. package/src/ui/dev/dev-shell.tsx +145 -0
  174. package/src/ui/home/home-grid.tsx +177 -0
  175. package/src/ui/hooks/use-mobile.ts +19 -0
  176. package/src/ui/index.ts +189 -0
  177. package/src/ui/install/adapter.ts +193 -0
  178. package/src/ui/install/deploy-targets-picker.tsx +185 -0
  179. package/src/ui/install/install-app-panel.tsx +912 -0
  180. package/src/ui/install/install-error.tsx +62 -0
  181. package/src/ui/install/perms-inactive.ts +50 -0
  182. package/src/ui/install/required-fields.ts +37 -0
  183. package/src/ui/install/tabs-form.tsx +557 -0
  184. package/src/ui/tokens.css +279 -0
  185. package/src/ui/utils.ts +78 -0
  186. package/src/ui-error.ts +139 -0
  187. package/src/ui-runtime/dev-landing-entry.tsx +258 -0
  188. package/src/ui-runtime/index.ts +83 -0
  189. package/src/ui-runtime/install-entry.tsx +119 -0
  190. package/src/ui-runtime/mount.ts +367 -0
  191. package/src/ui-runtime/runtime.css +56 -0
  192. package/src/ui-surface.ts +451 -0
  193. package/src/valkey.ts +43 -0
  194. package/src/worker/extract-tar.ts +125 -0
  195. package/src/worker/identity.ts +183 -0
  196. package/src/worker/index.ts +16 -0
  197. package/src/worker/public-ip.ts +92 -0
  198. package/src/worker/queue.ts +54 -0
  199. package/src/worker/reconcile.ts +127 -0
  200. package/src/worker/start-worker.ts +227 -0
  201. package/src/worker/ui-proxy.ts +335 -0
  202. package/src/worker/uplink.ts +523 -0
  203. package/src/worker/validate.ts +34 -0
  204. package/src/worker/verify-relay.ts +102 -0
  205. package/src/worker-protocol/app-dirs.ts +94 -0
  206. package/src/worker-protocol/home-proxy-paths.ts +76 -0
  207. package/src/worker-protocol/index.ts +55 -0
  208. package/src/worker-protocol/ingress.ts +166 -0
  209. package/src/worker-protocol/logs-socket-path.ts +50 -0
  210. package/src/worker-protocol/networks.ts +39 -0
  211. package/src/worker-protocol/node-version.ts +70 -0
  212. package/src/worker-protocol/silo-paths.ts +114 -0
  213. package/src/worker-protocol/spawn-paths.ts +165 -0
  214. package/src/worker-protocol/stats-socket-path.ts +40 -0
  215. package/src/worker-protocol/update-files.ts +77 -0
  216. package/src/worker-protocol/uplink/constants.ts +86 -0
  217. package/src/worker-protocol/uplink/errors.ts +61 -0
  218. package/src/worker-protocol/uplink/frame.ts +114 -0
  219. package/src/worker-protocol/uplink/identity.ts +40 -0
  220. package/src/worker-protocol/uplink/index.ts +63 -0
  221. package/src/worker-protocol/uplink/methods.ts +462 -0
  222. package/src/worker-protocol/uplink/mux.ts +646 -0
  223. package/src/worker-protocol/uplink/protocol.ts +11 -0
  224. package/src/worker-protocol/uplink/rpc.ts +309 -0
  225. package/src/worker-protocol/uplink/session.ts +540 -0
  226. package/src/worker-protocol/uplink/test-support.ts +188 -0
  227. package/src/worker-protocol/worker-mount.ts +27 -0
  228. package/src/worker-protocol/worker-state.ts +190 -0
  229. package/src/xml.ts +49 -0
  230. package/src/yaml.ts +15 -0
@@ -0,0 +1,968 @@
1
+ // `realm-sdk dev` HTTP server. Runs locally and gives the same iframe + loader
2
+ // experience Home offers, driven by a single realm.ts(x) on disk with no Home
3
+ // or Worker in the picture: it builds the iframe runtime, install-panel and
4
+ // dev-landing bundles into a temp dir, compiles the app against the on-disk
5
+ // fixture, and serves the landing, the install panel, iframe shells, the
6
+ // loader/action proxies into the running container, and an SSE reload channel.
7
+ //
8
+ // v0 scope: single worker. A merge loader runs on one response and the iframe
9
+ // runtime applies surface.merge([result]) for parity with Home's fan-out shape;
10
+ // multi-worker fan-out lands later.
11
+
12
+ import { join } from "@std/path";
13
+ import { z } from "zod";
14
+ import { runBuildRuntime } from "../cli/build-runtime.ts";
15
+ import { runBuildInstallPanel } from "../cli/build-install-panel.ts";
16
+ import { runBuildDevLanding } from "../cli/build-dev-landing.ts";
17
+ import { handleUiProxy, type UiProxyResponse } from "../worker/ui-proxy.ts";
18
+ import { resolveTrustedHeaders, stampHeaders } from "../trusted-headers.ts";
19
+ import { findBinDirs } from "../tailwind-bin.ts";
20
+ import { bundle } from "../bundle.ts";
21
+ import { extractAppName } from "../sandbox.ts";
22
+ import { iconSourceUrl } from "../app-icon.ts";
23
+ import { firstPublicDomain } from "../service-url.ts";
24
+ import type { WidgetManifest } from "../resolve.ts";
25
+ import type { RequestMethod } from "../ui-surface.ts";
26
+ import { buildPackedApp } from "../pack-build.ts";
27
+ import { bringUp, compileAndBundle, type DevState } from "./state.ts";
28
+ import {
29
+ handleInstall,
30
+ handlePreview,
31
+ installErrorToResponse,
32
+ InstallHandlerError,
33
+ } from "./install-handler.ts";
34
+
35
+ const KEY_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
36
+ const NAME_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
37
+ const HASH_RE = /^[a-f0-9]{64}$/;
38
+
39
+ interface DevServerOpts {
40
+ cwd: string;
41
+ port: number;
42
+ realmFile: string;
43
+ }
44
+
45
+ interface ReloadChannel {
46
+ push(): void;
47
+ subscribe(controller: ReadableStreamDefaultController<Uint8Array>): () => void;
48
+ /**
49
+ * Close every active SSE stream. `server.shutdown()` waits for
50
+ * in-flight responses to settle, and SSE responses never naturally
51
+ * complete, so this has to run before shutdown to avoid hanging the
52
+ * dev server on Ctrl-C while a browser tab is still subscribed.
53
+ */
54
+ closeAll(): void;
55
+ }
56
+
57
+ function createReloadChannel(): ReloadChannel {
58
+ const subscribers = new Set<ReadableStreamDefaultController<Uint8Array>>();
59
+ const encoder = new TextEncoder();
60
+ return {
61
+ push() {
62
+ const msg = `event: reload\ndata: ${Date.now()}\n\n`;
63
+ for (const ctrl of subscribers) {
64
+ try {
65
+ ctrl.enqueue(encoder.encode(msg));
66
+ } catch {
67
+ subscribers.delete(ctrl);
68
+ }
69
+ }
70
+ },
71
+ subscribe(controller) {
72
+ subscribers.add(controller);
73
+ return () => subscribers.delete(controller);
74
+ },
75
+ closeAll() {
76
+ for (const ctrl of subscribers) {
77
+ try {
78
+ ctrl.close();
79
+ } catch {
80
+ // Already closed.
81
+ }
82
+ }
83
+ subscribers.clear();
84
+ },
85
+ };
86
+ }
87
+
88
+ function escape(s: string): string {
89
+ return s
90
+ .replaceAll("&", "&amp;")
91
+ .replaceAll("<", "&lt;")
92
+ .replaceAll(">", "&gt;")
93
+ .replaceAll('"', "&quot;");
94
+ }
95
+
96
+ interface ShellPayload {
97
+ appName: string;
98
+ pathname: string;
99
+ sidebarOpen: boolean;
100
+ pages: { key: string; title: string; icon?: string }[];
101
+ }
102
+
103
+ interface LandingPayload extends ShellPayload {
104
+ routers: {
105
+ serviceId: string;
106
+ name: string;
107
+ iconSrc?: string;
108
+ publicDomain?: string;
109
+ }[];
110
+ widgets: (Pick<WidgetManifest, "title" | "icon" | "cols" | "rows"> & { key: string })[];
111
+ }
112
+
113
+ function readSidebarOpen(req: Request): boolean {
114
+ // Mirrors platform/home/src/routes/(authed)/_layout.tsx. The Sidebar
115
+ // provider writes `sidebar_state=true|false` on toggle; reading it
116
+ // server-side avoids the flash from defaulting open and then
117
+ // collapsing on hydrate.
118
+ const cookie = req.headers.get("cookie") ?? "";
119
+ const match = cookie.match(/(?:^|;\s*)sidebar_state=(true|false)/);
120
+ return match ? match[1] === "true" : true;
121
+ }
122
+
123
+ function pagesPayload(state: DevState | null): { key: string; title: string; icon?: string }[] {
124
+ const ui = state?.resolvedState.ui;
125
+ return Object.entries(ui?.pages ?? {}).map(([k, v]) => ({
126
+ key: k,
127
+ title: v.title,
128
+ icon: v.icon,
129
+ }));
130
+ }
131
+
132
+ function landingHtml(opts: { state: DevState; pathname: string; sidebarOpen: boolean }): string {
133
+ // Thin HTML shell: ships shell + Home metadata as JSON and mounts the
134
+ // dev-landing preact island, which owns the sidebar + breadcrumb, the
135
+ // postMessage bridge to /loader, and SSE reload. Chrome styling comes from
136
+ // runtime.css (clay theme + tailwind), so this page matches /install.
137
+ const { state, pathname, sidebarOpen } = opts;
138
+ const ui = state.resolvedState.ui;
139
+ const routers = Object.entries(state.resolvedState.services).flatMap(([serviceId, svc]) => {
140
+ const router = svc.router;
141
+ if (!router) return [];
142
+ // Mirror extractHomeView: `tile: false` keeps the route but no home tile.
143
+ if (router.tile === false) return [];
144
+ const publicDomain = firstPublicDomain(router.publicDomain);
145
+ const entry: LandingPayload["routers"][number] = {
146
+ serviceId,
147
+ name: router.name ?? serviceId,
148
+ };
149
+ // No Home here to cache and re-serve the bytes, so the landing loads the
150
+ // icon straight from its source.
151
+ if (router.icon) entry.iconSrc = iconSourceUrl(router.icon);
152
+ if (publicDomain) entry.publicDomain = publicDomain;
153
+ return [entry];
154
+ });
155
+ const widgets: LandingPayload["widgets"] = Object.entries(ui?.widgets ?? {}).map(([k, v]) => ({
156
+ key: k,
157
+ title: v.title,
158
+ icon: v.icon,
159
+ cols: v.cols,
160
+ rows: v.rows,
161
+ }));
162
+ const payload: LandingPayload = {
163
+ appName: state.appName,
164
+ pathname,
165
+ sidebarOpen,
166
+ pages: pagesPayload(state),
167
+ routers,
168
+ widgets,
169
+ };
170
+ const devState = JSON.stringify(payload);
171
+ return `<!doctype html>
172
+ <html lang="en">
173
+ <head>
174
+ <meta charset="utf-8">
175
+ <title>realm-sdk dev — ${escape(state.appName)}</title>
176
+ <link rel="stylesheet" href="/runtime.css">
177
+ </head>
178
+ <body>
179
+ <div id="root"></div>
180
+ <script type="application/json" id="dev-state">${devState.replaceAll("<", "\\u003c")}</script>
181
+ <script type="module" src="/dev-landing.js"></script>
182
+ </body>
183
+ </html>`;
184
+ }
185
+
186
+ function installHtml(opts: {
187
+ initialSource: string;
188
+ state: DevState | null;
189
+ appName: string;
190
+ sidebarOpen: boolean;
191
+ }): string {
192
+ // Same shell shape as landingHtml: two JSON script tags (dev-state for the
193
+ // shared shell, install-config for the panel itself) and one module entry.
194
+ // State is null on cold boot — the sidebar renders against the app name
195
+ // extracted from the bundle, and pages stay empty until an install lands.
196
+ const config = JSON.stringify({ initialSource: opts.initialSource, hideDeployTargets: true });
197
+ const payload: ShellPayload = {
198
+ appName: opts.appName,
199
+ pathname: "/install",
200
+ sidebarOpen: opts.sidebarOpen,
201
+ pages: pagesPayload(opts.state),
202
+ };
203
+ const devState = JSON.stringify(payload);
204
+ return `<!doctype html>
205
+ <html lang="en">
206
+ <head>
207
+ <meta charset="utf-8">
208
+ <title>realm-sdk dev — install ${escape(opts.appName)}</title>
209
+ <link rel="stylesheet" href="/runtime.css">
210
+ </head>
211
+ <body>
212
+ <div id="root"></div>
213
+ <script type="application/json" id="dev-state">${devState.replaceAll("<", "\\u003c")}</script>
214
+ <script type="application/json" id="install-config">${config.replaceAll("<", "\\u003c")}</script>
215
+ <script type="module" src="/install-panel.js"></script>
216
+ </body>
217
+ </html>`;
218
+ }
219
+
220
+ function shellHtml(opts: {
221
+ kind: string;
222
+ key: string;
223
+ bundleHash: string;
224
+ config: unknown;
225
+ }): string {
226
+ // Mirrors platform/home/src/routes/(authed)/api/ui/[appId]/[kind]/[key].ts
227
+ // — same importmap, same two stylesheets, same mount call. Two
228
+ // differences: bundle is served at /bundle/<hash> (no Home auth), and
229
+ // the parent here is the dev landing page rather than DashboardShell.
230
+ return `<!doctype html>
231
+ <html lang="en">
232
+ <head>
233
+ <meta charset="utf-8">
234
+ <title>${escape(opts.kind)}/${escape(opts.key)}</title>
235
+ <script type="importmap">
236
+ {
237
+ "imports": {
238
+ "@yourrealm/sdk/ui": "/runtime.js",
239
+ "@yourrealm/sdk/services": "/runtime.js",
240
+ "@yourrealm/sdk/preact": "/runtime.js",
241
+ "@yourrealm/sdk/jsx-runtime": "/runtime.js",
242
+ "@yourrealm/sdk": "/runtime.js",
243
+ "zod": "/runtime.js"
244
+ }
245
+ }
246
+ </script>
247
+ <link rel="stylesheet" href="/runtime.css">
248
+ <link rel="stylesheet" href="/bundle/${escape(opts.bundleHash)}.css">
249
+ </head>
250
+ <body>
251
+ <div id="root"></div>
252
+ <script type="module">
253
+ import { mount } from "/runtime.js";
254
+ import surface from "/bundle/${escape(opts.bundleHash)}.js";
255
+ mount({
256
+ surface: surface.default ?? surface,
257
+ container: document.getElementById("root"),
258
+ config: ${JSON.stringify(opts.config ?? null).replaceAll("<", "\\u003c")},
259
+ });
260
+ </script>
261
+ </body>
262
+ </html>`;
263
+ }
264
+
265
+ /**
266
+ * Strip `additionalProperties: false` before round-tripping JSON Schema
267
+ * back through Zod. Mirrors Home's `loosenJsonSchema` in the surface-call
268
+ * route: Zod's default "strip" mode emits `additionalProperties: false`
269
+ * which `z.fromJSONSchema` then parses as `.strict()`, rejecting any
270
+ * upstream payload with extra fields. Realm's contract is loose-by-
271
+ * default — services often grow fields over time.
272
+ */
273
+ function loosenJsonSchema(schema: unknown): unknown {
274
+ if (!schema || typeof schema !== "object") return schema;
275
+ if (Array.isArray(schema)) return schema.map(loosenJsonSchema);
276
+ const out: Record<string, unknown> = {};
277
+ for (const [k, v] of Object.entries(schema as Record<string, unknown>)) {
278
+ if (k === "additionalProperties" && v === false) continue;
279
+ out[k] = loosenJsonSchema(v);
280
+ }
281
+ return out;
282
+ }
283
+
284
+ // The proxy answers JSON text or nothing; a body that claims JSON and isn't
285
+ // reaches the schema check as null, which reports it as a mismatch.
286
+ function parseUpstreamJson(res: UiProxyResponse): unknown {
287
+ try {
288
+ return JSON.parse(res.body);
289
+ } catch {
290
+ return null;
291
+ }
292
+ }
293
+
294
+ function validateAgainst(
295
+ schema: unknown,
296
+ value: unknown,
297
+ ): { ok: true; data: unknown } | { ok: false; issues: unknown[] } {
298
+ if (!schema || typeof schema !== "object") return { ok: true, data: value };
299
+ let live;
300
+ try {
301
+ live = z.fromJSONSchema(loosenJsonSchema(schema) as Parameters<typeof z.fromJSONSchema>[0]);
302
+ } catch {
303
+ // The schema doesn't compile — pass through rather than block on one we
304
+ // can't parse ourselves. Mirrors Home.
305
+ return { ok: true, data: value };
306
+ }
307
+ const result = live.safeParse(value);
308
+ if (result.success) return { ok: true, data: result.data };
309
+ return { ok: false, issues: result.error.issues };
310
+ }
311
+
312
+ // `realm-sdk dev` has no Home and no session, but an author developing a
313
+ // surface must see the same brokered identity the app gets in production, so a
314
+ // fixed viewer runs through the same resolver Home uses on both of its doors.
315
+ const DEV_VIEWER = { username: "dev", email: "dev@realm.test", role: "admin" } as const;
316
+
317
+ function devStampedHeaders(
318
+ state: DevState,
319
+ declared: Record<string, string> | undefined,
320
+ ): Record<string, string> | undefined {
321
+ return stampHeaders(
322
+ declared,
323
+ resolveTrustedHeaders(state.resolvedState.trustedHeaders?.byRole, DEV_VIEWER),
324
+ );
325
+ }
326
+
327
+ type DevRequestShape = {
328
+ method: RequestMethod;
329
+ path: string;
330
+ query?: Record<string, string | number | boolean>;
331
+ body?: unknown;
332
+ };
333
+
334
+ const REQUEST_METHODS: ReadonlySet<string> = new Set(["GET", "POST", "PUT", "PATCH", "DELETE"]);
335
+
336
+ /** The iframe's own JSON, so the shape is checked here rather than trusted. */
337
+ function isRequestShape(value: unknown): value is DevRequestShape {
338
+ if (value === null || typeof value !== "object") return false;
339
+ const shape = value as { path?: unknown; method?: unknown };
340
+ return typeof shape.path === "string" && REQUEST_METHODS.has(shape.method as string);
341
+ }
342
+
343
+ async function callLoader(
344
+ state: DevState,
345
+ kind: "page" | "widget",
346
+ key: string,
347
+ name: string,
348
+ request: DevRequestShape,
349
+ ): Promise<Response> {
350
+ const ui = state.resolvedState.ui;
351
+ const surfaceMap = kind === "page" ? ui?.pages : ui?.widgets;
352
+ const surface = surfaceMap?.[key];
353
+ if (!surface) return Response.json({ error: "surface not found" }, { status: 404 });
354
+ const call = surface.loaders?.[name];
355
+ if (!call) return Response.json({ error: "loader not found" }, { status: 404 });
356
+
357
+ // No input validation — the request shape (method/path/query/body) is
358
+ // built by the surface's stitch fn; service/port come from the manifest.
359
+
360
+ // Headers in the manifest are already-resolved strings: compile()
361
+ // walked refs on the resolved state, so call.headers contains no
362
+ // `{__realmRef:...}` markers. Anything non-string here is an author bug
363
+ // we surface as 500.
364
+ let headers: Record<string, string> | undefined;
365
+ if (call.headers) {
366
+ headers = {};
367
+ for (const [k, v] of Object.entries(call.headers)) {
368
+ if (typeof v !== "string") {
369
+ return Response.json({ error: `header ${k} did not resolve to a string` }, { status: 500 });
370
+ }
371
+ headers[k] = v;
372
+ }
373
+ }
374
+
375
+ const payload = {
376
+ appId: state.appId,
377
+ service: call.service,
378
+ port: call.port,
379
+ path: request.path,
380
+ method: request.method,
381
+ query: request.query,
382
+ headers: devStampedHeaders(state, headers),
383
+ body: request.body,
384
+ };
385
+
386
+ // `handleUiProxy` is called directly, so a dev loader takes the same code
387
+ // path that runs inside a real Worker. The port bound is the worker's own
388
+ // wire state, which dev has none of, so nothing is bounded here.
389
+ const upstreamRes = await handleUiProxy(payload, () => undefined);
390
+
391
+ // Pass through service-level errors (4xx/5xx) verbatim — outputSchema
392
+ // describes the success shape and isn't meaningful for service errors.
393
+ if (upstreamRes.status < 200 || upstreamRes.status >= 300) {
394
+ const errBody = upstreamRes.body;
395
+ console.error(
396
+ `[realm-dev] loader ${kind}/${key}/${name} → upstream ${upstreamRes.status}: ${errBody.slice(0, 256)}`,
397
+ );
398
+ if (call.merge === true) {
399
+ let parsed: unknown = errBody;
400
+ try {
401
+ // The body comes from our own `handleUiProxy`, which always emits JSON
402
+ // on the error path, so a plain JSON.parse is enough.
403
+ parsed = JSON.parse(errBody);
404
+ } catch {
405
+ // upstream returned non-JSON; keep the raw text in `error`.
406
+ }
407
+ return Response.json(
408
+ { error: "all workers failed", failures: [{ workerId: "local", error: parsed }] },
409
+ { status: 502 },
410
+ );
411
+ }
412
+ return new Response(errBody, {
413
+ status: upstreamRes.status,
414
+ headers: { "content-type": upstreamRes.headers["content-type"] ?? "application/json" },
415
+ });
416
+ }
417
+
418
+ const body = parseUpstreamJson(upstreamRes);
419
+ const outputCheck = validateAgainst(call.outputSchema, body);
420
+ if (!outputCheck.ok) {
421
+ console.error(
422
+ `[realm-dev] loader ${kind}/${key}/${name} → outputSchema mismatch:`,
423
+ outputCheck.issues,
424
+ );
425
+ return Response.json(
426
+ { error: "service response does not match outputSchema", issues: outputCheck.issues },
427
+ { status: 502 },
428
+ );
429
+ }
430
+
431
+ if (call.merge === true) {
432
+ // v0: single worker. Wrap the validated body so the runtime's loader
433
+ // queryFn applies surface.merge([result]) — keeps the iframe contract
434
+ // identical to multi-worker fan-out for free.
435
+ return Response.json({ results: [outputCheck.data], failures: [] });
436
+ }
437
+ return Response.json(outputCheck.data);
438
+ }
439
+
440
+ async function callAction(
441
+ state: DevState,
442
+ kind: "page" | "widget",
443
+ key: string,
444
+ name: string,
445
+ request: DevRequestShape,
446
+ ): Promise<Response> {
447
+ const ui = state.resolvedState.ui;
448
+ const surfaceMap = kind === "page" ? ui?.pages : ui?.widgets;
449
+ const surface = surfaceMap?.[key];
450
+ if (!surface) return Response.json({ error: "surface not found" }, { status: 404 });
451
+ const call = surface.actions?.[name];
452
+ if (!call) return Response.json({ error: "action not found" }, { status: 404 });
453
+
454
+ // No input validation — the request shape is built by the stitch fn;
455
+ // service/port come from the manifest.
456
+
457
+ let headers: Record<string, string> | undefined;
458
+ if (call.headers) {
459
+ headers = {};
460
+ for (const [k, v] of Object.entries(call.headers)) {
461
+ if (typeof v !== "string") {
462
+ return Response.json({ error: `header ${k} did not resolve to a string` }, { status: 500 });
463
+ }
464
+ headers[k] = v;
465
+ }
466
+ }
467
+
468
+ const payload = {
469
+ appId: state.appId,
470
+ service: call.service,
471
+ port: call.port,
472
+ path: request.path,
473
+ method: request.method,
474
+ query: request.query,
475
+ headers: devStampedHeaders(state, headers),
476
+ body: request.body,
477
+ };
478
+
479
+ const upstreamRes = await handleUiProxy(payload, () => undefined);
480
+
481
+ if (upstreamRes.status < 200 || upstreamRes.status >= 300) {
482
+ const errBody = upstreamRes.body;
483
+ console.error(
484
+ `[realm-dev] action ${kind}/${key}/${name} → upstream ${upstreamRes.status}: ${errBody.slice(0, 256)}`,
485
+ );
486
+ return new Response(errBody, {
487
+ status: upstreamRes.status,
488
+ headers: { "content-type": upstreamRes.headers["content-type"] ?? "application/json" },
489
+ });
490
+ }
491
+
492
+ const body = parseUpstreamJson(upstreamRes);
493
+ const outputCheck = validateAgainst(call.outputSchema, body);
494
+ if (!outputCheck.ok) {
495
+ console.error(
496
+ `[realm-dev] action ${kind}/${key}/${name} → outputSchema mismatch:`,
497
+ outputCheck.issues,
498
+ );
499
+ return Response.json(
500
+ { error: "service response does not match outputSchema", issues: outputCheck.issues },
501
+ { status: 502 },
502
+ );
503
+ }
504
+ if (state.resolvedState.kind === "system") {
505
+ // v0 dev parity: single worker, but wrap so the runtime sees the
506
+ // same `{ results, failures }` shape as Home's fan-out path.
507
+ return Response.json({ results: [outputCheck.data], failures: [] });
508
+ }
509
+ return Response.json(outputCheck.data);
510
+ }
511
+
512
+ export async function startDevServer(opts: DevServerOpts): Promise<void> {
513
+ // The SDK spawns `esbuild` + `tailwindcss` by bare name. pnpm injects
514
+ // `<workspaceRoot>/node_modules/.bin` into PATH only for `pnpm <script>`,
515
+ // and running `realm-sdk` through `deno run` skips that. Every ancestor
516
+ // `.bin` is prepended, not just the nearest: a workspace package's local
517
+ // `.bin` holds only its direct deps, and both binaries are SDK devDeps.
518
+ const binDirs = findBinDirs(opts.cwd);
519
+ if (binDirs.length > 0) {
520
+ Deno.env.set("PATH", `${binDirs.join(":")}:${Deno.env.get("PATH") ?? ""}`);
521
+ }
522
+
523
+ const tmpDir = await Deno.makeTempDir({ prefix: "realm-dev-" });
524
+ const runtimeDir = join(tmpDir, "runtime");
525
+
526
+ console.log(`[realm-dev] building iframe runtime…`);
527
+ const buildCode = await runBuildRuntime(["--out", runtimeDir]);
528
+ if (buildCode !== 0) throw new Error("build-runtime failed");
529
+ const installPanelCode = await runBuildInstallPanel(["--out", runtimeDir]);
530
+ if (installPanelCode !== 0) throw new Error("build-install-panel failed");
531
+ const devLandingCode = await runBuildDevLanding(["--out", runtimeDir]);
532
+ if (devLandingCode !== 0) throw new Error("build-dev-landing failed");
533
+
534
+ // Boot state machine: a non-null `state` is normal mode, where every route
535
+ // works and the watcher recompiles on edits. A null one is install-required
536
+ // mode, where only /install, its API and the static assets it needs are
537
+ // served, anything needing a running stack 503s, and / + /pages/<key>
538
+ // redirect to /install. A full compile + bringUp is tried first so an app
539
+ // with a good realm-sdk.dev.ts boots straight into the dashboard; on failure
540
+ // the bundle is built only to extract the app name for the sidebar.
541
+ console.log(`[realm-dev] compiling realm.ts…`);
542
+ let state: DevState | null = null;
543
+ let appName = "(uninitialized)";
544
+ try {
545
+ state = await compileAndBundle({ cwd: opts.cwd, realmFile: opts.realmFile });
546
+ appName = state.appName;
547
+ } catch (err) {
548
+ console.warn(`[realm-dev] initial compile failed — entering install mode:`);
549
+ console.warn((err as Error).message);
550
+ try {
551
+ const source = await Deno.readTextFile(opts.realmFile);
552
+ const bundledCode = await bundle(source);
553
+ appName = await extractAppName(bundledCode);
554
+ } catch {
555
+ // Fall through with the placeholder appName.
556
+ }
557
+ }
558
+
559
+ type StackHandle = Awaited<ReturnType<typeof bringUp>>;
560
+ let stack: StackHandle | null = null;
561
+ if (state) {
562
+ console.log(`[realm-dev] booting stack (com.realm.dev=true)…`);
563
+ stack = await bringUp(state, tmpDir);
564
+ }
565
+
566
+ const reload = createReloadChannel();
567
+ const cwd = opts.cwd;
568
+
569
+ // File watcher: any change to a realm.ts(x) re-runs compile + bundle, in
570
+ // either mode — fixing the source in install-required mode is what promotes
571
+ // the server to normal mode without a restart. realm-sdk.dev.ts is not
572
+ // watched: the install panel is its only legitimate writer and already
573
+ // applies the resulting state + stack via /install/api/install.
574
+ const watcher = Deno.watchFs(cwd);
575
+ let pending: ReturnType<typeof setTimeout> | null = null;
576
+ // Reload on the app entry (`realm.tsx`/`realm.ts`, anchored to a path
577
+ // component start so a sibling `myrealm.ts` is ignored) or on any surface
578
+ // file under `pages/` / `widgets/`. The throwaway `.realm.dev-*.tsx` collapse
579
+ // entries never match (`.realm` ≠ `/realm`), so a collapse can't loop.
580
+ const RELOAD_RE = /(?:^|\/)realm\.tsx?$|\/(?:pages|widgets)\/[^/]+\.tsx$/;
581
+ (async () => {
582
+ try {
583
+ for await (const ev of watcher) {
584
+ if (!ev.paths.some((p) => RELOAD_RE.test(p))) continue;
585
+ if (pending !== null) clearTimeout(pending);
586
+ pending = setTimeout(async () => {
587
+ pending = null;
588
+ try {
589
+ const next = await compileAndBundle({ cwd: opts.cwd, realmFile: opts.realmFile });
590
+ const prev = stack;
591
+ stack = await bringUp(next, tmpDir);
592
+ // Only tear down the previous stack when its compose project is
593
+ // *different* from the new one. The project name derives from the
594
+ // appId, stable across realm.tsx edits, so a typical reload re-uses
595
+ // the project and bringUp's `compose up -d` has already updated it
596
+ // in place. Comparing handle identity instead is always true
597
+ // (bringUp returns a fresh handle) and would `compose down -v` the
598
+ // containers it just brought up.
599
+ if (prev && prev.project !== stack.project) prev.cleanup().catch(() => {});
600
+ const wasUninitialized = state === null;
601
+ state = next;
602
+ appName = next.appName;
603
+ console.log(wasUninitialized ? `[realm-dev] installed` : `[realm-dev] reloaded`);
604
+ reload.push();
605
+ } catch (err) {
606
+ console.error(`[realm-dev] reload failed: ${(err as Error).message}`);
607
+ }
608
+ }, 150);
609
+ }
610
+ } catch {
611
+ // Watcher closed during shutdown — fine.
612
+ }
613
+ })();
614
+
615
+ const handler = async (req: Request): Promise<Response> => {
616
+ const url = new URL(req.url);
617
+ const path = url.pathname;
618
+
619
+ if (path === "/" || path === "") {
620
+ // Install-required mode: no DevState yet, so the landing has nothing to
621
+ // render. Once the install succeeds, / serves the landing.
622
+ if (!state) {
623
+ return new Response(null, { status: 302, headers: { location: "/install" } });
624
+ }
625
+ return new Response(
626
+ landingHtml({ state, pathname: "/", sidebarOpen: readSidebarOpen(req) }),
627
+ {
628
+ headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" },
629
+ },
630
+ );
631
+ }
632
+
633
+ // /pages/<key> renders the same landing HTML with a different pathname;
634
+ // the dev-landing island reads the path from the JSON payload and mounts a
635
+ // page iframe instead of Home. An unknown key is not a 404 — the island
636
+ // shows a "not declared" notice with the sidebar still rendered, which is
637
+ // more useful to an operator on a stale bookmark mid-realm.ts edit.
638
+ const pagesRouteMatch = path.match(/^\/pages\/([^/]+)$/);
639
+ if (pagesRouteMatch) {
640
+ if (!state) {
641
+ return new Response(null, { status: 302, headers: { location: "/install" } });
642
+ }
643
+ return new Response(
644
+ landingHtml({ state, pathname: path, sidebarOpen: readSidebarOpen(req) }),
645
+ {
646
+ headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" },
647
+ },
648
+ );
649
+ }
650
+
651
+ // The iframe shell and its subresources load into a sandboxed iframe (no
652
+ // allow-same-origin → opaque origin). An ESM `<script type="module">` from
653
+ // an opaque origin is cross-origin against the dev server, so the bundle +
654
+ // runtime fetches need ACAO + CORP to satisfy CORS. Mirrors Home's
655
+ // middleware/security.ts isUiPath branch.
656
+ const uiHeaders = {
657
+ "access-control-allow-origin": "*",
658
+ "cross-origin-resource-policy": "cross-origin",
659
+ };
660
+
661
+ if (path === "/runtime.js") {
662
+ const bytes = await Deno.readFile(join(runtimeDir, "runtime.js"));
663
+ return new Response(bytes, {
664
+ headers: {
665
+ "content-type": "application/javascript",
666
+ "cache-control": "no-store",
667
+ ...uiHeaders,
668
+ },
669
+ });
670
+ }
671
+ if (path === "/runtime.css") {
672
+ const bytes = await Deno.readFile(join(runtimeDir, "runtime.css"));
673
+ return new Response(bytes, {
674
+ headers: { "content-type": "text/css", "cache-control": "no-store", ...uiHeaders },
675
+ });
676
+ }
677
+
678
+ if (path === "/install-panel.js") {
679
+ const bytes = await Deno.readFile(join(runtimeDir, "install-panel.js"));
680
+ return new Response(bytes, {
681
+ headers: {
682
+ "content-type": "application/javascript",
683
+ "cache-control": "no-store",
684
+ },
685
+ });
686
+ }
687
+
688
+ if (path === "/dev-landing.js") {
689
+ const bytes = await Deno.readFile(join(runtimeDir, "dev-landing.js"));
690
+ return new Response(bytes, {
691
+ headers: {
692
+ "content-type": "application/javascript",
693
+ "cache-control": "no-store",
694
+ },
695
+ });
696
+ }
697
+
698
+ if (path === "/install") {
699
+ // Pre-fill the box with the packed artifact — the exact bytes that ship
700
+ // and that Install re-consumes — so the dev funnel mirrors Home's paste →
701
+ // preview → install. A single-file app packs to its raw source (no
702
+ // markers); a multi-file app to the `// realm-file:` archive.
703
+ let initialSource = "";
704
+ try {
705
+ initialSource = await buildPackedApp(opts.cwd);
706
+ } catch (err) {
707
+ console.error(`[realm-dev] /install: cannot pack ${opts.cwd}: ${(err as Error).message}`);
708
+ }
709
+ return new Response(
710
+ installHtml({ initialSource, state, appName, sidebarOpen: readSidebarOpen(req) }),
711
+ {
712
+ headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" },
713
+ },
714
+ );
715
+ }
716
+
717
+ if (path === "/install/api/preview" && req.method === "POST") {
718
+ let body: { source?: unknown };
719
+ try {
720
+ body = await req.json();
721
+ } catch {
722
+ return Response.json({ error: "invalid body" }, { status: 400 });
723
+ }
724
+ if (typeof body.source !== "string") {
725
+ return Response.json({ error: "source must be a string" }, { status: 400 });
726
+ }
727
+ try {
728
+ const result = await handlePreview(body.source, {
729
+ cwd: opts.cwd,
730
+ appName,
731
+ });
732
+ return Response.json(result);
733
+ } catch (err) {
734
+ if (err instanceof InstallHandlerError) return installErrorToResponse(err);
735
+ throw err;
736
+ }
737
+ }
738
+
739
+ if (path === "/install/api/install" && req.method === "POST") {
740
+ let body: {
741
+ source?: unknown;
742
+ config?: unknown;
743
+ grants?: unknown;
744
+ deployed?: unknown;
745
+ };
746
+ try {
747
+ body = await req.json();
748
+ } catch {
749
+ return Response.json({ error: "invalid body" }, { status: 400 });
750
+ }
751
+ if (typeof body.source !== "string") {
752
+ return Response.json({ error: "source must be a string" }, { status: 400 });
753
+ }
754
+ const config = (body.config ?? {}) as Record<string, unknown>;
755
+ // deno-lint-ignore no-explicit-any
756
+ const grants = (body.grants ?? {}) as any;
757
+ try {
758
+ const prevStack = stack;
759
+ const result = await handleInstall(
760
+ { source: body.source, config, grants, deployed: body.deployed },
761
+ { cwd: opts.cwd, realmFile: opts.realmFile, tmpDir },
762
+ );
763
+ state = result.state;
764
+ stack = result.stack;
765
+ appName = result.state.appName;
766
+ if (prevStack && prevStack !== result.stack) {
767
+ // Best-effort: drops the orphan ActiveStack from the global
768
+ // registry. The new compose is bound to the same project name, so
769
+ // nothing is taken down.
770
+ prevStack.cleanup().catch(() => {});
771
+ }
772
+ reload.push();
773
+ return new Response(null, { status: 204 });
774
+ } catch (err) {
775
+ if (err instanceof InstallHandlerError) return installErrorToResponse(err);
776
+ throw err;
777
+ }
778
+ }
779
+
780
+ const bundleMatch = path.match(/^\/bundle\/([a-f0-9]+)\.(js|css)$/);
781
+ if (bundleMatch) {
782
+ if (!state) return new Response("install required", { status: 503 });
783
+ const hash = bundleMatch[1]!;
784
+ const ext = bundleMatch[2]!;
785
+ if (!HASH_RE.test(hash)) return new Response("not found", { status: 404 });
786
+ const b = state.bundles.get(hash);
787
+ if (!b) return new Response("not found", { status: 404 });
788
+ return new Response(ext === "js" ? b.jsBytes : b.cssBytes, {
789
+ headers: {
790
+ "content-type": ext === "js" ? "application/javascript" : "text/css",
791
+ "cache-control": "no-store",
792
+ ...uiHeaders,
793
+ },
794
+ });
795
+ }
796
+
797
+ const iframeMatch = path.match(/^\/iframe\/(page|widget)\/([^/]+)$/);
798
+ if (iframeMatch) {
799
+ if (!state) return new Response("install required", { status: 503 });
800
+ const kind = iframeMatch[1]!;
801
+ const key = iframeMatch[2]!;
802
+ if (!KEY_RE.test(key)) return new Response("not found", { status: 404 });
803
+ const hash = state.surfaceHashes.get(`${kind}:${key}`);
804
+ if (!hash) return new Response("not found", { status: 404 });
805
+ const devSurface = (
806
+ kind === "page" ? state.resolvedState.ui?.pages : state.resolvedState.ui?.widgets
807
+ )?.[key];
808
+ const origin = url.origin;
809
+ // CSP mirrors Home's iframe-shell route. The iframe is sandboxed without
810
+ // `allow-same-origin` (opaque origin), so 'self' matches nothing — the
811
+ // dev origin is listed explicitly. A surface that violates CSP fails here
812
+ // the same way it does in prod, rather than after deploy.
813
+ const csp = `default-src 'none'; script-src ${origin} 'unsafe-inline'; style-src ${origin} 'unsafe-inline'; img-src ${origin} data:; font-src ${origin} data:; connect-src 'none'; frame-src 'none'; base-uri 'none'`;
814
+ return new Response(
815
+ shellHtml({
816
+ kind,
817
+ key,
818
+ bundleHash: hash,
819
+ config: devSurface?.defaultConfig,
820
+ }),
821
+ {
822
+ headers: {
823
+ "content-type": "text/html; charset=utf-8",
824
+ "cache-control": "no-store",
825
+ "content-security-policy": csp,
826
+ ...uiHeaders,
827
+ },
828
+ },
829
+ );
830
+ }
831
+
832
+ const loaderMatch = path.match(/^\/loader\/(page|widget)\/([^/]+)\/([^/]+)$/);
833
+ if (loaderMatch && req.method === "POST") {
834
+ if (!state) return Response.json({ error: "install required" }, { status: 503 });
835
+ const kind = loaderMatch[1] as "page" | "widget";
836
+ const key = loaderMatch[2]!;
837
+ const name = loaderMatch[3]!;
838
+ if (!NAME_RE.test(key) || !NAME_RE.test(name)) {
839
+ return Response.json({ error: "not found" }, { status: 404 });
840
+ }
841
+ let body: { request?: unknown };
842
+ try {
843
+ body = await req.json();
844
+ } catch {
845
+ return Response.json({ error: "invalid body" }, { status: 400 });
846
+ }
847
+ if (!isRequestShape(body.request)) {
848
+ return Response.json({ error: "invalid request shape" }, { status: 400 });
849
+ }
850
+ return await callLoader(state, kind, key, name, body.request);
851
+ }
852
+
853
+ const actionMatch = path.match(/^\/action\/(page|widget)\/([^/]+)\/([^/]+)$/);
854
+ if (actionMatch && req.method === "POST") {
855
+ if (!state) return Response.json({ error: "install required" }, { status: 503 });
856
+ const kind = actionMatch[1] as "page" | "widget";
857
+ const key = actionMatch[2]!;
858
+ const name = actionMatch[3]!;
859
+ if (!NAME_RE.test(key) || !NAME_RE.test(name)) {
860
+ return Response.json({ error: "not found" }, { status: 404 });
861
+ }
862
+ let body: { request?: unknown };
863
+ try {
864
+ body = await req.json();
865
+ } catch {
866
+ return Response.json({ error: "invalid body" }, { status: 400 });
867
+ }
868
+ if (!isRequestShape(body.request)) {
869
+ return Response.json({ error: "invalid request shape" }, { status: 400 });
870
+ }
871
+ return await callAction(state, kind, key, name, body.request);
872
+ }
873
+
874
+ if (path === "/events") {
875
+ // SSE channel — the landing page subscribes; iframe shells don't,
876
+ // they reload via the parent.
877
+ let unsubscribe: (() => void) | null = null;
878
+ const stream = new ReadableStream<Uint8Array>({
879
+ start(controller) {
880
+ controller.enqueue(new TextEncoder().encode(`: connected\n\n`));
881
+ unsubscribe = reload.subscribe(controller);
882
+ },
883
+ cancel() {
884
+ unsubscribe?.();
885
+ },
886
+ });
887
+ return new Response(stream, {
888
+ headers: {
889
+ "content-type": "text/event-stream",
890
+ "cache-control": "no-store",
891
+ connection: "keep-alive",
892
+ },
893
+ });
894
+ }
895
+
896
+ return new Response("not found", { status: 404 });
897
+ };
898
+
899
+ const server = Deno.serve(
900
+ {
901
+ port: opts.port,
902
+ hostname: "127.0.0.1",
903
+ onListen: ({ port, hostname }) => {
904
+ const url = `http://${hostname}:${port}`;
905
+ console.log("");
906
+ if (state) {
907
+ const ui = state.resolvedState.ui;
908
+ const surfaceCount =
909
+ Object.keys(ui?.pages ?? {}).length + Object.keys(ui?.widgets ?? {}).length;
910
+ const surfaces = surfaceCount === 1 ? "1 surface" : `${surfaceCount} surfaces`;
911
+ console.log(` realm-sdk dev — ${state.appName} (${surfaces})`);
912
+ } else {
913
+ console.log(` realm-sdk dev — ${appName} (install required)`);
914
+ }
915
+ console.log(` ${url}`);
916
+ console.log("");
917
+ console.log(` watching ${opts.cwd}`);
918
+ console.log(` Ctrl-C to stop`);
919
+ console.log("");
920
+ },
921
+ },
922
+ handler,
923
+ );
924
+
925
+ // Drain on Ctrl-C: the bringUpStack module hooks beforeunload, but SIGINT
926
+ // bypasses that. The first signal starts graceful shutdown (docker compose
927
+ // down, ~3-5s); a second hard-exits in case cleanup hangs, without which
928
+ // breaking out of the `for await` takes three Ctrl-Cs.
929
+ let shuttingDown = false;
930
+ const onSignal = (sig: Deno.Signal) => {
931
+ if (shuttingDown) {
932
+ console.log(`[realm-dev] ${sig} again — hard exit`);
933
+ Deno.exit(130);
934
+ }
935
+ shuttingDown = true;
936
+ console.log(`[realm-dev] ${sig} received, shutting down (Ctrl-C again to force)…`);
937
+ // Closing the watcher unblocks the `for await` loop above so its
938
+ // iterator settles and stops keeping the event loop alive.
939
+ try {
940
+ watcher.close();
941
+ } catch {
942
+ // Already closed.
943
+ }
944
+ if (pending !== null) clearTimeout(pending);
945
+ // Close every SSE subscriber up front: server.shutdown() awaits all
946
+ // in-flight responses, and an SSE stream never naturally completes,
947
+ // so a browser tab on the landing page would otherwise pin shutdown
948
+ // forever. After this returns, in-flight HTTP requests can drain.
949
+ reload.closeAll();
950
+ // Drain in the background so the signal handler returns
951
+ // immediately and the next signal can land. Docker teardown and
952
+ // HTTP shutdown are independent — run them in parallel so the
953
+ // slowest one bounds the wait, not their sum.
954
+ (async () => {
955
+ if (stack) console.log(`[realm-dev] tearing down docker stack…`);
956
+ await Promise.all([
957
+ stack?.cleanup().catch(() => {}) ?? Promise.resolve(),
958
+ server.shutdown().catch(() => {}),
959
+ ]);
960
+ console.log(`[realm-dev] done`);
961
+ Deno.exit(0);
962
+ })();
963
+ };
964
+ Deno.addSignalListener("SIGINT", () => onSignal("SIGINT"));
965
+ Deno.addSignalListener("SIGTERM", () => onSignal("SIGTERM"));
966
+
967
+ await server.finished;
968
+ }