@junejs/server 0.0.20 → 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.20",
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.20",
34
- "@junejs/db": "0.0.20",
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/adapter.ts CHANGED
@@ -268,3 +268,69 @@ export function vercel(opts?: { runtime?: "node" | "edge"; regions?: string[] })
268
268
  },
269
269
  };
270
270
  }
271
+
272
+ // Deno Deploy target. The SAME portable bundle as workers/vercel (edge-light →
273
+ // react-dom server.edge.js, which Deno's web-standard runtime runs); the entry
274
+ // wraps the pipeline in `Deno.serve(withDenoAssets(...))`. Deno Deploy has no CDN,
275
+ // so the handler serves /_june/* from the co-located assets/ dir itself (like
276
+ // vercel()'s split, but in-process). `june build` produces dist/{worker.js,assets}
277
+ // and `deployctl deploy` ships the dir — no Build Output tree to construct.
278
+ //
279
+ // db: turso() works (libsql over HTTPS, env-driven). sqlite()/d1() are
280
+ // Cloudflare-only; validate() rejects them.
281
+ export function deno(opts?: { org?: string; app?: string }): JuneAdapter {
282
+ return {
283
+ name: "deno",
284
+ capabilities: { runtime: "edge", persistentConnections: true, assets: "server" },
285
+ // "deno" first (a dep may ship a Deno-specific build); else edge-light →
286
+ // server.edge.js (Web Streams + fetch, which Deno has).
287
+ conditions: ["deno", "edge-light", "edge", "import", "default"],
288
+
289
+ validate({ config }) {
290
+ const kind = config.resources?.db?.kind;
291
+ if (kind && kind !== "turso") {
292
+ throw new Error(
293
+ `deno(): the '${kind}' db isn't supported on Deno Deploy (D1 is Cloudflare-only).\n` +
294
+ " Use turso() (libsql over HTTPS) for a Deno db, or deploy to Workers.",
295
+ );
296
+ }
297
+ },
298
+
299
+ entry() {
300
+ // The `export default { fetch }` Web Standard export — what `deno serve` (and
301
+ // Deno Deploy's runtime) invokes, and the same shape vercel()'s Node runtime
302
+ // uses. (A top-level `Deno.serve()` only works under `deno run`, not the
303
+ // platform.) No env glue: withDenoAssets reads Deno.env itself.
304
+ return {
305
+ imports: [`import { withDenoAssets } from "@junejs/server/worker";`],
306
+ wrap: (pipelineVar) => `export default { fetch: withDenoAssets(${pipelineVar}) };`,
307
+ };
308
+ },
309
+
310
+ async emit({ outDir }) {
311
+ // The bundle (worker.js + chunks) and assets/ already sit in outDir. The
312
+ // deno.json declares the Deno Deploy target (org/app) + the runtime block.
313
+ // `type: "dynamic"` is REQUIRED — it's the tag that tells EA "run this
314
+ // entrypoint as a server" (the `{ fetch }` export `deno serve` runs). Omit
315
+ // it and EA falls back to framework auto-detection ("internally optimized"),
316
+ // finds nothing, and the revision dies with "No runtime entrypoint provided".
317
+ // (The other arm is `type: "static"` + cwd/spa for a prebuilt SPA.) Source
318
+ // config takes precedence over the dashboard, so the deploy is reproducible.
319
+ // org/app come from deno({ org, app }) — without them, `deno deploy` prompts.
320
+ await writeFile(
321
+ join(outDir, "deno.json"),
322
+ JSON.stringify(
323
+ {
324
+ deploy: {
325
+ ...(opts?.org ? { org: opts.org } : {}),
326
+ ...(opts?.app ? { app: opts.app } : {}),
327
+ runtime: { type: "dynamic", entrypoint: "worker.js" },
328
+ },
329
+ },
330
+ null,
331
+ 2,
332
+ ) + "\n",
333
+ );
334
+ },
335
+ };
336
+ }
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/deploy.ts CHANGED
@@ -52,7 +52,7 @@ export async function juneDeploy(appRoot: string, options: DeployOptions = {}):
52
52
  // fallback. workers() is the default when neither names one.
53
53
  const adapterName = (cfg.deploy?.adapter as { name?: string } | undefined)?.name;
54
54
  const target = adapterName ?? cfg.deploy?.target ?? "workers";
55
- if (target !== "workers" && target !== "vercel") {
55
+ if (target !== "workers" && target !== "vercel" && target !== "deno") {
56
56
  throw new Error(`unknown deploy target: ${target}`);
57
57
  }
58
58
 
@@ -62,9 +62,75 @@ export async function juneDeploy(appRoot: string, options: DeployOptions = {}):
62
62
  }
63
63
 
64
64
  const run = options.runCli ?? spawnCli;
65
- return target === "vercel"
66
- ? deployVercel(appRoot, options, run)
67
- : deployWorkers(appRoot, cfg, options, run);
65
+ if (target === "vercel") return deployVercel(appRoot, options, run);
66
+ if (target === "deno") return deployDeno(appRoot, cfg, options, run);
67
+ return deployWorkers(appRoot, cfg, options, run);
68
+ }
69
+
70
+ // --- Deno Deploy: `deno deploy` ships the built dir (worker.js + assets/) -------
71
+ // ONE-TIME app provisioning (the trap): the app must be created in DYNAMIC mode
72
+ // with worker.js as the entrypoint, or Deno Deploy auto-detects a framework
73
+ // ("internally optimized"), finds none, and every build dies with "No runtime
74
+ // entrypoint provided". The emitted deno.json's deploy.runtime.type="dynamic"
75
+ // only takes precedence AFTER a successful source-config build — a chicken-and-egg
76
+ // the first build can't satisfy. So provision once with the config forced:
77
+ // deno deploy create --org=<org> --app=<app> --source=local \
78
+ // --do-not-use-detected-build-config --runtime-mode=dynamic --entrypoint=worker.js
79
+ // After that, plain `deno deploy` re-uses the stuck-free config on every ship.
80
+ const DENO_CREATE_HINT =
81
+ "first deploy? provision the app in dynamic mode (auto-detect won't find an entrypoint):\n" +
82
+ " deno deploy create --org=<org> --app=<app> --source=local \\\n" +
83
+ " --do-not-use-detected-build-config --runtime-mode=dynamic --entrypoint=worker.js";
84
+
85
+ async function deployDeno(
86
+ appRoot: string,
87
+ cfg: Awaited<ReturnType<typeof loadJuneConfig>>,
88
+ options: DeployOptions,
89
+ run: CliRun,
90
+ ): Promise<DeployResult> {
91
+ const dist = join(appRoot, "dist");
92
+ if (!existsSync(join(dist, "worker.js"))) {
93
+ throw new Error(`no built bundle (expected ${join(dist, "worker.js")}) — run june build first`);
94
+ }
95
+ if (options.dryRun) {
96
+ console.log(`dry-run: built ${dist} (skipping deno deploy)`);
97
+ return { url: null, dryRun: true, configPath: dist, migrated: [] };
98
+ }
99
+ if (!process.env.DENO_DEPLOY_TOKEN) {
100
+ console.log(
101
+ "note: DENO_DEPLOY_TOKEN not set — `deno deploy` will use its own login\n" +
102
+ " (run `deno deploy login` once, or export the token).",
103
+ );
104
+ }
105
+ // Deno Deploy (the current platform; Classic + deployctl retire 2026-07-20). The
106
+ // emitted deno.json carries deploy.{org,app,runtime}, so `deno deploy` ships dist/
107
+ // (worker.js + chunks + assets/) with no extra flags onto an already-provisioned
108
+ // app. --prod targets production (default is a preview). Auth from DENO_DEPLOY_TOKEN.
109
+ const args = ["deno", "deploy"];
110
+ if (options.prod) args.push("--prod");
111
+
112
+ const { stdout, stderr, exitCode } = await run(args, dist);
113
+ if (stdout.trim()) console.log(stdout.trimEnd());
114
+ if (exitCode !== 0) {
115
+ const out = stderr + stdout;
116
+ if (/token|login|unauthorized|forbidden|organization was not found|app.*not found/i.test(out)) {
117
+ throw new Error(
118
+ "Deno Deploy auth/org failed — set DENO_DEPLOY_TOKEN (or `deno deploy login`) and the\n" +
119
+ " org/app in deno({ org, app }).\n " +
120
+ DENO_CREATE_HINT +
121
+ "\n" +
122
+ stderr.trim(),
123
+ );
124
+ }
125
+ if (/no runtime entrypoint|build config/i.test(out)) {
126
+ throw new Error("Deno Deploy build failed — " + DENO_CREATE_HINT + "\n" + stderr.trim());
127
+ }
128
+ throw new Error(`deno deploy failed (exit ${exitCode})\n${stderr.trim()}`);
129
+ }
130
+ // EA serves on *.deno.net (the prod alias <app>.<org>.deno.net + the per-revision
131
+ // preview); Classic used *.deno.dev. Match both so the URL is always surfaced.
132
+ const url = stdout.match(/https:\/\/\S+\.deno\.(net|dev)\S*/)?.[0] ?? null;
133
+ return { url, dryRun: false, configPath: dist, migrated: [] };
68
134
  }
69
135
 
70
136
  // --- Vercel: upload the prebuilt Build Output (.vercel/output/) ---------------
package/src/host.ts CHANGED
@@ -49,7 +49,7 @@ export interface JuneHost {
49
49
  // this with the in-isolate dual-graph loader; the seam stays for the fallback.
50
50
  spawnModule(entry: string, args: string[], opts: { conditions?: string[] }): SpawnedModule;
51
51
  // Open a LOCAL SQLite database — the internal primitive the `sqlite()` db
52
- // adapter builds on (docs/data-layer.md: openDb is demoted from the
52
+ // adapter builds on (docs/data-layer-boundary.md: openDb is demoted from the
53
53
  // user-facing API to a host primitive; apps declare `resources.db` instead).
54
54
  openDb(path: string): Promise<JuneDb>;
55
55
  }
package/src/index.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  export {
5
5
  workers,
6
6
  vercel,
7
+ deno,
7
8
  type JuneAdapter,
8
9
  type AdapterCapabilities,
9
10
  type ResourcePlan,
package/src/kv.ts CHANGED
@@ -1,4 +1,4 @@
1
- // `kv` resource adapters. The kv resource IS the cache (docs/data-layer
1
+ // `kv` resource adapters. The kv resource IS the cache (docs/data-layer-boundary
2
2
  // .md: "reframe cache as the kv resource — it already is one"): these wrap
3
3
  // @junejs/core/cache's CacheStore (memory / redis) as the simpler JuneKv contract,
4
4
  // so there is ONE key-value system, surfaced both as cache() and as ambient kv.
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
  });
@@ -215,3 +236,54 @@ export function withAssets(
215
236
  },
216
237
  };
217
238
  }
239
+
240
+ // The Deno-target twin of withAssets. Deno Deploy has NO CDN / asset binding, so
241
+ // the handler serves the hashed framework assets (/_june/*) from the bundle's
242
+ // co-located assets/ dir and falls through to the pipeline for everything else —
243
+ // the same split vercel() gets from the CDN, but in-process. The deno() adapter
244
+ // bakes `export default { fetch: withDenoAssets(pipeline) }` into the entry (the
245
+ // Web Standard handler `deno serve` / Deno Deploy runs). Deno globals are
246
+ // referenced only inside the body, so non-Deno bundles tree-shake this out.
247
+ declare const Deno: {
248
+ readFile(path: string | URL): Promise<Uint8Array>;
249
+ };
250
+
251
+ const ASSET_CONTENT_TYPES: Record<string, string> = {
252
+ css: "text/css; charset=utf-8",
253
+ js: "text/javascript; charset=utf-8",
254
+ json: "application/json; charset=utf-8",
255
+ map: "application/json; charset=utf-8",
256
+ svg: "image/svg+xml",
257
+ png: "image/png",
258
+ jpg: "image/jpeg",
259
+ jpeg: "image/jpeg",
260
+ webp: "image/webp",
261
+ avif: "image/avif",
262
+ ico: "image/x-icon",
263
+ woff2: "font/woff2",
264
+ woff: "font/woff",
265
+ };
266
+
267
+ export function withDenoAssets(pipeline: FetchPipeline): (request: Request) => Promise<Response> {
268
+ return async (request) => {
269
+ const url = new URL(request.url);
270
+ if (request.method === "GET" && url.pathname.startsWith("/_june/")) {
271
+ try {
272
+ const file = await Deno.readFile(new URL(`./assets${url.pathname}`, import.meta.url));
273
+ const ext = url.pathname.split(".").pop()?.toLowerCase() ?? "";
274
+ return new Response(file as BodyInit, {
275
+ headers: {
276
+ "content-type": ASSET_CONTENT_TYPES[ext] ?? "application/octet-stream",
277
+ "cache-control": "public, max-age=31536000, immutable",
278
+ },
279
+ });
280
+ } catch {
281
+ /* not on disk → fall through (the pipeline 404s) */
282
+ }
283
+ }
284
+ // No env arg: Deno has no platform bindings (unlike D1/KV on workers); env-
285
+ // driven resources (turso) read process.env / Deno.env themselves. Keeps the
286
+ // function off the --allow-env permission too.
287
+ return pipeline.fetch(request);
288
+ };
289
+ }