@timber-js/app 0.1.38 → 0.1.40
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/adapters/compress-module.d.ts +10 -0
- package/dist/adapters/compress-module.d.ts.map +1 -0
- package/dist/adapters/nitro.d.ts +1 -1
- package/dist/adapters/nitro.d.ts.map +1 -1
- package/dist/adapters/nitro.js +110 -3
- package/dist/adapters/nitro.js.map +1 -1
- package/dist/index.js +126 -2
- package/dist/index.js.map +1 -1
- package/dist/plugins/dev-server.d.ts.map +1 -1
- package/dist/server/compress.d.ts +37 -0
- package/dist/server/compress.d.ts.map +1 -0
- package/dist/server/route-element-builder.d.ts +1 -1
- package/dist/server/route-element-builder.d.ts.map +1 -1
- package/dist/server/rsc-entry/index.d.ts.map +1 -1
- package/dist/server/state-tree-diff.d.ts +43 -0
- package/dist/server/state-tree-diff.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/adapters/compress-module.ts +108 -0
- package/src/adapters/nitro.ts +16 -2
- package/src/plugins/dev-server.ts +7 -1
- package/src/server/compress.ts +200 -0
- package/src/server/route-element-builder.ts +27 -2
- package/src/server/rsc-entry/index.ts +9 -1
- package/src/server/state-tree-diff.ts +77 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dev-server.d.ts","sourceRoot":"","sources":["../../src/plugins/dev-server.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAiC,MAAM,MAAM,CAAC;AAGlE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;
|
|
1
|
+
{"version":3,"file":"dev-server.d.ts","sourceRoot":"","sources":["../../src/plugins/dev-server.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAiC,MAAM,MAAM,CAAC;AAGlE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAkChD;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,aAAa,GAAG,MAAM,CAmD1D"}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MIME types that benefit from compression.
|
|
3
|
+
* text/* is handled via prefix matching; these are the specific
|
|
4
|
+
* application/* and image/* types that are compressible.
|
|
5
|
+
*/
|
|
6
|
+
export declare const COMPRESSIBLE_TYPES: Set<string>;
|
|
7
|
+
/**
|
|
8
|
+
* Parse Accept-Encoding and return the best supported encoding.
|
|
9
|
+
* Prefers brotli (br) over gzip. Returns null if no supported encoding.
|
|
10
|
+
*
|
|
11
|
+
* We always prefer brotli regardless of quality values because:
|
|
12
|
+
* 1. Brotli achieves better compression ratios than gzip
|
|
13
|
+
* 2. All modern browsers that send br in Accept-Encoding support it well
|
|
14
|
+
* 3. Respecting q-values for br vs gzip adds complexity with no real benefit
|
|
15
|
+
*/
|
|
16
|
+
export declare function negotiateEncoding(acceptEncoding: string): 'br' | 'gzip' | null;
|
|
17
|
+
/**
|
|
18
|
+
* Determine if a response should be compressed.
|
|
19
|
+
*
|
|
20
|
+
* Returns false for:
|
|
21
|
+
* - Responses without a body (204, 304, null body)
|
|
22
|
+
* - Already-encoded responses (Content-Encoding set)
|
|
23
|
+
* - Non-compressible content types (images, binary)
|
|
24
|
+
* - SSE streams (text/event-stream — must not be buffered)
|
|
25
|
+
*/
|
|
26
|
+
export declare function shouldCompress(response: Response): boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Compress a Web Response if the client supports it and the content is compressible.
|
|
29
|
+
*
|
|
30
|
+
* Returns the original response unchanged if compression is not applicable.
|
|
31
|
+
* Returns a new Response with the compressed body, Content-Encoding, and Vary headers.
|
|
32
|
+
*
|
|
33
|
+
* The body is piped through a compression stream — no buffering of the full response.
|
|
34
|
+
* This preserves streaming behavior for HTML shell + deferred Suspense chunks.
|
|
35
|
+
*/
|
|
36
|
+
export declare function compressResponse(request: Request, response: Response): Response;
|
|
37
|
+
//# sourceMappingURL=compress.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"compress.d.ts","sourceRoot":"","sources":["../../src/server/compress.ts"],"names":[],"mappings":"AAaA;;;;GAIG;AACH,eAAO,MAAM,kBAAkB,aAc7B,CAAC;AASH;;;;;;;;GAQG;AACH,wBAAgB,iBAAiB,CAAC,cAAc,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,GAAG,IAAI,CAU9E;AAID;;;;;;;;GAQG;AACH,wBAAgB,cAAc,CAAC,QAAQ,EAAE,QAAQ,GAAG,OAAO,CAmB1D;AAID;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,QAAQ,GAAG,QAAQ,CAmC/E"}
|
|
@@ -62,5 +62,5 @@ export declare class RouteSignalWithContext extends Error {
|
|
|
62
62
|
* Does NOT serialize to RSC Flight — the caller decides whether to render
|
|
63
63
|
* to a stream or use the element directly (e.g., for action revalidation).
|
|
64
64
|
*/
|
|
65
|
-
export declare function buildRouteElement(req: Request, match: RouteMatch, interception?: InterceptionContext): Promise<RouteElementResult>;
|
|
65
|
+
export declare function buildRouteElement(req: Request, match: RouteMatch, interception?: InterceptionContext, clientStateTree?: Set<string> | null): Promise<RouteElementResult>;
|
|
66
66
|
//# sourceMappingURL=route-element-builder.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"route-element-builder.d.ts","sourceRoot":"","sources":["../../src/server/route-element-builder.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAKH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAChD,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AAK9D,OAAO,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAO7D,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,eAAe,CAAC;
|
|
1
|
+
{"version":3,"file":"route-element-builder.d.ts","sourceRoot":"","sources":["../../src/server/route-element-builder.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAKH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAChD,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AAK9D,OAAO,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAO7D,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,eAAe,CAAC;AAKzD,qDAAqD;AACrD,MAAM,WAAW,WAAW;IAC1B,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC,CAAC;CACvC;AAED,+CAA+C;AAC/C,MAAM,WAAW,oBAAoB;IACnC,SAAS,EAAE,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,OAAO,CAAC;IAC3C,OAAO,EAAE,mBAAmB,CAAC;CAC9B;AAED,+CAA+C;AAC/C,MAAM,WAAW,kBAAkB;IACjC,wFAAwF;IACxF,OAAO,EAAE,KAAK,CAAC,YAAY,CAAC;IAC5B,2CAA2C;IAC3C,YAAY,EAAE,WAAW,EAAE,CAAC;IAC5B,wDAAwD;IACxD,gBAAgB,EAAE,oBAAoB,EAAE,CAAC;IACzC,qCAAqC;IACrC,QAAQ,EAAE,mBAAmB,EAAE,CAAC;IAChC,4DAA4D;IAC5D,gBAAgB,EAAE,MAAM,CAAC;CAC1B;AAED;;;GAGG;AACH,qBAAa,sBAAuB,SAAQ,KAAK;aAE7B,MAAM,EAAE,UAAU,GAAG,cAAc;aACnC,gBAAgB,EAAE,oBAAoB,EAAE;aACxC,QAAQ,EAAE,mBAAmB,EAAE;gBAF/B,MAAM,EAAE,UAAU,GAAG,cAAc,EACnC,gBAAgB,EAAE,oBAAoB,EAAE,EACxC,QAAQ,EAAE,mBAAmB,EAAE;CAIlD;AAID;;;;;;;;;GASG;AACH,wBAAsB,iBAAiB,CACrC,GAAG,EAAE,OAAO,EACZ,KAAK,EAAE,UAAU,EACjB,YAAY,CAAC,EAAE,mBAAmB,EAClC,eAAe,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,IAAI,GACnC,OAAO,CAAC,kBAAkB,CAAC,CAgU7B"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/server/rsc-entry/index.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/server/rsc-entry/index.ts"],"names":[],"mappings":"AAuEA;;;;;GAKG;AACH,wBAAgB,0BAA0B,CAAC,OAAO,EAAE,CAAC,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,KAAK,IAAI,GAAG,IAAI,CAE/F;AA+WD,OAAO,EAAE,uBAAuB,EAAE,MAAM,gCAAgC,CAAC;8BAxPpD,OAAO,KAAG,OAAO,CAAC,QAAQ,CAAC;AA0PhD,wBAAiE"}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* State Tree Diffing — Server-side parsing and diffing of X-Timber-State-Tree.
|
|
3
|
+
*
|
|
4
|
+
* The client sends X-Timber-State-Tree on navigation requests, listing
|
|
5
|
+
* the sync segments it has cached. The server diffs this against the
|
|
6
|
+
* target route's segments to skip re-rendering unchanged sync layouts.
|
|
7
|
+
*
|
|
8
|
+
* This is a performance optimization only — NOT a security boundary.
|
|
9
|
+
* All access.ts files run regardless of the state tree content.
|
|
10
|
+
* A fabricated state tree can only cause extra rendering work or stale
|
|
11
|
+
* layouts — never auth bypass.
|
|
12
|
+
*
|
|
13
|
+
* See design/19-client-navigation.md §"X-Timber-State-Tree Header"
|
|
14
|
+
* See design/13-security.md §"State tree manipulation"
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* Parse the X-Timber-State-Tree header from a request.
|
|
18
|
+
*
|
|
19
|
+
* Returns a Set of segment paths the client has cached, or null if
|
|
20
|
+
* the header is missing, malformed, or empty. Parsing happens before
|
|
21
|
+
* renderToReadableStream — not inside the React render pass.
|
|
22
|
+
*
|
|
23
|
+
* @returns Set of sync segment paths, or null if no valid state tree
|
|
24
|
+
*/
|
|
25
|
+
export declare function parseClientStateTree(req: Request): Set<string> | null;
|
|
26
|
+
/**
|
|
27
|
+
* Determine whether a segment's layout rendering can be skipped.
|
|
28
|
+
*
|
|
29
|
+
* A segment is skipped when ALL of the following are true:
|
|
30
|
+
* 1. The client has the segment in its state tree (clientSegments contains urlPath)
|
|
31
|
+
* 2. The layout is sync (not an async function — async layouts always re-render)
|
|
32
|
+
* 3. The segment is NOT the leaf (pages are never cached across navigations)
|
|
33
|
+
*
|
|
34
|
+
* Access.ts still runs for skipped segments — this is enforced by the caller
|
|
35
|
+
* (buildRouteElement) which runs all access checks before building the tree.
|
|
36
|
+
*
|
|
37
|
+
* @param urlPath - The segment's URL path (e.g., "/", "/dashboard")
|
|
38
|
+
* @param layoutComponent - The loaded layout component function
|
|
39
|
+
* @param isLeaf - Whether this is the leaf segment (page segment)
|
|
40
|
+
* @param clientSegments - Set of paths from X-Timber-State-Tree, or null
|
|
41
|
+
*/
|
|
42
|
+
export declare function shouldSkipSegment(urlPath: string, layoutComponent: ((...args: unknown[]) => unknown) | undefined, isLeaf: boolean, clientSegments: Set<string> | null): boolean;
|
|
43
|
+
//# sourceMappingURL=state-tree-diff.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"state-tree-diff.d.ts","sourceRoot":"","sources":["../../src/server/state-tree-diff.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH;;;;;;;;GAQG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,OAAO,GAAG,GAAG,CAAC,MAAM,CAAC,GAAG,IAAI,CAarE;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,iBAAiB,CAC/B,OAAO,EAAE,MAAM,EACf,eAAe,EAAE,CAAC,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,OAAO,CAAC,GAAG,SAAS,EAC9D,MAAM,EAAE,OAAO,EACf,cAAc,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,IAAI,GACjC,OAAO,CAeT"}
|
package/package.json
CHANGED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Generated compression module template for self-hosted deployments.
|
|
2
|
+
//
|
|
3
|
+
// This file generates a standalone ESM module (_compress.mjs) that is
|
|
4
|
+
// written to the build output during adapter buildOutput(). It's imported
|
|
5
|
+
// by the Nitro entry and preview server at runtime.
|
|
6
|
+
//
|
|
7
|
+
// Uses CompressionStream (Web API) for gzip and node:zlib for brotli.
|
|
8
|
+
// Cloudflare Workers don't need this — the edge auto-compresses.
|
|
9
|
+
//
|
|
10
|
+
// See design/25-production-deployments.md.
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Generate a standalone ESM module that exports compressResponse().
|
|
14
|
+
*
|
|
15
|
+
* Written to `_compress.mjs` during buildOutput. Imported by the Nitro entry
|
|
16
|
+
* and preview server.
|
|
17
|
+
*
|
|
18
|
+
* @internal Exported for testing.
|
|
19
|
+
*/
|
|
20
|
+
export function generateCompressModule(): string {
|
|
21
|
+
return `// Generated by @timber-js/app — response compression for self-hosted deployments.
|
|
22
|
+
// Do not edit — this file is regenerated on each build.
|
|
23
|
+
// Uses CompressionStream (Web API) for gzip, node:zlib for brotli.
|
|
24
|
+
|
|
25
|
+
import { createBrotliCompress, constants as zlibConstants } from 'node:zlib';
|
|
26
|
+
import { Readable } from 'node:stream';
|
|
27
|
+
|
|
28
|
+
const COMPRESSIBLE_TYPES = new Set([
|
|
29
|
+
'text/html', 'text/css', 'text/plain', 'text/xml', 'text/javascript',
|
|
30
|
+
'text/x-component', 'application/json', 'application/javascript',
|
|
31
|
+
'application/xml', 'application/xhtml+xml', 'application/rss+xml',
|
|
32
|
+
'application/atom+xml', 'image/svg+xml',
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
const NO_COMPRESS_STATUSES = new Set([204, 304]);
|
|
36
|
+
|
|
37
|
+
function negotiateEncoding(acceptEncoding) {
|
|
38
|
+
if (!acceptEncoding) return null;
|
|
39
|
+
const tokens = acceptEncoding.split(',').map(s => s.split(';')[0].trim().toLowerCase());
|
|
40
|
+
if (tokens.includes('br')) return 'br';
|
|
41
|
+
if (tokens.includes('gzip')) return 'gzip';
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function shouldCompress(response) {
|
|
46
|
+
if (!response.body) return false;
|
|
47
|
+
if (NO_COMPRESS_STATUSES.has(response.status)) return false;
|
|
48
|
+
if (response.headers.has('Content-Encoding')) return false;
|
|
49
|
+
const contentType = response.headers.get('Content-Type');
|
|
50
|
+
if (!contentType) return false;
|
|
51
|
+
const mimeType = contentType.split(';')[0].trim().toLowerCase();
|
|
52
|
+
if (mimeType === 'text/event-stream') return false;
|
|
53
|
+
return COMPRESSIBLE_TYPES.has(mimeType);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function compressWithGzip(body) {
|
|
57
|
+
return body.pipeThrough(new CompressionStream('gzip'));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function compressWithBrotli(body) {
|
|
61
|
+
const brotli = createBrotliCompress({
|
|
62
|
+
params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 4 },
|
|
63
|
+
});
|
|
64
|
+
const reader = body.getReader();
|
|
65
|
+
const pump = async () => {
|
|
66
|
+
try {
|
|
67
|
+
while (true) {
|
|
68
|
+
const { done, value } = await reader.read();
|
|
69
|
+
if (done) { brotli.end(); return; }
|
|
70
|
+
if (!brotli.write(value)) {
|
|
71
|
+
await new Promise(resolve => brotli.once('drain', resolve));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
} catch (err) {
|
|
75
|
+
brotli.destroy(err instanceof Error ? err : new Error(String(err)));
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
pump();
|
|
79
|
+
return Readable.toWeb(brotli);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function compressResponse(request, response) {
|
|
83
|
+
if (!shouldCompress(response)) return response;
|
|
84
|
+
const acceptEncoding = request.headers.get('Accept-Encoding') || '';
|
|
85
|
+
const encoding = negotiateEncoding(acceptEncoding);
|
|
86
|
+
if (!encoding) return response;
|
|
87
|
+
const compressedBody = encoding === 'br'
|
|
88
|
+
? compressWithBrotli(response.body)
|
|
89
|
+
: compressWithGzip(response.body);
|
|
90
|
+
const headers = new Headers(response.headers);
|
|
91
|
+
headers.set('Content-Encoding', encoding);
|
|
92
|
+
headers.delete('Content-Length');
|
|
93
|
+
const existingVary = headers.get('Vary');
|
|
94
|
+
if (existingVary) {
|
|
95
|
+
if (!existingVary.toLowerCase().includes('accept-encoding')) {
|
|
96
|
+
headers.set('Vary', existingVary + ', Accept-Encoding');
|
|
97
|
+
}
|
|
98
|
+
} else {
|
|
99
|
+
headers.set('Vary', 'Accept-Encoding');
|
|
100
|
+
}
|
|
101
|
+
return new Response(compressedBody, {
|
|
102
|
+
status: response.status,
|
|
103
|
+
statusText: response.statusText,
|
|
104
|
+
headers,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
`;
|
|
108
|
+
}
|
package/src/adapters/nitro.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { writeFile, readFile, mkdir, cp } from 'node:fs/promises';
|
|
|
9
9
|
import { execFile } from 'node:child_process';
|
|
10
10
|
import { join, relative } from 'node:path';
|
|
11
11
|
import type { TimberPlatformAdapter, TimberConfig } from './types';
|
|
12
|
+
import { generateCompressModule } from './compress-module.js';
|
|
12
13
|
// Inlined from server/asset-headers.ts — adapters are loaded by Node at
|
|
13
14
|
// Vite startup time, before Vite's module resolver is available, so cross-
|
|
14
15
|
// directory .ts imports don't resolve.
|
|
@@ -198,6 +199,11 @@ export function nitro(options: NitroAdapterOptions = {}): TimberPlatformAdapter
|
|
|
198
199
|
await writeFile(join(outDir, '_timber-manifest-init.js'), config.manifestInit);
|
|
199
200
|
}
|
|
200
201
|
|
|
202
|
+
// Write the compression helper module for runtime use.
|
|
203
|
+
// See design/25-production-deployments.md — self-hosted deployments
|
|
204
|
+
// need application-level compression (Cloudflare handles it at the edge).
|
|
205
|
+
await writeFile(join(outDir, '_compress.mjs'), generateCompressModule());
|
|
206
|
+
|
|
201
207
|
// Copy rsc/ssr build output into the nitro dir so imports stay local
|
|
202
208
|
// during the Nitro bundling step (avoids broken relative paths in output).
|
|
203
209
|
await cp(join(buildDir, 'rsc'), join(outDir, 'rsc'), { recursive: true });
|
|
@@ -258,6 +264,7 @@ export function generateNitroEntry(
|
|
|
258
264
|
buildDir: string,
|
|
259
265
|
outDir: string,
|
|
260
266
|
preset: NitroPreset,
|
|
267
|
+
hasManifestInit = false,
|
|
261
268
|
): string {
|
|
262
269
|
// The RSC entry is the main request handler — it exports the fetch handler as default.
|
|
263
270
|
// rsc/ is copied into the nitro dir so the import is local.
|
|
@@ -286,6 +293,7 @@ export function generateNitroEntry(
|
|
|
286
293
|
|
|
287
294
|
import { defineEventHandler } from 'nitro/h3'
|
|
288
295
|
import handler, { runWithEarlyHintsSender } from '${serverEntryRelative}'
|
|
296
|
+
import { compressResponse } from './_compress.mjs'
|
|
289
297
|
|
|
290
298
|
// Set TIMBER_RUNTIME for instrumentation.ts conditional SDK initialization.
|
|
291
299
|
// See design/25-production-deployments.md §"TIMBER_RUNTIME".
|
|
@@ -295,7 +303,7 @@ export default defineEventHandler(async (event) => {
|
|
|
295
303
|
// h3 v2: event.req is the Web Request
|
|
296
304
|
const webRequest = event.req
|
|
297
305
|
${handlerCall}
|
|
298
|
-
return webResponse
|
|
306
|
+
return compressResponse(webRequest, webResponse)
|
|
299
307
|
})
|
|
300
308
|
`;
|
|
301
309
|
}
|
|
@@ -374,6 +382,9 @@ if (existsSync(manifestPath)) {
|
|
|
374
382
|
// Import the RSC handler (default export is the fetch-like handler).
|
|
375
383
|
const { default: handler, runWithEarlyHintsSender } = await import('${rscEntry}');
|
|
376
384
|
|
|
385
|
+
// Import compression helper for self-hosted response compression.
|
|
386
|
+
const { compressResponse } = await import('./_compress.mjs');
|
|
387
|
+
|
|
377
388
|
const MIME_TYPES = {
|
|
378
389
|
'.html': 'text/html',
|
|
379
390
|
'.js': 'application/javascript',
|
|
@@ -467,10 +478,13 @@ const server = createServer(async (req, res) => {
|
|
|
467
478
|
? (links) => { try { res.writeEarlyHints({ link: links }); } catch {} }
|
|
468
479
|
: undefined;
|
|
469
480
|
|
|
470
|
-
const
|
|
481
|
+
const rawResponse = earlyHintsSender && runWithEarlyHintsSender
|
|
471
482
|
? await runWithEarlyHintsSender(earlyHintsSender, () => handler(webRequest))
|
|
472
483
|
: await handler(webRequest);
|
|
473
484
|
|
|
485
|
+
// Compress the response for self-hosted deployments.
|
|
486
|
+
const webResponse = compressResponse(webRequest, rawResponse);
|
|
487
|
+
|
|
474
488
|
// Write the response back to the Node response.
|
|
475
489
|
res.writeHead(webResponse.status, Object.fromEntries(webResponse.headers.entries()));
|
|
476
490
|
|
|
@@ -18,6 +18,7 @@ import { join } from 'node:path';
|
|
|
18
18
|
import type { PluginContext } from '#/index.js';
|
|
19
19
|
import { setViteServer } from '#/server/dev-warnings.js';
|
|
20
20
|
import { sendErrorToOverlay, classifyErrorPhase, parseFirstAppFrame } from './dev-error-overlay.js';
|
|
21
|
+
import { compressResponse } from '#/server/compress.js';
|
|
21
22
|
|
|
22
23
|
// ─── Constants ────────────────────────────────────────────────────────────
|
|
23
24
|
|
|
@@ -193,8 +194,13 @@ function createTimberMiddleware(server: ViteDevServer, projectRoot: string) {
|
|
|
193
194
|
// Run the full pipeline
|
|
194
195
|
const webResponse = await handler(webRequest);
|
|
195
196
|
|
|
197
|
+
// Compress the response if the client supports it.
|
|
198
|
+
// In dev mode, compression is always enabled for parity with production.
|
|
199
|
+
// See design/25-production-deployments.md.
|
|
200
|
+
const finalResponse = compressResponse(webRequest, webResponse);
|
|
201
|
+
|
|
196
202
|
// Convert Web Response → Node ServerResponse
|
|
197
|
-
await sendWebResponse(res,
|
|
203
|
+
await sendWebResponse(res, finalResponse);
|
|
198
204
|
} catch (error) {
|
|
199
205
|
// Pipeline error — classify the phase, send to overlay, respond 500.
|
|
200
206
|
// The dev server remains running for recovery on file fix + HMR.
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// Response compression for self-hosted deployments (dev server, Nitro preview).
|
|
2
|
+
//
|
|
3
|
+
// Uses CompressionStream (Web Platform API) for gzip and node:zlib for
|
|
4
|
+
// brotli (CompressionStream doesn't support brotli). Cloudflare Workers
|
|
5
|
+
// auto-compress at the edge — this module is only used on Node.js/Bun.
|
|
6
|
+
//
|
|
7
|
+
// See design/25-production-deployments.md.
|
|
8
|
+
|
|
9
|
+
import { createBrotliCompress, constants as zlibConstants } from 'node:zlib';
|
|
10
|
+
import { Readable } from 'node:stream';
|
|
11
|
+
|
|
12
|
+
// ─── Constants ────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* MIME types that benefit from compression.
|
|
16
|
+
* text/* is handled via prefix matching; these are the specific
|
|
17
|
+
* application/* and image/* types that are compressible.
|
|
18
|
+
*/
|
|
19
|
+
export const COMPRESSIBLE_TYPES = new Set([
|
|
20
|
+
'text/html',
|
|
21
|
+
'text/css',
|
|
22
|
+
'text/plain',
|
|
23
|
+
'text/xml',
|
|
24
|
+
'text/javascript',
|
|
25
|
+
'text/x-component',
|
|
26
|
+
'application/json',
|
|
27
|
+
'application/javascript',
|
|
28
|
+
'application/xml',
|
|
29
|
+
'application/xhtml+xml',
|
|
30
|
+
'application/rss+xml',
|
|
31
|
+
'application/atom+xml',
|
|
32
|
+
'image/svg+xml',
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Status codes that should never be compressed (no body or special semantics).
|
|
37
|
+
*/
|
|
38
|
+
const NO_COMPRESS_STATUSES = new Set([204, 304]);
|
|
39
|
+
|
|
40
|
+
// ─── Encoding Negotiation ─────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Parse Accept-Encoding and return the best supported encoding.
|
|
44
|
+
* Prefers brotli (br) over gzip. Returns null if no supported encoding.
|
|
45
|
+
*
|
|
46
|
+
* We always prefer brotli regardless of quality values because:
|
|
47
|
+
* 1. Brotli achieves better compression ratios than gzip
|
|
48
|
+
* 2. All modern browsers that send br in Accept-Encoding support it well
|
|
49
|
+
* 3. Respecting q-values for br vs gzip adds complexity with no real benefit
|
|
50
|
+
*/
|
|
51
|
+
export function negotiateEncoding(acceptEncoding: string): 'br' | 'gzip' | null {
|
|
52
|
+
if (!acceptEncoding) return null;
|
|
53
|
+
|
|
54
|
+
// Parse tokens from the Accept-Encoding header (ignore quality values).
|
|
55
|
+
// e.g. "gzip;q=1.0, br;q=0.8, deflate" → ['gzip', 'br', 'deflate']
|
|
56
|
+
const tokens = acceptEncoding.split(',').map((s) => s.split(';')[0].trim().toLowerCase());
|
|
57
|
+
|
|
58
|
+
if (tokens.includes('br')) return 'br';
|
|
59
|
+
if (tokens.includes('gzip')) return 'gzip';
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ─── Compressibility Check ────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Determine if a response should be compressed.
|
|
67
|
+
*
|
|
68
|
+
* Returns false for:
|
|
69
|
+
* - Responses without a body (204, 304, null body)
|
|
70
|
+
* - Already-encoded responses (Content-Encoding set)
|
|
71
|
+
* - Non-compressible content types (images, binary)
|
|
72
|
+
* - SSE streams (text/event-stream — must not be buffered)
|
|
73
|
+
*/
|
|
74
|
+
export function shouldCompress(response: Response): boolean {
|
|
75
|
+
// No body to compress
|
|
76
|
+
if (!response.body) return false;
|
|
77
|
+
if (NO_COMPRESS_STATUSES.has(response.status)) return false;
|
|
78
|
+
|
|
79
|
+
// Already compressed
|
|
80
|
+
if (response.headers.has('Content-Encoding')) return false;
|
|
81
|
+
|
|
82
|
+
// Check content type
|
|
83
|
+
const contentType = response.headers.get('Content-Type');
|
|
84
|
+
if (!contentType) return false;
|
|
85
|
+
|
|
86
|
+
// Extract the MIME type (strip charset and other parameters)
|
|
87
|
+
const mimeType = contentType.split(';')[0].trim().toLowerCase();
|
|
88
|
+
|
|
89
|
+
// SSE must not be compressed — it relies on chunk-by-chunk delivery
|
|
90
|
+
if (mimeType === 'text/event-stream') return false;
|
|
91
|
+
|
|
92
|
+
return COMPRESSIBLE_TYPES.has(mimeType);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ─── Compression ──────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Compress a Web Response if the client supports it and the content is compressible.
|
|
99
|
+
*
|
|
100
|
+
* Returns the original response unchanged if compression is not applicable.
|
|
101
|
+
* Returns a new Response with the compressed body, Content-Encoding, and Vary headers.
|
|
102
|
+
*
|
|
103
|
+
* The body is piped through a compression stream — no buffering of the full response.
|
|
104
|
+
* This preserves streaming behavior for HTML shell + deferred Suspense chunks.
|
|
105
|
+
*/
|
|
106
|
+
export function compressResponse(request: Request, response: Response): Response {
|
|
107
|
+
// Check if response is compressible
|
|
108
|
+
if (!shouldCompress(response)) return response;
|
|
109
|
+
|
|
110
|
+
// Negotiate encoding with the client
|
|
111
|
+
const acceptEncoding = request.headers.get('Accept-Encoding') ?? '';
|
|
112
|
+
const encoding = negotiateEncoding(acceptEncoding);
|
|
113
|
+
if (!encoding) return response;
|
|
114
|
+
|
|
115
|
+
// Compress the body stream
|
|
116
|
+
const compressedBody = encoding === 'br'
|
|
117
|
+
? compressWithBrotli(response.body!)
|
|
118
|
+
: compressWithGzip(response.body!);
|
|
119
|
+
|
|
120
|
+
// Build new headers: copy originals, add compression headers, remove Content-Length
|
|
121
|
+
// (compressed size is unknown until streaming completes).
|
|
122
|
+
const headers = new Headers(response.headers);
|
|
123
|
+
headers.set('Content-Encoding', encoding);
|
|
124
|
+
headers.delete('Content-Length');
|
|
125
|
+
|
|
126
|
+
// Append to Vary header (preserve existing Vary values)
|
|
127
|
+
const existingVary = headers.get('Vary');
|
|
128
|
+
if (existingVary) {
|
|
129
|
+
if (!existingVary.toLowerCase().includes('accept-encoding')) {
|
|
130
|
+
headers.set('Vary', `${existingVary}, Accept-Encoding`);
|
|
131
|
+
}
|
|
132
|
+
} else {
|
|
133
|
+
headers.set('Vary', 'Accept-Encoding');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return new Response(compressedBody, {
|
|
137
|
+
status: response.status,
|
|
138
|
+
statusText: response.statusText,
|
|
139
|
+
headers,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ─── Gzip (CompressionStream API) ────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Compress a ReadableStream with gzip using the Web Platform CompressionStream API.
|
|
147
|
+
* Available in Node 18+, Bun, and Deno — no npm dependency needed.
|
|
148
|
+
*/
|
|
149
|
+
function compressWithGzip(body: ReadableStream<Uint8Array>): ReadableStream<Uint8Array> {
|
|
150
|
+
const compressionStream = new CompressionStream('gzip');
|
|
151
|
+
// Cast needed: CompressionStream's WritableStream<BufferSource> type is wider
|
|
152
|
+
// than ReadableStream's Uint8Array, but Uint8Array is a valid BufferSource.
|
|
153
|
+
return body.pipeThrough(compressionStream as unknown as TransformStream<Uint8Array, Uint8Array>);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ─── Brotli (node:zlib) ──────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Compress a ReadableStream with brotli using node:zlib.
|
|
160
|
+
*
|
|
161
|
+
* CompressionStream doesn't support brotli — it only handles gzip and deflate.
|
|
162
|
+
* We use node:zlib's createBrotliCompress() and bridge between Web streams
|
|
163
|
+
* and Node streams.
|
|
164
|
+
*/
|
|
165
|
+
function compressWithBrotli(body: ReadableStream<Uint8Array>): ReadableStream<Uint8Array> {
|
|
166
|
+
const brotli = createBrotliCompress({
|
|
167
|
+
params: {
|
|
168
|
+
// Quality 4 balances compression ratio and CPU time for streaming.
|
|
169
|
+
// Default (11) is too slow for real-time responses.
|
|
170
|
+
[zlibConstants.BROTLI_PARAM_QUALITY]: 4,
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// Pipe the Web ReadableStream into the Node brotli transform.
|
|
175
|
+
const reader = body.getReader();
|
|
176
|
+
|
|
177
|
+
// Pump chunks from the Web ReadableStream into the Node transform.
|
|
178
|
+
const pump = async (): Promise<void> => {
|
|
179
|
+
try {
|
|
180
|
+
while (true) {
|
|
181
|
+
const { done, value } = await reader.read();
|
|
182
|
+
if (done) {
|
|
183
|
+
brotli.end();
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
// Write to brotli, wait for drain if buffer is full
|
|
187
|
+
if (!brotli.write(value)) {
|
|
188
|
+
await new Promise<void>((resolve) => brotli.once('drain', resolve));
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
} catch (err) {
|
|
192
|
+
brotli.destroy(err instanceof Error ? err : new Error(String(err)));
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
// Start pumping (fire and forget — errors propagate via brotli stream)
|
|
196
|
+
pump();
|
|
197
|
+
|
|
198
|
+
// Convert the Node readable (brotli output) to a Web ReadableStream.
|
|
199
|
+
return Readable.toWeb(brotli) as ReadableStream<Uint8Array>;
|
|
200
|
+
}
|
|
@@ -32,6 +32,7 @@ import { setParsedSearchParams } from './request-context.js';
|
|
|
32
32
|
import type { SearchParamsDefinition } from '#/search-params/create.js';
|
|
33
33
|
import { wrapSegmentWithErrorBoundaries } from './error-boundary-wrapper.js';
|
|
34
34
|
import type { InterceptionContext } from './pipeline.js';
|
|
35
|
+
import { shouldSkipSegment } from './state-tree-diff.js';
|
|
35
36
|
|
|
36
37
|
// ─── Types ────────────────────────────────────────────────────────────────
|
|
37
38
|
|
|
@@ -91,7 +92,8 @@ export class RouteSignalWithContext extends Error {
|
|
|
91
92
|
export async function buildRouteElement(
|
|
92
93
|
req: Request,
|
|
93
94
|
match: RouteMatch,
|
|
94
|
-
interception?: InterceptionContext
|
|
95
|
+
interception?: InterceptionContext,
|
|
96
|
+
clientStateTree?: Set<string> | null
|
|
95
97
|
): Promise<RouteElementResult> {
|
|
96
98
|
const segments = match.segments as unknown as ManifestSegmentNode[];
|
|
97
99
|
|
|
@@ -308,8 +310,32 @@ export async function buildRouteElement(
|
|
|
308
310
|
// 1. Error boundaries (status files + error.tsx)
|
|
309
311
|
// 2. Layout component — wraps children + parallel slots
|
|
310
312
|
// 3. SegmentProvider — records position for useSelectedLayoutSegment
|
|
313
|
+
//
|
|
314
|
+
// When clientStateTree is provided (from X-Timber-State-Tree header on
|
|
315
|
+
// client navigation), sync layouts the client already has are skipped.
|
|
316
|
+
// Access.ts already ran for ALL segments in the pre-render loop above.
|
|
317
|
+
// See design/19-client-navigation.md §"X-Timber-State-Tree Header"
|
|
311
318
|
for (let i = segments.length - 1; i >= 0; i--) {
|
|
312
319
|
const segment = segments[i];
|
|
320
|
+
const isLeaf = i === segments.length - 1;
|
|
321
|
+
const layoutComponent = layoutBySegment.get(segment);
|
|
322
|
+
|
|
323
|
+
// Check if this segment's layout can be skipped for partial rendering.
|
|
324
|
+
// Skipped segments: no layout wrapping, no error boundaries, no slots,
|
|
325
|
+
// no AccessGate in element tree (access already ran pre-render).
|
|
326
|
+
const skip = shouldSkipSegment(
|
|
327
|
+
segment.urlPath,
|
|
328
|
+
layoutComponent,
|
|
329
|
+
isLeaf,
|
|
330
|
+
clientStateTree ?? null
|
|
331
|
+
);
|
|
332
|
+
|
|
333
|
+
if (skip) {
|
|
334
|
+
// Skip this segment entirely — the client uses its cached version.
|
|
335
|
+
// Access.ts already ran in the pre-render loop (security guarantee).
|
|
336
|
+
// Metadata was already resolved above (head elements are correct).
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
313
339
|
|
|
314
340
|
// Wrap with error boundaries from this segment (inside layout).
|
|
315
341
|
element = await wrapSegmentWithErrorBoundaries(segment, element, h);
|
|
@@ -335,7 +361,6 @@ export async function buildRouteElement(
|
|
|
335
361
|
}
|
|
336
362
|
|
|
337
363
|
// Wrap with layout if this segment has one — traced with OTEL span
|
|
338
|
-
const layoutComponent = layoutBySegment.get(segment);
|
|
339
364
|
if (layoutComponent) {
|
|
340
365
|
// Resolve parallel slots for this layout
|
|
341
366
|
const slotProps: Record<string, unknown> = {};
|
|
@@ -59,6 +59,7 @@ import {
|
|
|
59
59
|
escapeHtml,
|
|
60
60
|
isRscPayloadRequest,
|
|
61
61
|
} from './helpers.js';
|
|
62
|
+
import { parseClientStateTree } from '#/server/state-tree-diff.js';
|
|
62
63
|
import { buildRscPayloadResponse } from './rsc-payload.js';
|
|
63
64
|
import { renderRscStream } from './rsc-stream.js';
|
|
64
65
|
import { renderSsrResponse } from './ssr-renderer.js';
|
|
@@ -268,11 +269,18 @@ async function renderRoute(
|
|
|
268
269
|
return handleApiRoute(_req, match, segments, responseHeaders);
|
|
269
270
|
}
|
|
270
271
|
|
|
272
|
+
// Parse X-Timber-State-Tree for RSC payload requests (client navigation).
|
|
273
|
+
// The state tree lists sync segments the client has cached — the server
|
|
274
|
+
// skips re-rendering those layouts for a smaller, faster RSC payload.
|
|
275
|
+
// Only used for RSC requests — HTML requests always get a full render.
|
|
276
|
+
// See design/19-client-navigation.md §"X-Timber-State-Tree Header"
|
|
277
|
+
const clientStateTree = isRscPayloadRequest(_req) ? parseClientStateTree(_req) : null;
|
|
278
|
+
|
|
271
279
|
// Build the React element tree — loads modules, runs access checks,
|
|
272
280
|
// resolves metadata. DenySignal/RedirectSignal propagate for HTTP handling.
|
|
273
281
|
let routeResult;
|
|
274
282
|
try {
|
|
275
|
-
routeResult = await buildRouteElement(_req, match, interception);
|
|
283
|
+
routeResult = await buildRouteElement(_req, match, interception, clientStateTree);
|
|
276
284
|
} catch (error) {
|
|
277
285
|
// RouteSignalWithContext wraps DenySignal/RedirectSignal with layout context
|
|
278
286
|
if (error instanceof RouteSignalWithContext) {
|