@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,264 @@
1
+ // An app's file tree → one merged realm.ts source, with esbuild alone.
2
+ //
3
+ // The merge does two jobs, and an app can need either or both: attach the
4
+ // file-based UI surfaces, and inline the entry's relative-import closure (an app
5
+ // can have no UI at all — just realm.ts + relative helpers — and still has to
6
+ // arrive as one module, since `bundle()` blocks relative specifiers).
7
+ //
8
+ // Authoring is Next.js style: each pages/<key>.tsx / widgets/<key>.tsx exports
9
+ // `title`, `icon`, optional `loaders` / `actions` (and `cols` / `rows` for
10
+ // widgets) plus a default render fn; the author's realm.tsx never references
11
+ // them. The merge generates a glue entry that imports realm.tsx's app + each
12
+ // surface namespace, calls `app.attachSurfaces({ pages, widgets })`, and
13
+ // re-exports the app. esbuild inlines the relatives into one module; every
14
+ // package import — @yourrealm/sdk*, preact, zod — stays external. No TypeScript
15
+ // compiler is involved (surface exports are read off the namespace at runtime),
16
+ // which is what lets Home run this at install.
17
+ //
18
+ // Home consumes a packed artifact via `mergePackedSource` (unpack → confined tmp
19
+ // tree → merge); `realm-sdk dev` calls `mergeSurfaces` on the live app dir. The
20
+ // only confinement is esbuild's `--metafile` guard in `esbuildMerge`: refuse the
21
+ // merge if esbuild read any file outside the app root.
22
+
23
+ import { dirname, join } from "@std/path";
24
+ import { REALM_ENTRIES, unpackFiles } from "./pack.ts";
25
+ import { pkgRoot } from "./pkg-root.ts";
26
+
27
+ const ID_RE = /^[A-Za-z_$][\w$]*$/;
28
+
29
+ const SDK_DIR = pkgRoot(import.meta.url);
30
+
31
+ interface SurfaceFile {
32
+ kind: "page" | "widget";
33
+ key: string;
34
+ dir: "pages" | "widgets";
35
+ file: string;
36
+ }
37
+
38
+ export async function globSurfaces(
39
+ appDir: string,
40
+ kind: "page" | "widget",
41
+ ): Promise<SurfaceFile[]> {
42
+ const dir = kind === "page" ? "pages" : "widgets";
43
+ const out: SurfaceFile[] = [];
44
+ try {
45
+ for await (const entry of Deno.readDir(join(appDir, dir))) {
46
+ if (!entry.isFile || !entry.name.endsWith(".tsx")) continue;
47
+ const key = entry.name.slice(0, -".tsx".length);
48
+ if (!ID_RE.test(key)) {
49
+ throw new Error(
50
+ `${dir}/${entry.name}: surface key "${key}" must be a valid identifier — rename the file.`,
51
+ );
52
+ }
53
+ out.push({ kind, key, dir, file: entry.name });
54
+ }
55
+ } catch (err) {
56
+ if (!(err instanceof Deno.errors.NotFound)) throw err; // no pages/ or widgets/ dir is fine
57
+ }
58
+ return out.sort((a, b) => a.key.localeCompare(b.key));
59
+ }
60
+
61
+ const surfaceNs = (f: SurfaceFile): string => `__realm_${f.kind}_${f.key}`;
62
+
63
+ // `page()`/`widget()` call reading every field off the surface's namespace —
64
+ // missing optionals read as `undefined`, which the helpers + createApp tolerate.
65
+ function surfaceCall(f: SurfaceFile): string {
66
+ const ns = surfaceNs(f);
67
+ const factory = f.kind === "page" ? "__realmPage" : "__realmWidget";
68
+ const fields = [
69
+ `title: ${ns}.title`,
70
+ `icon: ${ns}.icon`,
71
+ `render: ${ns}.default`,
72
+ `stateSchema: ${ns}.stateSchema`,
73
+ `configSchema: ${ns}.configSchema`,
74
+ `loaders: ${ns}.loaders`,
75
+ `actions: ${ns}.actions`,
76
+ ];
77
+ if (f.kind === "widget") fields.push(`cols: ${ns}.cols`, `rows: ${ns}.rows`);
78
+ return `${factory}({ ${fields.join(", ")} })`;
79
+ }
80
+
81
+ // The glue entry: import the app + each surface namespace, attach the assembled
82
+ // ui, re-export the app. esbuild inlines the relatives; the app runs first
83
+ // (createApp during its import), then attachSurfaces in the glue body.
84
+ function surfaceGlue(entry: string, files: SurfaceFile[]): string {
85
+ // Surface-less app: the glue exists only to inline the entry's
86
+ // relative-import closure, so keep it to a bare re-export. Importing
87
+ // `@yourrealm/sdk/ui` with nothing to attach would pull a UI barrel into an
88
+ // app that has no UI — a QuickJS load-safety risk for no gain.
89
+ if (files.length === 0)
90
+ return `import __realmApp from "./${entry}";\nexport default __realmApp;\n`;
91
+ const lines = [
92
+ `import __realmApp from "./${entry}";`,
93
+ `import { page as __realmPage, widget as __realmWidget } from "@yourrealm/sdk/ui";`,
94
+ ];
95
+ for (const f of files) lines.push(`import * as ${surfaceNs(f)} from "./${f.dir}/${f.file}";`);
96
+ lines.push("__realmApp.attachSurfaces({", " pages: {");
97
+ for (const f of files) if (f.kind === "page") lines.push(` ${f.key}: ${surfaceCall(f)},`);
98
+ lines.push(" },", " widgets: {");
99
+ for (const f of files) if (f.kind === "widget") lines.push(` ${f.key}: ${surfaceCall(f)},`);
100
+ lines.push(" },", "});", "export default __realmApp;", "");
101
+ return lines.join("\n");
102
+ }
103
+
104
+ // esbuild / oxfmt are devDeps in node_modules/.bin, not necessarily on PATH.
105
+ // Walk up from the app dir to find them.
106
+ function findBin(startDir: string, name: string): string | null {
107
+ let dir = startDir;
108
+ while (true) {
109
+ const candidate = join(dir, "node_modules", ".bin", name);
110
+ try {
111
+ Deno.statSync(candidate);
112
+ return candidate;
113
+ } catch {
114
+ /* keep walking */
115
+ }
116
+ const parent = dirname(dir);
117
+ if (parent === dir) return null;
118
+ dir = parent;
119
+ }
120
+ }
121
+
122
+ // esbuild lives in node_modules/.bin. The author's app dir has one up the tree;
123
+ // Home's install-time tmp tree doesn't, so fall back to the SDK's own location,
124
+ // then to PATH (Home stages the binary there).
125
+ function findEsbuild(appDir: string): string {
126
+ return (
127
+ findBin(appDir, "esbuild") ??
128
+ (SDK_DIR === null ? null : findBin(SDK_DIR, "esbuild")) ??
129
+ "esbuild"
130
+ );
131
+ }
132
+
133
+ // Bundle the generated glue through esbuild via stdin, with the process cwd set
134
+ // to the app dir so the glue's relative imports (./realm.tsx, ./pages/*) resolve
135
+ // against the real tree. The app dir itself is never mutated — output + metafile
136
+ // go to a scratch dir outside it.
137
+ //
138
+ // esbuild follows every relative/absolute import straight to disk
139
+ // (`--packages=external` externalizes only bare specifiers), so a hostile
140
+ // `import "../../secret"` in a packed file would otherwise inline a
141
+ // control-plane file. The sole confinement is esbuild's own `--metafile`: refuse
142
+ // the merge if it read ANY input outside the app root. It keys off what esbuild
143
+ // actually resolved (static, dynamic, require, css) rather than a source scan,
144
+ // so no import form or line layout slips past it.
145
+ async function esbuildMerge(glue: string, appDir: string): Promise<string> {
146
+ const esbuild = findEsbuild(appDir);
147
+ const outDir = await Deno.makeTempDir({ prefix: "realm-merge-out-" });
148
+ const outFile = join(outDir, "merged.js");
149
+ const metaFile = join(outDir, "meta.json");
150
+ try {
151
+ const child = new Deno.Command(esbuild, {
152
+ args: [
153
+ "--bundle",
154
+ "--loader=tsx", // stdin has no filename to infer from
155
+ "--packages=external", // every bare import stays external; only relatives inline
156
+ // Normalize raw `zod` to the SDK re-export. A file shared between an
157
+ // app's container code and a UI surface imports `z` from raw `zod`, but
158
+ // Home's pruned image resolves only `@yourrealm/sdk`, which re-exports
159
+ // the same `z`. Alias fires before the external check, so the specifier
160
+ // is rewritten and left external, never resolved here.
161
+ "--alias:zod=@yourrealm/sdk",
162
+ "--format=esm",
163
+ "--platform=neutral",
164
+ "--target=esnext", // keep output close to source — no needless downleveling
165
+ "--jsx=preserve", // Home's bundle() does the JSX transform; keep <Page> readable
166
+ "--charset=utf8", // keep `…`/emoji literal instead of \u escapes
167
+ `--outfile=${outFile}`, // esbuild requires an output path to emit a metafile
168
+ `--metafile=${metaFile}`,
169
+ ],
170
+ cwd: appDir,
171
+ stdin: "piped",
172
+ stdout: "piped",
173
+ stderr: "piped",
174
+ }).spawn();
175
+ const writer = child.stdin.getWriter();
176
+ await writer.write(new TextEncoder().encode(glue));
177
+ await writer.close();
178
+ const { code, stderr } = await child.output();
179
+ if (code !== 0) throw new Error(new TextDecoder().decode(stderr));
180
+
181
+ // esbuild reports every input it read, keyed by a path relative to its cwd
182
+ // (the app dir). An in-app file never starts with `../`; anything that does
183
+ // (or is absolute) was read from outside the app — refuse it. `<stdin>` is
184
+ // the glue itself.
185
+ const meta = JSON.parse(await Deno.readTextFile(metaFile)) as {
186
+ inputs: Record<string, unknown>;
187
+ };
188
+ for (const input of Object.keys(meta.inputs)) {
189
+ if (input === "<stdin>") continue;
190
+ if (input.startsWith("/") || input.startsWith("../")) {
191
+ throw new Error(`merge refused: an import resolves outside the app root ("${input}")`);
192
+ }
193
+ }
194
+ return await Deno.readTextFile(outFile);
195
+ } finally {
196
+ await Deno.remove(outDir, { recursive: true }).catch(() => {});
197
+ }
198
+ }
199
+
200
+ export function findRealmEntry(appDir: string): string {
201
+ for (const name of REALM_ENTRIES) {
202
+ try {
203
+ Deno.statSync(join(appDir, name));
204
+ return name;
205
+ } catch {
206
+ /* try next */
207
+ }
208
+ }
209
+ throw new Error(`no ${REALM_ENTRIES.join(" or ")} in ${appDir}`);
210
+ }
211
+
212
+ /**
213
+ * Merge an app's tree into the one source `bundle()` ingests, using esbuild
214
+ * alone: attach the file-based surfaces AND inline the entry's relative-import
215
+ * closure. Runs at install (Home) and in dev.
216
+ *
217
+ * Every app goes through the merge, surfaces or not — a surface-less app can
218
+ * still be multi-file (`realm.ts` + plain relative helpers), and its relatives
219
+ * have to be inlined all the same or `bundle()` rejects them as blocked
220
+ * specifiers. Nothing is lost by merging the single-file case too: `bundle()`
221
+ * esbuilds the source into the QuickJS artifact immediately afterward either
222
+ * way.
223
+ */
224
+ export async function mergeSurfaces(
225
+ appDir: string,
226
+ // The entry's filename, when the caller already knows it. Defaults to the
227
+ // canonical realm.tsx/realm.ts an app dir holds — but a test fixture dir can
228
+ // hold several apps under arbitrary names (`sample.realm.ts`), and the test
229
+ // harness knows exactly which one it's merging.
230
+ entryName?: string,
231
+ ): Promise<{ source: string; surfaceCount: number }> {
232
+ const entry = entryName ?? findRealmEntry(appDir);
233
+ const files = [
234
+ ...(await globSurfaces(appDir, "page")),
235
+ ...(await globSurfaces(appDir, "widget")),
236
+ ];
237
+ return {
238
+ source: await esbuildMerge(surfaceGlue(entry, files), appDir),
239
+ surfaceCount: files.length,
240
+ };
241
+ }
242
+
243
+ /**
244
+ * Install-time entry point: unpack a packed artifact into a confined tmp tree
245
+ * and merge it into the single source `bundle()` ingests. Folded into `bundle()`
246
+ * / `bundleUiSurfaces()`, so Home handles a packed artifact at every call site.
247
+ * A hostile import that reaches outside the tmp tree is refused by esbuild's
248
+ * `--metafile` guard in `esbuildMerge`. No TypeScript compiler — safe in Home's
249
+ * least-privilege runtime.
250
+ */
251
+ export async function mergePackedSource(packed: string): Promise<string> {
252
+ const files = unpackFiles(packed);
253
+ const tmpDir = await Deno.makeTempDir({ prefix: "realm-unpack-" });
254
+ try {
255
+ for (const [path, content] of files) {
256
+ const dest = join(tmpDir, path);
257
+ await Deno.mkdir(dirname(dest), { recursive: true });
258
+ await Deno.writeTextFile(dest, content);
259
+ }
260
+ return (await mergeSurfaces(tmpDir)).source;
261
+ } finally {
262
+ await Deno.remove(tmpDir, { recursive: true }).catch(() => {});
263
+ }
264
+ }
@@ -0,0 +1,56 @@
1
+ // Resolve the `tailwindcss` binary by walking up from a starting dir
2
+ // looking for `node_modules/.bin/tailwindcss`.
3
+ //
4
+ // Under byonm (root `nodeModulesDir: "manual"`) pnpm writes a `.bin` into
5
+ // each workspace package's node_modules, so walking up from any caller
6
+ // finds the nearest one.
7
+
8
+ import { dirname, join } from "@std/path";
9
+
10
+ export function findTailwindBin(startDir: string): string {
11
+ let dir = startDir;
12
+ while (true) {
13
+ const candidate = join(dir, "node_modules", ".bin", "tailwindcss");
14
+ try {
15
+ Deno.statSync(candidate);
16
+ return candidate;
17
+ } catch {
18
+ // keep walking
19
+ }
20
+ const parent = dirname(dir);
21
+ if (parent === dir) {
22
+ throw new Error(
23
+ `tailwindcss binary not found by walking up from ${startDir}. ` +
24
+ `Add @tailwindcss/cli to a workspace package's devDependencies and run pnpm install.`,
25
+ );
26
+ }
27
+ dir = parent;
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Collect every `node_modules/.bin` directory from `startDir` up to
33
+ * the filesystem root. Useful for `realm-sdk dev`, which spawns esbuild
34
+ * + tailwindcss as bare-name commands and needs PATH primed when
35
+ * invoked directly via `deno run` (pnpm's bin-shimming only activates
36
+ * inside `pnpm <script>`). pnpm hoists most devDeps to the workspace
37
+ * root's `.bin`, but a workspace package's local `.bin` can shadow
38
+ * them — return all matches in walked order so callers prepend them
39
+ * to PATH from nearest to farthest.
40
+ */
41
+ export function findBinDirs(startDir: string): string[] {
42
+ const out: string[] = [];
43
+ let dir = startDir;
44
+ while (true) {
45
+ const candidate = join(dir, "node_modules", ".bin");
46
+ try {
47
+ const stat = Deno.statSync(candidate);
48
+ if (stat.isDirectory) out.push(candidate);
49
+ } catch {
50
+ // keep walking
51
+ }
52
+ const parent = dirname(dir);
53
+ if (parent === dir) return out;
54
+ dir = parent;
55
+ }
56
+ }
@@ -0,0 +1,192 @@
1
+ // `t.boot` — render compile output to docker-compose, splice a
2
+ // Playwright sidecar, and run `docker compose up -d --wait` against a
3
+ // unique project name. The harness returns a handle whose `page` is a
4
+ // browser context running *inside* the same compose network, so a
5
+ // realm.ts test can drive `http://serviceName:port` URLs the way a
6
+ // sibling container would.
7
+ //
8
+ // Boot reuses `compile`'s output. A failed compile is fatal here — the
9
+ // thrown error carries the full issues list so a test's catch site can
10
+ // still inspect it.
11
+ //
12
+ // Cleanup has two layers, both automatic:
13
+ // 1. The describe.ts wrapper tears down every stack a test booted
14
+ // after the body resolves or throws.
15
+ // 2. A module-level set of active handles is drained on
16
+ // `beforeunload`, so a test that crashes the process still tears
17
+ // down before exit.
18
+
19
+ import { chromium, type Page } from "playwright-core";
20
+ import { expect } from "@playwright/test";
21
+ import { convertToDockerCompose, type ComposeService } from "../runtime/docker/compose/index.ts";
22
+ import type { WorkerApp, WorkerState } from "../worker-protocol/index.ts";
23
+ import { compile as runCompile } from "./compile.ts";
24
+ import { RealmCompileError } from "../errors.ts";
25
+ import { bringUpStack } from "../dev/stack.ts";
26
+ import { fixtureIngressInput } from "./ingress-fixture.ts";
27
+ import type { CompileFixture } from "./types.ts";
28
+
29
+ // Pin this in lockstep with the playwright-core / @playwright/test
30
+ // versions in package.json AND the tag built from
31
+ // platform/playwright/Dockerfile. The run-server protocol is version-coupled.
32
+ const PLAYWRIGHT_IMAGE = "benjick/realm-playwright:1.58.0";
33
+ const PLAYWRIGHT_PORT = 14000;
34
+
35
+ interface BootInputs<App> {
36
+ bundledCode: string;
37
+ appName: string;
38
+ fixture: CompileFixture<App>;
39
+ }
40
+
41
+ export interface RealmBootHandle {
42
+ page: Page;
43
+ /**
44
+ * Map of service name → compose hostname. Indexable by any string;
45
+ * the runtime always returns `${appId}_${name}` (the renderer's compose
46
+ * key, which is also the in-network DNS name).
47
+ */
48
+ services: Record<string, string>;
49
+ expect: typeof expect;
50
+ }
51
+
52
+ /**
53
+ * Internal: what `boot()` actually returns to the describe.ts wrapper.
54
+ * The wrapper registers `cleanup` against the test body and returns
55
+ * the bare `handle` to the test author.
56
+ */
57
+ interface BootResult {
58
+ handle: RealmBootHandle;
59
+ cleanup: () => Promise<void>;
60
+ }
61
+
62
+ export async function boot<App>(inputs: BootInputs<App>): Promise<BootResult> {
63
+ const { bundledCode, appName, fixture } = inputs;
64
+ const result = await runCompile<App>({ bundledCode, appName, fixture });
65
+ if (result.error) {
66
+ throw new RealmCompileError([...result.issues]);
67
+ }
68
+
69
+ const appId = fixture.appId ?? appName;
70
+
71
+ // The harness renders in-process and never serializes to the wire, and the
72
+ // renderer reads only the fields it knows, so the resolve model's extra
73
+ // Home-only fields are simply ignored — cast straight to the wire shape.
74
+ const workerApp: WorkerApp = {
75
+ appId,
76
+ services: result.app.services as unknown as WorkerApp["services"],
77
+ ...(result.app.egress ? { egress: true } : {}),
78
+ externalNetworks: [],
79
+ dependsOn: {},
80
+ inlineFiles: {},
81
+ allowedAbsoluteHostPaths: [],
82
+ };
83
+ const wireState: WorkerState = {
84
+ apps: [workerApp],
85
+ artifactHashes: [],
86
+ workerNetworks: [],
87
+ // Nothing in the harness renders ingress, but the wire has no absent
88
+ // state for it - so state what this box's ingress would be, read off the
89
+ // same fixture the eval resolved `host.management()` from.
90
+ ingress: fixtureIngressInput(fixture.management),
91
+ };
92
+
93
+ // Single-app harness: one project per app, and boot only has one.
94
+ const compose = convertToDockerCompose(wireState)[0]!.compose;
95
+
96
+ // Mark every realm service for `realm-sdk down`. The Playwright
97
+ // sidecar gets the same label below.
98
+ for (const svc of Object.values(compose.services)) {
99
+ svc.labels = { ...svc.labels, "com.realm.test": "true" };
100
+ }
101
+
102
+ const hostPort = await pickFreePort();
103
+ const sidecarName = `${appId}_realm_playwright`;
104
+ compose.services[sidecarName] = playwrightSidecar({ appId, hostPort });
105
+
106
+ const project = `realm-test-${appId}-${nano(8)}`;
107
+ const tmpDir = await Deno.makeTempDir({ prefix: `${project}-` });
108
+
109
+ let stack;
110
+ try {
111
+ stack = await bringUpStack({ compose, project, tmpDir });
112
+ } catch (err) {
113
+ await Deno.remove(tmpDir, { recursive: true }).catch(() => {});
114
+ throw err;
115
+ }
116
+
117
+ const browser = await chromium.connect(`ws://localhost:${hostPort}/`);
118
+ const ctx = await browser.newContext();
119
+ const page = await ctx.newPage();
120
+
121
+ const services = new Proxy<Record<string, string>>(
122
+ {},
123
+ {
124
+ get: (_t, prop) => {
125
+ if (typeof prop !== "string") return undefined;
126
+ return `${appId}_${prop}`;
127
+ },
128
+ },
129
+ );
130
+
131
+ const cleanup = async () => {
132
+ await ctx.close().catch(() => {});
133
+ await browser.close().catch(() => {});
134
+ await stack.cleanup();
135
+ await Deno.remove(tmpDir, { recursive: true }).catch(() => {});
136
+ };
137
+
138
+ return { handle: { page, services, expect }, cleanup };
139
+ }
140
+
141
+ function playwrightSidecar(opts: { appId: string; hostPort: number }): ComposeService {
142
+ return {
143
+ image: PLAYWRIGHT_IMAGE,
144
+ labels: { "com.realm.test": "true" },
145
+ ports: [{ target: PLAYWRIGHT_PORT, published: opts.hostPort, protocol: "tcp" }],
146
+ networks: { [`realm_${opts.appId}`]: { aliases: ["realm-playwright"] } },
147
+ // `--wait` only waits for healthy containers. Without a check
148
+ // here, compose returns as soon as the container starts, which
149
+ // races chromium.connect against playwright's own boot. The check
150
+ // is the same TCP-probe e2e uses.
151
+ healthcheck: {
152
+ test: [
153
+ "CMD",
154
+ "node",
155
+ "-e",
156
+ `require('net').createConnection(${PLAYWRIGHT_PORT}, 'localhost').on('connect', () => process.exit(0)).on('error', () => process.exit(1))`,
157
+ ],
158
+ interval: "2s",
159
+ timeout: "3s",
160
+ retries: 60,
161
+ start_period: "5s",
162
+ },
163
+ cap_drop: ["ALL"],
164
+ cap_add: [],
165
+ security_opt: ["no-new-privileges:true"],
166
+ pids_limit: 4096,
167
+ ulimits: { nofile: 65536, nproc: 4096 },
168
+ restart: "no",
169
+ logging: { driver: "json-file", options: { "max-size": "10m", "max-file": "3" } },
170
+ };
171
+ }
172
+
173
+ async function pickFreePort(): Promise<number> {
174
+ // Another process could grab the port between close() and `docker compose
175
+ // up`; the bind failure surfaces at the docker-compose layer. The window is
176
+ // small enough not to matter across a test suite.
177
+ const listener = Deno.listen({ port: 0 });
178
+ const port = (listener.addr as Deno.NetAddr).port;
179
+ listener.close();
180
+ return port;
181
+ }
182
+
183
+ function nano(n: number): string {
184
+ // Project names are docker-compose-validated; a-z 0-9 only. Cryptographic
185
+ // uniqueness is not needed — collision odds with 8 lowercase hex chars are
186
+ // negligible across a single test run.
187
+ const bytes = new Uint8Array(Math.ceil(n / 2));
188
+ crypto.getRandomValues(bytes);
189
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0"))
190
+ .join("")
191
+ .slice(0, n);
192
+ }