@kudzujs/core 0.8.14 → 0.8.15
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/README.md +34 -6
- package/RELEASES.md +32 -0
- package/framework/README.md +18 -2
- package/framework/build.mjs +98 -2868
- package/framework/compiler/animation-frame-pass.mjs +103 -0
- package/framework/compiler/ast-helpers.mjs +181 -0
- package/framework/compiler/browser-signal-passes.mjs +182 -0
- package/framework/compiler/custom-hook-timer-pass.mjs +126 -0
- package/framework/compiler/effect-codegen.mjs +884 -0
- package/framework/compiler/handler-codegen.mjs +296 -0
- package/framework/compiler/normalization-pipeline.mjs +9 -0
- package/framework/compiler/react-migration-pass.mjs +338 -0
- package/framework/compiler/render-control-pass.mjs +96 -0
- package/framework/compiler/router-pass.mjs +245 -0
- package/framework/compiler/worker-compiler.mjs +163 -0
- package/framework/dev-server.mjs +244 -0
- package/package.json +1 -1
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import ts from "typescript"
|
|
2
|
+
import { bindingNames, functionVarDeclaresName, isFunctionLike, isLocalConst, isReferenceIdentifier, isShadowedIdentifier, loopDeclaresName, nearestFunction, sourceNodeError, statementDeclaresName, unwrapExpression } from "./ast-helpers.mjs"
|
|
3
|
+
|
|
4
|
+
export function createRouterPass({ withBase }) {
|
|
5
|
+
return function normalizeReactRouterSyntax(sourceFile, factory, context, base) {
|
|
6
|
+
const links = new Set()
|
|
7
|
+
const params = new Set()
|
|
8
|
+
const searchHooks = new Set()
|
|
9
|
+
const navigateHooks = new Set()
|
|
10
|
+
for (const statement of sourceFile.statements) {
|
|
11
|
+
if ((ts.isExportDeclaration(statement) || ts.isImportDeclaration(statement)) && statement.moduleSpecifier && ts.isStringLiteral(statement.moduleSpecifier) && statement.moduleSpecifier.text === "react-router-dom") {
|
|
12
|
+
if (ts.isExportDeclaration(statement)) throw sourceNodeError(statement, sourceFile, "React Router exports are not supported; import Link directly where it renders")
|
|
13
|
+
const clause = statement.importClause
|
|
14
|
+
if (clause?.isTypeOnly) continue
|
|
15
|
+
if (!clause) throw sourceNodeError(statement, sourceFile, "Side-effect React Router imports are not supported")
|
|
16
|
+
if (clause.name) throw sourceNodeError(clause.name, sourceFile, "React Router default imports are not supported; use named Link, useParams, useSearchParams, or useNavigate imports")
|
|
17
|
+
const bindings = clause.namedBindings
|
|
18
|
+
if (!bindings || ts.isNamespaceImport(bindings)) throw sourceNodeError(bindings ?? statement, sourceFile, "React Router namespace imports are not supported; use named Link, useParams, useSearchParams, or useNavigate imports")
|
|
19
|
+
for (const entry of bindings.elements) {
|
|
20
|
+
if (entry.isTypeOnly) continue
|
|
21
|
+
const imported = (entry.propertyName ?? entry.name).text
|
|
22
|
+
if (imported === "NavLink") throw sourceNodeError(entry, sourceFile, "React Router NavLink active-route semantics cannot be erased to a native anchor")
|
|
23
|
+
if (imported === "Link") links.add(entry.name.text)
|
|
24
|
+
else if (imported === "useParams") params.add(entry.name.text)
|
|
25
|
+
else if (imported === "useSearchParams") searchHooks.add(entry.name.text)
|
|
26
|
+
else if (imported === "useNavigate") navigateHooks.add(entry.name.text)
|
|
27
|
+
else throw sourceNodeError(entry, sourceFile, `React Router ${imported} is not supported; only named Link, useParams, useSearchParams, and useNavigate imports can be lowered`)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (!links.size && !params.size && !searchHooks.size && !navigateHooks.size) return sourceFile
|
|
32
|
+
|
|
33
|
+
let searchHelper = "__kUseSearchParam"
|
|
34
|
+
while (sourceFile.text.includes(searchHelper)) searchHelper += "_"
|
|
35
|
+
let searchWriterHelper = "__kUseSearchParamsWriter"
|
|
36
|
+
while (sourceFile.text.includes(searchWriterHelper)) searchWriterHelper += "_"
|
|
37
|
+
const searchDeclarations = new Set()
|
|
38
|
+
const searchReads = new Map()
|
|
39
|
+
const searchWrites = new Map()
|
|
40
|
+
const searchObjects = []
|
|
41
|
+
const collectSearchHooks = node => {
|
|
42
|
+
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && searchHooks.has(node.expression.text) && !isShadowedIdentifier(node.expression, sourceFile)) {
|
|
43
|
+
const declaration = node.parent
|
|
44
|
+
const statement = declaration?.parent?.parent
|
|
45
|
+
const owner = nearestFunction(node)
|
|
46
|
+
const first = ts.isVariableDeclaration(declaration) && ts.isArrayBindingPattern(declaration.name) ? declaration.name.elements[0] : undefined
|
|
47
|
+
const second = ts.isVariableDeclaration(declaration) && ts.isArrayBindingPattern(declaration.name) ? declaration.name.elements[1] : undefined
|
|
48
|
+
if (node.questionDotToken || node.arguments.length || node.typeArguments?.length || !ts.isVariableDeclaration(declaration) || declaration.initializer !== node || !isLocalConst(declaration) || !owner || statement?.parent !== owner.body || !ts.isBindingElement(first) || !ts.isIdentifier(first.name) || declaration.name.elements.length > 2 || second && (!ts.isBindingElement(second) || !ts.isIdentifier(second.name))) {
|
|
49
|
+
throw sourceNodeError(node, sourceFile, "React Router useSearchParams must initialize one top-level const [params] or [params, setParams] binding")
|
|
50
|
+
}
|
|
51
|
+
const entry = { name: first.name.text, setter: second?.name.text, declaration, statement, owner }
|
|
52
|
+
searchDeclarations.add(declaration)
|
|
53
|
+
searchObjects.push(entry)
|
|
54
|
+
}
|
|
55
|
+
ts.forEachChild(node, collectSearchHooks)
|
|
56
|
+
}
|
|
57
|
+
collectSearchHooks(sourceFile)
|
|
58
|
+
const localBindingShadowed = (node, entry, name = entry.name) => {
|
|
59
|
+
for (let current = node.parent; current && current !== entry.owner; current = current.parent) {
|
|
60
|
+
if (isFunctionLike(current) && (current.parameters.some(parameter => bindingNames(parameter.name).includes(name)) || functionVarDeclaresName(current, name))) return true
|
|
61
|
+
if (ts.isBlock(current) && current.statements.some(statement => statement !== entry.statement && statementDeclaresName(statement, name))) return true
|
|
62
|
+
if (ts.isCaseBlock(current) && current.clauses.some(clause => clause.statements.some(statement => statement !== entry.statement && statementDeclaresName(statement, name)))) return true
|
|
63
|
+
if (ts.isCatchClause(current) && current.variableDeclaration && bindingNames(current.variableDeclaration.name).includes(name)) return true
|
|
64
|
+
if ((ts.isForStatement(current) || ts.isForInStatement(current) || ts.isForOfStatement(current)) && loopDeclaresName(current, name)) return true
|
|
65
|
+
}
|
|
66
|
+
return false
|
|
67
|
+
}
|
|
68
|
+
for (const entry of searchObjects) {
|
|
69
|
+
const collectReads = node => {
|
|
70
|
+
if (ts.isIdentifier(node) && node.text === entry.name && isReferenceIdentifier(node) && !localBindingShadowed(node, entry)) {
|
|
71
|
+
if (entry.setter && ts.isCallExpression(node.parent) && node.parent.arguments.includes(node) && ts.isIdentifier(node.parent.expression) && node.parent.expression.text === entry.setter) return
|
|
72
|
+
const property = node.parent
|
|
73
|
+
const call = property?.parent
|
|
74
|
+
const declaration = call?.parent
|
|
75
|
+
const statement = declaration?.parent?.parent
|
|
76
|
+
if (!ts.isPropertyAccessExpression(property) || property.expression !== node || property.name.text !== "get" || !ts.isCallExpression(call) || call.expression !== property || call.questionDotToken || call.typeArguments?.length || call.arguments.length !== 1 || !ts.isStringLiteral(call.arguments[0])) {
|
|
77
|
+
throw sourceNodeError(node, sourceFile, 'React Router search parameters only support direct get("static-name") reads')
|
|
78
|
+
}
|
|
79
|
+
if (!ts.isVariableDeclaration(declaration) || declaration.initializer !== call || !ts.isIdentifier(declaration.name) || !isLocalConst(declaration) || statement?.parent !== entry.owner.body) {
|
|
80
|
+
throw sourceNodeError(call, sourceFile, "React Router search parameter get() must directly initialize one top-level const identifier")
|
|
81
|
+
}
|
|
82
|
+
searchReads.set(call, call.arguments[0])
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
ts.forEachChild(node, collectReads)
|
|
86
|
+
}
|
|
87
|
+
collectReads(entry.owner.body)
|
|
88
|
+
if (!entry.setter) continue
|
|
89
|
+
const collectWrites = node => {
|
|
90
|
+
if (ts.isIdentifier(node) && node.text === entry.setter && isReferenceIdentifier(node) && !localBindingShadowed(node, entry, entry.setter)) {
|
|
91
|
+
const call = node.parent
|
|
92
|
+
if (!ts.isCallExpression(call) || call.expression !== node || call.questionDotToken || call.typeArguments?.length || nearestFunction(call) === entry.owner) throw sourceNodeError(node, sourceFile, "React Router search parameter setters may only be called directly from a nested browser callback")
|
|
93
|
+
if (call.arguments.length < 1 || call.arguments.length > 2) throw sourceNodeError(call, sourceFile, "React Router search parameter setters require one inline updater and optional { replace: true }")
|
|
94
|
+
const updater = unwrapExpression(call.arguments[0])
|
|
95
|
+
if ((!ts.isArrowFunction(updater) && !ts.isFunctionExpression(updater)) || updater.asteriskToken || updater.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.AsyncKeyword) || updater.parameters.length !== 1 || !ts.isIdentifier(updater.parameters[0].name)) throw sourceNodeError(call.arguments[0], sourceFile, "React Router search parameter setters require one synchronous inline updater with one identifier parameter")
|
|
96
|
+
let replace = false
|
|
97
|
+
if (call.arguments.length === 2) {
|
|
98
|
+
const options = unwrapExpression(call.arguments[1])
|
|
99
|
+
const property = ts.isObjectLiteralExpression(options) && options.properties.length === 1 ? options.properties[0] : undefined
|
|
100
|
+
const name = property && ts.isPropertyAssignment(property) && !ts.isComputedPropertyName(property.name) && (ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)) ? property.name.text : undefined
|
|
101
|
+
if (name !== "replace" || property.initializer.kind !== ts.SyntaxKind.TrueKeyword) throw sourceNodeError(call.arguments[1], sourceFile, "React Router search parameter setters only support exactly { replace: true } as a second argument")
|
|
102
|
+
replace = true
|
|
103
|
+
}
|
|
104
|
+
searchWrites.set(call, { updater, replace })
|
|
105
|
+
return
|
|
106
|
+
}
|
|
107
|
+
ts.forEachChild(node, collectWrites)
|
|
108
|
+
}
|
|
109
|
+
collectWrites(entry.owner.body)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const navigateDeclarations = new Set()
|
|
113
|
+
const navigateCalls = new Map()
|
|
114
|
+
const navigateFunctions = []
|
|
115
|
+
const collectNavigateHooks = node => {
|
|
116
|
+
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && navigateHooks.has(node.expression.text) && !isShadowedIdentifier(node.expression, sourceFile)) {
|
|
117
|
+
const declaration = node.parent
|
|
118
|
+
const statement = declaration?.parent?.parent
|
|
119
|
+
const owner = nearestFunction(node)
|
|
120
|
+
if (node.questionDotToken || node.arguments.length || node.typeArguments?.length || !ts.isVariableDeclaration(declaration) || declaration.initializer !== node || !ts.isIdentifier(declaration.name) || !isLocalConst(declaration) || !owner || statement?.parent !== owner.body) {
|
|
121
|
+
throw sourceNodeError(node, sourceFile, "React Router useNavigate must initialize one top-level const identifier in a component")
|
|
122
|
+
}
|
|
123
|
+
const entry = { name: declaration.name.text, declaration, statement, owner }
|
|
124
|
+
navigateDeclarations.add(declaration)
|
|
125
|
+
navigateFunctions.push(entry)
|
|
126
|
+
}
|
|
127
|
+
ts.forEachChild(node, collectNavigateHooks)
|
|
128
|
+
}
|
|
129
|
+
collectNavigateHooks(sourceFile)
|
|
130
|
+
for (const entry of navigateFunctions) {
|
|
131
|
+
const collectCalls = node => {
|
|
132
|
+
if (ts.isIdentifier(node) && node.text === entry.name && isReferenceIdentifier(node) && !localBindingShadowed(node, entry)) {
|
|
133
|
+
const call = node.parent
|
|
134
|
+
if (!ts.isCallExpression(call) || call.expression !== node || call.questionDotToken || call.typeArguments?.length || nearestFunction(call) === entry.owner) {
|
|
135
|
+
throw sourceNodeError(node, sourceFile, "React Router navigate bindings may only be called directly from a nested browser callback")
|
|
136
|
+
}
|
|
137
|
+
if (call.arguments.length < 1 || call.arguments.length > 2 || !ts.isStringLiteral(call.arguments[0])) {
|
|
138
|
+
throw sourceNodeError(call, sourceFile, 'React Router useNavigate requires a static root-relative navigate("/path") destination')
|
|
139
|
+
}
|
|
140
|
+
const destination = call.arguments[0].text
|
|
141
|
+
const pathname = destination.match(/^[^?#]*/)[0]
|
|
142
|
+
let decoded
|
|
143
|
+
try { decoded = decodeURIComponent(pathname) } catch { throw sourceNodeError(call.arguments[0], sourceFile, 'React Router useNavigate requires a safe static root-relative navigate("/path") destination') }
|
|
144
|
+
if (!destination.startsWith("/") || destination.startsWith("//") || /%(?:2f|5c)/i.test(pathname) || /[\\\0]/.test(decoded) || decoded.split("/").includes("..") || [...decoded].some(character => character.charCodeAt(0) < 32 || character.charCodeAt(0) >= 127 && character.charCodeAt(0) <= 159)) throw sourceNodeError(call.arguments[0], sourceFile, 'React Router useNavigate requires a safe static root-relative navigate("/path") destination')
|
|
145
|
+
let method = "assign"
|
|
146
|
+
if (call.arguments.length === 2) {
|
|
147
|
+
const options = unwrapExpression(call.arguments[1])
|
|
148
|
+
const property = ts.isObjectLiteralExpression(options) && options.properties.length === 1 ? options.properties[0] : undefined
|
|
149
|
+
const name = property && ts.isPropertyAssignment(property) && !ts.isComputedPropertyName(property.name) && (ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)) ? property.name.text : undefined
|
|
150
|
+
if (name !== "replace" || property.initializer.kind !== ts.SyntaxKind.TrueKeyword) throw sourceNodeError(call.arguments[1], sourceFile, 'React Router useNavigate only supports exactly { replace: true } as a second argument')
|
|
151
|
+
method = "replace"
|
|
152
|
+
}
|
|
153
|
+
navigateCalls.set(call, { method, destination: withBase(base, destination) })
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
ts.forEachChild(node, collectCalls)
|
|
157
|
+
}
|
|
158
|
+
collectCalls(entry.owner.body)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const routerProps = new Set(["discover", "end", "prefetch", "preventScrollReset", "relative", "reloadDocument", "replace", "state", "viewTransition"])
|
|
162
|
+
const attributes = attributesNode => {
|
|
163
|
+
const output = []
|
|
164
|
+
let destination
|
|
165
|
+
for (const property of attributesNode.properties) {
|
|
166
|
+
if (ts.isJsxSpreadAttribute(property)) throw sourceNodeError(property, sourceFile, "React Router Link does not support spread attributes during native anchor lowering")
|
|
167
|
+
const name = property.name.text
|
|
168
|
+
if (name === "href") throw sourceNodeError(property, sourceFile, "React Router Link must not declare href; Kudzu derives it from to")
|
|
169
|
+
if (routerProps.has(name)) throw sourceNodeError(property, sourceFile, `React Router Link prop ${JSON.stringify(name)} cannot be erased to a native anchor`)
|
|
170
|
+
if (name !== "to") {
|
|
171
|
+
output.push(ts.visitEachChild(property, visitor, context))
|
|
172
|
+
continue
|
|
173
|
+
}
|
|
174
|
+
if (destination !== undefined) throw sourceNodeError(property, sourceFile, "React Router Link requires exactly one to attribute")
|
|
175
|
+
if (!property.initializer || !ts.isStringLiteral(property.initializer)) throw sourceNodeError(property, sourceFile, 'React Router Link requires a static root-relative to="/path"')
|
|
176
|
+
destination = property.initializer.text
|
|
177
|
+
const pathname = destination.match(/^[^?#]*/)[0]
|
|
178
|
+
let decoded
|
|
179
|
+
try { decoded = decodeURIComponent(pathname) } catch { throw sourceNodeError(property.initializer, sourceFile, 'React Router Link requires a safe static root-relative to="/path"') }
|
|
180
|
+
if (!destination.startsWith("/") || destination.startsWith("//") || /%(?:2f|5c)/i.test(pathname) || /[\\\0]/.test(decoded) || decoded.split("/").includes("..") || [...decoded].some(character => character.charCodeAt(0) < 32 || character.charCodeAt(0) >= 127 && character.charCodeAt(0) <= 159)) throw sourceNodeError(property.initializer, sourceFile, 'React Router Link requires a safe static root-relative to="/path"')
|
|
181
|
+
output.push(factory.createJsxAttribute(factory.createIdentifier("href"), factory.createStringLiteral(withBase(base, destination))))
|
|
182
|
+
}
|
|
183
|
+
if (destination === undefined) throw sourceNodeError(attributesNode.parent, sourceFile, "React Router Link requires exactly one static root-relative to attribute")
|
|
184
|
+
return factory.updateJsxAttributes(attributesNode, output)
|
|
185
|
+
}
|
|
186
|
+
const importedLink = tag => ts.isIdentifier(tag) && links.has(tag.text) && !isShadowedIdentifier(tag, sourceFile)
|
|
187
|
+
const visitor = node => {
|
|
188
|
+
if (ts.isVariableStatement(node) && node.declarationList.declarations.some(declaration => searchDeclarations.has(declaration) || navigateDeclarations.has(declaration))) {
|
|
189
|
+
const declarations = node.declarationList.declarations.flatMap(declaration => {
|
|
190
|
+
if (navigateDeclarations.has(declaration)) return []
|
|
191
|
+
if (!searchDeclarations.has(declaration)) return [ts.visitEachChild(declaration, visitor, context)]
|
|
192
|
+
const entry = searchObjects.find(candidate => candidate.declaration === declaration)
|
|
193
|
+
if (!entry?.setter) return []
|
|
194
|
+
return [factory.updateVariableDeclaration(declaration, declaration.name, declaration.exclamationToken, declaration.type, factory.createCallExpression(factory.createIdentifier(searchWriterHelper), undefined, []))]
|
|
195
|
+
})
|
|
196
|
+
if (!declarations.length) return undefined
|
|
197
|
+
return factory.updateVariableStatement(node, node.modifiers, factory.updateVariableDeclarationList(node.declarationList, declarations))
|
|
198
|
+
}
|
|
199
|
+
if (ts.isCallExpression(node) && searchReads.has(node)) return factory.createCallExpression(factory.createIdentifier(searchHelper), undefined, [searchReads.get(node)])
|
|
200
|
+
if (ts.isCallExpression(node) && searchWrites.has(node)) {
|
|
201
|
+
const { updater, replace } = searchWrites.get(node)
|
|
202
|
+
return factory.createCallExpression(factory.createPropertyAccessExpression(factory.createIdentifier("globalThis"), "__kSetSearchParams"), undefined, [ts.visitNode(updater, visitor), replace ? factory.createTrue() : factory.createFalse()])
|
|
203
|
+
}
|
|
204
|
+
if (ts.isCallExpression(node) && navigateCalls.has(node)) {
|
|
205
|
+
const { method, destination } = navigateCalls.get(node)
|
|
206
|
+
return factory.createCallExpression(factory.createPropertyAccessExpression(factory.createPropertyAccessExpression(factory.createIdentifier("globalThis"), "location"), method), undefined, [factory.createStringLiteral(destination)])
|
|
207
|
+
}
|
|
208
|
+
if (ts.isJsxElement(node) && importedLink(node.openingElement.tagName)) {
|
|
209
|
+
const opening = factory.updateJsxOpeningElement(node.openingElement, factory.createIdentifier("a"), node.openingElement.typeArguments, attributes(node.openingElement.attributes))
|
|
210
|
+
const closing = factory.updateJsxClosingElement(node.closingElement, factory.createIdentifier("a"))
|
|
211
|
+
return factory.updateJsxElement(node, opening, ts.visitNodes(node.children, visitor), closing)
|
|
212
|
+
}
|
|
213
|
+
if (ts.isJsxSelfClosingElement(node) && importedLink(node.tagName)) return factory.updateJsxSelfClosingElement(node, factory.createIdentifier("a"), node.typeArguments, attributes(node.attributes))
|
|
214
|
+
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && params.has(node.expression.text) && !isShadowedIdentifier(node.expression, sourceFile)) {
|
|
215
|
+
if (node.questionDotToken || node.arguments.length || (node.typeArguments?.length ?? 0) > 1) throw sourceNodeError(node, sourceFile, "React Router useParams must be called directly without runtime arguments and with at most one type argument")
|
|
216
|
+
return node
|
|
217
|
+
}
|
|
218
|
+
if (ts.isIdentifier(node) && links.has(node.text) && isReferenceIdentifier(node) && !isShadowedIdentifier(node, sourceFile)) throw sourceNodeError(node, sourceFile, "React Router Link imports may only be used as direct JSX elements")
|
|
219
|
+
if (ts.isIdentifier(node) && params.has(node.text) && isReferenceIdentifier(node) && !isShadowedIdentifier(node, sourceFile)) throw sourceNodeError(node, sourceFile, "React Router useParams imports may only be called directly")
|
|
220
|
+
if (ts.isIdentifier(node) && searchHooks.has(node.text) && isReferenceIdentifier(node) && !isShadowedIdentifier(node, sourceFile)) throw sourceNodeError(node, sourceFile, "React Router useSearchParams imports may only initialize the supported top-level tuple binding")
|
|
221
|
+
if (ts.isIdentifier(node) && navigateHooks.has(node.text) && isReferenceIdentifier(node) && !isShadowedIdentifier(node, sourceFile)) throw sourceNodeError(node, sourceFile, "React Router useNavigate imports may only initialize the supported top-level navigate binding")
|
|
222
|
+
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier) && node.moduleSpecifier.text === "react-router-dom") {
|
|
223
|
+
const clause = node.importClause
|
|
224
|
+
if (!clause || clause.isTypeOnly) return node
|
|
225
|
+
const bindings = clause.namedBindings
|
|
226
|
+
if (!bindings || !ts.isNamedImports(bindings)) return node
|
|
227
|
+
const elements = bindings.elements.filter(entry => entry.isTypeOnly || !["Link", "useParams", "useSearchParams", "useNavigate"].includes((entry.propertyName ?? entry.name).text))
|
|
228
|
+
if (!elements.length) return undefined
|
|
229
|
+
return factory.updateImportDeclaration(node, node.modifiers, factory.updateImportClause(clause, clause.isTypeOnly, undefined, factory.updateNamedImports(bindings, elements)), node.moduleSpecifier, node.attributes)
|
|
230
|
+
}
|
|
231
|
+
return ts.visitEachChild(node, visitor, context)
|
|
232
|
+
}
|
|
233
|
+
const normalized = ts.visitNode(sourceFile, visitor)
|
|
234
|
+
if (!params.size && !searchHooks.size) return normalized
|
|
235
|
+
const imports = [
|
|
236
|
+
...[...params].map(name => factory.createImportSpecifier(false, name === "useParams" ? undefined : factory.createIdentifier("useParams"), factory.createIdentifier(name))),
|
|
237
|
+
...(searchReads.size ? [factory.createImportSpecifier(false, factory.createIdentifier("useSearchParam"), factory.createIdentifier(searchHelper))] : []),
|
|
238
|
+
...(searchObjects.some(entry => entry.setter) ? [factory.createImportSpecifier(false, factory.createIdentifier("useSearchParamsWriter"), factory.createIdentifier(searchWriterHelper))] : [])
|
|
239
|
+
]
|
|
240
|
+
const declaration = factory.createImportDeclaration(undefined, factory.createImportClause(false, undefined, factory.createNamedImports(imports)), factory.createStringLiteral("@kudzujs/core"))
|
|
241
|
+
const statements = [...normalized.statements]
|
|
242
|
+
statements.splice(statements.findLastIndex(statement => ts.isImportDeclaration(statement)) + 1, 0, declaration)
|
|
243
|
+
return factory.updateSourceFile(normalized, statements)
|
|
244
|
+
}
|
|
245
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { createHash } from "node:crypto"
|
|
2
|
+
import { mkdir, readFile } from "node:fs/promises"
|
|
3
|
+
import { dirname, relative, resolve, sep } from "node:path"
|
|
4
|
+
import { build as bundle } from "esbuild"
|
|
5
|
+
import ts from "typescript"
|
|
6
|
+
import { containsJsx, isUnshadowedGlobal, nearestFunction, sourceNodeError } from "./ast-helpers.mjs"
|
|
7
|
+
|
|
8
|
+
export function createWorkerCompiler({
|
|
9
|
+
root,
|
|
10
|
+
sourceDirectory,
|
|
11
|
+
outputDirectory,
|
|
12
|
+
assetPath,
|
|
13
|
+
parseSourceFile,
|
|
14
|
+
resolveSourceImport,
|
|
15
|
+
runtimeModuleReference
|
|
16
|
+
}) {
|
|
17
|
+
const isImportMetaUrl = node => ts.isPropertyAccessExpression(node) && node.name.text === "url" && ts.isMetaProperty(node.expression) && node.expression.keywordToken === ts.SyntaxKind.ImportKeyword && node.expression.name.text === "meta"
|
|
18
|
+
|
|
19
|
+
const candidate = (node, sourceFile) => {
|
|
20
|
+
if (!ts.isNewExpression(node) || !ts.isIdentifier(node.expression) || node.expression.text !== "Worker") return undefined
|
|
21
|
+
const first = node.arguments?.[0]
|
|
22
|
+
if (!first || !ts.isNewExpression(first) || !ts.isIdentifier(first.expression) || first.expression.text !== "URL") return undefined
|
|
23
|
+
const specifier = first.arguments?.[0]
|
|
24
|
+
const base = first.arguments?.[1]
|
|
25
|
+
const relativeLiteral = ts.isStringLiteral(specifier) && (specifier.text.startsWith("./") || specifier.text.startsWith("../"))
|
|
26
|
+
if (!relativeLiteral && !(specifier && !ts.isStringLiteral(specifier) && base && isImportMetaUrl(base))) return undefined
|
|
27
|
+
return { worker: node, url: first, sourceFile }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const validateCandidate = (value, sourceFile) => {
|
|
31
|
+
const { worker, url } = value
|
|
32
|
+
if (!isUnshadowedGlobal(worker.expression, sourceFile)) throw sourceNodeError(worker.expression, sourceFile, "Relative TypeScript Workers require the unshadowed global Worker constructor")
|
|
33
|
+
if (!isUnshadowedGlobal(url.expression, sourceFile)) throw sourceNodeError(url.expression, sourceFile, "Relative TypeScript Workers require the unshadowed global URL constructor")
|
|
34
|
+
if (url.arguments?.length !== 2 || !isImportMetaUrl(url.arguments[1])) throw sourceNodeError(url, sourceFile, "Relative TypeScript Workers require new URL(relativeLiteral, import.meta.url)")
|
|
35
|
+
const specifierNode = url.arguments[0]
|
|
36
|
+
if (!ts.isStringLiteral(specifierNode) || !(specifierNode.text.startsWith("./") || specifierNode.text.startsWith("../"))) throw sourceNodeError(specifierNode, sourceFile, "Relative TypeScript Worker paths must be relative string literals")
|
|
37
|
+
if (/[\\?#]/.test(specifierNode.text) || !specifierNode.text.endsWith(".worker.ts")) throw sourceNodeError(specifierNode, sourceFile, "Relative TypeScript Worker paths must end in .worker.ts")
|
|
38
|
+
if (worker.arguments?.length !== 2) throw sourceNodeError(worker, sourceFile, 'Relative TypeScript Workers require exactly { type: "module" } as the second argument')
|
|
39
|
+
const options = worker.arguments[1]
|
|
40
|
+
if (!ts.isObjectLiteralExpression(options) || options.properties.length !== 1) throw sourceNodeError(options, sourceFile, 'Relative TypeScript Workers require exactly { type: "module" } as the second argument')
|
|
41
|
+
const property = options.properties[0]
|
|
42
|
+
const name = ts.isPropertyAssignment(property) && !ts.isComputedPropertyName(property.name) && (ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)) ? property.name.text : undefined
|
|
43
|
+
if (name !== "type" || !ts.isStringLiteral(property.initializer) || property.initializer.text !== "module") throw sourceNodeError(property, sourceFile, 'Relative TypeScript Workers require exactly { type: "module" } as the second argument')
|
|
44
|
+
return { worker, url, specifier: specifierNode.text, options }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const rewriteEffect = (callback, file, sourceFile, sourceFiles, workerReferences, factory, context) => {
|
|
48
|
+
const visit = node => {
|
|
49
|
+
const value = candidate(node, sourceFile)
|
|
50
|
+
if (value) {
|
|
51
|
+
if (nearestFunction(node) !== callback) throw sourceNodeError(node, sourceFile, "Relative TypeScript Worker construction must be directly inside the inline useEffect() callback, not a nested function")
|
|
52
|
+
const { worker, url, specifier, options } = validateCandidate(value, sourceFile)
|
|
53
|
+
const target = resolve(dirname(file), specifier)
|
|
54
|
+
const sourceRelative = relative(sourceDirectory, target)
|
|
55
|
+
if (sourceRelative.startsWith(`..${sep}`) || sourceRelative === ".." || resolve(sourceDirectory, sourceRelative) !== target) throw sourceNodeError(url.arguments[0], sourceFile, "Relative TypeScript Worker source must remain under src/")
|
|
56
|
+
if (!sourceFiles.has(target)) throw sourceNodeError(url.arguments[0], sourceFile, `Relative TypeScript Worker ${JSON.stringify(specifier)} must resolve to an existing .worker.ts file under src/`)
|
|
57
|
+
const identity = `${sourceRelative.replaceAll(sep, "/")}:${ts.getOriginalNode(node).getStart(sourceFile)}`
|
|
58
|
+
const placeholder = `/__kudzu_worker_${createHash("sha256").update(identity).digest("hex").slice(0, 16)}__.js`
|
|
59
|
+
workerReferences.push({ root: target, placeholder })
|
|
60
|
+
return factory.updateNewExpression(worker, worker.expression, worker.typeArguments, [factory.createStringLiteral(placeholder), options])
|
|
61
|
+
}
|
|
62
|
+
return ts.visitEachChild(node, visit, context)
|
|
63
|
+
}
|
|
64
|
+
return ts.visitEachChild(callback, visit, context)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const rejectConstructions = (expression, sourceFile, message) => {
|
|
68
|
+
const visit = node => {
|
|
69
|
+
if (candidate(node, sourceFile)) throw sourceNodeError(node, sourceFile, message)
|
|
70
|
+
ts.forEachChild(node, visit)
|
|
71
|
+
}
|
|
72
|
+
visit(expression.body ?? expression)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const rejectOrdinaryImports = (sourceFile, file, sourceFiles) => {
|
|
76
|
+
for (const node of sourceFile.statements) {
|
|
77
|
+
let specifier
|
|
78
|
+
let runtime = false
|
|
79
|
+
if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) {
|
|
80
|
+
specifier = node.moduleSpecifier
|
|
81
|
+
runtime = runtimeModuleReference(node)
|
|
82
|
+
} else if (ts.isImportEqualsDeclaration(node) && ts.isExternalModuleReference(node.moduleReference) && ts.isStringLiteral(node.moduleReference.expression)) {
|
|
83
|
+
specifier = node.moduleReference.expression
|
|
84
|
+
runtime = !node.isTypeOnly
|
|
85
|
+
}
|
|
86
|
+
if (!runtime || !specifier?.text.startsWith(".")) continue
|
|
87
|
+
let target
|
|
88
|
+
try {
|
|
89
|
+
target = resolveSourceImport(file, specifier.text, sourceFiles)
|
|
90
|
+
} catch {
|
|
91
|
+
continue
|
|
92
|
+
}
|
|
93
|
+
if (target.endsWith(".worker.ts")) throw sourceNodeError(specifier, sourceFile, "Worker source modules cannot be imported or re-exported as ordinary runtime modules; use new Worker(new URL(relative.worker.ts, import.meta.url), { type: \"module\" }) inside an inline useEffect() callback")
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const validateGraphs = async (roots, sourceFiles) => {
|
|
98
|
+
const visited = new Set()
|
|
99
|
+
const queue = [...roots]
|
|
100
|
+
while (queue.length) {
|
|
101
|
+
const file = queue.shift()
|
|
102
|
+
if (visited.has(file)) continue
|
|
103
|
+
visited.add(file)
|
|
104
|
+
const sourceFile = parseSourceFile(file, await readFile(file, "utf8"))
|
|
105
|
+
if (containsJsx(sourceFile)) throw sourceNodeError(sourceFile, sourceFile, "Worker modules must not contain JSX")
|
|
106
|
+
const visit = node => {
|
|
107
|
+
if (ts.isImportEqualsDeclaration(node)) throw sourceNodeError(node, sourceFile, "TypeScript import-equals declarations are not supported in Worker modules; use a relative ESM import")
|
|
108
|
+
if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) throw sourceNodeError(node, sourceFile, "Dynamic imports are not supported in Worker modules")
|
|
109
|
+
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === "require") throw sourceNodeError(node, sourceFile, "require() is not supported in Worker modules")
|
|
110
|
+
ts.forEachChild(node, visit)
|
|
111
|
+
}
|
|
112
|
+
visit(sourceFile)
|
|
113
|
+
for (const node of sourceFile.statements) {
|
|
114
|
+
if ((!ts.isImportDeclaration(node) && !ts.isExportDeclaration(node)) || !node.moduleSpecifier || !ts.isStringLiteral(node.moduleSpecifier) || !runtimeModuleReference(node)) continue
|
|
115
|
+
if (!node.moduleSpecifier.text.startsWith(".")) throw sourceNodeError(node.moduleSpecifier, sourceFile, "Worker modules may only use relative runtime imports")
|
|
116
|
+
try {
|
|
117
|
+
queue.push(resolveSourceImport(file, node.moduleSpecifier.text, sourceFiles))
|
|
118
|
+
} catch (error) {
|
|
119
|
+
const message = error.message.slice(error.message.indexOf("Relative import"))
|
|
120
|
+
throw sourceNodeError(node.moduleSpecifier, sourceFile, message)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const emit = async (references, sourceFiles, assetsDirectory, base, minify) => {
|
|
127
|
+
const roots = [...new Set(references.map(reference => reference.root))].sort()
|
|
128
|
+
if (!roots.length) return new Map()
|
|
129
|
+
await validateGraphs(roots, sourceFiles)
|
|
130
|
+
const workerDirectory = resolve(assetsDirectory, "workers")
|
|
131
|
+
await mkdir(workerDirectory, { recursive: true })
|
|
132
|
+
const result = await bundle({
|
|
133
|
+
absWorkingDir: root,
|
|
134
|
+
entryPoints: roots,
|
|
135
|
+
outbase: sourceDirectory,
|
|
136
|
+
outdir: workerDirectory,
|
|
137
|
+
entryNames: "[dir]/[name]-[hash]",
|
|
138
|
+
chunkNames: "chunks/[name]-[hash]",
|
|
139
|
+
bundle: true,
|
|
140
|
+
splitting: true,
|
|
141
|
+
format: "esm",
|
|
142
|
+
platform: "browser",
|
|
143
|
+
target: "es2022",
|
|
144
|
+
minify,
|
|
145
|
+
legalComments: "none",
|
|
146
|
+
metafile: true,
|
|
147
|
+
logLevel: "silent"
|
|
148
|
+
})
|
|
149
|
+
const emitted = new Map()
|
|
150
|
+
for (const [output, metadata] of Object.entries(result.metafile.outputs)) {
|
|
151
|
+
if (!metadata.entryPoint) continue
|
|
152
|
+
const entry = resolve(root, metadata.entryPoint)
|
|
153
|
+
const rootReferences = references.filter(reference => reference.root === entry)
|
|
154
|
+
const outputFile = resolve(root, output)
|
|
155
|
+
const url = assetPath(base, relative(outputDirectory, outputFile).replaceAll(sep, "/"))
|
|
156
|
+
for (const reference of rootReferences) emitted.set(reference.placeholder, url)
|
|
157
|
+
}
|
|
158
|
+
for (const reference of references) if (!emitted.has(reference.placeholder)) throw new Error(`Worker entry was not emitted: ${relative(root, reference.root)}`)
|
|
159
|
+
return emitted
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return { candidate, emit, rejectConstructions, rejectOrdinaryImports, rewriteEffect }
|
|
163
|
+
}
|