ignotum 0.0.13 → 0.0.15

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 (131) hide show
  1. package/README.md +6 -6
  2. package/dist/cli/bin.mjs +6111 -4448
  3. package/dist/cli/bin.mjs.map +1 -1
  4. package/dist/cli/emscripten-module-D5GzfUNv.mjs +533 -0
  5. package/dist/cli/emscripten-module-D5GzfUNv.mjs.map +1 -0
  6. package/dist/cli/ffi-7IElU4Co.mjs +148 -0
  7. package/dist/cli/ffi-7IElU4Co.mjs.map +1 -0
  8. package/dist/cli/managed-release-WHYLZwaS.mjs +10902 -0
  9. package/dist/cli/managed-release-WHYLZwaS.mjs.map +1 -0
  10. package/dist/cli/managed-release.d.mts +1 -0
  11. package/dist/cli/managed-release.mjs +14 -0
  12. package/dist/cli/managed-release.mjs.map +1 -0
  13. package/dist/cli/module-ES6BEMUI-D36I4NVn.mjs +1289 -0
  14. package/dist/cli/module-ES6BEMUI-D36I4NVn.mjs.map +1 -0
  15. package/dist/runtime/{api-Cv3hMbzo.d.ts → api-B4fwp5a2.d.ts} +4 -4
  16. package/dist/runtime/bootstrap-BISQSn_O.js +916 -0
  17. package/dist/runtime/bootstrap-BISQSn_O.js.map +1 -0
  18. package/dist/runtime/bootstrap-BTFScKAc.d.ts +545 -0
  19. package/dist/runtime/client.d.ts +50 -15
  20. package/dist/runtime/client.js +7 -1138
  21. package/dist/runtime/client.js.map +1 -1
  22. package/dist/runtime/descriptor-CTY4mtwS-cS43EbwS.js +269 -0
  23. package/dist/runtime/descriptor-CTY4mtwS-cS43EbwS.js.map +1 -0
  24. package/dist/runtime/{id-Bt9XWRGL.js → id--1m0NuUL.js} +4 -4
  25. package/dist/runtime/id--1m0NuUL.js.map +1 -0
  26. package/dist/runtime/{id-BzFHf3Wo-DGPCjgrf.d.ts → id-7an3nxTu-DZJSM5xh.d.ts} +2 -2
  27. package/dist/runtime/id-CBOt2kDo.d.ts +1 -0
  28. package/dist/runtime/identity-QjtJRxBD.d.ts +2 -0
  29. package/dist/runtime/image-Djtjy4Z8.js +19 -0
  30. package/dist/runtime/image-Djtjy4Z8.js.map +1 -0
  31. package/dist/runtime/index-D4seNHlX.d.ts +184 -0
  32. package/dist/runtime/internal/api.d.ts +1 -1
  33. package/dist/runtime/internal/api.js +30 -1
  34. package/dist/runtime/internal/api.js.map +1 -0
  35. package/dist/runtime/internal/bootstrap.d.ts +2 -0
  36. package/dist/runtime/internal/bootstrap.js +2 -0
  37. package/dist/runtime/internal/client.d.ts +2 -0
  38. package/dist/runtime/internal/client.js +75 -0
  39. package/dist/runtime/internal/client.js.map +1 -0
  40. package/dist/runtime/internal/host.d.ts +25 -8
  41. package/dist/runtime/internal/host.js +27 -6
  42. package/dist/runtime/internal/host.js.map +1 -1
  43. package/dist/runtime/internal/routes.d.ts +2 -0
  44. package/dist/runtime/internal/routes.js +2 -0
  45. package/dist/runtime/internal/server.d.ts +1 -1
  46. package/dist/runtime/internal/server.js +1 -1
  47. package/dist/runtime/internal/types.d.ts +2 -1
  48. package/dist/runtime/internal/types.js +1 -1
  49. package/dist/runtime/pagination-CBOt2kDo.d.ts +1 -0
  50. package/dist/runtime/pagination-D3qd6s9N.js +33 -0
  51. package/dist/runtime/pagination-D3qd6s9N.js.map +1 -0
  52. package/dist/runtime/result-BkziOG1L.d.ts +1 -0
  53. package/dist/runtime/router-BSBI1oN1.js +2377 -0
  54. package/dist/runtime/router-BSBI1oN1.js.map +1 -0
  55. package/dist/runtime/routes-Ce8TVP-T.js +690 -0
  56. package/dist/runtime/routes-Ce8TVP-T.js.map +1 -0
  57. package/dist/runtime/{schema-1Zs03-iS.js → schema-D1wOqiNH.js} +40 -12
  58. package/dist/runtime/schema-D1wOqiNH.js.map +1 -0
  59. package/dist/runtime/server.d.ts +5 -5
  60. package/dist/runtime/server.js +2 -2
  61. package/dist/runtime/{sync-Bs8J3fIr.d.ts → sync-D-GK2sv9.d.ts} +3 -2
  62. package/dist/runtime/{api-CAgKDij7.js → sync-DzUwA8W9.js} +22 -41
  63. package/dist/runtime/sync-DzUwA8W9.js.map +1 -0
  64. package/dist/runtime/types-DeCCyqLU-BIm7rU-0.d.ts +341 -0
  65. package/package.json +29 -4
  66. package/src/cli/agent-files.ts +14 -8
  67. package/src/cli/build/managed-client.ts +411 -0
  68. package/src/cli/build/managed-server.ts +294 -0
  69. package/src/cli/build/managed.ts +67 -0
  70. package/src/cli/build/output.ts +47 -0
  71. package/src/cli/build/server.ts +51 -220
  72. package/src/cli/client-config.ts +1 -140
  73. package/src/cli/client-styles.ts +1 -0
  74. package/src/cli/codegen.ts +9 -4
  75. package/src/cli/command.ts +28 -5
  76. package/src/cli/control-client.ts +84 -101
  77. package/src/cli/deploy.ts +133 -177
  78. package/src/cli/dev.ts +506 -107
  79. package/src/cli/image-assets.ts +499 -0
  80. package/src/cli/managed-client-boundaries.ts +103 -0
  81. package/src/cli/managed-dev-platform.ts +118 -0
  82. package/src/cli/managed-exports.ts +219 -0
  83. package/src/cli/managed-release-bin.ts +13 -0
  84. package/src/cli/managed-release.ts +260 -0
  85. package/src/cli/module-boundaries.ts +11 -1
  86. package/src/cli/new-app.ts +111 -87
  87. package/src/cli/route-codegen.ts +311 -0
  88. package/src/cli/route-static.ts +710 -0
  89. package/src/client/bootstrap.ts +110 -0
  90. package/src/client/errors.ts +13 -7
  91. package/src/client/files.ts +64 -0
  92. package/src/client/id.ts +9 -5
  93. package/src/client/image.ts +28 -0
  94. package/src/client/index.ts +25 -2
  95. package/src/client/managed-client.ts +729 -0
  96. package/src/client/managed-upgrade.ts +283 -0
  97. package/src/client/recovery-journal.ts +195 -0
  98. package/src/client/route-boundaries.ts +184 -0
  99. package/src/client/router-history.ts +152 -0
  100. package/src/client/router-store.ts +644 -0
  101. package/src/client/router.ts +324 -0
  102. package/src/client/routes.ts +28 -0
  103. package/src/client/sync.ts +66 -592
  104. package/src/dev-runtime/database.ts +77 -2
  105. package/src/dev-runtime/functions.ts +3 -3
  106. package/src/dev-runtime/id.ts +19 -4
  107. package/src/dev-runtime/managed-functions.ts +255 -0
  108. package/src/dev-runtime/managed-sync.ts +263 -0
  109. package/src/dev-runtime/managed-websocket.ts +71 -0
  110. package/src/dev-runtime/migrations.ts +20 -0
  111. package/src/dev-runtime/sync.ts +236 -236
  112. package/src/image-assets.d.ts +36 -0
  113. package/src/internal/client.ts +186 -0
  114. package/src/internal/host.ts +1 -1
  115. package/src/internal/http-paths.ts +0 -3
  116. package/src/internal/routes.ts +45 -0
  117. package/dist/runtime/api-CAgKDij7.js.map +0 -1
  118. package/dist/runtime/descriptor-Cyo9FP9n-C0SRVXNW.js +0 -154
  119. package/dist/runtime/descriptor-Cyo9FP9n-C0SRVXNW.js.map +0 -1
  120. package/dist/runtime/id-Bt9XWRGL.js.map +0 -1
  121. package/dist/runtime/id-Dz0apuB3.d.ts +0 -1
  122. package/dist/runtime/index-D54flWtH.d.ts +0 -402
  123. package/dist/runtime/pagination-DnKg3dkI-r5ZUxBBx.d.ts +0 -112
  124. package/dist/runtime/pagination-Dz0apuB3.d.ts +0 -1
  125. package/dist/runtime/result-DKAA4gpS.d.ts +0 -1
  126. package/dist/runtime/schema-1Zs03-iS.js.map +0 -1
  127. package/src/cli/build/client.ts +0 -119
  128. package/src/cli/build/public.ts +0 -186
  129. package/src/cli/client-entry.ts +0 -152
  130. package/src/cli/client-plugin.ts +0 -136
  131. package/src/client/app.ts +0 -15
@@ -2,7 +2,7 @@ import { Effect, FileSystem, Path, Schema, String } from "effect";
2
2
  import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
3
3
 
4
4
  import { agentAppFiles, type AppFile } from "./agent-files.js";
5
- import { generate } from "./codegen.js";
5
+ import { generateClientRoutes } from "./route-codegen.js";
6
6
  import { installDependencies, type PackageManager } from "./package-manager.js";
7
7
 
8
8
  export interface NewAppOptions {
@@ -135,43 +135,6 @@ export const increment = mutation({
135
135
  });
136
136
  `;
137
137
 
138
- const app = `import { app, Result, useMutation, useQuery } from "ignotum/client";
139
-
140
- import { api } from "@/_generated/api.js";
141
- import { counterIncrement } from "@/shared/utils.js";
142
-
143
- function App() {
144
- const count = useQuery(api.counter.get);
145
- const increment = useMutation(api.counter.increment);
146
-
147
- return (
148
- <main class="mx-auto max-w-sm px-6 py-20 text-center">
149
- <h1 class="text-2xl font-semibold">Counter</h1>
150
- {Result.match(count, {
151
- pending: () => <p class="mt-6">Loading...</p>,
152
- value: (value) => (
153
- <>
154
- <p class="my-6 text-5xl tabular-nums">{value}</p>
155
- <button
156
- class="rounded bg-zinc-900 px-4 py-2 text-white"
157
- type="button"
158
- onClick={() => void increment()}
159
- >
160
- Increment by {counterIncrement}
161
- </button>
162
- </>
163
- ),
164
- })}
165
- </main>
166
- );
167
- }
168
-
169
- export default app({
170
- title: "Counter",
171
- component: App,
172
- });
173
- `;
174
-
175
138
  const icon = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
176
139
  <rect width="64" height="64" rx="14" fill="#18181b" />
177
140
  <path d="M20 32h24M32 20v24" stroke="#fafafa" stroke-width="6" stroke-linecap="round" />
@@ -181,73 +144,134 @@ const icon = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
181
144
  const utils = `export const counterIncrement = 1;
182
145
  `;
183
146
 
184
- const readme = (appName: string): string => `# ${appName}
185
-
186
- A small Ignotum counter app.
187
-
188
- ## Getting started
189
-
190
- You need Node.js 22.18 or newer. The app generator installs dependencies by default. If you
191
- created the app with \`--no-install\`, install them now:
147
+ const home = `import { Result, useMutation, useQuery } from "ignotum/client";
148
+ import { api } from "@/_generated/api.js";
149
+ import { route } from "@/_generated/client.js";
150
+ import { counterIncrement } from "@/shared/utils.js";
192
151
 
193
- \`\`\`sh
194
- npx ignotum install
195
- \`\`\`
152
+ export default route({
153
+ title: "Counter",
154
+ component: function Counter() {
155
+ const count = useQuery(api.counter.get);
156
+ const increment = useMutation(api.counter.increment);
157
+
158
+ return (
159
+ <section class="text-center">
160
+ <h1 class="text-2xl font-semibold">Counter</h1>
161
+ {Result.match(count, {
162
+ pending: () => <p class="mt-6">Loading...</p>,
163
+ value: (value) => (
164
+ <>
165
+ <p class="my-6 text-5xl tabular-nums">{value}</p>
166
+ <button
167
+ class="rounded bg-zinc-900 px-4 py-2 text-white"
168
+ type="button"
169
+ onClick={() => void increment()}
170
+ >
171
+ Increment by {counterIncrement}
172
+ </button>
173
+ </>
174
+ ),
175
+ })}
176
+ </section>
177
+ );
178
+ },
179
+ });
180
+ `;
196
181
 
197
- This command uses pnpm when it is installed and otherwise uses npm.
182
+ const rootLayout = `import { Link } from "ignotum/client";
183
+ import { layout } from "@/_generated/client.js";
184
+ import { routes } from "@/_generated/routes.js";
185
+ import "./styles.css";
186
+
187
+ export default layout({
188
+ meta: { description: "A live counter built with Ignotum." },
189
+ component: function RootLayout({ children }) {
190
+ return (
191
+ <div class="mx-auto max-w-sm px-6 py-12">
192
+ <nav aria-label="Main" class="mb-10 flex justify-center gap-6">
193
+ <Link to={routes.index}>Counter</Link>
194
+ <Link to={routes.about}>About</Link>
195
+ </nav>
196
+ <main>{children}</main>
197
+ </div>
198
+ );
199
+ },
200
+ });
201
+ `;
198
202
 
199
- Start the app:
203
+ const about = `import { Link } from "ignotum/client";
204
+ import { route } from "@/_generated/client.js";
205
+ import { routes } from "@/_generated/routes.js";
206
+
207
+ export default route({
208
+ title: "About",
209
+ component: function About() {
210
+ return (
211
+ <section>
212
+ <h1 class="text-2xl font-semibold">About this counter</h1>
213
+ <p class="my-6">Open the counter in two tabs to see live updates.</p>
214
+ <Link to={routes.index}>Back to the counter</Link>
215
+ </section>
216
+ );
217
+ },
218
+ });
219
+ `;
200
220
 
201
- \`\`\`sh
202
- npx ignotum dev
203
- \`\`\`
221
+ const readme = (appName: string) => `# ${appName}
204
222
 
205
- Open <http://127.0.0.1:3210>.
223
+ An Ignotum counter app with live updates and two pages.
206
224
 
207
- The app generator creates \`_generated\`. The dev server checks those files before it starts
208
- and updates them when the schema or server functions change.
225
+ ## App files
209
226
 
210
- ## Deploy
227
+ - \`client/index.tsx\` defines the home page with \`route(...)\`.
228
+ - \`client/about.tsx\` defines the About page at \`/about\`.
229
+ - \`client/+layout.tsx\` shares navigation and styles across pages. Put shared providers here.
230
+ - \`client/styles.css\` contains app-wide styles. Ignotum supplies Tailwind CSS automatically.
231
+ - \`client/icon.svg\` is the optional app icon.
232
+ - \`server/schema.ts\` defines tables and \`server/counter.ts\` defines the counter functions.
233
+ - \`shared/utils.ts\` contains code used by the client and server.
234
+ - \`_generated\` contains generated bindings. Never edit these files.
211
235
 
212
- Sign in, then choose a unique slug on the first deployment:
236
+ Import \`route\`, \`layout\`, and URL \`values\` from \`@/_generated/client.js\`.
237
+ Import \`routes\` from \`@/_generated/routes.js\` and pass a reference to \`Link\`:
213
238
 
214
- \`\`\`sh
215
- npx ignotum auth login
216
- npx ignotum deploy --app my-app
239
+ \`\`\`tsx
240
+ <Link to={routes.about}>About</Link>
217
241
  \`\`\`
218
242
 
219
- The command prints the hosted URL and records the app link in \`.ignotum/app.json\`. Later
220
- deployments use \`npx ignotum deploy\` without the \`--app\` flag.
221
-
222
- ## App files
243
+ Page filenames determine URLs. Put helpers and shared components in files or folders starting
244
+ with \`_\` so they do not become pages. Keep the \`.js\` suffix on TypeScript imports.
223
245
 
224
- - \`server/schema.ts\` defines the database tables.
225
- - \`server/counter.ts\` defines the query and mutation used by the counter.
226
- - \`client/index.tsx\` defines the app title and UI.
227
- - \`client/icon.svg\` is the optional app icon.
228
- - Ignotum loads Tailwind CSS automatically. Custom CSS files are ordinary client modules.
229
- - \`shared/utils.ts\` contains code shared across the app.
230
- - \`_generated\` contains Ignotum's generated types and bindings. Do not edit it by hand.
231
- - \`.agents/skills/ignotum\` contains the app skill and references used by coding agents.
246
+ ## Development
232
247
 
233
- Run the typechecker after a change:
248
+ Use Node.js 22.18 or newer. Install dependencies before starting development.
249
+ Run \`npx ignotum install\` if dependencies are missing, then \`npx ignotum dev\`.
250
+ Open <http://127.0.0.1:3210>. The development server generates server, client, and route bindings
251
+ and updates them after edits. Run \`npx ignotum codegen\` to regenerate them separately.
252
+ After generating bindings, run \`npx tsc --noEmit\` to check the app.
234
253
 
235
- \`\`\`sh
236
- npx ignotum codegen
237
- npx tsc --noEmit
238
- \`\`\`
254
+ ## Deploy
239
255
 
240
- The [Ignotum documentation](https://docs.ignotum.cloud) covers schema values, indexes, queries,
241
- mutations, client hooks, deployment, guarantees, and hosted limits.
256
+ Sign in with \`npx ignotum auth login\`, then run \`npx ignotum deploy --app my-app\`.
257
+ Later deployments use \`npx ignotum deploy\`. The command prints the hosted URL and confirms
258
+ that the numbered App Version is live. If activation cannot be confirmed, follow the printed
259
+ \`ignotum deploy --resume <version-id>\` command to finish without creating another version.
242
260
 
243
- ## Claude Code
261
+ ## Coding agents
244
262
 
245
- If you use Claude Code, rename \`AGENTS.md\` to \`CLAUDE.md\` and \`.agents\` to \`.claude\` so it
246
- can find the app instructions and Ignotum skill.
263
+ Read \`AGENTS.md\` and \`.agents/skills/ignotum/SKILL.md\` before editing.
264
+ For Claude Code, rename \`AGENTS.md\` to \`CLAUDE.md\` and \`.agents\` to \`.claude\`.
247
265
  `;
248
266
 
249
- const appFiles = (appName: string): ReadonlyArray<AppFile> => [
250
- { content: app, path: "client/index.tsx" },
267
+ export const appFiles = (appName: string): ReadonlyArray<AppFile> => [
268
+ { content: home, path: "client/index.tsx" },
269
+ { content: rootLayout, path: "client/+layout.tsx" },
270
+ { content: about, path: "client/about.tsx" },
271
+ {
272
+ content: "body { margin: 0; background: #fafafa; color: #18181b; }\n",
273
+ path: "client/styles.css",
274
+ },
251
275
  { content: icon, path: "client/icon.svg" },
252
276
  { content: counterFunctions, path: "server/counter.ts" },
253
277
  { content: schema, path: "server/schema.ts" },
@@ -376,7 +400,7 @@ export const createApp = Effect.fn("NewApp.createApp")(function* (options: NewAp
376
400
  discard: true,
377
401
  });
378
402
 
379
- yield* generate(appDirectory);
403
+ yield* generateClientRoutes(appDirectory);
380
404
 
381
405
  const packageManager = options.install ? yield* installDependencies(appDirectory) : null;
382
406
 
@@ -0,0 +1,311 @@
1
+ import { Effect, FileSystem, Path, Schema } from "effect";
2
+ import { createServer } from "vite";
3
+ import {
4
+ AppModuleId,
5
+ LayoutDefinition,
6
+ RouteDefinition,
7
+ type UrlField,
8
+ } from "@ignotum/contracts/app-definition";
9
+ import { compilePatterns, parsePattern } from "@ignotum/shared/routing/pattern";
10
+ import {
11
+ generate,
12
+ generatedHeader,
13
+ generatedImageTypes,
14
+ inspectOutput,
15
+ writeOutput,
16
+ } from "./codegen.js";
17
+ import { createRouteExtractor, InvalidRouteSource, sourceError } from "./route-static.js";
18
+
19
+ export interface DiscoveredClientModule {
20
+ readonly path: string;
21
+ readonly relativePath: string;
22
+ readonly id: AppModuleId;
23
+ readonly kind: "route" | "layout";
24
+ readonly referenceKeys: ReadonlyArray<string>;
25
+ }
26
+ export interface ClientRouteDiscovery {
27
+ readonly routes: ReadonlyArray<RouteDefinition>;
28
+ readonly layouts: ReadonlyArray<LayoutDefinition>;
29
+ readonly modules: ReadonlyArray<DiscoveredClientModule>;
30
+ readonly dependencies: ReadonlyArray<string>;
31
+ }
32
+ const byName = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0);
33
+ const contains = (directory: string, child: string) =>
34
+ directory === "/" || child === directory || child.startsWith(`${directory}/`);
35
+ const toPattern = (parts: ReadonlyArray<string>) =>
36
+ `/${parts.map((part) => (part.startsWith("$$") ? `*${part.slice(2)}` : part.startsWith("$") ? `:${part.slice(1)}` : part)).join("/")}`;
37
+ const diagnostic = (file: string, message: string) =>
38
+ sourceError({ path: file, source: "" }, { start: 0 }, "route", message);
39
+ const candidate = (name: string) =>
40
+ /\.(ts|tsx)$/.test(name) && !/\.(test|spec|d)\.(ts|tsx)$/.test(name) && !name.startsWith("_");
41
+
42
+ export const discoverClientRoutes = Effect.fn("Routes.discoverClientRoutes")(function* (
43
+ directory: string,
44
+ ) {
45
+ const fs = yield* FileSystem.FileSystem;
46
+ const path = yield* Path.Path;
47
+ const appDirectory = yield* fs.realPath(directory);
48
+ const clientDirectory = path.join(appDirectory, "client");
49
+ const files: string[] = [];
50
+ const seen = new Set<string>();
51
+ const visit = Effect.fn("Routes.visitDirectory")(function* (
52
+ directory: string,
53
+ ): Effect.fn.Return<void, InvalidRouteSource> {
54
+ const real = yield* fs
55
+ .realPath(directory)
56
+ .pipe(Effect.mapError(() => diagnostic(directory, "Cannot read route directory.")));
57
+ if (real !== clientDirectory && !real.startsWith(`${clientDirectory}/`))
58
+ return yield* diagnostic(directory, "Route trees cannot escape client/ through symlinks.");
59
+ if (seen.has(real)) return yield* diagnostic(directory, "Repeated or cyclic route directory.");
60
+ seen.add(real);
61
+ const entries = yield* fs
62
+ .readDirectory(directory)
63
+ .pipe(Effect.mapError(() => diagnostic(directory, "Cannot read route directory.")));
64
+ for (const name of entries.sort(byName)) {
65
+ if (name.startsWith("_") || name.startsWith(".")) continue;
66
+ const file = path.join(directory, name);
67
+ const info = yield* fs
68
+ .stat(file)
69
+ .pipe(Effect.mapError(() => diagnostic(file, "Cannot inspect route source.")));
70
+ if (info.type === "Directory") yield* visit(file);
71
+ else if (info.type === "File" && candidate(name)) files.push(file);
72
+ }
73
+ });
74
+ yield* visit(clientDirectory);
75
+ if (
76
+ !files.some(
77
+ (file) =>
78
+ path.dirname(file) === clientDirectory && /^index\.(ts|tsx)$/.test(path.basename(file)),
79
+ )
80
+ )
81
+ return yield* diagnostic(
82
+ clientDirectory,
83
+ "A client/index.ts or client/index.tsx home page is required.",
84
+ );
85
+ return yield* Effect.scoped(
86
+ Effect.gen(function* () {
87
+ // Resolve only. No transforms, SSR runner, user Vite config, or app module evaluation.
88
+ const server = yield* Effect.acquireRelease(
89
+ Effect.tryPromise({
90
+ try: () =>
91
+ createServer({
92
+ root: appDirectory,
93
+ configFile: false,
94
+ envFile: false,
95
+ appType: "custom",
96
+ logLevel: "silent",
97
+ css: { postcss: {} },
98
+ optimizeDeps: { noDiscovery: true },
99
+ resolve: { tsconfigPaths: true },
100
+ server: { middlewareMode: true, watch: null, hmr: false },
101
+ }),
102
+ catch: () => diagnostic(clientDirectory, "Cannot initialize the app import resolver."),
103
+ }),
104
+ (server) => Effect.promise(() => server.close()),
105
+ );
106
+ const extractor = yield* createRouteExtractor(appDirectory, (specifier, importer) =>
107
+ Effect.tryPromise({
108
+ try: async () => {
109
+ const resolved = await server.environments.client.pluginContainer.resolveId(
110
+ specifier,
111
+ importer,
112
+ );
113
+ if (
114
+ resolved === null ||
115
+ (resolved.external !== undefined && resolved.external !== false) ||
116
+ resolved.id.includes("?")
117
+ )
118
+ throw new Error("Unresolved app module");
119
+ return resolved.id;
120
+ },
121
+ catch: () =>
122
+ diagnostic(importer, `Cannot resolve '${specifier}' as an app source module.`),
123
+ }),
124
+ );
125
+ const modules: DiscoveredClientModule[] = [];
126
+ const layouts: LayoutDefinition[] = [];
127
+ const pending: {
128
+ readonly file: string;
129
+ readonly scope: string;
130
+ readonly pattern: string;
131
+ readonly extracted: Effect.Success<ReturnType<typeof extractor.extract>>;
132
+ readonly module: DiscoveredClientModule;
133
+ }[] = [];
134
+ for (const file of files.sort(byName)) {
135
+ const relativePath = path.relative(clientDirectory, file).split(path.sep).join("/");
136
+ const keys = relativePath.replace(/\.(ts|tsx)$/, "").split("/");
137
+ const kind = keys.at(-1) === "+layout" ? "layout" : "route";
138
+ if (keys.some((key) => key.startsWith("+") && key !== "+layout"))
139
+ return yield* diagnostic(file, "Unsupported reserved route file name.");
140
+ const scope = toPattern(keys.slice(0, -1));
141
+ const pattern = toPattern(
142
+ keys.at(-1) === "index" || kind === "layout" ? keys.slice(0, -1) : keys,
143
+ );
144
+ const parsed = yield* parsePattern(pattern).pipe(
145
+ Effect.mapError((error) => diagnostic(file, error.message)),
146
+ );
147
+ // Source names can contain $, +, Unicode, and punctuation. Module identities use a reversible hex encoding.
148
+ const id = yield* Schema.decodeUnknownEffect(AppModuleId)(
149
+ `app/client/m${Buffer.from(relativePath).toString("hex")}.js`,
150
+ ).pipe(
151
+ Effect.mapError(() =>
152
+ diagnostic(file, "Route source name exceeds module identity limits."),
153
+ ),
154
+ );
155
+ const module = {
156
+ path: file,
157
+ relativePath,
158
+ id,
159
+ kind,
160
+ referenceKeys: kind === "route" ? keys : [],
161
+ } satisfies DiscoveredClientModule;
162
+ modules.push(module);
163
+ const extracted = yield* extractor.extract(file, kind);
164
+ if (kind === "layout") {
165
+ const layout = yield* Schema.decodeUnknownEffect(LayoutDefinition)({
166
+ id: `layout:${relativePath}`,
167
+ scope: parsed.pattern,
168
+ module: { module: id, exportName: "default" },
169
+ metadata: extracted.metadata,
170
+ boundaries: extracted.boundaries,
171
+ }).pipe(Effect.mapError((error) => diagnostic(file, String(error))));
172
+ layouts.push(layout);
173
+ } else pending.push({ file, scope, pattern, extracted, module });
174
+ }
175
+ const nestedLayouts = layouts.map((layout) => {
176
+ const parent = layouts
177
+ .filter((other) => other.id !== layout.id && contains(other.scope, layout.scope))
178
+ .sort((a, b) => b.scope.length - a.scope.length)[0]?.id;
179
+ // An explicit undefined encodes as null and changes the persisted definition identity.
180
+ return parent === undefined ? layout : { ...layout, parent };
181
+ });
182
+ const routes: RouteDefinition[] = [];
183
+ for (const { file, scope, pattern, extracted, module } of pending) {
184
+ const parsed = yield* parsePattern(pattern);
185
+ const params: ReadonlyArray<UrlField> =
186
+ extracted.params ??
187
+ parsed.segments.flatMap((segment) =>
188
+ segment.kind === "Literal"
189
+ ? []
190
+ : [
191
+ {
192
+ name: segment.name,
193
+ value:
194
+ segment.kind === "Parameter"
195
+ ? { type: "string" as const }
196
+ : { type: "array" as const, value: { type: "string" as const } },
197
+ },
198
+ ],
199
+ );
200
+ const route = yield* Schema.decodeUnknownEffect(RouteDefinition)({
201
+ id: `route:${module.relativePath}`,
202
+ pattern,
203
+ directoryScope: scope,
204
+ precedence: parsed.precedence,
205
+ params,
206
+ searchParams: extracted.searchParams,
207
+ page: { module: module.id, exportName: "default" },
208
+ layouts: nestedLayouts
209
+ .filter((layout) => contains(layout.scope, scope))
210
+ .sort((a, b) => a.scope.length - b.scope.length)
211
+ .map((layout) => layout.id),
212
+ metadata: extracted.metadata,
213
+ boundaries: { error: extracted.boundaries.error, invalid: extracted.boundaries.invalid },
214
+ }).pipe(Effect.mapError((error) => diagnostic(file, String(error))));
215
+ routes.push(route);
216
+ }
217
+ yield* compilePatterns(routes, nestedLayouts).pipe(
218
+ Effect.mapError((error) => diagnostic(clientDirectory, error.message)),
219
+ );
220
+ return {
221
+ routes,
222
+ layouts: nestedLayouts,
223
+ modules,
224
+ dependencies: [...extractor.dependencies].sort(byName),
225
+ } satisfies ClientRouteDiscovery;
226
+ }),
227
+ );
228
+ });
229
+
230
+ export const renderClientBindings = (): string => `${generatedHeader}
231
+
232
+ ${generatedImageTypes}
233
+
234
+ import { bindSchema } from "ignotum/internal/client";
235
+ import type { DataModel } from "./types.js";
236
+
237
+ export const { route, layout, values } = bindSchema<DataModel>();
238
+ `;
239
+ interface ReferenceTree {
240
+ reference?: string;
241
+ readonly children: Map<string, ReferenceTree>;
242
+ }
243
+ export const renderRouteReferences = (discovery: ClientRouteDiscovery): string => {
244
+ const tree: ReferenceTree = { children: new Map() };
245
+ const references: string[] = [];
246
+ for (const [index, route] of discovery.routes.entries()) {
247
+ const module = discovery.modules.find((module) => module.id === route.page.module)!;
248
+ const name = `route${index}`;
249
+ const params = route.params
250
+ .map(
251
+ (field) =>
252
+ `${JSON.stringify(field.name)}: ${field.value.type === "array" ? "readonly string[]" : "string"}`,
253
+ )
254
+ .join("; ");
255
+ references.push(
256
+ `const ${name} = createRouteReference<typeof import(${JSON.stringify(`../client/${module.relativePath.replace(/\.(ts|tsx)$/, ".js")}`)}).default, { ${params} }>(${JSON.stringify(route, null, 2)});`,
257
+ );
258
+ let cursor = tree;
259
+ for (const key of module.referenceKeys) {
260
+ let child = cursor.children.get(key);
261
+ if (child === undefined) {
262
+ child = { children: new Map() };
263
+ cursor.children.set(key, child);
264
+ }
265
+ cursor = child;
266
+ }
267
+ cursor.reference = name;
268
+ }
269
+ const render = (node: ReferenceTree): string => {
270
+ const object = `{ ${[...node.children].map(([key, child]) => `[${JSON.stringify(key)}]: ${render(child)}`).join(", ")} }`;
271
+ return node.reference === undefined
272
+ ? object
273
+ : node.children.size === 0
274
+ ? node.reference
275
+ : `Object.assign(${node.reference}, ${object})`;
276
+ };
277
+ return `${generatedHeader}\n\nimport { createRouteReference } from "ignotum/internal/client";\n\n${references.join("\n\n")}\n\nexport const routes = ${render(tree)};\n`;
278
+ };
279
+
280
+ export const generateClientRoutes = Effect.fn("Routes.generateClientRoutes")(function* (
281
+ appDirectory: string,
282
+ ) {
283
+ const path = yield* Path.Path;
284
+ const server = yield* generate(appDirectory);
285
+ const client = {
286
+ path: path.join(appDirectory, "_generated/client.ts"),
287
+ content: renderClientBindings(),
288
+ };
289
+ const clientInspection = yield* inspectOutput(client);
290
+ if (clientInspection === "write") yield* writeOutput(client);
291
+ const discovery = yield* discoverClientRoutes(appDirectory);
292
+ const routes = {
293
+ path: path.join(appDirectory, "_generated/routes.ts"),
294
+ content: renderRouteReferences(discovery),
295
+ };
296
+ const routesInspection = yield* inspectOutput(routes);
297
+ if (routesInspection === "write") yield* writeOutput(routes);
298
+ return {
299
+ ...discovery,
300
+ written: [
301
+ ...server.written,
302
+ ...(clientInspection === "write" ? [client.path] : []),
303
+ ...(routesInspection === "write" ? [routes.path] : []),
304
+ ],
305
+ unchanged: [
306
+ ...server.unchanged,
307
+ ...(clientInspection === "unchanged" ? [client.path] : []),
308
+ ...(routesInspection === "unchanged" ? [routes.path] : []),
309
+ ],
310
+ };
311
+ });