@abide/abide 0.100.0 → 0.102.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 (160) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/bin/abide.ts +227 -163
  3. package/docs/REGISTRY.md +726 -0
  4. package/docs/RULEBOOK.md +2753 -0
  5. package/docs/abide.md +12878 -0
  6. package/package.json +10 -3
  7. package/scaffold/AGENTS.md +31 -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 +262 -67
  13. package/src/compiler/argsSchemaOf.ts +1 -1
  14. package/src/compiler/checkApp.ts +43 -15
  15. package/src/compiler/checkedShadow.ts +39 -5
  16. package/src/compiler/classifyRead.ts +6 -4
  17. package/src/compiler/classifyTemplate.ts +1 -1
  18. package/src/compiler/compileApp.ts +433 -193
  19. package/src/compiler/compiledEntry.ts +43 -39
  20. package/src/compiler/componentGraph.ts +58 -71
  21. package/src/compiler/declaredExports.ts +24 -7
  22. package/src/compiler/emitClient.ts +141 -71
  23. package/src/compiler/emitComponent.ts +37 -8
  24. package/src/compiler/emitSource.ts +106 -13
  25. package/src/compiler/handlersUnder.ts +106 -56
  26. package/src/compiler/index.ts +7 -0
  27. package/src/compiler/mountsOf.ts +138 -0
  28. package/src/compiler/nearest.ts +14 -0
  29. package/src/compiler/parseAbide.ts +157 -20
  30. package/src/compiler/projectShadow.ts +213 -7
  31. package/src/compiler/reactiveTypes.ts +20 -7
  32. package/src/compiler/readSources.ts +15 -2
  33. package/src/compiler/readsIn.ts +61 -7
  34. package/src/compiler/renderMarkup.ts +3 -3
  35. package/src/compiler/scaffold.ts +30 -11
  36. package/src/compiler/serveLanguage.ts +125 -34
  37. package/src/compiler/sourceMapOf.ts +193 -0
  38. package/src/compiler/splitImports.ts +205 -83
  39. package/src/compiler/syncTypes.ts +32 -0
  40. package/src/compiler/tokenize.ts +14 -0
  41. package/src/compiler/writeDefaultProject.ts +1 -1
  42. package/src/compiler/writeTypes.ts +82 -0
  43. package/src/server/CLIENT_BUILD.ts +11 -13
  44. package/src/server/DEFAULT_PORT.ts +1 -1
  45. package/src/server/LAUNCHER_FILES.ts +9 -0
  46. package/src/server/MODULE_QUERIES.ts +18 -0
  47. package/src/server/TABLES.ts +7 -0
  48. package/src/server/VIRTUAL_DIRECTORY.ts +4 -5
  49. package/src/server/abideModules.ts +391 -0
  50. package/src/server/answerFailure.ts +16 -6
  51. package/src/server/answerRequest.ts +235 -106
  52. package/src/server/answerRpc.ts +124 -39
  53. package/src/server/answerSocket.ts +141 -48
  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 +179 -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 +37 -37
  65. package/src/server/componentModules.d.ts +3 -1
  66. package/src/server/compressAnswer.ts +48 -14
  67. package/src/server/config.ts +25 -29
  68. package/src/server/configFields.ts +31 -25
  69. package/src/server/createApp.ts +59 -73
  70. package/src/server/devWorker.ts +144 -183
  71. package/src/server/executableName.ts +4 -0
  72. package/src/server/fenceServer.ts +37 -35
  73. package/src/server/generatedSurfaces.ts +99 -66
  74. package/src/server/hooks.ts +124 -52
  75. package/src/server/iconAccent.ts +191 -0
  76. package/src/server/index.ts +11 -7
  77. package/src/server/installSourceMaps.ts +88 -0
  78. package/src/server/launcherOptions.ts +34 -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 +5 -5
  83. package/src/server/negotiateEncoding.ts +1 -1
  84. package/src/server/principal.ts +10 -5
  85. package/src/server/realOrAsGiven.ts +10 -0
  86. package/src/server/refuseAddressCollision.ts +21 -14
  87. package/src/server/remote.ts +49 -52
  88. package/src/server/render.ts +65 -94
  89. package/src/server/renderComponent.ts +5 -0
  90. package/src/server/reportHealth.ts +2 -2
  91. package/src/server/requestScope.ts +12 -1
  92. package/src/server/runBuild.ts +24 -0
  93. package/src/server/runCompiled.ts +5 -5
  94. package/src/server/runDesktop.ts +52 -27
  95. package/src/server/serveBuild.ts +43 -52
  96. package/src/server/serveDev.ts +81 -39
  97. package/src/server/shell.ts +7 -22
  98. package/src/server/sourceMappings.ts +139 -0
  99. package/src/server/surfaceOf.ts +18 -3
  100. package/src/server/testBuild.ts +1 -19
  101. package/src/server/withAbideSources.ts +117 -0
  102. package/src/shared/ADDRESSES.ts +7 -18
  103. package/src/shared/APP_META.ts +7 -0
  104. package/src/shared/CopyOptions.ts +1 -1
  105. package/src/shared/DEBUG_META.ts +1 -1
  106. package/src/shared/GLOBAL_GETS.ts +9 -3
  107. package/src/shared/RESERVED_DIRECTORIES.ts +15 -0
  108. package/src/shared/RPC_SERVER.ts +2 -2
  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 +1 -1
  116. package/src/shared/log.ts +27 -13
  117. package/src/shared/matchRoute.ts +23 -4
  118. package/src/shared/menuItem.ts +3 -3
  119. package/src/shared/reactive/Cursor.ts +3 -9
  120. package/src/shared/reactive/Keyed.ts +4 -3
  121. package/src/shared/reactive/NotLanded.ts +1 -1
  122. package/src/shared/reactive/Reactive.ts +81 -60
  123. package/src/shared/reactive/argsContract.ts +4 -4
  124. package/src/shared/reactive/canonicalKey.ts +4 -4
  125. package/src/shared/reactive/chunksOfStream.ts +6 -0
  126. package/src/shared/reactive/index.ts +1 -1
  127. package/src/shared/reactive/recordWrites.ts +103 -5
  128. package/src/shared/reactive/structural.ts +1 -1
  129. package/src/shared/reactive/tracking.ts +155 -21
  130. package/src/shared/reactive/watch.ts +10 -6
  131. package/src/shared/refuse.ts +1 -1
  132. package/src/shared/routePattern.ts +82 -46
  133. package/src/shared/rpc.ts +47 -13
  134. package/src/shared/rpcRequest.ts +27 -3
  135. package/src/shared/url.ts +4 -44
  136. package/src/shared/writePath.ts +1 -1
  137. package/src/ui/LAYER_NAMES.ts +1 -1
  138. package/src/ui/List.ts +3 -3
  139. package/src/ui/MOUNTING_LEVEL.ts +18 -0
  140. package/src/ui/Mismatch.ts +1 -1
  141. package/src/ui/RawRun.ts +1 -1
  142. package/src/ui/Router.ts +38 -31
  143. package/src/ui/Scope.ts +1 -1
  144. package/src/ui/TryRegion.ts +2 -2
  145. package/src/ui/applyHead.ts +33 -0
  146. package/src/ui/index.ts +1 -1
  147. package/src/ui/menuBridge.ts +1 -1
  148. package/src/ui/moveNode.ts +1 -1
  149. package/src/ui/reconcile.ts +2 -2
  150. package/src/ui/reportMenu.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 → page.abide} +0 -0
@@ -1,4 +1,4 @@
1
- // 38.30 — AN ANSWER, COMPRESSED WHOLE: a document, and a JSON value at least `COMPRESSION_FLOOR`
1
+ // 38.29 — AN ANSWER, COMPRESSED WHOLE: a document, and a JSON value at least `COMPRESSION_FLOOR`
2
2
  // bytes long. Both are in hand before a byte goes out — `render` sends nothing until the page is
3
3
  // built (41.1), and a value is stringified once — so one compression is the whole answer, with its
4
4
  // length known, where a stream compressed as it arrived had to be flushed at every chunk.
@@ -6,8 +6,10 @@
6
6
  // WHAT EACH LANE SENDS BESIDE THE BYTES is here too, so the two cannot drift. A document names
7
7
  // `accept-encoding` in `vary` through `DOCUMENT_HEADERS`, which its `HEAD` and error answers carry as
8
8
  // well; a JSON answer names it here. Both say their length: `abide dev` relays a body chunk by chunk
9
- // from its worker (38.36), and a relayed body with no declared length goes out chunked.
10
- import { brotliCompressSync, constants } from 'node:zlib' // bun has gzip and zstd of its own, and no brotli
9
+ // from its worker (38.35), and a relayed body with no declared length goes out chunked.
10
+ // BUN'S GZIP parts from zlib's past ~130 KB, which only the off-loop lane reaches — so that lane takes
11
+ // zlib's, the loop keeps bun's, and the two make the same bytes.
12
+ import { brotliCompress, brotliCompressSync, constants, gzip } from 'node:zlib' // bun has no brotli, and no compression off the loop
11
13
  import {
12
14
  ENCODINGS,
13
15
  type Encoding,
@@ -17,13 +19,18 @@ import {
17
19
  // QUALITY 5, NOT 11. This runs per request, and the top brotli level is priced for a build that
18
20
  // runs once: on an 18 KB docs page 11 took 15,800 µs against 5's 150, for 15% fewer bytes. 5 is
19
21
  // already under gzip's size at twice gzip's 70 µs.
20
- const QUALITY = 5
22
+ const BROTLI_OPTIONS = { params: { [constants.BROTLI_PARAM_QUALITY]: 5 } }
21
23
 
22
- // 38.69 — the bytes under which a JSON answer goes out as it is. Under it an answer saves tens of
24
+ // 38.68 — the bytes under which a JSON answer goes out as it is. Under it an answer saves tens of
23
25
  // bytes that leave in the same packet anyway, for 3–8 µs of a ~57 µs request; at 8 KB the same CPU
24
26
  // saves seven kilobytes.
25
27
  const COMPRESSION_FLOOR = 1024
26
28
 
29
+ // 38.95 — the bytes from which an answer is compressed off the event loop. Brotli 5 holds the loop
30
+ // ~360 µs at 64 KB and ~7 ms at 700 KB — every other request and socket waits that long — where
31
+ // the thread pool's hop adds ~20 µs to the answer it carries: 6% at 64 KB, 27% at 16 KB.
32
+ const OFF_LOOP_FLOOR = 64 * 1024
33
+
27
34
  const ENCODER = new TextEncoder()
28
35
 
29
36
  function compressed(
@@ -31,24 +38,48 @@ function compressed(
31
38
  encoding: Encoding,
32
39
  ): Uint8Array<ArrayBuffer> {
33
40
  return encoding === 'br'
34
- ? (brotliCompressSync(bytes, {
35
- params: { [constants.BROTLI_PARAM_QUALITY]: QUALITY },
36
- }) as Uint8Array<ArrayBuffer>)
41
+ ? (brotliCompressSync(bytes, BROTLI_OPTIONS) as Uint8Array<ArrayBuffer>)
37
42
  : Bun.gzipSync(bytes)
38
43
  }
39
44
 
45
+ function compressedOffLoop(
46
+ bytes: Uint8Array<ArrayBuffer>,
47
+ encoding: Encoding,
48
+ ): Promise<Uint8Array<ArrayBuffer>> {
49
+ return new Promise((resolve, reject) => {
50
+ const settle = (error: Error | null, result: Uint8Array): void => {
51
+ if (error === null) resolve(result as Uint8Array<ArrayBuffer>)
52
+ else reject(error)
53
+ }
54
+ if (encoding === 'br') brotliCompress(bytes, BROTLI_OPTIONS, settle)
55
+ else gzip(bytes, settle)
56
+ })
57
+ }
58
+
59
+ function answered(
60
+ body: Uint8Array<ArrayBuffer>,
61
+ encoding: Encoding | undefined,
62
+ headers: Headers,
63
+ status: number,
64
+ ): Response {
65
+ if (encoding !== undefined) headers.set('content-encoding', encoding)
66
+ headers.set('content-length', String(body.byteLength))
67
+ return new Response(body, { status, headers })
68
+ }
69
+
40
70
  /**
41
71
  * `answer` as the `Response` to `request`, in the encoding the request takes where it takes one.
42
72
  *
43
73
  * THE LANE IS THE BODY'S TYPE: a built document arrives as its bytes, and a JSON value as its text,
44
- * encoded once here and floored by its length.
74
+ * encoded once here and floored by its length. A `Promise` only where the compression runs off the
75
+ * loop, so an answer under that floor costs no await.
45
76
  */
46
77
  export function compressAnswer(
47
78
  answer: Uint8Array<ArrayBuffer> | string,
48
79
  request: Request,
49
80
  headers: Headers,
50
81
  status: number,
51
- ): Response {
82
+ ): Response | Promise<Response> {
52
83
  const json = typeof answer === 'string'
53
84
  const bytes = json ? ENCODER.encode(answer) : answer
54
85
  const encoding =
@@ -59,8 +90,11 @@ export function compressAnswer(
59
90
  ENCODINGS,
60
91
  )
61
92
  if (json) headers.append('vary', 'accept-encoding')
62
- const body = encoding === undefined ? bytes : compressed(bytes, encoding)
63
- if (encoding !== undefined) headers.set('content-encoding', encoding)
64
- headers.set('content-length', String(body.byteLength))
65
- return new Response(body, { status, headers })
93
+ if (encoding === undefined)
94
+ return answered(bytes, encoding, headers, status)
95
+ if (bytes.byteLength >= OFF_LOOP_FLOOR)
96
+ return compressedOffLoop(bytes, encoding).then((body) =>
97
+ answered(body, encoding, headers, status),
98
+ )
99
+ return answered(compressed(bytes, encoding), encoding, headers, status)
66
100
  }
@@ -1,16 +1,17 @@
1
1
  // SECTION 27 — THE RESOLVED CONFIGURATION. One process-wide value (27.9) over three layers, the last
2
2
  // winning (27.2): what abide derives, what the app's `onConfig` defaulted, and the environment.
3
3
  //
4
- // A `reactive` BODY, so `config.invalidate` is the core's own trigger and re-runs all of it (27.5),
4
+ // A `reactive` BODY, so `config.invalidate` is the core's own trigger and reruns all of it (27.5),
5
5
  // and a throw out of `onConfig` is the value's standing error, which a read throws (27.1).
6
6
 
7
7
  import { homedir } from 'node:os' // bun exposes no home directory of its own
8
+ import { isRecord } from '#shared/isRecord.ts'
8
9
  import { numberFromText } from '#shared/numberFromText.ts'
9
10
  import { reactive } from '#shared/reactive/index.ts'
10
11
  import { validationError } from '#shared/refuse.ts'
11
12
  import { runSchema } from '#shared/Schema.ts'
12
13
  import { appDirectories } from './appDirectories.ts'
13
- import { type Env, HOOKS } from './hooks.ts'
14
+ import { type Env, HOOKS, inOrder } from './hooks.ts'
14
15
  import { withoutTrailingSlash } from './withoutTrailingSlash.ts'
15
16
 
16
17
  /** The fields of an app's `package.json` 27.3 derives from. */
@@ -46,10 +47,6 @@ export interface Config {
46
47
  APP_NAME: string
47
48
  APP_VERSION: string
48
49
  APP_DATA_DIR: string
49
- ABIDE_LOGS: boolean
50
- ABIDE_OPENAPI: boolean
51
- ABIDE_MCP: boolean
52
- ABIDE_BRIDGE: boolean
53
50
  }
54
51
 
55
52
  /** The resolved configuration: abide's derived values, then `onConfig`'s defaults, then the env. */
@@ -57,24 +54,20 @@ export const config = reactive(resolve, { global: true })
57
54
 
58
55
  function resolve(): Config {
59
56
  const env: Env = { ...process.env }
60
- const hooked = HOOKS.config
61
- // 27.4 — SYNCHRONOUS BY CONTRACT; a promise handed back is a contract broken, not a value.
57
+ const hooked = inOrder(HOOKS.config)
62
58
  // 27.11 — handed what 27.3 derives under the environment, so a default can be built on
63
59
  // `APP_DATA_DIR` without deriving it a second time.
64
60
  const derivedValues = derived()
65
- const defaults = hooked?.defaults?.({ ...derivedValues, ...env }) ?? {}
66
- if (defaults instanceof Promise)
67
- throw new Error(
68
- 'abide: onConfig returned a promise. ConfigDefaults is synchronous; read what the defaults need before registering them. RULEBOOK 27.4.',
69
- )
70
- const document: Record<string, unknown> = {
71
- // 36.7 — CLOSED BY DEFAULT, and a boolean, so 27.8 reads the environment's spelling of one.
72
- ABIDE_LOGS: false,
73
- ABIDE_OPENAPI: false,
74
- ABIDE_MCP: false,
75
- ABIDE_BRIDGE: false,
76
- ...derivedValues,
77
- ...(defaults as Record<string, unknown>),
61
+ const document: Record<string, unknown> = { ...derivedValues }
62
+ // 27.6, 37.7 — EVERY `onConfig`'S DEFAULTS, merged over the ones before it.
63
+ for (const { defaults } of hooked) {
64
+ const given = defaults?.({ ...derivedValues, ...env }) ?? {}
65
+ // 27.4 — SYNCHRONOUS BY CONTRACT; a promise handed back is a contract broken, not a value.
66
+ if (given instanceof Promise)
67
+ throw new Error(
68
+ 'abide: onConfig returned a promise. ConfigDefaults is synchronous; read what the defaults need before registering them. RULEBOOK 27.4.',
69
+ )
70
+ Object.assign(document, given as Record<string, unknown>)
78
71
  }
79
72
  // 27.8 — THE ENVIRONMENT, COERCED TO THE DEFAULT IT OVERRIDES; a value that will not coerce leaves
80
73
  // the default standing.
@@ -83,13 +76,16 @@ function resolve(): Config {
83
76
  const coerced = coerce(text, document[name])
84
77
  if (coerced !== undefined) document[name] = coerced
85
78
  }
86
- // 27.7 — LAST, over the whole document.
87
- const schema = hooked?.options?.schema
88
- if (schema === undefined) return document as Config
89
- const checked = runSchema(schema, document)
90
- if (checked.issues !== null)
91
- return validationError(checked.issues as never) as never
92
- return checked.value as Config
79
+ // 27.7 — LAST, each over the whole document, and what each accepts merged back over it.
80
+ for (const { options } of hooked) {
81
+ const schema = options?.schema
82
+ if (schema === undefined) continue
83
+ const checked = runSchema(schema, document)
84
+ if (checked.issues !== null)
85
+ return validationError(checked.issues as never) as never
86
+ if (isRecord(checked.value)) Object.assign(document, checked.value)
87
+ }
88
+ return document as Config
93
89
  }
94
90
 
95
91
  function coerce(text: string, standing: unknown): unknown {
@@ -129,7 +125,7 @@ export function dataDirectory(name: string): string {
129
125
  return `${process.env.XDG_DATA_HOME ?? `${home}/.local/share`}/${name}`
130
126
  }
131
127
 
132
- // 28.5 — THE APP'S NAME IS `log`'s ROOT NAMESPACE, and the config is where the name is decided.
128
+ // 28.4 — THE APP'S NAME IS `log`'s ROOT NAMESPACE, and the config is where the name is decided.
133
129
  // PEEKED, so a log line written inside an effect does not subscribe it to the configuration.
134
130
  export function derivedAppName(): string | undefined {
135
131
  const resolved = config.peek()
@@ -1,8 +1,8 @@
1
- // 38.73 — THE FIELDS THE DESKTOP LAUNCHER'S START FORM ASKS FOR: the members of the `onConfig`
1
+ // 38.72 — THE FIELDS THE DESKTOP LAUNCHER'S START FORM ASKS FOR: the members of the `onConfig`
2
2
  // schema's JSON Schema, probed as 20.11 probes a handler's, and the fields `onConfig`'s defaults
3
3
  // return where the schema has no document to read.
4
4
 
5
- import { HOOKS } from './hooks.ts'
5
+ import { HOOKS, inOrder } from './hooks.ts'
6
6
 
7
7
  type Property = {
8
8
  type?: string | string[]
@@ -30,32 +30,38 @@ export type ConfigField = {
30
30
  export function configFields(
31
31
  env: Record<string, string | undefined>,
32
32
  ): ConfigField[] {
33
- const hooked = HOOKS.config
34
- let defaults: Record<string, unknown> = {}
35
- try {
36
- const given = hooked?.defaults?.({ ...env })
37
- if (given !== null && typeof given === 'object')
38
- defaults = given as Record<string, unknown>
39
- } catch {}
40
- const schema = hooked?.options?.schema as
41
- | {
42
- '~standard'?: {
43
- jsonSchema?: { input?: (options: unknown) => unknown }
33
+ // 27.6, 37.7 — EVERY `onConfig`, its defaults and its schema's fields merged in route order.
34
+ const defaults: Record<string, unknown> = {}
35
+ let declared: Record<string, Property> | undefined
36
+ const required = new Set<string>()
37
+ for (const { defaults: given, options } of inOrder(HOOKS.config)) {
38
+ try {
39
+ const answered = given?.({ ...env })
40
+ if (answered !== null && typeof answered === 'object')
41
+ Object.assign(defaults, answered)
42
+ } catch {}
43
+ const schema = options?.schema as
44
+ | {
45
+ '~standard'?: {
46
+ jsonSchema?: { input?: (options: unknown) => unknown }
47
+ }
44
48
  }
45
- }
46
- | undefined
47
- let document:
48
- | { properties?: Record<string, Property>; required?: string[] }
49
- | undefined
50
- try {
51
- document = schema?.['~standard']?.jsonSchema?.input?.({
52
- target: 'draft-2020-12',
53
- }) as typeof document
54
- } catch {}
49
+ | undefined
50
+ let document:
51
+ | { properties?: Record<string, Property>; required?: string[] }
52
+ | undefined
53
+ try {
54
+ document = schema?.['~standard']?.jsonSchema?.input?.({
55
+ target: 'draft-2020-12',
56
+ }) as typeof document
57
+ } catch {}
58
+ if (document?.properties !== undefined)
59
+ declared = { ...declared, ...document.properties }
60
+ for (const name of document?.required ?? []) required.add(name)
61
+ }
55
62
  const properties: Record<string, Property> =
56
- document?.properties ??
63
+ declared ??
57
64
  Object.fromEntries(Object.keys(defaults).map((name) => [name, {}]))
58
- const required = new Set(document?.required ?? [])
59
65
  const fields: ConfigField[] = []
60
66
  for (const [name, property] of Object.entries(properties)) {
61
67
  const fallback = defaults[name] ?? property.default
@@ -10,19 +10,19 @@ import { namedLogger } from '#shared/log.ts'
10
10
  import { compose } from '#shared/Middleware.ts'
11
11
  import { MOUNT, unmounted } from '#shared/mount.ts'
12
12
  import { RPC_SERVER } from '#shared/RPC_SERVER.ts'
13
- import {
14
- type AppSources,
15
- answerRequest,
16
- type LoadedSources,
17
- } from './answerRequest.ts'
13
+ import { servedTable } from './abideModules.ts'
14
+ import { type AppSources, answerRequest } from './answerRequest.ts'
18
15
  import { Mux, type MuxConnection } from './answerSocket.ts'
16
+ import { appDirectories } from './appDirectories.ts'
17
+ import { holdsAbide } from './appTree.ts'
19
18
  import { buildDirectory } from './buildDirectory.ts'
20
19
  import { APP_PACKAGE, type AppPackage, config, packageUnder } from './config.ts'
21
20
  import { McpSurface } from './generatedSurfaces.ts'
22
- import { HOOKS, reportUnexpected } from './hooks.ts'
21
+ import { HOOKS, inOrder, reportUnexpected, rootRungs } from './hooks.ts'
23
22
  import { installServer } from './installServer.ts'
24
23
  import { admitsMux, upgraded } from './muxUpgrade.ts'
25
24
  import { plainAnswer } from './plainAnswer.ts'
25
+ import { realOrAsGiven } from './realOrAsGiven.ts'
26
26
  import {
27
27
  type AnsweringApp,
28
28
  applyWritten,
@@ -35,7 +35,7 @@ import {
35
35
  import { testBuild } from './testBuild.ts'
36
36
  import { traceResponse } from './trace.ts'
37
37
 
38
- // 28.9 — one line per request.
38
+ // 28.8 — one line per request.
39
39
  const REQUEST_LOG = namedLogger('abide:request')
40
40
 
41
41
  /** The app object: what a host, a test or a script holds the app as. */
@@ -54,29 +54,16 @@ export interface App {
54
54
  [Symbol.asyncDispose](): Promise<void>
55
55
  }
56
56
 
57
- // 37.2 — the two shapes `default` may take.
58
- type OwnRoute =
59
- | ((
60
- request: Request,
61
- server: Bun.Server<unknown> | undefined,
62
- ) => Response | undefined | Promise<Response | undefined>)
63
- | {
64
- fetch: (
65
- request: Request,
66
- server: Bun.Server<unknown> | undefined,
67
- ) => unknown
68
- }
69
-
70
57
  /**
71
58
  * The app this process was started in, booted off what `abide build` left beside it.
72
59
  *
73
- * 42.1 — found where the CLI finds it (38.26), and booted off the build (38.28).
60
+ * 42.1 — found where the CLI finds it (38.25), and booted off the build (38.27).
74
61
  */
75
62
  export async function createApp(): Promise<App> {
76
63
  const root = appRoot(undefined)
77
64
  if (root === undefined)
78
65
  throw new Error(
79
- `abide: createApp found no app. Looked for ui/ in ${process.cwd()} and in ${resolve('src')}.`,
66
+ `abide: createApp found no app. Looked for a .abide under ${resolve('src')}, or under ${process.cwd()} where it has no src.`,
80
67
  )
81
68
  // 42.16 — under `bun test` the app is built for its sources as they stand rather than read off
82
69
  // whatever `abide build` last left, which a test run would otherwise need first and could find
@@ -85,21 +72,34 @@ export async function createApp(): Promise<App> {
85
72
  process.env.NODE_ENV === 'test'
86
73
  ? await testBuild(root)
87
74
  : buildDirectory(root)
88
- const { loadBuild } = await import('./serveBuild.ts')
75
+ const { loadBuild, serveBuild } = await import('./serveBuild.ts')
76
+ if (!(await serveBuild(root, out)))
77
+ throw new Error(
78
+ `abide: createApp found nothing under ${root} to build.`,
79
+ )
89
80
  return bootApp(originUnder(root), () => loadBuild({ root, out }))
90
81
  }
91
82
 
92
83
  /**
93
- * The directory `ui/` sits under, or `undefined` where none is found.
84
+ * The app's root — `--root`, else `src` where the working directory holds one, else the working
85
+ * directory — or `undefined` where it is no app's.
94
86
  *
95
- * 38.26 and 38.27 — `undefined` is a refusal and not a guess.
87
+ * 38.25 and 38.26 — `undefined` is a refusal and not a guess. An app's root is in a package — it or
88
+ * the directory above it holds the `package.json` (`appDirectories`) — and holds a `.abide`
89
+ * file; the package is asked first, so a command run from `/` answers at once rather than
90
+ * walking the disk.
91
+ *
92
+ * ITS REAL PATH, as the bundler and the resolver name every file under it: an app under `/tmp` is
93
+ * `/private/tmp` to both, and one spelling here is one fewer at every reader downstream.
96
94
  */
97
95
  export function appRoot(given: string | undefined): string | undefined {
98
- if (given !== undefined) return resolve(given)
99
- const here = process.cwd()
100
- if (existsSync(`${here}/ui`)) return here
96
+ if (given !== undefined) return realOrAsGiven(resolve(given))
101
97
  const under = resolve('src')
102
- return existsSync(`${under}/ui`) ? under : undefined
98
+ const root = realOrAsGiven(existsSync(under) ? under : process.cwd())
99
+ let packaged = false
100
+ for (const directory of appDirectories(root))
101
+ packaged ||= existsSync(`${directory}/package.json`)
102
+ return packaged && holdsAbide(root) ? root : undefined
103
103
  }
104
104
 
105
105
  /**
@@ -110,26 +110,26 @@ export function appRoot(given: string | undefined): string | undefined {
110
110
  * under.
111
111
  */
112
112
  export type AppOrigin = {
113
- /** The import of `src/server/app.ts`, where the app has one. 37.1. */
114
- hooks: (() => Promise<{ default?: OwnRoute }>) | undefined
113
+ /**
114
+ * Imports every module holding a `<script server>`, where the app has one. 37.1.
115
+ *
116
+ * Imported for what each CALLS: every hook it runs is filed by the time the import settles.
117
+ */
118
+ hooks: (() => Promise<unknown>) | undefined
115
119
  /** 27.3's name and version. */
116
120
  package: AppPackage
117
121
  }
118
122
 
119
- /** The path of the app's hooks module under its source root, where it has one. 37.1. */
120
- export function hooksModuleUnder(root: string): string | undefined {
121
- const hooksModule = `${root}/server/app.ts`
122
- return existsSync(hooksModule) ? hooksModule : undefined
123
- }
124
-
125
- /** The `AppOrigin` of an app on disk, found under its source root. */
123
+ /**
124
+ * The `AppOrigin` of an app on disk: its package under `root`, and its server blocks, out of the
125
+ * compilation this process serves (`serveCompilation`) — the build's, or `abide dev`'s.
126
+ */
126
127
  export function originUnder(root: string): AppOrigin {
127
- const hooksModule = hooksModuleUnder(root)
128
128
  return {
129
- hooks:
130
- hooksModule === undefined
131
- ? undefined
132
- : () => import(Bun.pathToFileURL(hooksModule).href),
129
+ hooks: async () => {
130
+ const table = await servedTable(root)
131
+ await Promise.all(table.servers.map((load) => load()))
132
+ },
133
133
  package: packageUnder(root),
134
134
  }
135
135
  }
@@ -143,15 +143,12 @@ export type BootedApp = App & { openMux(): Mux }
143
143
  /** An `App` booted over the sources `load` supplies, inside the app's `onStart` hooks. */
144
144
  export async function bootApp(
145
145
  origin: AppOrigin,
146
- load: () => Promise<LoadedSources>,
146
+ load: () => Promise<AppSources>,
147
147
  ): Promise<BootedApp> {
148
148
  installServer()
149
- // 37.1 — OPTIONAL, and imported for what it CALLS: every hook it runs is filed by the time the
150
- // import settles.
151
- const own =
152
- origin.hooks === undefined
153
- ? undefined
154
- : ((await origin.hooks()).default ?? undefined)
149
+ // 37.1 — EVERY SERVER BLOCK, imported for what it CALLS: every hook it runs is filed by the
150
+ // time the import settles, which is before `onStart` is composed around the boot.
151
+ if (origin.hooks !== undefined) await origin.hooks()
155
152
 
156
153
  // 37.4 — THE BOOT, INSIDE `onStart`. 37.6 — a throw out of a hook or out of the boot rejects
157
154
  // `createApp`, and nothing is served.
@@ -159,8 +156,8 @@ export async function bootApp(
159
156
  // 42.2 — THE CONFIGURATION IS READ AS PART OF THE BOOT, so a configuration that does not resolve
160
157
  // is a boot that fails rather than a first request that does.
161
158
  APP_PACKAGE.current = origin.package
162
- let sources: LoadedSources | undefined
163
- await compose(HOOKS.starts, async () => {
159
+ let sources: AppSources | undefined
160
+ await compose(inOrder(HOOKS.starts), async () => {
164
161
  config.invalidate()
165
162
  await config
166
163
  sources = await load()
@@ -169,17 +166,16 @@ export async function bootApp(
169
166
  throw new Error(
170
167
  'abide: an onStart hook returned without calling start(), so the app never booted. RULEBOOK 37.4.',
171
168
  )
172
- const booted: AppSources = {
173
- ...sources,
174
- own: own === undefined ? undefined : ownRoute(own),
175
- }
169
+ const booted = sources
176
170
 
177
- // 16.14, 37.3 — THE APP LANE'S ONION, composed once over the whole request path. 21.20 — the
178
- // centre takes its own time back out of what `fetch` counts for the whole onion.
179
- const answer = compose(HOOKS.rungs, async ({ request }) => {
171
+ // 16.14, 37.3 — THE APP LANE'S ONION, composed once over the whole request path: the root
172
+ // route's rungs, the ones below it running after routing (37.2, `answerRequest`). 21.20 — the
173
+ // center takes its own time back out of what `fetch` counts for the whole onion.
174
+ const rungs = rootRungs()
175
+ const answer = compose(rungs, async ({ request }) => {
180
176
  const began = performance.now()
181
177
  const answered = await answerRequest(booted, request)
182
- if (HOOKS.rungs.length > 0) rungsTook(began - performance.now())
178
+ if (rungs.length > 0) rungsTook(began - performance.now())
183
179
  return answered
184
180
  })
185
181
 
@@ -213,14 +209,14 @@ export async function bootApp(
213
209
  let answered: Response
214
210
  try {
215
211
  answered = await answer({ request })
216
- if (HOOKS.rungs.length > 0)
212
+ if (rungs.length > 0)
217
213
  rungsTook(performance.now() - started)
218
214
  } catch (thrown) {
219
215
  reportUnexpected(thrown)
220
216
  answered = plainAnswer('internal server error', 500)
221
217
  }
222
218
  const stamped = stamp(answered)
223
- // 28.8 — a closed namespace does not pay for the line it would have written.
219
+ // 28.7 — a closed namespace does not pay for the line it would have written.
224
220
  if (REQUEST_LOG.enabled())
225
221
  REQUEST_LOG.debug(
226
222
  request.method,
@@ -289,7 +285,7 @@ export async function bootApp(
289
285
  // 16.42 — a stopped app answers no `raw` call made outside every request.
290
286
  if (RPC_SERVER.raw === answerRaw) RPC_SERVER.raw = null
291
287
  bound = undefined
292
- await compose(HOOKS.stops, async () => {})(undefined)
288
+ await compose(inOrder(HOOKS.stops), async () => {})(undefined)
293
289
  }
294
290
 
295
291
  return {
@@ -416,13 +412,3 @@ function belowMount(request: Request): Request | Response {
416
412
  url.pathname = below
417
413
  return new Request(url, request)
418
414
  }
419
-
420
- function ownRoute(
421
- own: OwnRoute,
422
- ): (request: Request) => Promise<Response | undefined> {
423
- const answer = typeof own === 'function' ? own : own.fetch.bind(own)
424
- return async (request) => {
425
- const answered = await answer(request, LISTENING.server)
426
- return answered instanceof Response ? answered : undefined
427
- }
428
- }