@abide/abide 0.123.2 → 0.123.3

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 (94) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/bin/abide.ts +36 -65
  3. package/package.json +1 -1
  4. package/src/compiler/Diagnostics.ts +198 -0
  5. package/src/compiler/KeptChecker.ts +97 -0
  6. package/src/compiler/Lowering.ts +1 -1
  7. package/src/compiler/PositionedRefusal.ts +1 -1
  8. package/src/compiler/TypeSession.ts +62 -85
  9. package/src/compiler/appLayout.ts +28 -20
  10. package/src/compiler/argsDefaultsIn.ts +1 -1
  11. package/src/compiler/checkApp.ts +64 -68
  12. package/src/compiler/checkedShadow.ts +1 -1
  13. package/src/compiler/compileApp.ts +101 -70
  14. package/src/compiler/declaredExports.ts +2 -4
  15. package/src/compiler/emitClient.ts +1 -1
  16. package/src/compiler/formatAbide.ts +2 -2
  17. package/src/compiler/handlersUnder.ts +17 -29
  18. package/src/compiler/lineOf.ts +0 -19
  19. package/src/compiler/matchersOf.ts +3 -3
  20. package/src/compiler/mountsOf.ts +3 -3
  21. package/src/compiler/readSources.ts +16 -18
  22. package/src/compiler/readsIn.ts +1 -1
  23. package/src/compiler/serveLanguage.ts +23 -57
  24. package/src/compiler/sharedModuleWrites.ts +1 -1
  25. package/src/compiler/templateWarnings.ts +1 -2
  26. package/src/compiler/trackingWarnings.ts +1 -1
  27. package/src/server/CLIENT_BUILD.ts +4 -32
  28. package/src/server/RESUME_MILLISECONDS.ts +2 -0
  29. package/src/server/answerRequest.ts +11 -300
  30. package/src/server/answerRpc.ts +12 -54
  31. package/src/server/answerSocket.ts +8 -35
  32. package/src/server/appBuild.ts +377 -0
  33. package/src/server/appPlugins.ts +15 -17
  34. package/src/server/appTree.ts +6 -5
  35. package/src/server/argsContract.ts +209 -0
  36. package/src/server/bridgeRelay.ts +153 -65
  37. package/src/server/claudeCode.ts +30 -127
  38. package/src/server/config.ts +6 -7
  39. package/src/server/createApp.ts +30 -64
  40. package/src/server/devWorker.ts +11 -15
  41. package/src/server/handlerFileMount.ts +40 -0
  42. package/src/server/index.ts +2 -2
  43. package/src/server/packageDirectory.ts +14 -0
  44. package/src/server/preload.ts +3 -2
  45. package/src/server/principal.ts +4 -3
  46. package/src/server/remote.ts +1 -1
  47. package/src/server/runCompiled.ts +1 -1
  48. package/src/server/runDesktop.ts +1 -1
  49. package/src/server/seedBuffer.ts +1 -1
  50. package/src/server/serveDev.ts +24 -58
  51. package/src/server/servedApp.ts +390 -0
  52. package/src/server/signInChanged.ts +20 -0
  53. package/src/server/surfaceOf.ts +2 -1
  54. package/src/server/versionOf.ts +6 -15
  55. package/src/shared/applyPatch.ts +1 -1
  56. package/src/shared/muxFrames.ts +1 -1
  57. package/src/shared/reactive/Keyed.ts +1 -1
  58. package/src/shared/reactive/Reactive.ts +21 -8
  59. package/src/shared/reactive/argsContract.ts +131 -4
  60. package/src/shared/reactive/index.ts +1 -0
  61. package/src/shared/reactive/patchLog.ts +79 -0
  62. package/src/shared/reactive/patchMarks.ts +34 -0
  63. package/src/shared/reactive/recordWrites.ts +11 -14
  64. package/src/shared/reactive/share.ts +4 -3
  65. package/src/shared/rpc.ts +12 -19
  66. package/src/ui/ArmRegion.ts +10 -4
  67. package/src/ui/Level.ts +11 -20
  68. package/src/ui/LevelChain.ts +272 -0
  69. package/src/ui/Router.ts +54 -245
  70. package/src/ui/Scope.ts +17 -3
  71. package/src/ui/TryRegion.ts +22 -23
  72. package/src/ui/bindSpread.ts +2 -4
  73. package/src/ui/listenForRoutes.ts +3 -10
  74. package/src/ui/outboxQueue.ts +21 -3
  75. package/src/ui/reconcile.ts +4 -6
  76. package/src/ui/routing.ts +2 -1
  77. package/src/ui/rows.ts +2 -2
  78. package/src/ui/setAttribute.ts +12 -3
  79. package/src/ui/socketWire.ts +26 -11
  80. package/src/compiler/Finding.ts +0 -2
  81. package/src/compiler/Refusals.ts +0 -65
  82. package/src/compiler/formatDiagnosis.ts +0 -20
  83. package/src/server/appDirectories.ts +0 -9
  84. package/src/server/argsFor.ts +0 -39
  85. package/src/server/buildDirectory.ts +0 -10
  86. package/src/server/coerceArgs.ts +0 -109
  87. package/src/server/derivedArgs.ts +0 -26
  88. package/src/server/runBuild.ts +0 -24
  89. package/src/server/serveBuild.ts +0 -144
  90. package/src/server/testBuild.ts +0 -220
  91. package/src/shared/reactive/FOLLOWED.ts +0 -8
  92. package/src/shared/reactive/PATCHES.ts +0 -11
  93. package/src/shared/reactive/PATCH_SINKS.ts +0 -26
  94. package/src/shared/reactive/canonicalKey.ts +0 -95
@@ -18,16 +18,11 @@
18
18
  // is also the whole of the reload protocol: the reload client reconnects and reloads, so nothing
19
19
  // here has a message to send.
20
20
 
21
- import { watch } from 'node:fs' // bun exposes no file watcher of its own
22
21
  import { dirname, resolve } from 'node:path' // bun exposes no path helpers of its own
23
22
 
24
- import {
25
- type AppChecker,
26
- compilationOf,
27
- compileApp,
28
- openChecker,
29
- } from '#compiler/compileApp.ts'
30
- import { formatDiagnosis } from '#compiler/formatDiagnosis.ts'
23
+ import { compilationOf, compileApp } from '#compiler/compileApp.ts'
24
+ import { printDiagnoses } from '#compiler/Diagnostics.ts'
25
+ import { KeptChecker } from '#compiler/KeptChecker.ts'
31
26
  import { DEV_ADDRESS } from '#shared/ADDRESSES.ts'
32
27
 
33
28
  import type { Compilation } from './abideModules.ts'
@@ -100,9 +95,15 @@ export async function serveDev(
100
95
  let stale = false
101
96
 
102
97
  // THE CHECKER IS KEPT ACROSS SAVES, and what the watcher saw move since the last compile is
103
- // what it is told (`AppChecker.saw`). On the docs app a save reached the next answer in 182 ms
98
+ // what it is told (`KeptChecker`). On the docs app a save reached the next answer in 182 ms
104
99
  // opening a checker per compile and 86 ms keeping one; kept, it re-reads only what moved.
105
- let checker: AppChecker | undefined
100
+ //
101
+ // 38.4 — THE PROJECT, WATCHED, by the checker's own recursive watcher over the source root: the
102
+ // pages, the layouts, the stylesheets and every `public` directory are all under it, and every
103
+ // one of them is something the worker read at boot. IT IS NOT UNREFFED. It holds the process
104
+ // open, which is what a command that IS a server wants: the only ways out of `abide dev` are a
105
+ // signal and a throw, exactly as for `abide start`.
106
+ const checker = new KeptChecker(options.root, moved)
106
107
 
107
108
  async function reopen(): Promise<void> {
108
109
  let compiled: Compiled
@@ -115,19 +116,14 @@ export async function serveDev(
115
116
  }
116
117
 
117
118
  // A compile that threw is sent IN rather than thrown here: 38.4 keeps the app up, so what a
118
- // failure changes is what the worker serves.
119
- //
120
- // A THROW DROPS THE CHECKER. Most are an author's typo, but the checker's own process dying is
121
- // one too, and a session nobody can tell apart from a dead one is not one to keep.
119
+ // failure changes is what the worker serves. The throw drops the checker (`KeptChecker.run`).
122
120
  async function compile(): Promise<Compiled> {
123
121
  try {
124
- if (checker !== undefined && !checker.current())
125
- await closeChecker()
126
- checker ??= await openChecker(options.root)
127
- const compiled = await compileApp(options.root, checker)
122
+ const compiled = await checker.run((live) =>
123
+ compileApp(options.root, live),
124
+ )
128
125
  // 14 and 32.14 — what the compiler has to say, on every compile.
129
- for (const warning of compiled.warnings)
130
- console.warn(formatDiagnosis(warning, process.cwd()))
126
+ printDiagnoses(compiled.warnings, console.warn)
131
127
  watchDependencies(compiled.files)
132
128
  return {
133
129
  compilation: compilationOf(compiled),
@@ -135,7 +131,6 @@ export async function serveDev(
135
131
  failure: '',
136
132
  }
137
133
  } catch (thrown) {
138
- await closeChecker()
139
134
  const failure =
140
135
  thrown instanceof Error ? thrown.message : String(thrown)
141
136
  console.error(`abide dev: ${failure}`)
@@ -151,39 +146,19 @@ export async function serveDev(
151
146
  }
152
147
  }
153
148
 
154
- async function closeChecker(): Promise<void> {
155
- const closing = checker
156
- checker = undefined
157
- try {
158
- await closing?.close()
159
- } catch {
160
- // Already gone, which is what closing it was for.
161
- }
162
- }
163
-
164
- // A path a watcher saw move — or a move nobody could name — and the replacement it schedules.
149
+ // A move the checker's watchers heard, and the replacement it schedules.
165
150
  let settling: ReturnType<typeof setTimeout> | undefined
166
- function moved(path: string | undefined): void {
167
- checker?.saw(path)
151
+ function moved(): void {
168
152
  if (settling !== undefined) clearTimeout(settling)
169
153
  settling = setTimeout(replace, SETTLE_MILLISECONDS)
170
154
  }
171
155
 
172
156
  // 38.99 — A DEPENDENCY'S `.abide` IS THE APP'S TOO, and a linked package's lies outside the root's
173
157
  // watcher: each directory one was compiled from is watched once, for the session.
174
- const watchedDependencies = new Set<string>()
175
158
  function watchDependencies(files: Compilation['files']): void {
176
- for (const relative of Object.keys(files)) {
177
- if (placeOf(relative) !== 'dependency') continue
178
- const directory = dirname(resolve(options.root, relative))
179
- if (watchedDependencies.has(directory)) continue
180
- watchedDependencies.add(directory)
181
- watch(directory, (_event, filename) =>
182
- moved(
183
- filename === null ? undefined : `${directory}/${filename}`,
184
- ),
185
- )
186
- }
159
+ for (const relative of Object.keys(files))
160
+ if (placeOf(relative) === 'dependency')
161
+ checker.watch(dirname(resolve(options.root, relative)))
187
162
  }
188
163
 
189
164
  // The worker, compiled and answering.
@@ -273,17 +248,6 @@ export async function serveDev(
273
248
  compiling = reopen()
274
249
  await compiling
275
250
 
276
- // 38.4 — THE PROJECT, WATCHED. One recursive watcher over the source root: the pages, the
277
- // layouts, the stylesheets and every `public` directory are all under it, and every one of them is something
278
- // the worker read at boot.
279
- //
280
- // IT IS NOT UNREFFED. It holds the process open, which is what a command that IS a server
281
- // wants: the only ways out of `abide dev` are a signal and a throw, exactly as for
282
- // `abide start`.
283
- watch(options.root, { recursive: true }, (_event, filename) =>
284
- moved(filename === null ? undefined : `${options.root}/${filename}`),
285
- )
286
-
287
251
  // THE CHECKER AND THE WORKER GO WITH THE COMMAND. Each is a process of its own, and an app with
288
252
  // no tsconfig holds a temporary project on disk for as long as the checker is open. The worker
289
253
  // is told directly rather than left to its `disconnect`, which a worker busy inside a build
@@ -292,7 +256,9 @@ export async function serveDev(
292
256
  for (const signal of ['SIGINT', 'SIGTERM'] as const)
293
257
  process.once(signal, () => {
294
258
  worker?.terminate()
295
- void closeChecker().finally(() => process.kill(process.pid, signal))
259
+ void checker
260
+ .close()
261
+ .finally(() => process.kill(process.pid, signal))
296
262
  })
297
263
 
298
264
  // 18.28 — THE MUX'S LIMITS ARE THE APP'S CONFIGURATION, and so the worker's to resolve: each
@@ -0,0 +1,390 @@
1
+ // BOOTING AN APP FROM A COMPILATION: what every supplier — `abide start` off disk, `abide dev` out of
2
+ // memory, a compiled app out of itself, and a test over modules it holds — hands `bootApp`, and how
3
+ // the sources are assembled from what it hands. Where a part came from is the supplier's; how the
4
+ // app is assembled from it is here, once (`appSources`).
5
+ //
6
+ // THE ORDER IS THE PLUGIN, THEN THE TABLES, THEN THE HOOKS. The server's table is a module the
7
+ // compilation's plugin answers below `__abide` (`abideModules`), so it resolves only once that plugin
8
+ // is registered, and the hooks are the server blocks the table names. `serveApp` is the one way to
9
+ // that pair, and it registers before it reads.
10
+
11
+ import {
12
+ APP_MANIFEST_ADDRESS,
13
+ CLIENT_ADDRESS,
14
+ WORKER_ADDRESS,
15
+ } from '#shared/ADDRESSES.ts'
16
+ import { APP_META } from '#shared/APP_META.ts'
17
+ import { DEBUG_META } from '#shared/DEBUG_META.ts'
18
+ import { ERROR_META } from '#shared/ERROR_META.ts'
19
+ import { escapeAttribute } from '#shared/escapeText.ts'
20
+ import { ICON_ADDRESSES } from '#shared/LAUNCHER_FILES.ts'
21
+ import { appName } from '#shared/log.ts'
22
+ import { MOUNT, mounted } from '#shared/mount.ts'
23
+ import { MOUNT_META } from '#shared/MOUNT_META.ts'
24
+ import { OFFLINE_META } from '#shared/OFFLINE_META.ts'
25
+ import { parseRoutePattern, rankRoutes } from '#shared/routePattern.ts'
26
+
27
+ import {
28
+ type BuiltTable,
29
+ type Compilation,
30
+ serveCompilation,
31
+ servedTable,
32
+ } from './abideModules.ts'
33
+ import { answerFailure } from './answerFailure.ts'
34
+ import {
35
+ answerInside,
36
+ type AppSources,
37
+ type PublicFile,
38
+ type Ranked,
39
+ type RankedError,
40
+ type RouteRow,
41
+ type ServerTable,
42
+ } from './answerRequest.ts'
43
+ import { mountHandlers, type MountRow } from './answerRpc.ts'
44
+ import { contentEtag } from './answerValue.ts'
45
+ import { answerWorker } from './answerWorker.ts'
46
+ import { appManifest } from './appManifest.ts'
47
+ import { CLIENT_ENTRY, ENCODING_SUFFIX } from './CLIENT_BUILD.ts'
48
+ import { bundleServer, type ServedFile } from './clientBundle.ts'
49
+ import { packageUnder, settingOn } from './config.ts'
50
+ import type { AppOrigin } from './createApp.ts'
51
+ import { rungsOver } from './hooks.ts'
52
+ import { chunksOf, type Manifest } from './manifestOf.ts'
53
+ import { ENCODINGS } from './negotiateEncoding.ts'
54
+ import { DEFAULT_DOCUMENT, intoHead } from './render.ts'
55
+
56
+ /** An app whose compilation this process serves: what it is entered as, and its server's table. */
57
+ export type ServedApp = { origin: AppOrigin; table: BuiltTable }
58
+
59
+ /**
60
+ * Serves `compilation` to this process's imports, then reads the server's table out of it — the
61
+ * app under `root`, ready to boot.
62
+ *
63
+ * REGISTERED BEFORE THE FIRST AWAIT, so a caller that starts other work against the plugin — `abide
64
+ * dev`'s bundle — may start it as soon as this is called rather than once it settles.
65
+ */
66
+ export async function serveApp(
67
+ root: string,
68
+ compilation: Compilation,
69
+ ): Promise<ServedApp> {
70
+ serveCompilation(root, compilation)
71
+ const table = await servedTable(root)
72
+ return {
73
+ // 37.1 — every server block, imported for the hooks it files.
74
+ origin: {
75
+ hooks: () => Promise.all(table.servers.map((load) => load())),
76
+ package: packageUnder(root),
77
+ },
78
+ table,
79
+ }
80
+ }
81
+
82
+ /**
83
+ * What `abide build` produced, as the parts an `App` is booted over.
84
+ *
85
+ * Read off disk by `abide start` (`servedBuild`), and out of the executable itself by a compiled app
86
+ * (38.10). ONE SHAPE, so the two are answered by one path and differ only in where a byte is read
87
+ * from — a file is a PATH in both, and `Bun.file` reads the executable's own the same way it reads
88
+ * the disk's.
89
+ */
90
+ export type BuildParts = Omit<SourceParts, 'client' | 'explained'> & {
91
+ manifest: Manifest
92
+ /** Every file under `client/`, the precompressed copies among them, by name to its path. */
93
+ client: Map<string, string>
94
+ }
95
+
96
+ /** The sources an `App` is booted over, out of the parts `abide build` produced. */
97
+ export function builtSources(parts: BuildParts): Promise<AppSources> {
98
+ // THE PRECOMPRESSED COPIES ARE NOT ADDRESSES OF THEIR OWN. They are the same file in another
99
+ // encoding (38.5), so they are found under the name they encode and served as that name.
100
+ const suffixes = Object.values(ENCODING_SUFFIX) as string[]
101
+ const files = new Map<string, ServedFile>()
102
+ for (const [name, path] of parts.client) {
103
+ if (suffixes.some((suffix) => name.endsWith(suffix))) continue
104
+ const encoded: ServedFile['encoded'] = {}
105
+ for (const encoding of ENCODINGS) {
106
+ const copy = parts.client.get(name + ENCODING_SUFFIX[encoding])
107
+ if (copy !== undefined) encoded[encoding] = () => Bun.file(copy)
108
+ }
109
+ files.set(name, {
110
+ // THE TYPE IS THE NAME'S, not the path's: a compiled app's path carries a hash the
111
+ // bundler put before the extension, and a `.br`'s extension names nothing.
112
+ type: Bun.file(name).type,
113
+ body: () => Bun.file(path),
114
+ encoded,
115
+ })
116
+ }
117
+ return appSources({
118
+ ...parts,
119
+ explained: undefined,
120
+ client: {
121
+ manifest: parts.manifest,
122
+ head: '',
123
+ // 21.5 — EVERY NAME IN THE BUNDLE CARRIES A HASH OF ITS BYTES, the entry's too (38.5), so
124
+ // a name never answers two different files and a year is safe for all of them. A deploy
125
+ // is new names in a new document, never new bytes at an old address.
126
+ framework: bundleServer(
127
+ files,
128
+ 'public, max-age=31536000, immutable',
129
+ ),
130
+ offline: settingOn('ABIDE_OFFLINE', true),
131
+ },
132
+ })
133
+ }
134
+
135
+ /**
136
+ * What a command hands `appSources`: the parts it read, built or compiled, and the client lane.
137
+ *
138
+ * ONE SHAPE FOR EVERY SUPPLIER — `abide start` off disk, a compiled app out of itself, `abide dev` out
139
+ * of memory, and a test handing over modules it already holds — so what differs between them is
140
+ * where a part came from and never how the app is assembled from it.
141
+ */
142
+ export type SourceParts = {
143
+ table: ServerTable
144
+ /** The handler table the build wrote, each row with its module's loader. 16.2. */
145
+ handlers: MountRow[]
146
+ /** Every public file, by its name below the directory to its path. 38.18, 38.19. */
147
+ published: Map<string, string>
148
+ /**
149
+ * The bundle's manifest, its lane, and `head` — `abide dev`'s reload client (38.17), after the
150
+ * app's own tags.
151
+ *
152
+ * A PROMISE WHERE THE BUNDLE IS STILL BUILDING: `abide dev`'s handlers mount and its public files
153
+ * hash while the bundler runs, rather than after it — ~40 ms of a docs-app reload.
154
+ */
155
+ client: ClientLane | Promise<ClientLane>
156
+ explained: AppSources['explained']
157
+ }
158
+
159
+ type ClientLane = {
160
+ manifest: Manifest
161
+ framework: AppSources['framework']
162
+ head: string
163
+ /**
164
+ * 45.1, 45.9 — whether the worker saves `manifest`'s bundle and the pages register it: `abide
165
+ * build`'s, unless `ABIDE_OFFLINE` is off. Where not, the worker address answers the removing one.
166
+ */
167
+ offline: boolean
168
+ }
169
+
170
+ /**
171
+ * The sources an `App` is booted over, off the parts a command supplies.
172
+ *
173
+ * 30.9 — MOST SPECIFIC FIRST, ranked off the table rather than in it. The ordering IS the client
174
+ * router's own: both seams compile through `rankRoutes` and walk a route with `matchOf` — the
175
+ * server past a route that has no answer for the method asked, which the router never is — so
176
+ * "the two cannot resolve one address differently" is a grep rather than a promise. 35.2 — the
177
+ * error pages deepest directory first, so the nearest answers.
178
+ */
179
+ export async function appSources(parts: SourceParts): Promise<AppSources> {
180
+ const [mounted, published, client] = await Promise.all([
181
+ mountHandlers(parts.handlers),
182
+ publicFilesAt(parts.published),
183
+ parts.client,
184
+ ])
185
+ // 35.6 — every page is served in abide's own document; what it carries beyond that, the root
186
+ // `layout.abide` writes (35.15).
187
+ // 45.15 — installable where the pages are kept offline and the app has an icon to install by.
188
+ const icon = published.get(ICON_ADDRESSES.png)
189
+ const install =
190
+ icon !== undefined && settingOn('ABIDE_OFFLINE', true)
191
+ ? await appManifest(icon.path)
192
+ : undefined
193
+ const documentOf = (modules: string[]): string =>
194
+ intoHead(
195
+ DEFAULT_DOCUMENT,
196
+ clientTags(client.manifest, modules) +
197
+ (client.offline ? `<meta name="${OFFLINE_META}">` : '') +
198
+ (install?.tags ?? '') +
199
+ client.head,
200
+ )
201
+ const pages: Omit<Ranked, 'segments'>[] = []
202
+ for (const route of parts.table.routes)
203
+ pages.push({
204
+ pattern: route.pattern,
205
+ load: route.load,
206
+ layouts: route.layouts,
207
+ matchers: route.matchers,
208
+ document: documentOf(route.clientModules),
209
+ })
210
+ // 19.1 — the build refused two files at one route and method, so a route is one row however
211
+ // many methods answer at it — and 23.27 one matcher per position, so whichever file opens the
212
+ // row brings what every file at it would.
213
+ const rows = new Map<string, Omit<RouteRow, 'segments'>>()
214
+ const rowAt = (
215
+ pattern: string,
216
+ matchers: Record<string, string>,
217
+ ): Omit<RouteRow, 'segments'> => {
218
+ let row = rows.get(pattern)
219
+ if (row === undefined) {
220
+ row = {
221
+ pattern,
222
+ matchers,
223
+ page: undefined,
224
+ handlers: new Map(),
225
+ answers: new Map(),
226
+ }
227
+ rows.set(pattern, row)
228
+ }
229
+ return row
230
+ }
231
+ for (const page of rankRoutes(pages))
232
+ rowAt(page.pattern, page.matchers).page = page
233
+ for (const handler of mounted.handlers.values())
234
+ rowAt(handler.address, handler.matchers).handlers.set(
235
+ handler.method,
236
+ handler,
237
+ )
238
+ const errors: RankedError[] = []
239
+ for (const error of parts.table.errors)
240
+ errors.push({
241
+ ...error,
242
+ segments: parseRoutePattern(error.pattern, error.matchers),
243
+ })
244
+ errors.sort((a, b) => b.segments.length - a.segments.length)
245
+ const sources: AppSources = {
246
+ routes: rankRoutes([...rows.values()]),
247
+ errors,
248
+ // 35.18 — marked, so the browser hydrates no page over what an error page painted.
249
+ errorDocument: intoHead(documentOf([]), `<meta name="${ERROR_META}">`),
250
+ framework: (pathname, request) => {
251
+ if (pathname === WORKER_ADDRESS)
252
+ return answerWorker(
253
+ client.offline ? client.manifest : undefined,
254
+ )
255
+ if (pathname === APP_MANIFEST_ADDRESS && install !== undefined)
256
+ return new Response(install.body, {
257
+ headers: {
258
+ 'content-type': 'application/manifest+json',
259
+ 'cache-control': 'no-cache',
260
+ },
261
+ })
262
+ return client.framework(pathname, request)
263
+ },
264
+ published,
265
+ explained: parts.explained,
266
+ offline: client.offline,
267
+ ...mounted,
268
+ }
269
+ for (const row of sources.routes) {
270
+ if (row.page !== undefined)
271
+ row.answers.set(
272
+ row.page,
273
+ answerInside(
274
+ sources,
275
+ row.page,
276
+ // 37.14 — a page's lane paints a refusal as its error page does (35.2).
277
+ rungsOver(row.pattern, (failure, request) =>
278
+ answerFailure(
279
+ sources,
280
+ new URL(request.url).pathname,
281
+ failure,
282
+ ),
283
+ ),
284
+ ),
285
+ )
286
+ for (const handler of row.handlers.values())
287
+ row.answers.set(
288
+ handler,
289
+ answerInside(sources, handler, handler.rungs),
290
+ )
291
+ }
292
+ return sources
293
+ }
294
+
295
+ /**
296
+ * The head tags that load the app: its metas, stylesheet, entry script and the route's preloads.
297
+ *
298
+ * 38.22, and 38.23 for the stylesheet beside it. An app does not write either: the bundle's
299
+ * address is the build's, and a hand-written one is a path that goes stale the first time the
300
+ * output layout moves.
301
+ *
302
+ * THE STYLESHEET GOES FIRST, and ahead of the page's own head contributions for the same reason:
303
+ * a document that parses its script before its styles paints once unstyled. It is a plain `<link>`
304
+ * rather than anything the client does, so a page the server painted is styled with no JS at all.
305
+ *
306
+ * ONE STYLESHEET FOR THE APP: the bundler collects every stylesheet reachable from the entry — each
307
+ * component's scoped `<style>` (34.4) and each one a component imports (34.6) — into one file, so a
308
+ * page that renders none of those components is served them too. For a `<style>` block that costs
309
+ * bytes rather than correctness: every rule requires its component's scope token, so on a page the
310
+ * component is not on it matches nothing. An imported stylesheet carries no token and applies as
311
+ * written, wherever it is served.
312
+ *
313
+ * 38.28 — THE ROUTE'S CHUNKS ARE PRELOADED BESIDE THE ENTRY. Left to the client, the page's chunk
314
+ * and the shared runtime it imports are only asked for once the entry has arrived and run the
315
+ * router — a whole round trip after the document, for the largest file in the bundle. Named here,
316
+ * they are requested while the document is still being parsed.
317
+ *
318
+ * EVERY NAME IS THE MANIFEST'S, the entry's included: a build hashes it (38.5). A manifest with no
319
+ * entry is a bundle that failed under `abide dev` (38.17), and gets no tags rather than a
320
+ * `<script>` at a 404.
321
+ */
322
+ export function clientTags(manifest: Manifest, modules: string[]): string {
323
+ if (manifest.entry === undefined) return ''
324
+ // 28.10 — `DEBUG`, through the document rather than the bundle, where the server has one.
325
+ const debug = process.env.DEBUG
326
+ let tags =
327
+ debug === undefined || debug === ''
328
+ ? ''
329
+ : `<meta name="${DEBUG_META}" content="${escapeAttribute(debug)}">`
330
+ // 28.4 — the app's name, so a browser's `log.named` prefixes the one the server's does.
331
+ tags += `<meta name="${APP_META}" content="${escapeAttribute(appName())}">`
332
+ // 16.10 — the mount the browser resolves every address against, the root's `''` included: a
333
+ // lookup that finds no tag has walked the whole document to say so.
334
+ tags += `<meta name="${MOUNT_META}" content="${escapeAttribute(MOUNT.current())}">`
335
+ const client = mounted(CLIENT_ADDRESS)
336
+ tags +=
337
+ manifest.stylesheet === undefined
338
+ ? ''
339
+ : `<link rel="stylesheet" href="${client}/${manifest.stylesheet}">`
340
+ tags += `<script type="module" src="${client}/${manifest.entry}"></script>`
341
+ for (const chunk of chunksOf([`${CLIENT_ENTRY}.ts`, ...modules], manifest))
342
+ tags += `<link rel="modulepreload" href="${client}/${chunk}">`
343
+ return tags
344
+ }
345
+
346
+ /**
347
+ * The public files by address, off each file's name and where its bytes are.
348
+ *
349
+ * 38.18 and 38.19's directory, as an address per file — or a compiled app's, whose bytes are
350
+ * inside itself. Read at boot rather than per request, and the ETAG IS READ WITH IT: 21.13
351
+ * derives it from the bytes, so there is no cheaper moment than the one read that is happening
352
+ * anyway. The bytes are hashed and dropped — `Bun.file` streams the body when somebody asks, so a
353
+ * large file is not held in memory for the life of the process.
354
+ */
355
+ async function publicFilesAt(
356
+ paths: Map<string, string>,
357
+ ): Promise<Map<string, PublicFile>> {
358
+ const files = new Map<string, PublicFile>()
359
+ // THE READS OVERLAP. They are independent, and `abide dev` runs this again on every
360
+ // reload rather than once at boot — so it is reload latency an author waits on, not just
361
+ // a boot cost. Each is hashed as it lands, so no more than the reads in flight are held.
362
+ const names = [...paths.keys()]
363
+ const etags = await Promise.all(
364
+ names.map(async (name) =>
365
+ contentEtag(await Bun.file(paths.get(name) as string).bytes()),
366
+ ),
367
+ )
368
+ for (let index = 0; index < names.length; index += 1) {
369
+ const name = names[index] as string
370
+ files.set(`/${name}`, {
371
+ path: paths.get(name) as string,
372
+ etag: etags[index] as string,
373
+ type: publicType(name),
374
+ })
375
+ }
376
+ return files
377
+ }
378
+
379
+ /**
380
+ * A public file's content type, off its name's extension.
381
+ *
382
+ * 36.14, 36.17 — a text type without a charset is given UTF-8's rather than whatever a browser
383
+ * guesses.
384
+ */
385
+ function publicType(name: string): string {
386
+ const type = Bun.file(name).type
387
+ return type.startsWith('text/') && !type.includes('charset')
388
+ ? `${type}; charset=utf-8`
389
+ : type
390
+ }
@@ -0,0 +1,20 @@
1
+ // 44.34 — A SIGN-IN OR SIGN-OUT, heard by whatever on this server follows a browser across one — the
2
+ // bridge relay's status streams — without `principal.set` and `principal.clear` knowing what does.
3
+
4
+ type SignInListener = (caller: string, now: string, signedIn: string) => void
5
+
6
+ const LISTENERS: SignInListener[] = []
7
+
8
+ /** Runs `listener` on every sign-in or sign-out this process answers. */
9
+ export function onSignInChanged(listener: SignInListener): void {
10
+ LISTENERS.push(listener)
11
+ }
12
+
13
+ /** Tells every listener that the browser at `caller` is `now`, signed in as `signedIn` (`signedInAs`). */
14
+ export function signInChanged(
15
+ caller: string,
16
+ now: string,
17
+ signedIn: string,
18
+ ): void {
19
+ for (const listener of LISTENERS) listener(caller, now, signedIn)
20
+ }
@@ -1,6 +1,7 @@
1
1
  import type { Method } from '#shared/rpc.ts'
2
2
 
3
3
  import type { Mount, Mounted } from './answerRpc.ts'
4
+ import { argsDocumentOf } from './argsContract.ts'
4
5
 
5
6
  /** A generated surface a handler may be listed on, besides the browser's (20.4 is the build's). */
6
7
  export type Surface = 'mcp' | 'openapi' | 'cli'
@@ -51,7 +52,7 @@ export function surfaceOf(
51
52
  name: surfaceName(mount.method, mount.address),
52
53
  description:
53
54
  live === undefined ? mount.description : live.handler.description,
54
- schema: live === undefined ? mount.args : live.argsDocument,
55
+ schema: live === undefined ? mount.args : argsDocumentOf(live.handler),
55
56
  result: mount.result,
56
57
  matchers: mount.matchers,
57
58
  }
@@ -14,8 +14,8 @@
14
14
  // slow tab missed. A production that was not a patch breaks the chain.
15
15
 
16
16
  import { servedCellOf } from '#shared/reactive/index.ts'
17
- import { PATCH_SINKS, type PatchOp } from '#shared/reactive/PATCH_SINKS.ts'
18
- import { PATCHES } from '#shared/reactive/PATCHES.ts'
17
+ import { drainLog, type PatchOp } from '#shared/reactive/patchLog.ts'
18
+ import { writtenAt } from '#shared/reactive/patchMarks.ts'
19
19
 
20
20
  let next = Date.now() * 1000
21
21
 
@@ -41,10 +41,7 @@ const STAMPS = new WeakMap<object, Stamp>()
41
41
  export function versionOf(value: unknown): number {
42
42
  const cell = servedCellOf(value)
43
43
  const held = (value as { peek(): unknown }).peek()
44
- const patched =
45
- held !== null && typeof held === 'object'
46
- ? (PATCHES.written.get(held) ?? 0)
47
- : 0
44
+ const patched = writtenAt(held)
48
45
  const stamp = STAMPS.get(cell)
49
46
  if (
50
47
  stamp !== undefined &&
@@ -54,19 +51,13 @@ export function versionOf(value: unknown): number {
54
51
  )
55
52
  return stamp.version
56
53
  // THE WRITES SINCE THE LAST STAMP stand for the change only where nothing else moved it.
57
- const sink = PATCH_SINKS.get(cell)
58
- const onlyPatched =
54
+ const drained = drainLog(cell)
55
+ const ops =
59
56
  stamp !== undefined &&
60
57
  stamp.ordinal === cell.ordinal &&
61
58
  stamp.held === held
62
- const ops =
63
- onlyPatched && sink !== undefined && !sink.lost && sink.ops.length > 0
64
- ? sink.ops
59
+ ? drained
65
60
  : null
66
- if (sink !== undefined) {
67
- sink.ops = []
68
- sink.lost = false
69
- }
70
61
  next += 1
71
62
  // The chain carries on from the stamp this replaces, whose array it takes over.
72
63
  const recent = ops === null || stamp === undefined ? [] : stamp.recent
@@ -6,7 +6,7 @@
6
6
  // patch records passes, so the check costs a browser replaying its server nothing, and an op naming
7
7
  // `__proto__` or a member off the prototype chain is refused at 400 rather than reached. A `test` that fails is 409, the patch's precondition rather than its form.
8
8
  import { putOwn } from './putOwn.ts'
9
- import type { PatchOp } from './reactive/PATCH_SINKS.ts'
9
+ import type { PatchOp } from './reactive/patchLog.ts'
10
10
  import { structural } from './reactive/structural.ts'
11
11
  import { refuse } from './refuse.ts'
12
12
 
@@ -3,7 +3,7 @@
3
3
  // `patch` an event stream writes as events (18.20), multiplexed by `id`.
4
4
 
5
5
  import type { Problem } from './Failed.ts'
6
- import type { PatchOp } from './reactive/PATCH_SINKS.ts'
6
+ import type { PatchOp } from './reactive/patchLog.ts'
7
7
 
8
8
  /**
9
9
  * What a browser sends over the mux: a subscription, naming the version it holds (18.9), or its end.
@@ -11,11 +11,11 @@ import { checkArgs } from '../refuse.ts'
11
11
  import type { Schema } from '../Schema.ts'
12
12
  import {
13
13
  ARGS_GATE,
14
+ canonicalKey,
14
15
  type Gated,
15
16
  normalized,
16
17
  PARAMETER_DEFAULTS,
17
18
  } from './argsContract.ts'
18
- import { canonicalKey } from './canonicalKey.ts'
19
19
  import { CELL } from './CELL.ts'
20
20
  import { ENTRY_LIVES } from './ENTRY_LIVES.ts'
21
21
  import { armTimer } from './Expiry.ts'