@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
@@ -10,7 +10,7 @@
10
10
  // built per request, since a trigger names args rather than a value. A `global` value is also fed its productions, a guarded one only to a
11
11
  // subscription its rungs admitted when it opened (18.8) — they run once, in the connection's request.
12
12
 
13
- import { type Problem, problemOf } from '#shared/Failed.ts'
13
+ import { problemOf } from '#shared/Failed.ts'
14
14
  import { GLOBAL_GETS } from '#shared/GLOBAL_GETS.ts'
15
15
  import { namedLogger } from '#shared/log.ts'
16
16
  import { compose } from '#shared/Middleware.ts'
@@ -34,19 +34,103 @@ import {
34
34
  releaseScope,
35
35
  scopeFor,
36
36
  } from './requestScope.ts'
37
+ import { EVENT_STREAM_OPENER, streamEvent } from './streamEvent.ts'
37
38
  import { patchesSince, versionOf } from './versionOf.ts'
38
39
 
39
40
  /**
40
- * The far end of one connection to the mux, which its frames are written to.
41
+ * The far end of one connection to the mux, which its frames are written to as text.
41
42
  *
42
43
  * Bun's socket under `abide start`, and under `abide dev` a stand-in that posts each frame to the
43
44
  * listener holding the real one (38.14).
44
45
  */
45
- export type Peer = {
46
+ export type Socket = {
46
47
  send(text: string): unknown
47
48
  close(code: number, reason: string): unknown
48
49
  }
49
50
 
51
+ const ENCODER = new TextEncoder()
52
+
53
+ /**
54
+ * One `ServerFrame`, for every follower it is sent to.
55
+ *
56
+ * 18.6 — ITS BODY IS SERIALIZED ONCE PER VERSION, and each encoding of it once however many
57
+ * followers it reaches: a socket is sent the JSON with its follower's id spliced in, and an event
58
+ * stream the event, which names no follower and is the same text for every stream.
59
+ */
60
+ class Frame {
61
+ readonly op: ServerFrame['op']
62
+ readonly base: number | undefined
63
+ readonly version: number | undefined
64
+ // The value, the patch's ops or the refusal, as JSON.
65
+ readonly body: string | undefined
66
+ // What follows the id in the JSON, and the event's bytes, each written on first use.
67
+ private tail: string | undefined = undefined
68
+ private event: Uint8Array | undefined = undefined
69
+
70
+ constructor(
71
+ op: ServerFrame['op'],
72
+ base: number | undefined,
73
+ version: number | undefined,
74
+ body: string | undefined,
75
+ ) {
76
+ this.op = op
77
+ this.base = base
78
+ this.version = version
79
+ this.body = body
80
+ }
81
+
82
+ /** As `ServerFrame` JSON for `id`: `base` and `version` where it has them, then the body under its op's member. */
83
+ json(id: number): string {
84
+ if (this.tail === undefined) {
85
+ let tail = ''
86
+ if (this.base !== undefined) tail += `,"base":${this.base}`
87
+ if (this.version !== undefined) tail += `,"version":${this.version}`
88
+ if (this.body !== undefined)
89
+ tail += `,"${this.op === 'patch' ? 'ops' : this.op}":${this.body}`
90
+ this.tail = `${tail}}`
91
+ }
92
+ return `{"op":"${this.op}","id":${id}${this.tail}`
93
+ }
94
+
95
+ /** As the SSE event 18.20 makes it, encoded: the op its name, the version its id. */
96
+ asEvent(): Uint8Array {
97
+ this.event ??= ENCODER.encode(
98
+ streamEvent(
99
+ this.op,
100
+ this.version,
101
+ this.body ?? (this.op === 'value' ? 'null' : ''),
102
+ ),
103
+ )
104
+ return this.event
105
+ }
106
+ }
107
+
108
+ /** A trigger's frame (7.10, 7.18), the same for every follower and every trigger. */
109
+ const TRIGGERS = {
110
+ invalidate: new Frame('invalidate', undefined, undefined, undefined),
111
+ refresh: new Frame('refresh', undefined, undefined, undefined),
112
+ }
113
+
114
+ /** What a frame is written to, under the follower's id: a socket, or an event stream. */
115
+ type Peer = {
116
+ send(id: number, frame: Frame): void
117
+ close(code: number, reason: string): unknown
118
+ }
119
+
120
+ /** A socket's peer: each frame as the `ServerFrame` JSON a browser parses. */
121
+ class SocketPeer implements Peer {
122
+ readonly socket: Socket
123
+ constructor(socket: Socket) {
124
+ this.socket = socket
125
+ }
126
+ send(id: number, frame: Frame): void {
127
+ this.socket.send(frame.json(id))
128
+ }
129
+ close(code: number, reason: string): unknown {
130
+ return this.socket.close(code, reason)
131
+ }
132
+ }
133
+
50
134
  const SOCKET_LOG = namedLogger('abide:socket')
51
135
 
52
136
  // 18.3 — EVERY SUBSCRIPTION TO A HANDLER, by handler, for its triggers to reach (7.10, 7.18).
@@ -95,7 +179,7 @@ export function broadcast(
95
179
  // the cell it is fed from (`FOLLOWED`), whichever trigger reached it — where the entry it is
96
180
  // fed from is still the one standing.
97
181
  if (attach(rpc as Mounted['handler'], follower) !== undefined) continue
98
- send(follower.peer, { op: member, id: follower.id })
182
+ follower.peer.send(follower.id, TRIGGERS[member])
99
183
  }
100
184
  }
101
185
 
@@ -111,7 +195,7 @@ export function swept(
111
195
  for (const follower of followed.all) {
112
196
  if (attach(rpc as Mounted['handler'], follower) !== undefined)
113
197
  continue
114
- send(follower.peer, { op: member, id: follower.id })
198
+ follower.peer.send(follower.id, TRIGGERS[member])
115
199
  }
116
200
  }
117
201
  }
@@ -157,12 +241,14 @@ function attach(rpc: Mounted['handler'], follower: Follower): unknown {
157
241
  return serving
158
242
  }
159
243
 
160
- function send(peer: Peer, frame: ServerFrame): void {
161
- peer.send(JSON.stringify(frame))
162
- }
163
-
164
- function failure(error: unknown): Problem {
165
- return problemOf(failureOf(error))
244
+ /** A refusal's frame: the failure `error` is, as Problem Details (15.21). */
245
+ function refusal(error: unknown): Frame {
246
+ return new Frame(
247
+ 'error',
248
+ undefined,
249
+ undefined,
250
+ JSON.stringify(problemOf(failureOf(error))),
251
+ )
166
252
  }
167
253
 
168
254
  /**
@@ -180,11 +266,15 @@ export class Mux {
180
266
  this.app = app
181
267
  }
182
268
 
183
- /** One connection, upgraded from `request`, whose frames go to `peer`. */
184
- connect(request: Request, peer: Peer): MuxConnection {
269
+ /** One connection, upgraded from `request`, whose frames go to `socket`. */
270
+ connect(request: Request, socket: Socket): MuxConnection {
185
271
  if (SOCKET_LOG.enabled())
186
272
  SOCKET_LOG.debug('open', new URL(request.url).pathname)
187
- const connection = new MuxConnection(this, request, peer)
273
+ const connection = new MuxConnection(
274
+ this,
275
+ request,
276
+ new SocketPeer(socket),
277
+ )
188
278
  this.connections.add(connection)
189
279
  return connection
190
280
  }
@@ -267,26 +357,24 @@ export class MuxConnection {
267
357
  : undefined
268
358
  const followed = handler?.ui ? handler : undefined
269
359
  if (followed === undefined) {
270
- send(this.peer, {
271
- op: 'error',
360
+ this.peer.send(
272
361
  id,
273
- error: failure(
362
+ refusal(
274
363
  refuse(
275
364
  404,
276
365
  `No handler a browser follows answers at ${address}.`,
277
366
  ),
278
367
  ),
279
- })
368
+ )
280
369
  return
281
370
  }
282
371
  // 18.2 — ANOTHER ORIGIN IS CLOSED OUT unless the handler declared it: the same gate, and the
283
372
  // same own origin, as its http answer's (16.13, 16.24, 36.2).
284
373
  if (closedTo(followed.handler.crossOrigin, this.crossing)) {
285
- send(this.peer, {
286
- op: 'error',
374
+ this.peer.send(
287
375
  id,
288
- error: failure(crossingRefusal(this.crossing, 'subscription')),
289
- })
376
+ refusal(crossingRefusal(this.crossing, 'subscription')),
377
+ )
290
378
  return
291
379
  }
292
380
  // A SUBSCRIPTION IS HELD FROM THE FRAME ON, not from when its rungs let it through: an
@@ -322,7 +410,7 @@ export class MuxConnection {
322
410
  follow(this, id, followed, args, frame.version, admitted)
323
411
  } catch (thrown) {
324
412
  if (subscriptions.get(id) === pending) subscriptions.delete(id)
325
- send(this.peer, { op: 'error', id, error: failure(thrown) })
413
+ this.peer.send(id, refusal(thrown))
326
414
  }
327
415
  }
328
416
  }
@@ -444,14 +532,12 @@ export function followAsEvents(
444
532
  args: unknown,
445
533
  held: number | undefined,
446
534
  ): ReadableStream<Uint8Array> {
447
- const encoder = new TextEncoder()
448
535
  let stop = (): void => {}
449
536
  let unhold = (): void => {}
450
537
  let ended = false
451
538
  return new ReadableStream({
452
539
  start(controller) {
453
- // AN EVENT STREAM OPENS ON A COMMENT, so a proxy has bytes to pass before the first event.
454
- controller.enqueue(encoder.encode(':\n\n'))
540
+ controller.enqueue(ENCODER.encode(EVENT_STREAM_OPENER))
455
541
  // Ended by the app's stop as any body it holds open is, or that stop waits on it.
456
542
  unhold = heldByApp(() => {
457
543
  if (ended) return
@@ -461,8 +547,10 @@ export function followAsEvents(
461
547
  })
462
548
  stop = followWith(
463
549
  {
464
- send(text) {
465
- controller.enqueue(encoder.encode(eventOf(text)))
550
+ // 18.6 — the event written and encoded once for every stream, from the body
551
+ // serialized once for every follower: nothing is parsed back.
552
+ send(_id, frame) {
553
+ controller.enqueue(frame.asEvent())
466
554
  },
467
555
  close() {
468
556
  if (ended) return
@@ -487,37 +575,6 @@ export function followAsEvents(
487
575
  })
488
576
  }
489
577
 
490
- /**
491
- * One mux frame as the SSE event it is: `event`, `id` where it names a version, and `data`.
492
- *
493
- * 18.6 — READ OFF THE FRAME'S HEAD, never parsed: the body was serialized once for every follower
494
- * (`sendAll`), and a parse and a stringify per event stream would pay it again per follower. Every
495
- * frame this module writes is `{"op":…,"id":…`, numbers named `base` or `version`, and at most one
496
- * body last — so the body is the rest of the text, as it was written.
497
- */
498
- function eventOf(text: string): string {
499
- const opEnd = text.indexOf('"', 7)
500
- const op = text.slice(7, opEnd)
501
- let id = ''
502
- let data =
503
- op === 'value' || op === 'error' ? 'null' : op === 'patch' ? '[]' : ''
504
- // Past `,"id":` — a number, so the next comma is the next member's.
505
- let at = text.indexOf(',', opEnd + 6)
506
- while (at !== -1) {
507
- const nameEnd = text.indexOf('"', at + 2)
508
- const name = text.slice(at + 2, nameEnd)
509
- const from = nameEnd + 2
510
- if (name !== 'base' && name !== 'version') {
511
- data = text.slice(from, -1)
512
- break
513
- }
514
- at = text.indexOf(',', from)
515
- if (name === 'version')
516
- id = `id: ${text.slice(from, at === -1 ? -1 : at)}\n`
517
- }
518
- return `event: ${op}\n${id}data: ${data}\n\n`
519
- }
520
-
521
578
  // 18.6 — ONE WATCH PER VALUE SERVED, however many follow it, and each frame's body serialized once
522
579
  // per version: every follower standing where the others stand is sent the same bytes, with its own
523
580
  // id spliced in front.
@@ -544,7 +601,7 @@ function startFeed(serving: unknown): Feed {
544
601
  const fed = new Set<Follower>()
545
602
  // 7.10 — an invalidate of the value, however it was spelled, reaches every copy it feeds.
546
603
  const invalidated = (): void => {
547
- for (const one of fed) send(one.peer, { op: 'invalidate', id: one.id })
604
+ for (const one of fed) one.peer.send(one.id, TRIGGERS.invalidate)
548
605
  }
549
606
  let followed = FOLLOWED.get(past)
550
607
  if (followed === undefined) {
@@ -577,33 +634,36 @@ function startFeed(serving: unknown): Feed {
577
634
 
578
635
  function sendAll(serving: unknown, fed: Iterable<Follower>): void {
579
636
  const version = versionOf(serving)
580
- let valueTail: string | undefined
581
- // ONE BODY PER VERSION A FOLLOWER STANDS AT, since followers a burst left at different places
637
+ let valueFrame: Frame | undefined
638
+ // ONE FRAME PER VERSION A FOLLOWER STANDS AT, since followers a burst left at different places
582
639
  // along the chain are each sent the writes since their own.
583
- let patchTails: Map<number, string> | undefined
640
+ let patchFrames: Map<number, Frame> | undefined
584
641
  for (const one of fed) {
585
642
  if (one.sent === version) continue
586
643
  // 18.11 — the writes, where the follower stands on the chain they were written along.
587
644
  const since = one.sent
588
- let patchTail = since === undefined ? undefined : patchTails?.get(since)
589
- if (patchTail === undefined && since !== undefined) {
645
+ let frame = since === undefined ? undefined : patchFrames?.get(since)
646
+ if (frame === undefined && since !== undefined) {
590
647
  const ops = patchesSince(serving, since)
591
648
  if (ops !== undefined) {
592
- patchTail = `,"base":${since},"version":${version},"ops":${JSON.stringify(ops)}}`
593
- patchTails ??= new Map()
594
- patchTails.set(since, patchTail)
649
+ frame = new Frame('patch', since, version, JSON.stringify(ops))
650
+ patchFrames ??= new Map()
651
+ patchFrames.set(since, frame)
595
652
  }
596
653
  }
597
- if (patchTail === undefined) {
598
- if (valueTail === undefined) {
654
+ if (frame === undefined) {
655
+ if (valueFrame === undefined) {
599
656
  const held = (serving as { peek(): unknown }).peek()
600
- valueTail =
601
- held === undefined
602
- ? `,"version":${version}}`
603
- : `,"version":${version},"value":${JSON.stringify(held)}}`
657
+ valueFrame = new Frame(
658
+ 'value',
659
+ undefined,
660
+ version,
661
+ held === undefined ? undefined : JSON.stringify(held),
662
+ )
604
663
  }
605
- one.peer.send(`{"op":"value","id":${one.id}${valueTail}`)
606
- } else one.peer.send(`{"op":"patch","id":${one.id}${patchTail}`)
664
+ frame = valueFrame
665
+ }
666
+ one.peer.send(one.id, frame)
607
667
  one.sent = version
608
668
  }
609
669
  }
@@ -4,7 +4,6 @@
4
4
  // `Values` a handler returned — framed one chunk per pull (16.32, 16.44–16.47).
5
5
 
6
6
  import { isFailed, problemOf } from '#shared/Failed.ts'
7
- import { refusalFrame } from '#shared/framing.ts'
8
7
  import { compressAnswer } from './compressAnswer.ts'
9
8
  import { prefers } from './prefers.ts'
10
9
  import {
@@ -13,10 +12,13 @@ import {
13
12
  heldByApp,
14
13
  holdScope,
15
14
  } from './requestScope.ts'
15
+ import {
16
+ EVENT_STREAM_OPENER,
17
+ refusalFrame,
18
+ streamEvent,
19
+ } from './streamEvent.ts'
16
20
 
17
21
  const ENCODER = new TextEncoder()
18
- // AN EVENT STREAM OPENS ON A COMMENT — `bodyOf` says why.
19
- const SSE_OPENER = ':\n\n'
20
22
 
21
23
  function isValues(
22
24
  value: unknown,
@@ -70,6 +72,8 @@ export async function answerValue(
70
72
  headers.set('content-type', 'text/html; charset=utf-8')
71
73
  if (typeof value === 'string' || value instanceof Uint8Array)
72
74
  return new Response(value as BodyInit, { status, headers })
75
+ // 16.75.
76
+ headers.set('x-accel-buffering', 'no')
73
77
  return new Response(bodyOf(chunksOf(value), 'bytes'), {
74
78
  status,
75
79
  headers,
@@ -96,11 +100,11 @@ function isIterable(value: unknown): value is Iterable<unknown> {
96
100
 
97
101
  /**
98
102
  * The head of an event stream. 16.45 — never stored, whatever the value's own life said (21.8), and
99
- * never buffered by a proxy in front.
103
+ * 16.75 — never buffered by a proxy in front.
100
104
  */
101
105
  export function sseHeaders(headers: Headers): void {
102
106
  headers.set('content-type', 'text/event-stream')
103
- headers.set('cache-control', 'no-cache')
107
+ headers.set('cache-control', 'private, no-store')
104
108
  headers.set('x-accel-buffering', 'no')
105
109
  }
106
110
 
@@ -169,10 +173,16 @@ export function framed(
169
173
  first?: IteratorResult<unknown>,
170
174
  lines = 'application/jsonl',
171
175
  ): Response {
172
- if (framing === 'bytes')
173
- headers.set('content-type', 'application/octet-stream')
174
- else if (framing === 'jsonl') headers.set('content-type', lines)
175
- else sseHeaders(headers)
176
+ // 16.75 — whatever the framing: a proxy holding frames back until the end undoes one per pull.
177
+ // An event stream's head says so itself.
178
+ if (framing === 'sse') sseHeaders(headers)
179
+ else {
180
+ headers.set(
181
+ 'content-type',
182
+ framing === 'bytes' ? 'application/octet-stream' : lines,
183
+ )
184
+ headers.set('x-accel-buffering', 'no')
185
+ }
176
186
  return new Response(bodyOf(chunks, framing, first), {
177
187
  status,
178
188
  headers,
@@ -214,7 +224,7 @@ export function bodyOf(
214
224
  return new ReadableStream<Uint8Array>({
215
225
  start(controller) {
216
226
  if (framing === 'sse')
217
- controller.enqueue(ENCODER.encode(SSE_OPENER))
227
+ controller.enqueue(ENCODER.encode(EVENT_STREAM_OPENER))
218
228
  stopped = () => {
219
229
  if (ended) return
220
230
  release()
@@ -238,10 +248,7 @@ export function bodyOf(
238
248
  if (framing !== 'bytes' && isFailed(thrown)) {
239
249
  controller.enqueue(
240
250
  ENCODER.encode(
241
- refusalFrame(
242
- problemOf(thrown),
243
- framing === 'sse' ? 'data: ' : '',
244
- ),
251
+ refusalFrame(problemOf(thrown), framing),
245
252
  ),
246
253
  )
247
254
  controller.close()
@@ -267,7 +274,13 @@ export function bodyOf(
267
274
  controller.enqueue(ENCODER.encode(`${JSON.stringify(chunk)}\n`))
268
275
  else
269
276
  controller.enqueue(
270
- ENCODER.encode(`data: ${JSON.stringify(chunk)}\n\n`),
277
+ ENCODER.encode(
278
+ streamEvent(
279
+ undefined,
280
+ undefined,
281
+ JSON.stringify(chunk),
282
+ ),
283
+ ),
271
284
  )
272
285
  },
273
286
  cancel() {
@@ -5,23 +5,23 @@
5
5
  // So listing a directory lists its routes.
6
6
  //
7
7
  // ONE WALK for the sources, the handler files, the server and lib modules, and the public files
8
- // `abide build` copies and `abide dev` answers, and the refusals (23.16, 23.17, 23.18, 23.22) — and
9
- // ONE SKIP RULE, `skipped`, for every walk of the tree, beside `placeOf`, which answers the same
10
- // question of one path.
8
+ // `abide build` copies and `abide dev` answers, and the refusals (23.16, 23.17, 23.18, 23.22) — each
9
+ // file what `placeOf` says of its path, so the walk and every reader of one path agree, and ONE SKIP
10
+ // RULE, `skipped`, for every walk of the tree.
11
11
  //
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
15
  import { type Dirent, existsSync, readdirSync } from 'node:fs' // bun has no directory listing or sync existence check of its own
16
+ import type { Refusals } from '#compiler/Refusals.ts'
16
17
  import { ABIDE_ADDRESS } from '#shared/ADDRESSES.ts'
17
18
  import {
18
19
  HOLDS_SERVER,
19
- LIB_DIRECTORY,
20
20
  PUBLIC_DIRECTORY,
21
21
  RESERVED_DIRECTORIES,
22
22
  SERVER_DIRECTORY,
23
23
  } from '#shared/RESERVED_DIRECTORIES.ts'
24
- import { DIRECTORY_FILES, routeOf, skipped } from '#shared/routePattern.ts'
24
+ import { DIRECTORY_FILES, placeOf, routeOf, skipped } from './placement.ts'
25
25
 
26
26
  /** What one walk of the app finds, every path relative to the root. */
27
27
  type AppFiles = {
@@ -35,23 +35,13 @@ type AppFiles = {
35
35
  published: Map<string, string>
36
36
  }
37
37
 
38
- /** Told of each thing the tree refuses, at the path relative to the root. */
39
- type Refuse = (path: string, message: string) => void
40
-
41
- const THROW: Refuse = (_path, message) => {
42
- throw new Error(message)
43
- }
44
-
45
38
  /**
46
39
  * The app's `.abide` sources, handler files, server and lib modules and public files, refusing a
47
- * tree 23.16, 23.17, 23.18 or 23.22 forbids.
48
- *
49
- * `refuse` throws by default, which is the build's answer; `abide check` collects instead (38.33),
50
- * and the checker's route table ignores them, since a refused tree is the build's to report.
40
+ * tree 23.16, 23.17, 23.18 or 23.22 forbids into `refusals`, each at its path below the root.
51
41
  *
52
42
  * A missing root is an app with nothing in it, not a fault.
53
43
  */
54
- export function appFiles(root: string, refuse: Refuse = THROW): AppFiles {
44
+ export function appFiles(root: string, refusals: Refusals): AppFiles {
55
45
  const files: AppFiles = {
56
46
  abide: [],
57
47
  handlers: [],
@@ -61,7 +51,7 @@ export function appFiles(root: string, refuse: Refuse = THROW): AppFiles {
61
51
  // 23.22, 38.25 — a root that is its package's directory holds the package's own files beside its
62
52
  // pages.
63
53
  const packaged = existsSync(`${root}/package.json`)
64
- walk(root, '', undefined, files, refuse, packaged)
54
+ walk(root, '', undefined, files, refusals, packaged)
65
55
  files.abide.sort()
66
56
  files.handlers.sort()
67
57
  files.modules.sort()
@@ -69,13 +59,14 @@ export function appFiles(root: string, refuse: Refuse = THROW): AppFiles {
69
59
  }
70
60
 
71
61
  // `reserved` is the nearest reserved directory above `relative` — its path and its name — or
72
- // `undefined` where none is.
62
+ // `undefined` where none is: what 23.16 refuses is the nesting of two, which is a directory's shape
63
+ // rather than a file's place.
73
64
  function walk(
74
65
  directory: string,
75
66
  relative: string,
76
67
  reserved: { path: string; name: string } | undefined,
77
68
  files: AppFiles,
78
- refuse: Refuse,
69
+ refusals: Refusals,
79
70
  packaged = false,
80
71
  ): void {
81
72
  let entries: Dirent[]
@@ -99,9 +90,11 @@ function walk(
99
90
  HOLDS_SERVER.has(reserved.name)
100
91
  )
101
92
  )
102
- refuse(
93
+ refusals.at(
103
94
  path,
104
- `abide: ${path} is a ${entry.name} directory under the ${reserved.name} directory ${reserved.path}. RULEBOOK 23.16.`,
95
+ new Error(
96
+ `abide: ${path} is a ${entry.name} directory under the ${reserved.name} directory ${reserved.path}. RULEBOOK 23.16.`,
97
+ ),
105
98
  )
106
99
  within = { path, name: entry.name }
107
100
  }
@@ -110,37 +103,43 @@ function walk(
110
103
  `${path}/`,
111
104
  within,
112
105
  files,
113
- refuse,
106
+ refusals,
114
107
  )
115
108
  continue
116
109
  }
117
110
  const abide = path.endsWith('.abide')
118
111
  if (abide) files.abide.push(path)
119
- if (reserved === undefined) {
112
+ const place = placeOf(path)
113
+ if (place === 'address') {
120
114
  if (!abide && !isModule(path)) {
121
115
  if (packaged && relative === '' && !path.endsWith('.ts'))
122
116
  continue
123
- refuse(
117
+ refusals.at(
124
118
  path,
125
- `abide: ${path} is directly in an address directory, which holds pages, layouts, error pages and handler files alone. Move it into a lib directory beside it. RULEBOOK 23.22.`,
119
+ new Error(
120
+ `abide: ${path} is directly in an address directory, which holds pages, layouts, error pages and handler files alone. Move it into a lib directory beside it. RULEBOOK 23.22.`,
121
+ ),
126
122
  )
127
123
  continue
128
124
  }
129
125
  if (!abide) files.handlers.push(path)
130
126
  // 23.17 — anything that could answer here would answer in abide's own namespace.
131
- refuseFrameworkRoute(routeOf(path), path, refuse)
132
- } else if (DIRECTORY_FILES.has(entry.name))
133
- refuse(
127
+ const framework = frameworkRouteRefusal(routeOf(path), path)
128
+ if (framework !== undefined) refusals.at(path, framework)
129
+ continue
130
+ }
131
+ // Any other place is a fence's, and the walk passed through it.
132
+ const fence = reserved as { path: string; name: string }
133
+ if (DIRECTORY_FILES.has(entry.name))
134
+ refusals.at(
134
135
  path,
135
- `abide: ${path} is under the ${reserved.name} directory ${reserved.path}, where nothing has an address, and ${entry.name} is a route's name. RULEBOOK 23.18.`,
136
+ new Error(
137
+ `abide: ${path} is under the ${fence.name} directory ${fence.path}, where nothing has an address, and ${entry.name} is a route's name. RULEBOOK 23.18.`,
138
+ ),
136
139
  )
137
- else if (reserved.name === PUBLIC_DIRECTORY)
138
- files.published.set(path, publicAddress(path, reserved.path))
139
- else if (
140
- (reserved.name === SERVER_DIRECTORY ||
141
- reserved.name === LIB_DIRECTORY) &&
142
- isModule(path)
143
- )
140
+ else if (place === 'public')
141
+ files.published.set(path, publicAddress(path, fence.path))
142
+ else if ((place === 'server' || place === 'lib') && isModule(path))
144
143
  files.modules.push(path)
145
144
  }
146
145
  }
@@ -157,17 +156,16 @@ function isModule(path: string): boolean {
157
156
  )
158
157
  }
159
158
 
160
- /** Refuses `source` where `route` lies in the framework's own namespace. 23.17. */
161
- export function refuseFrameworkRoute(
159
+ /** The refusal of `source` where `route` lies in the framework's own namespace, or `undefined`. 23.17. */
160
+ export function frameworkRouteRefusal(
162
161
  route: string,
163
162
  source: string,
164
- refuse: Refuse = THROW,
165
- ): void {
166
- if (route === ABIDE_ADDRESS || route.startsWith(`${ABIDE_ADDRESS}/`))
167
- refuse(
168
- source,
169
- `abide: ${source} answers at ${route}, which is under ${ABIDE_ADDRESS}, where abide's own addresses are. RULEBOOK 23.17.`,
170
- )
163
+ ): Error | undefined {
164
+ return route === ABIDE_ADDRESS || route.startsWith(`${ABIDE_ADDRESS}/`)
165
+ ? new Error(
166
+ `abide: ${source} answers at ${route}, which is under ${ABIDE_ADDRESS}, where abide's own addresses are. RULEBOOK 23.17.`,
167
+ )
168
+ : undefined
171
169
  }
172
170
 
173
171
  // 36.11 — the directories above `public`, then the file's own path below it.
@@ -191,19 +189,6 @@ export function publishedUnder(
191
189
  return paths
192
190
  }
193
191
 
194
- /**
195
- * Every `.abide` under `root`, relative to it, in path order — the app's own, and none a
196
- * dependency ships.
197
- *
198
- * Refuses nothing: the language server lists them on every check, and a tree 23.16 forbids is the
199
- * build's to refuse (`appFiles`).
200
- */
201
- export function abideUnder(root: string): string[] {
202
- const found: string[] = []
203
- for (const path of abideFilesUnder(root, '')) found.push(path)
204
- return found.sort()
205
- }
206
-
207
192
  /**
208
193
  * Whether an app lies under `root`: a `.abide` anywhere or a handler file, stopping at the first.
209
194
  *
@@ -211,15 +196,13 @@ export function abideUnder(root: string): string[] {
211
196
  * is as cheap as the tree allows.
212
197
  */
213
198
  export function holdsApp(root: string): boolean {
214
- return !abideFilesUnder(root, '', true).next().done
199
+ return !appSourcesUnder(root, '').next().done
215
200
  }
216
201
 
217
- // Every `.abide` under `directory` — and, where `handlers`, every handler file outside a fence.
218
- function* abideFilesUnder(
202
+ // Every `.abide` under `directory`, and every handler file — a module whose place is an address.
203
+ function* appSourcesUnder(
219
204
  directory: string,
220
205
  relative: string,
221
- handlers = false,
222
- fenced = false,
223
206
  ): Generator<string> {
224
207
  let entries: Dirent[]
225
208
  try {
@@ -231,15 +214,10 @@ function* abideFilesUnder(
231
214
  if (skipped(entry.name)) continue
232
215
  const path = `${relative}${entry.name}`
233
216
  if (entry.isDirectory())
234
- yield* abideFilesUnder(
235
- `${directory}/${entry.name}`,
236
- `${path}/`,
237
- handlers,
238
- fenced || RESERVED_DIRECTORIES.has(entry.name),
239
- )
217
+ yield* appSourcesUnder(`${directory}/${entry.name}`, `${path}/`)
240
218
  else if (
241
219
  path.endsWith('.abide') ||
242
- (handlers && !fenced && isModule(path))
220
+ (isModule(path) && placeOf(path) === 'address')
243
221
  )
244
222
  yield path
245
223
  }