@abide/abide 0.104.0 → 0.105.1

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 (85) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/bin/abide.ts +80 -173
  3. package/docs/REGISTRY.md +17 -17
  4. package/docs/RULEBOOK.md +50 -12
  5. package/docs/abide.md +9 -9
  6. package/package.json +1 -1
  7. package/src/compiler/Lowering.ts +15 -5
  8. package/src/compiler/Refusals.ts +57 -0
  9. package/src/compiler/TypeSession.ts +45 -86
  10. package/src/compiler/appLayout.ts +447 -0
  11. package/src/compiler/appMap.ts +21 -20
  12. package/src/compiler/checkApp.ts +61 -97
  13. package/src/compiler/checkedShadow.ts +11 -9
  14. package/src/compiler/classifyRead.ts +3 -6
  15. package/src/compiler/compileApp.ts +180 -512
  16. package/src/compiler/componentGraph.ts +2 -71
  17. package/src/compiler/declaredExports.ts +79 -60
  18. package/src/compiler/emitClient.ts +11 -13
  19. package/src/compiler/emitComponent.ts +21 -6
  20. package/src/compiler/emitSource.ts +20 -20
  21. package/src/compiler/handlersUnder.ts +46 -36
  22. package/src/compiler/hookCalls.ts +152 -0
  23. package/src/compiler/mountsOf.ts +31 -54
  24. package/src/compiler/readSources.ts +27 -42
  25. package/src/compiler/readsIn.ts +5 -16
  26. package/src/compiler/resolverIn.ts +77 -0
  27. package/src/server/abideModules.ts +92 -123
  28. package/src/server/answerRequest.ts +18 -4
  29. package/src/server/answerSocket.ts +136 -76
  30. package/src/server/answerValue.ts +28 -15
  31. package/src/server/appTree.ts +49 -71
  32. package/src/server/claudeApi.ts +2 -5
  33. package/src/server/clientBundle.ts +198 -0
  34. package/src/server/coerceArgs.ts +1 -1
  35. package/src/server/commands.ts +93 -57
  36. package/src/server/createApp.ts +90 -16
  37. package/src/server/deferredBuilds.ts +175 -0
  38. package/src/server/devWorker.ts +107 -319
  39. package/src/server/faceOf.ts +120 -0
  40. package/src/server/fenceServer.ts +10 -18
  41. package/src/server/index.ts +3 -0
  42. package/src/server/placement.ts +167 -0
  43. package/src/server/remote.ts +14 -42
  44. package/src/server/render.ts +25 -17
  45. package/src/server/renderComponent.ts +9 -5
  46. package/src/server/requestScope.ts +10 -4
  47. package/src/server/responseRelay.ts +389 -0
  48. package/src/server/runCompiled.ts +9 -12
  49. package/src/server/runDesktop.ts +6 -9
  50. package/src/server/serveBuild.ts +26 -73
  51. package/src/server/serveDev.ts +19 -176
  52. package/src/server/streamEvent.ts +29 -0
  53. package/src/server/withAbideSources.ts +2 -1
  54. package/src/shared/HandlerRoute.ts +68 -0
  55. package/src/shared/REFUSAL_KEY.ts +5 -0
  56. package/src/shared/answerOf.ts +105 -78
  57. package/src/shared/filledPath.ts +1 -9
  58. package/src/shared/framing.ts +127 -128
  59. package/src/shared/queryText.ts +15 -7
  60. package/src/shared/reactive/Cursor.ts +4 -2
  61. package/src/shared/reactive/Reactive.ts +285 -197
  62. package/src/shared/reactive/index.ts +11 -8
  63. package/src/shared/reactive/pairReactive.ts +1 -1
  64. package/src/shared/reactive/scoped.ts +4 -3
  65. package/src/shared/reactive/share.ts +11 -1
  66. package/src/shared/routePattern.ts +0 -123
  67. package/src/shared/rpc.ts +17 -24
  68. package/src/shared/rpcRequest.ts +18 -47
  69. package/src/shared/takeEarlyCall.ts +12 -37
  70. package/src/shared/url.ts +1 -1
  71. package/src/ui/Level.ts +276 -0
  72. package/src/ui/List.ts +17 -17
  73. package/src/ui/Router.ts +20 -87
  74. package/src/ui/applyHead.ts +92 -79
  75. package/src/ui/index.ts +4 -2
  76. package/src/ui/leave.ts +5 -0
  77. package/src/ui/reconcile.ts +23 -104
  78. package/src/ui/regions.ts +27 -2
  79. package/src/ui/rows.ts +123 -2
  80. package/src/ui/wire.ts +12 -27
  81. package/src/compiler/hooksCalled.ts +0 -35
  82. package/src/server/clientBuildOptions.ts +0 -54
  83. package/src/server/refuseAddressCollision.ts +0 -37
  84. package/src/shared/reactive/chunksOfStream.ts +0 -33
  85. package/src/ui/MOUNTING_LEVEL.ts +0 -18
@@ -14,15 +14,15 @@
14
14
  // BY POSITION, NOT BY PATH (23.19): a directory a file mounts places its files where the app says,
15
15
  // so a `server` directory inside it is fenced as the app's own would be, wherever the package lies.
16
16
 
17
- import { resolve } from 'node:path' // bun exposes no path helpers of its own
18
17
  import { SERVER_DIRECTORY } from '#shared/RESERVED_DIRECTORIES.ts'
19
- import { placeOf } from '#shared/routePattern.ts'
18
+ import type { Compilation } from './abideModules.ts'
20
19
  import {
21
20
  COMPONENT_NAMESPACE,
22
- type Compilation,
21
+ realFileOf,
23
22
  STYLE_NAMESPACE,
24
23
  TEXT_NAMESPACE,
25
- } from './abideModules.ts'
24
+ } from './faceOf.ts'
25
+ import { placeOf, positionOf } from './placement.ts'
26
26
 
27
27
  // Every namespace a browser bundle loads a file of the app's in: its modules, its components, a
28
28
  // file's text and a component's stylesheet.
@@ -38,18 +38,6 @@ export function fenceServer(
38
38
  root: string,
39
39
  mounted: Compilation['mounted'],
40
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
41
  // Anything with a `server` directory in its path, and `placeOf` decides by its position — a
54
42
  // load it passes on falls through to the next plugin's, or the bundler's own.
55
43
  const filter = new RegExp(`/${SERVER_DIRECTORY}/`)
@@ -58,14 +46,18 @@ export function fenceServer(
58
46
  setup(build) {
59
47
  for (const namespace of FENCED)
60
48
  build.onLoad({ filter, namespace }, (args) => {
61
- const position = positionOf(args.path)
49
+ const position = positionOf(
50
+ root,
51
+ mounted,
52
+ realFileOf(args.path),
53
+ )
62
54
  if (
63
55
  position === undefined ||
64
56
  placeOf(position) !== 'server'
65
57
  )
66
58
  return undefined
67
59
  throw new Error(
68
- `abide: ${position.replace(/::\w+$/, '')} is under a server directory, and the browser's bundle imports it. RULEBOOK 16.29.`,
60
+ `abide: ${position} is under a server directory, and the browser's bundle imports it. RULEBOOK 16.29.`,
69
61
  )
70
62
  })
71
63
  },
@@ -47,6 +47,9 @@ export { type Config, config } from './config.ts'
47
47
  export type { ConfigField } from './configFields.ts'
48
48
  // 42 — the app object, and 37's hooks a `<script server>` calls.
49
49
  export { type App, createApp } from './createApp.ts'
50
+ // 41.28's run-time half: an emitted component reads it on either side of its setup, and one whose
51
+ // setup made a call answers as a page does. Not an app's name.
52
+ export { callsInRender } from './deferredBuilds.ts'
50
53
  // 37.1's run-time half: what a compiled server module calls around its `<script server>`, so the
51
54
  // hooks it makes are filed at its route.
52
55
  export {
@@ -0,0 +1,167 @@
1
+ // WHERE A FILE SITS IN AN APP, AND SO WHAT IT IS — answered once per file, off the path it answers by
2
+ // (its POSITION, 23.19), and read by every walk, fence, emit and check rather than re-derived beside
3
+ // each. The route GRAMMAR is `routePattern`'s, which the browser's router reads too; this half is the
4
+ // build's and the server's alone, so it stays out of the client's closure.
5
+
6
+ import { resolve } from 'node:path' // bun exposes no path helpers of its own
7
+ import { LAUNCHER_PAGE } from '#shared/LAUNCHER_FILES.ts'
8
+ import {
9
+ HOLDS_SERVER,
10
+ RESERVED_DIRECTORIES,
11
+ SERVER_DIRECTORY,
12
+ } from '#shared/RESERVED_DIRECTORIES.ts'
13
+
14
+ /** Where a file sits in an app, which is what it is to every reader (`placeOf`). */
15
+ export type Place =
16
+ | 'address'
17
+ | 'server'
18
+ | 'public'
19
+ | 'lib'
20
+ | 'tests'
21
+ | 'dependency'
22
+ | 'skipped'
23
+
24
+ /** Whether a `.abide` is a page, a layout, an error page or a component. */
25
+ type FileKind = 'page' | 'layout' | 'error' | 'component'
26
+
27
+ /** One file placed in the app: where it is, where it answers by, and what that makes it. */
28
+ export type Placement = {
29
+ /** Below the root for the app's own, and above it — `../node_modules/…` — for a dependency's. */
30
+ relative: string
31
+ /** 23.19 — the path it answers by: its own below the root, or the one its mount gives it. */
32
+ position: string
33
+ place: Place
34
+ /** 23.21 — what a `.abide` at `position` is; a handler file is read as a page would be. */
35
+ kind: FileKind
36
+ /** 23.14 — the route of a page, a layout or an error page, and `undefined` for a component. */
37
+ route: string | undefined
38
+ /** 37.1 — the route its hooks and private handlers are filed at. */
39
+ scope: string
40
+ /**
41
+ * 34.23 — a package's file, by where it LIES rather than where it answers: compiled on the app's
42
+ * runtime (34.26), and reported on by its package rather than the app.
43
+ */
44
+ dependency: boolean
45
+ }
46
+
47
+ /**
48
+ * The file at `relative` placed at `position`.
49
+ *
50
+ * 23.21 — WHERE IT SITS, THEN ITS NAME. Every `.abide` directly in an address directory is a page
51
+ * but a `layout.abide`, an `error.abide` and the root's `launcher.abide`; one under a fence is a
52
+ * component whatever it is called. 37.1 — its hooks are filed at an address's own route; under a
53
+ * `lib` or a `tests` directory, at the route of the directory holding it, so a component an admin
54
+ * page renders runs under the admin route's rungs; under a `server` or a `public` directory, for a
55
+ * dependency's file, and for the launcher (38.82), at the root's.
56
+ *
57
+ * `position` is RELATIVE TO THE APP'S ROOT: its directories are its route, so an absolute path would
58
+ * read the machine's directories as the app's.
59
+ */
60
+ export function placement(relative: string, position = relative): Placement {
61
+ const place = placeOf(position)
62
+ let kind: FileKind = 'component'
63
+ let scope = '/'
64
+ if (place === 'address' && position !== LAUNCHER_PAGE) {
65
+ const name = position.slice(position.lastIndexOf('/') + 1)
66
+ kind =
67
+ name === 'layout.abide'
68
+ ? 'layout'
69
+ : name === 'error.abide'
70
+ ? 'error'
71
+ : 'page'
72
+ scope = routeOf(position)
73
+ } else if (place === 'lib' || place === 'tests') {
74
+ const parts = position.split('/')
75
+ // The fence `placeOf` named is the first one, and its name is its place.
76
+ scope = `/${parts.slice(0, parts.indexOf(place)).join('/')}`
77
+ }
78
+ return {
79
+ relative,
80
+ position,
81
+ place,
82
+ kind,
83
+ route: kind === 'component' ? undefined : routeOf(position),
84
+ scope,
85
+ dependency: placeOf(relative) === 'dependency',
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Where the file at `path`, relative to the app's root, sits.
91
+ *
92
+ * - `dependency` — 34.24: a package's, outside the root or under `node_modules`, whose directories
93
+ * are its package's layout and none of the app's;
94
+ * - `skipped` — under a dot directory, which no walk of the app enters;
95
+ * - `server`, `public`, `lib` or `tests` — 23.15: under that fence, the nearest to the root deciding,
96
+ * save a `server` under a `lib` or a `tests`, which is `server` — the fence the browser's bundle
97
+ * is checked against (16.29) is never hidden by one above it (23.16);
98
+ * - `address` — 23.14: everywhere else, its directories the segments of its route.
99
+ *
100
+ * The last part is the file itself, so a path ending in `/` is a directory and every part is read.
101
+ */
102
+ export function placeOf(path: string): Place {
103
+ if (path.startsWith('../') || path.startsWith('/')) return 'dependency'
104
+ const parts = path.split('/')
105
+ // A `lib` or a `tests` met first, which a `server` below it still overrides.
106
+ let fence: Place | undefined
107
+ for (let index = 0; index < parts.length - 1; index += 1) {
108
+ const part = parts[index] as string
109
+ if (skipped(part))
110
+ return fence ?? (part === 'node_modules' ? 'dependency' : 'skipped')
111
+ if (fence !== undefined) {
112
+ if (part === SERVER_DIRECTORY) return 'server'
113
+ } else if (RESERVED_DIRECTORIES.has(part)) {
114
+ if (!HOLDS_SERVER.has(part)) return part as Place
115
+ fence = part as Place
116
+ }
117
+ }
118
+ return fence ?? 'address'
119
+ }
120
+
121
+ /** Whether a walk of the app passes over the entry `name`: a dot file or directory, or `node_modules`. */
122
+ export function skipped(name: string): boolean {
123
+ return name.startsWith('.') || name === 'node_modules'
124
+ }
125
+
126
+ /**
127
+ * The route a file answers at — its path below the root without its extension.
128
+ *
129
+ * 23.14 — `media/[id].abide` and `media/getGrid.ts` are `/media/[id]` and `/media/getGrid`; an
130
+ * `index.abide`, a `layout.abide` and an `error.abide` are their directory's.
131
+ */
132
+ export function routeOf(path: string): string {
133
+ const parts = path.split('/')
134
+ const file = parts.pop() as string
135
+ // The extension alone: `[...rest].abide` carries dots of its own.
136
+ if (!DIRECTORY_FILES.has(file)) parts.push(file.replace(EXTENSION, ''))
137
+ return `/${parts.filter((part) => part !== '').join('/')}`
138
+ }
139
+
140
+ const EXTENSION = /\.(?:abide|ts)$/
141
+
142
+ /** 23.14, 23.18 — the names whose route is their directory's, and so a route's wherever they stand. */
143
+ export const DIRECTORY_FILES = new Set([
144
+ 'index.abide',
145
+ 'layout.abide',
146
+ 'error.abide',
147
+ ])
148
+
149
+ /**
150
+ * The position of the file at the absolute `path`: the one a mount gives it where a directory in
151
+ * `mounted` holds it (23.19), its own below `root` where the root does, `undefined` otherwise.
152
+ *
153
+ * `mounted` is `Compilation.mounted`, a directory below the root to the position its files take —
154
+ * directory-shaped, because a module under a mounted `server` directory is placed by it too.
155
+ */
156
+ export function positionOf(
157
+ root: string,
158
+ mounted: Readonly<Record<string, string>>,
159
+ path: string,
160
+ ): string | undefined {
161
+ for (const directory in mounted) {
162
+ const prefix = `${resolve(root, directory)}/`
163
+ if (path.startsWith(prefix))
164
+ return `${mounted[directory]}${path.slice(prefix.length)}`
165
+ }
166
+ return path.startsWith(`${root}/`) ? path.slice(root.length + 1) : undefined
167
+ }
@@ -4,7 +4,8 @@
4
4
  // sent (36.6), and neither variable is read anywhere else.
5
5
 
6
6
  import { applyPatch } from '#shared/applyPatch.ts'
7
- import { filledPath, parametersOf } from '#shared/filledPath.ts'
7
+ import { eventsOf } from '#shared/framing.ts'
8
+ import { HandlerRoute } from '#shared/HandlerRoute.ts'
8
9
  import { firstMatch, partsOf } from '#shared/matchRoute.ts'
9
10
  import type { PatchOp } from '#shared/reactive/PATCH_SINKS.ts'
10
11
  import { rankRoutes } from '#shared/routePattern.ts'
@@ -59,13 +60,14 @@ export async function callHandler(
59
60
  const asked = method?.toUpperCase()
60
61
  const handler = handlerAt(handlers, address, asked)
61
62
  const args = callArgs(given)
63
+ const route = new HandlerRoute(address)
62
64
  const chosen = (asked ??
63
65
  (handler?.method as Method | undefined) ??
64
66
  (args === undefined ? 'GET' : 'POST')) as Method
65
67
  const request =
66
68
  args instanceof URLSearchParams
67
- ? textRequest(chosen, address, args)
68
- : rpcRequest(chosen, address, args)
69
+ ? textRequest(chosen, route, args)
70
+ : rpcRequest(chosen, route, args)
69
71
  const answer = await fetch(`${url}${request.url}`, {
70
72
  ...request.init,
71
73
  headers: { ...remoteHeaders(), ...request.init.headers },
@@ -129,19 +131,19 @@ function handlerAt(
129
131
  */
130
132
  function textRequest(
131
133
  method: Method,
132
- route: string,
134
+ route: HandlerRoute,
133
135
  given: URLSearchParams,
134
136
  ): { url: string; init: RequestInit } {
135
- let address = route
137
+ let address = route.address
136
138
  let pairs = given
137
- if (route.includes('[')) {
139
+ if (route.segments !== undefined) {
138
140
  const params: Record<string, string> = {}
139
141
  pairs = new URLSearchParams()
140
- const named = parametersOf(route)
141
142
  for (const [name, value] of given)
142
- if (named.has(name)) params[name] = value
143
+ if (route.names.has(name)) params[name] = value
143
144
  else pairs.append(name, value)
144
- address = filledPath(route, params, route)
145
+ // Thrown as the message it is, which the command line prints.
146
+ address = route.path(params, (_name, message) => new Error(message))
145
147
  }
146
148
  if (argsInURL(method))
147
149
  return { url: `${address}?${pairs}`, init: { method } }
@@ -169,12 +171,12 @@ export async function watch(
169
171
  given: string[],
170
172
  signal?: AbortSignal,
171
173
  ): Promise<number> {
172
- const path = `/${address.replace(/^\//, '')}`
174
+ const route = new HandlerRoute(`/${address.replace(/^\//, '')}`)
173
175
  const args = callArgs(given)
174
176
  const request =
175
177
  args instanceof URLSearchParams
176
- ? textRequest('GET', path, args)
177
- : rpcRequest('GET', path, args)
178
+ ? textRequest('GET', route, args)
179
+ : rpcRequest('GET', route, args)
178
180
  const answer = await fetch(`${url}${request.url}`, {
179
181
  ...request.init,
180
182
  headers: {
@@ -207,33 +209,3 @@ export async function watch(
207
209
  }
208
210
  return 0
209
211
  }
210
-
211
- /** Each event of an event stream: its `event` name, `message` where it named none, and its `data`. */
212
- async function* eventsOf(
213
- body: ReadableStream<Uint8Array>,
214
- ): AsyncIterable<{ name: string; data: string }> {
215
- const decoder = new TextDecoder()
216
- let buffered = ''
217
- for await (const chunk of body) {
218
- buffered += decoder.decode(chunk, { stream: true })
219
- for (
220
- let end = buffered.indexOf('\n\n');
221
- end !== -1;
222
- end = buffered.indexOf('\n\n')
223
- ) {
224
- const block = buffered.slice(0, end)
225
- buffered = buffered.slice(end + 2)
226
- let name = 'message'
227
- let data: string | undefined
228
- for (const line of block.split('\n')) {
229
- if (line.startsWith('event:')) name = line.slice(6).trim()
230
- else if (line.startsWith('data:')) {
231
- const field = line.slice(5).replace(/^ /, '')
232
- data = data === undefined ? field : `${data}\n${field}`
233
- }
234
- }
235
- // A comment alone — the stream's opener — is no event.
236
- if (data !== undefined) yield { name, data }
237
- }
238
- }
239
- }
@@ -6,11 +6,10 @@
6
6
  import { regionMarkup } from '#shared/anchorMarkup.ts'
7
7
  import { EARLY_CALLS } from '#shared/EARLY_CALLS.ts'
8
8
  import { headKeyOf } from '#shared/headKeyOf.ts'
9
- import { isReactive, isStreaming, loadOf } from '#shared/reactive/index.ts'
10
9
  import { isThenable } from '#shared/reactive/isThenable.ts'
11
10
  import type { ShareScope } from '#shared/reactive/share.ts'
12
11
  import { setUpLevels } from '#shared/setUpLevels.ts'
13
- import { VERSION_HEADER } from '#shared/VERSION_HEADER.ts'
12
+ import { buildDeferred, markPainted } from './deferredBuilds.ts'
14
13
  import { currentRequestScope, type PageCall } from './requestScope.ts'
15
14
 
16
15
  /**
@@ -90,12 +89,19 @@ export async function composePage(
90
89
  })
91
90
  await new Promise((settled) => setImmediate(settled))
92
91
  const outermost = levels[0] as Rendered
93
- // THE OUTERMOST HEAD FIRST: reading it builds the chain, which is what pushes the heads below.
94
- // Written end to end rather than merged: `render` merges it with the shell's in one pass (39.4).
95
- const head = outermost.head
92
+ const built = await buildDeferred(
93
+ currentRequestScope()?.page?.calls ?? [],
94
+ () => {
95
+ // THE OUTERMOST HEAD FIRST: reading it builds the chain, which is what pushes the heads
96
+ // below. Written end to end rather than merged: `render` merges it with the shell's in one
97
+ // pass (39.4).
98
+ const head = outermost.head
99
+ return { head: head + heads.join(''), body: outermost.body }
100
+ },
101
+ )
96
102
  return {
97
- head: head + heads.join(''),
98
- body: outermost.body,
103
+ head: built.head,
104
+ body: built.body,
99
105
  // 35.15 — the document's, which only the root layout writes, and it is outermost.
100
106
  document: outermost.document,
101
107
  }
@@ -317,6 +323,7 @@ export async function renderDocument(
317
323
  // makes is known, and a script in the head starts each one as the request the browser's own
318
324
  // `Rpc` would make — while the rest of the document and the bundle are still on their way.
319
325
  const calls = currentRequestScope()?.page?.calls
326
+ if (calls !== undefined) markPainted(calls)
320
327
  const early =
321
328
  calls !== undefined && calls.length > 0 ? earlyCallScript(calls) : ''
322
329
  // 39.4 — THE SHELL IS THE OUTERMOST CONTRIBUTOR, so its head is merged with the page's in the
@@ -335,15 +342,14 @@ export async function renderDocument(
335
342
  * The head markup that starts each of the page's calls before the bundle arrives.
336
343
  *
337
344
  * 41.20 — THE SCRIPT ITSELF: each call started with `fetch`, and its answer left under the call's
338
- * key in `EARLY_CALLS` for `wire` to take (41.21). A framed answer's body is left UNREAD (16.32),
339
- * so a streaming handler's copy takes its chunks as they land rather than after the last one.
345
+ * key in `EARLY_CALLS` for `wire` to take (41.21). An answer's body is left UNREAD (16.32), so a
346
+ * streaming handler's copy takes its chunks as they land rather than after the last one.
340
347
  * Inline because nothing else runs before the bundle has arrived, which is the wait it exists to
341
348
  * overlap.
342
349
  *
343
350
  * 41.25 — A ROW ENDING IN `1` IS A CALL THE MARKUP WAS BUILT FROM SETTLED, a refusal included, and
344
- * its entry is marked `painted` for hydration to wait on — a body that is not JSON read to its bytes,
345
- * so the wait covers it too. Read here, where the markup has just been built and only microtasks
346
- * have run since. 41.26 — A CALL THAT STREAMS IS NEVER MARKED: the
351
+ * its entry is marked `painted` for hydration to wait on — its body read to its bytes, so the wait
352
+ * covers it too. Marked as each pass of the markup is built (`markPainted`), not here. 41.26 — A CALL THAT STREAMS IS NEVER MARKED: the
347
353
  * browser reads its chunks as they land (16.32), so no wait for its answer makes it settled where
348
354
  * the page hydrates, and its region is 41.24's to put right.
349
355
  *
@@ -358,18 +364,20 @@ function earlyCallScript(calls: PageCall[]): string {
358
364
  | [string, string, RequestInit]
359
365
  | [string, string, RequestInit, 1]
360
366
  )[] = []
361
- for (const { request, reached } of calls)
367
+ for (const { request, painted } of calls)
362
368
  rows.push(
363
- isReactive(reached) &&
364
- loadOf(reached) === undefined &&
365
- !isStreaming(reached)
369
+ painted === true
366
370
  ? [request.key, request.url, request.init, 1]
367
371
  : [request.key, request.url, request.init],
368
372
  )
369
373
  return `<script type="application/json">${JSON.stringify(rows).replace(/</g, '\\u003c')}</script><script>${EARLY_CALL_RUNNER}</script>`
370
374
  }
371
375
 
372
- const EARLY_CALL_RUNNER = `(function(s,c){for(var i=0;i<c.length;i++)(function(k,u,n,h){var e=s[k]={painted:h,status:void 0,statusText:void 0,type:void 0,version:void 0,life:void 0,text:void 0,bytes:void 0,body:void 0};e.promise=fetch(u,n).then(function(r){e.status=r.status;e.statusText=r.statusText;e.type=r.headers.get("content-type");e.version=r.headers.get("${VERSION_HEADER}");e.life=r.headers.get("cache-control");if(r.ok&&e.type&&!/^application\\/([^;]*\\+)?json\\s*(;|$)/.test(e.type)){if(h)return r.arrayBuffer().then(function(b){e.bytes=new Uint8Array(b)});e.body=r.body;return}return r.text()}).then(function(t){if(t!==void 0)e.text=t},function(){})})(c[i][0],c[i][1],c[i][2],c[i][3])})(self.${EARLY_CALLS}||(self.${EARLY_CALLS}={}),JSON.parse(document.currentScript.previousElementSibling.textContent))`
376
+ // THE RUNNER RECORDS, IT NEVER CLASSIFIES: the `Response`, and a painted call's body read to its end
377
+ // — the one decision is `painted`, which the server made. `answerOf` reads the record as it reads an
378
+ // answer it fetched, so no second reading of a content type lives in a string no typecheck reaches.
379
+ // The shape is `EarlyCall`'s.
380
+ export const EARLY_CALL_RUNNER = `(function(s,c){for(var i=0;i<c.length;i++)(function(k,u,n,h){var e=s[k]={painted:h,response:void 0,bytes:void 0};e.promise=fetch(u,n).then(function(r){return h?r.arrayBuffer().then(function(b){e.bytes=b;e.response=r}):e.response=r}).catch(function(){})})(c[i][0],c[i][1],c[i][2],c[i][3])})(self.${EARLY_CALLS}||(self.${EARLY_CALLS}={}),JSON.parse(document.currentScript.previousElementSibling.textContent))`
373
381
 
374
382
  /** The early-call runner's hash, as `script-src` names it. 29.9. */
375
383
  export const EARLY_CALL_HASH = `'sha256-${new Bun.CryptoHasher('sha256').update(EARLY_CALL_RUNNER).digest('base64')}'`
@@ -1,4 +1,5 @@
1
1
  import { regionMarkup } from '#shared/anchorMarkup.ts'
2
+ import { callsMadeSoFar, deferBuild, refuseDocument } from './deferredBuilds.ts'
2
3
  import type { Component, Rendered } from './render.ts'
3
4
 
4
5
  /**
@@ -25,14 +26,17 @@ export function renderComponent(
25
26
  heads: string[],
26
27
  ): string {
27
28
  if (component == null) return regionMarkup('')
29
+ const callsFrom = callsMadeSoFar()
28
30
  const rendered = (
29
31
  component as (props: Record<string, () => unknown>) => Rendered
30
32
  )(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
- )
33
+ // 41.28 — A CHILD WHOSE SETUP MADE A CALL ANSWERS AS A PAGE DOES, with a `scope` and its build
34
+ // left for the first read, and is built after the turn where a page is being built.
35
+ if ('scope' in rendered) {
36
+ const marker = deferBuild(rendered, heads, callsFrom)
37
+ if (marker !== undefined) return marker
38
+ }
39
+ refuseDocument(rendered)
36
40
  if (rendered.head !== '') heads.push(rendered.head)
37
41
  return regionMarkup(rendered.body)
38
42
  }
@@ -11,6 +11,7 @@
11
11
 
12
12
  import { AsyncLocalStorage } from 'node:async_hooks' // bun implements AsyncLocalStorage and has no context-carrying api of its own
13
13
  import { Cookies } from '#shared/cookies.ts'
14
+ import type { Framed } from '#shared/framing.ts'
14
15
  import type { Params } from '#shared/matchRoute.ts'
15
16
  import type { Principal } from '#shared/principal.ts'
16
17
  import { SCOPING, type Scope } from '#shared/reactive/scoping.ts'
@@ -18,7 +19,7 @@ import type { RpcRequest } from '#shared/rpcRequest.ts'
18
19
  import type { McpSurface } from './generatedSurfaces.ts'
19
20
 
20
21
  /** The three framings a stream is answered in. 17.6. */
21
- export type Framing = 'jsonl' | 'sse' | 'bytes'
22
+ export type Framing = Framed | 'bytes'
22
23
 
23
24
  /** What one request, or one `App.run`, carries across every `await` its work makes. */
24
25
  export type RequestScope = Scope & {
@@ -123,10 +124,15 @@ export type AnsweringApp = {
123
124
  }
124
125
 
125
126
  /**
126
- * One call a page's render made: the request the browser will make for it, and what it reached —
127
- * read once the markup is built, for whether it was painted settled (41.25).
127
+ * One call a page's render made: the request the browser will make for it, what it reached, and
128
+ * whether the markup was built from it settled — `undefined` until the pass that built from it
129
+ * ends (41.25, `markPainted`).
128
130
  */
129
- export type PageCall = { request: RpcRequest; reached: unknown }
131
+ export type PageCall = {
132
+ request: RpcRequest
133
+ reached: unknown
134
+ painted: boolean | undefined
135
+ }
130
136
 
131
137
  // `undefined` IS A STORE, not the absence of one: a value built outside every request runs its body
132
138
  // under it (`Cell.scope`), rather than under whichever request scheduled the drain.