@abide/abide 0.34.2 → 0.36.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/AGENTS.md +196 -215
- package/CHANGELOG.md +46 -0
- package/README.md +75 -69
- package/package.json +2 -1
- package/src/abideResolverPlugin.ts +53 -9
- package/src/lib/server/runtime/STREAMED_HTML_HEADER.ts +13 -0
- package/src/lib/server/runtime/buildPreloadManifest.ts +151 -0
- package/src/lib/server/runtime/createServer.ts +4 -1
- package/src/lib/server/runtime/createUiPageRenderer.ts +104 -9
- package/src/lib/server/runtime/flushingGzipStream.ts +62 -0
- package/src/lib/server/runtime/gzipResponse.ts +23 -11
- package/src/lib/server/runtime/serializeCacheSnapshot.ts +22 -33
- package/src/lib/shared/cache.ts +10 -3
- package/src/lib/shared/cacheManagedSlot.ts +10 -0
- package/src/lib/shared/withCacheManaged.ts +18 -0
- package/src/lib/ui/README.md +1 -1
- package/src/lib/ui/compile/REACTIVE_CALLEES.ts +6 -6
- package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +1 -0
- package/src/lib/ui/compile/compileShadow.ts +60 -55
- package/src/lib/ui/compile/desugarSignals.ts +93 -38
- package/src/lib/ui/compile/lowerDocAccess.ts +26 -11
- package/src/lib/ui/compile/parseTemplate.ts +8 -5
- package/src/lib/ui/compile/prepareNestedScript.ts +2 -2
- package/src/lib/ui/compile/renameSignalRefs.ts +153 -23
- package/src/lib/ui/compile/types/TemplateAttr.ts +4 -2
- package/src/lib/ui/dom/applyResolved.ts +27 -7
- package/src/lib/ui/dom/awaitBlock.ts +7 -3
- package/src/lib/ui/dom/each.ts +8 -15
- package/src/lib/ui/dom/eachAsync.ts +8 -9
- package/src/lib/ui/dom/switchBlock.ts +7 -3
- package/src/lib/ui/dom/tryBlock.ts +16 -5
- package/src/lib/ui/dom/when.ts +7 -3
- package/src/lib/ui/installHotBridge.ts +2 -0
- package/src/lib/ui/remoteProxy.ts +32 -6
- package/src/lib/ui/router.ts +26 -10
- package/src/lib/ui/runtime/REQUEST_SUPERSEDED.ts +8 -0
- package/src/lib/ui/runtime/abortNode.ts +22 -0
- package/src/lib/ui/runtime/currentAbortSignal.ts +26 -0
- package/src/lib/ui/runtime/escapeKey.ts +10 -4
- package/src/lib/ui/runtime/reactiveAbortState.ts +15 -0
- package/src/lib/ui/runtime/runNode.ts +9 -0
- package/src/lib/ui/runtime/scopeGroup.ts +40 -0
- package/src/lib/ui/runtime/unlinkDeps.ts +8 -0
- package/src/lib/ui/seedStreamedResolution.ts +22 -0
- package/src/lib/ui/startClient.ts +17 -3
- package/src/lib/shared/types/CacheSnapshot.ts +0 -16
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
import { appNameSlot } from '../../shared/appNameSlot.ts'
|
|
2
2
|
import { SSR_CACHE_CONTROL } from '../../shared/CACHE_CONTROL_VALUES.ts'
|
|
3
3
|
import { formatTraceparent } from '../../shared/formatTraceparent.ts'
|
|
4
|
+
import { isReplayableMethod } from '../../shared/isReplayableMethod.ts'
|
|
4
5
|
import { layoutChainForRoute } from '../../shared/layoutChainForRoute.ts'
|
|
6
|
+
import type { CacheEntry } from '../../shared/types/CacheEntry.ts'
|
|
7
|
+
import type { CacheSnapshotEntry } from '../../shared/types/CacheSnapshotEntry.ts'
|
|
8
|
+
import type { StreamedResolution } from '../../shared/types/StreamedResolution.ts'
|
|
5
9
|
import { renderChain } from '../../ui/renderChain.ts'
|
|
6
10
|
import { renderToStream } from '../../ui/renderToStream.ts'
|
|
7
11
|
import type { UiComponent } from '../../ui/runtime/types/UiComponent.ts'
|
|
8
12
|
import { pageUrlFromStore } from './pageUrlFromStore.ts'
|
|
9
13
|
import { SSR_SWAP_SCRIPT } from './SSR_SWAP_SCRIPT.ts'
|
|
14
|
+
import { STREAMED_HTML_HEADER } from './STREAMED_HTML_HEADER.ts'
|
|
10
15
|
import { safeJsonForScript } from './safeJsonForScript.ts'
|
|
11
16
|
import { serializeCacheSnapshot } from './serializeCacheSnapshot.ts'
|
|
17
|
+
import { streamCacheResolutions } from './streamCacheResolutions.ts'
|
|
12
18
|
import type { RequestStore } from './types/RequestStore.ts'
|
|
13
19
|
|
|
14
20
|
/* A abide-ui page module: its default export is the compiled component. */
|
|
@@ -21,6 +27,19 @@ function wantsJson(request: Request): boolean {
|
|
|
21
27
|
return (request.headers.get('accept') ?? '').includes('application/json')
|
|
22
28
|
}
|
|
23
29
|
|
|
30
|
+
/* Defines `window.__abideResolve` ahead of the body: the vanilla collector each
|
|
31
|
+
streamed cache resolution calls, buffering payloads for startClient to drain into
|
|
32
|
+
the store before hydration (the bundle is deferred, so it runs after every chunk). */
|
|
33
|
+
const CACHE_RESOLVE_SCRIPT =
|
|
34
|
+
'window.__abideResolve=function(r){(window.__abideResumeCache=window.__abideResumeCache||[]).push(r)}'
|
|
35
|
+
|
|
36
|
+
/* One streamed cache resolution as an inline `__abideResolve(...)` call. `<` is escaped
|
|
37
|
+
(as in encodeResume) so a body value can't close the script early. */
|
|
38
|
+
function resolveChunk(resolution: StreamedResolution): string {
|
|
39
|
+
const payload = JSON.stringify(resolution).replace(/</g, '\\u003c')
|
|
40
|
+
return `<script>__abideResolve(${payload})</script>`
|
|
41
|
+
}
|
|
42
|
+
|
|
24
43
|
/*
|
|
25
44
|
The abide-ui SSR document renderer. A matched route + params in, a finished HTML
|
|
26
45
|
Response out.
|
|
@@ -30,9 +49,16 @@ await blocks STREAMS: the pending shell flushes first, then each resolved fragme
|
|
|
30
49
|
(`<abide-resolve>` carrying a JSON `<script>`) as its promise settles, swapped into its boundary
|
|
31
50
|
by the inline SSR_SWAP_SCRIPT — which also registers the value into the resume
|
|
32
51
|
manifest so client hydration adopts it without re-fetching (see abide/ui/awaitBlock).
|
|
52
|
+
Then, once the stream has run every `{#await}` thunk (creating and settling its cache
|
|
53
|
+
entry mid-stream, after the render-return snapshot), each such entry streams an inline
|
|
54
|
+
`__abideResolve(...)` chunk — a warm snapshot, or a `{ key, miss }` marker for an
|
|
55
|
+
unshippable body (→ live refetch) — seeding the client store before the deferred bundle
|
|
56
|
+
so the block's subscription read is warm (no refetch).
|
|
33
57
|
|
|
34
58
|
`__SSR__` carries the route/params, mount base, trace, app name, client timeout,
|
|
35
|
-
and the
|
|
59
|
+
and the render-return cache snapshot (top-level `await` reads; the client seeds its tab
|
|
60
|
+
store from it). `{#await}` reads aren't settled yet at render-return — they arrive over
|
|
61
|
+
the stream as above. A route's
|
|
36
62
|
`layout.abide` files wrap the page outermost-first (every ancestor directory's
|
|
37
63
|
layout applies); the chain server-renders as one document via `renderChain`, the
|
|
38
64
|
page folded into each layout's `<slot/>` outlet. The client router re-resolves the
|
|
@@ -44,6 +70,7 @@ export function createUiPageRenderer({
|
|
|
44
70
|
clientTimeout,
|
|
45
71
|
pages,
|
|
46
72
|
layouts,
|
|
73
|
+
routePreloads = {},
|
|
47
74
|
healthPayload,
|
|
48
75
|
}: {
|
|
49
76
|
shell: string
|
|
@@ -51,6 +78,7 @@ export function createUiPageRenderer({
|
|
|
51
78
|
clientTimeout: number | undefined
|
|
52
79
|
pages: Record<string, LoadPage>
|
|
53
80
|
layouts: Record<string, LoadPage>
|
|
81
|
+
routePreloads?: Record<string, string[]>
|
|
54
82
|
healthPayload: (request: Request) => Promise<Record<string, unknown>>
|
|
55
83
|
}): {
|
|
56
84
|
renderPage: (
|
|
@@ -65,13 +93,15 @@ export function createUiPageRenderer({
|
|
|
65
93
|
stack?: string,
|
|
66
94
|
) => Promise<Response | undefined>
|
|
67
95
|
} {
|
|
68
|
-
/* Build the __SSR__ <script> the client (startClient) reads on boot.
|
|
96
|
+
/* Build the __SSR__ <script> the client (startClient) reads on boot. The inline
|
|
97
|
+
(settled) cache partition is computed once by the caller and threaded in, so the
|
|
98
|
+
streaming branch can also drain the pending partition over the same render. */
|
|
69
99
|
async function stateTag(
|
|
70
100
|
routeUrl: string,
|
|
71
101
|
params: Record<string, string>,
|
|
72
102
|
store: RequestStore,
|
|
103
|
+
inline: CacheSnapshotEntry[],
|
|
73
104
|
): Promise<string> {
|
|
74
|
-
const { inline } = await serializeCacheSnapshot(store.cache)
|
|
75
105
|
const health = store.healthRead ? await healthPayload(store.req) : undefined
|
|
76
106
|
const payload = safeJsonForScript({
|
|
77
107
|
route: routeUrl,
|
|
@@ -86,6 +116,32 @@ export function createUiPageRenderer({
|
|
|
86
116
|
return `<script>window.__SSR__ = ${payload};</script>`
|
|
87
117
|
}
|
|
88
118
|
|
|
119
|
+
/* Per-route `<link rel=modulepreload>`s for the route's page + layout-chain chunks
|
|
120
|
+
and their route-only static runtime deps (the shell already preloads the entry's
|
|
121
|
+
shared runtime). Those chunks are dynamically imported by the entry, so the browser
|
|
122
|
+
discovers them only after the entry runs at parse-end ≈ stream-close; preloading them
|
|
123
|
+
in <head> overlaps their transfer with the stream. Rebased onto the mount base like
|
|
124
|
+
the shell's own `/_app/` refs. Cached per route — the set is render-invariant. */
|
|
125
|
+
const preloadLinkCache = new Map<string, string>()
|
|
126
|
+
function routePreloadLinks(routeUrl: string): string {
|
|
127
|
+
const cached = preloadLinkCache.get(routeUrl)
|
|
128
|
+
if (cached !== undefined) {
|
|
129
|
+
return cached
|
|
130
|
+
}
|
|
131
|
+
const links = (routePreloads[routeUrl] ?? [])
|
|
132
|
+
.map((chunk) => `<link rel="modulepreload" href="${base}/_app/${chunk}" />`)
|
|
133
|
+
.join('')
|
|
134
|
+
preloadLinkCache.set(routeUrl, links)
|
|
135
|
+
return links
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/* Splices the route preloads in before the shell's </head> (case-insensitive, like the
|
|
139
|
+
build-time injector). A no-op when there are none or the shell carries no </head>. */
|
|
140
|
+
function injectRoutePreloads(html: string, routeUrl: string): string {
|
|
141
|
+
const links = routePreloadLinks(routeUrl)
|
|
142
|
+
return links === '' ? html : html.replace(/<\/head>/i, `${links}</head>`)
|
|
143
|
+
}
|
|
144
|
+
|
|
89
145
|
async function renderPage(
|
|
90
146
|
routeUrl: string,
|
|
91
147
|
params: Record<string, string>,
|
|
@@ -117,14 +173,24 @@ export function createUiPageRenderer({
|
|
|
117
173
|
params,
|
|
118
174
|
)
|
|
119
175
|
|
|
176
|
+
/* Snapshot the cache settled by render-return — top-level `await` reads (render
|
|
177
|
+
blocked on them) — into __SSR__. A `{#await}` read is NOT here: its expression
|
|
178
|
+
is a thunk `renderToStream` runs lazily, so its entry is created mid-stream and
|
|
179
|
+
seeded after the drain (see below). */
|
|
180
|
+
const inline = await serializeCacheSnapshot(store.cache)
|
|
181
|
+
const inlinedKeys = new Set(inline.map((entry) => entry.key))
|
|
182
|
+
|
|
120
183
|
/* No await blocks → render synchronously, ship buffered. */
|
|
121
184
|
if (ssr.awaits.length === 0) {
|
|
122
|
-
const html =
|
|
123
|
-
|
|
185
|
+
const html = injectRoutePreloads(
|
|
186
|
+
shell.replace(SSR_MARKER, (_match, key: string) =>
|
|
187
|
+
key === 'body' ? ssr.html : key === 'state' ? '' : '',
|
|
188
|
+
),
|
|
189
|
+
routeUrl,
|
|
124
190
|
)
|
|
125
191
|
const withState = html.replace(
|
|
126
192
|
'</body>',
|
|
127
|
-
`${await stateTag(routeUrl, params, store)}</body>`,
|
|
193
|
+
`${await stateTag(routeUrl, params, store, inline)}</body>`,
|
|
128
194
|
)
|
|
129
195
|
return new Response(withState, {
|
|
130
196
|
headers: {
|
|
@@ -139,9 +205,14 @@ export function createUiPageRenderer({
|
|
|
139
205
|
Fill head/state but LEAVE the body marker intact — it's the split point for
|
|
140
206
|
streaming the page body into `#app`; consuming it here would append the body
|
|
141
207
|
after the whole shell (outside `#app`), breaking hydration. */
|
|
142
|
-
const head =
|
|
143
|
-
|
|
144
|
-
|
|
208
|
+
const head =
|
|
209
|
+
`<script>${SSR_SWAP_SCRIPT}${CACHE_RESOLVE_SCRIPT}</script>` +
|
|
210
|
+
`${await stateTag(routeUrl, params, store, inline)}`
|
|
211
|
+
const filled = injectRoutePreloads(
|
|
212
|
+
shell.replace(/<!--ssr:(head|state)-->/g, (_match, key: string) =>
|
|
213
|
+
key === 'head' ? head : '',
|
|
214
|
+
),
|
|
215
|
+
routeUrl,
|
|
145
216
|
)
|
|
146
217
|
const [before, after] = filled.split(BODY_MARKER)
|
|
147
218
|
const encoder = new TextEncoder()
|
|
@@ -158,6 +229,26 @@ export function createUiPageRenderer({
|
|
|
158
229
|
)
|
|
159
230
|
first = false
|
|
160
231
|
}
|
|
232
|
+
/* The {#await} reads created (and settled) their cache entries DURING the
|
|
233
|
+
stream — the await expression is a thunk `renderToStream` ran lazily —
|
|
234
|
+
so they missed the render-return snapshot. Drain them now: each lands an
|
|
235
|
+
inline `__abideResolve(...)` chunk (a warm snapshot, or a `{ key, miss }`
|
|
236
|
+
marker for an unshippable body → live refetch) before the deferred
|
|
237
|
+
bundle, so startClient seeds the store and the block's subscription read
|
|
238
|
+
is warm. Skip keys already shipped inline in __SSR__. */
|
|
239
|
+
const streamedEntries = Array.from(store.cache.entries.values()).filter(
|
|
240
|
+
(entry: CacheEntry) =>
|
|
241
|
+
entry.settled === true &&
|
|
242
|
+
entry.request !== undefined &&
|
|
243
|
+
isReplayableMethod(entry.request.method.toUpperCase()) &&
|
|
244
|
+
!inlinedKeys.has(entry.key),
|
|
245
|
+
)
|
|
246
|
+
for await (const resolution of streamCacheResolutions(
|
|
247
|
+
store.cache,
|
|
248
|
+
streamedEntries,
|
|
249
|
+
)) {
|
|
250
|
+
controller.enqueue(encoder.encode(resolveChunk(resolution)))
|
|
251
|
+
}
|
|
161
252
|
controller.enqueue(encoder.encode(after ?? ''))
|
|
162
253
|
controller.close()
|
|
163
254
|
},
|
|
@@ -166,6 +257,10 @@ export function createUiPageRenderer({
|
|
|
166
257
|
headers: {
|
|
167
258
|
'Content-Type': 'text/html; charset=utf-8',
|
|
168
259
|
'Cache-Control': SSR_CACHE_CONTROL,
|
|
260
|
+
/* Mark the progressively-flushed document so gzipResponse compresses it
|
|
261
|
+
with a per-chunk-flushing gzip (the plain CompressionStream buffers the
|
|
262
|
+
head and defeats streaming); the marker is stripped before send. */
|
|
263
|
+
[STREAMED_HTML_HEADER]: '1',
|
|
169
264
|
},
|
|
170
265
|
},
|
|
171
266
|
)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { constants, createGzip } from 'node:zlib'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
A gzip TransformStream that emits a decodable block after every input chunk via
|
|
5
|
+
Z_SYNC_FLUSH, instead of buffering until its deflate window fills like the web
|
|
6
|
+
CompressionStream. Used for the streamed SSR document so the head reaches the
|
|
7
|
+
browser compressed-but-decodable immediately — the preload scanner sees the
|
|
8
|
+
entry/css links and the pending shell paints — rather than only at stream close.
|
|
9
|
+
node:zlib is required here: the web CompressionStream exposes no per-chunk flush.
|
|
10
|
+
*/
|
|
11
|
+
export function flushingGzipStream(): TransformStream<Uint8Array, Uint8Array> {
|
|
12
|
+
const gzip = createGzip()
|
|
13
|
+
let sink: TransformStreamDefaultController<Uint8Array>
|
|
14
|
+
/* `closed` gates enqueue: once the consumer cancels (client disconnect), the node
|
|
15
|
+
gzip can still emit a trailing `data` that would throw on the closed controller. */
|
|
16
|
+
let closed = false
|
|
17
|
+
gzip.on('data', (chunk: Buffer) => {
|
|
18
|
+
if (closed) {
|
|
19
|
+
return
|
|
20
|
+
}
|
|
21
|
+
/* enqueue can still race a just-closed controller (cancel is async); treat the
|
|
22
|
+
throw as the consumer having gone and tear down. */
|
|
23
|
+
try {
|
|
24
|
+
sink.enqueue(new Uint8Array(chunk))
|
|
25
|
+
} catch {
|
|
26
|
+
closed = true
|
|
27
|
+
gzip.destroy()
|
|
28
|
+
}
|
|
29
|
+
})
|
|
30
|
+
/* The spec'd Transformer.cancel hook (consumer-cancellation) postdates this TS lib's
|
|
31
|
+
Transformer type; declare it locally so the literal type-checks while the platform
|
|
32
|
+
(Bun) still invokes it. Typing the const sidesteps the fresh-literal excess-property
|
|
33
|
+
check without weakening start/transform/flush. */
|
|
34
|
+
const transformer: Transformer<Uint8Array, Uint8Array> & {
|
|
35
|
+
cancel(reason?: unknown): void
|
|
36
|
+
} = {
|
|
37
|
+
start(controller) {
|
|
38
|
+
sink = controller
|
|
39
|
+
gzip.on('error', (error) => controller.error(error))
|
|
40
|
+
},
|
|
41
|
+
/* Resolve only once the chunk is compressed AND sync-flushed, so its bytes are
|
|
42
|
+
on the wire before the stream pulls the next (possibly delayed) chunk. */
|
|
43
|
+
transform(chunk) {
|
|
44
|
+
return new Promise((resolve) => {
|
|
45
|
+
gzip.write(chunk, () => gzip.flush(constants.Z_SYNC_FLUSH, () => resolve()))
|
|
46
|
+
})
|
|
47
|
+
},
|
|
48
|
+
/* End the deflate stream; its trailing bytes arrive as `data` before `end`. */
|
|
49
|
+
flush() {
|
|
50
|
+
return new Promise((resolve) => {
|
|
51
|
+
gzip.on('end', () => resolve())
|
|
52
|
+
gzip.end()
|
|
53
|
+
})
|
|
54
|
+
},
|
|
55
|
+
/* Consumer went away — stop enqueueing and tear down the node stream. */
|
|
56
|
+
cancel() {
|
|
57
|
+
closed = true
|
|
58
|
+
gzip.destroy()
|
|
59
|
+
},
|
|
60
|
+
}
|
|
61
|
+
return new TransformStream(transformer)
|
|
62
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { isStreamingResponse } from '../../shared/isStreamingResponse.ts'
|
|
2
2
|
import { acceptsGzip } from './acceptsGzip.ts'
|
|
3
|
+
import { flushingGzipStream } from './flushingGzipStream.ts'
|
|
4
|
+
import { STREAMED_HTML_HEADER } from './STREAMED_HTML_HEADER.ts'
|
|
3
5
|
|
|
4
6
|
/*
|
|
5
7
|
Compressible Content-Types — text and structured-text payloads. Binary or
|
|
@@ -11,18 +13,27 @@ const COMPRESSIBLE_TYPE =
|
|
|
11
13
|
|
|
12
14
|
/*
|
|
13
15
|
Gzips a dynamic response (SSR HTML, rpc/json replies, the plain 404) when the
|
|
14
|
-
client accepts it
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
16
|
+
client accepts it. Static assets never reach here — they already carry a
|
|
17
|
+
precompressed Content-Encoding, which short-circuits the first guard. Skipped
|
|
18
|
+
for: already-encoded bodies, bodiless responses (204/304/HEAD), non-compressible
|
|
19
|
+
types (images, fonts, archives), and frame-delimited streams (SSE/jsonl, where
|
|
20
|
+
gzip buffering would stall per-frame flush). No byte-size floor: Bun doesn't
|
|
21
|
+
expose a string body's length before send, and measuring would mean buffering the
|
|
22
|
+
body — the framing overhead on the rare tiny body is negligible against
|
|
23
|
+
compressing every page and rpc payload.
|
|
24
|
+
|
|
25
|
+
Buffered bodies take the web CompressionStream (best ratio, one flush at close).
|
|
26
|
+
The streamed SSR document self-marks (STREAMED_HTML_HEADER) and takes a
|
|
27
|
+
per-chunk-flushing gzip instead: the plain CompressionStream buffers the head
|
|
28
|
+
until its deflate window fills, which defeats streaming (the browser can't
|
|
29
|
+
preload-scan the head or paint the pending shell until the stream nearly closes).
|
|
30
|
+
The marker is stripped so it never reaches the client.
|
|
24
31
|
*/
|
|
25
32
|
export function gzipResponse(req: Request, response: Response): Response {
|
|
33
|
+
const streamedHtml = response.headers.has(STREAMED_HTML_HEADER)
|
|
34
|
+
if (streamedHtml) {
|
|
35
|
+
response.headers.delete(STREAMED_HTML_HEADER)
|
|
36
|
+
}
|
|
26
37
|
if (!response.body || response.headers.has('Content-Encoding')) {
|
|
27
38
|
return response
|
|
28
39
|
}
|
|
@@ -38,7 +49,8 @@ export function gzipResponse(req: Request, response: Response): Response {
|
|
|
38
49
|
headers.append('Vary', 'Accept-Encoding')
|
|
39
50
|
/* The stored length no longer matches the compressed body (and is unknown for a stream). */
|
|
40
51
|
headers.delete('Content-Length')
|
|
41
|
-
|
|
52
|
+
const compressor = streamedHtml ? flushingGzipStream() : new CompressionStream('gzip')
|
|
53
|
+
return new Response(response.body.pipeThrough(compressor), {
|
|
42
54
|
status: response.status,
|
|
43
55
|
statusText: response.statusText,
|
|
44
56
|
headers,
|
|
@@ -1,45 +1,34 @@
|
|
|
1
1
|
import { isReplayableMethod } from '../../shared/isReplayableMethod.ts'
|
|
2
|
-
import type { CacheEntry } from '../../shared/types/CacheEntry.ts'
|
|
3
|
-
import type { CacheSnapshot } from '../../shared/types/CacheSnapshot.ts'
|
|
4
2
|
import type { CacheSnapshotEntry } from '../../shared/types/CacheSnapshotEntry.ts'
|
|
5
3
|
import type { CacheStore } from '../../shared/types/CacheStore.ts'
|
|
6
4
|
import { snapshotEntryFromCache } from './snapshotEntryFromCache.ts'
|
|
7
5
|
|
|
8
6
|
/*
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
go to `pending` for the response streamer to drain and resolve over the wire.
|
|
7
|
+
Snapshots the request-scoped cache for SSR at a single instant: every replayable
|
|
8
|
+
(GET/DELETE) entry settled by now, serialized to a wire-safe CacheSnapshotEntry the
|
|
9
|
+
client seeds its store from. Unsettled and non-replayable entries are skipped; a body
|
|
10
|
+
that can't ship (binary / streaming / rejected) drops out via snapshotEntryFromCache.
|
|
14
11
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
12
|
+
Snapshots concurrently — the awaits are immediate (entries are already settled), but
|
|
13
|
+
their body reads run in parallel. Never blocks on an unsettled entry.
|
|
14
|
+
|
|
15
|
+
WHEN it's called decides what it sees. createUiPageRenderer calls it at render-return
|
|
16
|
+
for `__SSR__` — that catches only top-level `await` reads (render blocked on them). A
|
|
17
|
+
`{#await cache()}` read does NOT appear: its expression is a thunk renderToStream runs
|
|
18
|
+
lazily, so its entry is created mid-stream, after this snapshot. The renderer snapshots
|
|
19
|
+
AGAIN once the stream has drained (entries then exist and are settled) and seeds those
|
|
20
|
+
over the wire — see its post-stream `__abideResolve` pass. So for a streaming page the
|
|
21
|
+
render-return snapshot is typically empty; the warm cache arrives over the stream.
|
|
20
22
|
*/
|
|
21
|
-
export async function serializeCacheSnapshot(store: CacheStore): Promise<
|
|
22
|
-
const settled
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
}
|
|
29
|
-
if (!isReplayableMethod(entry.request.method.toUpperCase())) {
|
|
30
|
-
continue
|
|
31
|
-
}
|
|
32
|
-
if (entry.settled) {
|
|
33
|
-
settled.push(entry)
|
|
34
|
-
} else {
|
|
35
|
-
pending.push(entry)
|
|
36
|
-
}
|
|
37
|
-
}
|
|
23
|
+
export async function serializeCacheSnapshot(store: CacheStore): Promise<CacheSnapshotEntry[]> {
|
|
24
|
+
const settled = Array.from(store.entries.values()).filter(
|
|
25
|
+
(entry) =>
|
|
26
|
+
entry.settled === true &&
|
|
27
|
+
entry.request !== undefined &&
|
|
28
|
+
isReplayableMethod(entry.request.method.toUpperCase()),
|
|
29
|
+
)
|
|
38
30
|
const snapshots = await Promise.all(
|
|
39
31
|
settled.map((entry) => snapshotEntryFromCache(store, entry)),
|
|
40
32
|
)
|
|
41
|
-
|
|
42
|
-
(snapshot): snapshot is CacheSnapshotEntry => snapshot !== undefined,
|
|
43
|
-
)
|
|
44
|
-
return { inline, pending }
|
|
33
|
+
return snapshots.filter((snapshot): snapshot is CacheSnapshotEntry => snapshot !== undefined)
|
|
45
34
|
}
|
package/src/lib/shared/cache.ts
CHANGED
|
@@ -24,6 +24,7 @@ import type { CacheStore } from './types/CacheStore.ts'
|
|
|
24
24
|
import type { RawRemoteFunction } from './types/RawRemoteFunction.ts'
|
|
25
25
|
import type { RemoteFunction } from './types/RemoteFunction.ts'
|
|
26
26
|
import type { Subscribable } from './types/Subscribable.ts'
|
|
27
|
+
import { withCacheManaged } from './withCacheManaged.ts'
|
|
27
28
|
|
|
28
29
|
type AnyRemote<Args, Return> = RemoteFunction<Args, Return> | RawRemoteFunction<Args>
|
|
29
30
|
type Producer<Args, Return> = (args?: Args) => Promise<Return>
|
|
@@ -309,8 +310,12 @@ function invokeProducer<Args, Return>(
|
|
|
309
310
|
}
|
|
310
311
|
/* Miss: time the producer run — where a request's time actually goes (an
|
|
311
312
|
external fetch, a computation). Producer path only; the remote path must
|
|
312
|
-
keep its own promise so getRemoteMeta can read the recorded Request.
|
|
313
|
-
|
|
313
|
+
keep its own promise so getRemoteMeta can read the recorded Request. The
|
|
314
|
+
producer runs cache-managed so a bare RPC inside it isn't scope-bound — the
|
|
315
|
+
cache coalesces and owns this flight. */
|
|
316
|
+
const promise = cacheLog.trace<Return>(`cache ${key}`, () =>
|
|
317
|
+
withCacheManaged(() => producer(args)),
|
|
318
|
+
)
|
|
314
319
|
registerEntry(store, key, promise, options, undefined, () => producer(args))
|
|
315
320
|
return promise
|
|
316
321
|
}
|
|
@@ -326,7 +331,9 @@ function invokeRemote<Args>(
|
|
|
326
331
|
if (existing) {
|
|
327
332
|
return shareable(existing.promise as Promise<Response>)
|
|
328
333
|
}
|
|
329
|
-
|
|
334
|
+
/* Cache-managed: the shared flight isn't bound to the reader that triggered the
|
|
335
|
+
miss, so its scope disposing can't abort a request other readers still join. */
|
|
336
|
+
const promise = withCacheManaged(() => rawFn(args as Args))
|
|
330
337
|
const request = getRemoteMeta(promise)
|
|
331
338
|
if (!request) {
|
|
332
339
|
throw new Error(
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Set while cache() synchronously invokes the underlying remote/producer, so the
|
|
3
|
+
client's currentAbortSignal skips scope-binding for cache-managed calls. The cache
|
|
4
|
+
coalesces one in-flight request across every reader and owns its lifetime (it
|
|
5
|
+
evicts the entry when the request rejects), so a single reader navigating away must
|
|
6
|
+
not abort a flight the others still depend on. A plain boolean — cache invokes the
|
|
7
|
+
underlying call synchronously and never re-enters across an await. Harmless on the
|
|
8
|
+
server, where there is no reactive observer to bind anyway.
|
|
9
|
+
*/
|
|
10
|
+
export const cacheManagedSlot: { active: boolean } = { active: false }
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { cacheManagedSlot } from './cacheManagedSlot.ts'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
Runs `invoke` with cacheManagedSlot flagged, so any RPC fired synchronously inside
|
|
5
|
+
it (the cache's underlying remote/producer call) skips reactive scope-binding — the
|
|
6
|
+
cache, not the calling reader, owns the shared flight's lifetime. Save/restore keeps
|
|
7
|
+
it correct under nesting (a producer that itself reads cache). The fetch is fired
|
|
8
|
+
synchronously and the flag clears before the await, so it never spans the network.
|
|
9
|
+
*/
|
|
10
|
+
export function withCacheManaged<T>(invoke: () => T): T {
|
|
11
|
+
const previous = cacheManagedSlot.active
|
|
12
|
+
cacheManagedSlot.active = true
|
|
13
|
+
try {
|
|
14
|
+
return invoke()
|
|
15
|
+
} finally {
|
|
16
|
+
cacheManagedSlot.active = previous
|
|
17
|
+
}
|
|
18
|
+
}
|
package/src/lib/ui/README.md
CHANGED
|
@@ -36,7 +36,7 @@ every page, and `.abide` files are the only component format.
|
|
|
36
36
|
|
|
37
37
|
## Idioms
|
|
38
38
|
|
|
39
|
-
- **Signals are the surface**: `state(v)`, `derived(fn)`, `effect(fn)`, `
|
|
39
|
+
- **Signals are the surface**: `state(v)`, `derived(fn)`, `effect(fn)`, `props()`.
|
|
40
40
|
You write plain assignment (`count += 1`, `items.push(x)`); the compiler lowers
|
|
41
41
|
it. Templates auto-read (`{count}`); ordering and cross-references compose.
|
|
42
42
|
- **Everything dynamic lives in `{ }`** — `{expr}` text, `name={expr}`,
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/* The callee names the `.abide` compiler recognises as reactive declarations
|
|
2
|
-
(`let x = state(...)`, `linked(...)`, `computed(...)`,
|
|
3
|
-
"is this a reactive binding" allowlist read by
|
|
4
|
-
scoper, and the type-checking shadow. How each
|
|
5
|
-
vs a `.value` cell — is decided per-site; this is
|
|
6
|
-
new primitive is a single edit here. */
|
|
2
|
+
(`let x = state(...)`, `linked(...)`, `computed(...)`, and the destructuring
|
|
3
|
+
`const {…} = props()`): the shared "is this a reactive binding" allowlist read by
|
|
4
|
+
the desugarer, the nested-script scoper, and the type-checking shadow. How each
|
|
5
|
+
lowers — a serializable doc slot vs a `.value` cell — is decided per-site; this is
|
|
6
|
+
only the membership set, so a new primitive is a single edit here. */
|
|
7
7
|
export const REACTIVE_CALLEES: ReadonlySet<string> = new Set([
|
|
8
8
|
'state',
|
|
9
9
|
'linked',
|
|
10
10
|
'computed',
|
|
11
|
-
'
|
|
11
|
+
'props',
|
|
12
12
|
])
|
|
@@ -32,6 +32,7 @@ export const UI_RUNTIME_IMPORTS: { name: string; specifier: string }[] = [
|
|
|
32
32
|
{ name: 'mountSlot', specifier: 'ui/dom/mountSlot' },
|
|
33
33
|
{ name: 'mountChild', specifier: 'ui/dom/mountChild' },
|
|
34
34
|
{ name: 'hydrate', specifier: 'ui/dom/hydrate' },
|
|
35
|
+
{ name: 'escapeKey', specifier: 'ui/runtime/escapeKey' },
|
|
35
36
|
{ name: 'nextBlockId', specifier: 'ui/runtime/nextBlockId' },
|
|
36
37
|
{ name: 'enterRenderPass', specifier: 'ui/runtime/enterRenderPass' },
|
|
37
38
|
{ name: 'exitRenderPass', specifier: 'ui/runtime/exitRenderPass' },
|