@abide/abide 0.34.1 → 0.35.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 +3 -3
- package/CHANGELOG.md +50 -0
- package/README.md +2 -2
- 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/server/runtime/snapshotEntryFromCache.ts +9 -0
- package/src/lib/server/sockets/defineSocket.ts +6 -1
- package/src/lib/shared/cacheEntryFromSnapshot.ts +14 -12
- package/src/lib/shared/contentBodyKind.ts +27 -0
- package/src/lib/shared/createSocketSubRegistry.ts +112 -0
- package/src/lib/shared/decodeResponse.ts +5 -4
- package/src/lib/shared/isStreamingResponse.ts +5 -5
- package/src/lib/test/createTestSocketChannel.ts +9 -70
- 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/generateSSR.ts +28 -47
- 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/skeletonContext.ts +83 -0
- package/src/lib/ui/compile/types/SkeletonContext.ts +15 -0
- package/src/lib/ui/compile/types/TemplateAttr.ts +4 -2
- package/src/lib/ui/dom/applyResolved.ts +27 -7
- package/src/lib/ui/installHotBridge.ts +2 -0
- package/src/lib/ui/runtime/escapeKey.ts +10 -4
- package/src/lib/ui/seedStreamedResolution.ts +22 -0
- package/src/lib/ui/socketChannel.ts +18 -98
- package/src/lib/ui/startClient.ts +17 -3
- package/src/lib/shared/types/CacheSnapshot.ts +0 -16
|
@@ -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
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { isReplayableMethod } from '../../shared/isReplayableMethod.ts'
|
|
2
|
+
import { isStreamingResponse } from '../../shared/isStreamingResponse.ts'
|
|
2
3
|
import type { CacheEntry } from '../../shared/types/CacheEntry.ts'
|
|
3
4
|
import type { CacheSnapshotEntry } from '../../shared/types/CacheSnapshotEntry.ts'
|
|
4
5
|
import type { CacheStore } from '../../shared/types/CacheStore.ts'
|
|
@@ -38,6 +39,14 @@ export async function snapshotEntryFromCache(
|
|
|
38
39
|
if (store.entries.get(entry.key) !== entry) {
|
|
39
40
|
return undefined
|
|
40
41
|
}
|
|
42
|
+
/* A streaming body (SSE / JSONL / NDJSON) can't ship: `response.text()` below would
|
|
43
|
+
hang buffering a never-ending stream, and `decodeResponse` refuses it on the client
|
|
44
|
+
anyway — so a snapshot value would diverge from a live read. Skip it (the same guard
|
|
45
|
+
`decodeResponse` applies), letting the client live-fetch and get the proper streaming
|
|
46
|
+
error. */
|
|
47
|
+
if (isStreamingResponse(response)) {
|
|
48
|
+
return undefined
|
|
49
|
+
}
|
|
41
50
|
const contentType = (response.headers.get('content-type') ?? '').toLowerCase()
|
|
42
51
|
if (!isTextual(contentType)) {
|
|
43
52
|
return undefined
|
|
@@ -6,6 +6,7 @@ import { getActiveServer } from '../runtime/getActiveServer.ts'
|
|
|
6
6
|
import { registerSocket } from './registerSocket.ts'
|
|
7
7
|
import type { Socket } from './types/Socket.ts'
|
|
8
8
|
import type { SocketOptions } from './types/SocketOptions.ts'
|
|
9
|
+
import type { SocketServerFrame } from './types/SocketServerFrame.ts'
|
|
9
10
|
|
|
10
11
|
/*
|
|
11
12
|
Server-side construction of a Socket. The bundler rewrites every
|
|
@@ -118,7 +119,11 @@ export function defineSocket<T>(name: string, opts: SocketOptions = {}): Socket<
|
|
|
118
119
|
}
|
|
119
120
|
const server = cachedServer
|
|
120
121
|
if (server) {
|
|
121
|
-
|
|
122
|
+
/* Typed against the shared wire contract so a `SocketServerFrame` change can't
|
|
123
|
+
silently drift from this construction site (the dispatcher's `send` is already
|
|
124
|
+
typed; this was the last `msg` frame built through an unchecked JSON.stringify). */
|
|
125
|
+
const frame: SocketServerFrame = { type: 'msg', socket: name, message: validated }
|
|
126
|
+
server.publish(topic, JSON.stringify(frame))
|
|
122
127
|
}
|
|
123
128
|
}
|
|
124
129
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { contentBodyKind } from './contentBodyKind.ts'
|
|
1
2
|
import { contentTypeOf } from './contentTypeOf.ts'
|
|
2
3
|
import type { CacheEntry } from './types/CacheEntry.ts'
|
|
3
4
|
import type { CacheSnapshotEntry } from './types/CacheSnapshotEntry.ts'
|
|
@@ -31,30 +32,31 @@ export function cacheEntryFromSnapshot(entry: CacheSnapshotEntry): CacheEntry {
|
|
|
31
32
|
|
|
32
33
|
/*
|
|
33
34
|
Synchronously decodes a snapshot body so the warm entry reads without a
|
|
34
|
-
microtask hop on first render.
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
35
|
+
microtask hop on first render. A strict subset of `decodeResponse`: it warms
|
|
36
|
+
only the `json`/`text` kinds (the same `contentBodyKind` the live read switches
|
|
37
|
+
on, so the two cannot disagree about a body), returning a value identical to
|
|
38
|
+
what awaiting the Response would yield. Every other kind — non-2xx, 204,
|
|
39
|
+
streaming, binary — yields no warm value and defers to the async path, which
|
|
40
|
+
throws HttpError / returns the streaming error / blobs exactly as a live call
|
|
41
|
+
would.
|
|
38
42
|
*/
|
|
39
43
|
function warmValueFromSnapshot(status: number, headers: Headers, body: string): unknown {
|
|
40
44
|
if (status === 204 || status < 200 || status >= 300) {
|
|
41
45
|
return undefined
|
|
42
46
|
}
|
|
43
|
-
const
|
|
44
|
-
if (
|
|
45
|
-
/*
|
|
46
|
-
|
|
47
|
-
application/x-ndjson; fall back to the async path (return undefined)
|
|
48
|
-
rather than throwing a SyntaxError synchronously during hydration.
|
|
49
|
-
*/
|
|
47
|
+
const kind = contentBodyKind(contentTypeOf(headers))
|
|
48
|
+
if (kind === 'json') {
|
|
49
|
+
/* The body may still be malformed JSON; fall back to the async path rather than
|
|
50
|
+
throwing a SyntaxError synchronously during hydration. */
|
|
50
51
|
try {
|
|
51
52
|
return JSON.parse(body)
|
|
52
53
|
} catch {
|
|
53
54
|
return undefined
|
|
54
55
|
}
|
|
55
56
|
}
|
|
56
|
-
if (
|
|
57
|
+
if (kind === 'text') {
|
|
57
58
|
return body
|
|
58
59
|
}
|
|
60
|
+
/* streaming and binary defer to the async path (the live decode throws / blobs). */
|
|
59
61
|
return undefined
|
|
60
62
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { STREAMING_CONTENT_TYPES } from './STREAMING_CONTENT_TYPES.ts'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
The single classification of a Content-Type into the body kind that decides how
|
|
5
|
+
its bytes become a value. One ordering, read by every decoder, so the live read
|
|
6
|
+
(`decodeResponse`) and the synchronous warm read (`warmValueFromSnapshot`) cannot
|
|
7
|
+
classify the same body differently — the divergence that shipped a warm value a
|
|
8
|
+
live read rejected (the streaming-guard-in-one-but-not-the-other bug).
|
|
9
|
+
|
|
10
|
+
`streaming` is tested FIRST and the order is load-bearing: `application/jsonl`
|
|
11
|
+
and `application/x-ndjson` both contain `json`, so a json-first scan would
|
|
12
|
+
mis-bucket a stream as parseable JSON.
|
|
13
|
+
*/
|
|
14
|
+
export type ContentBodyKind = 'streaming' | 'json' | 'text' | 'binary'
|
|
15
|
+
|
|
16
|
+
export function contentBodyKind(contentType: string): ContentBodyKind {
|
|
17
|
+
if (STREAMING_CONTENT_TYPES.some((type) => contentType.startsWith(type))) {
|
|
18
|
+
return 'streaming'
|
|
19
|
+
}
|
|
20
|
+
if (contentType.includes('json')) {
|
|
21
|
+
return 'json'
|
|
22
|
+
}
|
|
23
|
+
if (contentType.startsWith('text/')) {
|
|
24
|
+
return 'text'
|
|
25
|
+
}
|
|
26
|
+
return 'binary'
|
|
27
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { SocketClientFrame } from '../server/sockets/types/SocketClientFrame.ts'
|
|
2
|
+
import type { SocketServerFrame } from '../server/sockets/types/SocketServerFrame.ts'
|
|
3
|
+
import type { SocketChannel } from './types/SocketChannel.ts'
|
|
4
|
+
import type { SocketSubCallbacks } from './types/SocketSubCallbacks.ts'
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
The multiplex bookkeeping every SocketChannel shares: the local subscription
|
|
8
|
+
registry plus the inbound `SocketServerFrame` routing. A channel supplies only
|
|
9
|
+
its transport (`send`, and when to call `drainSubs` on a drop); everything that
|
|
10
|
+
encodes the wire contract — how a `msg` fans out to a socket's subs, how
|
|
11
|
+
`replay`/`end`/`err` address one sub — lives here once, so the browser multiplex
|
|
12
|
+
and the test harness cannot drift on the protocol (ADR-0005).
|
|
13
|
+
|
|
14
|
+
`send` carries an outbound client frame however the channel can (the browser
|
|
15
|
+
queues until open and reconnects; the test harness queues until open). Routing
|
|
16
|
+
and the registry never touch the transport, so they stay identical across both.
|
|
17
|
+
*/
|
|
18
|
+
export function createSocketSubRegistry(send: (frame: SocketClientFrame) => void): {
|
|
19
|
+
channel: SocketChannel
|
|
20
|
+
routeFrame: (frame: SocketServerFrame) => void
|
|
21
|
+
drainSubs: () => SocketSubCallbacks[]
|
|
22
|
+
} {
|
|
23
|
+
const subs = new Map<string, { socket: string; callbacks: SocketSubCallbacks }>()
|
|
24
|
+
/* Reverse index for `msg` fan-out: one server publish addresses a socket, not a sub. */
|
|
25
|
+
const subsBySocket = new Map<string, Set<string>>()
|
|
26
|
+
|
|
27
|
+
function dropSub(id: string): void {
|
|
28
|
+
const entry = subs.get(id)
|
|
29
|
+
if (!entry) {
|
|
30
|
+
return
|
|
31
|
+
}
|
|
32
|
+
subs.delete(id)
|
|
33
|
+
const set = subsBySocket.get(entry.socket)
|
|
34
|
+
if (set) {
|
|
35
|
+
set.delete(id)
|
|
36
|
+
if (set.size === 0) {
|
|
37
|
+
subsBySocket.delete(entry.socket)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/*
|
|
43
|
+
Routes one inbound frame to its sub(s):
|
|
44
|
+
`msg` → every local sub of that socket (addressed by socket name)
|
|
45
|
+
`replay` → the one sub that requested the seed (its batched tail)
|
|
46
|
+
`end`/`err` → the one sub, dropped first so its iterator can't take another frame
|
|
47
|
+
A frame for an unknown sub/socket is ignored — a sub torn down between request
|
|
48
|
+
and delivery.
|
|
49
|
+
*/
|
|
50
|
+
function routeFrame(frame: SocketServerFrame): void {
|
|
51
|
+
if (frame.type === 'msg') {
|
|
52
|
+
const targets = subsBySocket.get(frame.socket)
|
|
53
|
+
if (!targets) {
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
for (const subId of targets) {
|
|
57
|
+
subs.get(subId)?.callbacks.onMessage(frame.message)
|
|
58
|
+
}
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
if (frame.type === 'replay') {
|
|
62
|
+
subs.get(frame.sub)?.callbacks.onReplay(frame.messages)
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
const sub = subs.get(frame.sub)
|
|
66
|
+
if (!sub) {
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
dropSub(frame.sub)
|
|
70
|
+
if (frame.type === 'end') {
|
|
71
|
+
sub.callbacks.onEnd()
|
|
72
|
+
} else {
|
|
73
|
+
sub.callbacks.onError(frame.message)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/* Tear down every sub on a transport drop and hand the caller their callbacks.
|
|
78
|
+
Clears the registry BEFORE the caller runs `onDisconnect`, so a consumer that
|
|
79
|
+
re-subscribes in reaction (in a later microtask) registers onto fresh state. */
|
|
80
|
+
function drainSubs(): SocketSubCallbacks[] {
|
|
81
|
+
const active = [...subs.values()].map((entry) => entry.callbacks)
|
|
82
|
+
subs.clear()
|
|
83
|
+
subsBySocket.clear()
|
|
84
|
+
return active
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const channel: SocketChannel = {
|
|
88
|
+
subscribe(id, socket, replay, callbacks) {
|
|
89
|
+
subs.set(id, { socket, callbacks })
|
|
90
|
+
/* Not getOrInsertComputed: browser-side code, and Safari/Chrome only shipped it within the last browser cycle (26.2 / 145). */
|
|
91
|
+
let set = subsBySocket.get(socket)
|
|
92
|
+
if (!set) {
|
|
93
|
+
set = new Set()
|
|
94
|
+
subsBySocket.set(socket, set)
|
|
95
|
+
}
|
|
96
|
+
set.add(id)
|
|
97
|
+
send({ type: 'sub', sub: id, socket, replay })
|
|
98
|
+
},
|
|
99
|
+
unsubscribe(id) {
|
|
100
|
+
if (!subs.has(id)) {
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
dropSub(id)
|
|
104
|
+
send({ type: 'unsub', sub: id })
|
|
105
|
+
},
|
|
106
|
+
publish(socket, message) {
|
|
107
|
+
send({ type: 'pub', socket, message })
|
|
108
|
+
},
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return { channel, routeFrame, drainSubs }
|
|
112
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
+
import { contentBodyKind } from './contentBodyKind.ts'
|
|
1
2
|
import { contentTypeOf } from './contentTypeOf.ts'
|
|
2
3
|
import { HttpError } from './HttpError.ts'
|
|
3
|
-
import { isStreamingResponse } from './isStreamingResponse.ts'
|
|
4
4
|
|
|
5
5
|
/*
|
|
6
6
|
Decodes a Response into the natural body value based on Content-Type:
|
|
@@ -33,15 +33,16 @@ export async function decodeResponse(response: Response): Promise<unknown> {
|
|
|
33
33
|
return undefined
|
|
34
34
|
}
|
|
35
35
|
const contentType = contentTypeOf(response.headers)
|
|
36
|
-
|
|
36
|
+
const kind = contentBodyKind(contentType)
|
|
37
|
+
if (kind === 'streaming') {
|
|
37
38
|
throw new Error(
|
|
38
39
|
`[abide] response at ${response.url} is a stream (${contentType}) — use tail(fn.stream(args)) for a reactive view, or fn.stream(args) for per-call iteration, instead of awaiting the bare call or cache()`,
|
|
39
40
|
)
|
|
40
41
|
}
|
|
41
|
-
if (
|
|
42
|
+
if (kind === 'json') {
|
|
42
43
|
return response.json()
|
|
43
44
|
}
|
|
44
|
-
if (
|
|
45
|
+
if (kind === 'text') {
|
|
45
46
|
return response.text()
|
|
46
47
|
}
|
|
47
48
|
return response.blob()
|
|
@@ -1,12 +1,12 @@
|
|
|
1
|
+
import { contentBodyKind } from './contentBodyKind.ts'
|
|
1
2
|
import { contentTypeOf } from './contentTypeOf.ts'
|
|
2
|
-
import { STREAMING_CONTENT_TYPES } from './STREAMING_CONTENT_TYPES.ts'
|
|
3
3
|
|
|
4
4
|
/*
|
|
5
5
|
Whether a Response carries a streaming body (SSE / JSONL / NDJSON) by its
|
|
6
|
-
Content-Type, so callers drain it frame-by-frame instead of buffering.
|
|
7
|
-
|
|
6
|
+
Content-Type, so callers drain it frame-by-frame instead of buffering. The
|
|
7
|
+
streaming bucket of the shared `contentBodyKind` classification. Shared by the
|
|
8
|
+
CLI print path and the MCP tool dispatcher.
|
|
8
9
|
*/
|
|
9
10
|
export function isStreamingResponse(response: Response): boolean {
|
|
10
|
-
|
|
11
|
-
return STREAMING_CONTENT_TYPES.some((type) => contentType.startsWith(type))
|
|
11
|
+
return contentBodyKind(contentTypeOf(response.headers)) === 'streaming'
|
|
12
12
|
}
|
|
@@ -2,8 +2,7 @@ import type { Socket } from '../server/sockets/types/Socket.ts'
|
|
|
2
2
|
import type { SocketClientFrame } from '../server/sockets/types/SocketClientFrame.ts'
|
|
3
3
|
import type { SocketServerFrame } from '../server/sockets/types/SocketServerFrame.ts'
|
|
4
4
|
import { buildSocketOverChannel } from '../shared/buildSocketOverChannel.ts'
|
|
5
|
-
import
|
|
6
|
-
import type { SocketSubCallbacks } from '../shared/types/SocketSubCallbacks.ts'
|
|
5
|
+
import { createSocketSubRegistry } from '../shared/createSocketSubRegistry.ts'
|
|
7
6
|
|
|
8
7
|
/*
|
|
9
8
|
Test-side substitute for the browser socketChannel: one ws to the booted
|
|
@@ -23,8 +22,6 @@ export function createTestSocketChannel(wsUrl: string): {
|
|
|
23
22
|
/* `using channel = createTestSocketChannel(url)` — disposal closes the ws. */
|
|
24
23
|
[Symbol.dispose]: () => void
|
|
25
24
|
} {
|
|
26
|
-
const subs = new Map<string, { socket: string; callbacks: SocketSubCallbacks }>()
|
|
27
|
-
const subsBySocket = new Map<string, Set<string>>()
|
|
28
25
|
let pendingSends: string[] = []
|
|
29
26
|
|
|
30
27
|
const ws = new WebSocket(wsUrl)
|
|
@@ -48,20 +45,9 @@ export function createTestSocketChannel(wsUrl: string): {
|
|
|
48
45
|
pendingSends.push(message)
|
|
49
46
|
}
|
|
50
47
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
return
|
|
55
|
-
}
|
|
56
|
-
subs.delete(id)
|
|
57
|
-
const set = subsBySocket.get(entry.socket)
|
|
58
|
-
if (set) {
|
|
59
|
-
set.delete(id)
|
|
60
|
-
if (set.size === 0) {
|
|
61
|
-
subsBySocket.delete(entry.socket)
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
}
|
|
48
|
+
/* Same sub registry + frame routing the browser channel uses; this harness owns
|
|
49
|
+
only the bare ws (no reconnect — a test drives the lifecycle through close()). */
|
|
50
|
+
const registry = createSocketSubRegistry(send)
|
|
65
51
|
|
|
66
52
|
ws.addEventListener('open', flushPending)
|
|
67
53
|
ws.addEventListener('message', (event) => {
|
|
@@ -71,70 +57,23 @@ export function createTestSocketChannel(wsUrl: string): {
|
|
|
71
57
|
} catch {
|
|
72
58
|
return
|
|
73
59
|
}
|
|
74
|
-
|
|
75
|
-
const targets = subsBySocket.get(frame.socket)
|
|
76
|
-
if (!targets) {
|
|
77
|
-
return
|
|
78
|
-
}
|
|
79
|
-
for (const subId of targets) {
|
|
80
|
-
subs.get(subId)?.callbacks.onMessage(frame.message)
|
|
81
|
-
}
|
|
82
|
-
return
|
|
83
|
-
}
|
|
84
|
-
if (frame.type === 'replay') {
|
|
85
|
-
subs.get(frame.sub)?.callbacks.onReplay(frame.messages)
|
|
86
|
-
return
|
|
87
|
-
}
|
|
88
|
-
const sub = subs.get(frame.sub)
|
|
89
|
-
if (!sub) {
|
|
90
|
-
return
|
|
91
|
-
}
|
|
92
|
-
dropSub(frame.sub)
|
|
93
|
-
if (frame.type === 'end') {
|
|
94
|
-
sub.callbacks.onEnd()
|
|
95
|
-
} else {
|
|
96
|
-
sub.callbacks.onError(frame.message)
|
|
97
|
-
}
|
|
60
|
+
registry.routeFrame(frame)
|
|
98
61
|
})
|
|
99
62
|
/* A drop after subs are live is unexpected; surface it so iterators unblock
|
|
100
63
|
instead of awaiting a frame that never comes. Idempotent — the first of
|
|
101
|
-
error/close
|
|
64
|
+
error/close drains the subs, the second finds none. error covers a failed
|
|
102
65
|
handshake (no open, no clean close) that close alone would miss. */
|
|
103
66
|
function disconnectAll(): void {
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
subsBySocket.clear()
|
|
107
|
-
for (const sub of active) {
|
|
108
|
-
sub.callbacks.onDisconnect()
|
|
67
|
+
for (const callbacks of registry.drainSubs()) {
|
|
68
|
+
callbacks.onDisconnect()
|
|
109
69
|
}
|
|
110
70
|
}
|
|
111
71
|
ws.addEventListener('close', disconnectAll)
|
|
112
72
|
ws.addEventListener('error', disconnectAll)
|
|
113
73
|
|
|
114
|
-
const channel: SocketChannel = {
|
|
115
|
-
subscribe(id, socket, replay, callbacks) {
|
|
116
|
-
subs.set(id, { socket, callbacks })
|
|
117
|
-
let set = subsBySocket.get(socket)
|
|
118
|
-
if (!set) {
|
|
119
|
-
set = new Set()
|
|
120
|
-
subsBySocket.set(socket, set)
|
|
121
|
-
}
|
|
122
|
-
set.add(id)
|
|
123
|
-
send({ type: 'sub', sub: id, socket, replay })
|
|
124
|
-
},
|
|
125
|
-
unsubscribe(id) {
|
|
126
|
-
if (!subs.has(id)) {
|
|
127
|
-
return
|
|
128
|
-
}
|
|
129
|
-
dropSub(id)
|
|
130
|
-
send({ type: 'unsub', sub: id })
|
|
131
|
-
},
|
|
132
|
-
publish: (socket, message) => send({ type: 'pub', socket, message }),
|
|
133
|
-
}
|
|
134
|
-
|
|
135
74
|
/* Same Socket<T> builder the browser proxy uses, over this test channel. */
|
|
136
75
|
function socket<T>(name: string): Socket<T> {
|
|
137
|
-
return buildSocketOverChannel<T>(name, () => channel)
|
|
76
|
+
return buildSocketOverChannel<T>(name, () => registry.channel)
|
|
138
77
|
}
|
|
139
78
|
|
|
140
79
|
const close = () => ws.close()
|
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' },
|