@junejs/server 0.0.8 → 0.0.10
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/package.json +8 -3
- package/src/adapter.ts +222 -0
- package/src/app.ts +69 -6
- package/src/blob.ts +1 -1
- package/src/build.ts +207 -57
- package/src/client-bundle.ts +24 -11
- package/src/css-modules-ambient.d.ts +9 -0
- package/src/css-modules-loader.d.mts +7 -0
- package/src/css-modules-loader.mjs +20 -0
- package/src/css-modules.ts +185 -0
- package/src/css.ts +118 -0
- package/src/d1-migrate.ts +209 -0
- package/src/d1.ts +53 -0
- package/src/db.ts +8 -47
- package/src/deploy.ts +121 -21
- package/src/dev-reload.ts +56 -10
- package/src/dev.ts +32 -1
- package/src/host.ts +7 -78
- package/src/index.ts +28 -1
- package/src/kv.ts +1 -1
- package/src/migrate.ts +122 -0
- package/src/negotiate.ts +0 -2
- package/src/pipeline.ts +140 -31
- package/src/resources.ts +47 -0
- package/src/sqlite-driver.ts +160 -0
- package/src/worker.ts +98 -8
package/src/build.ts
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
// content freeze (content/*.md → app/_content.ts) is what removes fs from the
|
|
18
18
|
// dynamic route's graph; the worker reads frozen data, never the filesystem.
|
|
19
19
|
|
|
20
|
+
import { createHash } from "node:crypto";
|
|
20
21
|
import { existsSync } from "node:fs";
|
|
21
22
|
import { mkdir, readdir, rm, writeFile } from "node:fs/promises";
|
|
22
23
|
import { basename, dirname, join, relative, sep } from "node:path";
|
|
@@ -24,13 +25,17 @@ import { pathToFileURL } from "node:url";
|
|
|
24
25
|
|
|
25
26
|
import { loadJuneConfig } from "./config-loader";
|
|
26
27
|
import { resolveAgent, resolveSpeculationRules } from "@junejs/core/config";
|
|
27
|
-
import {
|
|
28
|
+
import { buildLinkHeader } from "@junejs/core/discovery";
|
|
29
|
+
import { routeFromModule, type BrandedRoute } from "@junejs/core/route";
|
|
30
|
+
import { workers, type JuneAdapter, type ResourcePlan } from "./adapter";
|
|
28
31
|
import type { DocumentConfig } from "@junejs/core/document";
|
|
29
32
|
import { collection } from "./content";
|
|
30
33
|
import { createWorker, type WorkerManifest } from "./worker";
|
|
31
34
|
import { findExtraFile } from "./router";
|
|
32
|
-
import type { ExtraHandler, LayoutComponent } from "./pipeline";
|
|
35
|
+
import type { ExtraHandler, LayoutComponent, LoadingComponent } from "./pipeline";
|
|
33
36
|
import { findClientEntry, bundleClientToFile, CLIENT_SCRIPT_URL } from "./client-bundle";
|
|
37
|
+
import { findGlobalCss, minifyCss, processCss, STYLES_URL } from "./css";
|
|
38
|
+
import { buildModuleCss, rolldownCssModulesPlugin, registerCssModules } from "./css-modules";
|
|
34
39
|
|
|
35
40
|
export type BuildResult = {
|
|
36
41
|
outFile: string;
|
|
@@ -42,7 +47,13 @@ export type BuildResult = {
|
|
|
42
47
|
|
|
43
48
|
// The segment layout CHAIN root→leaf: every directory level (route groups
|
|
44
49
|
// included) may contribute a layout.* that wraps routes below it.
|
|
45
|
-
type RouteEntry = {
|
|
50
|
+
type RouteEntry = {
|
|
51
|
+
path: string;
|
|
52
|
+
file: string;
|
|
53
|
+
dynamic: boolean;
|
|
54
|
+
layouts: string[];
|
|
55
|
+
loading?: string; // nearest loading.tsx up the tree → streaming Suspense fallback
|
|
56
|
+
};
|
|
46
57
|
|
|
47
58
|
const PAGE_BASENAMES = new Set(["page", "index"]);
|
|
48
59
|
const ROUTE_EXTS = [".tsx", ".jsx", ".ts", ".js"];
|
|
@@ -61,15 +72,17 @@ export async function scanRoutes(
|
|
|
61
72
|
dir = appDir,
|
|
62
73
|
layouts: string[] = [],
|
|
63
74
|
out: RouteEntry[] = [],
|
|
75
|
+
loading?: string,
|
|
64
76
|
): Promise<RouteEntry[]> {
|
|
65
77
|
const ownLayout = segmentFile(dir, "layout");
|
|
66
78
|
const chain = ownLayout ? [...layouts, ownLayout] : layouts;
|
|
79
|
+
const nearestLoading = segmentFile(dir, "loading") ?? loading;
|
|
67
80
|
const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
68
81
|
for (const e of entries) {
|
|
69
82
|
if (e.name.startsWith("_") || e.name.startsWith(".")) continue;
|
|
70
83
|
const full = join(dir, e.name);
|
|
71
84
|
if (e.isDirectory()) {
|
|
72
|
-
await scanRoutes(appDir, full, chain, out);
|
|
85
|
+
await scanRoutes(appDir, full, chain, out, nearestLoading);
|
|
73
86
|
continue;
|
|
74
87
|
}
|
|
75
88
|
const ext = e.name.match(/\.[^.]+$/)?.[0] ?? "";
|
|
@@ -78,7 +91,13 @@ export async function scanRoutes(
|
|
|
78
91
|
const relDir = relative(appDir, dir);
|
|
79
92
|
const segments = relDir === "" ? [] : relDir.split(sep).filter((s) => !isRouteGroup(s));
|
|
80
93
|
const path = "/" + segments.join("/");
|
|
81
|
-
out.push({
|
|
94
|
+
out.push({
|
|
95
|
+
path: path === "/" ? "/" : path,
|
|
96
|
+
file: full,
|
|
97
|
+
dynamic: /\[.+\]/.test(path),
|
|
98
|
+
layouts: chain,
|
|
99
|
+
loading: nearestLoading,
|
|
100
|
+
});
|
|
82
101
|
}
|
|
83
102
|
return out;
|
|
84
103
|
}
|
|
@@ -128,13 +147,16 @@ export async function freezeConfig(appRoot: string): Promise<{
|
|
|
128
147
|
// which re-freezes through buildManifest — sets the SAME clientScript, keeping
|
|
129
148
|
// prerendered pages byte-equivalent to the live worker (parity).
|
|
130
149
|
const hasClient = findClientEntry(join(appRoot, "app")) !== undefined;
|
|
150
|
+
const hasCss = findGlobalCss(join(appRoot, "app")) !== null;
|
|
131
151
|
return {
|
|
132
152
|
document: {
|
|
133
153
|
site: cfg.site ?? {},
|
|
134
154
|
speculationRules: resolveSpeculationRules(cfg.speculation ?? undefined),
|
|
135
155
|
speculationDelivery: "inline",
|
|
136
156
|
viewTransitions: cfg.viewTransitions ?? true,
|
|
157
|
+
clientRouter: cfg.clientRouter ?? false,
|
|
137
158
|
clientScript: hasClient ? CLIENT_SCRIPT_URL : null,
|
|
159
|
+
styles: hasCss ? STYLES_URL : null,
|
|
138
160
|
},
|
|
139
161
|
agent: resolveAgent(cfg.agent),
|
|
140
162
|
earlyHints: cfg.earlyHints ?? [],
|
|
@@ -166,21 +188,32 @@ export async function buildManifest(appRoot: string): Promise<WorkerManifest> {
|
|
|
166
188
|
return chain;
|
|
167
189
|
};
|
|
168
190
|
|
|
191
|
+
const loadingCache = new Map<string, LoadingComponent | null>();
|
|
192
|
+
const loadingFor = async (file?: string): Promise<LoadingComponent | undefined> => {
|
|
193
|
+
if (!file) return undefined;
|
|
194
|
+
if (!loadingCache.has(file)) loadingCache.set(file, await importLayout(file) as LoadingComponent | null);
|
|
195
|
+
return loadingCache.get(file) ?? undefined;
|
|
196
|
+
};
|
|
197
|
+
|
|
169
198
|
const routes: Record<string, BrandedRoute> = {};
|
|
170
199
|
const dynamicRoutes: Array<{ pattern: string; def: BrandedRoute }> = [];
|
|
171
200
|
const layoutChains: Record<string, LayoutComponent[]> = {};
|
|
201
|
+
const loadings: Record<string, LoadingComponent> = {};
|
|
172
202
|
|
|
173
203
|
for (const r of scanned) {
|
|
174
|
-
const mod =
|
|
175
|
-
|
|
204
|
+
const mod = await import(pathToFileURL(r.file).href);
|
|
205
|
+
const def = routeFromModule(mod);
|
|
206
|
+
if (!def) continue;
|
|
176
207
|
const chain = await componentsFor(r.layouts);
|
|
208
|
+
const loading = await loadingFor(r.loading);
|
|
177
209
|
if (r.dynamic) {
|
|
178
|
-
dynamicRoutes.push({ pattern: r.path, def
|
|
210
|
+
dynamicRoutes.push({ pattern: r.path, def });
|
|
179
211
|
layoutChains[r.path] = chain;
|
|
180
212
|
} else {
|
|
181
|
-
routes[r.path] =
|
|
213
|
+
routes[r.path] = def;
|
|
182
214
|
layoutChains[r.path] = chain;
|
|
183
215
|
}
|
|
216
|
+
if (loading) loadings[r.path] = loading;
|
|
184
217
|
}
|
|
185
218
|
|
|
186
219
|
let extra: ExtraHandler | undefined;
|
|
@@ -194,6 +227,7 @@ export async function buildManifest(appRoot: string): Promise<WorkerManifest> {
|
|
|
194
227
|
routes,
|
|
195
228
|
dynamicRoutes,
|
|
196
229
|
layoutChains,
|
|
230
|
+
loadings,
|
|
197
231
|
document: frozen.document,
|
|
198
232
|
agent: frozen.agent,
|
|
199
233
|
earlyHints: frozen.earlyHints,
|
|
@@ -222,9 +256,75 @@ export async function juneBuild(
|
|
|
222
256
|
if (routes.length === 0) throw new Error(`no page.* routes found under ${appDir}`);
|
|
223
257
|
|
|
224
258
|
const frozen = await freezeConfig(appRoot);
|
|
259
|
+
// The deploy adapter packages the portable build for its target (default:
|
|
260
|
+
// built-in workers()). It contributes the entry's export wrapper + emits the
|
|
261
|
+
// deploy config.
|
|
262
|
+
const fullConfig = await loadJuneConfig(appRoot);
|
|
263
|
+
const adapter = (fullConfig.deploy?.adapter as JuneAdapter | undefined) ?? workers();
|
|
264
|
+
|
|
265
|
+
// Fail fast on a config the target can't honor (e.g. Vercel has no D1) BEFORE
|
|
266
|
+
// the expensive bundle/prerender. The adapter only needs to know which
|
|
267
|
+
// resources are declared, so a presence-only plan suffices here.
|
|
268
|
+
adapter.validate?.({
|
|
269
|
+
plan: { db: fullConfig.resources?.db ? { binding: "DB", databaseName: "" } : undefined },
|
|
270
|
+
config: fullConfig,
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
// Compile the global stylesheet ONCE and content-hash it: the built worker and
|
|
274
|
+
// prerendered HTML link `/global.<hash>.css`, served immutable (cache forever,
|
|
275
|
+
// a content change ships a new URL → no revalidation, no stale window). Dev
|
|
276
|
+
// keeps the stable /global.css; only the asset HREF diverges dev↔built, never
|
|
277
|
+
// render semantics. freezeConfig + buildManifest both default styles to the
|
|
278
|
+
// stable URL — override both with the hashed one.
|
|
279
|
+
const cssOut = await processCss(appDir, { minify: true });
|
|
280
|
+
let cssAsset: string | null = null;
|
|
281
|
+
if (cssOut !== null) {
|
|
282
|
+
const hash = createHash("sha256").update(cssOut).digest("hex").slice(0, 8);
|
|
283
|
+
cssAsset = `_june/global.${hash}.css`; // under the reserved /_june/ prefix
|
|
284
|
+
frozen.document.styles = `/${cssAsset}`;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// CSS Modules: glob + transform app/**/*.module.css ONCE → the per-file class
|
|
288
|
+
// maps (the bundlers + dev loaders look these up) AND the collected stylesheet,
|
|
289
|
+
// which is content-hashed + emitted + linked just like global.css.
|
|
290
|
+
const { maps: cssModuleMaps, css: rawModuleCss } = await buildModuleCss(appDir, appRoot);
|
|
291
|
+
// Minify for build (dev serves it readable). Scoped class names are untouched,
|
|
292
|
+
// so the hashed sheet still matches the maps the bundlers/loaders hand out.
|
|
293
|
+
const moduleCss = rawModuleCss === null ? null : await minifyCss(rawModuleCss, "modules.css");
|
|
294
|
+
let moduleCssAsset: string | null = null;
|
|
295
|
+
if (moduleCss !== null) {
|
|
296
|
+
const hash = createHash("sha256").update(moduleCss).digest("hex").slice(0, 8);
|
|
297
|
+
moduleCssAsset = `_june/modules.${hash}.css`;
|
|
298
|
+
frozen.document.moduleStyles = `/${moduleCssAsset}`;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Same for the client islands bundle: build + content-hash it NOW (before the
|
|
302
|
+
// entry codegen + prerender) so both freeze the hashed /_june/client.<hash>.js
|
|
303
|
+
// and it can be served immutable. The asset is written here. The client may
|
|
304
|
+
// import .module.css too, so it gets the same module maps.
|
|
305
|
+
const assetsDir = join(outDir, "assets");
|
|
306
|
+
const clientEntry = findClientEntry(appDir);
|
|
307
|
+
let clientAsset: string | null = null;
|
|
308
|
+
if (clientEntry) {
|
|
309
|
+
clientAsset = await bundleClientToFile(clientEntry, appRoot, assetsDir, cssModuleMaps);
|
|
310
|
+
frozen.document.clientScript = `/${clientAsset}`;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Declared resources become two things: a build-time plan (→ platform bindings
|
|
314
|
+
// the adapter emits) and a runtime provider wired into the generated entry.
|
|
315
|
+
// A resource-less app imports no config and emits no bindings, so its output
|
|
316
|
+
// is byte-identical to before — the parity guarantee holds.
|
|
317
|
+
const resourcesCfg = fullConfig.resources;
|
|
318
|
+
const hasResources = !!(resourcesCfg?.db || resourcesCfg?.kv || resourcesCfg?.blob);
|
|
225
319
|
|
|
226
320
|
// ---- generated entry -----------------------------------------------------
|
|
227
|
-
|
|
321
|
+
// Routes are namespace-imported and adapted with routeFromModule, so the
|
|
322
|
+
// multi-export page shape (default view + named loader/json/md) and the legacy
|
|
323
|
+
// route({}) default export both work.
|
|
324
|
+
const imports: string[] = [
|
|
325
|
+
`import { createWorker } from "@junejs/server/worker";`,
|
|
326
|
+
`import { routeFromModule } from "@junejs/core/route";`,
|
|
327
|
+
];
|
|
228
328
|
const statics: string[] = [];
|
|
229
329
|
const dynamics: string[] = [];
|
|
230
330
|
const layoutIds = new Map<string, string>();
|
|
@@ -237,12 +337,24 @@ export async function juneBuild(
|
|
|
237
337
|
}
|
|
238
338
|
return id;
|
|
239
339
|
};
|
|
340
|
+
const loadingIds = new Map<string, string>();
|
|
341
|
+
const loadingId = (file: string) => {
|
|
342
|
+
let id = loadingIds.get(file);
|
|
343
|
+
if (!id) {
|
|
344
|
+
id = `Ld${loadingIds.size}`;
|
|
345
|
+
loadingIds.set(file, id);
|
|
346
|
+
imports.push(`import ${id} from ${JSON.stringify(importPath(genDir, file))};`);
|
|
347
|
+
}
|
|
348
|
+
return id;
|
|
349
|
+
};
|
|
240
350
|
const chains: string[] = [];
|
|
351
|
+
const loadings: string[] = [];
|
|
241
352
|
routes.forEach((r, i) => {
|
|
242
|
-
imports.push(`import r${i} from ${JSON.stringify(importPath(genDir, r.file))};`);
|
|
243
|
-
if (r.dynamic) dynamics.push(` { pattern: ${JSON.stringify(r.path)}, def: r${i} },`);
|
|
244
|
-
else statics.push(` ${JSON.stringify(r.path)}: r${i},`);
|
|
353
|
+
imports.push(`import * as r${i} from ${JSON.stringify(importPath(genDir, r.file))};`);
|
|
354
|
+
if (r.dynamic) dynamics.push(` { pattern: ${JSON.stringify(r.path)}, def: routeFromModule(r${i}) },`);
|
|
355
|
+
else statics.push(` ${JSON.stringify(r.path)}: routeFromModule(r${i}),`);
|
|
245
356
|
chains.push(` ${JSON.stringify(r.path)}: [${r.layouts.map(layoutId).join(", ")}],`);
|
|
357
|
+
if (r.loading) loadings.push(` ${JSON.stringify(r.path)}: ${loadingId(r.loading)},`);
|
|
246
358
|
});
|
|
247
359
|
|
|
248
360
|
const builtExtraFile = findExtraFile(appDir);
|
|
@@ -250,10 +362,33 @@ export async function juneBuild(
|
|
|
250
362
|
imports.push(`import extra from ${JSON.stringify(importPath(genDir, builtExtraFile))};`);
|
|
251
363
|
}
|
|
252
364
|
|
|
365
|
+
// The Link header is frozen here from the same builder the pipeline uses, so
|
|
366
|
+
// the static and dynamic surfaces advertise identically. The adapter wraps the
|
|
367
|
+
// portable pipeline for its target (workers() → withAssets).
|
|
368
|
+
const linkHeader = buildLinkHeader(frozen.agent);
|
|
369
|
+
const adapterEntry = adapter.entry({ linkHeader });
|
|
370
|
+
|
|
371
|
+
// Resources (when declared) are bound from the worker's env (env.DB → D1) by
|
|
372
|
+
// an env-aware provider. We bake a pure FLAGS descriptor — never importing the
|
|
373
|
+
// user's config, which would drag the host-only sqlite()/dev server into the
|
|
374
|
+
// workerd bundle. Only emitted when something is declared, so resource-less
|
|
375
|
+
// bundles stay byte-identical (and host code never enters the graph).
|
|
376
|
+
const resourceFlags = {
|
|
377
|
+
db: !!resourcesCfg?.db,
|
|
378
|
+
kv: !!resourcesCfg?.kv,
|
|
379
|
+
blob: !!resourcesCfg?.blob,
|
|
380
|
+
};
|
|
381
|
+
if (hasResources) {
|
|
382
|
+
imports.push(`import { bindWorkerResources } from "@junejs/server/resources";`);
|
|
383
|
+
}
|
|
384
|
+
const resourcesField = hasResources
|
|
385
|
+
? `\n resources: bindWorkerResources(${JSON.stringify(resourceFlags)}),`
|
|
386
|
+
: "";
|
|
253
387
|
const entry = `// AUTO-GENERATED by \`june build\` — do not edit. Regenerate: june build .
|
|
388
|
+
${adapterEntry.imports.join("\n")}
|
|
254
389
|
${imports.join("\n")}
|
|
255
390
|
|
|
256
|
-
|
|
391
|
+
const pipeline = createWorker({
|
|
257
392
|
routes: {
|
|
258
393
|
${statics.join("\n")}
|
|
259
394
|
},
|
|
@@ -262,11 +397,16 @@ ${dynamics.join("\n")}
|
|
|
262
397
|
],
|
|
263
398
|
layoutChains: {
|
|
264
399
|
${chains.join("\n")}
|
|
400
|
+
},
|
|
401
|
+
loadings: {
|
|
402
|
+
${loadings.join("\n")}
|
|
265
403
|
},
|
|
266
404
|
document: ${JSON.stringify(frozen.document, null, 2).replace(/\n/g, "\n ")},
|
|
267
405
|
agent: ${JSON.stringify(frozen.agent)},
|
|
268
|
-
earlyHints: ${JSON.stringify(frozen.earlyHints)},${builtExtraFile ? "\n extra," : ""}
|
|
406
|
+
earlyHints: ${JSON.stringify(frozen.earlyHints)},${builtExtraFile ? "\n extra," : ""}${resourcesField}
|
|
269
407
|
});
|
|
408
|
+
|
|
409
|
+
${adapterEntry.wrap("pipeline")}
|
|
270
410
|
`;
|
|
271
411
|
const entryFile = join(genDir, "worker-entry.tsx");
|
|
272
412
|
await writeFile(entryFile, entry);
|
|
@@ -277,6 +417,7 @@ ${chains.join("\n")}
|
|
|
277
417
|
input: entryFile,
|
|
278
418
|
cwd: appRoot,
|
|
279
419
|
platform: "browser", // workerd's surface is web-standard; no node:* in the graph
|
|
420
|
+
plugins: [rolldownCssModulesPlugin(cssModuleMaps)], // .module.css → scoped class map
|
|
280
421
|
external: (id: string) => {
|
|
281
422
|
// Binary assets stay external — wrangler's CompiledWasm/Data rules own them.
|
|
282
423
|
if (/\.(wasm|ttf|otf|woff2?|png|jpe?g|avif|webp)$/.test(id)) return true;
|
|
@@ -286,8 +427,9 @@ ${chains.join("\n")}
|
|
|
286
427
|
return list.some((e) => id === e || id.startsWith(`${e}/`));
|
|
287
428
|
},
|
|
288
429
|
resolve: {
|
|
289
|
-
// Conditions BAKED at build (
|
|
290
|
-
|
|
430
|
+
// Conditions BAKED at build (the target has no runtime conditions, reminder
|
|
431
|
+
// #3). Adapter-owned: workers → workerd, vercel → edge-light.
|
|
432
|
+
conditionNames: adapter.conditions,
|
|
291
433
|
},
|
|
292
434
|
});
|
|
293
435
|
const result = await bundle.write({ dir: outDir, format: "esm", entryFileNames: "worker.js" });
|
|
@@ -301,20 +443,29 @@ ${chains.join("\n")}
|
|
|
301
443
|
// Same render path as the bundle (createWorker over the frozen manifest), so
|
|
302
444
|
// what ships is what the parity test verified.
|
|
303
445
|
const prerendered: string[] = [];
|
|
446
|
+
// Prerender imports route modules in-process, so the runtime CSS-Modules
|
|
447
|
+
// interceptor must be active for any route that imports a .module.css.
|
|
448
|
+
await registerCssModules(cssModuleMaps);
|
|
304
449
|
const manifest = await buildManifest(appRoot);
|
|
450
|
+
if (cssAsset) manifest.document.styles = `/${cssAsset}`; // prerendered HTML links the hashed sheet
|
|
451
|
+
if (clientAsset) manifest.document.clientScript = `/${clientAsset}`;
|
|
452
|
+
if (moduleCssAsset) manifest.document.moduleStyles = `/${moduleCssAsset}`;
|
|
305
453
|
const worker = createWorker(manifest);
|
|
306
|
-
const assetsDir = join(outDir, "assets");
|
|
307
454
|
let hasAssets = false;
|
|
308
455
|
|
|
309
456
|
for (const r of routes.filter((x) => !x.dynamic)) {
|
|
310
457
|
const def = manifest.routes[r.path];
|
|
311
458
|
if (!def || !def.prerender) continue;
|
|
312
459
|
const stem = r.path === "/" ? "index" : r.path.slice(1);
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
460
|
+
// The homepage's projection requests are `/.md` / `/.json` (negotiate strips
|
|
461
|
+
// the ext back to `/`); withAssets serves them for `Accept: text/markdown`.
|
|
462
|
+
const mdReq = r.path === "/" ? "/.md" : `${r.path}.md`;
|
|
463
|
+
const jsonReq = r.path === "/" ? "/.json" : `${r.path}.json`;
|
|
464
|
+
const targets: Array<[string, string]> = [
|
|
465
|
+
[r.path, `${stem}.html`],
|
|
466
|
+
[mdReq, `${stem}.md`],
|
|
467
|
+
];
|
|
468
|
+
if (typeof def.json === "function") targets.push([jsonReq, `${stem}.json`]);
|
|
318
469
|
for (const [reqPath, file] of targets) {
|
|
319
470
|
const res = await worker.fetch(new Request(`https://prerender.june${reqPath}`));
|
|
320
471
|
if (!res.ok) throw new Error(`prerender ${reqPath} → ${res.status}`);
|
|
@@ -326,43 +477,42 @@ ${chains.join("\n")}
|
|
|
326
477
|
hasAssets = true;
|
|
327
478
|
}
|
|
328
479
|
|
|
329
|
-
// ---- client islands bundle:
|
|
330
|
-
//
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
480
|
+
// ---- client islands bundle: built + content-hashed earlier (assets/_june/
|
|
481
|
+
// client.<hash>.js, frozen into the document). No entry → page ships zero JS.
|
|
482
|
+
if (clientAsset) hasAssets = true;
|
|
483
|
+
|
|
484
|
+
// ---- global stylesheet: app/global.css → assets/global.css ---------------
|
|
485
|
+
// Served at /global.css; the frozen document already <link>s it. Compiled
|
|
486
|
+
// (Tailwind) or passed through (plain CSS). No file → no asset.
|
|
487
|
+
if (cssOut !== null && cssAsset) {
|
|
488
|
+
const dest = join(assetsDir, cssAsset);
|
|
489
|
+
await mkdir(dirname(dest), { recursive: true }); // cssAsset includes the _june/ subdir
|
|
490
|
+
await writeFile(dest, cssOut);
|
|
491
|
+
hasAssets = true;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// ---- collected CSS Modules: app/**/*.module.css → assets/_june/modules.<hash>.css
|
|
495
|
+
if (moduleCss !== null && moduleCssAsset) {
|
|
496
|
+
const dest = join(assetsDir, moduleCssAsset);
|
|
497
|
+
await mkdir(dirname(dest), { recursive: true });
|
|
498
|
+
await writeFile(dest, moduleCss);
|
|
335
499
|
hasAssets = true;
|
|
336
500
|
}
|
|
337
501
|
|
|
338
|
-
// ----
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
main: "./worker.js",
|
|
353
|
-
compatibility_date: "2025-01-01",
|
|
354
|
-
compatibility_flags: ["nodejs_compat"],
|
|
355
|
-
...(hasAssets ? { assets: { directory: "./assets" } } : {}),
|
|
356
|
-
// config deploy.domain → a Workers custom domain; without it the
|
|
357
|
-
// generated file regenerating on every build would silently drop a
|
|
358
|
-
// hand-attached domain.
|
|
359
|
-
...(deployCfg?.domain
|
|
360
|
-
? { routes: [{ pattern: deployCfg.domain, custom_domain: true }] }
|
|
361
|
-
: {}),
|
|
362
|
-
},
|
|
363
|
-
null,
|
|
364
|
-
2,
|
|
365
|
-
) + "\n",
|
|
502
|
+
// ---- deploy structure (adapter-owned: wrangler.jsonc for workers) --------
|
|
503
|
+
const pkgPath = join(appRoot, "package.json");
|
|
504
|
+
const pkgName = existsSync(pkgPath)
|
|
505
|
+
? (JSON.parse(await Bun.file(pkgPath).text()) as { name?: string }).name
|
|
506
|
+
: undefined;
|
|
507
|
+
const defaultName = (pkgName ?? basename(appRoot)).replace(/[^a-z0-9-]/gi, "-").toLowerCase();
|
|
508
|
+
// A declared `db` → a D1 binding named DB (the name bindWorkerResources reads).
|
|
509
|
+
const plan: ResourcePlan = resourcesCfg?.db
|
|
510
|
+
? { db: { binding: "DB", databaseName: `${defaultName}-db` } }
|
|
511
|
+
: {};
|
|
512
|
+
await adapter.emit({ appRoot, outDir, hasAssets, linkHeader, config: fullConfig, plan, defaultName });
|
|
513
|
+
if (plan.db) {
|
|
514
|
+
console.log(
|
|
515
|
+
` ↳ d1 binding "${plan.db.binding}" emitted — set database_id in wrangler.jsonc (wrangler d1 create ${plan.db.databaseName})`,
|
|
366
516
|
);
|
|
367
517
|
}
|
|
368
518
|
|
package/src/client-bundle.ts
CHANGED
|
@@ -18,13 +18,16 @@
|
|
|
18
18
|
// Host-coupled (node:fs + Rolldown), so it lives in @junejs/server, never the pure
|
|
19
19
|
// contract layer.
|
|
20
20
|
|
|
21
|
+
import { createHash } from "node:crypto";
|
|
21
22
|
import { existsSync } from "node:fs";
|
|
22
|
-
import { mkdir } from "node:fs/promises";
|
|
23
|
-
import { join } from "node:path";
|
|
23
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
24
|
+
import { dirname, join } from "node:path";
|
|
25
|
+
|
|
26
|
+
import { rolldownCssModulesPlugin, type ModuleMaps } from "./css-modules";
|
|
24
27
|
|
|
25
28
|
// The URL the document loads + the asset path the bundle is written to. Single
|
|
26
29
|
// source of truth so build (freeze) and dev (live) agree.
|
|
27
|
-
export const CLIENT_SCRIPT_URL = "/client.js";
|
|
30
|
+
export const CLIENT_SCRIPT_URL = "/_june/client.js";
|
|
28
31
|
const CLIENT_BASENAME = "_client";
|
|
29
32
|
const CLIENT_EXTS = [".tsx", ".ts", ".jsx", ".js"];
|
|
30
33
|
|
|
@@ -39,7 +42,7 @@ export function findClientEntry(appDir: string): string | undefined {
|
|
|
39
42
|
|
|
40
43
|
type BundleMode = "development" | "production";
|
|
41
44
|
|
|
42
|
-
async function bundleClient(entryFile: string, cwd: string, mode: BundleMode) {
|
|
45
|
+
async function bundleClient(entryFile: string, cwd: string, mode: BundleMode, maps: ModuleMaps = {}) {
|
|
43
46
|
const { rolldown } = await import("rolldown");
|
|
44
47
|
const bundle = await rolldown({
|
|
45
48
|
input: entryFile,
|
|
@@ -47,6 +50,7 @@ async function bundleClient(entryFile: string, cwd: string, mode: BundleMode) {
|
|
|
47
50
|
// The client graph is plain web — browser conditions, and React's dev/prod
|
|
48
51
|
// branch resolved at build (no `process` in the browser to read it at runtime).
|
|
49
52
|
platform: "browser",
|
|
53
|
+
plugins: [rolldownCssModulesPlugin(maps)], // islands may import .module.css
|
|
50
54
|
transform: { define: { "process.env.NODE_ENV": JSON.stringify(mode) } },
|
|
51
55
|
resolve: { conditionNames: ["browser", "import", "default"] },
|
|
52
56
|
});
|
|
@@ -54,18 +58,27 @@ async function bundleClient(entryFile: string, cwd: string, mode: BundleMode) {
|
|
|
54
58
|
}
|
|
55
59
|
|
|
56
60
|
// Build the client entry to a single `client.js` string (dev serves this live).
|
|
57
|
-
export async function bundleClientToString(entryFile: string, cwd: string): Promise<string> {
|
|
58
|
-
const bundle = await bundleClient(entryFile, cwd, "development");
|
|
61
|
+
export async function bundleClientToString(entryFile: string, cwd: string, maps: ModuleMaps = {}): Promise<string> {
|
|
62
|
+
const bundle = await bundleClient(entryFile, cwd, "development", maps);
|
|
59
63
|
const { output } = await bundle.generate({ format: "esm", entryFileNames: "client.js" });
|
|
60
64
|
await bundle.close();
|
|
61
65
|
const entry = output.find((o) => o.type === "chunk" && o.isEntry);
|
|
62
66
|
return entry && entry.type === "chunk" ? entry.code : "";
|
|
63
67
|
}
|
|
64
68
|
|
|
65
|
-
// Build the client entry
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
69
|
+
// Build the client entry, content-hash it, and write `<destDir>/_june/client.<hash>.js`
|
|
70
|
+
// (immutable-cacheable, like the hashed CSS). Returns the asset filename
|
|
71
|
+
// (`_june/client.<hash>.js`) so the build can freeze its URL into the document.
|
|
72
|
+
export async function bundleClientToFile(entryFile: string, cwd: string, destDir: string, maps: ModuleMaps = {}): Promise<string> {
|
|
73
|
+
const bundle = await bundleClient(entryFile, cwd, "production", maps);
|
|
74
|
+
const { output } = await bundle.generate({ format: "esm", entryFileNames: "client.js" });
|
|
70
75
|
await bundle.close();
|
|
76
|
+
const entry = output.find((o) => o.type === "chunk" && o.isEntry);
|
|
77
|
+
const code = entry && entry.type === "chunk" ? entry.code : "";
|
|
78
|
+
const hash = createHash("sha256").update(code).digest("hex").slice(0, 8);
|
|
79
|
+
const fileName = `_june/client.${hash}.js`;
|
|
80
|
+
const dest = join(destDir, fileName);
|
|
81
|
+
await mkdir(dirname(dest), { recursive: true });
|
|
82
|
+
await writeFile(dest, code);
|
|
83
|
+
return fileName;
|
|
71
84
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Ambient types for CSS Modules: `import styles from "./X.module.css"` resolves
|
|
2
|
+
// to a map of local class name → scoped name. June ships this so app code (and
|
|
3
|
+
// these tests/fixtures) typecheck; the create-june template references an
|
|
4
|
+
// equivalent june-env.d.ts. (No top-level import/export → this is a GLOBAL
|
|
5
|
+
// declaration, so the ambient module applies everywhere.)
|
|
6
|
+
declare module "*.module.css" {
|
|
7
|
+
const classes: Record<string, string>;
|
|
8
|
+
export default classes;
|
|
9
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Types for the Node ESM CSS-Modules loader (a runtime hook, .mjs).
|
|
2
|
+
export function initialize(data?: { maps?: Record<string, Record<string, string>> }): void;
|
|
3
|
+
export function load(
|
|
4
|
+
url: string,
|
|
5
|
+
context: unknown,
|
|
6
|
+
next: (url: string, context: unknown) => unknown,
|
|
7
|
+
): Promise<{ format: string; source: string; shortCircuit: boolean } | unknown>;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Node ESM loader for CSS Modules (dev). `module.register` runs this in its own
|
|
2
|
+
// context, so it must be a plain, self-contained .mjs — it does NOT transform;
|
|
3
|
+
// it just looks up the precomputed class map (handed in via init data) and
|
|
4
|
+
// returns it as a JS module. The maps come from css-modules.ts's single glob
|
|
5
|
+
// pass, so the served CSS and these maps agree by construction.
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
|
|
8
|
+
let MAPS = {};
|
|
9
|
+
|
|
10
|
+
export function initialize(data) {
|
|
11
|
+
MAPS = (data && data.maps) || {};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function load(url, context, next) {
|
|
15
|
+
if (url.endsWith(".module.css")) {
|
|
16
|
+
const map = MAPS[fileURLToPath(url)] || {};
|
|
17
|
+
return { format: "module", source: "export default " + JSON.stringify(map) + ";", shortCircuit: true };
|
|
18
|
+
}
|
|
19
|
+
return next(url, context);
|
|
20
|
+
}
|