@kudzujs/core 0.8.31 → 0.8.33
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/MIGRATION_ROADMAP.md +13 -1
- package/PERFORMANCE.md +76 -0
- package/README.md +1 -1
- package/RELEASES.md +57 -0
- package/docs/next-architecture/README.md +1 -1
- package/docs/next-architecture/compiler-current-architecture.md +10 -6
- package/docs/next-architecture/large-application-ai-native-roadmap.md +15 -9
- package/docs/next-architecture/versioning.md +3 -1
- package/framework/binding-runtime.js +5 -8
- package/framework/build.mjs +154 -49
- package/framework/compiler/project-session.mjs +21 -0
- package/framework/compiler/source-compiler.mjs +26 -16
- package/framework/compiler/source-graph.mjs +39 -37
- package/framework/compiler/worker-compiler.mjs +0 -8
- package/framework/list-runtime.js +10 -18
- package/package.json +1 -1
package/MIGRATION_ROADMAP.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
This document is the source of truth for Kudzu's product direction, architecture invariants, and future development order. Read it before extending React-shaped syntax or browser capabilities.
|
|
4
4
|
|
|
5
|
-
The executable post-`0.8.
|
|
5
|
+
The executable post-`0.8.33` compiler and large-application sequence is maintained in [`docs/next-architecture/large-application-ai-native-roadmap.md`](./docs/next-architecture/large-application-ai-native-roadmap.md). Follow its PR dependencies for implementation work; this document remains authoritative when selecting or accepting a migration capability.
|
|
6
6
|
|
|
7
7
|
[`GOAL_A.md`](./GOAL_A.md) and [`GOAL_B.md`](./GOAL_B.md) are completed capability-validation records. Their commerce and realtime dashboard fixtures prove general lifecycle, navigation, async-workflow, and Worker capabilities; they are not separate product verticals or future priority lists.
|
|
8
8
|
|
|
@@ -296,6 +296,18 @@ This queue orders the next investigations by general migration value. Start only
|
|
|
296
296
|
- Chrome coverage removes and recreates the same route and keyed row before old work resolves, proving replacement ownership stays fresh without cancelling application promises or adding a scheduler.
|
|
297
297
|
- The focused 5,000-event Chrome dispatch median remains 6.4 ms; the native fixture retains its emitted paths with a measured 209 B raw / 94 B aggregate gzip correctness cost.
|
|
298
298
|
|
|
299
|
+
### Completed In 0.8.32
|
|
300
|
+
|
|
301
|
+
- Production artifacts complete in staging; public/generated collisions and late `afterBuild()` failure preserve the previous successful `dist`.
|
|
302
|
+
- Same-root builds use an exclusive PID lock, stale locks fail closed, interrupted promotion backups recover on the next admitted build after lock removal, and successful replacement removes stale output.
|
|
303
|
+
- Route HTML writes use bounded batches, keyed reverse/remove paths avoid repeated map reconstruction, and binding/condition commits share one dispatch without adding a runtime or public API.
|
|
304
|
+
|
|
305
|
+
### Completed In 0.8.33
|
|
306
|
+
|
|
307
|
+
- Build root, source paths, source graph resolution, source records, and Worker compilation now belong to one explicit ProjectSession created for each build.
|
|
308
|
+
- Programmatic build and development entry points accept an explicit root while omitted roots preserve call-time CWD and existing CLI behavior.
|
|
309
|
+
- One process compiles two independent roots with identical module names and verifies isolated config, HTML, `.kudzu`, source results, and Worker output; no browser runtime or migration syntax changes.
|
|
310
|
+
|
|
299
311
|
## Cross-Cutting Performance Gates
|
|
300
312
|
|
|
301
313
|
Every migration feature must preserve:
|
package/PERFORMANCE.md
CHANGED
|
@@ -2,6 +2,82 @@
|
|
|
2
2
|
|
|
3
3
|
Reproducibility classes: `npm run benchmark`, `npm run benchmark:keyed`, and `npm run benchmark:native` are maintained in this repository; `npm run benchmark:commerce` is a maintained paired runner over the public external storefront; older excluded-workspace sections are historical provenance only and are not current framework rankings.
|
|
4
4
|
|
|
5
|
+
## 2026-08-11 React, Vue, And Svelte Check
|
|
6
|
+
|
|
7
|
+
This is a current local check, not a maintained framework ranking. The external `/home/kft/Documents/etc/demo/benchmarks` workspace is not Git-provenanced, but its matched fixtures, validators, dependencies, and raw result files were present and reused without source changes. The Kudzu target used this P0.5 candidate with `0.8.31` package metadata; controls were React 19.2.7, Vue 3.5.40, Svelte 5.56.6, and Vite 7.3.6 on Node 24.14.0, Linux x64, and Chrome 142.0.7444.175.
|
|
8
|
+
|
|
9
|
+
Each fixture received one warm-up and seven rotating clean production builds. Seven fresh headless Chrome profiles per target validated exact row count/order/text, retained and released DOM identity, fresh state on re-entry, and effect lifecycle counts before recording MutationObserver completion. React, Vue, and Svelte are CSR controls with no initial rows in HTML; Kudzu emits all initial rows, so total deploy size is reported but is not architecture-equivalent.
|
|
10
|
+
|
|
11
|
+
| Fixture | Framework | Build median | Initial JS gzip | Total deploy raw |
|
|
12
|
+
|---|---|---:|---:|---:|
|
|
13
|
+
| 1,000 keyed rows with local state | Kudzu | 1,212 ms | 10.2 KB | 576.0 KB |
|
|
14
|
+
| | Svelte | 1,745 ms | 13.1 KB | 33.8 KB |
|
|
15
|
+
| | Vue | 1,584 ms | 24.4 KB | 61.6 KB |
|
|
16
|
+
| | React | 1,939 ms | 59.4 KB | 189.5 KB |
|
|
17
|
+
| 1,000 keyed effects | Kudzu | 735 ms | 9.8 KB | 226.0 KB |
|
|
18
|
+
| | Svelte | 1,554 ms | 12.5 KB | 32.4 KB |
|
|
19
|
+
| | Vue | 1,444 ms | 24.5 KB | 61.9 KB |
|
|
20
|
+
| | React | 1,741 ms | 59.5 KB | 189.7 KB |
|
|
21
|
+
| 100 parents x 10 keyed children | Kudzu | 732 ms | 8.2 KB | 354.6 KB |
|
|
22
|
+
| | Svelte | 1,613 ms | 13.1 KB | 33.8 KB |
|
|
23
|
+
| | Vue | 1,437 ms | 24.6 KB | 62.1 KB |
|
|
24
|
+
| | React | 1,718 ms | 59.5 KB | 190.0 KB |
|
|
25
|
+
|
|
26
|
+
Kudzu's build medians were 23% to 58% lower and its initial JavaScript gzip was 21% to 86% lower than the three CSR controls. A separate seven-run GNU `time` check on the keyed-row fixture measured median build peak RSS of 157.4 MiB for Kudzu, 209.2 MiB for Svelte, 208.0 MiB for Vue, and 281.3 MiB for React.
|
|
27
|
+
|
|
28
|
+
| Fixture operation | Kudzu | Svelte | Vue | React |
|
|
29
|
+
|---|---:|---:|---:|---:|
|
|
30
|
+
| Row edit | 5.6 ms | 4.9 ms | 5.7 ms | 11.9 ms |
|
|
31
|
+
| Reverse 1,000 rows | 28.2 ms | 94.3 ms | 23.2 ms | 51.8 ms |
|
|
32
|
+
| Remove row | 5.0 ms | 8.7 ms | 7.6 ms | 15.6 ms |
|
|
33
|
+
| Re-add row | 7.7 ms | 16.8 ms | 8.1 ms | 18.5 ms |
|
|
34
|
+
| Effect dependency update | 4.8 ms | 9.8 ms | 10.0 ms | 14.9 ms |
|
|
35
|
+
| Effect-unrelated update | 2.8 ms | 7.3 ms | 2.7 ms | 10.1 ms |
|
|
36
|
+
| Reverse effect rows | 11.7 ms | 71.5 ms | 13.4 ms | 29.5 ms |
|
|
37
|
+
| Nested child update | 2.7 ms | 3.4 ms | 6.5 ms | 12.0 ms |
|
|
38
|
+
| Reverse 10 children | 0.7 ms | 1.1 ms | 3.1 ms | 9.0 ms |
|
|
39
|
+
| Reverse 100 parents | 6.8 ms | 7.7 ms | 7.3 ms | 11.1 ms |
|
|
40
|
+
| Remove parent | 1.1 ms | 1.5 ms | 3.4 ms | 6.7 ms |
|
|
41
|
+
|
|
42
|
+
These initial browser runs were grouped by target and some seven-sample ranges were wide, so they were treated as directional rather than a framework ranking. The candidate then removed repeated keyed-root lookups and redundant removal-map reconstruction across the generic, nested, and reducer list paths, and merged binding/condition state dispatch into one committer. The keyed-row JavaScript graph decreased from 26,962 B raw / 10,508 B gzip to 26,768 B raw / 10,485 B gzip.
|
|
43
|
+
|
|
44
|
+
A 31-round rotating follow-up ran fresh Chrome profiles in alternating and periodically reversed framework order. Its full-list completion predicate retained the exact 1,000-row content and identity checks inside timing:
|
|
45
|
+
|
|
46
|
+
| Framework | Edit | Reverse | Remove | Re-add |
|
|
47
|
+
|---|---:|---:|---:|---:|
|
|
48
|
+
| Kudzu | 5.9 ms | 23.4 ms | 5.6 ms | 8.0 ms |
|
|
49
|
+
| Svelte | 5.5 ms | 97.0 ms | 9.2 ms | 14.7 ms |
|
|
50
|
+
| Vue | 6.8 ms | 26.6 ms | 9.6 ms | 10.0 ms |
|
|
51
|
+
| React | 14.7 ms | 51.0 ms | 20.2 ms | 14.3 ms |
|
|
52
|
+
|
|
53
|
+
Paired sign tests established Kudzu's reverse, remove, and re-add advantage over Vue at 6.0 ms (`p=0.00143`), 4.1 ms (`p=0.000192`), and 2.1 ms (`p=0.0107`) median differences. Kudzu also beat Svelte for those operations in 31/31, 28/31, and 27/31 rounds. The full-list edit result remained dominated by the validator: Kudzu versus Svelte had a 0.8 ms directional loss with `p=0.281`, while Kudzu versus Vue had a 0.4 ms directional gain with `p=0.720`.
|
|
54
|
+
|
|
55
|
+
A final edit-only protocol kept complete 1,000-row correctness validation after each measurement but used only the edited row's class/input identity as the completion signal. Over 31 rotating rounds, Kudzu measured 0.7 ms versus Svelte 1.4 ms and Vue 1.8 ms. Paired differences favored Kudzu by 0.7 ms in 30/31 rounds (`p=2.98e-8`) versus Svelte and by 0.9 ms in 31/31 rounds (`p=9.31e-10`) versus Vue. Complete seven-run suite samples remain in the external workspace's `benchmarks/results/*-current.{json,md}` files; rotating raw samples are temporary measurement artifacts and this section remains a current local check rather than a maintained general ranking.
|
|
56
|
+
|
|
57
|
+
## 0.8.32 Staged Output Emission
|
|
58
|
+
|
|
59
|
+
Measured UTC 2026-08-11 on an Intel Core i5-9500 with 6 cores, Linux 6.17.0-19-generic, Node 24.14.0, and npm 11.9.0. The baseline was clean tag `v0.8.31` at `06b436e`; baseline and the pre-release P0.5 candidate used the same installed dependencies and the public 1,000-product storefront fixture at `f2d5be1a516c539e30f7125f6870d42b1dd02ecd`. The later same-root lock and interrupted-backup recovery hardening was correctness-tested but not included in this timing array.
|
|
60
|
+
|
|
61
|
+
One warm-up followed by 21 alternating replacement builds preserved the preceding output so the candidate exercised staging, promotion, and prior-tree removal on every run. Generated `.kudzu` scratch was cleaned outside timing. The candidate folds collision validation into one public copy traversal and writes the already-rendered route HTML in bounded batches of 64.
|
|
62
|
+
|
|
63
|
+
| Target | Build median | Output |
|
|
64
|
+
|---|---:|---:|
|
|
65
|
+
| `v0.8.31` | 20,392.7 ms | 3,056 files / 11,137,074 B |
|
|
66
|
+
| P0.5 candidate | 19,229.1 ms | 3,056 files / 11,137,074 B |
|
|
67
|
+
|
|
68
|
+
The candidate median is 5.71% lower. Every relative artifact path and SHA-256 hash matched, so deploy raw and gzip sizes are unchanged and no browser bytes are added.
|
|
69
|
+
|
|
70
|
+
```text
|
|
71
|
+
v0.8.31: [20868.5,19436.9,22023.6,21433.5,20634.0,20410.1,20392.7,21408.8,20252.0,19858.3,20489.9,20835.2,20450.2,18727.3,17239.1,17578.2,17672.1,17465.1,21087.0,18892.8,18727.9]
|
|
72
|
+
candidate: [20481.6,18692.6,21060.7,19881.9,19510.8,20844.1,20348.6,19229.1,20304.8,19852.9,18929.3,20472.5,17384.9,18628.6,17890.2,18841.0,19755.6,17805.9,18282.5,17770.5,17370.3]
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
APP_ROOT=/tmp/opencode/kudzu-based-bench/apps/shop-kudzu \
|
|
77
|
+
BASELINE_ROOT=/tmp/opencode/kudzu-p05-profile \
|
|
78
|
+
PRESERVE_OUTPUT=1 RUNS=21 npm run benchmark:commerce
|
|
79
|
+
```
|
|
80
|
+
|
|
5
81
|
## 0.8.31 Async Native Handler Ownership
|
|
6
82
|
|
|
7
83
|
Measured UTC 2026-08-10 on Apple M3, 8 logical CPUs, 8 GiB RAM, macOS 26.5.2 / Darwin 25.5.0, Node 25.6.1, npm 11.18.0, and Chrome 151.0.7922.76. The baseline was clean tag `v0.8.30` at `9bb5ce8`; baseline and candidate used the same installed dependencies.
|
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@ Kudzu compiles ordinary React-shaped TypeScript and TSX into complete static HTM
|
|
|
14
14
|
|
|
15
15
|
> Experimental `0.8.x`: the compiler API and supported TSX surface may change.
|
|
16
16
|
|
|
17
|
-
**Latest release: 0.8.
|
|
17
|
+
**Latest release: 0.8.33 - Project-scoped compilation.** Every build now owns its root, source graph, source maps, compiler paths, and Worker compiler in an explicit ProjectSession, so independent projects compile safely in one Node process while the CLI keeps its current-directory behavior. Read the [release notes](./RELEASES.md#0833---project-scoped-compilation), open the [release page](https://github.com/kudzujs/kudzu/releases/tag/v0.8.33), or follow the [architecture packet](./docs/next-architecture/README.md).
|
|
18
18
|
|
|
19
19
|
- [Documentation](https://kudzujs.cloud/docs)
|
|
20
20
|
- [Installation guide](https://kudzujs.cloud/docs#install)
|
package/RELEASES.md
CHANGED
|
@@ -1,5 +1,62 @@
|
|
|
1
1
|
# Kudzu Releases
|
|
2
2
|
|
|
3
|
+
## 0.8.33 - Project-scoped compilation
|
|
4
|
+
|
|
5
|
+
Kudzu 0.8.33 completes P0.6 by replacing import-time project globals with an explicit build-scoped ProjectSession. Independent roots can now compile in one Node process without sharing paths, source records, graph resolution, or Worker compiler ownership.
|
|
6
|
+
|
|
7
|
+
### Changed in 0.8.33
|
|
8
|
+
|
|
9
|
+
- Each `build()` creates a ProjectSession containing the absolute project root, `src`, `src/pages`, `.kudzu`, and `dist` paths.
|
|
10
|
+
- Source graph resolution, source indexes, reachable source sets, source compiler helpers, and Worker compilation are bound to that session instead of the directory where compiler modules were first imported.
|
|
11
|
+
- The internal programmatic build and development entry points accept an explicit `root`; omitted roots still use call-time `process.cwd()`, preserving existing `kudzu build` and `kudzu dev` behavior.
|
|
12
|
+
- Config loading, styles, static assets, route diagnostics, generated modules, output locking, staging, promotion, and development serving all resolve against the selected project.
|
|
13
|
+
- Parsed-module and export-summary caching remains deferred to P0.7; this release establishes ownership without adding speculative shared caches or changing browser output.
|
|
14
|
+
|
|
15
|
+
### Validation
|
|
16
|
+
|
|
17
|
+
- `npm run check`, `npm test`, `npm run test:package`, and all 191 tests pass.
|
|
18
|
+
- One imported `build()` function compiles two roots with identical source filenames in sequence and verifies isolated config metadata, HTML, source results, `.kudzu` modules, and content-distinct Worker bundles.
|
|
19
|
+
- The standard CLI output-safety fixture still verifies staging, collision rejection, lock behavior, recovery, and replacement through call-time CWD.
|
|
20
|
+
- The repository build emits 148 pages, and existing static zero-JavaScript, interactive capability, Worker, navigation, ownership, and migration behavior remains covered.
|
|
21
|
+
- P0.7 parsed module and export summary caching is next.
|
|
22
|
+
|
|
23
|
+
### Upgrade
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npm install @kudzujs/core@^0.8.33
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## 0.8.32 - Staged and collision-safe output
|
|
30
|
+
|
|
31
|
+
Kudzu 0.8.32 completes P0.5 by building production artifacts away from the active deploy tree, rejecting public/generated collisions, and replacing `dist` only after generation and trusted `afterBuild()` work succeed.
|
|
32
|
+
|
|
33
|
+
### Changed in 0.8.32
|
|
34
|
+
|
|
35
|
+
- Route HTML, runtime, handler, chunk, Worker, source CSS, and configured CSS artifacts complete in a project-local staging tree before promotion.
|
|
36
|
+
- Public files merge only into unowned paths. A public file cannot replace a generated file or directory, and duplicate configured stylesheet outputs fail before publication.
|
|
37
|
+
- Ordinary compiler, bundler, public-copy, and `afterBuild()` failures leave the previous successful `dist` unchanged.
|
|
38
|
+
- Same-root builds use an exclusive PID lock. Active overlap and dead/invalid lock files fail closed; after an operator removes a stale lock, the next admitted build recovers a backup left by interrupted promotion. Promotion is a guarded rollback/recovery sequence rather than a lock-free atomic directory exchange.
|
|
39
|
+
- Successful replacement removes stale output and staging/backup artifacts. Development rebuild errors retain the previous on-disk HTML while the existing error overlay reports the source failure.
|
|
40
|
+
- Route HTML writes use bounded batches. Keyed reverse/remove paths avoid repeated root-map work, and binding plus condition updates share one state commit dispatch.
|
|
41
|
+
|
|
42
|
+
### Performance
|
|
43
|
+
|
|
44
|
+
- Before final lock/recovery hardening, twenty-one alternating local replacement builds of the 1,011-page commerce fixture observed a 20,392.7 ms to 19,229.1 ms median change, 5.71% lower, while all 3,056 paths and 11,137,074 deploy bytes retained identical SHA-256 hashes.
|
|
45
|
+
- The optimized 1,000-row browser graph decreased by 194 B raw / 23 B gzip. External exploratory browser comparisons and their non-publication limitations are documented in `PERFORMANCE.md`; they are not a release gate or maintained general ranking.
|
|
46
|
+
|
|
47
|
+
### Validation
|
|
48
|
+
|
|
49
|
+
- `npm run check`, `npm test`, `npm run test:package`, and all 190 tests pass.
|
|
50
|
+
- Focused integration coverage verifies seven public/generated namespaces, configured-style collisions, late hook failure, successful stale-file removal, same-root overlap rejection, stale-lock diagnosis, interrupted-backup recovery after lock removal, and no staging/backup remnants.
|
|
51
|
+
- Existing keyed identity/state, nested lists, reducer rows, SVG, effects, Workers, navigation, async ownership, and static zero-JavaScript behavior remain covered.
|
|
52
|
+
- P0.6 ProjectSession and explicit root is next.
|
|
53
|
+
|
|
54
|
+
### Upgrade
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
npm install @kudzujs/core@^0.8.32
|
|
58
|
+
```
|
|
59
|
+
|
|
3
60
|
## 0.8.31 - Async native-handler ownership
|
|
4
61
|
|
|
5
62
|
Kudzu 0.8.31 completes P0.4 by tying every mounted native-handler context to its DOM registration lifetime. Async work may finish after route, keyed, or conditional removal, but it cannot write through ownership that no longer exists.
|
|
@@ -11,7 +11,7 @@ The top-level [`GOAL_A.md`](../../GOAL_A.md) and [`GOAL_B.md`](../../GOAL_B.md)
|
|
|
11
11
|
| C: state/resource model | Research only | Reduced fixtures expose a limitation |
|
|
12
12
|
| D: routing compatibility | Current behavior preserved | Revisit only with migration evidence and invariant review |
|
|
13
13
|
|
|
14
|
-
The active post-`0.8.
|
|
14
|
+
The active post-`0.8.33` implementation sequence is [`large-application-ai-native-roadmap.md`](./large-application-ai-native-roadmap.md). P0.6 ProjectSession and explicit root is complete; P0.7 parsed module and export summary caching is next. The plan orders compiler semantic generalization, large-application foundations, compatibility boundaries, AI tooling, and production validation without changing the invariants below.
|
|
15
15
|
|
|
16
16
|
## Required Invariants
|
|
17
17
|
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
# Current Compiler Architecture
|
|
2
2
|
|
|
3
|
-
This maps the current `0.8.
|
|
3
|
+
This maps the current `0.8.33` architecture built on the completed `0.8.23` Goal A compiler foundation. File and function names are the stable references; line numbers are intentionally omitted because later work may still move code.
|
|
4
4
|
|
|
5
5
|
## Responsibility Map
|
|
6
6
|
|
|
7
7
|
| Responsibility | Current owner | Current contract |
|
|
8
8
|
|---|---|---|
|
|
9
9
|
| CLI entry | [`bin/kudzu.mjs`](../../bin/kudzu.mjs) | Dispatches build and development commands. |
|
|
10
|
+
| Project session | [`framework/compiler/project-session.mjs`](../../framework/compiler/project-session.mjs), `createProjectSession()` | Owns one absolute root, standard project paths, source records, bound graph operations, and Worker compiler for a build. Omitted roots resolve from call-time CWD. |
|
|
10
11
|
| Build orchestration | [`framework/build.mjs`](../../framework/build.mjs), `build()` | Coordinates config, discovery, source compilation, RouteIR rendering, CapabilityIR projection, generator invocation, artifact emission, and `afterBuild`. |
|
|
11
12
|
| Reachability/import resolution | [`framework/compiler/source-compiler.mjs`](../../framework/compiler/source-compiler.mjs), `reachableSourceFiles()`; [`framework/compiler/source-graph.mjs`](../../framework/compiler/source-graph.mjs), `ordinaryRuntimeDependencies()`, `resolveSourceImport()` | Starts from page entries, follows relative runtime imports/re-exports and validated Worker references, excludes unreachable migration source, and fails unresolved ordinary edges or dynamic imports at the importer source location before code generation. |
|
|
12
13
|
| Ordered normalization | [`framework/compiler/normalization-pipeline.mjs`](../../framework/compiler/normalization-pipeline.mjs), `applyNormalizationPasses()`; [`framework/compiler/source-compiler.mjs`](../../framework/compiler/source-compiler.mjs), `normalizeCompilerSource()` | Applies migration/resource passes in order and repairs TypeScript parent pointers after every structural change. Imported source uses the same pipeline. |
|
|
@@ -28,15 +29,16 @@ This maps the current `0.8.31` architecture built on the completed `0.8.23` Goal
|
|
|
28
29
|
| Route capability projection | [`framework/compiler/route-capability-planner.mjs`](../../framework/compiler/route-capability-planner.mjs), `planRouteCapabilities()` | Validates RouteIR v1 and purely folds rendered plans and route facts into CapabilityIR v1. |
|
|
29
30
|
| Effect entry generation | [`framework/compiler/effect-codegen.mjs`](../../framework/compiler/effect-codegen.mjs) | Generates ordinary, dependency, owned, and navigable effect entries from rendered descriptors. |
|
|
30
31
|
| Runtime generation | [`framework/compiler/runtime-codegen.mjs`](../../framework/compiler/runtime-codegen.mjs), [`framework/compiler/list-runtime-codegen.mjs`](../../framework/compiler/list-runtime-codegen.mjs), [`framework/compiler/param-codegen.mjs`](../../framework/compiler/param-codegen.mjs) | Consumes versioned contracts, specializes authored capability sources with fail-closed anchors, and returns source/define results without filesystem ownership. |
|
|
31
|
-
| Artifact emission | `framework/build.mjs` | Selects required files from CapabilityIR
|
|
32
|
+
| Artifact emission | `framework/build.mjs` | Selects required files from CapabilityIR, writes route HTML in bounded batches, writes and bundles the complete generation in a project-local staging sibling, copies public subtrees without replacing generated paths, runs `afterBuild`, then promotes with rollback so failed builds preserve the prior `dist`. Byte-identical native, parameter, and effect route entries reuse one exact-source transform result within the current build only. |
|
|
32
33
|
| Browser capabilities | [`framework/*.js`](../../framework/) | Small optional modules for commands, bindings, lists, effects, native handlers, serialization, parameters, and navigation; native contexts invalidate writes and refs at DOM ownership release, with no component runtime. |
|
|
33
34
|
| Opt-in navigation | [`framework/navigation-runtime.js`](../../framework/navigation-runtime.js) plus `framework/build.mjs` navigation configuration/emission | Fetches and validates complete same-origin documents, replaces only the marked route range, manages route/layout disposal, history, focus, finite prefetch retention, and native fallback. |
|
|
34
|
-
| Development serving | [`framework/dev-server.mjs`](../../framework/dev-server.mjs) and [`framework/dev-state.js`](../../framework/dev-state.js) | Rebuild/watch/SSE and response-only short-lived state restoration;
|
|
35
|
+
| Development serving | [`framework/dev-server.mjs`](../../framework/dev-server.mjs) and [`framework/dev-state.js`](../../framework/dev-state.js) | Rebuild/watch/SSE and response-only short-lived state restoration; failed rebuilds show the existing error overlay while preserving the previous on-disk output. |
|
|
35
36
|
|
|
36
37
|
## Current Data Flow
|
|
37
38
|
|
|
38
39
|
```text
|
|
39
40
|
src/pages entries + config
|
|
41
|
+
-> ProjectSession(root) with project paths, source records, graph, and Worker compiler
|
|
40
42
|
-> project discovery and reachable relative graph
|
|
41
43
|
-> compileSource()
|
|
42
44
|
-> ordered normalization and parent repair
|
|
@@ -54,8 +56,10 @@ src/pages entries + config
|
|
|
54
56
|
-> CapabilityIR v1
|
|
55
57
|
-> specialize and emit only selected runtime/capability ESM
|
|
56
58
|
-> reuse exact generated route-entry transforms within this build
|
|
57
|
-
-> write route index.html, CSS/assets, Worker graphs, rewrites
|
|
58
|
-
->
|
|
59
|
+
-> write route index.html, CSS/assets, Worker graphs, and rewrites into staging
|
|
60
|
+
-> copy public paths only where they do not replace generated artifacts
|
|
61
|
+
-> optional afterBuild() against staging
|
|
62
|
+
-> rollback-safe promotion to dist; recover an interrupted backup on the next admitted build after stale-lock removal
|
|
59
63
|
```
|
|
60
64
|
|
|
61
65
|
The browser consumes static HTML first. State seeds and descriptors in that HTML connect only to emitted command, binding, list, native-handler, effect, parameter, or navigation modules. Browser execution patches owned DOM directly; it does not invoke component functions or reconstruct a component tree.
|
|
@@ -66,7 +70,7 @@ The browser consumes static HTML first. State seeds and descriptors in that HTML
|
|
|
66
70
|
- Transient component rewrite indexes remain source-local AST indexes; handler, binding, derived, keyed, effect, and component ownership now have explicit JSON-safe source results.
|
|
67
71
|
- `build()` still owns explicit artifact selection and filesystem writes after generator results are produced.
|
|
68
72
|
- Runtime generators intentionally specialize readable authored sources through exact anchors; every required anchor fails closed, but a future generator format may remove this transitional dependency.
|
|
69
|
-
- Source reachability and source compilation
|
|
73
|
+
- Source reachability and source compilation remain in one session-bound compiler factory because both consume the same normalization and import graph contracts; parsed-module and export-summary caching is deferred to P0.7.
|
|
70
74
|
- Imported, specialized, and compiler-synthesized trees still use conservative name/scope fallback where the source-local binding index does not own the complete AST; cross-module semantics remain deferred to stable ModuleSymbol and SiteId work.
|
|
71
75
|
|
|
72
76
|
These are future simplification opportunities, not incomplete Goal A contracts. Goal A changed no source support, browser output semantics, or browser architecture.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
## Status
|
|
4
4
|
|
|
5
|
-
Active execution plan after `0.8.
|
|
5
|
+
Active execution plan after `0.8.33`. This document turns the current compiler audit into an ordered implementation program. It does not mark any planned capability as supported and does not authorize a React runtime, VDOM, hydration, retained browser component tree, generic rerenderer, public store/query/resource API, SPA router, or islands.
|
|
6
6
|
|
|
7
7
|
[`MIGRATION_ROADMAP.md`](../../MIGRATION_ROADMAP.md) remains authoritative for product invariants and fixture-driven feature selection. This plan is authoritative for the order and completion evidence of compiler generalization, large-application foundations, compatibility boundaries, AI tooling, and scale validation. If implementation evidence changes a boundary, update this document before broadening a patch.
|
|
8
8
|
|
|
@@ -34,7 +34,7 @@ The current limiting architecture is observable in these files:
|
|
|
34
34
|
|
|
35
35
|
| Concern | Current owner | Limitation to remove |
|
|
36
36
|
|---|---|---|
|
|
37
|
-
| Project graph | `framework/compiler/source-graph.mjs`, `
|
|
37
|
+
| Project graph | `framework/compiler/project-session.mjs`, `source-graph.mjs`, `build.mjs` | Explicit build-scoped root and source ownership; repeated parsing, narrow export resolution, and no incremental invalidation remain |
|
|
38
38
|
| Normalization | `framework/compiler/source-compiler.mjs`, focused passes | Pass order and package ownership are implicit; semantically equal source often follows different shape-specific paths |
|
|
39
39
|
| Component/state analysis | `framework/compiler/source-compiler.mjs`, `analysis/component-analysis.mjs` | AST identity, identifier text, source offsets, and caller-side AST specialization remain central |
|
|
40
40
|
| Handler/binding analysis | `framework/compiler/descriptor-session.mjs`, `handler-lowering.mjs` | Capture/import/state discovery is name-based and arbitrary handlers become code before IR finalization |
|
|
@@ -112,7 +112,7 @@ This is an incremental evolution of the current repository:
|
|
|
112
112
|
- Resource-specific ownership passes into a package-neutral ResourceIR when evidence permits.
|
|
113
113
|
- Serialized handler URL searches into explicit artifact references.
|
|
114
114
|
- Site-wide capability unions into route capability signatures.
|
|
115
|
-
- Full
|
|
115
|
+
- Full development rebuilds into project-session invalidation; rollback-safe production output and explicit session ownership are complete.
|
|
116
116
|
- String-only diagnostics into structured diagnostics suitable for machines and AI agents.
|
|
117
117
|
|
|
118
118
|
### Avoid
|
|
@@ -133,7 +133,9 @@ This is an incremental evolution of the current repository:
|
|
|
133
133
|
- [x] P0.2 Symbol-aware descriptor discovery is complete in `0.8.29`. Native handler, effect, remaining binding, list evaluator, optimized-command, and effect-resource discovery/lowering use the source-local binding index when it owns the complete AST; synthesized trees retain the existing fail-safe path. Focused lexical-shadow, JSON-safe IR, resource-ownership checks, all 187 tests, and packed-package smoke pass.
|
|
134
134
|
- [x] P0.3 Graph diagnostics is complete in `0.8.30`. Reachable ordinary modules validate relative runtime imports/re-exports and reject every dynamic `import()` at the importer source location before compilation or generated module loading. Ordinary and Worker traversal retain separate ownership, type-only and unreachable edges remain excluded, focused page/helper/re-export/dynamic fixtures pass with all 189 tests and packed-package smoke, and no export-symbol graph, ProjectSession, runtime, or public API is added.
|
|
135
135
|
- [x] P0.4 Async native handler ownership is complete in `0.8.31`. Mounted native registrations invalidate direct/captured setters, queued commits, and captured refs before listener removal. Chrome route, keyed-row, document-disposal, and synchronous event-dispatch checks, all 189 tests, and packed-package smoke pass without cancelling application promises or adding a scheduler.
|
|
136
|
-
- [
|
|
136
|
+
- [x] P0.5 Staged and collision-safe output is complete in `0.8.32`. Production output completes in one project-local staging tree, public files are compared against generated route/runtime/handler/chunk/Worker/CSS paths while copying, `afterBuild` runs before rollback-safe promotion, and ordinary failures preserve the prior `dist`. Same-root overlap and stale locks fail closed; after stale-lock removal, an interrupted promotion backup recovers on the next admitted build. Focused collision/replacement/dev checks pass with equivalent `v0.8.31` commerce deploy output; `.kudzu` remains compiler scratch for P0.6.
|
|
137
|
+
- [x] P0.6 Explicit ProjectSession is complete in `0.8.33`. Each build owns an absolute root, project paths, source records, bound graph operations, and Worker compiler. Explicit-root build/dev entry points preserve omitted-root CLI CWD behavior. One imported build function compiles two same-shaped roots with isolated config, HTML, `.kudzu`, source results, and Worker bundles; all 191 tests and package checks pass without browser or source-syntax changes.
|
|
138
|
+
- [ ] P0.7 Parsed module and export summary caching is next. Shared modules must be parsed and summarized once per project session without sharing transformed mutable AST.
|
|
137
139
|
|
|
138
140
|
### P0: Semantic Correctness And Compiler Foundation
|
|
139
141
|
|
|
@@ -145,7 +147,7 @@ P0 creates the semantic base required by every later large-application capabilit
|
|
|
145
147
|
| P0.2 | Descriptor consumers use SymbolRef | P0.1 | Handler/effect/binding capture and import discovery no longer depends only on text names |
|
|
146
148
|
| P0.3 | Graph diagnostics | P0.1 | Relative dynamic imports and unresolved runtime edges fail at the source location |
|
|
147
149
|
| P0.4 | Async native handler ownership | None | Pending handlers cannot write after route or DOM ownership is released |
|
|
148
|
-
| P0.5 |
|
|
150
|
+
| P0.5 | Staged collision-safe build | None | Failed builds preserve prior output and public files cannot overwrite generated artifacts |
|
|
149
151
|
| P0.6 | Explicit ProjectSession | P0.1 | Root and caches are build-scoped; two projects can compile safely in one process |
|
|
150
152
|
| P0.7 | Parsed module/export cache | P0.6 | Shared modules are parsed and summarized once per build |
|
|
151
153
|
| P0.8 | Stable ModuleSymbol and SiteId | P0.6-P0.7 | Imports, re-exports, aliases, owners, and call sites use stable identities outside a pass |
|
|
@@ -275,7 +277,7 @@ Every result carries a stable source-local binding slot, debug name, declaration
|
|
|
275
277
|
|
|
276
278
|
**Completed in `0.8.31`:** the existing native registration lifetime now guards state mutation, queued commits, and captured ref lookup. Route and keyed-row browser checks recreate the same ownership IDs before old work resolves, and document disposal invalidates a pending layout handler. Replacement state and DOM remain fresh. Both 5,000-event Chrome dispatch medians are 6.4 ms, and the 209 B raw / 94 B aggregate gzip runtime cost is recorded in `PERFORMANCE.md`.
|
|
277
279
|
|
|
278
|
-
### PR 5:
|
|
280
|
+
### PR 5: Staged And Collision-Safe Output
|
|
279
281
|
|
|
280
282
|
**Objective:** make production builds safe before scaling the build graph.
|
|
281
283
|
|
|
@@ -283,7 +285,9 @@ Every result carries a stable source-local binding slot, debug name, declaration
|
|
|
283
285
|
|
|
284
286
|
**Tests:** public collisions with route HTML, core runtime, handler entry, chunk, Worker namespace, source CSS, and configured CSS; failed build preserves prior `dist`.
|
|
285
287
|
|
|
286
|
-
**Done condition:** output is staged, validated, and
|
|
288
|
+
**Done condition:** output is staged, validated, and promoted with rollback/recovery; public content cannot silently replace generated artifacts.
|
|
289
|
+
|
|
290
|
+
**Completed in `0.8.32`:** Kudzu emits into a locked project-local staging sibling, validates public files during one collision-safe copy traversal, runs trusted `afterBuild` mutation there, and promotes with backup rollback. Active overlap and stale locks fail closed; after stale-lock removal, the next admitted build restores an interrupted promotion backup. This is a guarded two-rename replacement, not a lock-free atomic directory exchange. Route HTML is written in bounded batches. Route HTML, core runtime, handler entry, actual esbuild chunk, Worker namespace, source CSS, and configured CSS collisions preserve the complete prior manifest; late hook failure also preserves it and successful replacement removes stale files. Before final lock/recovery hardening, twenty-one alternating 1,011-page replacement builds observed a 5.71% lower median with identical 3,056-file / 11,137,074-byte commerce deploy output.
|
|
287
291
|
|
|
288
292
|
### PR 6: ProjectSession And Explicit Root
|
|
289
293
|
|
|
@@ -293,6 +297,8 @@ Every result carries a stable source-local binding slot, debug name, declaration
|
|
|
293
297
|
|
|
294
298
|
**Done condition:** two independent roots compile in one process, current CLI behavior and artifacts remain unchanged, and source caches cannot leak between projects.
|
|
295
299
|
|
|
300
|
+
**Completed in `0.8.33`:** `createProjectSession()` resolves one explicit or call-time-CWD root and owns standard paths, source records, bound graph resolution, and a root-bound Worker compiler. Build, development, config, styles, routes, generated modules, locking, staging, and promotion consume that ownership. One process compiles two roots with identical module names and verifies distinct config, HTML, `.kudzu`, source results, and Worker bundles. Existing CLI output-safety coverage remains unchanged; no parsed-module cache, browser runtime, source syntax, or public migration API is added.
|
|
301
|
+
|
|
296
302
|
### PR 7: Parsed Module And Export Summary Cache
|
|
297
303
|
|
|
298
304
|
**Objective:** make work proportional to unique modules instead of importer edges.
|
|
@@ -430,7 +436,7 @@ The first comparison is Kudzu versus React + Vite using the same agent, model, t
|
|
|
430
436
|
## Production Gates Before 1.0
|
|
431
437
|
|
|
432
438
|
- Async native and effect work cannot write after ownership release.
|
|
433
|
-
- Build output is
|
|
439
|
+
- Build output is staged, collision-safe, and rollback/recovery guarded.
|
|
434
440
|
- Source maps connect generated route code to TS/TSX diagnostics.
|
|
435
441
|
- Chrome, Firefox, and WebKit pass required journeys.
|
|
436
442
|
- Accessibility automation and keyboard checks cover forms, navigation, dialogs, menus, charts, and errors.
|
|
@@ -457,4 +463,4 @@ The first comparison is Kudzu versus React + Vite using the same agent, model, t
|
|
|
457
463
|
|
|
458
464
|
## Immediate Decision
|
|
459
465
|
|
|
460
|
-
PR 1 through PR
|
|
466
|
+
PR 1 through PR 6 are complete. The next PR is **PR 7: Parsed Module And Export Summary Cache**. Do not skip directly to a store, resource, router, virtualization, or ecosystem package feature.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Planned Version Sequence
|
|
2
2
|
|
|
3
|
-
This is an execution sequence, not release history. `0.8.16` through `0.8.
|
|
3
|
+
This is an execution sequence, not release history. `0.8.16` through `0.8.33` are completed scopes represented by package/release records.
|
|
4
4
|
|
|
5
5
|
Keep each patch behavior-preserving and independently reviewable. If a boundary proves inseparable, revise this plan before combining releases; do not silently broaden a patch.
|
|
6
6
|
|
|
@@ -22,6 +22,8 @@ Keep each patch behavior-preserving and independently reviewable. If a boundary
|
|
|
22
22
|
| `0.8.29` | Move native handler, effect, remaining binding, list evaluator, optimized-command, and effect-resource discovery/lowering onto source-local lexical identity. | Shadowed imports/globals/state/resources remain distinct, HandlerIR/BindingIR round trips pass, synthesized trees retain the fallback, and no runtime or public API is added. |
|
|
23
23
|
| `0.8.30` | Validate ordinary runtime graph edges before code generation and reject dynamic imports at the importer source. | Page/helper/re-export/dynamic fixtures report original file/range/specifier without `.kudzu` paths; Worker, type-only, unreachable, output, and runtime behavior remain unchanged. |
|
|
24
24
|
| `0.8.31` | Invalidate pending native-handler contexts when their route, keyed, conditional, or document DOM owner is released. | Late setters, queued commits, and captured refs cannot mutate replacement ownership; synchronous dispatch remains within the performance gate and runtime bytes are recorded. |
|
|
25
|
+
| `0.8.32` | Stage and validate production output before rollback-safe promotion, reject same-root overlap, and tighten keyed browser paths. | Build/hook failures preserve the prior output, stale locks fail closed, interrupted backups recover on the next admitted build after lock removal, public/generated collisions fail, and deploy output remains equivalent. |
|
|
26
|
+
| `0.8.33` | Move root, graph, source records, compiler paths, and Worker ownership into an explicit build-scoped ProjectSession. | Two independent roots compile through one imported build entry without config, source, `.kudzu`, Worker, or output leakage; CLI CWD behavior remains unchanged. |
|
|
25
27
|
|
|
26
28
|
## Sequence Rules
|
|
27
29
|
|
|
@@ -41,14 +41,12 @@ export function patchBinding(node, target, value) {
|
|
|
41
41
|
|
|
42
42
|
function commitBindings(id) {
|
|
43
43
|
const bindings = bindingTargets.get(id)
|
|
44
|
-
if (
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
44
|
+
if (bindings) {
|
|
45
|
+
for (const binding of bindings) {
|
|
46
|
+
if (!binding.node.isConnected) bindings.delete(binding)
|
|
47
|
+
else patchBinding(binding.node, binding.target, binding.read())
|
|
48
|
+
}
|
|
48
49
|
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function commitConditions(id) {
|
|
52
50
|
const conditions = conditionTargets.get(id)
|
|
53
51
|
if (!conditions) return
|
|
54
52
|
for (const condition of conditions) {
|
|
@@ -58,7 +56,6 @@ function commitConditions(id) {
|
|
|
58
56
|
}
|
|
59
57
|
|
|
60
58
|
registerCommitter(commitBindings)
|
|
61
|
-
registerCommitter(commitConditions)
|
|
62
59
|
registerMountHook(mountBindings)
|
|
63
60
|
registerMountHook(mountConditions)
|
|
64
61
|
registerUnmountHook(unmountBindings)
|
package/framework/build.mjs
CHANGED
|
@@ -1,29 +1,23 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto"
|
|
2
|
-
import { cp, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises"
|
|
2
|
+
import { cp, mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises"
|
|
3
3
|
import { dirname, join, relative, resolve, sep } from "node:path"
|
|
4
4
|
import { pathToFileURL } from "node:url"
|
|
5
5
|
import { build as bundle, transform } from "esbuild"
|
|
6
6
|
import { createEffectCodegen } from "./compiler/effect-codegen.mjs"
|
|
7
7
|
import { generateListRuntime } from "./compiler/list-runtime-codegen.mjs"
|
|
8
8
|
import { assetPath, browserPath, relativeModulePath, withBase } from "./compiler/path-helpers.mjs"
|
|
9
|
-
import {
|
|
9
|
+
import { createProjectSession } from "./compiler/project-session.mjs"
|
|
10
|
+
import { createSourceCompiler } from "./compiler/source-compiler.mjs"
|
|
10
11
|
import { createParamCodegen } from "./compiler/param-codegen.mjs"
|
|
11
12
|
import { planRouteCapabilities, usesRouteDependencyRuntime } from "./compiler/route-capability-planner.mjs"
|
|
12
13
|
import { generateBindingRuntime, generateCoreRuntime, generateEffectRuntime, generateNativeRuntime, generateNavigationRuntime, specializeRuntime } from "./compiler/runtime-codegen.mjs"
|
|
13
|
-
import { emitWorkers } from "./compiler/worker-compiler.mjs"
|
|
14
14
|
import { renderPage } from "./core.mjs"
|
|
15
15
|
import { parseDevHost, parseDevPort, startDevServer } from "./dev-server.mjs"
|
|
16
16
|
|
|
17
17
|
export { parseDevHost, parseDevPort }
|
|
18
18
|
export { specializeRuntime }
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
const sourceDirectory = join(root, "src")
|
|
22
|
-
const pagesDirectory = join(sourceDirectory, "pages")
|
|
23
|
-
const workDirectory = join(root, ".kudzu")
|
|
24
|
-
const outputDirectory = join(root, "dist")
|
|
25
|
-
|
|
26
|
-
async function loadConfig() {
|
|
20
|
+
async function loadConfig(root) {
|
|
27
21
|
for (const name of ["kudzu.config.mjs", "kudzu.config.js"]) {
|
|
28
22
|
const file = join(root, name)
|
|
29
23
|
if (!(await exists(file))) continue
|
|
@@ -34,11 +28,40 @@ async function loadConfig() {
|
|
|
34
28
|
return {}
|
|
35
29
|
}
|
|
36
30
|
|
|
37
|
-
export async function build({ quiet = false, minify = true } = {}) {
|
|
38
|
-
const
|
|
31
|
+
export async function build({ quiet = false, minify = true, root: projectRoot = process.cwd() } = {}) {
|
|
32
|
+
const project = createProjectSession(projectRoot)
|
|
33
|
+
const { root, outputDirectory } = project
|
|
34
|
+
const stagedOutput = join(root, ".kudzu-dist-staging")
|
|
35
|
+
const backupOutput = join(root, ".kudzu-dist-backup")
|
|
36
|
+
const lockPath = join(root, ".kudzu-build.lock")
|
|
37
|
+
const lock = await acquireBuildLock(lockPath)
|
|
38
|
+
try {
|
|
39
|
+
await recoverOutput(outputDirectory, backupOutput)
|
|
40
|
+
await rm(stagedOutput, { recursive: true, force: true })
|
|
41
|
+
const { result, pageCount, behaviorCount } = await buildInto(project, stagedOutput, { minify })
|
|
42
|
+
await promoteOutput(stagedOutput, outputDirectory, backupOutput)
|
|
43
|
+
if (!quiet) console.log(`Built ${pageCount} page(s), ${behaviorCount} interactive page(s) into dist/`)
|
|
44
|
+
return result
|
|
45
|
+
} finally {
|
|
46
|
+
try {
|
|
47
|
+
await rm(stagedOutput, { recursive: true, force: true })
|
|
48
|
+
} finally {
|
|
49
|
+
try {
|
|
50
|
+
await lock.close()
|
|
51
|
+
} finally {
|
|
52
|
+
await rm(lockPath, { force: true })
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function buildInto(project, outputDirectory, { minify }) {
|
|
59
|
+
const { root, sourceDirectory, pagesDirectory, workDirectory } = project
|
|
60
|
+
const { collectClientModules, compileClientModule, compiledPath, compileSource, layoutExportError, orderSourceStyles, reachableSourceFiles, safeStaticFiles } = createSourceCompiler(project)
|
|
61
|
+
const config = await loadConfig(root)
|
|
39
62
|
const base = normalizeBase(config.base)
|
|
40
|
-
const configuredStyles = normalizeStyles(config.styles, base)
|
|
41
|
-
const publicDirectory = normalizePublicDirectory(config.publicDir)
|
|
63
|
+
const configuredStyles = normalizeStyles(config.styles, base, project)
|
|
64
|
+
const publicDirectory = normalizePublicDirectory(config.publicDir, project)
|
|
42
65
|
const navigationGroups = normalizeNavigation(config.navigation)
|
|
43
66
|
const navigationRoutes = navigationGroups.flatMap(group => group.routes)
|
|
44
67
|
const navigationByRoute = new Map(navigationGroups.flatMap(group => group.routes.map(route => [route, group])))
|
|
@@ -52,7 +75,6 @@ export async function build({ quiet = false, minify = true } = {}) {
|
|
|
52
75
|
group.hasParams = false
|
|
53
76
|
}
|
|
54
77
|
await rm(workDirectory, { recursive: true, force: true })
|
|
55
|
-
await rm(outputDirectory, { recursive: true, force: true })
|
|
56
78
|
await mkdir(workDirectory, { recursive: true })
|
|
57
79
|
await mkdir(outputDirectory, { recursive: true })
|
|
58
80
|
|
|
@@ -62,15 +84,17 @@ export async function build({ quiet = false, minify = true } = {}) {
|
|
|
62
84
|
const discoveredCssFiles = projectFiles.filter(file => file.toLowerCase().endsWith(".css") && !configuredStyleSources.has(file)).sort()
|
|
63
85
|
if (!allSourceFiles.length) throw new Error("No TypeScript files found in src/")
|
|
64
86
|
const allSourceFileSet = new Set(allSourceFiles)
|
|
65
|
-
const sourceIndex =
|
|
87
|
+
const sourceIndex = project.sourceIndex
|
|
88
|
+
for (const [file, source] of await Promise.all(allSourceFiles.map(async file => [file, await readFile(file, "utf8")]))) sourceIndex.set(file, source)
|
|
66
89
|
const pageFiles = allSourceFiles.filter(file => file.startsWith(`${pagesDirectory}${sep}`) && file.endsWith(".tsx"))
|
|
67
90
|
if (!pageFiles.length) throw new Error("No pages found in src/pages/")
|
|
68
91
|
const sourceFiles = reachableSourceFiles(pageFiles, allSourceFileSet, sourceIndex)
|
|
69
|
-
const sourceFileSet =
|
|
92
|
+
const sourceFileSet = project.sourceFiles
|
|
93
|
+
for (const file of sourceFiles) sourceFileSet.add(file)
|
|
70
94
|
const staticFiles = await safeStaticFiles(projectFiles)
|
|
71
95
|
const cssFiles = orderSourceStyles(discoveredCssFiles, sourceFiles, sourceIndex, staticFiles)
|
|
72
96
|
const importedAssets = new Set()
|
|
73
|
-
const { cssModules, cssOutputs } = await prepareSourceStyles(cssFiles, staticFiles, importedAssets, base)
|
|
97
|
+
const { cssModules, cssOutputs } = await prepareSourceStyles(cssFiles, staticFiles, importedAssets, base, project)
|
|
74
98
|
|
|
75
99
|
const sourceResults = []
|
|
76
100
|
for (const file of sourceFiles) {
|
|
@@ -113,7 +137,7 @@ export async function build({ quiet = false, minify = true } = {}) {
|
|
|
113
137
|
if (typeof module.default !== "function") throw new Error(`${relative(root, pageFile)} must export a default component`)
|
|
114
138
|
if (Object.hasOwn(module, "layout") && typeof module.layout !== "function") throw layoutExportError(pageFile, sourceIndex.get(pageFile))
|
|
115
139
|
|
|
116
|
-
const runtimeSchema = runtimeRouteSchema(module, pageFile)
|
|
140
|
+
const runtimeSchema = runtimeRouteSchema(module, pageFile, project)
|
|
117
141
|
if (runtimeSchema) {
|
|
118
142
|
const conflicting = rewrites.find(rewrite => sameRuntimePrecedence(rewrite, runtimeSchema))
|
|
119
143
|
if (conflicting) throw new Error(`Ambiguous runtime routes: ${conflicting.route} and ${runtimeSchema.route}`)
|
|
@@ -125,9 +149,9 @@ export async function build({ quiet = false, minify = true } = {}) {
|
|
|
125
149
|
segments: runtimeSchema.segments
|
|
126
150
|
})
|
|
127
151
|
}
|
|
128
|
-
const entries = runtimeSchema ? [{ params: {}, props: {} }] : await staticPathEntries(module, pageFile)
|
|
152
|
+
const entries = runtimeSchema ? [{ params: {}, props: {} }] : await staticPathEntries(module, pageFile, root)
|
|
129
153
|
for (const { params, props } of entries) {
|
|
130
|
-
const route = runtimeSchema?.route ?? routeFromPage(pageFile, params)
|
|
154
|
+
const route = runtimeSchema?.route ?? routeFromPage(pageFile, params, pagesDirectory)
|
|
131
155
|
const applicationRoute = `/${route}`
|
|
132
156
|
const routePath = withBase(base, `/${route}`)
|
|
133
157
|
const metadataContext = { route: routePath, params, props }
|
|
@@ -207,7 +231,7 @@ export async function build({ quiet = false, minify = true } = {}) {
|
|
|
207
231
|
const renderedEffects = new Set(plans.flatMap(plan => plan.effects.map(effect => `${effect.module}:${effect.handler}`)))
|
|
208
232
|
const renderedWorkerReferences = workerReferences.filter(reference => renderedEffects.has(`${reference.module}:${reference.handler}`))
|
|
209
233
|
if (renderedWorkerReferences.length && await exists(join(publicDirectory, "assets", "workers"))) throw new Error("public/assets/workers collides with Kudzu's generated Worker asset namespace")
|
|
210
|
-
const workerAssets = await
|
|
234
|
+
const workerAssets = await project.workerCompiler.emit(renderedWorkerReferences, sourceFileSet, assetsDirectory, base, minify)
|
|
211
235
|
for (const module of emittedHandlerModules) {
|
|
212
236
|
for (const reference of workerReferences) {
|
|
213
237
|
if (reference.module !== assetPath(base, `assets/${module.path}`)) continue
|
|
@@ -227,11 +251,13 @@ export async function build({ quiet = false, minify = true } = {}) {
|
|
|
227
251
|
runtime: { shared: hasSharedRuntime, dependency: hasDependencyRuntime }
|
|
228
252
|
} = capabilityIR
|
|
229
253
|
const runtimeName = usesDependencyRuntime => usesDependencyRuntime ? "kudzu-deps.js" : "kudzu.js"
|
|
230
|
-
for (
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
254
|
+
for (let offset = 0; offset < pageEntries.length; offset += 64) {
|
|
255
|
+
await Promise.all(pageEntries.slice(offset, offset + 64).map(async entry => {
|
|
256
|
+
const routeDirectory = join(outputDirectory, entry.route)
|
|
257
|
+
await mkdir(routeDirectory, { recursive: true })
|
|
258
|
+
const html = preloadModules(entry.html.replace(runtimePlaceholder, escapeAttribute(assetPath(base, `assets/${runtimeName(entry.usesDependencyRuntime)}`))))
|
|
259
|
+
await writeFile(join(routeDirectory, "index.html"), html)
|
|
260
|
+
}))
|
|
235
261
|
}
|
|
236
262
|
if (navigationRoutes.length || behaviorCount && (hasSharedRuntime || regularBehaviorCount)) {
|
|
237
263
|
const runtimeFile = hasSharedRuntime ? "./shared-runtime.js" : "./runtime.js"
|
|
@@ -316,10 +342,6 @@ export async function build({ quiet = false, minify = true } = {}) {
|
|
|
316
342
|
}
|
|
317
343
|
const sortedRewrites = rewrites.sort((left, right) => runtimeSpecificity(right) - runtimeSpecificity(left) || left.pattern.localeCompare(right.pattern))
|
|
318
344
|
await writeFile(join(workDirectory, "kudzu-plan.json"), JSON.stringify({ routes: plans, rewrites: sortedRewrites }, null, 2))
|
|
319
|
-
for (const file of new Set([...cssFiles, ...importedAssets])) {
|
|
320
|
-
const collision = join(publicDirectory, "assets", relative(sourceDirectory, file))
|
|
321
|
-
if (await exists(collision)) throw new Error(`${relative(root, collision)} collides with emitted source asset ${relative(root, file)}`)
|
|
322
|
-
}
|
|
323
345
|
for (const file of cssFiles) {
|
|
324
346
|
const output = join(assetsDirectory, relative(sourceDirectory, file))
|
|
325
347
|
await mkdir(dirname(output), { recursive: true })
|
|
@@ -339,17 +361,95 @@ export async function build({ quiet = false, minify = true } = {}) {
|
|
|
339
361
|
if (typeof css !== "string") throw new Error(`${style.label}.transform must return CSS text or an object with a css string`)
|
|
340
362
|
}
|
|
341
363
|
const output = join(outputDirectory, style.output.slice(1))
|
|
364
|
+
if (await exists(output)) throw new Error(`${style.label}.output ${JSON.stringify(style.output)} collides with a generated artifact`)
|
|
342
365
|
await mkdir(dirname(output), { recursive: true })
|
|
343
366
|
await writeFile(output, css)
|
|
344
367
|
}
|
|
345
|
-
if (await exists(publicDirectory))
|
|
368
|
+
if (await exists(publicDirectory)) {
|
|
369
|
+
await copyPublic(publicDirectory, outputDirectory, outputDirectory, root)
|
|
370
|
+
}
|
|
346
371
|
if (config.afterBuild !== undefined) {
|
|
347
372
|
if (typeof config.afterBuild !== "function") throw new Error("kudzu.config afterBuild must be a function")
|
|
348
373
|
await config.afterBuild({ root, outDir: outputDirectory, sourceDir: sourceDirectory, base, routes: plans.map(plan => plan.route), plans, rewrites: sortedRewrites })
|
|
349
374
|
}
|
|
350
375
|
|
|
351
|
-
|
|
352
|
-
|
|
376
|
+
return { result: { sourceResults }, pageCount: plans.length, behaviorCount }
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async function acquireBuildLock(lockPath, root = dirname(lockPath)) {
|
|
380
|
+
let lock
|
|
381
|
+
try {
|
|
382
|
+
lock = await open(lockPath, "wx")
|
|
383
|
+
} catch (error) {
|
|
384
|
+
if (error?.code !== "EEXIST") throw error
|
|
385
|
+
}
|
|
386
|
+
if (lock) {
|
|
387
|
+
try {
|
|
388
|
+
await lock.writeFile(String(process.pid))
|
|
389
|
+
return lock
|
|
390
|
+
} catch (error) {
|
|
391
|
+
await lock.close()
|
|
392
|
+
await rm(lockPath, { force: true })
|
|
393
|
+
throw error
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
const owner = Number(await readFile(lockPath, "utf8").catch(() => ""))
|
|
397
|
+
if (Number.isInteger(owner) && owner > 0) {
|
|
398
|
+
let active = true
|
|
399
|
+
try {
|
|
400
|
+
process.kill(owner, 0)
|
|
401
|
+
} catch (error) {
|
|
402
|
+
if (error?.code === "ESRCH") active = false
|
|
403
|
+
else throw error
|
|
404
|
+
}
|
|
405
|
+
if (active) throw new Error(`Another Kudzu build is already running for ${root} (PID ${owner})`)
|
|
406
|
+
throw new Error(`A stale Kudzu build lock for PID ${owner} exists at ${lockPath}; remove it before building`)
|
|
407
|
+
}
|
|
408
|
+
throw new Error(`An invalid Kudzu build lock exists at ${lockPath}; remove it before building`)
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async function recoverOutput(finalOutput, backupOutput) {
|
|
412
|
+
if (!await exists(backupOutput)) return
|
|
413
|
+
if (await exists(finalOutput)) await rm(backupOutput, { recursive: true, force: true })
|
|
414
|
+
else await rename(backupOutput, finalOutput)
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
async function promoteOutput(stagedOutput, finalOutput, backup) {
|
|
418
|
+
const previous = await exists(finalOutput)
|
|
419
|
+
if (previous) await rename(finalOutput, backup)
|
|
420
|
+
try {
|
|
421
|
+
await rename(stagedOutput, finalOutput)
|
|
422
|
+
} catch (error) {
|
|
423
|
+
if (previous) {
|
|
424
|
+
try {
|
|
425
|
+
await rename(backup, finalOutput)
|
|
426
|
+
} catch (rollbackError) {
|
|
427
|
+
throw new AggregateError([error, rollbackError], "Kudzu output promotion and rollback both failed")
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
throw error
|
|
431
|
+
}
|
|
432
|
+
if (previous) await rm(backup, { recursive: true, force: true }).catch(error => console.warn(`Built output was promoted, but ${backup} could not be removed and will be retried on the next build: ${error.message}`))
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
async function copyPublic(sourceDirectory, destinationDirectory, destinationRoot, root) {
|
|
436
|
+
for (const entry of await readdir(sourceDirectory, { withFileTypes: true })) {
|
|
437
|
+
const source = join(sourceDirectory, entry.name)
|
|
438
|
+
const destination = join(destinationDirectory, entry.name)
|
|
439
|
+
let generated
|
|
440
|
+
try {
|
|
441
|
+
generated = await stat(destination)
|
|
442
|
+
} catch (error) {
|
|
443
|
+
if (error?.code !== "ENOENT") throw error
|
|
444
|
+
}
|
|
445
|
+
if (!generated) {
|
|
446
|
+
await cp(source, destination, { recursive: entry.isDirectory(), force: false, errorOnExist: true })
|
|
447
|
+
} else if (entry.isDirectory() && generated.isDirectory()) {
|
|
448
|
+
await copyPublic(source, destination, destinationRoot, root)
|
|
449
|
+
} else {
|
|
450
|
+
throw new Error(`${relative(root, source)} collides with generated output ${relative(destinationRoot, destination).replaceAll(sep, "/")}`)
|
|
451
|
+
}
|
|
452
|
+
}
|
|
353
453
|
}
|
|
354
454
|
|
|
355
455
|
function preloadModules(html) {
|
|
@@ -414,11 +514,13 @@ async function writeBundledJavaScript(file, source, minify, define) {
|
|
|
414
514
|
await writeFile(file, result.outputFiles[0].contents)
|
|
415
515
|
}
|
|
416
516
|
|
|
417
|
-
export async function dev({ port = parseDevPort(process.env.PORT), host = parseDevHost(process.env.HOST) } = {}) {
|
|
517
|
+
export async function dev({ port = parseDevPort(process.env.PORT), host = parseDevHost(process.env.HOST), root: projectRoot = process.cwd() } = {}) {
|
|
418
518
|
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error(`Invalid dev server port: ${port}`)
|
|
419
519
|
if (typeof host !== "string" || !host.trim()) throw new Error(`Invalid dev server host: ${host}`)
|
|
420
|
-
const
|
|
421
|
-
|
|
520
|
+
const project = createProjectSession(projectRoot)
|
|
521
|
+
const { root, sourceDirectory, workDirectory, outputDirectory } = project
|
|
522
|
+
const base = normalizeBase((await loadConfig(root)).base)
|
|
523
|
+
return startDevServer({ build: options => build({ ...options, root }), port, host, base, sourceDirectory, workDirectory, outputDirectory })
|
|
422
524
|
}
|
|
423
525
|
|
|
424
526
|
function inlineJson(value) {
|
|
@@ -433,11 +535,11 @@ function escapeAttribute(value) {
|
|
|
433
535
|
return escapeHtml(value).replaceAll('"', """).replaceAll("'", "'")
|
|
434
536
|
}
|
|
435
537
|
|
|
436
|
-
async function prepareSourceStyles(cssFiles, staticFiles, importedAssets, base) {
|
|
538
|
+
async function prepareSourceStyles(cssFiles, staticFiles, importedAssets, base, { root, sourceDirectory }) {
|
|
437
539
|
const cssModules = new Map()
|
|
438
540
|
const cssOutputs = new Map()
|
|
439
541
|
for (const file of cssFiles) {
|
|
440
|
-
let css = rewriteCssUrls(await readFile(file, "utf8"), file, staticFiles, importedAssets, base)
|
|
542
|
+
let css = rewriteCssUrls(await readFile(file, "utf8"), file, staticFiles, importedAssets, base, root, sourceDirectory)
|
|
441
543
|
if (file.toLowerCase().endsWith(".module.css")) {
|
|
442
544
|
if (/\bcomposes\s*:/i.test(maskCssCommentsAndStrings(css))) throw new Error(`${relative(root, file)} CSS Modules composes is not supported`)
|
|
443
545
|
const prefix = `k${createHash("sha256").update(relative(sourceDirectory, file).replaceAll(sep, "/")).digest("hex").slice(0, 8)}`
|
|
@@ -451,7 +553,7 @@ async function prepareSourceStyles(cssFiles, staticFiles, importedAssets, base)
|
|
|
451
553
|
return { cssModules, cssOutputs }
|
|
452
554
|
}
|
|
453
555
|
|
|
454
|
-
function rewriteCssUrls(css, file, staticFiles, importedAssets, base) {
|
|
556
|
+
function rewriteCssUrls(css, file, staticFiles, importedAssets, base, root, sourceDirectory) {
|
|
455
557
|
let output = ""
|
|
456
558
|
let cursor = 0
|
|
457
559
|
let index = 0
|
|
@@ -496,7 +598,7 @@ function rewriteCssUrls(css, file, staticFiles, importedAssets, base) {
|
|
|
496
598
|
continue
|
|
497
599
|
}
|
|
498
600
|
const value = css.slice(valueStart, end).trim()
|
|
499
|
-
const replacement = rewriteCssUrl(value, quote, file, staticFiles, importedAssets, base)
|
|
601
|
+
const replacement = rewriteCssUrl(value, quote, file, staticFiles, importedAssets, base, root, sourceDirectory)
|
|
500
602
|
output += css.slice(cursor, index) + (replacement ?? css.slice(index, close + 1))
|
|
501
603
|
cursor = close + 1
|
|
502
604
|
index = close + 1
|
|
@@ -504,7 +606,7 @@ function rewriteCssUrls(css, file, staticFiles, importedAssets, base) {
|
|
|
504
606
|
return output + css.slice(cursor)
|
|
505
607
|
}
|
|
506
608
|
|
|
507
|
-
function rewriteCssUrl(value, quote, file, staticFiles, importedAssets, base) {
|
|
609
|
+
function rewriteCssUrl(value, quote, file, staticFiles, importedAssets, base, root, sourceDirectory) {
|
|
508
610
|
if (!value || value.startsWith("/") || value.startsWith("#") || value.startsWith("//") || /^[a-z][a-z\d+.-]*:/i.test(value)) return undefined
|
|
509
611
|
const split = value.search(/[?#]/)
|
|
510
612
|
const pathname = split === -1 ? value : value.slice(0, split)
|
|
@@ -546,11 +648,12 @@ function maskCssCommentsAndStrings(css) {
|
|
|
546
648
|
return masked.join("")
|
|
547
649
|
}
|
|
548
650
|
|
|
549
|
-
function normalizeStyles(value, base) {
|
|
651
|
+
function normalizeStyles(value, base, { root }) {
|
|
550
652
|
if (value === undefined) return { urls: [], sources: [] }
|
|
551
653
|
if (!Array.isArray(value)) throw new Error("kudzu.config styles must be an array")
|
|
552
654
|
const urls = []
|
|
553
655
|
const sources = []
|
|
656
|
+
const outputs = new Set()
|
|
554
657
|
for (let index = 0; index < value.length; index++) {
|
|
555
658
|
const style = value[index]
|
|
556
659
|
const label = `kudzu.config styles[${index}]`
|
|
@@ -570,6 +673,8 @@ function normalizeStyles(value, base) {
|
|
|
570
673
|
if (typeof style.source !== "string" || !style.source) throw new Error(`${label}.source must be a non-empty file path`)
|
|
571
674
|
if (typeof style.output !== "string" || !style.output.startsWith("/") || style.output.startsWith("//") || /[%?#\\\0]/.test(style.output) || style.output.split("/").includes("..") || !style.output.endsWith(".css")) throw new Error(`${label}.output must be a root-relative .css path without query, hash, or traversal`)
|
|
572
675
|
if (style.transform !== undefined && typeof style.transform !== "function") throw new Error(`${label}.transform must be a function`)
|
|
676
|
+
if (outputs.has(style.output)) throw new Error(`${label}.output duplicates another configured style output ${JSON.stringify(style.output)}`)
|
|
677
|
+
outputs.add(style.output)
|
|
573
678
|
const entry = { label, source: resolve(root, style.source), output: style.output, transform: style.transform }
|
|
574
679
|
sources.push(entry)
|
|
575
680
|
urls.push(withBase(base, style.output))
|
|
@@ -577,7 +682,7 @@ function normalizeStyles(value, base) {
|
|
|
577
682
|
return { urls, sources }
|
|
578
683
|
}
|
|
579
684
|
|
|
580
|
-
function normalizePublicDirectory(value) {
|
|
685
|
+
function normalizePublicDirectory(value, { root, outputDirectory, workDirectory }) {
|
|
581
686
|
if (value === undefined) return join(root, "public")
|
|
582
687
|
if (typeof value !== "string" || !value) throw new Error("kudzu.config publicDir must be a non-empty directory path")
|
|
583
688
|
const directory = resolve(root, value)
|
|
@@ -668,7 +773,7 @@ function normalizeBase(value) {
|
|
|
668
773
|
const printEffectEntry = createEffectCodegen({ assetPath, inlineJson, relativeModulePath })
|
|
669
774
|
const printParamEntry = createParamCodegen({ browserPath, inlineJson, relativeModulePath })
|
|
670
775
|
|
|
671
|
-
async function staticPathEntries(module, file) {
|
|
776
|
+
async function staticPathEntries(module, file, root) {
|
|
672
777
|
if (typeof module.getStaticPaths !== "function") return [{ params: {}, props: {} }]
|
|
673
778
|
const entries = await module.getStaticPaths()
|
|
674
779
|
if (!Array.isArray(entries)) throw new Error(`${relative(root, file)} getStaticPaths() must return an array`)
|
|
@@ -682,11 +787,11 @@ async function staticPathEntries(module, file) {
|
|
|
682
787
|
})
|
|
683
788
|
}
|
|
684
789
|
|
|
685
|
-
function runtimeRouteSchema(module, file) {
|
|
790
|
+
function runtimeRouteSchema(module, file, { root, pagesDirectory }) {
|
|
686
791
|
if (!Object.hasOwn(module, "runtimeParams")) return undefined
|
|
687
792
|
if (module.runtimeParams !== true) throw new Error(`${relative(root, file)} runtimeParams must be exactly true`)
|
|
688
793
|
if (typeof module.getStaticPaths === "function") throw new Error(`${relative(root, file)} runtimeParams cannot be combined with getStaticPaths()`)
|
|
689
|
-
const route = pageRoutePattern(file)
|
|
794
|
+
const route = pageRoutePattern(file, pagesDirectory)
|
|
690
795
|
if (route.includes("[...")) throw new Error(`Catch-all routes are not supported: ${route}`)
|
|
691
796
|
const names = new Set()
|
|
692
797
|
const segments = route.split("/").map(segment => {
|
|
@@ -705,7 +810,7 @@ function runtimeRouteSchema(module, file) {
|
|
|
705
810
|
return { route, segments, params: [...names] }
|
|
706
811
|
}
|
|
707
812
|
|
|
708
|
-
function pageRoutePattern(file) {
|
|
813
|
+
function pageRoutePattern(file, pagesDirectory) {
|
|
709
814
|
const page = relative(pagesDirectory, file).replace(/\\/g, "/").replace(/\.tsx$/, "")
|
|
710
815
|
return page === "index" ? "" : page.replace(/\/index$/, "")
|
|
711
816
|
}
|
|
@@ -719,7 +824,7 @@ function sameRuntimePrecedence(left, right) {
|
|
|
719
824
|
return left.segments.every((segment, index) => segment.literal === undefined || right.segments[index].literal === undefined || segment.literal === right.segments[index].literal)
|
|
720
825
|
}
|
|
721
826
|
|
|
722
|
-
function routeFromPage(file, params = {}) {
|
|
827
|
+
function routeFromPage(file, params = {}, pagesDirectory) {
|
|
723
828
|
const page = relative(pagesDirectory, file).replace(/\\/g, "/").replace(/\.tsx$/, "")
|
|
724
829
|
if (page.includes("[...")) throw new Error(`Catch-all routes are not supported: ${page}`)
|
|
725
830
|
const filled = page.replace(/\[([^\]]+)\]/g, (_, name) => {
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { join, resolve } from "node:path"
|
|
2
|
+
import { assetPath } from "./path-helpers.mjs"
|
|
3
|
+
import { createSourceGraph } from "./source-graph.mjs"
|
|
4
|
+
import { createWorkerCompiler } from "./worker-compiler.mjs"
|
|
5
|
+
|
|
6
|
+
export function createProjectSession(projectRoot = process.cwd()) {
|
|
7
|
+
const root = resolve(projectRoot)
|
|
8
|
+
const sourceDirectory = join(root, "src")
|
|
9
|
+
const graph = createSourceGraph(root)
|
|
10
|
+
return {
|
|
11
|
+
root,
|
|
12
|
+
sourceDirectory,
|
|
13
|
+
pagesDirectory: join(sourceDirectory, "pages"),
|
|
14
|
+
workDirectory: join(root, ".kudzu"),
|
|
15
|
+
outputDirectory: join(root, "dist"),
|
|
16
|
+
sourceIndex: new Map(),
|
|
17
|
+
sourceFiles: new Set(),
|
|
18
|
+
graph,
|
|
19
|
+
workerCompiler: createWorkerCompiler({ root, sourceDirectory, assetPath, ...graph })
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -18,17 +18,15 @@ import { assetPath, relativeModulePath, withBase } from "./path-helpers.mjs"
|
|
|
18
18
|
import { createReactMigrationPass, reactMemoExpression } from "./react-migration-pass.mjs"
|
|
19
19
|
import { normalizeRenderControlFlow } from "./render-control-pass.mjs"
|
|
20
20
|
import { createRouterPass } from "./router-pass.mjs"
|
|
21
|
-
import {
|
|
22
|
-
import { createWorkerCompiler } from "./worker-compiler.mjs"
|
|
21
|
+
import { createProjectSession } from "./project-session.mjs"
|
|
23
22
|
import { createZustandPass } from "./zustand-pass.mjs"
|
|
24
23
|
|
|
25
|
-
|
|
26
|
-
const sourceDirectory
|
|
27
|
-
const
|
|
28
|
-
const workDirectory = join(root, ".kudzu")
|
|
24
|
+
export function createSourceCompiler(project) {
|
|
25
|
+
const { root, sourceDirectory, pagesDirectory, workDirectory, workerCompiler } = project
|
|
26
|
+
const { ordinaryRuntimeDependencies, parseSourceFile, resolveSourceImport, runtimeModuleReference } = project.graph
|
|
29
27
|
const staticAssetExtensions = new Set([".avif", ".gif", ".ico", ".jpeg", ".jpg", ".otf", ".png", ".svg", ".ttf", ".webp", ".woff", ".woff2"])
|
|
30
28
|
|
|
31
|
-
|
|
29
|
+
function compileSource(file, sourceFiles, sourceIndex, staticFiles, cssModules, base) {
|
|
32
30
|
const importedAssets = new Set()
|
|
33
31
|
const source = sourceIndex.get(file)
|
|
34
32
|
const semantic = createSemanticArtifact(relative(root, file).replaceAll(sep, "/"))
|
|
@@ -90,7 +88,7 @@ function emittedPackageReference(source, file, packages) {
|
|
|
90
88
|
return found
|
|
91
89
|
}
|
|
92
90
|
|
|
93
|
-
|
|
91
|
+
function reachableSourceFiles(entries, sourceFiles, sourceIndex) {
|
|
94
92
|
const reachable = new Set()
|
|
95
93
|
const ordinary = new Set()
|
|
96
94
|
const workers = new Set()
|
|
@@ -2767,7 +2765,7 @@ function localComponentDeclaration(sourceFile, name) {
|
|
|
2767
2765
|
return undefined
|
|
2768
2766
|
}
|
|
2769
2767
|
|
|
2770
|
-
|
|
2768
|
+
async function collectClientModules(entries, sourceFiles) {
|
|
2771
2769
|
const modules = new Set()
|
|
2772
2770
|
const queue = [...new Set(entries)]
|
|
2773
2771
|
while (queue.length) {
|
|
@@ -2795,7 +2793,7 @@ export async function collectClientModules(entries, sourceFiles) {
|
|
|
2795
2793
|
return [...modules].sort()
|
|
2796
2794
|
}
|
|
2797
2795
|
|
|
2798
|
-
|
|
2796
|
+
async function compileClientModule(file, sourceFiles, staticFiles, cssModules, base) {
|
|
2799
2797
|
const importedAssets = new Set()
|
|
2800
2798
|
const source = await readFile(file, "utf8")
|
|
2801
2799
|
const transformer = context => sourceFile => {
|
|
@@ -2840,7 +2838,7 @@ function resolveStaticImport(importer, specifier, staticFiles) {
|
|
|
2840
2838
|
return target
|
|
2841
2839
|
}
|
|
2842
2840
|
|
|
2843
|
-
|
|
2841
|
+
async function safeStaticFiles(files) {
|
|
2844
2842
|
const sourceRoot = await realpath(sourceDirectory)
|
|
2845
2843
|
const entries = await Promise.all(files.map(async file => {
|
|
2846
2844
|
try {
|
|
@@ -2855,7 +2853,7 @@ export async function safeStaticFiles(files) {
|
|
|
2855
2853
|
return new Set(entries.filter(Boolean))
|
|
2856
2854
|
}
|
|
2857
2855
|
|
|
2858
|
-
|
|
2856
|
+
function orderSourceStyles(cssFiles, sourceFiles, sourceIndex, staticFiles) {
|
|
2859
2857
|
const ordered = []
|
|
2860
2858
|
const seenStyles = new Set()
|
|
2861
2859
|
const seenSources = new Set()
|
|
@@ -2940,7 +2938,7 @@ function rejectUnsupportedClientImports(sourceFile, file) {
|
|
|
2940
2938
|
visit(sourceFile)
|
|
2941
2939
|
}
|
|
2942
2940
|
|
|
2943
|
-
|
|
2941
|
+
function layoutExportError(file, source) {
|
|
2944
2942
|
const sourceFile = parseSourceFile(file, source)
|
|
2945
2943
|
for (const statement of sourceFile.statements) {
|
|
2946
2944
|
if (ts.isExportDeclaration(statement) && statement.exportClause && ts.isNamedExports(statement.exportClause)) {
|
|
@@ -2956,20 +2954,32 @@ export function layoutExportError(file, source) {
|
|
|
2956
2954
|
return new Error(`${relative(root, file)} layout export must be a function`)
|
|
2957
2955
|
}
|
|
2958
2956
|
|
|
2959
|
-
|
|
2957
|
+
function clientModulePath(file) {
|
|
2960
2958
|
return `modules/${relative(sourceDirectory, file).replaceAll(sep, "/").replace(/\.(?:ts|tsx)$/, ".js")}`
|
|
2961
2959
|
}
|
|
2962
2960
|
|
|
2963
|
-
|
|
2961
|
+
function compiledPath(file) {
|
|
2964
2962
|
return join(workDirectory, relative(sourceDirectory, file)).replace(/\.(?:ts|tsx)$/, ".mjs")
|
|
2965
2963
|
}
|
|
2966
2964
|
|
|
2967
2965
|
const compileEventCommand = createCommandSpecializer({ isPrimitiveLiteral: isPrimitiveDefaultLiteral })
|
|
2968
2966
|
const { analyzeZustandStores, normalizeZustandMigrationSyntax } = createZustandPass({ isSerializableStateLiteral, nativeCaptureNames, sourceDirectory })
|
|
2969
|
-
const workerCompiler = createWorkerCompiler({ root, sourceDirectory, assetPath, parseSourceFile, resolveSourceImport, runtimeModuleReference })
|
|
2970
2967
|
const handlerLowering = createHandlerLowering({ cloneAst, synthesizeTree })
|
|
2971
2968
|
const printHandlerModule = createHandlerCodegen({
|
|
2972
2969
|
resolveClientImport: (entry, handlerPath) => entry.package ? entry.target : relativeModulePath(handlerPath, clientModulePath(entry.target))
|
|
2973
2970
|
})
|
|
2974
2971
|
const { normalizeReactMigrationSyntax, validateUseIdSyntax } = createReactMigrationPass({ cloneAst, jsxTagName })
|
|
2975
2972
|
const normalizeReactRouterSyntax = createRouterPass({ withBase })
|
|
2973
|
+
|
|
2974
|
+
return { collectClientModules, compileClientModule, compiledPath, compileSource, layoutExportError, orderSourceStyles, reachableSourceFiles, safeStaticFiles }
|
|
2975
|
+
}
|
|
2976
|
+
|
|
2977
|
+
const currentCompiler = () => createSourceCompiler(createProjectSession())
|
|
2978
|
+
export const collectClientModules = (...arguments_) => currentCompiler().collectClientModules(...arguments_)
|
|
2979
|
+
export const compileClientModule = (...arguments_) => currentCompiler().compileClientModule(...arguments_)
|
|
2980
|
+
export const compiledPath = (...arguments_) => currentCompiler().compiledPath(...arguments_)
|
|
2981
|
+
export const compileSource = (...arguments_) => currentCompiler().compileSource(...arguments_)
|
|
2982
|
+
export const layoutExportError = (...arguments_) => currentCompiler().layoutExportError(...arguments_)
|
|
2983
|
+
export const orderSourceStyles = (...arguments_) => currentCompiler().orderSourceStyles(...arguments_)
|
|
2984
|
+
export const reachableSourceFiles = (...arguments_) => currentCompiler().reachableSourceFiles(...arguments_)
|
|
2985
|
+
export const safeStaticFiles = (...arguments_) => currentCompiler().safeStaticFiles(...arguments_)
|
|
@@ -2,18 +2,46 @@ import { dirname, extname, join, relative, resolve } from "node:path"
|
|
|
2
2
|
import ts from "typescript"
|
|
3
3
|
import { sourceNodeError } from "./ast-helpers.mjs"
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
export function createSourceGraph(root) {
|
|
6
|
+
const resolveSourceImport = (importer, specifier, sourceFiles) => {
|
|
7
|
+
const base = resolve(dirname(importer), specifier)
|
|
8
|
+
const extension = extname(base)
|
|
9
|
+
const stem = /\.(?:js|jsx|ts|tsx)$/.test(extension) ? base.slice(0, -extension.length) : base
|
|
10
|
+
const candidates = extension === ".ts" || extension === ".tsx"
|
|
11
|
+
? [base]
|
|
12
|
+
: [`${stem}.ts`, `${stem}.tsx`, join(stem, "index.ts"), join(stem, "index.tsx")]
|
|
13
|
+
const matches = candidates.filter(candidate => sourceFiles.has(candidate))
|
|
14
|
+
if (matches.length !== 1) throw new Error(`${relative(root, importer)} Relative import ${JSON.stringify(specifier)} must resolve to one TypeScript file in src/`)
|
|
15
|
+
return matches[0]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const ordinaryRuntimeDependencies = (file, sourceFile, sourceFiles, isStaticImport) => {
|
|
19
|
+
const dependencies = []
|
|
20
|
+
const rejectDynamicImports = node => {
|
|
21
|
+
if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) {
|
|
22
|
+
const argument = node.arguments[0]
|
|
23
|
+
const specifier = node.arguments.length === 1 && ts.isStringLiteralLike(argument) ? JSON.stringify(argument.text) : argument?.getText(sourceFile) ?? "<missing>"
|
|
24
|
+
throw sourceNodeError(node, sourceFile, `Dynamic import ${specifier} is not supported in ordinary source modules`)
|
|
25
|
+
}
|
|
26
|
+
ts.forEachChild(node, rejectDynamicImports)
|
|
27
|
+
}
|
|
28
|
+
rejectDynamicImports(sourceFile)
|
|
29
|
+
for (const node of sourceFile.statements) {
|
|
30
|
+
if ((!ts.isImportDeclaration(node) && !ts.isExportDeclaration(node)) || !runtimeModuleReference(node) || !node.moduleSpecifier || !ts.isStringLiteral(node.moduleSpecifier)) continue
|
|
31
|
+
const specifier = node.moduleSpecifier
|
|
32
|
+
if (!specifier.text.startsWith(".") || isStaticImport(specifier.text)) continue
|
|
33
|
+
try {
|
|
34
|
+
dependencies.push(resolveSourceImport(file, specifier.text, sourceFiles))
|
|
35
|
+
} catch (error) {
|
|
36
|
+
const detail = error.message.slice(error.message.indexOf("Relative import"))
|
|
37
|
+
const edge = ts.isExportDeclaration(node) ? "re-export" : "import"
|
|
38
|
+
throw sourceNodeError(specifier, sourceFile, detail.replace("Relative import", `Relative runtime ${edge}`))
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return dependencies
|
|
42
|
+
}
|
|
6
43
|
|
|
7
|
-
|
|
8
|
-
const base = resolve(dirname(importer), specifier)
|
|
9
|
-
const extension = extname(base)
|
|
10
|
-
const stem = /\.(?:js|jsx|ts|tsx)$/.test(extension) ? base.slice(0, -extension.length) : base
|
|
11
|
-
const candidates = extension === ".ts" || extension === ".tsx"
|
|
12
|
-
? [base]
|
|
13
|
-
: [`${stem}.ts`, `${stem}.tsx`, join(stem, "index.ts"), join(stem, "index.tsx")]
|
|
14
|
-
const matches = candidates.filter(candidate => sourceFiles.has(candidate))
|
|
15
|
-
if (matches.length !== 1) throw new Error(`${relative(root, importer)} Relative import ${JSON.stringify(specifier)} must resolve to one TypeScript file in src/`)
|
|
16
|
-
return matches[0]
|
|
44
|
+
return { ordinaryRuntimeDependencies, parseSourceFile, resolveSourceImport, runtimeModuleReference }
|
|
17
45
|
}
|
|
18
46
|
|
|
19
47
|
export function runtimeModuleReference(node) {
|
|
@@ -25,32 +53,6 @@ export function runtimeModuleReference(node) {
|
|
|
25
53
|
return clause.namedBindings?.elements.some(entry => !entry.isTypeOnly) ?? false
|
|
26
54
|
}
|
|
27
55
|
|
|
28
|
-
export function ordinaryRuntimeDependencies(file, sourceFile, sourceFiles, isStaticImport) {
|
|
29
|
-
const dependencies = []
|
|
30
|
-
const rejectDynamicImports = node => {
|
|
31
|
-
if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) {
|
|
32
|
-
const argument = node.arguments[0]
|
|
33
|
-
const specifier = node.arguments.length === 1 && ts.isStringLiteralLike(argument) ? JSON.stringify(argument.text) : argument?.getText(sourceFile) ?? "<missing>"
|
|
34
|
-
throw sourceNodeError(node, sourceFile, `Dynamic import ${specifier} is not supported in ordinary source modules`)
|
|
35
|
-
}
|
|
36
|
-
ts.forEachChild(node, rejectDynamicImports)
|
|
37
|
-
}
|
|
38
|
-
rejectDynamicImports(sourceFile)
|
|
39
|
-
for (const node of sourceFile.statements) {
|
|
40
|
-
if ((!ts.isImportDeclaration(node) && !ts.isExportDeclaration(node)) || !runtimeModuleReference(node) || !node.moduleSpecifier || !ts.isStringLiteral(node.moduleSpecifier)) continue
|
|
41
|
-
const specifier = node.moduleSpecifier
|
|
42
|
-
if (!specifier.text.startsWith(".") || isStaticImport(specifier.text)) continue
|
|
43
|
-
try {
|
|
44
|
-
dependencies.push(resolveSourceImport(file, specifier.text, sourceFiles))
|
|
45
|
-
} catch (error) {
|
|
46
|
-
const detail = error.message.slice(error.message.indexOf("Relative import"))
|
|
47
|
-
const edge = ts.isExportDeclaration(node) ? "re-export" : "import"
|
|
48
|
-
throw sourceNodeError(specifier, sourceFile, detail.replace("Relative import", `Relative runtime ${edge}`))
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
return dependencies
|
|
52
|
-
}
|
|
53
|
-
|
|
54
56
|
export function parseSourceFile(file, source) {
|
|
55
57
|
return ts.createSourceFile(file, source, ts.ScriptTarget.ES2022, true, file.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS)
|
|
56
58
|
}
|
|
@@ -4,8 +4,6 @@ import { dirname, relative, resolve, sep } from "node:path"
|
|
|
4
4
|
import { build as bundle } from "esbuild"
|
|
5
5
|
import ts from "typescript"
|
|
6
6
|
import { containsJsx, isUnshadowedGlobal, nearestFunction, sourceNodeError } from "./ast-helpers.mjs"
|
|
7
|
-
import { assetPath } from "./path-helpers.mjs"
|
|
8
|
-
import { parseSourceFile, resolveSourceImport, runtimeModuleReference } from "./source-graph.mjs"
|
|
9
7
|
|
|
10
8
|
export function createWorkerCompiler({
|
|
11
9
|
root,
|
|
@@ -168,9 +166,3 @@ export function createWorkerCompiler({
|
|
|
168
166
|
|
|
169
167
|
return { candidate, emit, rejectConstructions, rejectOrdinaryImports, rewriteEffect }
|
|
170
168
|
}
|
|
171
|
-
|
|
172
|
-
export function emitWorkers(references, sourceFiles, assetsDirectory, base, minify) {
|
|
173
|
-
const root = process.cwd()
|
|
174
|
-
const sourceDirectory = resolve(root, "src")
|
|
175
|
-
return createWorkerCompiler({ root, sourceDirectory, assetPath, parseSourceFile, resolveSourceImport, runtimeModuleReference }).emit(references, sourceFiles, assetsDirectory, base, minify)
|
|
176
|
-
}
|
|
@@ -195,12 +195,13 @@ function updateList(list) {
|
|
|
195
195
|
const currentTokens = [...list.roots.keys()]
|
|
196
196
|
const nextTokens = entries.map(entry => entry.token)
|
|
197
197
|
if (nextTokens.length === currentTokens.length && nextTokens.every((token, index) => token === currentTokens[currentTokens.length - index - 1])) {
|
|
198
|
+
const nextRoots = nextTokens.map(token => [token, list.roots.get(token)])
|
|
198
199
|
const parent = list.container ?? list.start.parentNode
|
|
199
200
|
const reordered = parent.ownerDocument.createDocumentFragment()
|
|
200
|
-
reordered.append(...
|
|
201
|
+
reordered.append(...nextRoots.map(([, node]) => node))
|
|
201
202
|
parent.insertBefore(reordered, list.boundary)
|
|
202
|
-
list.roots = new Map(
|
|
203
|
-
if (__KUDZU_LIST_STABLE_FAST_PATHS__) list.orderedRoots =
|
|
203
|
+
list.roots = new Map(nextRoots)
|
|
204
|
+
if (__KUDZU_LIST_STABLE_FAST_PATHS__) list.orderedRoots = nextRoots.map(([, node]) => node)
|
|
204
205
|
list.container ??= parent
|
|
205
206
|
list.items = items
|
|
206
207
|
return
|
|
@@ -210,7 +211,6 @@ function updateList(list) {
|
|
|
210
211
|
const removedIndex = currentTokens.indexOf(removed)
|
|
211
212
|
if (removed && nextTokens.every((token, index) => token === currentTokens[index >= removedIndex ? index + 1 : index])) {
|
|
212
213
|
removeListRoot(list, removed)
|
|
213
|
-
list.roots = new Map(nextTokens.map(token => [token, list.roots.get(token)]))
|
|
214
214
|
if (__KUDZU_LIST_STABLE_FAST_PATHS__) list.orderedRoots = nextTokens.map(token => list.roots.get(token))
|
|
215
215
|
list.items = items
|
|
216
216
|
return
|
|
@@ -472,12 +472,12 @@ function updateNestedList(list, items) {
|
|
|
472
472
|
return true
|
|
473
473
|
}
|
|
474
474
|
if (items.length === previous.length && items.every((item, index) => item === previous[previous.length - index - 1])) {
|
|
475
|
-
const
|
|
475
|
+
const roots = [...list.roots].reverse()
|
|
476
476
|
const parent = list.container ?? list.start.parentNode
|
|
477
477
|
const reordered = parent.ownerDocument.createDocumentFragment()
|
|
478
|
-
reordered.append(...
|
|
478
|
+
reordered.append(...roots.map(([, node]) => node))
|
|
479
479
|
parent.insertBefore(reordered, list.boundary)
|
|
480
|
-
list.roots = new Map(
|
|
480
|
+
list.roots = new Map(roots)
|
|
481
481
|
list.items = items
|
|
482
482
|
list.container ??= parent
|
|
483
483
|
return true
|
|
@@ -487,10 +487,6 @@ function updateNestedList(list, items) {
|
|
|
487
487
|
while (removed < items.length && items[removed] === previous[removed]) removed++
|
|
488
488
|
if (items.every((item, index) => item === previous[index >= removed ? index + 1 : index])) {
|
|
489
489
|
removeListRoot(list, keyToken(previous[removed]?.[list.descriptor.key]))
|
|
490
|
-
list.roots = new Map(items.map(item => {
|
|
491
|
-
const token = keyToken(item[list.descriptor.key])
|
|
492
|
-
return [token, list.roots.get(token)]
|
|
493
|
-
}))
|
|
494
490
|
list.items = items
|
|
495
491
|
return true
|
|
496
492
|
}
|
|
@@ -540,12 +536,12 @@ function updateReducerList(list, items) {
|
|
|
540
536
|
return true
|
|
541
537
|
}
|
|
542
538
|
if (items.length === previous.length && items.every((item, index) => item === previous[previous.length - index - 1])) {
|
|
543
|
-
const
|
|
539
|
+
const roots = [...list.roots].reverse()
|
|
544
540
|
const parent = list.container ?? list.start.parentNode
|
|
545
541
|
const reordered = parent.ownerDocument.createDocumentFragment()
|
|
546
|
-
reordered.append(...
|
|
542
|
+
reordered.append(...roots.map(([, node]) => node))
|
|
547
543
|
parent.insertBefore(reordered, list.boundary)
|
|
548
|
-
list.roots = new Map(
|
|
544
|
+
list.roots = new Map(roots)
|
|
549
545
|
list.items = items
|
|
550
546
|
list.container ??= parent
|
|
551
547
|
return true
|
|
@@ -555,10 +551,6 @@ function updateReducerList(list, items) {
|
|
|
555
551
|
while (removed < items.length && items[removed] === previous[removed]) removed++
|
|
556
552
|
if (items.every((item, index) => item === previous[index >= removed ? index + 1 : index])) {
|
|
557
553
|
removeListRoot(list, keyToken(previous[removed]?.[list.descriptor.key]))
|
|
558
|
-
list.roots = new Map(items.map(item => {
|
|
559
|
-
const token = keyToken(item[list.descriptor.key])
|
|
560
|
-
return [token, list.roots.get(token)]
|
|
561
|
-
}))
|
|
562
554
|
list.items = items
|
|
563
555
|
return true
|
|
564
556
|
}
|