@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/server/serveDev.ts
CHANGED
|
@@ -28,12 +28,13 @@ import {
|
|
|
28
28
|
} from '#compiler/compileApp.ts'
|
|
29
29
|
import { formatDiagnosis } from '#compiler/formatDiagnosis.ts'
|
|
30
30
|
import { DEV_ADDRESS } from '#shared/ADDRESSES.ts'
|
|
31
|
-
import { placeOf } from '#shared/routePattern.ts'
|
|
32
31
|
import type { Compilation } from './abideModules.ts'
|
|
33
32
|
import { DEFAULT_PORT } from './DEFAULT_PORT.ts'
|
|
34
|
-
import type {
|
|
33
|
+
import type { FromWorker, ToWorker } from './devWorker.ts'
|
|
35
34
|
import { upgraded } from './muxUpgrade.ts'
|
|
35
|
+
import { placeOf } from './placement.ts'
|
|
36
36
|
import { plainAnswer } from './plainAnswer.ts'
|
|
37
|
+
import { askOver } from './responseRelay.ts'
|
|
37
38
|
import { SETTLE_MILLISECONDS } from './SETTLE_MILLISECONDS.ts'
|
|
38
39
|
|
|
39
40
|
/** What `abide dev` serves, and where. */
|
|
@@ -46,20 +47,6 @@ export type DevOptions = {
|
|
|
46
47
|
port?: number | undefined
|
|
47
48
|
}
|
|
48
49
|
|
|
49
|
-
// A request in flight: the request itself — because a worker replaced before it answered answers
|
|
50
|
-
// nothing, and the ask has to be put to the app that exists (38.24) — and, once its head has come
|
|
51
|
-
// back, the stream the body is fed into and the pull waiting on the worker's next chunk (38.35).
|
|
52
|
-
type InFlight = Omit<Ask, 'id' | 'method' | 'url'> & {
|
|
53
|
-
// 16.22 — the request bun handed the listener, which is the one its idle timeout is raised on.
|
|
54
|
-
request: Request
|
|
55
|
-
settle: (response: Response) => void
|
|
56
|
-
stream: ReadableStreamDefaultController<Uint8Array> | null
|
|
57
|
-
pulled: (() => void) | null
|
|
58
|
-
// A failure the worker sent behind a chunk still queued, raised on the pull after it — raising it
|
|
59
|
-
// at once would discard the chunk, which is the markup saying what broke.
|
|
60
|
-
failed: string | null
|
|
61
|
-
}
|
|
62
|
-
|
|
63
50
|
// The app's process, spoken to in its own protocol.
|
|
64
51
|
type DevWorker = {
|
|
65
52
|
postMessage(message: ToWorker): void
|
|
@@ -75,9 +62,6 @@ type Compiled = {
|
|
|
75
62
|
|
|
76
63
|
const LAST_DEV_PORT = 3099
|
|
77
64
|
|
|
78
|
-
// 37.3 — the answers the worker said `admitsMux`, which the listener upgrades on.
|
|
79
|
-
const ADMITTED = new WeakSet<Response>()
|
|
80
|
-
|
|
81
65
|
// An app-mux socket's data: the id the worker knows its connection by, and what `Mux.connect` reads
|
|
82
66
|
// off the upgrade. `undefined` on the reload client's.
|
|
83
67
|
type Carried =
|
|
@@ -92,7 +76,11 @@ type Carried =
|
|
|
92
76
|
export async function serveDev(
|
|
93
77
|
options: DevOptions,
|
|
94
78
|
): Promise<Bun.Server<undefined>> {
|
|
95
|
-
|
|
79
|
+
// 38.24, 38.35 — every request, asked of the worker there is and streamed back a chunk per read.
|
|
80
|
+
// 16.22 — a call's idle timeout raised on the request bun handed this listener.
|
|
81
|
+
const relay = askOver((request, seconds) =>
|
|
82
|
+
server.timeout(request, seconds),
|
|
83
|
+
)
|
|
96
84
|
// Every socket this listener holds: the reload client's, with no data, and the app mux's, carrying
|
|
97
85
|
// the connection id the worker knows it by.
|
|
98
86
|
const sockets = new Set<Bun.ServerWebSocket<Carried>>()
|
|
@@ -100,12 +88,6 @@ export async function serveDev(
|
|
|
100
88
|
let nextConnection = 0
|
|
101
89
|
let worker: DevWorker | undefined
|
|
102
90
|
let ready = false
|
|
103
|
-
let nextId = 0
|
|
104
|
-
// ASKED BEFORE THERE IS ANYONE TO ASK. A replacement terminates the worker and the next one
|
|
105
|
-
// does not exist until its compile finishes, so a request arriving in that window has
|
|
106
|
-
// nowhere to go — posted to the terminated worker it is never answered, and the reader's
|
|
107
|
-
// tab hangs until they give up. It waits here instead.
|
|
108
|
-
const queued: InFlight[] = []
|
|
109
91
|
|
|
110
92
|
// ONE COMPILE AT A TIME, and the worker is started from the LAST one. A save landing while a
|
|
111
93
|
// compile runs marks it stale rather than starting a second: two in flight each started a
|
|
@@ -222,11 +204,7 @@ export async function serveDev(
|
|
|
222
204
|
if (message.kind === 'ready') ready = true
|
|
223
205
|
else if (message.kind === 'socket-send')
|
|
224
206
|
muxed.get(message.connection)?.send(message.text)
|
|
225
|
-
else
|
|
226
|
-
const asked = inFlight.get(message.id)?.request
|
|
227
|
-
if (asked !== undefined)
|
|
228
|
-
server.timeout(asked, message.seconds)
|
|
229
|
-
} else answer(started, message)
|
|
207
|
+
else relay.take(message)
|
|
230
208
|
},
|
|
231
209
|
// 38.41 — AN APP THAT EXITS ON ITS OWN is replaced, and says so: a listener still
|
|
232
210
|
// forwarding to it leaves every tab hanging.
|
|
@@ -235,7 +213,8 @@ export async function serveDev(
|
|
|
235
213
|
console.error(
|
|
236
214
|
'abide dev: the app exited, so it was restarted',
|
|
237
215
|
)
|
|
238
|
-
|
|
216
|
+
// 38.41 — the request in hand may be what ended it, so none is asked again.
|
|
217
|
+
relay.endAll('abide dev: the app exited', false)
|
|
239
218
|
replace()
|
|
240
219
|
},
|
|
241
220
|
},
|
|
@@ -261,25 +240,9 @@ export async function serveDev(
|
|
|
261
240
|
published,
|
|
262
241
|
failure,
|
|
263
242
|
})
|
|
264
|
-
// The worker holds every request behind its own compile, so what waited
|
|
265
|
-
// to wait for
|
|
266
|
-
|
|
267
|
-
for (const again of waiting) forward(again)
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
// WHAT AN APP THAT IS GONE WAS HOLDING. A request whose head has not gone out is re-asked of the
|
|
271
|
-
// replacement (38.24), or answered where the app ended itself — the request in hand may be what
|
|
272
|
-
// ended it (38.41). 38.37 — one whose head has gone out cannot be answered again, and ends as
|
|
273
|
-
// `abide start`'s does.
|
|
274
|
-
function endInFlight(reask: boolean, why: string): void {
|
|
275
|
-
for (const held of inFlight.values()) {
|
|
276
|
-
if (held.stream !== null) {
|
|
277
|
-
held.stream.error(new Error(why))
|
|
278
|
-
held.pulled?.()
|
|
279
|
-
} else if (reask) queued.push(held)
|
|
280
|
-
else held.settle(plainAnswer(why, 500))
|
|
281
|
-
}
|
|
282
|
-
inFlight.clear()
|
|
243
|
+
// The worker holds every request behind its own compile, so what waited for one only had
|
|
244
|
+
// to wait for it to EXIST.
|
|
245
|
+
relay.replace(started)
|
|
283
246
|
}
|
|
284
247
|
|
|
285
248
|
// 38.15 — THE REPLACEMENT. The old worker is terminated, every socket it could still be
|
|
@@ -293,10 +256,11 @@ export async function serveDev(
|
|
|
293
256
|
ready = false
|
|
294
257
|
worker?.terminate()
|
|
295
258
|
worker = undefined
|
|
259
|
+
relay.replace(undefined)
|
|
296
260
|
for (const socket of sockets) socket.terminate()
|
|
297
261
|
sockets.clear()
|
|
298
262
|
muxed.clear()
|
|
299
|
-
|
|
263
|
+
relay.endAll('abide dev replaced the app mid-response', true)
|
|
300
264
|
if (compiling !== undefined) stale = true
|
|
301
265
|
else compiling = reopen()
|
|
302
266
|
}
|
|
@@ -315,127 +279,6 @@ export async function serveDev(
|
|
|
315
279
|
moved(filename === null ? undefined : `${options.root}/${filename}`),
|
|
316
280
|
)
|
|
317
281
|
|
|
318
|
-
// A REQUEST AS A MESSAGE. `Request` does not survive a structured clone, so what crosses is
|
|
319
|
-
// the four things the worker rebuilds one from — and the request's body is collected here, which
|
|
320
|
-
// is what lets it be asked again of a replacement (38.24). The answer's body streams (38.35).
|
|
321
|
-
//
|
|
322
|
-
// COLLECTED ONCE, HERE, rather than inside `forward`: a body can only be read off a
|
|
323
|
-
// `Request` once, and a re-asked request is the second read that is not there.
|
|
324
|
-
async function ask(request: Request): Promise<Response> {
|
|
325
|
-
const body = request.body === null ? null : await request.arrayBuffer()
|
|
326
|
-
return await new Promise<Response>((resolve) => {
|
|
327
|
-
forward({
|
|
328
|
-
request,
|
|
329
|
-
headers: identityOnly(request.headers),
|
|
330
|
-
body,
|
|
331
|
-
settle: resolve,
|
|
332
|
-
stream: null,
|
|
333
|
-
pulled: null,
|
|
334
|
-
failed: null,
|
|
335
|
-
})
|
|
336
|
-
})
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
// 38.38 — ASKED WITHOUT `accept-encoding`, so the app answers uncompressed. A failure after the
|
|
340
|
-
// head is shown as markup appended to the stream (`pull` in the worker), and markup appended to a
|
|
341
|
-
// compressed stream is a stream no browser can decode. Localhost buys nothing from the bytes saved.
|
|
342
|
-
function identityOnly(headers: Headers): [string, string][] {
|
|
343
|
-
const kept: [string, string][] = []
|
|
344
|
-
for (const [name, value] of headers)
|
|
345
|
-
if (name !== 'accept-encoding') kept.push([name, value])
|
|
346
|
-
return kept
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
// 38.35, 38.36 — THE HEAD SETTLES THE RESPONSE, and its body pulls one chunk from the worker per
|
|
350
|
-
// read the connection makes: a high-water mark of 0 asks for nothing ahead of the reader.
|
|
351
|
-
function answer(
|
|
352
|
-
from: DevWorker,
|
|
353
|
-
message: Exclude<
|
|
354
|
-
FromWorker,
|
|
355
|
-
{ kind: 'ready' | 'socket-send' | 'timeout' }
|
|
356
|
-
>,
|
|
357
|
-
): void {
|
|
358
|
-
const waiting = inFlight.get(message.id)
|
|
359
|
-
if (waiting === undefined) return
|
|
360
|
-
const { id } = message
|
|
361
|
-
if (message.kind === 'head') {
|
|
362
|
-
if (message.empty || message.whole !== undefined) {
|
|
363
|
-
inFlight.delete(id)
|
|
364
|
-
const answered = new Response(message.whole ?? null, {
|
|
365
|
-
status: message.status,
|
|
366
|
-
statusText: message.statusText,
|
|
367
|
-
headers: message.headers,
|
|
368
|
-
})
|
|
369
|
-
// The admission has no body, so an admitted answer is always settled here.
|
|
370
|
-
if (message.admitted) ADMITTED.add(answered)
|
|
371
|
-
waiting.settle(answered)
|
|
372
|
-
return
|
|
373
|
-
}
|
|
374
|
-
const body = new ReadableStream<Uint8Array>(
|
|
375
|
-
{
|
|
376
|
-
start(controller) {
|
|
377
|
-
waiting.stream = controller
|
|
378
|
-
},
|
|
379
|
-
pull(controller) {
|
|
380
|
-
if (waiting.failed !== null) {
|
|
381
|
-
inFlight.delete(id)
|
|
382
|
-
controller.error(new Error(waiting.failed))
|
|
383
|
-
return
|
|
384
|
-
}
|
|
385
|
-
return new Promise<void>((done) => {
|
|
386
|
-
waiting.pulled = done
|
|
387
|
-
from.postMessage({ kind: 'pull', id })
|
|
388
|
-
})
|
|
389
|
-
},
|
|
390
|
-
cancel() {
|
|
391
|
-
inFlight.delete(id)
|
|
392
|
-
from.postMessage({ kind: 'cancel', id })
|
|
393
|
-
},
|
|
394
|
-
},
|
|
395
|
-
{ highWaterMark: 0 },
|
|
396
|
-
)
|
|
397
|
-
waiting.settle(
|
|
398
|
-
new Response(body, {
|
|
399
|
-
status: message.status,
|
|
400
|
-
statusText: message.statusText,
|
|
401
|
-
headers: message.headers,
|
|
402
|
-
}),
|
|
403
|
-
)
|
|
404
|
-
return
|
|
405
|
-
}
|
|
406
|
-
const stream = waiting.stream
|
|
407
|
-
const pulled = waiting.pulled
|
|
408
|
-
waiting.pulled = null
|
|
409
|
-
if (stream === null) return
|
|
410
|
-
if (message.kind === 'chunk')
|
|
411
|
-
stream.enqueue(new Uint8Array(message.bytes))
|
|
412
|
-
else if (message.kind === 'end') {
|
|
413
|
-
inFlight.delete(id)
|
|
414
|
-
stream.close()
|
|
415
|
-
} else waiting.failed = message.message
|
|
416
|
-
pulled?.()
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
// NOT TRANSFERRED. Handing the buffer over detaches it here, and a request re-asked of the
|
|
420
|
-
// replacement worker would post a body that is no longer there.
|
|
421
|
-
function forward(waiting: InFlight): void {
|
|
422
|
-
if (worker === undefined) {
|
|
423
|
-
queued.push(waiting)
|
|
424
|
-
return
|
|
425
|
-
}
|
|
426
|
-
nextId += 1
|
|
427
|
-
const id = nextId
|
|
428
|
-
inFlight.set(id, waiting)
|
|
429
|
-
worker.postMessage({
|
|
430
|
-
kind: 'request',
|
|
431
|
-
id,
|
|
432
|
-
method: waiting.request.method,
|
|
433
|
-
url: waiting.request.url,
|
|
434
|
-
headers: waiting.headers,
|
|
435
|
-
body: waiting.body,
|
|
436
|
-
})
|
|
437
|
-
}
|
|
438
|
-
|
|
439
282
|
// THE CHECKER AND THE WORKER GO WITH THE COMMAND. Each is a process of its own, and an app with
|
|
440
283
|
// no tsconfig holds a temporary project on disk for as long as the checker is open. The worker
|
|
441
284
|
// is told directly rather than left to its `disconnect`, which a worker busy inside a build
|
|
@@ -461,11 +304,11 @@ export async function serveDev(
|
|
|
461
304
|
const mux =
|
|
462
305
|
asked !== DEV_ADDRESS &&
|
|
463
306
|
request.headers.get('upgrade') === 'websocket'
|
|
464
|
-
if (!mux && asked !== DEV_ADDRESS) return ask(request)
|
|
307
|
+
if (!mux && asked !== DEV_ADDRESS) return relay.ask(request)
|
|
465
308
|
// 37.3 — THE MUX'S UPGRADE IS ASKED OF THE APP FIRST, whose lane runs in the worker.
|
|
466
309
|
if (mux && ready) {
|
|
467
|
-
const answered = await ask(request)
|
|
468
|
-
if (!
|
|
310
|
+
const answered = await relay.ask(request)
|
|
311
|
+
if (!relay.admitted(answered)) return answered
|
|
469
312
|
}
|
|
470
313
|
// NOT WHILE THE APP IS COMPILING. The reload client and the browser's mux both read a
|
|
471
314
|
// refusal as "still rebuilding" and retry, which is what keeps a reload from racing
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// 16.32, 18.20 — AN EVENT STREAM, WRITTEN: the one writer of `text/event-stream` behind a streaming
|
|
2
|
+
// handler's sse body and a followed `GET` answered as events. `framing.ts`'s `frameReader` is the
|
|
3
|
+
// one reader.
|
|
4
|
+
|
|
5
|
+
import type { Problem } from '#shared/Failed.ts'
|
|
6
|
+
import type { Framed } from '#shared/framing.ts'
|
|
7
|
+
import { REFUSAL_KEY } from '#shared/REFUSAL_KEY.ts'
|
|
8
|
+
|
|
9
|
+
/** AN EVENT STREAM OPENS ON A COMMENT, so a proxy has bytes to pass before the first event. */
|
|
10
|
+
export const EVENT_STREAM_OPENER = ':\n\n'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* One event: its `event` name and `id` where it has them, and `data` — one line, which JSON is as
|
|
14
|
+
* `JSON.stringify` writes it.
|
|
15
|
+
*/
|
|
16
|
+
export function streamEvent(
|
|
17
|
+
name: string | undefined,
|
|
18
|
+
id: number | undefined,
|
|
19
|
+
data: string,
|
|
20
|
+
): string {
|
|
21
|
+
return `${name === undefined ? '' : `event: ${name}\n`}${id === undefined ? '' : `id: ${id}\n`}data: ${data}\n\n`
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A framed stream's last frame, as the server writes it for a refusal (16.68). */
|
|
25
|
+
export function refusalFrame(problem: Problem, framing: Framed): string {
|
|
26
|
+
return framing === 'jsonl'
|
|
27
|
+
? `${JSON.stringify({ [REFUSAL_KEY]: problem })}\n`
|
|
28
|
+
: streamEvent('refusal', undefined, JSON.stringify(problem))
|
|
29
|
+
}
|
package/src/server/versionOf.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
// 18.7 — THE VERSION A VALUE'S STANDING PRODUCTION IS NAMED BY OVER THE WIRE. Stamped where it is
|
|
2
2
|
// asked for rather than where a production is minted, so a value no browser follows pays nothing on
|
|
3
3
|
// its production path. A production is told apart from the one before by the ordinal, what is held
|
|
4
|
-
//
|
|
5
|
-
// for a patch).
|
|
4
|
+
// and the patch epoch of what is held (5.7 moves no ordinal for a patch).
|
|
6
5
|
//
|
|
7
6
|
// ONE COUNTER FOR THE PROCESS, started at the boot time in microseconds, so no two productions share
|
|
8
7
|
// a number: not two values, not an entry evicted and built again, and not this process and the one
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { relative, resolve } from 'node:path' // bun exposes no path helpers of its own
|
|
2
|
-
import {
|
|
2
|
+
import type { Compilation } from './abideModules.ts'
|
|
3
|
+
import { COMPONENT_NAMESPACE } from './faceOf.ts'
|
|
3
4
|
import { COMPONENT_QUERY } from './MODULE_QUERIES.ts'
|
|
4
5
|
import {
|
|
5
6
|
decodeMappings,
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// 16.9, 16.66 — A HANDLER'S ROUTE, COMPILED ONCE: what takes a call's route parameters out of its
|
|
2
|
+
// args and into the path, keeping the rest for the query or the body. Built once per address by
|
|
3
|
+
// what holds the handler — an `Rpc`, a browser's `wire` — and read by every call through it: the
|
|
4
|
+
// request a call is (`rpcRequest`), the in-process call's early refusal, and `abide call`.
|
|
5
|
+
|
|
6
|
+
import { filledSegments } from './filledPath.ts'
|
|
7
|
+
import { validationError } from './refuse.ts'
|
|
8
|
+
import { parseRoutePattern, type Segment } from './routePattern.ts'
|
|
9
|
+
|
|
10
|
+
/** 15.8 — a parameter no address carries, refused as args a gate refused. */
|
|
11
|
+
function pathRefusal(name: string, message: string): unknown {
|
|
12
|
+
return validationError<Record<string, unknown>>({ [name]: [message] })
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** A handler's address, parsed once: its segments and the names its parameters take. */
|
|
16
|
+
export class HandlerRoute {
|
|
17
|
+
readonly address: string
|
|
18
|
+
/** The parsed route, or `undefined` where it has no parameters and the path is the address. */
|
|
19
|
+
readonly segments: Segment[] | undefined
|
|
20
|
+
readonly names: Set<string>
|
|
21
|
+
|
|
22
|
+
constructor(address: string) {
|
|
23
|
+
this.address = address
|
|
24
|
+
this.names = new Set()
|
|
25
|
+
if (!address.includes('[')) {
|
|
26
|
+
this.segments = undefined
|
|
27
|
+
return
|
|
28
|
+
}
|
|
29
|
+
this.segments = parseRoutePattern(address)
|
|
30
|
+
for (const segment of this.segments)
|
|
31
|
+
if (segment.kind !== 'static') this.names.add(segment.name)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The path with each parameter `params` names written in.
|
|
36
|
+
*
|
|
37
|
+
* 30.3, 30.19 — one no address carries is thrown: by default as args a gate refused, so every
|
|
38
|
+
* caller answers it as the wire answers a 422.
|
|
39
|
+
*/
|
|
40
|
+
path(
|
|
41
|
+
params: Record<string, unknown> | undefined,
|
|
42
|
+
refused: (name: string, message: string) => unknown = pathRefusal,
|
|
43
|
+
): string {
|
|
44
|
+
return this.segments === undefined
|
|
45
|
+
? this.address
|
|
46
|
+
: filledSegments(this.segments, params ?? {}, this.address, refused)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* `args` split: the path, and the members no parameter took — `undefined` where none is left —
|
|
51
|
+
* so the query or body never carries a parameter twice (16.66).
|
|
52
|
+
*/
|
|
53
|
+
split(args: Record<string, unknown> | undefined): {
|
|
54
|
+
path: string
|
|
55
|
+
rest: Record<string, unknown> | undefined
|
|
56
|
+
} {
|
|
57
|
+
if (this.segments === undefined)
|
|
58
|
+
return { path: this.address, rest: args }
|
|
59
|
+
const path = this.path(args)
|
|
60
|
+
let rest: Record<string, unknown> | undefined
|
|
61
|
+
for (const name in args) {
|
|
62
|
+
if (this.names.has(name)) continue
|
|
63
|
+
rest ??= {}
|
|
64
|
+
rest[name] = args[name]
|
|
65
|
+
}
|
|
66
|
+
return { path, rest }
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -22,3 +22,9 @@ export const RESERVED_DIRECTORIES: ReadonlySet<string> = new Set([
|
|
|
22
22
|
LIB_DIRECTORY,
|
|
23
23
|
TESTS_DIRECTORY,
|
|
24
24
|
])
|
|
25
|
+
|
|
26
|
+
/** 23.16 — the fences a `server` directory nests in: it fences a graph rather than an address. */
|
|
27
|
+
export const HOLDS_SERVER: ReadonlySet<string> = new Set([
|
|
28
|
+
LIB_DIRECTORY,
|
|
29
|
+
TESTS_DIRECTORY,
|
|
30
|
+
])
|
package/src/shared/answerOf.ts
CHANGED
|
@@ -1,13 +1,52 @@
|
|
|
1
1
|
// 41.21 — A CALL THE PAGE ALREADY STARTED IS TAKEN, NOT MADE AGAIN, and what a call answered is read
|
|
2
2
|
// one way whoever made it: a handler's `wire`, and a handler abide mounts itself (`seededDocument`).
|
|
3
|
+
//
|
|
4
|
+
// ONE CLASSIFIER. The early-call script (41.20) records the `Response` and nothing it decides, so
|
|
5
|
+
// an answer the page started and one fetched here are read by `answerFrom` alike.
|
|
3
6
|
|
|
4
7
|
import { failedFrom, isProblem } from './Failed.ts'
|
|
5
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
carriesBytes,
|
|
10
|
+
type Framed,
|
|
11
|
+
framedChunks,
|
|
12
|
+
framingOf,
|
|
13
|
+
} from './framing.ts'
|
|
6
14
|
import { principalRotated } from './principalRotated.ts'
|
|
7
15
|
import { refuse } from './refuse.ts'
|
|
8
16
|
import type { RpcRequest } from './rpcRequest.ts'
|
|
9
|
-
import {
|
|
17
|
+
import { takeEarlyCall } from './takeEarlyCall.ts'
|
|
10
18
|
import { VERSION_HEADER } from './VERSION_HEADER.ts'
|
|
19
|
+
import { versionInEtag } from './versionInEtag.ts'
|
|
20
|
+
|
|
21
|
+
/** What a call answered, classified once. */
|
|
22
|
+
export type Answer = {
|
|
23
|
+
status: number
|
|
24
|
+
statusText: string
|
|
25
|
+
/** A 2xx. */
|
|
26
|
+
ok: boolean
|
|
27
|
+
/** What precedes each chunk's JSON on its line (16.32), or `null` where the answer is not framed. */
|
|
28
|
+
framing: Framed | null
|
|
29
|
+
/**
|
|
30
|
+
* Whether a subscriber may follow it (18.24): a 2xx that is not a binary, whose bytes the mux's
|
|
31
|
+
* JSON frames cannot carry.
|
|
32
|
+
*/
|
|
33
|
+
followable: boolean
|
|
34
|
+
/** The version its `ETag` named (18.5). */
|
|
35
|
+
version: number | undefined
|
|
36
|
+
/** The `max-age` its `cache-control` named, in ms (18.14, 21.8). */
|
|
37
|
+
life: number | undefined
|
|
38
|
+
text: string | undefined
|
|
39
|
+
/**
|
|
40
|
+
* A binary answer's body read to the end (16.34), set in place of `text` — a body read as text
|
|
41
|
+
* is not the bytes it carried.
|
|
42
|
+
*/
|
|
43
|
+
bytes: Uint8Array | undefined
|
|
44
|
+
/**
|
|
45
|
+
* A streamed answer's body, left unread so its chunks are taken as they land (16.32): framed,
|
|
46
|
+
* or a binary's where `framing` is `null`.
|
|
47
|
+
*/
|
|
48
|
+
body: ReadableStream<Uint8Array> | null | undefined
|
|
49
|
+
}
|
|
11
50
|
|
|
12
51
|
/**
|
|
13
52
|
* A call's answer: the early call's where the page started one, fetched where it did not.
|
|
@@ -26,87 +65,77 @@ export function answerOf(
|
|
|
26
65
|
): Answer | Promise<Answer> {
|
|
27
66
|
const early = takeEarlyCall(request.key)
|
|
28
67
|
if (early === undefined) return fetched(request, streams, signal)
|
|
29
|
-
if (early.painted === 1)
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
// The early call failed as a request — or read a binary as text, which lost its bytes — so
|
|
37
|
-
// this one is made again.
|
|
38
|
-
if (early.body === undefined || early.body === null)
|
|
39
|
-
return fetched(request, streams, signal)
|
|
40
|
-
if (streams) return early
|
|
41
|
-
const read = new Response(early.body)
|
|
42
|
-
return carriesBytes(early.type) && succeeded(early.status)
|
|
43
|
-
? read
|
|
44
|
-
.bytes()
|
|
45
|
-
.then((bytes) => ({ ...early, bytes, body: undefined }))
|
|
46
|
-
: read.text().then((text) => ({ ...early, text, body: undefined }))
|
|
47
|
-
})
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* An early call's answer where it has landed read: as text, or — for one the server painted, whose
|
|
52
|
-
* body the runner reads to the end so hydration waits on it — as bytes, decoded where the answer is
|
|
53
|
-
* not a binary.
|
|
54
|
-
*/
|
|
55
|
-
function landedAnswer(early: Answer): Answer | undefined {
|
|
56
|
-
if (readAsText(early)) return early
|
|
57
|
-
if (early.bytes === undefined) return undefined
|
|
58
|
-
return carriesBytes(early.type) && succeeded(early.status)
|
|
59
|
-
? early
|
|
60
|
-
: {
|
|
61
|
-
...early,
|
|
62
|
-
text: new TextDecoder().decode(early.bytes),
|
|
63
|
-
bytes: undefined,
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/** Whether an early call's answer arrived as text that is what it carried — no binary read as one. */
|
|
68
|
-
function readAsText(early: Answer): boolean {
|
|
69
|
-
return (
|
|
70
|
-
early.text !== undefined &&
|
|
71
|
-
!(carriesBytes(early.type) && succeeded(early.status))
|
|
68
|
+
if (early.painted === 1 && early.response !== undefined)
|
|
69
|
+
return answerFrom(early.response, early.bytes, streams)
|
|
70
|
+
// An early call whose request failed is made again.
|
|
71
|
+
return early.promise.then(() =>
|
|
72
|
+
early.response === undefined
|
|
73
|
+
? fetched(request, streams, signal)
|
|
74
|
+
: answerFrom(early.response, early.bytes, streams),
|
|
72
75
|
)
|
|
73
76
|
}
|
|
74
77
|
|
|
75
|
-
|
|
76
|
-
return status !== undefined && status >= 200 && status < 300
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Fetches a call's answer; a framed answer to a streaming copy keeps its body unread (16.32).
|
|
81
|
-
*
|
|
82
|
-
* Anything else is read to its end: as bytes where it is a binary (16.34), as text otherwise.
|
|
83
|
-
*/
|
|
78
|
+
/** Fetches a call's answer. */
|
|
84
79
|
async function fetched(
|
|
85
80
|
request: RpcRequest,
|
|
86
81
|
streams: boolean,
|
|
87
82
|
signal: AbortSignal | undefined,
|
|
88
83
|
): Promise<Answer> {
|
|
89
|
-
const
|
|
84
|
+
const response = await fetch(
|
|
90
85
|
request.url,
|
|
91
86
|
signal === undefined ? request.init : { ...request.init, signal },
|
|
92
87
|
)
|
|
93
88
|
// THE HANDLER SIGNED THIS CALLER IN OR OUT, and the cookie is already the browser's.
|
|
94
|
-
principalRotated(
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
89
|
+
principalRotated(response)
|
|
90
|
+
return answerFrom(response, undefined, streams)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* `response`, classified: a body `read` to its end already (an early call the server painted)
|
|
95
|
+
* decoded as what it carried, and one unread read as bytes where it is a binary (16.34), as text
|
|
96
|
+
* otherwise — or left unread for a `streams` caller where it is framed or a binary (16.32, 16.46).
|
|
97
|
+
*/
|
|
98
|
+
function answerFrom(
|
|
99
|
+
response: Response,
|
|
100
|
+
read: ArrayBuffer | undefined,
|
|
101
|
+
streams: boolean,
|
|
102
|
+
): Answer | Promise<Answer> {
|
|
103
|
+
const { status, statusText, headers } = response
|
|
104
|
+
const type = headers.get('content-type')
|
|
105
|
+
const ok = status >= 200 && status < 300
|
|
106
|
+
const framing = framingOf(type)
|
|
107
|
+
const binary = ok && carriesBytes(type)
|
|
108
|
+
const seconds = /max-age=(\d+)/.exec(headers.get('cache-control') ?? '')
|
|
109
|
+
const answer: Answer = {
|
|
110
|
+
status,
|
|
111
|
+
statusText,
|
|
112
|
+
ok,
|
|
113
|
+
framing,
|
|
114
|
+
followable: ok && !binary,
|
|
115
|
+
version: versionInEtag(headers.get(VERSION_HEADER)),
|
|
116
|
+
life: seconds === null ? undefined : Number(seconds[1]) * 1000,
|
|
117
|
+
text: undefined,
|
|
118
|
+
bytes: undefined,
|
|
119
|
+
body: undefined,
|
|
120
|
+
}
|
|
121
|
+
if (read !== undefined) {
|
|
122
|
+
if (binary) answer.bytes = new Uint8Array(read)
|
|
123
|
+
else answer.text = new TextDecoder().decode(read)
|
|
124
|
+
return answer
|
|
125
|
+
}
|
|
126
|
+
if (streams && ok && (binary || framing !== null)) {
|
|
127
|
+
answer.body = response.body
|
|
128
|
+
return answer
|
|
109
129
|
}
|
|
130
|
+
return binary
|
|
131
|
+
? response.arrayBuffer().then((buffer) => {
|
|
132
|
+
answer.bytes = new Uint8Array(buffer)
|
|
133
|
+
return answer
|
|
134
|
+
})
|
|
135
|
+
: response.text().then((text) => {
|
|
136
|
+
answer.text = text
|
|
137
|
+
return answer
|
|
138
|
+
})
|
|
110
139
|
}
|
|
111
140
|
|
|
112
141
|
/**
|
|
@@ -120,14 +149,12 @@ async function fetched(
|
|
|
120
149
|
* value is one production. A marked one reads its body as it lands (`framedStream`).
|
|
121
150
|
*/
|
|
122
151
|
export function answered(answer: Answer): unknown {
|
|
123
|
-
|
|
124
|
-
if (
|
|
125
|
-
const ok = succeeded(status)
|
|
126
|
-
if (ok) {
|
|
152
|
+
if (answer.status === 204) return undefined
|
|
153
|
+
if (answer.ok) {
|
|
127
154
|
// 16.34 — a binary decodes back to the bytes it was.
|
|
128
155
|
if (answer.bytes !== undefined) return answer.bytes
|
|
129
|
-
|
|
130
|
-
|
|
156
|
+
if (answer.framing !== null)
|
|
157
|
+
return framedChunks(answer.text ?? '', answer.framing)
|
|
131
158
|
}
|
|
132
159
|
let body: unknown
|
|
133
160
|
try {
|
|
@@ -135,7 +162,7 @@ export function answered(answer: Answer): unknown {
|
|
|
135
162
|
} catch {
|
|
136
163
|
body = undefined
|
|
137
164
|
}
|
|
138
|
-
if (ok) return body
|
|
165
|
+
if (answer.ok) return body
|
|
139
166
|
if (isProblem(body)) return failedFrom(body)
|
|
140
|
-
return refuse(status, answer.statusText || undefined)
|
|
167
|
+
return refuse(answer.status, answer.statusText || undefined)
|
|
141
168
|
}
|
package/src/shared/filledPath.ts
CHANGED
|
@@ -18,7 +18,7 @@ export function filledPath(
|
|
|
18
18
|
return filledSegments(parseRoutePattern(pattern), params, spelled, refused)
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
/** `filledPath` over a route already parsed
|
|
21
|
+
/** `filledPath` over a route already parsed: a handler's, compiled once (`HandlerRoute`). */
|
|
22
22
|
export function filledSegments(
|
|
23
23
|
segments: Segment[],
|
|
24
24
|
params: Record<string, unknown>,
|
|
@@ -50,14 +50,6 @@ export function filledSegments(
|
|
|
50
50
|
return address === '' ? '/' : address
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
/** The names of `pattern`'s parameters — what a call takes out of its args and into the path (16.66). */
|
|
54
|
-
export function parametersOf(pattern: string): Set<string> {
|
|
55
|
-
const names = new Set<string>()
|
|
56
|
-
for (const segment of parseRoutePattern(pattern))
|
|
57
|
-
if (segment.kind !== 'static') names.add(segment.name)
|
|
58
|
-
return names
|
|
59
|
-
}
|
|
60
|
-
|
|
61
53
|
/**
|
|
62
54
|
* A param value encoded as one path segment; `.` and `..` are refused.
|
|
63
55
|
*
|