@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 +90 -2
- package/dist/build.d.ts +43 -0
- package/dist/build.js +81 -0
- package/dist/index.d.ts +39 -0
- package/dist/index.js +53 -0
- package/dist/routes.d.ts +26 -0
- package/dist/routes.js +75 -0
- package/package.json +55 -4
- package/src/build.ts +136 -0
- package/src/index.ts +85 -0
- package/src/routes.ts +80 -0
package/README.md
CHANGED
|
@@ -1,3 +1,91 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @txco/react-router
|
|
2
2
|
|
|
3
|
-
|
|
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
|
package/dist/build.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/routes.d.ts
ADDED
|
@@ -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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|