@k8ordo/framework 1.0.0 → 1.1.0

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/README.md CHANGED
@@ -33,12 +33,13 @@ dependencies in either mode: the build bundles what the application runs.
33
33
 
34
34
  <!-- peers -->
35
35
 
36
- | Package | Version | Required | Needed for |
37
- | ---------------- | ------- | -------- | ------------------------------------------ |
38
- | `@k8ordo/router` | ^1.0.0 | yes | routing, re-exported by the framework |
39
- | `react` | ≥19.3.0 | yes | rendering |
40
- | `react-dom` | ≥19.3.0 | yes | rendering |
41
- | `vite` | ≥8.0.0 | yes | the build (`framework()` is a Vite plugin) |
36
+ | Package | Version | Required | Needed for |
37
+ | --------------------- | -------- | -------- | ------------------------------------------ |
38
+ | `@k8ordo/router` | ^1.1.0 | yes | routing, re-exported by the framework |
39
+ | `react` | ≥19.3.0 | yes | rendering |
40
+ | `react-dom` | ≥19.3.0 | yes | rendering |
41
+ | `vite` | ≥8.0.0 | yes | the build (`framework()` is a Vite plugin) |
42
+ | `oxc-transform-react` | ^0.152.0 | optional | `reactCompiler: true` (React Compiler) |
42
43
 
43
44
  <!-- /peers -->
44
45
 
@@ -134,7 +135,9 @@ A value `paths` does not list — a product added after the build — is
134
135
  answered by a `fallback.tsx` beside the page: the build writes it once as a
135
136
  shell, the layouts in HTML, and the browser draws the page from the URL.
136
137
  The build also writes the `_redirects` through which Netlify and Cloudflare
137
- reach the shell, and `vercel()` the same rewrites for Vercel.
138
+ reach the shell, and `vercel()` the same rewrites for Vercel — around them,
139
+ routes of your own: headers on every page, redirects, a rewrite decided by
140
+ `Accept`, a proxy to another origin.
138
141
 
139
142
  ## AI Agent Documentation
140
143
 
@@ -1,2 +1 @@
1
- import { defineRoutes } from "@k8ordo/router";
2
- export { defineRoutes };
1
+ export { defineRoutes } from "@k8ordo/router";
package/dist/index.mjs CHANGED
@@ -1,2 +1 @@
1
- import { bindParams, href, isNotFound, matchPath, navigateTo, normalizePathname, notFound, useMatch, usePathname, usePendingPathname, withBase } from "@k8ordo/router";
2
- export { bindParams, href, isNotFound, matchPath, navigateTo, normalizePathname, notFound, useMatch, usePathname, usePendingPathname, withBase };
1
+ export { bindParams, href, isNotFound, matchPath, navigateTo, normalizePathname, notFound, useMatch, usePathname, usePendingPathname, withBase } from "@k8ordo/router";
@@ -39,4 +39,5 @@ export declare class FallbackCatch extends Component<CatchProps, CatchState> {
39
39
  export declare const FallbackBoundary: ({ children }: {
40
40
  children: ReactNode;
41
41
  }) => ReactNode;
42
- //#endregion
42
+ //#endregion
43
+ export {};
@@ -26,4 +26,5 @@ type AnyGuard = (context: {
26
26
  * inner guard assume what an outer one checked.
27
27
  */
28
28
  export declare const runGuards: (guards: readonly AnyGuard[], context: Parameters<AnyGuard>[0]) => Promise<Response | null>;
29
- //#endregion
29
+ //#endregion
30
+ export {};
@@ -36,4 +36,5 @@ export declare class PageBoundary extends Component<{
36
36
  componentDidCatch(error: unknown): void;
37
37
  render(): ReactNode;
38
38
  }
39
- //#endregion
39
+ //#endregion
40
+ export {};
@@ -46,4 +46,5 @@ export declare const parseParams: (schemas: readonly ParamsSchema[], raw: Readon
46
46
  * says.
47
47
  */
48
48
  export declare const parseCatchAllParams: (schemas: readonly ParamsSchema[], raw: Readonly<Record<string, string>>) => ParsedParams;
49
- //#endregion
49
+ //#endregion
50
+ export {};
@@ -18,4 +18,5 @@ type ResolvedRedirect = {
18
18
  * the table normalizes it, so `/old/` is `/old` here too.
19
19
  */
20
20
  export declare const resolveRedirects: (declared: Readonly<Record<string, RedirectTarget>>) => ((pattern: string, pathname: string) => ResolvedRedirect | null);
21
- //#endregion
21
+ //#endregion
22
+ export {};
@@ -25,4 +25,5 @@ export declare class Redirect extends Error {
25
25
  */
26
26
  export declare const redirect: (to: string) => never;
27
27
  export declare const isRedirect: (value: unknown) => value is Redirect;
28
- //#endregion
28
+ //#endregion
29
+ export {};
@@ -32,4 +32,5 @@ export declare const methodNotAllowed: (module: AnyRouteModule) => Response;
32
32
  * answers the request as much as a guard does.
33
33
  */
34
34
  export declare const runRoute: ({ handler, headOnly }: RouteAnswer, context: RouteContext) => Promise<Response>;
35
- //#endregion
35
+ //#endregion
36
+ export {};
@@ -0,0 +1,66 @@
1
+ import { Plugin, PluginOption } from "vite";
2
+ import "react";
3
+ //#region ../framework-engine/dist/host.d.mts
4
+ /** What `framework()` takes whatever the mode. */
5
+ type EngineOptions = {
6
+ /** Where the route files live, relative to the project root. */
7
+ readonly routesDir?: string;
8
+ /**
9
+ * Compiles the client components with React Compiler — the Rust one,
10
+ * through `@vitejs/plugin-react`'s `compiler`, which needs
11
+ * `oxc-transform-react` installed. Server components are left as they are.
12
+ */
13
+ readonly reactCompiler?: boolean;
14
+ };
15
+ //#endregion
16
+ //#region src/documents.d.ts
17
+ /**
18
+ * The `Content-Security-Policy` an application wants on every page, as
19
+ * directives and their sources. The build writes it into each page's
20
+ * `<meta>`, with the hashes of that page's signed inline scripts added.
21
+ */
22
+ type ContentSecurityPolicy = Readonly<Record<string, readonly string[]>>;
23
+ //#endregion
24
+ //#region src/static.d.ts
25
+ type StaticOptions = EngineOptions & {
26
+ /**
27
+ * Every route rendered at build time and written to files; no server at
28
+ * run time, so nothing that answers a request (Server Actions, guards, the
29
+ * request API) builds.
30
+ */
31
+ readonly mode: 'static';
32
+ /**
33
+ * Pathnames for routes with parameters, in the table's terms — without
34
+ * Vite's `base`. Static rendering cannot invent them, and a build that
35
+ * quietly skipped half the site would be worse than one that refuses.
36
+ *
37
+ * The patterns that need covering are handed in, so a site whose parameter
38
+ * takes the same values everywhere — a locale segment, say — expands them
39
+ * rather than listing every page twice.
40
+ *
41
+ * For a page with a `fallback.tsx` beside it, a pathname that still holds
42
+ * a parameter (`/ja/posts/:id`) is where its shell stands: the values the
43
+ * build did not write are answered there. Without one, the bare pattern
44
+ * is the shell's location.
45
+ */
46
+ readonly paths?: (patterns: readonly string[]) => readonly string[] | Promise<readonly string[]>;
47
+ /**
48
+ * The origin the site is served from — `https://example.com`. With it the
49
+ * build also writes `sitemap.xml`, listing every page it rendered at its
50
+ * URL, Vite's `base` included; without it, no sitemap, because a sitemap of
51
+ * relative URLs is not one.
52
+ */
53
+ readonly site?: string;
54
+ /**
55
+ * The `Content-Security-Policy` every page carries, as directives and
56
+ * their sources. The framework decides no policy: it writes this one into
57
+ * each page's `<meta>`, adding to `script-src` the hashes of its own inline
58
+ * scripts on that page — the payload and React's. An inline script of the
59
+ * application's is allowed by its hash here, as
60
+ * `colorSchemeScriptHash()` gives `@k8ordo/color-scheme`'s. Without it, no
61
+ * policy is written.
62
+ */
63
+ readonly csp?: ContentSecurityPolicy;
64
+ };
65
+ //#endregion
66
+ export { ContentSecurityPolicy as n, EngineOptions as r, StaticOptions as t };
package/dist/vercel.d.mts CHANGED
@@ -1,4 +1,46 @@
1
+ import "./static-BaGdAVF1.mjs";
1
2
  import { Plugin } from "vite";
3
+ //#region src/vercel-output.d.ts
4
+ /**
5
+ * A route as Vercel's Build Output API (v3) reads it, written to
6
+ * `.vercel/output/config.json` as given — the fields an application needs.
7
+ */
8
+ type VercelRoute = {
9
+ /**
10
+ * A regular expression Vercel matches against the whole pathname, the
11
+ * query left out — `^/posts/` does not match `/posts/first`, `^/posts/.+$`
12
+ * does — regardless of case unless `caseSensitive`.
13
+ */
14
+ readonly src: string;
15
+ /** A path in the deployment, or a URL elsewhere. */
16
+ readonly dest?: string;
17
+ readonly status?: number;
18
+ readonly headers?: Readonly<Record<string, string>>;
19
+ /** Keep matching the routes after this one: a route that only adds headers. */
20
+ readonly continue?: boolean;
21
+ /**
22
+ * Look for `dest` among the files; when none is there, go on with `dest`
23
+ * as the pathname.
24
+ */
25
+ readonly check?: boolean;
26
+ readonly caseSensitive?: boolean;
27
+ readonly has?: ReadonlyArray<{
28
+ readonly type: 'header';
29
+ /** Lowercase, as Vercel compares it. */
30
+ readonly key: string;
31
+ readonly value?: string;
32
+ }>;
33
+ };
34
+ type VercelOptions = {
35
+ /** Under `mode: 'static'`, routes tried before any file. */
36
+ readonly beforeFiles?: readonly VercelRoute[];
37
+ /**
38
+ * Under `mode: 'static'`, routes tried once neither a file nor a shell
39
+ * answered, before `404.html`.
40
+ */
41
+ readonly beforeNotFound?: readonly VercelRoute[];
42
+ };
43
+ //#endregion
2
44
  //#region src/vercel.d.ts
3
45
  /**
4
46
  * Deploys the application to Vercel: after `vite build`, the build is also
@@ -9,7 +51,11 @@ import { Plugin } from "vite";
9
51
  * nothing but its own directory, which is all the handler needs: server mode
10
52
  * builds it with every dependency bundled in. Under `mode: 'static'` there is
11
53
  * no function, only the rewrites that answer a value the build did not write
12
- * with its shell — Vercel never reads `_redirects`.
54
+ * with its shell — Vercel never reads `_redirects` — and the application's
55
+ * own routes around them, written as given: `beforeFiles` ahead of every
56
+ * file, `beforeNotFound` ahead of `404.html`. A file a `route.ts` wrote is
57
+ * served with the `Content-Type` its `GET` answered.
13
58
  */
14
- export declare const vercel: () => Plugin;
15
- //#endregion
59
+ export declare const vercel: (options?: VercelOptions) => Plugin;
60
+ //#endregion
61
+ export type { VercelOptions, VercelRoute };
package/dist/vercel.mjs CHANGED
@@ -7,12 +7,17 @@ import path from "node:path";
7
7
  const FUNCTION = "handler";
8
8
  /**
9
9
  * The hashed assets are marked immutable in the `hit` phase — only once a
10
- * file answered — so a missing one is never cached for a year.
10
+ * file answered — so a missing one is never cached for a year. `src` spells
11
+ * out the rest of the path, since Vercel matches it against the whole path
12
+ * and a prefix alone matches no file; `important`, as `@vercel/next` marks
13
+ * its own hashed files, keeps another route's `Cache-Control` from taking
14
+ * its place.
11
15
  */
12
16
  const immutableAssets = (base) => ({
13
- src: `^${RegExp.escape(`${base}assets/`)}`,
17
+ src: `^${RegExp.escape(`${base}assets/`)}.+$`,
14
18
  headers: { "cache-control": "public, max-age=31536000, immutable" },
15
- continue: true
19
+ continue: true,
20
+ important: true
16
21
  });
17
22
  /** Files first, and whatever names none goes to the handler. */
18
23
  const configFor = (base) => ({
@@ -57,15 +62,21 @@ const routeOf = (rule) => {
57
62
  };
58
63
  };
59
64
  /**
60
- * Files first; only what names none is rewritten — a value the build did
61
- * not write to its shell, the payload URL of a built URL that is not a page
62
- * to HTML — and what nothing rewrote is the build's `404.html`.
65
+ * The application's `beforeFiles`, then the files; only what names none is
66
+ * rewritten — a value the build did not write to its shell, the payload URL
67
+ * of a built URL that is not a page to HTML — and what nothing rewrote goes
68
+ * through the application's `beforeNotFound` to the build's `404.html`. A
69
+ * file a route.ts wrote is served with the type its `GET` answered, even
70
+ * where the extension says the same, so no file is left to the types Vercel
71
+ * gives an extension.
63
72
  */
64
- const staticConfigFor = ({ base, rewrites, notFound }) => ({
73
+ const staticConfigFor = ({ base, rewrites, notFound, types }, { beforeFiles = [], beforeNotFound = [] }) => ({
65
74
  version: 3,
66
75
  routes: [
76
+ ...beforeFiles,
67
77
  { handle: "filesystem" },
68
78
  ...rewrites.map((rule) => routeOf(rule)),
79
+ ...beforeNotFound,
69
80
  ...notFound ? [{
70
81
  src: "^/.*$",
71
82
  dest: withBase("/404.html", base),
@@ -73,7 +84,8 @@ const staticConfigFor = ({ base, rewrites, notFound }) => ({
73
84
  }] : [],
74
85
  { handle: "hit" },
75
86
  immutableAssets(base)
76
- ]
87
+ ],
88
+ ...types.size === 0 ? {} : { overrides: Object.fromEntries([...types].toSorted(([a], [b]) => a < b ? -1 : 1).map(([file, contentType]) => [withBase(`/${file}`, base).slice(1), { contentType }])) }
77
89
  });
78
90
  /**
79
91
  * A Node.js function that hands Vercel the handler as it is: the launcher
@@ -129,10 +141,11 @@ const writeVercelOutput = async (root, dirs, base) => {
129
141
  /**
130
142
  * A static build laid out as Vercel's Build Output API (v3) reads it: the
131
143
  * client build as static files at Vite's `base`, and the rewrites a host
132
- * applies once no file answered as routes — no function. `_redirects` stays
133
- * behind: Vercel never reads it, and would serve it as a file.
144
+ * applies once no file answered as routes, around them the application's
145
+ * own — no function. `_redirects` stays behind: Vercel never reads it, and
146
+ * would serve it as a file.
134
147
  */
135
- const writeStaticVercelOutput = async (output) => {
148
+ const writeStaticVercelOutput = async (output, routes = {}) => {
136
149
  const dir = path.join(output.root, ".vercel", "output");
137
150
  await rm(dir, {
138
151
  recursive: true,
@@ -143,7 +156,7 @@ const writeStaticVercelOutput = async (output) => {
143
156
  recursive: true,
144
157
  filter: (source) => source !== redirects
145
158
  });
146
- await writeFile(path.join(dir, "config.json"), json(staticConfigFor(output)));
159
+ await writeFile(path.join(dir, "config.json"), json(staticConfigFor(output, routes)));
147
160
  };
148
161
  //#endregion
149
162
  //#region src/vercel.ts
@@ -156,9 +169,14 @@ const writeStaticVercelOutput = async (output) => {
156
169
  * nothing but its own directory, which is all the handler needs: server mode
157
170
  * builds it with every dependency bundled in. Under `mode: 'static'` there is
158
171
  * no function, only the rewrites that answer a value the build did not write
159
- * with its shell — Vercel never reads `_redirects`.
172
+ * with its shell — Vercel never reads `_redirects` — and the application's
173
+ * own routes around them, written as given: `beforeFiles` ahead of every
174
+ * file, `beforeNotFound` ahead of `404.html`. A file a `route.ts` wrote is
175
+ * served with the `Content-Type` its `GET` answered.
160
176
  */
161
- const vercel = () => {
177
+ const vercel = (options = {}) => {
178
+ const { beforeFiles, beforeNotFound } = options;
179
+ for (const route of [...beforeFiles ?? [], ...beforeNotFound ?? []]) for (const { key } of route.has ?? []) if (key !== key.toLowerCase()) throw new Error(`vercel() route ${route.src} has the header key "${key}" — write it lowercase ("${key.toLowerCase()}"), as Vercel compares it`);
162
180
  let isStatic = false;
163
181
  let logger;
164
182
  return {
@@ -167,9 +185,10 @@ const vercel = () => {
167
185
  configResolved(config) {
168
186
  isStatic = config.plugins.some((plugin) => plugin.name === "k8ordo:static");
169
187
  ({logger} = config);
188
+ if (!isStatic && (beforeFiles !== void 0 || beforeNotFound !== void 0)) throw new Error("vercel() takes beforeFiles and beforeNotFound under mode: 'static' only — under mode: 'server' the handler answers every URL no file names");
170
189
  },
171
190
  api: { async writeStatic(output) {
172
- await writeStaticVercelOutput(output);
191
+ await writeStaticVercelOutput(output, options);
173
192
  logger?.info("k8ordo: wrote .vercel/output");
174
193
  } },
175
194
  buildApp: {
package/dist/vite.d.mts CHANGED
@@ -1,62 +1,5 @@
1
- import { Plugin, PluginOption } from "vite";
2
- import "react";
3
- //#region ../framework-engine/dist/host.d.mts
4
- /** What `framework()` takes whatever the mode. */
5
- type EngineOptions = {
6
- /** Where the route files live, relative to the project root. */
7
- readonly routesDir?: string;
8
- };
9
- //#endregion
10
- //#region src/documents.d.ts
11
- /**
12
- * The `Content-Security-Policy` an application wants on every page, as
13
- * directives and their sources. The build writes it into each page's
14
- * `<meta>`, with the hashes of that page's signed inline scripts added.
15
- */
16
- type ContentSecurityPolicy = Readonly<Record<string, readonly string[]>>;
17
- //#endregion
18
- //#region src/static.d.ts
19
- type StaticOptions = EngineOptions & {
20
- /**
21
- * Every route rendered at build time and written to files; no server at
22
- * run time, so nothing that answers a request (Server Actions, guards, the
23
- * request API) builds.
24
- */
25
- readonly mode: 'static';
26
- /**
27
- * Pathnames for routes with parameters, in the table's terms — without
28
- * Vite's `base`. Static rendering cannot invent them, and a build that
29
- * quietly skipped half the site would be worse than one that refuses.
30
- *
31
- * The patterns that need covering are handed in, so a site whose parameter
32
- * takes the same values everywhere — a locale segment, say — expands them
33
- * rather than listing every page twice.
34
- *
35
- * For a page with a `fallback.tsx` beside it, a pathname that still holds
36
- * a parameter (`/ja/posts/:id`) is where its shell stands: the values the
37
- * build did not write are answered there. Without one, the bare pattern
38
- * is the shell's location.
39
- */
40
- readonly paths?: (patterns: readonly string[]) => readonly string[] | Promise<readonly string[]>;
41
- /**
42
- * The origin the site is served from — `https://example.com`. With it the
43
- * build also writes `sitemap.xml`, listing every page it rendered at its
44
- * URL, Vite's `base` included; without it, no sitemap, because a sitemap of
45
- * relative URLs is not one.
46
- */
47
- readonly site?: string;
48
- /**
49
- * The `Content-Security-Policy` every page carries, as directives and
50
- * their sources. The framework decides no policy: it writes this one into
51
- * each page's `<meta>`, adding to `script-src` the hashes of its own inline
52
- * scripts on that page — the payload and React's. An inline script of the
53
- * application's is allowed by its hash here, as
54
- * `colorSchemeScriptHash()` gives `@k8ordo/color-scheme`'s. Without it, no
55
- * policy is written.
56
- */
57
- readonly csp?: ContentSecurityPolicy;
58
- };
59
- //#endregion
1
+ import { n as ContentSecurityPolicy, r as EngineOptions, t as StaticOptions } from "./static-BaGdAVF1.mjs";
2
+ import { PluginOption } from "vite";
60
3
  //#region src/vite.d.ts
61
4
  export type ServerOptions = EngineOptions & {
62
5
  /**
package/dist/vite.mjs CHANGED
@@ -1079,7 +1079,7 @@ const engine = (options, host) => {
1079
1079
  mode: host.mode
1080
1080
  });
1081
1081
  return [
1082
- react(),
1082
+ react({ compiler: options.reactCompiler === true }),
1083
1083
  rsc(),
1084
1084
  {
1085
1085
  name: "k8ordo:engine",
@@ -1351,10 +1351,12 @@ const redirectPage = (to) => {
1351
1351
  const escaped = escapeMarkup(to);
1352
1352
  return `<!doctype html><html><head><meta charset="utf-8"><meta http-equiv="refresh" content="0;url=${escaped}"><link rel="canonical" href="${escaped}"><title>Redirecting</title></head><body><a href="${escaped}">${escaped}</a></body></html>\n`;
1353
1353
  };
1354
- const HEADER_ONLY = /* @__PURE__ */ new Set([
1355
- "frame-ancestors",
1356
- "report-uri",
1357
- "sandbox"
1354
+ const IGNORED = "is ignored in a <meta> — set it as a header at the host";
1355
+ const HEADER_ONLY = /* @__PURE__ */ new Map([
1356
+ ["frame-ancestors", IGNORED],
1357
+ ["report-uri", IGNORED],
1358
+ ["sandbox", IGNORED],
1359
+ ["report-to", "names an endpoint only a Reporting-Endpoints response header defines — set the policy as a header at the host"]
1358
1360
  ]);
1359
1361
  /** What decides scripts: `script-src`, or `default-src` without it. */
1360
1362
  const scriptDirectiveOf = (policy) => {
@@ -1374,15 +1376,16 @@ const elementDirectivesOf = (policy) => {
1374
1376
  };
1375
1377
  /**
1376
1378
  * What would make a policy wrong to write into every page, named at once:
1377
- * a directive a `<meta>` ignores, a source that is not one, and
1379
+ * a directive that works only from a header, a source that is not one, and
1378
1380
  * `'strict-dynamic'`, under which the framework's module script — allowed by
1379
1381
  * where it comes from, since a file cannot sign it — would never load.
1380
1382
  */
1381
1383
  const policyProblems = (policy) => {
1382
1384
  const problems = [];
1383
1385
  for (const [name, sources] of Object.entries(policy)) {
1386
+ const headerOnly = HEADER_ONLY.get(name);
1384
1387
  if (!/^[a-z][a-z-]*$/u.test(name)) problems.push(`"${name}" is not a directive name`);
1385
- else if (HEADER_ONLY.has(name)) problems.push(`${name} is ignored in a <meta> — set it as a header at the host`);
1388
+ else if (headerOnly !== void 0) problems.push(`${name} ${headerOnly}`);
1386
1389
  for (const source of sources) if (source === "" || /[\s;,]/u.test(source)) problems.push(`"${source}" in ${name} is not one source — give each its own entry`);
1387
1390
  }
1388
1391
  for (const [name, sources] of elementDirectivesOf(policy)) if (sources.includes("'strict-dynamic'")) problems.push(`'strict-dynamic' in ${name} would block the framework's module script, which a file allows by where it comes from ('self'), not by a hash`);
@@ -2085,10 +2088,13 @@ const staticMode = (options) => {
2085
2088
  const failed = [];
2086
2089
  const redirected = /* @__PURE__ */ new Set();
2087
2090
  const unanswered = [];
2091
+ const types = /* @__PURE__ */ new Map();
2088
2092
  await inParallel(plan.paths.flatMap((pathname) => {
2089
2093
  if (byRoute.has(pathname)) return [async () => {
2090
- const status = await writeRoute(path.join(clientDir, dirFor(pathname)), handler, urlFor(pathname));
2094
+ const file = path.join(clientDir, dirFor(pathname));
2095
+ const { status, type } = await writeRoute(file, handler, urlFor(pathname));
2091
2096
  if (status !== 200) unanswered.push(`${pathname} (${String(status)})`);
2097
+ else if (type !== null) types.set(path.relative(clientDir, file).split(path.sep).join("/"), type);
2092
2098
  }];
2093
2099
  const dir = path.join(clientDir, dirFor(pathname));
2094
2100
  return [async () => {
@@ -2153,7 +2159,8 @@ const staticMode = (options) => {
2153
2159
  client: clientDir,
2154
2160
  base,
2155
2161
  rewrites,
2156
- notFound: unmatched !== null
2162
+ notFound: unmatched !== null,
2163
+ types
2157
2164
  });
2158
2165
  else if (vercel === void 0 && process.env.VERCEL !== void 0 && plan.shells.length > 0) builder.config.logger.warn("k8ordo: this build runs on Vercel, which does not read _redirects — add vercel() from @k8ordo/framework/vercel, or the shells are never served");
2159
2166
  const shells = plan.shells.length === 0 ? "" : `, ${String(plan.shells.length)} ${plan.shells.length === 1 ? "shell" : "shells"} (${plan.shells.map((shell) => shell.location).join(", ")})`;
@@ -2209,19 +2216,27 @@ const write = async (file, handler, url, csp, { notFoundIsPage, shell }) => {
2209
2216
  return written;
2210
2217
  };
2211
2218
  /**
2212
- * Writes what a route.ts's GET answered, as the file at its pathname — a
2213
- * static host then serves it with the type its extension says. Anything but a
2214
- * 200 is not a file the site has, and nothing is written.
2219
+ * Writes what a route.ts's GET answered, as the file at its pathname, and
2220
+ * says the type it answered with — a file keeps none, so a static host serves
2221
+ * it with the type its extension says, and only an adapter that can name a
2222
+ * file's type (`vercel()`) serves the GET's. Anything but a 200 is not a file
2223
+ * the site has, and nothing is written.
2215
2224
  */
2216
2225
  const writeRoute = async (file, handler, url) => {
2217
2226
  const response = await handler(new Request(url));
2218
2227
  if (response.status !== 200) {
2219
2228
  if (response.status === 500) console.error(`k8ordo: ${url} — ${await response.text()}`);
2220
- return response.status;
2229
+ return {
2230
+ status: response.status,
2231
+ type: null
2232
+ };
2221
2233
  }
2222
2234
  await mkdir(path.dirname(file), { recursive: true });
2223
2235
  await writeFile(file, Buffer.from(await response.arrayBuffer()));
2224
- return response.status;
2236
+ return {
2237
+ status: response.status,
2238
+ type: response.headers.get("content-type")
2239
+ };
2225
2240
  };
2226
2241
  //#endregion
2227
2242
  //#region src/vite.ts
package/docs/GUIDE.md CHANGED
@@ -234,7 +234,9 @@ The package is split by where the code runs.
234
234
  `dist/server.js` runs, for a server of your own to import. A static build
235
235
  it serves as a static host does, its `_redirects` applied.
236
236
  - `@k8ordo/framework/vercel` — `vercel()`, the plugin that writes a build
237
- of either mode for Vercel.
237
+ of either mode for Vercel, and the types `VercelOptions` and
238
+ `VercelRoute`. Under static mode it also takes routes of your own,
239
+ `beforeFiles` and `beforeNotFound`.
238
240
  - `@k8ordo/framework/generated` — what the generated `.k8ordo/` files
239
241
  import. Not for code written by hand.
240
242
 
@@ -245,10 +247,22 @@ dev dependencies.
245
247
 
246
248
  ## The options
247
249
 
248
- `framework()` takes `mode`, which is required, and `routesDir`, the route
249
- directory (default `src/routes`). Three more describe files, so only
250
- `mode: 'static'` takes them — passing them with `mode: 'server'` is a type
251
- error:
250
+ `framework()` takes `mode`, which is required, `routesDir`, the route
251
+ directory (default `src/routes`), and `reactCompiler`, in either mode.
252
+
253
+ `reactCompiler: true` compiles the client components with React Compiler —
254
+ the Rust one, `oxc-transform-react`, through `@vitejs/plugin-react`'s
255
+ `compiler` — in `vite dev` and in the build. Install it beside the framework
256
+ as a dev dependency, in the range the package's README gives under Peer
257
+ Dependencies. Without it, `vite dev` and `vite build` stop as soon as they
258
+ read the config, with React's plugin's own error
259
+ (`React Compiler requires the optional …`), which names
260
+ `react({ compiler: true })`: the option the framework sets for you. Only
261
+ what runs in the browser is compiled; Server Components, and the HTML a
262
+ client component renders on the server, are left as written.
263
+
264
+ Three more describe files, so only `mode: 'static'` takes them — passing
265
+ them with `mode: 'server'` is a type error:
252
266
 
253
267
  - `paths` — the pathnames of routes with parameters, and where a
254
268
  `fallback.tsx`'s shell stands
package/docs/llms.txt CHANGED
@@ -52,7 +52,9 @@ Start with GUIDE.md, then follow the reference most relevant to the task.
52
52
  - [Design Guide](GUIDE.md): choosing a mode and what static mode refuses,
53
53
  setup (install, `vite.config.ts`, `tsconfig.json`, the root layout as the
54
54
  document), `vite dev` and `vite preview` against a static build, the
55
- entries, and the plugin's options.
55
+ entries, and the plugin's options — `reactCompiler` among them, React
56
+ Compiler on the client components, which needs `oxc-transform-react`
57
+ installed.
56
58
 
57
59
  ## References
58
60
 
@@ -103,7 +105,8 @@ Start with GUIDE.md, then follow the reference most relevant to the task.
103
105
  `node dist/server.js` (`PORT`, `HOST`, every dependency bundled in, and
104
106
  `resolve.external` for one that cannot be), `serve` (compression, `ETag`,
105
107
  `Range`), the request handler under Node.js, Bun, Deno and Cloudflare
106
- Workers, the same-origin rule for a `POST`, and `vercel()` in either mode;
108
+ Workers, the same-origin rule for a `POST`, and `vercel()` in either mode,
109
+ with routes of your own under static mode;
107
110
  a tab opened before a deploy, and Vite's `base`.
108
111
 
109
112
  ## Entry points
@@ -130,7 +133,7 @@ Start with GUIDE.md, then follow the reference most relevant to the task.
130
133
  `<head>`, with the hashes of the framework's own inline scripts on that
131
134
  page added to `script-src` (or `default-src` made into it) and
132
135
  `script-src-elem`; a policy with `'strict-dynamic'`, `frame-ancestors`,
133
- `report-uri` or `sandbox` is refused. `FrameworkOptions` is
136
+ `report-uri`, `report-to` or `sandbox` is refused. `FrameworkOptions` is
134
137
  `StaticOptions | ServerOptions`. This entry loads Vite and is for
135
138
  `vite.config.ts` only.
136
139
  - `@k8ordo/framework/server` — server mode only; a static build refuses any
@@ -179,7 +182,15 @@ Start with GUIDE.md, then follow the reference most relevant to the task.
179
182
  `.vercel/output/` (Build Output API v3) for `vercel build` /
180
183
  `vercel deploy --prebuilt` — the client build as static files at Vite's
181
184
  `base` (`assets/` immutable), and under server mode the request handler
182
- as one streaming Node.js function; under static mode no function, and the
183
- shells' rewrites as routes tried after the files, then `404.html`.
185
+ as one streaming Node.js function; under static mode no function, the
186
+ shells' rewrites as routes tried after the files, then `404.html`, and
187
+ each file a `route.ts` wrote served with the `Content-Type` its `GET`
188
+ answered (`overrides`) where other hosts go by its extension.
189
+ Under static mode `vercel({ beforeFiles, beforeNotFound })` also takes
190
+ routes of the application's own (`VercelRoute`: `src`, `dest`, `status`,
191
+ `headers`, `continue`, `check`, `caseSensitive`, `has`), written as given
192
+ before the files and before `404.html`; Vercel alone applies them,
193
+ matching `src` against the whole pathname (`^/posts/.+$`, not
194
+ `^/posts/`).
184
195
  - `@k8ordo/framework/generated` — what the generated `.k8ordo/` files
185
196
  import (`defineRoutes`, `Register`, …). Not for code written by hand.
@@ -91,9 +91,18 @@ page — one that reached it from content — is refused. The framework's module
91
91
  script is allowed by where it comes from (`'self'`), since nothing in a file
92
92
  can sign it, which is why the build refuses a policy with `'strict-dynamic'`.
93
93
  It refuses `frame-ancestors`, `report-uri` and `sandbox` too, which a
94
- `<meta>` ignores: set those as headers at the host. Without `csp`, no policy
95
- is written. There is no `nonce()` for this mode: what an application signed
96
- with it would land in the file, and make every build differ.
94
+ `<meta>` ignores: set those as headers at the host. It refuses `report-to`
95
+ as well: the endpoint it names is defined only by a `Reporting-Endpoints`
96
+ response header, so set a policy that reports as a header at the host.
97
+ That header cannot carry the hashes the build adds page by page, and a
98
+ script runs only where every policy allows it: wherever the header decides
99
+ a script element — `script-src` (`default-src` without it) and
100
+ `script-src-elem` — allow `'self'` and `'unsafe-inline'` with no hash or
101
+ nonce beside them (either turns `'unsafe-inline'` off), and keep `csp`,
102
+ without `report-to`, for the `<meta>`, which allows inline scripts only by
103
+ hash. Without `csp`, no policy is written. There is no `nonce()` for this
104
+ mode: what an application signed with it would land in the file, and make
105
+ every build differ.
97
106
 
98
107
  A `fallback.tsx`'s shell draws its page in the browser from data the
99
108
  browser fetches
@@ -505,6 +505,163 @@ case-sensitively, as the route table matches; then `404.html` under a `404`,
505
505
  when the build wrote one. `vercel()` writes them once the last page is
506
506
  written, whichever side of `framework()` it sits.
507
507
 
508
+ **A file a `route.ts` wrote is served with the type its `GET` answered**,
509
+ the `Content-Type` `vite dev` sends, written as that file's entry in the
510
+ `overrides` of `config.json`: a feed at `blog/feed` is
511
+ `application/rss+xml`, not the `application/octet-stream` of a file with no
512
+ extension. The type is written even where the extension says the same, so
513
+ no file is left to the type Vercel gives an extension; only a `GET` that
514
+ named none — a body of bytes with no `Content-Type` — leaves its file to it.
515
+ `vite preview` and `serve` still go by the extension
516
+ ([A route.ts under static mode](routing.md#under-static-mode)).
517
+
518
+ ### Routes of your own
519
+
520
+ Under `mode: 'static'`, `vercel()` also takes routes of the application's
521
+ own, in the language Vercel's Build Output API reads — `src`, a regular
522
+ expression for the pathname, with `dest`, `status`, `headers`, `continue`,
523
+ `check`, `caseSensitive` and `has` — and writes them into
524
+ `.vercel/output/config.json` as given, in two places among its own:
525
+
526
+ ```text
527
+ ...beforeFiles
528
+ { handle: 'filesystem' } the files
529
+ ...the shells' rewrites
530
+ ...beforeNotFound
531
+ 404.html under a 404 when the build wrote one
532
+ { handle: 'hit' }
533
+ assets/ sent immutable
534
+ ```
535
+
536
+ `beforeFiles` is tried before any file, so it is where headers for every
537
+ answer go, and a redirect or a rewrite that has to win over a file that is
538
+ there. `beforeNotFound` is tried once neither a file nor a shell answered,
539
+ so a catch-all of yours there never hides a shell: it is where a URL the
540
+ build wrote nothing for is proxied to another origin, or answered with a 404
541
+ of another body.
542
+
543
+ ```ts
544
+ // vite.config.ts
545
+ import { vercel } from '@k8ordo/framework/vercel';
546
+ import { framework } from '@k8ordo/framework/vite';
547
+ import { defineConfig } from 'vite';
548
+
549
+ const acceptsMarkdown = [
550
+ { type: 'header', key: 'accept', value: '.*text/markdown.*' },
551
+ ] as const;
552
+
553
+ export default defineConfig({
554
+ plugins: [
555
+ framework({ mode: 'static' }),
556
+ vercel({
557
+ beforeFiles: [
558
+ {
559
+ src: '^/.*$',
560
+ headers: { 'X-Content-Type-Options': 'nosniff' },
561
+ continue: true,
562
+ },
563
+ {
564
+ src: '^/((?:[^/]+/)*[^/]+)/$',
565
+ status: 308,
566
+ headers: { Location: '/$1' },
567
+ },
568
+ {
569
+ src: '^/posts/([a-z0-9-]+)$',
570
+ has: acceptsMarkdown,
571
+ dest: '/posts/$1/article.md',
572
+ check: true,
573
+ caseSensitive: true,
574
+ },
575
+ {
576
+ src: '^/feed$',
577
+ headers: { 'Vercel-CDN-Cache-Control': 'max-age=3600' },
578
+ continue: true,
579
+ caseSensitive: true,
580
+ },
581
+ ],
582
+ beforeNotFound: [
583
+ {
584
+ src: '^/feed$',
585
+ dest: 'https://api.example.com/feed',
586
+ caseSensitive: true,
587
+ },
588
+ {
589
+ src: '^/.*$',
590
+ has: acceptsMarkdown,
591
+ dest: '/not-found.md',
592
+ status: 404,
593
+ },
594
+ ],
595
+ }),
596
+ ],
597
+ });
598
+ ```
599
+
600
+ - A route that only adds headers says `continue: true`, and comes before
601
+ the route that answers: an answering route ends the matching. What
602
+ `beforeFiles` adds this way is on every answer the routes lead to — a
603
+ file, a redirect, `404.html`, a `dest` with a `status`, an answer proxied
604
+ from another origin.
605
+ - The redirect takes the trailing slash off and keeps the query:
606
+ `/about/?x=1` goes to `/about?x=1`, which Vercel answers from
607
+ `about/index.html` as it is. Its pattern takes no empty segment, so it
608
+ never answers with `Location: //evil.test`, which a browser reads as
609
+ another origin. Vercel itself answers `//evil.test/` before any route, with
610
+ a 308 to `/evil.test/` that carries none of your headers.
611
+ - `check: true` looks for `dest` among the files. When none is there, the
612
+ matching goes on with `dest` as the pathname: `beforeNotFound` sees
613
+ `/posts/nope/article.md`, not `/posts/nope`. Here a `route.ts` at
614
+ `routes/posts/[slug]/article.md/` wrote each post's markdown beside its
615
+ page, and a client asking for markdown is answered with it. Have `paths`
616
+ give the pages and their markdown from one list of posts, so no post is
617
+ written without the other.
618
+ - `has` matches the header's text against `value`, a regular expression,
619
+ and negotiates nothing: `.*text/markdown.*` takes
620
+ `Accept: text/markdown, text/html;q=0.9`, and not a browser's
621
+ `text/html,application/xhtml+xml,*/*;q=0.8`, though `*/*` would take
622
+ markdown.
623
+ - A proxy is two routes: its headers ahead of the files, and the `dest` that
624
+ answers after them. The `dest` is asked with the request's query:
625
+ `/feed?page=2` fetches `https://api.example.com/feed?page=2`.
626
+ - `dest` with `status` answers with that file under that status, as the
627
+ build's own `404.html` route does.
628
+
629
+ What to keep in mind:
630
+
631
+ - **`src` matches the whole pathname**, never just its start, and never the
632
+ query: `^/posts/` does not match `/posts/first`, and `^/posts/.+$` does.
633
+ - **Only Vercel applies them.** `vite dev`, `vite preview` and `serve`
634
+ answer as if they were not there — no header, no redirect, nothing decided
635
+ by `Accept` — since an imitation of Vercel's routing that differed from it
636
+ would pass locally and fail once deployed. A URL only a route of yours
637
+ answers is a 404 there.
638
+ - **No `Cache-Control` in a route every path takes.** It reaches every file
639
+ too, those under `assets/` among them, which `vercel()` marks `immutable`
640
+ in the `hit` phase, after yours. Give a `Cache-Control` a `src` that names
641
+ the paths it is for.
642
+ - **Vercel matches `src` regardless of case** unless the route says
643
+ `caseSensitive: true`, while the routes `vercel()` writes itself are
644
+ case-sensitive, as the route table is. A route without it answers
645
+ `/POSTS/first` as well.
646
+ - **`has` names a header in lowercase**, as Vercel compares it. `vercel()`
647
+ refuses another spelling when it is called:
648
+
649
+ ```text
650
+ vercel() route ^/posts/([a-z0-9-]+)$ has the header key "Accept" — write it lowercase ("accept"), as Vercel compares it
651
+ ```
652
+
653
+ - **Nothing else is checked.** The routes are written as given — under a
654
+ `base` their `src` and `dest` carry it — and their patterns are Vercel's
655
+ (PCRE), which JavaScript's `RegExp` would misjudge, so a mistake shows once
656
+ deployed. Try a change on a preview deployment first.
657
+ - **Static mode only.** Under `mode: 'server'` the request handler answers
658
+ every URL no file names, and a `guard.ts` or a `route.ts` decides what it
659
+ sends. The build refuses them there:
660
+
661
+ ```text
662
+ vercel() takes beforeFiles and beforeNotFound under mode: 'static' only — under mode: 'server' the handler answers every URL no file names
663
+ ```
664
+
508
665
  ## A tab opened before a deploy
509
666
 
510
667
  A tab keeps running the script it loaded, while every payload it fetches
@@ -280,10 +280,14 @@ with params needs `paths`, as a page's does
280
280
  ([Parameters](params.md#routes-with-parameters)) — with a request whose
281
281
  origin is `site` when that option is given (an RSS feed's links are
282
282
  absolute), and writes what it answered as the file at that pathname:
283
- `dist/client/feed.xml`. A static host then serves it with the type its
284
- extension says. The `GET` has to answer `200`; anything else stops the build
285
- naming the pathname. A file answers nothing but `GET`, so a `route.ts`
286
- exporting another method is refused by name, in the build and in `vite dev`:
283
+ `dist/client/feed.xml`. A file keeps no type of its own, so a static host
284
+ serves it with the type its extension says — `vite preview` and `serve` too,
285
+ which send a file with no extension as `application/octet-stream`. On
286
+ Vercel, `vercel()` serves it with the `Content-Type` its `GET` answered, the
287
+ type `vite dev` sends ([Deploying to Vercel](deploy.md#deploying-to-vercel)).
288
+ The `GET` has to answer `200`; anything else stops the build naming the
289
+ pathname. A file answers nothing but `GET`, so a `route.ts` exporting
290
+ another method is refused by name, in the build and in `vite dev`:
287
291
 
288
292
  ```
289
293
  static build writes a route.ts as the file its GET answers, and a file cannot answer another method — these export one:
@@ -299,6 +303,11 @@ A `route.ts` at `/` cannot be a file (the host serves `/` from `index.html`),
299
303
  nor one with pages written below it, which would need a directory of the same
300
304
  name. What it writes is not a page, so `sitemap.xml` leaves it out.
301
305
 
306
+ Name the type in the `Response`. A string body with none is sent as
307
+ `text/plain;charset=UTF-8` — the `Response` sets it — so a `sitemap.xml`
308
+ returned that way is plain text on Vercel and in `vite dev`, whatever its
309
+ extension says. `vite dev` shows it before a deploy does.
310
+
302
311
  ## The generated files
303
312
 
304
313
  The framework writes `.k8ordo/` and keeps it in step with the directories.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@k8ordo/framework",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "The k8ordo framework: routes/ compiled into React Server Components, run with or without a server.",
5
5
  "keywords": [
6
6
  "k8ordo",
@@ -62,7 +62,7 @@
62
62
  },
63
63
  "dependencies": {
64
64
  "@vitejs/plugin-react": "6.1.2",
65
- "@vitejs/plugin-rsc": "0.5.36",
65
+ "@vitejs/plugin-rsc": "0.5.38",
66
66
  "compressible": "2.0.18",
67
67
  "fresh": "2.0.0",
68
68
  "mime-types": "3.0.2",
@@ -73,26 +73,33 @@
73
73
  "devDependencies": {
74
74
  "@arethetypeswrong/cli": "0.18.5",
75
75
  "@k8ordo/framework-engine": "^0.1.1",
76
- "@k8ordo/router": "^1.0.0",
76
+ "@k8ordo/router": "^1.1.0",
77
77
  "@types/compressible": "2.0.3",
78
78
  "@types/fresh": "0.5.3",
79
79
  "@types/mime-types": "3.0.1",
80
80
  "@types/range-parser": "1.2.7",
81
81
  "@types/react": "19.3.0",
82
+ "oxc-transform-react": "0.154.0",
82
83
  "publint": "0.3.25",
83
- "react": "19.3.0",
84
- "react-dom": "19.3.0",
84
+ "react": "19.3.1",
85
+ "react-dom": "19.3.1",
85
86
  "typescript": "7.0.2",
86
- "vite": "npm:@voidzero-dev/vite-plus-core@1.1.0",
87
- "vite-plus": "1.1.0",
88
- "vitest": "5.0.3"
87
+ "vite": "npm:@voidzero-dev/vite-plus-core@1.3.0",
88
+ "vite-plus": "1.3.0",
89
+ "vitest": "5.0.5"
89
90
  },
90
91
  "peerDependencies": {
91
- "@k8ordo/router": "^1.0.0",
92
+ "@k8ordo/router": "^1.1.0",
93
+ "oxc-transform-react": "^0.152.0",
92
94
  "react": ">=19.3.0",
93
95
  "react-dom": ">=19.3.0",
94
96
  "vite": ">=8.0.0"
95
97
  },
98
+ "peerDependenciesMeta": {
99
+ "oxc-transform-react": {
100
+ "optional": true
101
+ }
102
+ },
96
103
  "engines": {
97
104
  "node": ">=24.0.0"
98
105
  },