@junejs/server 0.0.2
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/LICENSE +21 -0
- package/README.md +17 -0
- package/package.json +46 -0
- package/src/app.ts +155 -0
- package/src/blob.ts +99 -0
- package/src/build.ts +367 -0
- package/src/client-bundle.ts +71 -0
- package/src/config-loader.ts +30 -0
- package/src/content.ts +102 -0
- package/src/db.ts +61 -0
- package/src/deploy.ts +72 -0
- package/src/dev-reload.ts +77 -0
- package/src/dev.ts +41 -0
- package/src/host.ts +234 -0
- package/src/index.ts +42 -0
- package/src/instrumentation.ts +33 -0
- package/src/kv.ts +34 -0
- package/src/negotiate.ts +57 -0
- package/src/pipeline.ts +248 -0
- package/src/resources.ts +28 -0
- package/src/router.ts +263 -0
- package/src/worker.ts +101 -0
package/src/build.ts
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
// `june build` — produce a Workers-ready bundle from a June app.
|
|
2
|
+
//
|
|
3
|
+
// What the dev server discovers at REQUEST time (filesystem routes,
|
|
4
|
+
// june.config.ts, content/ markdown), the build discovers ONCE and FREEZES into
|
|
5
|
+
// a static manifest fed to createWorker(). The built worker renders through the
|
|
6
|
+
// SAME pipeline as dev (pipeline.ts), so its surfaces are byte-equivalent —
|
|
7
|
+
// proven by test/parity.test.ts, not hoped for.
|
|
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.
|
|
15
|
+
//
|
|
16
|
+
// REMINDER #4: nothing in the worker graph may statically import node:*. The
|
|
17
|
+
// content freeze (content/*.md → app/_content.ts) is what removes fs from the
|
|
18
|
+
// dynamic route's graph; the worker reads frozen data, never the filesystem.
|
|
19
|
+
|
|
20
|
+
import { existsSync } from "node:fs";
|
|
21
|
+
import { mkdir, readdir, rm, writeFile } from "node:fs/promises";
|
|
22
|
+
import { basename, dirname, join, relative, sep } from "node:path";
|
|
23
|
+
import { pathToFileURL } from "node:url";
|
|
24
|
+
|
|
25
|
+
import { loadJuneConfig } from "./config-loader";
|
|
26
|
+
import { resolveAgent, resolveSpeculationRules } from "@junejs/core/config";
|
|
27
|
+
import { isRouteDefinition, type BrandedRoute } from "@junejs/core/route";
|
|
28
|
+
import type { DocumentConfig } from "@junejs/core/document";
|
|
29
|
+
import { collection } from "./content";
|
|
30
|
+
import { createWorker, type WorkerManifest } from "./worker";
|
|
31
|
+
import { findExtraFile } from "./router";
|
|
32
|
+
import type { ExtraHandler, LayoutComponent } from "./pipeline";
|
|
33
|
+
import { findClientEntry, bundleClientToFile, CLIENT_SCRIPT_URL } from "./client-bundle";
|
|
34
|
+
|
|
35
|
+
export type BuildResult = {
|
|
36
|
+
outFile: string;
|
|
37
|
+
routes: string[];
|
|
38
|
+
dynamicRoutes: string[];
|
|
39
|
+
contentCollections: string[];
|
|
40
|
+
prerendered: string[];
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// The segment layout CHAIN root→leaf: every directory level (route groups
|
|
44
|
+
// included) may contribute a layout.* that wraps routes below it.
|
|
45
|
+
type RouteEntry = { path: string; file: string; dynamic: boolean; layouts: string[] };
|
|
46
|
+
|
|
47
|
+
const PAGE_BASENAMES = new Set(["page", "index"]);
|
|
48
|
+
const ROUTE_EXTS = [".tsx", ".jsx", ".ts", ".js"];
|
|
49
|
+
|
|
50
|
+
const isRouteGroup = (name: string) => /^\(.+\)$/.test(name);
|
|
51
|
+
|
|
52
|
+
function segmentFile(dir: string, base: string): string | undefined {
|
|
53
|
+
return ROUTE_EXTS.map((e) => join(dir, `${base}${e}`)).find(existsSync);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Walk app/ for page.* files → route paths (mirrors router.ts conventions:
|
|
57
|
+
// route groups vanish from URLs, `_`-prefixed entries are private), carrying the
|
|
58
|
+
// layout chain accumulated from each directory level.
|
|
59
|
+
export async function scanRoutes(
|
|
60
|
+
appDir: string,
|
|
61
|
+
dir = appDir,
|
|
62
|
+
layouts: string[] = [],
|
|
63
|
+
out: RouteEntry[] = [],
|
|
64
|
+
): Promise<RouteEntry[]> {
|
|
65
|
+
const ownLayout = segmentFile(dir, "layout");
|
|
66
|
+
const chain = ownLayout ? [...layouts, ownLayout] : layouts;
|
|
67
|
+
const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
68
|
+
for (const e of entries) {
|
|
69
|
+
if (e.name.startsWith("_") || e.name.startsWith(".")) continue;
|
|
70
|
+
const full = join(dir, e.name);
|
|
71
|
+
if (e.isDirectory()) {
|
|
72
|
+
await scanRoutes(appDir, full, chain, out);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const ext = e.name.match(/\.[^.]+$/)?.[0] ?? "";
|
|
76
|
+
if (!ROUTE_EXTS.includes(ext)) continue;
|
|
77
|
+
if (!PAGE_BASENAMES.has(basename(e.name, ext))) continue;
|
|
78
|
+
const relDir = relative(appDir, dir);
|
|
79
|
+
const segments = relDir === "" ? [] : relDir.split(sep).filter((s) => !isRouteGroup(s));
|
|
80
|
+
const path = "/" + segments.join("/");
|
|
81
|
+
out.push({ path: path === "/" ? "/" : path, file: full, dynamic: /\[.+\]/.test(path), layouts: chain });
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// content/<collection>/*.md → app/_content.ts (the build-time content manifest).
|
|
87
|
+
// This is the FREEZE that removes node:fs from the worker graph: routes import
|
|
88
|
+
// frozen entries from ./_content instead of reading the filesystem at request.
|
|
89
|
+
export async function generateContent(appRoot: string): Promise<string[]> {
|
|
90
|
+
const contentDir = join(appRoot, "content");
|
|
91
|
+
if (!existsSync(contentDir)) return [];
|
|
92
|
+
const dirs = (await readdir(contentDir, { withFileTypes: true })).filter((e) => e.isDirectory());
|
|
93
|
+
if (dirs.length === 0) return [];
|
|
94
|
+
|
|
95
|
+
let out =
|
|
96
|
+
"// AUTO-GENERATED by `june build` — edit content/**/*.md, not this file.\n" +
|
|
97
|
+
"export type ContentEntry = { slug: string; data: Record<string, string | string[]>; body: string; original: string; html: string };\n";
|
|
98
|
+
const names: string[] = [];
|
|
99
|
+
for (const d of dirs) {
|
|
100
|
+
const entries = collection(join(contentDir, d.name)).map(({ slug, data, body, original, html }) => ({
|
|
101
|
+
slug,
|
|
102
|
+
data,
|
|
103
|
+
body,
|
|
104
|
+
original,
|
|
105
|
+
html,
|
|
106
|
+
}));
|
|
107
|
+
const constName = d.name.replace(/[^A-Za-z0-9]/g, "_").toUpperCase();
|
|
108
|
+
// Singular finder name: posts → post, otherwise <name>Entry.
|
|
109
|
+
const finder = d.name.endsWith("s") ? d.name.slice(0, -1) : `${d.name}Entry`;
|
|
110
|
+
out += `export const ${constName}: ContentEntry[] = ${JSON.stringify(entries, null, 2)};\n`;
|
|
111
|
+
out += `export const ${finder} = (slug: string): ContentEntry | null => ${constName}.find((p) => p.slug === slug) ?? null;\n`;
|
|
112
|
+
names.push(d.name);
|
|
113
|
+
}
|
|
114
|
+
await writeFile(join(appRoot, "app", "_content.ts"), out);
|
|
115
|
+
return names;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Freeze june.config.ts → the serializable bits the worker inlines.
|
|
119
|
+
export async function freezeConfig(appRoot: string): Promise<{
|
|
120
|
+
document: DocumentConfig;
|
|
121
|
+
agent: WorkerManifest["agent"];
|
|
122
|
+
earlyHints: string[];
|
|
123
|
+
buildExternal: string[];
|
|
124
|
+
}> {
|
|
125
|
+
const cfg = await loadJuneConfig(appRoot);
|
|
126
|
+
// An app with a client entry gets the islands runtime URL frozen into its
|
|
127
|
+
// document. Detected HERE (not just in juneBuild) so the prerender path —
|
|
128
|
+
// which re-freezes through buildManifest — sets the SAME clientScript, keeping
|
|
129
|
+
// prerendered pages byte-equivalent to the live worker (parity).
|
|
130
|
+
const hasClient = findClientEntry(join(appRoot, "app")) !== undefined;
|
|
131
|
+
return {
|
|
132
|
+
document: {
|
|
133
|
+
site: cfg.site ?? {},
|
|
134
|
+
speculationRules: resolveSpeculationRules(cfg.speculation ?? undefined),
|
|
135
|
+
speculationDelivery: "inline",
|
|
136
|
+
viewTransitions: cfg.viewTransitions ?? true,
|
|
137
|
+
clientScript: hasClient ? CLIENT_SCRIPT_URL : null,
|
|
138
|
+
},
|
|
139
|
+
agent: resolveAgent(cfg.agent),
|
|
140
|
+
earlyHints: cfg.earlyHints ?? [],
|
|
141
|
+
buildExternal: cfg.build?.external ?? [],
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function importLayout(file: string): Promise<LayoutComponent | null> {
|
|
146
|
+
const mod = (await import(pathToFileURL(file).href)) as { default?: LayoutComponent };
|
|
147
|
+
return typeof mod.default === "function" ? mod.default : null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// The FREEZE, in-process: import route modules + layouts, build the manifest a
|
|
151
|
+
// createWorker() can run immediately. Used by prerender and by the parity test
|
|
152
|
+
// (its render path is identical to the Rolldown-bundled worker).
|
|
153
|
+
export async function buildManifest(appRoot: string): Promise<WorkerManifest> {
|
|
154
|
+
const appDir = join(appRoot, "app");
|
|
155
|
+
const frozen = await freezeConfig(appRoot);
|
|
156
|
+
const scanned = (await scanRoutes(appDir)).sort((a, b) => a.path.localeCompare(b.path));
|
|
157
|
+
|
|
158
|
+
const layoutCache = new Map<string, LayoutComponent | null>();
|
|
159
|
+
const componentsFor = async (files: string[]): Promise<LayoutComponent[]> => {
|
|
160
|
+
const chain: LayoutComponent[] = [];
|
|
161
|
+
for (const f of files) {
|
|
162
|
+
if (!layoutCache.has(f)) layoutCache.set(f, await importLayout(f));
|
|
163
|
+
const c = layoutCache.get(f) ?? null;
|
|
164
|
+
if (c) chain.push(c);
|
|
165
|
+
}
|
|
166
|
+
return chain;
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const routes: Record<string, BrandedRoute> = {};
|
|
170
|
+
const dynamicRoutes: Array<{ pattern: string; def: BrandedRoute }> = [];
|
|
171
|
+
const layoutChains: Record<string, LayoutComponent[]> = {};
|
|
172
|
+
|
|
173
|
+
for (const r of scanned) {
|
|
174
|
+
const mod = (await import(pathToFileURL(r.file).href)) as { default?: unknown };
|
|
175
|
+
if (!isRouteDefinition(mod.default)) continue;
|
|
176
|
+
const chain = await componentsFor(r.layouts);
|
|
177
|
+
if (r.dynamic) {
|
|
178
|
+
dynamicRoutes.push({ pattern: r.path, def: mod.default });
|
|
179
|
+
layoutChains[r.path] = chain;
|
|
180
|
+
} else {
|
|
181
|
+
routes[r.path] = mod.default;
|
|
182
|
+
layoutChains[r.path] = chain;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
let extra: ExtraHandler | undefined;
|
|
187
|
+
const extraFile = findExtraFile(appDir);
|
|
188
|
+
if (extraFile) {
|
|
189
|
+
const mod = (await import(pathToFileURL(extraFile).href)) as { default?: unknown };
|
|
190
|
+
if (typeof mod.default === "function") extra = mod.default as ExtraHandler;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
routes,
|
|
195
|
+
dynamicRoutes,
|
|
196
|
+
layoutChains,
|
|
197
|
+
document: frozen.document,
|
|
198
|
+
agent: frozen.agent,
|
|
199
|
+
earlyHints: frozen.earlyHints,
|
|
200
|
+
extra,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function importPath(fromDir: string, file: string): string {
|
|
205
|
+
const p = relative(fromDir, file).split(sep).join("/").replace(/\.[^.]+$/, "");
|
|
206
|
+
return p.startsWith(".") ? p : `./${p}`;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export async function juneBuild(
|
|
210
|
+
appRoot: string,
|
|
211
|
+
options: { outDir?: string; external?: string[] } = {},
|
|
212
|
+
): Promise<BuildResult> {
|
|
213
|
+
const appDir = join(appRoot, "app");
|
|
214
|
+
if (!existsSync(appDir)) throw new Error(`no app/ directory in ${appRoot} — is this a June app?`);
|
|
215
|
+
const genDir = join(appRoot, ".june");
|
|
216
|
+
const outDir = options.outDir ?? join(appRoot, "dist");
|
|
217
|
+
await mkdir(genDir, { recursive: true });
|
|
218
|
+
await rm(outDir, { recursive: true, force: true }); // stale chunks must not ship
|
|
219
|
+
|
|
220
|
+
const contentCollections = await generateContent(appRoot);
|
|
221
|
+
const routes = (await scanRoutes(appDir)).sort((a, b) => a.path.localeCompare(b.path));
|
|
222
|
+
if (routes.length === 0) throw new Error(`no page.* routes found under ${appDir}`);
|
|
223
|
+
|
|
224
|
+
const frozen = await freezeConfig(appRoot);
|
|
225
|
+
|
|
226
|
+
// ---- generated entry -----------------------------------------------------
|
|
227
|
+
const imports: string[] = [`import { createWorker } from "@junejs/server/worker";`];
|
|
228
|
+
const statics: string[] = [];
|
|
229
|
+
const dynamics: string[] = [];
|
|
230
|
+
const layoutIds = new Map<string, string>();
|
|
231
|
+
const layoutId = (file: string) => {
|
|
232
|
+
let id = layoutIds.get(file);
|
|
233
|
+
if (!id) {
|
|
234
|
+
id = `L${layoutIds.size}`;
|
|
235
|
+
layoutIds.set(file, id);
|
|
236
|
+
imports.push(`import ${id} from ${JSON.stringify(importPath(genDir, file))};`);
|
|
237
|
+
}
|
|
238
|
+
return id;
|
|
239
|
+
};
|
|
240
|
+
const chains: string[] = [];
|
|
241
|
+
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},`);
|
|
245
|
+
chains.push(` ${JSON.stringify(r.path)}: [${r.layouts.map(layoutId).join(", ")}],`);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
const builtExtraFile = findExtraFile(appDir);
|
|
249
|
+
if (builtExtraFile) {
|
|
250
|
+
imports.push(`import extra from ${JSON.stringify(importPath(genDir, builtExtraFile))};`);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const entry = `// AUTO-GENERATED by \`june build\` — do not edit. Regenerate: june build .
|
|
254
|
+
${imports.join("\n")}
|
|
255
|
+
|
|
256
|
+
export default createWorker({
|
|
257
|
+
routes: {
|
|
258
|
+
${statics.join("\n")}
|
|
259
|
+
},
|
|
260
|
+
dynamicRoutes: [
|
|
261
|
+
${dynamics.join("\n")}
|
|
262
|
+
],
|
|
263
|
+
layoutChains: {
|
|
264
|
+
${chains.join("\n")}
|
|
265
|
+
},
|
|
266
|
+
document: ${JSON.stringify(frozen.document, null, 2).replace(/\n/g, "\n ")},
|
|
267
|
+
agent: ${JSON.stringify(frozen.agent)},
|
|
268
|
+
earlyHints: ${JSON.stringify(frozen.earlyHints)},${builtExtraFile ? "\n extra," : ""}
|
|
269
|
+
});
|
|
270
|
+
`;
|
|
271
|
+
const entryFile = join(genDir, "worker-entry.tsx");
|
|
272
|
+
await writeFile(entryFile, entry);
|
|
273
|
+
|
|
274
|
+
// ---- bundle (Rolldown; self-contained ESM for workerd) -------------------
|
|
275
|
+
const { rolldown } = await import("rolldown");
|
|
276
|
+
const bundle = await rolldown({
|
|
277
|
+
input: entryFile,
|
|
278
|
+
cwd: appRoot,
|
|
279
|
+
platform: "browser", // workerd's surface is web-standard; no node:* in the graph
|
|
280
|
+
external: (id: string) => {
|
|
281
|
+
// Binary assets stay external — wrangler's CompiledWasm/Data rules own them.
|
|
282
|
+
if (/\.(wasm|ttf|otf|woff2?|png|jpe?g|avif|webp)$/.test(id)) return true;
|
|
283
|
+
// config build.external: packages wrangler must bundle itself (its own
|
|
284
|
+
// esbuild owns their .wasm/asset rules — e.g. workers-og).
|
|
285
|
+
const list = options.external ?? frozen.buildExternal;
|
|
286
|
+
return list.some((e) => id === e || id.startsWith(`${e}/`));
|
|
287
|
+
},
|
|
288
|
+
resolve: {
|
|
289
|
+
// Conditions BAKED at build (workerd has no runtime conditions, reminder #3).
|
|
290
|
+
conditionNames: ["workerd", "edge", "import", "default"],
|
|
291
|
+
},
|
|
292
|
+
});
|
|
293
|
+
const result = await bundle.write({ dir: outDir, format: "esm", entryFileNames: "worker.js" });
|
|
294
|
+
await bundle.close();
|
|
295
|
+
const outFile = join(
|
|
296
|
+
outDir,
|
|
297
|
+
result.output.find((o) => o.type === "chunk" && o.isEntry)?.fileName ?? "worker.js",
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
// ---- prerender: opted-in static routes render THROUGH the worker ---------
|
|
301
|
+
// Same render path as the bundle (createWorker over the frozen manifest), so
|
|
302
|
+
// what ships is what the parity test verified.
|
|
303
|
+
const prerendered: string[] = [];
|
|
304
|
+
const manifest = await buildManifest(appRoot);
|
|
305
|
+
const worker = createWorker(manifest);
|
|
306
|
+
const assetsDir = join(outDir, "assets");
|
|
307
|
+
let hasAssets = false;
|
|
308
|
+
|
|
309
|
+
for (const r of routes.filter((x) => !x.dynamic)) {
|
|
310
|
+
const def = manifest.routes[r.path];
|
|
311
|
+
if (!def || !def.prerender) continue;
|
|
312
|
+
const stem = r.path === "/" ? "index" : r.path.slice(1);
|
|
313
|
+
const targets: Array<[string, string]> = [[r.path, `${stem}.html`]];
|
|
314
|
+
if (r.path !== "/") {
|
|
315
|
+
targets.push([`${r.path}.md`, `${stem}.md`]);
|
|
316
|
+
if (typeof def.json === "function") targets.push([`${r.path}.json`, `${stem}.json`]);
|
|
317
|
+
}
|
|
318
|
+
for (const [reqPath, file] of targets) {
|
|
319
|
+
const res = await worker.fetch(new Request(`https://prerender.june${reqPath}`));
|
|
320
|
+
if (!res.ok) throw new Error(`prerender ${reqPath} → ${res.status}`);
|
|
321
|
+
const dest = join(assetsDir, file);
|
|
322
|
+
await mkdir(dirname(dest), { recursive: true });
|
|
323
|
+
await writeFile(dest, Buffer.from(await res.arrayBuffer()));
|
|
324
|
+
}
|
|
325
|
+
prerendered.push(r.path);
|
|
326
|
+
hasAssets = true;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ---- client islands bundle: app/_client.* → assets/client.js -------------
|
|
330
|
+
// Served at /client.js by the assets binding; the frozen document (freezeConfig)
|
|
331
|
+
// already points <script src> at it. No entry → no bundle, page ships zero JS.
|
|
332
|
+
const clientEntry = findClientEntry(appDir);
|
|
333
|
+
if (clientEntry) {
|
|
334
|
+
await bundleClientToFile(clientEntry, appRoot, assetsDir);
|
|
335
|
+
hasAssets = true;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ---- wrangler config (only when the app doesn't manage its own) ----------
|
|
339
|
+
if (!existsSync(join(appRoot, "wrangler.toml")) && !existsSync(join(appRoot, "wrangler.jsonc"))) {
|
|
340
|
+
const pkgPath = join(appRoot, "package.json");
|
|
341
|
+
const pkg = existsSync(pkgPath)
|
|
342
|
+
? (JSON.parse(await Bun.file(pkgPath).text()) as { name?: string })
|
|
343
|
+
: {};
|
|
344
|
+
await writeFile(
|
|
345
|
+
join(outDir, "wrangler.jsonc"),
|
|
346
|
+
JSON.stringify(
|
|
347
|
+
{
|
|
348
|
+
name: (pkg.name ?? basename(appRoot)).replace(/[^a-z0-9-]/gi, "-").toLowerCase(),
|
|
349
|
+
main: "./worker.js",
|
|
350
|
+
compatibility_date: "2025-01-01",
|
|
351
|
+
compatibility_flags: ["nodejs_compat"],
|
|
352
|
+
...(hasAssets ? { assets: { directory: "./assets" } } : {}),
|
|
353
|
+
},
|
|
354
|
+
null,
|
|
355
|
+
2,
|
|
356
|
+
) + "\n",
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return {
|
|
361
|
+
outFile,
|
|
362
|
+
routes: routes.filter((r) => !r.dynamic).map((r) => r.path),
|
|
363
|
+
dynamicRoutes: routes.filter((r) => r.dynamic).map((r) => r.path),
|
|
364
|
+
contentCollections,
|
|
365
|
+
prerendered,
|
|
366
|
+
};
|
|
367
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// The client bundle — the host half of v0.1 islands.
|
|
2
|
+
//
|
|
3
|
+
// The contract layer renders `<june-island>` markers (server) and exposes
|
|
4
|
+
// `hydrateIslands` (client). This module is the BUILD/DEV glue that turns an
|
|
5
|
+
// app's client entry into the `/client.js` the document loads.
|
|
6
|
+
//
|
|
7
|
+
// Convention: `app/_client.{tsx,ts,jsx,js}` (the `_` prefix marks it private, so
|
|
8
|
+
// the route scanner already ignores it — same convention as `_content.ts`). The
|
|
9
|
+
// author registers islands and calls `hydrateIslands` there:
|
|
10
|
+
//
|
|
11
|
+
// // app/_client.tsx
|
|
12
|
+
// import { hydrateIslands } from "@junejs/core/islands-client";
|
|
13
|
+
// import { Counter } from "./Counter";
|
|
14
|
+
// hydrateIslands({ Counter });
|
|
15
|
+
//
|
|
16
|
+
// Absent entry → no `/client.js`, no `clientScript`, the page ships zero JS.
|
|
17
|
+
//
|
|
18
|
+
// Host-coupled (node:fs + Rolldown), so it lives in @junejs/server, never the pure
|
|
19
|
+
// contract layer.
|
|
20
|
+
|
|
21
|
+
import { existsSync } from "node:fs";
|
|
22
|
+
import { mkdir } from "node:fs/promises";
|
|
23
|
+
import { join } from "node:path";
|
|
24
|
+
|
|
25
|
+
// The URL the document loads + the asset path the bundle is written to. Single
|
|
26
|
+
// source of truth so build (freeze) and dev (live) agree.
|
|
27
|
+
export const CLIENT_SCRIPT_URL = "/client.js";
|
|
28
|
+
const CLIENT_BASENAME = "_client";
|
|
29
|
+
const CLIENT_EXTS = [".tsx", ".ts", ".jsx", ".js"];
|
|
30
|
+
|
|
31
|
+
// Find the app's client entry, if it has one. `appDir` is the `app/` directory.
|
|
32
|
+
export function findClientEntry(appDir: string): string | undefined {
|
|
33
|
+
for (const ext of CLIENT_EXTS) {
|
|
34
|
+
const file = join(appDir, CLIENT_BASENAME + ext);
|
|
35
|
+
if (existsSync(file)) return file;
|
|
36
|
+
}
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
type BundleMode = "development" | "production";
|
|
41
|
+
|
|
42
|
+
async function bundleClient(entryFile: string, cwd: string, mode: BundleMode) {
|
|
43
|
+
const { rolldown } = await import("rolldown");
|
|
44
|
+
const bundle = await rolldown({
|
|
45
|
+
input: entryFile,
|
|
46
|
+
cwd,
|
|
47
|
+
// The client graph is plain web — browser conditions, and React's dev/prod
|
|
48
|
+
// branch resolved at build (no `process` in the browser to read it at runtime).
|
|
49
|
+
platform: "browser",
|
|
50
|
+
transform: { define: { "process.env.NODE_ENV": JSON.stringify(mode) } },
|
|
51
|
+
resolve: { conditionNames: ["browser", "import", "default"] },
|
|
52
|
+
});
|
|
53
|
+
return bundle;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// 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");
|
|
59
|
+
const { output } = await bundle.generate({ format: "esm", entryFileNames: "client.js" });
|
|
60
|
+
await bundle.close();
|
|
61
|
+
const entry = output.find((o) => o.type === "chunk" && o.isEntry);
|
|
62
|
+
return entry && entry.type === "chunk" ? entry.code : "";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Build the client entry to `<destDir>/client.js` (build freezes it as an asset).
|
|
66
|
+
export async function bundleClientToFile(entryFile: string, cwd: string, destDir: string): Promise<void> {
|
|
67
|
+
const bundle = await bundleClient(entryFile, cwd, "production");
|
|
68
|
+
await mkdir(destDir, { recursive: true });
|
|
69
|
+
await bundle.write({ dir: destDir, format: "esm", entryFileNames: "client.js" });
|
|
70
|
+
await bundle.close();
|
|
71
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// The fs side of config: load the user's june.config.{ts,js} from the app root.
|
|
2
|
+
// @junejs/core/config owns the SCHEMA and the pure resolvers (defineJune,
|
|
3
|
+
// resolveAgent, resolveSpeculationRules); this host module is the only place
|
|
4
|
+
// that touches the filesystem — keeping node:* out of the pure layer.
|
|
5
|
+
//
|
|
6
|
+
// Reminder (rebuild-plan Phase 2): "the dev server never reading june.config.ts
|
|
7
|
+
// went unnoticed for days." A config value MUST change observable output — the
|
|
8
|
+
// dev server's config-changes-output test guards exactly this.
|
|
9
|
+
|
|
10
|
+
import { existsSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { pathToFileURL } from "node:url";
|
|
13
|
+
|
|
14
|
+
import type { JuneConfig } from "@junejs/core/config";
|
|
15
|
+
|
|
16
|
+
// Probe the given dir AND its parent: callers pass either the app root
|
|
17
|
+
// (build/deploy) or the routes dir `app/` (serve) — the config file lives at
|
|
18
|
+
// the app root in both layouts.
|
|
19
|
+
export async function loadJuneConfig(appDir: string): Promise<JuneConfig> {
|
|
20
|
+
for (const dir of [appDir, join(appDir, "..")]) {
|
|
21
|
+
for (const name of ["june.config.ts", "june.config.js"]) {
|
|
22
|
+
const path = join(dir, name);
|
|
23
|
+
if (existsSync(path)) {
|
|
24
|
+
const mod = (await import(pathToFileURL(path).href)) as { default?: JuneConfig };
|
|
25
|
+
return mod.default ?? {};
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return {};
|
|
30
|
+
}
|
package/src/content.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// june content — the content pipeline: markdown files as content collections,
|
|
2
|
+
// dual-audience by construction.
|
|
3
|
+
//
|
|
4
|
+
// content/posts/2026-06-10-hello.md
|
|
5
|
+
// ├─ frontmatter → entry.data (title/date/description/tags…) → metadata
|
|
6
|
+
// ├─ body → entry.html (rendered) for the view projection
|
|
7
|
+
// └─ THE FILE → entry.original — served VERBATIM as the .md projection.
|
|
8
|
+
//
|
|
9
|
+
// The last line is the differentiator: other frameworks' "markdown output" is
|
|
10
|
+
// a lossy HTML→md conversion; June's .md projection IS the authored source.
|
|
11
|
+
// Agents read exactly what the author wrote (frontmatter included).
|
|
12
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { marked } from "marked";
|
|
15
|
+
|
|
16
|
+
export type ContentEntry = {
|
|
17
|
+
slug: string;
|
|
18
|
+
file: string;
|
|
19
|
+
/** Parsed frontmatter (string values; `tags`-style lists become string[]). */
|
|
20
|
+
data: Record<string, string | string[]>;
|
|
21
|
+
/** The markdown body (frontmatter stripped). */
|
|
22
|
+
body: string;
|
|
23
|
+
/** The authored file, verbatim — the agent-facing .md projection. */
|
|
24
|
+
original: string;
|
|
25
|
+
/** The body rendered to HTML (marked). */
|
|
26
|
+
html: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// Minimal frontmatter: `key: value` lines between --- fences; `[a, b]` lists
|
|
30
|
+
// supported, nested YAML is not — keep frontmatter flat and simple. (A full
|
|
31
|
+
// YAML parser is a later, deliberate dependency.)
|
|
32
|
+
function parseFrontmatter(raw: string): { data: ContentEntry["data"]; body: string } {
|
|
33
|
+
if (!raw.startsWith("---")) return { data: {}, body: raw };
|
|
34
|
+
const end = raw.indexOf("\n---", 3);
|
|
35
|
+
if (end === -1) return { data: {}, body: raw };
|
|
36
|
+
const data: ContentEntry["data"] = {};
|
|
37
|
+
for (const line of raw.slice(3, end).split("\n")) {
|
|
38
|
+
const i = line.indexOf(":");
|
|
39
|
+
if (i === -1) continue;
|
|
40
|
+
const key = line.slice(0, i).trim();
|
|
41
|
+
const value = line.slice(i + 1).trim();
|
|
42
|
+
if (!key) continue;
|
|
43
|
+
if (value.startsWith("[") && value.endsWith("]")) {
|
|
44
|
+
data[key] = value
|
|
45
|
+
.slice(1, -1)
|
|
46
|
+
.split(",")
|
|
47
|
+
.map((s) => s.trim().replace(/^["']|["']$/g, ""))
|
|
48
|
+
.filter(Boolean);
|
|
49
|
+
} else {
|
|
50
|
+
data[key] = value.replace(/^["']|["']$/g, "");
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return { data, body: raw.slice(end + 4).replace(/^\n+/, "") };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// mtime-keyed memo: correct under dev edits, free in production.
|
|
57
|
+
const memo = new Map<string, { mtime: number; entry: ContentEntry }>();
|
|
58
|
+
|
|
59
|
+
function loadEntry(file: string, slug: string): ContentEntry {
|
|
60
|
+
const mtime = statSync(file).mtimeMs;
|
|
61
|
+
const hit = memo.get(file);
|
|
62
|
+
if (hit && hit.mtime === mtime) return hit.entry;
|
|
63
|
+
const original = readFileSync(file, "utf8");
|
|
64
|
+
const { data, body } = parseFrontmatter(original);
|
|
65
|
+
const entry: ContentEntry = {
|
|
66
|
+
slug,
|
|
67
|
+
file,
|
|
68
|
+
data,
|
|
69
|
+
body,
|
|
70
|
+
original,
|
|
71
|
+
html: marked.parse(body, { async: false }) as string,
|
|
72
|
+
};
|
|
73
|
+
memo.set(file, { mtime, entry });
|
|
74
|
+
return entry;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** All entries in a content directory, newest-first by `date` (then slug). */
|
|
78
|
+
export function collection(dir: string): ContentEntry[] {
|
|
79
|
+
const entries = readdirSync(dir)
|
|
80
|
+
.filter((f) => f.endsWith(".md") || f.endsWith(".mdx"))
|
|
81
|
+
.map((f) => loadEntry(join(dir, f), f.replace(/\.(md|mdx)$/, "")));
|
|
82
|
+
return entries.sort((a, b) => {
|
|
83
|
+
const da = String(a.data.date ?? "");
|
|
84
|
+
const db = String(b.data.date ?? "");
|
|
85
|
+
return db.localeCompare(da) || a.slug.localeCompare(b.slug);
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** One entry by slug, or null. */
|
|
90
|
+
export function entry(dir: string, slug: string): ContentEntry | null {
|
|
91
|
+
// Guard the slug — it comes from the URL.
|
|
92
|
+
if (!/^[A-Za-z0-9._-]+$/.test(slug)) return null;
|
|
93
|
+
for (const ext of [".md", ".mdx"]) {
|
|
94
|
+
const file = join(dir, slug + ext);
|
|
95
|
+
try {
|
|
96
|
+
return loadEntry(file, slug);
|
|
97
|
+
} catch {
|
|
98
|
+
/* try next ext */
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
package/src/db.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// `db` resource adapters — implementations of the @junejs/core JuneDb contract.
|
|
2
|
+
// Declared in june.config.ts (`resources.db: sqlite(...)` / `d1(...)`), opened
|
|
3
|
+
// by the host, injected onto RouteContext as `ctx.db`. The framework depends on
|
|
4
|
+
// the JuneDb contract; these adapters (and Juno on top) are swappable.
|
|
5
|
+
|
|
6
|
+
import type { DbFactory, JuneDb, RunResult } from "@junejs/core/resources";
|
|
7
|
+
|
|
8
|
+
import { host } from "./host";
|
|
9
|
+
|
|
10
|
+
// Local SQLite — the zero-config dev default (embedded file or :memory:). Built
|
|
11
|
+
// on the demoted host.openDb primitive (the sync bun:/node: driver wrapped async).
|
|
12
|
+
export function sqlite(opts: { path?: string } = {}): DbFactory {
|
|
13
|
+
const path = opts.path ?? ":memory:";
|
|
14
|
+
return { kind: "sqlite", open: () => host.openDb(path) };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// --- D1 (Cloudflare) — the third openDb impl (rebuild-plan Phase 5) ----------
|
|
18
|
+
// The binding comes from the worker's env at request time, so d1() takes the
|
|
19
|
+
// already-bound D1Database. Adapts D1's prepare().bind().all()/first()/run() to
|
|
20
|
+
// the JuneDb contract.
|
|
21
|
+
|
|
22
|
+
type D1Result<T> = { results: T[] };
|
|
23
|
+
type D1Meta = { changes?: number; last_row_id?: number };
|
|
24
|
+
interface D1PreparedStatement {
|
|
25
|
+
bind(...values: unknown[]): D1PreparedStatement;
|
|
26
|
+
all<T = unknown>(): Promise<D1Result<T>>;
|
|
27
|
+
first<T = unknown>(): Promise<T | null>;
|
|
28
|
+
run(): Promise<{ meta: D1Meta }>;
|
|
29
|
+
}
|
|
30
|
+
export interface D1Database {
|
|
31
|
+
prepare(sql: string): D1PreparedStatement;
|
|
32
|
+
exec(sql: string): Promise<unknown>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function d1(binding: D1Database): DbFactory {
|
|
36
|
+
const db: JuneDb = {
|
|
37
|
+
async query<T>(sql: string, params: unknown[] = []) {
|
|
38
|
+
return (await binding.prepare(sql).bind(...params).all<T>()).results;
|
|
39
|
+
},
|
|
40
|
+
async get<T>(sql: string, params: unknown[] = []) {
|
|
41
|
+
return (await binding.prepare(sql).bind(...params).first<T>()) ?? undefined;
|
|
42
|
+
},
|
|
43
|
+
async run(sql: string, params: unknown[] = []): Promise<RunResult> {
|
|
44
|
+
const { meta } = await binding.prepare(sql).bind(...params).run();
|
|
45
|
+
return { changes: meta.changes ?? 0, lastInsertRowid: meta.last_row_id ?? 0 };
|
|
46
|
+
},
|
|
47
|
+
async exec(sql: string) {
|
|
48
|
+
await binding.exec(sql);
|
|
49
|
+
},
|
|
50
|
+
// D1 has no INTERACTIVE transactions (only batch()); run the fn inline. For
|
|
51
|
+
// true atomicity, group writes through batch() at the call site. Documented
|
|
52
|
+
// limitation of the edge backend — the contract stays uniform.
|
|
53
|
+
async transaction<T>(fn: (tx: JuneDb) => Promise<T>) {
|
|
54
|
+
return fn(db);
|
|
55
|
+
},
|
|
56
|
+
async close() {
|
|
57
|
+
/* the binding outlives the request; nothing to close */
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
return { kind: "d1", open: async () => db };
|
|
61
|
+
}
|