@abide/abide 0.34.1 → 0.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +3 -3
- package/CHANGELOG.md +50 -0
- package/README.md +2 -2
- package/package.json +2 -1
- package/src/abideResolverPlugin.ts +53 -9
- package/src/lib/server/runtime/STREAMED_HTML_HEADER.ts +13 -0
- package/src/lib/server/runtime/buildPreloadManifest.ts +151 -0
- package/src/lib/server/runtime/createServer.ts +4 -1
- package/src/lib/server/runtime/createUiPageRenderer.ts +104 -9
- package/src/lib/server/runtime/flushingGzipStream.ts +62 -0
- package/src/lib/server/runtime/gzipResponse.ts +23 -11
- package/src/lib/server/runtime/serializeCacheSnapshot.ts +22 -33
- package/src/lib/server/runtime/snapshotEntryFromCache.ts +9 -0
- package/src/lib/server/sockets/defineSocket.ts +6 -1
- package/src/lib/shared/cacheEntryFromSnapshot.ts +14 -12
- package/src/lib/shared/contentBodyKind.ts +27 -0
- package/src/lib/shared/createSocketSubRegistry.ts +112 -0
- package/src/lib/shared/decodeResponse.ts +5 -4
- package/src/lib/shared/isStreamingResponse.ts +5 -5
- package/src/lib/test/createTestSocketChannel.ts +9 -70
- package/src/lib/ui/README.md +1 -1
- package/src/lib/ui/compile/REACTIVE_CALLEES.ts +6 -6
- package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +1 -0
- package/src/lib/ui/compile/compileShadow.ts +60 -55
- package/src/lib/ui/compile/desugarSignals.ts +93 -38
- package/src/lib/ui/compile/generateSSR.ts +28 -47
- package/src/lib/ui/compile/lowerDocAccess.ts +26 -11
- package/src/lib/ui/compile/parseTemplate.ts +8 -5
- package/src/lib/ui/compile/prepareNestedScript.ts +2 -2
- package/src/lib/ui/compile/renameSignalRefs.ts +153 -23
- package/src/lib/ui/compile/skeletonContext.ts +83 -0
- package/src/lib/ui/compile/types/SkeletonContext.ts +15 -0
- package/src/lib/ui/compile/types/TemplateAttr.ts +4 -2
- package/src/lib/ui/dom/applyResolved.ts +27 -7
- package/src/lib/ui/installHotBridge.ts +2 -0
- package/src/lib/ui/runtime/escapeKey.ts +10 -4
- package/src/lib/ui/seedStreamedResolution.ts +22 -0
- package/src/lib/ui/socketChannel.ts +18 -98
- package/src/lib/ui/startClient.ts +17 -3
- package/src/lib/shared/types/CacheSnapshot.ts +0 -16
|
@@ -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`,
|
|
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
|
-
|
|
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
|
|
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(...)` / `
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
|
97
|
+
return visit
|
|
73
98
|
}
|
|
74
|
-
return ts.visitNode(root,
|
|
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
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|