@timber-js/app 0.2.0-alpha.145 → 0.2.0-alpha.146
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/_chunks/{actions-DzFMnUAR.js → actions-OIZ8siQu.js} +28 -43
- package/dist/_chunks/actions-OIZ8siQu.js.map +1 -0
- package/dist/_chunks/{handler-store-DjISKYEB.js → handler-store-BVDgyTh7.js} +26 -2
- package/dist/_chunks/handler-store-BVDgyTh7.js.map +1 -0
- package/dist/_chunks/{href-validation-EZktELp8.js → href-validation-DyZIzfMJ.js} +18 -2
- package/dist/_chunks/{href-validation-EZktELp8.js.map → href-validation-DyZIzfMJ.js.map} +1 -1
- package/dist/_chunks/{navigation-root-DLc4s4ZC.js → navigation-root-bUDZLw74.js} +23 -2
- package/dist/_chunks/navigation-root-bUDZLw74.js.map +1 -0
- package/dist/_chunks/tracing-M_zKubhk.js.map +1 -1
- package/dist/adapters/cloudflare.d.ts +1 -15
- package/dist/adapters/cloudflare.d.ts.map +1 -1
- package/dist/adapters/cloudflare.js +10 -28
- package/dist/adapters/cloudflare.js.map +1 -1
- package/dist/adapters/compress-module.d.ts.map +1 -1
- package/dist/adapters/nitro.d.ts.map +1 -1
- package/dist/adapters/nitro.js +18 -5
- package/dist/adapters/nitro.js.map +1 -1
- package/dist/cache/index.js +39 -12
- package/dist/cache/index.js.map +1 -1
- package/dist/cache/redis-handler.d.ts +72 -8
- package/dist/cache/redis-handler.d.ts.map +1 -1
- package/dist/cache/stable-stringify.d.ts +9 -0
- package/dist/cache/stable-stringify.d.ts.map +1 -1
- package/dist/cache/timber-cache.d.ts +3 -1
- package/dist/cache/timber-cache.d.ts.map +1 -1
- package/dist/client/browser-entry/action-dispatch.d.ts.map +1 -1
- package/dist/client/browser-entry/router-init.d.ts.map +1 -1
- package/dist/client/browser-entry/scroll.d.ts +9 -0
- package/dist/client/browser-entry/scroll.d.ts.map +1 -1
- package/dist/client/error-boundary.d.ts.map +1 -1
- package/dist/client/error-boundary.js +11 -6
- package/dist/client/error-boundary.js.map +1 -1
- package/dist/client/index.js +3 -3
- package/dist/client/index.js.map +1 -1
- package/dist/client/internal.js +99 -24
- package/dist/client/internal.js.map +1 -1
- package/dist/client/link.d.ts.map +1 -1
- package/dist/client/navigation-api.d.ts.map +1 -1
- package/dist/client/navigation-root.d.ts +13 -0
- package/dist/client/navigation-root.d.ts.map +1 -1
- package/dist/client/router.d.ts +7 -0
- package/dist/client/router.d.ts.map +1 -1
- package/dist/client/rsc-fetch.d.ts.map +1 -1
- package/dist/dev-tools/404-page.d.ts +6 -2
- package/dist/dev-tools/404-page.d.ts.map +1 -1
- package/dist/dev-tools/dev-page-shell.d.ts +77 -0
- package/dist/dev-tools/dev-page-shell.d.ts.map +1 -0
- package/dist/dev-tools/error-page.d.ts +4 -30
- package/dist/dev-tools/error-page.d.ts.map +1 -1
- package/dist/dev-tools/index.d.ts +2 -1
- package/dist/dev-tools/index.d.ts.map +1 -1
- package/dist/dev-tools/instrumentation.d.ts +11 -1
- package/dist/dev-tools/instrumentation.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +430 -253
- package/dist/index.js.map +1 -1
- package/dist/plugins/cache.d.ts +43 -9
- package/dist/plugins/cache.d.ts.map +1 -1
- package/dist/plugins/cloudflare-wasm.d.ts +4 -1
- package/dist/plugins/cloudflare-wasm.d.ts.map +1 -1
- package/dist/plugins/dev-server.d.ts +16 -0
- package/dist/plugins/dev-server.d.ts.map +1 -1
- package/dist/plugins/server-bundle.d.ts.map +1 -1
- package/dist/plugins/shims.d.ts +23 -0
- package/dist/plugins/shims.d.ts.map +1 -1
- package/dist/server/action-handler.d.ts.map +1 -1
- package/dist/server/actions.d.ts +3 -2
- package/dist/server/actions.d.ts.map +1 -1
- package/dist/server/cookie-context.d.ts.map +1 -1
- package/dist/server/cookie-parsing.d.ts.map +1 -1
- package/dist/server/fallback-error.d.ts +2 -2
- package/dist/server/fallback-error.d.ts.map +1 -1
- package/dist/server/index.js +1 -1
- package/dist/server/internal.js +1 -1
- package/dist/server/internal.js.map +1 -1
- package/dist/server/node-stream-transforms.d.ts +24 -6
- package/dist/server/node-stream-transforms.d.ts.map +1 -1
- package/dist/server/pipeline.d.ts +11 -0
- package/dist/server/pipeline.d.ts.map +1 -1
- package/dist/server/primitives.d.ts +1 -11
- package/dist/server/primitives.d.ts.map +1 -1
- package/dist/server/rsc-entry/index.d.ts.map +1 -1
- package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
- package/dist/server/slot-resolver.d.ts +6 -4
- package/dist/server/slot-resolver.d.ts.map +1 -1
- package/dist/server/ssr-entry.d.ts.map +1 -1
- package/dist/server/tracing.d.ts +2 -1
- package/dist/server/tracing.d.ts.map +1 -1
- package/dist/shared/head-header.d.ts +54 -0
- package/dist/shared/head-header.d.ts.map +1 -0
- package/dist/shared/href-validation.d.ts +11 -0
- package/dist/shared/href-validation.d.ts.map +1 -1
- package/dist/shared/redirect-type.d.ts +17 -0
- package/dist/shared/redirect-type.d.ts.map +1 -0
- package/dist/shims/headers.d.ts +52 -1
- package/dist/shims/headers.d.ts.map +1 -1
- package/dist/shims/navigation-client.d.ts +33 -11
- package/dist/shims/navigation-client.d.ts.map +1 -1
- package/package.json +10 -9
- package/src/adapters/cloudflare.ts +13 -47
- package/src/adapters/compress-module.ts +9 -3
- package/src/adapters/nitro.ts +9 -2
- package/src/cache/redis-handler.ts +77 -13
- package/src/cache/stable-stringify.ts +65 -2
- package/src/cache/timber-cache.ts +25 -13
- package/src/cli.ts +0 -0
- package/src/client/browser-entry/action-dispatch.ts +7 -2
- package/src/client/browser-entry/router-init.ts +2 -1
- package/src/client/browser-entry/scroll.ts +23 -0
- package/src/client/error-boundary.tsx +19 -5
- package/src/client/link.tsx +4 -1
- package/src/client/navigation-api.ts +5 -1
- package/src/client/navigation-root.tsx +16 -0
- package/src/client/router.ts +107 -29
- package/src/client/rsc-fetch.ts +6 -6
- package/src/dev-tools/404-page.ts +22 -123
- package/src/dev-tools/dev-page-shell.ts +256 -0
- package/src/dev-tools/error-page.ts +27 -203
- package/src/dev-tools/index.ts +7 -1
- package/src/dev-tools/instrumentation.ts +39 -2
- package/src/index.ts +1 -0
- package/src/plugins/cache.ts +79 -21
- package/src/plugins/cloudflare-wasm.ts +48 -10
- package/src/plugins/dev-server.ts +32 -8
- package/src/plugins/server-bundle.ts +25 -9
- package/src/plugins/shims.ts +94 -52
- package/src/server/action-handler.ts +10 -2
- package/src/server/actions.ts +3 -2
- package/src/server/compress.ts +11 -2
- package/src/server/cookie-context.ts +11 -0
- package/src/server/cookie-parsing.ts +16 -4
- package/src/server/fallback-error.ts +9 -4
- package/src/server/node-stream-transforms.ts +61 -24
- package/src/server/pipeline.ts +12 -0
- package/src/server/primitives.ts +10 -31
- package/src/server/rsc-entry/index.ts +7 -2
- package/src/server/rsc-entry/rsc-payload.ts +4 -3
- package/src/server/slot-resolver.ts +27 -14
- package/src/server/ssr-entry.ts +17 -28
- package/src/server/tracing.ts +17 -1
- package/src/shared/head-header.ts +69 -0
- package/src/shared/href-validation.ts +24 -0
- package/src/shared/redirect-type.ts +17 -0
- package/src/shims/headers.ts +119 -2
- package/src/shims/navigation-client.ts +48 -27
- package/LICENSE +0 -8
- package/dist/_chunks/actions-DzFMnUAR.js.map +0 -1
- package/dist/_chunks/handler-store-DjISKYEB.js.map +0 -1
- package/dist/_chunks/navigation-root-DLc4s4ZC.js.map +0 -1
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
} from './sensitive-fields.js';
|
|
36
36
|
import type { FormFlashData } from './form-flash.js';
|
|
37
37
|
import { checkVersionSkew, applyReloadHeaders } from './version-skew.js';
|
|
38
|
+
import { encodeHeadHeader, HEAD_HEADER } from '../shared/head-header.js';
|
|
38
39
|
import { logActionError } from './logger.js';
|
|
39
40
|
|
|
40
41
|
// ─── Types ────────────────────────────────────────────────────────────────
|
|
@@ -299,9 +300,16 @@ async function handleRscAction(
|
|
|
299
300
|
_tree: result.revalidation.element,
|
|
300
301
|
};
|
|
301
302
|
headers['X-Timber-Revalidation'] = '1';
|
|
302
|
-
// Forward head elements
|
|
303
|
+
// Forward head elements (percent-encoded JSON) so the client can update
|
|
304
|
+
// <head>. Raw JSON.stringify is NOT safe here: header values must be
|
|
305
|
+
// ByteStrings, so a title with an em dash or emoji would make
|
|
306
|
+
// `new Response(...)` throw — a 500 after the action's mutation already
|
|
307
|
+
// committed, dropping Set-Cookie too. See TIM-1058.
|
|
303
308
|
if (result.revalidation.headElements.length > 0) {
|
|
304
|
-
|
|
309
|
+
const encodedHead = encodeHeadHeader(result.revalidation.headElements);
|
|
310
|
+
if (encodedHead !== null) {
|
|
311
|
+
headers[HEAD_HEADER] = encodedHead;
|
|
312
|
+
}
|
|
305
313
|
}
|
|
306
314
|
} else {
|
|
307
315
|
payload = result.actionResult;
|
package/src/server/actions.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { getCacheHandler } from '../cache/handler-store';
|
|
|
18
18
|
import { DenySignal, RedirectSignal } from './primitives';
|
|
19
19
|
import { withSpan } from './tracing';
|
|
20
20
|
import { revalidationAls, type RevalidationState } from './als-registry.js';
|
|
21
|
+
import type { HeadElementPayload } from '../shared/head-header.js';
|
|
21
22
|
|
|
22
23
|
// ─── Types ───────────────────────────────────────────────────────────────
|
|
23
24
|
|
|
@@ -25,8 +26,8 @@ import { revalidationAls, type RevalidationState } from './als-registry.js';
|
|
|
25
26
|
export interface RevalidationResult {
|
|
26
27
|
/** React element tree (pre-serialization — passed to renderToReadableStream). */
|
|
27
28
|
element: unknown;
|
|
28
|
-
/** Resolved head elements for metadata. */
|
|
29
|
-
headElements:
|
|
29
|
+
/** Resolved head elements for metadata (forwarded via X-Timber-Head). */
|
|
30
|
+
headElements: HeadElementPayload[];
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
/** Renderer function that builds a React element tree for a given path. */
|
package/src/server/compress.ts
CHANGED
|
@@ -174,7 +174,7 @@ export function compressResponse(request: Request, response: Response): Response
|
|
|
174
174
|
// progressively.
|
|
175
175
|
|
|
176
176
|
import { createGzip, constants } from 'node:zlib';
|
|
177
|
-
import { Readable } from 'node:stream';
|
|
177
|
+
import { Readable, pipeline } from 'node:stream';
|
|
178
178
|
|
|
179
179
|
/**
|
|
180
180
|
* Compress a ReadableStream with gzip, flushing each chunk immediately.
|
|
@@ -186,7 +186,16 @@ import { Readable } from 'node:stream';
|
|
|
186
186
|
function compressWithGzip(body: ReadableStream<Uint8Array>): ReadableStream<Uint8Array> {
|
|
187
187
|
const gzip = createGzip({ flush: constants.Z_SYNC_FLUSH });
|
|
188
188
|
const nodeInput = Readable.fromWeb(body as import('stream/web').ReadableStream);
|
|
189
|
-
|
|
189
|
+
|
|
190
|
+
// pipeline() instead of bare pipe(): pipe() does not forward source errors,
|
|
191
|
+
// so an erroring body (streaming route handler, dropped upstream fetch)
|
|
192
|
+
// would emit an unhandled 'error' event and crash the process. pipeline()
|
|
193
|
+
// destroys gzip on source error — the returned web stream rejects — and
|
|
194
|
+
// tears down nodeInput when the consumer cancels the web stream, which
|
|
195
|
+
// cancels the source body via fromWeb. The error reaches the consumer
|
|
196
|
+
// through the web stream; the callback only marks it handled at the Node
|
|
197
|
+
// layer.
|
|
198
|
+
pipeline(nodeInput, gzip, () => {});
|
|
190
199
|
|
|
191
200
|
return Readable.toWeb(gzip) as ReadableStream<Uint8Array>;
|
|
192
201
|
}
|
|
@@ -195,6 +195,11 @@ export function getCookieJar(): RequestCookies {
|
|
|
195
195
|
maxAge: 0,
|
|
196
196
|
expires: new Date(0),
|
|
197
197
|
};
|
|
198
|
+
// delete() is a jar entry point like set() — caller-supplied
|
|
199
|
+
// path/domain are serialized verbatim into the Set-Cookie header,
|
|
200
|
+
// so they must pass the same attribute-injection guard. See
|
|
201
|
+
// design/29-cookies.md §"Cookie Attribute Validation" (TIM-1026).
|
|
202
|
+
assertValidCookieOptions(opts);
|
|
198
203
|
store.cookieJar.set(name, { name, value: '', options: opts });
|
|
199
204
|
// Remove from read view
|
|
200
205
|
map.delete(name);
|
|
@@ -460,6 +465,12 @@ function setRaw(
|
|
|
460
465
|
// the cookie jar / RYW map.
|
|
461
466
|
assertValidCookieName(name);
|
|
462
467
|
assertValidCookieValue(name, value);
|
|
468
|
+
// Options get the same entry-point guard as set()/delete(). For the
|
|
469
|
+
// setFromHeaders path this can only fire on attributes parseSetCookie
|
|
470
|
+
// cannot produce (it splits on `;` and normalizes Max-Age/SameSite),
|
|
471
|
+
// but setRaw is the jar entry point — the invariant lives here, not in
|
|
472
|
+
// the caller. See design/29-cookies.md §"Cookie Attribute Validation".
|
|
473
|
+
assertValidCookieOptions(options);
|
|
463
474
|
store.cookieJar.set(name, { name, value, options });
|
|
464
475
|
// Deletion cookies (Max-Age=0) should not appear in the read map.
|
|
465
476
|
if (options.maxAge === 0) {
|
|
@@ -110,15 +110,27 @@ export function parseSetCookie(
|
|
|
110
110
|
case 'domain':
|
|
111
111
|
options.domain = val;
|
|
112
112
|
break;
|
|
113
|
-
case 'max-age':
|
|
114
|
-
|
|
113
|
+
case 'max-age': {
|
|
114
|
+
// RFC 6265 §5.2.2: a non-numeric Max-Age is ignored, not fatal.
|
|
115
|
+
// Dropping NaN/Infinity here keeps this parser total and keeps
|
|
116
|
+
// `Max-Age=NaN` out of the serialized Set-Cookie header.
|
|
117
|
+
const maxAge = Number(val);
|
|
118
|
+
if (Number.isFinite(maxAge)) options.maxAge = maxAge;
|
|
115
119
|
break;
|
|
120
|
+
}
|
|
116
121
|
case 'expires':
|
|
117
122
|
options.expires = new Date(val);
|
|
118
123
|
break;
|
|
119
|
-
case 'samesite':
|
|
120
|
-
|
|
124
|
+
case 'samesite': {
|
|
125
|
+
// Unknown SameSite values are ignored (browsers treat them as
|
|
126
|
+
// unspecified) so forwarding a quirky upstream header via
|
|
127
|
+
// setFromHeaders never trips the entry-point validator.
|
|
128
|
+
const sameSite = val.toLowerCase();
|
|
129
|
+
if (sameSite === 'strict' || sameSite === 'lax' || sameSite === 'none') {
|
|
130
|
+
options.sameSite = sameSite;
|
|
131
|
+
}
|
|
121
132
|
break;
|
|
133
|
+
}
|
|
122
134
|
case 'secure':
|
|
123
135
|
options.secure = true;
|
|
124
136
|
break;
|
|
@@ -34,14 +34,15 @@ export async function renderFallbackError(
|
|
|
34
34
|
rootSegment: ManifestSegmentNode,
|
|
35
35
|
clientBootstrap: ClientBootstrapConfig,
|
|
36
36
|
globalError?: GlobalErrorFile,
|
|
37
|
-
projectRoot?: string
|
|
37
|
+
projectRoot?: string,
|
|
38
|
+
hmrOptions?: import('../dev-tools/dev-page-shell.js').DevErrorHmrOptions
|
|
38
39
|
): Promise<Response> {
|
|
39
40
|
// Source-map the error's stack trace in dev mode so the error page
|
|
40
41
|
// shows original source positions. See TIM-811.
|
|
41
42
|
sourceMapError(error);
|
|
42
43
|
|
|
43
44
|
if (isDev) {
|
|
44
|
-
return renderDevErrorPage(error, projectRoot);
|
|
45
|
+
return renderDevErrorPage(error, projectRoot, hmrOptions);
|
|
45
46
|
}
|
|
46
47
|
// Lazy import to avoid loading error-renderer in the pipeline module
|
|
47
48
|
const { renderErrorPage } = await import('./rsc-entry/error-renderer.js');
|
|
@@ -91,7 +92,11 @@ export async function renderFallbackError(
|
|
|
91
92
|
*
|
|
92
93
|
* Dev-only — the dynamic import has zero production cost.
|
|
93
94
|
*/
|
|
94
|
-
export async function renderDevErrorPage(
|
|
95
|
+
export async function renderDevErrorPage(
|
|
96
|
+
error: unknown,
|
|
97
|
+
projectRoot?: string,
|
|
98
|
+
hmrOptions?: import('../dev-tools/dev-page-shell.js').DevErrorHmrOptions
|
|
99
|
+
): Promise<Response> {
|
|
95
100
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
96
101
|
const root = projectRoot ?? process.cwd();
|
|
97
102
|
|
|
@@ -100,7 +105,7 @@ export async function renderDevErrorPage(error: unknown, projectRoot?: string):
|
|
|
100
105
|
// Dynamic import — keeps dev-error-page.ts and its transitive deps
|
|
101
106
|
// (dev-error-overlay.ts, @jridgewell/trace-mapping) out of production bundles.
|
|
102
107
|
const { generateDevErrorPage } = await import('../dev-tools/error-page.js');
|
|
103
|
-
html = generateDevErrorPage(err, 'render', root);
|
|
108
|
+
html = generateDevErrorPage(err, 'render', root, hmrOptions);
|
|
104
109
|
// Inject Vite client script so the error overlay fires when HMR connects.
|
|
105
110
|
html = html.replace(
|
|
106
111
|
'</head>',
|
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
* Readable.toWeb() conversion for the Response body.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import { Transform } from 'node:stream';
|
|
21
|
+
import { PassThrough, Transform, type Readable } from 'node:stream';
|
|
22
|
+
import { pipeline } from 'node:stream/promises';
|
|
22
23
|
import { createGzip, constants } from 'node:zlib';
|
|
23
24
|
|
|
24
25
|
import {
|
|
@@ -265,37 +266,73 @@ export function createNodeFlightInjector(
|
|
|
265
266
|
const NOINDEX_SCRIPT =
|
|
266
267
|
'<script>document.head.appendChild(Object.assign(document.createElement("meta"),{name:"robots",content:"noindex"}))</script>';
|
|
267
268
|
|
|
269
|
+
function isAbortError(error: unknown): boolean {
|
|
270
|
+
if (error instanceof DOMException && error.name === 'AbortError') return true;
|
|
271
|
+
if (error instanceof Error && error.name === 'AbortError') return true;
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
274
|
+
|
|
268
275
|
/**
|
|
269
|
-
* Node
|
|
276
|
+
* Run the Node SSR post-processing pipeline with post-shell error handling.
|
|
277
|
+
*
|
|
278
|
+
* Equivalent to wrapStreamWithErrorHandling() in ssr-render.ts: when the
|
|
279
|
+
* stream errors after the shell has flushed (deny/throw inside Suspense,
|
|
280
|
+
* an RSC read timeout in the flight injector, or an upstream Fizz error),
|
|
281
|
+
* the response closes cleanly with a trailing noindex script instead of
|
|
282
|
+
* aborting the connection mid-body. Abort errors (client disconnect,
|
|
283
|
+
* render-timeout abort) close the output silently — no noindex, no log.
|
|
270
284
|
*
|
|
271
|
-
*
|
|
272
|
-
*
|
|
273
|
-
*
|
|
285
|
+
* Why this is a pump function and not a Transform stage (TIM-1019):
|
|
286
|
+
* stream.promises.pipeline() destroys every stage in the chain when any
|
|
287
|
+
* stage errors, so a Transform *inside* the pipeline can never append
|
|
288
|
+
* bytes after a failure — by the time its 'error' listener fires, the
|
|
289
|
+
* transform and everything downstream are already destroyed and push()/
|
|
290
|
+
* end() are no-ops. The output PassThrough therefore lives OUTSIDE the
|
|
291
|
+
* pipeline() call: the last stage pipes into it (pipe() ends the output
|
|
292
|
+
* on upstream success but leaves it open on upstream error), and the
|
|
293
|
+
* pipeline's rejection handler appends the noindex script.
|
|
294
|
+
*
|
|
295
|
+
* Consumer cancellation (e.g. Readable.toWeb() cancel on client
|
|
296
|
+
* disconnect) is wired back into the pipeline via an AbortSignal so the
|
|
297
|
+
* source and all stages are torn down — the teardown that membership in
|
|
298
|
+
* pipeline() used to provide.
|
|
274
299
|
*/
|
|
275
|
-
export function
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
300
|
+
export function runNodePipelineWithErrorHandling(
|
|
301
|
+
source: Readable,
|
|
302
|
+
transforms: readonly [Transform, ...Transform[]],
|
|
303
|
+
signal?: AbortSignal
|
|
304
|
+
): PassThrough {
|
|
305
|
+
const output = new PassThrough();
|
|
306
|
+
const last = transforms[transforms.length - 1]!;
|
|
307
|
+
last.pipe(output);
|
|
308
|
+
|
|
309
|
+
// If the consumer destroys the output (client disconnect), abort the
|
|
310
|
+
// pipeline so the Fizz source and flight injector release their
|
|
311
|
+
// resources. On normal completion the pipeline has already settled and
|
|
312
|
+
// the abort is a no-op.
|
|
313
|
+
const consumerGone = new AbortController();
|
|
314
|
+
output.once('close', () => consumerGone.abort());
|
|
315
|
+
|
|
316
|
+
// The cast satisfies pipeline()'s [source, ...transforms, destination]
|
|
317
|
+
// tuple overload — TS can't prove the shape across a spread, but the
|
|
318
|
+
// signature above guarantees a non-empty transform list at compile time.
|
|
319
|
+
const stages = [source, ...transforms] as unknown as readonly [
|
|
320
|
+
Readable,
|
|
321
|
+
...Transform[],
|
|
322
|
+
Transform,
|
|
323
|
+
];
|
|
324
|
+
|
|
325
|
+
pipeline(stages, { signal: consumerGone.signal }).catch((error: unknown) => {
|
|
326
|
+
if (output.writableEnded || output.destroyed) return;
|
|
327
|
+
if (isAbortError(error) || signal?.aborted || consumerGone.signal.aborted) {
|
|
328
|
+
output.end();
|
|
290
329
|
return;
|
|
291
330
|
}
|
|
292
|
-
|
|
293
331
|
logStreamingError({ error });
|
|
294
|
-
|
|
295
|
-
transform.end();
|
|
332
|
+
output.end(Buffer.from(NOINDEX_SCRIPT, 'utf-8'));
|
|
296
333
|
});
|
|
297
334
|
|
|
298
|
-
return
|
|
335
|
+
return output;
|
|
299
336
|
}
|
|
300
337
|
|
|
301
338
|
// ─── Compression ─────────────────────────────────────────────────────────────
|
package/src/server/pipeline.ts
CHANGED
|
@@ -187,6 +187,18 @@ export interface PipelineConfig {
|
|
|
187
187
|
debugComponents?: Array<{ name: string; env: string | null; stack: unknown[] | null }>
|
|
188
188
|
) => void;
|
|
189
189
|
|
|
190
|
+
/**
|
|
191
|
+
* HMR connection options for dev-only HTML pages (dev 404 page, fallback
|
|
192
|
+
* error page) — set by the dev server after the RSC entry module is
|
|
193
|
+
* imported, like `devPipelineErrorHandler`. The RSC environment can't
|
|
194
|
+
* read the Vite server config (separate module graph), so the dev server
|
|
195
|
+
* plumbs the resolved HMR endpoint (protocol/host/port/path/token) here
|
|
196
|
+
* for the pages' auto-reload WebSocket. See TIM-1067.
|
|
197
|
+
*
|
|
198
|
+
* Undefined in production — zero overhead.
|
|
199
|
+
*/
|
|
200
|
+
devHmrOptions?: import('../dev-tools/dev-page-shell.js').DevErrorHmrOptions;
|
|
201
|
+
|
|
190
202
|
/**
|
|
191
203
|
* Fallback error renderer — called when a catastrophic error escapes the
|
|
192
204
|
* render phase. Produces an HTML Response instead of a bare empty 500.
|
package/src/server/primitives.ts
CHANGED
|
@@ -8,7 +8,10 @@ import { getWaitUntil as _getWaitUntil } from './waituntil-bridge.js';
|
|
|
8
8
|
import { isDebug } from './debug.js';
|
|
9
9
|
import { getRequestSearchString } from './request-context.js';
|
|
10
10
|
import { mergePreservedSearchParams } from '../shared/merge-search-params.js';
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
assertRelativeRedirectPath,
|
|
13
|
+
validateExternalRedirectUrl,
|
|
14
|
+
} from '../shared/href-validation.js';
|
|
12
15
|
|
|
13
16
|
// ─── Dev-mode validation ────────────────────────────────────────────────────
|
|
14
17
|
|
|
@@ -207,17 +210,9 @@ export function notFound(): never {
|
|
|
207
210
|
deny(404);
|
|
208
211
|
}
|
|
209
212
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
* Provided for API compatibility with libraries that import `RedirectType`
|
|
214
|
-
* from `next/navigation`. In timber, `redirect()` always uses `replace`
|
|
215
|
-
* semantics (no history entry for the redirect itself).
|
|
216
|
-
*/
|
|
217
|
-
export const RedirectType = {
|
|
218
|
-
push: 'push',
|
|
219
|
-
replace: 'replace',
|
|
220
|
-
} as const;
|
|
213
|
+
// Single source of truth shared with the client next/navigation shim —
|
|
214
|
+
// see shared/redirect-type.ts.
|
|
215
|
+
export { RedirectType } from '../shared/redirect-type.js';
|
|
221
216
|
|
|
222
217
|
// ─── RedirectSignal ─────────────────────────────────────────────────────────
|
|
223
218
|
|
|
@@ -237,12 +232,6 @@ export class RedirectSignal extends Error {
|
|
|
237
232
|
}
|
|
238
233
|
}
|
|
239
234
|
|
|
240
|
-
/** Pattern matching absolute URLs: http(s):// or protocol-relative // */
|
|
241
|
-
const ABSOLUTE_URL_RE = /^(?:[a-zA-Z][a-zA-Z\d+\-.]*:|\/\/)/;
|
|
242
|
-
|
|
243
|
-
// eslint-disable-next-line no-control-regex -- must match all C0 controls to prevent bypass
|
|
244
|
-
const REDIRECT_CONTROL_CHARS_RE = /[\x00-\x1f]/g;
|
|
245
|
-
|
|
246
235
|
/**
|
|
247
236
|
* Options for redirect() — alternative to passing a bare status code.
|
|
248
237
|
*/
|
|
@@ -303,19 +292,9 @@ export function redirect(path: string, statusOrOptions?: number | RedirectOption
|
|
|
303
292
|
if (status < 300 || status > 399) {
|
|
304
293
|
throw new Error(`redirect() requires a 3xx status code, got ${status}.`);
|
|
305
294
|
}
|
|
306
|
-
//
|
|
307
|
-
//
|
|
308
|
-
|
|
309
|
-
// with forward slashes. Browsers normalize \ → / during URL resolution, so
|
|
310
|
-
// "/\evil.com" becomes "//evil.com" (protocol-relative open redirect).
|
|
311
|
-
const cleaned = path.replace(REDIRECT_CONTROL_CHARS_RE, '').trimStart().replaceAll('\\', '/');
|
|
312
|
-
|
|
313
|
-
if (ABSOLUTE_URL_RE.test(cleaned)) {
|
|
314
|
-
throw new Error(
|
|
315
|
-
`redirect() only accepts relative URLs. Got absolute URL: "${path}". ` +
|
|
316
|
-
'Use redirectExternal(url, allowList) for external redirects.'
|
|
317
|
-
);
|
|
318
|
-
}
|
|
295
|
+
// Relative-only validation shared with the client next/navigation shim —
|
|
296
|
+
// strips C0 controls and normalizes backslashes before the scheme check.
|
|
297
|
+
assertRelativeRedirectPath(path);
|
|
319
298
|
|
|
320
299
|
let resolvedPath = path;
|
|
321
300
|
if (preserveSearchParams) {
|
|
@@ -268,7 +268,9 @@ async function createRequestHandler(manifest: typeof routeManifest, runtimeConfi
|
|
|
268
268
|
const { generateDev404Page, collectRoutes } = await import('../../dev-tools/404-page.js');
|
|
269
269
|
const routes = collectRoutes(manifest.root);
|
|
270
270
|
const pathname = new URL(req.url).pathname;
|
|
271
|
-
|
|
271
|
+
// devHmrOptions is set by the dev server after this module is
|
|
272
|
+
// imported (read at request time, not closure-capture time).
|
|
273
|
+
const html = generateDev404Page(pathname, routes, pipelineConfig.devHmrOptions);
|
|
272
274
|
return new Response(html, {
|
|
273
275
|
status: 404,
|
|
274
276
|
headers: {
|
|
@@ -316,7 +318,10 @@ async function createRequestHandler(manifest: typeof routeManifest, runtimeConfi
|
|
|
316
318
|
// (TIM-807). Passing manifest.root (segment tree object) here would
|
|
317
319
|
// stringify to "[object Object]" and misclassify app frames as
|
|
318
320
|
// internal, dropping source locations from the dev error page (TIM-808).
|
|
319
|
-
isDev ? manifest.viteRoot : undefined
|
|
321
|
+
isDev ? manifest.viteRoot : undefined,
|
|
322
|
+
// HMR options for the dev error page's auto-reload WebSocket —
|
|
323
|
+
// set by the dev server after import, read at request time.
|
|
324
|
+
isDev ? pipelineConfig.devHmrOptions : undefined
|
|
320
325
|
),
|
|
321
326
|
renderDenyFallback: async (deny, req, responseHeaders, matchedRoute) => {
|
|
322
327
|
// Render the deny page (403.tsx, 404.tsx, etc.) for DenySignals
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import type { LayoutEntry } from '../deny-renderer.js';
|
|
13
13
|
import { renderDenyPageAsRsc } from '../deny-renderer.js';
|
|
14
14
|
import type { RouteMatch } from '../pipeline.js';
|
|
15
|
+
import { encodeHeadHeader, HEAD_HEADER } from '../../shared/head-header.js';
|
|
15
16
|
import type { RedirectSignal } from '../primitives.js';
|
|
16
17
|
import type { HeadElement, LayoutComponentEntry } from '../route-element-builder.js';
|
|
17
18
|
import type { ManifestSegmentNode } from '../route-matcher.js';
|
|
@@ -175,9 +176,9 @@ export async function buildRscPayloadResponse(
|
|
|
175
176
|
|
|
176
177
|
// Send resolved head elements so the client can update document.title
|
|
177
178
|
// and <meta> tags after SPA navigation. See design/16-metadata.md.
|
|
178
|
-
const
|
|
179
|
-
if (
|
|
180
|
-
responseHeaders.set(
|
|
179
|
+
const encodedHead = encodeHeadHeader(headElements);
|
|
180
|
+
if (encodedHead !== null) {
|
|
181
|
+
responseHeaders.set(HEAD_HEADER, encodedHead);
|
|
181
182
|
}
|
|
182
183
|
|
|
183
184
|
// Send segment metadata so the client can populate its segment cache
|
|
@@ -68,10 +68,12 @@ async function renderDefaultFallback(
|
|
|
68
68
|
* Finds a matching page in the slot's sub-tree for the current route.
|
|
69
69
|
* Falls back to default.tsx if no match, or null if no default.
|
|
70
70
|
*
|
|
71
|
-
* When a match is found, the element is wrapped
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
71
|
+
* When a match is found, the element is wrapped per segment in the slot's
|
|
72
|
+
* matched chain with (innermost → outermost):
|
|
73
|
+
* 1. Error boundaries
|
|
74
|
+
* 2. Layout
|
|
75
|
+
* 3. SlotAccessGate if the segment has access.ts (gate OUTSIDE the layout,
|
|
76
|
+
* so a denied layout never executes — see TIM-1074)
|
|
75
77
|
*/
|
|
76
78
|
export async function resolveSlotElement(
|
|
77
79
|
slotNode: ManifestSegmentNode,
|
|
@@ -155,20 +157,25 @@ export async function resolveSlotElement(
|
|
|
155
157
|
element = h(SafeSlotPage, {});
|
|
156
158
|
}
|
|
157
159
|
|
|
158
|
-
// Wrap with error boundaries
|
|
159
|
-
// (everything between slot root and leaf).
|
|
160
|
-
// order as route-element-builder.ts
|
|
161
|
-
// (index 0) is handled
|
|
160
|
+
// Wrap with error boundaries, layouts, and access gates from
|
|
161
|
+
// intermediate slot segments (everything between slot root and leaf).
|
|
162
|
+
// Process innermost-first, same order as route-element-builder.ts
|
|
163
|
+
// handles main segments. The slot root (index 0) is handled below.
|
|
162
164
|
element = await wrapWithIntermediateSegments(slotMatch.chain, element, h);
|
|
163
165
|
|
|
164
|
-
// Wrap
|
|
165
|
-
//
|
|
166
|
+
// Wrap with slot root's layout — INSIDE the access gate, so the layout
|
|
167
|
+
// server component never executes when access.ts denies. See TIM-1074.
|
|
168
|
+
element = await wrapWithLayout(slotNode, element, h);
|
|
169
|
+
|
|
170
|
+
// Wrap in SlotAccessGate if slot root has access.ts. The gate is
|
|
171
|
+
// OUTSIDE the layout, matching the segment model in
|
|
172
|
+
// route-element-builder.ts: a denied layout must not render, or it
|
|
173
|
+
// would fetch/serialize protected data into the RSC Flight stream.
|
|
174
|
+
// On denial: denied.tsx → default.tsx → null (graceful degradation),
|
|
175
|
+
// rendered WITHOUT the denied slot's own layout.
|
|
166
176
|
// See design/04-authorization.md §"Slot-Level Auth".
|
|
167
177
|
element = await wrapWithAccessGate(slotNode, element, h);
|
|
168
178
|
|
|
169
|
-
// Wrap with slot root's layout (outermost, outside access gate)
|
|
170
|
-
element = await wrapWithLayout(slotNode, element, h);
|
|
171
|
-
|
|
172
179
|
// Wrap with slot root's error boundaries (outermost)
|
|
173
180
|
element = await wrapSegmentWithErrorBoundaries(slotNode, element, h);
|
|
174
181
|
|
|
@@ -204,6 +211,12 @@ export async function resolveSlotElement(
|
|
|
204
211
|
* not just the slot root. Without this, nested slot children (e.g.,
|
|
205
212
|
* @admin/reports/access.ts) and intercepting route children with access.ts
|
|
206
213
|
* would bypass authorization. See TIM-999.
|
|
214
|
+
*
|
|
215
|
+
* Wrap order per segment (innermost → outermost): error boundaries → layout
|
|
216
|
+
* → access gate. The gate must be OUTSIDE the layout so a denying access.ts
|
|
217
|
+
* prevents the layout server component from executing — otherwise the layout
|
|
218
|
+
* would fetch/serialize protected data into the RSC Flight stream. Matches
|
|
219
|
+
* route-element-builder.ts. See TIM-1074.
|
|
207
220
|
*/
|
|
208
221
|
async function wrapWithIntermediateSegments(
|
|
209
222
|
chain: ManifestSegmentNode[],
|
|
@@ -213,8 +226,8 @@ async function wrapWithIntermediateSegments(
|
|
|
213
226
|
for (let i = chain.length - 1; i > 0; i--) {
|
|
214
227
|
const seg = chain[i];
|
|
215
228
|
element = await wrapSegmentWithErrorBoundaries(seg, element, h);
|
|
216
|
-
element = await wrapWithAccessGate(seg, element, h);
|
|
217
229
|
element = await wrapWithLayout(seg, element, h);
|
|
230
|
+
element = await wrapWithAccessGate(seg, element, h);
|
|
218
231
|
}
|
|
219
232
|
return element;
|
|
220
233
|
}
|
package/src/server/ssr-entry.ts
CHANGED
|
@@ -55,9 +55,7 @@ let _nodeStreamImports: {
|
|
|
55
55
|
createNodeHeadInjector: typeof import('./node-stream-transforms.js').createNodeHeadInjector;
|
|
56
56
|
createNodeMoveSuffixTransform: typeof import('./node-stream-transforms.js').createNodeMoveSuffixTransform;
|
|
57
57
|
createNodeFlightInjector: typeof import('./node-stream-transforms.js').createNodeFlightInjector;
|
|
58
|
-
|
|
59
|
-
pipeline: typeof import('node:stream/promises').pipeline;
|
|
60
|
-
PassThrough: typeof import('node:stream').PassThrough;
|
|
58
|
+
runNodePipelineWithErrorHandling: typeof import('./node-stream-transforms.js').runNodePipelineWithErrorHandling;
|
|
61
59
|
ReadableFromWeb: (
|
|
62
60
|
webStream: import('stream/web').ReadableStream
|
|
63
61
|
) => import('node:stream').Readable;
|
|
@@ -65,9 +63,8 @@ let _nodeStreamImports: {
|
|
|
65
63
|
|
|
66
64
|
if (useNodeStreams) {
|
|
67
65
|
try {
|
|
68
|
-
const [transforms,
|
|
66
|
+
const [transforms, stream] = await Promise.all([
|
|
69
67
|
import('./node-stream-transforms.js'),
|
|
70
|
-
import('node:stream/promises'),
|
|
71
68
|
import('node:stream'),
|
|
72
69
|
]);
|
|
73
70
|
_nodeStreamImports = {
|
|
@@ -75,9 +72,7 @@ if (useNodeStreams) {
|
|
|
75
72
|
createNodeHeadInjector: transforms.createNodeHeadInjector,
|
|
76
73
|
createNodeMoveSuffixTransform: transforms.createNodeMoveSuffixTransform,
|
|
77
74
|
createNodeFlightInjector: transforms.createNodeFlightInjector,
|
|
78
|
-
|
|
79
|
-
pipeline: streamPromises.pipeline,
|
|
80
|
-
PassThrough: stream.PassThrough,
|
|
75
|
+
runNodePipelineWithErrorHandling: transforms.runNodePipelineWithErrorHandling,
|
|
81
76
|
ReadableFromWeb: (webStream: import('stream/web').ReadableStream) =>
|
|
82
77
|
stream.Readable.fromWeb(webStream) as import('node:stream').Readable,
|
|
83
78
|
};
|
|
@@ -192,9 +187,7 @@ export async function handleSsr(
|
|
|
192
187
|
createNodeHeadInjector,
|
|
193
188
|
createNodeMoveSuffixTransform,
|
|
194
189
|
createNodeFlightInjector,
|
|
195
|
-
|
|
196
|
-
pipeline,
|
|
197
|
-
PassThrough,
|
|
190
|
+
runNodePipelineWithErrorHandling,
|
|
198
191
|
} = _nodeStreamImports;
|
|
199
192
|
|
|
200
193
|
const renderTimeoutMs = _runtimeConfig.renderTimeoutMs ?? undefined;
|
|
@@ -224,15 +217,18 @@ export async function handleSsr(
|
|
|
224
217
|
// React 19.3+ emits <!DOCTYPE html> automatically when the root
|
|
225
218
|
// element is <html>, so no framework-level doctype prepend needed.
|
|
226
219
|
//
|
|
227
|
-
// Pipeline: buffer →
|
|
220
|
+
// Pipeline: buffer → headInjector → flightInjector → moveSuffix → output
|
|
228
221
|
//
|
|
229
222
|
// 1. buffer: coalesces Fizz micro-chunks into coherent fragments (TIM-528)
|
|
230
|
-
// 2.
|
|
231
|
-
// 3.
|
|
232
|
-
// 4.
|
|
233
|
-
//
|
|
223
|
+
// 2. headInjector: injects metadata before </head>
|
|
224
|
+
// 3. flightInjector: interleaves RSC scripts between HTML chunks (TIM-529)
|
|
225
|
+
// 4. moveSuffix: strips </body></html>, re-emits at end (TIM-530)
|
|
226
|
+
//
|
|
227
|
+
// Post-shell error handling lives in the pump, not in a pipeline
|
|
228
|
+
// stage: pipeline() destroys every stage on failure, so only an
|
|
229
|
+
// output kept outside the pipeline can append the noindex script
|
|
230
|
+
// and close cleanly (TIM-1019).
|
|
234
231
|
const bufferedTransform = createNodeBufferedTransform();
|
|
235
|
-
const errorHandler = createNodeErrorHandler(navContext.signal);
|
|
236
232
|
const headInjector = createNodeHeadInjector(navContext.headHtml);
|
|
237
233
|
const flightInjector = createNodeFlightInjector(navContext.rscStream, {
|
|
238
234
|
renderTimeoutMs,
|
|
@@ -240,18 +236,11 @@ export async function handleSsr(
|
|
|
240
236
|
});
|
|
241
237
|
const moveSuffix = createNodeMoveSuffixTransform();
|
|
242
238
|
|
|
243
|
-
const output =
|
|
244
|
-
pipeline(
|
|
239
|
+
const output = runNodePipelineWithErrorHandling(
|
|
245
240
|
nodeHtmlStream,
|
|
246
|
-
bufferedTransform,
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
flightInjector,
|
|
250
|
-
moveSuffix,
|
|
251
|
-
output
|
|
252
|
-
).catch(() => {
|
|
253
|
-
// Pipeline errors are handled by errorHandler transform
|
|
254
|
-
});
|
|
241
|
+
[bufferedTransform, headInjector, flightInjector, moveSuffix],
|
|
242
|
+
navContext.signal
|
|
243
|
+
);
|
|
255
244
|
const _pipelineEnd = performance.now();
|
|
256
245
|
|
|
257
246
|
// Record SSR sub-timings for Server-Timing header (detailed mode).
|
package/src/server/tracing.ts
CHANGED
|
@@ -94,6 +94,16 @@ export function getTraceStore(): TraceStore | undefined {
|
|
|
94
94
|
|
|
95
95
|
// ─── Dev-Mode OTEL Auto-Init ─────────────────────────────────────────────
|
|
96
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Well-known key marking dev tracing as initialized.
|
|
99
|
+
*
|
|
100
|
+
* The RSC entry module re-evaluates on every HMR invalidation and calls
|
|
101
|
+
* initDevTracing() again — without this guard, each call would register a
|
|
102
|
+
* fresh provider/processor and duplicate every span's output. Symbol.for()
|
|
103
|
+
* survives module re-evaluation. See TIM-1067, B49.
|
|
104
|
+
*/
|
|
105
|
+
const DEV_TRACING_INIT_KEY = Symbol.for('timber.dev.tracing-initialized');
|
|
106
|
+
|
|
97
107
|
/**
|
|
98
108
|
* Initialize a minimal OTEL SDK in dev mode so spans are recorded and
|
|
99
109
|
* fed to the DevSpanProcessor for dev log output.
|
|
@@ -102,11 +112,15 @@ export function getTraceStore(): TraceStore | undefined {
|
|
|
102
112
|
* our DevSpanProcessor alongside theirs. If no SDK is configured,
|
|
103
113
|
* we create a BasicTracerProvider with our processor.
|
|
104
114
|
*
|
|
105
|
-
*
|
|
115
|
+
* Idempotent across module re-evaluations (HMR) — only the first call
|
|
116
|
+
* registers. Only called in dev mode — zero overhead in production.
|
|
106
117
|
*/
|
|
107
118
|
export async function initDevTracing(
|
|
108
119
|
config: import('../dev-tools/logger.js').DevLoggerConfig
|
|
109
120
|
): Promise<void> {
|
|
121
|
+
const globals = globalThis as Record<symbol, unknown>;
|
|
122
|
+
if (globals[DEV_TRACING_INIT_KEY]) return;
|
|
123
|
+
|
|
110
124
|
const api = await getOtelApi();
|
|
111
125
|
if (!api) return;
|
|
112
126
|
|
|
@@ -145,6 +159,8 @@ export async function initDevTracing(
|
|
|
145
159
|
|
|
146
160
|
// Reset cached tracer so next getTracer() picks up the new provider
|
|
147
161
|
_tracer = undefined;
|
|
162
|
+
|
|
163
|
+
globals[DEV_TRACING_INIT_KEY] = true;
|
|
148
164
|
}
|
|
149
165
|
|
|
150
166
|
// ─── OTEL Span Helpers ───────────────────────────────────────────────────
|