@timber-js/app 0.1.1 → 0.1.2
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/dist/index.js +5 -2
- package/dist/index.js.map +1 -1
- package/dist/plugins/entries.d.ts.map +1 -1
- package/package.json +2 -1
- package/src/adapters/cloudflare.ts +325 -0
- package/src/adapters/nitro.ts +366 -0
- package/src/adapters/types.ts +63 -0
- package/src/cache/index.ts +91 -0
- package/src/cache/redis-handler.ts +91 -0
- package/src/cache/register-cached-function.ts +99 -0
- package/src/cache/singleflight.ts +26 -0
- package/src/cache/stable-stringify.ts +21 -0
- package/src/cache/timber-cache.ts +116 -0
- package/src/cli.ts +201 -0
- package/src/client/browser-entry.ts +663 -0
- package/src/client/error-boundary.tsx +209 -0
- package/src/client/form.tsx +200 -0
- package/src/client/head.ts +61 -0
- package/src/client/history.ts +46 -0
- package/src/client/index.ts +60 -0
- package/src/client/link-navigate-interceptor.tsx +62 -0
- package/src/client/link-status-provider.tsx +40 -0
- package/src/client/link.tsx +310 -0
- package/src/client/nuqs-adapter.tsx +117 -0
- package/src/client/router-ref.ts +25 -0
- package/src/client/router.ts +563 -0
- package/src/client/segment-cache.ts +194 -0
- package/src/client/segment-context.ts +57 -0
- package/src/client/ssr-data.ts +95 -0
- package/src/client/types.ts +4 -0
- package/src/client/unload-guard.ts +34 -0
- package/src/client/use-cookie.ts +122 -0
- package/src/client/use-link-status.ts +46 -0
- package/src/client/use-navigation-pending.ts +47 -0
- package/src/client/use-params.ts +71 -0
- package/src/client/use-pathname.ts +43 -0
- package/src/client/use-query-states.ts +133 -0
- package/src/client/use-router.ts +77 -0
- package/src/client/use-search-params.ts +74 -0
- package/src/client/use-selected-layout-segment.ts +110 -0
- package/src/content/index.ts +13 -0
- package/src/cookies/define-cookie.ts +137 -0
- package/src/cookies/index.ts +9 -0
- package/src/fonts/ast.ts +359 -0
- package/src/fonts/css.ts +68 -0
- package/src/fonts/fallbacks.ts +248 -0
- package/src/fonts/google.ts +332 -0
- package/src/fonts/local.ts +177 -0
- package/src/fonts/types.ts +88 -0
- package/src/index.ts +413 -0
- package/src/plugins/adapter-build.ts +118 -0
- package/src/plugins/build-manifest.ts +323 -0
- package/src/plugins/build-report.ts +353 -0
- package/src/plugins/cache-transform.ts +199 -0
- package/src/plugins/chunks.ts +90 -0
- package/src/plugins/content.ts +136 -0
- package/src/plugins/dev-error-overlay.ts +230 -0
- package/src/plugins/dev-logs.ts +280 -0
- package/src/plugins/dev-server.ts +389 -0
- package/src/plugins/dynamic-transform.ts +161 -0
- package/src/plugins/entries.ts +207 -0
- package/src/plugins/fonts.ts +581 -0
- package/src/plugins/mdx.ts +179 -0
- package/src/plugins/react-prod.ts +56 -0
- package/src/plugins/routing.ts +419 -0
- package/src/plugins/server-action-exports.ts +220 -0
- package/src/plugins/server-bundle.ts +113 -0
- package/src/plugins/shims.ts +168 -0
- package/src/plugins/static-build.ts +207 -0
- package/src/routing/codegen.ts +396 -0
- package/src/routing/index.ts +14 -0
- package/src/routing/interception.ts +173 -0
- package/src/routing/scanner.ts +487 -0
- package/src/routing/status-file-lint.ts +114 -0
- package/src/routing/types.ts +100 -0
- package/src/search-params/analyze.ts +192 -0
- package/src/search-params/codecs.ts +153 -0
- package/src/search-params/create.ts +314 -0
- package/src/search-params/index.ts +23 -0
- package/src/search-params/registry.ts +31 -0
- package/src/server/access-gate.tsx +142 -0
- package/src/server/action-client.ts +473 -0
- package/src/server/action-handler.ts +325 -0
- package/src/server/actions.ts +236 -0
- package/src/server/asset-headers.ts +81 -0
- package/src/server/body-limits.ts +102 -0
- package/src/server/build-manifest.ts +234 -0
- package/src/server/canonicalize.ts +90 -0
- package/src/server/client-module-map.ts +58 -0
- package/src/server/csrf.ts +79 -0
- package/src/server/deny-renderer.ts +302 -0
- package/src/server/dev-logger.ts +419 -0
- package/src/server/dev-span-processor.ts +78 -0
- package/src/server/dev-warnings.ts +282 -0
- package/src/server/early-hints-sender.ts +55 -0
- package/src/server/early-hints.ts +142 -0
- package/src/server/error-boundary-wrapper.ts +69 -0
- package/src/server/error-formatter.ts +184 -0
- package/src/server/flush.ts +182 -0
- package/src/server/form-data.ts +176 -0
- package/src/server/form-flash.ts +93 -0
- package/src/server/html-injectors.ts +445 -0
- package/src/server/index.ts +222 -0
- package/src/server/instrumentation.ts +136 -0
- package/src/server/logger.ts +145 -0
- package/src/server/manifest-status-resolver.ts +215 -0
- package/src/server/metadata-render.ts +527 -0
- package/src/server/metadata-routes.ts +189 -0
- package/src/server/metadata.ts +263 -0
- package/src/server/middleware-runner.ts +32 -0
- package/src/server/nuqs-ssr-provider.tsx +63 -0
- package/src/server/pipeline.ts +555 -0
- package/src/server/prerender.ts +139 -0
- package/src/server/primitives.ts +264 -0
- package/src/server/proxy.ts +43 -0
- package/src/server/request-context.ts +554 -0
- package/src/server/route-element-builder.ts +395 -0
- package/src/server/route-handler.ts +153 -0
- package/src/server/route-matcher.ts +316 -0
- package/src/server/rsc-entry/api-handler.ts +112 -0
- package/src/server/rsc-entry/error-renderer.ts +177 -0
- package/src/server/rsc-entry/helpers.ts +147 -0
- package/src/server/rsc-entry/index.ts +688 -0
- package/src/server/rsc-entry/ssr-bridge.ts +18 -0
- package/src/server/slot-resolver.ts +359 -0
- package/src/server/ssr-entry.ts +161 -0
- package/src/server/ssr-render.ts +200 -0
- package/src/server/status-code-resolver.ts +282 -0
- package/src/server/tracing.ts +281 -0
- package/src/server/tree-builder.ts +354 -0
- package/src/server/types.ts +150 -0
- package/src/shims/font-google.ts +67 -0
- package/src/shims/headers.ts +11 -0
- package/src/shims/image.ts +48 -0
- package/src/shims/link.ts +9 -0
- package/src/shims/navigation-client.ts +52 -0
- package/src/shims/navigation.ts +31 -0
- package/src/shims/server-only-noop.js +5 -0
- package/src/utils/directive-parser.ts +529 -0
- package/src/utils/format.ts +10 -0
- package/src/utils/startup-timer.ts +102 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request body size limits — returns 413 when exceeded.
|
|
3
|
+
* See design/08-forms-and-actions.md §"FormData Limits"
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface BodyLimitsConfig {
|
|
7
|
+
limits?: {
|
|
8
|
+
actionBodySize?: string;
|
|
9
|
+
uploadBodySize?: string;
|
|
10
|
+
maxFields?: number;
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type BodyLimitResult = { ok: true } | { ok: false; status: 411 | 413 };
|
|
15
|
+
|
|
16
|
+
export type BodyKind = 'action' | 'upload';
|
|
17
|
+
|
|
18
|
+
const KB = 1024;
|
|
19
|
+
const MB = 1024 * KB;
|
|
20
|
+
const GB = 1024 * MB;
|
|
21
|
+
|
|
22
|
+
export const DEFAULT_LIMITS = {
|
|
23
|
+
actionBodySize: 1 * MB,
|
|
24
|
+
uploadBodySize: 10 * MB,
|
|
25
|
+
maxFields: 100,
|
|
26
|
+
} as const;
|
|
27
|
+
|
|
28
|
+
const SIZE_PATTERN = /^(\d+(?:\.\d+)?)\s*(kb|mb|gb)?$/i;
|
|
29
|
+
|
|
30
|
+
/** Parse a human-readable size string ("1mb", "512kb", "1024") into bytes. */
|
|
31
|
+
export function parseBodySize(size: string): number {
|
|
32
|
+
const match = SIZE_PATTERN.exec(size.trim());
|
|
33
|
+
if (!match) {
|
|
34
|
+
throw new Error(
|
|
35
|
+
`Invalid body size format: "${size}". Expected format like "1mb", "512kb", or "1024".`
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const value = Number.parseFloat(match[1]);
|
|
40
|
+
const unit = (match[2] ?? '').toLowerCase();
|
|
41
|
+
|
|
42
|
+
switch (unit) {
|
|
43
|
+
case 'kb':
|
|
44
|
+
return Math.floor(value * KB);
|
|
45
|
+
case 'mb':
|
|
46
|
+
return Math.floor(value * MB);
|
|
47
|
+
case 'gb':
|
|
48
|
+
return Math.floor(value * GB);
|
|
49
|
+
case '':
|
|
50
|
+
return Math.floor(value);
|
|
51
|
+
default:
|
|
52
|
+
throw new Error(`Unknown size unit: "${unit}"`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Check whether a request body exceeds the configured size limit (stateless, no ALS). */
|
|
57
|
+
export function enforceBodyLimits(
|
|
58
|
+
req: Request,
|
|
59
|
+
kind: BodyKind,
|
|
60
|
+
config: BodyLimitsConfig
|
|
61
|
+
): BodyLimitResult {
|
|
62
|
+
const contentLength = req.headers.get('Content-Length');
|
|
63
|
+
if (!contentLength) {
|
|
64
|
+
// Reject requests without Content-Length — prevents body limit bypass via
|
|
65
|
+
// chunked transfer-encoding. Browsers always send Content-Length for form POSTs.
|
|
66
|
+
return { ok: false, status: 411 };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const bodySize = Number.parseInt(contentLength, 10);
|
|
70
|
+
if (Number.isNaN(bodySize)) {
|
|
71
|
+
return { ok: false, status: 411 };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const limit = resolveLimit(kind, config);
|
|
75
|
+
return bodySize <= limit ? { ok: true } : { ok: false, status: 413 };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Check whether a FormData payload exceeds the configured field count limit. */
|
|
79
|
+
export function enforceFieldLimit(formData: FormData, config: BodyLimitsConfig): BodyLimitResult {
|
|
80
|
+
const maxFields = config.limits?.maxFields ?? DEFAULT_LIMITS.maxFields;
|
|
81
|
+
// Count unique keys — FormData.keys() yields duplicates for multi-value fields,
|
|
82
|
+
// so we use a Set to count distinct field names.
|
|
83
|
+
const fieldCount = new Set(formData.keys()).size;
|
|
84
|
+
return fieldCount <= maxFields ? { ok: true } : { ok: false, status: 413 };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Resolve the byte limit for a given body kind, using config overrides or defaults.
|
|
89
|
+
*/
|
|
90
|
+
function resolveLimit(kind: BodyKind, config: BodyLimitsConfig): number {
|
|
91
|
+
const userLimits = config.limits;
|
|
92
|
+
|
|
93
|
+
if (kind === 'action') {
|
|
94
|
+
return userLimits?.actionBodySize
|
|
95
|
+
? parseBodySize(userLimits.actionBodySize)
|
|
96
|
+
: DEFAULT_LIMITS.actionBodySize;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return userLimits?.uploadBodySize
|
|
100
|
+
? parseBodySize(userLimits.uploadBodySize)
|
|
101
|
+
: DEFAULT_LIMITS.uploadBodySize;
|
|
102
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build manifest types and utilities for CSS and JS asset tracking.
|
|
3
|
+
*
|
|
4
|
+
* The build manifest maps route segment file paths to their output
|
|
5
|
+
* chunks from Vite's client build. This enables:
|
|
6
|
+
* - <link rel="stylesheet"> injection in HTML <head>
|
|
7
|
+
* - <script type="module"> with hashed URLs in production
|
|
8
|
+
* - <link rel="modulepreload"> for client chunk dependencies
|
|
9
|
+
* - Link preload headers for Early Hints (103)
|
|
10
|
+
*
|
|
11
|
+
* In dev mode, Vite's HMR client handles CSS/JS injection, so the build
|
|
12
|
+
* manifest is empty. In production, it's populated from Vite's
|
|
13
|
+
* .vite/manifest.json after the client build.
|
|
14
|
+
*
|
|
15
|
+
* Design docs: 18-build-system.md §"Build Manifest", 02-rendering-pipeline.md §"Early Hints"
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** A font asset entry in the build manifest. */
|
|
19
|
+
export interface ManifestFontEntry {
|
|
20
|
+
/** URL path to the font file (e.g. `/_timber/fonts/inter-latin-400-abc123.woff2`). */
|
|
21
|
+
href: string;
|
|
22
|
+
/** Font format (e.g. `woff2`). */
|
|
23
|
+
format: string;
|
|
24
|
+
/** Crossorigin attribute — always `anonymous` for fonts. */
|
|
25
|
+
crossOrigin: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Build manifest mapping input file paths to output asset URLs. */
|
|
29
|
+
export interface BuildManifest {
|
|
30
|
+
/** Map from input file path (relative to project root) to output CSS URLs. */
|
|
31
|
+
css: Record<string, string[]>;
|
|
32
|
+
/** Map from input file path to output JS chunk URL (hashed filename). */
|
|
33
|
+
js: Record<string, string>;
|
|
34
|
+
/** Map from input file path to transitive JS dependency URLs for modulepreload. */
|
|
35
|
+
modulepreload: Record<string, string[]>;
|
|
36
|
+
/** Map from input file path to font assets used by that module. */
|
|
37
|
+
fonts: Record<string, ManifestFontEntry[]>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Empty build manifest used in dev mode. */
|
|
41
|
+
export const EMPTY_BUILD_MANIFEST: BuildManifest = {
|
|
42
|
+
css: {},
|
|
43
|
+
js: {},
|
|
44
|
+
modulepreload: {},
|
|
45
|
+
fonts: {},
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** Segment shape expected by collectRouteCss (matches ManifestSegmentNode). */
|
|
49
|
+
interface SegmentWithFiles {
|
|
50
|
+
layout?: { filePath: string };
|
|
51
|
+
page?: { filePath: string };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Collect all CSS files needed for a matched route's segment chain.
|
|
56
|
+
*
|
|
57
|
+
* Walks segments root → leaf, collecting CSS for each layout and page.
|
|
58
|
+
* Deduplicates while preserving order (root layout CSS first).
|
|
59
|
+
*/
|
|
60
|
+
export function collectRouteCss(segments: SegmentWithFiles[], manifest: BuildManifest): string[] {
|
|
61
|
+
const seen = new Set<string>();
|
|
62
|
+
const result: string[] = [];
|
|
63
|
+
|
|
64
|
+
for (const segment of segments) {
|
|
65
|
+
for (const file of [segment.layout, segment.page]) {
|
|
66
|
+
if (!file) continue;
|
|
67
|
+
const cssFiles = manifest.css[file.filePath];
|
|
68
|
+
if (!cssFiles) continue;
|
|
69
|
+
for (const url of cssFiles) {
|
|
70
|
+
if (!seen.has(url)) {
|
|
71
|
+
seen.add(url);
|
|
72
|
+
result.push(url);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return result;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Generate <link rel="stylesheet"> tags for CSS URLs.
|
|
83
|
+
*
|
|
84
|
+
* Returns an HTML string to prepend to headHtml for injection
|
|
85
|
+
* via injectHead() before </head>.
|
|
86
|
+
*/
|
|
87
|
+
export function buildCssLinkTags(cssUrls: string[]): string {
|
|
88
|
+
return cssUrls.map((url) => `<link rel="stylesheet" href="${url}">`).join('');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Generate a Link header value for CSS preload hints.
|
|
93
|
+
*
|
|
94
|
+
* Cloudflare CDN automatically converts Link headers with rel=preload
|
|
95
|
+
* into 103 Early Hints responses. This avoids platform-specific 103
|
|
96
|
+
* sending code.
|
|
97
|
+
*
|
|
98
|
+
* Example output: `</assets/root.css>; rel=preload; as=style, </assets/page.css>; rel=preload; as=style`
|
|
99
|
+
*/
|
|
100
|
+
export function buildLinkHeaders(cssUrls: string[]): string {
|
|
101
|
+
return cssUrls.map((url) => `<${url}>; rel=preload; as=style`).join(', ');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ─── Font utilities ──────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Collect all font entries needed for a matched route's segment chain.
|
|
108
|
+
*
|
|
109
|
+
* Walks segments root → leaf, collecting fonts for each layout and page.
|
|
110
|
+
* Deduplicates by href while preserving order.
|
|
111
|
+
*/
|
|
112
|
+
export function collectRouteFonts(
|
|
113
|
+
segments: SegmentWithFiles[],
|
|
114
|
+
manifest: BuildManifest
|
|
115
|
+
): ManifestFontEntry[] {
|
|
116
|
+
const seen = new Set<string>();
|
|
117
|
+
const result: ManifestFontEntry[] = [];
|
|
118
|
+
|
|
119
|
+
for (const segment of segments) {
|
|
120
|
+
for (const file of [segment.layout, segment.page]) {
|
|
121
|
+
if (!file) continue;
|
|
122
|
+
const fonts = manifest.fonts[file.filePath];
|
|
123
|
+
if (!fonts) continue;
|
|
124
|
+
for (const entry of fonts) {
|
|
125
|
+
if (!seen.has(entry.href)) {
|
|
126
|
+
seen.add(entry.href);
|
|
127
|
+
result.push(entry);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return result;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Generate <link rel="preload"> tags for font assets.
|
|
138
|
+
*
|
|
139
|
+
* Font preloads use `as=font` and always include `crossorigin` (required
|
|
140
|
+
* for font preloads even for same-origin resources per the spec).
|
|
141
|
+
*/
|
|
142
|
+
export function buildFontPreloadTags(fonts: ManifestFontEntry[]): string {
|
|
143
|
+
return fonts
|
|
144
|
+
.map(
|
|
145
|
+
(f) =>
|
|
146
|
+
`<link rel="preload" href="${f.href}" as="font" type="font/${f.format}" crossorigin="${f.crossOrigin}">`
|
|
147
|
+
)
|
|
148
|
+
.join('');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Generate Link header values for font preload hints.
|
|
153
|
+
*
|
|
154
|
+
* Cloudflare CDN converts Link headers with rel=preload into 103 Early Hints.
|
|
155
|
+
*
|
|
156
|
+
* Example: `</fonts/inter.woff2>; rel=preload; as=font; crossorigin`
|
|
157
|
+
*/
|
|
158
|
+
export function buildFontLinkHeaders(fonts: ManifestFontEntry[]): string {
|
|
159
|
+
return fonts.map((f) => `<${f.href}>; rel=preload; as=font; crossorigin`).join(', ');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ─── JS chunk utilities ──────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Collect JS chunk URLs for a matched route's segment chain.
|
|
166
|
+
*
|
|
167
|
+
* Walks segments root → leaf, collecting the JS chunk for each layout
|
|
168
|
+
* and page. Deduplicates while preserving order.
|
|
169
|
+
*/
|
|
170
|
+
export function collectRouteJs(segments: SegmentWithFiles[], manifest: BuildManifest): string[] {
|
|
171
|
+
const seen = new Set<string>();
|
|
172
|
+
const result: string[] = [];
|
|
173
|
+
|
|
174
|
+
for (const segment of segments) {
|
|
175
|
+
for (const file of [segment.layout, segment.page]) {
|
|
176
|
+
if (!file) continue;
|
|
177
|
+
const jsUrl = manifest.js[file.filePath];
|
|
178
|
+
if (!jsUrl) continue;
|
|
179
|
+
if (!seen.has(jsUrl)) {
|
|
180
|
+
seen.add(jsUrl);
|
|
181
|
+
result.push(jsUrl);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return result;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Collect modulepreload URLs for a matched route's segment chain.
|
|
191
|
+
*
|
|
192
|
+
* Walks segments root → leaf, collecting transitive JS dependencies
|
|
193
|
+
* for each layout and page. Deduplicates across segments.
|
|
194
|
+
*/
|
|
195
|
+
export function collectRouteModulepreloads(
|
|
196
|
+
segments: SegmentWithFiles[],
|
|
197
|
+
manifest: BuildManifest
|
|
198
|
+
): string[] {
|
|
199
|
+
const seen = new Set<string>();
|
|
200
|
+
const result: string[] = [];
|
|
201
|
+
|
|
202
|
+
for (const segment of segments) {
|
|
203
|
+
for (const file of [segment.layout, segment.page]) {
|
|
204
|
+
if (!file) continue;
|
|
205
|
+
const preloads = manifest.modulepreload[file.filePath];
|
|
206
|
+
if (!preloads) continue;
|
|
207
|
+
for (const url of preloads) {
|
|
208
|
+
if (!seen.has(url)) {
|
|
209
|
+
seen.add(url);
|
|
210
|
+
result.push(url);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return result;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Generate <link rel="modulepreload"> tags for JS dependency URLs.
|
|
221
|
+
*
|
|
222
|
+
* Modulepreload hints tell the browser to fetch and parse JS modules
|
|
223
|
+
* before they're needed, reducing waterfall latency for dynamic imports.
|
|
224
|
+
*/
|
|
225
|
+
export function buildModulepreloadTags(urls: string[]): string {
|
|
226
|
+
return urls.map((url) => `<link rel="modulepreload" href="${url}">`).join('');
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Generate a <script type="module"> tag for a JS entry point.
|
|
231
|
+
*/
|
|
232
|
+
export function buildEntryScriptTag(url: string): string {
|
|
233
|
+
return `<script type="module" src="${url}"></script>`;
|
|
234
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL canonicalization — runs once at the request boundary.
|
|
3
|
+
*
|
|
4
|
+
* Every layer (proxy.ts, middleware.ts, access.ts, components) sees the same
|
|
5
|
+
* canonical path. No re-decoding occurs at any later stage.
|
|
6
|
+
*
|
|
7
|
+
* See design/07-routing.md §"URL Canonicalization & Security"
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Result of canonicalization — either a clean path or a rejection. */
|
|
11
|
+
export type CanonicalizeResult = { ok: true; pathname: string } | { ok: false; status: 400 };
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Encoded separators that produce a 400 rejection.
|
|
15
|
+
* %2f (/) and %5c (\) cause path-confusion attacks.
|
|
16
|
+
*/
|
|
17
|
+
const ENCODED_SEPARATOR_RE = /%2f|%5c/i;
|
|
18
|
+
|
|
19
|
+
/** Null byte — rejected. */
|
|
20
|
+
const NULL_BYTE_RE = /%00/i;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Canonicalize a URL pathname.
|
|
24
|
+
*
|
|
25
|
+
* 1. Reject encoded separators (%2f, %5c) and null bytes (%00)
|
|
26
|
+
* 2. Single percent-decode
|
|
27
|
+
* 3. Collapse // → /
|
|
28
|
+
* 4. Resolve .. segments (reject if escaping root)
|
|
29
|
+
* 5. Strip trailing slash (except root "/")
|
|
30
|
+
*
|
|
31
|
+
* @param rawPathname - The raw pathname from the request URL (percent-encoded)
|
|
32
|
+
* @param stripTrailingSlash - Whether to strip trailing slashes. Default: true.
|
|
33
|
+
*/
|
|
34
|
+
export function canonicalize(rawPathname: string, stripTrailingSlash = true): CanonicalizeResult {
|
|
35
|
+
// Step 1: Reject dangerous encoded sequences BEFORE decoding.
|
|
36
|
+
// This must happen on the raw input so %252f doesn't bypass after a single decode.
|
|
37
|
+
if (ENCODED_SEPARATOR_RE.test(rawPathname)) {
|
|
38
|
+
return { ok: false, status: 400 };
|
|
39
|
+
}
|
|
40
|
+
if (NULL_BYTE_RE.test(rawPathname)) {
|
|
41
|
+
return { ok: false, status: 400 };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Step 2: Single percent-decode.
|
|
45
|
+
// Double-encoded input (%2561 → %61) stays as %61 — not decoded again.
|
|
46
|
+
let decoded: string;
|
|
47
|
+
try {
|
|
48
|
+
decoded = decodeURIComponent(rawPathname);
|
|
49
|
+
} catch {
|
|
50
|
+
// Malformed percent-encoding → 400
|
|
51
|
+
return { ok: false, status: 400 };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Reject null bytes that appeared after decoding (from valid %00-like sequences
|
|
55
|
+
// that weren't caught above — belt and suspenders).
|
|
56
|
+
if (decoded.includes('\0')) {
|
|
57
|
+
return { ok: false, status: 400 };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Backslash is NOT a path separator — keep as literal character.
|
|
61
|
+
// But reject if it would create // after normalization (e.g., /\evil.com).
|
|
62
|
+
// We do NOT convert \ to / — it stays as a literal.
|
|
63
|
+
|
|
64
|
+
// Step 3: Collapse consecutive slashes.
|
|
65
|
+
let pathname = decoded.replace(/\/\/+/g, '/');
|
|
66
|
+
|
|
67
|
+
// Step 4: Resolve .. and . segments.
|
|
68
|
+
const segments = pathname.split('/');
|
|
69
|
+
const resolved: string[] = [];
|
|
70
|
+
for (const seg of segments) {
|
|
71
|
+
if (seg === '..') {
|
|
72
|
+
if (resolved.length <= 1) {
|
|
73
|
+
// Trying to escape root — 400
|
|
74
|
+
return { ok: false, status: 400 };
|
|
75
|
+
}
|
|
76
|
+
resolved.pop();
|
|
77
|
+
} else if (seg !== '.') {
|
|
78
|
+
resolved.push(seg);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
pathname = resolved.join('/') || '/';
|
|
83
|
+
|
|
84
|
+
// Step 5: Strip trailing slash (except root "/").
|
|
85
|
+
if (stripTrailingSlash && pathname.length > 1 && pathname.endsWith('/')) {
|
|
86
|
+
pathname = pathname.slice(0, -1);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return { ok: true, pathname };
|
|
90
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client module map — maps client reference IDs to their SSR module loaders.
|
|
3
|
+
*
|
|
4
|
+
* When the RSC stream contains a client component reference (from a
|
|
5
|
+
* "use client" module), the SSR environment needs to resolve it to the
|
|
6
|
+
* actual component module so it can render the component to HTML.
|
|
7
|
+
*
|
|
8
|
+
* The @vitejs/plugin-rsc SSR runtime handles this internally via its
|
|
9
|
+
* setRequireModule hook — it imports client reference modules from the
|
|
10
|
+
* SSR environment's module graph. This module provides the configuration
|
|
11
|
+
* bridge between timber's entry system and the RSC plugin's runtime.
|
|
12
|
+
*
|
|
13
|
+
* Design docs: 18-build-system.md §"Entry Files", 02-rendering-pipeline.md
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Client reference metadata used during SSR to resolve client components.
|
|
18
|
+
*
|
|
19
|
+
* The RSC plugin tracks these internally via `clientReferenceMetaMap`.
|
|
20
|
+
* At SSR time, when `createFromReadableStream` encounters a client
|
|
21
|
+
* reference in the RSC payload, it uses the module map to import the
|
|
22
|
+
* actual component for server-side HTML rendering.
|
|
23
|
+
*/
|
|
24
|
+
export interface ClientModuleEntry {
|
|
25
|
+
/** The module ID (Vite-resolved file path) */
|
|
26
|
+
id: string;
|
|
27
|
+
/** The export name (e.g., 'default', 'Counter') */
|
|
28
|
+
name: string;
|
|
29
|
+
/** Async module loader */
|
|
30
|
+
load: () => Promise<Record<string, unknown>>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Create a client module map for SSR resolution.
|
|
35
|
+
*
|
|
36
|
+
* In dev mode, the RSC plugin's SSR runtime (`@vitejs/plugin-rsc/ssr`)
|
|
37
|
+
* handles client reference resolution automatically via its initialize()
|
|
38
|
+
* function which sets up `setRequireModule` with dynamic imports through
|
|
39
|
+
* Vite's dev server. No explicit module map is needed.
|
|
40
|
+
*
|
|
41
|
+
* In production builds, the RSC plugin generates a `virtual:vite-rsc/client-references`
|
|
42
|
+
* module that maps client reference IDs to their chunk imports. The SSR
|
|
43
|
+
* runtime reads this at startup.
|
|
44
|
+
*
|
|
45
|
+
* This function returns an empty map as a placeholder — the actual
|
|
46
|
+
* resolution is handled by the RSC plugin's runtime internals.
|
|
47
|
+
*/
|
|
48
|
+
export function createClientModuleMap(): Record<string, ClientModuleEntry> {
|
|
49
|
+
// The @vitejs/plugin-rsc SSR runtime handles client reference
|
|
50
|
+
// resolution internally. In dev mode, it uses Vite's module runner
|
|
51
|
+
// to dynamically import client modules. In production, it reads
|
|
52
|
+
// from the virtual:vite-rsc/client-references manifest.
|
|
53
|
+
//
|
|
54
|
+
// This empty map serves as the timber-side type contract. Framework
|
|
55
|
+
// code that needs to interact with client references at a higher
|
|
56
|
+
// level (e.g., collecting CSS deps, prefetch hints) can extend this.
|
|
57
|
+
return {};
|
|
58
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CSRF protection — Origin header validation.
|
|
3
|
+
*
|
|
4
|
+
* Auto-derived from the Host header for single-origin deployments.
|
|
5
|
+
* Configurable via allowedOrigins for multi-origin setups.
|
|
6
|
+
* Disable with csrf: false (not recommended outside local dev).
|
|
7
|
+
*
|
|
8
|
+
* See design/08-forms-and-actions.md §"CSRF Protection"
|
|
9
|
+
* See design/13-security.md §"Security Testing Checklist" #6
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// ─── Types ────────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
export interface CsrfConfig {
|
|
15
|
+
/** Explicit list of allowed origins. Replaces Host-based auto-derivation. */
|
|
16
|
+
allowedOrigins?: string[];
|
|
17
|
+
/** Set to false to disable CSRF validation entirely. */
|
|
18
|
+
csrf?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type CsrfResult = { ok: true } | { ok: false; status: 403 };
|
|
22
|
+
|
|
23
|
+
// ─── Constants ────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
/** HTTP methods that are considered safe (no mutation). */
|
|
26
|
+
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
|
27
|
+
|
|
28
|
+
// ─── Implementation ───────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Validate the Origin header against the request's Host.
|
|
32
|
+
*
|
|
33
|
+
* For mutation methods (POST, PUT, PATCH, DELETE):
|
|
34
|
+
* - If `csrf: false`, skip validation.
|
|
35
|
+
* - If `allowedOrigins` is set, Origin must match one exactly (no wildcards).
|
|
36
|
+
* - Otherwise, Origin's host must match the request's Host header.
|
|
37
|
+
*
|
|
38
|
+
* Safe methods (GET, HEAD, OPTIONS) always pass.
|
|
39
|
+
*/
|
|
40
|
+
export function validateCsrf(req: Request, config: CsrfConfig): CsrfResult {
|
|
41
|
+
// Safe methods don't need CSRF protection
|
|
42
|
+
if (SAFE_METHODS.has(req.method)) {
|
|
43
|
+
return { ok: true };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Explicitly disabled
|
|
47
|
+
if (config.csrf === false) {
|
|
48
|
+
return { ok: true };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const origin = req.headers.get('Origin');
|
|
52
|
+
|
|
53
|
+
// No Origin header on a mutation → reject
|
|
54
|
+
if (!origin) {
|
|
55
|
+
return { ok: false, status: 403 };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// If allowedOrigins is configured, use that instead of Host-based derivation
|
|
59
|
+
if (config.allowedOrigins) {
|
|
60
|
+
const allowed = config.allowedOrigins.includes(origin);
|
|
61
|
+
return allowed ? { ok: true } : { ok: false, status: 403 };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Auto-derive from Host header
|
|
65
|
+
const host = req.headers.get('Host');
|
|
66
|
+
if (!host) {
|
|
67
|
+
return { ok: false, status: 403 };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Extract hostname from Origin URL and compare to Host header
|
|
71
|
+
let originHost: string;
|
|
72
|
+
try {
|
|
73
|
+
originHost = new URL(origin).host;
|
|
74
|
+
} catch {
|
|
75
|
+
return { ok: false, status: 403 };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return originHost === host ? { ok: true } : { ok: false, status: 403 };
|
|
79
|
+
}
|