@junejs/server 1.0.0-dev.19 → 1.0.0-dev.24

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.
Files changed (43) hide show
  1. package/dist/adapter-DcZ-W1-7.d.ts +62 -0
  2. package/dist/adapter.d.ts +1 -61
  3. package/dist/agent-durable.js +1 -43
  4. package/dist/agent-native.d.ts +15 -5
  5. package/dist/agent-native.js +85 -16
  6. package/dist/app.js +2 -7
  7. package/dist/build.d.ts +5 -26
  8. package/dist/build.js +10 -276
  9. package/dist/config-freeze-DwI-Rmvq.d.ts +17 -0
  10. package/dist/config-freeze.d.ts +2 -0
  11. package/dist/config-freeze.js +51 -0
  12. package/dist/connection-auth.d.ts +39 -0
  13. package/dist/connection-auth.js +31 -0
  14. package/dist/content-freeze-CyiW2fRK.d.ts +4 -0
  15. package/dist/content-freeze.d.ts +2 -0
  16. package/dist/content-freeze.js +83 -0
  17. package/dist/core-version.js +1 -1
  18. package/dist/index.d.ts +9 -4
  19. package/dist/index.js +7 -2
  20. package/dist/manifest-gcNWqWoI.d.ts +11 -0
  21. package/dist/manifest.d.ts +2 -0
  22. package/dist/manifest.js +111 -0
  23. package/dist/route-scan-DMlwoESy.d.ts +13 -0
  24. package/dist/route-scan.d.ts +2 -0
  25. package/dist/route-scan.js +55 -0
  26. package/dist/turn-events.d.ts +5 -0
  27. package/dist/turn-events.js +46 -0
  28. package/dist/worker-BkFHGtxp.d.ts +61 -0
  29. package/dist/worker.d.ts +1 -60
  30. package/package.json +2 -2
  31. package/src/agent-discover.ts +10 -7
  32. package/src/agent-durable.ts +1 -39
  33. package/src/agent-native.ts +133 -21
  34. package/src/app.ts +4 -6
  35. package/src/build.ts +26 -440
  36. package/src/config-freeze.ts +104 -0
  37. package/src/connection-auth.ts +101 -0
  38. package/src/content-freeze.ts +151 -0
  39. package/src/core-version.ts +1 -1
  40. package/src/index.ts +14 -0
  41. package/src/manifest.ts +128 -0
  42. package/src/route-scan.ts +84 -0
  43. package/src/turn-events.ts +43 -0
package/src/build.ts CHANGED
@@ -6,46 +6,51 @@
6
6
  // SAME pipeline as dev (pipeline.ts), so its surfaces are byte-equivalent —
7
7
  // proven by test/parity.test.ts, not hoped for.
8
8
  //
9
- // Two entry points:
10
- // buildManifest(appRoot) → an in-process WorkerManifest (freeze only; used by
11
- // the parity test and by prerender — same render path as the bundle).
12
- // juneBuild(appRoot) → the full build: content freeze + generated entry +
13
- // Rolldown bundle (workerd conditions, binary externals) +
14
- // prerender-through-the-worker + wrangler config.
9
+ // The build's separable stages live in sibling modules (re-exported here so
10
+ // consumers keep one import root):
11
+ // route-scan.ts — app/ + .june/routes/ discovery and merge (scanAppRoutes)
12
+ // content-freeze.ts — content/ → app/_content.ts (generateContent)
13
+ // config-freeze.ts — june.config.ts → serializable worker bits (freezeConfig)
14
+ // manifest.ts — the in-process freeze (buildManifest; parity + prerender)
15
+ // This file keeps the full-build orchestration: juneBuild = content freeze +
16
+ // generated entry + Rolldown bundle (workerd conditions, binary externals) +
17
+ // prerender-through-the-worker + wrangler config.
15
18
  //
16
19
  // REMINDER #4: nothing in the worker graph may statically import node:*. The
17
20
  // content freeze (content/*.md → app/_content.ts) is what removes fs from the
18
21
  // dynamic route's graph; the worker reads frozen data, never the filesystem.
19
22
 
20
- import { execFileSync } from "node:child_process";
21
23
  import { createHash } from "node:crypto";
22
24
  import { existsSync, lstatSync } from "node:fs";
23
- import { copyFile, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
25
+ import { copyFile, mkdir, readdir, rm, writeFile } from "node:fs/promises";
24
26
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
25
- import { pathToFileURL } from "node:url";
26
27
 
27
- import { findJuneConfigPath, loadJuneConfig } from "./config-loader";
28
- import { resolveAgent, resolveClientRouter, resolveSpeculationRules } from "@junejs/core/config";
29
- import type { ContentSource, JuneConfig } from "@junejs/core/config";
28
+ import { loadJuneConfig } from "./config-loader";
30
29
  import { buildLinkHeader } from "@junejs/core/discovery";
31
30
  import { localeHref } from "@junejs/core/i18n";
32
- import { routeFromModule, type BrandedRoute } from "@junejs/core/route";
33
- import { workers, vercel, deno, staticSite, type JuneAdapter, type ResourcePlan } from "./adapter";
34
- import type { DocumentConfig } from "@junejs/core/document";
31
+ import type { BrandedRoute } from "@junejs/core/route";
32
+ import type { ResourcePlan } from "./adapter";
35
33
  import { generateAgentModule } from "./agent-compile";
36
- import { generateContentModule } from "./content";
37
- import { createWorker, type WorkerManifest } from "./worker";
34
+ import { freezeConfig, resolveDeployAdapter, workerName } from "./config-freeze";
35
+ import { generateContent } from "./content-freeze";
36
+ import { createWorker } from "./worker";
37
+ import { buildManifest, importLayout, type ImportedLayout } from "./manifest";
38
38
  import { findMiddlewareFile } from "./router";
39
+ import { scanAppRoutes } from "./route-scan";
39
40
  import { resolveBoundary } from "./segment";
40
- import type { ExtraHandler, LayoutComponent, LoadingComponent, ResourceHandler } from "./pipeline";
41
- import { findClientEntry, bundleClientToFile, CLIENT_SCRIPT_URL } from "./client-bundle";
41
+ import { findClientEntry, bundleClientToFile } from "./client-bundle";
42
42
  import { RESERVED_PREFIX } from "./static-files";
43
43
  import { jsxTransform } from "./tsconfig-jsx";
44
44
  import { generateIslandRegistry } from "./island-registry";
45
45
  import { buildRsc, findRscRoutes } from "./rsc-build";
46
- import { cssTargets, findGlobalCss, globalCssUsesTailwind, minifyCss, processCss, STYLES_URL } from "./css";
46
+ import { cssTargets, minifyCss, processCss } from "./css";
47
47
  import { buildModuleCss, rolldownCssModulesPlugin, registerCssModules } from "./css-modules";
48
48
 
49
+ export { scanRoutes, scanAppRoutes, type RouteEntry } from "./route-scan";
50
+ export { generateContent } from "./content-freeze";
51
+ export { freezeConfig, normalizeBase, resolveDeployAdapter, workerName } from "./config-freeze";
52
+ export { buildManifest } from "./manifest";
53
+
49
54
  export type BuildResult = {
50
55
  outFile: string;
51
56
  routes: string[];
@@ -54,424 +59,12 @@ export type BuildResult = {
54
59
  prerendered: string[];
55
60
  };
56
61
 
57
- // The segment layout CHAIN root→leaf: every directory level (route groups
58
- // included) may contribute a layout.* that wraps routes below it.
59
- type RouteEntry = {
60
- path: string;
61
- file: string;
62
- dynamic: boolean;
63
- resource?: boolean; // a route.* resource route (raw-Response handler), not a page
64
- layouts: string[];
65
- loading?: string; // nearest loading.tsx up the tree → streaming Suspense fallback
66
- };
67
-
68
- const PAGE_BASENAMES = new Set(["page", "index"]);
69
- const ROUTE_EXTS = [".tsx", ".jsx", ".ts", ".js"];
70
-
71
- const isRouteGroup = (name: string) => /^\(.+\)$/.test(name);
72
-
73
62
  // Bun built-ins (`bun`, `bun:sqlite`, …) exist only at the Bun runtime and must never enter the
74
63
  // workerd graph. Marking them external keeps rolldown from constant-folding the `const x = "bun";
75
64
  // import(x)` runtime guard (in @junejs/core's cache.ts) and warning UNRESOLVED_IMPORT. Exported so
76
65
  // the build keeps externalizing them — see test/build-externals.test.ts.
77
66
  export const isBunSpecifier = (id: string): boolean => id === "bun" || id.startsWith("bun:");
78
67
 
79
- // Resolve the deploy adapter from config. An explicit `adapter` INSTANCE wins; otherwise the
80
- // `target` NAME selects the matching built-in — so a DECLARATIVE config (e.g. kura.toml, which
81
- // can't express a `vercel()` call) can pick any target by string, not just "static". "workers" is
82
- // the default. Kept in lockstep with deploy.ts's own target→deployer switch so a build for one
83
- // target is never deployed as another. vercel()/deno() take their opts (runtime/regions, org/app)
84
- // which JuneConfig.deploy doesn't carry yet, so they use defaults here.
85
- export function resolveDeployAdapter(deploy: JuneConfig["deploy"]): JuneAdapter {
86
- if (deploy?.adapter) return deploy.adapter as JuneAdapter;
87
- switch (deploy?.target) {
88
- case "static":
89
- return staticSite();
90
- case "vercel":
91
- return vercel();
92
- case "deno":
93
- return deno();
94
- default:
95
- return workers({ name: deploy?.name, domain: deploy?.domain });
96
- }
97
- }
98
-
99
- function segmentFile(dir: string, base: string): string | undefined {
100
- return ROUTE_EXTS.map((e) => join(dir, `${base}${e}`)).find(existsSync);
101
- }
102
-
103
- // Walk app/ for page.* files → route paths (mirrors router.ts conventions:
104
- // route groups vanish from URLs, `_`-prefixed entries are private), carrying the
105
- // layout chain accumulated from each directory level.
106
- export async function scanRoutes(
107
- appDir: string,
108
- dir = appDir,
109
- layouts: string[] = [],
110
- out: RouteEntry[] = [],
111
- loading?: string,
112
- ): Promise<RouteEntry[]> {
113
- const ownLayout = segmentFile(dir, "layout");
114
- const chain = ownLayout ? [...layouts, ownLayout] : layouts;
115
- const nearestLoading = segmentFile(dir, "loading") ?? loading;
116
- const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
117
- for (const e of entries) {
118
- if (e.name.startsWith("_") || e.name.startsWith(".")) continue;
119
- const full = join(dir, e.name);
120
- if (e.isDirectory()) {
121
- await scanRoutes(appDir, full, chain, out, nearestLoading);
122
- continue;
123
- }
124
- const ext = e.name.match(/\.[^.]+$/)?.[0] ?? "";
125
- if (!ROUTE_EXTS.includes(ext)) continue;
126
- const base = basename(e.name, ext);
127
- const resource = base === "route";
128
- if (!PAGE_BASENAMES.has(base) && !resource) continue;
129
- const relDir = relative(appDir, dir);
130
- const segments = relDir === "" ? [] : relDir.split(sep).filter((s) => !isRouteGroup(s));
131
- const path = "/" + segments.join("/");
132
- out.push({
133
- path: path === "/" ? "/" : path,
134
- file: full,
135
- dynamic: /\[.+\]/.test(path),
136
- resource,
137
- layouts: chain,
138
- loading: nearestLoading,
139
- });
140
- }
141
- return out;
142
- }
143
-
144
- // content/<collection>/*.md → app/_content.ts (the build-time content manifest).
145
- // This is the FREEZE that removes node:fs from the worker graph: routes import
146
- // frozen entries from ./_content instead of reading the filesystem at request.
147
- //
148
- // Extra sources (config `content.sources`) merge dirs OUTSIDE `content/` into named
149
- // collections — the docs-as-code seam (a repo's own `docs/` feeds the site directly).
150
- // Their dirs resolve against the app root, so "../docs" reaches a sibling directory.
151
- //
152
- // Locale buckets are DECLARED, not guessed: only dirs named in config i18n
153
- // (defaultLocale + locales keys) split off as locale mirrors. Guessing by shape
154
- // (the old BCP-47 regex) silently swallowed any 2–3-letter folder — `cli/`,
155
- // `sdk/`, `api/`, `faq/` all read as locales and vanished from the default set.
156
- // No i18n config ⇒ NO buckets (an undeclared locale is not a locale). The regex
157
- // remains only as the fallback when the config itself cannot be loaded.
158
- export async function generateContent(appRoot: string): Promise<string[]> {
159
- const contentDir = join(appRoot, "content");
160
- // app/ is the output dir for _content.ts (and the seed). Ensure it exists so the now-always
161
- // write can't throw an opaque ENOENT when called standalone (e.g. `june gen`); the full build's
162
- // own "no app/ directory" check runs earlier, so this never masks that clearer error.
163
- await mkdir(join(appRoot, "app"), { recursive: true });
164
- const emit = async (sources: ContentSource[], knownLocales: readonly string[] | undefined): Promise<string[]> => {
165
- // Emission (incl. the per-locale layout and source merging) lives in ./content so it's
166
- // pure and unit-testable; this stays the thin fs wrapper.
167
- const { code, names } = generateContentModule(contentDir, knownLocales, sources);
168
- // Always write, even with zero collections: `code` still carries the canonical ContentEntry
169
- // type, which is the single source of truth the bootstrap seed appends its stubs against (an
170
- // app with no local content/ — docs-as-code — needs this valid empty module to exist).
171
- await writeFile(join(appRoot, "app", "_content.ts"), code);
172
- return names;
173
- };
174
- const resolveSources = (sources: ContentSource[]): ContentSource[] =>
175
- sources.map((s) => ({ ...s, dir: resolve(appRoot, s.dir) }));
176
- const declaredLocales = (config: JuneConfig): string[] =>
177
- config.i18n ? [...new Set([config.i18n.defaultLocale, ...Object.keys(config.i18n.locales)])] : [];
178
- // Learn sources + locales from june.config.ts — TOLERANTLY. A wrapper-generated config (e.g.
179
- // Kura's) imports app/_content.ts, which does not exist before the FIRST freeze. So on a failed
180
- // config load: generate a default scan to create that import's target, then re-probe the config
181
- // and regenerate with the real sources/locales. Self-healing, no bootstrap flag. Content errors
182
- // from emit() (slug collision, missing source dir) stay loud — only the CONFIG LOAD is tolerated.
183
- let config: JuneConfig | null = null;
184
- try {
185
- config = await loadJuneConfig(appRoot);
186
- } catch {
187
- /* two-pass below */
188
- }
189
- if (config) return emit(resolveSources(config.content?.sources ?? []), declaredLocales(config));
190
- // Pass 1 (throwaway when the probe succeeds): legacy regex locale detection, since the
191
- // declared set is unknowable without the config. emit() writes app/_content.ts with the
192
- // canonical ContentEntry type plus a `const` per collection FOUND — but a docs-as-code app
193
- // keeps ALL content in external content.sources with NO local content/, so it finds zero
194
- // collections and thus emits no EXPORTS (no `DOCS`). seedContentImports then appends stubs for
195
- // the exact names the config imports (e.g. Kura's `import { DOCS }`), so the re-probe can load.
196
- const names = await emit([], undefined);
197
- await seedContentImports(appRoot);
198
- const probed = probeConfigFresh(appRoot);
199
- if (probed === null) {
200
- console.warn("[june gen] june.config.ts failed to load — content.sources/i18n locales (if any) not applied");
201
- return names;
202
- }
203
- return emit(resolveSources(probed.sources), probed.locales);
204
- }
205
-
206
- // Bootstrap seed: ensure app/_content.ts exports every name the config imports from it, so the
207
- // re-probe's config load resolves BEFORE the first real freeze. A docs-as-code app (content only
208
- // in external content.sources, no local content/) leaves Pass 1's default scan empty — nothing
209
- // seeds the collections — so here we append stubs for the EXACT named imports the config takes
210
- // from the app/_content module (a bare `import { DOCS }` of it → `export const DOCS = []`). The
211
- // caller's emit() already wrote the module's canonical ContentEntry type (even when empty), so the
212
- // stubs type against that single source of truth. Overwritten by the real freeze that follows a
213
- // successful probe; a no-op once content exists.
214
- async function seedContentImports(appRoot: string): Promise<void> {
215
- const cfgPath = findJuneConfigPath(appRoot);
216
- if (!cfgPath) return;
217
- const contentFile = join(appRoot, "app", "_content.ts");
218
- const current = existsSync(contentFile) ? await readFile(contentFile, "utf8") : "";
219
- const cfgSrc = await readFile(cfgPath, "utf8");
220
- // Only stub valid JS identifiers — a name carrying comments/other tokens (rare, but valid TS)
221
- // must never reach `new RegExp(...)` (would throw) or the emitted stub (would be invalid TS).
222
- const isIdent = (s: string): boolean => /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(s);
223
- // A Set so a name matched more than once (e.g. a comment mentioning the import + the real one)
224
- // yields ONE stub — a duplicate `export const <name>` would be invalid TS. Also skip names the
225
- // module already exports.
226
- const seen = new Set<string>();
227
- const stubs: string[] = [];
228
- // Each named `import { A, B as C }` the config takes from the app/_content module → stub each.
229
- // The `(?:\.\w+)?` tolerates an explicit extension (`app/_content.ts`/`.js`/`.mjs`), which
230
- // NodeNext / verbatimModuleSyntax configs require — else the import goes undetected and unstubbed.
231
- for (const m of cfgSrc.matchAll(/import\s*(?:type\s+)?\{([^}]*)\}\s*from\s*["'][^"']*app\/_content(?:\.\w+)?["']/g)) {
232
- // Strip block + line comments from the specifier list BEFORE splitting: a valid
233
- // `{ DOCS /* keep */ }` must still yield the identifier `DOCS` (not be dropped), and a comment
234
- // could otherwise carry a `,` that breaks the split. Then drop `type` markers per specifier.
235
- const inner = (m[1] ?? "").replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, "");
236
- for (const part of inner.split(",")) {
237
- const name = part.replace(/\btype\b/g, "").split(/\s+as\s+/).pop()?.trim();
238
- if (!name || !isIdent(name) || seen.has(name)) continue;
239
- seen.add(name);
240
- // Escape before interpolating: a valid identifier may contain `$`, a regex metachar (an
241
- // anchor), which would corrupt the already-exported check. The stub below uses the raw name.
242
- const reName = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
243
- if (!new RegExp(`export (?:const|function|type) ${reName}\\b`).test(current)) {
244
- stubs.push(`export const ${name}: ContentEntry[] = [];`);
245
- }
246
- }
247
- }
248
- if (stubs.length) await writeFile(contentFile, current + stubs.join("\n") + "\n");
249
- }
250
-
251
- // Re-probe ONLY the content-relevant config (sources + declared i18n locales) in a FRESH
252
- // subprocess. The bootstrap retry can't re-import in-process: a failed ESM load is cached as
253
- // errored, and Bun also caches the failed RESOLUTION of the config's own imports — so a
254
- // same-process retry (even cache-busted) re-rejects after the missing app/_content.ts appears.
255
- // A child process has a clean module map, and both fields are plain JSON, so they survive the
256
- // pipe. Returns null when the config is genuinely broken.
257
- function probeConfigFresh(appRoot: string): { sources: ContentSource[]; locales: string[] } | null {
258
- const path = findJuneConfigPath(appRoot);
259
- if (!path) return { sources: [], locales: [] };
260
- // Markers isolate the JSON from anything the config prints at import time. `.then` (not TLA)
261
- // so the eval works as CJS under both `bun -e` and `node -e`; execArgv carries loader flags
262
- // (e.g. --experimental-strip-types) so the child can read the same TS config the parent does.
263
- const code =
264
- `import(${JSON.stringify(pathToFileURL(path).href)}).then(` +
265
- `(m) => { const c = m.default ?? {}; process.stdout.write("\\n__JUNE_CONTENT__" + JSON.stringify({ ` +
266
- `sources: c.content?.sources ?? [], ` +
267
- `locales: c.i18n ? [...new Set([c.i18n.defaultLocale, ...Object.keys(c.i18n.locales ?? {})])] : [] ` +
268
- `}) + "__END__"); }, () => process.exit(42));`;
269
- try {
270
- const out = execFileSync(process.execPath, [...process.execArgv, "-e", code], {
271
- encoding: "utf8",
272
- stdio: ["ignore", "pipe", "ignore"],
273
- });
274
- const m = out.match(/__JUNE_CONTENT__(.*?)__END__/s);
275
- return m ? (JSON.parse(m[1]!) as { sources: ContentSource[]; locales: string[] }) : null;
276
- } catch {
277
- return null;
278
- }
279
- }
280
-
281
- // Freeze june.config.ts → the serializable bits the worker inlines.
282
- export async function freezeConfig(appRoot: string): Promise<{
283
- document: DocumentConfig;
284
- agent: WorkerManifest["agent"];
285
- i18n: WorkerManifest["i18n"];
286
- earlyHints: string[];
287
- buildExternal: string[];
288
- }> {
289
- const cfg = await loadJuneConfig(appRoot);
290
- // An app with a client entry gets the islands runtime URL frozen into its
291
- // document. Detected HERE (not just in juneBuild) so the prerender path —
292
- // which re-freezes through buildManifest — sets the SAME clientScript, keeping
293
- // prerendered pages byte-equivalent to the live worker (parity).
294
- // Client entry: app/_client.* wins; fall back to .june/routes/_client.* (framework slot).
295
- const hasClient =
296
- findClientEntry(join(appRoot, "app")) !== undefined ||
297
- findClientEntry(join(appRoot, ".june", "routes")) !== undefined;
298
- const hasCss = findGlobalCss(join(appRoot, "app")) !== null;
299
- return {
300
- document: {
301
- site: cfg.site ?? {},
302
- speculationRules: resolveSpeculationRules(cfg.speculation ?? undefined),
303
- speculationDelivery: "inline",
304
- viewTransitions: cfg.viewTransitions ?? true,
305
- // Default the baseline reset OFF when the app uses Tailwind (its Preflight is the reset).
306
- cssReset: cfg.cssReset ?? !globalCssUsesTailwind(join(appRoot, "app")),
307
- clientRouter: resolveClientRouter(cfg.clientRouter),
308
- clientScript: hasClient ? CLIENT_SCRIPT_URL : null,
309
- styles: hasCss ? STYLES_URL : null,
310
- // Deploy subpath (JuneConfig.basePath). Frozen so the document prefixes its
311
- // asset URLs; the prerender path re-freezes through buildManifest and gets the
312
- // same value, keeping static pages' asset links correct under the subpath.
313
- basePath: normalizeBase(cfg.basePath),
314
- },
315
- agent: resolveAgent(cfg.agent),
316
- // Pass i18n through as-is: the in-process buildManifest keeps a resolveLocale
317
- // hook (parity test), and the codegen JSON.stringify below drops the function
318
- // (worker hook support is the codegen pass — see the manifest field comment).
319
- i18n: cfg.i18n,
320
- earlyHints: cfg.earlyHints ?? [],
321
- buildExternal: cfg.build?.external ?? [],
322
- };
323
- }
324
-
325
- // A deploy basePath is stored with a leading slash and no trailing slash; empty
326
- // ("" — the default) means a root deploy. So "/openab/docs/" → "/openab/docs",
327
- // "openab" → "/openab", undefined → "".
328
- export function normalizeBase(base?: string): string {
329
- if (!base) return "";
330
- const b = base.startsWith("/") ? base : `/${base}`;
331
- return b.endsWith("/") ? b.slice(0, -1) : b;
332
- }
333
-
334
- type ImportedLayout = { component: LayoutComponent; boundary: boolean };
335
- async function importLayout(file: string): Promise<ImportedLayout | null> {
336
- const mod = (await import(pathToFileURL(file).href)) as {
337
- default?: LayoutComponent;
338
- segmentBoundary?: unknown;
339
- };
340
- return typeof mod.default === "function"
341
- ? { component: mod.default, boundary: mod.segmentBoundary === true }
342
- : null;
343
- }
344
-
345
- // A wrangler-valid worker name from a package name or directory name. Wrangler requires lowercase
346
- // [a-z0-9-] not starting/ending with a dash — so a SCOPED package name (`@scope/pkg`) must lose its
347
- // scope (else the `@` sanitizes to a leading dash and wrangler rejects the config). Drop the scope,
348
- // lowercase, collapse non-alphanumerics to single dashes, trim edge dashes; fall back to "app" if
349
- // nothing survives (e.g. an all-punctuation name). Exported for tests.
350
- export function workerName(raw: string): string {
351
- return (
352
- raw
353
- .replace(/^@[^/]+\//, "")
354
- .toLowerCase()
355
- .replace(/[^a-z0-9-]+/g, "-")
356
- .replace(/^-+|-+$/g, "") || "app"
357
- );
358
- }
359
-
360
- // The FREEZE, in-process: import route modules + layouts, build the manifest a
361
- // createWorker() can run immediately. Used by prerender and by the parity test
362
- // (its render path is identical to the Rolldown-bundled worker).
363
- export async function buildManifest(appRoot: string): Promise<WorkerManifest> {
364
- const appDir = join(appRoot, "app");
365
- const juneRoutesDir = join(appRoot, ".june", "routes");
366
- const frozen = await freezeConfig(appRoot);
367
-
368
- // Merge routes: app/ takes priority over .june/routes/ (app/ is the escape hatch).
369
- // .june/routes/ is the convention slot for framework-generated routes (e.g. kura
370
- // writes its docs/search/og routes there so the user never manages boilerplate).
371
- const appRoutes = await scanRoutes(appDir);
372
- const frameworkRoutes = existsSync(juneRoutesDir) ? await scanRoutes(juneRoutesDir) : [];
373
- const appPaths = new Set(appRoutes.map((r) => r.path));
374
- const scanned = [
375
- ...appRoutes,
376
- ...frameworkRoutes.filter((r) => !appPaths.has(r.path)),
377
- ].sort((a, b) => a.path.localeCompare(b.path));
378
-
379
- const layoutCache = new Map<string, ImportedLayout | null>();
380
- const loadCached = async (f: string): Promise<ImportedLayout | null> => {
381
- if (!layoutCache.has(f)) layoutCache.set(f, await importLayout(f));
382
- return layoutCache.get(f) ?? null;
383
- };
384
- // The chain (root→leaf) + boundary index + shell key, via the SHARED resolver
385
- // (the one place the deepest-wins rule lives), so the frozen manifest and the
386
- // dev resolver can't drift — the parity contract.
387
- const componentsFor = async (
388
- files: string[],
389
- ): Promise<{ chain: LayoutComponent[]; boundaryIndex: number | null; key: string | null }> => {
390
- const items = [];
391
- for (const f of files) {
392
- const c = await loadCached(f);
393
- items.push({ file: f, entry: c?.component ?? null, boundary: !!c?.boundary });
394
- }
395
- return resolveBoundary(items);
396
- };
397
-
398
- const loadingCache = new Map<string, LoadingComponent | null>();
399
- const loadingFor = async (file?: string): Promise<LoadingComponent | undefined> => {
400
- if (!file) return undefined;
401
- if (!loadingCache.has(file)) {
402
- const loaded = await importLayout(file);
403
- loadingCache.set(file, loaded ? (loaded.component as LoadingComponent) : null);
404
- }
405
- return loadingCache.get(file) ?? undefined;
406
- };
407
-
408
- const routes: Record<string, BrandedRoute> = {};
409
- const dynamicRoutes: Array<{ pattern: string; def: BrandedRoute }> = [];
410
- const resourceRoutes: Array<{ pattern: string; handler: ResourceHandler }> = [];
411
- const layoutChains: Record<string, LayoutComponent[]> = {};
412
- const layoutBoundaries: Record<string, { index: number; key: string }> = {};
413
- const loadings: Record<string, LoadingComponent> = {};
414
-
415
- for (const r of scanned) {
416
- const mod = await import(pathToFileURL(r.file).href);
417
- // Resource route (route.*): the default export is the Response handler.
418
- if (r.resource) {
419
- const handler = (mod as { default?: unknown }).default;
420
- if (typeof handler === "function") resourceRoutes.push({ pattern: r.path, handler: handler as ResourceHandler });
421
- continue;
422
- }
423
- const def = routeFromModule(mod);
424
- if (!def) continue;
425
- const { chain, boundaryIndex, key } = await componentsFor(r.layouts);
426
- const loading = await loadingFor(r.loading);
427
- if (r.dynamic) {
428
- dynamicRoutes.push({ pattern: r.path, def });
429
- layoutChains[r.path] = chain;
430
- } else {
431
- routes[r.path] = def;
432
- layoutChains[r.path] = chain;
433
- }
434
- if (boundaryIndex !== null && key !== null) layoutBoundaries[r.path] = { index: boundaryIndex, key };
435
- if (loading) loadings[r.path] = loading;
436
- }
437
-
438
- let extra: ExtraHandler | undefined;
439
- const extraFile = findMiddlewareFile(appDir);
440
- if (extraFile) {
441
- const mod = (await import(pathToFileURL(extraFile).href)) as { default?: unknown };
442
- if (typeof mod.default === "function") extra = mod.default as ExtraHandler;
443
- }
444
-
445
- // The durable agent's DO address (createWorker routes the chat endpoint by
446
- // it). Set whenever an agent/ directory exists — the surface is inert without
447
- // an env.AGENT binding, so in-process consumers (prerender, the parity test)
448
- // are unaffected.
449
- let agentName: string | undefined;
450
- const agentDir = join(appDir, frozen.agent.runtime.dir);
451
- if (frozen.agent.runtime.enabled && existsSync(agentDir)) {
452
- const cfgFile = join(agentDir, "agent.ts");
453
- const cfg = existsSync(cfgFile)
454
- ? ((await import(pathToFileURL(cfgFile).href)).default as { name?: string } | undefined)
455
- : undefined;
456
- agentName = cfg?.name ?? basename(agentDir);
457
- }
458
-
459
- return {
460
- routes,
461
- dynamicRoutes,
462
- resourceRoutes,
463
- layoutChains,
464
- layoutBoundaries,
465
- loadings,
466
- document: frozen.document,
467
- agent: frozen.agent,
468
- agentName,
469
- i18n: frozen.i18n,
470
- earlyHints: frozen.earlyHints,
471
- extra,
472
- };
473
- }
474
-
475
68
  function importPath(fromDir: string, file: string): string {
476
69
  const p = relative(fromDir, file).split(sep).join("/").replace(/\.[^.]+$/, "");
477
70
  return p.startsWith(".") ? p : `./${p}`;
@@ -503,14 +96,7 @@ export async function juneBuild(
503
96
 
504
97
  const contentCollections = await generateContent(appRoot);
505
98
  // Same merge as buildManifest: app/ takes priority over .june/routes/.
506
- const juneRoutesDir2 = join(appRoot, ".june", "routes");
507
- const appRoutes2 = await scanRoutes(appDir);
508
- const frameworkRoutes2 = existsSync(juneRoutesDir2) ? await scanRoutes(juneRoutesDir2) : [];
509
- const appPaths2 = new Set(appRoutes2.map((r) => r.path));
510
- const routes = [
511
- ...appRoutes2,
512
- ...frameworkRoutes2.filter((r) => !appPaths2.has(r.path)),
513
- ].sort((a, b) => a.path.localeCompare(b.path));
99
+ const routes = await scanAppRoutes(appRoot);
514
100
  if (routes.length === 0) throw new Error(`no page.* routes found under ${appDir} or .june/routes/`);
515
101
 
516
102
  const frozen = await freezeConfig(appRoot);
@@ -0,0 +1,104 @@
1
+ // Freeze june.config.ts → the serializable bits the worker inlines, plus the
2
+ // small config-derived helpers (deploy adapter resolution, base path and worker
3
+ // name normalization). Extracted from build.ts; consumed by buildManifest and
4
+ // juneBuild so both freeze through the SAME code (the parity contract).
5
+
6
+ import { existsSync } from "node:fs";
7
+ import { join } from "node:path";
8
+
9
+ import { resolveAgent, resolveClientRouter, resolveSpeculationRules } from "@junejs/core/config";
10
+ import type { JuneConfig } from "@junejs/core/config";
11
+ import type { DocumentConfig } from "@junejs/core/document";
12
+ import { workers, vercel, deno, staticSite, type JuneAdapter } from "./adapter";
13
+ import { findClientEntry, CLIENT_SCRIPT_URL } from "./client-bundle";
14
+ import { loadJuneConfig } from "./config-loader";
15
+ import { findGlobalCss, globalCssUsesTailwind, STYLES_URL } from "./css";
16
+ import type { WorkerManifest } from "./worker";
17
+
18
+ // Resolve the deploy adapter from config. An explicit `adapter` INSTANCE wins; otherwise the
19
+ // `target` NAME selects the matching built-in — so a DECLARATIVE config (e.g. kura.toml, which
20
+ // can't express a `vercel()` call) can pick any target by string, not just "static". "workers" is
21
+ // the default. Kept in lockstep with deploy.ts's own target→deployer switch so a build for one
22
+ // target is never deployed as another. vercel()/deno() take their opts (runtime/regions, org/app)
23
+ // which JuneConfig.deploy doesn't carry yet, so they use defaults here.
24
+ export function resolveDeployAdapter(deploy: JuneConfig["deploy"]): JuneAdapter {
25
+ if (deploy?.adapter) return deploy.adapter as JuneAdapter;
26
+ switch (deploy?.target) {
27
+ case "static":
28
+ return staticSite();
29
+ case "vercel":
30
+ return vercel();
31
+ case "deno":
32
+ return deno();
33
+ default:
34
+ return workers({ name: deploy?.name, domain: deploy?.domain });
35
+ }
36
+ }
37
+
38
+ // Freeze june.config.ts → the serializable bits the worker inlines.
39
+ export async function freezeConfig(appRoot: string): Promise<{
40
+ document: DocumentConfig;
41
+ agent: WorkerManifest["agent"];
42
+ i18n: WorkerManifest["i18n"];
43
+ earlyHints: string[];
44
+ buildExternal: string[];
45
+ }> {
46
+ const cfg = await loadJuneConfig(appRoot);
47
+ // An app with a client entry gets the islands runtime URL frozen into its
48
+ // document. Detected HERE (not just in juneBuild) so the prerender path —
49
+ // which re-freezes through buildManifest — sets the SAME clientScript, keeping
50
+ // prerendered pages byte-equivalent to the live worker (parity).
51
+ // Client entry: app/_client.* wins; fall back to .june/routes/_client.* (framework slot).
52
+ const hasClient =
53
+ findClientEntry(join(appRoot, "app")) !== undefined ||
54
+ findClientEntry(join(appRoot, ".june", "routes")) !== undefined;
55
+ const hasCss = findGlobalCss(join(appRoot, "app")) !== null;
56
+ return {
57
+ document: {
58
+ site: cfg.site ?? {},
59
+ speculationRules: resolveSpeculationRules(cfg.speculation ?? undefined),
60
+ speculationDelivery: "inline",
61
+ viewTransitions: cfg.viewTransitions ?? true,
62
+ // Default the baseline reset OFF when the app uses Tailwind (its Preflight is the reset).
63
+ cssReset: cfg.cssReset ?? !globalCssUsesTailwind(join(appRoot, "app")),
64
+ clientRouter: resolveClientRouter(cfg.clientRouter),
65
+ clientScript: hasClient ? CLIENT_SCRIPT_URL : null,
66
+ styles: hasCss ? STYLES_URL : null,
67
+ // Deploy subpath (JuneConfig.basePath). Frozen so the document prefixes its
68
+ // asset URLs; the prerender path re-freezes through buildManifest and gets the
69
+ // same value, keeping static pages' asset links correct under the subpath.
70
+ basePath: normalizeBase(cfg.basePath),
71
+ },
72
+ agent: resolveAgent(cfg.agent),
73
+ // Pass i18n through as-is: the in-process buildManifest keeps a resolveLocale
74
+ // hook (parity test), and the codegen JSON.stringify drops the function
75
+ // (worker hook support is the codegen pass — see the manifest field comment).
76
+ i18n: cfg.i18n,
77
+ earlyHints: cfg.earlyHints ?? [],
78
+ buildExternal: cfg.build?.external ?? [],
79
+ };
80
+ }
81
+
82
+ // A deploy basePath is stored with a leading slash and no trailing slash; empty
83
+ // ("" — the default) means a root deploy. So "/openab/docs/" → "/openab/docs",
84
+ // "openab" → "/openab", undefined → "".
85
+ export function normalizeBase(base?: string): string {
86
+ if (!base) return "";
87
+ const b = base.startsWith("/") ? base : `/${base}`;
88
+ return b.endsWith("/") ? b.slice(0, -1) : b;
89
+ }
90
+
91
+ // A wrangler-valid worker name from a package name or directory name. Wrangler requires lowercase
92
+ // [a-z0-9-] not starting/ending with a dash — so a SCOPED package name (`@scope/pkg`) must lose its
93
+ // scope (else the `@` sanitizes to a leading dash and wrangler rejects the config). Drop the scope,
94
+ // lowercase, collapse non-alphanumerics to single dashes, trim edge dashes; fall back to "app" if
95
+ // nothing survives (e.g. an all-punctuation name). Exported for tests.
96
+ export function workerName(raw: string): string {
97
+ return (
98
+ raw
99
+ .replace(/^@[^/]+\//, "")
100
+ .toLowerCase()
101
+ .replace(/[^a-z0-9-]+/g, "-")
102
+ .replace(/^-+|-+$/g, "") || "app"
103
+ );
104
+ }