@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@junejs/server",
3
- "version": "0.0.21",
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.21",
34
- "@junejs/db": "0.0.21",
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 { findExtraFile, listRoutes, matchRouteTree, resolveNotFound, routeFiles, type SegmentMatch } from "./router";
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 = findExtraFile(appDir);
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): Promise<Resolved | null> => {
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 { findExtraFile } from "./router";
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
- if (!PAGE_BASENAMES.has(basename(e.name, ext))) continue;
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 = findExtraFile(appDir);
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 = findExtraFile(appDir);
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 `/.md` / `/.json` (negotiate strips
491
- // the ext back to `/`); withAssets serves them for `Accept: text/markdown`.
492
- const mdReq = r.path === "/" ? "/.md" : `${r.path}.md`;
493
- const jsonReq = r.path === "/" ? "/.json" : `${r.path}.json`;
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
- export type RouteResolver = (pathname: string) => Promise<Resolved | null>;
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 type ExtraHandler = (
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/_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 {
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, `_extra${ext}`);
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 = fileFor(entries, dir, "page") ?? fileFor(entries, dir, "index");
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
  });