@abide/abide 0.34.0 → 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 +20 -0
- package/package.json +1 -1
- 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/compile/compileShadow.ts +11 -4
- package/src/lib/ui/compile/generateSSR.ts +28 -47
- package/src/lib/ui/compile/skeletonContext.ts +83 -0
- package/src/lib/ui/compile/types/SkeletonContext.ts +15 -0
- package/src/lib/ui/socketChannel.ts +18 -98
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
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
|
+
|
|
17
|
+
## 0.34.1
|
|
18
|
+
|
|
19
|
+
### Patch Changes
|
|
20
|
+
|
|
21
|
+
- [`fcaf01b`](https://github.com/briancray/abide/commit/fcaf01bf160e300258568a0614f6b3cbef11e585) - project linked as a writable let in the typecheck shadow ([`cc9f3aa`](https://github.com/briancray/abide/commit/cc9f3aa0573be03318cbd8c8e389035fc6683408))
|
|
22
|
+
|
|
3
23
|
## 0.34.0
|
|
4
24
|
|
|
5
25
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -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()
|
|
@@ -280,14 +280,21 @@ function scopeLineFor(
|
|
|
280
280
|
const typeNode = call.typeArguments?.[0]
|
|
281
281
|
const annotation = typeNode === undefined ? '' : `: ${verbatim(typeNode)}`
|
|
282
282
|
const fn = call.arguments[0]
|
|
283
|
+
/* `linked` is a writable `State<T>` at runtime (it reseeds AND accepts `.value =`
|
|
284
|
+
writes), so project it as `let`; `computed` is genuinely read-only, so `const`. */
|
|
285
|
+
const keyword = callee === 'linked' ? 'let' : 'const'
|
|
286
|
+
/* binding-name map offset = past the keyword + space (`let ` = 4, `const ` = 6) */
|
|
287
|
+
const keywordOffset = keyword.length + 1
|
|
283
288
|
if (fn === undefined) {
|
|
284
|
-
|
|
285
|
-
|
|
289
|
+
return {
|
|
290
|
+
text: `${keyword} ${name} = undefined;`,
|
|
291
|
+
segments: [span(declaration.name, keywordOffset)],
|
|
292
|
+
}
|
|
286
293
|
}
|
|
287
|
-
const prefix =
|
|
294
|
+
const prefix = `${keyword} ${name}${annotation} = (`
|
|
288
295
|
return {
|
|
289
296
|
text: `${prefix}${verbatim(fn)})();`,
|
|
290
|
-
segments: [span(declaration.name,
|
|
297
|
+
segments: [span(declaration.name, keywordOffset), span(fn, prefix.length)],
|
|
291
298
|
}
|
|
292
299
|
}
|
|
293
300
|
/* prop<T>('key'): Props field `key[?]: T`, scope binding read from props. */
|
|
@@ -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 {
|
|
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
|
-
|
|
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 =>
|
|
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
|
-
/*
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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 (
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
151
|
-
callbacks run so a consumer reacting to
|
|
152
|
-
catch resolves in a microtask, after this
|
|
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
|
|
159
|
-
|
|
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
|
}
|