@abide/abide 0.102.0 → 0.103.0

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 (67) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/bin/abide.ts +31 -28
  3. package/docs/REGISTRY.md +30 -26
  4. package/docs/RULEBOOK.md +98 -83
  5. package/docs/abide.md +771 -522
  6. package/package.json +2 -1
  7. package/scaffold/AGENTS.md +13 -6
  8. package/src/compiler/TypeSession.ts +110 -35
  9. package/src/compiler/appMap.ts +96 -0
  10. package/src/compiler/argsSchemaOf.ts +1 -1
  11. package/src/compiler/checkApp.ts +35 -18
  12. package/src/compiler/checkedShadow.ts +40 -7
  13. package/src/compiler/compileApp.ts +228 -70
  14. package/src/compiler/compiledEntry.ts +10 -6
  15. package/src/compiler/componentGraph.ts +6 -7
  16. package/src/compiler/declaredExports.ts +3 -3
  17. package/src/compiler/emitClient.ts +3 -3
  18. package/src/compiler/emitComponent.ts +9 -9
  19. package/src/compiler/emitSource.ts +5 -9
  20. package/src/compiler/handlersUnder.ts +114 -73
  21. package/src/compiler/hooksCalled.ts +35 -0
  22. package/src/compiler/mountsOf.ts +30 -18
  23. package/src/compiler/parseAbide.ts +12 -12
  24. package/src/compiler/projectShadow.ts +70 -45
  25. package/src/compiler/readsIn.ts +2 -2
  26. package/src/compiler/scaffold.ts +24 -21
  27. package/src/compiler/serveLanguage.ts +0 -24
  28. package/src/compiler/sharedModuleWrites.ts +2 -2
  29. package/src/compiler/splitImports.ts +4 -4
  30. package/src/compiler/writeDefaultProject.ts +28 -1
  31. package/src/server/MODULE_QUERIES.ts +12 -3
  32. package/src/server/abideModules.ts +117 -51
  33. package/src/server/answerRequest.ts +9 -4
  34. package/src/server/answerRpc.ts +18 -16
  35. package/src/server/appTree.ts +83 -24
  36. package/src/server/commands.ts +5 -4
  37. package/src/server/componentModules.d.ts +1 -2
  38. package/src/server/createApp.ts +23 -11
  39. package/src/server/devWorker.ts +5 -1
  40. package/src/server/fenceServer.ts +36 -13
  41. package/src/server/hooks.ts +17 -4
  42. package/src/server/index.ts +1 -0
  43. package/src/server/installSourceMaps.ts +6 -1
  44. package/src/server/launcherOptions.ts +12 -13
  45. package/src/server/launcherPage.ts +2 -2
  46. package/src/server/nativeMenu.ts +3 -3
  47. package/src/server/preload.ts +13 -0
  48. package/src/server/runDesktop.ts +11 -16
  49. package/src/server/serveDev.ts +8 -2
  50. package/src/server/surfaceOf.ts +2 -0
  51. package/src/server/withAbideSources.ts +1 -1
  52. package/src/shared/ADDRESSES.ts +3 -0
  53. package/src/shared/LAUNCHER_FILES.ts +9 -0
  54. package/src/shared/RESERVED_DIRECTORIES.ts +13 -4
  55. package/src/shared/index.ts +2 -1
  56. package/src/{ui → shared}/menuBridge.ts +1 -1
  57. package/src/shared/menuItem.ts +2 -2
  58. package/src/shared/onMenu.ts +58 -0
  59. package/src/{ui → shared}/reportMenu.ts +7 -9
  60. package/src/shared/routePattern.ts +58 -20
  61. package/src/shared/rpc.ts +1 -1
  62. package/src/ui/Router.ts +0 -6
  63. package/src/compiler/syncTypes.ts +0 -32
  64. package/src/compiler/writeTypes.ts +0 -82
  65. package/src/server/LAUNCHER_FILES.ts +0 -9
  66. /package/scaffold/src/{page.abide → index.abide} +0 -0
  67. /package/src/{compiler → shared}/SPECIFIERS.ts +0 -0
@@ -7,8 +7,8 @@
7
7
  // WHICH FACE AN IMPORT GETS IS SPELLED ON ITS SPECIFIER (`MODULE_QUERIES`), because bun hands a plugin
8
8
  // no import attributes: a compiled module asks for `?component`, a browser module for its own
9
9
  // `?style`, a text import for `?text`. A `.abide` named bare is a module abide did not compile
10
- // asking — on a server that is the bound face 34.16 states, beside every name the module exports,
11
- // and in a browser the component module itself.
10
+ // asking — on a server that is the bound face 34.16 states, and in a browser the component module
11
+ // itself.
12
12
  //
13
13
  // THE TABLES — the route, error and handler tables, and in a browser the router's table and the
14
14
  // entry — are the build's own modules, served from `__abide` below the root, where no app writes.
@@ -17,13 +17,16 @@
17
17
  // that registered one, so a second app booted in the same process — a test's — adds its compilation
18
18
  // to this one rather than registering a plugin nobody asks.
19
19
 
20
- import { dirname, resolve } from 'node:path' // bun exposes no path helpers of its own
20
+ import { basename, dirname, resolve } from 'node:path' // bun exposes no path helpers of its own
21
+ import { SERVER_SPECIFIER } from '#shared/SPECIFIERS.ts'
21
22
  import type { ServerTable } from './answerRequest.ts'
22
23
  import type { MountRow } from './answerRpc.ts'
23
24
  import { installSourceMaps, type SourceMaps } from './installSourceMaps.ts'
24
25
  import {
25
26
  CLIENT_QUERY,
26
27
  COMPONENT_QUERY,
28
+ HANDLER_FACE,
29
+ HANDLER_SUFFIX,
27
30
  STYLE_QUERY,
28
31
  TEXT_QUERY,
29
32
  } from './MODULE_QUERIES.ts'
@@ -51,8 +54,18 @@ export type CompiledFile = {
51
54
  export type Compilation = {
52
55
  /** Every `.abide` compiled, by its path below the root. */
53
56
  files: Record<string, CompiledFile>
57
+ /**
58
+ * Every handler file, by its path below the root: the route its hooks are filed at (37.1), and
59
+ * the module a browser is handed in its place (16.74), `''` where the browser is handed none.
60
+ */
61
+ handlers: Record<string, HandlerFace>
54
62
  /** The build's own modules, by name below `__abide`. */
55
63
  tables: Record<string, string>
64
+ /**
65
+ * 23.19 — every directory a file mounts, by its path below the root, to the position its files
66
+ * take: what the browser's fence reads a mounted file's place by (16.29).
67
+ */
68
+ mounted: Record<string, string>
56
69
  }
57
70
 
58
71
  // A VIRTUAL FACE'S PATH: its file's, with a suffix that is not a query. Both registries key a module
@@ -68,29 +81,40 @@ export const COMPONENT_NAMESPACE = 'abide-component'
68
81
  /** The namespaces a file's text and a component's stylesheet are loaded in. */
69
82
  export const TEXT_NAMESPACE = 'abide-text'
70
83
  export const STYLE_NAMESPACE = 'abide-style'
84
+ const RELATIVE_OR_SUBPATH = /^(?:\.\.?\/|#)/
71
85
 
72
- /** What the plugin answers from: a compiled file or a table by absolute path. */
73
- type Served = {
74
- file: (path: string) => CompiledFile | undefined
75
- table: (path: string) => string | undefined
76
- }
86
+ /** One handler file, as `Compilation.handlers` holds it. */
87
+ type HandlerFace = { scope: string; client: string }
77
88
 
78
89
  /** The plugin serving `compilation`'s files and tables, for one bundle. */
79
90
  export function abideModules(
80
91
  root: string,
81
92
  compilation: Compilation,
82
93
  ): Bun.BunPlugin {
83
- const files = new Map<string, CompiledFile>()
84
- const tables = new Map<string, string>()
85
- addCompilation(realOrAsGiven(root), compilation, files, tables)
86
- return modulesPlugin({
87
- file: (path) => files.get(path),
88
- table: (path) => tables.get(path),
89
- })
94
+ const held: Held = {
95
+ files: new Map(),
96
+ handlers: new Map(),
97
+ tables: new Map(),
98
+ }
99
+ addCompilation(realOrAsGiven(root), compilation, held)
100
+ return modulesPlugin(held)
101
+ }
102
+
103
+ /**
104
+ * A compilation as the plugin keys it, and answers from: a compiled file, a handler file or a table
105
+ * by absolute path.
106
+ */
107
+ type Held = {
108
+ files: Map<string, CompiledFile>
109
+ handlers: Map<string, HandlerFace>
110
+ tables: Map<string, string>
90
111
  }
91
112
 
92
- const RUNTIME_FILES = new Map<string, CompiledFile>()
93
- const RUNTIME_TABLES = new Map<string, string>()
113
+ const RUNTIME: Held = {
114
+ files: new Map(),
115
+ handlers: new Map(),
116
+ tables: new Map(),
117
+ }
94
118
  let registered = false
95
119
 
96
120
  /**
@@ -99,16 +123,11 @@ let registered = false
99
123
  */
100
124
  export function serveCompilation(root: string, compilation: Compilation): void {
101
125
  const real = realOrAsGiven(root)
102
- addCompilation(real, compilation, RUNTIME_FILES, RUNTIME_TABLES)
126
+ addCompilation(real, compilation, RUNTIME)
103
127
  installSourceMaps(serverMapsOf(compilation, real), real)
104
128
  if (registered) return
105
129
  registered = true
106
- Bun.plugin(
107
- modulesPlugin({
108
- file: (path) => RUNTIME_FILES.get(path),
109
- table: (path) => RUNTIME_TABLES.get(path),
110
- }),
111
- )
130
+ Bun.plugin(modulesPlugin(RUNTIME))
112
131
  }
113
132
 
114
133
  /** What the server's table exports: its routes and error pages, server blocks and handlers. */
@@ -146,14 +165,15 @@ export function serverMapsOf(
146
165
  function addCompilation(
147
166
  root: string,
148
167
  compilation: Compilation,
149
- files: Map<string, CompiledFile>,
150
- tables: Map<string, string>,
168
+ held: Held,
151
169
  ): void {
152
170
  // RESOLVED, not joined: a dependency's file is keyed above the root (`../node_modules/…`).
153
171
  for (const [relative, compiled] of Object.entries(compilation.files))
154
- files.set(resolve(root, relative), compiled)
172
+ held.files.set(resolve(root, relative), compiled)
173
+ for (const [relative, face] of Object.entries(compilation.handlers))
174
+ held.handlers.set(resolve(root, relative), face)
155
175
  for (const [name, text] of Object.entries(compilation.tables))
156
- tables.set(`${root}/${VIRTUAL_DIRECTORY}/${name}`, text)
176
+ held.tables.set(`${root}/${VIRTUAL_DIRECTORY}/${name}`, text)
157
177
  }
158
178
 
159
179
  // `Bun.resolveSync` asks EVERY abide plugin's `onResolve` again — this one's, and the runtime's, which
@@ -178,7 +198,7 @@ export function resolvedFile(specifier: string, from: string): string {
178
198
  }
179
199
  }
180
200
 
181
- function modulesPlugin(served: Served): Bun.BunPlugin {
201
+ function modulesPlugin(served: Held): Bun.BunPlugin {
182
202
  return {
183
203
  name: 'abide-modules',
184
204
  setup(build) {
@@ -264,12 +284,55 @@ function modulesPlugin(served: Served): Bun.BunPlugin {
264
284
  dirname(pathOf(args.importer)),
265
285
  args.path,
266
286
  )
267
- return served.table(path) === undefined
287
+ return served.tables.get(path) === undefined
268
288
  ? undefined
269
289
  : { path, namespace: 'abide-table' }
270
290
  },
271
291
  )
272
292
 
293
+ // A HANDLER FILE IS FOUND BY WHERE ITS IMPORT RESOLVES, however the import spells it, and
294
+ // only from the app's own modules: a package's never names one, and asking bun again for
295
+ // each of a package's relative imports is a resolve per module of the bundle. IN A BUNDLE
296
+ // THE FILTER IS THE HANDLER FILES' NAMES: a hook matching every relative import costs the
297
+ // bundle abide's own `sideEffects`, and the barrel's rest — the validator, the csp — moved
298
+ // into the chunk every page loads (38.94). The runtime is told of each compilation after
299
+ // it registered, so it matches every relative or `#` import and asks.
300
+ // Each handler file's name without `.ts` — what an import of one ends in.
301
+ let stems: string[] | undefined
302
+ if (bundling) {
303
+ const names = new Set<string>()
304
+ for (const path of served.handlers.keys())
305
+ names.add(basename(path, '.ts'))
306
+ stems = [...names]
307
+ }
308
+ const handlerImport =
309
+ stems === undefined
310
+ ? RELATIVE_OR_SUBPATH
311
+ : new RegExp(
312
+ `(?:^|/)(?:${stems.map(RegExp.escape).join('|')})(?:\\.ts)?$`,
313
+ )
314
+ if (stems?.length !== 0)
315
+ build.onResolve({ filter: handlerImport }, (args) => {
316
+ if (resolving || !RELATIVE_OR_SUBPATH.test(args.path))
317
+ return undefined
318
+ const importer = pathOf(args.importer)
319
+ if (importer.includes('/node_modules/')) return undefined
320
+ let path: string
321
+ try {
322
+ path = resolved(args.path, args.importer)
323
+ } catch {
324
+ return undefined
325
+ }
326
+ const face = served.handlers.get(path)
327
+ if (face === undefined) return undefined
328
+ // 20.4 — a handler the browser is handed no wrapper for is no import of its.
329
+ if (inBrowser(args.importer) && face.client === '')
330
+ throw new Error(
331
+ `abide: ${pathOf(args.importer)} imports ${args.path}, whose handler withholds itself from the browser (\`clients: { ui: false }\`). Call it from a <script server>, or let the browser have it. RULEBOOK 20.4.`,
332
+ )
333
+ return { path: `${path}${HANDLER_SUFFIX}` }
334
+ })
335
+
273
336
  // A RELATIVE SPECIFIER A FACE IMPORTS AT RUN TIME is resolved from the file the face
274
337
  // stands for: bun's runtime reads one from an importer in a namespace as no path at
275
338
  // all. A bare one it resolves itself. Registered after every face, so a `.abide` is
@@ -294,6 +357,22 @@ function modulesPlugin(served: Served): Bun.BunPlugin {
294
357
  }
295
358
  },
296
359
  )
360
+ // 16.74, 37.1 — A HANDLER FILE: the browser's is its wrapper, and the server's is the file
361
+ // as written, between the two calls that file its hooks at its route — on its first
362
+ // line, so every line of the file stays where its frames name it. ITS FACE is its path
363
+ // with a suffix, in bun's own namespace — so what it imports, relative, `#` or bare,
364
+ // resolves from its directory as the file's own imports do. A namespace of abide's own
365
+ // had bun resolve a bare one from the working directory: a second abide, run from `/`.
366
+ build.onLoad({ filter: HANDLER_FACE }, async (args) => {
367
+ const path = args.path.slice(0, -HANDLER_SUFFIX.length)
368
+ const face = served.handlers.get(path) as HandlerFace
369
+ if (browser) return { contents: face.client, loader: 'js' }
370
+ const text = await Bun.file(path).text()
371
+ return {
372
+ contents: `import { fileRoute as __fileRoute } from '${SERVER_SPECIFIER}';__fileRoute(${JSON.stringify(face.scope)});${text}\n;__fileRoute(undefined)\n`,
373
+ loader: 'ts',
374
+ }
375
+ })
297
376
  build.onLoad(
298
377
  { filter: /.*/, namespace: 'abide-client' },
299
378
  (args) => ({
@@ -307,10 +386,7 @@ function modulesPlugin(served: Served): Bun.BunPlugin {
307
386
  build.onLoad({ filter: /.*/, namespace: 'abide-bound' }, (args) => {
308
387
  const path = args.path.slice(0, -BOUND.length)
309
388
  return {
310
- contents: boundModule(
311
- path,
312
- compiledAt(served, path).server,
313
- ),
389
+ contents: boundModule(path),
314
390
  loader: 'js',
315
391
  }
316
392
  })
@@ -339,7 +415,7 @@ function modulesPlugin(served: Served): Bun.BunPlugin {
339
415
  build.onLoad(
340
416
  { filter: /.*/, namespace: 'abide-table' },
341
417
  (args) => ({
342
- contents: served.table(args.path) ?? '',
418
+ contents: served.tables.get(args.path) ?? '',
343
419
  loader: 'ts',
344
420
  }),
345
421
  )
@@ -355,8 +431,8 @@ function pathOf(importer: string): string {
355
431
 
356
432
  // THE PATH BUN NAMES A FILE BY HAS ITS LINKS RESOLVED, and so has the root a compilation is served
357
433
  // under (`serveCompilation`).
358
- function compiledAt(served: Served, path: string): CompiledFile {
359
- const compiled = served.file(path)
434
+ function compiledAt(served: Held, path: string): CompiledFile {
435
+ const compiled = served.files.get(path)
360
436
  if (compiled === undefined)
361
437
  throw new Error(
362
438
  `abide: ${path} is imported, and no compile of this app reached it.`,
@@ -364,22 +440,12 @@ function compiledAt(served: Served, path: string): CompiledFile {
364
440
  return compiled
365
441
  }
366
442
 
367
- const SCANNER = new Bun.Transpiler({ loader: 'ts' })
368
-
369
443
  // 34.16 — BOUND, NOT RAW. The compiled module is a function of accessors answering its markup (33.4);
370
444
  // what an app's own module calls is a function of plain props answering a `Component` (35.4), which
371
- // `render` invokes with nothing. Every name the module exports rides beside it: a `<script module>`'s,
372
- // and the handlers a `<script server>` declares (34.23).
373
- //
374
- // NAMED RATHER THAN `export *`: the server blocks load side by side at boot, and a star export
375
- // through a module still loading found none of its names.
376
- function boundModule(path: string, server: string): string {
377
- const specifier = JSON.stringify(path)
378
- const names: string[] = []
379
- for (const name of SCANNER.scan(server).exports)
380
- if (name !== 'default') names.push(name)
381
- return `import component from ${specifier}
382
- ${names.length === 0 ? '' : `export { ${names.join(', ')} } from ${specifier}\n`}export default (props = {}) => {
445
+ // `render` invokes with nothing. The component is the module's one export (34.17).
446
+ function boundModule(path: string): string {
447
+ return `import component from ${JSON.stringify(path)}
448
+ export default (props = {}) => {
383
449
  const accessors = {}
384
450
  for (const key in props) {
385
451
  const value = props[key]
@@ -13,6 +13,7 @@ import {
13
13
  HEALTH_ADDRESS,
14
14
  MCP_ADDRESS,
15
15
  OPENAPI_ADDRESS,
16
+ PRIVATE_HANDLERS_ADDRESS,
16
17
  } from '#shared/ADDRESSES.ts'
17
18
  import { APP_META } from '#shared/APP_META.ts'
18
19
  import { DEBUG_META } from '#shared/DEBUG_META.ts'
@@ -102,7 +103,7 @@ type Ranked = RankedError & {
102
103
  /**
103
104
  * One route of the app: the page at it, and the handler answering each method there.
104
105
  *
105
- * 19.1 — one file per method per route, so a route is a row and a method is a column, and 19.21's
106
+ * 19.1 — one file per method per route, so a route is a row and a method is a column, and 19.20's
106
107
  * "the most specific route answering the method" is a walk down the rows asking each column.
107
108
  */
108
109
  type RouteRow = {
@@ -196,7 +197,11 @@ export async function answerRequest(
196
197
  // 37.3 — THE MUX'S UPGRADE PASSES THE APP LANE as every request does (`muxUpgrade`).
197
198
  const admitted = muxAdmission(asked, request, app)
198
199
  if (admitted !== undefined) return admitted
199
- if (asked.startsWith(`${ABIDE_ADDRESS}/`)) {
200
+ // 16.70 — a private handler is answered by the route table, below abide's own namespace.
201
+ if (
202
+ asked.startsWith(`${ABIDE_ADDRESS}/`) &&
203
+ !asked.startsWith(`${PRIVATE_HANDLERS_ADDRESS}/`)
204
+ ) {
200
205
  const answer = app.framework(asked, request)
201
206
  if (answer === undefined) return notFound()
202
207
  return readsOnly(request) ? answer : methodRefused(app, asked, READS)
@@ -222,7 +227,7 @@ export async function answerRequest(
222
227
  refuse(400, 'The address is not a valid path.'),
223
228
  )
224
229
 
225
- // 19.21 — THE MOST SPECIFIC ROUTE ANSWERING THE METHOD, an `OPTIONS` asking about the method
230
+ // 19.20 — THE MOST SPECIFIC ROUTE ANSWERING THE METHOD, an `OPTIONS` asking about the method
226
231
  // its preflight names (16.24). A route that matches and answers some other method is held, so
227
232
  // an address nothing answers the method at is a 405 naming what is answered there (15.17).
228
233
  const preflight = request.method === 'OPTIONS'
@@ -271,7 +276,7 @@ export async function answerRequest(
271
276
  // above it, the root's having run already, and an error in it reaches that route's `onError`.
272
277
  const scope = currentRequestScope() as RequestScope
273
278
  scope.route =
274
- found.handler === undefined ? found.route.pattern : found.handler.route
279
+ found.handler === undefined ? found.route.pattern : found.handler.scope
275
280
  scope.params = found.params
276
281
  const answer = found.route.answers.get(
277
282
  found.handler ?? (found.route.page as Ranked),
@@ -16,7 +16,6 @@ import type { Gated } from '#shared/reactive/argsContract.ts'
16
16
  import { deadlineOf, heldGenerationOf } from '#shared/reactive/index.ts'
17
17
  import type { Defaults } from '#shared/reactive/parameterDefaults.ts'
18
18
  import { notFound, refuse } from '#shared/refuse.ts'
19
- import { routeOf } from '#shared/routePattern.ts'
20
19
  import type { Method, Rpc } from '#shared/rpc.ts'
21
20
  import { argsInURL } from '#shared/rpcRequest.ts'
22
21
  import { VERSION_HEADER } from '#shared/VERSION_HEADER.ts'
@@ -66,7 +65,14 @@ export type Mount = {
66
65
  address: string
67
66
  method: Method
68
67
  source: string
68
+ /** The handler file's name, or the private handler's binding. */
69
69
  name: string
70
+ /** What the source's module exports it as: `default` for a handler file. */
71
+ export: string
72
+ /** The route its hooks are filed at, which its rungs are read over (37.1, 16.72). */
73
+ scope: string
74
+ /** Whether it is a private handler, listed nowhere (16.71). */
75
+ private: boolean
70
76
  /** Whether a browser is handed a wrapper for it. 20.4. */
71
77
  ui: boolean
72
78
  /** Whether the command line lists it — the shell's listing and completion, never whether `call` reaches it. 20.3, 38.62. */
@@ -97,18 +103,15 @@ export type Mount = {
97
103
 
98
104
  /**
99
105
  * A row of the table the build wrote, with the import of the module its handler is declared in —
100
- * the file's server module, the one the route table loads too, so a page and a call reach one
101
- * handler.
106
+ * the handler file itself, or a private handler's `.abide` server module, the one the route table
107
+ * loads too, so a page and a call reach one handler.
102
108
  */
103
109
  export type MountRow = Mount & { load: () => Promise<Record<string, unknown>> }
104
110
 
105
111
  /** A handler's row, mounted: the handler imported and its args schema probed. */
106
112
  export type Mounted = Mount & {
107
- method: Method
108
- /** The route of the file declaring it, which its hooks are filed at (37.1, 37.2). */
109
- route: string
110
113
  /**
111
- * The rungs filed over `route`, read once at mount (16.14) — what an http call and a socket
114
+ * The rungs filed over `scope`, read once at mount (16.14) — what an http call and a socket
112
115
  * subscription to it both pass through (37.2, 18.26).
113
116
  */
114
117
  rungs: AppRung[]
@@ -148,12 +151,14 @@ export function mountedOwn(
148
151
  {
149
152
  address,
150
153
  method: 'GET',
151
- route: '/',
152
154
  // The root's rungs run before routing (37.3), and nothing is filed below a route
153
155
  // of abide's own.
154
156
  rungs: [],
155
157
  source: '',
156
158
  name,
159
+ export: name,
160
+ scope: '/',
161
+ private: false,
157
162
  ui: false,
158
163
  cli: false,
159
164
  mcp: false,
@@ -188,20 +193,17 @@ export async function mountHandlers(
188
193
  const handlers = new Map<string, Mounted>()
189
194
  for (let at = 0; at < table.length; at += 1) {
190
195
  const mount = table[at] as Mount
191
- const exported = (modules[at] as Record<string, unknown>)[mount.name]
196
+ const exported = (modules[at] as Record<string, unknown>)[mount.export]
192
197
  const handler = exported as Rpc<unknown, unknown> & Gated<unknown>
193
198
  const declared = argsDocumentOf(handler)
194
199
  // 16.9, 16.10 — the address `rpc.url` and `rpc.raw` build on, under the mount — and what a
195
200
  // page's render turns a call it made into the browser's request by (41.20).
196
201
  handler.address = underMount(mount.address)
197
- // 23.19 — the route its address answers below, which is its mount's for a package's file,
198
- // never the file's place on disk.
199
- const route = routeOf(mount.address)
200
202
  handlers.set(handlerKey(mount.method, mount.address), {
201
203
  ...mount,
202
- method: mount.method,
203
- route,
204
- rungs: rungsOver(route),
204
+ // 37.1, 16.72 — the route its file's hooks are filed at, which the build read off
205
+ // where the file answers (23.19's mount for a package's), never its place on disk.
206
+ rungs: rungsOver(mount.scope),
205
207
  handler,
206
208
  argsDocument: declared ?? (mount.args as Document | undefined),
207
209
  wireArgs: declared === undefined ? mount.args : undefined,
@@ -219,7 +221,7 @@ export function handlerKey(method: string, address: string): string {
219
221
  * The `Response` to a call of `found` made over http, `params` being what its route matched.
220
222
  *
221
223
  * `found` answers the request's method — or, for an `OPTIONS`, the method its preflight asks
222
- * about: which handler a request reaches is the route table's to say (19.21).
224
+ * about: which handler a request reaches is the route table's to say (19.20).
223
225
  */
224
226
  export async function answerRpc(
225
227
  found: Mounted,
@@ -1,28 +1,35 @@
1
- // 23.14 — WHERE AN APP'S ADDRESSES ARE, read off the tree in one walk. Every directory below the root
2
- // is a segment of an address, and a `page.abide`, `layout.abide` or `error.abide` in it answers there;
3
- // a `server` or `public` directory is never one (23.15) — the first fences a graph and the second
4
- // holds files answered as authored, at the path of the directories above it. So an app is organized
5
- // by subject (`media/[id]/page.abide`, `media/server`) and the addresses follow.
1
+ // 23.14 — WHERE AN APP'S ADDRESSES ARE, read off the tree in one walk. A file directly in a directory
2
+ // below the root is an address — a page, a layout, an error page or a handler file — and nothing
3
+ // else is there (23.22); a fence is never one (23.15): `server` fences a graph, `public` holds files
4
+ // answered as authored at the path of the directories above it, and `lib` and `tests` hold the rest.
5
+ // So listing a directory lists its routes.
6
6
  //
7
- // ONE WALK for the sources and the public files `abide build` copies and `abide dev` answers, and
8
- // the refusals (23.16, 23.17, 23.18) — and ONE SKIP RULE, `skipped`, for every walk of the tree,
9
- // beside `placeOf`, which answers the same question of one path.
7
+ // ONE WALK for the sources, the handler files, the server and lib modules, and the public files
8
+ // `abide build` copies and `abide dev` answers, and the refusals (23.16, 23.17, 23.18, 23.22) — and
9
+ // ONE SKIP RULE, `skipped`, for every walk of the tree, beside `placeOf`, which answers the same
10
+ // question of one path.
10
11
  //
11
12
  // A WALK RATHER THAN A GLOB: a glob descends `node_modules` and the build output before a filter
12
13
  // can drop them.
13
14
 
14
- import { type Dirent, readdirSync } from 'node:fs' // bun has no directory listing of its own
15
+ import { type Dirent, existsSync, readdirSync } from 'node:fs' // bun has no directory listing or sync existence check of its own
15
16
  import { ABIDE_ADDRESS } from '#shared/ADDRESSES.ts'
16
17
  import {
18
+ LIB_DIRECTORY,
17
19
  PUBLIC_DIRECTORY,
18
20
  RESERVED_DIRECTORIES,
21
+ SERVER_DIRECTORY,
19
22
  } from '#shared/RESERVED_DIRECTORIES.ts'
20
- import { fileKind, routeOf, skipped } from '#shared/routePattern.ts'
23
+ import { DIRECTORY_FILES, routeOf, skipped } from '#shared/routePattern.ts'
21
24
 
22
25
  /** What one walk of the app finds, every path relative to the root. */
23
- export type AppFiles = {
26
+ type AppFiles = {
24
27
  /** Every `.abide`, in path order. */
25
28
  abide: string[]
29
+ /** Every handler file, in path order. */
30
+ handlers: string[]
31
+ /** Every module under a `server` or a `lib` directory, in path order — run as it is imported. */
32
+ modules: string[]
26
33
  /** Every file under a `public` directory, to the address it is answered at below the mount. */
27
34
  published: Map<string, string>
28
35
  }
@@ -35,7 +42,8 @@ const THROW: Refuse = (_path, message) => {
35
42
  }
36
43
 
37
44
  /**
38
- * The app's `.abide` sources and public files, refusing a tree 23.16, 23.17 or 23.18 forbids.
45
+ * The app's `.abide` sources, handler files, server and lib modules and public files, refusing a
46
+ * tree 23.16, 23.17, 23.18 or 23.22 forbids.
39
47
  *
40
48
  * `refuse` throws by default, which is the build's answer; `abide check` collects instead (38.33),
41
49
  * and the checker's route table ignores them, since a refused tree is the build's to report.
@@ -43,9 +51,19 @@ const THROW: Refuse = (_path, message) => {
43
51
  * A missing root is an app with nothing in it, not a fault.
44
52
  */
45
53
  export function appFiles(root: string, refuse: Refuse = THROW): AppFiles {
46
- const files: AppFiles = { abide: [], published: new Map() }
47
- walk(root, '', undefined, files, refuse)
54
+ const files: AppFiles = {
55
+ abide: [],
56
+ handlers: [],
57
+ modules: [],
58
+ published: new Map(),
59
+ }
60
+ // 23.22, 38.25 — a root that is its package's directory holds the package's own files beside its
61
+ // pages.
62
+ const packaged = existsSync(`${root}/package.json`)
63
+ walk(root, '', undefined, files, refuse, packaged)
48
64
  files.abide.sort()
65
+ files.handlers.sort()
66
+ files.modules.sort()
49
67
  return files
50
68
  }
51
69
 
@@ -57,6 +75,7 @@ function walk(
57
75
  reserved: { path: string; name: string } | undefined,
58
76
  files: AppFiles,
59
77
  refuse: Refuse,
78
+ packaged = false,
60
79
  ): void {
61
80
  let entries: Dirent[]
62
81
  try {
@@ -87,21 +106,49 @@ function walk(
87
106
  )
88
107
  continue
89
108
  }
90
- if (path.endsWith('.abide')) files.abide.push(path)
109
+ const abide = path.endsWith('.abide')
110
+ if (abide) files.abide.push(path)
91
111
  if (reserved === undefined) {
112
+ if (!abide && !isModule(path)) {
113
+ if (packaged && relative === '' && !path.endsWith('.ts'))
114
+ continue
115
+ refuse(
116
+ path,
117
+ `abide: ${path} is directly in an address directory, which holds pages, layouts, error pages and handler files alone. Move it into a lib directory beside it. RULEBOOK 23.22.`,
118
+ )
119
+ continue
120
+ }
121
+ if (!abide) files.handlers.push(path)
92
122
  // 23.17 — anything that could answer here would answer in abide's own namespace.
93
- if (path.endsWith('.abide'))
94
- refuseFrameworkRoute(routeOf(path), path, refuse)
95
- } else if (fileKind(entry.name) !== 'component')
123
+ refuseFrameworkRoute(routeOf(path), path, refuse)
124
+ } else if (DIRECTORY_FILES.has(entry.name))
96
125
  refuse(
97
126
  path,
98
- `abide: ${path} is a ${entry.name} under the ${reserved.name} directory ${reserved.path}, where nothing has an address. RULEBOOK 23.18.`,
127
+ `abide: ${path} is under the ${reserved.name} directory ${reserved.path}, where nothing has an address, and ${entry.name} is a route's name. RULEBOOK 23.18.`,
99
128
  )
100
129
  else if (reserved.name === PUBLIC_DIRECTORY)
101
130
  files.published.set(path, publicAddress(path, reserved.path))
131
+ else if (
132
+ (reserved.name === SERVER_DIRECTORY ||
133
+ reserved.name === LIB_DIRECTORY) &&
134
+ isModule(path)
135
+ )
136
+ files.modules.push(path)
102
137
  }
103
138
  }
104
139
 
140
+ /**
141
+ * Whether `path` names a module the app runs — neither a declaration nor a test: directly in an
142
+ * address directory, a handler file rather than a stray.
143
+ */
144
+ function isModule(path: string): boolean {
145
+ return (
146
+ path.endsWith('.ts') &&
147
+ !path.endsWith('.d.ts') &&
148
+ !path.endsWith('.test.ts')
149
+ )
150
+ }
151
+
105
152
  /** Refuses `source` where `route` lies in the framework's own namespace. 23.17. */
106
153
  export function refuseFrameworkRoute(
107
154
  route: string,
@@ -150,18 +197,21 @@ export function abideUnder(root: string): string[] {
150
197
  }
151
198
 
152
199
  /**
153
- * Whether a `.abide` lies anywhere under `root` — stopping at the first.
200
+ * Whether an app lies under `root`: a `.abide` anywhere or a handler file, stopping at the first.
154
201
  *
155
202
  * 38.26's question, asked of a root before anything is built from it, so asking it of a large tree
156
203
  * is as cheap as the tree allows.
157
204
  */
158
- export function holdsAbide(root: string): boolean {
159
- return !abideFilesUnder(root, '').next().done
205
+ export function holdsApp(root: string): boolean {
206
+ return !abideFilesUnder(root, '', true).next().done
160
207
  }
161
208
 
209
+ // Every `.abide` under `directory` — and, where `handlers`, every handler file outside a fence.
162
210
  function* abideFilesUnder(
163
211
  directory: string,
164
212
  relative: string,
213
+ handlers = false,
214
+ fenced = false,
165
215
  ): Generator<string> {
166
216
  let entries: Dirent[]
167
217
  try {
@@ -173,7 +223,16 @@ function* abideFilesUnder(
173
223
  if (skipped(entry.name)) continue
174
224
  const path = `${relative}${entry.name}`
175
225
  if (entry.isDirectory())
176
- yield* abideFilesUnder(`${directory}/${entry.name}`, `${path}/`)
177
- else if (path.endsWith('.abide')) yield path
226
+ yield* abideFilesUnder(
227
+ `${directory}/${entry.name}`,
228
+ `${path}/`,
229
+ handlers,
230
+ fenced || RESERVED_DIRECTORIES.has(entry.name),
231
+ )
232
+ else if (
233
+ path.endsWith('.abide') ||
234
+ (handlers && !fenced && isModule(path))
235
+ )
236
+ yield path
178
237
  }
179
238
  }
@@ -33,7 +33,7 @@ type SourceCommands = Record<
33
33
  | 'dev'
34
34
  | 'build'
35
35
  | 'check'
36
- | 'sync'
36
+ | 'routes'
37
37
  | 'compile'
38
38
  | 'bundle'
39
39
  | 'lsp',
@@ -151,9 +151,9 @@ function commandsOf(host: Host): Row[] {
151
151
  'check',
152
152
  ),
153
153
  fromSource(
154
- 'sync',
155
- 'write the types a .ts reads a .abide by, into .abide/types',
156
- 'sync',
154
+ 'routes [--json]',
155
+ 'every address the app answers, its file, and where each hook is filed',
156
+ 'routes',
157
157
  ),
158
158
  fromSource(
159
159
  'compile',
@@ -227,6 +227,7 @@ function parsed(argv: string[]) {
227
227
  method: { type: 'string' },
228
228
  target: { type: 'string' },
229
229
  platforms: { type: 'string' },
230
+ json: { type: 'boolean' },
230
231
  help: { type: 'boolean', short: 'h' },
231
232
  },
232
233
  allowPositionals: true,
@@ -1,8 +1,7 @@
1
1
  // 35.4 — what a `.ts` holds where it imports a `.abide`: the component, called with its props to
2
2
  // bind them, answering the `Component` `render` takes (`abideModules`). A wildcard, so the props
3
3
  // are not checked here; a declaration in a module would be an augmentation, which takes no wildcard.
4
- // The names a `.abide` exports are typed by what 34.24 writes, which an app's tsconfig reaches
5
- // before this.
4
+ // The component is all a `.abide` exports (34.17).
6
5
  declare module '*.abide' {
7
6
  const component: (
8
7
  props?: Record<string, unknown>,