@abide/abide 0.101.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 (161) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/bin/abide.ts +226 -164
  3. package/docs/REGISTRY.md +730 -0
  4. package/docs/RULEBOOK.md +2768 -0
  5. package/docs/abide.md +13127 -0
  6. package/package.json +6 -3
  7. package/scaffold/AGENTS.md +38 -0
  8. package/scaffold/CLAUDE.md +1 -0
  9. package/scaffold/src/layout.abide +18 -0
  10. package/src/compiler/Lowering.ts +78 -9
  11. package/src/compiler/ShadowMap.ts +2 -2
  12. package/src/compiler/TypeSession.ts +341 -71
  13. package/src/compiler/appMap.ts +96 -0
  14. package/src/compiler/argsSchemaOf.ts +1 -1
  15. package/src/compiler/checkApp.ts +53 -8
  16. package/src/compiler/checkedShadow.ts +73 -6
  17. package/src/compiler/classifyRead.ts +6 -4
  18. package/src/compiler/classifyTemplate.ts +1 -1
  19. package/src/compiler/compileApp.ts +598 -200
  20. package/src/compiler/compiledEntry.ts +48 -40
  21. package/src/compiler/componentGraph.ts +57 -71
  22. package/src/compiler/declaredExports.ts +26 -9
  23. package/src/compiler/emitClient.ts +142 -72
  24. package/src/compiler/emitComponent.ts +41 -12
  25. package/src/compiler/emitSource.ts +102 -13
  26. package/src/compiler/handlersUnder.ts +164 -76
  27. package/src/compiler/hooksCalled.ts +35 -0
  28. package/src/compiler/index.ts +7 -0
  29. package/src/compiler/mountsOf.ts +150 -0
  30. package/src/compiler/nearest.ts +14 -0
  31. package/src/compiler/parseAbide.ts +157 -20
  32. package/src/compiler/projectShadow.ts +250 -19
  33. package/src/compiler/reactiveTypes.ts +20 -7
  34. package/src/compiler/readSources.ts +15 -2
  35. package/src/compiler/readsIn.ts +62 -8
  36. package/src/compiler/renderMarkup.ts +3 -3
  37. package/src/compiler/scaffold.ts +37 -15
  38. package/src/compiler/serveLanguage.ts +101 -34
  39. package/src/compiler/sharedModuleWrites.ts +2 -2
  40. package/src/compiler/sourceMapOf.ts +193 -0
  41. package/src/compiler/splitImports.ts +205 -83
  42. package/src/compiler/tokenize.ts +14 -0
  43. package/src/compiler/writeDefaultProject.ts +29 -2
  44. package/src/server/CLIENT_BUILD.ts +11 -13
  45. package/src/server/DEFAULT_PORT.ts +1 -1
  46. package/src/server/MODULE_QUERIES.ts +27 -0
  47. package/src/server/TABLES.ts +7 -0
  48. package/src/server/VIRTUAL_DIRECTORY.ts +4 -5
  49. package/src/server/abideModules.ts +457 -0
  50. package/src/server/answerFailure.ts +16 -6
  51. package/src/server/answerRequest.ts +241 -107
  52. package/src/server/answerRpc.ts +107 -33
  53. package/src/server/answerSocket.ts +65 -20
  54. package/src/server/answerValue.ts +22 -9
  55. package/src/server/appDirectories.ts +1 -1
  56. package/src/server/appPlugins.ts +1 -1
  57. package/src/server/appTree.ts +238 -0
  58. package/src/server/argsFor.ts +2 -2
  59. package/src/server/assistant.ts +2 -2
  60. package/src/server/bridgeRelay.ts +6 -8
  61. package/src/server/claudeCode.ts +0 -6
  62. package/src/server/clientBuildOptions.ts +8 -3
  63. package/src/server/coerceArgs.ts +1 -1
  64. package/src/server/commands.ts +38 -37
  65. package/src/server/componentModules.d.ts +2 -1
  66. package/src/server/compressAnswer.ts +4 -4
  67. package/src/server/config.ts +25 -29
  68. package/src/server/configFields.ts +31 -25
  69. package/src/server/createApp.ts +77 -79
  70. package/src/server/devWorker.ts +118 -181
  71. package/src/server/executableName.ts +4 -0
  72. package/src/server/fenceServer.ts +57 -32
  73. package/src/server/generatedSurfaces.ts +99 -66
  74. package/src/server/hooks.ts +137 -52
  75. package/src/server/iconAccent.ts +191 -0
  76. package/src/server/index.ts +12 -7
  77. package/src/server/installSourceMaps.ts +93 -0
  78. package/src/server/launcherOptions.ts +33 -31
  79. package/src/server/launcherPage.ts +107 -64
  80. package/src/server/manifestOf.ts +16 -13
  81. package/src/server/namesUnder.ts +2 -2
  82. package/src/server/nativeMenu.ts +6 -6
  83. package/src/server/negotiateEncoding.ts +1 -1
  84. package/src/server/preload.ts +13 -0
  85. package/src/server/principal.ts +10 -5
  86. package/src/server/realOrAsGiven.ts +10 -0
  87. package/src/server/refuseAddressCollision.ts +21 -14
  88. package/src/server/remote.ts +49 -52
  89. package/src/server/render.ts +65 -94
  90. package/src/server/renderComponent.ts +5 -0
  91. package/src/server/reportHealth.ts +2 -2
  92. package/src/server/requestScope.ts +12 -1
  93. package/src/server/runBuild.ts +24 -0
  94. package/src/server/runCompiled.ts +5 -5
  95. package/src/server/runDesktop.ts +48 -28
  96. package/src/server/serveBuild.ts +43 -52
  97. package/src/server/serveDev.ts +79 -37
  98. package/src/server/shell.ts +7 -22
  99. package/src/server/sourceMappings.ts +139 -0
  100. package/src/server/surfaceOf.ts +20 -3
  101. package/src/server/testBuild.ts +1 -19
  102. package/src/server/withAbideSources.ts +117 -0
  103. package/src/shared/ADDRESSES.ts +10 -18
  104. package/src/shared/APP_META.ts +7 -0
  105. package/src/shared/CopyOptions.ts +1 -1
  106. package/src/shared/DEBUG_META.ts +1 -1
  107. package/src/shared/LAUNCHER_FILES.ts +9 -0
  108. package/src/shared/RESERVED_DIRECTORIES.ts +24 -0
  109. package/src/shared/cookies.ts +2 -2
  110. package/src/shared/csp.ts +1 -1
  111. package/src/shared/escapeText.ts +1 -1
  112. package/src/shared/filledPath.ts +85 -0
  113. package/src/shared/framing.ts +79 -6
  114. package/src/shared/headKeyOf.ts +2 -0
  115. package/src/shared/index.ts +2 -1
  116. package/src/shared/log.ts +27 -13
  117. package/src/shared/matchRoute.ts +23 -4
  118. package/src/{ui → shared}/menuBridge.ts +2 -2
  119. package/src/shared/menuItem.ts +4 -4
  120. package/src/shared/onMenu.ts +58 -0
  121. package/src/shared/reactive/Keyed.ts +2 -2
  122. package/src/shared/reactive/NotLanded.ts +1 -1
  123. package/src/shared/reactive/Reactive.ts +43 -43
  124. package/src/shared/reactive/argsContract.ts +4 -4
  125. package/src/shared/reactive/canonicalKey.ts +4 -4
  126. package/src/shared/reactive/chunksOfStream.ts +6 -0
  127. package/src/shared/reactive/index.ts +1 -1
  128. package/src/shared/reactive/recordWrites.ts +103 -5
  129. package/src/shared/reactive/structural.ts +1 -1
  130. package/src/shared/reactive/tracking.ts +1 -1
  131. package/src/shared/reactive/watch.ts +3 -3
  132. package/src/shared/refuse.ts +1 -1
  133. package/src/{ui → shared}/reportMenu.ts +8 -10
  134. package/src/shared/routePattern.ts +122 -48
  135. package/src/shared/rpc.ts +37 -8
  136. package/src/shared/rpcRequest.ts +27 -3
  137. package/src/shared/url.ts +4 -44
  138. package/src/shared/writePath.ts +1 -1
  139. package/src/ui/LAYER_NAMES.ts +1 -1
  140. package/src/ui/List.ts +3 -3
  141. package/src/ui/MOUNTING_LEVEL.ts +18 -0
  142. package/src/ui/Mismatch.ts +1 -1
  143. package/src/ui/RawRun.ts +1 -1
  144. package/src/ui/Router.ts +37 -36
  145. package/src/ui/Scope.ts +1 -1
  146. package/src/ui/TryRegion.ts +2 -2
  147. package/src/ui/applyHead.ts +33 -0
  148. package/src/ui/index.ts +1 -1
  149. package/src/ui/moveNode.ts +1 -1
  150. package/src/ui/reconcile.ts +2 -2
  151. package/src/ui/routing.ts +2 -2
  152. package/src/ui/setAttribute.ts +1 -1
  153. package/src/ui/setText.ts +1 -1
  154. package/src/ui/socketWire.ts +9 -7
  155. package/src/ui/wire.ts +1 -1
  156. package/scaffold/src/server/app.ts +0 -6
  157. package/scaffold/src/shared/.gitkeep +0 -0
  158. package/scaffold/src/ui/app.html +0 -8
  159. package/src/server/componentImports.ts +0 -56
  160. /package/scaffold/src/{ui/pages/page.abide → index.abide} +0 -0
  161. /package/src/{compiler → shared}/SPECIFIERS.ts +0 -0
@@ -1,22 +1,26 @@
1
- // 38.5 AND 38.29 — THE MANIFEST: the files the entry was written under, and each module a route
1
+ // 38.5 AND 38.28 — THE MANIFEST: the files the entry was written under, and each module a route
2
2
  // loads in a browser to every chunk that module needs before it can run. Read off the bundler's
3
3
  // own metafile rather than off the emitted code, so a chunk the bundler hoisted into a shared file
4
4
  // is in the list because the bundler says it is imported, not because a pattern over its output
5
5
  // happened to match.
6
6
  //
7
7
  // THE ENTRY'S NAME IS HERE BECAUSE A BUILD HASHES IT (38.5). `abide dev` names it by its source
8
- // (38.17) and reads it off this all the same, so the tags the document carries come from one
8
+ // (38.16) and reads it off this all the same, so the tags the document carries come from one
9
9
  // place in both lanes rather than from a constant in one and a lookup in the other.
10
10
  //
11
11
  // THE STATIC CLOSURE AND NOTHING ELSE. An `import-statement` edge is a chunk the module cannot
12
12
  // run without; a `dynamic-import` edge is a destination somebody may never navigate to, and
13
13
  // preloading it is the whole route table on every page.
14
14
  //
15
- // THE ENTRY IS LEFT OUT of every list, because the document already loads it by name (38.23) and
15
+ // THE ENTRY IS LEFT OUT of every list, because the document already loads it by name (38.22) and
16
16
  // a preload of the same address is a second request line for bytes already on their way.
17
17
 
18
18
  import { resolve } from 'node:path' // bun exposes no path helpers of its own
19
19
  import { CLIENT_ENTRY } from './CLIENT_BUILD.ts'
20
+ import { VIRTUAL_DIRECTORY } from './VIRTUAL_DIRECTORY.ts'
21
+
22
+ // The entry's key: the table it is served as, below the root.
23
+ const ENTRY_MODULE = `${VIRTUAL_DIRECTORY}/${CLIENT_ENTRY}.ts`
20
24
 
21
25
  export type Manifest = {
22
26
  /** The entry's file name, or undefined where the bundle has none — a dev build that failed. */
@@ -24,10 +28,10 @@ export type Manifest = {
24
28
  /**
25
29
  * The stylesheet the bundler collected under the entry, or undefined where none was imported.
26
30
  *
27
- * 38.24.
31
+ * 38.23.
28
32
  */
29
33
  stylesheet: string | undefined
30
- /** A module's path below `base` to the chunk file names it needs, its own first. */
34
+ /** A module's path below the root — a route's `.abide` — to the chunk file names it needs, its own first. */
31
35
  chunks: Record<string, string[]>
32
36
  }
33
37
 
@@ -40,19 +44,18 @@ export const NO_MANIFEST: Manifest = {
40
44
  /**
41
45
  * The manifest read off a bundle's metafile: the entry, its stylesheet, and each module's chunks.
42
46
  *
43
- * `base` IS THE TREE THE ROUTE TABLE NAMES MODULES BELOW. The metafile names a file on disk
44
- * relative to the working directory with symlinks resolved, and a module a plugin served under its
45
- * namespace as the plugin spelled it — so `abide build` passes its output's real path, and
46
- * `abide dev` passes the virtual directory its plugin resolves into.
47
+ * `root` IS THE TREE THE ROUTE TABLE NAMES MODULES BELOW, as its real path: the metafile names a file
48
+ * relative to the working directory with symlinks resolved, and a table the plugin served under its
49
+ * namespace as the plugin spelled it (`abideModules`).
47
50
  */
48
51
  export function manifestOf(
49
52
  metafile: Bun.BuildMetafile,
50
- base: string,
53
+ root: string,
51
54
  ): Manifest {
52
55
  const manifest: Manifest = { ...NO_MANIFEST, chunks: {} }
53
56
  const imports = new Map<string, string[]>()
54
57
  const modules = new Map<string, string>()
55
- const prefix = `${base}/`
58
+ const prefix = `${root}/`
56
59
  for (const [output, described] of Object.entries(metafile.outputs)) {
57
60
  if (!isModule(output)) continue
58
61
  const statically: string[] = []
@@ -66,7 +69,7 @@ export function manifestOf(
66
69
  if (!path.startsWith(prefix)) continue
67
70
  const module = path.slice(prefix.length)
68
71
  modules.set(module, chunkName(output))
69
- if (module !== `${CLIENT_ENTRY}.ts`) continue
72
+ if (module !== ENTRY_MODULE) continue
70
73
  manifest.entry = chunkName(output)
71
74
  // NOT IN bun-types, and in the metafile: the stylesheet an entry's CSS was collected into.
72
75
  const sheet = (described as { cssBundle?: string }).cssBundle
@@ -103,7 +106,7 @@ export function chunksOf(modules: string[], manifest: Manifest): string[] {
103
106
  *
104
107
  * A MODULE, NOT A STYLESHEET. The bundler reports the CSS it collected both as a static import of
105
108
  * the entry and as an output whose entry point IS the entry — so unfiltered it is fetched as a
106
- * script, and it takes the entry's own place in the manifest. 38.24 links it already.
109
+ * script, and it takes the entry's own place in the manifest. 38.23 links it already.
107
110
  */
108
111
  function isModule(output: string): boolean {
109
112
  return output.endsWith('.js')
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * The paths below `directory` that `pattern` matches, or none for a directory that is not there.
3
3
  *
4
- * A missing directory is the common case rather than an error: an app has no `ui/public` or
5
- * `server/rpc` until its author adds one. Narrowed to that one code for `shellUnder`'s reason: a
4
+ * A missing directory is the common case rather than an error: a build has no `client/` or
5
+ * `public/` until it writes one. Narrowed to that one code because a
6
6
  * directory that exists and cannot be read is a different fault and must not read as an empty one.
7
7
  */
8
8
  export function namesUnder(directory: string, pattern = '**/*'): string[] {
@@ -1,5 +1,5 @@
1
- // THE DESKTOP APP'S MENU BAR (38.89): the default, with what the page the window shows exports,
2
- // built as an `NSMenu`.
1
+ // THE DESKTOP APP'S MENU BAR (38.88): the default, with the menus the standing layouts and page file
2
+ // with `onMenu`, built as an `NSMenu`.
3
3
 
4
4
  import { FFIType } from 'bun:ffi'
5
5
  import type { MenuRole } from '#shared/menuItem.ts'
@@ -27,8 +27,8 @@ export type NativeItem =
27
27
  items?: NativeItem[] | undefined
28
28
  }
29
29
 
30
- // 38.90 — A ROLE AppKit ALREADY ANSWERS is sent down the responder chain, so `copy` copies whatever
31
- // holds the selection and is greyed where nothing does. The rest are the launcher's own.
30
+ // 38.89 — A ROLE AppKit ALREADY ANSWERS is sent down the responder chain, so `copy` copies whatever
31
+ // holds the selection and is grayed where nothing does. The rest are the launcher's own.
32
32
  const RESPONDER_ROLES: Partial<Record<MenuRole, string>> = {
33
33
  about: 'orderFrontStandardAboutPanel:',
34
34
  hide: 'hide:',
@@ -76,8 +76,8 @@ const NAMED_KEYS: Record<string, string> = {
76
76
  /**
77
77
  * The menu bar the window shows: the application, File, Edit, View and Window menus, and `added`.
78
78
  *
79
- * 38.89 — AN ADDED MENU NAMED AS A DEFAULT ONE GROWS IT, and any other stands before Window, where
80
- * the platform puts an app's own. 38.84 — no Connect… where `launcher.json` turns connecting off.
79
+ * 38.88 — AN ADDED MENU NAMED AS A DEFAULT ONE GROWS IT, and any other stands before Window, where
80
+ * the platform puts an app's own. 38.83 — no Connect… where what `onLauncher` answers turns connecting off.
81
81
  */
82
82
  export function menuBar(
83
83
  appName: string,
@@ -1,4 +1,4 @@
1
- // 38.30 — WHICH OF THE ENCODINGS ON OFFER A REQUEST TAKES, or `undefined` for the bytes as they
1
+ // 38.29 — WHICH OF THE ENCODINGS ON OFFER A REQUEST TAKES, or `undefined` for the bytes as they
2
2
  // are. What is on offer is asked rather than assumed: a request preferring brotli for a file with
3
3
  // only a gzip copy takes the gzip, where picking first and checking after would send it neither.
4
4
  //
@@ -0,0 +1,13 @@
1
+ // 38.109 — `@abide/abide/test`, PRELOADED BY `bun test`: the app's compilation served before any test
2
+ // file is loaded, so a test imports a `.abide` as any module — beside the code it tests, at the top
3
+ // of the file, with no `createApp` run first. Bun loads every import of a file before it evaluates
4
+ // any, so registering the compilation from inside a test is always too late; a preload is the
5
+ // one place that runs first. The build is `createApp`'s own under `bun test` (42.16), so a test that
6
+ // boots the app afterwards reuses it rather than building twice.
7
+
8
+ import { appRoot, servedBuild } from './createApp.ts'
9
+
10
+ const root = appRoot(undefined)
11
+ // A run from a directory that holds no app — a workspace's root — preloads nothing.
12
+ // Fresh whatever `NODE_ENV` says: `bun test` sets it only where nothing else has.
13
+ if (root !== undefined) await servedBuild(root, true)
@@ -20,7 +20,7 @@ import { declare } from '#shared/rpc.ts'
20
20
  import { mountedOwn } from './answerRpc.ts'
21
21
  import { callerOrMint, mintCaller } from './callerOf.ts'
22
22
  import { config, positiveSetting } from './config.ts'
23
- import { HOOKS } from './hooks.ts'
23
+ import { HOOKS, inOrder } from './hooks.ts'
24
24
  import { within } from './requestScope.ts'
25
25
 
26
26
  // 26.18.
@@ -150,12 +150,17 @@ async function documentOf(
150
150
  expiresAt: new Date(expires).toISOString(),
151
151
  error: undefined,
152
152
  }
153
- const resolve = HOOKS.principal
154
- if (resolve === undefined)
153
+ const resolvers = inOrder(HOOKS.principal)
154
+ if (resolvers.length === 0)
155
155
  return { ...(isRecord(claims) ? claims : {}), ...baseline }
156
156
  try {
157
- const resolved = await resolve(claims)
158
- return { ...(isRecord(resolved) ? resolved : {}), ...baseline }
157
+ // 37.7 — EVERY RESOLVER, handed the claims, its answer merged over the ones before it.
158
+ const resolved: Record<string, unknown> = {}
159
+ for (const resolve of resolvers) {
160
+ const answered = await resolve(claims)
161
+ if (isRecord(answered)) Object.assign(resolved, answered)
162
+ }
163
+ return { ...resolved, ...baseline }
159
164
  } catch (thrown) {
160
165
  // 26.3 — a resolver that failed leaves the caller unauthenticated, and says why.
161
166
  PRINCIPAL_LOG.warning('onPrincipal threw', thrown)
@@ -0,0 +1,10 @@
1
+ import { realpathSync } from 'node:fs' // bun exposes no realpath of its own
2
+
3
+ // A root that is not there is refused by the command that reads it, at the path it was given.
4
+ export function realOrAsGiven(path: string): string {
5
+ try {
6
+ return realpathSync(path)
7
+ } catch {
8
+ return path
9
+ }
10
+ }
@@ -1,30 +1,37 @@
1
- import { PUBLIC_DIRECTORY } from './CLIENT_BUILD.ts'
2
-
3
1
  /**
4
- * Throws, naming both, when a public file and a page claim the same address.
2
+ * Throws, naming both, when a public file claims a route's address or another public file's.
5
3
  *
6
- * 36.13 — A PUBLIC FILE AND A PAGE MAY NOT CLAIM ONE ADDRESS. The two are separate walks of two
7
- * separate trees, so from inside either one the collision is invisible; this is the one place
8
- * they meet, and naming BOTH is the clause — naming one leaves the reader to find the other.
4
+ * 36.13 — the walks that find routes and public files cannot see each other's collisions from
5
+ * inside either one; this is the one place they meet, and naming BOTH is the clause — naming one
6
+ * leaves the reader to find the other. Two `public` directories can claim one address too, since
7
+ * each mounts at the path above it (36.11).
9
8
  *
10
9
  * THE BUILD AND DEV, and `abide start` trusts the build: `answerRequest` answers a public file
11
10
  * AHEAD of any route on the ground that 36.13 makes a collision impossible at run time, and 30.8
12
11
  * says dev must resolve an address the way the built app does.
13
12
  *
14
- * PUBLIC ADDRESSES IN, so a caller hands whichever it already holds: the build derives one per
15
- * globbed name (36.11 — the path below the directory IS the address) and the dev worker's
16
- * `published` map is keyed by them already.
13
+ * `published` is each public file's source path to its address, as `appTree` reads them.
17
14
  */
18
15
  export function refuseAddressCollision(
19
- routes: readonly { pattern: string }[],
20
- publicAddresses: Iterable<string>,
16
+ pages: readonly { pattern: string }[],
17
+ handlers: readonly { address: string }[],
18
+ published: Map<string, string>,
21
19
  command: string,
22
20
  ): void {
23
21
  const patterns = new Set<string>()
24
- for (const route of routes) patterns.add(route.pattern)
25
- for (const address of publicAddresses)
22
+ for (const page of pages) patterns.add(page.pattern)
23
+ for (const handler of handlers) patterns.add(handler.address)
24
+ const claimed = new Map<string, string>()
25
+ for (const [source, address] of published) {
26
26
  if (patterns.has(address))
27
27
  throw new Error(
28
- `${command}: ui/${PUBLIC_DIRECTORY}${address} and the page at ${address} claim the same address. RULEBOOK 36.13.`,
28
+ `${command}: ${source} and the route ${address} claim the same address. RULEBOOK 36.13.`,
29
29
  )
30
+ const other = claimed.get(address)
31
+ if (other !== undefined)
32
+ throw new Error(
33
+ `${command}: ${other} and ${source} are both answered at ${address}. RULEBOOK 36.13.`,
34
+ )
35
+ claimed.set(address, source)
36
+ }
30
37
  }
@@ -1,12 +1,13 @@
1
- // THE CLIENT FOR A RUNNING APP — what the shell (`shell.ts`), `call`, `logs`, `openapi` and `mcp` talk to
1
+ // THE CLIENT FOR A RUNNING APP — what the shell (`shell.ts`), `call`, `watch`, `openapi` and `mcp` talk to
2
2
  // it with, the shell on the app served here included.
3
3
  // Which app is `--url`, then `ABIDE_APP_URL`, then the local one; `ABIDE_APP_TOKEN` is the bearer
4
4
  // sent (36.6), and neither variable is read anywhere else.
5
5
 
6
- import { LOGS_ADDRESS, RPC_ADDRESS } from '#shared/ADDRESSES.ts'
7
6
  import { applyPatch } from '#shared/applyPatch.ts'
8
- import type { LogRecord } from '#shared/log.ts'
7
+ import { filledPath, parametersOf } from '#shared/filledPath.ts'
8
+ import { firstMatch, partsOf } from '#shared/matchRoute.ts'
9
9
  import type { PatchOp } from '#shared/reactive/PATCH_SINKS.ts'
10
+ import { rankRoutes } from '#shared/routePattern.ts'
10
11
  import type { Method } from '#shared/rpc.ts'
11
12
  import { argsInURL, rpcRequest } from '#shared/rpcRequest.ts'
12
13
  import type { Mount } from './answerRpc.ts'
@@ -43,8 +44,8 @@ const FAULTED = 8
43
44
  /**
44
45
  * Calls one handler on a running app; returns the CLI's exit code and the answer's text.
45
46
  *
46
- * 38.8 — ONE CALL TO ONE HANDLER, spelled `invoices/getInvoice id=4310` in argv and in the shell
47
- * alike, and 38.62's args.
47
+ * 38.8 — ONE CALL TO ONE HANDLER, spelled `invoices/getInvoice id=4310` — or `invoices/4310` where
48
+ * the route carries the parameter — in argv and in the shell alike, and 38.61's args.
48
49
  */
49
50
  export async function callHandler(
50
51
  url: string,
@@ -54,10 +55,11 @@ export async function callHandler(
54
55
  handlers: Mount[],
55
56
  signal?: AbortSignal,
56
57
  ): Promise<{ code: number; text: string }> {
57
- const address = `${RPC_ADDRESS}/${path.replace(/^\//, '')}`
58
- const handler = handlers.find((mount) => mount.address === address)
58
+ const address = `/${path.replace(/^\//, '')}`
59
+ const asked = method?.toUpperCase()
60
+ const handler = handlerAt(handlers, address, asked)
59
61
  const args = callArgs(given)
60
- const chosen = (method?.toUpperCase() ??
62
+ const chosen = (asked ??
61
63
  (handler?.method as Method | undefined) ??
62
64
  (args === undefined ? 'GET' : 'POST')) as Method
63
65
  const request =
@@ -78,7 +80,7 @@ export async function callHandler(
78
80
  /**
79
81
  * A call's args out of the words after its address, or `undefined` where there are none.
80
82
  *
81
- * 38.62 — `name=value` words, kept as the text they are for the app to coerce as it coerces a query's
83
+ * 38.61 — `name=value` words, kept as the text they are for the app to coerce as it coerces a query's
82
84
  * (19.7), against the schema it holds the call to; or, where the first word opens one, a JSON object.
83
85
  */
84
86
  function callArgs(
@@ -102,12 +104,45 @@ function callArgs(
102
104
  return pairs
103
105
  }
104
106
 
105
- /** The request a call with text args is: in the URL where its method carries them, a form where not. */
107
+ /**
108
+ * The handler `address` names: its route as written (`media/[id]`), or an address its route matches
109
+ * (`media/4310`) — the most specific, of `method` where one is asked for.
110
+ */
111
+ function handlerAt(
112
+ handlers: Mount[],
113
+ address: string,
114
+ method: string | undefined,
115
+ ): Mount | undefined {
116
+ const rows: { pattern: string; mount: Mount }[] = []
117
+ for (const mount of handlers) {
118
+ if (method !== undefined && mount.method !== method) continue
119
+ if (mount.address === address) return mount
120
+ rows.push({ pattern: mount.address, mount })
121
+ }
122
+ return firstMatch(rankRoutes(rows), partsOf(address) ?? [])?.route.mount
123
+ }
124
+
125
+ /**
126
+ * The request a call with text args is: in the URL where its method carries them, a form where not.
127
+ *
128
+ * 16.9 — a route written with its parameters takes them out of the args and into the path.
129
+ */
106
130
  function textRequest(
107
131
  method: Method,
108
- address: string,
109
- pairs: URLSearchParams,
132
+ route: string,
133
+ given: URLSearchParams,
110
134
  ): { url: string; init: RequestInit } {
135
+ let address = route
136
+ let pairs = given
137
+ if (route.includes('[')) {
138
+ const params: Record<string, string> = {}
139
+ pairs = new URLSearchParams()
140
+ const named = parametersOf(route)
141
+ for (const [name, value] of given)
142
+ if (named.has(name)) params[name] = value
143
+ else pairs.append(name, value)
144
+ address = filledPath(route, params, route)
145
+ }
111
146
  if (argsInURL(method))
112
147
  return { url: `${address}?${pairs}`, init: { method } }
113
148
  return {
@@ -120,49 +155,11 @@ function textRequest(
120
155
  }
121
156
  }
122
157
 
123
- /**
124
- * Prints every line `log.records` publishes, as it is written; returns the exit code.
125
- *
126
- * 38.10.
127
- */
128
- export async function followLogs(url: string): Promise<number> {
129
- const answer = await fetch(`${url}${LOGS_ADDRESS}`, {
130
- headers: { ...remoteHeaders(), accept: 'text/event-stream' },
131
- })
132
- if (!answer.ok || answer.body === null) {
133
- console.error(
134
- `logs: the app answered ${answer.status}. ABIDE_LOGS opens the subscription.`,
135
- )
136
- return 1
137
- }
138
- // 18.20 — the lines held arrive as the value, and each line after as an `add` at `/-`; a trim's
139
- // `remove`s are the buffer letting old lines go, which a tail has already printed.
140
- for await (const event of eventsOf(answer.body)) {
141
- if (event.name === 'error') console.error(`logs: ${event.data}`)
142
- const lines =
143
- event.name === 'value'
144
- ? ((JSON.parse(event.data) as LogRecord[] | null) ?? [])
145
- : event.name === 'patch'
146
- ? addedBy(JSON.parse(event.data) as PatchOp[])
147
- : []
148
- for (const { time, level, namespace, message } of lines)
149
- console.log(`${time} ${level} ${namespace} ${message}`)
150
- }
151
- return 0
152
- }
153
-
154
- /** The records a patch appended: the value of each `add`. */
155
- function addedBy(ops: PatchOp[]): LogRecord[] {
156
- const added: LogRecord[] = []
157
- for (const op of ops) if (op.op === 'add') added.push(op.value as LogRecord)
158
- return added
159
- }
160
-
161
158
  /**
162
159
  * Follows a `GET` of the app at `url` as an event stream, printing its value and its value after each
163
160
  * patch; returns the exit code.
164
161
  *
165
- * 38.70, 18.20 — THE EVENT STREAM ANY CLIENT MAY ASK A FOLLOWED `GET` FOR, read by one: `event: value`
162
+ * 38.69, 18.20 — THE EVENT STREAM ANY CLIENT MAY ASK A FOLLOWED `GET` FOR, read by one: `event: value`
166
163
  * replaces what is held and `event: patch` is JSON Patch applied over it. A handler the server does
167
164
  * not follow — one built per request — answers once, and is printed once.
168
165
  */
@@ -172,7 +169,7 @@ export async function watch(
172
169
  given: string[],
173
170
  signal?: AbortSignal,
174
171
  ): Promise<number> {
175
- const path = `${RPC_ADDRESS}/${address.replace(/^\//, '')}`
172
+ const path = `/${address.replace(/^\//, '')}`
176
173
  const args = callArgs(given)
177
174
  const request =
178
175
  args instanceof URLSearchParams
@@ -27,17 +27,25 @@ import { currentRequestScope, type PageCall } from './requestScope.ts'
27
27
  // biome-ignore lint/suspicious/noEmptyInterface: REGISTRY declares `interface Component {}`.
28
28
  export interface Component {}
29
29
 
30
- /** The document a render renders into, defaulting to `src/ui/app.html`. 35.6. */
31
- export type Shell = string | URL | undefined
32
-
33
- // Where a shell was not given and `src/ui/app.html` is not there either. The rulebook does not
34
- // say what happens in that case; serving nothing would be worse than serving this.
35
- const BARE_SHELL = '<!doctype html><html><head></head><body></body></html>'
30
+ /**
31
+ * 35.6 — THE DOCUMENT EVERY RENDER WRITES INTO: an encoding and a viewport, each keyed, so the
32
+ * component's own replace them (35.15), and nothing else — what a document carries beyond that is
33
+ * the component's to write.
34
+ */
35
+ export const DEFAULT_DOCUMENT =
36
+ '<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head><body></body></html>'
36
37
 
37
38
  const ENCODER = new TextEncoder()
38
39
 
39
- /** What a compiled component answers: its head and its body markup. */
40
- export type Rendered = { head: string; body: string }
40
+ /**
41
+ * What a compiled component answers: its head and its body markup, and the document's `<html>`
42
+ * and `<body>` it writes, rendered empty (35.15), or `undefined`.
43
+ */
44
+ export type Rendered = {
45
+ head: string
46
+ body: string
47
+ document: { html: string; body: string } | undefined
48
+ }
41
49
 
42
50
  /**
43
51
  * The page inside its layouts, every level set up first and then built.
@@ -85,7 +93,12 @@ export async function composePage(
85
93
  // THE OUTERMOST HEAD FIRST: reading it builds the chain, which is what pushes the heads below.
86
94
  // Written end to end rather than merged: `render` merges it with the shell's in one pass (39.4).
87
95
  const head = outermost.head
88
- return { head: head + heads.join(''), body: outermost.body }
96
+ return {
97
+ head: head + heads.join(''),
98
+ body: outermost.body,
99
+ // 35.15 — the document's, which only the root layout writes, and it is outermost.
100
+ document: outermost.document,
101
+ }
89
102
  }
90
103
 
91
104
  /**
@@ -185,46 +198,6 @@ function dropShadowed(markup: string, total: Map<string, number>): string {
185
198
  })
186
199
  }
187
200
 
188
- /**
189
- * The keyed elements at the top of a shell's head, end to end, and nothing else.
190
- *
191
- * 39.14 — WHAT A CLIENT NAVIGATION PUTS BACK. `mergeHead` drops every shell entry a level
192
- * overrides, so once the server has painted the document the shell's `<title>` is nowhere a
193
- * browser can read it — and a navigation to a page with no title owes it back. The build hands
194
- * these to the router rather than the server writing them into each page: the shell is one per
195
- * app, so it rides in the route table once instead of in every document. KEYED ONLY, because an
196
- * entry with no key is never withdrawn (39.5) and a shell's inline `<style>` would be the route
197
- * table's largest string for nothing. Top level only, as `mergeHead` keys it.
198
- */
199
- export function keyedHead(document: string): string {
200
- const unwrap = {
201
- element(element: HTMLRewriterTypes.Element) {
202
- element.removeAndKeepContent()
203
- },
204
- }
205
- const top = { depth: 0 }
206
- const rewriter = new HTMLRewriter()
207
- .onDocument({
208
- doctype(doctype) {
209
- doctype.remove()
210
- },
211
- comments(comment) {
212
- comment.remove()
213
- },
214
- text(text) {
215
- if (top.depth === 0) text.remove()
216
- },
217
- })
218
- .on('html', unwrap)
219
- .on('head', unwrap)
220
- return dropAtHeadTop(
221
- rewriter,
222
- document.slice(0, headAnchor(document)),
223
- (key) => key === undefined,
224
- top,
225
- )
226
- }
227
-
228
201
  /**
229
202
  * `markup` through `rewriter`, with each element at the top of the head removed where `drop`
230
203
  * answers true for its key — `undefined` for an element with none. `top` is the depth the walk is
@@ -300,23 +273,25 @@ export function boundPage(
300
273
  }
301
274
 
302
275
  /**
303
- * The document's bytes: `component` rendered into `shell`.
276
+ * The document's bytes: `component` rendered into abide's own document (35.6).
304
277
  *
305
278
  * 35.4, 35.5 — the component is already bound to its props.
306
279
  */
307
- export async function render(
280
+ export function render(component: Component): Promise<Uint8Array<ArrayBuffer>> {
281
+ return renderDocument(component, DEFAULT_DOCUMENT)
282
+ }
283
+
284
+ /**
285
+ * `render` into `given`: abide's own document with what the server adds to it — a page's client
286
+ * tags, the launcher page's stylesheet. NOT PUBLIC SURFACE — `server/index.ts` exports `render`.
287
+ */
288
+ export async function renderDocument(
308
289
  component: Component,
309
- shell?: Shell,
290
+ given: string,
310
291
  ): Promise<Uint8Array<ArrayBuffer>> {
311
- // GUARDED, because the string form is already settled: `shellText` is `async`, so awaiting it
312
- // unconditionally wrapped a promise and spent a microtask turn per request for nothing.
313
- const document = typeof shell === 'string' ? shell : await shellText(shell)
314
-
315
292
  // TWO INSERTION POINTS, CLAMPED AND ORDERED. `</head>` and `</body>` are both OPTIONAL end
316
293
  // tags, so a valid shell can carry neither: an absent anchor means "the end of the document",
317
294
  // and the clamp is what keeps the head ahead of the body whatever order the shell wrote them in.
318
- const bodyAt = bodyAnchor(document)
319
- const headAt = headAnchor(document)
320
295
  // 41.1 — NOTHING GOES OUT UNTIL THE PAGE IS BUILT. The status line goes out with the first byte,
321
296
  // and a page reading a record that is not there is a 404 only while no byte has: so the page and
322
297
  // its layouts are set up (41.19) and built before the shell's head is sent, and a refusal or a
@@ -331,6 +306,13 @@ export async function render(
331
306
  const rendered = isThenable(answered) ? await answered : answered
332
307
  const head = rendered.head
333
308
  const body = rendered.body
309
+ // 35.15 — the root layout's `<html>` and `<body>` attributes, on the document's own two.
310
+ const document =
311
+ rendered.document === undefined
312
+ ? given
313
+ : withDocumentAttributes(given, rendered.document)
314
+ const bodyAt = bodyAnchor(document)
315
+ const headAt = headAnchor(document)
334
316
  // 41.20 — THE PAGE'S CALLS LEAVE AHEAD OF ITS MARKUP. Setup has run, so every call the page
335
317
  // makes is known, and a script in the head starts each one as the request the browser's own
336
318
  // `Rpc` would make — while the rest of the document and the bundle are still on their way.
@@ -399,13 +381,20 @@ function bodyAnchor(document: string): number {
399
381
  }
400
382
 
401
383
  /**
402
- * Where a shell's head ends, clamped ahead of its body.
384
+ * The shell with `tags` at the head anchor `render` uses.
403
385
  *
404
- * NOT PUBLIC SURFACE: `server/index.ts` does not re-export it. `answerRequest` is the second
405
- * caller — a shell has to carry the client entry's script tag (38.23), and putting that anywhere
406
- * but the head this finds is the same clamped-and-ordered question over again.
386
+ * So a shell with no `</head>` puts them where its head content goes rather than inside the body.
387
+ * NOT PUBLIC SURFACE: `server/index.ts` does not re-export it. A shell has to carry the client
388
+ * entry's script tag (38.22), and putting that anywhere but the head `render` finds is the same
389
+ * clamped-and-ordered question over again.
407
390
  */
408
- export function headAnchor(document: string): number {
391
+ export function intoHead(shell: string, tags: string): string {
392
+ const at = headAnchor(shell)
393
+ return shell.slice(0, at) + tags + shell.slice(at)
394
+ }
395
+
396
+ /** Where a shell's head ends, clamped ahead of its body. */
397
+ function headAnchor(document: string): number {
409
398
  const bodyAt = bodyAnchor(document)
410
399
  // THE OPENING `<body` IS THE FALLBACK, not the body anchor. Collapsed onto `bodyAt`, a shell
411
400
  // with no `</head>` put the page's `<title>`, `<meta>` and stylesheet links after the mount
@@ -428,37 +417,19 @@ function bodyOpen(document: string): number {
428
417
  }
429
418
 
430
419
  /**
431
- * The text of a shell given as a URL, or of the default one.
420
+ * `document` with the attributes `written` carries — an `<html …></html>` and a `<body …></body>`,
421
+ * each rendered empty — written onto its own `<html>` and `<body>` tags.
432
422
  *
433
- * The URL and absent forms only — the string form is already settled and its caller above takes
434
- * it without a turn, so a second copy of that test here is a branch nothing reaches and a second
435
- * owner of the question.
423
+ * 35.15, 35.17 — RENDERED AS ANY ELEMENT IS, so an attribute is escaped, absent or live exactly as
424
+ * one on a `<div>` is, and lifted off the rendered pair by its fixed frame rather than parsed.
436
425
  */
437
- async function shellText(shell: URL | undefined): Promise<string> {
438
- if (shell !== undefined) return await Bun.file(shell).text()
439
- return await shellUnder('src')
440
- }
441
-
442
- /**
443
- * The shell at `<root>/ui/app.html`, or a bare document where there is none.
444
- *
445
- * 35.6's default, read from a ROOT rather than from the process's directory. `render` on its own
446
- * is called from wherever the app is, so `src` is the root it passes; `loadBuild` is handed the
447
- * root `abide build` compiled and the two need not be the same directory. NOT PUBLIC SURFACE —
448
- * `server/index.ts` does not re-export it.
449
- */
450
- export async function shellUnder(root: string): Promise<string> {
451
- // `.text()` already fails when the file is not there, so no `.exists()` asks first. The read
452
- // happens per render for a bare `render` call — 20 µs and 98% of the op — and `loadBuild` is the caller that hoists
453
- // it, reading the shell once at boot rather than once a request.
454
- try {
455
- return await Bun.file(`${root}/ui/app.html`).text()
456
- } catch (thrown) {
457
- // NARROWED TO THE ONE CASE THE LINE ABOVE ARGUES FOR. A bare catch made every failure look
458
- // like an absent file, so a project that HAS a shell which is momentarily unreadable —
459
- // EACCES, EISDIR, EMFILE under load — was served the bare shell with no stylesheet, no
460
- // client script and nothing said, on one request in a thousand.
461
- if ((thrown as { code?: unknown }).code !== 'ENOENT') throw thrown
462
- return BARE_SHELL
463
- }
426
+ function withDocumentAttributes(
427
+ document: string,
428
+ written: { html: string; body: string },
429
+ ): string {
430
+ const html = written.html.slice('<html'.length, -'></html>'.length)
431
+ const body = written.body.slice('<body'.length, -'></body>'.length)
432
+ return document
433
+ .replace(/<html(?=[\s>])/i, `<html${html}`)
434
+ .replace(/<body(?=[\s>])/i, `<body${body}`)
464
435
  }
@@ -28,6 +28,11 @@ export function renderComponent(
28
28
  const rendered = (
29
29
  component as (props: Record<string, () => unknown>) => Rendered
30
30
  )(props)
31
+ // 35.16 — a document is written by the outermost component alone, and this one is placed.
32
+ if (rendered.document !== undefined)
33
+ throw new Error(
34
+ 'abide: a component that writes <html> is placed inside another, and only the outermost component writes the document. RULEBOOK 35.16.',
35
+ )
31
36
  if (rendered.head !== '') heads.push(rendered.head)
32
37
  return regionMarkup(rendered.body)
33
38
  }