@timber-js/app 0.1.38 → 0.1.39
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 +128 -49
- 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 +2 -3
- package/src/adapters/compress-module.ts +108 -0
- package/src/adapters/nitro.ts +39 -77
- 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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@timber-js/app",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.39",
|
|
4
4
|
"description": "Vite-native React framework for Cloudflare Workers — correct HTTP semantics, real status codes, pages that work without JavaScript",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cloudflare-workers",
|
|
@@ -87,8 +87,7 @@
|
|
|
87
87
|
"dependencies": {
|
|
88
88
|
"@opentelemetry/api": "^1.9.0",
|
|
89
89
|
"@opentelemetry/context-async-hooks": "^2.6.0",
|
|
90
|
-
"@opentelemetry/sdk-trace-base": "^2.6.0"
|
|
91
|
-
"nitro": "^3.0.0"
|
|
90
|
+
"@opentelemetry/sdk-trace-base": "^2.6.0"
|
|
92
91
|
},
|
|
93
92
|
"peerDependencies": {
|
|
94
93
|
"@content-collections/core": "^0.14.0",
|
|
@@ -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
|
@@ -5,10 +5,11 @@
|
|
|
5
5
|
// compression, graceful shutdown, static file serving, and platform quirks.
|
|
6
6
|
// See design/11-platform.md and design/25-production-deployments.md.
|
|
7
7
|
|
|
8
|
-
import { writeFile,
|
|
8
|
+
import { writeFile, 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,29 +199,19 @@ export function nitro(options: NitroAdapterOptions = {}): TimberPlatformAdapter
|
|
|
198
199
|
await writeFile(join(outDir, '_timber-manifest-init.js'), config.manifestInit);
|
|
199
200
|
}
|
|
200
201
|
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
|
|
204
|
-
await
|
|
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());
|
|
205
206
|
|
|
206
|
-
//
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
// side-effect-only globalThis assignments from imported modules.
|
|
210
|
-
if (config.manifestInit) {
|
|
211
|
-
const rscEntry = join(outDir, 'rsc', 'index.js');
|
|
212
|
-
const rscContent = await readFile(rscEntry, 'utf-8');
|
|
213
|
-
await writeFile(rscEntry, `${config.manifestInit}\n${rscContent}`);
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
// Generate the Nitro entry point (imports from ./rsc/ within nitro dir)
|
|
217
|
-
const entry = generateNitroEntry(buildDir, outDir, preset);
|
|
207
|
+
// Generate the Nitro entry point
|
|
208
|
+
const hasManifestInit = !!config.manifestInit;
|
|
209
|
+
const entry = generateNitroEntry(buildDir, outDir, preset, hasManifestInit);
|
|
218
210
|
await writeFile(join(outDir, 'entry.ts'), entry);
|
|
219
211
|
|
|
220
|
-
//
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
await runNitroBuild(outDir, preset, options.nitroConfig);
|
|
212
|
+
// Generate the Nitro config with static asset cache rules
|
|
213
|
+
const nitroConfig = generateNitroConfig(preset, options.nitroConfig);
|
|
214
|
+
await writeFile(join(outDir, 'nitro.config.ts'), nitroConfig);
|
|
224
215
|
},
|
|
225
216
|
|
|
226
217
|
// Only presets that produce a locally-runnable server get preview().
|
|
@@ -258,15 +249,22 @@ export function generateNitroEntry(
|
|
|
258
249
|
buildDir: string,
|
|
259
250
|
outDir: string,
|
|
260
251
|
preset: NitroPreset,
|
|
252
|
+
hasManifestInit = false
|
|
261
253
|
): string {
|
|
262
254
|
// The RSC entry is the main request handler — it exports the fetch handler as default.
|
|
263
|
-
//
|
|
264
|
-
|
|
265
|
-
//
|
|
266
|
-
|
|
255
|
+
// The Vite RSC plugin outputs it to rsc/index.js.
|
|
256
|
+
let serverEntryRelative = relative(outDir, join(buildDir, 'rsc', 'index.js'));
|
|
257
|
+
// Ensure the import path starts with ./ for ESM compatibility
|
|
258
|
+
if (!serverEntryRelative.startsWith('.')) {
|
|
259
|
+
serverEntryRelative = './' + serverEntryRelative;
|
|
260
|
+
}
|
|
267
261
|
const runtimeName = PRESET_CONFIGS[preset].runtimeName;
|
|
268
262
|
const earlyHints = PRESET_CONFIGS[preset].supportsEarlyHints;
|
|
269
263
|
|
|
264
|
+
// Build manifest init must be imported before the handler so that
|
|
265
|
+
// globalThis.__TIMBER_BUILD_MANIFEST__ is set when the virtual module evaluates.
|
|
266
|
+
const manifestImport = hasManifestInit ? "import './_timber-manifest-init.js'\n" : '';
|
|
267
|
+
|
|
270
268
|
// On node-server and bun, wrap the handler with ALS so the pipeline
|
|
271
269
|
// can send 103 Early Hints via res.writeEarlyHints(). Other presets
|
|
272
270
|
// either don't support 103 or handle it at the CDN level.
|
|
@@ -284,18 +282,19 @@ export function generateNitroEntry(
|
|
|
284
282
|
return `// Generated by @timber-js/app/adapters/nitro
|
|
285
283
|
// Do not edit — this file is regenerated on each build.
|
|
286
284
|
|
|
287
|
-
import { defineEventHandler } from '
|
|
285
|
+
${manifestImport}import { defineEventHandler, toWebRequest, sendWebResponse } from 'h3'
|
|
288
286
|
import handler, { runWithEarlyHintsSender } from '${serverEntryRelative}'
|
|
287
|
+
import { compressResponse } from './_compress.mjs'
|
|
289
288
|
|
|
290
289
|
// Set TIMBER_RUNTIME for instrumentation.ts conditional SDK initialization.
|
|
291
290
|
// See design/25-production-deployments.md §"TIMBER_RUNTIME".
|
|
292
291
|
process.env.TIMBER_RUNTIME = '${runtimeName}'
|
|
293
292
|
|
|
294
293
|
export default defineEventHandler(async (event) => {
|
|
295
|
-
|
|
296
|
-
const webRequest = event.req
|
|
294
|
+
const webRequest = toWebRequest(event)
|
|
297
295
|
${handlerCall}
|
|
298
|
-
|
|
296
|
+
const finalResponse = compressResponse(webRequest, webResponse)
|
|
297
|
+
return sendWebResponse(event, finalResponse)
|
|
299
298
|
})
|
|
300
299
|
`;
|
|
301
300
|
}
|
|
@@ -309,7 +308,6 @@ export function generateNitroConfig(
|
|
|
309
308
|
|
|
310
309
|
const config: Record<string, unknown> = {
|
|
311
310
|
preset: presetConfig.nitroPreset,
|
|
312
|
-
entry: './entry.ts',
|
|
313
311
|
output: { dir: presetConfig.outputDir },
|
|
314
312
|
// Static asset cache headers — hashed assets are immutable, others get 1h.
|
|
315
313
|
// See design/25-production-deployments.md §"CDN / Edge Cache"
|
|
@@ -325,7 +323,7 @@ export function generateNitroConfig(
|
|
|
325
323
|
return `// Generated by @timber-js/app/adapters/nitro
|
|
326
324
|
// Do not edit — this file is regenerated on each build.
|
|
327
325
|
|
|
328
|
-
import { defineNitroConfig } from '
|
|
326
|
+
import { defineNitroConfig } from 'nitropack/config'
|
|
329
327
|
|
|
330
328
|
export default defineNitroConfig(${configJson})
|
|
331
329
|
`;
|
|
@@ -374,6 +372,9 @@ if (existsSync(manifestPath)) {
|
|
|
374
372
|
// Import the RSC handler (default export is the fetch-like handler).
|
|
375
373
|
const { default: handler, runWithEarlyHintsSender } = await import('${rscEntry}');
|
|
376
374
|
|
|
375
|
+
// Import compression helper for self-hosted response compression.
|
|
376
|
+
const { compressResponse } = await import('./_compress.mjs');
|
|
377
|
+
|
|
377
378
|
const MIME_TYPES = {
|
|
378
379
|
'.html': 'text/html',
|
|
379
380
|
'.js': 'application/javascript',
|
|
@@ -401,10 +402,9 @@ const MIME_TYPES = {
|
|
|
401
402
|
|
|
402
403
|
const publicDir = join(__dirname, '${publicDir}');
|
|
403
404
|
const port = parseInt(process.env.PORT || '3000', 10);
|
|
404
|
-
const host = process.env.HOST || process.env.HOSTNAME || 'localhost';
|
|
405
405
|
|
|
406
406
|
const server = createServer(async (req, res) => {
|
|
407
|
-
const url = new URL(req.url || '/', \`http
|
|
407
|
+
const url = new URL(req.url || '/', \`http://localhost:\${port}\`);
|
|
408
408
|
|
|
409
409
|
// Try serving static files from the public directory first.
|
|
410
410
|
const filePath = join(publicDir, url.pathname);
|
|
@@ -467,10 +467,13 @@ const server = createServer(async (req, res) => {
|
|
|
467
467
|
? (links) => { try { res.writeEarlyHints({ link: links }); } catch {} }
|
|
468
468
|
: undefined;
|
|
469
469
|
|
|
470
|
-
const
|
|
470
|
+
const rawResponse = earlyHintsSender && runWithEarlyHintsSender
|
|
471
471
|
? await runWithEarlyHintsSender(earlyHintsSender, () => handler(webRequest))
|
|
472
472
|
: await handler(webRequest);
|
|
473
473
|
|
|
474
|
+
// Compress the response for self-hosted deployments.
|
|
475
|
+
const webResponse = compressResponse(webRequest, rawResponse);
|
|
476
|
+
|
|
474
477
|
// Write the response back to the Node response.
|
|
475
478
|
res.writeHead(webResponse.status, Object.fromEntries(webResponse.headers.entries()));
|
|
476
479
|
|
|
@@ -496,11 +499,11 @@ const server = createServer(async (req, res) => {
|
|
|
496
499
|
}
|
|
497
500
|
});
|
|
498
501
|
|
|
499
|
-
server.listen(port,
|
|
502
|
+
server.listen(port, () => {
|
|
500
503
|
console.log();
|
|
501
504
|
console.log(' ⚡ timber preview server running at:');
|
|
502
505
|
console.log();
|
|
503
|
-
console.log(\` ➜ http
|
|
506
|
+
console.log(\` ➜ http://localhost:\${port}\`);
|
|
504
507
|
console.log();
|
|
505
508
|
});
|
|
506
509
|
`;
|
|
@@ -531,47 +534,6 @@ export function generateNitroPreviewCommand(
|
|
|
531
534
|
};
|
|
532
535
|
}
|
|
533
536
|
|
|
534
|
-
/**
|
|
535
|
-
* Run the Nitro production build using the programmatic API.
|
|
536
|
-
* Uses dynamic import so nitro is only loaded at build time.
|
|
537
|
-
* Externalizes the timber RSC/SSR output — those files are pre-built
|
|
538
|
-
* by timber and have internal references that nitro's bundler can't follow.
|
|
539
|
-
*/
|
|
540
|
-
async function runNitroBuild(
|
|
541
|
-
nitroDir: string,
|
|
542
|
-
preset: NitroPreset,
|
|
543
|
-
userConfig?: Record<string, unknown>
|
|
544
|
-
): Promise<void> {
|
|
545
|
-
const presetConfig = PRESET_CONFIGS[preset];
|
|
546
|
-
const { createNitro, build: nitroBuild, prepare, copyPublicAssets } = await import('nitro');
|
|
547
|
-
|
|
548
|
-
const nitro = await createNitro({
|
|
549
|
-
rootDir: nitroDir,
|
|
550
|
-
preset: presetConfig.nitroPreset,
|
|
551
|
-
// Use renderer.entry so Nitro wraps our handler with its server runtime
|
|
552
|
-
// (HTTP server, static file serving, graceful shutdown, etc.).
|
|
553
|
-
// Using `entry` directly would bypass the Nitro server runtime.
|
|
554
|
-
renderer: { entry: join(nitroDir, 'entry.ts') },
|
|
555
|
-
output: { dir: join(nitroDir, presetConfig.outputDir) },
|
|
556
|
-
routeRules: {
|
|
557
|
-
'/assets/**': { headers: { 'Cache-Control': IMMUTABLE_CACHE } },
|
|
558
|
-
},
|
|
559
|
-
// Don't bundle the timber RSC/SSR build output — it has its own
|
|
560
|
-
// internal file references that nitro's bundler can't follow.
|
|
561
|
-
// Mark them as external so rollup leaves the imports as-is.
|
|
562
|
-
rollupConfig: {
|
|
563
|
-
external: [/\.\.\/rsc\//],
|
|
564
|
-
},
|
|
565
|
-
...presetConfig.extraConfig,
|
|
566
|
-
...userConfig,
|
|
567
|
-
});
|
|
568
|
-
|
|
569
|
-
await prepare(nitro);
|
|
570
|
-
await copyPublicAssets(nitro);
|
|
571
|
-
await nitroBuild(nitro);
|
|
572
|
-
await nitro.close();
|
|
573
|
-
}
|
|
574
|
-
|
|
575
537
|
/** Spawn a Nitro preview process and pipe stdio. */
|
|
576
538
|
function spawnNitroPreview(command: string, args: string[], cwd: string): Promise<void> {
|
|
577
539
|
return new Promise<void>((resolve, reject) => {
|
|
@@ -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.
|