toiljs 0.0.81 → 0.0.83
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/CHANGELOG.md +10 -0
- package/build/backend/.tsbuildinfo +1 -1
- package/build/cli/.tsbuildinfo +1 -1
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/prerender.d.ts +1 -0
- package/build/compiler/prerender.js +36 -6
- package/build/compiler/routes.d.ts +2 -0
- package/build/compiler/routes.js +19 -0
- package/build/compiler/sri.d.ts +4 -0
- package/build/compiler/sri.js +75 -0
- package/build/compiler/vite.js +2 -0
- package/build/devserver/.tsbuildinfo +1 -1
- package/build/devserver/http/runtime.d.ts +1 -0
- package/build/devserver/http/runtime.js +88 -0
- package/build/devserver/production.js +3 -2
- package/package.json +1 -1
- package/src/compiler/prerender.ts +64 -11
- package/src/compiler/routes.ts +38 -0
- package/src/compiler/sri.ts +90 -0
- package/src/compiler/vite.ts +6 -0
- package/src/devserver/http/runtime.ts +100 -0
- package/src/devserver/production.ts +7 -1
- package/test/dynamic-routing.test.ts +132 -0
- package/test/sri.test.ts +73 -0
package/package.json
CHANGED
|
@@ -7,7 +7,7 @@ import type * as TS from 'typescript';
|
|
|
7
7
|
import type { Plugin } from 'vite';
|
|
8
8
|
|
|
9
9
|
import { type ResolvedToilConfig } from './config.js';
|
|
10
|
-
import { scanRoutes } from './routes.js';
|
|
10
|
+
import { patternToBracketFile, scanRoutes, staticSectionPattern } from './routes.js';
|
|
11
11
|
import { injectSeoHtml, routeSeo } from './seo.js';
|
|
12
12
|
|
|
13
13
|
type Ts = typeof TS;
|
|
@@ -115,12 +115,51 @@ export function extractStaticMetadata(ts: Ts, filePath: string): Record<string,
|
|
|
115
115
|
return extractStaticExports(ts, filePath, ['metadata']).metadata ?? null;
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
+
/**
|
|
119
|
+
* True when the route file has a literal `export const <name> = true`. Used to detect the edge-SSR
|
|
120
|
+
* opt-in (`export const ssr = true`) without spinning up a Vite SSR server, so the static prerender
|
|
121
|
+
* can leave SSR routes to the SSR path.
|
|
122
|
+
*/
|
|
123
|
+
export function exportsTrue(ts: Ts, filePath: string, name: string): boolean {
|
|
124
|
+
let source: string;
|
|
125
|
+
try {
|
|
126
|
+
source = fs.readFileSync(filePath, 'utf8');
|
|
127
|
+
} catch {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
const sf = ts.createSourceFile(
|
|
131
|
+
filePath,
|
|
132
|
+
source,
|
|
133
|
+
ts.ScriptTarget.Latest,
|
|
134
|
+
true,
|
|
135
|
+
ts.ScriptKind.TSX,
|
|
136
|
+
);
|
|
137
|
+
for (const stmt of sf.statements) {
|
|
138
|
+
if (!ts.isVariableStatement(stmt)) continue;
|
|
139
|
+
if (!stmt.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)) continue;
|
|
140
|
+
for (const decl of stmt.declarationList.declarations) {
|
|
141
|
+
if (
|
|
142
|
+
ts.isIdentifier(decl.name) &&
|
|
143
|
+
decl.name.text === name &&
|
|
144
|
+
decl.initializer?.kind === ts.SyntaxKind.TrueKeyword
|
|
145
|
+
) {
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
|
|
118
153
|
/**
|
|
119
154
|
* Build-only plugin that statically pre-renders per-route HTML for SEO. After the bundle is written,
|
|
120
|
-
* it takes the built shell (`index.html`), and for each
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
155
|
+
* it takes the built shell (`index.html`), and for each route bakes that route's `metadata` (merged
|
|
156
|
+
* over the site-wide `seo` defaults) into a browsable HTML file so a JS-less crawler gets correct
|
|
157
|
+
* per-page tags:
|
|
158
|
+
* - a STATIC route (`/about`) -> `<route>/index.html`;
|
|
159
|
+
* - a DYNAMIC route (`/blog/:id`) -> a literal bracket template `blog/[id].html` that the static
|
|
160
|
+
* server serves for any concrete `/blog/<x>` and the client router hydrates. Its canonical points
|
|
161
|
+
* at the route's static section (`/blog`); the client sets the exact per-value URL on hydration.
|
|
162
|
+
* A dynamic template is a file, never a `<seg>/index.html` folder, so it never shadows a sibling.
|
|
124
163
|
*/
|
|
125
164
|
export function prerenderPlugin(cfg: ResolvedToilConfig): Plugin {
|
|
126
165
|
return {
|
|
@@ -139,15 +178,29 @@ export function prerenderPlugin(cfg: ResolvedToilConfig): Plugin {
|
|
|
139
178
|
const ts = await loadTypeScript(cfg.root);
|
|
140
179
|
|
|
141
180
|
const routes = scanRoutes(cfg.routesAbsDir).filter(
|
|
142
|
-
(r) => r.slot === undefined && !r.intercept
|
|
181
|
+
(r) => r.slot === undefined && !r.intercept,
|
|
143
182
|
);
|
|
144
183
|
for (const route of routes) {
|
|
184
|
+
const isDynamic = /[:*]/.test(route.pattern);
|
|
185
|
+
// A dynamic route that opts into edge SSR (`export const ssr = true`) is rendered by
|
|
186
|
+
// the SSR template path. Baking a static bracket template would let the edge's
|
|
187
|
+
// static-first serving shadow it, so leave SSR routes to SSR. (When `ts` is null the
|
|
188
|
+
// check is skipped and we bake anyway: fail-OPEN deliberately favors the common
|
|
189
|
+
// non-SSR dynamic route, which would 404 without a template; typescript is always
|
|
190
|
+
// present in a real toiljs project, so this branch is effectively unreachable.)
|
|
191
|
+
if (isDynamic && ts && exportsTrue(ts, route.file, 'ssr')) continue;
|
|
145
192
|
const metadata = ts ? extractStaticMetadata(ts, route.file) : null;
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
193
|
+
// A dynamic route's concrete URL is unknown at build, so its canonical/OG URL points
|
|
194
|
+
// at the static section (`/blog/:id` -> `/blog`); the client refines it on hydration.
|
|
195
|
+
const seoPattern = isDynamic ? staticSectionPattern(route.pattern) : route.pattern;
|
|
196
|
+
const html = injectSeoHtml(shell, routeSeo(cfg.seo, metadata, seoPattern));
|
|
197
|
+
// Dynamic -> a literal bracket template (`blog/[id].html`); static -> `<route>/index.html`
|
|
198
|
+
// (the `/` route is the shell itself). The bracket file is never a folder+index.html.
|
|
199
|
+
const target = isDynamic
|
|
200
|
+
? path.join(outDir, patternToBracketFile(route.pattern))
|
|
201
|
+
: route.pattern === '/'
|
|
202
|
+
? shellPath
|
|
203
|
+
: path.join(outDir, route.pattern.replace(/^\//, ''), 'index.html');
|
|
151
204
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
152
205
|
fs.writeFileSync(target, html);
|
|
153
206
|
}
|
package/src/compiler/routes.ts
CHANGED
|
@@ -34,6 +34,44 @@ function toUrlSegment(segment: string): string {
|
|
|
34
34
|
.replace(/^\[(.+)\]$/, ':$1');
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/** Inverse of {@link toUrlSegment}: turns a URL pattern segment back into its on-disk bracket
|
|
38
|
+
* form (`:id`→`[id]`, `*x`→`[...x]`, `**x`→`[[...x]]`). Static segments pass through unchanged. */
|
|
39
|
+
function bracketSegment(segment: string): string {
|
|
40
|
+
if (segment.startsWith('**')) return `[[...${segment.slice(2)}]]`;
|
|
41
|
+
if (segment.startsWith('*')) return `[...${segment.slice(1)}]`;
|
|
42
|
+
if (segment.startsWith(':')) return `[${segment.slice(1)}]`;
|
|
43
|
+
return segment;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The on-disk HTML filename (relative to the build output dir) a DYNAMIC route pattern bakes to:
|
|
48
|
+
* the inverse of the scan mapping, with the last segment as the file and the rest as directories.
|
|
49
|
+
* This is the literal bracket template the static server serves for any matching URL and the client
|
|
50
|
+
* router hydrates, so it must not become a `<seg>/index.html` folder (which would shadow siblings).
|
|
51
|
+
* /blog/:id -> blog/[id].html
|
|
52
|
+
* /docs/*slug -> docs/[...slug].html
|
|
53
|
+
* /docs/**slug -> docs/[[...slug]].html
|
|
54
|
+
* /:category/:id -> [category]/[id].html
|
|
55
|
+
*/
|
|
56
|
+
export function patternToBracketFile(pattern: string): string {
|
|
57
|
+
return pattern.split('/').filter(Boolean).map(bracketSegment).join('/') + '.html';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The static "section" of a pattern: the same pattern with every dynamic (`:`/`*`) segment dropped.
|
|
62
|
+
* Used as the canonical/OG base URL when baking a dynamic route's build-time SEO (the concrete
|
|
63
|
+
* per-value URL is unknown at build; the client sets it on hydration).
|
|
64
|
+
* /blog/:id -> /blog
|
|
65
|
+
* /:category/:id -> /
|
|
66
|
+
*/
|
|
67
|
+
export function staticSectionPattern(pattern: string): string {
|
|
68
|
+
const kept = pattern
|
|
69
|
+
.split('/')
|
|
70
|
+
.filter(Boolean)
|
|
71
|
+
.filter((s) => !s.startsWith(':') && !s.startsWith('*'));
|
|
72
|
+
return '/' + kept.join('/');
|
|
73
|
+
}
|
|
74
|
+
|
|
37
75
|
/** Interception markers: `(.)` same level, `(..)` up one, `(...)` from the routes root. */
|
|
38
76
|
const INTERCEPT_RE = /^\((\.{1,3})\)(.+)$/;
|
|
39
77
|
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
import type { Plugin } from 'vite';
|
|
6
|
+
|
|
7
|
+
import { type ResolvedToilConfig } from './config.js';
|
|
8
|
+
|
|
9
|
+
/** SHA-384 Subresource-Integrity token (`sha384-<base64>`) for a local file, or `null` if it can't
|
|
10
|
+
* be read. Memoized per absolute path (the same asset is referenced by many baked pages). */
|
|
11
|
+
function computeSri(absPath: string, cache: Map<string, string | null>): string | null {
|
|
12
|
+
const cached = cache.get(absPath);
|
|
13
|
+
if (cached !== undefined) return cached;
|
|
14
|
+
let sri: string | null;
|
|
15
|
+
try {
|
|
16
|
+
sri = `sha384-${createHash('sha384').update(fs.readFileSync(absPath)).digest('base64')}`;
|
|
17
|
+
} catch {
|
|
18
|
+
sri = null;
|
|
19
|
+
}
|
|
20
|
+
cache.set(absPath, sri);
|
|
21
|
+
return sri;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Resolve a tag URL to a path relative to `outDir`, or `null` if it is not a local build asset
|
|
25
|
+
* (an absolute scheme, protocol-relative, `data:`, or a traversal are all skipped). */
|
|
26
|
+
function toLocalAsset(url: string, base: string): string | null {
|
|
27
|
+
const clean = url.split('?')[0].split('#')[0];
|
|
28
|
+
// scheme (`https:`, `data:`), protocol-relative (`//cdn`), or empty -> not ours to hash.
|
|
29
|
+
if (clean === '' || /^[a-z][a-z0-9+.-]*:/i.test(clean) || clean.startsWith('//')) return null;
|
|
30
|
+
let rel = clean;
|
|
31
|
+
if (base && base !== '/' && rel.startsWith(base)) rel = rel.slice(base.length);
|
|
32
|
+
else if (rel.startsWith('/')) rel = rel.slice(1);
|
|
33
|
+
else if (rel.startsWith('./')) rel = rel.slice(2);
|
|
34
|
+
if (rel === '' || rel.includes('..')) return null;
|
|
35
|
+
return rel;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Add Subresource Integrity to every LOCAL `<script src>`, `<link rel="modulepreload" href>`, and
|
|
40
|
+
* `<link rel="stylesheet" href>` in `html`: an `integrity="sha384-…"` computed from the asset's
|
|
41
|
+
* bytes plus `crossorigin="anonymous"` (required for the browser to verify + a no-op same-origin).
|
|
42
|
+
* External URLs, inline scripts, non-script/style links, and tags that already carry `integrity`
|
|
43
|
+
* are left untouched. Vite content-hashes these assets, so the integrity is stable and matches the
|
|
44
|
+
* immutable-cached bytes the edge serves.
|
|
45
|
+
*/
|
|
46
|
+
export function injectSri(html: string, outDir: string, base: string): string {
|
|
47
|
+
const cache = new Map<string, string | null>();
|
|
48
|
+
return html.replace(/<(script|link)\b([^>]*)>/gi, (full, tag: string, rawAttrs: string) => {
|
|
49
|
+
if (/\bintegrity\s*=/i.test(rawAttrs)) return full; // already integrity-tagged
|
|
50
|
+
const selfClose = /\/\s*$/.test(rawAttrs);
|
|
51
|
+
const attrs = rawAttrs.replace(/\s*\/\s*$/, '');
|
|
52
|
+
const isScript = tag.toLowerCase() === 'script';
|
|
53
|
+
if (!isScript) {
|
|
54
|
+
const rel = /\brel\s*=\s*["']?([^"'\s>]+)/i.exec(attrs)?.[1]?.toLowerCase();
|
|
55
|
+
if (rel !== 'stylesheet' && rel !== 'modulepreload') return full;
|
|
56
|
+
}
|
|
57
|
+
const attr = isScript ? 'src' : 'href';
|
|
58
|
+
const url = new RegExp(`\\b${attr}\\s*=\\s*["']([^"']+)["']`, 'i').exec(attrs)?.[1];
|
|
59
|
+
if (url === undefined) return full; // inline script or no url
|
|
60
|
+
const rel = toLocalAsset(url, base);
|
|
61
|
+
if (rel === null) return full;
|
|
62
|
+
const sri = computeSri(path.join(outDir, rel), cache);
|
|
63
|
+
if (sri === null) return full;
|
|
64
|
+
const cross = /\bcrossorigin\b/i.test(attrs) ? '' : ' crossorigin="anonymous"';
|
|
65
|
+
return `<${tag}${attrs} integrity="${sri}"${cross}${selfClose ? ' /' : ''}>`;
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Build-only plugin: after the bundle is written, rewrites the built shell (`outDir/index.html`) so
|
|
71
|
+
* every local script/stylesheet/modulepreload carries an `integrity`. It runs in `closeBundle`
|
|
72
|
+
* (assets are on disk, so real bytes are hashed) and is registered BEFORE the prerender / SSR-
|
|
73
|
+
* template passes, which bake per-route HTML, bracket templates, and the SSR `.tmpl` FROM this
|
|
74
|
+
* shell -- so every emitted page inherits SRI and the `.tmpl` coherence hash covers it. Build-only:
|
|
75
|
+
* the dev server serves un-hashed modules straight from Vite, where SRI would be meaningless.
|
|
76
|
+
*/
|
|
77
|
+
export function sriPlugin(cfg: ResolvedToilConfig): Plugin {
|
|
78
|
+
return {
|
|
79
|
+
name: 'toil:sri',
|
|
80
|
+
apply: 'build',
|
|
81
|
+
closeBundle() {
|
|
82
|
+
const outDir = path.resolve(cfg.root, cfg.outDir);
|
|
83
|
+
const shell = path.join(outDir, 'index.html');
|
|
84
|
+
if (!fs.existsSync(shell)) return;
|
|
85
|
+
const html = fs.readFileSync(shell, 'utf8');
|
|
86
|
+
const out = injectSri(html, outDir, cfg.base);
|
|
87
|
+
if (out !== html) fs.writeFileSync(shell, out);
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
package/src/compiler/vite.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { imageReportPlugin } from './image-report.js';
|
|
|
15
15
|
import { imageBlurPlugin } from './image-blur.js';
|
|
16
16
|
import { toilPlugin } from './plugin.js';
|
|
17
17
|
import { prerenderPlugin } from './prerender.js';
|
|
18
|
+
import { sriPlugin } from './sri.js';
|
|
18
19
|
|
|
19
20
|
// `vite-plugin-node-polyfills` rewrites react/react-dom to import its `vite-plugin-node-polyfills/
|
|
20
21
|
// shims/*` modules. When a consumer links toiljs by symlink (`file:`/workspace), that package lives
|
|
@@ -182,6 +183,11 @@ export async function createViteConfig(cfg: ResolvedToilConfig): Promise<InlineC
|
|
|
182
183
|
})
|
|
183
184
|
: undefined,
|
|
184
185
|
cfg.images ? imageReportPlugin(cfg.root, cfg.toilDir) : undefined,
|
|
186
|
+
// Subresource Integrity (build only): adds integrity="sha384-…" + crossorigin to every
|
|
187
|
+
// local script/stylesheet/modulepreload in the shell. MUST precede prerenderPlugin so the
|
|
188
|
+
// per-route/bracket/SSR pages baked from the shell inherit it (and the SSR .tmpl coherence
|
|
189
|
+
// hash covers it).
|
|
190
|
+
sriPlugin(cfg),
|
|
185
191
|
// Static per-route SEO prerender (build only): bakes each route's metadata into its HTML.
|
|
186
192
|
cfg.seo ? prerenderPlugin(cfg) : undefined,
|
|
187
193
|
// Preload bundled fonts (build only). Disabled by `client.fonts: false`.
|
|
@@ -84,6 +84,106 @@ export function resolveStaticFile(root: string, requestPath: string): string | n
|
|
|
84
84
|
return resolveFileInside(root, decoded.replace(/^\/+/, ''));
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
// --- Dynamic bracket-template routing (mirrors the toil-backend edge) ---------
|
|
88
|
+
//
|
|
89
|
+
// The build emits a dynamic route `blog/[id].tsx` as a literal `blog/[id].html`
|
|
90
|
+
// template that boots the SPA for any concrete `/blog/<x>`. When no exact file
|
|
91
|
+
// matches, walk the URL segments and, at each directory, serve a bracket sibling
|
|
92
|
+
// using the client router's precedence: a real subdir wins (static > dynamic),
|
|
93
|
+
// then `[x].html` (one segment), then `[x]/` (descend), then `[...x].html` (1+),
|
|
94
|
+
// then `[[...x]].html` (0+). Directory scans are memoized.
|
|
95
|
+
|
|
96
|
+
interface DirDynamic {
|
|
97
|
+
singleFile: string | null;
|
|
98
|
+
singleDir: string | null;
|
|
99
|
+
catchall: string | null;
|
|
100
|
+
optCatchall: string | null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const dynDirCache = new Map<string, DirDynamic>();
|
|
104
|
+
|
|
105
|
+
function classifyBracket(name: string, isDir: boolean): keyof DirDynamic | null {
|
|
106
|
+
if (isDir) {
|
|
107
|
+
if (name.startsWith('[') && name.endsWith(']') && !name.includes('...') && name.length > 2)
|
|
108
|
+
return 'singleDir';
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
if (!name.endsWith('.html')) return null;
|
|
112
|
+
const stem = name.slice(0, -'.html'.length);
|
|
113
|
+
if (stem.startsWith('[[...') && stem.endsWith(']]') && stem.length > 7) return 'optCatchall';
|
|
114
|
+
if (stem.startsWith('[...') && stem.endsWith(']') && stem.length > 5) return 'catchall';
|
|
115
|
+
if (stem.startsWith('[') && stem.endsWith(']') && !stem.startsWith('[..') && stem.length > 2)
|
|
116
|
+
return 'singleFile';
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function scanDirDynamic(absDir: string): DirDynamic {
|
|
121
|
+
const cached = dynDirCache.get(absDir);
|
|
122
|
+
if (cached) return cached;
|
|
123
|
+
const d: DirDynamic = { singleFile: null, singleDir: null, catchall: null, optCatchall: null };
|
|
124
|
+
try {
|
|
125
|
+
for (const e of fs.readdirSync(absDir, { withFileTypes: true })) {
|
|
126
|
+
const kind = classifyBracket(e.name, e.isDirectory());
|
|
127
|
+
if (kind === null) continue;
|
|
128
|
+
const cur = d[kind];
|
|
129
|
+
if (cur === null || e.name < cur) d[kind] = e.name;
|
|
130
|
+
}
|
|
131
|
+
} catch {
|
|
132
|
+
// missing/unreadable directory: no templates
|
|
133
|
+
}
|
|
134
|
+
dynDirCache.set(absDir, d);
|
|
135
|
+
return d;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const joinRel = (dir: string, name: string): string => (dir === '' ? name : `${dir}/${name}`);
|
|
139
|
+
|
|
140
|
+
function isDirInside(root: string, rel: string): boolean {
|
|
141
|
+
const resolved = path.resolve(root, rel);
|
|
142
|
+
if (resolved !== root && !resolved.startsWith(root + path.sep)) return false;
|
|
143
|
+
try {
|
|
144
|
+
return fs.statSync(resolved).isDirectory();
|
|
145
|
+
} catch {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function resolveDynamicWalk(root: string, dir: string, segs: string[], i: number): string | null {
|
|
151
|
+
if (i >= segs.length) {
|
|
152
|
+
// Nothing left: only an optional catch-all (0 segments) serves the bare dir.
|
|
153
|
+
const oc = scanDirDynamic(path.join(root, dir)).optCatchall;
|
|
154
|
+
return oc === null ? null : joinRel(dir, oc);
|
|
155
|
+
}
|
|
156
|
+
const isLast = i + 1 === segs.length;
|
|
157
|
+
// A real static subdirectory wins over any dynamic template at this level.
|
|
158
|
+
const child = joinRel(dir, segs[i]);
|
|
159
|
+
if (isDirInside(root, child)) {
|
|
160
|
+
const r = resolveDynamicWalk(root, child, segs, i + 1);
|
|
161
|
+
if (r !== null) return r;
|
|
162
|
+
}
|
|
163
|
+
const d = scanDirDynamic(path.join(root, dir));
|
|
164
|
+
if (isLast && d.singleFile !== null) return joinRel(dir, d.singleFile);
|
|
165
|
+
if (d.singleDir !== null) {
|
|
166
|
+
const r = resolveDynamicWalk(root, joinRel(dir, d.singleDir), segs, i + 1);
|
|
167
|
+
if (r !== null) return r;
|
|
168
|
+
}
|
|
169
|
+
if (d.catchall !== null) return joinRel(dir, d.catchall);
|
|
170
|
+
if (d.optCatchall !== null) return joinRel(dir, d.optCatchall);
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Resolve a request path to a bracket-template file (`blog/[id].html`) if one matches, else null. */
|
|
175
|
+
export function resolveDynamicFile(root: string, requestPath: string): string | null {
|
|
176
|
+
let decoded: string;
|
|
177
|
+
try {
|
|
178
|
+
decoded = decodeURIComponent(requestPath);
|
|
179
|
+
} catch {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
const segs = decoded.split('/').filter(Boolean);
|
|
183
|
+
const rel = resolveDynamicWalk(root, '', segs, 0);
|
|
184
|
+
return rel === null ? null : resolveFileInside(root, rel);
|
|
185
|
+
}
|
|
186
|
+
|
|
87
187
|
export interface PreparedHttpResponse {
|
|
88
188
|
readonly status: number;
|
|
89
189
|
readonly headers: readonly (readonly [string, string])[];
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
isDispatchableMethod,
|
|
32
32
|
prepareSsrResponse,
|
|
33
33
|
prepareWasmResponse,
|
|
34
|
+
resolveDynamicFile,
|
|
34
35
|
resolveStaticFile,
|
|
35
36
|
runtimeServerOptions,
|
|
36
37
|
toEnvelopeRequest,
|
|
@@ -456,7 +457,12 @@ async function startBuiltHttpServer(
|
|
|
456
457
|
// lookup EVERY route served the root index.html, so view-source on e.g. /login showed
|
|
457
458
|
// the home page's <head> (title/description/canonical/og) instead of the page's own.
|
|
458
459
|
const routeHtml = resolveStaticFile(paths.staticRoot, `${request.path}/index.html`);
|
|
459
|
-
|
|
460
|
+
// A dynamic route with no baked concrete page is served by its bracket template
|
|
461
|
+
// (`blog/[id].html`) matched the way the client router matches, mirroring the edge; the
|
|
462
|
+
// client then hydrates it for the concrete URL. Falls back to the root shell if none.
|
|
463
|
+
const dynHtml =
|
|
464
|
+
routeHtml === null ? resolveDynamicFile(paths.staticRoot, request.path) : null;
|
|
465
|
+
response.sendFile(routeHtml ?? dynHtml ?? paths.indexHtml);
|
|
460
466
|
return;
|
|
461
467
|
}
|
|
462
468
|
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
import ts from 'typescript';
|
|
6
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
7
|
+
|
|
8
|
+
import { exportsTrue } from '../src/compiler/prerender';
|
|
9
|
+
import { patternToBracketFile, staticSectionPattern } from '../src/compiler/routes';
|
|
10
|
+
import { resolveDynamicFile } from '../src/devserver/http/runtime';
|
|
11
|
+
|
|
12
|
+
describe('patternToBracketFile', () => {
|
|
13
|
+
it('inverts URL patterns back to on-disk bracket filenames', () => {
|
|
14
|
+
expect(patternToBracketFile('/blog/:id')).toBe('blog/[id].html');
|
|
15
|
+
expect(patternToBracketFile('/docs/*slug')).toBe('docs/[...slug].html');
|
|
16
|
+
expect(patternToBracketFile('/docs/**slug')).toBe('docs/[[...slug]].html');
|
|
17
|
+
expect(patternToBracketFile('/:category/:id')).toBe('[category]/[id].html');
|
|
18
|
+
expect(patternToBracketFile('/gallery/photo/:id')).toBe('gallery/photo/[id].html');
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('staticSectionPattern', () => {
|
|
23
|
+
it('strips dynamic segments to the static section (for the template canonical)', () => {
|
|
24
|
+
expect(staticSectionPattern('/blog/:id')).toBe('/blog');
|
|
25
|
+
expect(staticSectionPattern('/docs/*slug')).toBe('/docs');
|
|
26
|
+
expect(staticSectionPattern('/:category/:id')).toBe('/');
|
|
27
|
+
expect(staticSectionPattern('/gallery/photo/:id')).toBe('/gallery/photo');
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('exportsTrue (edge-SSR opt-in detection)', () => {
|
|
32
|
+
const files: string[] = [];
|
|
33
|
+
function tmp(source: string): string {
|
|
34
|
+
const file = path.join(os.tmpdir(), `toil-ssrflag-${String(files.length)}-${process.pid}.tsx`);
|
|
35
|
+
fs.writeFileSync(file, source);
|
|
36
|
+
files.push(file);
|
|
37
|
+
return file;
|
|
38
|
+
}
|
|
39
|
+
afterEach(() => {
|
|
40
|
+
for (const f of files.splice(0)) fs.rmSync(f, { force: true });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('detects `export const ssr = true`', () => {
|
|
44
|
+
expect(exportsTrue(ts, tmp(`export const ssr = true;\nexport default () => null;\n`), 'ssr')).toBe(
|
|
45
|
+
true,
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
it('is false for ssr=false, a non-literal, or an absent export', () => {
|
|
49
|
+
expect(exportsTrue(ts, tmp(`export const ssr = false;\n`), 'ssr')).toBe(false);
|
|
50
|
+
expect(exportsTrue(ts, tmp(`const ssr = true;\n`), 'ssr')).toBe(false); // not exported
|
|
51
|
+
expect(exportsTrue(ts, tmp(`export const other = true;\n`), 'ssr')).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('resolveDynamicFile (self-host bracket routing, mirrors the edge)', () => {
|
|
56
|
+
const roots: string[] = [];
|
|
57
|
+
function siteRoot(): string {
|
|
58
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'toil-dyn-'));
|
|
59
|
+
roots.push(root);
|
|
60
|
+
return root;
|
|
61
|
+
}
|
|
62
|
+
function write(root: string, rel: string, body = '<html></html>'): void {
|
|
63
|
+
const full = path.join(root, rel);
|
|
64
|
+
fs.mkdirSync(path.dirname(full), { recursive: true });
|
|
65
|
+
fs.writeFileSync(full, body);
|
|
66
|
+
}
|
|
67
|
+
afterEach(() => {
|
|
68
|
+
for (const d of roots.splice(0)) fs.rmSync(d, { recursive: true, force: true });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('serves a single-segment [id].html for a matching URL, one segment only', () => {
|
|
72
|
+
const root = siteRoot();
|
|
73
|
+
write(root, 'blog/[id].html', 'blog-shell');
|
|
74
|
+
expect(resolveDynamicFile(root, '/blog/hello')).toBe(path.join(root, 'blog/[id].html'));
|
|
75
|
+
expect(resolveDynamicFile(root, '/blog/a/b')).toBeNull();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('serves a required catch-all for 1+ segments but not zero', () => {
|
|
79
|
+
const root = siteRoot();
|
|
80
|
+
write(root, 'docs/[...slug].html', 'docs');
|
|
81
|
+
expect(resolveDynamicFile(root, '/docs/a')).toBe(path.join(root, 'docs/[...slug].html'));
|
|
82
|
+
expect(resolveDynamicFile(root, '/docs/a/b/c')).toBe(path.join(root, 'docs/[...slug].html'));
|
|
83
|
+
expect(resolveDynamicFile(root, '/docs')).toBeNull();
|
|
84
|
+
// Parity with the client router + edge: a literal `index.html` segment is a slug (matches
|
|
85
|
+
// the catch-all), while a bare `/docs/` is zero trailing segments (no required-catch-all match).
|
|
86
|
+
expect(resolveDynamicFile(root, '/docs/index.html')).toBe(
|
|
87
|
+
path.join(root, 'docs/[...slug].html'),
|
|
88
|
+
);
|
|
89
|
+
expect(resolveDynamicFile(root, '/docs/')).toBeNull();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('serves an optional catch-all for 0+ segments', () => {
|
|
93
|
+
const root = siteRoot();
|
|
94
|
+
write(root, 'files/[[...slug]].html', 'files');
|
|
95
|
+
expect(resolveDynamicFile(root, '/files')).toBe(path.join(root, 'files/[[...slug]].html'));
|
|
96
|
+
expect(resolveDynamicFile(root, '/files/a/b')).toBe(path.join(root, 'files/[[...slug]].html'));
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('prefers single over catch-all for one segment, catch-all for deeper', () => {
|
|
100
|
+
const root = siteRoot();
|
|
101
|
+
write(root, 'shop/[id].html', 'one');
|
|
102
|
+
write(root, 'shop/[...rest].html', 'many');
|
|
103
|
+
expect(fs.readFileSync(resolveDynamicFile(root, '/shop/x') as string, 'utf8')).toBe('one');
|
|
104
|
+
expect(fs.readFileSync(resolveDynamicFile(root, '/shop/x/y') as string, 'utf8')).toBe('many');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('prefers a real static subdirectory over a dynamic sibling', () => {
|
|
108
|
+
const root = siteRoot();
|
|
109
|
+
write(root, 'blog/[id].html', 'template');
|
|
110
|
+
write(root, 'blog/about/deep/[id].html', 'deep');
|
|
111
|
+
// /blog/about/deep/7 descends the real dirs to the deep template, not blog/[id].html.
|
|
112
|
+
expect(resolveDynamicFile(root, '/blog/about/deep/7')).toBe(
|
|
113
|
+
path.join(root, 'blog/about/deep/[id].html'),
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('resolves a nested bracket directory', () => {
|
|
118
|
+
const root = siteRoot();
|
|
119
|
+
write(root, '[category]/[id].html', 'cat');
|
|
120
|
+
expect(resolveDynamicFile(root, '/electronics/9')).toBe(
|
|
121
|
+
path.join(root, '[category]/[id].html'),
|
|
122
|
+
);
|
|
123
|
+
expect(resolveDynamicFile(root, '/electronics')).toBeNull();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('returns null when no bracket template exists', () => {
|
|
127
|
+
const root = siteRoot();
|
|
128
|
+
write(root, 'about/index.html', 'about');
|
|
129
|
+
expect(resolveDynamicFile(root, '/blog/foo')).toBeNull();
|
|
130
|
+
expect(resolveDynamicFile(root, '/x/y/z')).toBeNull();
|
|
131
|
+
});
|
|
132
|
+
});
|
package/test/sri.test.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
6
|
+
|
|
7
|
+
import { injectSri } from '../src/compiler/sri';
|
|
8
|
+
|
|
9
|
+
describe('injectSri', () => {
|
|
10
|
+
const dirs: string[] = [];
|
|
11
|
+
function outDir(files: Record<string, string>): string {
|
|
12
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'toil-sri-'));
|
|
13
|
+
dirs.push(root);
|
|
14
|
+
for (const [rel, body] of Object.entries(files)) {
|
|
15
|
+
const full = path.join(root, rel);
|
|
16
|
+
fs.mkdirSync(path.dirname(full), { recursive: true });
|
|
17
|
+
fs.writeFileSync(full, body);
|
|
18
|
+
}
|
|
19
|
+
return root;
|
|
20
|
+
}
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
for (const d of dirs.splice(0)) fs.rmSync(d, { recursive: true, force: true });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('adds sha384 integrity + crossorigin to a local module script', () => {
|
|
26
|
+
const root = outDir({ 'assets/index-abc.js': 'console.log(1)' });
|
|
27
|
+
const html = injectSri(
|
|
28
|
+
'<script type="module" crossorigin src="/assets/index-abc.js"></script>',
|
|
29
|
+
root,
|
|
30
|
+
'/',
|
|
31
|
+
);
|
|
32
|
+
expect(html).toMatch(/integrity="sha384-[A-Za-z0-9+/]+=*"/);
|
|
33
|
+
// Vite already put crossorigin on the tag, so it isn't duplicated.
|
|
34
|
+
expect(html.match(/crossorigin/g)?.length).toBe(1);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('adds integrity to stylesheets and modulepreload, plus a missing crossorigin', () => {
|
|
38
|
+
const root = outDir({
|
|
39
|
+
'assets/a.css': 'body{}',
|
|
40
|
+
'assets/chunk.js': 'export default 1',
|
|
41
|
+
});
|
|
42
|
+
const css = injectSri('<link rel="stylesheet" href="/assets/a.css">', root, '/');
|
|
43
|
+
expect(css).toMatch(/integrity="sha384-/);
|
|
44
|
+
expect(css).toContain('crossorigin="anonymous"');
|
|
45
|
+
const pre = injectSri('<link rel="modulepreload" href="/assets/chunk.js" />', root, '/');
|
|
46
|
+
expect(pre).toMatch(/integrity="sha384-/);
|
|
47
|
+
expect(pre.trimEnd().endsWith('/>')).toBe(true); // self-closing preserved
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('leaves external URLs, inline scripts, icons, and missing files alone', () => {
|
|
51
|
+
const root = outDir({ 'assets/x.js': 'x' });
|
|
52
|
+
const external = '<script src="https://cdn.example.com/a.js"></script>';
|
|
53
|
+
expect(injectSri(external, root, '/')).toBe(external);
|
|
54
|
+
const inline = '<script type="application/json">{"a":1}</script>';
|
|
55
|
+
expect(injectSri(inline, root, '/')).toBe(inline);
|
|
56
|
+
const icon = '<link rel="icon" href="/favicon.ico">';
|
|
57
|
+
expect(injectSri(icon, root, '/')).toBe(icon);
|
|
58
|
+
const missing = '<script src="/assets/gone.js"></script>';
|
|
59
|
+
expect(injectSri(missing, root, '/')).toBe(missing); // unreadable -> skipped, not crashed
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('does not double-tag a script that already has integrity', () => {
|
|
63
|
+
const root = outDir({ 'assets/x.js': 'x' });
|
|
64
|
+
const already = '<script src="/assets/x.js" integrity="sha384-existing"></script>';
|
|
65
|
+
expect(injectSri(already, root, '/')).toBe(already);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('honors a non-root base', () => {
|
|
69
|
+
const root = outDir({ 'assets/x.js': 'x' });
|
|
70
|
+
const html = injectSri('<script src="/sub/assets/x.js"></script>', root, '/sub/');
|
|
71
|
+
expect(html).toMatch(/integrity="sha384-/);
|
|
72
|
+
});
|
|
73
|
+
});
|