@junejs/server 0.0.21 → 0.0.22
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 +3 -3
- package/src/app.ts +11 -5
- package/src/build.ts +34 -10
- package/src/negotiate.ts +13 -0
- package/src/pipeline.ts +24 -2
- package/src/router.ts +37 -11
- package/src/worker.ts +23 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@junejs/server",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.22",
|
|
4
4
|
"description": "June host adapters + dev server. Host-coupled; depends on the pure @junejs/core contract layer.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -30,8 +30,8 @@
|
|
|
30
30
|
"typecheck": "tsc --noEmit"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@junejs/core": "0.0.
|
|
34
|
-
"@junejs/db": "0.0.
|
|
33
|
+
"@junejs/core": "0.0.22",
|
|
34
|
+
"@junejs/db": "0.0.22",
|
|
35
35
|
"marked": "^18.0.5",
|
|
36
36
|
"rolldown": "^1.1.0",
|
|
37
37
|
"lightningcss": "^1.27.0"
|
package/src/app.ts
CHANGED
|
@@ -18,8 +18,8 @@ import { resolveAgent, resolveSpeculationRules, type JuneConfig } from "@junejs/
|
|
|
18
18
|
import type { DocumentConfig } from "@junejs/core/document";
|
|
19
19
|
import { runWithTrace, type RequestTrace } from "@junejs/core/instrumentation";
|
|
20
20
|
|
|
21
|
-
import {
|
|
22
|
-
import { createPipeline, type ExtraHandler, type LayoutComponent, type Pipeline, type Resolved } from "./pipeline";
|
|
21
|
+
import { findMiddlewareFile, isResourceFile, listRoutes, matchRouteTree, resolveNotFound, routeFiles, type SegmentMatch } from "./router";
|
|
22
|
+
import { createPipeline, type ExtraHandler, type LayoutComponent, type Pipeline, type Resolved, type ResourceHandler } from "./pipeline";
|
|
23
23
|
import { memoizeResources } from "./resources";
|
|
24
24
|
import { findClientEntry, bundleClientToString, CLIENT_SCRIPT_URL } from "./client-bundle";
|
|
25
25
|
import { findGlobalCss, processCssCached, STYLES_URL } from "./css";
|
|
@@ -140,7 +140,7 @@ export function createApp({ appDir: appDirInput, config = {} }: CreateAppOptions
|
|
|
140
140
|
}
|
|
141
141
|
}
|
|
142
142
|
let extra: ExtraHandler | undefined;
|
|
143
|
-
const extraFile =
|
|
143
|
+
const extraFile = findMiddlewareFile(appDir);
|
|
144
144
|
if (extraFile) {
|
|
145
145
|
const mod = (await import(pathToFileURL(extraFile).href)) as { default?: unknown };
|
|
146
146
|
if (typeof mod.default === "function") extra = mod.default as ExtraHandler;
|
|
@@ -153,10 +153,16 @@ export function createApp({ appDir: appDirInput, config = {} }: CreateAppOptions
|
|
|
153
153
|
earlyHints: config.earlyHints,
|
|
154
154
|
resources,
|
|
155
155
|
notFoundComponent,
|
|
156
|
-
resolve: async (pathname)
|
|
156
|
+
resolve: async (pathname) => {
|
|
157
157
|
const match = await matchRouteTree(appDir, pathname, { pageConvention: true });
|
|
158
158
|
if (!match) return null;
|
|
159
159
|
const mod = await import(pathToFileURL(match.file).href);
|
|
160
|
+
// Resource route (route.*): the default export is the Response handler.
|
|
161
|
+
if (isResourceFile(match.file)) {
|
|
162
|
+
const handler = (mod as { default?: unknown }).default;
|
|
163
|
+
if (typeof handler !== "function") return null;
|
|
164
|
+
return { handler: handler as ResourceHandler, params: match.params };
|
|
165
|
+
}
|
|
160
166
|
const def = routeFromModule(mod);
|
|
161
167
|
if (!def) return null;
|
|
162
168
|
return {
|
|
@@ -164,7 +170,7 @@ export function createApp({ appDir: appDirInput, config = {} }: CreateAppOptions
|
|
|
164
170
|
params: match.params,
|
|
165
171
|
chain: await loadChain(match.segments),
|
|
166
172
|
loading: await nearestLoading(match.segments),
|
|
167
|
-
};
|
|
173
|
+
} satisfies Resolved;
|
|
168
174
|
},
|
|
169
175
|
});
|
|
170
176
|
}
|
package/src/build.ts
CHANGED
|
@@ -31,8 +31,8 @@ import { workers, type JuneAdapter, type ResourcePlan } from "./adapter";
|
|
|
31
31
|
import type { DocumentConfig } from "@junejs/core/document";
|
|
32
32
|
import { collection } from "./content";
|
|
33
33
|
import { createWorker, type WorkerManifest } from "./worker";
|
|
34
|
-
import {
|
|
35
|
-
import type { ExtraHandler, LayoutComponent, LoadingComponent } from "./pipeline";
|
|
34
|
+
import { findMiddlewareFile } from "./router";
|
|
35
|
+
import type { ExtraHandler, LayoutComponent, LoadingComponent, ResourceHandler } from "./pipeline";
|
|
36
36
|
import { findClientEntry, bundleClientToFile, CLIENT_SCRIPT_URL } from "./client-bundle";
|
|
37
37
|
import { cssTargets, findGlobalCss, minifyCss, processCss, STYLES_URL } from "./css";
|
|
38
38
|
import { buildModuleCss, rolldownCssModulesPlugin, registerCssModules } from "./css-modules";
|
|
@@ -51,6 +51,7 @@ type RouteEntry = {
|
|
|
51
51
|
path: string;
|
|
52
52
|
file: string;
|
|
53
53
|
dynamic: boolean;
|
|
54
|
+
resource?: boolean; // a route.* resource route (raw-Response handler), not a page
|
|
54
55
|
layouts: string[];
|
|
55
56
|
loading?: string; // nearest loading.tsx up the tree → streaming Suspense fallback
|
|
56
57
|
};
|
|
@@ -87,7 +88,9 @@ export async function scanRoutes(
|
|
|
87
88
|
}
|
|
88
89
|
const ext = e.name.match(/\.[^.]+$/)?.[0] ?? "";
|
|
89
90
|
if (!ROUTE_EXTS.includes(ext)) continue;
|
|
90
|
-
|
|
91
|
+
const base = basename(e.name, ext);
|
|
92
|
+
const resource = base === "route";
|
|
93
|
+
if (!PAGE_BASENAMES.has(base) && !resource) continue;
|
|
91
94
|
const relDir = relative(appDir, dir);
|
|
92
95
|
const segments = relDir === "" ? [] : relDir.split(sep).filter((s) => !isRouteGroup(s));
|
|
93
96
|
const path = "/" + segments.join("/");
|
|
@@ -95,6 +98,7 @@ export async function scanRoutes(
|
|
|
95
98
|
path: path === "/" ? "/" : path,
|
|
96
99
|
file: full,
|
|
97
100
|
dynamic: /\[.+\]/.test(path),
|
|
101
|
+
resource,
|
|
98
102
|
layouts: chain,
|
|
99
103
|
loading: nearestLoading,
|
|
100
104
|
});
|
|
@@ -197,11 +201,18 @@ export async function buildManifest(appRoot: string): Promise<WorkerManifest> {
|
|
|
197
201
|
|
|
198
202
|
const routes: Record<string, BrandedRoute> = {};
|
|
199
203
|
const dynamicRoutes: Array<{ pattern: string; def: BrandedRoute }> = [];
|
|
204
|
+
const resourceRoutes: Array<{ pattern: string; handler: ResourceHandler }> = [];
|
|
200
205
|
const layoutChains: Record<string, LayoutComponent[]> = {};
|
|
201
206
|
const loadings: Record<string, LoadingComponent> = {};
|
|
202
207
|
|
|
203
208
|
for (const r of scanned) {
|
|
204
209
|
const mod = await import(pathToFileURL(r.file).href);
|
|
210
|
+
// Resource route (route.*): the default export is the Response handler.
|
|
211
|
+
if (r.resource) {
|
|
212
|
+
const handler = (mod as { default?: unknown }).default;
|
|
213
|
+
if (typeof handler === "function") resourceRoutes.push({ pattern: r.path, handler: handler as ResourceHandler });
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
205
216
|
const def = routeFromModule(mod);
|
|
206
217
|
if (!def) continue;
|
|
207
218
|
const chain = await componentsFor(r.layouts);
|
|
@@ -217,7 +228,7 @@ export async function buildManifest(appRoot: string): Promise<WorkerManifest> {
|
|
|
217
228
|
}
|
|
218
229
|
|
|
219
230
|
let extra: ExtraHandler | undefined;
|
|
220
|
-
const extraFile =
|
|
231
|
+
const extraFile = findMiddlewareFile(appDir);
|
|
221
232
|
if (extraFile) {
|
|
222
233
|
const mod = (await import(pathToFileURL(extraFile).href)) as { default?: unknown };
|
|
223
234
|
if (typeof mod.default === "function") extra = mod.default as ExtraHandler;
|
|
@@ -226,6 +237,7 @@ export async function buildManifest(appRoot: string): Promise<WorkerManifest> {
|
|
|
226
237
|
return {
|
|
227
238
|
routes,
|
|
228
239
|
dynamicRoutes,
|
|
240
|
+
resourceRoutes,
|
|
229
241
|
layoutChains,
|
|
230
242
|
loadings,
|
|
231
243
|
document: frozen.document,
|
|
@@ -350,15 +362,26 @@ export async function juneBuild(
|
|
|
350
362
|
};
|
|
351
363
|
const chains: string[] = [];
|
|
352
364
|
const loadings: string[] = [];
|
|
365
|
+
const resources: string[] = [];
|
|
353
366
|
routes.forEach((r, i) => {
|
|
367
|
+
// Resource route (route.*): the default export IS the handler — import it
|
|
368
|
+
// directly (no routeFromModule, no layout chain).
|
|
369
|
+
if (r.resource) {
|
|
370
|
+
imports.push(`import h${i} from ${JSON.stringify(importPath(genDir, r.file))};`);
|
|
371
|
+
resources.push(` { pattern: ${JSON.stringify(r.path)}, handler: h${i} },`);
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
354
374
|
imports.push(`import * as r${i} from ${JSON.stringify(importPath(genDir, r.file))};`);
|
|
355
375
|
if (r.dynamic) dynamics.push(` { pattern: ${JSON.stringify(r.path)}, def: routeFromModule(r${i}) },`);
|
|
356
376
|
else statics.push(` ${JSON.stringify(r.path)}: routeFromModule(r${i}),`);
|
|
357
377
|
chains.push(` ${JSON.stringify(r.path)}: [${r.layouts.map(layoutId).join(", ")}],`);
|
|
358
378
|
if (r.loading) loadings.push(` ${JSON.stringify(r.path)}: ${loadingId(r.loading)},`);
|
|
359
379
|
});
|
|
380
|
+
const resourceRoutesField = resources.length
|
|
381
|
+
? `\n resourceRoutes: [\n${resources.join("\n")}\n ],`
|
|
382
|
+
: "";
|
|
360
383
|
|
|
361
|
-
const builtExtraFile =
|
|
384
|
+
const builtExtraFile = findMiddlewareFile(appDir);
|
|
362
385
|
if (builtExtraFile) {
|
|
363
386
|
imports.push(`import extra from ${JSON.stringify(importPath(genDir, builtExtraFile))};`);
|
|
364
387
|
}
|
|
@@ -418,7 +441,7 @@ ${statics.join("\n")}
|
|
|
418
441
|
},
|
|
419
442
|
dynamicRoutes: [
|
|
420
443
|
${dynamics.join("\n")}
|
|
421
|
-
]
|
|
444
|
+
],${resourceRoutesField}
|
|
422
445
|
layoutChains: {
|
|
423
446
|
${chains.join("\n")}
|
|
424
447
|
},
|
|
@@ -487,10 +510,11 @@ ${adapterEntry.wrap("pipeline")}
|
|
|
487
510
|
const def = manifest.routes[r.path];
|
|
488
511
|
if (!def || !def.prerender) continue;
|
|
489
512
|
const stem = r.path === "/" ? "index" : r.path.slice(1);
|
|
490
|
-
// The homepage's projection requests are
|
|
491
|
-
// the
|
|
492
|
-
|
|
493
|
-
const
|
|
513
|
+
// The homepage's projection requests are `/index.md` / `/index.json` (negotiate
|
|
514
|
+
// treats `/index` as the alias for `/`); these become the `index.md` /
|
|
515
|
+
// `index.json` assets the worker serves at the same intuitive paths.
|
|
516
|
+
const mdReq = r.path === "/" ? "/index.md" : `${r.path}.md`;
|
|
517
|
+
const jsonReq = r.path === "/" ? "/index.json" : `${r.path}.json`;
|
|
494
518
|
const targets: Array<[string, string]> = [
|
|
495
519
|
[r.path, `${stem}.html`],
|
|
496
520
|
[mdReq, `${stem}.md`],
|
package/src/negotiate.ts
CHANGED
|
@@ -43,6 +43,19 @@ export function negotiate(url: URL, request: Request): Negotiated {
|
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
// "/index" is the conventional alias for the home route "/": the home page's
|
|
47
|
+
// projections live at the intuitive `/index.md` / `/index.json` (and plain
|
|
48
|
+
// `/index` serves the home view), matching the build's `index.md` / `index.json`
|
|
49
|
+
// assets.
|
|
50
|
+
if (pathname === "/index") {
|
|
51
|
+
pathname = "/";
|
|
52
|
+
} else if (target && pathname === "/") {
|
|
53
|
+
// A bare projection on the root — `/.md`, `/.json` — is NOT a real URL (the
|
|
54
|
+
// home surface is `/index.md`). Stripping the extension collapses it to "/",
|
|
55
|
+
// so steer it back to the literal path: no route matches → 404.
|
|
56
|
+
pathname = url.pathname;
|
|
57
|
+
}
|
|
58
|
+
|
|
46
59
|
if (!target) {
|
|
47
60
|
const accept = request.headers.get("accept") ?? "";
|
|
48
61
|
for (const [re, t] of ACCEPT_TARGET) {
|
package/src/pipeline.ts
CHANGED
|
@@ -56,9 +56,19 @@ export type Resolved = {
|
|
|
56
56
|
loading?: React.ComponentType;
|
|
57
57
|
};
|
|
58
58
|
|
|
59
|
+
// A RESOURCE route (app/**/route.*): a handler returning a raw Response — binary,
|
|
60
|
+
// custom content-type, webhook — resolved by the SAME matcher (params from the
|
|
61
|
+
// path), so it lives in the route table instead of a hand-rolled url branch.
|
|
62
|
+
export type ResourceHandler = (
|
|
63
|
+
request: Request,
|
|
64
|
+
ctx: RouteContext,
|
|
65
|
+
) => Response | Promise<Response>;
|
|
66
|
+
export type ResolvedResource = { handler: ResourceHandler; params: Record<string, string> };
|
|
67
|
+
|
|
59
68
|
// The one thing dev and worker do differently: turn a clean pathname into a
|
|
60
69
|
// matched route. Dev walks the filesystem; the worker reads the frozen manifest.
|
|
61
|
-
|
|
70
|
+
// A match is either a page (Resolved) or a resource route (ResolvedResource).
|
|
71
|
+
export type RouteResolver = (pathname: string) => Promise<Resolved | ResolvedResource | null>;
|
|
62
72
|
|
|
63
73
|
export type PipelineConfig = {
|
|
64
74
|
docConfig: DocumentConfig;
|
|
@@ -80,10 +90,18 @@ export type PipelineConfig = {
|
|
|
80
90
|
extra?: ExtraHandler;
|
|
81
91
|
};
|
|
82
92
|
|
|
83
|
-
export
|
|
93
|
+
// app/_middleware.* default export. Runs after the agent surface, before route
|
|
94
|
+
// resolution; return null to pass through, a Response to short-circuit.
|
|
95
|
+
// ⚠ Don't authorize here — authorization is the single defineAction
|
|
96
|
+
// run(input, ctx) gate; a check here is a second, ungoverned path.
|
|
97
|
+
// ⚠ It runs BEFORE routes, so over-broad url matching shadows a page. For a
|
|
98
|
+
// custom endpoint (binary, webhook) prefer a route.* resource route.
|
|
99
|
+
export type MiddlewareHandler = (
|
|
84
100
|
request: Request,
|
|
85
101
|
url: URL,
|
|
86
102
|
) => Promise<Response | null> | Response | null;
|
|
103
|
+
/** @deprecated the former name (app/_extra); use {@link MiddlewareHandler}. */
|
|
104
|
+
export type ExtraHandler = MiddlewareHandler;
|
|
87
105
|
|
|
88
106
|
export type Pipeline = { fetch(request: Request): Promise<Response> };
|
|
89
107
|
|
|
@@ -379,6 +397,10 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
|
|
|
379
397
|
target,
|
|
380
398
|
speculative,
|
|
381
399
|
};
|
|
400
|
+
|
|
401
|
+
// Resource route (route.*): a raw-Response handler — no projection, no doc
|
|
402
|
+
// shell. It's matched like any route, so it can't shadow a page.
|
|
403
|
+
if ("handler" in resolved) return resolved.handler(request, ctx);
|
|
382
404
|
// Streaming Suspense: a view request on a route with loading.tsx AND
|
|
383
405
|
// static metadata flushes the shell + fallback before load() resolves.
|
|
384
406
|
// (data-derived metadata can't stream — the <head> needs the title.)
|
package/src/router.ts
CHANGED
|
@@ -31,14 +31,27 @@ export type RouteTreeMatch = {
|
|
|
31
31
|
|
|
32
32
|
const routeExtensions = new Set([".tsx", ".jsx", ".ts", ".js"]);
|
|
33
33
|
|
|
34
|
-
// app/
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
|
|
34
|
+
// app/_middleware.* — the pre-route middleware seam (a `_` file, so never a
|
|
35
|
+
// route): runs after the agent surface, before route resolution. Return null to
|
|
36
|
+
// pass; return a Response to short-circuit. Two cautions (see MiddlewareHandler):
|
|
37
|
+
// don't authorize here (that's the single run(input, ctx) gate), and because it
|
|
38
|
+
// runs BEFORE routes, over-broad matching can shadow a page. For a custom
|
|
39
|
+
// endpoint (binary, webhook), prefer a route.* resource route instead.
|
|
40
|
+
// `_extra` is the deprecated former name (warned + still honored for one cycle).
|
|
41
|
+
// Dev and the build look it up through this ONE helper so the conventions can't
|
|
42
|
+
// drift.
|
|
43
|
+
export function findMiddlewareFile(appDir: string): string | null {
|
|
38
44
|
for (const ext of routeExtensions) {
|
|
39
|
-
const f = join(appDir, `
|
|
45
|
+
const f = join(appDir, `_middleware${ext}`);
|
|
40
46
|
if (existsSync(f)) return f;
|
|
41
47
|
}
|
|
48
|
+
for (const ext of routeExtensions) {
|
|
49
|
+
const f = join(appDir, `_extra${ext}`);
|
|
50
|
+
if (existsSync(f)) {
|
|
51
|
+
console.warn("[june] app/_extra is deprecated — rename it to app/_middleware.");
|
|
52
|
+
return f;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
42
55
|
return null;
|
|
43
56
|
}
|
|
44
57
|
|
|
@@ -58,6 +71,14 @@ function isPageFile(file: string) {
|
|
|
58
71
|
return base === "page" || base === "index";
|
|
59
72
|
}
|
|
60
73
|
|
|
74
|
+
// app/**/route.* — a RESOURCE route: a handler returning a raw Response (binary,
|
|
75
|
+
// custom content-type, webhook), not a React page. It's a first-class route (in
|
|
76
|
+
// the route table, params from the path, resolved by the SAME matcher), so it
|
|
77
|
+
// can't shadow pages by accident and doesn't need hand-rolled url matching.
|
|
78
|
+
export function isResourceFile(file: string): boolean {
|
|
79
|
+
return baseName(file) === "route";
|
|
80
|
+
}
|
|
81
|
+
|
|
61
82
|
// Special (never-a-route) files that shape the segment tree.
|
|
62
83
|
const SPECIAL_FILES = new Set(["layout", "loading", "error", "not-found"]);
|
|
63
84
|
|
|
@@ -118,7 +139,7 @@ function routePath(appDir: string, file: string) {
|
|
|
118
139
|
// Route groups shape the filesystem, not the URL.
|
|
119
140
|
const parts = withoutExtension.split("/").filter((p) => !isRouteGroup(p));
|
|
120
141
|
|
|
121
|
-
if (parts.at(-1) === "page" || parts.at(-1) === "index") {
|
|
142
|
+
if (parts.at(-1) === "page" || parts.at(-1) === "index" || parts.at(-1) === "route") {
|
|
122
143
|
parts.pop();
|
|
123
144
|
}
|
|
124
145
|
|
|
@@ -146,9 +167,12 @@ export async function matchRouteTree(
|
|
|
146
167
|
const entries = await listDir(dir);
|
|
147
168
|
const segments = [...chain, segmentAt(dir, entries)];
|
|
148
169
|
|
|
149
|
-
// Terminal: URL consumed → find the page in this dir.
|
|
170
|
+
// Terminal: URL consumed → find the page (or resource route) in this dir.
|
|
150
171
|
if (rest.length === 0) {
|
|
151
|
-
const page =
|
|
172
|
+
const page =
|
|
173
|
+
fileFor(entries, dir, "page") ??
|
|
174
|
+
fileFor(entries, dir, "index") ??
|
|
175
|
+
fileFor(entries, dir, "route");
|
|
152
176
|
if (page) return { file: page, params, segments };
|
|
153
177
|
} else if (!options.pageConvention) {
|
|
154
178
|
// Legacy flat convention: a non-special leaf FILE names the final segment
|
|
@@ -251,17 +275,19 @@ export async function routeFiles(
|
|
|
251
275
|
if (!routeExtensions.has(file.match(/\.[^.]+$/)?.[0] ?? "")) return false;
|
|
252
276
|
if (isSpecialFile(file)) return false;
|
|
253
277
|
if (file.split(sep).some((p) => p.startsWith("_"))) return false;
|
|
254
|
-
if (options.pageConvention && !isPageFile(file)) return false;
|
|
278
|
+
if (options.pageConvention && !isPageFile(file) && !isResourceFile(file)) return false;
|
|
255
279
|
return true;
|
|
256
280
|
});
|
|
257
281
|
}
|
|
258
282
|
|
|
259
|
-
// All route paths under appDir (for sitemap / llms.txt / api-catalog).
|
|
283
|
+
// All PAGE route paths under appDir (for sitemap / llms.txt / api-catalog).
|
|
284
|
+
// Resource routes (route.*) are excluded — they're machine endpoints (og images,
|
|
285
|
+
// webhooks), not pages, so they don't belong in the human/agent sitemap.
|
|
260
286
|
export async function listRoutes(
|
|
261
287
|
appDir: string,
|
|
262
288
|
options: MatchOptions = {},
|
|
263
289
|
): Promise<string[]> {
|
|
264
|
-
const files = await routeFiles(appDir, options);
|
|
290
|
+
const files = (await routeFiles(appDir, options)).filter((file) => !isResourceFile(file));
|
|
265
291
|
return [...new Set(files.map((file) => routePath(appDir, file)))].sort();
|
|
266
292
|
}
|
|
267
293
|
|
package/src/worker.ts
CHANGED
|
@@ -16,13 +16,16 @@ import type { AgentConfig } from "@junejs/core/config";
|
|
|
16
16
|
import type { DocumentConfig } from "@junejs/core/document";
|
|
17
17
|
import type { Resources } from "@junejs/core/resources";
|
|
18
18
|
|
|
19
|
-
import { createPipeline, type ExtraHandler, type LayoutComponent, type LoadingComponent, type Resolved } from "./pipeline";
|
|
19
|
+
import { createPipeline, type ExtraHandler, type LayoutComponent, type LoadingComponent, type Resolved, type ResolvedResource, type ResourceHandler } from "./pipeline";
|
|
20
20
|
|
|
21
21
|
export type WorkerManifest = {
|
|
22
22
|
// Static paths → route definitions ("/", "/users", ...).
|
|
23
23
|
routes: Record<string, BrandedRoute>;
|
|
24
24
|
// Dynamic patterns in file-route syntax ("/posts/[slug]", "/docs/[...path]").
|
|
25
25
|
dynamicRoutes?: Array<{ pattern: string; def: BrandedRoute }>;
|
|
26
|
+
// Resource routes (app/**/route.*): a raw-Response handler keyed by its pattern
|
|
27
|
+
// (static or dynamic). Matched after pages, so a page never gets shadowed.
|
|
28
|
+
resourceRoutes?: Array<{ pattern: string; handler: ResourceHandler }>;
|
|
26
29
|
// Layout chains (root→leaf) keyed by route path / dynamic pattern. The build
|
|
27
30
|
// freezes the same chain the dev server loads from app/layout.* files.
|
|
28
31
|
layoutChains?: Record<string, LayoutComponent[]>;
|
|
@@ -93,6 +96,11 @@ export function createWorker(
|
|
|
93
96
|
def: d.def,
|
|
94
97
|
pattern: d.pattern,
|
|
95
98
|
}));
|
|
99
|
+
const resources = (manifest.resourceRoutes ?? []).map((r) => ({
|
|
100
|
+
...compilePattern(r.pattern),
|
|
101
|
+
handler: r.handler,
|
|
102
|
+
pattern: r.pattern,
|
|
103
|
+
}));
|
|
96
104
|
const routeList = [
|
|
97
105
|
...Object.keys(manifest.routes),
|
|
98
106
|
...(manifest.dynamicRoutes ?? []).map((d) => d.pattern),
|
|
@@ -116,7 +124,7 @@ export function createWorker(
|
|
|
116
124
|
notFoundComponent: manifest.notFound,
|
|
117
125
|
extra: manifest.extra,
|
|
118
126
|
resources: provider ? () => provider(currentEnv) : undefined,
|
|
119
|
-
resolve: async (pathname): Promise<Resolved | null> => {
|
|
127
|
+
resolve: async (pathname): Promise<Resolved | ResolvedResource | null> => {
|
|
120
128
|
const staticDef = manifest.routes[pathname];
|
|
121
129
|
if (staticDef)
|
|
122
130
|
return { def: staticDef, params: {}, chain: chainFor(pathname), loading: loadingFor(pathname) };
|
|
@@ -134,6 +142,19 @@ export function createWorker(
|
|
|
134
142
|
return { def: d.def, params, chain: chainFor(d.pattern), loading: loadingFor(d.pattern) };
|
|
135
143
|
}
|
|
136
144
|
}
|
|
145
|
+
// Resource routes after pages (so a page is never shadowed by one).
|
|
146
|
+
for (const r of resources) {
|
|
147
|
+
const m = pathname.match(r.regex);
|
|
148
|
+
if (m) {
|
|
149
|
+
const params = Object.fromEntries(
|
|
150
|
+
r.names.flatMap((n, i) => {
|
|
151
|
+
const v = m[i + 1];
|
|
152
|
+
return v === undefined ? [] : [[n, decodeURIComponent(v)]];
|
|
153
|
+
}),
|
|
154
|
+
);
|
|
155
|
+
return { handler: r.handler, params };
|
|
156
|
+
}
|
|
157
|
+
}
|
|
137
158
|
return null;
|
|
138
159
|
},
|
|
139
160
|
});
|