@kudzujs/core 0.4.1 → 0.4.3

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 CHANGED
@@ -77,7 +77,7 @@ export default function HomePage() {
77
77
  npm run dev
78
78
  ```
79
79
 
80
- Pages live in `src/pages`; `index.tsx` maps to `/`. Production output is written to `dist/`.
80
+ Pages live in `src/pages`; `index.tsx` maps to `/`. `npm run dev` serves locally on `127.0.0.1`, reloads the browser after successful rebuilds, and shows build failures in an error overlay. Across that full-page reload, compatible Kudzu logical state is briefly preserved by route-unique state variable name for the current pathname, query, and hash, including controlled properties, conditions, and keyed-list arrays. Renamed, removed, and duplicate-named state is skipped. Uncontrolled DOM state, focus, selection, and imperative DOM mutations are not preserved. Set `PORT` to change the default port of `3000`. The development client and state snapshot are dev-only; production output in `dist/` is unaffected.
81
81
 
82
82
  ## State Semantics
83
83
 
@@ -9,8 +9,9 @@
9
9
  - `list-runtime.js`: optional keyed list validation, external item-expression evaluation, dynamic item-handler scopes, moves, and cleanup.
10
10
  - `serialization.js`: capture deserialization shared by binding and native handlers.
11
11
  - `native-runtime.js`: optional runtime for normal synchronous and asynchronous ESM handlers.
12
+ - `dev-state.js`: dev-only, short-lived logical-state snapshot validation and restoration.
12
13
  - `*.d.ts`: public TypeScript and JSX declarations.
13
14
 
14
- Static routes receive no browser runtime. Command routes receive `runtime.js`; reactive attributes and conditions add `binding-runtime.js`; keyed lists add `list-runtime.js`; native handlers add `native-runtime.js`. Capability runtimes share state and lifecycle hooks through `shared-runtime.js`. Generated evaluators live under `dist/assets/handlers/`.
15
+ Static routes receive no browser runtime. Command routes receive `runtime.js`; reactive attributes and conditions add `binding-runtime.js`; keyed lists add `list-runtime.js`; native handlers add `native-runtime.js`. Capability runtimes share state and lifecycle hooks through `shared-runtime.js`. Generated evaluators live under `dist/assets/handlers/`. The dev server derives stable state identities from route-unique variable names in each route plan; every state sharing a duplicate name is omitted. It then injects its SSE reload, short-lived full-URL-scoped logical-state snapshot, and build-error client into responses only, never into `dist/`. Snapshots are consumed even when the next page is static or broken. Reload restoration covers compatible framework state, not uncontrolled DOM state, focus, selection, or imperative mutations.
15
16
 
16
17
  Page `metadata` can emit description, canonical, favicon, manifest, Open Graph, and Twitter Card tags without a client runtime.
@@ -1,9 +1,11 @@
1
1
  import { createServer } from "node:http"
2
+ import { randomUUID } from "node:crypto"
2
3
  import { cp, mkdir, readFile, readdir, rm, stat, watch, writeFile } from "node:fs/promises"
3
4
  import { extname, join, relative, resolve, sep } from "node:path"
4
5
  import { pathToFileURL } from "node:url"
5
6
  import ts from "typescript"
6
7
  import { renderPage } from "./core.mjs"
8
+ import { stateSchema } from "./dev-state.js"
7
9
 
8
10
  const root = process.cwd()
9
11
  const sourceDirectory = join(root, "src")
@@ -11,6 +13,8 @@ const pagesDirectory = join(sourceDirectory, "pages")
11
13
  const workDirectory = join(root, ".kudzu")
12
14
  const outputDirectory = join(root, "dist")
13
15
 
16
+ 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>`
17
+
14
18
  export async function build({ quiet = false } = {}) {
15
19
  await rm(workDirectory, { recursive: true, force: true })
16
20
  await rm(outputDirectory, { recursive: true, force: true })
@@ -108,44 +112,144 @@ function specializeRuntime(source, events, hasStateSeed) {
108
112
  .replace(" if (initialState) for (const [id, value] of JSON.parse(initialState)) browserState.set(id, value)\n", "")
109
113
  }
110
114
 
111
- export async function dev() {
112
- await build()
115
+ export function parseDevPort(value) {
116
+ if (value === undefined || value.trim() === "") return 3000
117
+ if (!/^\d+$/.test(value)) throw new Error(`Invalid dev server port: ${value}`)
118
+ const port = Number(value)
119
+ if (port > 65535) throw new Error(`Invalid dev server port: ${value}`)
120
+ return port
121
+ }
122
+
123
+ export async function dev({ port = parseDevPort(process.env.PORT) } = {}) {
124
+ if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error(`Invalid dev server port: ${port}`)
125
+
126
+ let buildError
127
+ let revision = 0
128
+ const session = randomUUID()
129
+ try {
130
+ await build()
131
+ revision++
132
+ } catch (error) {
133
+ buildError = errorText(error)
134
+ console.error(error)
135
+ }
136
+
137
+ const clients = new Set()
113
138
 
114
139
  const server = createServer(async (request, response) => {
115
140
  try {
116
- const pathname = decodeURIComponent(new URL(request.url, "http://localhost").pathname)
141
+ const url = new URL(request.url, "http://localhost")
142
+ const pathname = decodeURIComponent(url.pathname)
143
+ if (pathname === "/__kudzu_reload") {
144
+ response.writeHead(200, {
145
+ "content-type": "text/event-stream; charset=utf-8",
146
+ "cache-control": "no-cache, no-transform",
147
+ connection: "keep-alive"
148
+ })
149
+ response.write(": connected\n\n")
150
+ clients.add(response)
151
+ request.on("close", () => clients.delete(response))
152
+ if (buildError) sendEvent(response, "build-error", buildError)
153
+ else if (url.searchParams.get("session") !== session || url.searchParams.get("revision") !== String(revision)) sendEvent(response, "reload")
154
+ return
155
+ }
156
+ if (pathname === "/__kudzu_dev.js") {
157
+ response.writeHead(200, { "content-type": "text/javascript; charset=utf-8", "cache-control": "no-store" })
158
+ response.end(await readFile(new URL("./dev-state.js", import.meta.url)))
159
+ return
160
+ }
161
+
117
162
  const relativePath = pathname.replace(/^\/+/, "")
118
163
  let file = resolve(outputDirectory, relativePath)
119
164
  if (!file.startsWith(`${outputDirectory}${sep}`) && file !== outputDirectory) throw new Error("Invalid path")
120
165
 
121
166
  if ((await exists(file)) && (await stat(file)).isDirectory()) file = join(file, "index.html")
122
167
  if (!(await exists(file)) && !extname(file)) file = join(file, "index.html")
123
- const content = await readFile(file)
124
- response.writeHead(200, { "content-type": contentType(file) })
168
+ const isHtml = extname(file) === ".html"
169
+ const content = isHtml
170
+ ? injectDevClient(buildError ? errorPage(buildError) : await readFile(file, "utf8"), session, revision, buildError ? [] : await devSchema(pathname))
171
+ : await readFile(file)
172
+ response.writeHead(200, {
173
+ "content-type": contentType(file),
174
+ "cache-control": "no-store"
175
+ })
125
176
  response.end(content)
126
177
  } catch {
127
- response.writeHead(404, { "content-type": "text/plain; charset=utf-8" })
178
+ response.writeHead(404, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" })
128
179
  response.end("Not found")
129
180
  }
130
181
  })
131
182
 
132
- server.listen(3000, () => console.log("Kudzu dev server: http://localhost:3000"))
183
+ server.listen(port, "127.0.0.1", () => console.log(`Kudzu dev server: http://127.0.0.1:${server.address().port}`))
133
184
 
134
185
  let timer
135
- const watcher = watch(sourceDirectory, { recursive: true })
136
- for await (const event of watcher) {
137
- clearTimeout(timer)
138
- timer = setTimeout(async () => {
186
+ let rebuilding = false
187
+ let pending = false
188
+ let changedFile
189
+ const rebuild = async () => {
190
+ if (rebuilding) {
191
+ pending = true
192
+ return
193
+ }
194
+ rebuilding = true
195
+ do {
196
+ pending = false
139
197
  try {
140
198
  await build({ quiet: true })
141
- console.log(`Rebuilt after ${event.filename ?? "source change"}`)
199
+ buildError = undefined
200
+ revision++
201
+ console.log(`Rebuilt after ${changedFile ?? "source change"}`)
202
+ for (const client of clients) sendEvent(client, "reload")
142
203
  } catch (error) {
204
+ buildError = errorText(error)
143
205
  console.error(error)
206
+ for (const client of clients) sendEvent(client, "build-error", buildError)
144
207
  }
145
- }, 80)
208
+ } while (pending)
209
+ rebuilding = false
210
+ }
211
+ const watcher = watch(sourceDirectory, { recursive: true })
212
+ for await (const event of watcher) {
213
+ changedFile = event.filename
214
+ clearTimeout(timer)
215
+ timer = setTimeout(rebuild, 80)
216
+ }
217
+ }
218
+
219
+ function injectDevClient(html, session, revision, schema) {
220
+ return `${html}${devClient(session, revision, schema)}`
221
+ }
222
+
223
+ async function devSchema(pathname) {
224
+ try {
225
+ const plan = JSON.parse(await readFile(join(workDirectory, "kudzu-plan.json"), "utf8"))
226
+ const route = pathname.replace(/\/(?:index\.html)?$/, "") || "/"
227
+ return stateSchema(plan.routes.find(entry => entry.route === route)?.states ?? [])
228
+ } catch {
229
+ return []
146
230
  }
147
231
  }
148
232
 
233
+ function inlineJson(value) {
234
+ return JSON.stringify(value).replaceAll("<", "\\u003c").replaceAll("\u2028", "\\u2028").replaceAll("\u2029", "\\u2029")
235
+ }
236
+
237
+ function errorPage(error) {
238
+ 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>`
239
+ }
240
+
241
+ function errorText(error) {
242
+ return String(error?.message ?? error)
243
+ }
244
+
245
+ function sendEvent(response, event, data = "") {
246
+ response.write(`event: ${event}\n${String(data).replaceAll("\r", "").split("\n").map(line => `data: ${line}\n`).join("")}\n`)
247
+ }
248
+
249
+ function escapeHtml(value) {
250
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;")
251
+ }
252
+
149
253
  async function compile(file) {
150
254
  const source = await readFile(file, "utf8")
151
255
  const nativeHandlers = []
@@ -0,0 +1,98 @@
1
+ const maxAge = 10000
2
+
3
+ export function stateSchema(states) {
4
+ const occurrences = new Map()
5
+ for (const { name } of states) if (typeof name === "string") occurrences.set(name, (occurrences.get(name) ?? 0) + 1)
6
+ return states.flatMap(({ id, name }) => {
7
+ return typeof id === "string" && occurrences.get(name) === 1 ? [[id, name]] : []
8
+ })
9
+ }
10
+
11
+ export function snapshotState(storage, route, state, schema, now = Date.now()) {
12
+ try {
13
+ storage.removeItem(storageKey(route))
14
+ } catch {
15
+ return false
16
+ }
17
+ try {
18
+ const identities = new Map(schema)
19
+ const values = [...state].flatMap(([id, value]) => {
20
+ const identity = identities.get(id)
21
+ return typeof identity === "string" && jsonSafe(value) ? [[identity, value]] : []
22
+ })
23
+ if (values.length) storage.setItem(storageKey(route), JSON.stringify({ time: now, values }))
24
+ return values.length > 0
25
+ } catch {
26
+ return false
27
+ }
28
+ }
29
+
30
+ export function restoreState(storage, route, state, schema, commit, now = Date.now()) {
31
+ let snapshot
32
+ try {
33
+ const raw = storage.getItem(storageKey(route))
34
+ if (raw === null) return []
35
+ storage.removeItem(storageKey(route))
36
+ snapshot = JSON.parse(raw)
37
+ } catch {
38
+ return []
39
+ }
40
+
41
+ if (!(state instanceof Map) || !Array.isArray(schema) || typeof commit !== "function") return []
42
+ if (!snapshot || !Number.isFinite(snapshot.time) || now < snapshot.time || now - snapshot.time > maxAge || !Array.isArray(snapshot.values)) return []
43
+ const ids = new Map(schema.flatMap(entry => Array.isArray(entry) && entry.length === 2 && typeof entry[0] === "string" && typeof entry[1] === "string" ? [[entry[1], entry[0]]] : []))
44
+ const changes = []
45
+ const seen = new Set()
46
+ for (const entry of snapshot.values) {
47
+ if (!Array.isArray(entry) || entry.length !== 2) continue
48
+ const [identity, value] = entry
49
+ const id = ids.get(identity)
50
+ if (typeof identity !== "string" || seen.has(identity) || !state.has(id) || !jsonSafe(value) || shape(value) !== shape(state.get(id))) continue
51
+ seen.add(identity)
52
+ changes.push({ id, value, original: state.get(id) })
53
+ }
54
+ for (const { id, value } of changes) state.set(id, value)
55
+ try {
56
+ for (const { id } of changes) commit(id, state.get(id))
57
+ } catch {
58
+ for (const { id, original } of changes) state.set(id, original)
59
+ for (const { id } of changes) {
60
+ try { commit(id, state.get(id)) } catch {}
61
+ }
62
+ return []
63
+ }
64
+ return changes.map(({ id }) => id)
65
+ }
66
+
67
+ function storageKey(route) {
68
+ return `__kudzu_state:${route}`
69
+ }
70
+
71
+ function shape(value) {
72
+ if (Array.isArray(value)) return "array"
73
+ if (value === null) return "null"
74
+ return typeof value === "object" ? "object" : typeof value
75
+ }
76
+
77
+ function jsonSafe(value, seen = new Set()) {
78
+ if (value === null || typeof value === "string" || typeof value === "boolean") return true
79
+ if (typeof value === "number") return Number.isFinite(value) && !Object.is(value, -0)
80
+ if (!value || typeof value !== "object" || seen.has(value) || Object.getOwnPropertySymbols(value).length) return false
81
+ if (!Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype) return false
82
+
83
+ const descriptors = Object.getOwnPropertyDescriptors(value)
84
+ if (Array.isArray(value)) {
85
+ if (Object.keys(descriptors).some(key => key !== "length" && !/^(0|[1-9]\d*)$/.test(key))) return false
86
+ if (Object.keys(value).length !== value.length) return false
87
+ }
88
+ seen.add(value)
89
+ for (const [key, descriptor] of Object.entries(descriptors)) {
90
+ if (Array.isArray(value) && key === "length") continue
91
+ if (!descriptor.enumerable || !("value" in descriptor) || !jsonSafe(descriptor.value, seen)) {
92
+ seen.delete(value)
93
+ return false
94
+ }
95
+ }
96
+ seen.delete(value)
97
+ return true
98
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kudzujs/core",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "description": "HTML-first TSX framework with synchronous state semantics and no virtual DOM",
5
5
  "type": "module",
6
6
  "license": "MIT",