@abide/abide 0.39.0 → 0.40.1
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 +318 -146
- package/CHANGELOG.md +89 -0
- package/README.md +77 -79
- package/package.json +1 -1
- package/src/lib/server/rpc/parseArgs.ts +10 -1
- package/src/lib/server/rpc/runWithVerbTimeout.ts +7 -9
- package/src/lib/server/rpc/types/VerbHelper.ts +18 -21
- package/src/lib/server/runtime/SSR_SWAP_SCRIPT.ts +8 -7
- package/src/lib/server/sockets/createSocketDispatcher.ts +12 -3
- package/src/lib/server/sockets/defineSocket.ts +3 -1
- package/src/lib/shared/REF_JSON_HEADER.ts +9 -0
- package/src/lib/shared/REF_JSON_TAGS.ts +31 -0
- package/src/lib/shared/assertExhaustive.ts +14 -0
- package/src/lib/shared/buildRpcRequest.ts +8 -1
- package/src/lib/shared/decodeRefJson.ts +110 -0
- package/src/lib/shared/encodeRefJson.ts +106 -0
- package/src/lib/test/createTestSocketChannel.ts +6 -2
- package/src/lib/ui/compile/TS_PRINTER.ts +7 -0
- package/src/lib/ui/compile/analyzeComponent.ts +15 -16
- package/src/lib/ui/compile/assertRuntimeHelpersBound.ts +66 -0
- package/src/lib/ui/compile/assertTranspiles.ts +19 -0
- package/src/lib/ui/compile/compileModule.ts +56 -34
- package/src/lib/ui/compile/compileSSR.ts +1 -3
- package/src/lib/ui/compile/compileShadow.ts +14 -7
- package/src/lib/ui/compile/desugarSignals.ts +168 -107
- package/src/lib/ui/compile/generateBuild.ts +49 -59
- package/src/lib/ui/compile/generateSSR.ts +30 -21
- package/src/lib/ui/compile/hoistCells.ts +2 -2
- package/src/lib/ui/compile/isWhitespaceText.ts +11 -0
- package/src/lib/ui/compile/lowerContext.ts +23 -10
- package/src/lib/ui/compile/lowerDocAccess.ts +29 -3
- package/src/lib/ui/compile/lowerScript.ts +64 -0
- package/src/lib/ui/compile/parseTemplate.ts +74 -0
- package/src/lib/ui/compile/renameSignalRefs.ts +160 -90
- package/src/lib/ui/compile/resolveBranches.ts +21 -0
- package/src/lib/ui/compile/stripEffects.ts +22 -17
- package/src/lib/ui/compile/types/AnalyzedComponent.ts +5 -0
- package/src/lib/ui/compile/types/TemplateNode.ts +12 -2
- package/src/lib/ui/createScope.ts +14 -0
- package/src/lib/ui/dom/appendText.ts +2 -3
- package/src/lib/ui/dom/appendTextAt.ts +2 -3
- package/src/lib/ui/dom/applyResolved.ts +5 -8
- package/src/lib/ui/dom/awaitBlock.ts +22 -2
- package/src/lib/ui/dom/each.ts +4 -0
- package/src/lib/ui/dom/on.ts +7 -0
- package/src/lib/ui/dom/parseRawNodes.ts +17 -0
- package/src/lib/ui/dom/switchBlock.ts +4 -0
- package/src/lib/ui/dom/when.ts +4 -0
- package/src/lib/ui/installInspectorBridge.ts +3 -1
- package/src/lib/ui/navigate.ts +28 -3
- package/src/lib/ui/renderToStream.ts +17 -9
- package/src/lib/ui/resumeSeedScript.ts +16 -6
- package/src/lib/ui/router.ts +108 -15
- package/src/lib/ui/runtime/NODE_STATE.ts +22 -0
- package/src/lib/ui/runtime/RESUME.ts +13 -6
- package/src/lib/ui/runtime/clientPage.ts +114 -9
- package/src/lib/ui/runtime/createComputedNode.ts +5 -3
- package/src/lib/ui/runtime/createEffectNode.ts +3 -1
- package/src/lib/ui/runtime/createSignalNode.ts +4 -2
- package/src/lib/ui/runtime/flushEffects.ts +8 -5
- package/src/lib/ui/runtime/historyEntries.ts +39 -1
- package/src/lib/ui/runtime/localStoragePersistence.ts +14 -8
- package/src/lib/ui/runtime/readNode.ts +8 -7
- package/src/lib/ui/runtime/runNode.ts +18 -2
- package/src/lib/ui/runtime/scope.ts +12 -1
- package/src/lib/ui/runtime/trigger.ts +40 -24
- package/src/lib/ui/runtime/types/ReactiveNode.ts +4 -1
- package/src/lib/ui/runtime/updateIfNecessary.ts +40 -0
- package/src/lib/ui/socketChannel.ts +5 -2
- package/src/lib/ui/tryEncodeResume.ts +20 -0
- package/src/lib/ui/types/Scope.ts +9 -3
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Inverse of encodeRefJson — rebuild a value graph (cycles, shared references, and the
|
|
3
|
+
JSON-hostile types) from a `[rootValue, slots]` ref-json string. Two passes are
|
|
4
|
+
required: pass one allocates every hoisted container as an empty shell, pass two fills
|
|
5
|
+
them. The shell must exist before its contents are filled so a back-reference to an
|
|
6
|
+
ancestor resolves to the already-allocated object — which is what reconnects a cycle.
|
|
7
|
+
Inline primitives need no shell; they decode directly where they sit.
|
|
8
|
+
*/
|
|
9
|
+
import { REF_JSON_TAGS } from './REF_JSON_TAGS'
|
|
10
|
+
|
|
11
|
+
export function decodeRefJson(text: string): unknown {
|
|
12
|
+
const parsed = JSON.parse(text)
|
|
13
|
+
if (!Array.isArray(parsed) || parsed.length !== 2 || !Array.isArray(parsed[1])) {
|
|
14
|
+
// encodeRefJson always emits a `[rootValue, slots]` pair; anything else isn't our format.
|
|
15
|
+
throw new TypeError('decodeRefJson: not a ref-json payload')
|
|
16
|
+
}
|
|
17
|
+
const [rootValue, slots] = parsed as [unknown, unknown[]]
|
|
18
|
+
// Pass 1: an empty container shell per slot. Pass 2 fills them so a ref to an ancestor finds its shell.
|
|
19
|
+
const shells = slots.map(buildShell)
|
|
20
|
+
slots.forEach((slot, index) => {
|
|
21
|
+
fillShell(slot, shells[index], shells)
|
|
22
|
+
})
|
|
23
|
+
return resolveValue(rootValue, shells)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Empty container matching a slot's kind. Every slot is a container (only containers are hoisted).
|
|
27
|
+
function buildShell(slot: unknown): unknown {
|
|
28
|
+
if (Array.isArray(slot)) {
|
|
29
|
+
if (slot[0] === REF_JSON_TAGS.ARRAY) {
|
|
30
|
+
return []
|
|
31
|
+
}
|
|
32
|
+
if (slot[0] === REF_JSON_TAGS.MAP) {
|
|
33
|
+
return new Map()
|
|
34
|
+
}
|
|
35
|
+
if (slot[0] === REF_JSON_TAGS.SET) {
|
|
36
|
+
return new Set()
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return {}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Decode an inline value: bare primitive (itself), a leaf-special, or a reference to a built shell.
|
|
43
|
+
function resolveValue(value: unknown, shells: unknown[]): unknown {
|
|
44
|
+
if (!Array.isArray(value)) {
|
|
45
|
+
return value
|
|
46
|
+
}
|
|
47
|
+
switch (value[0]) {
|
|
48
|
+
case REF_JSON_TAGS.REF:
|
|
49
|
+
return shells[value[1] as number]
|
|
50
|
+
case REF_JSON_TAGS.UNDEFINED:
|
|
51
|
+
return undefined
|
|
52
|
+
case REF_JSON_TAGS.DATE:
|
|
53
|
+
return new Date(value[1] as number)
|
|
54
|
+
case REF_JSON_TAGS.REGEXP:
|
|
55
|
+
return new RegExp(value[1] as string, value[2] as string)
|
|
56
|
+
case REF_JSON_TAGS.BIGINT:
|
|
57
|
+
return BigInt(value[1] as string)
|
|
58
|
+
case REF_JSON_TAGS.NUMBER:
|
|
59
|
+
return decodeNumberToken(value[1] as string)
|
|
60
|
+
default:
|
|
61
|
+
throw new TypeError(`decodeRefJson: unknown value tag ${String(value[0])}`)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Fill a container shell from its slot, resolving each inline child.
|
|
66
|
+
function fillShell(slot: unknown, shell: unknown, shells: unknown[]): void {
|
|
67
|
+
if (!Array.isArray(slot)) {
|
|
68
|
+
const target = shell as Record<string, unknown>
|
|
69
|
+
const record = slot as Record<string, unknown>
|
|
70
|
+
for (const key of Object.keys(record)) {
|
|
71
|
+
target[key] = resolveValue(record[key], shells)
|
|
72
|
+
}
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
if (slot[0] === REF_JSON_TAGS.ARRAY) {
|
|
76
|
+
const target = shell as unknown[]
|
|
77
|
+
// Loop (not push(...spread)) so a huge array can't blow the call-stack arg limit.
|
|
78
|
+
for (let index = 1; index < slot.length; index++) {
|
|
79
|
+
target.push(resolveValue(slot[index], shells))
|
|
80
|
+
}
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
if (slot[0] === REF_JSON_TAGS.MAP) {
|
|
84
|
+
const target = shell as Map<unknown, unknown>
|
|
85
|
+
for (const [key, val] of slot[1] as [unknown, unknown][]) {
|
|
86
|
+
target.set(resolveValue(key, shells), resolveValue(val, shells))
|
|
87
|
+
}
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
if (slot[0] === REF_JSON_TAGS.SET) {
|
|
91
|
+
const target = shell as Set<unknown>
|
|
92
|
+
for (const member of slot[1] as unknown[]) {
|
|
93
|
+
target.add(resolveValue(member, shells))
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Reverse of encodeRefJson's numberToken.
|
|
99
|
+
function decodeNumberToken(token: string): number {
|
|
100
|
+
if (token === 'NaN') {
|
|
101
|
+
return Number.NaN
|
|
102
|
+
}
|
|
103
|
+
if (token === 'Infinity') {
|
|
104
|
+
return Number.POSITIVE_INFINITY
|
|
105
|
+
}
|
|
106
|
+
if (token === '-Infinity') {
|
|
107
|
+
return Number.NEGATIVE_INFINITY
|
|
108
|
+
}
|
|
109
|
+
return -0
|
|
110
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Serialise an arbitrary value graph to a JSON string that survives cycles and shared
|
|
3
|
+
references — the gap JSON.stringify can't cross. Only mutable CONTAINERS (objects,
|
|
4
|
+
arrays, Maps, Sets) are hoisted into a flat `slots` array and referenced by index, so
|
|
5
|
+
a cycle becomes a back-reference instead of infinite recursion, and two paths to the
|
|
6
|
+
same container decode back to the same object. Primitives are encoded INLINE at their
|
|
7
|
+
position (not hoisted), so a primitive-heavy payload stays near plain-JSON size and
|
|
8
|
+
speed. Beyond plain objects/arrays it round-trips the types JSON drops or coerces:
|
|
9
|
+
undefined, bigint, NaN, ±Infinity, -0, Date, RegExp, Map and Set. Functions and
|
|
10
|
+
symbols can't be serialised and encode as undefined, matching JSON.stringify. Output
|
|
11
|
+
shape is `[rootValue, slots]`. Decode with decodeRefJson. Not streaming — the whole
|
|
12
|
+
graph is walked up front.
|
|
13
|
+
*/
|
|
14
|
+
import { REF_JSON_TAGS } from './REF_JSON_TAGS'
|
|
15
|
+
|
|
16
|
+
export function encodeRefJson(value: unknown): string {
|
|
17
|
+
// Hoisted containers only; slots[i] is addressed by ['~r', i]. Primitives stay inline.
|
|
18
|
+
const slots: unknown[] = []
|
|
19
|
+
// Container identity → slot index: a revisited container emits a back-reference instead of recursing.
|
|
20
|
+
const ids = new Map<object, number>()
|
|
21
|
+
|
|
22
|
+
// Hoist a container to a slot (once) and return its index. Reserve the index BEFORE encoding
|
|
23
|
+
// children so a cyclic child resolves back to this slot.
|
|
24
|
+
function intern(container: object): number {
|
|
25
|
+
const seen = ids.get(container)
|
|
26
|
+
if (seen !== undefined) {
|
|
27
|
+
return seen
|
|
28
|
+
}
|
|
29
|
+
const index = slots.length
|
|
30
|
+
slots.push(0)
|
|
31
|
+
ids.set(container, index)
|
|
32
|
+
slots[index] = encodeContainer(container)
|
|
33
|
+
return index
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Inline encoding of a value at its position: bare primitive, leaf-special tag, or a container reference.
|
|
37
|
+
function encodeValue(input: unknown): unknown {
|
|
38
|
+
if (input === null) {
|
|
39
|
+
return null
|
|
40
|
+
}
|
|
41
|
+
const type = typeof input
|
|
42
|
+
if (type === 'string' || type === 'boolean') {
|
|
43
|
+
return input
|
|
44
|
+
}
|
|
45
|
+
if (type === 'number') {
|
|
46
|
+
const numeric = input as number
|
|
47
|
+
// -0, NaN, ±Infinity can't round-trip as bare JSON; tag them.
|
|
48
|
+
if (!Number.isFinite(numeric) || Object.is(numeric, -0)) {
|
|
49
|
+
return [REF_JSON_TAGS.NUMBER, numberToken(numeric)]
|
|
50
|
+
}
|
|
51
|
+
return numeric
|
|
52
|
+
}
|
|
53
|
+
if (type === 'bigint') {
|
|
54
|
+
return [REF_JSON_TAGS.BIGINT, (input as bigint).toString()]
|
|
55
|
+
}
|
|
56
|
+
// undefined | function | symbol — not representable; fold to undefined as JSON drops them.
|
|
57
|
+
if (type !== 'object') {
|
|
58
|
+
return [REF_JSON_TAGS.UNDEFINED]
|
|
59
|
+
}
|
|
60
|
+
if (input instanceof Date) {
|
|
61
|
+
return [REF_JSON_TAGS.DATE, input.getTime()]
|
|
62
|
+
}
|
|
63
|
+
if (input instanceof RegExp) {
|
|
64
|
+
return [REF_JSON_TAGS.REGEXP, input.source, input.flags]
|
|
65
|
+
}
|
|
66
|
+
// Mutable container: hoist for identity and reference it — primitives above never reach here.
|
|
67
|
+
return [REF_JSON_TAGS.REF, intern(input as object)]
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// A hoisted container's slot: a tagged array (or plain object) whose nested values are encoded inline.
|
|
71
|
+
function encodeContainer(container: object): unknown {
|
|
72
|
+
if (Array.isArray(container)) {
|
|
73
|
+
return [REF_JSON_TAGS.ARRAY, ...container.map(encodeValue)]
|
|
74
|
+
}
|
|
75
|
+
if (container instanceof Map) {
|
|
76
|
+
return [
|
|
77
|
+
REF_JSON_TAGS.MAP,
|
|
78
|
+
Array.from(container, ([key, val]) => [encodeValue(key), encodeValue(val)]),
|
|
79
|
+
]
|
|
80
|
+
}
|
|
81
|
+
if (container instanceof Set) {
|
|
82
|
+
return [REF_JSON_TAGS.SET, Array.from(container, encodeValue)]
|
|
83
|
+
}
|
|
84
|
+
const record = container as Record<string, unknown>
|
|
85
|
+
// Own enumerable keys, built in one loop — ~2× cheaper than Object.fromEntries(keys.map(...))
|
|
86
|
+
// (no intermediate pairs array / closures) on this per-object always-hot path.
|
|
87
|
+
const encoded: Record<string, unknown> = {}
|
|
88
|
+
for (const key of Object.keys(record)) {
|
|
89
|
+
encoded[key] = encodeValue(record[key])
|
|
90
|
+
}
|
|
91
|
+
return encoded
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return JSON.stringify([encodeValue(value), slots])
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Stable token for the numbers JSON can't carry.
|
|
98
|
+
function numberToken(value: number): string {
|
|
99
|
+
if (Object.is(value, -0)) {
|
|
100
|
+
return '-0'
|
|
101
|
+
}
|
|
102
|
+
if (Number.isNaN(value)) {
|
|
103
|
+
return 'NaN'
|
|
104
|
+
}
|
|
105
|
+
return value > 0 ? 'Infinity' : '-Infinity'
|
|
106
|
+
}
|
|
@@ -3,6 +3,8 @@ import type { SocketClientFrame } from '../server/sockets/types/SocketClientFram
|
|
|
3
3
|
import type { SocketServerFrame } from '../server/sockets/types/SocketServerFrame.ts'
|
|
4
4
|
import { buildSocketOverChannel } from '../shared/buildSocketOverChannel.ts'
|
|
5
5
|
import { createSocketSubRegistry } from '../shared/createSocketSubRegistry.ts'
|
|
6
|
+
import { decodeRefJson } from '../shared/decodeRefJson.ts'
|
|
7
|
+
import { encodeRefJson } from '../shared/encodeRefJson.ts'
|
|
6
8
|
|
|
7
9
|
/*
|
|
8
10
|
Test-side substitute for the browser socketChannel: one ws to the booted
|
|
@@ -37,7 +39,8 @@ export function createTestSocketChannel(wsUrl: string): {
|
|
|
37
39
|
}
|
|
38
40
|
|
|
39
41
|
function send(frame: SocketClientFrame): void {
|
|
40
|
-
|
|
42
|
+
// ref-json, matching the browser channel + the server's dispatcher/publish codec.
|
|
43
|
+
const message = encodeRefJson(frame)
|
|
41
44
|
if (ws.readyState === WebSocket.OPEN) {
|
|
42
45
|
ws.send(message)
|
|
43
46
|
return
|
|
@@ -53,7 +56,8 @@ export function createTestSocketChannel(wsUrl: string): {
|
|
|
53
56
|
ws.addEventListener('message', (event) => {
|
|
54
57
|
let frame: SocketServerFrame
|
|
55
58
|
try {
|
|
56
|
-
|
|
59
|
+
// The server sends ref-json frames (createSocketDispatcher/defineSocket); decode to match.
|
|
60
|
+
frame = decodeRefJson(event.data as string) as SocketServerFrame
|
|
57
61
|
} catch {
|
|
58
62
|
return
|
|
59
63
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import ts from 'typescript'
|
|
2
|
+
|
|
3
|
+
/* The shared TypeScript printer every compile pass prints its transformed tree with.
|
|
4
|
+
A `ts.Printer` is stateless and reusable, so the passes share one instance rather
|
|
5
|
+
than each re-creating the same `{ newLine: LineFeed }` printer. LineFeed keeps the
|
|
6
|
+
emitted source `\n`-delimited regardless of the host platform. */
|
|
7
|
+
export const TS_PRINTER = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed })
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { lowerDocAccess } from './lowerDocAccess.ts'
|
|
1
|
+
import { lowerScript } from './lowerScript.ts'
|
|
3
2
|
import { parseTemplate } from './parseTemplate.ts'
|
|
4
3
|
import { scopeCss } from './scopeCss.ts'
|
|
5
4
|
import type { AnalyzedComponent } from './types/AnalyzedComponent.ts'
|
|
@@ -30,19 +29,18 @@ export function analyzeComponent(source: string, scopeSeed?: string): AnalyzedCo
|
|
|
30
29
|
const scriptBody = (scriptMatch?.[1] ?? '').trim()
|
|
31
30
|
const template = source.replace(/^\s*<script[^>]*>[\s\S]*?<\/script>/, '').trim()
|
|
32
31
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
.trim()
|
|
32
|
+
/* `lowerScript` parses the script ONCE and chains signal desugaring, reference
|
|
33
|
+
renaming, and doc-access lowering over that single tree, then hoists top-level
|
|
34
|
+
imports off the tree structurally — imports live at module scope, not inside the
|
|
35
|
+
mount/render function the body becomes. It returns the collected signal name sets. */
|
|
36
|
+
const {
|
|
37
|
+
body: script,
|
|
38
|
+
ssrBody: ssrScript,
|
|
39
|
+
imports,
|
|
40
|
+
stateNames,
|
|
41
|
+
derivedNames,
|
|
42
|
+
computedNames,
|
|
43
|
+
} = lowerScript(scriptBody)
|
|
46
44
|
/* The parser keeps each `<style>` as an in-place node (one inside an expression
|
|
47
45
|
is text, never a node). `annotateScopes` mutates the tree — assigning each
|
|
48
46
|
style its scope attribute and stamping covered elements — and returns the
|
|
@@ -51,7 +49,8 @@ export function analyzeComponent(source: string, scopeSeed?: string): AnalyzedCo
|
|
|
51
49
|
const styles = annotateScopes(nodes, [], scopeSeed, { count: 0 })
|
|
52
50
|
return {
|
|
53
51
|
script,
|
|
54
|
-
|
|
52
|
+
ssrScript,
|
|
53
|
+
imports,
|
|
55
54
|
stateNames,
|
|
56
55
|
derivedNames,
|
|
57
56
|
computedNames,
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import ts from 'typescript'
|
|
2
|
+
import { UI_RUNTIME_IMPORTS } from './UI_RUNTIME_IMPORTS.ts'
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
Independent backstop on the per-component dead-import filter (`compileModule`). The filter
|
|
6
|
+
decides which runtime helpers to import by reading the generated output's identifiers; if it
|
|
7
|
+
ever undercounts — as a raw token scan once did, mis-reading the tail of a module after a
|
|
8
|
+
`${…}` template substitution — a helper gets CALLED but never imported, and the bundle throws
|
|
9
|
+
`ReferenceError` the instant `build()` runs. The router escalates that into a reload loop, so
|
|
10
|
+
the failure is both opaque and unrecoverable.
|
|
11
|
+
|
|
12
|
+
This re-derives the same question a DIFFERENT way (so it can't share the filter's blind spot):
|
|
13
|
+
walk the final module's AST, find every call whose callee is a bare runtime-helper identifier,
|
|
14
|
+
and require that name to be imported or locally bound. A helper name inside a string / template /
|
|
15
|
+
comment is not a `CallExpression` callee, so a docs component quoting framework code
|
|
16
|
+
(`mount(...)` in a snippet) never false-positives. Compile-time only; never on the hot path.
|
|
17
|
+
*/
|
|
18
|
+
export function assertRuntimeHelpersBound(module: string, context: string): void {
|
|
19
|
+
const helperNames = new Set(UI_RUNTIME_IMPORTS.map((entry) => entry.name))
|
|
20
|
+
const source = ts.createSourceFile(
|
|
21
|
+
'module.ts',
|
|
22
|
+
module,
|
|
23
|
+
ts.ScriptTarget.Latest,
|
|
24
|
+
/* setParentNodes */ true,
|
|
25
|
+
)
|
|
26
|
+
/* Names a bare call can resolve to: every import binding plus every declared name.
|
|
27
|
+
Collected generously (any identifier in a binding position) — over-approximating the
|
|
28
|
+
bound set only risks missing a defect, never raising a false alarm on valid output. */
|
|
29
|
+
const bound = new Set<string>()
|
|
30
|
+
const calledHelpers: { name: string; position: number }[] = []
|
|
31
|
+
const visit = (node: ts.Node): void => {
|
|
32
|
+
if (
|
|
33
|
+
(ts.isVariableDeclaration(node) ||
|
|
34
|
+
ts.isFunctionDeclaration(node) ||
|
|
35
|
+
ts.isParameter(node) ||
|
|
36
|
+
ts.isBindingElement(node) ||
|
|
37
|
+
ts.isImportSpecifier(node) ||
|
|
38
|
+
ts.isImportClause(node) ||
|
|
39
|
+
ts.isNamespaceImport(node)) &&
|
|
40
|
+
node.name !== undefined &&
|
|
41
|
+
ts.isIdentifier(node.name)
|
|
42
|
+
) {
|
|
43
|
+
bound.add(node.name.text)
|
|
44
|
+
}
|
|
45
|
+
if (
|
|
46
|
+
ts.isCallExpression(node) &&
|
|
47
|
+
ts.isIdentifier(node.expression) &&
|
|
48
|
+
helperNames.has(node.expression.text)
|
|
49
|
+
) {
|
|
50
|
+
calledHelpers.push({
|
|
51
|
+
name: node.expression.text,
|
|
52
|
+
position: node.expression.getStart(source),
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
node.forEachChild(visit)
|
|
56
|
+
}
|
|
57
|
+
visit(source)
|
|
58
|
+
|
|
59
|
+
const unbound = calledHelpers.find((call) => !bound.has(call.name))
|
|
60
|
+
if (unbound !== undefined) {
|
|
61
|
+
const { line, character } = source.getLineAndCharacterOfPosition(unbound.position)
|
|
62
|
+
throw new Error(
|
|
63
|
+
`[abide] ${context} calls runtime helper \`${unbound.name}\` at line ${line + 1}:${character + 1} but never imports it — the dead-import filter dropped it. Please report this with the component source.\nOutput:\n${module}`,
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Fail-loud guard for generated code. The compile passes build modules as strings and
|
|
3
|
+
AST — a corruption (an un-handled rewrite position, a generator bug, a stale lowering)
|
|
4
|
+
otherwise ships as a broken bundle that fails opaquely at load. Transpiling the output
|
|
5
|
+
turns it into a compile-time error naming the stage and showing the offending source.
|
|
6
|
+
`context` labels which stage produced it. Compile-time only; never on the hot path.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const transpiler = new Bun.Transpiler({ loader: 'ts' })
|
|
10
|
+
|
|
11
|
+
export function assertTranspiles(code: string, context: string): void {
|
|
12
|
+
try {
|
|
13
|
+
transpiler.transformSync(code)
|
|
14
|
+
} catch (error) {
|
|
15
|
+
throw new Error(
|
|
16
|
+
`[abide] ${context} produced invalid syntax — please report this with the component source. Output:\n${code}\n\n${String(error)}`,
|
|
17
|
+
)
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
import ts from 'typescript'
|
|
1
2
|
import { ABIDE_PACKAGE_NAME } from '../../shared/ABIDE_PACKAGE_NAME.ts'
|
|
2
3
|
import { analyzeComponent } from './analyzeComponent.ts'
|
|
4
|
+
import { assertRuntimeHelpersBound } from './assertRuntimeHelpersBound.ts'
|
|
5
|
+
import { assertTranspiles } from './assertTranspiles.ts'
|
|
3
6
|
import { compileComponent } from './compileComponent.ts'
|
|
4
7
|
import { compileSSR } from './compileSSR.ts'
|
|
5
8
|
import { UI_RUNTIME_IMPORTS } from './UI_RUNTIME_IMPORTS.ts'
|
|
@@ -58,14 +61,6 @@ if (!hotReplace(${id}, component)) location.reload()
|
|
|
58
61
|
}
|
|
59
62
|
|
|
60
63
|
const ssrBody = indent(compileSSR(source, isLayout, options.moduleId, analyzed))
|
|
61
|
-
/* Per-component dead-import elimination: emit only the runtime names this module
|
|
62
|
-
actually references. A component that uses no `each`/`await`/`html` shouldn't
|
|
63
|
-
drag those modules into its chunk. The package isn't globally side-effect-free
|
|
64
|
-
(the dev/runtime entries register globals), so a bundler can't tree-shake the
|
|
65
|
-
unused imports for us — but the generated code is the one place that knows
|
|
66
|
-
exactly which names it emitted, so it filters here. A name absent from the body
|
|
67
|
-
is unreferenced; erring toward inclusion (a stray match in user script) only
|
|
68
|
-
keeps a harmless unused import, never drops a needed one. */
|
|
69
64
|
const moduleBody = `function build(host, $props) {
|
|
70
65
|
${body}
|
|
71
66
|
}
|
|
@@ -89,37 +84,37 @@ component.hydrate = hydrateInto
|
|
|
89
84
|
component.build = build
|
|
90
85
|
component.hydratable = ${analyzed.hydratable}
|
|
91
86
|
${options.moduleId === undefined ? '' : `component.__abideId = ${JSON.stringify(options.moduleId)}\n`}`
|
|
92
|
-
/*
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
(
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
if (entry.name === 'enterScope' || entry.name === 'exitScope') {
|
|
106
|
-
return true
|
|
107
|
-
}
|
|
108
|
-
/* Render-pass helpers are emitted by both back-ends (e.g. client and server
|
|
109
|
-
await/try blocks both call nextBlockId); their names are distinctive enough
|
|
110
|
-
that scanning both surfaces adds no false match. */
|
|
111
|
-
const surface = entry.specifier.startsWith('ui/runtime/')
|
|
112
|
-
? `${clientSurface}\n${ssrBody}`
|
|
113
|
-
: clientSurface
|
|
114
|
-
return new RegExp(`\\b${entry.name}\\b`).test(surface)
|
|
115
|
-
}
|
|
116
|
-
const importBlock = UI_RUNTIME_IMPORTS.filter(isReferenced)
|
|
87
|
+
/* Per-component dead-import elimination: emit only the runtime helpers this module
|
|
88
|
+
references. A component using no `each`/`await`/`html` shouldn't drag those modules
|
|
89
|
+
into its chunk, and the package isn't globally side-effect-free (the dev/runtime
|
|
90
|
+
entries register globals), so a bundler can't tree-shake them for us.
|
|
91
|
+
Which to keep: tokenize the generated body and keep the names it genuinely
|
|
92
|
+
references. Reading the ACTUAL output (not hand-tracking emit sites) is safe by
|
|
93
|
+
construction — it can never drop a needed import, the way a missed emit-site tag
|
|
94
|
+
could. Tokenizing rather than substring-matching (`\bname\b`) means a name inside a
|
|
95
|
+
string/comment/HTML literal (e.g. an `on`-attribute in static markup) no longer
|
|
96
|
+
forces a spurious import, so no per-surface scoping is needed: a client-only helper
|
|
97
|
+
simply doesn't appear as an identifier in the SSR body. */
|
|
98
|
+
const referenced = collectIdentifiers(`${userImports}\n${body}\n${ssrBody}\n${moduleBody}`)
|
|
99
|
+
const importBlock = UI_RUNTIME_IMPORTS.filter((entry) => referenced.has(entry.name))
|
|
117
100
|
.map((entry) => `import { ${entry.name} } from '${ABIDE_PACKAGE_NAME}/${entry.specifier}'`)
|
|
118
101
|
.join('\n')
|
|
119
|
-
|
|
102
|
+
const module = `${importBlock}
|
|
120
103
|
${userImports}
|
|
121
104
|
|
|
122
105
|
${moduleBody}`
|
|
106
|
+
/* Fail-loud over the WHOLE module, not just the script: the `generateBuild` /
|
|
107
|
+
`generateSSR` back-ends emit the build and render bodies as string-codegen, the
|
|
108
|
+
largest un-typed surface in the pipeline. A corruption there (a bad emit on a
|
|
109
|
+
template shape no test exercises) otherwise ships as a broken bundle; this surfaces
|
|
110
|
+
it as a located compile error for every component. */
|
|
111
|
+
assertTranspiles(module, 'component module generation')
|
|
112
|
+
/* `assertTranspiles` only proves the output PARSES — a call to an un-imported helper is
|
|
113
|
+
valid syntax, so it slips through. This second guard proves the output is BOUND: every
|
|
114
|
+
runtime helper it calls is actually imported (an independent check of the dead-import
|
|
115
|
+
filter above), turning a runtime `ReferenceError` into a located compile error. */
|
|
116
|
+
assertRuntimeHelpersBound(module, 'component module generation')
|
|
117
|
+
return module
|
|
123
118
|
}
|
|
124
119
|
|
|
125
120
|
/* Indents a body block for embedding inside a wrapper function. Lines whose start
|
|
@@ -152,3 +147,30 @@ function unescapedBacktickCount(line: string): number {
|
|
|
152
147
|
}
|
|
153
148
|
return count
|
|
154
149
|
}
|
|
150
|
+
|
|
151
|
+
/* The identifier names a generated module references — every Identifier node, walked
|
|
152
|
+
from the real output's AST so string / comment / HTML-literal contents are excluded.
|
|
153
|
+
Used to decide which runtime helpers to import; reading the output (vs hand-tracking
|
|
154
|
+
emit sites) can never drop a needed import. A full parse (not a raw token scan) is what
|
|
155
|
+
keeps template literals honest: a `${…}` substitution — e.g. `navigate(`/p?ts=${Date.now()}`)`
|
|
156
|
+
in a handler — leaves the scanner unable to find the substitution's closing `}` without
|
|
157
|
+
the parser's re-scan, so a token-only pass mis-reads the rest of the module as template
|
|
158
|
+
text and drops every helper referenced after it (an `import`-less `effect`/`mountChild`
|
|
159
|
+
→ a `ReferenceError` at mount → the router's reload fallback → a refresh loop). */
|
|
160
|
+
function collectIdentifiers(code: string): Set<string> {
|
|
161
|
+
const source = ts.createSourceFile(
|
|
162
|
+
'module.ts',
|
|
163
|
+
code,
|
|
164
|
+
ts.ScriptTarget.Latest,
|
|
165
|
+
/* setParentNodes */ false,
|
|
166
|
+
)
|
|
167
|
+
const names = new Set<string>()
|
|
168
|
+
const visit = (node: ts.Node): void => {
|
|
169
|
+
if (ts.isIdentifier(node)) {
|
|
170
|
+
names.add(node.text)
|
|
171
|
+
}
|
|
172
|
+
node.forEachChild(visit)
|
|
173
|
+
}
|
|
174
|
+
visit(source)
|
|
175
|
+
return names
|
|
176
|
+
}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { analyzeComponent } from './analyzeComponent.ts'
|
|
2
2
|
import { generateSSR } from './generateSSR.ts'
|
|
3
3
|
import { SSR_ESCAPE } from './SSR_ESCAPE.ts'
|
|
4
|
-
import { stripEffects } from './stripEffects.ts'
|
|
5
4
|
import type { AnalyzedComponent } from './types/AnalyzedComponent.ts'
|
|
6
5
|
|
|
7
6
|
/*
|
|
@@ -44,8 +43,7 @@ export function compileSSR(
|
|
|
44
43
|
scopeSeed?: string,
|
|
45
44
|
analyzed: AnalyzedComponent = analyzeComponent(source, scopeSeed),
|
|
46
45
|
): string {
|
|
47
|
-
const {
|
|
48
|
-
const lowered = stripEffects(script)
|
|
46
|
+
const { ssrScript: lowered, stateNames, derivedNames, computedNames, nodes } = analyzed
|
|
49
47
|
const ssr = generateSSR(nodes, stateNames, derivedNames, computedNames, isLayout)
|
|
50
48
|
/* No `<style>` in the markup — the scoped CSS is bundled into the entry stylesheet
|
|
51
49
|
the shell links (see `abideUiPlugin`), so SSR output is styled by that sheet. The
|
|
@@ -381,19 +381,26 @@ function emitNode(node: TemplateNode, builder: Builder): void {
|
|
|
381
381
|
narrowing — emitting it inside the `if` block (as a plain child) instead
|
|
382
382
|
gave it the positive narrowing, so a literal-union compare read as a
|
|
383
383
|
"no overlap" and a typeof-narrowed branch saw the wrong member. */
|
|
384
|
-
const
|
|
385
|
-
(child): child is Extract<TemplateNode, { kind: 'case' }> =>
|
|
386
|
-
child.kind === 'case' && child.match === undefined,
|
|
384
|
+
const branches = node.children.filter(
|
|
385
|
+
(child): child is Extract<TemplateNode, { kind: 'case' }> => child.kind === 'case',
|
|
387
386
|
)
|
|
388
|
-
const thenChildren = node.children.filter((child) => child !==
|
|
387
|
+
const thenChildren = node.children.filter((child) => child.kind !== 'case')
|
|
389
388
|
builder.raw('if ')
|
|
390
389
|
builder.expr(node.condition, node.loc)
|
|
391
390
|
builder.raw(' {\n')
|
|
392
391
|
emitNodes(thenChildren, builder)
|
|
393
392
|
builder.raw('}')
|
|
394
|
-
if
|
|
395
|
-
|
|
396
|
-
|
|
393
|
+
/* `elseif` → a real `else if` so its body inherits the prior conditions' negative
|
|
394
|
+
narrowing plus its own positive; `else` → the trailing block. */
|
|
395
|
+
for (const branch of branches) {
|
|
396
|
+
if (branch.condition !== undefined) {
|
|
397
|
+
builder.raw(' else if ')
|
|
398
|
+
builder.expr(branch.condition, branch.loc)
|
|
399
|
+
builder.raw(' {\n')
|
|
400
|
+
} else {
|
|
401
|
+
builder.raw(' else {\n')
|
|
402
|
+
}
|
|
403
|
+
emitNodes(branch.children, builder)
|
|
397
404
|
builder.raw('}')
|
|
398
405
|
}
|
|
399
406
|
builder.raw('\n')
|