@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
@@ -130,7 +130,7 @@ export function parseTemplate(source: string, baseOffset = 0): { nodes: Template
130
130
  cursor += 1
131
131
  }
132
132
  if (source.charAt(cursor) !== '=') {
133
- attrs.push({ kind: 'static', name, value: '' }) // boolean attribute
133
+ attrs.push({ kind: 'static', name, value: '', bare: true }) // boolean attribute
134
134
  continue
135
135
  }
136
136
  cursor += 1 // past '='
@@ -284,13 +284,16 @@ function rejectStrayBranches(
284
284
  /* Turns a component's attributes into props. A component has no directives —
285
285
  every attribute is a prop under its written name, so `on*`/`bind:`/`attach`
286
286
  round-trip to their original names (the kinds the tag-blind attribute parser
287
- assigned) instead of being dropped. A static value becomes a string literal;
288
- every other kind keeps its `code`, letting a prop hold any value, functions
289
- included (e.g. an `onclick` callback). */
287
+ assigned) instead of being dropped. A static value becomes a string literal
288
+ a bare attribute coerces to `true` instead; every other kind keeps its `code`,
289
+ letting a prop hold any value, functions included (e.g. an `onclick` callback). */
290
290
  function toProps(attrs: TemplateAttr[]): { name: string; code: string; loc?: number }[] {
291
291
  return attrs.map((attr) => {
292
292
  if (attr.kind === 'static') {
293
- return { name: attr.name, code: JSON.stringify(attr.value) }
293
+ /* A bare attribute (`<Toggle on />`) is a boolean flag: coerce it to
294
+ `true` so the prop reads as a boolean, not the empty string a native
295
+ element would serialise. An explicit `on=""` stays the empty string. */
296
+ return { name: attr.name, code: attr.bare ? 'true' : JSON.stringify(attr.value) }
294
297
  }
295
298
  /* Every non-static kind keeps its `code`/`loc`; only the prop name differs —
296
299
  a directive (`event`/`bind`/`attach`) round-trips to its written name. */
@@ -3,7 +3,7 @@ import { REACTIVE_CALLEES } from './REACTIVE_CALLEES.ts'
3
3
 
4
4
  /*
5
5
  The signal binding names a `<script>` nested in a control-flow branch declares
6
- (`state`/`linked`/`computed`/`prop`). The back-end adds them to the deref scope so both the
6
+ (`state`/`linked`/`computed`). The back-end adds them to the deref scope so both the
7
7
  script body and the branch's markup rewrite `{a}` → `a.value` — these stay PLAIN
8
8
  signals (local to the branch's render, owned by its scope, re-seeded from the
9
9
  in-scope data each mount), unlike the top-level component script which desugars to
@@ -30,7 +30,7 @@ export function nestedBindingNames(code: string): Set<string> {
30
30
  return names
31
31
  }
32
32
 
33
- /* The callee name of a `NAME = state(...)` / `computed(...)` / `prop(...)` declaration —
33
+ /* The callee name of a `NAME = state(...)` / `computed(...)` / `linked(...)` declaration —
34
34
  bare or the explicit scope form (`scope().state(...)` / `c.state(...)`), receiver-agnostic. */
35
35
  function signalCallee(declaration: ts.VariableDeclaration): string | undefined {
36
36
  const initializer = declaration.initializer
@@ -9,6 +9,13 @@ touched — declaration names, parameter names, and property names are collected
9
9
  into a skip set first, and object shorthand (`{ count }`) is expanded to
10
10
  `{ count: model.count }`. This is the bridge from the signal surface the author
11
11
  writes to the patch substrate underneath.
12
+
13
+ The rewrite is lexically scope-aware: a reference whose name is re-bound by an
14
+ enclosing scope (a function/arrow parameter or a nested local declaration) refers
15
+ to that inner binding, not the component signal, so it is left untouched. Without
16
+ this, a callback like `list.map(option => option.toUpperCase())` in a component
17
+ that also has an `option` prop (`const { option } = props()`) would have its loop
18
+ variable rewritten to `option()` and blow up at runtime (`option is not a function`).
12
19
  */
13
20
  export function renameSignalRefs(
14
21
  code: string,
@@ -18,6 +25,10 @@ export function renameSignalRefs(
18
25
  ): string {
19
26
  const source = ts.createSourceFile('component.ts', code, ts.ScriptTarget.Latest, true)
20
27
 
28
+ /* The signal names that a nested binding can shadow — only these matter for
29
+ scope tracking, so we ignore every other local binding. */
30
+ const signalNames = new Set<string>([...stateNames, ...derivedNames, ...computedNames])
31
+
21
32
  /* Identifier nodes that are names, not value reads — never rewritten. */
22
33
  const skip = new Set<ts.Node>()
23
34
  const collect = (node: ts.Node): void => {
@@ -45,33 +56,47 @@ export function renameSignalRefs(
45
56
 
46
57
  const result = ts.transform(source, [
47
58
  (context) => (root) => {
48
- const visit = (node: ts.Node): ts.Node => {
49
- /* Shorthand `{ count }` `{ count: model.count }` / `{ total: total.value }`. */
50
- if (ts.isShorthandPropertyAssignment(node)) {
51
- const replacement = referenceFor(
52
- node.name.text,
53
- stateNames,
54
- derivedNames,
55
- computedNames,
56
- )
57
- if (replacement !== undefined) {
58
- return ts.factory.createPropertyAssignment(node.name.text, replacement)
59
+ /* Each visitor carries the set of signal names shadowed by the scopes it sits
60
+ inside; entering a scope that re-binds a signal name produces a fresh visitor
61
+ with that name added, so shadowing is per-branch (a sibling scope is unaffected). */
62
+ const makeVisitor = (shadowed: ReadonlySet<string>): ts.Visitor => {
63
+ const visit = (node: ts.Node): ts.Node => {
64
+ /* Shorthand `{ count }` → `{ count: model.count }` / `{ total: total.value }`,
65
+ unless a nearer scope shadows the name. */
66
+ if (ts.isShorthandPropertyAssignment(node) && !shadowed.has(node.name.text)) {
67
+ const replacement = referenceFor(
68
+ node.name.text,
69
+ stateNames,
70
+ derivedNames,
71
+ computedNames,
72
+ )
73
+ if (replacement !== undefined) {
74
+ return ts.factory.createPropertyAssignment(node.name.text, replacement)
75
+ }
59
76
  }
60
- }
61
- if (ts.isIdentifier(node) && !skip.has(node)) {
62
- const replacement = referenceFor(
63
- node.text,
64
- stateNames,
65
- derivedNames,
66
- computedNames,
67
- )
68
- if (replacement !== undefined) {
69
- return replacement
77
+ if (ts.isIdentifier(node) && !skip.has(node) && !shadowed.has(node.text)) {
78
+ const replacement = referenceFor(
79
+ node.text,
80
+ stateNames,
81
+ derivedNames,
82
+ computedNames,
83
+ )
84
+ if (replacement !== undefined) {
85
+ return replacement
86
+ }
70
87
  }
88
+ /* Recurse with the scope this node introduces folded in (same visitor when
89
+ it adds no shadowing name, so unscoped subtrees allocate nothing extra). */
90
+ const inner = extendShadowed(node, shadowed, signalNames)
91
+ return ts.visitEachChild(
92
+ node,
93
+ inner === shadowed ? visit : makeVisitor(inner),
94
+ context,
95
+ )
71
96
  }
72
- return ts.visitEachChild(node, visit, context)
97
+ return visit
73
98
  }
74
- return ts.visitNode(root, visit) as ts.SourceFile
99
+ return ts.visitNode(root, makeVisitor(new Set())) as ts.SourceFile
75
100
  },
76
101
  ])
77
102
  const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed })
@@ -80,6 +105,111 @@ export function renameSignalRefs(
80
105
  return output
81
106
  }
82
107
 
108
+ /* The shadowed-name set for `node`'s children: the parent set plus any signal name
109
+ that `node` re-binds as a new scope. Returns the parent set unchanged (same
110
+ reference) when `node` introduces no colliding binding, so the caller can skip
111
+ allocating a new visitor. */
112
+ function extendShadowed(
113
+ node: ts.Node,
114
+ shadowed: ReadonlySet<string>,
115
+ signalNames: ReadonlySet<string>,
116
+ ): ReadonlySet<string> {
117
+ const introduced = new Set<string>()
118
+ collectScopeBindings(node, introduced)
119
+ const added = [...introduced].filter((name) => signalNames.has(name) && !shadowed.has(name))
120
+ if (added.length === 0) {
121
+ return shadowed
122
+ }
123
+ return new Set<string>([...shadowed, ...added])
124
+ }
125
+
126
+ /* The names `node` binds when it opens a lexical scope — function/arrow parameters
127
+ (and the function's own name), block-level `let`/`const`/`function`/`class`,
128
+ `for`-header declarations, and the `catch` binding. Only names bound directly at
129
+ this scope; deeper scopes are folded in as the walk descends into them. */
130
+ function collectScopeBindings(node: ts.Node, into: Set<string>): void {
131
+ const parameters = functionParameters(node)
132
+ if (parameters !== undefined) {
133
+ for (const parameter of parameters) {
134
+ collectBindingNames(parameter.name, into)
135
+ }
136
+ if (
137
+ (ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node)) &&
138
+ node.name !== undefined
139
+ ) {
140
+ into.add(node.name.text)
141
+ }
142
+ return
143
+ }
144
+ if (ts.isBlock(node) || ts.isModuleBlock(node) || ts.isCaseBlock(node)) {
145
+ const statements = ts.isCaseBlock(node)
146
+ ? node.clauses.flatMap((clause) => [...clause.statements])
147
+ : node.statements
148
+ for (const statement of statements) {
149
+ collectStatementBindings(statement, into)
150
+ }
151
+ return
152
+ }
153
+ if (ts.isCatchClause(node) && node.variableDeclaration !== undefined) {
154
+ collectBindingNames(node.variableDeclaration.name, into)
155
+ return
156
+ }
157
+ if (ts.isForStatement(node) || ts.isForInStatement(node) || ts.isForOfStatement(node)) {
158
+ const initializer = node.initializer
159
+ if (initializer !== undefined && ts.isVariableDeclarationList(initializer)) {
160
+ for (const declaration of initializer.declarations) {
161
+ collectBindingNames(declaration.name, into)
162
+ }
163
+ }
164
+ }
165
+ }
166
+
167
+ /* The names a single block-level statement binds: `var`/`let`/`const`, and named
168
+ `function`/`class` declarations. */
169
+ function collectStatementBindings(statement: ts.Statement, into: Set<string>): void {
170
+ if (ts.isVariableStatement(statement)) {
171
+ for (const declaration of statement.declarationList.declarations) {
172
+ collectBindingNames(declaration.name, into)
173
+ }
174
+ }
175
+ if (
176
+ (ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) &&
177
+ statement.name !== undefined
178
+ ) {
179
+ into.add(statement.name.text)
180
+ }
181
+ }
182
+
183
+ /* Every identifier bound by a binding name — a plain identifier or the leaves of a
184
+ destructuring pattern (object/array, including rest elements). */
185
+ function collectBindingNames(name: ts.BindingName, into: Set<string>): void {
186
+ if (ts.isIdentifier(name)) {
187
+ into.add(name.text)
188
+ return
189
+ }
190
+ for (const element of name.elements) {
191
+ if (ts.isBindingElement(element)) {
192
+ collectBindingNames(element.name, into)
193
+ }
194
+ }
195
+ }
196
+
197
+ /* The parameter list of a function-like node, or undefined if `node` is not one. */
198
+ function functionParameters(node: ts.Node): ts.NodeArray<ts.ParameterDeclaration> | undefined {
199
+ if (
200
+ ts.isArrowFunction(node) ||
201
+ ts.isFunctionDeclaration(node) ||
202
+ ts.isFunctionExpression(node) ||
203
+ ts.isMethodDeclaration(node) ||
204
+ ts.isConstructorDeclaration(node) ||
205
+ ts.isGetAccessorDeclaration(node) ||
206
+ ts.isSetAccessorDeclaration(node)
207
+ ) {
208
+ return node.parameters
209
+ }
210
+ return undefined
211
+ }
212
+
83
213
  /* `model.<name>` for a state binding, `<name>()` for a computed doc-slot (the
84
214
  string-free reader `scope().derive` returns), `<name>.value` for a runtime cell
85
215
  (linked / lens / transform-state), else undefined. */
@@ -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,5 +1,7 @@
1
1
  /*
2
- An element attribute. `static` is a literal; `expression` is `name={code}` bound
2
+ An element attribute. `static` is a literal `bare` marks a valueless attribute
3
+ (`disabled`, not `disabled=""`), letting a component coerce it to `true` while a
4
+ native element still serialises it as `name=""`; `expression` is `name={code}` bound
3
5
  reactively; `event` is `on<event>={code}` where `code` evaluates to the handler;
4
6
  `bind` is `bind:<property>={lvalue}`, a two-way binding whose `code` is the
5
7
  writable doc path read into the property and written back on input; `attach` is
@@ -9,7 +11,7 @@ absolute offset of `code` in the original `.abide` source (see TextPart) —
9
11
  optional, set only when the parser tracks positions for the type-checking shadow.
10
12
  */
11
13
  export type TemplateAttr =
12
- | { kind: 'static'; name: string; value: string }
14
+ | { kind: 'static'; name: string; value: string; bare?: true }
13
15
  | { kind: 'expression'; name: string; code: string; loc?: number }
14
16
  | { kind: 'event'; event: string; code: string; loc?: number }
15
17
  | { kind: 'bind'; property: string; code: string; loc?: number }
@@ -1,13 +1,23 @@
1
1
  import { RESUME } from '../runtime/RESUME.ts'
2
+ import { seedStreamedResolution } from '../seedStreamedResolution.ts'
2
3
 
3
4
  /*
4
- Client consumer of an SSR stream fragment. Parses a streamed
5
- `<abide-resolve data-id="ID"><script type="application/json">…</script>…</abide-resolve>`
6
- frame, registers its serialized value in the resume manifest (for later hydration), finds the
7
- matching `<!--abide:await:ID-->…<!--/abide:await:ID-->` boundary in `root`, removes
8
- the pending nodes between the markers, and inserts the resolved content in their
9
- place. The pending shell painted instantly; this swaps in each value as it
10
- arrives completing the out-of-order streaming loop on the client.
5
+ Bundle-side consumer of an SSR stream chunk, the counterpart of the doc stream's inline
6
+ vanilla scripts (SSR_SWAP_SCRIPT's `__abideSwap` + `__abideResolve`) for a stream the
7
+ running bundle consumes itself streaming SPA navigation, socket-delivered SSR. It routes
8
+ the two chunk kinds the stream interleaves:
9
+
10
+ - `<abide-cache>{StreamedResolution}</abide-cache>` seed the streamed cache partition.
11
+ A script set via innerHTML never runs, so the cache channel rides a data frame here
12
+ (not the doc stream's `<script>__abideResolve(…)</script>`); seeding it keeps the
13
+ pending {#await} read warm instead of cold-missing to the network.
14
+ - `<abide-resolve data-id="ID"><script type="application/json">…</script>…</abide-resolve>`
15
+ → register the value in the resume manifest (for hydration), find the matching
16
+ `<!--abide:await:ID-->…<!--/abide:await:ID-->` boundary in `root`, remove the pending
17
+ nodes between the markers, and insert the resolved content in their place.
18
+
19
+ The pending shell painted instantly; this swaps in each value as it arrives — completing
20
+ the out-of-order streaming loop on the client.
11
21
  */
12
22
  // @documentation plumbing
13
23
  export function applyResolved(root: Element, frame: string): void {
@@ -17,6 +27,16 @@ export function applyResolved(root: Element, frame: string): void {
17
27
  if (resolved === null || resolved.getAttribute === undefined) {
18
28
  return
19
29
  }
30
+ /* A cache-seed frame warms the streamed cache partition — paired with the DOM swap so a
31
+ bundle-consumed stream can't adopt a resolved branch while dropping its cache key. */
32
+ if (resolved.nodeName === 'ABIDE-CACHE') {
33
+ try {
34
+ seedStreamedResolution(JSON.parse(resolved.textContent ?? 'null'))
35
+ } catch {
36
+ /* malformed payload — leave unseeded; the read falls back to a live fetch */
37
+ }
38
+ return
39
+ }
20
40
  const id = resolved.getAttribute('data-id')
21
41
  if (id === null) {
22
42
  return
@@ -24,6 +24,7 @@ import { effect } from './effect.ts'
24
24
  import { enterScope } from './enterScope.ts'
25
25
  import { exitScope } from './exitScope.ts'
26
26
  import { enterRenderPass } from './runtime/enterRenderPass.ts'
27
+ import { escapeKey } from './runtime/escapeKey.ts'
27
28
  import { exitRenderPass } from './runtime/exitRenderPass.ts'
28
29
  import { hotReloadEnabled } from './runtime/hotReloadEnabled.ts'
29
30
  import { hotReplace } from './runtime/hotReplace.ts'
@@ -68,6 +69,7 @@ export function installHotBridge(): void {
68
69
  mountSlot,
69
70
  mountChild,
70
71
  hydrate,
72
+ escapeKey,
71
73
  nextBlockId,
72
74
  enterRenderPass,
73
75
  exitRenderPass,
@@ -4,10 +4,16 @@ Escapes one object key into a JSON Pointer reference token (RFC 6901): `~`→`~0
4
4
  survives a `/`-joined path instead of being mis-split into segments. `~` is
5
5
  escaped first so a `/`→`~1` substitution isn't re-escaped. The common key (a
6
6
  plain identifier) contains neither char, so the scan returns it untouched.
7
+
8
+ Coerces first: lowerDocAccess wraps every dynamic path segment in this call, and a
9
+ segment is often a number (an array index `lines[i]`) — String() mirrors the `+`
10
+ join that built the path before, leaving numerics as their decimal text.
7
11
  */
8
- export function escapeKey(key: string): string {
9
- if (!key.includes('~') && !key.includes('/')) {
10
- return key
12
+ // @documentation plumbing
13
+ export function escapeKey(key: string | number): string {
14
+ const segment = String(key)
15
+ if (!segment.includes('~') && !segment.includes('/')) {
16
+ return segment
11
17
  }
12
- return key.replace(/~/g, '~0').replace(/\//g, '~1')
18
+ return segment.replace(/~/g, '~0').replace(/\//g, '~1')
13
19
  }
@@ -0,0 +1,22 @@
1
+ import { activeCacheStore } from '../shared/activeCacheStore.ts'
2
+ import { cacheEntryFromSnapshot } from '../shared/cacheEntryFromSnapshot.ts'
3
+ import type { StreamedResolution } from '../shared/types/StreamedResolution.ts'
4
+
5
+ /*
6
+ Seeds one streamed cache resolution into the active store — the single sink for the
7
+ streamed (pending {#await}) cache partition. A full CacheSnapshotEntry warms the entry
8
+ so a `cache()` read resolves synchronously (no wire round-trip) and `<template await>`
9
+ adopts without a refetch; a `{ key, miss }` marker — a body the server couldn't snapshot
10
+ (binary / rejected / evicted) — is a no-op, so that read falls back to a live fetch.
11
+
12
+ Shared by startClient's boot drain, the live `window.__abideResolve`, and applyResolved,
13
+ so no resolved-frame consumer can swap DOM while silently dropping the cache channel —
14
+ the asymmetry that made streamed reads cold-miss to the network.
15
+ */
16
+ // @documentation plumbing
17
+ export function seedStreamedResolution(resolution: StreamedResolution): void {
18
+ if ('miss' in resolution) {
19
+ return
20
+ }
21
+ activeCacheStore().entries.set(resolution.key, cacheEntryFromSnapshot(resolution))
22
+ }
@@ -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
  }