@abide/abide 0.34.1 → 0.34.2

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 CHANGED
@@ -1,5 +1,19 @@
1
1
  # abide
2
2
 
3
+ ## 0.34.2
4
+
5
+ ### Patch Changes
6
+
7
+ - [`91b03c5`](https://github.com/briancray/abide/commit/91b03c540e7139205f1518a6e2429411df43b859) - enforce the msg frame against the shared wire type ([`77fedf1`](https://github.com/briancray/abide/commit/77fedf17787b8bd8fbcdc56c722016ec4e905836))
8
+
9
+ - [`91b03c5`](https://github.com/briancray/abide/commit/91b03c540e7139205f1518a6e2429411df43b859) - extract the shared sub registry + frame routing ([`b81dcce`](https://github.com/briancray/abide/commit/b81dcceb63ccd92dd04f9108173510e8c132a02c))
10
+
11
+ - [`91b03c5`](https://github.com/briancray/abide/commit/91b03c540e7139205f1518a6e2429411df43b859) - classify response body kind once for live and warm decode ([`bd4fb4c`](https://github.com/briancray/abide/commit/bd4fb4cec2813f3d58ee6b277d2428ba75e521ae))
12
+
13
+ - [`c1242f3`](https://github.com/briancray/abide/commit/c1242f3bd4343c760362950d7953a172b2d112f5) - fix(cache): honor the streaming guard in the SSR cache-snapshot round-trip. The snapshot path's content-type classifiers were hand-mirrored against `decodeResponse` but omitted its `isStreamingResponse` refusal, so a `cache()`d GET to a streaming endpoint (SSE / NDJSON / JSONL) would (a) hang SSR — `snapshotEntryFromCache` called `response.text()` on a never-ending body — and (b) break isomorphism — `warmValueFromSnapshot` warm-decoded the body to a value while a live read throws the "use tail()/stream()" error. The server now skips streaming responses (shared `isStreamingResponse`) and the warm decoder defers them to the async path, keeping it a strict subset of `decodeResponse`.
14
+
15
+ - [`f710ba8`](https://github.com/briancray/abide/commit/f710ba8a6266f67c7ff03d9117e1484a7c63f59a) - fix(ui): single shared `skeletonContext` pass drives `<!--a-->` anchor placement for both back-ends, so the SSR string and client build can't disagree about skeleton markers. Previously the server tracked skeleton position as mutable traversal state reset at each fresh-context boundary; a forgotten reset (component slot content, snippet bodies) leaked an anchor the client never emitted, desyncing hydration. Boundaries are now enumerated once, declaratively. Adds a generative, reference-checked congruence harness (`uiRenderCongruenceFuzz`) that combinatorially nests every fresh-context boundary inside skeletonable parents and checks marker congruence + content against a by-construction reference + a hydration round-trip — catching this whole drift class without hand-enumerating shapes.
16
+
3
17
  ## 0.34.1
4
18
 
5
19
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abide/abide",
3
- "version": "0.34.1",
3
+ "version": "0.34.2",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "description": "Isomorphic multimodal HTTP framework built for humans and machines in a single Bun runtime",
@@ -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()
@@ -2,10 +2,9 @@ import { OUTLET_TAG } from '../runtime/OUTLET_TAG.ts'
2
2
  import { componentWrapperTag } from './componentWrapperTag.ts'
3
3
  import { groupBindParts } from './groupBindParts.ts'
4
4
  import { isControlFlow } from './isControlFlow.ts'
5
- import { isTextLeaf } from './isTextLeaf.ts'
6
5
  import { lowerContext } from './lowerContext.ts'
7
6
  import { scopeAttr } from './scopeAttr.ts'
8
- import { skeletonable } from './skeletonable.ts'
7
+ import { skeletonContext } from './skeletonContext.ts'
9
8
  import { staticAttr } from './staticAttr.ts'
10
9
  import { staticTextPart } from './staticTextPart.ts'
11
10
  import { stripEffects } from './stripEffects.ts'
@@ -65,41 +64,33 @@ export function generateSSR(
65
64
  return children.map((child) => generate(child, target)).join('')
66
65
  }
67
66
 
67
+ /* Per-node skeleton position, computed once. Both back-ends read this single source of
68
+ truth so their `<!--a-->` anchor placement cannot drift — the fresh-context boundaries
69
+ (control-flow branches, component/slot/snippet content) are enumerated there, not
70
+ re-tracked here as mutable state that a forgotten reset could leak past. */
71
+ const { inSkeleton, markText } = skeletonContext(nodes)
72
+
68
73
  /* A control-flow branch's content, generated exactly like a normal child list so
69
74
  a branch holds ANY content (components, text, nested blocks). `generate` emits
70
75
  nested `<script>`s in document order; `withNestedScripts` puts their bindings in
71
76
  scope — matching the client build, so hydration stays aligned. The caller wraps
72
77
  it in the `[ … ]` range markers the runtime tracks (unconditionally per block,
73
- so an empty/false branch still emits the boundary the client claims). */
78
+ so an empty/false branch still emits the boundary the client claims). The branch's
79
+ fresh build context is already recorded by `skeletonContext`, so its children read
80
+ their own (reset) position — no flag juggling here. */
74
81
  function branchContent(children: TemplateNode[], target: string): string {
75
- /* A control-flow branch is a fresh build context — the block runtime mounts it, not
76
- the parent skeleton — so reset the skeleton/anchor tracking; the branch's own
77
- skeletonable elements re-enter it. */
78
- const previousSkeleton = inSkeleton
79
- const previousMark = markText
80
- inSkeleton = false
81
- markText = false
82
- const out = withNestedScripts(children, () => generateInto(children, target))
83
- inSkeleton = previousSkeleton
84
- markText = previousMark
85
- return out
82
+ return withNestedScripts(children, () => generateInto(children, target))
86
83
  }
87
84
  const openRange = (target: string): string => push(target, RANGE_OPEN)
88
85
  const closeRange = (target: string): string => push(target, RANGE_CLOSE)
89
86
 
90
- /* True inside a skeletonable subtree; `markText` true when, additionally, the current
91
- element is NOT a text-leaf — so its reactive text is interleaved and the client uses
92
- an `<!--a-->` anchor. The marker is kept both sides (like control-flow ranges), so
93
- SSR markup stays identical to the client DOM. */
94
- let inSkeleton = false
95
- let markText = false
96
-
97
87
  /* In a skeleton, a control-flow block or slot is positioned by an `<!--a-->` anchor
98
88
  (cloned into the located parent), so it can sit anywhere among static siblings.
99
89
  Emitted both sides in document order — the client's anchor scan lines up with it.
100
90
  Outside a skeleton (top-level / inside a branch) blocks mount on the host directly,
101
91
  so no anchor. */
102
- const anchorMark = (target: string): string => (inSkeleton ? push(target, '<!--a-->') : '')
92
+ const anchorMark = (node: TemplateNode, target: string): string =>
93
+ inSkeleton.get(node) ? push(target, '<!--a-->') : ''
103
94
 
104
95
  function generate(node: TemplateNode, target: string): string {
105
96
  /* A control-flow block is positioned by an `<!--a-->` anchor when in a skeleton
@@ -107,7 +98,7 @@ export function generateSSR(
107
98
  mirroring the client's single `skeletonMarkup` control-flow branch — rather than
108
99
  prefixing each block kind's own `anchorMark` call (six sites that had to stay in
109
100
  lock-step). `anchorMark` no-ops outside a skeleton, so non-block nodes ignore it. */
110
- const anchor = isControlFlow(node) ? anchorMark(target) : ''
101
+ const anchor = isControlFlow(node) ? anchorMark(node, target) : ''
111
102
  if (node.kind === 'text') {
112
103
  return node.parts
113
104
  .map((part) => {
@@ -116,7 +107,7 @@ export function generateSSR(
116
107
  return markup === '' ? '' : push(target, markup)
117
108
  }
118
109
  const value = `$text(${lowerExpression(part.code)})`
119
- return markText
110
+ return markText.get(node)
120
111
  ? `${target}.push('<!--a-->' + ${value});\n`
121
112
  : `${target}.push(${value});\n`
122
113
  })
@@ -197,6 +188,11 @@ export function generateSSR(
197
188
  const parts = node.props.map(
198
189
  (prop) => `${JSON.stringify(prop.name)}: () => (${lowerExpression(prop.code)})`,
199
190
  )
191
+ /* Slot content is a fresh build context — the child's `<slot>` mounts it via
192
+ `mountSlot`, not the parent skeleton clone, and the client builds it through
193
+ `componentParts`/`generateChildren` (never the skeleton path). `skeletonContext`
194
+ records it reset, so its children emit no enclosing-skeleton anchors the client
195
+ slot builder would lack. */
200
196
  const slotCode = generateInto(node.children, '$slot')
201
197
  if (slotCode.trim() !== '') {
202
198
  parts.push(
@@ -256,21 +252,11 @@ export function generateSSR(
256
252
  }
257
253
  code += push(target, '>')
258
254
  if (!VOID_TAGS.has(node.tag)) {
259
- /* Track skeleton context: reactive text gets an `<!--a-->` anchor only when
260
- interleaved (this element has non-text children) inside a skeletonable
261
- subtree matching the client's anchor vs marker-free text-leaf choice. */
262
- const entering = !inSkeleton && skeletonable(node)
263
- if (entering) {
264
- inSkeleton = true
265
- }
266
- const previousMark = markText
267
- markText = inSkeleton && !isTextLeaf(node)
268
- /* A `<script>` child scopes its bindings to this element's subtree. */
255
+ /* Each child's skeleton position (whether its reactive text interleaves into an
256
+ anchor, whether a nested block anchors) is already recorded by `skeletonContext`
257
+ read per node, not tracked here. A `<script>` child scopes its bindings to
258
+ this element's subtree. */
269
259
  code += withNestedScripts(node.children, () => generateInto(node.children, target))
270
- markText = previousMark
271
- if (entering) {
272
- inSkeleton = false
273
- }
274
260
  code += push(target, `</${node.tag}>`)
275
261
  }
276
262
  return code
@@ -281,19 +267,14 @@ export function generateSSR(
281
267
  by an `<!--a-->` anchor and its content bounded by a `[ … ]` range (matching the
282
268
  client's `mountSlot`), so it can sit among static siblings. The fallback is a fresh,
283
269
  non-skeleton build context — the client builds it via `mountSlot`/`fillBefore`, not the
284
- skeleton clone — so its reactive text takes no anchor (reset like `branchContent`). */
270
+ skeleton clone — so its reactive text takes no anchor (`skeletonContext` records the
271
+ fallback children reset). */
285
272
  function generateSlot(
286
273
  node: Extract<TemplateNode, { kind: 'element' }>,
287
274
  target: string,
288
275
  ): string {
289
- const wrap = inSkeleton
290
- const previousSkeleton = inSkeleton
291
- const previousMark = markText
292
- inSkeleton = false
293
- markText = false
276
+ const wrap = inSkeleton.get(node)
294
277
  const fallback = generateInto(node.children, target)
295
- inSkeleton = previousSkeleton
296
- markText = previousMark
297
278
  const body =
298
279
  fallback.trim() === ''
299
280
  ? `if ($props && $props.$children) { ${target}.push($props.$children()); }\n`
@@ -301,7 +282,7 @@ export function generateSSR(
301
282
  if (!wrap) {
302
283
  return body
303
284
  }
304
- return `${anchorMark(target)}${openRange(target)}${body}${closeRange(target)}`
285
+ return `${anchorMark(node, target)}${openRange(target)}${body}${closeRange(target)}`
305
286
  }
306
287
 
307
288
  /* Boundary markers + a `$awaits` registration carrying the promise and
@@ -0,0 +1,83 @@
1
+ import { isControlFlow } from './isControlFlow.ts'
2
+ import { isTextLeaf } from './isTextLeaf.ts'
3
+ import { skeletonable } from './skeletonable.ts'
4
+ import type { SkeletonContext } from './types/SkeletonContext.ts'
5
+ import type { TemplateNode } from './types/TemplateNode.ts'
6
+
7
+ /*
8
+ The single source of truth for where skeleton markers go. One top-down walk records,
9
+ per node, whether it sits inside a parser-backed skeleton clone (`inSkeleton`) and
10
+ whether its reactive text is interleaved (`markText`) — the two facts that decide
11
+ `<!--a-->` anchor placement. Both back-ends read this instead of re-deriving the
12
+ position (the client structurally, the server as mutable traversal state), which is
13
+ what let them drift: a fresh-context boundary the server forgot to reset leaked an
14
+ anchor the client never emitted, desyncing hydration.
15
+
16
+ A fresh-context boundary resets to NOT-in-skeleton, because the content there is
17
+ built by its own runtime (a control-flow block's branch, a component's slot content,
18
+ a `<slot>`'s fallback, a snippet's body) — never cloned by the enclosing skeleton —
19
+ so an enclosing skeletonable subtree must not stamp markers onto it. Enumerating
20
+ every such boundary HERE, once, makes "forgetting to reset one" impossible.
21
+ */
22
+ export function skeletonContext(nodes: TemplateNode[]): SkeletonContext {
23
+ const inSkeleton = new WeakMap<TemplateNode, boolean>()
24
+ const markText = new WeakMap<TemplateNode, boolean>()
25
+
26
+ /* Walk `node` carrying the context that applies AT it; recurse into children with the
27
+ context that applies to THEM. */
28
+ function visit(node: TemplateNode, nodeInSkeleton: boolean, nodeMarkText: boolean): void {
29
+ inSkeleton.set(node, nodeInSkeleton)
30
+ markText.set(node, nodeMarkText)
31
+
32
+ /* Control-flow branches, component slot content, and snippet bodies are fresh build
33
+ contexts — their children re-enter the skeleton only via their own skeletonable
34
+ elements, so reset both flags. (A standalone branch/case is reached as a child of
35
+ its control-flow node and likewise resets.) */
36
+ if (isControlFlow(node) || node.kind === 'component' || node.kind === 'snippet') {
37
+ for (const child of childrenOf(node)) {
38
+ visit(child, false, false)
39
+ }
40
+ return
41
+ }
42
+ /* A `branch`/`case` is a transparent grouping inside its control-flow block (its
43
+ children are generated directly, the wrapper never emits markup) — pass the
44
+ already-reset context through so a skeletonable element inside it re-enters. */
45
+ if (node.kind === 'branch' || node.kind === 'case') {
46
+ for (const child of node.children) {
47
+ visit(child, nodeInSkeleton, nodeMarkText)
48
+ }
49
+ return
50
+ }
51
+ if (node.kind !== 'element') {
52
+ return // text / script / style carry no skeleton children
53
+ }
54
+ if (node.tag === 'slot') {
55
+ /* The slot's own children are its fallback — a fresh context built by `mountSlot`,
56
+ not the enclosing clone. */
57
+ for (const child of node.children) {
58
+ visit(child, false, false)
59
+ }
60
+ return
61
+ }
62
+ /* A skeletonable element not already in a skeleton opens one; its descendants are in
63
+ skeleton. Reactive text interleaves (takes an anchor) unless this element is a
64
+ text-leaf (only text/style children), which binds its text marker-free. */
65
+ const childInSkeleton = nodeInSkeleton || skeletonable(node)
66
+ const childMarkText = childInSkeleton && !isTextLeaf(node)
67
+ for (const child of node.children) {
68
+ visit(child, childInSkeleton, childMarkText)
69
+ }
70
+ }
71
+
72
+ for (const node of nodes) {
73
+ visit(node, false, false)
74
+ }
75
+ return { inSkeleton, markText }
76
+ }
77
+
78
+ /* The child list of any node that has one (control-flow, component, snippet, element);
79
+ `[]` for the leaf kinds (text/script/style). `isControlFlow` is not a type guard, so the
80
+ `in` check is what narrows the union for the fresh-context branch. */
81
+ function childrenOf(node: TemplateNode): TemplateNode[] {
82
+ return 'children' in node ? node.children : []
83
+ }
@@ -0,0 +1,15 @@
1
+ import type { TemplateNode } from './TemplateNode.ts'
2
+
3
+ /*
4
+ The per-node skeleton position both back-ends consult so their marker placement
5
+ cannot drift. `inSkeleton` is true when a node sits inside a parser-backed skeleton
6
+ clone (so control-flow blocks and `<slot>`s take an `<!--a-->` anchor at their
7
+ position); `markText` is true when, additionally, the node's reactive text is
8
+ interleaved with element siblings (so it takes an `<!--a-->` anchor rather than
9
+ binding marker-free on a text-leaf element). Keyed by node identity — both maps are
10
+ filled by one shared `skeletonContext` pass over the parsed tree.
11
+ */
12
+ export type SkeletonContext = {
13
+ inSkeleton: WeakMap<TemplateNode, boolean>
14
+ markText: WeakMap<TemplateNode, boolean>
15
+ }
@@ -1,24 +1,17 @@
1
1
  import type { SocketClientFrame } from '../server/sockets/types/SocketClientFrame.ts'
2
2
  import type { SocketServerFrame } from '../server/sockets/types/SocketServerFrame.ts'
3
+ import { createSocketSubRegistry } from '../shared/createSocketSubRegistry.ts'
3
4
  import { SOCKETS_PATH } from '../shared/SOCKETS_PATH.ts'
4
5
  import type { SocketChannel } from '../shared/types/SocketChannel.ts'
5
- import type { SocketSubCallbacks } from '../shared/types/SocketSubCallbacks.ts'
6
6
  import { withBase } from '../shared/withBase.ts'
7
7
 
8
8
  let singleton: SocketChannel | undefined
9
9
 
10
10
  /*
11
- Lazily opens the single multiplexed ws used by every socket proxy on
12
- the page. Routes inbound frames:
13
- `msg` all local subs of that socket
14
- `replay` the matching sub (its batched seed)
15
- `end` → the matching sub
16
- `err` → the matching sub
17
-
18
- `msg` frames carry no sub id: one publish from the server fans out to
19
- every connected ws via Bun's native publish, and each ws delivers the
20
- message to every local sub of that socket. `replay`/`end`/`err` are
21
- per-sub — a seed batch belongs to the sub that requested it.
11
+ Lazily opens the single multiplexed ws used by every socket proxy on the page.
12
+ The sub registry + inbound `msg`/`replay`/`end`/`err` routing lives in
13
+ `createSocketSubRegistry` (shared with the test harness); this channel owns the
14
+ transport that wraps it connect, reconnect/backoff, and visibility.
22
15
 
23
16
  Outbound frames sent before `ws.onopen` fires are queued and flushed
24
17
  on open. The channel reconnects on close with bounded backoff;
@@ -46,8 +39,6 @@ export function getSocketChannel(): SocketChannel {
46
39
  if (singleton) {
47
40
  return singleton
48
41
  }
49
- const subs = new Map<string, { socket: string; callbacks: SocketSubCallbacks }>()
50
- const subsBySocket = new Map<string, Set<string>>()
51
42
  let ws: WebSocket | undefined
52
43
  let pendingSends: string[] = []
53
44
  let backoffMs = 250
@@ -73,6 +64,10 @@ export function getSocketChannel(): SocketChannel {
73
64
  connect()
74
65
  }
75
66
 
67
+ /* The local sub registry + inbound frame routing; this channel owns only the
68
+ transport (connect/reconnect/backoff/visibility) around it. */
69
+ const registry = createSocketSubRegistry(send)
70
+
76
71
  function connect(): void {
77
72
  /* Backoff window owns reconnection; queued frames flush when its attempt opens. */
78
73
  if (reconnectTimer !== undefined) {
@@ -99,64 +94,26 @@ export function getSocketChannel(): SocketChannel {
99
94
  } catch {
100
95
  return
101
96
  }
102
- if (frame.type === 'msg') {
103
- /*
104
- One Bun-published frame fans out to every local sub of
105
- that socket on this ws — addressed by socket name, not
106
- per-sub id.
107
- */
108
- const targets = subsBySocket.get(frame.socket)
109
- if (!targets) {
110
- return
111
- }
112
- for (const subId of targets) {
113
- subs.get(subId)?.callbacks.onMessage(frame.message)
114
- }
115
- return
116
- }
117
- if (frame.type === 'replay') {
118
- subs.get(frame.sub)?.callbacks.onReplay(frame.messages)
119
- return
120
- }
121
- if (frame.type === 'end') {
122
- const sub = subs.get(frame.sub)
123
- if (!sub) {
124
- return
125
- }
126
- dropSub(frame.sub)
127
- sub.callbacks.onEnd()
128
- return
129
- }
130
- if (frame.type === 'err') {
131
- const sub = subs.get(frame.sub)
132
- if (!sub) {
133
- return
134
- }
135
- dropSub(frame.sub)
136
- sub.callbacks.onError(frame.message)
137
- return
138
- }
97
+ registry.routeFrame(frame)
139
98
  })
140
99
  ws.addEventListener('close', () => {
141
- const active = [...subs.values()]
142
- subs.clear()
143
- subsBySocket.clear()
100
+ const active = registry.drainSubs()
144
101
  /*
145
102
  Drop any queued frames too. We've just torn down every local
146
103
  sub, so replaying their `sub`/`unsub`/`pub` frames on
147
104
  reconnect would open ghost subscriptions on the server that
148
105
  no client object tracks (and never gets an `unsub`). This
149
106
  keeps the "channel never re-subscribes" contract above
150
- honest — consumers re-open fresh subs. Cleared before the
151
- callbacks run so a consumer reacting to the disconnect (its
152
- catch resolves in a microtask, after this handler) queues
153
- onto a fresh list.
107
+ honest — consumers re-open fresh subs. `drainSubs` cleared the
108
+ registry before these callbacks run so a consumer reacting to
109
+ the disconnect (its catch resolves in a microtask, after this
110
+ handler) registers onto a fresh list.
154
111
  */
155
112
  const hadPending = pendingSends.length > 0
156
113
  pendingSends = []
157
114
  ws = undefined
158
- for (const sub of active) {
159
- sub.callbacks.onDisconnect()
115
+ for (const callbacks of active) {
116
+ callbacks.onDisconnect()
160
117
  }
161
118
  if (active.length === 0 && !hadPending) {
162
119
  return
@@ -169,21 +126,6 @@ export function getSocketChannel(): SocketChannel {
169
126
  })
170
127
  }
171
128
 
172
- function dropSub(id: string): void {
173
- const entry = subs.get(id)
174
- if (!entry) {
175
- return
176
- }
177
- subs.delete(id)
178
- const set = subsBySocket.get(entry.socket)
179
- if (set) {
180
- set.delete(id)
181
- if (set.size === 0) {
182
- subsBySocket.delete(entry.socket)
183
- }
184
- }
185
- }
186
-
187
129
  /*
188
130
  Release the ws when the tab hides rather than hold an idle connection the
189
131
  browser throttles. Closing rides the normal drop path (close handler →
@@ -200,28 +142,6 @@ export function getSocketChannel(): SocketChannel {
200
142
  }
201
143
  })
202
144
 
203
- singleton = {
204
- subscribe(id, socket, replay, callbacks) {
205
- subs.set(id, { socket, callbacks })
206
- /* Not getOrInsertComputed: browser-side code, and Safari/Chrome only shipped it within the last browser cycle (26.2 / 145). */
207
- let set = subsBySocket.get(socket)
208
- if (!set) {
209
- set = new Set()
210
- subsBySocket.set(socket, set)
211
- }
212
- set.add(id)
213
- send({ type: 'sub', sub: id, socket, replay })
214
- },
215
- unsubscribe(id) {
216
- if (!subs.has(id)) {
217
- return
218
- }
219
- dropSub(id)
220
- send({ type: 'unsub', sub: id })
221
- },
222
- publish(socket, message) {
223
- send({ type: 'pub', socket, message })
224
- },
225
- }
145
+ singleton = registry.channel
226
146
  return singleton
227
147
  }