@abide/abide 0.104.0 → 0.105.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/CHANGELOG.md +16 -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/reconcile.ts +4 -110
  77. package/src/ui/rows.ts +123 -2
  78. package/src/ui/wire.ts +12 -27
  79. package/src/compiler/hooksCalled.ts +0 -35
  80. package/src/server/clientBuildOptions.ts +0 -54
  81. package/src/server/refuseAddressCollision.ts +0 -37
  82. package/src/shared/reactive/chunksOfStream.ts +0 -33
  83. package/src/ui/MOUNTING_LEVEL.ts +0 -18
@@ -1,46 +1,26 @@
1
- // 16.32 — A FRAMED ANSWER, read one JSON value per line: the whole line for jsonl, the `data:` line of
2
- // each event for sse. A line that does not carry the prefix — an sse `event:` line, a blank line — is
3
- // skipped. One line reader behind both a body read to the end and one read as it lands.
1
+ // 16.32 — A FRAMED ANSWER, read one JSON value per frame: each line for jsonl, the `data` of each
2
+ // event for sse. One reader, `frameReader`, behind both a body read to the end and one read as it
3
+ // lands — and behind `abide watch`, which reads an event stream's events by name. The writer is the
4
+ // server's (`server/streamEvent.ts`).
4
5
  //
5
6
  // 16.68 — AND ITS LAST FRAME MAY BE A REFUSAL: one that reached the stream after it had answered, so
6
- // after its status was sent. An sse `event: refusal` and the Problem Details on its `data:` line, or a
7
+ // after its status was sent. An sse `event: refusal` and the Problem Details as its `data`, or a
7
8
  // jsonl line `{"$refusal": <Problem Details>}` — read here as the refusal, thrown where it lands.
8
9
 
9
- import { failedFrom, isProblem, type Problem } from './Failed.ts'
10
+ import { failedFrom, isProblem } from './Failed.ts'
11
+ import { REFUSAL_KEY } from './REFUSAL_KEY.ts'
10
12
 
11
- /** The key a jsonl line carries a refusal under (16.68). */
12
- const REFUSAL_KEY = '$refusal'
13
+ /** The two framings a streamed JSON answer is read in. 17.6. */
14
+ export type Framed = 'jsonl' | 'sse'
13
15
 
14
- /** A framed stream's last frame, as the server writes it for a refusal (16.68). */
15
- export function refusalFrame(problem: Problem, prefix: string): string {
16
- return prefix === ''
17
- ? `${JSON.stringify({ [REFUSAL_KEY]: problem })}\n`
18
- : `event: refusal\n${prefix}${JSON.stringify(problem)}\n\n`
19
- }
20
-
21
- // What a read of one framed body carries from one line to the next: whether an sse event named the
22
- // data line after it a refusal.
23
- type Reading = { refusal: boolean }
24
-
25
- // A refusal's frame, read: thrown where it lands among the chunks.
26
- class Refused {
27
- problem: Problem
28
- constructor(problem: Problem) {
29
- this.problem = problem
30
- }
31
- }
32
-
33
- /**
34
- * What precedes a chunk's JSON on its line: nothing for jsonl, `data: ` for sse, and `null` where
35
- * the answer is not framed at all.
36
- */
37
- export function framingPrefix(type: string | null | undefined): string | null {
16
+ /** How an answer at `type` is framed, or `null` where it is not framed at all. */
17
+ export function framingOf(type: string | null | undefined): Framed | null {
38
18
  if (
39
19
  type?.startsWith('application/jsonl') ||
40
20
  type?.startsWith('application/x-ndjson')
41
21
  )
42
- return ''
43
- if (type?.startsWith('text/event-stream')) return 'data: '
22
+ return 'jsonl'
23
+ if (type?.startsWith('text/event-stream')) return 'sse'
44
24
  return null
45
25
  }
46
26
 
@@ -52,24 +32,102 @@ export function framingPrefix(type: string | null | undefined): string | null {
52
32
  */
53
33
  export function carriesBytes(type: string | null | undefined): boolean {
54
34
  if (type === null || type === undefined || type === '') return false
55
- if (framingPrefix(type) !== null) return false
35
+ if (framingOf(type) !== null) return false
56
36
  const semicolon = type.indexOf(';')
57
37
  const essence = semicolon === -1 ? type : type.slice(0, semicolon)
58
38
  return essence !== 'application/json' && !essence.endsWith('+json')
59
39
  }
60
40
 
41
+ /** One frame: an sse event's name — `message` where it named none — `id` and `data`, or a jsonl line. */
42
+ export type Frame = { name: string; id: string | undefined; data: string }
43
+
44
+ /**
45
+ * A framed body's reader, fed its text as it arrives: pushes each frame the text completes onto
46
+ * `frames` — and, where `ended`, the last one too.
47
+ *
48
+ * An event stream is read as the format says (18.27) — fields by name, a comment skipped, `data`
49
+ * lines joined, the event ending at a blank line. A jsonl line is a frame with nothing but its data.
50
+ * A closure over the read's own state, which is all it captures.
51
+ */
52
+ function frameReader(
53
+ lines: boolean,
54
+ ): (text: string, ended: boolean, frames: Frame[]) => void {
55
+ // The text after the last newline read, held until its line completes.
56
+ let rest = ''
57
+ let name = 'message'
58
+ let id: string | undefined
59
+ let data: string | undefined
60
+ return (text, ended, frames) => {
61
+ // A long line across many reads is held unscanned until its newline lands, or each read
62
+ // rescans everything before it.
63
+ if (!ended && !text.includes('\n')) {
64
+ rest += text
65
+ return
66
+ }
67
+ const read = (rest + text).split('\n')
68
+ // The unfinished last line waits for the next read; at the end, a blank line ends it.
69
+ rest = ended ? '' : (read.pop() as string)
70
+ if (ended) read.push('')
71
+ for (const line of read) {
72
+ // A blank line ends an event; a jsonl line is one.
73
+ if (lines || line === '') {
74
+ const complete = lines ? line || undefined : data
75
+ if (complete !== undefined)
76
+ frames.push({ name, id, data: complete })
77
+ name = 'message'
78
+ id = data = undefined
79
+ continue
80
+ }
81
+ // A field and its value, the one space after the colon dropped and a CRLF's `\r` with
82
+ // it; a line with no colon is a field with no value. A comment — an event stream's
83
+ // opener — names the field ``, which is none.
84
+ const colon = line.indexOf(':')
85
+ const field = colon === -1 ? line : line.slice(0, colon)
86
+ if (field !== 'data' && field !== 'event' && field !== 'id')
87
+ continue
88
+ let start = colon === -1 ? line.length : colon + 1
89
+ if (line.charCodeAt(start) === 32) start += 1
90
+ const value = line.slice(
91
+ start,
92
+ line.charCodeAt(line.length - 1) === 13
93
+ ? line.length - 1
94
+ : line.length,
95
+ )
96
+ if (field === 'data')
97
+ data = data === undefined ? value : `${data}\n${value}`
98
+ else if (field === 'event') name = value
99
+ else id = value
100
+ }
101
+ }
102
+ }
103
+
104
+ /**
105
+ * The chunk a frame carried, or — where it is a refusal (16.68) — that refusal, thrown.
106
+ *
107
+ * Per chunk: the reserved key is tested before any key list is built.
108
+ */
109
+ function chunkOf(frame: Frame, lines: boolean): unknown {
110
+ const value = JSON.parse(frame.data)
111
+ const problem = !lines
112
+ ? frame.name === 'refusal' && value
113
+ : typeof value === 'object' &&
114
+ Object.hasOwn(Object(value), REFUSAL_KEY) &&
115
+ Object.keys(value).length === 1 &&
116
+ value[REFUSAL_KEY]
117
+ if (isProblem(problem)) throw failedFrom(problem)
118
+ return value
119
+ }
120
+
61
121
  /**
62
- * A streamed answer's chunks: each JSON value its framing carried, or, for bytes (16.46), each
63
- * `Uint8Array` as it was read — bytes have no framing to split them back into the chunks the
64
- * handler yielded.
122
+ * A streamed answer's chunks: each JSON value its `framing` carried, or, for bytes (16.46) — an
123
+ * unread body with no framing — each `Uint8Array` as it was read: bytes have no framing to split
124
+ * them back into the chunks the handler yielded.
65
125
  */
66
126
  export function answerChunks(
67
127
  body: ReadableStream<Uint8Array>,
68
- type: string | null | undefined,
128
+ framing: Framed | null,
69
129
  ): AsyncIterable<unknown> {
70
- return carriesBytes(type)
71
- ? bytesStream(body)
72
- : framedStream(body, framingPrefix(type) ?? '')
130
+ return framing === null ? bytesStream(body) : framedStream(body, framing)
73
131
  }
74
132
 
75
133
  /**
@@ -92,113 +150,54 @@ async function* bytesStream(
92
150
  }
93
151
  }
94
152
 
95
- /** The chunks a framed text carried, every line of it complete. */
96
- export function framedChunks(text: string, prefix: string): unknown[] {
97
- const chunks: unknown[] = []
98
- readLines(text, prefix, true, chunks, { refusal: false })
99
- for (const chunk of chunks)
100
- if (chunk instanceof Refused) throw failedFrom(chunk.problem)
101
- return chunks
153
+ /** The chunks a framed text carried, every frame of it complete. */
154
+ export function framedChunks(text: string, framing: Framed): unknown[] {
155
+ const lines = framing === 'jsonl'
156
+ const frames: Frame[] = []
157
+ frameReader(lines)(text, true, frames)
158
+ return frames.map((frame) => chunkOf(frame, lines))
159
+ }
160
+
161
+ /** An event stream's events, yielded as each completes. */
162
+ export function eventsOf(
163
+ body: ReadableStream<Uint8Array>,
164
+ ): AsyncGenerator<Frame> {
165
+ return framedStream(body, 'sse', true) as AsyncGenerator<Frame>
102
166
  }
103
167
 
104
168
  /**
105
- * A framed body's chunks, yielded as each line completes.
169
+ * A framed body's chunks — or, where `events`, its frames as they are — yielded as each frame
170
+ * completes.
106
171
  *
107
172
  * Canceled when the reader stops reading — a refresh starts a new run and the old one's reader has
108
173
  * nothing left to hand anything.
109
174
  */
110
175
  export async function* framedStream(
111
176
  body: ReadableStream<Uint8Array>,
112
- prefix: string,
177
+ framing: Framed,
178
+ events?: true,
113
179
  ): AsyncGenerator<unknown> {
180
+ const lines = framing === 'jsonl'
114
181
  const reader = body.getReader()
115
182
  const decoder = new TextDecoder()
116
- const chunks: unknown[] = []
117
- const reading: Reading = { refusal: false }
118
- let buffered = ''
183
+ const framed = frameReader(lines)
184
+ const frames: Frame[] = []
119
185
  try {
120
186
  for (;;) {
121
187
  const { done, value } = await reader.read()
122
- const decoded = done
123
- ? decoder.decode()
124
- : decoder.decode(value, { stream: true })
125
- // A long line across many reads is held unscanned until its newline lands, or each read
126
- // rescans everything before it.
127
- if (!done && !decoded.includes('\n')) {
128
- buffered += decoded
129
- continue
130
- }
131
- buffered = readLines(
132
- buffered + decoded,
133
- prefix,
188
+ framed(
189
+ done
190
+ ? decoder.decode()
191
+ : decoder.decode(value, { stream: true }),
134
192
  done,
135
- chunks,
136
- reading,
193
+ frames,
137
194
  )
138
- for (const chunk of chunks) {
139
- if (chunk instanceof Refused) throw failedFrom(chunk.problem)
140
- yield chunk
141
- }
142
- chunks.length = 0
195
+ for (const frame of frames)
196
+ yield events ? frame : chunkOf(frame, lines)
197
+ frames.length = 0
143
198
  if (done) return
144
199
  }
145
200
  } finally {
146
201
  void reader.cancel().catch(() => {})
147
202
  }
148
203
  }
149
-
150
- /**
151
- * Parses each complete line of `text` into `chunks`, and the unfinished last one too where `ended`.
152
- *
153
- * Answers what is left of `text` after the last newline.
154
- */
155
- function readLines(
156
- text: string,
157
- prefix: string,
158
- ended: boolean,
159
- chunks: unknown[],
160
- reading: Reading,
161
- ): string {
162
- let start = 0
163
- for (;;) {
164
- const newline = text.indexOf('\n', start)
165
- if (newline === -1 && !ended) return text.slice(start)
166
- const end = newline === -1 ? text.length : newline
167
- if (prefix !== '' && text.startsWith('event: refusal', start))
168
- reading.refusal = true
169
- else if (end > start && text.startsWith(prefix, start)) {
170
- const value: unknown = JSON.parse(
171
- text.slice(start + prefix.length, end),
172
- )
173
- chunks.push(refusalIn(value, prefix, reading) ?? value)
174
- reading.refusal = false
175
- }
176
- if (newline === -1) return ''
177
- start = newline + 1
178
- }
179
- }
180
-
181
- // The refusal a value carries as its frame: the data line an sse event named one, or a jsonl line
182
- // holding the one reserved key and Problem Details under it.
183
- function refusalIn(
184
- value: unknown,
185
- prefix: string,
186
- reading: Reading,
187
- ): Refused | undefined {
188
- if (prefix !== '')
189
- return reading.refusal && isProblem(value)
190
- ? new Refused(value)
191
- : undefined
192
- // Per chunk: the reserved key is tested before any key list is built.
193
- if (
194
- value === null ||
195
- typeof value !== 'object' ||
196
- !Object.hasOwn(value, REFUSAL_KEY)
197
- )
198
- return undefined
199
- const keys = Object.keys(value)
200
- const problem = (value as Record<string, unknown>)[REFUSAL_KEY]
201
- return keys.length === 1 && isProblem(problem)
202
- ? new Refused(problem)
203
- : undefined
204
- }
@@ -1,18 +1,26 @@
1
1
  /**
2
2
  * A record of members as a query string.
3
3
  *
4
- * ONE QUERY-STRING RULE for `rpcRequest`'s args (16.18) and `url`'s `Query` (30.6): a member left
4
+ * 30.28 — ONE QUERY-STRING RULE for `rpcRequest`'s args (16.18) and `url`'s `Query`: a member left
5
5
  * `undefined` is omitted, a list is its member repeated, and anything else is its text — `null`
6
- * included, which 19.7 reads back as `null` where the schema declares it. An empty list writes
7
- * nothing here; `rpcRequest` spells it as a bare name.
6
+ * included, which 19.7 reads back as `null` where the schema declares it. AN EMPTY LIST IS ITS NAME
7
+ * WITH NO `=`, last, which no text value spells — `tags=` is the one-member list `['']` — so 19.7
8
+ * reads it back as `[]` rather than losing the member.
8
9
  */
9
10
  export function queryText(members: Record<string, unknown>): string {
10
11
  const query = new URLSearchParams()
12
+ let bare = ''
11
13
  for (const [name, value] of Object.entries(members)) {
12
14
  if (value === undefined) continue
13
- if (Array.isArray(value))
14
- for (const each of value) query.append(name, String(each))
15
- else query.append(name, String(value))
15
+ if (!Array.isArray(value)) query.append(name, String(value))
16
+ else if (value.length === 0) bare += `&${bareName(name)}`
17
+ else for (const each of value) query.append(name, String(each))
16
18
  }
17
- return query.toString()
19
+ const text = query.toString()
20
+ return text === '' ? bare.slice(1) : text + bare
21
+ }
22
+
23
+ /** A query member's name in the encoding `URLSearchParams` gives it, without its `=`. */
24
+ export function bareName(name: string): string {
25
+ return new URLSearchParams([[name, '']]).toString().slice(0, -1)
18
26
  }
@@ -1,7 +1,7 @@
1
1
  // THE CURSOR. RULEBOOK 2.6: a streaming producer's chunks, read from the chunk buffer by ordinal,
2
2
  // so two chunks between two `await`s are two turns of the loop, and a patch or a write over the
3
3
  // stream moves no place (5.7, 9.12). Where the run ENDS is the caller's settling, handed to
4
- // `closeOn`: `for await` over a `Reactive` is `Reactive.ts`'s `producedRun`.
4
+ // `closeOn`: one run's reader is `Reactive.ts`'s `runChunks`.
5
5
  //
6
6
  // A NARROW FACE rather than the `Cell`, the same way `tracking.ts` takes a `Tracked`: what a
7
7
  // cursor reads is a handful of fields, and naming them keeps the cell's internals out of a
@@ -15,7 +15,7 @@ import {
15
15
  } from './tracking.ts'
16
16
 
17
17
  /**
18
- * What a cursor reads, and all of a cell the server is handed (`servedCellOf`).
18
+ * What a cursor reads of a cell.
19
19
  *
20
20
  * `ring[index]` carries ordinal `ringBase + index + 1`, so a cursor holding an ordinal finds its
21
21
  * place in a stream's chunk buffer without the buffer having to hold one.
@@ -92,6 +92,8 @@ export class Cursor {
92
92
  this.reader = newSubscriber([source], null)
93
93
  this.reader.notify = (): void => this.woken()
94
94
  source.readers.push(this.reader)
95
+ // ONE PLACED PAST EVERY CHUNK is a wake and never behind, so no patch has a place to keep.
96
+ if (from === Number.POSITIVE_INFINITY) return
95
97
  let cursors = CURSORS.get(source)
96
98
  if (cursors === undefined) {
97
99
  cursors = new Set()