@abide/abide 0.103.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.
- package/CHANGELOG.md +29 -0
- package/bin/abide.ts +80 -173
- package/docs/REGISTRY.md +27 -27
- package/docs/RULEBOOK.md +68 -24
- package/docs/abide.md +31 -46
- package/package.json +1 -1
- package/src/compiler/Lowering.ts +15 -5
- package/src/compiler/Refusals.ts +57 -0
- package/src/compiler/TypeSession.ts +46 -89
- package/src/compiler/appLayout.ts +447 -0
- package/src/compiler/appMap.ts +21 -25
- package/src/compiler/checkApp.ts +61 -97
- package/src/compiler/checkedShadow.ts +12 -10
- package/src/compiler/classifyRead.ts +3 -6
- package/src/compiler/compileApp.ts +183 -478
- package/src/compiler/componentGraph.ts +2 -71
- package/src/compiler/declaredExports.ts +79 -60
- package/src/compiler/emitClient.ts +11 -13
- package/src/compiler/emitComponent.ts +21 -6
- package/src/compiler/emitSource.ts +20 -20
- package/src/compiler/handlersUnder.ts +46 -36
- package/src/compiler/hookCalls.ts +152 -0
- package/src/compiler/mountsOf.ts +31 -54
- package/src/compiler/projectShadow.ts +1 -1
- package/src/compiler/readSources.ts +27 -42
- package/src/compiler/readsIn.ts +5 -16
- package/src/compiler/resolverIn.ts +77 -0
- package/src/server/abideModules.ts +152 -146
- package/src/server/answerRequest.ts +18 -4
- package/src/server/answerSocket.ts +136 -76
- package/src/server/answerValue.ts +28 -15
- package/src/server/appTree.ts +59 -73
- package/src/server/claudeApi.ts +2 -5
- package/src/server/clientBundle.ts +198 -0
- package/src/server/coerceArgs.ts +1 -1
- package/src/server/commands.ts +93 -57
- package/src/server/createApp.ts +90 -16
- package/src/server/deferredBuilds.ts +175 -0
- package/src/server/devWorker.ts +107 -319
- package/src/server/faceOf.ts +120 -0
- package/src/server/fenceServer.ts +10 -18
- package/src/server/generatedSurfaces.ts +12 -53
- package/src/server/index.ts +3 -0
- package/src/server/placement.ts +167 -0
- package/src/server/remote.ts +14 -42
- package/src/server/render.ts +25 -17
- package/src/server/renderComponent.ts +9 -5
- package/src/server/requestScope.ts +10 -4
- package/src/server/responseRelay.ts +389 -0
- package/src/server/runCompiled.ts +9 -12
- package/src/server/runDesktop.ts +6 -9
- package/src/server/serveBuild.ts +26 -73
- package/src/server/serveDev.ts +19 -176
- package/src/server/streamEvent.ts +29 -0
- package/src/server/versionOf.ts +1 -2
- package/src/server/withAbideSources.ts +2 -1
- package/src/shared/HandlerRoute.ts +68 -0
- package/src/shared/REFUSAL_KEY.ts +5 -0
- package/src/shared/RESERVED_DIRECTORIES.ts +6 -0
- package/src/shared/answerOf.ts +105 -78
- package/src/shared/filledPath.ts +1 -9
- package/src/shared/framing.ts +127 -128
- package/src/shared/queryText.ts +15 -7
- package/src/shared/reactive/Cursor.ts +69 -124
- package/src/shared/reactive/Reactive.ts +296 -162
- package/src/shared/reactive/callLoad.ts +4 -18
- package/src/shared/reactive/index.ts +11 -8
- package/src/shared/reactive/pairReactive.ts +1 -1
- package/src/shared/reactive/scoped.ts +4 -3
- package/src/shared/reactive/share.ts +11 -1
- package/src/shared/routePattern.ts +0 -110
- package/src/shared/rpc.ts +17 -24
- package/src/shared/rpcRequest.ts +18 -47
- package/src/shared/takeEarlyCall.ts +12 -37
- package/src/shared/url.ts +1 -1
- package/src/ui/Level.ts +276 -0
- package/src/ui/List.ts +17 -17
- package/src/ui/Router.ts +20 -87
- package/src/ui/applyHead.ts +92 -79
- package/src/ui/index.ts +4 -2
- package/src/ui/reconcile.ts +4 -110
- package/src/ui/rows.ts +123 -2
- package/src/ui/wire.ts +12 -27
- package/src/compiler/hooksCalled.ts +0 -35
- package/src/server/clientBuildOptions.ts +0 -54
- package/src/server/refuseAddressCollision.ts +0 -37
- package/src/shared/reactive/chunksOfStream.ts +0 -57
- package/src/ui/MOUNTING_LEVEL.ts +0 -18
package/src/shared/framing.ts
CHANGED
|
@@ -1,46 +1,26 @@
|
|
|
1
|
-
// 16.32 — A FRAMED ANSWER, read one JSON value per
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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
|
|
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
|
|
10
|
+
import { failedFrom, isProblem } from './Failed.ts'
|
|
11
|
+
import { REFUSAL_KEY } from './REFUSAL_KEY.ts'
|
|
10
12
|
|
|
11
|
-
/** The
|
|
12
|
-
|
|
13
|
+
/** The two framings a streamed JSON answer is read in. 17.6. */
|
|
14
|
+
export type Framed = 'jsonl' | 'sse'
|
|
13
15
|
|
|
14
|
-
/**
|
|
15
|
-
export function
|
|
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 '
|
|
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 (
|
|
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)
|
|
63
|
-
* `Uint8Array` as it was read
|
|
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
|
-
|
|
128
|
+
framing: Framed | null,
|
|
69
129
|
): AsyncIterable<unknown> {
|
|
70
|
-
return
|
|
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
|
|
96
|
-
export function framedChunks(text: string,
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
117
|
-
const
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
136
|
-
reading,
|
|
193
|
+
frames,
|
|
137
194
|
)
|
|
138
|
-
for (const
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
}
|
package/src/shared/queryText.ts
CHANGED
|
@@ -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
|
|
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.
|
|
7
|
-
*
|
|
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
|
-
|
|
15
|
-
else query.append(name, String(
|
|
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
|
-
|
|
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,14 +1,12 @@
|
|
|
1
|
-
// THE CURSOR. RULEBOOK 2.
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// held. An app that wants every message keeps them — a channel is an array.
|
|
1
|
+
// THE CURSOR. RULEBOOK 2.6: a streaming producer's chunks, read from the chunk buffer by ordinal,
|
|
2
|
+
// so two chunks between two `await`s are two turns of the loop, and a patch or a write over the
|
|
3
|
+
// stream moves no place (5.7, 9.12). Where the run ENDS is the caller's settling, handed to
|
|
4
|
+
// `closeOn`: one run's reader is `Reactive.ts`'s `runChunks`.
|
|
6
5
|
//
|
|
7
6
|
// A NARROW FACE rather than the `Cell`, the same way `tracking.ts` takes a `Tracked`: what a
|
|
8
7
|
// cursor reads is a handful of fields, and naming them keeps the cell's internals out of a
|
|
9
8
|
// second file.
|
|
10
9
|
|
|
11
|
-
import { PATCHES } from './PATCHES.ts'
|
|
12
10
|
import {
|
|
13
11
|
dropReader,
|
|
14
12
|
newSubscriber,
|
|
@@ -17,27 +15,20 @@ import {
|
|
|
17
15
|
} from './tracking.ts'
|
|
18
16
|
|
|
19
17
|
/**
|
|
20
|
-
* What a cursor reads
|
|
18
|
+
* What a cursor reads of a cell.
|
|
21
19
|
*
|
|
22
20
|
* `ring[index]` carries ordinal `ringBase + index + 1`, so a cursor holding an ordinal finds its
|
|
23
21
|
* place in a stream's chunk buffer without the buffer having to hold one.
|
|
24
22
|
*/
|
|
25
23
|
export type Retained = Tracked & {
|
|
26
|
-
/** The window a `throttle` or a `debounce` holds open, `holding` while a production waits on it. */
|
|
27
|
-
cap: { holding: boolean } | null
|
|
28
24
|
ordinal: number
|
|
29
25
|
ring: unknown[]
|
|
30
26
|
ringBase: number
|
|
31
|
-
servable: boolean
|
|
32
|
-
state: number
|
|
33
|
-
/**
|
|
34
|
-
* Non-null on a streaming producer, whose ring is the accumulation of the run being served
|
|
35
|
-
* (9.1) and is retained whole — the chunks ARE the value.
|
|
36
|
-
*/
|
|
37
|
-
stream: object | null
|
|
38
|
-
value: unknown
|
|
39
27
|
}
|
|
40
28
|
|
|
29
|
+
/** What `take` answers when the cursor has caught up — never a chunk, which may be `undefined`. */
|
|
30
|
+
export const CAUGHT_UP: unique symbol = Symbol('caught up')
|
|
31
|
+
|
|
41
32
|
// The cursors standing over each source, for a patch to keep the place of the ones behind (5.7).
|
|
42
33
|
const CURSORS = new WeakMap<Retained, Set<Cursor>>()
|
|
43
34
|
|
|
@@ -70,62 +61,39 @@ export function hasCursorBehind(source: Retained): boolean {
|
|
|
70
61
|
}
|
|
71
62
|
|
|
72
63
|
/**
|
|
73
|
-
* A
|
|
64
|
+
* A reader of a stream's chunks, in order, waking whoever waits on it when one lands.
|
|
74
65
|
*
|
|
75
|
-
* A RECORD, NOT A SCOPE.
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
* function object hanging off it is live that whole time. Fields on a prototype are the object
|
|
79
|
-
* alone, and `at` — read and written on every turn — becomes a property load on a fixed structure
|
|
80
|
-
* rather than a scope load.
|
|
66
|
+
* A RECORD, NOT A SCOPE. A cursor outlives the call that made it — a `for await` holds one for the
|
|
67
|
+
* life of the loop — so fields on a prototype are the object alone, and `at`, read and written on
|
|
68
|
+
* every turn, is a property load on a fixed structure rather than a scope load.
|
|
81
69
|
*/
|
|
82
|
-
export class Cursor
|
|
70
|
+
export class Cursor {
|
|
83
71
|
source: Retained
|
|
84
|
-
/** The ordinal of the last
|
|
72
|
+
/** The ordinal of the last chunk this cursor handed over. */
|
|
85
73
|
at: number
|
|
86
|
-
/**
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
patched: number
|
|
93
|
-
/**
|
|
94
|
-
* The turns asked for and not yet answered.
|
|
95
|
-
*
|
|
96
|
-
* A QUEUE rather than one slot because a consumer may ask twice before either answer arrives
|
|
97
|
-
* — `Promise.all([ it.next(), it.next() ])` is the shape, and with one slot the second `next`
|
|
98
|
-
* overwrote the first's resolver and that promise never settled at all. `for await` asks one
|
|
99
|
-
* at a time, which is why nothing here said so.
|
|
100
|
-
*/
|
|
101
|
-
waiting: ((result: IteratorResult<unknown>) => void)[]
|
|
74
|
+
/** Called once at the next wake, then cleared. */
|
|
75
|
+
wake: (() => void) | null
|
|
76
|
+
/** Whether the run `closeOn` was handed has settled. */
|
|
77
|
+
closed: boolean
|
|
78
|
+
/** What that run rejected with. */
|
|
79
|
+
failure: unknown
|
|
102
80
|
reader: Subscriber
|
|
103
81
|
|
|
104
82
|
constructor(source: Retained, from: number) {
|
|
105
83
|
this.source = source
|
|
106
84
|
this.at = from
|
|
107
|
-
this.
|
|
108
|
-
this.
|
|
109
|
-
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
// rather than discovered — see the same shape in `Reactive`'s `settled`.
|
|
85
|
+
this.wake = null
|
|
86
|
+
this.closed = false
|
|
87
|
+
this.failure = undefined
|
|
88
|
+
// The one closure left, and it captures this cursor and nothing else. `notify` is called as a
|
|
89
|
+
// method on the SUBSCRIBER, so it cannot be the cursor's own method. Registered outside the
|
|
90
|
+
// tracking protocol, so the source it waits on is declared rather than discovered — see the
|
|
91
|
+
// same shape in `Reactive`'s `settled`.
|
|
115
92
|
this.reader = newSubscriber([source], null)
|
|
116
|
-
|
|
117
|
-
// lands three productions on a consumer holding two turns answers both.
|
|
118
|
-
this.reader.notify = (): void => {
|
|
119
|
-
const waiting = this.waiting
|
|
120
|
-
while (waiting.length !== 0) {
|
|
121
|
-
const next = this.take()
|
|
122
|
-
if (next === null) return
|
|
123
|
-
;(waiting.shift() as (result: IteratorResult<unknown>) => void)(
|
|
124
|
-
next,
|
|
125
|
-
)
|
|
126
|
-
}
|
|
127
|
-
}
|
|
93
|
+
this.reader.notify = (): void => this.woken()
|
|
128
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
|
|
129
97
|
let cursors = CURSORS.get(source)
|
|
130
98
|
if (cursors === undefined) {
|
|
131
99
|
cursors = new Set()
|
|
@@ -135,83 +103,60 @@ export class Cursor implements AsyncIterator<unknown> {
|
|
|
135
103
|
}
|
|
136
104
|
|
|
137
105
|
/**
|
|
138
|
-
*
|
|
106
|
+
* The next chunk, or `CAUGHT_UP`.
|
|
139
107
|
*
|
|
140
|
-
* Reading
|
|
108
|
+
* Reading by ordinal is what keeps a burst from being a gap or a repeat.
|
|
141
109
|
*/
|
|
142
|
-
take():
|
|
110
|
+
take(): unknown {
|
|
143
111
|
const source = this.source
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
if (value === undefined) return null
|
|
149
|
-
const patched =
|
|
150
|
-
value !== null && typeof value === 'object'
|
|
151
|
-
? (PATCHES.written.get(value) ?? 0)
|
|
152
|
-
: 0
|
|
153
|
-
if (at >= source.ordinal && patched === this.patched) return null
|
|
154
|
-
// 5.9 — A PRODUCTION A WINDOW HOLDS has minted its ordinal and is not yet the value: the
|
|
155
|
-
// turn is the release's, which wakes this cursor.
|
|
156
|
-
if (source.cap?.holding === true) return null
|
|
157
|
-
// The standing production is the only one held, so everything minted or patched
|
|
158
|
-
// since the last turn collapses into it.
|
|
159
|
-
this.at = source.ordinal
|
|
160
|
-
this.patched = patched
|
|
161
|
-
return { value, done: false }
|
|
162
|
-
}
|
|
163
|
-
if (at >= source.ordinal) return null
|
|
164
|
-
// A run that restarted moved the buffer past this cursor: answer from its start.
|
|
165
|
-
if (at < source.ringBase) at = source.ringBase
|
|
166
|
-
const value = source.ring[at - source.ringBase]
|
|
167
|
-
this.at = at + 1
|
|
168
|
-
return { value, done: false }
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
next(): Promise<IteratorResult<unknown>> {
|
|
172
|
-
if (!this.reader.alive)
|
|
173
|
-
return Promise.resolve({ value: undefined, done: true })
|
|
174
|
-
// A TURN ALREADY ASKED FOR AND NOT ANSWERED KEEPS THE ORDER: taking from the
|
|
175
|
-
// ring here while one is queued would hand the later caller the earlier
|
|
176
|
-
// production.
|
|
177
|
-
if (this.waiting.length === 0) {
|
|
178
|
-
const ready = this.take()
|
|
179
|
-
// NOT `async`, and no `await` on the caught-up arm. A cursor behind a
|
|
180
|
-
// stream's chunks is the common shape while a burst drains, and an
|
|
181
|
-
// `async` here would put a microtask tick between every one of those
|
|
182
|
-
// turns.
|
|
183
|
-
if (ready !== null) return Promise.resolve(ready)
|
|
184
|
-
}
|
|
185
|
-
const waiting = this.waiting
|
|
186
|
-
return new Promise((resolve) => {
|
|
187
|
-
waiting.push(resolve)
|
|
188
|
-
})
|
|
112
|
+
if (this.at >= source.ordinal) return CAUGHT_UP
|
|
113
|
+
const value = source.ring[this.at - source.ringBase]
|
|
114
|
+
this.at += 1
|
|
115
|
+
return value
|
|
189
116
|
}
|
|
190
117
|
|
|
191
118
|
/**
|
|
192
|
-
* Ends the cursor
|
|
119
|
+
* Ends the cursor's run where `run` settles: `closed` is set, `failure` holds a rejection, and
|
|
120
|
+
* the current `turn` wakes.
|
|
193
121
|
*
|
|
194
|
-
*
|
|
122
|
+
* ONE REACTION ON THE CLOSE FOR THE WHOLE RUN, never one per chunk held until the run ends. A
|
|
123
|
+
* chunk landing in the same turn as the close is still unread when the close wakes the caller,
|
|
124
|
+
* so a drain before reading `closed` hands it over rather than losing it to whichever settled
|
|
125
|
+
* first.
|
|
195
126
|
*/
|
|
196
|
-
|
|
197
|
-
|
|
127
|
+
closeOn(run: PromiseLike<unknown>): void {
|
|
128
|
+
run.then(
|
|
129
|
+
() => {
|
|
130
|
+
this.closed = true
|
|
131
|
+
this.woken()
|
|
132
|
+
},
|
|
133
|
+
(error: unknown) => {
|
|
134
|
+
this.closed = true
|
|
135
|
+
this.failure = error
|
|
136
|
+
this.woken()
|
|
137
|
+
},
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Settles at the next chunk or the close, whichever lands first. */
|
|
142
|
+
turn(): Promise<void> {
|
|
143
|
+
return new Promise((resolve) => {
|
|
144
|
+
this.wake = resolve
|
|
145
|
+
})
|
|
198
146
|
}
|
|
199
147
|
|
|
200
|
-
|
|
201
|
-
|
|
148
|
+
private woken(): void {
|
|
149
|
+
const wake = this.wake
|
|
150
|
+
this.wake = null
|
|
151
|
+
wake?.()
|
|
202
152
|
}
|
|
203
153
|
|
|
204
|
-
|
|
154
|
+
/** Drops the subscriber; nothing else does. */
|
|
155
|
+
stop(): void {
|
|
205
156
|
const reader = this.reader
|
|
206
157
|
reader.alive = false
|
|
207
158
|
dropReader(this.source, reader)
|
|
208
159
|
CURSORS.get(this.source)?.delete(this)
|
|
209
|
-
|
|
210
|
-
while (waiting.length !== 0)
|
|
211
|
-
(waiting.shift() as (result: IteratorResult<unknown>) => void)({
|
|
212
|
-
value: undefined,
|
|
213
|
-
done: true,
|
|
214
|
-
})
|
|
215
|
-
return Promise.resolve({ value: undefined, done: true })
|
|
160
|
+
this.wake = null
|
|
216
161
|
}
|
|
217
162
|
}
|