@kudzujs/core 0.8.13 → 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 +62 -0
- package/framework/README.md +22 -2
- package/framework/build.mjs +123 -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,244 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto"
|
|
2
|
+
import { readFile, stat, watch } from "node:fs/promises"
|
|
3
|
+
import { createServer } from "node:http"
|
|
4
|
+
import { extname, join, resolve, sep } from "node:path"
|
|
5
|
+
import { stateSchema } from "./dev-state.js"
|
|
6
|
+
|
|
7
|
+
const devClient = (session, revision, schema) => `<script>(()=>{const show=event=>{let box=document.getElementById("__kudzu_error");if(!box){box=document.createElement("div");box.id="__kudzu_error";box.setAttribute("role","alert");box.setAttribute("aria-live","assertive");box.style.cssText="position:fixed;inset:0;z-index:2147483647;overflow:auto;padding:2rem;background:#200;color:#fff;font:16px/1.5 ui-monospace,monospace";const title=document.createElement("strong"),text=document.createElement("pre");title.textContent="Kudzu build error";text.style.whiteSpace="pre-wrap";box.append(title,text);document.body.append(box)}box.querySelector("pre").textContent=event.data};const schema=${inlineJson(schema)},route=location.pathname+location.search+location.hash,urls=[...document.querySelectorAll('script[type="module"][src]')].map(node=>node.src).filter(url=>/\/assets\/kudzu(?:-(?:binding|list|native))?\.js$/.test(new URL(url).pathname));const devImport=import("/__kudzu_dev.js"),runtimeImports=Promise.allSettled(urls.map(url=>import(url)));const ready=(async()=>{const dev=await devImport,modules=await runtimeImports,runtime=modules.find(result=>result.status==="fulfilled"&&result.value.browserState instanceof Map&&typeof result.value.commitDom==="function")?.value;try{dev.restoreState(sessionStorage,route,runtime?.browserState,schema,runtime?.commitDom)}catch{}return{dev,runtime}})().catch(()=>({}));const events=new EventSource("/__kudzu_reload?session=${session}&revision=${revision}");let reloading=false;events.addEventListener("reload",async()=>{if(reloading)return;reloading=true;try{const{dev,runtime}=await ready;dev?.snapshotState(sessionStorage,route,runtime?.browserState,schema)}catch{}location.reload()});events.addEventListener("build-error",show)})()</script>`
|
|
8
|
+
|
|
9
|
+
export function parseDevPort(value) {
|
|
10
|
+
if (value === undefined || value.trim() === "") return 3000
|
|
11
|
+
if (!/^\d+$/.test(value)) throw new Error(`Invalid dev server port: ${value}`)
|
|
12
|
+
const port = Number(value)
|
|
13
|
+
if (port > 65535) throw new Error(`Invalid dev server port: ${value}`)
|
|
14
|
+
return port
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function parseDevHost(value) {
|
|
18
|
+
return value?.trim() || "127.0.0.1"
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function startDevServer({ build, port, host, base, sourceDirectory, workDirectory, outputDirectory }) {
|
|
22
|
+
let buildError
|
|
23
|
+
let revision = 0
|
|
24
|
+
const session = randomUUID()
|
|
25
|
+
try {
|
|
26
|
+
await build({ minify: false })
|
|
27
|
+
revision++
|
|
28
|
+
} catch (error) {
|
|
29
|
+
buildError = errorText(error)
|
|
30
|
+
console.error(error)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const clients = new Set()
|
|
34
|
+
const server = createServer(async (request, response) => {
|
|
35
|
+
try {
|
|
36
|
+
const url = new URL(request.url, "http://localhost")
|
|
37
|
+
const rawPathname = url.pathname
|
|
38
|
+
const pathname = decodeURIComponent(rawPathname)
|
|
39
|
+
if (pathname === "/__kudzu_reload") {
|
|
40
|
+
response.writeHead(200, { "content-type": "text/event-stream; charset=utf-8", "cache-control": "no-cache, no-transform", connection: "keep-alive" })
|
|
41
|
+
response.write(": connected\n\n")
|
|
42
|
+
clients.add(response)
|
|
43
|
+
request.on("close", () => clients.delete(response))
|
|
44
|
+
if (buildError) sendEvent(response, "build-error", buildError)
|
|
45
|
+
else if (url.searchParams.get("session") !== session || url.searchParams.get("revision") !== String(revision)) sendEvent(response, "reload")
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
if (pathname === "/__kudzu_dev.js") {
|
|
49
|
+
response.writeHead(200, { "content-type": "text/javascript; charset=utf-8", "cache-control": "no-store" })
|
|
50
|
+
response.end(await readFile(new URL("./dev-state.js", import.meta.url)))
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const relativePath = stripBaseStrict(pathname, decodeURIComponent(base)).replace(/^\/+/, "")
|
|
55
|
+
let file = resolve(outputDirectory, relativePath)
|
|
56
|
+
if (!file.startsWith(`${outputDirectory}${sep}`) && file !== outputDirectory) throw new Error("Invalid path")
|
|
57
|
+
if ((await exists(file)) && (await stat(file)).isDirectory()) file = join(file, "index.html")
|
|
58
|
+
if (!(await exists(file)) && !extname(file)) file = join(file, "index.html")
|
|
59
|
+
let matchedRoute
|
|
60
|
+
if (!(await exists(file)) && !buildError) {
|
|
61
|
+
const plan = JSON.parse(await readFile(join(workDirectory, "kudzu-plan.json"), "utf8"))
|
|
62
|
+
const rewrite = plan.rewrites?.find(entry => runtimePathValues(rawPathname, entry, browserPath(base)))
|
|
63
|
+
if (rewrite) {
|
|
64
|
+
file = resolve(outputDirectory, rewrite.file)
|
|
65
|
+
matchedRoute = rewrite.pattern
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const isHtml = extname(file) === ".html"
|
|
69
|
+
const content = isHtml
|
|
70
|
+
? injectDevClient(buildError ? errorPage(buildError) : await readFile(file, "utf8"), session, revision, buildError ? [] : await devSchema(workDirectory, withBase(base, stripBaseStrict(pathname, decodeURIComponent(base))), matchedRoute))
|
|
71
|
+
: await readFile(file)
|
|
72
|
+
response.writeHead(200, { "content-type": contentType(file), "cache-control": "no-store" })
|
|
73
|
+
response.end(content)
|
|
74
|
+
} catch {
|
|
75
|
+
response.writeHead(404, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" })
|
|
76
|
+
response.end("Not found")
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
const listeningPort = await listenDevServer(server, port, host)
|
|
81
|
+
console.log(`Kudzu dev server: http://${host}:${listeningPort}`)
|
|
82
|
+
|
|
83
|
+
let timer
|
|
84
|
+
let rebuilding = false
|
|
85
|
+
let pending = false
|
|
86
|
+
let changedFile
|
|
87
|
+
const rebuild = async () => {
|
|
88
|
+
if (rebuilding) {
|
|
89
|
+
pending = true
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
rebuilding = true
|
|
93
|
+
do {
|
|
94
|
+
pending = false
|
|
95
|
+
try {
|
|
96
|
+
await build({ quiet: true, minify: false })
|
|
97
|
+
buildError = undefined
|
|
98
|
+
revision++
|
|
99
|
+
console.log(`Rebuilt after ${changedFile ?? "source change"}`)
|
|
100
|
+
for (const client of clients) sendEvent(client, "reload")
|
|
101
|
+
} catch (error) {
|
|
102
|
+
buildError = errorText(error)
|
|
103
|
+
console.error(error)
|
|
104
|
+
for (const client of clients) sendEvent(client, "build-error", buildError)
|
|
105
|
+
}
|
|
106
|
+
} while (pending)
|
|
107
|
+
rebuilding = false
|
|
108
|
+
}
|
|
109
|
+
const watcher = watch(sourceDirectory, { recursive: true })
|
|
110
|
+
for await (const event of watcher) {
|
|
111
|
+
changedFile = event.filename
|
|
112
|
+
clearTimeout(timer)
|
|
113
|
+
timer = setTimeout(rebuild, 80)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function listenDevServer(server, port, host) {
|
|
118
|
+
let candidate = port
|
|
119
|
+
while (true) {
|
|
120
|
+
try {
|
|
121
|
+
await new Promise((resolve, reject) => {
|
|
122
|
+
const onError = error => {
|
|
123
|
+
server.off("listening", onListening)
|
|
124
|
+
reject(error)
|
|
125
|
+
}
|
|
126
|
+
const onListening = () => {
|
|
127
|
+
server.off("error", onError)
|
|
128
|
+
resolve()
|
|
129
|
+
}
|
|
130
|
+
server.once("error", onError)
|
|
131
|
+
server.once("listening", onListening)
|
|
132
|
+
server.listen(candidate, host)
|
|
133
|
+
})
|
|
134
|
+
return server.address().port
|
|
135
|
+
} catch (error) {
|
|
136
|
+
if (error.code !== "EADDRINUSE" || candidate === 0 || candidate === 65535) throw error
|
|
137
|
+
console.log(`Port ${candidate} is in use, trying ${candidate + 1}`)
|
|
138
|
+
candidate++
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function injectDevClient(html, session, revision, schema) {
|
|
144
|
+
return `${html}${devClient(session, revision, schema).replace("binding|list|native", "binding|deps|list|native")}`
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function stripBaseStrict(path, base) {
|
|
148
|
+
if (!base) return path
|
|
149
|
+
if (path === base) return "/"
|
|
150
|
+
if (path.startsWith(`${base}/`)) return path.slice(base.length)
|
|
151
|
+
throw new Error("Path is outside the configured base")
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function devSchema(workDirectory, pathname, matchedRoute) {
|
|
155
|
+
try {
|
|
156
|
+
const plan = JSON.parse(await readFile(join(workDirectory, "kudzu-plan.json"), "utf8"))
|
|
157
|
+
const route = matchedRoute ?? (pathname.replace(/\/(?:index\.html)?$/, "") || "/")
|
|
158
|
+
return stateSchema(plan.routes.find(entry => entry.route === route)?.states ?? [])
|
|
159
|
+
} catch {
|
|
160
|
+
return []
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function runtimePathValues(pathname, rewrite, base) {
|
|
165
|
+
try {
|
|
166
|
+
let path = stripBrowserBase(pathname, base)
|
|
167
|
+
if (path.length > 1 && path.endsWith("/")) path = path.slice(0, -1)
|
|
168
|
+
const rawSegments = path.slice(1).split("/")
|
|
169
|
+
if (rawSegments.length !== rewrite.segments.length) return undefined
|
|
170
|
+
const values = Object.create(null)
|
|
171
|
+
for (let index = 0; index < rewrite.segments.length; index++) {
|
|
172
|
+
const segment = rewrite.segments[index]
|
|
173
|
+
const value = decodeRuntimeSegment(rawSegments[index], Boolean(segment.param))
|
|
174
|
+
if (segment.literal !== undefined && value !== segment.literal) return undefined
|
|
175
|
+
if (segment.param) values[segment.param] = value
|
|
176
|
+
}
|
|
177
|
+
return values
|
|
178
|
+
} catch {
|
|
179
|
+
return undefined
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function stripBrowserBase(path, base) {
|
|
184
|
+
if (!base) return path
|
|
185
|
+
const pathSegments = path.slice(1).split("/")
|
|
186
|
+
const baseSegments = base.slice(1).split("/").map(segment => decodeURIComponent(segment))
|
|
187
|
+
if (pathSegments.length < baseSegments.length || baseSegments.some((segment, index) => decodeRuntimeSegment(pathSegments[index], false) !== segment)) throw new Error("Path is outside the configured base")
|
|
188
|
+
return `/${pathSegments.slice(baseSegments.length).join("/")}`
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function decodeRuntimeSegment(raw, param) {
|
|
192
|
+
if (param && /%(?:2f|5c)/i.test(raw)) throw new Error("Encoded separator")
|
|
193
|
+
const value = decodeURIComponent(raw)
|
|
194
|
+
const decodedDots = value.replace(/%2e/gi, ".")
|
|
195
|
+
if (param && (!value || value === "." || value === ".." || decodedDots === "." || decodedDots === ".." || /[\\/?#]/.test(value) || [...value].some(character => character.charCodeAt(0) < 32 || character.charCodeAt(0) >= 127 && character.charCodeAt(0) <= 159) || /%(?:2f|5c)/i.test(value))) throw new Error("Invalid runtime parameter")
|
|
196
|
+
return value
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function inlineJson(value) {
|
|
200
|
+
return JSON.stringify(value).replaceAll("<", "\\u003c").replaceAll("\u2028", "\\u2028").replaceAll("\u2029", "\\u2029")
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function errorPage(error) {
|
|
204
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Kudzu build error</title></head><body><div id="__kudzu_error" role="alert" aria-live="assertive" style="position:fixed;inset:0;overflow:auto;padding:2rem;background:#200;color:#fff;font:16px/1.5 ui-monospace,monospace"><strong>Kudzu build error</strong><pre style="white-space:pre-wrap">${escapeHtml(error)}</pre></div></body></html>`
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function errorText(error) {
|
|
208
|
+
return String(error?.message ?? error)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function sendEvent(response, event, data = "") {
|
|
212
|
+
response.write(`event: ${event}\n${String(data).replaceAll("\r", "").split("\n").map(line => `data: ${line}\n`).join("")}\n`)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function escapeHtml(value) {
|
|
216
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">")
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function browserPath(path) {
|
|
220
|
+
return path ? new URL(path, "http://kudzu.local").pathname : ""
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function withBase(base, path) {
|
|
224
|
+
return base ? `${base}${path}` : path
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function exists(path) {
|
|
228
|
+
try {
|
|
229
|
+
await stat(path)
|
|
230
|
+
return true
|
|
231
|
+
} catch {
|
|
232
|
+
return false
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function contentType(file) {
|
|
237
|
+
return {
|
|
238
|
+
".html": "text/html; charset=utf-8",
|
|
239
|
+
".css": "text/css; charset=utf-8",
|
|
240
|
+
".js": "text/javascript; charset=utf-8",
|
|
241
|
+
".json": "application/json; charset=utf-8",
|
|
242
|
+
".svg": "image/svg+xml"
|
|
243
|
+
}[extname(file)] ?? "application/octet-stream"
|
|
244
|
+
}
|