@kudzujs/core 0.8.53 → 0.8.56

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.
@@ -1,6 +1,6 @@
1
1
  import { createHash, randomUUID } from "node:crypto"
2
2
  import { cp, mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises"
3
- import { dirname, join, relative, resolve, sep } from "node:path"
3
+ import { dirname, isAbsolute, 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"
@@ -8,9 +8,11 @@ import { generateListRuntime } from "./compiler/list-runtime-codegen.mjs"
8
8
  import { assetPath, browserPath, relativeModulePath, withBase } from "./compiler/path-helpers.mjs"
9
9
  import { createProjectSession } from "./compiler/project-session.mjs"
10
10
  import { createRouteBuildRecord, planRouteArtifacts } from "./compiler/route-build-record.mjs"
11
+ import { createRouteArtifactReport } from "./compiler/route-artifact-report.mjs"
12
+ import { planRuntimeFamilies } from "./compiler/runtime-family-planner.mjs"
11
13
  import { createSourceCompiler } from "./compiler/source-compiler.mjs"
12
14
  import { createParamCodegen } from "./compiler/param-codegen.mjs"
13
- import { planRouteCapabilities, usesRouteDependencyRuntime } from "./compiler/route-capability-planner.mjs"
15
+ import { usesRouteDependencyRuntime } from "./compiler/route-capability-planner.mjs"
14
16
  import { generateBindingRuntime, generateCoreRuntime, generateEffectRuntime, generateNativeRuntime, generateNavigationRuntime, specializeRuntime } from "./compiler/runtime-codegen.mjs"
15
17
  import { renderPage } from "./core.mjs"
16
18
  import { parseDevHost, parseDevPort, startDevServer } from "./dev-server.mjs"
@@ -31,6 +33,10 @@ async function loadConfig(root) {
31
33
 
32
34
  export async function build({ quiet = false, minify = true, root: projectRoot = process.cwd() } = {}) {
33
35
  const project = createProjectSession(projectRoot)
36
+ return buildWithSession(project, { quiet, minify })
37
+ }
38
+
39
+ export async function buildWithSession(project, { changedFiles, quiet = false, minify = true } = {}) {
34
40
  const { root, outputDirectory } = project
35
41
  const stagedOutput = join(root, ".kudzu-dist-staging")
36
42
  const backupOutput = join(root, ".kudzu-dist-backup")
@@ -39,8 +45,9 @@ export async function build({ quiet = false, minify = true, root: projectRoot =
39
45
  try {
40
46
  await recoverOutput(outputDirectory, backupOutput)
41
47
  await rm(stagedOutput, { recursive: true, force: true })
42
- const { result, pageCount, behaviorCount } = await buildInto(project, stagedOutput, { minify })
48
+ const { result, pageCount, behaviorCount, cache } = await buildInto(project, stagedOutput, { changedFiles, minify })
43
49
  await promoteOutput(stagedOutput, outputDirectory, backupOutput)
50
+ project.buildCache = cache
44
51
  if (!quiet) console.log(`Built ${pageCount} page(s), ${behaviorCount} interactive page(s) into dist/`)
45
52
  return result
46
53
  } finally {
@@ -56,8 +63,11 @@ export async function build({ quiet = false, minify = true, root: projectRoot =
56
63
  }
57
64
  }
58
65
 
59
- async function buildInto(project, outputDirectory, { minify }) {
66
+ async function buildInto(project, outputDirectory, { changedFiles, minify }) {
60
67
  const { root, sourceDirectory, pagesDirectory, workDirectory } = project
68
+ const previous = project.buildCache
69
+ project.buildGeneration = (project.buildGeneration ?? 0) + 1
70
+ project.buildDirectory = project.buildGeneration > 1 ? join(workDirectory, "build", String(project.buildGeneration)) : workDirectory
61
71
  const { collectClientModules, compileClientModule, compiledPath, compileSource, layoutExportError, orderSourceStyles, reachableSourceFiles, safeStaticFiles } = createSourceCompiler(project)
62
72
  const config = await loadConfig(root)
63
73
  const base = normalizeBase(config.base)
@@ -66,7 +76,6 @@ async function buildInto(project, outputDirectory, { minify }) {
66
76
  const globalStyleUrlSet = new Set(globalStyleUrls)
67
77
  const publicDirectory = normalizePublicDirectory(config.publicDir, project)
68
78
  const navigationGroups = normalizeNavigation(config.navigation)
69
- const navigationRoutes = navigationGroups.flatMap(group => group.routes)
70
79
  const navigationByRoute = new Map(navigationGroups.flatMap(group => group.routes.map(route => [route, group])))
71
80
  for (const group of navigationGroups) {
72
81
  group.assetPath = assetPath(base, `assets/${group.assetName}`)
@@ -74,6 +83,7 @@ async function buildInto(project, outputDirectory, { minify }) {
74
83
  group.layoutId = `l-${group.id}`
75
84
  group.records = []
76
85
  group.routeRecords = []
86
+ group.buildRecords = []
77
87
  group.hasEffects = false
78
88
  group.hasParams = false
79
89
  }
@@ -87,11 +97,14 @@ async function buildInto(project, outputDirectory, { minify }) {
87
97
  if (!allSourceFiles.length) throw new Error("No TypeScript files found in src/")
88
98
  const allSourceFileSet = new Set(allSourceFiles)
89
99
  const sourceIndex = project.sourceIndex
100
+ for (const file of sourceIndex.keys()) if (file.startsWith(`${sourceDirectory}${sep}`) && !allSourceFileSet.has(file)) sourceIndex.delete(file)
90
101
  for (const [file, source] of await Promise.all(allSourceFiles.map(async file => [file, await readFile(file, "utf8")]))) sourceIndex.set(file, source)
91
102
  const pageFiles = allSourceFiles.filter(file => file.startsWith(`${pagesDirectory}${sep}`) && file.endsWith(".tsx"))
92
103
  if (!pageFiles.length) throw new Error("No pages found in src/pages/")
93
- const sourceFiles = reachableSourceFiles(pageFiles, allSourceFileSet, sourceIndex)
104
+ const pageSources = new Map(pageFiles.map(file => [file, new Set(reachableSourceFiles([file], allSourceFileSet, sourceIndex))]))
105
+ const sourceFiles = [...new Set([...pageSources.values()].flatMap(files => [...files]))].sort()
94
106
  const sourceFileSet = project.sourceFiles
107
+ sourceFileSet.clear()
95
108
  for (const file of sourceFiles) sourceFileSet.add(file)
96
109
  const staticFiles = await safeStaticFiles(projectFiles)
97
110
  const stylesByPage = new Map(pageFiles.map(file => [file, orderSourceStyles([file], sourceFiles, sourceIndex, staticFiles).filter(style => !configuredStyleSources.has(style))]))
@@ -99,15 +112,26 @@ async function buildInto(project, outputDirectory, { minify }) {
99
112
  const importedAssets = new Set()
100
113
  const { cssModules, cssOutputs } = await prepareSourceStyles(cssFiles, staticFiles, importedAssets, base, project)
101
114
 
115
+ const affectedPages = affectedPageFiles({ changedFiles, pageFiles, pageSources, previous, sourceDirectory })
116
+ expandAffectedNavigationGroups(affectedPages, previous?.pageRenders, navigationGroups)
117
+ const affectedSources = new Set([...affectedPages].flatMap(file => [...pageSources.get(file)]))
102
118
  const sourceResults = []
119
+ const sourceResultsByFile = new Map()
120
+ let compiledModules = 0
103
121
  for (const file of sourceFiles) {
104
122
  if (file.endsWith(".worker.ts")) continue
105
- const result = compileSource(file, sourceFileSet, sourceIndex, staticFiles, cssModules, base)
123
+ let result = !affectedSources.has(file) ? previous?.sourceResults.get(file) : undefined
124
+ if (!result) {
125
+ result = compileSource(file, sourceFileSet, sourceIndex, staticFiles, cssModules, base)
126
+ compiledModules++
127
+ }
128
+ result = { ...result, buildModule: { ...result.buildModule, path: relative(root, compiledPath(file)).replaceAll(sep, "/") } }
106
129
  for (const asset of result.importedAssets) importedAssets.add(resolve(root, asset))
107
130
  const output = resolve(root, result.buildModule.path)
108
131
  await mkdir(dirname(output), { recursive: true })
109
132
  await writeFile(output, result.buildModule.code)
110
133
  sourceResults.push(result)
134
+ sourceResultsByFile.set(file, result)
111
135
  }
112
136
  const handlerModules = sourceResults.flatMap(result => result.handlerModule ? [result.handlerModule] : [])
113
137
  const workerReferences = sourceResults.flatMap(result => result.moduleIR.effects.flatMap(effect => {
@@ -116,7 +140,8 @@ async function buildInto(project, outputDirectory, { minify }) {
116
140
  return effect.workers.map(worker => ({ ...worker, module: assetPath(base, `assets/${result.handlerModule.path}`), handler: handler.exportName }))
117
141
  }))
118
142
 
119
- const routeRecords = []
143
+ let routeRecords = []
144
+ const routeDrafts = []
120
145
  const routeEntryTransforms = new Map()
121
146
  const routeEntrySources = new Map()
122
147
  const routeEntryPaths = new Map()
@@ -124,9 +149,38 @@ async function buildInto(project, outputDirectory, { minify }) {
124
149
  const emittedRoutes = new Set()
125
150
  const emittedApplicationRoutes = new Set()
126
151
  const emittedNavigationRecords = []
127
- const runtimePlaceholder = `/__kudzu_runtime_${randomUUID()}.js`
152
+ const navigationAssets = new Map()
153
+ const placeholders = previous?.placeholders ?? {
154
+ runtime: `/__kudzu_runtime_${randomUUID()}.js`,
155
+ binding: `/__kudzu_binding_${randomUUID()}.js`,
156
+ list: `/__kudzu_list_${randomUUID()}.js`
157
+ }
158
+ const runtimePlaceholder = placeholders.runtime
159
+ const bindingPlaceholder = placeholders.binding
160
+ const listPlaceholder = placeholders.list
161
+ const pageRenders = new Map()
162
+ let renderedPages = 0
128
163
 
129
164
  for (const pageFile of pageFiles) {
165
+ const cached = !affectedPages.has(pageFile) ? previous?.pageRenders.get(pageFile) : undefined
166
+ if (cached) {
167
+ replayPageRender(cached, {
168
+ emittedApplicationRoutes,
169
+ emittedNavigationRecords,
170
+ emittedRoutes,
171
+ navigationAssets,
172
+ navigationByRoute,
173
+ routeDrafts,
174
+ routeRecords,
175
+ rewrites
176
+ })
177
+ pageRenders.set(pageFile, cached)
178
+ continue
179
+ }
180
+ renderedPages++
181
+ const draftOffset = routeDrafts.length
182
+ const navigationOffset = emittedNavigationRecords.length
183
+ const rewriteOffset = rewrites.length
130
184
  const sourceStyleUrls = stylesByPage.get(pageFile).map(file => assetPath(base, `assets/${relative(sourceDirectory, file).replaceAll(sep, "/")}`)).filter(url => !globalStyleUrlSet.has(url))
131
185
  const styleUrls = [...sourceStyleUrls, ...globalStyleUrls]
132
186
  const compiledFile = compiledPath(pageFile)
@@ -156,6 +210,7 @@ async function buildInto(project, outputDirectory, { minify }) {
156
210
  const pageMetadata = await resolveDocumentMetadata(module.metadata, metadataContext, `${relative(root, pageFile)} metadata`)
157
211
  const navigationGroup = navigationByRoute.get(applicationRoute)
158
212
  const navigable = Boolean(navigationGroup)
213
+ if (navigationGroup) navigationAssets.set(routePath, navigationGroup.assetPath)
159
214
  const effectPath = `effects/${route ? `${route}/index` : "index"}.js`
160
215
  const nativePath = `native/${route ? `${route}/index` : "index"}.js`
161
216
  const paramPath = `params/${route ? `${route}/index` : "index"}.js`
@@ -182,6 +237,8 @@ async function buildInto(project, outputDirectory, { minify }) {
182
237
  managedStyles: navigable ? sourceStyleUrls : [],
183
238
  base,
184
239
  runtimeAsset: runtimePlaceholder,
240
+ bindingAsset: bindingPlaceholder,
241
+ listAsset: listPlaceholder,
185
242
  effectAsset: assetPath(base, `assets/${effectPath}`),
186
243
  nativeAsset: assetPath(base, `assets/${nativePath}`),
187
244
  paramAsset: assetPath(base, `assets/${paramPath}`),
@@ -193,30 +250,16 @@ async function buildInto(project, outputDirectory, { minify }) {
193
250
  navigationGroup.hasParams ||= result.hasParams
194
251
  }
195
252
  const usesDependencyRuntime = usesRouteDependencyRuntime({ plan: result.plan, navigable, hasBindings: result.hasBindings, hasLists: result.hasLists })
196
- const routeRuntimeName = usesDependencyRuntime ? "kudzu-deps.js" : "kudzu.js"
197
253
  const plan = { route: routePath, ...result.plan }
198
- const entries = {}
199
- let html = inlineQueryFormCarry(result.html, plan)
200
- if (result.hasParams) {
201
- const entry = retainRouteEntry(paramPath, output => printParamEntry(runtimeSchema, plan.params, plan.searchParams, plan.searchParamsWritable, output, join(outputDirectory, "assets"), base, routeRuntimeName, navigable), routeEntrySources, routeEntryPaths, outputDirectory)
202
- entries.param = entry.path
203
- html = html.replaceAll(assetPath(base, `assets/${paramPath}`), assetPath(base, `assets/${entry.path}`))
204
- }
205
- if (result.hasEffects) {
206
- const entry = retainRouteEntry(effectPath, output => printEffectEntry(runtimeEffects(plan.effects, navigable), output, handlerModules, join(outputDirectory, "assets"), base, entries.param, routeRuntimeName, navigable), routeEntrySources, routeEntryPaths, outputDirectory)
207
- entries.effect = entry.path
208
- html = html.replaceAll(assetPath(base, `assets/${effectPath}`), assetPath(base, `assets/${entry.path}`))
209
- }
210
- if (plan.events.some(event => event.native)) {
211
- const modules = [...new Set(plan.events.filter(event => event.native).map(event => event.native.module))]
212
- const entry = retainRouteEntry(nativePath, () => printNativeEntrySource(modules, base), routeEntrySources, routeEntryPaths, outputDirectory)
213
- entries.native = entry.path
214
- html = html.replaceAll(assetPath(base, `assets/${nativePath}`), assetPath(base, `assets/${entry.path}`))
254
+ const entries = {
255
+ ...(result.hasParams ? { param: paramPath } : {}),
256
+ ...(result.hasEffects ? { effect: effectPath } : {}),
257
+ ...(plan.events.some(event => event.native) ? { native: nativePath } : {})
215
258
  }
216
- routeRecords.push(createRouteBuildRecord({
259
+ const record = createRouteBuildRecord({
217
260
  route: routePath,
218
261
  output: route,
219
- html,
262
+ html: inlineQueryFormCarry(result.html, plan),
220
263
  plan,
221
264
  handlerReferences: result.handlerReferences,
222
265
  styles: styleUrls,
@@ -233,8 +276,17 @@ async function buildInto(project, outputDirectory, { minify }) {
233
276
  },
234
277
  entries,
235
278
  runtimeSchema
236
- }))
279
+ })
280
+ routeRecords.push(record)
281
+ if (navigationGroup) navigationGroup.buildRecords.push(record)
282
+ routeDrafts.push({ record, result, runtimeSchema, navigationGroup, applicationRoute, effectPath, nativePath, paramPath })
237
283
  }
284
+ pageRenders.set(pageFile, {
285
+ drafts: routeDrafts.slice(draftOffset).map(({ navigationGroup: _, ...draft }) => draft),
286
+ layout: module.layout,
287
+ navigationRecords: emittedNavigationRecords.slice(navigationOffset).map(({ group: _, ...record }) => record),
288
+ rewrites: rewrites.slice(rewriteOffset)
289
+ })
238
290
  }
239
291
 
240
292
  for (const group of navigationGroups) for (const route of group.routes) if (!emittedApplicationRoutes.has(route)) throw new Error(`${group.label} route ${JSON.stringify(route)} is not an emitted route`)
@@ -245,12 +297,65 @@ async function buildInto(project, outputDirectory, { minify }) {
245
297
  group.records.sort((left, right) => (right.segments?.filter(segment => segment !== null).length ?? 0) - (left.segments?.filter(segment => segment !== null).length ?? 0) || left.id.localeCompare(right.id))
246
298
  }
247
299
 
300
+ const runtimePlan = planRuntimeFamilies(routeRecords, navigationGroups)
301
+ for (const group of navigationGroups) {
302
+ group.runtimeFamily = runtimePlan.familyByRecord.get(group.buildRecords[0])
303
+ group.assetPath = assetPath(base, `assets/runtime/${group.runtimeFamily.id}/${group.assetName}`)
304
+ }
305
+ const runtimeFamilyByRecord = new Map()
306
+ routeRecords = routeDrafts.map(draft => {
307
+ const { record, result, runtimeSchema, navigationGroup, effectPath, nativePath, paramPath } = draft
308
+ const family = runtimePlan.familyByRecord.get(record)
309
+ if (record.capabilities.hasBehaviors && !family) throw new Error(`Interactive route has no runtime family: ${record.route}`)
310
+ const runtimeDirectory = family ? join(outputDirectory, "assets", "runtime", family.id) : undefined
311
+ const routeRuntimeName = record.capabilities.usesDependencyRuntime ? "kudzu-deps.js" : "kudzu.js"
312
+ const entries = {}
313
+ let html = record.html
314
+ if (record.capabilities.hasParams) {
315
+ const entry = retainRouteEntry(paramPath, output => printParamEntry(runtimeSchema, record.plan.params, record.plan.searchParams, record.plan.searchParamsWritable, output, runtimeDirectory, base, routeRuntimeName, record.capabilities.navigable), routeEntrySources, routeEntryPaths, outputDirectory)
316
+ entries.param = entry.path
317
+ html = html.replaceAll(assetPath(base, `assets/${paramPath}`), assetPath(base, `assets/${entry.path}`))
318
+ }
319
+ if (record.capabilities.hasEffects) {
320
+ const entry = retainRouteEntry(effectPath, output => printEffectEntry(runtimeEffects(record.plan.effects, record.capabilities.navigable), output, handlerModules, join(outputDirectory, "assets"), base, entries.param, routeRuntimeName, record.capabilities.navigable, runtimeDirectory), routeEntrySources, routeEntryPaths, outputDirectory)
321
+ entries.effect = entry.path
322
+ html = html.replaceAll(assetPath(base, `assets/${effectPath}`), assetPath(base, `assets/${entry.path}`))
323
+ }
324
+ if (record.plan.events.some(event => event.native)) {
325
+ const modules = [...new Set(record.plan.events.filter(event => event.native).map(event => event.native.module))]
326
+ const nativeRuntime = assetPath(base, `assets/runtime/${family.id}/kudzu-native.js`)
327
+ const entry = retainRouteEntry(nativePath, () => printNativeEntrySource(modules, nativeRuntime), routeEntrySources, routeEntryPaths, outputDirectory)
328
+ entries.native = entry.path
329
+ html = html.replaceAll(assetPath(base, `assets/${nativePath}`), assetPath(base, `assets/${entry.path}`))
330
+ }
331
+ if (family) {
332
+ html = html.replaceAll(runtimePlaceholder, escapeAttribute(assetPath(base, `assets/runtime/${family.id}/${routeRuntimeName}`)))
333
+ html = html.replaceAll(bindingPlaceholder, escapeAttribute(assetPath(base, `assets/runtime/${family.id}/kudzu-binding.js`)))
334
+ html = html.replaceAll(listPlaceholder, escapeAttribute(assetPath(base, `assets/runtime/${family.id}/kudzu-list.js`)))
335
+ }
336
+ if (navigationGroup) html = html.replaceAll(escapeAttribute(navigationAssets.get(record.route)), escapeAttribute(navigationGroup.assetPath))
337
+ const finalRecord = createRouteBuildRecord({
338
+ route: record.route,
339
+ output: record.output,
340
+ html,
341
+ plan: record.plan,
342
+ handlerReferences: result.handlerReferences,
343
+ styles: record.artifacts.styles,
344
+ capabilities: record.capabilities,
345
+ entries,
346
+ runtimeSchema
347
+ })
348
+ if (family) runtimeFamilyByRecord.set(finalRecord, family)
349
+ if (navigationGroup) navigationAssets.set(finalRecord.route, navigationGroup.assetPath)
350
+ return finalRecord
351
+ })
352
+
248
353
  const assetsDirectory = join(outputDirectory, "assets")
249
354
  await mkdir(assetsDirectory, { recursive: true })
250
355
  const { handlerModules: emittedHandlerModules, workerReferences: renderedWorkerReferences, styles: renderedStyles } = planRouteArtifacts(routeRecords, handlerModules, workerReferences, module => assetPath(base, `assets/${module.path}`))
251
356
  const renderedStyleUrls = new Set(renderedStyles)
252
357
  if (renderedWorkerReferences.length && await exists(join(publicDirectory, "assets", "workers"))) throw new Error("public/assets/workers collides with Kudzu's generated Worker asset namespace")
253
- const workerAssets = await project.workerCompiler.emit(renderedWorkerReferences, sourceFileSet, assetsDirectory, base, minify)
358
+ const { assets: workerAssets, outputs: workerOutputs } = await project.workerCompiler.emit(renderedWorkerReferences, sourceFileSet, assetsDirectory, base, minify)
254
359
  for (const module of emittedHandlerModules) {
255
360
  for (const reference of workerReferences) {
256
361
  if (reference.module !== assetPath(base, `assets/${module.path}`)) continue
@@ -260,63 +365,55 @@ async function buildInto(project, outputDirectory, { minify }) {
260
365
  if (module.code.includes("/__kudzu_worker_")) throw new Error(`Worker URL placeholder survived in ${module.path}`)
261
366
  }
262
367
  const plans = routeRecords.map(record => record.plan)
263
- const capabilityIR = planRouteCapabilities(routeRecords, { navigationRouteCount: navigationRoutes.length })
264
- const {
265
- routes: { behaviors: behaviorCount, regularBehaviors: regularBehaviorCount, dependencyStateSeeds: dependencyStateSeedCount },
266
- events: { command: commandEvents, hasNativeHandlers },
267
- bindings: { count: bindingCount },
268
- lists,
269
- effects: { any: hasEffects, derivedDependencies: hasDerivedEffectDependencies, captures: hasEffectCaptures },
270
- captures: { nestedState: hasNestedStateCaptures, setter: hasSetterCaptures },
271
- runtime: { shared: hasSharedRuntime, dependency: hasDependencyRuntime }
272
- } = capabilityIR
273
- const runtimeName = usesDependencyRuntime => usesDependencyRuntime ? "kudzu-deps.js" : "kudzu.js"
368
+ const behaviorCount = routeRecords.filter(record => record.capabilities.hasBehaviors).length
274
369
  for (let offset = 0; offset < routeRecords.length; offset += 64) {
275
370
  await Promise.all(routeRecords.slice(offset, offset + 64).map(async record => {
276
371
  const routeDirectory = join(outputDirectory, record.output)
277
372
  await mkdir(routeDirectory, { recursive: true })
278
- const html = preloadModules(record.html.replace(runtimePlaceholder, escapeAttribute(assetPath(base, `assets/${runtimeName(record.capabilities.usesDependencyRuntime)}`))))
279
- await writeFile(join(routeDirectory, "index.html"), html)
373
+ if ([runtimePlaceholder, bindingPlaceholder, listPlaceholder].some(placeholder => record.html.includes(placeholder))) throw new Error(`Runtime family placeholder survived in ${record.route}`)
374
+ await writeFile(join(routeDirectory, "index.html"), preloadModules(record.html))
280
375
  }))
281
376
  }
282
- if (navigationRoutes.length || behaviorCount && (hasSharedRuntime || regularBehaviorCount)) {
283
- const runtimeFile = hasSharedRuntime ? "./shared-runtime.js" : "./runtime.js"
284
- const runtime = generateCoreRuntime(await readFile(new URL(runtimeFile, import.meta.url), "utf8"), capabilityIR)
285
- await writeJavaScript(join(assetsDirectory, "kudzu.js"), runtime, minify)
286
- }
287
- if (hasDependencyRuntime) {
288
- const runtime = specializeRuntime(await readFile(new URL("./dependency-runtime.js", import.meta.url), "utf8"), commandEvents, dependencyStateSeedCount > 0)
289
- await writeJavaScript(join(assetsDirectory, "kudzu-deps.js"), runtime, minify)
290
- }
291
- if (bindingCount || hasNativeHandlers || hasEffectCaptures) await writeJavaScript(join(assetsDirectory, "kudzu-serialization.js"), await readFile(new URL("./serialization.js", import.meta.url), "utf8"), minify, {
292
- "globalThis.__KUDZU_CAPTURE_STATE__": String(hasNestedStateCaptures),
293
- "globalThis.__KUDZU_CAPTURE_SETTER__": String(hasSetterCaptures)
294
- })
295
- if (hasEffects) {
296
- const generated = generateEffectRuntime(await readFile(new URL("./effect-runtime.js", import.meta.url), "utf8"), capabilityIR)
297
- await writeBundledJavaScript(join(assetsDirectory, "kudzu-effect.js"), generated.source, minify, generated.define)
298
- }
299
- if (bindingCount || lists.styleCount) await writeJavaScript(join(assetsDirectory, "kudzu-style.js"), await readFile(new URL("./style.js", import.meta.url), "utf8"), minify)
300
- if (bindingCount) {
301
- const generated = generateBindingRuntime(await readFile(new URL("./binding-runtime.js", import.meta.url), "utf8"), capabilityIR, navigationRoutes.length > 0)
302
- await writeBundledJavaScript(join(assetsDirectory, "kudzu-binding.js"), generated.source, minify, generated.define)
303
- }
304
- if (hasDerivedEffectDependencies) await writeJavaScript(join(assetsDirectory, "kudzu-collection-selector.js"), await readFile(new URL("./collection-selector.js", import.meta.url), "utf8"), minify)
305
- if (lists.count) {
306
- if (lists.selectors && !hasDerivedEffectDependencies) await writeJavaScript(join(assetsDirectory, "kudzu-collection-selector.js"), await readFile(new URL("./collection-selector.js", import.meta.url), "utf8"), minify)
307
- const generated = generateListRuntime(await readFile(new URL("./list-runtime.js", import.meta.url), "utf8"), capabilityIR)
308
- await writeBundledJavaScript(join(assetsDirectory, "kudzu-list.js"), generated.source, minify, generated.define)
309
- if (lists.selectors && !hasDerivedEffectDependencies) await rm(join(assetsDirectory, "kudzu-collection-selector.js"))
310
- }
311
- if (hasNativeHandlers) {
312
- const generated = generateNativeRuntime(await readFile(new URL("./native-runtime.js", import.meta.url), "utf8"), capabilityIR)
313
- await writeJavaScript(join(assetsDirectory, "kudzu-native.js"), generated.source, minify, generated.define)
314
- for (const [path, source] of routeEntrySources) if (path.startsWith("native/")) await writeRetainedRouteEntry(path, source, assetsDirectory, minify, routeEntryTransforms)
377
+ for (const family of runtimePlan.families) {
378
+ const capabilityIR = family.capability
379
+ const { bindings, captures, effects, events, lists, routes, runtime } = capabilityIR
380
+ const familyDirectory = join(assetsDirectory, "runtime", family.id)
381
+ await mkdir(familyDirectory, { recursive: true })
382
+ if (runtime.dependency) {
383
+ const source = await readFile(new URL("./dependency-runtime.js", import.meta.url), "utf8")
384
+ await writeJavaScript(join(familyDirectory, "kudzu-deps.js"), specializeRuntime(source, events.command, routes.dependencyStateSeeds > 0), minify)
385
+ } else {
386
+ const source = await readFile(new URL(runtime.shared ? "./shared-runtime.js" : "./runtime.js", import.meta.url), "utf8")
387
+ await writeJavaScript(join(familyDirectory, "kudzu.js"), generateCoreRuntime(source, capabilityIR), minify)
388
+ }
389
+ if (bindings.count || events.hasNativeHandlers || effects.captures) await writeJavaScript(join(familyDirectory, "kudzu-serialization.js"), await readFile(new URL("./serialization.js", import.meta.url), "utf8"), minify, {
390
+ "globalThis.__KUDZU_CAPTURE_STATE__": String(captures.nestedState),
391
+ "globalThis.__KUDZU_CAPTURE_SETTER__": String(captures.setter)
392
+ })
393
+ if (effects.any) {
394
+ const generated = generateEffectRuntime(await readFile(new URL("./effect-runtime.js", import.meta.url), "utf8"), capabilityIR)
395
+ await writeBundledJavaScript(join(familyDirectory, "kudzu-effect.js"), generated.source, minify, generated.define)
396
+ }
397
+ if (bindings.count || lists.styleCount) await writeJavaScript(join(familyDirectory, "kudzu-style.js"), await readFile(new URL("./style.js", import.meta.url), "utf8"), minify)
398
+ if (bindings.count) {
399
+ const generated = generateBindingRuntime(await readFile(new URL("./binding-runtime.js", import.meta.url), "utf8"), capabilityIR, family.navigation)
400
+ await writeBundledJavaScript(join(familyDirectory, "kudzu-binding.js"), generated.source, minify, generated.define)
401
+ }
402
+ if (effects.derivedDependencies || lists.selectors) await writeJavaScript(join(familyDirectory, "kudzu-collection-selector.js"), await readFile(new URL("./collection-selector.js", import.meta.url), "utf8"), minify)
403
+ if (lists.count) {
404
+ const generated = generateListRuntime(await readFile(new URL("./list-runtime.js", import.meta.url), "utf8"), capabilityIR)
405
+ await writeBundledJavaScript(join(familyDirectory, "kudzu-list.js"), generated.source, minify, generated.define)
406
+ }
407
+ if (events.hasNativeHandlers) {
408
+ const generated = generateNativeRuntime(await readFile(new URL("./native-runtime.js", import.meta.url), "utf8"), capabilityIR)
409
+ await writeJavaScript(join(familyDirectory, "kudzu-native.js"), generated.source, minify, generated.define)
410
+ }
315
411
  }
412
+ for (const [path, source] of routeEntrySources) if (path.startsWith("native/")) await writeRetainedRouteEntry(path, source, assetsDirectory, minify, routeEntryTransforms)
316
413
  if (navigationGroups.length) {
317
414
  const navigationSource = await readFile(new URL("./navigation-runtime.js", import.meta.url), "utf8")
318
415
  for (const group of navigationGroups) {
319
- await writeJavaScript(join(assetsDirectory, group.assetName), generateNavigationRuntime(navigationSource, group), minify)
416
+ await writeJavaScript(join(assetsDirectory, "runtime", group.runtimeFamily.id, group.assetName), generateNavigationRuntime(navigationSource, group), minify)
320
417
  }
321
418
  }
322
419
  for (const handlerModule of emittedHandlerModules) {
@@ -333,8 +430,9 @@ async function buildInto(project, outputDirectory, { minify }) {
333
430
  await mkdir(dirname(output), { recursive: true })
334
431
  await writeJavaScript(output, module.code, minify)
335
432
  }
433
+ let handlerMetafile
336
434
  if (clientModules.length || emittedHandlerModules.some(module => module.hasPackageImports)) {
337
- await bundle({
435
+ const result = await bundle({
338
436
  entryPoints: emittedHandlerModules.map(module => join(assetsDirectory, module.path)),
339
437
  outbase: join(assetsDirectory, "handlers"),
340
438
  outdir: join(assetsDirectory, "handlers"),
@@ -347,12 +445,16 @@ async function buildInto(project, outputDirectory, { minify }) {
347
445
  target: "es2022",
348
446
  minify,
349
447
  legalComments: "none",
448
+ metafile: true,
350
449
  logLevel: "silent"
351
450
  })
451
+ handlerMetafile = result.metafile
352
452
  await rm(join(assetsDirectory, "modules"), { recursive: true, force: true })
353
453
  }
354
454
  const sortedRewrites = rewrites.sort((left, right) => runtimeSpecificity(right) - runtimeSpecificity(left) || left.pattern.localeCompare(right.pattern))
355
455
  await writeFile(join(workDirectory, "kudzu-plan.json"), JSON.stringify({ routes: plans, rewrites: sortedRewrites }, null, 2))
456
+ const artifacts = createRouteArtifactReport(routeRecords, { base, handlerMetafile, outputDirectory, navigationAssets, runtimeFamilies: runtimePlan.families, runtimeFamilyByRecord, workerReferences: renderedWorkerReferences, workerOutputs })
457
+ await writeFile(join(workDirectory, "kudzu-artifacts.json"), JSON.stringify(artifacts, null, 2))
356
458
  const emittedCssFiles = new Set()
357
459
  for (const file of cssFiles.filter(file => renderedStyleUrls.has(assetPath(base, `assets/${relative(sourceDirectory, file).replaceAll(sep, "/")}`)))) {
358
460
  const output = join(assetsDirectory, relative(sourceDirectory, file))
@@ -384,10 +486,81 @@ async function buildInto(project, outputDirectory, { minify }) {
384
486
  }
385
487
  if (config.afterBuild !== undefined) {
386
488
  if (typeof config.afterBuild !== "function") throw new Error("kudzu.config afterBuild must be a function")
387
- await config.afterBuild({ root, outDir: outputDirectory, sourceDir: sourceDirectory, base, routes: plans.map(plan => plan.route), plans, rewrites: sortedRewrites })
489
+ await config.afterBuild({ root, outDir: outputDirectory, sourceDir: sourceDirectory, base, routes: plans.map(plan => plan.route), plans, rewrites: sortedRewrites, artifacts })
490
+ }
491
+
492
+ const incremental = { compiledModules, renderedPages }
493
+ return {
494
+ result: { sourceResults, incremental },
495
+ pageCount: plans.length,
496
+ behaviorCount,
497
+ cache: { pageRenders, pageSources, placeholders, sourceResults: sourceResultsByFile }
388
498
  }
499
+ }
389
500
 
390
- return { result: { sourceResults }, pageCount: plans.length, behaviorCount }
501
+ function affectedPageFiles({ changedFiles, pageFiles, pageSources, previous, sourceDirectory }) {
502
+ if (!previous || changedFiles === undefined) return new Set(pageFiles)
503
+ if (changedFiles.some(file => typeof file !== "string")) return new Set(pageFiles)
504
+ const changes = new Set(changedFiles.map(file => isAbsolute(file) ? file : resolve(sourceDirectory, file)))
505
+ if ([...changes].some(file => !/\.(?:ts|tsx)$/.test(file))) return new Set(pageFiles)
506
+ const affected = new Set()
507
+ for (const page of pageFiles) {
508
+ const current = pageSources.get(page)
509
+ const prior = previous.pageSources.get(page)
510
+ if (!prior || [...changes].some(file => current.has(file) || prior.has(file))) affected.add(page)
511
+ }
512
+ return affected
513
+ }
514
+
515
+ function expandAffectedNavigationGroups(affected, pageRenders, groups) {
516
+ if (!affected.size || !pageRenders || !groups.length) return
517
+ const groupRoutes = groups.map(group => new Set(group.routes))
518
+ const groupIndexes = new Set()
519
+ for (const page of affected) {
520
+ const render = pageRenders.get(page)
521
+ if (!render) {
522
+ for (let index = 0; index < groups.length; index++) groupIndexes.add(index)
523
+ continue
524
+ }
525
+ for (const draft of render.drafts) for (let index = 0; index < groups.length; index++) if (groupRoutes[index].has(draft.applicationRoute)) groupIndexes.add(index)
526
+ }
527
+ for (const [page, render] of pageRenders) {
528
+ if (render.drafts.some(draft => [...groupIndexes].some(index => groupRoutes[index].has(draft.applicationRoute)))) affected.add(page)
529
+ }
530
+ }
531
+
532
+ function replayPageRender(cached, state) {
533
+ for (const rewrite of cached.rewrites) {
534
+ const conflicting = state.rewrites.find(entry => sameRuntimePrecedence(entry, rewrite))
535
+ if (conflicting) throw new Error(`Ambiguous runtime routes: ${conflicting.route} and ${rewrite.route}`)
536
+ state.rewrites.push(rewrite)
537
+ }
538
+ for (const entry of cached.navigationRecords) {
539
+ const group = state.navigationByRoute.get(entry.route)
540
+ const routeRecord = { ...entry, group }
541
+ state.emittedNavigationRecords.push(routeRecord)
542
+ state.emittedApplicationRoutes.add(entry.route)
543
+ if (!group) continue
544
+ if (typeof cached.layout !== "function") throw new Error(`${group.label} emitted route ${JSON.stringify(entry.record.path ?? entry.record.id)} must export a layout function so Kudzu can emit route markers`)
545
+ if (group.layoutIdentity && group.layoutIdentity !== cached.layout) throw new Error(`${group.label} routes ${JSON.stringify(group.layoutRoute)} and ${JSON.stringify(entry.route)} must export the same layout function identity`)
546
+ group.layoutIdentity = cached.layout
547
+ group.layoutRoute ??= entry.route
548
+ group.records.push(entry.record)
549
+ group.routeRecords.push(routeRecord)
550
+ }
551
+ for (const draft of cached.drafts) {
552
+ const navigationGroup = state.navigationByRoute.get(draft.applicationRoute)
553
+ if (state.emittedRoutes.has(draft.record.route)) throw new Error(`Duplicate route: ${draft.record.route}`)
554
+ state.emittedRoutes.add(draft.record.route)
555
+ if (navigationGroup) {
556
+ state.navigationAssets.set(draft.record.route, navigationGroup.assetPath)
557
+ navigationGroup.buildRecords.push(draft.record)
558
+ navigationGroup.hasEffects ||= draft.result.hasEffects
559
+ navigationGroup.hasParams ||= draft.result.hasParams
560
+ }
561
+ state.routeRecords.push(draft.record)
562
+ state.routeDrafts.push({ ...draft, navigationGroup })
563
+ }
391
564
  }
392
565
 
393
566
  async function acquireBuildLock(lockPath, root = dirname(lockPath)) {
@@ -489,10 +662,9 @@ function inlineQueryFormCarry(html, plan) {
489
662
  return html.replace("</body>", `${script}</body>`)
490
663
  }
491
664
 
492
- function printNativeEntrySource(modules, base) {
665
+ function printNativeEntrySource(modules, runtime) {
493
666
  const imports = modules.map((module, index) => `import * as __kNativeModule${index} from ${JSON.stringify(module)}`).join("\n")
494
667
  const registrations = modules.map((module, index) => `[${JSON.stringify(module)}, __kNativeModule${index}]`).join(",")
495
- const runtime = assetPath(base, "assets/kudzu-native.js")
496
668
  return `import { registerNativeModules } from ${JSON.stringify(runtime)}\n${imports}\nregisterNativeModules([${registrations}])`
497
669
  }
498
670
 
@@ -545,7 +717,7 @@ async function writeBundledJavaScript(file, source, minify, define) {
545
717
  stdin: { contents: source, resolveDir: dirname(file), sourcefile: file },
546
718
  bundle: true,
547
719
  write: false,
548
- external: ["./kudzu.js", "./kudzu-binding.js", "./kudzu-serialization.js", "./kudzu-style.js"],
720
+ external: ["./kudzu.js", "./kudzu-binding.js", "./kudzu-collection-selector.js", "./kudzu-serialization.js", "./kudzu-style.js"],
549
721
  define,
550
722
  format: "esm",
551
723
  target: "es2022",
@@ -562,7 +734,7 @@ export async function dev({ port = parseDevPort(process.env.PORT), host = parseD
562
734
  const project = createProjectSession(projectRoot)
563
735
  const { root, sourceDirectory, workDirectory, outputDirectory } = project
564
736
  const base = normalizeBase((await loadConfig(root)).base)
565
- return startDevServer({ build: options => build({ ...options, root }), port, host, base, sourceDirectory, workDirectory, outputDirectory })
737
+ return startDevServer({ build: options => buildWithSession(project, options), port, host, base, sourceDirectory, workDirectory, outputDirectory })
566
738
  }
567
739
 
568
740
  function inlineJson(value) {
@@ -1,7 +1,7 @@
1
1
  import { join } from "node:path"
2
2
 
3
3
  export function createEffectCodegen({ assetPath, inlineJson, relativeModulePath }) {
4
- function printEffectEntry(effects, output, handlerModules, assetsDirectory, base, paramPath, runtimeName) {
4
+ function printEffectEntry(effects, output, handlerModules, assetsDirectory, runtimeDirectory, base, paramPath, runtimeName) {
5
5
  const hasCleanup = effects.some(effect => effect.cleanup)
6
6
  const hasDependencies = effects.some(effect => effect.dependencies?.length || effect.itemDependencies?.length)
7
7
  const hasOwners = effects.some(effect => effect.owner)
@@ -14,10 +14,10 @@ function printEffectEntry(effects, output, handlerModules, assetsDirectory, base
14
14
  })
15
15
  const imports = [
16
16
  hasCleanup || hasDependencies || hasOwners
17
- ? `import * as __kRuntime from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, runtimeName)))}\nconst { browserState, commitDom } = __kRuntime`
18
- : `import { browserState, commitDom } from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, runtimeName)))}`,
19
- `import { createEffectContext } from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, "kudzu-effect.js")))}`,
20
- ...(hasDependencyExpressions ? [`import { evaluateCollectionExpression as __kEvaluateDependency } from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, "kudzu-collection-selector.js")))}`] : []),
17
+ ? `import * as __kRuntime from ${JSON.stringify(relativeModulePath(output, join(runtimeDirectory, runtimeName)))}\nconst { browserState, commitDom } = __kRuntime`
18
+ : `import { browserState, commitDom } from ${JSON.stringify(relativeModulePath(output, join(runtimeDirectory, runtimeName)))}`,
19
+ `import { createEffectContext } from ${JSON.stringify(relativeModulePath(output, join(runtimeDirectory, "kudzu-effect.js")))}`,
20
+ ...(hasDependencyExpressions ? [`import { evaluateCollectionExpression as __kEvaluateDependency } from ${JSON.stringify(relativeModulePath(output, join(runtimeDirectory, "kudzu-collection-selector.js")))}`] : []),
21
21
  ...(paramPath ? [`import ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, paramPath)))}`] : []),
22
22
  ...modules.map((module, index) => `import * as __kEffectModule${index} from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, module.path)))}`)
23
23
  ]
@@ -178,7 +178,7 @@ addEventListener("pagehide", event => {
178
178
  })`
179
179
  }
180
180
 
181
- function printNavigableEffectEntry(effects, output, handlerModules, assetsDirectory, base) {
181
+ function printNavigableEffectEntry(effects, output, handlerModules, assetsDirectory, runtimeDirectory, base) {
182
182
  const hasDependencyExpressions = effects.some(effect => effect.dependencyExpressions?.length)
183
183
  const moduleUrls = [...new Set(effects.map(effect => effect.module))]
184
184
  const modules = moduleUrls.map(url => {
@@ -187,9 +187,9 @@ function printNavigableEffectEntry(effects, output, handlerModules, assetsDirect
187
187
  return module
188
188
  })
189
189
  const imports = [
190
- `import * as __kRuntime from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, "kudzu.js")))}`,
191
- `import { createEffectContext } from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, "kudzu-effect.js")))}`,
192
- ...(hasDependencyExpressions ? [`import { evaluateCollectionExpression as __kEvaluateDependency } from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, "kudzu-collection-selector.js")))}`] : []),
190
+ `import * as __kRuntime from ${JSON.stringify(relativeModulePath(output, join(runtimeDirectory, "kudzu.js")))}`,
191
+ `import { createEffectContext } from ${JSON.stringify(relativeModulePath(output, join(runtimeDirectory, "kudzu-effect.js")))}`,
192
+ ...(hasDependencyExpressions ? [`import { evaluateCollectionExpression as __kEvaluateDependency } from ${JSON.stringify(relativeModulePath(output, join(runtimeDirectory, "kudzu-collection-selector.js")))}`] : []),
193
193
  ...modules.map((module, index) => `import * as __kEffectModule${index} from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, module.path)))}`)
194
194
  ]
195
195
  const entries = moduleUrls.map((url, index) => `[${JSON.stringify(url)}, __kEffectModule${index}]`).join(",")
@@ -300,7 +300,7 @@ ${hasDependencyExpressions ? printDerivedDependencyRead("__kRuntime.browserState
300
300
  }`
301
301
  }
302
302
 
303
- function printOwnedNavigableEffectEntry(effects, output, handlerModules, assetsDirectory, base) {
303
+ function printOwnedNavigableEffectEntry(effects, output, handlerModules, assetsDirectory, runtimeDirectory, base) {
304
304
  const hasItemDependencies = effects.some(effect => effect.itemDependencies?.length)
305
305
  const hasDependencyExpressions = effects.some(effect => effect.dependencyExpressions?.length)
306
306
  const moduleUrls = [...new Set(effects.map(effect => effect.module))]
@@ -310,9 +310,9 @@ function printOwnedNavigableEffectEntry(effects, output, handlerModules, assetsD
310
310
  return module
311
311
  })
312
312
  const imports = [
313
- `import * as __kRuntime from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, "kudzu.js")))}`,
314
- `import { createEffectContext } from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, "kudzu-effect.js")))}`,
315
- ...(hasDependencyExpressions ? [`import { evaluateCollectionExpression as __kEvaluateDependency } from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, "kudzu-collection-selector.js")))}`] : []),
313
+ `import * as __kRuntime from ${JSON.stringify(relativeModulePath(output, join(runtimeDirectory, "kudzu.js")))}`,
314
+ `import { createEffectContext } from ${JSON.stringify(relativeModulePath(output, join(runtimeDirectory, "kudzu-effect.js")))}`,
315
+ ...(hasDependencyExpressions ? [`import { evaluateCollectionExpression as __kEvaluateDependency } from ${JSON.stringify(relativeModulePath(output, join(runtimeDirectory, "kudzu-collection-selector.js")))}`] : []),
316
316
  ...modules.map((module, index) => `import * as __kEffectModule${index} from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, module.path)))}`)
317
317
  ]
318
318
  const entries = moduleUrls.map((url, index) => `[${JSON.stringify(url)}, __kEffectModule${index}]`).join(",")
@@ -876,9 +876,9 @@ async function invokeCleanup() {
876
876
  }${disposal}`
877
877
  }
878
878
 
879
- return (effects, output, handlerModules, assetsDirectory, base, paramPath, runtimeName, navigable) => navigable
879
+ return (effects, output, handlerModules, assetsDirectory, base, paramPath, runtimeName, navigable, runtimeDirectory = assetsDirectory) => navigable
880
880
  ? effects.some(effect => effect.owner)
881
- ? printOwnedNavigableEffectEntry(effects, output, handlerModules, assetsDirectory, base)
882
- : printNavigableEffectEntry(effects, output, handlerModules, assetsDirectory, base)
883
- : printEffectEntry(effects, output, handlerModules, assetsDirectory, base, paramPath, runtimeName)
881
+ ? printOwnedNavigableEffectEntry(effects, output, handlerModules, assetsDirectory, runtimeDirectory, base)
882
+ : printNavigableEffectEntry(effects, output, handlerModules, assetsDirectory, runtimeDirectory, base)
883
+ : printEffectEntry(effects, output, handlerModules, assetsDirectory, runtimeDirectory, base, paramPath, runtimeName)
884
884
  }