@abide/abide 0.110.4 → 0.112.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 +23 -0
- package/docs/REGISTRY.md +30 -29
- package/docs/RULEBOOK.md +98 -22
- package/docs/abide.md +46 -32
- package/package.json +1 -1
- package/src/compiler/Lowering.ts +5 -0
- package/src/compiler/classifyTemplate.ts +129 -34
- package/src/compiler/compileApp.ts +2 -1
- package/src/compiler/emitClient.ts +128 -14
- package/src/compiler/renderMarkup.ts +14 -8
- package/src/server/BRIDGE_VERSION.ts +1 -1
- package/src/server/HEARTBEAT_MILLISECONDS.ts +2 -0
- package/src/server/IDLE_TIMEOUT_SECONDS.ts +6 -0
- package/src/server/answerRequest.ts +44 -3
- package/src/server/answerRpc.ts +14 -41
- package/src/server/answerSocket.ts +2 -2
- package/src/server/answerValue.ts +15 -3
- package/src/server/answerWorker.ts +195 -0
- package/src/server/appManifest.ts +48 -0
- package/src/server/assistant.ts +1 -1
- package/src/server/bridgeRelay.ts +4 -50
- package/src/server/claudeApi.ts +1 -1
- package/src/server/claudeCode.ts +3 -3
- package/src/server/clientBundle.ts +7 -0
- package/src/server/config.ts +14 -0
- package/src/server/createApp.ts +28 -4
- package/src/server/devWorker.ts +4 -1
- package/src/server/render.ts +2 -2
- package/src/server/requestScope.ts +31 -11
- package/src/server/responseRelay.ts +7 -7
- package/src/server/serveBuild.ts +2 -0
- package/src/server/serveDev.ts +4 -1
- package/src/server/streamEvent.ts +60 -1
- package/src/shared/ADDRESSES.ts +11 -0
- package/src/shared/ASCII_WHITESPACE.ts +6 -0
- package/src/shared/LONGEST_DELAY.ts +2 -0
- package/src/shared/OFFLINE_CACHES.ts +12 -0
- package/src/shared/OFFLINE_META.ts +7 -0
- package/src/shared/answerOf.ts +29 -4
- package/src/shared/attributeText.ts +1 -1
- package/src/shared/reactive/Expiry.ts +13 -1
- package/src/shared/reactive/Reactive.ts +13 -3
- package/src/shared/safeUrl.ts +8 -1
- package/src/ui/Level.ts +43 -95
- package/src/ui/Router.ts +37 -13
- package/src/ui/bindGroup.ts +41 -0
- package/src/ui/bindProperty.ts +1 -21
- package/src/ui/index.ts +5 -3
- package/src/ui/reconcileHead.ts +215 -0
- package/src/ui/registerWorker.ts +34 -0
- package/src/ui/routing.ts +1 -0
- package/src/ui/setClassGroup.ts +79 -0
- package/src/ui/setStyleGroup.ts +130 -0
- package/src/ui/socketWire.ts +33 -2
- package/src/ui/applyHead.ts +0 -214
|
@@ -25,7 +25,7 @@ export type ToRelay =
|
|
|
25
25
|
| { kind: 'pull'; id: number }
|
|
26
26
|
| { kind: 'cancel'; id: number }
|
|
27
27
|
|
|
28
|
-
/** What the worker's half sends: a request's idle timeout
|
|
28
|
+
/** What the worker's half sends: a request's idle timeout set, and its head, each chunk, and its end. */
|
|
29
29
|
export type FromRelay =
|
|
30
30
|
| { kind: 'timeout'; id: number; seconds: number }
|
|
31
31
|
| {
|
|
@@ -56,8 +56,8 @@ export function describe(thrown: unknown): string {
|
|
|
56
56
|
|
|
57
57
|
/**
|
|
58
58
|
* The worker's half: answers each request the listener sends with `fetch`, and its body a chunk per
|
|
59
|
-
* pull. `take` is handed every `ToRelay` message; `timeout`
|
|
60
|
-
* listener holds its connection (16.
|
|
59
|
+
* pull. `take` is handed every `ToRelay` message; `timeout` sets a request's idle timeout where the
|
|
60
|
+
* listener holds its connection (16.23); `hangUpAll` aborts every request still being answered.
|
|
61
61
|
*/
|
|
62
62
|
export function serveOver(
|
|
63
63
|
channel: Channel<FromRelay>,
|
|
@@ -80,7 +80,7 @@ export function serveOver(
|
|
|
80
80
|
number,
|
|
81
81
|
{ reader: ReadableStreamDefaultReader<Uint8Array>; html: boolean }
|
|
82
82
|
>()
|
|
83
|
-
// 16.
|
|
83
|
+
// 16.23 — each request built here, known by the ask it was built from.
|
|
84
84
|
const asks = new WeakMap<Request, number>()
|
|
85
85
|
|
|
86
86
|
const ended = (id: number): void => {
|
|
@@ -203,7 +203,7 @@ export function serveOver(
|
|
|
203
203
|
// and, once its head has come back, the stream the body is fed into and the pull waiting on the
|
|
204
204
|
// worker's next chunk (38.35).
|
|
205
205
|
type InFlight = {
|
|
206
|
-
// 16.
|
|
206
|
+
// 16.23 — the request bun handed the listener, which is the one its idle timeout is set on.
|
|
207
207
|
request: Request
|
|
208
208
|
headers: [string, string][]
|
|
209
209
|
body: ArrayBuffer | null
|
|
@@ -223,7 +223,7 @@ type InFlight = {
|
|
|
223
223
|
* compiles; `endAll` ends what the worker that is gone was answering.
|
|
224
224
|
*/
|
|
225
225
|
export function askOver(
|
|
226
|
-
|
|
226
|
+
setIdleTimeout: (request: Request, seconds: number) => void,
|
|
227
227
|
) {
|
|
228
228
|
let channel: Channel<ToRelay> | undefined
|
|
229
229
|
const inFlight = new Map<number, InFlight>()
|
|
@@ -334,7 +334,7 @@ export function askOver(
|
|
|
334
334
|
const waiting = inFlight.get(message.id)
|
|
335
335
|
if (waiting === undefined) return
|
|
336
336
|
if (message.kind === 'timeout') {
|
|
337
|
-
|
|
337
|
+
setIdleTimeout(waiting.request, message.seconds)
|
|
338
338
|
return
|
|
339
339
|
}
|
|
340
340
|
if (message.kind === 'head') {
|
package/src/server/serveBuild.ts
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
PUBLIC_DIRECTORY,
|
|
25
25
|
} from './CLIENT_BUILD.ts'
|
|
26
26
|
import { bundleServer, type ServedFile } from './clientBundle.ts'
|
|
27
|
+
import { settingOn } from './config.ts'
|
|
27
28
|
import type { Manifest } from './manifestOf.ts'
|
|
28
29
|
import { namesUnder } from './namesUnder.ts'
|
|
29
30
|
import { ENCODINGS } from './negotiateEncoding.ts'
|
|
@@ -137,6 +138,7 @@ export function builtSources(parts: BuildParts): Promise<AppSources> {
|
|
|
137
138
|
files,
|
|
138
139
|
'public, max-age=31536000, immutable',
|
|
139
140
|
),
|
|
141
|
+
offline: settingOn('ABIDE_OFFLINE', true),
|
|
140
142
|
},
|
|
141
143
|
})
|
|
142
144
|
}
|
package/src/server/serveDev.ts
CHANGED
|
@@ -31,6 +31,7 @@ import { DEV_ADDRESS } from '#shared/ADDRESSES.ts'
|
|
|
31
31
|
import type { Compilation } from './abideModules.ts'
|
|
32
32
|
import { DEFAULT_PORT } from './DEFAULT_PORT.ts'
|
|
33
33
|
import type { FromWorker, ToWorker } from './devWorker.ts'
|
|
34
|
+
import { IDLE_TIMEOUT_SECONDS } from './IDLE_TIMEOUT_SECONDS.ts'
|
|
34
35
|
import { muxSocketOptions, sendOrClose, upgraded } from './muxUpgrade.ts'
|
|
35
36
|
import { placeOf } from './placement.ts'
|
|
36
37
|
import { plainAnswer } from './plainAnswer.ts'
|
|
@@ -77,7 +78,7 @@ export async function serveDev(
|
|
|
77
78
|
options: DevOptions,
|
|
78
79
|
): Promise<Bun.Server<undefined>> {
|
|
79
80
|
// 38.24, 38.35 — every request, asked of the worker there is and streamed back a chunk per read.
|
|
80
|
-
// 16.
|
|
81
|
+
// 16.23 — a request's idle timeout set on the request bun handed this listener.
|
|
81
82
|
const relay = askOver((request, seconds) =>
|
|
82
83
|
served?.timeout(request, seconds),
|
|
83
84
|
)
|
|
@@ -302,6 +303,8 @@ export async function serveDev(
|
|
|
302
303
|
...(port === undefined ? {} : { port }),
|
|
303
304
|
// 16.54, as `App.listen` lifts it: the worker behind this is where the ceiling is read.
|
|
304
305
|
maxRequestBodySize: Number.MAX_SAFE_INTEGER,
|
|
306
|
+
// 16.56 — the bound a request received whole lifts and its answer puts back (16.23).
|
|
307
|
+
idleTimeout: IDLE_TIMEOUT_SECONDS,
|
|
305
308
|
async fetch(request, listening) {
|
|
306
309
|
const asked = new URL(request.url).pathname
|
|
307
310
|
// THE MUX STANDS UNDER THE MOUNT, which is the app's configuration and so the worker's to
|
|
@@ -5,9 +5,68 @@
|
|
|
5
5
|
import type { Problem } from '#shared/Failed.ts'
|
|
6
6
|
import type { Framed } from '#shared/framing.ts'
|
|
7
7
|
import { REFUSAL_KEY } from '#shared/REFUSAL_KEY.ts'
|
|
8
|
+
import { HEARTBEAT_MILLISECONDS } from './HEARTBEAT_MILLISECONDS.ts'
|
|
8
9
|
|
|
9
10
|
/** 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
|
+
export const EVENT_STREAM_OPENER = new TextEncoder().encode(':\n\n')
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* `body`, read one chunk a pull; `release` runs once it closes, errors or its reader hangs up.
|
|
15
|
+
*
|
|
16
|
+
* 16.57 — `beats`: a comment wherever a read has waited a heartbeat for its next chunk. A COMMENT
|
|
17
|
+
* RATHER THAN AN EVENT: every reader skips one (18.27), and it gives a proxy in front bytes to
|
|
18
|
+
* pass, so neither it nor the reader takes a quiet stream for a dead one. A bridge's reader goes by
|
|
19
|
+
* it too (44.27), where a frame would sit in the pairing's inbox through a drop.
|
|
20
|
+
*
|
|
21
|
+
* BEATS ONLY WHILE A READ WAITS, at a high-water mark of 0: a reader that stalled or never started
|
|
22
|
+
* pulls nothing, so no comment queues behind it and no timer outlives it, and `body` is pulled no
|
|
23
|
+
* further ahead than it was. A read the beat won is kept for the next pull.
|
|
24
|
+
*/
|
|
25
|
+
export function heldBody(
|
|
26
|
+
body: ReadableStream<Uint8Array>,
|
|
27
|
+
release: () => void,
|
|
28
|
+
beats: boolean,
|
|
29
|
+
): ReadableStream<Uint8Array> {
|
|
30
|
+
const reader = body.getReader()
|
|
31
|
+
let pending: ReturnType<typeof reader.read> | undefined
|
|
32
|
+
return new ReadableStream<Uint8Array>(
|
|
33
|
+
{
|
|
34
|
+
async pull(controller) {
|
|
35
|
+
pending ??= reader.read()
|
|
36
|
+
let beat: ReturnType<typeof setTimeout> | undefined
|
|
37
|
+
try {
|
|
38
|
+
const next = beats
|
|
39
|
+
? await Promise.race([
|
|
40
|
+
pending,
|
|
41
|
+
new Promise<undefined>((quiet) => {
|
|
42
|
+
beat = setTimeout(
|
|
43
|
+
() => quiet(undefined),
|
|
44
|
+
HEARTBEAT_MILLISECONDS,
|
|
45
|
+
)
|
|
46
|
+
}),
|
|
47
|
+
])
|
|
48
|
+
: await pending
|
|
49
|
+
if (next === undefined)
|
|
50
|
+
return controller.enqueue(EVENT_STREAM_OPENER)
|
|
51
|
+
pending = undefined
|
|
52
|
+
if (!next.done) return controller.enqueue(next.value)
|
|
53
|
+
controller.close()
|
|
54
|
+
release()
|
|
55
|
+
} catch (thrown) {
|
|
56
|
+
controller.error(thrown)
|
|
57
|
+
release()
|
|
58
|
+
} finally {
|
|
59
|
+
clearTimeout(beat)
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
cancel(reason) {
|
|
63
|
+
release()
|
|
64
|
+
return reader.cancel(reason)
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
{ highWaterMark: 0 },
|
|
68
|
+
)
|
|
69
|
+
}
|
|
11
70
|
|
|
12
71
|
/**
|
|
13
72
|
* One event: its `event` name and `id` where it has them, and `data` — one line, which JSON is as
|
package/src/shared/ADDRESSES.ts
CHANGED
|
@@ -59,3 +59,14 @@ export const SOCKET_ADDRESS = `${ABIDE_ADDRESS}/socket`
|
|
|
59
59
|
* 26.2.
|
|
60
60
|
*/
|
|
61
61
|
export const PRINCIPAL_ADDRESS = `${ABIDE_ADDRESS}/principal`
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Where an app with `ABIDE_OFFLINE` on serves its service worker, and where every other app serves
|
|
65
|
+
* the one that removes it.
|
|
66
|
+
*
|
|
67
|
+
* 45.1, 45.9.
|
|
68
|
+
*/
|
|
69
|
+
export const WORKER_ADDRESS = `${ABIDE_ADDRESS}/worker.js`
|
|
70
|
+
|
|
71
|
+
/** The web app manifest an app answers where it keeps its pages offline and has an icon (45.15). */
|
|
72
|
+
export const APP_MANIFEST_ADDRESS = `${ABIDE_ADDRESS}/manifest.webmanifest`
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A run of ASCII whitespace, which is what a `class` value is split into tokens on — read by the
|
|
3
|
+
* compiler finding the classes a static `class` holds and by the runtime writing a `class={expr}`'s
|
|
4
|
+
* tokens (32.71), so both split one way.
|
|
5
|
+
*/
|
|
6
|
+
export const ASCII_WHITESPACE = /[\t\n\f\r ]+/
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// 45.3, 45.5 — THE CACHES THE SERVICE WORKER KEEPS, named once for the worker the server writes and
|
|
2
|
+
// the router that asks whether a page was saved (45.12). A leaf, for the reason every constant
|
|
3
|
+
// crossing a seam is one.
|
|
4
|
+
|
|
5
|
+
/** What every cache the worker keeps is named under, so a cache an earlier worker named is found. */
|
|
6
|
+
export const OFFLINE_CACHE_PREFIX = 'abide:'
|
|
7
|
+
|
|
8
|
+
/** The pages, the handlers' answers and the public files the worker saved, by address. */
|
|
9
|
+
export const OFFLINE_SAVED = `${OFFLINE_CACHE_PREFIX}saved`
|
|
10
|
+
|
|
11
|
+
/** The build's bundle, saved as the worker installs. */
|
|
12
|
+
export const OFFLINE_BUNDLE = `${OFFLINE_CACHE_PREFIX}bundle`
|
package/src/shared/answerOf.ts
CHANGED
|
@@ -81,15 +81,40 @@ async function fetched(
|
|
|
81
81
|
streams: boolean,
|
|
82
82
|
signal: AbortSignal | undefined,
|
|
83
83
|
): Promise<Answer> {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
84
|
+
let response: Response
|
|
85
|
+
try {
|
|
86
|
+
response = await fetch(
|
|
87
|
+
request.url,
|
|
88
|
+
signal === undefined ? request.init : { ...request.init, signal },
|
|
89
|
+
)
|
|
90
|
+
} catch (error) {
|
|
91
|
+
// An abort is the load's own ending (16.49), which the value has already answered.
|
|
92
|
+
if (signal?.aborted === true) throw error
|
|
93
|
+
// 16.79 — NO ANSWER AT ALL, as a status the app can narrow on rather than the browser's
|
|
94
|
+
// own `TypeError`, whose message differs in each.
|
|
95
|
+
return unanswered()
|
|
96
|
+
}
|
|
88
97
|
// THE HANDLER SIGNED THIS CALLER IN OR OUT, and the cookie is already the browser's.
|
|
89
98
|
principalRotated(response)
|
|
90
99
|
return answerFrom(response, undefined, streams)
|
|
91
100
|
}
|
|
92
101
|
|
|
102
|
+
/** The answer a call that reached no server stands in for: an empty 503, which `answered` refuses. */
|
|
103
|
+
function unanswered(): Answer {
|
|
104
|
+
return {
|
|
105
|
+
status: 503,
|
|
106
|
+
statusText: '',
|
|
107
|
+
ok: false,
|
|
108
|
+
framing: null,
|
|
109
|
+
followable: false,
|
|
110
|
+
version: undefined,
|
|
111
|
+
life: undefined,
|
|
112
|
+
text: '',
|
|
113
|
+
bytes: undefined,
|
|
114
|
+
body: undefined,
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
93
118
|
/**
|
|
94
119
|
* `response`, classified: a body `read` to its end already (an early call the server painted)
|
|
95
120
|
* decoded as what it carried, and one unread read as bytes where it is a binary (16.34), as text
|
|
@@ -31,7 +31,7 @@ export function attributeText(name: string, value: unknown): string | null {
|
|
|
31
31
|
const safe = safeUrl(text)
|
|
32
32
|
if (safe !== text)
|
|
33
33
|
RENDER_LOG.warning(
|
|
34
|
-
`abide: \`${name}\` was handed a URL whose scheme is not http:, https:, mailto: or
|
|
34
|
+
`abide: \`${name}\` was handed a URL whose scheme is not http:, https:, mailto:, tel:, blob: or data:, and is written empty. RULEBOOK 32.20.`,
|
|
35
35
|
)
|
|
36
36
|
return safe
|
|
37
37
|
}
|
|
@@ -38,6 +38,8 @@
|
|
|
38
38
|
// nothing is dropped early, and the read-driven backstop in `Reactive.ts` catches up the
|
|
39
39
|
// moment the clock does.
|
|
40
40
|
|
|
41
|
+
import { LONGEST_DELAY } from '../LONGEST_DELAY.ts'
|
|
42
|
+
|
|
41
43
|
export type Expiry = {
|
|
42
44
|
/** The life of one production, in milliseconds. */
|
|
43
45
|
ms: number
|
|
@@ -90,13 +92,23 @@ export function expiryFor(
|
|
|
90
92
|
* number and has no process to hold open, so the clause is satisfied there by there being nothing
|
|
91
93
|
* to satisfy. CLAUDE.md's uniformity bias is exactly this shape — one mechanism, inert on the side
|
|
92
94
|
* that cannot need it.
|
|
95
|
+
*
|
|
96
|
+
* PAST WHAT `setTimeout` HOLDS — 2^31 - 1 ms, near 25 days — a delay is run after 1 ms, and a
|
|
97
|
+
* deadline that fires early finds nothing expired and re-arms: a timer a millisecond for the life of
|
|
98
|
+
* the value. So `Infinity` arms nothing, and a longer finite delay arms at the cap — early, which a
|
|
99
|
+
* deadline re-checks and re-arms from, and which a debounce or throttle window that long takes as
|
|
100
|
+
* its end.
|
|
93
101
|
*/
|
|
94
102
|
export function armTimer(
|
|
95
103
|
expiry: { timer: ReturnType<typeof setTimeout> | null },
|
|
96
104
|
delay: number,
|
|
97
105
|
fire: () => void,
|
|
98
106
|
): void {
|
|
99
|
-
|
|
107
|
+
if (delay === Number.POSITIVE_INFINITY) return
|
|
108
|
+
const timer = setTimeout(
|
|
109
|
+
fire,
|
|
110
|
+
delay > 0 ? Math.min(delay, LONGEST_DELAY) : 0,
|
|
111
|
+
)
|
|
100
112
|
const unref = (timer as { unref?: () => void }).unref
|
|
101
113
|
if (typeof unref === 'function') unref.call(timer)
|
|
102
114
|
expiry.timer = timer
|
|
@@ -1998,7 +1998,16 @@ function refused(cell: Cell, failure: unknown): unknown {
|
|
|
1998
1998
|
// had set it true, and `error` went from nothing to this. A guard that asked about
|
|
1999
1999
|
// either is woken here or never.
|
|
2000
2000
|
probesMoved(cell)
|
|
2001
|
-
if (
|
|
2001
|
+
if (cell.servable) return failure
|
|
2002
|
+
// 7.19 — A SERVER THAT COULD NOT BE REACHED (16.79) says nothing about the value held, so the
|
|
2003
|
+
// invalidated value is served again rather than lost to a read that throws, as 4.12 serves one.
|
|
2004
|
+
if (
|
|
2005
|
+
cell.landed &&
|
|
2006
|
+
isFailedNamed(failure, 'HttpError') &&
|
|
2007
|
+
(failure as Failed<string, unknown>).status === 503
|
|
2008
|
+
)
|
|
2009
|
+
serveAgain(cell)
|
|
2010
|
+
else cascade(cell)
|
|
2002
2011
|
return failure
|
|
2003
2012
|
}
|
|
2004
2013
|
|
|
@@ -3253,7 +3262,9 @@ function settled(cell: Cell, untilStreaming = false): Promise<unknown> {
|
|
|
3253
3262
|
if (untilStreaming && cell.stream !== null)
|
|
3254
3263
|
return Promise.resolve(STREAMING)
|
|
3255
3264
|
if (cell.servable && !busyNow(cell)) return Promise.resolve(heldValue(cell))
|
|
3256
|
-
|
|
3265
|
+
// 2.8 — UNSERVED, NOT UNLANDED: an invalidated value has landed (7.8), and a reload that failed
|
|
3266
|
+
// over it leaves the failure as the answer, or the await waits on a load that has ended.
|
|
3267
|
+
if (!busyNow(cell)) {
|
|
3257
3268
|
const failure = failureOf(cell)
|
|
3258
3269
|
if (failure !== undefined) return Promise.reject(failure)
|
|
3259
3270
|
}
|
|
@@ -3280,7 +3291,6 @@ function settled(cell: Cell, untilStreaming = false): Promise<unknown> {
|
|
|
3280
3291
|
settle(heldValue(cell))
|
|
3281
3292
|
return
|
|
3282
3293
|
}
|
|
3283
|
-
if (cell.landed) return
|
|
3284
3294
|
const failure = failureOf(cell)
|
|
3285
3295
|
if (failure !== undefined) {
|
|
3286
3296
|
waiter.alive = false
|
package/src/shared/safeUrl.ts
CHANGED
|
@@ -8,7 +8,14 @@
|
|
|
8
8
|
// attribute's name.
|
|
9
9
|
import { textOf } from './Raw.ts'
|
|
10
10
|
|
|
11
|
-
const SAFE_SCHEMES = new Set([
|
|
11
|
+
const SAFE_SCHEMES = new Set([
|
|
12
|
+
'http:',
|
|
13
|
+
'https:',
|
|
14
|
+
'mailto:',
|
|
15
|
+
'tel:',
|
|
16
|
+
'blob:',
|
|
17
|
+
'data:',
|
|
18
|
+
])
|
|
12
19
|
|
|
13
20
|
/** A URL attribute's text, or `''` where its scheme is outside the safe set. */
|
|
14
21
|
export function safeUrl(value: unknown): string {
|
package/src/ui/Level.ts
CHANGED
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
} from '#shared/reactive/share.ts'
|
|
7
7
|
import { untracked } from '#shared/reactive/tracking.ts'
|
|
8
8
|
import { type Disposer, watchTracked } from '#shared/reactive/watch.ts'
|
|
9
|
-
import {
|
|
9
|
+
import { reconciled, reconcileHead } from './reconcileHead.ts'
|
|
10
10
|
import { isEmptyRegion, type Region } from './regions.ts'
|
|
11
11
|
import type { Scope } from './Scope.ts'
|
|
12
12
|
|
|
@@ -43,10 +43,10 @@ export class Level {
|
|
|
43
43
|
*/
|
|
44
44
|
share: ShareScope | null = null
|
|
45
45
|
/**
|
|
46
|
-
*
|
|
47
|
-
* once its `mount` returns; `undefined` outside one, where
|
|
46
|
+
* Whether a component built on the client contributed a head while this level mounts, which is
|
|
47
|
+
* reconciled once its `mount` returns; `undefined` outside one, where it is reconciled at once.
|
|
48
48
|
*/
|
|
49
|
-
unapplied:
|
|
49
|
+
unapplied: boolean | undefined = undefined
|
|
50
50
|
/**
|
|
51
51
|
* This level's own head markup as it reads now; `undefined` for a level with no `<head>`.
|
|
52
52
|
*
|
|
@@ -82,11 +82,31 @@ export class Level {
|
|
|
82
82
|
this.share = share
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
/**
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
85
|
+
/** Reconciles the document head from the chain this level stands in (`reconcileHead`). */
|
|
86
|
+
reconcile(): void {
|
|
87
|
+
Level.reconcile(this.levels)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Reconciles the document head from `levels`: each level's own head, then every component's in
|
|
92
|
+
* the order they mounted, outermost first — so the deepest wins by key (39.4). Read untracked:
|
|
93
|
+
* a head read here is followed by its own watch, not by whatever wrote.
|
|
94
|
+
*/
|
|
95
|
+
static reconcile(levels: Level[]): void {
|
|
96
|
+
reconcileHead(untracked(Level.chain, levels, undefined))
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Every contributor in `levels` and the head it reads now, outermost first. */
|
|
100
|
+
static chain(
|
|
101
|
+
levels: Level[],
|
|
102
|
+
_?: undefined,
|
|
103
|
+
): [who: object, text: string][] {
|
|
104
|
+
const chain: [object, string][] = []
|
|
105
|
+
for (const level of levels) {
|
|
106
|
+
if (level.head !== undefined) chain.push([level, level.head()])
|
|
107
|
+
for (const one of level.contributed) chain.push([one, one.head()])
|
|
108
|
+
}
|
|
109
|
+
return chain
|
|
90
110
|
}
|
|
91
111
|
|
|
92
112
|
/**
|
|
@@ -100,78 +120,18 @@ export class Level {
|
|
|
100
120
|
const contribution = new HeadContribution(this, head)
|
|
101
121
|
this.contributed.add(contribution)
|
|
102
122
|
contribution.dispose = watchTracked(() => contribution.follow())
|
|
103
|
-
// Batched while this level mounts;
|
|
104
|
-
// under whatever is deeper (39.4) — only the levels below, since it was added last here.
|
|
105
|
-
const text = contribution.last ?? ''
|
|
123
|
+
// Batched while this level mounts; reconciled at once for an arm or a row mounted after it.
|
|
106
124
|
if (isEmptyRegion(region))
|
|
107
|
-
if (this.unapplied !== undefined) this.unapplied
|
|
108
|
-
else
|
|
109
|
-
applyHead(
|
|
110
|
-
text,
|
|
111
|
-
untracked(Level.headsOf, this.levels, this.at + 1),
|
|
112
|
-
)
|
|
125
|
+
if (this.unapplied !== undefined) this.unapplied = true
|
|
126
|
+
else this.reconcile()
|
|
113
127
|
return contribution
|
|
114
128
|
}
|
|
115
129
|
|
|
116
|
-
/**
|
|
117
|
-
* Writes `text` over the `last` that `contributor` in this level wrote.
|
|
118
|
-
*
|
|
119
|
-
* 39.4 — IT YIELDS TO EVERY CONTRIBUTOR DEEPER, read as they stand at the write and untracked:
|
|
120
|
-
* the components that contributed after it, then every level below, so a layout's
|
|
121
|
-
* `<title>{site}</title>` moving does not write over the page's `<title>`, and the page's head
|
|
122
|
-
* moving wakes only the page's own watch. 39.15 — its keyless nodes replace the ones it wrote.
|
|
123
|
-
*/
|
|
124
|
-
reapply(text: string, last: string, contributor: HeadContribution): void {
|
|
125
|
-
applyHead(
|
|
126
|
-
text,
|
|
127
|
-
untracked(Level.headsAfter, this, contributor),
|
|
128
|
-
last,
|
|
129
|
-
// Read only where a key `last` held is withdrawn.
|
|
130
|
-
() => untracked(Level.headsBefore, this, contributor),
|
|
131
|
-
)
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* The heads either side of `contribution` in `level` (`headsBefore`, `headsAfter`). One `level`
|
|
136
|
-
* does not hold is the level's own head (`followHead`), which stands before every component's.
|
|
137
|
-
*/
|
|
138
|
-
static headsAround(
|
|
139
|
-
level: Level,
|
|
140
|
-
contribution: HeadContribution,
|
|
141
|
-
): [string, string] {
|
|
142
|
-
return [
|
|
143
|
-
Level.headsBefore(level, contribution),
|
|
144
|
-
Level.headsAfter(level, contribution),
|
|
145
|
-
]
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/** Every level above `level`, then what stands before `contribution` in it. */
|
|
149
|
-
static headsBefore(level: Level, contribution: HeadContribution): string {
|
|
150
|
-
let outer = Level.headsOf(level.levels, 0, level.at)
|
|
151
|
-
if (!level.contributed.has(contribution)) return outer
|
|
152
|
-
outer += level.head?.() ?? ''
|
|
153
|
-
for (const one of level.contributed) {
|
|
154
|
-
if (one === contribution) break
|
|
155
|
-
outer += one.head()
|
|
156
|
-
}
|
|
157
|
-
return outer
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/** What stands after `contribution` in `level`, then every level below it. */
|
|
161
|
-
static headsAfter(level: Level, contribution: HeadContribution): string {
|
|
162
|
-
let inner = ''
|
|
163
|
-
let past = !level.contributed.has(contribution)
|
|
164
|
-
for (const one of level.contributed)
|
|
165
|
-
if (past) inner += one.head()
|
|
166
|
-
else if (one === contribution) past = true
|
|
167
|
-
return inner + Level.headsOf(level.levels, level.at + 1)
|
|
168
|
-
}
|
|
169
|
-
|
|
170
130
|
/**
|
|
171
131
|
* 39.13 — A HEAD THAT READS A VALUE FOLLOWS IT for as long as the level is mounted, which is
|
|
172
132
|
* `scope`'s life. The first run only subscribes: the head it would write is already there,
|
|
173
133
|
* written by the server or applied by the level's mount ahead of its fill (39.6). Followed as a
|
|
174
|
-
* component's is, by a contribution the level does not hold
|
|
134
|
+
* component's is, by a contribution the level does not hold.
|
|
175
135
|
*/
|
|
176
136
|
followHead(scope: Scope): void {
|
|
177
137
|
const own = new HeadContribution(this, this.head as () => string)
|
|
@@ -190,8 +150,8 @@ export class Level {
|
|
|
190
150
|
values: new Map(),
|
|
191
151
|
level: this,
|
|
192
152
|
})
|
|
193
|
-
this.unapplied =
|
|
194
|
-
let unapplied:
|
|
153
|
+
this.unapplied = false
|
|
154
|
+
let unapplied: boolean
|
|
195
155
|
let result: Result
|
|
196
156
|
try {
|
|
197
157
|
result = run()
|
|
@@ -200,20 +160,9 @@ export class Level {
|
|
|
200
160
|
this.unapplied = undefined
|
|
201
161
|
leaveShare(back)
|
|
202
162
|
}
|
|
203
|
-
|
|
204
|
-
// layout's `<slot/>` applied its own head ahead of the components that layout holds.
|
|
205
|
-
if (unapplied !== '')
|
|
206
|
-
applyHead(unapplied, Level.headsOf(this.levels, this.at + 1))
|
|
163
|
+
if (unapplied) this.reconcile()
|
|
207
164
|
return result
|
|
208
165
|
}
|
|
209
|
-
|
|
210
|
-
/** The heads of `levels` from `from` to `to`, outermost first. */
|
|
211
|
-
static headsOf(levels: Level[], from: number, to = levels.length): string {
|
|
212
|
-
let markup = ''
|
|
213
|
-
for (let at = from; at < to; at += 1)
|
|
214
|
-
markup += (levels[at] as Level).headMarkup()
|
|
215
|
-
return markup
|
|
216
|
-
}
|
|
217
166
|
}
|
|
218
167
|
|
|
219
168
|
/**
|
|
@@ -236,24 +185,23 @@ export class HeadContribution {
|
|
|
236
185
|
follow(): void {
|
|
237
186
|
const text = this.head()
|
|
238
187
|
const last = this.last
|
|
239
|
-
if (last !== undefined && text !== last)
|
|
240
|
-
this.level.reapply(text, last, this)
|
|
241
188
|
this.last = text
|
|
189
|
+
if (last !== undefined && text !== last) this.level.reconcile()
|
|
242
190
|
}
|
|
243
191
|
|
|
244
192
|
/**
|
|
245
|
-
* 39.14 — WITHDRAWN AS A LEAVING LEVEL'S IS
|
|
246
|
-
*
|
|
247
|
-
*
|
|
193
|
+
* 39.14 — WITHDRAWN AS A LEAVING LEVEL'S IS: the chain is reconciled without it. Adopted first
|
|
194
|
+
* where the server painted it and nothing has reconciled since, so what it painted is known to
|
|
195
|
+
* be its own. Nothing where the level is leaving too: the router reconciles the whole chain
|
|
196
|
+
* once, and cleared its contributions before stopping it.
|
|
248
197
|
*/
|
|
249
198
|
stop(): void {
|
|
250
199
|
this.dispose?.()
|
|
251
200
|
const level = this.level
|
|
252
201
|
if (!level.contributed.has(this)) return
|
|
253
|
-
|
|
254
|
-
untracked(Level.headsAround, level, this),
|
|
255
|
-
)
|
|
202
|
+
if (!reconciled(this)) level.reconcile()
|
|
256
203
|
level.contributed.delete(this)
|
|
204
|
+
level.reconcile()
|
|
257
205
|
}
|
|
258
206
|
}
|
|
259
207
|
|