@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.
Files changed (40) hide show
  1. package/AGENTS.md +3 -3
  2. package/CHANGELOG.md +50 -0
  3. package/README.md +2 -2
  4. package/package.json +2 -1
  5. package/src/abideResolverPlugin.ts +53 -9
  6. package/src/lib/server/runtime/STREAMED_HTML_HEADER.ts +13 -0
  7. package/src/lib/server/runtime/buildPreloadManifest.ts +151 -0
  8. package/src/lib/server/runtime/createServer.ts +4 -1
  9. package/src/lib/server/runtime/createUiPageRenderer.ts +104 -9
  10. package/src/lib/server/runtime/flushingGzipStream.ts +62 -0
  11. package/src/lib/server/runtime/gzipResponse.ts +23 -11
  12. package/src/lib/server/runtime/serializeCacheSnapshot.ts +22 -33
  13. package/src/lib/server/runtime/snapshotEntryFromCache.ts +9 -0
  14. package/src/lib/server/sockets/defineSocket.ts +6 -1
  15. package/src/lib/shared/cacheEntryFromSnapshot.ts +14 -12
  16. package/src/lib/shared/contentBodyKind.ts +27 -0
  17. package/src/lib/shared/createSocketSubRegistry.ts +112 -0
  18. package/src/lib/shared/decodeResponse.ts +5 -4
  19. package/src/lib/shared/isStreamingResponse.ts +5 -5
  20. package/src/lib/test/createTestSocketChannel.ts +9 -70
  21. package/src/lib/ui/README.md +1 -1
  22. package/src/lib/ui/compile/REACTIVE_CALLEES.ts +6 -6
  23. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +1 -0
  24. package/src/lib/ui/compile/compileShadow.ts +60 -55
  25. package/src/lib/ui/compile/desugarSignals.ts +93 -38
  26. package/src/lib/ui/compile/generateSSR.ts +28 -47
  27. package/src/lib/ui/compile/lowerDocAccess.ts +26 -11
  28. package/src/lib/ui/compile/parseTemplate.ts +8 -5
  29. package/src/lib/ui/compile/prepareNestedScript.ts +2 -2
  30. package/src/lib/ui/compile/renameSignalRefs.ts +153 -23
  31. package/src/lib/ui/compile/skeletonContext.ts +83 -0
  32. package/src/lib/ui/compile/types/SkeletonContext.ts +15 -0
  33. package/src/lib/ui/compile/types/TemplateAttr.ts +4 -2
  34. package/src/lib/ui/dom/applyResolved.ts +27 -7
  35. package/src/lib/ui/installHotBridge.ts +2 -0
  36. package/src/lib/ui/runtime/escapeKey.ts +10 -4
  37. package/src/lib/ui/seedStreamedResolution.ts +22 -0
  38. package/src/lib/ui/socketChannel.ts +18 -98
  39. package/src/lib/ui/startClient.ts +17 -3
  40. 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, piping the body through a CompressionStream so a buffered
15
- body and a streamed SSR document take the identical path. Static assets never
16
- reach here — they already carry a precompressed Content-Encoding, which
17
- short-circuits the first guard. Skipped for: already-encoded bodies, bodiless
18
- responses (204/304/HEAD), non-compressible types (images, fonts, archives),
19
- and frame-delimited streams (SSE/jsonl, where gzip buffering would stall
20
- per-frame flush). No byte-size floor: Bun doesn't expose a string body's length
21
- before send, and measuring would mean buffering the streamed SSR document —
22
- the framing overhead on the rare tiny body is negligible against compressing
23
- every page and rpc payload.
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
- return new Response(response.body.pipeThrough(new CompressionStream('gzip')), {
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
- Partitions the request-scoped cache for SSR. Entries settled by the time
10
- `render()` returns were consumed via `await` (render blocked on them) and ship
11
- inline in the document's `__SSR__` blob. Entries still pending were consumed
12
- via `{#await}` render emitted their pending branch without blocking so they
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
- Unlike the old buffer-everything path, this never awaits the pending promises:
16
- that's the whole point of streaming. Settled entries are read concurrently (the
17
- awaits are immediate since they're already resolved, but their body reads run in
18
- parallel); pending entries are handed back as-is for the streamer to await one
19
- chunk at a time.
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<CacheSnapshot> {
22
- const settled: CacheEntry[] = []
23
- const pending: CacheEntry[] = []
24
- for (const entry of store.entries.values()) {
25
- /* Producer entries carry no wire request nothing to rehydrate against, skip. */
26
- if (!entry.request) {
27
- continue
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
- const inline = snapshots.filter(
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
- server.publish(topic, JSON.stringify({ type: 'msg', socket: name, message: validated }))
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. Mirrors decodeResponse for the textual cases the
35
- snapshot ships; non-2xx and 204 yield no warm value and fall back to the async
36
- path, which throws HttpError / returns undefined exactly as a live call would.
37
- Binary/xml bodies also skip the warm path and decode asynchronously.
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 contentType = contentTypeOf(headers)
44
- if (contentType.includes('json')) {
45
- /*
46
- `.includes('json')` also matches streaming/non-JSON types like
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 (contentType.startsWith('text/')) {
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
- if (isStreamingResponse(response)) {
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 (contentType.includes('json')) {
42
+ if (kind === 'json') {
42
43
  return response.json()
43
44
  }
44
- if (contentType.startsWith('text/')) {
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
- Shared by the CLI print path and the MCP tool dispatcher.
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
- const contentType = contentTypeOf(response.headers)
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 type { SocketChannel } from '../shared/types/SocketChannel.ts'
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
- function dropSub(id: string): void {
52
- const entry = subs.get(id)
53
- if (!entry) {
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
- if (frame.type === 'msg') {
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 clears subs, the second finds none. error covers a failed
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 active = [...subs.values()]
105
- subs.clear()
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()
@@ -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)`, `prop(name)`.
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(...)`, `prop(...)`): the shared
3
- "is this a reactive binding" allowlist read by the desugarer, the nested-script
4
- scoper, and the type-checking shadow. How each lowers — a serializable doc slot
5
- vs a `.value` cell — is decided per-site; this is only the membership set, so a
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
- 'prop',
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' },