@junejs/server 0.0.9 → 0.0.11
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 +226 -0
- package/src/app.ts +65 -3
- package/src/blob.ts +1 -1
- package/src/build.ts +178 -55
- 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/pipeline.ts +119 -18
- package/src/resources.ts +47 -0
- package/src/sqlite-driver.ts +160 -0
- package/src/worker.ts +41 -13
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";
|
|
@@ -26,12 +27,15 @@ import { loadJuneConfig } from "./config-loader";
|
|
|
26
27
|
import { resolveAgent, resolveSpeculationRules } from "@junejs/core/config";
|
|
27
28
|
import { buildLinkHeader } from "@junejs/core/discovery";
|
|
28
29
|
import { routeFromModule, type BrandedRoute } from "@junejs/core/route";
|
|
30
|
+
import { workers, type JuneAdapter, type ResourcePlan } from "./adapter";
|
|
29
31
|
import type { DocumentConfig } from "@junejs/core/document";
|
|
30
32
|
import { collection } from "./content";
|
|
31
33
|
import { createWorker, type WorkerManifest } from "./worker";
|
|
32
34
|
import { findExtraFile } from "./router";
|
|
33
|
-
import type { ExtraHandler, LayoutComponent } from "./pipeline";
|
|
35
|
+
import type { ExtraHandler, LayoutComponent, LoadingComponent } from "./pipeline";
|
|
34
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";
|
|
35
39
|
|
|
36
40
|
export type BuildResult = {
|
|
37
41
|
outFile: string;
|
|
@@ -43,7 +47,13 @@ export type BuildResult = {
|
|
|
43
47
|
|
|
44
48
|
// The segment layout CHAIN root→leaf: every directory level (route groups
|
|
45
49
|
// included) may contribute a layout.* that wraps routes below it.
|
|
46
|
-
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
|
+
};
|
|
47
57
|
|
|
48
58
|
const PAGE_BASENAMES = new Set(["page", "index"]);
|
|
49
59
|
const ROUTE_EXTS = [".tsx", ".jsx", ".ts", ".js"];
|
|
@@ -62,15 +72,17 @@ export async function scanRoutes(
|
|
|
62
72
|
dir = appDir,
|
|
63
73
|
layouts: string[] = [],
|
|
64
74
|
out: RouteEntry[] = [],
|
|
75
|
+
loading?: string,
|
|
65
76
|
): Promise<RouteEntry[]> {
|
|
66
77
|
const ownLayout = segmentFile(dir, "layout");
|
|
67
78
|
const chain = ownLayout ? [...layouts, ownLayout] : layouts;
|
|
79
|
+
const nearestLoading = segmentFile(dir, "loading") ?? loading;
|
|
68
80
|
const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
69
81
|
for (const e of entries) {
|
|
70
82
|
if (e.name.startsWith("_") || e.name.startsWith(".")) continue;
|
|
71
83
|
const full = join(dir, e.name);
|
|
72
84
|
if (e.isDirectory()) {
|
|
73
|
-
await scanRoutes(appDir, full, chain, out);
|
|
85
|
+
await scanRoutes(appDir, full, chain, out, nearestLoading);
|
|
74
86
|
continue;
|
|
75
87
|
}
|
|
76
88
|
const ext = e.name.match(/\.[^.]+$/)?.[0] ?? "";
|
|
@@ -79,7 +91,13 @@ export async function scanRoutes(
|
|
|
79
91
|
const relDir = relative(appDir, dir);
|
|
80
92
|
const segments = relDir === "" ? [] : relDir.split(sep).filter((s) => !isRouteGroup(s));
|
|
81
93
|
const path = "/" + segments.join("/");
|
|
82
|
-
out.push({
|
|
94
|
+
out.push({
|
|
95
|
+
path: path === "/" ? "/" : path,
|
|
96
|
+
file: full,
|
|
97
|
+
dynamic: /\[.+\]/.test(path),
|
|
98
|
+
layouts: chain,
|
|
99
|
+
loading: nearestLoading,
|
|
100
|
+
});
|
|
83
101
|
}
|
|
84
102
|
return out;
|
|
85
103
|
}
|
|
@@ -129,13 +147,16 @@ export async function freezeConfig(appRoot: string): Promise<{
|
|
|
129
147
|
// which re-freezes through buildManifest — sets the SAME clientScript, keeping
|
|
130
148
|
// prerendered pages byte-equivalent to the live worker (parity).
|
|
131
149
|
const hasClient = findClientEntry(join(appRoot, "app")) !== undefined;
|
|
150
|
+
const hasCss = findGlobalCss(join(appRoot, "app")) !== null;
|
|
132
151
|
return {
|
|
133
152
|
document: {
|
|
134
153
|
site: cfg.site ?? {},
|
|
135
154
|
speculationRules: resolveSpeculationRules(cfg.speculation ?? undefined),
|
|
136
155
|
speculationDelivery: "inline",
|
|
137
156
|
viewTransitions: cfg.viewTransitions ?? true,
|
|
157
|
+
clientRouter: cfg.clientRouter ?? false,
|
|
138
158
|
clientScript: hasClient ? CLIENT_SCRIPT_URL : null,
|
|
159
|
+
styles: hasCss ? STYLES_URL : null,
|
|
139
160
|
},
|
|
140
161
|
agent: resolveAgent(cfg.agent),
|
|
141
162
|
earlyHints: cfg.earlyHints ?? [],
|
|
@@ -167,15 +188,24 @@ export async function buildManifest(appRoot: string): Promise<WorkerManifest> {
|
|
|
167
188
|
return chain;
|
|
168
189
|
};
|
|
169
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
|
+
|
|
170
198
|
const routes: Record<string, BrandedRoute> = {};
|
|
171
199
|
const dynamicRoutes: Array<{ pattern: string; def: BrandedRoute }> = [];
|
|
172
200
|
const layoutChains: Record<string, LayoutComponent[]> = {};
|
|
201
|
+
const loadings: Record<string, LoadingComponent> = {};
|
|
173
202
|
|
|
174
203
|
for (const r of scanned) {
|
|
175
204
|
const mod = await import(pathToFileURL(r.file).href);
|
|
176
205
|
const def = routeFromModule(mod);
|
|
177
206
|
if (!def) continue;
|
|
178
207
|
const chain = await componentsFor(r.layouts);
|
|
208
|
+
const loading = await loadingFor(r.loading);
|
|
179
209
|
if (r.dynamic) {
|
|
180
210
|
dynamicRoutes.push({ pattern: r.path, def });
|
|
181
211
|
layoutChains[r.path] = chain;
|
|
@@ -183,6 +213,7 @@ export async function buildManifest(appRoot: string): Promise<WorkerManifest> {
|
|
|
183
213
|
routes[r.path] = def;
|
|
184
214
|
layoutChains[r.path] = chain;
|
|
185
215
|
}
|
|
216
|
+
if (loading) loadings[r.path] = loading;
|
|
186
217
|
}
|
|
187
218
|
|
|
188
219
|
let extra: ExtraHandler | undefined;
|
|
@@ -196,6 +227,7 @@ export async function buildManifest(appRoot: string): Promise<WorkerManifest> {
|
|
|
196
227
|
routes,
|
|
197
228
|
dynamicRoutes,
|
|
198
229
|
layoutChains,
|
|
230
|
+
loadings,
|
|
199
231
|
document: frozen.document,
|
|
200
232
|
agent: frozen.agent,
|
|
201
233
|
earlyHints: frozen.earlyHints,
|
|
@@ -224,6 +256,66 @@ export async function juneBuild(
|
|
|
224
256
|
if (routes.length === 0) throw new Error(`no page.* routes found under ${appDir}`);
|
|
225
257
|
|
|
226
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);
|
|
227
319
|
|
|
228
320
|
// ---- generated entry -----------------------------------------------------
|
|
229
321
|
// Routes are namespace-imported and adapted with routeFromModule, so the
|
|
@@ -245,12 +337,24 @@ export async function juneBuild(
|
|
|
245
337
|
}
|
|
246
338
|
return id;
|
|
247
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
|
+
};
|
|
248
350
|
const chains: string[] = [];
|
|
351
|
+
const loadings: string[] = [];
|
|
249
352
|
routes.forEach((r, i) => {
|
|
250
353
|
imports.push(`import * as r${i} from ${JSON.stringify(importPath(genDir, r.file))};`);
|
|
251
354
|
if (r.dynamic) dynamics.push(` { pattern: ${JSON.stringify(r.path)}, def: routeFromModule(r${i}) },`);
|
|
252
355
|
else statics.push(` ${JSON.stringify(r.path)}: routeFromModule(r${i}),`);
|
|
253
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)},`);
|
|
254
358
|
});
|
|
255
359
|
|
|
256
360
|
const builtExtraFile = findExtraFile(appDir);
|
|
@@ -258,14 +362,30 @@ export async function juneBuild(
|
|
|
258
362
|
imports.push(`import extra from ${JSON.stringify(importPath(genDir, builtExtraFile))};`);
|
|
259
363
|
}
|
|
260
364
|
|
|
261
|
-
// The
|
|
262
|
-
// the
|
|
263
|
-
//
|
|
264
|
-
// layer can't do. The Link header is frozen here from the same builder the
|
|
265
|
-
// pipeline uses, so the static and dynamic surfaces advertise identically.
|
|
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).
|
|
266
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
|
+
: "";
|
|
267
387
|
const entry = `// AUTO-GENERATED by \`june build\` — do not edit. Regenerate: june build .
|
|
268
|
-
|
|
388
|
+
${adapterEntry.imports.join("\n")}
|
|
269
389
|
${imports.join("\n")}
|
|
270
390
|
|
|
271
391
|
const pipeline = createWorker({
|
|
@@ -277,13 +397,16 @@ ${dynamics.join("\n")}
|
|
|
277
397
|
],
|
|
278
398
|
layoutChains: {
|
|
279
399
|
${chains.join("\n")}
|
|
400
|
+
},
|
|
401
|
+
loadings: {
|
|
402
|
+
${loadings.join("\n")}
|
|
280
403
|
},
|
|
281
404
|
document: ${JSON.stringify(frozen.document, null, 2).replace(/\n/g, "\n ")},
|
|
282
405
|
agent: ${JSON.stringify(frozen.agent)},
|
|
283
|
-
earlyHints: ${JSON.stringify(frozen.earlyHints)},${builtExtraFile ? "\n extra," : ""}
|
|
406
|
+
earlyHints: ${JSON.stringify(frozen.earlyHints)},${builtExtraFile ? "\n extra," : ""}${resourcesField}
|
|
284
407
|
});
|
|
285
408
|
|
|
286
|
-
|
|
409
|
+
${adapterEntry.wrap("pipeline")}
|
|
287
410
|
`;
|
|
288
411
|
const entryFile = join(genDir, "worker-entry.tsx");
|
|
289
412
|
await writeFile(entryFile, entry);
|
|
@@ -294,6 +417,7 @@ export default withAssets(pipeline, { link: ${JSON.stringify(linkHeader)} });
|
|
|
294
417
|
input: entryFile,
|
|
295
418
|
cwd: appRoot,
|
|
296
419
|
platform: "browser", // workerd's surface is web-standard; no node:* in the graph
|
|
420
|
+
plugins: [rolldownCssModulesPlugin(cssModuleMaps)], // .module.css → scoped class map
|
|
297
421
|
external: (id: string) => {
|
|
298
422
|
// Binary assets stay external — wrangler's CompiledWasm/Data rules own them.
|
|
299
423
|
if (/\.(wasm|ttf|otf|woff2?|png|jpe?g|avif|webp)$/.test(id)) return true;
|
|
@@ -303,8 +427,9 @@ export default withAssets(pipeline, { link: ${JSON.stringify(linkHeader)} });
|
|
|
303
427
|
return list.some((e) => id === e || id.startsWith(`${e}/`));
|
|
304
428
|
},
|
|
305
429
|
resolve: {
|
|
306
|
-
// Conditions BAKED at build (
|
|
307
|
-
|
|
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,
|
|
308
433
|
},
|
|
309
434
|
});
|
|
310
435
|
const result = await bundle.write({ dir: outDir, format: "esm", entryFileNames: "worker.js" });
|
|
@@ -318,9 +443,14 @@ export default withAssets(pipeline, { link: ${JSON.stringify(linkHeader)} });
|
|
|
318
443
|
// Same render path as the bundle (createWorker over the frozen manifest), so
|
|
319
444
|
// what ships is what the parity test verified.
|
|
320
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);
|
|
321
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}`;
|
|
322
453
|
const worker = createWorker(manifest);
|
|
323
|
-
const assetsDir = join(outDir, "assets");
|
|
324
454
|
let hasAssets = false;
|
|
325
455
|
|
|
326
456
|
for (const r of routes.filter((x) => !x.dynamic)) {
|
|
@@ -347,49 +477,42 @@ export default withAssets(pipeline, { link: ${JSON.stringify(linkHeader)} });
|
|
|
347
477
|
hasAssets = true;
|
|
348
478
|
}
|
|
349
479
|
|
|
350
|
-
// ---- client islands bundle:
|
|
351
|
-
//
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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);
|
|
356
499
|
hasAssets = true;
|
|
357
500
|
}
|
|
358
501
|
|
|
359
|
-
// ----
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
main: "./worker.js",
|
|
374
|
-
compatibility_date: "2025-01-01",
|
|
375
|
-
compatibility_flags: ["nodejs_compat"],
|
|
376
|
-
// run_worker_first: the worker wraps asset serving (withAssets) so
|
|
377
|
-
// prerendered pages still get Link headers + Accept:markdown
|
|
378
|
-
// negotiation. The ASSETS binding lets the worker serve the static
|
|
379
|
-
// files itself.
|
|
380
|
-
...(hasAssets
|
|
381
|
-
? { assets: { directory: "./assets", binding: "ASSETS", run_worker_first: true } }
|
|
382
|
-
: {}),
|
|
383
|
-
// config deploy.domain → a Workers custom domain; without it the
|
|
384
|
-
// generated file regenerating on every build would silently drop a
|
|
385
|
-
// hand-attached domain.
|
|
386
|
-
...(deployCfg?.domain
|
|
387
|
-
? { routes: [{ pattern: deployCfg.domain, custom_domain: true }] }
|
|
388
|
-
: {}),
|
|
389
|
-
},
|
|
390
|
-
null,
|
|
391
|
-
2,
|
|
392
|
-
) + "\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})`,
|
|
393
516
|
);
|
|
394
517
|
}
|
|
395
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
|
+
}
|