@abide/abide 0.123.1 → 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 (98) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/bin/abide.ts +36 -65
  3. package/docs/REGISTRY.md +2 -2
  4. package/docs/RULEBOOK.md +2 -2
  5. package/docs/abide.md +2 -2
  6. package/package.json +1 -1
  7. package/src/compiler/Diagnostics.ts +198 -0
  8. package/src/compiler/KeptChecker.ts +97 -0
  9. package/src/compiler/Lowering.ts +1 -1
  10. package/src/compiler/PositionedRefusal.ts +1 -1
  11. package/src/compiler/TypeSession.ts +62 -85
  12. package/src/compiler/appLayout.ts +28 -20
  13. package/src/compiler/argsDefaultsIn.ts +1 -1
  14. package/src/compiler/checkApp.ts +64 -68
  15. package/src/compiler/checkedShadow.ts +1 -1
  16. package/src/compiler/compileApp.ts +101 -70
  17. package/src/compiler/declaredExports.ts +2 -4
  18. package/src/compiler/emitClient.ts +1 -1
  19. package/src/compiler/formatAbide.ts +2 -2
  20. package/src/compiler/handlersUnder.ts +17 -29
  21. package/src/compiler/lineOf.ts +0 -19
  22. package/src/compiler/matchersOf.ts +3 -3
  23. package/src/compiler/mountsOf.ts +3 -3
  24. package/src/compiler/readSources.ts +16 -18
  25. package/src/compiler/readsIn.ts +1 -1
  26. package/src/compiler/serveLanguage.ts +23 -57
  27. package/src/compiler/sharedModuleWrites.ts +1 -1
  28. package/src/compiler/templateWarnings.ts +1 -2
  29. package/src/compiler/trackingWarnings.ts +1 -1
  30. package/src/server/CLIENT_BUILD.ts +4 -32
  31. package/src/server/RESUME_MILLISECONDS.ts +2 -0
  32. package/src/server/answerRequest.ts +11 -300
  33. package/src/server/answerRpc.ts +12 -54
  34. package/src/server/answerSocket.ts +8 -35
  35. package/src/server/appBuild.ts +377 -0
  36. package/src/server/appPlugins.ts +15 -17
  37. package/src/server/appTree.ts +6 -5
  38. package/src/server/argsContract.ts +209 -0
  39. package/src/server/bridgeRelay.ts +153 -65
  40. package/src/server/claudeCode.ts +30 -127
  41. package/src/server/config.ts +6 -7
  42. package/src/server/createApp.ts +30 -64
  43. package/src/server/devWorker.ts +11 -15
  44. package/src/server/handlerFileMount.ts +40 -0
  45. package/src/server/index.ts +2 -2
  46. package/src/server/packageDirectory.ts +14 -0
  47. package/src/server/preload.ts +3 -2
  48. package/src/server/principal.ts +4 -3
  49. package/src/server/remote.ts +1 -1
  50. package/src/server/runCompiled.ts +1 -1
  51. package/src/server/runDesktop.ts +1 -1
  52. package/src/server/seedBuffer.ts +1 -1
  53. package/src/server/serveDev.ts +24 -58
  54. package/src/server/servedApp.ts +390 -0
  55. package/src/server/signInChanged.ts +20 -0
  56. package/src/server/surfaceOf.ts +2 -1
  57. package/src/server/versionOf.ts +6 -15
  58. package/src/shared/applyPatch.ts +1 -1
  59. package/src/shared/muxFrames.ts +1 -1
  60. package/src/shared/reactive/Keyed.ts +1 -1
  61. package/src/shared/reactive/Reactive.ts +21 -8
  62. package/src/shared/reactive/argsContract.ts +131 -4
  63. package/src/shared/reactive/index.ts +1 -0
  64. package/src/shared/reactive/patchLog.ts +79 -0
  65. package/src/shared/reactive/patchMarks.ts +34 -0
  66. package/src/shared/reactive/recordWrites.ts +11 -14
  67. package/src/shared/reactive/share.ts +4 -3
  68. package/src/shared/rpc.ts +12 -19
  69. package/src/shared/validateJson.ts +14 -3
  70. package/src/ui/ArmRegion.ts +10 -4
  71. package/src/ui/Level.ts +11 -20
  72. package/src/ui/LevelChain.ts +272 -0
  73. package/src/ui/Router.ts +54 -245
  74. package/src/ui/Scope.ts +17 -3
  75. package/src/ui/TryRegion.ts +22 -23
  76. package/src/ui/bindSpread.ts +2 -4
  77. package/src/ui/listenForRoutes.ts +3 -10
  78. package/src/ui/outboxQueue.ts +21 -3
  79. package/src/ui/reconcile.ts +4 -6
  80. package/src/ui/routing.ts +2 -1
  81. package/src/ui/rows.ts +2 -2
  82. package/src/ui/setAttribute.ts +12 -3
  83. package/src/ui/socketWire.ts +26 -11
  84. package/src/compiler/Finding.ts +0 -2
  85. package/src/compiler/Refusals.ts +0 -65
  86. package/src/compiler/formatDiagnosis.ts +0 -20
  87. package/src/server/appDirectories.ts +0 -9
  88. package/src/server/argsFor.ts +0 -39
  89. package/src/server/buildDirectory.ts +0 -10
  90. package/src/server/coerceArgs.ts +0 -109
  91. package/src/server/derivedArgs.ts +0 -95
  92. package/src/server/runBuild.ts +0 -24
  93. package/src/server/serveBuild.ts +0 -144
  94. package/src/server/testBuild.ts +0 -220
  95. package/src/shared/reactive/FOLLOWED.ts +0 -8
  96. package/src/shared/reactive/PATCHES.ts +0 -11
  97. package/src/shared/reactive/PATCH_SINKS.ts +0 -26
  98. package/src/shared/reactive/canonicalKey.ts +0 -95
@@ -11,11 +11,11 @@ import { GLOBAL_GETS } from '#shared/GLOBAL_GETS.ts'
11
11
  import { isRecord } from '#shared/isRecord.ts'
12
12
  import type { JsonValue } from '#shared/JsonValue.ts'
13
13
  import { mounted as underMount } from '#shared/mount.ts'
14
- import type { Gated } from '#shared/reactive/argsContract.ts'
14
+ import type { Gated, HeldToAnnotation } from '#shared/reactive/argsContract.ts'
15
15
  import { deadlineOf, heldGenerationOf } from '#shared/reactive/index.ts'
16
16
  import type { Defaults } from '#shared/reactive/parameterDefaults.ts'
17
17
  import { notFound, refuse } from '#shared/refuse.ts'
18
- import type { HeldToAnnotation, Method, Rpc } from '#shared/rpc.ts'
18
+ import type { Method, Rpc } from '#shared/rpc.ts'
19
19
  import { RPC_SERVER } from '#shared/RPC_SERVER.ts'
20
20
  import { argsInURL } from '#shared/rpcRequest.ts'
21
21
  import { VERSION_HEADER } from '#shared/VERSION_HEADER.ts'
@@ -29,14 +29,8 @@ import {
29
29
  } from './answerFailure.ts'
30
30
  import { followAsEvents } from './answerSocket.ts'
31
31
  import { answerValue, namesEtag, sseHeaders } from './answerValue.ts'
32
- import { argsFor } from './argsFor.ts'
32
+ import { argsFor, collapsed, derivedArgs } from './argsContract.ts'
33
33
  import { callerOf } from './callerOf.ts'
34
- import {
35
- argsDocumentOf,
36
- coerceArgs,
37
- collapsed,
38
- type Document,
39
- } from './coerceArgs.ts'
40
34
  import { positiveSetting } from './config.ts'
41
35
  import {
42
36
  allows,
@@ -122,12 +116,6 @@ export type Mounted = Mount & {
122
116
  */
123
117
  rungs: LaneRung[]
124
118
  handler: Rpc<unknown, unknown> & Gated<unknown> & HeldToAnnotation
125
- /**
126
- * The args' JSON Schema, probed once at mount.
127
- *
128
- * 19.7, 20.11 — a third-party schema converts itself on every probe.
129
- */
130
- argsDocument: Document | undefined
131
119
  }
132
120
 
133
121
  /**
@@ -168,7 +156,6 @@ export function mountedOwn(
168
156
  args: undefined,
169
157
  result: undefined,
170
158
  handler: handler as Mounted['handler'],
171
- argsDocument: undefined,
172
159
  },
173
160
  ]
174
161
  }
@@ -195,10 +182,12 @@ export async function mountHandlers(
195
182
  const mount = table[at] as Mount
196
183
  const exported = (modules[at] as Record<string, unknown>)[mount.export]
197
184
  const handler = exported as Mounted['handler']
198
- const declared = argsDocumentOf(handler)
199
185
  // 16.9, 16.10 — the address `rpc.url` and `rpc.raw` build on, under the mount — and what a
200
186
  // page's render turns a call it made into the browser's request by (41.20).
201
187
  handler.address = underMount(mount.address)
188
+ // 19.9 — the schema the build derived off its annotation, for a handler whose module did not
189
+ // file it on load: a bundled executable's faces carry no `derivedArgs` call.
190
+ if (mount.args !== undefined) derivedArgs(handler, mount.args)
202
191
  handlers.set(handlerKey(mount.method, mount.address), {
203
192
  ...mount,
204
193
  // 37.1, 16.72 — the route its file's hooks are filed at, which the build read off
@@ -206,7 +195,6 @@ export async function mountHandlers(
206
195
  // 37.14 — a handler's lane answers a refusal as Problem Details (15.1).
207
196
  rungs: rungsOver(mount.scope, answered),
208
197
  handler,
209
- argsDocument: declared ?? (mount.args as Document | undefined),
210
198
  })
211
199
  }
212
200
  return { handlers }
@@ -254,12 +242,15 @@ export async function answerRpc(
254
242
  return answered(crossingRefusal(crossing, found.method))
255
243
  const raw = await argsOf(request, url, inUrl, form, handler.maxBodySize)
256
244
  if (isFailed(raw)) return answered(raw)
257
- const sent = withParameters(raw, params, inUrl || form, found)
258
- if (isFailed(sent)) return answered(sent)
259
245
  let args: unknown
260
246
  try {
261
247
  // A URL's and a form's args are text, and the query tells a bare name from `name=` (19.7).
262
- args = argsFor(found, sent, inUrl ? url.search : form ? '' : undefined)
248
+ args = argsFor(
249
+ handler,
250
+ raw,
251
+ inUrl ? url.search : form ? '' : undefined,
252
+ params,
253
+ )
263
254
  } catch (refused) {
264
255
  if (isFailed(refused)) return answered(refused)
265
256
  throw refused
@@ -478,39 +469,6 @@ async function argsOf(
478
469
  return parsed
479
470
  }
480
471
 
481
- /**
482
- * The args a request sent, with the parameters its route matched laid in.
483
- *
484
- * 16.65 — the path's, whatever the method. 16.66 — a parameter the query or the body names as
485
- * well is refused rather than one of the two chosen. A JSON body's members are typed already, so the
486
- * path's text is coerced to the declared types before it joins them (19.7); text args are coerced
487
- * with the rest of the text.
488
- */
489
- function withParameters(
490
- sent: unknown,
491
- params: Record<string, string | string[]>,
492
- text: boolean,
493
- found: Mounted,
494
- ): unknown | Failed<'HttpError', undefined> {
495
- let none = true
496
- for (const name in params) {
497
- none = false
498
- if (isRecord(sent) && name in sent)
499
- return refuse(
500
- 400,
501
- `The parameter '${name}' is in the path and in the request as well.`,
502
- )
503
- }
504
- if (none) return sent
505
- const fromPath = text
506
- ? params
507
- : (coerceArgs(params, found.argsDocument, '') as Record<
508
- string,
509
- unknown
510
- >)
511
- return isRecord(sent) ? { ...sent, ...fromPath } : { ...fromPath }
512
- }
513
-
514
472
  /**
515
473
  * The request's body, received whole, under the ceiling `maxBodySize` sets, or
516
474
  * `ABIDE_MAX_REQUEST_BODY_SIZE` where it is unset, or a 413 refusal past it.
@@ -16,16 +16,14 @@ import { namedLogger } from '#shared/log.ts'
16
16
  import { compose } from '#shared/Middleware.ts'
17
17
  import { unmounted } from '#shared/mount.ts'
18
18
  import type { BrowserFrame, ServerFrame } from '#shared/muxFrames.ts'
19
- import { FOLLOWED } from '#shared/reactive/FOLLOWED.ts'
20
- import { servedCellOf } from '#shared/reactive/index.ts'
19
+ import { followServed } from '#shared/reactive/index.ts'
21
20
  import { matchesPattern } from '#shared/reactive/matchesPattern.ts'
22
- import { PATCH_SINKS } from '#shared/reactive/PATCH_SINKS.ts'
23
21
  import { unowned } from '#shared/reactive/watch.ts'
24
22
  import { refuse } from '#shared/refuse.ts'
25
23
  import { rpcRequest } from '#shared/rpcRequest.ts'
26
24
 
27
25
  import { handlerKey, type Mounted } from './answerRpc.ts'
28
- import { argsFor } from './argsFor.ts'
26
+ import { argsFor } from './argsContract.ts'
29
27
  import { positiveSetting } from './config.ts'
30
28
  import { closedTo, crossingOrigin, crossingRefusal } from './crossingOrigin.ts'
31
29
  import { failureOf } from './hooks.ts'
@@ -182,7 +180,7 @@ export function broadcast(
182
180
  for (const follower of followed.all) {
183
181
  if (!matchesPattern(follower.args, pattern)) continue
184
182
  // A fed copy is refreshed by the production the reload mints (7.18), and invalidated through
185
- // the cell it is fed from (`FOLLOWED`), whichever trigger reached it — where the entry it is
183
+ // the cell it is fed from (`followServed`), whichever trigger reached it — where the entry it is
186
184
  // fed from is still the one standing.
187
185
  if (attach(rpc as Mounted['handler'], follower) !== undefined) continue
188
186
  follower.peer.send(follower.id, TRIGGERS[member])
@@ -409,7 +407,7 @@ export class MuxConnection {
409
407
  const pending = (): void => {}
410
408
  try {
411
409
  // 18.3 — the args a `GET` over http is answered with, sent as JSON.
412
- const args = argsFor(followed, frame.args, undefined)
410
+ const args = argsFor(followed.handler, frame.args, undefined)
413
411
  // 18.8 — A GUARDED `GET`'s RUNGS RUN ONCE, as the subscription opens, in this
414
412
  // connection's request: one they admit is fed the value, and one they refuse is sent its
415
413
  // triggers alone.
@@ -622,19 +620,12 @@ function feed(serving: unknown, fed: Follower): () => void {
622
620
  }
623
621
 
624
622
  function startFeed(serving: unknown): Feed {
625
- const past = servedCellOf(serving)
626
- const release = sinkFor(past)
627
623
  const fed = new Set<Follower>()
628
- // 7.10 — an invalidate of the value, however it was spelled, reaches every copy it feeds.
629
- const invalidated = (): void => {
624
+ // 18.11 — A VALUE FED TO A FOLLOWER KEEPS ITS PATCHES AS DATA while anyone is fed it, and 7.10 —
625
+ // an invalidate of the value, however it was spelled, reaches every copy it feeds.
626
+ const release = followServed(serving, () => {
630
627
  for (const one of fed) one.peer.send(one.id, TRIGGERS.invalidate)
631
- }
632
- let followed = FOLLOWED.get(past)
633
- if (followed === undefined) {
634
- followed = new Set()
635
- FOLLOWED.set(past, followed)
636
- }
637
- followed.add(invalidated)
628
+ })
638
629
  const shared: Feed = {
639
630
  fed,
640
631
  landed: false,
@@ -642,8 +633,6 @@ function startFeed(serving: unknown): Feed {
642
633
  stopWatch()
643
634
  release()
644
635
  FEEDS.delete(serving as object)
645
- followed.delete(invalidated)
646
- if (followed.size === 0) FOLLOWED.delete(past)
647
636
  },
648
637
  }
649
638
  FEEDS.set(serving as object, shared)
@@ -698,19 +687,3 @@ function addressIn(named: string): string {
698
687
  const path = URL.parse(named)?.pathname ?? named
699
688
  return unmounted(path) ?? path
700
689
  }
701
-
702
- /**
703
- * Holds a patch sink on `cell`; returns what releases it.
704
- *
705
- * 18.11 — A VALUE FED TO A FOLLOWER KEEPS ITS PATCHES AS DATA while anyone is fed it, and stops
706
- * when the last one goes: a sink nobody drains grows with every patch.
707
- */
708
- function sinkFor(cell: object): () => void {
709
- const sink = PATCH_SINKS.get(cell) ?? { ops: [], lost: false, fed: 0 }
710
- PATCH_SINKS.set(cell, sink)
711
- sink.fed += 1
712
- return () => {
713
- sink.fed -= 1
714
- if (sink.fed === 0) PATCH_SINKS.delete(cell)
715
- }
716
- }
@@ -0,0 +1,377 @@
1
+ // THE BUILD OF THE APP UNDER A ROOT, ON DISK: where it lives (38.27), what is in it, whether there
2
+ // is one, and making one where there is not (38.100). `abide build` writes it, and `abide start`,
3
+ // `createApp`, the test preload and the CLI's own reads take it — every one through here, so the
4
+ // layout is spelled once and "is there a build" has one answer.
5
+ //
6
+ // THE COMPILATION IS THE MARKER. A build removes it before writing anything and writes it last, so a
7
+ // build that failed partway leaves no build — `abide start` builds again and says why, rather than
8
+ // booting what is left (38.100).
9
+ //
10
+ // 42.16 — UNDER `bun test`, THE BUILD IS ONE PER STATE OF THE APP'S SOURCES: its directory is named
11
+ // after a hash of what made it, so a build either matches the sources or does not exist, and every
12
+ // test process — every `--parallel` worker, every spawned child — reuses the one that does. Eight
13
+ // workers each building their own copy of a 1.3 s build waited 7–10 s apiece.
14
+
15
+ import { mkdir, rename, rm, utimes, writeFile } from 'node:fs/promises' // bun exposes no directory creation, rename, removal, mtime write or exclusive create of its own
16
+ import { dirname } from 'node:path' // bun exposes no path helpers of its own
17
+
18
+ import type { Compilation } from './abideModules.ts'
19
+ import type { AppSources } from './answerRequest.ts'
20
+ import type { Manifest } from './manifestOf.ts'
21
+ import { namesUnder } from './namesUnder.ts'
22
+ import { packageDirectory } from './packageDirectory.ts'
23
+ import { builtSources, serveApp, type ServedApp } from './servedApp.ts'
24
+
25
+ /** What builds the app under `root` into `out` where nobody has: spawned, or the CLI's own. */
26
+ export type Builder = (root: string, out: string) => Promise<unknown>
27
+
28
+ /** Where each part of the build in `out` is. */
29
+ export type BuildFiles = {
30
+ /** The compilation — every `.abide` compiled, and the build's tables — and the marker. */
31
+ compilation: string
32
+ /** 38.5 — beside the bundle rather than in it: everything under `client` is the browser's. */
33
+ manifest: string
34
+ /** The browser bundle, each file beside its precompressed copies (38.5). */
35
+ client: string
36
+ /** 38.18 — every `public` directory, each file at its address. */
37
+ published: string
38
+ }
39
+
40
+ // The CLI, spawned rather than imported: the compiler is not reachable from the server seam.
41
+ const ABIDE_BIN = Bun.fileURLToPath(
42
+ new URL('../../bin/abide.ts', import.meta.url),
43
+ )
44
+ // The framework's own source is part of what made a build: a compiler change is a new build.
45
+ const FRAMEWORK = Bun.fileURLToPath(new URL('../..', import.meta.url))
46
+ const POLL_MS = 50
47
+ // A test build no process has reused for this long is swept by the next one that builds.
48
+ const UNUSED_FOR_MS = 24 * 60 * 60 * 1000
49
+
50
+ // One lookup per app per test process, however many files in it call `createApp`.
51
+ const TEST_BUILDS = new Map<string, Promise<string>>()
52
+
53
+ /** Where the build of the app under `root` goes: `.abide` in its package. 38.27. */
54
+ export function buildDirectory(root: string): string {
55
+ return `${packageDirectory(root)}/.abide`
56
+ }
57
+
58
+ export function buildFiles(out: string): BuildFiles {
59
+ return {
60
+ compilation: `${out}/compilation.json`,
61
+ manifest: `${out}/manifest.json`,
62
+ client: `${out}/client`,
63
+ published: `${out}/public`,
64
+ }
65
+ }
66
+
67
+ /**
68
+ * The files a build into `out` writes, its previous marker removed first — so that from here until
69
+ * `markBuild`, `out` holds no build.
70
+ */
71
+ export async function unmarkedBuild(out: string): Promise<BuildFiles> {
72
+ const files = buildFiles(out)
73
+ await rm(files.compilation, { force: true })
74
+ return files
75
+ }
76
+
77
+ /** Ends a build: its compilation, written whole and last, is what says there is one. */
78
+ export function markBuild(
79
+ files: BuildFiles,
80
+ compilation: Compilation,
81
+ ): Promise<void> {
82
+ return writeWhole(files.compilation, JSON.stringify(compilation))
83
+ }
84
+
85
+ /**
86
+ * Writes `path` whole: to a name of its own beside it, then renamed over it.
87
+ *
88
+ * 38.66 — a reader, or a second build, finds the last complete file there and never a partial one.
89
+ */
90
+ export async function writeWhole(
91
+ path: string,
92
+ content: string | Blob,
93
+ ): Promise<void> {
94
+ const partial = `${path}.${crypto.randomUUID()}.partial`
95
+ await Bun.write(partial, content)
96
+ await rename(partial, path)
97
+ }
98
+
99
+ /** What a build in `out` holds beside its compilation: the manifest, and every file it serves. */
100
+ export async function builtFiles(out: string): Promise<{
101
+ manifest: Manifest
102
+ client: Map<string, string>
103
+ published: Map<string, string>
104
+ }> {
105
+ const files = buildFiles(out)
106
+ return {
107
+ manifest: (await Bun.file(files.manifest).json()) as Manifest,
108
+ client: pathsUnder(files.client),
109
+ published: pathsUnder(files.published),
110
+ }
111
+ }
112
+
113
+ /**
114
+ * The build in `out` served to this process's imports, with the sources an `App` is booted over —
115
+ * or `undefined` where `out` holds no build and `build` made none.
116
+ *
117
+ * 38.6 — what `abide start` boots: no compiler is loaded, and every `.abide` the app imports is
118
+ * answered out of what the build compiled. 38.100 — AN APP NOBODY BUILT IS BUILT, by `build`,
119
+ * rather than refused; `undefined` builds nothing, for a reader aimed at a build already there.
120
+ */
121
+ export async function servedBuild(
122
+ root: string,
123
+ out: string,
124
+ build: Builder | undefined,
125
+ ): Promise<(ServedApp & { load: () => Promise<AppSources> }) | undefined> {
126
+ const marker = buildFiles(out).compilation
127
+ if (!(await Bun.file(marker).exists())) {
128
+ if (build === undefined) return undefined
129
+ await build(root, out)
130
+ // A FRESH HANDLE: one taken before the build wrote the file reads it as empty.
131
+ if (!(await Bun.file(marker).exists())) return undefined
132
+ }
133
+ // Read beside the table's import rather than after it; a boot that never loads drops it.
134
+ const files = builtFiles(out)
135
+ files.catch(() => {})
136
+ const served = await serveApp(
137
+ root,
138
+ (await Bun.file(marker).json()) as Compilation,
139
+ )
140
+ return {
141
+ ...served,
142
+ load: async () =>
143
+ builtSources({
144
+ table: served.table,
145
+ handlers: served.table.handlers,
146
+ ...(await files),
147
+ }),
148
+ }
149
+ }
150
+
151
+ /** `abide build` of the app under `root` into `out`, run as the CLI. */
152
+ export async function spawnedBuild(root: string, out: string): Promise<void> {
153
+ const child = Bun.spawn(
154
+ [process.execPath, ABIDE_BIN, 'build', '--root', root, '--out', out],
155
+ { stdout: 'ignore', stderr: 'pipe' },
156
+ )
157
+ const [exitCode, said] = await Promise.all([
158
+ child.exited,
159
+ new Response(child.stderr).text(),
160
+ ])
161
+ if (exitCode !== 0)
162
+ throw new Error(
163
+ `abide: could not build the app under ${root}.\n${said}`,
164
+ )
165
+ }
166
+
167
+ /**
168
+ * Where this process reads the build of the app under `root` from: its `buildDirectory`, or — `fresh`,
169
+ * which `bun test` is (42.16) — the build of its sources as they stand, made if no process has.
170
+ */
171
+ export async function processBuild(
172
+ root: string,
173
+ fresh = process.env.NODE_ENV === 'test',
174
+ ): Promise<string> {
175
+ if (!fresh) return buildDirectory(root)
176
+ let built = TEST_BUILDS.get(root)
177
+ if (built === undefined) {
178
+ // UNDER `node_modules`, so a bare import in the built modules resolves as it does from the
179
+ // app, and so it is ignored wherever `node_modules` is.
180
+ built = sourceHash(root).then((key) =>
181
+ sharedBuild(
182
+ `${packageDirectory(root)}/node_modules/.cache/abide`,
183
+ `build-${key}`,
184
+ (out) => spawnedBuild(root, out),
185
+ ),
186
+ )
187
+ TEST_BUILDS.set(root, built)
188
+ }
189
+ return built
190
+ }
191
+
192
+ /** Every file under `directory`, by the name below it to its path. */
193
+ function pathsUnder(directory: string): Map<string, string> {
194
+ const paths = new Map<string, string>()
195
+ for (const name of namesUnder(directory))
196
+ paths.set(name, `${directory}/${name}`)
197
+ return paths
198
+ }
199
+
200
+ /**
201
+ * `builds/key`, made by `build` in exactly one process however many ask at once.
202
+ *
203
+ * THE FIRST TO CREATE `key.lock` BUILDS — an exclusive create is atomic, so one process wins — into
204
+ * a staging directory renamed into place, so nothing reads a half-written build. The rest wait for
205
+ * it, and take the lock over if the process holding it has ended.
206
+ */
207
+ export async function sharedBuild(
208
+ builds: string,
209
+ key: string,
210
+ build: (out: string) => Promise<void>,
211
+ ): Promise<string> {
212
+ const out = `${builds}/${key}`
213
+ const lock = `${out}.lock`
214
+ await mkdir(builds, { recursive: true })
215
+ for (;;) {
216
+ if (await isDirectory(out)) {
217
+ const now = new Date()
218
+ await utimes(out, now, now)
219
+ return out
220
+ }
221
+ if (await claim(lock)) {
222
+ try {
223
+ if (!(await isDirectory(out))) await buildInto(out, build)
224
+ } finally {
225
+ await rm(lock, { force: true })
226
+ }
227
+ await sweep(builds, key)
228
+ return out
229
+ }
230
+ if (await holderEnded(lock)) await rm(lock, { force: true })
231
+ else await Bun.sleep(POLL_MS)
232
+ }
233
+ }
234
+
235
+ async function buildInto(
236
+ out: string,
237
+ build: (out: string) => Promise<void>,
238
+ ): Promise<void> {
239
+ const staging = `${out}.tmp-${process.pid}`
240
+ await rm(staging, { recursive: true, force: true })
241
+ try {
242
+ await build(staging)
243
+ await rename(staging, out)
244
+ } catch (thrown) {
245
+ await rm(staging, { recursive: true, force: true })
246
+ // TWO HOLDERS, where one took over a lock the other had just taken over: the first rename
247
+ // stands, and it is the same build.
248
+ if (await isDirectory(out)) return
249
+ throw thrown
250
+ }
251
+ }
252
+
253
+ /**
254
+ * A hash of everything a build of the app under `root` is made from.
255
+ *
256
+ * The app's files, its package's manifest and tsconfig and the lockfile above them, the framework's
257
+ * own source, and `root` itself, since a build carries the paths it was made at.
258
+ */
259
+ export async function sourceHash(root: string): Promise<string> {
260
+ const app = packageDirectory(root)
261
+ const paths = [
262
+ ...filesUnder(root),
263
+ ...filesUnder(`${FRAMEWORK}src`),
264
+ ...filesUnder(`${FRAMEWORK}bin`),
265
+ `${app}/package.json`,
266
+ `${app}/tsconfig.json`,
267
+ ]
268
+ const lockfile = await lockfileAbove(app)
269
+ if (lockfile !== undefined) paths.push(lockfile)
270
+ const contents = await Promise.all(
271
+ paths.map((path) =>
272
+ Bun.file(path)
273
+ .bytes()
274
+ .catch(() => undefined),
275
+ ),
276
+ )
277
+ const hasher = new Bun.CryptoHasher('sha256')
278
+ hasher.update(root)
279
+ for (let at = 0; at < paths.length; at += 1) {
280
+ const bytes = contents[at]
281
+ if (bytes === undefined) continue
282
+ hasher.update(paths[at] as string)
283
+ hasher.update(bytes)
284
+ }
285
+ return hasher.digest('hex').slice(0, 16)
286
+ }
287
+
288
+ // Sorted, so the hash does not depend on the order a directory lists in. Dot entries and
289
+ // `node_modules` are skipped: neither is the app's source, and the builds live in one.
290
+ function filesUnder(directory: string): string[] {
291
+ const found: string[] = []
292
+ for (const name of namesUnder(directory))
293
+ if (!`/${name}`.includes('/node_modules/'))
294
+ found.push(`${directory}/${name}`)
295
+ return found.sort()
296
+ }
297
+
298
+ async function lockfileAbove(directory: string): Promise<string | undefined> {
299
+ for (let at = directory; ; at = dirname(at)) {
300
+ for (const name of ['bun.lock', 'bun.lockb'])
301
+ if (await Bun.file(`${at}/${name}`).exists()) return `${at}/${name}`
302
+ if (dirname(at) === at) return undefined
303
+ }
304
+ }
305
+
306
+ async function claim(lock: string): Promise<boolean> {
307
+ try {
308
+ await writeFile(lock, String(process.pid), { flag: 'wx' })
309
+ return true
310
+ } catch (thrown) {
311
+ if ((thrown as { code?: string }).code === 'EEXIST') return false
312
+ throw thrown
313
+ }
314
+ }
315
+
316
+ async function holderEnded(lock: string): Promise<boolean> {
317
+ const text = await Bun.file(lock)
318
+ .text()
319
+ .catch(() => undefined)
320
+ // Gone is not ended: the holder finished, and the next pass finds its build.
321
+ if (text === undefined) return false
322
+ // Empty is a holder between creating the lock and writing its pid.
323
+ return text !== '' && !isRunning(Number(text))
324
+ }
325
+
326
+ /** Removes builds unused for a day, and what an ended process left half-made. */
327
+ async function sweep(builds: string, kept: string): Promise<void> {
328
+ const names = new Bun.Glob('*').scanSync({ cwd: builds, onlyFiles: false })
329
+ const removals: Promise<void>[] = []
330
+ const now = Date.now()
331
+ for (const name of names) {
332
+ if (name === kept) continue
333
+ const staged = /\.tmp-(\d+)$/.exec(name)
334
+ if (staged !== null) {
335
+ if (!isRunning(Number(staged[1])))
336
+ removals.push(
337
+ rm(`${builds}/${name}`, { recursive: true, force: true }),
338
+ )
339
+ continue
340
+ }
341
+ if (name.endsWith('.lock')) continue
342
+ removals.push(
343
+ Bun.file(`${builds}/${name}`)
344
+ .stat()
345
+ .then((found) =>
346
+ now - found.mtimeMs > UNUSED_FOR_MS
347
+ ? rm(`${builds}/${name}`, {
348
+ recursive: true,
349
+ force: true,
350
+ })
351
+ : undefined,
352
+ )
353
+ .catch(() => undefined),
354
+ )
355
+ }
356
+ await Promise.all(removals)
357
+ }
358
+
359
+ async function isDirectory(path: string): Promise<boolean> {
360
+ return (
361
+ (
362
+ await Bun.file(path)
363
+ .stat()
364
+ .catch(() => undefined)
365
+ )?.isDirectory() === true
366
+ )
367
+ }
368
+
369
+ function isRunning(pid: number): boolean {
370
+ try {
371
+ process.kill(pid, 0)
372
+ return true
373
+ } catch (thrown) {
374
+ // EPERM is a process that exists and is someone else's.
375
+ return (thrown as { code?: string }).code === 'EPERM'
376
+ }
377
+ }
@@ -1,26 +1,24 @@
1
- import { appDirectories } from './appDirectories.ts'
1
+ import { packageDirectory } from './packageDirectory.ts'
2
2
 
3
3
  /**
4
4
  * The app's own bundler plugins, loaded from `bunfig.toml`'s `[serve.static] plugins`.
5
5
  *
6
6
  * 38.43 — declared where bun already has an app declare them, the key `bun-plugin-tailwind`
7
- * documents. `Bun.build` reads no bunfig, so both client builds read this one — in
8
- * `appDirectories`, as `config` finds the app's `package.json`.
7
+ * documents. `Bun.build` reads no bunfig, so both client builds read this one — the app's
8
+ * package's (`packageDirectory`), beside the `package.json` `config` reads.
9
9
  */
10
10
  export async function appPlugins(root: string): Promise<Bun.BunPlugin[]> {
11
- for (const directory of appDirectories(root)) {
12
- const file = Bun.file(`${directory}/bunfig.toml`)
13
- if (!(await file.exists())) continue
14
- const parsed = Bun.TOML.parse(await file.text()) as {
15
- serve?: { static?: { plugins?: string[] } }
16
- }
17
- const plugins: Bun.BunPlugin[] = []
18
- for (const specifier of parsed.serve?.static?.plugins ?? [])
19
- plugins.push(
20
- (await import(Bun.resolveSync(specifier, directory)))
21
- .default as Bun.BunPlugin,
22
- )
23
- return plugins
11
+ const directory = packageDirectory(root)
12
+ const file = Bun.file(`${directory}/bunfig.toml`)
13
+ if (!(await file.exists())) return []
14
+ const parsed = Bun.TOML.parse(await file.text()) as {
15
+ serve?: { static?: { plugins?: string[] } }
24
16
  }
25
- return []
17
+ const plugins: Bun.BunPlugin[] = []
18
+ for (const specifier of parsed.serve?.static?.plugins ?? [])
19
+ plugins.push(
20
+ (await import(Bun.resolveSync(specifier, directory)))
21
+ .default as Bun.BunPlugin,
22
+ )
23
+ return plugins
26
24
  }
@@ -12,15 +12,16 @@
12
12
  // A WALK RATHER THAN A GLOB: a glob descends `node_modules` and the build output before a filter
13
13
  // can drop them.
14
14
 
15
- import { type Dirent, existsSync, readdirSync } from 'node:fs' // bun has no directory listing or sync existence check of its own
15
+ import { type Dirent, readdirSync } from 'node:fs' // bun has no directory listing of its own
16
16
 
17
- import type { Refusals } from '#compiler/Refusals.ts'
17
+ import type { Diagnostics } from '#compiler/Diagnostics.ts'
18
18
  import { ABIDE_ADDRESS } from '#shared/ADDRESSES.ts'
19
19
  import {
20
20
  PUBLIC_DIRECTORY,
21
21
  RESERVED_DIRECTORIES,
22
22
  } from '#shared/RESERVED_DIRECTORIES.ts'
23
23
 
24
+ import { packageDirectory } from './packageDirectory.ts'
24
25
  import {
25
26
  DIRECTORY_FILES,
26
27
  isBenchFile,
@@ -54,7 +55,7 @@ type AppFiles = {
54
55
  *
55
56
  * A missing root is an app with nothing in it, not a fault.
56
57
  */
57
- export function appFiles(root: string, refusals: Refusals): AppFiles {
58
+ export function appFiles(root: string, refusals: Diagnostics): AppFiles {
58
59
  const files: AppFiles = {
59
60
  abide: [],
60
61
  handlers: [],
@@ -64,7 +65,7 @@ export function appFiles(root: string, refusals: Refusals): AppFiles {
64
65
  }
65
66
  // 23.22, 38.25 — a root that is its package's directory holds the package's own files beside its
66
67
  // pages.
67
- const packaged = existsSync(`${root}/package.json`)
68
+ const packaged = packageDirectory(root) === root
68
69
  walk(root, '', undefined, files, refusals, packaged)
69
70
  files.abide.sort()
70
71
  files.handlers.sort()
@@ -81,7 +82,7 @@ function walk(
81
82
  relative: string,
82
83
  reserved: { path: string; name: string } | undefined,
83
84
  files: AppFiles,
84
- refusals: Refusals,
85
+ refusals: Diagnostics,
85
86
  packaged = false,
86
87
  ): void {
87
88
  let entries: Dirent[]