@txco/react-router 0.0.0-stage → 0.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
@@ -1,3 +1,91 @@
1
- # Temporary Holding Version
1
+ # @txco/react-router
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A [React Router](https://reactrouter.com) preset for
4
+ [Thanks, Computer](https://www.thanks.computer) (txco). After
5
+ `react-router build`, it writes a **Web ABI build** beside your `build/`:
6
+
7
+ - a copy of `build/client` as `public/`;
8
+ - a route-aware SPA fallback and a catch-all, as `ops/`;
9
+ - a `txco-web.json` manifest.
10
+
11
+ `txco apply` installs the build into a stack.
12
+
13
+ ```sh
14
+ npm install -D @txco/react-router
15
+ ```
16
+
17
+ React Router 7 and 8, in framework mode.
18
+
19
+ ## Usage
20
+
21
+ ```ts
22
+ // react-router.config.ts
23
+ import type { Config } from "@react-router/dev/config";
24
+ import txco from "@txco/react-router";
25
+
26
+ export default {
27
+ ssr: false, // SPA mode: the client renders every page
28
+ prerender: ["/about"], // optional: pages served as files
29
+ presets: [txco()],
30
+ } satisfies Config;
31
+ ```
32
+
33
+ Bind the build to a stack in your workspace's `txco.yaml`. The path is
34
+ relative to the workspace root, and must lie outside `OPS/`:
35
+
36
+ ```yaml
37
+ stacks:
38
+ web:
39
+ abi: txco-web
40
+ ```
41
+
42
+ Then build, check and deploy:
43
+
44
+ ```sh
45
+ react-router build # writes build/ as usual, and txco-web/
46
+ txco web check txco-web # installs it on a scratch chassis and probes it
47
+ txco apply # or: txco push web
48
+ ```
49
+
50
+ Gitignore `txco-web/`. It's all build output, and the fallback ops embed the
51
+ app shell, asset hashes included, so they change on every build.
52
+
53
+ ## How a request is answered
54
+
55
+ 1. **A file**, such as a prerendered page (`/about` → `about/index.html`), an
56
+ asset, or a `.data` file, is served before your stack runs.
57
+ 2. **Your own ops** in the stack run next.
58
+ 3. **The preset's ops** go last:
59
+ - a navigation to a path your routes know (`/users/42` for
60
+ `users/:id`) gets the app shell with status 200;
61
+ - any other navigation gets the shell with status 404, and the client
62
+ renders its error boundary;
63
+ - everything else, such as a missing asset or a POST, gets a plain 404.
64
+
65
+ The matcher comes from React Router's own route table: params (`:id`),
66
+ optional segments (`:lang?`, `beta?`), a final splat (`*`) and `basename`.
67
+ It's case-insensitive unless a route sets `caseSensitive`. A splat route
68
+ makes every path known. A route the matcher can't express makes every
69
+ navigation get the shell with 200.
70
+
71
+ When `/` is prerendered, React Router writes the shell as
72
+ `__spa-fallback.html`, and the preset uses that.
73
+
74
+ ## Server rendering
75
+
76
+ With `ssr: true`, React Router builds a server, and no chassis runs one yet.
77
+ The preset deploys what was prerendered, gives every other page the 404 page,
78
+ and warns. For an app the client renders, set `ssr: false`.
79
+
80
+ ## Options
81
+
82
+ | Option | Default | Meaning |
83
+ | ------------ | -------------- | ------- |
84
+ | `out` | `"txco-web"` | The Web ABI directory to write, relative to the project root. It must be a directory of its own, outside `OPS/` and `build/`. |
85
+ | `routeAware` | `true` | `false` gives every page path the shell with 200. |
86
+ | `immutable` | `["assets/"]` | The `public/` prefixes cached for a year. |
87
+ | `scope` | `900000` | The navigation ops' scope. The catch-all goes 900 above it. |
88
+
89
+ ## License
90
+
91
+ MIT
@@ -0,0 +1,43 @@
1
+ import { type NavMode } from "@txco/web-abi/producer";
2
+ import { type RouteManifest } from "./routes.js";
3
+ export declare const NAME = "@txco/react-router";
4
+ export declare const VERSION = "0.1.0";
5
+ export interface TxcoOptions {
6
+ /** The Web ABI directory to write, relative to the project root. Default "txco-web". */
7
+ out?: string;
8
+ /**
9
+ * Give an unknown page path the shell with 404, from the route table.
10
+ * false answers every page path with the shell and 200. Default true.
11
+ */
12
+ routeAware?: boolean;
13
+ /** The public/ prefixes cached for a year. Default: Vite's assetsDir (`assets/`). */
14
+ immutable?: string[];
15
+ /** The navigation op's scope; the catch-all goes 900 above it. Default 900000. */
16
+ scope?: number;
17
+ }
18
+ export interface BuildInput {
19
+ /** The project root (Vite's root). */
20
+ root: string;
21
+ /** reactRouterConfig.buildDirectory: client output is <it>/client. */
22
+ buildDirectory: string;
23
+ /** Vite's base (where the assets are served). */
24
+ base: string;
25
+ /** Vite's build.assetsDir. */
26
+ assetsDir: string;
27
+ /** reactRouterConfig.basename (where the routes are). */
28
+ basename: string;
29
+ /** reactRouterConfig.ssr. */
30
+ ssr: boolean;
31
+ routes: RouteManifest;
32
+ options: TxcoOptions;
33
+ log: {
34
+ info(msg: string): void;
35
+ warn(msg: string): void;
36
+ };
37
+ }
38
+ export interface BuildResult {
39
+ mode: NavMode;
40
+ ops: string[];
41
+ manifest: Record<string, unknown>;
42
+ }
43
+ export declare function writeBuild(i: BuildInput): Promise<BuildResult>;
package/dist/build.js ADDED
@@ -0,0 +1,81 @@
1
+ // Writes the Web ABI build from React Router's finished output. Called from
2
+ // the preset's buildEnd; takes plain inputs, so it's tested without a build.
3
+ import { readFile, rm, stat } from "node:fs/promises";
4
+ import { join, relative, resolve } from "node:path";
5
+ import { copyPublic, listFiles, looksHashed, publicPrefix, renderOps, writeManifest, writeOps, } from "@txco/web-abi/producer";
6
+ import { routesMatcher } from "./routes.js";
7
+ export const NAME = "@txco/react-router";
8
+ export const VERSION = "0.1.0";
9
+ /** An op answer is capped at 4 MiB (--op-payload-max), base64 included. */
10
+ const PAGE_WARN_BYTES = 1 << 20;
11
+ const exists = (p) => stat(p).then(() => true, () => false);
12
+ export async function writeBuild(i) {
13
+ const warn = (msg) => i.log.warn(`[txco] ${msg}`);
14
+ const clientDir = resolve(i.root, i.buildDirectory, "client");
15
+ if (!(await exists(clientDir)))
16
+ throw new Error(`[txco] React Router's client build isn't at ${clientDir}`);
17
+ const out = resolve(i.root, i.options.out ?? "txco-web");
18
+ const { prefix, external } = publicPrefix(i.base);
19
+ if (external)
20
+ warn(`base is ${i.base}: the assets load from there, and the pages are deployed at the root`);
21
+ await rm(out, { recursive: true, force: true });
22
+ const { skipped } = await copyPublic(clientDir, out, prefix);
23
+ if (skipped.length > 0)
24
+ warn(`build/client has ${skipped.length} dot path(s) (${skipped.slice(0, 3).join(", ")}); they never deploy`);
25
+ const readPage = async (name) => {
26
+ const html = await readFile(join(clientDir, name), "utf8").catch(() => null);
27
+ return html === null ? null : { name, html };
28
+ };
29
+ let mode;
30
+ let page = null;
31
+ let routes;
32
+ const notes = [];
33
+ if (i.ssr) {
34
+ // No chassis runs a server yet: deploy what was prerendered.
35
+ warn("ssr: true needs a server, and no chassis runs one yet: only the prerendered pages deploy. For an SPA, set ssr: false.");
36
+ mode = "404";
37
+ notes.push("A server-rendered React Router app: only its prerendered pages deploy until a runner exists.");
38
+ }
39
+ else {
40
+ // In SPA mode the shell is __spa-fallback.html when "/" is prerendered.
41
+ page = (await readPage("__spa-fallback.html")) ?? (await readPage("index.html"));
42
+ if (!page)
43
+ throw new Error(`[txco] an ssr: false build needs its shell (index.html or __spa-fallback.html) in ${clientDir}`);
44
+ const matcher = i.options.routeAware === false ? null : routesMatcher(i.routes, i.basename);
45
+ if (matcher) {
46
+ mode = "routes";
47
+ routes = matcher;
48
+ notes.push("The route set comes from React Router's route table.");
49
+ }
50
+ else {
51
+ mode = "spa";
52
+ if (i.options.routeAware !== false)
53
+ notes.push("A route couldn't be expressed as a matcher, so unknown pages get 200 too.");
54
+ }
55
+ }
56
+ if (page && Buffer.byteLength(page.html) > PAGE_WARN_BYTES) {
57
+ warn(`${page.name} is ${Buffer.byteLength(page.html)} bytes; the op that serves it is capped at 4 MiB, base64 included`);
58
+ }
59
+ const ops = renderOps({ mode, scope: i.options.scope, producer: NAME, page, routes, notes });
60
+ await writeOps(out, ops);
61
+ const files = await listFiles(join(out, "public"));
62
+ const assets = i.assetsDir.replace(/^\/+|\/+$/g, "");
63
+ const immutable = i.options.immutable ?? (assets && files.some((f) => f.startsWith(`${prefix}${assets}/`)) ? [`${prefix}${assets}/`] : []);
64
+ for (const p of immutable) {
65
+ const unhashed = files.filter((f) => f.startsWith(p) && !looksHashed(f.split("/").pop()));
66
+ if (unhashed.length > 0) {
67
+ warn(`${unhashed.length} file(s) under the immutable prefix ${p} carry no content hash (${unhashed.slice(0, 3).join(", ")}), so a change to them wouldn't reach a browser for a year`);
68
+ }
69
+ }
70
+ const manifest = {
71
+ abi: 1,
72
+ ...(immutable.length > 0 ? { immutable } : {}),
73
+ "x-producer": { name: NAME, version: VERSION },
74
+ "x-react-router": { ssr: i.ssr, mode, routes: Object.keys(i.routes).length },
75
+ };
76
+ await writeManifest(out, manifest);
77
+ const shown = relative(process.cwd(), out) || ".";
78
+ i.log.info(`[txco] Web ABI build (${mode}) at ${shown}: ${files.length} public file(s), ${Object.keys(ops).length} op(s). ` +
79
+ `Next: \`txco web check ${shown}\`, bind it in txco.yaml (stacks: <name>: abi: ${shown}), then \`txco apply\`.`);
80
+ return { mode, ops: Object.keys(ops).sort(), manifest };
81
+ }
@@ -0,0 +1,39 @@
1
+ import { type TxcoOptions } from "./build.js";
2
+ import type { RouteManifest } from "./routes.js";
3
+ export type { TxcoOptions } from "./build.js";
4
+ export { routeRegex, routesMatcher } from "./routes.js";
5
+ interface ResolvedConfig {
6
+ buildDirectory: string;
7
+ basename: string;
8
+ ssr: boolean;
9
+ routes: RouteManifest;
10
+ }
11
+ interface BuildEndArgs {
12
+ buildManifest?: {
13
+ routes: RouteManifest;
14
+ };
15
+ reactRouterConfig: ResolvedConfig;
16
+ viteConfig: {
17
+ root: string;
18
+ base: string;
19
+ build: {
20
+ assetsDir: string;
21
+ };
22
+ logger: {
23
+ info(msg: string): void;
24
+ warn(msg: string): void;
25
+ };
26
+ };
27
+ }
28
+ export interface Preset {
29
+ name: string;
30
+ reactRouterConfig?: (args: {
31
+ reactRouterUserConfig: unknown;
32
+ }) => {
33
+ buildEnd: (args: BuildEndArgs) => Promise<void>;
34
+ };
35
+ reactRouterConfigResolved?: (args: {
36
+ reactRouterConfig: ResolvedConfig;
37
+ }) => Promise<void>;
38
+ }
39
+ export default function txco(options?: TxcoOptions): Preset;
package/dist/index.js ADDED
@@ -0,0 +1,53 @@
1
+ // @txco/react-router: a React Router preset that writes a Web ABI build for
2
+ // Thanks, Computer (txco) after `react-router build`:
3
+ //
4
+ // txco-web/
5
+ // txco-web.json the manifest
6
+ // public/ a copy of build/client
7
+ // ops/ the route-aware SPA fallback and the catch-all
8
+ //
9
+ // // react-router.config.ts
10
+ // import type { Config } from "@react-router/dev/config";
11
+ // import txco from "@txco/react-router";
12
+ // export default { ssr: false, presets: [txco()] } satisfies Config;
13
+ //
14
+ // `txco apply` installs it into the stack txco.yaml binds it to.
15
+ import { readdir } from "node:fs/promises";
16
+ import { resolve } from "node:path";
17
+ import { checkOutDir } from "@txco/web-abi/producer";
18
+ import { NAME, writeBuild } from "./build.js";
19
+ export { routeRegex, routesMatcher } from "./routes.js";
20
+ export default function txco(options = {}) {
21
+ return {
22
+ name: NAME,
23
+ // Fail before a long build: the preset wipes and rewrites `out`.
24
+ async reactRouterConfigResolved({ reactRouterConfig }) {
25
+ const root = process.cwd();
26
+ const out = resolve(root, options.out ?? "txco-web");
27
+ const build = resolve(root, reactRouterConfig.buildDirectory);
28
+ const problems = checkOutDir({
29
+ dir: out,
30
+ protect: [root, build],
31
+ notInside: [build],
32
+ entries: await readdir(out).catch(() => null),
33
+ });
34
+ if (problems.length > 0)
35
+ throw new Error(`[txco] ${problems.join("\n")}`);
36
+ },
37
+ reactRouterConfig: () => ({
38
+ async buildEnd({ buildManifest, reactRouterConfig, viteConfig }) {
39
+ await writeBuild({
40
+ root: viteConfig.root,
41
+ buildDirectory: reactRouterConfig.buildDirectory,
42
+ base: viteConfig.base,
43
+ assetsDir: viteConfig.build.assetsDir,
44
+ basename: reactRouterConfig.basename,
45
+ ssr: reactRouterConfig.ssr,
46
+ routes: buildManifest?.routes ?? reactRouterConfig.routes,
47
+ options,
48
+ log: viteConfig.logger,
49
+ });
50
+ },
51
+ }),
52
+ };
53
+ }
@@ -0,0 +1,26 @@
1
+ /** One entry of React Router's flat RouteManifest. */
2
+ export interface RouteEntry {
3
+ id: string;
4
+ parentId?: string;
5
+ path?: string;
6
+ index?: boolean;
7
+ caseSensitive?: boolean;
8
+ }
9
+ export type RouteManifest = Record<string, RouteEntry>;
10
+ /** Every route's full path pattern (joined up its parentId chain), deduped. */
11
+ export declare function routePaths(routes: RouteManifest): {
12
+ path: string;
13
+ caseSensitive: boolean;
14
+ }[];
15
+ /**
16
+ * One route path as a regex source matching a request path (leading "/",
17
+ * an optional trailing "/"). React Router's syntax: `:param`, `:param?`,
18
+ * a static `segment?`, and a final `*`. null for anything else.
19
+ */
20
+ export declare function routeRegex(path: string, caseSensitive?: boolean): string | null;
21
+ /**
22
+ * The anchored matcher for every route, under `basename`, with "/" escaped
23
+ * for txcl's /…/ literal. null when a route can't be translated (the caller
24
+ * then answers every navigation with the shell and 200).
25
+ */
26
+ export declare function routesMatcher(routes: RouteManifest, basename?: string): string | null;
package/dist/routes.js ADDED
@@ -0,0 +1,75 @@
1
+ // React Router's route table as one RE2 matcher, for the route-aware SPA
2
+ // fallback: a navigation to a path a route knows gets the shell with 200,
3
+ // any other the shell with 404. Pure, so it's unit-tested.
4
+ /** Every route's full path pattern (joined up its parentId chain), deduped. */
5
+ export function routePaths(routes) {
6
+ const seen = new Map();
7
+ for (const r of Object.values(routes)) {
8
+ const parts = [];
9
+ let caseSensitive = false;
10
+ for (let cur = r, n = 0; cur && n < 100; cur = cur.parentId ? routes[cur.parentId] : undefined, n++) {
11
+ if (cur.path)
12
+ parts.unshift(cur.path.replace(/^\/+|\/+$/g, ""));
13
+ if (cur.caseSensitive)
14
+ caseSensitive = true;
15
+ }
16
+ const path = parts.filter(Boolean).join("/");
17
+ seen.set(path, (seen.get(path) ?? false) || caseSensitive);
18
+ }
19
+ return [...seen].map(([path, caseSensitive]) => ({ path, caseSensitive })).sort((a, b) => a.path.localeCompare(b.path));
20
+ }
21
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
22
+ /**
23
+ * One route path as a regex source matching a request path (leading "/",
24
+ * an optional trailing "/"). React Router's syntax: `:param`, `:param?`,
25
+ * a static `segment?`, and a final `*`. null for anything else.
26
+ */
27
+ export function routeRegex(path, caseSensitive = false) {
28
+ const segs = path.split("/").filter(Boolean);
29
+ let re = "";
30
+ for (let i = 0; i < segs.length; i++) {
31
+ const seg = segs[i];
32
+ if (seg === "*") {
33
+ if (i !== segs.length - 1)
34
+ return null;
35
+ re += "(?:/.*)?";
36
+ }
37
+ else if (/^:[A-Za-z0-9_-]+\?$/.test(seg)) {
38
+ re += "(?:/[^/]+)?";
39
+ }
40
+ else if (/^:[A-Za-z0-9_-]+$/.test(seg)) {
41
+ re += "/[^/]+";
42
+ }
43
+ else if (seg.includes(":") || seg.includes("*")) {
44
+ return null; // a param or splat inside a segment: not React Router syntax
45
+ }
46
+ else if (seg.endsWith("?")) {
47
+ re += `(?:/${escapeRe(seg.slice(0, -1))})?`;
48
+ }
49
+ else {
50
+ re += `/${escapeRe(seg)}`;
51
+ }
52
+ }
53
+ re += "/?";
54
+ return caseSensitive ? re : `(?i:${re})`;
55
+ }
56
+ /**
57
+ * The anchored matcher for every route, under `basename`, with "/" escaped
58
+ * for txcl's /…/ literal. null when a route can't be translated (the caller
59
+ * then answers every navigation with the shell and 200).
60
+ */
61
+ export function routesMatcher(routes, basename = "/") {
62
+ // React Router matches basename case-insensitively (stripBasename).
63
+ const base = basename.replace(/^\/+|\/+$/g, "");
64
+ const prefix = base ? `(?i:/${escapeRe(base)})` : "";
65
+ const alts = [];
66
+ for (const { path, caseSensitive } of routePaths(routes)) {
67
+ const re = routeRegex(path, caseSensitive);
68
+ if (re === null)
69
+ return null;
70
+ alts.push(prefix + re);
71
+ }
72
+ if (alts.length === 0)
73
+ return null;
74
+ return `^(?:${alts.join("|")})$`.replace(/\//g, "\\/");
75
+ }
package/package.json CHANGED
@@ -1,6 +1,57 @@
1
1
  {
2
2
  "name": "@txco/react-router",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "React Router preset for Thanks, Computer (txco): after `react-router build`, writes a Web ABI build — build/client as public/, a route-aware SPA fallback as ops/, and txco-web.json — for `txco apply` to install.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "sideEffects": false,
8
+ "keywords": [
9
+ "react-router",
10
+ "react-router-preset",
11
+ "remix",
12
+ "spa",
13
+ "txco",
14
+ "thanks-computer",
15
+ "web-abi"
16
+ ],
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/LoremLabs/thanks-computer.git",
20
+ "directory": "sdk/react-router"
21
+ },
22
+ "homepage": "https://github.com/LoremLabs/thanks-computer/tree/master/sdk/react-router#readme",
23
+ "bugs": "https://github.com/LoremLabs/thanks-computer/issues",
24
+ "files": [
25
+ "dist",
26
+ "src",
27
+ "README.md"
28
+ ],
29
+ "exports": {
30
+ ".": {
31
+ "types": "./dist/index.d.ts",
32
+ "default": "./dist/index.js"
33
+ }
34
+ },
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
38
+ "engines": {
39
+ "node": ">=20"
40
+ },
41
+ "scripts": {
42
+ "clean": "rm -rf dist",
43
+ "build": "tsc -p tsconfig.json",
44
+ "prepublishOnly": "npm run clean && npm run build",
45
+ "test": "npm run build && node --test test/*.test.mjs"
46
+ },
47
+ "dependencies": {
48
+ "@txco/web-abi": "^0.2.0"
49
+ },
50
+ "peerDependencies": {
51
+ "@react-router/dev": "^7.0.0 || ^8.0.0"
52
+ },
53
+ "devDependencies": {
54
+ "@types/node": "^20.0.0",
55
+ "typescript": "^5.4.0"
56
+ }
57
+ }
package/src/build.ts ADDED
@@ -0,0 +1,136 @@
1
+ // Writes the Web ABI build from React Router's finished output. Called from
2
+ // the preset's buildEnd; takes plain inputs, so it's tested without a build.
3
+ import { readFile, rm, stat } from "node:fs/promises";
4
+ import { join, relative, resolve } from "node:path";
5
+
6
+ import {
7
+ copyPublic,
8
+ listFiles,
9
+ looksHashed,
10
+ publicPrefix,
11
+ renderOps,
12
+ writeManifest,
13
+ writeOps,
14
+ type NavMode,
15
+ } from "@txco/web-abi/producer";
16
+
17
+ import { routesMatcher, type RouteManifest } from "./routes.js";
18
+
19
+ export const NAME = "@txco/react-router";
20
+ export const VERSION = "0.1.0";
21
+ /** An op answer is capped at 4 MiB (--op-payload-max), base64 included. */
22
+ const PAGE_WARN_BYTES = 1 << 20;
23
+
24
+ export interface TxcoOptions {
25
+ /** The Web ABI directory to write, relative to the project root. Default "txco-web". */
26
+ out?: string;
27
+ /**
28
+ * Give an unknown page path the shell with 404, from the route table.
29
+ * false answers every page path with the shell and 200. Default true.
30
+ */
31
+ routeAware?: boolean;
32
+ /** The public/ prefixes cached for a year. Default: Vite's assetsDir (`assets/`). */
33
+ immutable?: string[];
34
+ /** The navigation op's scope; the catch-all goes 900 above it. Default 900000. */
35
+ scope?: number;
36
+ }
37
+
38
+ export interface BuildInput {
39
+ /** The project root (Vite's root). */
40
+ root: string;
41
+ /** reactRouterConfig.buildDirectory: client output is <it>/client. */
42
+ buildDirectory: string;
43
+ /** Vite's base (where the assets are served). */
44
+ base: string;
45
+ /** Vite's build.assetsDir. */
46
+ assetsDir: string;
47
+ /** reactRouterConfig.basename (where the routes are). */
48
+ basename: string;
49
+ /** reactRouterConfig.ssr. */
50
+ ssr: boolean;
51
+ routes: RouteManifest;
52
+ options: TxcoOptions;
53
+ log: { info(msg: string): void; warn(msg: string): void };
54
+ }
55
+
56
+ export interface BuildResult {
57
+ mode: NavMode;
58
+ ops: string[];
59
+ manifest: Record<string, unknown>;
60
+ }
61
+
62
+ const exists = (p: string) => stat(p).then(() => true, () => false);
63
+
64
+ export async function writeBuild(i: BuildInput): Promise<BuildResult> {
65
+ const warn = (msg: string) => i.log.warn(`[txco] ${msg}`);
66
+ const clientDir = resolve(i.root, i.buildDirectory, "client");
67
+ if (!(await exists(clientDir))) throw new Error(`[txco] React Router's client build isn't at ${clientDir}`);
68
+ const out = resolve(i.root, i.options.out ?? "txco-web");
69
+ const { prefix, external } = publicPrefix(i.base);
70
+ if (external) warn(`base is ${i.base}: the assets load from there, and the pages are deployed at the root`);
71
+
72
+ await rm(out, { recursive: true, force: true });
73
+ const { skipped } = await copyPublic(clientDir, out, prefix);
74
+ if (skipped.length > 0) warn(`build/client has ${skipped.length} dot path(s) (${skipped.slice(0, 3).join(", ")}); they never deploy`);
75
+
76
+ const readPage = async (name: string) => {
77
+ const html = await readFile(join(clientDir, name), "utf8").catch(() => null);
78
+ return html === null ? null : { name, html };
79
+ };
80
+
81
+ let mode: NavMode;
82
+ let page = null;
83
+ let routes: string | undefined;
84
+ const notes: string[] = [];
85
+ if (i.ssr) {
86
+ // No chassis runs a server yet: deploy what was prerendered.
87
+ warn("ssr: true needs a server, and no chassis runs one yet: only the prerendered pages deploy. For an SPA, set ssr: false.");
88
+ mode = "404";
89
+ notes.push("A server-rendered React Router app: only its prerendered pages deploy until a runner exists.");
90
+ } else {
91
+ // In SPA mode the shell is __spa-fallback.html when "/" is prerendered.
92
+ page = (await readPage("__spa-fallback.html")) ?? (await readPage("index.html"));
93
+ if (!page) throw new Error(`[txco] an ssr: false build needs its shell (index.html or __spa-fallback.html) in ${clientDir}`);
94
+ const matcher = i.options.routeAware === false ? null : routesMatcher(i.routes, i.basename);
95
+ if (matcher) {
96
+ mode = "routes";
97
+ routes = matcher;
98
+ notes.push("The route set comes from React Router's route table.");
99
+ } else {
100
+ mode = "spa";
101
+ if (i.options.routeAware !== false) notes.push("A route couldn't be expressed as a matcher, so unknown pages get 200 too.");
102
+ }
103
+ }
104
+ if (page && Buffer.byteLength(page.html) > PAGE_WARN_BYTES) {
105
+ warn(`${page.name} is ${Buffer.byteLength(page.html)} bytes; the op that serves it is capped at 4 MiB, base64 included`);
106
+ }
107
+
108
+ const ops = renderOps({ mode, scope: i.options.scope, producer: NAME, page, routes, notes });
109
+ await writeOps(out, ops);
110
+
111
+ const files = await listFiles(join(out, "public"));
112
+ const assets = i.assetsDir.replace(/^\/+|\/+$/g, "");
113
+ const immutable =
114
+ i.options.immutable ?? (assets && files.some((f) => f.startsWith(`${prefix}${assets}/`)) ? [`${prefix}${assets}/`] : []);
115
+ for (const p of immutable) {
116
+ const unhashed = files.filter((f) => f.startsWith(p) && !looksHashed(f.split("/").pop()!));
117
+ if (unhashed.length > 0) {
118
+ warn(`${unhashed.length} file(s) under the immutable prefix ${p} carry no content hash (${unhashed.slice(0, 3).join(", ")}), so a change to them wouldn't reach a browser for a year`);
119
+ }
120
+ }
121
+
122
+ const manifest: Record<string, unknown> = {
123
+ abi: 1,
124
+ ...(immutable.length > 0 ? { immutable } : {}),
125
+ "x-producer": { name: NAME, version: VERSION },
126
+ "x-react-router": { ssr: i.ssr, mode, routes: Object.keys(i.routes).length },
127
+ };
128
+ await writeManifest(out, manifest);
129
+
130
+ const shown = relative(process.cwd(), out) || ".";
131
+ i.log.info(
132
+ `[txco] Web ABI build (${mode}) at ${shown}: ${files.length} public file(s), ${Object.keys(ops).length} op(s). ` +
133
+ `Next: \`txco web check ${shown}\`, bind it in txco.yaml (stacks: <name>: abi: ${shown}), then \`txco apply\`.`,
134
+ );
135
+ return { mode, ops: Object.keys(ops).sort(), manifest };
136
+ }
package/src/index.ts ADDED
@@ -0,0 +1,85 @@
1
+ // @txco/react-router: a React Router preset that writes a Web ABI build for
2
+ // Thanks, Computer (txco) after `react-router build`:
3
+ //
4
+ // txco-web/
5
+ // txco-web.json the manifest
6
+ // public/ a copy of build/client
7
+ // ops/ the route-aware SPA fallback and the catch-all
8
+ //
9
+ // // react-router.config.ts
10
+ // import type { Config } from "@react-router/dev/config";
11
+ // import txco from "@txco/react-router";
12
+ // export default { ssr: false, presets: [txco()] } satisfies Config;
13
+ //
14
+ // `txco apply` installs it into the stack txco.yaml binds it to.
15
+ import { readdir } from "node:fs/promises";
16
+ import { resolve } from "node:path";
17
+
18
+ import { checkOutDir } from "@txco/web-abi/producer";
19
+
20
+ import { NAME, writeBuild, type TxcoOptions } from "./build.js";
21
+ import type { RouteManifest } from "./routes.js";
22
+
23
+ export type { TxcoOptions } from "./build.js";
24
+ export { routeRegex, routesMatcher } from "./routes.js";
25
+
26
+ // The parts of React Router's types the preset reads, declared here so the
27
+ // package needs no React Router at build time. They match @react-router/dev's
28
+ // Preset and BuildEndHook (v7 and v8).
29
+ interface ResolvedConfig {
30
+ buildDirectory: string;
31
+ basename: string;
32
+ ssr: boolean;
33
+ routes: RouteManifest;
34
+ }
35
+ interface BuildEndArgs {
36
+ buildManifest?: { routes: RouteManifest };
37
+ reactRouterConfig: ResolvedConfig;
38
+ viteConfig: {
39
+ root: string;
40
+ base: string;
41
+ build: { assetsDir: string };
42
+ logger: { info(msg: string): void; warn(msg: string): void };
43
+ };
44
+ }
45
+ export interface Preset {
46
+ name: string;
47
+ reactRouterConfig?: (args: { reactRouterUserConfig: unknown }) => { buildEnd: (args: BuildEndArgs) => Promise<void> };
48
+ reactRouterConfigResolved?: (args: { reactRouterConfig: ResolvedConfig }) => Promise<void>;
49
+ }
50
+
51
+ export default function txco(options: TxcoOptions = {}): Preset {
52
+ return {
53
+ name: NAME,
54
+
55
+ // Fail before a long build: the preset wipes and rewrites `out`.
56
+ async reactRouterConfigResolved({ reactRouterConfig }) {
57
+ const root = process.cwd();
58
+ const out = resolve(root, options.out ?? "txco-web");
59
+ const build = resolve(root, reactRouterConfig.buildDirectory);
60
+ const problems = checkOutDir({
61
+ dir: out,
62
+ protect: [root, build],
63
+ notInside: [build],
64
+ entries: await readdir(out).catch(() => null),
65
+ });
66
+ if (problems.length > 0) throw new Error(`[txco] ${problems.join("\n")}`);
67
+ },
68
+
69
+ reactRouterConfig: () => ({
70
+ async buildEnd({ buildManifest, reactRouterConfig, viteConfig }) {
71
+ await writeBuild({
72
+ root: viteConfig.root,
73
+ buildDirectory: reactRouterConfig.buildDirectory,
74
+ base: viteConfig.base,
75
+ assetsDir: viteConfig.build.assetsDir,
76
+ basename: reactRouterConfig.basename,
77
+ ssr: reactRouterConfig.ssr,
78
+ routes: buildManifest?.routes ?? reactRouterConfig.routes,
79
+ options,
80
+ log: viteConfig.logger,
81
+ });
82
+ },
83
+ }),
84
+ };
85
+ }
package/src/routes.ts ADDED
@@ -0,0 +1,80 @@
1
+ // React Router's route table as one RE2 matcher, for the route-aware SPA
2
+ // fallback: a navigation to a path a route knows gets the shell with 200,
3
+ // any other the shell with 404. Pure, so it's unit-tested.
4
+
5
+ /** One entry of React Router's flat RouteManifest. */
6
+ export interface RouteEntry {
7
+ id: string;
8
+ parentId?: string;
9
+ path?: string;
10
+ index?: boolean;
11
+ caseSensitive?: boolean;
12
+ }
13
+
14
+ export type RouteManifest = Record<string, RouteEntry>;
15
+
16
+ /** Every route's full path pattern (joined up its parentId chain), deduped. */
17
+ export function routePaths(routes: RouteManifest): { path: string; caseSensitive: boolean }[] {
18
+ const seen = new Map<string, boolean>();
19
+ for (const r of Object.values(routes)) {
20
+ const parts: string[] = [];
21
+ let caseSensitive = false;
22
+ for (let cur: RouteEntry | undefined = r, n = 0; cur && n < 100; cur = cur.parentId ? routes[cur.parentId] : undefined, n++) {
23
+ if (cur.path) parts.unshift(cur.path.replace(/^\/+|\/+$/g, ""));
24
+ if (cur.caseSensitive) caseSensitive = true;
25
+ }
26
+ const path = parts.filter(Boolean).join("/");
27
+ seen.set(path, (seen.get(path) ?? false) || caseSensitive);
28
+ }
29
+ return [...seen].map(([path, caseSensitive]) => ({ path, caseSensitive })).sort((a, b) => a.path.localeCompare(b.path));
30
+ }
31
+
32
+ const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
33
+
34
+ /**
35
+ * One route path as a regex source matching a request path (leading "/",
36
+ * an optional trailing "/"). React Router's syntax: `:param`, `:param?`,
37
+ * a static `segment?`, and a final `*`. null for anything else.
38
+ */
39
+ export function routeRegex(path: string, caseSensitive = false): string | null {
40
+ const segs = path.split("/").filter(Boolean);
41
+ let re = "";
42
+ for (let i = 0; i < segs.length; i++) {
43
+ const seg = segs[i];
44
+ if (seg === "*") {
45
+ if (i !== segs.length - 1) return null;
46
+ re += "(?:/.*)?";
47
+ } else if (/^:[A-Za-z0-9_-]+\?$/.test(seg)) {
48
+ re += "(?:/[^/]+)?";
49
+ } else if (/^:[A-Za-z0-9_-]+$/.test(seg)) {
50
+ re += "/[^/]+";
51
+ } else if (seg.includes(":") || seg.includes("*")) {
52
+ return null; // a param or splat inside a segment: not React Router syntax
53
+ } else if (seg.endsWith("?")) {
54
+ re += `(?:/${escapeRe(seg.slice(0, -1))})?`;
55
+ } else {
56
+ re += `/${escapeRe(seg)}`;
57
+ }
58
+ }
59
+ re += "/?";
60
+ return caseSensitive ? re : `(?i:${re})`;
61
+ }
62
+
63
+ /**
64
+ * The anchored matcher for every route, under `basename`, with "/" escaped
65
+ * for txcl's /…/ literal. null when a route can't be translated (the caller
66
+ * then answers every navigation with the shell and 200).
67
+ */
68
+ export function routesMatcher(routes: RouteManifest, basename = "/"): string | null {
69
+ // React Router matches basename case-insensitively (stripBasename).
70
+ const base = basename.replace(/^\/+|\/+$/g, "");
71
+ const prefix = base ? `(?i:/${escapeRe(base)})` : "";
72
+ const alts: string[] = [];
73
+ for (const { path, caseSensitive } of routePaths(routes)) {
74
+ const re = routeRegex(path, caseSensitive);
75
+ if (re === null) return null;
76
+ alts.push(prefix + re);
77
+ }
78
+ if (alts.length === 0) return null;
79
+ return `^(?:${alts.join("|")})$`.replace(/\//g, "\\/");
80
+ }