@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.
@@ -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("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;")
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kudzujs/core",
3
- "version": "0.8.13",
3
+ "version": "0.8.15",
4
4
  "description": "HTML-first TSX framework with synchronous state semantics and no virtual DOM",
5
5
  "type": "module",
6
6
  "license": "MIT",