@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 +1 -1
- package/framework/README.md +2 -1
- package/framework/build.mjs +117 -13
- package/framework/dev-state.js +98 -0
- package/package.json +1 -1
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 `/`.
|
|
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
|
|
package/framework/README.md
CHANGED
|
@@ -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.
|
package/framework/build.mjs
CHANGED
|
@@ -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
|
|
112
|
-
|
|
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
|
|
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
|
|
124
|
-
|
|
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(
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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("&", "&").replaceAll("<", "<").replaceAll(">", ">")
|
|
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
|
+
}
|