@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
package/src/bundle.ts ADDED
@@ -0,0 +1,546 @@
1
+ // Bundles a realm.ts source into a self-contained JS string for QuickJS
2
+ // evaluation. The bundling step is independent of permission shape.
3
+
4
+ import { dirname, join } from "@std/path";
5
+ import { log } from "@local/logger";
6
+ import { pkgRoot } from "./pkg-root.ts";
7
+ import { RealmCompileError } from "./errors.ts";
8
+ import { sha256Hex } from "./generator/hash.ts";
9
+ import { isPacked } from "./pack.ts";
10
+ import { mergePackedSource } from "./surfaces.ts";
11
+
12
+ const EVAL_PKG_DIR = pkgRoot(import.meta.url);
13
+
14
+ // `preact` and `preact/hooks` are allowed because surface render fns import
15
+ // hooks directly from preact, and the static import has to resolve at QuickJS
16
+ // bundle time. The imports never fire inside QuickJS — render fns only run in
17
+ // the iframe — so both specifiers alias to local noop stubs below rather than
18
+ // pulling real preact into the sandbox bundle.
19
+ const ALLOWED_IMPORT_PREFIXES = ["@yourrealm/sdk", "preact"];
20
+
21
+ function validateImports(source: string): void {
22
+ const importPattern = /(?:import|export)\s.*?from\s+["']([^"']+)["']|import\s*["']([^"']+)["']/g;
23
+ let match;
24
+ while ((match = importPattern.exec(source)) !== null) {
25
+ const specifier = match[1] ?? match[2]!;
26
+ if (specifier.startsWith("./") || specifier.startsWith("../") || specifier.startsWith("/")) {
27
+ throw new RealmCompileError({
28
+ kind: "validation",
29
+ path: ["imports"],
30
+ message: `blocked specifier: ${specifier}`,
31
+ });
32
+ }
33
+ if (!ALLOWED_IMPORT_PREFIXES.some((p) => specifier === p || specifier.startsWith(p + "/"))) {
34
+ throw new RealmCompileError({
35
+ kind: "validation",
36
+ path: ["imports"],
37
+ message: `blocked specifier: ${specifier}`,
38
+ });
39
+ }
40
+ }
41
+ }
42
+
43
+ function findNodeModules(startDir: string): string | null {
44
+ // Walk up looking for a node_modules that exposes `@yourrealm/sdk`. The first
45
+ // match isn't always right: when this file lives inside the SDK itself,
46
+ // `packages/sdk/node_modules/` exists but can't contain `@yourrealm/sdk` (a
47
+ // package can't depend on itself). The caller's node_modules (Home, an app
48
+ // dir, the workspace root) is the one esbuild resolves realm.ts's imports in.
49
+ let dir = startDir;
50
+ while (true) {
51
+ const candidate = join(dir, "node_modules");
52
+ try {
53
+ Deno.statSync(candidate);
54
+ try {
55
+ Deno.statSync(join(candidate, "@yourrealm", "sdk"));
56
+ return candidate;
57
+ } catch {
58
+ // node_modules exists but doesn't expose @yourrealm/sdk - keep walking
59
+ }
60
+ } catch {
61
+ // no node_modules at this level
62
+ }
63
+ const parent = dirname(dir);
64
+ if (parent === dir) return null;
65
+ dir = parent;
66
+ }
67
+ }
68
+
69
+ // The esbuild binary usually sits in the same node_modules bundle() already
70
+ // resolved (`<nodeModules>/.bin/esbuild`). Spawn that path instead of a bare
71
+ // `esbuild`, which only resolves when the *invoking* package depends on esbuild
72
+ // (Home's runtime ENV, the SDK's own tests) — not when `realm-sdk build` runs
73
+ // from an app dir that doesn't.
74
+ //
75
+ // The second candidate is what pnpm's isolated layout requires: only a
76
+ // package's *direct* dependencies' binaries land in its `.bin`, so an app dir
77
+ // gets `.bin/realm-sdk` and nothing else while esbuild (the SDK's dependency,
78
+ // not the app's) stays in `packages/sdk/node_modules/.bin`. A bare `esbuild` is
79
+ // the last resort, and only resolves when something put it on PATH.
80
+ export function esbuildBinIn(nodeModules: string | null): string {
81
+ const candidates = [
82
+ nodeModules === null ? null : join(nodeModules, ".bin", "esbuild"),
83
+ EVAL_PKG_DIR === null ? null : join(EVAL_PKG_DIR, "node_modules", ".bin", "esbuild"),
84
+ ];
85
+ for (const candidate of candidates) {
86
+ if (candidate === null) continue;
87
+ try {
88
+ Deno.statSync(candidate);
89
+ return candidate;
90
+ } catch {
91
+ // not here - try the next candidate
92
+ }
93
+ }
94
+ return "esbuild";
95
+ }
96
+
97
+ // A process-stable dir per resolved node_modules, holding a single
98
+ // `node_modules` symlink. Each bundle runs in a temp *subdir* of it and lets
99
+ // esbuild walk up to resolve `@yourrealm/sdk`, so the symlink is created once
100
+ // rather than per call: per-call create/remove churn races under concurrent
101
+ // bundling (`deno test --parallel`), where esbuild fails EINVAL/ENOENT on the
102
+ // just-created symlink. Roots leak for the process lifetime (one tiny symlink
103
+ // dir each) - the OS reclaims /tmp.
104
+ const bundleRoots = new Map<string, string>();
105
+ function bundleRootFor(nodeModules: string): string {
106
+ const cached = bundleRoots.get(nodeModules);
107
+ if (cached) return cached;
108
+ const root = Deno.makeTempDirSync({ prefix: "realm-bundle-root-" });
109
+ Deno.symlinkSync(nodeModules, join(root, "node_modules"));
110
+ bundleRoots.set(nodeModules, root);
111
+ return root;
112
+ }
113
+
114
+ // ---- on-disk bundle cache ---------------------------------------------------
115
+ //
116
+ // Memoize successful bundles. Recompute re-evals every app on every trigger
117
+ // (webhook, boot, deploy) and each eval re-bundles, so without this an unchanged
118
+ // app re-spawns esbuild on every reconcile.
119
+ //
120
+ // The cache lives on disk rather than in the heap because of what it holds: a
121
+ // bundle is ~610KB, ~110ms of esbuild to produce, ~0.4ms to read back. An in-RAM
122
+ // map is ~610MB at 1000 apps, and capping it evicts every entry before reuse,
123
+ // because a recompute touches every installed app. Disk has neither problem.
124
+ //
125
+ // Content-addressed like `ui-bundle-storage.ts`: `<key>.js`, written
126
+ // tmp-then-rename so a partial file is never visible at the real path. Nothing
127
+ // here is durable - the whole directory can be deleted at any moment, at a cost
128
+ // of one rebuild per app.
129
+
130
+ const HASH_RE = /^[0-9a-f]{64}$/;
131
+
132
+ let cacheDirOverride: string | null = null;
133
+ let cacheDirResolved: string | null | undefined;
134
+ let buildId: string | null = null;
135
+
136
+ /**
137
+ * Point the bundle cache at a persistent directory. Home calls this at boot with
138
+ * a path under `HOME_DATA_DIR` so the cache survives a restart. Everything else
139
+ * (the CLI, app tests, `realm-sdk dev`) leaves it unset and shares one directory
140
+ * under the temp dir, which a container start wipes and the OS reclaims.
141
+ *
142
+ * `buildId` identifies the shipped build of the SDK, and a persistent directory
143
+ * needs one whenever the SDK isn't on disk as source - see `sdkFingerprint`.
144
+ * Without it the persistent directory is refused and the ephemeral one used
145
+ * instead: slower, never stale.
146
+ */
147
+ export function setBundleCacheDir(dir: string, opts?: { buildId?: string }): void {
148
+ cacheDirOverride = dir;
149
+ buildId = opts?.buildId ?? null;
150
+ cacheDirResolved = undefined;
151
+ fingerprint = null;
152
+ }
153
+
154
+ /** Shared by every process that didn't ask for a directory. */
155
+ function ephemeralCacheDir(): string | null {
156
+ const base = Deno.env.get("TMPDIR") ?? "/tmp";
157
+ // One shared directory, not one per process: a per-process temp dir is never
158
+ // reclaimed until the OS gets to it, and `pnpm test` alone spawns dozens.
159
+ // Sharing is safe because entries are content-addressed.
160
+ for (const dir of [join(base, "realm-bundle-cache"), null]) {
161
+ try {
162
+ if (dir === null) return Deno.makeTempDirSync({ prefix: "realm-bundle-cache-" });
163
+ Deno.mkdirSync(dir, { recursive: true });
164
+ return dir;
165
+ } catch {
166
+ // Try the next candidate; a null return disables caching entirely.
167
+ }
168
+ }
169
+ return null;
170
+ }
171
+
172
+ /** Where bundles land, or `null` when nothing is usable and we bundle uncached. */
173
+ function bundleCacheDir(): string | null {
174
+ if (cacheDirResolved !== undefined) return cacheDirResolved;
175
+ if (cacheDirOverride !== null) {
176
+ try {
177
+ Deno.mkdirSync(cacheDirOverride, { recursive: true });
178
+ cacheDirResolved = cacheDirOverride;
179
+ return cacheDirResolved;
180
+ } catch (err) {
181
+ // A read-only or full data dir costs speed, not correctness. Falling back
182
+ // rather than throwing keeps an unwritable dir from failing every compile,
183
+ // which would turn Home's default-app seeding into a crashloop.
184
+ log.warn(`bundle cache: ${cacheDirOverride} unusable, falling back: ${String(err)}`);
185
+ }
186
+ }
187
+ cacheDirResolved = ephemeralCacheDir();
188
+ return cacheDirResolved;
189
+ }
190
+
191
+ // The key has to cover the SDK build as well as the app source: esbuild inlines
192
+ // `@yourrealm/sdk` into every bundle, so one realm.ts compiles to different
193
+ // output after an SDK change, and a cache on disk outlives the process and, in
194
+ // production, the *image* (HOME_DATA_DIR survives an upgrade).
195
+ //
196
+ // Only one of the two layouts can be fingerprinted from disk:
197
+ //
198
+ // dev checkout - `@yourrealm/sdk` resolves to `packages/sdk`, so `src/` holds
199
+ // every input, including this file (the esbuild argv and the preact/jsx stub
200
+ // text inlined into every bundle) and `surfaces.ts` (which rewrites a packed
201
+ // app's source before esbuild sees it). Hashing `src/` is sound.
202
+ //
203
+ // Home image - only the pre-bundled `dist/` is staged, built from the barrel
204
+ // entries alone. This file, `surfaces.ts`, `pack.ts`, `compile.ts` and
205
+ // `sandbox.ts` are NOT in it - they can't be, they touch `Deno` at module
206
+ // scope - so the files that decide bundle output are exactly the ones a
207
+ // `dist/` hash misses.
208
+ //
209
+ // A dist-only layout therefore needs `buildId` from the caller (Home passes the
210
+ // image's GIT_HASH). Without one the persistent directory is refused outright,
211
+ // because a cache that cannot be invalidated is worse than no cache.
212
+ let fingerprint: Promise<string> | null = null;
213
+
214
+ function sdkFingerprint(): Promise<string> {
215
+ if (fingerprint === null) {
216
+ const pending = computeSdkFingerprint();
217
+ // Don't pin a rejection: this tree is known to throw transiently (see the
218
+ // esbuild retry note below), and a memoized failure would fail every
219
+ // compile for the life of the process.
220
+ pending.catch(() => {
221
+ if (fingerprint === pending) fingerprint = null;
222
+ });
223
+ fingerprint = pending;
224
+ }
225
+ return fingerprint;
226
+ }
227
+
228
+ async function collectSdkFiles(root: string, dir: string, out: string[]): Promise<void> {
229
+ const entries = [];
230
+ for await (const entry of Deno.readDir(dir)) entries.push(entry);
231
+ // readDir order isn't stable across filesystems and the hash is order-
232
+ // sensitive: two boxes must agree on the key to reuse a restored cache.
233
+ entries.sort((a, b) => (a.name < b.name ? -1 : 1));
234
+ for (const entry of entries) {
235
+ const path = join(dir, entry.name);
236
+ if (entry.isDirectory) {
237
+ if (entry.name === "node_modules") continue; // a dev checkout has one; esbuild reaches it by resolution, not by path
238
+ await collectSdkFiles(root, path, out);
239
+ } else if (/\.(ts|tsx|js|mjs|json)$/.test(entry.name)) {
240
+ // Relative to the SDK root, so where the checkout happens to live doesn't
241
+ // change the key - a restored HOME_DATA_DIR keeps working.
242
+ out.push(path.slice(root.length), await Deno.readTextFile(path));
243
+ }
244
+ }
245
+ }
246
+
247
+ async function computeSdkFingerprint(): Promise<string> {
248
+ const nodeModules = findNodeModules(
249
+ Deno.env.get("ESBUILD_RESOLVE_DIR") ?? EVAL_PKG_DIR ?? Deno.cwd(),
250
+ );
251
+ const sdkDir = nodeModules === null ? EVAL_PKG_DIR : join(nodeModules, "@yourrealm", "sdk");
252
+ if (sdkDir === null) {
253
+ throw new Error(
254
+ "bundle cache: no node_modules exposes @yourrealm/sdk and the SDK package is not on disk",
255
+ );
256
+ }
257
+ const parts: string[] = [];
258
+ let hashedSource = false;
259
+ for (const sub of ["src", "dist"]) {
260
+ // Probe the subdir, then walk it unguarded: guarding the whole walk would
261
+ // swallow a NotFound raised *inside* it - a file vanishing mid-walk - and
262
+ // memoize the truncated prefix as if it were the whole tree, exactly the
263
+ // staleness this fingerprint exists to prevent.
264
+ try {
265
+ if (!(await Deno.stat(join(sdkDir, sub))).isDirectory) continue;
266
+ } catch (e) {
267
+ if (e instanceof Deno.errors.NotFound) continue;
268
+ throw e;
269
+ }
270
+ await collectSdkFiles(sdkDir, join(sdkDir, sub), parts);
271
+ if (sub === "src") hashedSource = true;
272
+ }
273
+ if (parts.length === 0) {
274
+ throw new Error(
275
+ `bundle cache: no SDK sources under ${sdkDir}/{src,dist} to fingerprint - ` +
276
+ `a cached bundle could not be invalidated when the SDK changes`,
277
+ );
278
+ }
279
+ // `dist/` alone cannot identify the bundler (see the note above), so a caller
280
+ // that wants a persistent directory has to name its build. Refuse the
281
+ // directory rather than hand back a key that can't invalidate.
282
+ if (buildId !== null) parts.push(`buildId:${buildId}`);
283
+ if (!hashedSource && buildId === null && cacheDirOverride !== null) {
284
+ log.warn(
285
+ `bundle cache: ${sdkDir} ships no src/ and no buildId was given, ` +
286
+ `so ${cacheDirOverride} can't be invalidated - using an ephemeral cache`,
287
+ );
288
+ cacheDirOverride = null;
289
+ cacheDirResolved = undefined;
290
+ }
291
+ // package.json too, for its dependency ranges and `exports` map. It is the one
292
+ // input not covered exactly: a `pnpm update` inside an unchanged range swaps
293
+ // an inlined dependency without moving anything hashed here. The image path
294
+ // has no such gap (dist/ is pre-bundled, so a dependency bump rewrites it),
295
+ // and a dev checkout clears the cache with the rest of dev state.
296
+ try {
297
+ parts.push("package.json", await Deno.readTextFile(join(sdkDir, "package.json")));
298
+ } catch (e) {
299
+ if (!(e instanceof Deno.errors.NotFound)) throw e;
300
+ }
301
+ // The esbuild binary is the other input to the output. Stat rather than
302
+ // `--version`: a spawn costs more than the whole tree read above, and a
303
+ // swapped binary moves size and mtime together.
304
+ try {
305
+ const stat = Deno.statSync(esbuildBinIn(nodeModules));
306
+ parts.push(`esbuild:${stat.size}:${stat.mtime?.getTime() ?? 0}`);
307
+ } catch {
308
+ // Not resolvable here - bundling itself will fail next, with a better message.
309
+ }
310
+ return await sha256Hex(parts.join("\0"));
311
+ }
312
+
313
+ // Collapses concurrent callers asking for the same source onto one esbuild run
314
+ // (multi-worker recompute, `deno test --parallel`). Entries are deleted the
315
+ // moment they settle, so nothing is retained here - that is the disk's job - and
316
+ // a transient esbuild flake (see the retry note below) isn't pinned.
317
+ const inFlight = new Map<string, Promise<string>>();
318
+
319
+ /**
320
+ * Bundle realm.ts source into a self-contained JS string.
321
+ * Inlines @yourrealm/sdk and all dependencies (zod, dayjs).
322
+ * Output is an IIFE that assigns the module exports to `__realm_module__`.
323
+ *
324
+ * Uses esbuild CLI binary (not the JS API) so it works when Home
325
+ * is bundled into a single file by Deno.bundle(). Memoized on disk.
326
+ */
327
+ export async function bundle(source: string): Promise<string> {
328
+ const key = await sha256Hex(`${await sdkFingerprint()}\0${source}`);
329
+ const existing = inFlight.get(key);
330
+ if (existing) return await existing;
331
+ // No await between the call and the set, so two callers can't both miss.
332
+ const pending = bundleThroughDisk(key, source);
333
+ inFlight.set(key, pending);
334
+ try {
335
+ return await pending;
336
+ } finally {
337
+ inFlight.delete(key);
338
+ }
339
+ }
340
+
341
+ async function bundleThroughDisk(key: string, source: string): Promise<string> {
342
+ const dir = bundleCacheDir();
343
+ if (dir === null) return await bundleUncached(source);
344
+ const path = join(dir, `${key}.js`);
345
+ try {
346
+ const hit = await Deno.readTextFile(path);
347
+ // A hit has to look like esbuild's output before it is trusted. `rename`
348
+ // isn't preceded by an fsync, so a crash in that window can leave a torn or
349
+ // empty file at the real path - and since every read below refreshes the
350
+ // mtime, the age sweep would never reap it. Falling through rebuilds and
351
+ // overwrites it instead.
352
+ if (!hit.includes("__realm_module__")) throw new Deno.errors.NotFound(path);
353
+ // Keep entries in use young, so the sweep reaps only what stopped being read
354
+ // (an app's previous source, a discarded install preview) and never an
355
+ // installed app. Awaited rather than floated: an op still in flight when a
356
+ // test ends is what `deno test`'s sanitizer reports as a leak, and one
357
+ // stat-sized write against a ~110ms miss isn't worth that class of flake.
358
+ try {
359
+ const now = new Date();
360
+ await Deno.utime(path, now, now);
361
+ } catch {
362
+ // A read-only cache dir still serves reads; it just can't age entries.
363
+ }
364
+ return hit;
365
+ } catch (e) {
366
+ if (!(e instanceof Deno.errors.NotFound)) throw e;
367
+ }
368
+
369
+ const code = await bundleUncached(source);
370
+ try {
371
+ const tmp = `${path}.tmp-${crypto.randomUUID()}`;
372
+ await Deno.writeTextFile(tmp, code);
373
+ await Deno.rename(tmp, path);
374
+ } catch {
375
+ // A full or read-only cache dir costs speed, not correctness - the bundle in
376
+ // hand is already good. Swallow so a deploy never fails on a cache write.
377
+ }
378
+ return code;
379
+ }
380
+
381
+ /**
382
+ * Delete cached bundles that nothing has read for `maxAgeMs`, plus any
383
+ * `.tmp-*` file a crash left mid-write. Returns the number of files removed.
384
+ *
385
+ * Age, not liveness: the key is a hash of (SDK build + source), so there is no
386
+ * app id to match rows against, and an app that changes its source orphans its
387
+ * old entry with no event to hang a sweep off. Reads keep live entries young,
388
+ * so an installed app is never reaped no matter how long it sits unchanged.
389
+ *
390
+ * Per-file errors are swallowed so one bad file doesn't stop the sweep.
391
+ */
392
+ export async function sweepBundleCache(maxAgeMs: number): Promise<number> {
393
+ const dir = bundleCacheDir();
394
+ if (dir === null) return 0;
395
+ const cutoff = Date.now() - maxAgeMs;
396
+ let removed = 0;
397
+ try {
398
+ // Deno.readDir is lazy - a missing dir surfaces on first iteration.
399
+ for await (const entry of Deno.readDir(dir)) {
400
+ if (!entry.isFile) continue;
401
+ const stale = /\.tmp-[0-9a-f-]+$/.test(entry.name);
402
+ if (!stale && !(entry.name.endsWith(".js") && HASH_RE.test(entry.name.slice(0, -3))))
403
+ continue;
404
+ const path = join(dir, entry.name);
405
+ try {
406
+ const stat = await Deno.stat(path);
407
+ if ((stat.mtime?.getTime() ?? 0) > cutoff) continue;
408
+ await Deno.remove(path);
409
+ removed++;
410
+ } catch {
411
+ // Best effort - the next sweep picks up survivors.
412
+ }
413
+ }
414
+ } catch (e) {
415
+ if (e instanceof Deno.errors.NotFound) return 0;
416
+ throw e;
417
+ }
418
+ return removed;
419
+ }
420
+
421
+ async function bundleUncached(source: string): Promise<string> {
422
+ const resolveDir = Deno.env.get("ESBUILD_RESOLVE_DIR") ?? EVAL_PKG_DIR ?? Deno.cwd();
423
+
424
+ // A packed artifact is unpacked and its file-based surfaces merged in here, at
425
+ // install — with esbuild alone, no TypeScript compiler, so it is safe in Home.
426
+ // A plain (unmarked) source passes straight through, and the merge inlines
427
+ // relatives so validateImports below still only sees the package allowlist.
428
+ if (isPacked(source)) source = await mergePackedSource(source);
429
+
430
+ validateImports(source);
431
+
432
+ // Run inside a subdir of the shared root (which holds the one node_modules
433
+ // symlink) so esbuild resolves @yourrealm/sdk by walking up - no per-call
434
+ // symlink. With no node_modules found it falls back to a bare /tmp dir, where
435
+ // esbuild cannot resolve the SDK.
436
+ const nodeModules = findNodeModules(resolveDir);
437
+ const root = nodeModules ? bundleRootFor(nodeModules) : undefined;
438
+ const tmpDir = await Deno.makeTempDir(
439
+ root ? { dir: root, prefix: "b-" } : { prefix: "realm-bundle-" },
440
+ );
441
+ // Always .tsx so esbuild's tsx loader handles both plain TS and JSX. The
442
+ // render fns inside `ui:` surfaces never run inside QuickJS (only their
443
+ // existence is checked), so the JSX compiles to captured `h(...)` calls that
444
+ // fire only when the iframe runtime mounts.
445
+ const tmpFile = join(tmpDir, "realm-eval.tsx");
446
+ // esbuild's automatic JSX transform emits `import { jsx } from
447
+ // "@yourrealm/sdk/jsx-runtime"` for every JSX element, and the SDK's
448
+ // jsx-runtime entry re-exports preact/jsx-runtime — which Home's runtime image
449
+ // does not stage. The QuickJS bundle never invokes render, so these stubs in
450
+ // the same tmpdir let esbuild short-circuit the chain locally.
451
+ const jsxStubPath = join(tmpDir, "jsx-runtime-stub.js");
452
+ const preactStubPath = join(tmpDir, "preact-stub.js");
453
+ const preactHooksStubPath = join(tmpDir, "preact-hooks-stub.js");
454
+
455
+ try {
456
+ await Deno.writeTextFile(tmpFile, source);
457
+ await Deno.writeTextFile(
458
+ jsxStubPath,
459
+ // Identity / noop exports. Render fns capture these in their
460
+ // closure but never invoke them inside QuickJS.
461
+ "const noop = () => null;\n" +
462
+ "export const jsx = noop;\n" +
463
+ "export const jsxs = noop;\n" +
464
+ "export const jsxDEV = noop;\n" +
465
+ "export const Fragment = noop;\n",
466
+ );
467
+ await Deno.writeTextFile(
468
+ preactStubPath,
469
+ "const noop = () => null;\n" +
470
+ "export const h = noop;\n" +
471
+ "export const Fragment = noop;\n" +
472
+ "export const render = noop;\n",
473
+ );
474
+ await Deno.writeTextFile(
475
+ preactHooksStubPath,
476
+ // Same shape as preact/hooks at the type level — the render fn captures
477
+ // these names but never calls them inside QuickJS. The iframe runtime
478
+ // supplies the real implementations.
479
+ "const noop = () => {};\n" +
480
+ "export const useState = (init) => [init, noop];\n" +
481
+ "export const useEffect = noop;\n" +
482
+ "export const useMemo = (fn) => fn();\n" +
483
+ "export const useCallback = (fn) => fn;\n" +
484
+ "export const useReducer = (_, init) => [init, noop];\n" +
485
+ "export const useRef = (init) => ({ current: init });\n",
486
+ );
487
+
488
+ const esbuildArgs = [
489
+ tmpFile,
490
+ "--bundle",
491
+ "--format=iife",
492
+ "--global-name=__realm_module__",
493
+ "--platform=neutral",
494
+ "--main-fields=module,main",
495
+ "--target=es2020",
496
+ "--jsx=automatic",
497
+ "--jsx-import-source=@yourrealm/sdk",
498
+ // Short-circuits the jsx-runtime chain to the local stubs above, so
499
+ // preact need not be resolvable inside Home's eval image.
500
+ `--alias:preact/jsx-runtime=${jsxStubPath}`,
501
+ `--alias:preact/hooks=${preactHooksStubPath}`,
502
+ `--alias:preact=${preactStubPath}`,
503
+ ];
504
+
505
+ // Retry transient resolution/FS hiccups. Running ~16 esbuild subprocesses at
506
+ // once (`deno test --parallel`) occasionally trips macOS resolving the SDK
507
+ // through pnpm's symlinked node_modules — a spurious "Could not resolve",
508
+ // EINVAL, or ENOENT that clears on a retry. A syntax error doesn't match and
509
+ // fails on the first attempt; a genuinely unresolvable (but allowlisted) SDK
510
+ // subpath is textually identical to the transient case, so it pays the
511
+ // retries and then fails with the same esbuild message.
512
+ const TRANSIENT =
513
+ /Could not resolve|Cannot read file|invalid argument|no such file or directory|operation not permitted/;
514
+ for (let attempt = 0; ; attempt++) {
515
+ // Re-resolve the binary on each attempt. Under `nodeModulesDir: "auto"`
516
+ // Deno rebuilds the workspace-member link (node_modules/@yourrealm/sdk) on
517
+ // every invocation as a remove-then-symlink, leaving a window where the
518
+ // link is gone; a bundle landing in another process's window resolves no
519
+ // node_modules, falls back to a bare "esbuild" that isn't on PATH, and
520
+ // dies. Re-resolving makes that window cost a retry, not the whole eval.
521
+ let output;
522
+ try {
523
+ output = await new Deno.Command(esbuildBinIn(findNodeModules(resolveDir)), {
524
+ args: esbuildArgs,
525
+ stdout: "piped",
526
+ stderr: "piped",
527
+ }).output();
528
+ } catch (e) {
529
+ // A spawn failure throws rather than returning a code, so it would
530
+ // otherwise skip the retry below entirely.
531
+ if (attempt >= 2) throw e;
532
+ await new Promise((r) => setTimeout(r, 20 * (attempt + 1)));
533
+ continue;
534
+ }
535
+ const { code, stdout, stderr } = output;
536
+ if (code === 0) return new TextDecoder().decode(stdout);
537
+ const err = new TextDecoder().decode(stderr);
538
+ if (attempt >= 2 || !TRANSIENT.test(err)) {
539
+ throw new RealmCompileError({ kind: "eval", message: err });
540
+ }
541
+ await new Promise((r) => setTimeout(r, 20 * (attempt + 1)));
542
+ }
543
+ } finally {
544
+ await Deno.remove(tmpDir, { recursive: true }).catch(() => {});
545
+ }
546
+ }
@@ -0,0 +1,84 @@
1
+ // `realm-sdk build-dev-landing --out <dir>` — esbuild the dev-landing preact
2
+ // entry into `dev-landing.js`, next to `runtime.js`. CSS is shared: runtime.css's
3
+ // Tailwind scan already covers `packages/sdk/src/ui-runtime/dev-landing-entry.tsx`.
4
+ // `realm-sdk dev` builds it alongside runtime.js + install-panel.js on first boot
5
+ // and serves it at `/dev-landing.js`, mounted by a thin HTML shell.
6
+
7
+ import { resolve } from "@std/path";
8
+ import { sdkDir } from "../pkg-root.ts";
9
+
10
+ const ENTRY = resolve(sdkDir(import.meta.url), "src/ui-runtime/dev-landing-entry.tsx");
11
+
12
+ interface BuildOpts {
13
+ outDir: string;
14
+ }
15
+
16
+ async function buildJs(outDir: string): Promise<void> {
17
+ const cmd = new Deno.Command("esbuild", {
18
+ args: [
19
+ ENTRY,
20
+ "--bundle",
21
+ `--outfile=${outDir}/dev-landing.js`,
22
+ "--format=esm",
23
+ "--platform=browser",
24
+ "--target=es2020",
25
+ "--main-fields=module,main",
26
+ "--jsx=automatic",
27
+ "--jsx-import-source=preact",
28
+ "--alias:react=preact/compat",
29
+ "--alias:react-dom=preact/compat",
30
+ "--alias:react/jsx-runtime=preact/jsx-runtime",
31
+ '--define:process.env.NODE_ENV="production"',
32
+ "--minify",
33
+ ],
34
+ stdout: "piped",
35
+ stderr: "piped",
36
+ });
37
+ const { code, stderr } = await cmd.output();
38
+ if (code !== 0) {
39
+ const err = new TextDecoder().decode(stderr);
40
+ throw new Error(`esbuild dev-landing failed:\n${err}`);
41
+ }
42
+ }
43
+
44
+ async function buildDevLanding(opts: BuildOpts): Promise<void> {
45
+ const outDir = resolve(Deno.cwd(), opts.outDir);
46
+ await Deno.mkdir(outDir, { recursive: true });
47
+ await buildJs(outDir);
48
+ const jsStat = await Deno.stat(`${outDir}/dev-landing.js`);
49
+ console.log(`dev-landing → ${outDir}: dev-landing.js (${(jsStat.size / 1024).toFixed(1)} KB)`);
50
+ }
51
+
52
+ export async function runBuildDevLanding(args: string[]): Promise<number> {
53
+ let outDir = "./ui-runtime";
54
+ for (let i = 0; i < args.length; i++) {
55
+ const a = args[i]!;
56
+ if (a === "--out" || a === "-o") {
57
+ const next = args[i + 1];
58
+ if (!next) {
59
+ console.error("--out requires a directory argument");
60
+ return 2;
61
+ }
62
+ outDir = next;
63
+ i++;
64
+ continue;
65
+ }
66
+ if (a === "--help" || a === "-h") {
67
+ console.log(`Usage: realm-sdk build-dev-landing [--out DIR]
68
+
69
+ Builds the dev landing entry (dev-landing.js) into DIR.
70
+ DIR defaults to ./ui-runtime (relative to the current directory).
71
+ `);
72
+ return 0;
73
+ }
74
+ console.error(`Unknown flag: ${a}`);
75
+ return 2;
76
+ }
77
+ try {
78
+ await buildDevLanding({ outDir });
79
+ return 0;
80
+ } catch (err) {
81
+ console.error((err as Error).message);
82
+ return 1;
83
+ }
84
+ }