@kudzujs/core 0.6.5 → 0.6.7

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/GOAL_B.md ADDED
@@ -0,0 +1,149 @@
1
+ # Goal B: Static Realtime Dashboards
2
+
3
+ Goal B makes Kudzu sufficient for a ThingsBoard-shaped realtime device dashboard without turning Kudzu into a stream runtime, widget framework, or server platform. It preserves complete static documents, zero-JavaScript routes that use no browser capabilities, direct DOM ownership, native navigation fallback, and the absence of React, a VDOM, hydration, or a retained browser component tree.
4
+
5
+ `MIGRATION_ROADMAP.md` remains the source of truth for compiler invariants and fixture-first development. This document is the implementation contract for realtime dashboard work.
6
+
7
+ ## Product Target
8
+
9
+ A user can statically deploy this flow:
10
+
11
+ ```text
12
+ dashboard -> devices -> device detail -> alarms -> settings
13
+ ```
14
+
15
+ The first vertical slice is deliberately smaller:
16
+
17
+ ```text
18
+ plain route <-> realtime device dashboard
19
+ ```
20
+
21
+ The dashboard receives a logical 1,000 telemetry samples per second, keeps a bounded history in a module Worker, downsamples it, and updates one imperative chart without routing samples through `useState()`.
22
+
23
+ ## Runtime Model
24
+
25
+ ```text
26
+ Complete static dashboard shell
27
+ -> route-specific effect ESM
28
+ -> route-owned module Worker
29
+ -> bounded telemetry buffer and downsampling
30
+ -> batched imperative chart updates
31
+ ```
32
+
33
+ Kudzu state is for low-frequency UI state such as selected device, time range, filters, tabs, connection status, alarm status, and widget configuration. High-frequency samples belong in a Worker or imperative browser module.
34
+
35
+ The Worker is a capability, not a framework runtime. Routes that do not create one must not load its graph. Static routes must remain JavaScript-free.
36
+
37
+ ## Milestone 1: Relative TypeScript Workers
38
+
39
+ Status: implemented and verified. The compiler recognizes only the exact inline-effect form below, emits its validated graph separately, rewrites the constructor to the base-aware same-origin asset, and leaves routes without this capability on their existing output paths.
40
+
41
+ Support this exact shape inside a compiled inline `useEffect` callback:
42
+
43
+ ```tsx
44
+ useEffect(() => {
45
+ const worker = new Worker(
46
+ new URL("../telemetry.worker.ts", import.meta.url),
47
+ { type: "module" },
48
+ )
49
+
50
+ const onMessage = (event: MessageEvent<ChartFrame>) => chart.render(event.data)
51
+ worker.addEventListener("message", onMessage)
52
+
53
+ return () => {
54
+ worker.removeEventListener("message", onMessage)
55
+ worker.terminate()
56
+ chart.dispose()
57
+ }
58
+ }, [])
59
+ ```
60
+
61
+ Compiler requirements:
62
+
63
+ - accept only an unshadowed `Worker` with `new URL(relativeLiteral, import.meta.url)` and literal `{ type: "module" }`;
64
+ - resolve one `.worker.ts` entry under project source and bundle its relative TypeScript graph separately from window code;
65
+ - emit deterministic content-hashed ESM under `assets/workers` and rewrite the constructor to the base-aware emitted URL;
66
+ - reject package imports, JSX, TypeScript import-equals declarations, dynamic imports, `require()`, paths outside source, malformed options, and unsupported Worker forms with source locations;
67
+ - reject ordinary runtime imports or re-exports of `.worker.ts`; type-only imports may erase normally;
68
+ - do not mark Worker files as navigation capability scripts or import them into the window;
69
+ - create the Worker only when the owning effect mounts;
70
+ - preserve byte-for-byte generated shared/list/effect paths for builds without relative TypeScript Workers where practical;
71
+ - reject generated/public asset collisions instead of silently overwriting output.
72
+
73
+ Worker construction inside imported helpers or imported keyed-row effects, `SharedWorker`, classic workers, inline Blob workers, and arbitrary dynamic Worker URLs are outside Milestone 1. Imported keyed rows must move Worker ownership to a directly compiled page or local component effect so lexical global analysis remains tied to the original source tree.
74
+
75
+ ## Ownership
76
+
77
+ | Owner | Lifetime | Dashboard responsibility |
78
+ |---|---|---|
79
+ | Document | Full document | authentication expiry and global diagnostics |
80
+ | Layout | Enhanced navigation session | tenant session or shared transport |
81
+ | Route | Current dashboard/device | Worker, telemetry subscription, request cancellation |
82
+ | DOM range | Current widget | chart, gauge, map, table, animation frame |
83
+ | Worker | Explicit owner cleanup | parsing, bounded buffering, aggregation, downsampling |
84
+
85
+ Leaving a route must remove message listeners, stop chart work, terminate its Worker, and invalidate stale UI writes before another route mounts. BFCache-preserved documents must retain their live ownership until a real document exit.
86
+
87
+ ## Fixture Contract
88
+
89
+ The first fixture must provide:
90
+
91
+ - one complete realtime dashboard document and one complete plain document in an opt-in navigation group;
92
+ - one unrelated static route with zero JavaScript;
93
+ - a relative TypeScript Worker importing at least one relative helper;
94
+ - a fixed-capacity ring buffer with deterministic eviction;
95
+ - logical 1,000 samples/second input in batches rather than a 1 ms browser timer;
96
+ - bounded downsampled frames delivered at no more than display cadence;
97
+ - one imperative canvas or DOM chart updated without sample-level Kudzu setters;
98
+ - direct load, dashboard-to-plain navigation, back/forward, and repeated cached revisits;
99
+ - exact counters for Worker starts, terminations, messages, renders, listeners, and stale post-cleanup work;
100
+ - native document fallback when JavaScript or Worker creation fails.
101
+
102
+ ## Acceptance Criteria
103
+
104
+ - the Worker graph is absent from static and plain route HTML and is fetched only after the dashboard effect mounts;
105
+ - two unchanged production builds emit identical Worker names and bytes;
106
+ - changing Worker source changes its content hash;
107
+ - base-prefixed deployment produces a valid same-origin Worker URL;
108
+ - 30 dashboard/plain cycles create and terminate exactly 30 route Workers with no growing listener or chart ownership;
109
+ - messages arriving after cleanup cannot update removed route DOM;
110
+ - the ring buffer remains at its configured capacity under sustained input;
111
+ - chart rendering is batched and sample ingestion does not call `useState()`;
112
+ - routes without Worker capabilities remain byte-for-byte unaffected;
113
+ - output raw/gzip cost, clean build time, sample throughput, render cadence, and lifecycle counters are recorded;
114
+ - `npm run check`, `npm test`, package dry-run, and browser checks pass.
115
+
116
+ Verified measurements for the focused `/dash` fixture: the minified Worker graph is `assets/workers/telemetry.worker-BVG2SA55.js`, 907 B raw and 477 B gzip. The dashboard window graph is 11,388 B raw and 5,148 B gzip across its shared runtime, effect runtime, navigation, route effect entry, and handler module; the Worker is not part of that graph. Seven clean minified builds measured 455.1, 459.9, 460.7, 463.5, 467.6, 472.6, and 475.2 ms, with a 463.5 ms median.
117
+
118
+ The real-Worker browser check uses real wall time and requires sustained generation beyond 1,130 samples at 700-1,300 logical samples/second, an exact 128-sample ring bound, batches of 10, exactly 24 displayed points, multiple renders, and a render ceiling below 25 Hz. Delayed Worker ticks catch up in batches to the logical 1,000 samples/second clock; frames emit no more often than every 50 ms. The imperative chart performs a minimal canvas path draw. The navigation ownership check completed 30 dashboard/plain cycles with exactly 30 starts and 30 terminations, exactly 60 listener additions and removals across message and error listeners, zero retained listeners after every cleanup, disposed every old chart canvas, fresh ownership on back/forward and cached revisits, and no render from a removed message listener invoked after cleanup. Dashboard, plain, and static HTML contain no Worker asset URL; plain does not load the route effect graph, and static contains no script, capability marker, or state payload. A no-Worker equivalent emitted byte-identical shared runtime, effect runtime, navigation, and route effect entry files with no `assets/workers` directory. An unreachable imported-row effect referencing `unused.worker.ts` emitted no Worker asset. Two unchanged builds emitted identical Worker names and bytes, and a controlled downsample-source change changed the emitted hash.
119
+
120
+ ## Delivery Order
121
+
122
+ 1. **Worker compiler capability**: exact syntax, graph bundling, hashing, base rewriting, diagnostics, and zero-cost exclusion.
123
+ 2. **Capability conformance fixture**: mock telemetry Worker, bounded buffer, downsampling, imperative DOM ownership, and route cleanup.
124
+
125
+ Further work belongs to the React migration roadmap and starts from a reduced compatibility fixture that fails. Kudzu does not implement device, alarm, transport, or widget product features.
126
+
127
+ ## Performance Gates
128
+
129
+ - sustained 1,000 samples/second does not create one main-thread task or Kudzu state commit per sample;
130
+ - Worker memory is bounded by declared buffer capacity;
131
+ - chart updates are batched to at most one per display frame;
132
+ - dashboard departure stops observable messages and renders before the next route mounts;
133
+ - repeated navigation leaves no growing Worker, timer, listener, chart, state, DOM, or heap ownership;
134
+ - Worker support adds no bytes to routes and builds that do not use it;
135
+ - material losses are profiled and fixed or documented as explicit tradeoffs using matched initial content and behavior.
136
+
137
+ ## Non-Goals
138
+
139
+ - implementing ThingsBoard's server, protocol, rule engine, database, or complete UI;
140
+ - storing telemetry samples in Kudzu component state;
141
+ - adding a general observable, scheduler, stream, state, or widget runtime;
142
+ - retaining a browser component tree;
143
+ - request-time SSR, Server Actions, or a hidden application server;
144
+ - a plugin marketplace or arbitrary third-party React widgets;
145
+ - claiming Worker isolation as a security sandbox.
146
+
147
+ ## Completion Definition
148
+
149
+ Goal B Milestone 1 is complete when the focused realtime fixture proves deterministic relative TypeScript Worker emission, bounded high-frequency processing, imperative chart updates, exact route ownership and cleanup across repeated navigation, native/static fallback, zero-cost exclusion, source diagnostics, and recorded production measurements.
package/README.md CHANGED
@@ -330,9 +330,21 @@ const rows = items.map(item => <ItemRow
330
330
  />)
331
331
  ```
332
332
 
333
- The original component remains reusable across multiple lists and ordinary JSX. No component function or component runtime is shipped to the browser. Kudzu emits initial items as static HTML, then adds, removes, updates, styles, conditional branches, and moves keyed elements directly. The map may appear directly in JSX or in one top-level immutable `const` rendered once as a JSX child. Existing keys move without remounting, preserving uncontrolled descendant state. Direct `item.<field>` reads use compact markers; derived item expressions compile to external ESM evaluators. Single-level item-local `&&` and ternary JSX conditions patch only their bounded branch and mount or unmount its handlers. Item-local handlers and effects receive the latest JSON-safe item for their key. Effects mount after a row is connected and clean up when it is removed. A direct primitive item dependency such as `[item.name]`, optionally mixed with state as `[version, item.name]`, reruns only rows whose selected value changed; the replacement setup receives the complete latest item. Unrelated fields and reorder do not rerun it, while a key change removes and mounts the row. The item remains stored once in shared list state; runtime descriptors carry a placeholder that the list runtime fills when mounting or updating the keyed root.
333
+ The map may also stay inside one same-file component that receives the local state array directly:
334
334
 
335
- Each item must be an ordinary plain object with a unique string or finite-number key; nested data may contain only JSON-safe arrays, ordinary plain objects, and primitive values. Null-prototype objects are rejected to preserve JSON round-trip parity. The current syntax requires a local-state `.map`, one identifier callback parameter, one intrinsic JSX root or top-level local or relative-imported row component, and `key={item.<field>}`. Row components accept destructured projected props, top-level single-`const` calculations and inline effects before one intrinsic return. Effect dependencies inside a row may be empty, direct primitive Kudzu state identifiers, or direct `item.<field>` properties whose selected values remain JSON-safe primitives. Whole-item, computed, nested, derived, `__proto__`, `prototype`, and `constructor` dependencies are rejected. A list alias may only be rendered once and cannot be read by other JavaScript. Derived expressions must be pure and synchronous: item reads, literals, operators, templates, approved read-only string/array methods, deterministic `Math` methods, and `String`/`Number`/`Boolean` conversion are supported. Component state, imported helpers used inside calculations, browser globals, Promise values, mutation, arbitrary calls, and prototype-sensitive properties are rejected. Package or namespace row imports, same-file exported rows, reusable aliases, prop spreads/defaults/rest, children, nested item conditions, lists, or component tags, refs, and `dangerouslySetInnerHTML` remain unsupported. Keyed rows must be placed inside an explicit `<tbody>`, `<thead>`, or `<tfoot>`.
335
+ ```tsx
336
+ function ItemList({ items }: { items: Item[] }) {
337
+ return <ul>{items.map(item => <li key={item.id}>{item.name}</li>)}</ul>
338
+ }
339
+
340
+ return <ItemList items={items} />
341
+ ```
342
+
343
+ The original row component remains reusable across multiple lists and ordinary JSX. State-backed list wrappers and row components are specialized to intrinsic JSX at build time; no component function or component runtime is shipped to the browser. Kudzu emits initial items as static HTML, then adds, removes, updates, styles, conditional branches, and moves keyed elements directly. The map may appear directly in JSX, in one top-level immutable `const` rendered once as a JSX child, or in one same-file synchronous wrapper receiving the state identifier as a direct prop. Existing keys move without remounting, preserving uncontrolled descendant state. Direct `item.<field>` reads use compact markers; derived item expressions compile to external ESM evaluators. Single-level item-local `&&` and ternary JSX conditions patch only their bounded branch and mount or unmount its handlers. Item-local handlers and effects receive the latest JSON-safe item for their key. Effects mount after a row is connected and clean up when it is removed. A direct primitive item dependency such as `[item.name]`, optionally mixed with state as `[version, item.name]`, reruns only rows whose selected value changed; the replacement setup receives the complete latest item. Unrelated fields and reorder do not rerun it, while a key change removes and mounts the row. The item remains stored once in shared list state; runtime descriptors carry a placeholder that the list runtime fills when mounting or updating the keyed root.
344
+
345
+ Each item must be an ordinary plain object with a unique string or finite-number key; nested data may contain only JSON-safe arrays, ordinary plain objects, and primitive values. Null-prototype objects are rejected to preserve JSON round-trip parity. The current syntax requires a local-state `.map`, one identifier callback parameter, one intrinsic JSX root or top-level local or relative-imported row component, and `key={item.<field>}`. State-backed list wrappers must be unexported same-file components with one destructured props parameter, an intrinsic return root, no effects, and direct local-state props at every call. Row components accept destructured projected props, top-level single-`const` calculations and inline effects before one intrinsic return. Effect dependencies inside a row may be empty, direct primitive Kudzu state identifiers, or direct `item.<field>` properties whose selected values remain JSON-safe primitives. Whole-item, computed, nested, derived, `__proto__`, `prototype`, and `constructor` dependencies are rejected. A list alias may only be rendered once and cannot be read by other JavaScript. Derived expressions must be pure and synchronous: item reads, literals, operators, templates, approved read-only string/array methods, deterministic `Math` methods, and `String`/`Number`/`Boolean` conversion are supported. Component state, imported helpers used inside calculations, browser globals, Promise values, mutation, arbitrary calls, and prototype-sensitive properties are rejected. Imported list wrappers, package or namespace row imports, same-file exported rows, reusable aliases, prop spreads/defaults/rest, children, nested item conditions, lists, or component tags, refs, and `dangerouslySetInnerHTML` remain unsupported. Keyed rows must be placed inside an explicit `<tbody>`, `<thead>`, or `<tfoot>`.
346
+
347
+ The focused wrapper fixture emits 1,393 B raw / 500 B gzip HTML and 10,719 B raw / 4,665 B gzip JavaScript across its route capabilities. After one warm-up, seven clean builds measured 314.1, 325.3, 322.3, 327.2, 336.1, 322.4, and 315.0 ms, with a 322.4 ms median.
336
348
 
337
349
  ## Effects
338
350
 
@@ -380,6 +392,22 @@ Dependency values are limited to JSON-safe strings, finite numbers, booleans, an
380
392
 
381
393
  Effect callbacks must be inline and block-bodied. Named or dynamically obtained cleanup functions, cleanup parameters or generators, other return values, callback parameters, and non-serializable captures are rejected. Async effects cannot return cleanup functions; the cleanup itself may be async. Pages without effects receive no effect entry. Empty-dependency effects retain their smaller output, and dependency-only capability code is isolated to the routes that use `kudzu-deps.js` unless another capability already requires the shared runtime.
382
394
 
395
+ An inline effect may own an exact relative TypeScript module Worker:
396
+
397
+ ```tsx
398
+ useEffect(() => {
399
+ const worker = new Worker(
400
+ new URL("../telemetry.worker.ts", import.meta.url),
401
+ { type: "module" },
402
+ )
403
+ return () => worker.terminate()
404
+ }, [])
405
+ ```
406
+
407
+ Kudzu resolves the path from the callback source, bundles the Worker and its relative TypeScript imports separately as content-hashed ESM under `assets/workers`, and rewrites the constructor to the base-prefixed same-origin asset URL. The Worker is fetched only when the effect mounts; it is not a capability script, preload, or window import. Unrendered effect handlers do not cause their Worker root to be emitted. This slice requires unshadowed global `Worker` and `URL`, exact `import.meta.url`, a relative `.worker.ts` string literal, and exactly `{ type: "module" }`. Worker graphs reject JSX, package runtime imports, TypeScript import-equals declarations, dynamic imports, `require()`, missing files, and paths outside `src`. Worker source cannot be imported or re-exported as an ordinary runtime module. Construction in event handlers, imported helpers, or imported keyed-row effects is rejected; move keyed-row Worker ownership to a directly compiled page or local component effect. Public or absolute JavaScript Workers remain ordinary browser code and are not transformed.
408
+
409
+ Route-owned browser requests use the same dependency-effect cleanup rather than a request runtime. Keep the effect callback synchronous, create an `AbortController` and timeout inside it, start the promise chain, and directly return cleanup that clears the timer and aborts the request. A command-only handler can update primitive command/revision state; the dependency effect then owns the request. Replacement or route disposal runs cleanup before the next setup and invalidates the old effect's setters. Applications must still check `response.ok`, distinguish timeout from other failures, and guard any imperative DOM writes themselves.
410
+
383
411
  A matched mount-fetch benchmark renders a title and two keyed rows from local JSON. With one warm-up and seven rotating clean builds, Kudzu shipped initial HTML, 3.4 KB initial JS gzip, 8.1 KB total output, and built in 374 ms. React CSR shipped no initial content, 59.3 KB initial JS gzip, 189.2 KB total output, and built in 992 ms. Hand-written ESM shipped 534 B initial JS gzip, 1.2 KB total output, and built in 210 ms. Fresh-profile Chrome medians to loaded data were 157.9 ms, 166.5 ms, and 153.4 ms respectively.
384
412
 
385
413
  A matched resize-listener cleanup fixture, measured with the same warm-up and seven rotating clean builds, shipped 1.2 KB JavaScript gzip and built in 402 ms with Kudzu. Svelte shipped 10.1 KB and built in 861 ms, Vue shipped 23.6 KB and built in 768 ms, and React shipped 59.1 KB and built in 1,058 ms. Kudzu and the 127 B hand-written Astro baseline emitted initial HTML; the CSR fixtures did not.
@@ -495,6 +523,7 @@ Supported:
495
523
  - Base-path deployments, multiple CSS files, and `afterBuild`
496
524
  - Primitive `useState` bindings
497
525
  - Mount-only `useEffect(fn, [])` compiled to route-specific ESM
526
+ - Relative TypeScript module Workers owned by inline effects
498
527
  - Conditional and keyed-row effect ownership with cleanup on DOM removal
499
528
  - Synchronous and async event handlers
500
529
  - Relative imported helpers in native handlers
@@ -17,9 +17,11 @@
17
17
 
18
18
  Static routes receive no browser runtime. Command routes receive `runtime.js`; dependency effects use route-specific `kudzu-deps.js` unless that route already requires shared commit hooks; runtime bracket pages using `useParams()` add one route-specific pathname matcher; reactive attributes and conditions add `binding-runtime.js`; keyed lists add `list-runtime.js`; native handlers add `native-runtime.js`; effects add `effect-runtime.js` and one route-specific entry. Generated module scripts live in the document head, so cold downloads overlap HTML transfer while standard module deferral preserves execution after parsing. A single effect with one dependency compiles to a direct runner; generic maps, sets, and ordering are reserved for larger effect graphs. Dependency commits coalesce in a microtask; affected cleanups are awaited in declaration order before replacement setups run. Document cleanup integrates with shared unmount hooks when present and otherwise disposes directly on non-persisted `pagehide`. List builds remove unused text-range, attribute, event, expression, condition, seed, and mount branches. Effect builds omit capture deserialization entirely when every effect scope is empty. Capability runtimes share state and lifecycle hooks through `shared-runtime.js`. Generated evaluators and their bundled relative TypeScript helpers live under `dist/assets/handlers/`; shared helper chunks are emitted only when multiple handler entries need them. Runtime fallback rewrites are ordered by specificity in `.kudzu/kudzu-plan.json` and passed to `afterBuild()`; exact static files take precedence in development. The dev server derives stable state identities from route-unique state 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.
19
19
 
20
+ Exact relative `.worker.ts` constructors in inline effects are validated and bundled in a separate content-hashed ESM graph under `dist/assets/workers/`. Those files are referenced only by rendered effect handlers and never become document capability scripts, preloads, or window imports; unreachable source effects do not emit their Worker roots. Worker graphs allow relative TypeScript ESM runtime imports only and reject JSX, package runtime imports, import-equals declarations, dynamic imports, `require()`, and paths outside `src`. Ordinary runtime imports or re-exports of `.worker.ts` and Worker construction in imported keyed-row effects are rejected.
21
+
20
22
  Page `metadata` can emit description, canonical, favicon, manifest, Open Graph, and Twitter Card tags without a client runtime. Source CSS and global `kudzu.config` styles are emitted in document heads before `afterBuild()` runs; static stylesheet links in component JSX fail compilation instead of loading from the body.
21
23
 
22
- Direct JSON-safe primitive keyed-item dependencies subscribe each row record to its owning list commit and compare selected fields after `list-runtime.js` synchronously refreshes the row marker. Only changed rows rerun with the complete latest item; reorder compares equal, unrelated fields do nothing, and key changes remain remove plus mount. Builds without item dependencies emit no item reader or list-state subscription code.
24
+ Same-file components receiving a direct local-state array prop are specialized to intrinsic JSX before keyed-list analysis, so their component function is not retained in the browser. Direct JSON-safe primitive keyed-item dependencies subscribe each row record to its owning list commit and compare selected fields after `list-runtime.js` synchronously refreshes the row marker. Only changed rows rerun with the complete latest item; reorder compares equal, unrelated fields do nothing, and key changes remain remove plus mount. Builds without item dependencies emit no item reader or list-state subscription code.
23
25
 
24
26
  `kudzu.config` may opt one emitted shared-layout group into same-document navigation with legacy `navigation: { routes: ["/product", "/items/[id]"] }`, or multiple groups with `navigation: { groups: [{ routes: [...] }, { routes: [...] }] }`. The forms are mutually exclusive. Identities are globally unique emitted exact paths or `runtimeParams` patterns; each group uses one page-exported layout function identity. Runtime records securely match concrete pathnames under `base`, and their cache-safe parameter initializer runs before route DOM/effects mount on every transition. Each group receives a deterministic route-hashed asset specialized to only its records, pattern decoder, and effect/parameter lifecycle needs. Cross-group and ungrouped anchors remain native and are not prefetched; overlapping path domains across groups fail the build. Route effect entries export cache-safe layout and route mount functions: layout effects, including conditional/keyed DOM-owned effects, persist for the group session; route effects receive a fresh owner registry after each route insertion; and non-persisted page disposal cleans route before layout. Direct primitive state, runtime parameter, and keyed-item property dependencies and cleanup are supported. Fragment payloads and coordinated View Transitions are not implemented.
25
27
 
@@ -45,8 +45,10 @@ export async function build({ quiet = false, minify = true } = {}) {
45
45
  const sourceIndex = new Map(await Promise.all(sourceFiles.map(async file => [file, await readFile(file, "utf8")])))
46
46
 
47
47
  const handlerModules = []
48
+ const workerReferences = []
48
49
  for (const file of sourceFiles) {
49
- const handlerModule = await compile(file, sourceFileSet, sourceIndex, base)
50
+ if (file.endsWith(".worker.ts")) continue
51
+ const handlerModule = await compile(file, sourceFileSet, sourceIndex, base, workerReferences)
50
52
  if (handlerModule) handlerModules.push(handlerModule)
51
53
  }
52
54
 
@@ -161,6 +163,18 @@ export async function build({ quiet = false, minify = true } = {}) {
161
163
 
162
164
  const assetsDirectory = join(outputDirectory, "assets")
163
165
  await mkdir(assetsDirectory, { recursive: true })
166
+ const renderedEffects = new Set(plans.flatMap(plan => plan.effects.map(effect => `${effect.module}:${effect.handler}`)))
167
+ const renderedWorkerReferences = workerReferences.filter(reference => renderedEffects.has(`${reference.module}:${reference.handler}`))
168
+ if (renderedWorkerReferences.length && await exists(join(root, "public", "assets", "workers"))) throw new Error("public/assets/workers collides with Kudzu's generated Worker asset namespace")
169
+ const workerAssets = await emitWorkers(renderedWorkerReferences, sourceFileSet, assetsDirectory, base, minify)
170
+ for (const module of handlerModules) {
171
+ for (const reference of workerReferences) {
172
+ if (reference.module !== assetPath(base, `assets/${module.path}`)) continue
173
+ const url = workerAssets.get(reference.placeholder) ?? "about:blank"
174
+ module.code = module.code.replaceAll(JSON.stringify(reference.placeholder), JSON.stringify(url))
175
+ }
176
+ if (module.code.includes("/__kudzu_worker_")) throw new Error(`Worker URL placeholder survived in ${module.path}`)
177
+ }
164
178
  const commandEvents = [...new Set(plans.flatMap(plan => plan.events.filter(event => event.commands).map(event => event.event)))].sort()
165
179
  const nativeEvents = [...new Set(plans.flatMap(plan => plan.events.filter(event => event.native).map(event => event.event)))].sort()
166
180
  const hasTextBindings = plans.some(plan => plan.bindings.some(binding => binding.target === "text"))
@@ -1528,7 +1542,7 @@ function escapeAttribute(value) {
1528
1542
  return escapeHtml(value).replaceAll('"', "&quot;").replaceAll("'", "&#39;")
1529
1543
  }
1530
1544
 
1531
- async function compile(file, sourceFiles, sourceIndex, base) {
1545
+ async function compile(file, sourceFiles, sourceIndex, base, workerReferences) {
1532
1546
  const source = sourceIndex.get(file)
1533
1547
  const nativeHandlers = []
1534
1548
  const effectHandlers = []
@@ -1544,7 +1558,7 @@ async function compile(file, sourceFiles, sourceIndex, base) {
1544
1558
  jsx: ts.JsxEmit.ReactJSX,
1545
1559
  jsxImportSource: "@kudzujs/core"
1546
1560
  },
1547
- transformers: { before: [createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings, listExpressions, assetPath(base, `assets/${handlerPath}`), file, sourceFiles, sourceIndex, clientImports)] },
1561
+ transformers: { before: [createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings, listExpressions, assetPath(base, `assets/${handlerPath}`), file, sourceFiles, sourceIndex, clientImports, workerReferences)] },
1548
1562
  reportDiagnostics: true
1549
1563
  })
1550
1564
 
@@ -1574,12 +1588,13 @@ async function compile(file, sourceFiles, sourceIndex, base) {
1574
1588
  return { path: handlerPath, code: moduleResult.outputText, hasNativeHandlers: nativeHandlers.length > 0, hasEffects: effectHandlers.length > 0, clientImports: [...clientImports] }
1575
1589
  }
1576
1590
 
1577
- function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings, listExpressions, handlerUrl, file, sourceFiles, sourceIndex, clientImports) {
1591
+ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings, listExpressions, handlerUrl, file, sourceFiles, sourceIndex, clientImports, workerReferences) {
1578
1592
  return context => sourceFile => {
1579
1593
  const factory = context.factory
1580
1594
  const hasLinkElements = /<link/i.test(sourceFile.text)
1581
1595
  sourceFile = normalizeRenderControlFlow(sourceFile, factory, context)
1582
1596
  ts.setParentRecursive(sourceFile, false)
1597
+ rejectOrdinaryWorkerImports(sourceFile, file, sourceFiles)
1583
1598
  const importBindings = clientImportBindings(sourceFile, file, sourceFiles)
1584
1599
  const hasUseEffectImport = sourceFile.statements.some(statement => ts.isImportDeclaration(statement) && statement.moduleSpecifier.text === "@kudzujs/core" && statement.importClause?.namedBindings && ts.isNamedImports(statement.importClause.namedBindings) && statement.importClause.namedBindings.elements.some(entry => !entry.propertyName && entry.name.text === "useEffect"))
1585
1600
  const importedSources = new Map()
@@ -1686,8 +1701,36 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
1686
1701
  }
1687
1702
  }
1688
1703
  }
1704
+ const fail = (node, message) => {
1705
+ throw sourceNodeError(node, sourceFile, message)
1706
+ }
1707
+ const componentSpecializations = new WeakMap()
1708
+ const specializedDeclarations = new WeakSet()
1709
+ const stateBackedComponentFunctions = new WeakSet()
1710
+ const stateBackedComponentRoots = []
1711
+ for (const [name, component] of components) {
1712
+ const calls = jsxTagUses(sourceFile, name)
1713
+ const stateBackedCalls = calls.filter(call => isStateBackedListComponentCall(call, component.function, settersByFunction.get(nearestFunction(call)) ?? new Map()))
1714
+ if (!stateBackedCalls.length) continue
1715
+ if (isExportedDeclaration(component.declaration)) fail(component.declaration, `State-backed list component ${name} cannot be exported`)
1716
+ if (identifierReferenceCount(sourceFile, name) !== calls.length) fail(component.declaration, `State-backed list component ${name} may only be referenced as JSX`)
1717
+ if (stateBackedCalls.length !== calls.length) fail(component.declaration, `State-backed list component ${name} must receive its mapped prop from local state at every call`)
1718
+ for (const call of stateBackedCalls) {
1719
+ const specialization = specializeComponentCall(call, component.function, sourceFile, factory, context, fail)
1720
+ if (specialization.effects.length) fail(call, "State-backed list components cannot declare effects")
1721
+ componentSpecializations.set(call, specialization)
1722
+ stateBackedComponentRoots.push(specialization.root)
1723
+ }
1724
+ specializedDeclarations.add(component.declaration)
1725
+ stateBackedComponentFunctions.add(component.function)
1726
+ }
1689
1727
  const rawRenderedLists = []
1690
1728
  const collectRenderedLists = node => {
1729
+ const specialization = componentSpecializations.get(node)
1730
+ if (specialization) {
1731
+ collectRenderedLists(specialization.root)
1732
+ return
1733
+ }
1691
1734
  if (ts.isJsxExpression(node) && node.initializer === undefined && node.expression && (ts.isJsxElement(node.parent) || ts.isJsxFragment(node.parent))) {
1692
1735
  const parts = listLocalUses.get(node) ?? keyedListParts(node.expression, settersForNode(node, settersByFunction))
1693
1736
  if (parts) rawRenderedLists.push({ node, parts })
@@ -1695,9 +1738,6 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
1695
1738
  ts.forEachChild(node, collectRenderedLists)
1696
1739
  }
1697
1740
  collectRenderedLists(sourceFile)
1698
- const fail = (node, message) => {
1699
- throw sourceNodeError(node, sourceFile, message)
1700
- }
1701
1741
  const rejectUnsupportedRenderControl = node => {
1702
1742
  if (ts.isIfStatement(node) && containsRenderControl(node, jsxLocalsByFunction.get(nearestFunction(node)) ?? new Set())) {
1703
1743
  const setters = settersForNode(node, settersByFunction)
@@ -1712,8 +1752,6 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
1712
1752
  const tag = jsxTagName(parts.root)
1713
1753
  return tag && ts.isIdentifier(tag) && tag.text[0] === tag.text[0].toUpperCase() ? [tag.text] : []
1714
1754
  }))
1715
- const componentSpecializations = new WeakMap()
1716
- const specializedDeclarations = new WeakSet()
1717
1755
  const keyedComponentCalls = new Set(rawRenderedLists.map(({ parts }) => parts.root))
1718
1756
  for (const name of listComponentNames) {
1719
1757
  let component = components.get(name)
@@ -1725,8 +1763,12 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
1725
1763
  component = { function: resolveComponentExport(binding.target, imported, importedSource, sourceFiles), declaration: undefined }
1726
1764
  }
1727
1765
  if (local && isExportedDeclaration(component.declaration)) fail(component.declaration, `Keyed list component ${name} cannot be exported`)
1728
- const calls = jsxTagUses(sourceFile, name)
1729
- if (local && identifierReferenceCount(sourceFile, name) !== calls.length) fail(component.declaration, `Keyed list component ${name} may only be referenced as JSX`)
1766
+ const declaredCalls = jsxTagUses(sourceFile, name)
1767
+ if (local && identifierReferenceCount(sourceFile, name) !== declaredCalls.length) fail(component.declaration, `Keyed list component ${name} may only be referenced as JSX`)
1768
+ const calls = [
1769
+ ...declaredCalls.filter(call => !stateBackedComponentFunctions.has(nearestFunction(call))),
1770
+ ...stateBackedComponentRoots.flatMap(root => jsxTagUses(root, name))
1771
+ ]
1730
1772
  for (const call of calls) {
1731
1773
  const specialization = specializeComponentCall(call, component.function, sourceFile, factory, context, fail)
1732
1774
  if (specialization.effects.length && !keyedComponentCalls.has(call)) fail(call, "Effectful keyed row components may only be used directly as keyed map rows")
@@ -1855,7 +1897,19 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
1855
1897
  if (invalidCleanup) effectFail(invalidCleanup, "useEffect() cleanup functions cannot declare parameters or be generators")
1856
1898
  if (returns.cleanup && callback.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.AsyncKeyword)) effectFail(callback, "useEffect() async callbacks cannot return cleanup functions")
1857
1899
  const setters = settersForNode(node, settersByFunction)
1858
- const descriptor = compileNativeCallback(callback, setters, factory, effectHandlers, listEffect?.imports ?? importBindings, clientImports, "effect", dependencyItem, true, returns.cleanup)
1900
+ const callbackSource = listEffect?.sourceFile ?? sourceFile
1901
+ const callbackFile = callbackSource.fileName
1902
+ const workerStart = workerReferences.length
1903
+ let compiledCallback
1904
+ if (listEffect && callbackFile !== file) {
1905
+ const originalCallback = listEffect.source.arguments[0]
1906
+ rejectWorkerConstructions(originalCallback, callbackSource, "Relative TypeScript Worker construction in imported keyed-row effects is not supported; construct the Worker in a directly compiled page or local component effect")
1907
+ compiledCallback = callback
1908
+ } else {
1909
+ compiledCallback = rewriteEffectWorkers(callback, callbackFile, callbackSource, sourceFiles, workerReferences, factory, context)
1910
+ }
1911
+ const descriptor = compileNativeCallback(compiledCallback, setters, factory, effectHandlers, listEffect?.imports ?? importBindings, clientImports, "effect", dependencyItem, true, returns.cleanup)
1912
+ for (const reference of workerReferences.slice(workerStart)) Object.assign(reference, { module: handlerUrl, handler: descriptor.exportName })
1859
1913
  usesListItem ||= Boolean(itemDependencies.length && !listEffect)
1860
1914
  usesBehavior = true
1861
1915
  return factory.updateCallExpression(node, node.expression, node.typeArguments, [
@@ -2116,6 +2170,36 @@ function keyedListParts(expression, setters) {
2116
2170
  return { state, callback, root, item: callback.parameters[0].name.text, keyField: field }
2117
2171
  }
2118
2172
 
2173
+ function isStateBackedListComponentCall(call, component, setters) {
2174
+ if (component.parameters.length !== 1 || !ts.isObjectBindingPattern(component.parameters[0].name)) return false
2175
+ const attributes = ts.isJsxElement(call) ? call.openingElement.attributes : call.attributes
2176
+ const stateNames = new Set(setters.values())
2177
+ const mappedProps = new Set()
2178
+ for (const element of component.parameters[0].name.elements) {
2179
+ if (!ts.isIdentifier(element.name)) continue
2180
+ const prop = (element.propertyName ?? element.name).getText()
2181
+ const attribute = attributes.properties.find(entry => ts.isJsxAttribute(entry) && entry.name.getText() === prop)
2182
+ const value = attribute?.initializer && ts.isJsxExpression(attribute.initializer) ? unwrapExpression(attribute.initializer.expression) : undefined
2183
+ if (value && ts.isIdentifier(value) && stateNames.has(value.text)) mappedProps.add(element.name.text)
2184
+ }
2185
+ if (!mappedProps.size) return false
2186
+ const returned = ts.isBlock(component.body)
2187
+ ? [...component.body.statements].reverse().find(ts.isReturnStatement)?.expression
2188
+ : component.body
2189
+ if (!returned || !containsJsx(returned)) return false
2190
+ let found = false
2191
+ const visit = node => {
2192
+ if (found || node !== returned && isFunctionLike(node)) return
2193
+ if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === "map" && ts.isIdentifier(node.expression.expression) && mappedProps.has(node.expression.expression.text)) {
2194
+ found = true
2195
+ return
2196
+ }
2197
+ ts.forEachChild(node, visit)
2198
+ }
2199
+ visit(returned)
2200
+ return found
2201
+ }
2202
+
2119
2203
  function validateKeyedList(parts, sourceFile, listValues, listEventItems, listConditions) {
2120
2204
  const fail = (node, message) => {
2121
2205
  throw sourceNodeError(node, sourceFile, message)
@@ -2547,6 +2631,7 @@ function compileEvent(expression, setters, functions, factory, nativeHandlers, h
2547
2631
  const optimized = compileOptimizedEvent(expression, setters, factory)
2548
2632
  if (optimized) return optimized
2549
2633
 
2634
+ rejectWorkerConstructions(expression, expression.getSourceFile(), "Relative TypeScript Worker construction is only supported directly inside an inline useEffect() callback")
2550
2635
  const descriptor = compileNativeCallback(expression, setters, factory, nativeHandlers, importBindings, clientImports, "handler", listItem)
2551
2636
  return factory.createCallExpression(factory.createIdentifier("__kNativeBehavior"), undefined, [
2552
2637
  factory.createStringLiteral(handlerUrl),
@@ -2609,9 +2694,81 @@ function compileOptimizedEvent(expression, setters, factory) {
2609
2694
  }
2610
2695
 
2611
2696
  const nativeGlobals = new Set([
2612
- "Array", "ArrayBuffer", "BigInt", "Boolean", "Date", "Error", "Event", "FormData", "Infinity", "Intl", "JSON", "Map", "Math", "NaN", "Number", "Object", "Promise", "Proxy", "RangeError", "ReferenceError", "Reflect", "RegExp", "Set", "String", "Symbol", "TypeError", "URL", "URLSearchParams", "WeakMap", "WeakSet", "WebSocket", "atob", "btoa", "clearInterval", "clearTimeout", "console", "crypto", "document", "fetch", "globalThis", "history", "isFinite", "isNaN", "location", "navigator", "parseFloat", "parseInt", "queueMicrotask", "requestAnimationFrame", "setInterval", "setTimeout", "structuredClone", "undefined", "window"
2697
+ "Array", "ArrayBuffer", "BigInt", "Boolean", "Date", "Error", "Event", "FormData", "Infinity", "Intl", "JSON", "Map", "Math", "NaN", "Number", "Object", "Promise", "Proxy", "RangeError", "ReferenceError", "Reflect", "RegExp", "Set", "String", "Symbol", "TypeError", "URL", "URLSearchParams", "WeakMap", "WeakSet", "WebSocket", "Worker", "atob", "btoa", "clearInterval", "clearTimeout", "console", "crypto", "document", "fetch", "globalThis", "history", "isFinite", "isNaN", "location", "navigator", "parseFloat", "parseInt", "queueMicrotask", "requestAnimationFrame", "setInterval", "setTimeout", "structuredClone", "undefined", "window"
2613
2698
  ])
2614
2699
 
2700
+ function rewriteEffectWorkers(callback, file, sourceFile, sourceFiles, workerReferences, factory, context) {
2701
+ const visit = node => {
2702
+ const candidate = relativeWorkerCandidate(node, sourceFile)
2703
+ if (candidate) {
2704
+ if (nearestFunction(node) !== callback) throw sourceNodeError(node, sourceFile, "Relative TypeScript Worker construction must be directly inside the inline useEffect() callback, not a nested function")
2705
+ const { worker, url, specifier, options } = validateWorkerCandidate(candidate, sourceFile)
2706
+ const target = resolve(dirname(file), specifier)
2707
+ const sourceRelative = relative(sourceDirectory, target)
2708
+ if (sourceRelative.startsWith(`..${sep}`) || sourceRelative === ".." || resolve(sourceDirectory, sourceRelative) !== target) throw sourceNodeError(url.arguments[0], sourceFile, "Relative TypeScript Worker source must remain under src/")
2709
+ 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/`)
2710
+ const identity = `${sourceRelative.replaceAll(sep, "/")}:${ts.getOriginalNode(node).getStart(sourceFile)}`
2711
+ const placeholder = `/__kudzu_worker_${createHash("sha256").update(identity).digest("hex").slice(0, 16)}__.js`
2712
+ workerReferences.push({ root: target, placeholder })
2713
+ return factory.updateNewExpression(worker, worker.expression, worker.typeArguments, [factory.createStringLiteral(placeholder), options])
2714
+ }
2715
+ return ts.visitEachChild(node, visit, context)
2716
+ }
2717
+ return ts.visitEachChild(callback, visit, context)
2718
+ }
2719
+
2720
+ function rejectWorkerConstructions(expression, sourceFile, message) {
2721
+ const visit = node => {
2722
+ if (relativeWorkerCandidate(node, sourceFile)) throw sourceNodeError(node, sourceFile, message)
2723
+ ts.forEachChild(node, visit)
2724
+ }
2725
+ visit(expression.body ?? expression)
2726
+ }
2727
+
2728
+ function relativeWorkerCandidate(node, sourceFile) {
2729
+ if (!ts.isNewExpression(node) || !ts.isIdentifier(node.expression) || node.expression.text !== "Worker") return undefined
2730
+ const first = node.arguments?.[0]
2731
+ if (!first || !ts.isNewExpression(first) || !ts.isIdentifier(first.expression) || first.expression.text !== "URL") return undefined
2732
+ const specifier = first.arguments?.[0]
2733
+ const base = first.arguments?.[1]
2734
+ const relativeLiteral = ts.isStringLiteral(specifier) && (specifier.text.startsWith("./") || specifier.text.startsWith("../"))
2735
+ if (!relativeLiteral && !(specifier && !ts.isStringLiteral(specifier) && base && isImportMetaUrl(base))) return undefined
2736
+ return { worker: node, url: first, sourceFile }
2737
+ }
2738
+
2739
+ function validateWorkerCandidate(candidate, sourceFile) {
2740
+ const { worker, url } = candidate
2741
+ if (!isUnshadowedGlobal(worker.expression, sourceFile)) throw sourceNodeError(worker.expression, sourceFile, "Relative TypeScript Workers require the unshadowed global Worker constructor")
2742
+ if (!isUnshadowedGlobal(url.expression, sourceFile)) throw sourceNodeError(url.expression, sourceFile, "Relative TypeScript Workers require the unshadowed global URL constructor")
2743
+ if (url.arguments?.length !== 2 || !isImportMetaUrl(url.arguments[1])) throw sourceNodeError(url, sourceFile, "Relative TypeScript Workers require new URL(relativeLiteral, import.meta.url)")
2744
+ const specifierNode = url.arguments[0]
2745
+ if (!ts.isStringLiteral(specifierNode) || !(specifierNode.text.startsWith("./") || specifierNode.text.startsWith("../"))) throw sourceNodeError(specifierNode, sourceFile, "Relative TypeScript Worker paths must be relative string literals")
2746
+ if (/[\\?#]/.test(specifierNode.text) || !specifierNode.text.endsWith(".worker.ts")) throw sourceNodeError(specifierNode, sourceFile, "Relative TypeScript Worker paths must end in .worker.ts")
2747
+ if (worker.arguments?.length !== 2) throw sourceNodeError(worker, sourceFile, 'Relative TypeScript Workers require exactly { type: "module" } as the second argument')
2748
+ const options = worker.arguments[1]
2749
+ if (!ts.isObjectLiteralExpression(options) || options.properties.length !== 1) throw sourceNodeError(options, sourceFile, 'Relative TypeScript Workers require exactly { type: "module" } as the second argument')
2750
+ const property = options.properties[0]
2751
+ const name = ts.isPropertyAssignment(property) && !ts.isComputedPropertyName(property.name) && (ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)) ? property.name.text : undefined
2752
+ 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')
2753
+ return { worker, url, specifier: specifierNode.text, options }
2754
+ }
2755
+
2756
+ function isImportMetaUrl(node) {
2757
+ return ts.isPropertyAccessExpression(node) && node.name.text === "url" && ts.isMetaProperty(node.expression) && node.expression.keywordToken === ts.SyntaxKind.ImportKeyword && node.expression.name.text === "meta"
2758
+ }
2759
+
2760
+ function isUnshadowedGlobal(identifier, sourceFile) {
2761
+ if (isShadowedIdentifier(identifier, sourceFile)) return false
2762
+ return !sourceFile.statements.some(statement => {
2763
+ if (statementDeclaresName(statement, identifier.text)) return true
2764
+ if (!ts.isImportDeclaration(statement) || !statement.importClause) return false
2765
+ const clause = statement.importClause
2766
+ if (clause.name?.text === identifier.text) return true
2767
+ if (clause.namedBindings && ts.isNamespaceImport(clause.namedBindings)) return clause.namedBindings.name.text === identifier.text
2768
+ return clause.namedBindings && ts.isNamedImports(clause.namedBindings) ? clause.namedBindings.elements.some(entry => entry.name.text === identifier.text) : false
2769
+ })
2770
+ }
2771
+
2615
2772
  function nativeCaptureNames(expression, setters) {
2616
2773
  return captureNames(expression, expression.body, setters)
2617
2774
  }
@@ -2704,7 +2861,31 @@ function isShadowedIdentifier(node, scopeRoot) {
2704
2861
 
2705
2862
  function statementDeclaresName(statement, name) {
2706
2863
  if (ts.isVariableStatement(statement)) return statement.declarationList.declarations.some(declaration => bindingNames(declaration.name).includes(name))
2707
- return (ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) && statement.name?.text === name
2864
+ if (ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement) || ts.isImportEqualsDeclaration(statement)) return statement.name?.text === name
2865
+ if ((ts.isEnumDeclaration(statement) || ts.isModuleDeclaration(statement)) && !statement.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.DeclareKeyword)) return ts.isIdentifier(statement.name) && statement.name.text === name
2866
+ return false
2867
+ }
2868
+
2869
+ function rejectOrdinaryWorkerImports(sourceFile, file, sourceFiles) {
2870
+ for (const node of sourceFile.statements) {
2871
+ let specifier
2872
+ let runtime = false
2873
+ if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) {
2874
+ specifier = node.moduleSpecifier
2875
+ runtime = runtimeModuleReference(node)
2876
+ } else if (ts.isImportEqualsDeclaration(node) && ts.isExternalModuleReference(node.moduleReference) && ts.isStringLiteral(node.moduleReference.expression)) {
2877
+ specifier = node.moduleReference.expression
2878
+ runtime = !node.isTypeOnly
2879
+ }
2880
+ if (!runtime || !specifier?.text.startsWith(".")) continue
2881
+ let target
2882
+ try {
2883
+ target = resolveSourceImport(file, specifier.text, sourceFiles)
2884
+ } catch {
2885
+ continue
2886
+ }
2887
+ 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")
2888
+ }
2708
2889
  }
2709
2890
 
2710
2891
  function loopDeclaresName(loop, name) {
@@ -2736,7 +2917,12 @@ function clientImportBindings(sourceFile, file, sourceFiles) {
2736
2917
  const bindings = new Map()
2737
2918
  for (const node of sourceFile.statements) {
2738
2919
  if (!ts.isImportDeclaration(node) || !node.importClause || node.importClause.isTypeOnly || !ts.isStringLiteral(node.moduleSpecifier) || !node.moduleSpecifier.text.startsWith(".")) continue
2739
- const target = resolveSourceImport(file, node.moduleSpecifier.text, sourceFiles)
2920
+ let target
2921
+ try {
2922
+ target = resolveSourceImport(file, node.moduleSpecifier.text, sourceFiles)
2923
+ } catch (error) {
2924
+ throw sourceNodeError(node.moduleSpecifier, sourceFile, error.message)
2925
+ }
2740
2926
  if (node.importClause.name) bindings.set(node.importClause.name.text, { kind: "default", local: node.importClause.name.text, target })
2741
2927
  const named = node.importClause.namedBindings
2742
2928
  if (named && ts.isNamespaceImport(named)) bindings.set(named.name.text, { kind: "namespace", local: named.name.text, target })
@@ -2845,6 +3031,71 @@ function printClientImports(entries, handlerPath) {
2845
3031
  return imports.join("\n")
2846
3032
  }
2847
3033
 
3034
+ async function emitWorkers(references, sourceFiles, assetsDirectory, base, minify) {
3035
+ const roots = [...new Set(references.map(reference => reference.root))].sort()
3036
+ if (!roots.length) return new Map()
3037
+ await validateWorkerGraphs(roots, sourceFiles)
3038
+ const workerDirectory = join(assetsDirectory, "workers")
3039
+ await mkdir(workerDirectory, { recursive: true })
3040
+ const result = await bundle({
3041
+ absWorkingDir: root,
3042
+ entryPoints: roots,
3043
+ outbase: sourceDirectory,
3044
+ outdir: workerDirectory,
3045
+ entryNames: "[dir]/[name]-[hash]",
3046
+ chunkNames: "chunks/[name]-[hash]",
3047
+ bundle: true,
3048
+ splitting: true,
3049
+ format: "esm",
3050
+ platform: "browser",
3051
+ target: "es2022",
3052
+ minify,
3053
+ legalComments: "none",
3054
+ metafile: true,
3055
+ logLevel: "silent"
3056
+ })
3057
+ const emitted = new Map()
3058
+ for (const [output, metadata] of Object.entries(result.metafile.outputs)) {
3059
+ if (!metadata.entryPoint) continue
3060
+ const entry = resolve(root, metadata.entryPoint)
3061
+ const rootReferences = references.filter(reference => reference.root === entry)
3062
+ const outputFile = resolve(root, output)
3063
+ const url = assetPath(base, relative(outputDirectory, outputFile).replaceAll(sep, "/"))
3064
+ for (const reference of rootReferences) emitted.set(reference.placeholder, url)
3065
+ }
3066
+ for (const reference of references) if (!emitted.has(reference.placeholder)) throw new Error(`Worker entry was not emitted: ${relative(root, reference.root)}`)
3067
+ return emitted
3068
+ }
3069
+
3070
+ async function validateWorkerGraphs(roots, sourceFiles) {
3071
+ const visited = new Set()
3072
+ const queue = [...roots]
3073
+ while (queue.length) {
3074
+ const file = queue.shift()
3075
+ if (visited.has(file)) continue
3076
+ visited.add(file)
3077
+ const sourceFile = parseSourceFile(file, await readFile(file, "utf8"))
3078
+ if (containsJsx(sourceFile)) throw sourceNodeError(sourceFile, sourceFile, "Worker modules must not contain JSX")
3079
+ const visit = node => {
3080
+ if (ts.isImportEqualsDeclaration(node)) throw sourceNodeError(node, sourceFile, "TypeScript import-equals declarations are not supported in Worker modules; use a relative ESM import")
3081
+ if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) throw sourceNodeError(node, sourceFile, "Dynamic imports are not supported in Worker modules")
3082
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === "require") throw sourceNodeError(node, sourceFile, "require() is not supported in Worker modules")
3083
+ ts.forEachChild(node, visit)
3084
+ }
3085
+ visit(sourceFile)
3086
+ for (const node of sourceFile.statements) {
3087
+ if ((!ts.isImportDeclaration(node) && !ts.isExportDeclaration(node)) || !node.moduleSpecifier || !ts.isStringLiteral(node.moduleSpecifier) || !runtimeModuleReference(node)) continue
3088
+ if (!node.moduleSpecifier.text.startsWith(".")) throw sourceNodeError(node.moduleSpecifier, sourceFile, "Worker modules may only use relative runtime imports")
3089
+ try {
3090
+ queue.push(resolveSourceImport(file, node.moduleSpecifier.text, sourceFiles))
3091
+ } catch (error) {
3092
+ const message = error.message.slice(error.message.indexOf("Relative import"))
3093
+ throw sourceNodeError(node.moduleSpecifier, sourceFile, message)
3094
+ }
3095
+ }
3096
+ }
3097
+ }
3098
+
2848
3099
  async function collectClientModules(entries, sourceFiles) {
2849
3100
  const modules = new Set()
2850
3101
  const queue = [...new Set(entries)]
@@ -2853,6 +3104,7 @@ async function collectClientModules(entries, sourceFiles) {
2853
3104
  if (modules.has(file)) continue
2854
3105
  const source = await readFile(file, "utf8")
2855
3106
  const sourceFile = parseSourceFile(file, source)
3107
+ rejectWorkerConstructions(sourceFile, sourceFile, "Relative TypeScript Worker construction is only supported directly inside an inline useEffect() callback, not imported client helpers")
2856
3108
  if (containsJsx(sourceFile)) throw new Error(`${relative(root, file)} Imported client helpers must not contain JSX`)
2857
3109
  rejectUnsupportedClientImports(sourceFile, file)
2858
3110
  modules.add(file)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kudzujs/core",
3
- "version": "0.6.5",
3
+ "version": "0.6.7",
4
4
  "description": "HTML-first TSX framework with synchronous state semantics and no virtual DOM",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -25,6 +25,7 @@
25
25
  "bin/",
26
26
  "framework/",
27
27
  "GOAL_A.md",
28
+ "GOAL_B.md",
28
29
  "README.md",
29
30
  "LICENSE"
30
31
  ],