@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/pipeline.ts
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
// THE render core — ONE funnel shared by the dev server (app.ts, fs-driven) and
|
|
2
|
+
// the built worker (worker.ts, manifest-driven). The PoC wrote this pipeline
|
|
3
|
+
// TWICE (server.tsx + worker.tsx) and the copies drifted: the title-template
|
|
4
|
+
// and charset parity bugs only surfaced because dogfood pages happened to hit
|
|
5
|
+
// them. Here both callers delegate to the same code, so byte-equivalence is
|
|
6
|
+
// structural — the golden parity test (test/parity.test.ts) proves it.
|
|
7
|
+
//
|
|
8
|
+
// Worker-safe: @junejs/core (pure) + react + react-dom/server only. No node:*, no
|
|
9
|
+
// Bun.* — the dev-only and worker-only concerns (fs route discovery vs frozen
|
|
10
|
+
// manifest) are injected as a RouteResolver, not branched on here.
|
|
11
|
+
|
|
12
|
+
import React from "react";
|
|
13
|
+
// renderToReadableStream (NOT renderToStaticMarkup): it is the ONE render
|
|
14
|
+
// function present in every react-dom/server build — node, browser, AND edge.
|
|
15
|
+
// workerd resolves react-dom/server to server.edge (server.browser needs
|
|
16
|
+
// MessageChannel), which exports only the streaming API (reminder #3). Using it
|
|
17
|
+
// on both dev and worker keeps the bundle workerd-ready AND byte-equivalent.
|
|
18
|
+
import { renderToReadableStream } from "react-dom/server";
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
resolveProjection,
|
|
22
|
+
type BrandedRoute,
|
|
23
|
+
type Metadata,
|
|
24
|
+
type RenderTarget,
|
|
25
|
+
type RouteContext,
|
|
26
|
+
} from "@junejs/core/route";
|
|
27
|
+
import { Document, type DocumentConfig } from "@junejs/core/document";
|
|
28
|
+
import { isResourceManifest } from "@junejs/core/agent";
|
|
29
|
+
import {
|
|
30
|
+
apiCatalog,
|
|
31
|
+
buildLinkHeader,
|
|
32
|
+
llmsTxt,
|
|
33
|
+
mcpServerCard,
|
|
34
|
+
robotsTxt,
|
|
35
|
+
sitemapXml,
|
|
36
|
+
} from "@junejs/core/discovery";
|
|
37
|
+
import { mcpHandler } from "@junejs/core/mcp";
|
|
38
|
+
import type { AgentConfig } from "@junejs/core/config";
|
|
39
|
+
import type { Resources } from "@junejs/core/resources";
|
|
40
|
+
|
|
41
|
+
import { negotiate } from "./negotiate";
|
|
42
|
+
|
|
43
|
+
export type LayoutComponent = React.ComponentType<{ children: React.ReactNode }>;
|
|
44
|
+
|
|
45
|
+
// What a resolver returns for a matched pathname: the route definition, its
|
|
46
|
+
// params, and the layout chain (root→leaf) that wraps it.
|
|
47
|
+
export type Resolved = {
|
|
48
|
+
def: BrandedRoute;
|
|
49
|
+
params: Record<string, string>;
|
|
50
|
+
chain: LayoutComponent[];
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// The one thing dev and worker do differently: turn a clean pathname into a
|
|
54
|
+
// matched route. Dev walks the filesystem; the worker reads the frozen manifest.
|
|
55
|
+
export type RouteResolver = (pathname: string) => Promise<Resolved | null>;
|
|
56
|
+
|
|
57
|
+
export type PipelineConfig = {
|
|
58
|
+
docConfig: DocumentConfig;
|
|
59
|
+
agent: AgentConfig;
|
|
60
|
+
// The route list for discovery surfaces (sitemap / llms.txt). Async so dev can
|
|
61
|
+
// re-scan the filesystem; the worker returns a frozen array.
|
|
62
|
+
routeList: () => Promise<string[]> | string[];
|
|
63
|
+
resolve: RouteResolver;
|
|
64
|
+
// Opened data resources (db/kv/blob) injected onto ctx before load(). A
|
|
65
|
+
// provider so opening is lazy/memoized; absent → no resources on ctx.
|
|
66
|
+
resources?: () => Promise<Resources> | Resources;
|
|
67
|
+
earlyHints?: string[];
|
|
68
|
+
htmlCacheControl?: string;
|
|
69
|
+
notFoundComponent?: React.ComponentType<{ pathname: string }>;
|
|
70
|
+
// The app's pre-route escape hatch (app/_extra.*): runs after the agent
|
|
71
|
+
// surface, before route resolution. Return null to fall through. For
|
|
72
|
+
// responses route() can't express yet (binary, custom content types) —
|
|
73
|
+
// e.g. an og:image PNG route.
|
|
74
|
+
extra?: ExtraHandler;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export type ExtraHandler = (
|
|
78
|
+
request: Request,
|
|
79
|
+
url: URL,
|
|
80
|
+
) => Promise<Response | null> | Response | null;
|
|
81
|
+
|
|
82
|
+
export type Pipeline = { fetch(request: Request): Promise<Response> };
|
|
83
|
+
|
|
84
|
+
const DefaultNotFound: React.ComponentType<{ pathname: string }> = ({ pathname }) =>
|
|
85
|
+
React.createElement(
|
|
86
|
+
"main",
|
|
87
|
+
null,
|
|
88
|
+
React.createElement("h1", null, "404 — Not found"),
|
|
89
|
+
React.createElement("p", null, pathname),
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
function text(body: string, contentType: string, init?: ResponseInit) {
|
|
93
|
+
const headers = new Headers(init?.headers);
|
|
94
|
+
headers.set("content-type", contentType);
|
|
95
|
+
return new Response(body, { ...init, headers });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function createPipeline(cfg: PipelineConfig): Pipeline {
|
|
99
|
+
const { docConfig, agent } = cfg;
|
|
100
|
+
const NotFound = cfg.notFoundComponent ?? DefaultNotFound;
|
|
101
|
+
|
|
102
|
+
function htmlHeaders(): Headers {
|
|
103
|
+
const headers = new Headers({ "content-type": "text/html; charset=utf-8" });
|
|
104
|
+
const links = [buildLinkHeader(agent), ...(cfg.earlyHints ?? [])].filter(Boolean) as string[];
|
|
105
|
+
if (links.length) headers.set("link", links.join(", "));
|
|
106
|
+
if (cfg.htmlCacheControl) headers.set("cache-control", cfg.htmlCacheControl);
|
|
107
|
+
return headers;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function renderDocument(
|
|
111
|
+
node: React.ReactNode,
|
|
112
|
+
metadata: Metadata | undefined,
|
|
113
|
+
status: number,
|
|
114
|
+
chain: LayoutComponent[],
|
|
115
|
+
): Promise<Response> {
|
|
116
|
+
// Wrap root→leaf: chain[0] is outermost.
|
|
117
|
+
const wrapped = chain.reduceRight<React.ReactNode>(
|
|
118
|
+
(acc, L) => React.createElement(L, null, acc),
|
|
119
|
+
node,
|
|
120
|
+
);
|
|
121
|
+
const stream = await renderToReadableStream(
|
|
122
|
+
React.createElement(Document, { config: docConfig, metadata, children: wrapped }),
|
|
123
|
+
);
|
|
124
|
+
await stream.allReady; // fully resolved markup (no streamed Suspense fallbacks)
|
|
125
|
+
const html = "<!doctype html>\n" + (await new Response(stream).text());
|
|
126
|
+
return new Response(html, { status, headers: htmlHeaders() });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function notFoundResponse(target: RenderTarget, pathname: string): Promise<Response> | Response {
|
|
130
|
+
// Data clients get a JSON 404; humans get the rendered NotFound document.
|
|
131
|
+
return target !== "view"
|
|
132
|
+
? Response.json({ error: "Not Found", path: pathname }, { status: 404 })
|
|
133
|
+
: renderDocument(
|
|
134
|
+
React.createElement(NotFound, { pathname }),
|
|
135
|
+
{ title: "Not found", robots: "noindex" },
|
|
136
|
+
404,
|
|
137
|
+
[],
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function resolveMeta(def: BrandedRoute, data: unknown, ctx: RouteContext): Metadata | undefined {
|
|
142
|
+
return typeof def.metadata === "function"
|
|
143
|
+
? (def.metadata as (d: unknown, c: RouteContext) => Metadata)(data, ctx)
|
|
144
|
+
: def.metadata;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function renderMarkdown(def: BrandedRoute, data: unknown, ctx: RouteContext): Promise<Response> {
|
|
148
|
+
if (def.md) return text(await def.md(data, ctx), "text/markdown; charset=utf-8");
|
|
149
|
+
const payload = def.json ? await def.json(data, ctx) : data;
|
|
150
|
+
return text("```json\n" + JSON.stringify(payload, null, 2) + "\n```\n", "text/markdown; charset=utf-8");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function renderProjection(
|
|
154
|
+
resolved: Resolved,
|
|
155
|
+
target: RenderTarget,
|
|
156
|
+
data: unknown,
|
|
157
|
+
ctx: RouteContext,
|
|
158
|
+
): Promise<Response> {
|
|
159
|
+
const { def, chain } = resolved;
|
|
160
|
+
if (target === "md") return renderMarkdown(def, data, ctx);
|
|
161
|
+
|
|
162
|
+
switch (resolveProjection(def, target)) {
|
|
163
|
+
case "json":
|
|
164
|
+
return Response.json(await def.json!(data, ctx));
|
|
165
|
+
case "agent": {
|
|
166
|
+
const out = await def.agent!(data, ctx);
|
|
167
|
+
const body = isResourceManifest(out) ? out.toManifest() : out;
|
|
168
|
+
return text(JSON.stringify(body), "application/vnd.june-agent+json");
|
|
169
|
+
}
|
|
170
|
+
default: {
|
|
171
|
+
const node = def.view ? def.view(data, ctx) : null;
|
|
172
|
+
return renderDocument(node, resolveMeta(def, data, ctx), 200, chain);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function discovery(url: URL): Promise<Response | null> {
|
|
178
|
+
switch (url.pathname) {
|
|
179
|
+
case "/llms.txt":
|
|
180
|
+
return text(
|
|
181
|
+
llmsTxt(url.origin, await cfg.routeList(), agent, docConfig.site),
|
|
182
|
+
"text/markdown; charset=utf-8",
|
|
183
|
+
);
|
|
184
|
+
case "/robots.txt":
|
|
185
|
+
return text(robotsTxt(url.origin), "text/plain; charset=utf-8");
|
|
186
|
+
case "/sitemap.xml":
|
|
187
|
+
return text(sitemapXml(url.origin, await cfg.routeList()), "application/xml; charset=utf-8");
|
|
188
|
+
case "/.well-known/api-catalog":
|
|
189
|
+
return text(JSON.stringify(apiCatalog(url.origin, agent)), "application/linkset+json");
|
|
190
|
+
case "/.well-known/mcp/server-card.json":
|
|
191
|
+
return agent.mcp ? Response.json(mcpServerCard(url.origin)) : null;
|
|
192
|
+
default:
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
async fetch(request: Request): Promise<Response> {
|
|
199
|
+
const url = new URL(request.url);
|
|
200
|
+
|
|
201
|
+
// --- agent surface ---------------------------------------------------
|
|
202
|
+
if (url.pathname === "/mcp") {
|
|
203
|
+
if (!agent.mcp) return notFoundResponse("view", url.pathname);
|
|
204
|
+
// The agent's tool calls run through the same resources (and, once auth
|
|
205
|
+
// is wired, the same principal) the UI uses.
|
|
206
|
+
const res = cfg.resources ? await cfg.resources() : undefined;
|
|
207
|
+
return mcpHandler(request, { request, db: res?.db, kv: res?.kv, blob: res?.blob });
|
|
208
|
+
}
|
|
209
|
+
if (request.method === "GET" && agent.discovery) {
|
|
210
|
+
const d = await discovery(url);
|
|
211
|
+
if (d) return d;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// --- app escape hatch --------------------------------------------------
|
|
215
|
+
// After the agent surface (framework-owned), before routes: the app can
|
|
216
|
+
// claim any path the route conventions can't express yet.
|
|
217
|
+
if (cfg.extra) {
|
|
218
|
+
const out = await cfg.extra(request, url);
|
|
219
|
+
if (out) return out;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// --- routes ----------------------------------------------------------
|
|
223
|
+
const { target, pathname, speculative } = negotiate(url, request);
|
|
224
|
+
const resolved = await cfg.resolve(pathname);
|
|
225
|
+
if (!resolved) return notFoundResponse(target, pathname);
|
|
226
|
+
|
|
227
|
+
const res = cfg.resources ? await cfg.resources() : undefined;
|
|
228
|
+
const ctx: RouteContext = {
|
|
229
|
+
request,
|
|
230
|
+
url,
|
|
231
|
+
params: resolved.params,
|
|
232
|
+
target,
|
|
233
|
+
speculative,
|
|
234
|
+
db: res?.db,
|
|
235
|
+
kv: res?.kv,
|
|
236
|
+
blob: res?.blob,
|
|
237
|
+
};
|
|
238
|
+
let data: unknown;
|
|
239
|
+
try {
|
|
240
|
+
data = resolved.def.load ? await resolved.def.load(ctx) : undefined;
|
|
241
|
+
} catch {
|
|
242
|
+
// unknown slug etc. → 404 (segment error boundaries are a later milestone)
|
|
243
|
+
return notFoundResponse(target, pathname);
|
|
244
|
+
}
|
|
245
|
+
return renderProjection(resolved, target, data, ctx);
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
}
|
package/src/resources.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Resource resolution — open the handles declared in june.config.ts `resources`
|
|
2
|
+
// and hand them to the pipeline, which injects them onto RouteContext (ctx.db /
|
|
3
|
+
// ctx.kv / ctx.blob). A resource not declared is never opened (and, for static
|
|
4
|
+
// apps, tree-shaken out — the build freeze knows which routes touch resources).
|
|
5
|
+
|
|
6
|
+
import type { ResourceConfig, Resources } from "@junejs/core/resources";
|
|
7
|
+
|
|
8
|
+
// Open every declared resource once. Returns a memoized provider so the same
|
|
9
|
+
// long-lived handles are reused across requests (one SQLite connection, etc.).
|
|
10
|
+
export function memoizeResources(
|
|
11
|
+
config?: ResourceConfig,
|
|
12
|
+
): () => Promise<Resources> {
|
|
13
|
+
if (!config || (!config.db && !config.kv && !config.blob)) {
|
|
14
|
+
const empty: Resources = {};
|
|
15
|
+
return () => Promise.resolve(empty);
|
|
16
|
+
}
|
|
17
|
+
let opened: Promise<Resources> | null = null;
|
|
18
|
+
return () => {
|
|
19
|
+
if (!opened) {
|
|
20
|
+
opened = (async () => ({
|
|
21
|
+
db: config.db ? await config.db.open() : undefined,
|
|
22
|
+
kv: config.kv ? await config.kv.open() : undefined,
|
|
23
|
+
blob: config.blob ? await config.blob.open() : undefined,
|
|
24
|
+
}))();
|
|
25
|
+
}
|
|
26
|
+
return opened;
|
|
27
|
+
};
|
|
28
|
+
}
|
package/src/router.ts
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
// The file-route matcher: a recursive-descent walk over the app directory that
|
|
2
|
+
// turns a URL into (page file, params, segment chain). The SAME conventions
|
|
3
|
+
// drive `june dev` and `june build` (rebuild-plan Phase 3) — one matcher, no
|
|
4
|
+
// drift between what dev serves and what the build freezes.
|
|
5
|
+
import { existsSync } from "node:fs";
|
|
6
|
+
import { readdir } from "node:fs/promises";
|
|
7
|
+
import { join, relative, sep } from "node:path";
|
|
8
|
+
|
|
9
|
+
export type RouteMatch = {
|
|
10
|
+
file: string;
|
|
11
|
+
params: Record<string, string>;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
// One directory level of a matched route, root → … → the page's own dir. Each
|
|
15
|
+
// level contributes its special files to the rendered tree: layout wraps,
|
|
16
|
+
// loading becomes a Suspense fallback, error becomes the recovery UI for the
|
|
17
|
+
// segment's load/render, not-found resolves 404s for paths under it.
|
|
18
|
+
export type SegmentMatch = {
|
|
19
|
+
dir: string;
|
|
20
|
+
layout?: string;
|
|
21
|
+
loading?: string;
|
|
22
|
+
error?: string;
|
|
23
|
+
notFound?: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type RouteTreeMatch = {
|
|
27
|
+
file: string;
|
|
28
|
+
params: Record<string, string>;
|
|
29
|
+
segments: SegmentMatch[];
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const routeExtensions = new Set([".tsx", ".jsx", ".ts", ".js"]);
|
|
33
|
+
|
|
34
|
+
// app/_extra.* — the pre-route escape hatch (a `_` file, so never a route).
|
|
35
|
+
// Dev and the build look it up through this ONE helper so the conventions
|
|
36
|
+
// cannot drift.
|
|
37
|
+
export function findExtraFile(appDir: string): string | null {
|
|
38
|
+
for (const ext of routeExtensions) {
|
|
39
|
+
const f = join(appDir, `_extra${ext}`);
|
|
40
|
+
if (existsSync(f)) return f;
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type MatchOptions = {
|
|
46
|
+
// When true, only `page.*` and `index.*` files are routes. This lets a route
|
|
47
|
+
// folder colocate `model.ts`, `actions.ts`, `queries.ts`, `_components/`,
|
|
48
|
+
// `_tests/` without them becoming accidental routes.
|
|
49
|
+
pageConvention?: boolean;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
function baseName(file: string) {
|
|
53
|
+
return (file.split(sep).pop() ?? "").replace(/\.[^.]+$/, "");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isPageFile(file: string) {
|
|
57
|
+
const base = baseName(file);
|
|
58
|
+
return base === "page" || base === "index";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Special (never-a-route) files that shape the segment tree.
|
|
62
|
+
const SPECIAL_FILES = new Set(["layout", "loading", "error", "not-found"]);
|
|
63
|
+
|
|
64
|
+
function isSpecialFile(file: string) {
|
|
65
|
+
return SPECIAL_FILES.has(baseName(file));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const isRouteGroup = (name: string) => /^\(.+\)$/.test(name);
|
|
69
|
+
const isParamDir = (name: string) => /^\[([A-Za-z_][A-Za-z0-9_]*)\]$/.test(name);
|
|
70
|
+
const isCatchAllDir = (name: string) => /^\[\.\.\.([A-Za-z_][A-Za-z0-9_]*)\]$/.test(name);
|
|
71
|
+
const paramName = (name: string) => name.replace(/^\[(\.\.\.)?|\]$/g, "");
|
|
72
|
+
|
|
73
|
+
type DirEntry = { name: string; dir: boolean };
|
|
74
|
+
|
|
75
|
+
async function listDir(dir: string): Promise<DirEntry[]> {
|
|
76
|
+
const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
77
|
+
return entries
|
|
78
|
+
.filter((e) => !e.name.startsWith("_") && !e.name.startsWith("."))
|
|
79
|
+
.map((e) => ({ name: e.name, dir: e.isDirectory() }));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function fileFor(entries: DirEntry[], dir: string, base: string): string | undefined {
|
|
83
|
+
for (const ext of [".tsx", ".jsx", ".ts", ".js"]) {
|
|
84
|
+
if (entries.some((e) => !e.dir && e.name === base + ext)) return join(dir, base + ext);
|
|
85
|
+
}
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function segmentAt(dir: string, entries: DirEntry[]): SegmentMatch {
|
|
90
|
+
return {
|
|
91
|
+
dir,
|
|
92
|
+
layout: fileFor(entries, dir, "layout"),
|
|
93
|
+
loading: fileFor(entries, dir, "loading"),
|
|
94
|
+
error: fileFor(entries, dir, "error"),
|
|
95
|
+
notFound: fileFor(entries, dir, "not-found"),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function walk(dir: string): Promise<string[]> {
|
|
100
|
+
const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
101
|
+
const files = await Promise.all(
|
|
102
|
+
entries.map((entry) => {
|
|
103
|
+
const path = join(dir, entry.name);
|
|
104
|
+
return entry.isDirectory() ? walk(path) : [path];
|
|
105
|
+
}),
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
return files.flat();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function routePath(appDir: string, file: string) {
|
|
112
|
+
const rel = relative(appDir, file).split(sep).join("/");
|
|
113
|
+
const withoutExtension = rel.replace(/\.[^.]+$/, "");
|
|
114
|
+
// Route groups shape the filesystem, not the URL.
|
|
115
|
+
const parts = withoutExtension.split("/").filter((p) => !isRouteGroup(p));
|
|
116
|
+
|
|
117
|
+
if (parts.at(-1) === "page" || parts.at(-1) === "index") {
|
|
118
|
+
parts.pop();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return `/${parts.join("/")}`.replace(/\/+/g, "/").replace(/\/$/, "") || "/";
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Recursive-descent matcher over the app directory. Priority at every level:
|
|
125
|
+
// exact static segment > [param] > [...catchAll]; route groups `(name)` descend
|
|
126
|
+
// without consuming a URL segment; `_`-prefixed entries never participate.
|
|
127
|
+
// Returns the page file, accumulated params (catch-all joins with "/"), and the
|
|
128
|
+
// chain of segments (with their special files) from the app root to the page.
|
|
129
|
+
export async function matchRouteTree(
|
|
130
|
+
appDir: string,
|
|
131
|
+
pathname: string,
|
|
132
|
+
options: MatchOptions = {},
|
|
133
|
+
): Promise<RouteTreeMatch | null> {
|
|
134
|
+
const urlSegments = pathname.split("/").filter(Boolean).map(decodeURIComponent);
|
|
135
|
+
|
|
136
|
+
async function descend(
|
|
137
|
+
dir: string,
|
|
138
|
+
rest: string[],
|
|
139
|
+
params: Record<string, string>,
|
|
140
|
+
chain: SegmentMatch[],
|
|
141
|
+
): Promise<RouteTreeMatch | null> {
|
|
142
|
+
const entries = await listDir(dir);
|
|
143
|
+
const segments = [...chain, segmentAt(dir, entries)];
|
|
144
|
+
|
|
145
|
+
// Terminal: URL consumed → find the page in this dir.
|
|
146
|
+
if (rest.length === 0) {
|
|
147
|
+
const page = fileFor(entries, dir, "page") ?? fileFor(entries, dir, "index");
|
|
148
|
+
if (page) return { file: page, params, segments };
|
|
149
|
+
} else if (!options.pageConvention) {
|
|
150
|
+
// Legacy flat convention: a non-special leaf FILE names the final segment
|
|
151
|
+
// (examples/rsc: about.tsx → /about). Only valid for the last segment.
|
|
152
|
+
if (rest.length === 1) {
|
|
153
|
+
const leaf = fileFor(entries, dir, rest[0]!);
|
|
154
|
+
if (leaf && !isSpecialFile(leaf) && !isPageFile(leaf)) {
|
|
155
|
+
return { file: leaf, params, segments };
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Route groups: try descending into every (group) without consuming URL.
|
|
161
|
+
for (const e of entries) {
|
|
162
|
+
if (!e.dir || !isRouteGroup(e.name)) continue;
|
|
163
|
+
const hit = await descend(join(dir, e.name), rest, params, segments);
|
|
164
|
+
if (hit) return hit;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (rest.length === 0) return null;
|
|
168
|
+
const [head, ...tail] = rest as [string, ...string[]];
|
|
169
|
+
|
|
170
|
+
// 1) exact static dir
|
|
171
|
+
const exact = entries.find((e) => e.dir && e.name === head);
|
|
172
|
+
if (exact) {
|
|
173
|
+
const hit = await descend(join(dir, head), tail, params, segments);
|
|
174
|
+
if (hit) return hit;
|
|
175
|
+
}
|
|
176
|
+
// 2) [param] dirs
|
|
177
|
+
for (const e of entries) {
|
|
178
|
+
if (!e.dir || !isParamDir(e.name)) continue;
|
|
179
|
+
const hit = await descend(
|
|
180
|
+
join(dir, e.name),
|
|
181
|
+
tail,
|
|
182
|
+
{ ...params, [paramName(e.name)]: head },
|
|
183
|
+
segments,
|
|
184
|
+
);
|
|
185
|
+
if (hit) return hit;
|
|
186
|
+
}
|
|
187
|
+
// 3) [...catchAll] dirs consume everything remaining
|
|
188
|
+
for (const e of entries) {
|
|
189
|
+
if (!e.dir || !isCatchAllDir(e.name)) continue;
|
|
190
|
+
const hit = await descend(
|
|
191
|
+
join(dir, e.name),
|
|
192
|
+
[],
|
|
193
|
+
{ ...params, [paramName(e.name)]: rest.join("/") },
|
|
194
|
+
segments,
|
|
195
|
+
);
|
|
196
|
+
if (hit) return hit;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return descend(appDir, urlSegments, {}, []);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// 404 path: walk the longest matchable STATIC prefix of the URL collecting
|
|
206
|
+
// segments, so the not-found page renders inside the layouts it lives under;
|
|
207
|
+
// the not-found file is the nearest one up that chain.
|
|
208
|
+
export async function resolveNotFound(
|
|
209
|
+
appDir: string,
|
|
210
|
+
pathname: string,
|
|
211
|
+
): Promise<{ segments: SegmentMatch[]; notFound?: string }> {
|
|
212
|
+
const urlSegments = pathname.split("/").filter(Boolean).map(decodeURIComponent);
|
|
213
|
+
const segments: SegmentMatch[] = [];
|
|
214
|
+
let dir = appDir;
|
|
215
|
+
|
|
216
|
+
for (let i = 0; i <= urlSegments.length; i++) {
|
|
217
|
+
const entries = await listDir(dir);
|
|
218
|
+
segments.push(segmentAt(dir, entries));
|
|
219
|
+
if (i === urlSegments.length) break;
|
|
220
|
+
const next = entries.find((e) => e.dir && e.name === urlSegments[i]);
|
|
221
|
+
if (!next) break;
|
|
222
|
+
dir = join(dir, next.name);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const notFound = [...segments].reverse().find((s) => s.notFound)?.notFound;
|
|
226
|
+
return { segments, notFound };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Every route FILE under appDir (absolute paths). Special files
|
|
230
|
+
// (layout/loading/error/not-found) and `_`-prefixed entries are never routes.
|
|
231
|
+
// Used by discovery (route list) and by the dev-server warmup that imports each
|
|
232
|
+
// route module so its defineAction() side effects register before /mcp is hit.
|
|
233
|
+
export async function routeFiles(
|
|
234
|
+
appDir: string,
|
|
235
|
+
options: MatchOptions = {},
|
|
236
|
+
): Promise<string[]> {
|
|
237
|
+
return (await walk(appDir)).filter((file) => {
|
|
238
|
+
if (!routeExtensions.has(file.match(/\.[^.]+$/)?.[0] ?? "")) return false;
|
|
239
|
+
if (isSpecialFile(file)) return false;
|
|
240
|
+
if (file.split(sep).some((p) => p.startsWith("_"))) return false;
|
|
241
|
+
if (options.pageConvention && !isPageFile(file)) return false;
|
|
242
|
+
return true;
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// All route paths under appDir (for sitemap / llms.txt / api-catalog).
|
|
247
|
+
export async function listRoutes(
|
|
248
|
+
appDir: string,
|
|
249
|
+
options: MatchOptions = {},
|
|
250
|
+
): Promise<string[]> {
|
|
251
|
+
const files = await routeFiles(appDir, options);
|
|
252
|
+
return [...new Set(files.map((file) => routePath(appDir, file)))].sort();
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Flat-shaped result; delegates to the tree matcher.
|
|
256
|
+
export async function matchRoute(
|
|
257
|
+
appDir: string,
|
|
258
|
+
pathname: string,
|
|
259
|
+
options: MatchOptions = {},
|
|
260
|
+
): Promise<RouteMatch | null> {
|
|
261
|
+
const tree = await matchRouteTree(appDir, pathname, options);
|
|
262
|
+
return tree ? { file: tree.file, params: tree.params } : null;
|
|
263
|
+
}
|
package/src/worker.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// The built worker's runtime — what `june build` GENERATES an entry for.
|
|
2
|
+
// workerd has no filesystem, so everything the dev server discovers at request
|
|
3
|
+
// time (routes, config, content, layout chains) arrives here as a FROZEN
|
|
4
|
+
// manifest built at build time. createWorker() feeds that manifest to the SAME
|
|
5
|
+
// render core the dev server uses (pipeline.ts) — so the built surfaces are
|
|
6
|
+
// byte-equivalent to dev (test/parity.test.ts proves it), not a re-implementation.
|
|
7
|
+
//
|
|
8
|
+
// Worker-safe by construction: this file + pipeline.ts touch only @junejs/core +
|
|
9
|
+
// react. The content pipeline's fs reads are frozen into the manifest at build,
|
|
10
|
+
// never run here.
|
|
11
|
+
|
|
12
|
+
import type React from "react";
|
|
13
|
+
|
|
14
|
+
import type { BrandedRoute } from "@junejs/core/route";
|
|
15
|
+
import type { AgentConfig } from "@junejs/core/config";
|
|
16
|
+
import type { DocumentConfig } from "@junejs/core/document";
|
|
17
|
+
import type { Resources } from "@junejs/core/resources";
|
|
18
|
+
|
|
19
|
+
import { createPipeline, type ExtraHandler, type LayoutComponent, type Resolved } from "./pipeline";
|
|
20
|
+
|
|
21
|
+
export type WorkerManifest = {
|
|
22
|
+
// Static paths → route definitions ("/", "/users", ...).
|
|
23
|
+
routes: Record<string, BrandedRoute>;
|
|
24
|
+
// Dynamic patterns in file-route syntax ("/posts/[slug]", "/docs/[...path]").
|
|
25
|
+
dynamicRoutes?: Array<{ pattern: string; def: BrandedRoute }>;
|
|
26
|
+
// Layout chains (root→leaf) keyed by route path / dynamic pattern. The build
|
|
27
|
+
// freezes the same chain the dev server loads from app/layout.* files.
|
|
28
|
+
layoutChains?: Record<string, LayoutComponent[]>;
|
|
29
|
+
document: DocumentConfig;
|
|
30
|
+
agent: AgentConfig;
|
|
31
|
+
// Preload Link values (config earlyHints + auto font hints), frozen at build.
|
|
32
|
+
earlyHints?: string[];
|
|
33
|
+
htmlCacheControl?: string;
|
|
34
|
+
notFound?: React.ComponentType<{ pathname: string }>;
|
|
35
|
+
// The app/_extra.* pre-route handler, imported by the generated entry.
|
|
36
|
+
extra?: ExtraHandler;
|
|
37
|
+
// Opened data resources (db/kv/blob) injected onto ctx. On workerd the D1/KV/R2
|
|
38
|
+
// bindings come from env per request, so the generated entry passes a provider.
|
|
39
|
+
resources?: () => Promise<Resources> | Resources;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
type Compiled = { regex: RegExp; names: string[]; def: BrandedRoute; pattern: string };
|
|
43
|
+
|
|
44
|
+
// "/posts/[slug]" | "/docs/[...path]" → matcher
|
|
45
|
+
function compilePattern(pattern: string): { regex: RegExp; names: string[] } {
|
|
46
|
+
const names: string[] = [];
|
|
47
|
+
const source = pattern
|
|
48
|
+
.split("/")
|
|
49
|
+
.map((seg) => {
|
|
50
|
+
const ca = seg.match(/^\[\.\.\.(\w+)\]$/);
|
|
51
|
+
if (ca) {
|
|
52
|
+
names.push(ca[1]!);
|
|
53
|
+
return "(.+)";
|
|
54
|
+
}
|
|
55
|
+
const p = seg.match(/^\[(\w+)\]$/);
|
|
56
|
+
if (p) {
|
|
57
|
+
names.push(p[1]!);
|
|
58
|
+
return "([^/]+)";
|
|
59
|
+
}
|
|
60
|
+
return seg.replace(/[.*+?^${}()|\\]/g, "\\$&");
|
|
61
|
+
})
|
|
62
|
+
.join("/");
|
|
63
|
+
return { regex: new RegExp(`^${source}$`), names };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function createWorker(manifest: WorkerManifest): { fetch(request: Request): Promise<Response> } {
|
|
67
|
+
const dynamic: Compiled[] = (manifest.dynamicRoutes ?? []).map((d) => ({
|
|
68
|
+
...compilePattern(d.pattern),
|
|
69
|
+
def: d.def,
|
|
70
|
+
pattern: d.pattern,
|
|
71
|
+
}));
|
|
72
|
+
const routeList = [
|
|
73
|
+
...Object.keys(manifest.routes),
|
|
74
|
+
...(manifest.dynamicRoutes ?? []).map((d) => d.pattern),
|
|
75
|
+
].sort();
|
|
76
|
+
|
|
77
|
+
const chainFor = (key: string): LayoutComponent[] => manifest.layoutChains?.[key] ?? [];
|
|
78
|
+
|
|
79
|
+
return createPipeline({
|
|
80
|
+
docConfig: manifest.document,
|
|
81
|
+
agent: manifest.agent,
|
|
82
|
+
routeList: () => routeList,
|
|
83
|
+
earlyHints: manifest.earlyHints,
|
|
84
|
+
htmlCacheControl: manifest.htmlCacheControl,
|
|
85
|
+
notFoundComponent: manifest.notFound,
|
|
86
|
+
extra: manifest.extra,
|
|
87
|
+
resources: manifest.resources,
|
|
88
|
+
resolve: async (pathname): Promise<Resolved | null> => {
|
|
89
|
+
const staticDef = manifest.routes[pathname];
|
|
90
|
+
if (staticDef) return { def: staticDef, params: {}, chain: chainFor(pathname) };
|
|
91
|
+
for (const d of dynamic) {
|
|
92
|
+
const m = pathname.match(d.regex);
|
|
93
|
+
if (m) {
|
|
94
|
+
const params = Object.fromEntries(d.names.map((n, i) => [n, decodeURIComponent(m[i + 1]!)]));
|
|
95
|
+
return { def: d.def, params, chain: chainFor(d.pattern) };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
}
|