@abide/abide 0.102.0 → 0.104.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 (73) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/bin/abide.ts +31 -28
  3. package/docs/REGISTRY.md +40 -36
  4. package/docs/RULEBOOK.md +113 -92
  5. package/docs/abide.md +788 -554
  6. package/package.json +2 -1
  7. package/scaffold/AGENTS.md +13 -6
  8. package/src/compiler/TypeSession.ts +108 -35
  9. package/src/compiler/appMap.ts +91 -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 +271 -76
  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 +71 -46
  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 +156 -53
  33. package/src/server/answerRequest.ts +9 -4
  34. package/src/server/answerRpc.ts +18 -16
  35. package/src/server/appTree.ts +92 -25
  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/generatedSurfaces.ts +12 -53
  42. package/src/server/hooks.ts +17 -4
  43. package/src/server/index.ts +1 -0
  44. package/src/server/installSourceMaps.ts +6 -1
  45. package/src/server/launcherOptions.ts +12 -13
  46. package/src/server/launcherPage.ts +2 -2
  47. package/src/server/nativeMenu.ts +3 -3
  48. package/src/server/preload.ts +13 -0
  49. package/src/server/runDesktop.ts +11 -16
  50. package/src/server/serveDev.ts +8 -2
  51. package/src/server/surfaceOf.ts +2 -0
  52. package/src/server/versionOf.ts +1 -2
  53. package/src/server/withAbideSources.ts +1 -1
  54. package/src/shared/ADDRESSES.ts +3 -0
  55. package/src/shared/LAUNCHER_FILES.ts +9 -0
  56. package/src/shared/RESERVED_DIRECTORIES.ts +19 -4
  57. package/src/shared/index.ts +2 -1
  58. package/src/{ui → shared}/menuBridge.ts +1 -1
  59. package/src/shared/menuItem.ts +2 -2
  60. package/src/shared/onMenu.ts +58 -0
  61. package/src/shared/reactive/Cursor.ts +66 -123
  62. package/src/shared/reactive/Reactive.ts +74 -28
  63. package/src/shared/reactive/callLoad.ts +4 -18
  64. package/src/shared/reactive/chunksOfStream.ts +10 -34
  65. package/src/{ui → shared}/reportMenu.ts +7 -9
  66. package/src/shared/routePattern.ts +74 -23
  67. package/src/shared/rpc.ts +1 -1
  68. package/src/ui/Router.ts +0 -6
  69. package/src/compiler/syncTypes.ts +0 -32
  70. package/src/compiler/writeTypes.ts +0 -82
  71. package/src/server/LAUNCHER_FILES.ts +0 -9
  72. /package/scaffold/src/{page.abide → index.abide} +0 -0
  73. /package/src/{compiler → shared}/SPECIFIERS.ts +0 -0
@@ -1,28 +1,36 @@
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
+ HOLDS_SERVER,
19
+ LIB_DIRECTORY,
17
20
  PUBLIC_DIRECTORY,
18
21
  RESERVED_DIRECTORIES,
22
+ SERVER_DIRECTORY,
19
23
  } from '#shared/RESERVED_DIRECTORIES.ts'
20
- import { fileKind, routeOf, skipped } from '#shared/routePattern.ts'
24
+ import { DIRECTORY_FILES, routeOf, skipped } from '#shared/routePattern.ts'
21
25
 
22
26
  /** What one walk of the app finds, every path relative to the root. */
23
- export type AppFiles = {
27
+ type AppFiles = {
24
28
  /** Every `.abide`, in path order. */
25
29
  abide: string[]
30
+ /** Every handler file, in path order. */
31
+ handlers: string[]
32
+ /** Every module under a `server` or a `lib` directory, in path order — read for 37.13. */
33
+ modules: string[]
26
34
  /** Every file under a `public` directory, to the address it is answered at below the mount. */
27
35
  published: Map<string, string>
28
36
  }
@@ -35,7 +43,8 @@ const THROW: Refuse = (_path, message) => {
35
43
  }
36
44
 
37
45
  /**
38
- * The app's `.abide` sources and public files, refusing a tree 23.16, 23.17 or 23.18 forbids.
46
+ * The app's `.abide` sources, handler files, server and lib modules and public files, refusing a
47
+ * tree 23.16, 23.17, 23.18 or 23.22 forbids.
39
48
  *
40
49
  * `refuse` throws by default, which is the build's answer; `abide check` collects instead (38.33),
41
50
  * and the checker's route table ignores them, since a refused tree is the build's to report.
@@ -43,9 +52,19 @@ const THROW: Refuse = (_path, message) => {
43
52
  * A missing root is an app with nothing in it, not a fault.
44
53
  */
45
54
  export function appFiles(root: string, refuse: Refuse = THROW): AppFiles {
46
- const files: AppFiles = { abide: [], published: new Map() }
47
- walk(root, '', undefined, files, refuse)
55
+ const files: AppFiles = {
56
+ abide: [],
57
+ handlers: [],
58
+ modules: [],
59
+ published: new Map(),
60
+ }
61
+ // 23.22, 38.25 — a root that is its package's directory holds the package's own files beside its
62
+ // pages.
63
+ const packaged = existsSync(`${root}/package.json`)
64
+ walk(root, '', undefined, files, refuse, packaged)
48
65
  files.abide.sort()
66
+ files.handlers.sort()
67
+ files.modules.sort()
49
68
  return files
50
69
  }
51
70
 
@@ -57,6 +76,7 @@ function walk(
57
76
  reserved: { path: string; name: string } | undefined,
58
77
  files: AppFiles,
59
78
  refuse: Refuse,
79
+ packaged = false,
60
80
  ): void {
61
81
  let entries: Dirent[]
62
82
  try {
@@ -71,7 +91,14 @@ function walk(
71
91
  if (entry.isDirectory()) {
72
92
  let within = reserved
73
93
  if (RESERVED_DIRECTORIES.has(entry.name)) {
74
- if (reserved !== undefined)
94
+ // 23.16 — `server` nests in the fences that hold code.
95
+ if (
96
+ reserved !== undefined &&
97
+ !(
98
+ entry.name === SERVER_DIRECTORY &&
99
+ HOLDS_SERVER.has(reserved.name)
100
+ )
101
+ )
75
102
  refuse(
76
103
  path,
77
104
  `abide: ${path} is a ${entry.name} directory under the ${reserved.name} directory ${reserved.path}. RULEBOOK 23.16.`,
@@ -87,21 +114,49 @@ function walk(
87
114
  )
88
115
  continue
89
116
  }
90
- if (path.endsWith('.abide')) files.abide.push(path)
117
+ const abide = path.endsWith('.abide')
118
+ if (abide) files.abide.push(path)
91
119
  if (reserved === undefined) {
120
+ if (!abide && !isModule(path)) {
121
+ if (packaged && relative === '' && !path.endsWith('.ts'))
122
+ continue
123
+ refuse(
124
+ path,
125
+ `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.`,
126
+ )
127
+ continue
128
+ }
129
+ if (!abide) files.handlers.push(path)
92
130
  // 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')
131
+ refuseFrameworkRoute(routeOf(path), path, refuse)
132
+ } else if (DIRECTORY_FILES.has(entry.name))
96
133
  refuse(
97
134
  path,
98
- `abide: ${path} is a ${entry.name} under the ${reserved.name} directory ${reserved.path}, where nothing has an address. RULEBOOK 23.18.`,
135
+ `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
136
  )
100
137
  else if (reserved.name === PUBLIC_DIRECTORY)
101
138
  files.published.set(path, publicAddress(path, reserved.path))
139
+ else if (
140
+ (reserved.name === SERVER_DIRECTORY ||
141
+ reserved.name === LIB_DIRECTORY) &&
142
+ isModule(path)
143
+ )
144
+ files.modules.push(path)
102
145
  }
103
146
  }
104
147
 
148
+ /**
149
+ * Whether `path` names a module the app runs — neither a declaration nor a test: directly in an
150
+ * address directory, a handler file rather than a stray.
151
+ */
152
+ function isModule(path: string): boolean {
153
+ return (
154
+ path.endsWith('.ts') &&
155
+ !path.endsWith('.d.ts') &&
156
+ !path.endsWith('.test.ts')
157
+ )
158
+ }
159
+
105
160
  /** Refuses `source` where `route` lies in the framework's own namespace. 23.17. */
106
161
  export function refuseFrameworkRoute(
107
162
  route: string,
@@ -150,18 +205,21 @@ export function abideUnder(root: string): string[] {
150
205
  }
151
206
 
152
207
  /**
153
- * Whether a `.abide` lies anywhere under `root` — stopping at the first.
208
+ * Whether an app lies under `root`: a `.abide` anywhere or a handler file, stopping at the first.
154
209
  *
155
210
  * 38.26's question, asked of a root before anything is built from it, so asking it of a large tree
156
211
  * is as cheap as the tree allows.
157
212
  */
158
- export function holdsAbide(root: string): boolean {
159
- return !abideFilesUnder(root, '').next().done
213
+ export function holdsApp(root: string): boolean {
214
+ return !abideFilesUnder(root, '', true).next().done
160
215
  }
161
216
 
217
+ // Every `.abide` under `directory` — and, where `handlers`, every handler file outside a fence.
162
218
  function* abideFilesUnder(
163
219
  directory: string,
164
220
  relative: string,
221
+ handlers = false,
222
+ fenced = false,
165
223
  ): Generator<string> {
166
224
  let entries: Dirent[]
167
225
  try {
@@ -173,7 +231,16 @@ function* abideFilesUnder(
173
231
  if (skipped(entry.name)) continue
174
232
  const path = `${relative}${entry.name}`
175
233
  if (entry.isDirectory())
176
- yield* abideFilesUnder(`${directory}/${entry.name}`, `${path}/`)
177
- else if (path.endsWith('.abide')) yield path
234
+ yield* abideFilesUnder(
235
+ `${directory}/${entry.name}`,
236
+ `${path}/`,
237
+ handlers,
238
+ fenced || RESERVED_DIRECTORIES.has(entry.name),
239
+ )
240
+ else if (
241
+ path.endsWith('.abide') ||
242
+ (handlers && !fenced && isModule(path))
243
+ )
244
+ yield path
178
245
  }
179
246
  }
@@ -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>,
@@ -14,7 +14,7 @@ import { servedTable } from './abideModules.ts'
14
14
  import { type AppSources, answerRequest } from './answerRequest.ts'
15
15
  import { Mux, type MuxConnection } from './answerSocket.ts'
16
16
  import { appDirectories } from './appDirectories.ts'
17
- import { holdsAbide } from './appTree.ts'
17
+ import { holdsApp } from './appTree.ts'
18
18
  import { buildDirectory } from './buildDirectory.ts'
19
19
  import { APP_PACKAGE, type AppPackage, config, packageUnder } from './config.ts'
20
20
  import { McpSurface } from './generatedSurfaces.ts'
@@ -65,21 +65,33 @@ export async function createApp(): Promise<App> {
65
65
  throw new Error(
66
66
  `abide: createApp found no app. Looked for a .abide under ${resolve('src')}, or under ${process.cwd()} where it has no src.`,
67
67
  )
68
- // 42.16 — under `bun test` the app is built for its sources as they stand rather than read off
69
- // whatever `abide build` last left, which a test run would otherwise need first and could find
70
- // stale.
71
- const out =
72
- process.env.NODE_ENV === 'test'
73
- ? await testBuild(root)
74
- : buildDirectory(root)
75
- const { loadBuild, serveBuild } = await import('./serveBuild.ts')
76
- if (!(await serveBuild(root, out)))
68
+ const out = await servedBuild(root)
69
+ if (out === undefined)
77
70
  throw new Error(
78
71
  `abide: createApp found nothing under ${root} to build.`,
79
72
  )
73
+ const { loadBuild } = await import('./serveBuild.ts')
80
74
  return bootApp(originUnder(root), () => loadBuild({ root, out }))
81
75
  }
82
76
 
77
+ /**
78
+ * The build of the app under `root`, served to this process's imports — or `undefined` where
79
+ * nothing under it builds. `fresh` builds it for its sources as they stand.
80
+ *
81
+ * 42.16 — under `bun test` the app is built for its sources as they stand rather than read off
82
+ * whatever `abide build` last left, which a test run would otherwise need first and could find
83
+ * stale. One answer for `createApp` and the test preload (38.109), so a test that boots the app
84
+ * reuses the build its imports were served from.
85
+ */
86
+ export async function servedBuild(
87
+ root: string,
88
+ fresh = process.env.NODE_ENV === 'test',
89
+ ): Promise<string | undefined> {
90
+ const out = fresh ? await testBuild(root) : buildDirectory(root)
91
+ const { serveBuild } = await import('./serveBuild.ts')
92
+ return (await serveBuild(root, out)) ? out : undefined
93
+ }
94
+
83
95
  /**
84
96
  * The app's root — `--root`, else `src` where the working directory holds one, else the working
85
97
  * directory — or `undefined` where it is no app's.
@@ -99,7 +111,7 @@ export function appRoot(given: string | undefined): string | undefined {
99
111
  let packaged = false
100
112
  for (const directory of appDirectories(root))
101
113
  packaged ||= existsSync(`${directory}/package.json`)
102
- return packaged && holdsAbide(root) ? root : undefined
114
+ return packaged && holdsApp(root) ? root : undefined
103
115
  }
104
116
 
105
117
  /**
@@ -378,7 +378,11 @@ async function open(
378
378
  bundleInMemory(
379
379
  root,
380
380
  // 16.28, 16.29 — the browser's view of the server, as `abide build` gives it.
381
- [fenceServer(root), abideModules(root, compilation), ...own],
381
+ [
382
+ fenceServer(root, compilation.mounted),
383
+ abideModules(root, compilation),
384
+ ...own,
385
+ ],
382
386
  paged,
383
387
  publicAddresses,
384
388
  compilation,
@@ -1,7 +1,8 @@
1
1
  // 16.29 — WHAT THE BROWSER'S BUNDLE SEES OF THE SERVER: nothing under a `server` directory, at any
2
2
  // depth, so a secret or a database client cannot ride into the browser by being imported from the
3
- // wrong file. A handler crosses as the wrapper its file's browser module carries in place of the
4
- // server block (34.23), which the compiler wrote; this is the fence around everything else.
3
+ // wrong file. A handler crosses as the wrapper the compiler wrote in its place — a handler file as
4
+ // that file, a private handler in its server block's place (16.28); this is the fence around
5
+ // everything else.
5
6
  //
6
7
  // A PLUGIN ON THE BUNDLE, NOT A REWRITE OF THE PAGES. A rewrite reaches the specifiers the compiler
7
8
  // emitted; the bundle reaches every edge — a helper the page imports, importing a server module,
@@ -9,11 +10,16 @@
9
10
  //
10
11
  // REGISTERED AHEAD OF `abideModules`, AND IN ITS NAMESPACES: a load is answered by the first plugin
11
12
  // that answers it, and a component or a file's text is loaded there rather than as a `file`.
13
+ //
14
+ // BY POSITION, NOT BY PATH (23.19): a directory a file mounts places its files where the app says,
15
+ // so a `server` directory inside it is fenced as the app's own would be, wherever the package lies.
12
16
 
17
+ import { resolve } from 'node:path' // bun exposes no path helpers of its own
13
18
  import { SERVER_DIRECTORY } from '#shared/RESERVED_DIRECTORIES.ts'
14
19
  import { placeOf } from '#shared/routePattern.ts'
15
20
  import {
16
21
  COMPONENT_NAMESPACE,
22
+ type Compilation,
17
23
  STYLE_NAMESPACE,
18
24
  TEXT_NAMESPACE,
19
25
  } from './abideModules.ts'
@@ -25,24 +31,41 @@ const FENCED = ['file', COMPONENT_NAMESPACE, TEXT_NAMESPACE, STYLE_NAMESPACE]
25
31
  /**
26
32
  * The bundler plugin refusing everything under a `server` directory to the browser's bundle.
27
33
  *
28
- * `root` is its real path (`appRoot`), which is how the bundler names a file. Listed before
29
- * `abideModules`.
34
+ * `root` is its real path (`appRoot`), which is how the bundler names a file; `mounted` is the
35
+ * compilation's. Listed before `abideModules`.
30
36
  */
31
- export function fenceServer(root: string): Bun.BunPlugin {
32
- // Anything with a `server` directory in its path below the root, and `placeOf` decides — a load
33
- // it passes on falls through to the next plugin's, or the bundler's own.
34
- const filter = new RegExp(
35
- `^${RegExp.escape(root)}/(?:.*/)?${SERVER_DIRECTORY}/`,
36
- )
37
+ export function fenceServer(
38
+ root: string,
39
+ mounted: Compilation['mounted'],
40
+ ): Bun.BunPlugin {
41
+ // Mounted directories first: one under the root's `node_modules` is the app's at its position.
42
+ const directories: [string, string][] = []
43
+ for (const [directory, at] of Object.entries(mounted))
44
+ directories.push([`${resolve(root, directory)}/`, at])
45
+ const positionOf = (path: string): string | undefined => {
46
+ for (const [directory, at] of directories)
47
+ if (path.startsWith(directory))
48
+ return `${at}${path.slice(directory.length)}`
49
+ return path.startsWith(`${root}/`)
50
+ ? path.slice(root.length + 1)
51
+ : undefined
52
+ }
53
+ // Anything with a `server` directory in its path, and `placeOf` decides by its position — a
54
+ // load it passes on falls through to the next plugin's, or the bundler's own.
55
+ const filter = new RegExp(`/${SERVER_DIRECTORY}/`)
37
56
  return {
38
57
  name: 'abide-fence',
39
58
  setup(build) {
40
59
  for (const namespace of FENCED)
41
60
  build.onLoad({ filter, namespace }, (args) => {
42
- const below = args.path.slice(root.length + 1)
43
- if (placeOf(below) !== 'server') return undefined
61
+ const position = positionOf(args.path)
62
+ if (
63
+ position === undefined ||
64
+ placeOf(position) !== 'server'
65
+ )
66
+ return undefined
44
67
  throw new Error(
45
- `abide: ${below.replace(/::\w+$/, '')} is under a server directory, and the browser's bundle imports it. RULEBOOK 16.29.`,
68
+ `abide: ${position.replace(/::\w+$/, '')} is under a server directory, and the browser's bundle imports it. RULEBOOK 16.29.`,
46
69
  )
47
70
  })
48
71
  },
@@ -29,7 +29,7 @@ import { type Listed, surfaceOf } from './surfaceOf.ts'
29
29
  const OPENAPI_LOG = namedLogger('abide:openapi')
30
30
  const MCP_LOG = namedLogger('abide:mcp')
31
31
 
32
- // What MCP and OpenAPI add to a listing: whether the handler is keyed.
32
+ // What OpenAPI adds to a listing: whether the handler is keyed.
33
33
  type Published = Listed & { keyed: boolean }
34
34
 
35
35
  /** Every handler `surface` lists (`surfaceOf`). */
@@ -212,18 +212,8 @@ export function openApi(
212
212
  return Object.keys(document().paths).length > 0 ? document : undefined
213
213
  }
214
214
 
215
- /** What the MCP surface publishes: a tool per call that takes args or writes, a resource per read. */
216
- type Tools = { tools: Published[]; resources: Published[] }
215
+ const published = new WeakMap<Map<string, Mounted>, Reactive<Published[]>>()
217
216
 
218
- const published = new WeakMap<Map<string, Mounted>, Reactive<Tools>>()
219
-
220
- function toolsOf(handlers: Map<string, Mounted>): Tools {
221
- const found: Tools = { tools: [], resources: [] }
222
- for (const one of listed(handlers, 'mcp'))
223
- if (one.keyed || one.method !== 'GET') found.tools.push(one)
224
- else found.resources.push(one)
225
- return found
226
- }
227
217
 
228
218
  type JsonRpc = {
229
219
  jsonrpc: '2.0'
@@ -264,18 +254,20 @@ export class McpSurface {
264
254
 
265
255
  /** Whether the route table publishes any handler to it. 20.7. */
266
256
  get opened(): boolean {
267
- const { tools, resources } = this.#published()
268
- return tools.length > 0 || resources.length > 0
257
+ return this.#published().length > 0
269
258
  }
270
259
 
271
- #published(): Tools {
272
- return overTable(published, this.#handlers, toolsOf)()
260
+ #published(): Published[] {
261
+ // Every handler the MCP surface lists, each a tool — a read as much as a mutation. 20.9.
262
+ return overTable(published, this.#handlers, (handlers) =>
263
+ listed(handlers, 'mcp'),
264
+ )()
273
265
  }
274
266
 
275
267
  /** Every tool it lists. 20.9. */
276
268
  list(): ToolDefinition[] {
277
269
  const listing: ToolDefinition[] = []
278
- for (const tool of this.#published().tools) {
270
+ for (const tool of this.#published()) {
279
271
  if (tool.description === undefined)
280
272
  MCP_LOG.warning(
281
273
  `${tool.name} is published with no description.`,
@@ -305,7 +297,7 @@ export class McpSurface {
305
297
  }
306
298
 
307
299
  #tool(name: unknown): Published | undefined {
308
- return this.#published().tools.find((one) => one.name === name)
300
+ return this.#published().find((one) => one.name === name)
309
301
  }
310
302
 
311
303
  async #called(
@@ -335,7 +327,7 @@ export class McpSurface {
335
327
  return {
336
328
  result: {
337
329
  protocolVersion: MCP_PROTOCOL_VERSION,
338
- capabilities: { tools: {}, resources: {} },
330
+ capabilities: { tools: {} },
339
331
  serverInfo: appIdentity(),
340
332
  },
341
333
  }
@@ -343,19 +335,6 @@ export class McpSurface {
343
335
  return { result: {} }
344
336
  case 'tools/list':
345
337
  return { result: { tools: this.list() } }
346
- case 'resources/list':
347
- return {
348
- result: {
349
- resources: this.#published().resources.map(
350
- (resource) => ({
351
- uri: `abide:${resource.address}`,
352
- name: resource.name,
353
- description: resource.description ?? '',
354
- mimeType: 'application/json',
355
- }),
356
- ),
357
- },
358
- }
359
338
  case 'tools/call': {
360
339
  const tool = this.#tool(params?.name)
361
340
  if (tool === undefined)
@@ -375,25 +354,6 @@ export class McpSurface {
375
354
  },
376
355
  }
377
356
  }
378
- case 'resources/read': {
379
- const uri = String(params?.uri ?? '')
380
- const resource = this.#published().resources.find(
381
- (one) => `abide:${one.address}` === uri,
382
- )
383
- if (resource === undefined)
384
- return mcpFailure(-32602, `No resource at ${uri}.`)
385
- const { text, failed } = await this.#called(
386
- resource,
387
- undefined,
388
- asker,
389
- )
390
- if (failed) return mcpFailure(-32603, text)
391
- return {
392
- result: {
393
- contents: [{ uri, mimeType: 'application/json', text }],
394
- },
395
- }
396
- }
397
357
  default:
398
358
  return mcpFailure(-32601, `No method ${method}.`)
399
359
  }
@@ -404,8 +364,7 @@ export class McpSurface {
404
364
  * Answers one MCP JSON-RPC request over `surface`, or `undefined` where it publishes nothing.
405
365
  *
406
366
  * 20.9 — THE STREAMABLE-HTTP MCP ENDPOINT, answering JSON-RPC over `POST`, as the request's own
407
- * caller: a handler that takes no args is a resource, read at its address; one that takes args, and
408
- * every mutation, is a tool.
367
+ * caller: every handler it lists is a tool, a read as much as a mutation.
409
368
  */
410
369
  export async function answerMcp(
411
370
  surface: McpSurface,
@@ -1,5 +1,5 @@
1
- // SECTION 37 — THE LIFECYCLE HOOKS, called from a page's or a layout's `<script server>` rather
2
- // than exported from it (37.1). Each call files its function here, at the ROUTE of the file that
1
+ // SECTION 37 — THE LIFECYCLE HOOKS, called from a `.abide`'s `<script server>` or a handler file
2
+ // rather than exported from it (37.1). Each call files its function here, at the ROUTE of the file that
3
3
  // called it, and hands back what unfiles it; `createApp` reads what is filed once every module
4
4
  // holding a server block has run.
5
5
  //
@@ -22,6 +22,7 @@ import {
22
22
  } from '#shared/routePattern.ts'
23
23
  import type { Schema } from '#shared/Schema.ts'
24
24
  import { config } from './config.ts'
25
+ import type { LauncherOptions } from './launcherOptions.ts'
25
26
  import { currentRequestScope } from './requestScope.ts'
26
27
 
27
28
  export type AppRung = Middleware<{ request: Request }, Response>
@@ -51,17 +52,20 @@ export const HOOKS = {
51
52
  options: ConfigOptions | undefined
52
53
  }>[],
53
54
  principal: [] as Filed<(claims: unknown) => unknown | Promise<unknown>>[],
55
+ launcher: [] as Filed<() => LauncherOptions>[],
54
56
  }
55
57
 
56
58
  const ROOT = '/'
57
59
 
58
- // The route a hook called now is filed at — the server block being evaluated, or the root.
60
+ // The route a hook called now is filed at — the server block or handler file being evaluated, or
61
+ // the root.
59
62
  let filing: string | undefined
60
63
 
61
64
  /**
62
65
  * Sets the route the hooks called next are filed at, or the root again for `undefined`.
63
66
  *
64
- * 37.1 — what a compiled server module calls around its `<script server>`; nothing an app writes.
67
+ * 37.1 — what a compiled server module calls around its `<script server>`, and the plugin around a
68
+ * handler file; nothing an app writes.
65
69
  */
66
70
  export function fileRoute(route: string | undefined): void {
67
71
  filing = route
@@ -208,6 +212,15 @@ export function onPrincipal(
208
212
  return filed(HOOKS.principal, resolve)
209
213
  }
210
214
 
215
+ /**
216
+ * Files what the desktop app's window and launcher are; returns what unfiles it.
217
+ *
218
+ * 38.80 — called in `src/launcher.abide` alone (38.107), and read as the app launches.
219
+ */
220
+ export function onLauncher(options: () => LauncherOptions): () => void {
221
+ return filed(HOOKS.launcher, options)
222
+ }
223
+
211
224
  /**
212
225
  * Logs an unexpected error and hands it to every `onError` hook.
213
226
  *
@@ -58,6 +58,7 @@ export {
58
58
  onConfig,
59
59
  onError,
60
60
  onHealth,
61
+ onLauncher,
61
62
  onPrincipal,
62
63
  onStart,
63
64
  onStop,
@@ -7,6 +7,7 @@
7
7
  // rebuild, and a test boots several apps in one process.
8
8
 
9
9
  import { resolve } from 'node:path' // bun exposes no path helpers of its own
10
+ import { HANDLER_FACE } from './MODULE_QUERIES.ts'
10
11
  import { decodeMappings, placeAt, type Segment } from './sourceMappings.ts'
11
12
 
12
13
  /** Per `.abide` below the root, as its server module is served there: the file, and its `mappings`. */
@@ -63,7 +64,11 @@ function placeFrames(error: Error, frames: CallSite[]): string {
63
64
 
64
65
  // A call site's column counts from 0 where the stack bun writes counts from 1.
65
66
  function placed(frame: CallSite): string {
66
- const file = frame.getFileName() ?? '<anonymous>'
67
+ // A handler file's frames name the file the author wrote, not the face it is served as.
68
+ const file = (frame.getFileName() ?? '<anonymous>').replace(
69
+ HANDLER_FACE,
70
+ '',
71
+ )
67
72
  const line = frame.getLineNumber() ?? 0
68
73
  const column = frame.getColumnNumber() ?? 0
69
74
  const registered = registeredFor(file)