@timber-js/app 0.1.24 → 0.1.26
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/nitro.d.ts +9 -0
- package/dist/adapters/nitro.d.ts.map +1 -1
- package/dist/adapters/nitro.js +175 -7
- package/dist/adapters/nitro.js.map +1 -1
- package/dist/cli.js +2 -2
- package/dist/cli.js.map +1 -1
- package/dist/client/browser-dev.d.ts +29 -0
- package/dist/client/browser-dev.d.ts.map +1 -0
- package/dist/client/browser-links.d.ts +32 -0
- package/dist/client/browser-links.d.ts.map +1 -0
- package/dist/client/index.d.ts +1 -1
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +46 -20
- package/dist/client/index.js.map +1 -1
- package/dist/client/navigation-context.d.ts +10 -8
- package/dist/client/navigation-context.d.ts.map +1 -1
- package/dist/client/transition-root.d.ts +54 -0
- package/dist/client/transition-root.d.ts.map +1 -0
- package/dist/client/use-router.d.ts +14 -0
- package/dist/client/use-router.d.ts.map +1 -1
- package/dist/server/index.js +264 -218
- package/dist/server/index.js.map +1 -1
- package/dist/server/metadata-platform.d.ts +34 -0
- package/dist/server/metadata-platform.d.ts.map +1 -0
- package/dist/server/metadata-render.d.ts.map +1 -1
- package/dist/server/metadata-social.d.ts +24 -0
- package/dist/server/metadata-social.d.ts.map +1 -0
- package/dist/server/pipeline-interception.d.ts +32 -0
- package/dist/server/pipeline-interception.d.ts.map +1 -0
- package/dist/server/pipeline-metadata.d.ts +31 -0
- package/dist/server/pipeline-metadata.d.ts.map +1 -0
- package/dist/server/pipeline.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/adapters/nitro.ts +187 -10
- package/src/cli.ts +10 -3
- package/src/client/browser-dev.ts +142 -0
- package/src/client/browser-entry.ts +32 -222
- package/src/client/browser-links.ts +90 -0
- package/src/client/index.ts +1 -1
- package/src/client/navigation-context.ts +39 -9
- package/src/client/transition-root.tsx +86 -0
- package/src/client/use-router.ts +17 -15
- package/src/server/metadata-platform.ts +229 -0
- package/src/server/metadata-render.ts +9 -363
- package/src/server/metadata-social.ts +184 -0
- package/src/server/pipeline-interception.ts +76 -0
- package/src/server/pipeline-metadata.ts +90 -0
- package/src/server/pipeline.ts +2 -148
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Platform-specific metadata rendering — icons, Apple Web App, App Links, iTunes.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from metadata-render.ts to keep files under 500 lines.
|
|
5
|
+
*
|
|
6
|
+
* See design/16-metadata.md
|
|
7
|
+
*/
|
|
8
|
+
import type { Metadata } from './types.js';
|
|
9
|
+
import type { HeadElement } from './metadata.js';
|
|
10
|
+
/**
|
|
11
|
+
* Render icon link elements (favicon, shortcut, apple-touch-icon, custom).
|
|
12
|
+
*/
|
|
13
|
+
export declare function renderIcons(icons: NonNullable<Metadata['icons']>, elements: HeadElement[]): void;
|
|
14
|
+
/**
|
|
15
|
+
* Render alternate link elements (canonical, hreflang, media, types).
|
|
16
|
+
*/
|
|
17
|
+
export declare function renderAlternates(alternates: NonNullable<Metadata['alternates']>, elements: HeadElement[]): void;
|
|
18
|
+
/**
|
|
19
|
+
* Render site verification meta tags (Google, Yahoo, Yandex, custom).
|
|
20
|
+
*/
|
|
21
|
+
export declare function renderVerification(verification: NonNullable<Metadata['verification']>, elements: HeadElement[]): void;
|
|
22
|
+
/**
|
|
23
|
+
* Render Apple Web App meta tags and startup image links.
|
|
24
|
+
*/
|
|
25
|
+
export declare function renderAppleWebApp(appleWebApp: NonNullable<Metadata['appleWebApp']>, elements: HeadElement[]): void;
|
|
26
|
+
/**
|
|
27
|
+
* Render App Links (al:*) meta tags for deep linking across platforms.
|
|
28
|
+
*/
|
|
29
|
+
export declare function renderAppLinks(appLinks: NonNullable<Metadata['appLinks']>, elements: HeadElement[]): void;
|
|
30
|
+
/**
|
|
31
|
+
* Render Apple iTunes smart banner meta tag.
|
|
32
|
+
*/
|
|
33
|
+
export declare function renderItunes(itunes: NonNullable<Metadata['itunes']>, elements: HeadElement[]): void;
|
|
34
|
+
//# sourceMappingURL=metadata-platform.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"metadata-platform.d.ts","sourceRoot":"","sources":["../../src/server/metadata-platform.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAC3C,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAEjD;;GAEG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,WAAW,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC,EAAE,QAAQ,EAAE,WAAW,EAAE,GAAG,IAAI,CA6ChG;AAED;;GAEG;AACH,wBAAgB,gBAAgB,CAC9B,UAAU,EAAE,WAAW,CAAC,QAAQ,CAAC,YAAY,CAAC,CAAC,EAC/C,QAAQ,EAAE,WAAW,EAAE,GACtB,IAAI,CA+BN;AAED;;GAEG;AACH,wBAAgB,kBAAkB,CAChC,YAAY,EAAE,WAAW,CAAC,QAAQ,CAAC,cAAc,CAAC,CAAC,EACnD,QAAQ,EAAE,WAAW,EAAE,GACtB,IAAI,CAkBN;AAED;;GAEG;AACH,wBAAgB,iBAAiB,CAC/B,WAAW,EAAE,WAAW,CAAC,QAAQ,CAAC,aAAa,CAAC,CAAC,EACjD,QAAQ,EAAE,WAAW,EAAE,GACtB,IAAI,CAmCN;AAED;;GAEG;AACH,wBAAgB,cAAc,CAC5B,QAAQ,EAAE,WAAW,CAAC,QAAQ,CAAC,UAAU,CAAC,CAAC,EAC3C,QAAQ,EAAE,WAAW,EAAE,GACtB,IAAI,CAwCN;AAED;;GAEG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,WAAW,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC,EAAE,QAAQ,EAAE,WAAW,EAAE,GAAG,IAAI,CAQnG"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"metadata-render.d.ts","sourceRoot":"","sources":["../../src/server/metadata-render.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAC3C,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;
|
|
1
|
+
{"version":3,"file":"metadata-render.d.ts","sourceRoot":"","sources":["../../src/server/metadata-render.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAC3C,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAajD;;;;;;;;GAQG;AACH,wBAAgB,wBAAwB,CAAC,QAAQ,EAAE,QAAQ,GAAG,WAAW,EAAE,CAkI1E"}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Social metadata rendering — Open Graph and Twitter Card meta tags.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from metadata-render.ts to keep files under 500 lines.
|
|
5
|
+
*
|
|
6
|
+
* See design/16-metadata.md
|
|
7
|
+
*/
|
|
8
|
+
import type { Metadata } from './types.js';
|
|
9
|
+
import type { HeadElement } from './metadata.js';
|
|
10
|
+
/**
|
|
11
|
+
* Render Open Graph metadata into head element descriptors.
|
|
12
|
+
*
|
|
13
|
+
* Handles og:title, og:description, og:image (with dimensions/alt),
|
|
14
|
+
* og:video, og:audio, og:article:author, and other OG properties.
|
|
15
|
+
*/
|
|
16
|
+
export declare function renderOpenGraph(og: NonNullable<Metadata['openGraph']>, elements: HeadElement[]): void;
|
|
17
|
+
/**
|
|
18
|
+
* Render Twitter Card metadata into head element descriptors.
|
|
19
|
+
*
|
|
20
|
+
* Handles twitter:card, twitter:site, twitter:title, twitter:image,
|
|
21
|
+
* twitter:player, and twitter:app (per-platform name/id/url).
|
|
22
|
+
*/
|
|
23
|
+
export declare function renderTwitter(tw: NonNullable<Metadata['twitter']>, elements: HeadElement[]): void;
|
|
24
|
+
//# sourceMappingURL=metadata-social.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"metadata-social.d.ts","sourceRoot":"","sources":["../../src/server/metadata-social.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAC3C,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAEjD;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,EAAE,EAAE,WAAW,CAAC,QAAQ,CAAC,WAAW,CAAC,CAAC,EAAE,QAAQ,EAAE,WAAW,EAAE,GAAG,IAAI,CAoErG;AAED;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,EAAE,EAAE,WAAW,CAAC,QAAQ,CAAC,SAAS,CAAC,CAAC,EAAE,QAAQ,EAAE,WAAW,EAAE,GAAG,IAAI,CA0FjG"}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interception route matching for the request pipeline.
|
|
3
|
+
*
|
|
4
|
+
* Matches target URLs against interception rewrites to support the
|
|
5
|
+
* modal route pattern (soft navigation intercepts).
|
|
6
|
+
*
|
|
7
|
+
* Extracted from pipeline.ts to keep files under 500 lines.
|
|
8
|
+
*
|
|
9
|
+
* See design/07-routing.md §"Intercepting Routes"
|
|
10
|
+
*/
|
|
11
|
+
/** Result of a successful interception match. */
|
|
12
|
+
export interface InterceptionMatchResult {
|
|
13
|
+
/** The pathname to re-match (the source/intercepting route's parent). */
|
|
14
|
+
sourcePathname: string;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Check if an intercepting route applies for this soft navigation.
|
|
18
|
+
*
|
|
19
|
+
* Matches the target pathname against interception rewrites, constrained
|
|
20
|
+
* by the source URL (X-Timber-URL header — where the user navigates FROM).
|
|
21
|
+
*
|
|
22
|
+
* Returns the source pathname to re-match if interception applies, or null.
|
|
23
|
+
*/
|
|
24
|
+
export declare function findInterceptionMatch(targetPathname: string, sourceUrl: string, rewrites: import('#/routing/interception.js').InterceptionRewrite[]): InterceptionMatchResult | null;
|
|
25
|
+
/**
|
|
26
|
+
* Check if a pathname matches a URL pattern with dynamic segments.
|
|
27
|
+
*
|
|
28
|
+
* Supports [param] (single segment) and [...param] (one or more segments).
|
|
29
|
+
* Static segments must match exactly.
|
|
30
|
+
*/
|
|
31
|
+
export declare function pathnameMatchesPattern(pathname: string, pattern: string): boolean;
|
|
32
|
+
//# sourceMappingURL=pipeline-interception.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pipeline-interception.d.ts","sourceRoot":"","sources":["../../src/server/pipeline-interception.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,iDAAiD;AACjD,MAAM,WAAW,uBAAuB;IACtC,yEAAyE;IACzE,cAAc,EAAE,MAAM,CAAC;CACxB;AAED;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CACnC,cAAc,EAAE,MAAM,EACtB,SAAS,EAAE,MAAM,EACjB,QAAQ,EAAE,OAAO,2BAA2B,EAAE,mBAAmB,EAAE,GAClE,uBAAuB,GAAG,IAAI,CAYhC;AAED;;;;;GAKG;AACH,wBAAgB,sBAAsB,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CA0BjF"}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Metadata route helpers for the request pipeline.
|
|
3
|
+
*
|
|
4
|
+
* Handles serving static metadata files and serializing sitemap responses.
|
|
5
|
+
* Extracted from pipeline.ts to keep files under 500 lines.
|
|
6
|
+
*
|
|
7
|
+
* See design/16-metadata.md §"Metadata Routes"
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Serve a static metadata file by reading it from disk.
|
|
11
|
+
*
|
|
12
|
+
* Static metadata route files (.xml, .txt, .json, .png, .ico, .svg, etc.)
|
|
13
|
+
* are served as-is with the appropriate Content-Type header.
|
|
14
|
+
* Text files include charset=utf-8; binary files do not.
|
|
15
|
+
*
|
|
16
|
+
* See design/16-metadata.md §"Metadata Routes"
|
|
17
|
+
*/
|
|
18
|
+
export declare function serveStaticMetadataFile(metaMatch: import('./route-matcher.js').MetadataRouteMatch): Promise<Response>;
|
|
19
|
+
/**
|
|
20
|
+
* Serialize a sitemap array to XML.
|
|
21
|
+
* Follows the sitemap.org protocol: https://www.sitemaps.org/protocol.html
|
|
22
|
+
*/
|
|
23
|
+
export declare function serializeSitemap(entries: Array<{
|
|
24
|
+
url: string;
|
|
25
|
+
lastModified?: string | Date;
|
|
26
|
+
changeFrequency?: string;
|
|
27
|
+
priority?: number;
|
|
28
|
+
}>): string;
|
|
29
|
+
/** Escape special XML characters. */
|
|
30
|
+
export declare function escapeXml(str: string): string;
|
|
31
|
+
//# sourceMappingURL=pipeline-metadata.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pipeline-metadata.d.ts","sourceRoot":"","sources":["../../src/server/pipeline-metadata.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAgBH;;;;;;;;GAQG;AACH,wBAAsB,uBAAuB,CAC3C,SAAS,EAAE,OAAO,oBAAoB,EAAE,kBAAkB,GACzD,OAAO,CAAC,QAAQ,CAAC,CAYnB;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAC9B,OAAO,EAAE,KAAK,CAAC;IACb,GAAG,EAAE,MAAM,CAAC;IACZ,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB,CAAC,GACD,MAAM,CAoBR;AAED,qCAAqC;AACrC,wBAAgB,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAO7C"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"pipeline.d.ts","sourceRoot":"","sources":["../../src/server/pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;
|
|
1
|
+
{"version":3,"file":"pipeline.d.ts","sourceRoot":"","sources":["../../src/server/pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAGH,OAAO,EAAY,KAAK,WAAW,EAAE,MAAM,YAAY,CAAC;AACxD,OAAO,EAAiB,KAAK,YAAY,EAAE,MAAM,wBAAwB,CAAC;AAoC1E,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAItD,sEAAsE;AACtE,MAAM,WAAW,UAAU;IACzB,mDAAmD;IACnD,QAAQ,EAAE,WAAW,EAAE,CAAC;IACxB,oEAAoE;IACpE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC,CAAC;IAC1C,uDAAuD;IACvD,UAAU,CAAC,EAAE,YAAY,CAAC;CAC3B;AAED,6DAA6D;AAC7D,MAAM,MAAM,YAAY,GAAG,CAAC,QAAQ,EAAE,MAAM,KAAK,UAAU,GAAG,IAAI,CAAC;AAEnE,sEAAsE;AACtE,MAAM,MAAM,oBAAoB,GAAG,CACjC,QAAQ,EAAE,MAAM,KACb,OAAO,oBAAoB,EAAE,kBAAkB,GAAG,IAAI,CAAC;AAE5D,iEAAiE;AACjE,MAAM,WAAW,mBAAmB;IAClC,iEAAiE;IACjE,cAAc,EAAE,MAAM,CAAC;CACxB;AAED,6DAA6D;AAC7D,MAAM,MAAM,aAAa,GAAG,CAC1B,GAAG,EAAE,OAAO,EACZ,KAAK,EAAE,UAAU,EACjB,eAAe,EAAE,OAAO,EACxB,oBAAoB,EAAE,OAAO,EAC7B,YAAY,CAAC,EAAE,mBAAmB,KAC/B,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;AAElC,+DAA+D;AAC/D,MAAM,MAAM,iBAAiB,GAAG,CAC9B,KAAK,EAAE,UAAU,EACjB,GAAG,EAAE,OAAO,EACZ,eAAe,EAAE,OAAO,KACrB,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;AAI1B,MAAM,WAAW,cAAc;IAC7B,iFAAiF;IACjF,KAAK,CAAC,EAAE,WAAW,CAAC;IACpB,gFAAgF;IAChF,WAAW,CAAC,EAAE,MAAM,OAAO,CAAC;QAAE,OAAO,EAAE,WAAW,CAAA;KAAE,CAAC,CAAC;IACtD,qEAAqE;IACrE,UAAU,EAAE,YAAY,CAAC;IACzB,iGAAiG;IACjG,kBAAkB,CAAC,EAAE,oBAAoB,CAAC;IAC1C,kEAAkE;IAClE,MAAM,EAAE,aAAa,CAAC;IACtB,kEAAkE;IAClE,aAAa,CAAC,EAAE,CAAC,GAAG,EAAE,OAAO,EAAE,eAAe,EAAE,OAAO,KAAK,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;IACzF,kFAAkF;IAClF,UAAU,CAAC,EAAE,iBAAiB,CAAC;IAC/B,gFAAgF;IAChF,kBAAkB,CAAC,EAAE,OAAO,CAAC;IAC7B,yGAAyG;IACzG,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;;OAIG;IACH,oBAAoB,CAAC,EAAE,OAAO,2BAA2B,EAAE,mBAAmB,EAAE,CAAC;IACjF;;;;;OAKG;IACH,kBAAkB,CAAC,EAAE,OAAO,CAAC;IAC7B;;;;;;OAMG;IACH,eAAe,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACxD;;;;;;;;;;OAUG;IACH,mBAAmB,CAAC,EAAE,CACpB,KAAK,EAAE,OAAO,EACd,GAAG,EAAE,OAAO,EACZ,eAAe,EAAE,OAAO,KACrB,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;CACnC;AAID;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,cAAc,GAAG,CAAC,GAAG,EAAE,OAAO,KAAK,OAAO,CAAC,QAAQ,CAAC,CAuU1F"}
|
package/package.json
CHANGED
package/src/adapters/nitro.ts
CHANGED
|
@@ -213,9 +213,15 @@ export function nitro(options: NitroAdapterOptions = {}): TimberPlatformAdapter
|
|
|
213
213
|
// local runtime — Vite's built-in preview is the fallback.
|
|
214
214
|
preview: LOCALLY_PREVIEWABLE.has(preset)
|
|
215
215
|
? async (_config: TimberConfig, buildDir: string) => {
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
216
|
+
// Generate a standalone preview server that uses Node's built-in
|
|
217
|
+
// HTTP server. The Nitro entry.ts can't be run directly because
|
|
218
|
+
// it imports h3 (a Nitro dependency not available at runtime).
|
|
219
|
+
const previewScript = generatePreviewScript(buildDir, preset);
|
|
220
|
+
const scriptPath = join(buildDir, 'nitro', '_preview-server.mjs');
|
|
221
|
+
await writeFile(scriptPath, previewScript);
|
|
222
|
+
|
|
223
|
+
const command = preset === 'bun' ? 'bun' : 'node';
|
|
224
|
+
await spawnNitroPreview(command, [scriptPath], join(buildDir, 'nitro'));
|
|
219
225
|
}
|
|
220
226
|
: undefined,
|
|
221
227
|
|
|
@@ -239,7 +245,13 @@ export function generateNitroEntry(
|
|
|
239
245
|
preset: NitroPreset,
|
|
240
246
|
hasManifestInit = false
|
|
241
247
|
): string {
|
|
242
|
-
|
|
248
|
+
// The RSC entry is the main request handler — it exports the fetch handler as default.
|
|
249
|
+
// The Vite RSC plugin outputs it to rsc/index.js.
|
|
250
|
+
let serverEntryRelative = relative(outDir, join(buildDir, 'rsc', 'index.js'));
|
|
251
|
+
// Ensure the import path starts with ./ for ESM compatibility
|
|
252
|
+
if (!serverEntryRelative.startsWith('.')) {
|
|
253
|
+
serverEntryRelative = './' + serverEntryRelative;
|
|
254
|
+
}
|
|
243
255
|
const runtimeName = PRESET_CONFIGS[preset].runtimeName;
|
|
244
256
|
const earlyHints = PRESET_CONFIGS[preset].supportsEarlyHints;
|
|
245
257
|
|
|
@@ -250,10 +262,6 @@ export function generateNitroEntry(
|
|
|
250
262
|
// On node-server and bun, wrap the handler with ALS so the pipeline
|
|
251
263
|
// can send 103 Early Hints via res.writeEarlyHints(). Other presets
|
|
252
264
|
// either don't support 103 or handle it at the CDN level.
|
|
253
|
-
const earlyHintsImport = earlyHints
|
|
254
|
-
? `import { runWithEarlyHintsSender } from '${serverEntryRelative}'\n`
|
|
255
|
-
: '';
|
|
256
|
-
|
|
257
265
|
const handlerCall = earlyHints
|
|
258
266
|
? ` const nodeRes = event.node?.res
|
|
259
267
|
const earlyHintsSender = (typeof nodeRes?.writeEarlyHints === 'function')
|
|
@@ -268,8 +276,8 @@ export function generateNitroEntry(
|
|
|
268
276
|
return `// Generated by @timber-js/app/adapters/nitro
|
|
269
277
|
// Do not edit — this file is regenerated on each build.
|
|
270
278
|
|
|
271
|
-
${manifestImport}
|
|
272
|
-
import {
|
|
279
|
+
${manifestImport}import { defineEventHandler, toWebRequest, sendWebResponse } from 'h3'
|
|
280
|
+
import handler, { runWithEarlyHintsSender } from '${serverEntryRelative}'
|
|
273
281
|
|
|
274
282
|
// Set TIMBER_RUNTIME for instrumentation.ts conditional SDK initialization.
|
|
275
283
|
// See design/25-production-deployments.md §"TIMBER_RUNTIME".
|
|
@@ -318,6 +326,175 @@ export default defineNitroConfig(${configJson})
|
|
|
318
326
|
/** Presets that produce a locally-runnable server entry. */
|
|
319
327
|
const LOCALLY_PREVIEWABLE = new Set<NitroPreset>(['node-server', 'bun']);
|
|
320
328
|
|
|
329
|
+
/**
|
|
330
|
+
* Generate a standalone preview server script that uses Node's built-in
|
|
331
|
+
* HTTP server. This bypasses Nitro entirely — the Nitro entry.ts imports
|
|
332
|
+
* h3 which isn't available outside a Nitro build. For local preview we
|
|
333
|
+
* just need to serve static files and route requests to the RSC handler.
|
|
334
|
+
*
|
|
335
|
+
* @internal Exported for testing.
|
|
336
|
+
*/
|
|
337
|
+
export function generatePreviewScript(buildDir: string, preset: NitroPreset): string {
|
|
338
|
+
const rscEntryRelative = relative(join(buildDir, 'nitro'), join(buildDir, 'rsc', 'index.js'));
|
|
339
|
+
const rscEntry = rscEntryRelative.startsWith('.') ? rscEntryRelative : './' + rscEntryRelative;
|
|
340
|
+
const publicDir = './public';
|
|
341
|
+
const manifestInitPath = './_timber-manifest-init.js';
|
|
342
|
+
const runtimeName = PRESET_CONFIGS[preset].runtimeName;
|
|
343
|
+
|
|
344
|
+
return `// Generated by @timber-js/app — standalone preview server.
|
|
345
|
+
// Uses Node's built-in HTTP server to serve static assets and route
|
|
346
|
+
// dynamic requests through the RSC handler. No Nitro/h3 dependency.
|
|
347
|
+
|
|
348
|
+
import { createServer } from 'node:http';
|
|
349
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
350
|
+
import { join, extname } from 'node:path';
|
|
351
|
+
import { fileURLToPath } from 'node:url';
|
|
352
|
+
import { existsSync } from 'node:fs';
|
|
353
|
+
|
|
354
|
+
// Set runtime before importing the handler.
|
|
355
|
+
process.env.TIMBER_RUNTIME = '${runtimeName}';
|
|
356
|
+
|
|
357
|
+
// Load the build manifest if it exists.
|
|
358
|
+
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
|
359
|
+
const manifestPath = join(__dirname, '${manifestInitPath}');
|
|
360
|
+
if (existsSync(manifestPath)) {
|
|
361
|
+
await import('${manifestInitPath}');
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Import the RSC handler (default export is the fetch-like handler).
|
|
365
|
+
const { default: handler, runWithEarlyHintsSender } = await import('${rscEntry}');
|
|
366
|
+
|
|
367
|
+
const MIME_TYPES = {
|
|
368
|
+
'.html': 'text/html',
|
|
369
|
+
'.js': 'application/javascript',
|
|
370
|
+
'.mjs': 'application/javascript',
|
|
371
|
+
'.css': 'text/css',
|
|
372
|
+
'.json': 'application/json',
|
|
373
|
+
'.png': 'image/png',
|
|
374
|
+
'.jpg': 'image/jpeg',
|
|
375
|
+
'.jpeg': 'image/jpeg',
|
|
376
|
+
'.gif': 'image/gif',
|
|
377
|
+
'.svg': 'image/svg+xml',
|
|
378
|
+
'.ico': 'image/x-icon',
|
|
379
|
+
'.woff': 'font/woff',
|
|
380
|
+
'.woff2': 'font/woff2',
|
|
381
|
+
'.ttf': 'font/ttf',
|
|
382
|
+
'.otf': 'font/otf',
|
|
383
|
+
'.webp': 'image/webp',
|
|
384
|
+
'.avif': 'image/avif',
|
|
385
|
+
'.webm': 'video/webm',
|
|
386
|
+
'.mp4': 'video/mp4',
|
|
387
|
+
'.txt': 'text/plain',
|
|
388
|
+
'.xml': 'application/xml',
|
|
389
|
+
'.wasm': 'application/wasm',
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
const publicDir = join(__dirname, '${publicDir}');
|
|
393
|
+
const port = parseInt(process.env.PORT || '3000', 10);
|
|
394
|
+
|
|
395
|
+
const server = createServer(async (req, res) => {
|
|
396
|
+
const url = new URL(req.url || '/', \`http://localhost:\${port}\`);
|
|
397
|
+
|
|
398
|
+
// Try serving static files from the public directory first.
|
|
399
|
+
const filePath = join(publicDir, url.pathname);
|
|
400
|
+
// Prevent path traversal.
|
|
401
|
+
if (filePath.startsWith(publicDir)) {
|
|
402
|
+
try {
|
|
403
|
+
const fileStat = await stat(filePath);
|
|
404
|
+
if (fileStat.isFile()) {
|
|
405
|
+
const ext = extname(filePath);
|
|
406
|
+
const contentType = MIME_TYPES[ext] || 'application/octet-stream';
|
|
407
|
+
const body = await readFile(filePath);
|
|
408
|
+
// Hashed assets get immutable cache, others get short cache.
|
|
409
|
+
const cacheControl = url.pathname.startsWith('/assets/')
|
|
410
|
+
? 'public, max-age=31536000, immutable'
|
|
411
|
+
: 'public, max-age=3600, must-revalidate';
|
|
412
|
+
res.writeHead(200, {
|
|
413
|
+
'Content-Type': contentType,
|
|
414
|
+
'Content-Length': body.length,
|
|
415
|
+
'Cache-Control': cacheControl,
|
|
416
|
+
});
|
|
417
|
+
res.end(body);
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
} catch {
|
|
421
|
+
// File not found — fall through to the RSC handler.
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Convert Node request to Web Request.
|
|
426
|
+
const headers = new Headers();
|
|
427
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
428
|
+
if (value) {
|
|
429
|
+
if (Array.isArray(value)) {
|
|
430
|
+
for (const v of value) headers.append(key, v);
|
|
431
|
+
} else {
|
|
432
|
+
headers.set(key, value);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
let body = undefined;
|
|
438
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
439
|
+
body = await new Promise((resolve) => {
|
|
440
|
+
const chunks = [];
|
|
441
|
+
req.on('data', (chunk) => chunks.push(chunk));
|
|
442
|
+
req.on('end', () => resolve(Buffer.concat(chunks)));
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const webRequest = new Request(url.href, {
|
|
447
|
+
method: req.method,
|
|
448
|
+
headers,
|
|
449
|
+
body,
|
|
450
|
+
duplex: body ? 'half' : undefined,
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
try {
|
|
454
|
+
// Support 103 Early Hints when available.
|
|
455
|
+
const earlyHintsSender = (typeof res.writeEarlyHints === 'function')
|
|
456
|
+
? (links) => { try { res.writeEarlyHints({ link: links }); } catch {} }
|
|
457
|
+
: undefined;
|
|
458
|
+
|
|
459
|
+
const webResponse = earlyHintsSender && runWithEarlyHintsSender
|
|
460
|
+
? await runWithEarlyHintsSender(earlyHintsSender, () => handler(webRequest))
|
|
461
|
+
: await handler(webRequest);
|
|
462
|
+
|
|
463
|
+
// Write the response back to the Node response.
|
|
464
|
+
res.writeHead(webResponse.status, Object.fromEntries(webResponse.headers.entries()));
|
|
465
|
+
|
|
466
|
+
if (webResponse.body) {
|
|
467
|
+
const reader = webResponse.body.getReader();
|
|
468
|
+
const pump = async () => {
|
|
469
|
+
while (true) {
|
|
470
|
+
const { done, value } = await reader.read();
|
|
471
|
+
if (done) { res.end(); return; }
|
|
472
|
+
res.write(value);
|
|
473
|
+
}
|
|
474
|
+
};
|
|
475
|
+
await pump();
|
|
476
|
+
} else {
|
|
477
|
+
res.end();
|
|
478
|
+
}
|
|
479
|
+
} catch (err) {
|
|
480
|
+
console.error('[timber preview] Request error:', err);
|
|
481
|
+
if (!res.headersSent) {
|
|
482
|
+
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
483
|
+
}
|
|
484
|
+
res.end('Internal Server Error');
|
|
485
|
+
}
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
server.listen(port, () => {
|
|
489
|
+
console.log();
|
|
490
|
+
console.log(' ⚡ timber preview server running at:');
|
|
491
|
+
console.log();
|
|
492
|
+
console.log(\` ➜ http://localhost:\${port}\`);
|
|
493
|
+
console.log();
|
|
494
|
+
});
|
|
495
|
+
`;
|
|
496
|
+
}
|
|
497
|
+
|
|
321
498
|
/** Command descriptor for Nitro preview — testable without spawning. */
|
|
322
499
|
export interface NitroPreviewCommand {
|
|
323
500
|
command: string;
|
package/src/cli.ts
CHANGED
|
@@ -132,7 +132,7 @@ export async function runPreview(options: CommandOptions): Promise<void> {
|
|
|
132
132
|
const adapter = config?.adapter as import('./adapters/types').TimberPlatformAdapter | undefined;
|
|
133
133
|
|
|
134
134
|
if (resolvePreviewStrategy(adapter) === 'adapter') {
|
|
135
|
-
const buildDir = join(root, '
|
|
135
|
+
const buildDir = join(root, 'dist');
|
|
136
136
|
const timberConfig = { output: (config?.output ?? 'server') as 'server' | 'static' };
|
|
137
137
|
await adapter!.preview!(timberConfig, buildDir);
|
|
138
138
|
return;
|
|
@@ -189,9 +189,16 @@ async function main(): Promise<void> {
|
|
|
189
189
|
}
|
|
190
190
|
}
|
|
191
191
|
|
|
192
|
-
//
|
|
192
|
+
// Run main when executed as a CLI (not imported in tests).
|
|
193
|
+
// The bin shim (bin/timber.mjs) does `import '../dist/cli.js'`, so
|
|
194
|
+
// process.argv[1] points to the shim, not this file. We check both:
|
|
195
|
+
// direct execution AND being imported by the timber bin shim.
|
|
193
196
|
const isDirectExecution =
|
|
194
|
-
typeof process !== 'undefined' &&
|
|
197
|
+
typeof process !== 'undefined' &&
|
|
198
|
+
process.argv[1] &&
|
|
199
|
+
(import.meta.url.endsWith(process.argv[1]) ||
|
|
200
|
+
process.argv[1].endsWith('bin/timber.mjs') ||
|
|
201
|
+
process.argv[1].endsWith('bin/timber'));
|
|
195
202
|
|
|
196
203
|
if (isDirectExecution) {
|
|
197
204
|
main().catch((err) => {
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dev-only browser helpers — server log replay and client error forwarding.
|
|
3
|
+
*
|
|
4
|
+
* These are only active when import.meta.hot is available (Vite dev mode).
|
|
5
|
+
* Extracted from browser-entry.ts to keep files under 500 lines.
|
|
6
|
+
*
|
|
7
|
+
* See design/21-dev-server.md §"HMR Wiring"
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { isPageUnloading } from './unload-guard.js';
|
|
11
|
+
|
|
12
|
+
// ─── HMR Hot Interface ──────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
/** Minimal interface for Vite's HMR channel. */
|
|
15
|
+
export interface HotInterface {
|
|
16
|
+
on(event: string, cb: (...args: unknown[]) => void): void;
|
|
17
|
+
send(event: string, data: unknown): void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// ─── Server Log Replay ──────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
/** Payload shape from plugins/dev-logs.ts */
|
|
23
|
+
interface ServerLogPayload {
|
|
24
|
+
level: 'log' | 'warn' | 'error' | 'debug' | 'info';
|
|
25
|
+
args: unknown[];
|
|
26
|
+
location: string | null;
|
|
27
|
+
timestamp: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Deserialize a serialized arg back into a console-friendly value.
|
|
32
|
+
*
|
|
33
|
+
* Handles Error objects (serialized as { __type: 'Error', ... }),
|
|
34
|
+
* Maps, Sets, and passes everything else through.
|
|
35
|
+
*/
|
|
36
|
+
function deserializeArg(arg: unknown): unknown {
|
|
37
|
+
if (arg === '[undefined]') return undefined;
|
|
38
|
+
if (arg === null || typeof arg !== 'object') return arg;
|
|
39
|
+
|
|
40
|
+
const obj = arg as Record<string, unknown>;
|
|
41
|
+
|
|
42
|
+
if (obj.__type === 'Error') {
|
|
43
|
+
const err = new Error(obj.message as string);
|
|
44
|
+
err.name = obj.name as string;
|
|
45
|
+
if (obj.stack) err.stack = obj.stack as string;
|
|
46
|
+
return err;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (obj.__type === 'Map') {
|
|
50
|
+
return new Map(
|
|
51
|
+
Object.entries(obj.entries as Record<string, unknown>).map(([k, v]) => [k, deserializeArg(v)])
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (obj.__type === 'Set') {
|
|
56
|
+
return new Set((obj.values as unknown[]).map(deserializeArg));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (Array.isArray(arg)) {
|
|
60
|
+
return arg.map(deserializeArg);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Plain object — recurse
|
|
64
|
+
const result: Record<string, unknown> = {};
|
|
65
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
66
|
+
result[key] = deserializeArg(value);
|
|
67
|
+
}
|
|
68
|
+
return result;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Set up the HMR listener that replays server console output in the browser.
|
|
73
|
+
*
|
|
74
|
+
* Each message arrives with a log level and serialized args. We prepend
|
|
75
|
+
* a styled "[SERVER]" badge and call the matching console method.
|
|
76
|
+
*/
|
|
77
|
+
export function setupServerLogReplay(hot: Pick<HotInterface, 'on'>): void {
|
|
78
|
+
/** CSS styles for the [SERVER] badge in browser console. */
|
|
79
|
+
const BADGE_STYLES: Record<string, string> = {
|
|
80
|
+
log: 'background: #0070f3; color: white; padding: 1px 5px; border-radius: 3px; font-weight: bold;',
|
|
81
|
+
info: 'background: #0070f3; color: white; padding: 1px 5px; border-radius: 3px; font-weight: bold;',
|
|
82
|
+
warn: 'background: #f5a623; color: white; padding: 1px 5px; border-radius: 3px; font-weight: bold;',
|
|
83
|
+
error:
|
|
84
|
+
'background: #e00; color: white; padding: 1px 5px; border-radius: 3px; font-weight: bold;',
|
|
85
|
+
debug:
|
|
86
|
+
'background: #666; color: white; padding: 1px 5px; border-radius: 3px; font-weight: bold;',
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
hot.on('timber:server-log', (data: unknown) => {
|
|
90
|
+
const payload = data as ServerLogPayload;
|
|
91
|
+
const level = payload.level;
|
|
92
|
+
const fn = console[level] ?? console.log;
|
|
93
|
+
const args = payload.args.map(deserializeArg);
|
|
94
|
+
|
|
95
|
+
const badge = `%cSERVER`;
|
|
96
|
+
const style = BADGE_STYLES[level] ?? BADGE_STYLES.log;
|
|
97
|
+
const locationSuffix = payload.location ? ` (${payload.location})` : '';
|
|
98
|
+
|
|
99
|
+
fn.call(console, badge, style, ...args, locationSuffix ? `\n → ${payload.location}` : '');
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ─── Client Error Forwarding ────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Set up global error handlers that forward uncaught client-side
|
|
107
|
+
* errors to the dev server via Vite's HMR channel.
|
|
108
|
+
*
|
|
109
|
+
* The server receives 'timber:client-error' events, and echoes them
|
|
110
|
+
* back as Vite '{ type: "error" }' payloads to trigger the overlay.
|
|
111
|
+
*/
|
|
112
|
+
export function setupClientErrorForwarding(hot: Pick<HotInterface, 'send'>): void {
|
|
113
|
+
window.addEventListener('error', (event: ErrorEvent) => {
|
|
114
|
+
// Skip errors without useful information
|
|
115
|
+
if (!event.error && !event.message) return;
|
|
116
|
+
// Skip errors during page unload — these are abort-related, not application errors
|
|
117
|
+
if (isPageUnloading()) return;
|
|
118
|
+
|
|
119
|
+
const error = event.error;
|
|
120
|
+
hot.send('timber:client-error', {
|
|
121
|
+
message: error?.message ?? event.message,
|
|
122
|
+
stack: error?.stack ?? '',
|
|
123
|
+
componentStack: error?.componentStack ?? null,
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
window.addEventListener('unhandledrejection', (event: PromiseRejectionEvent) => {
|
|
128
|
+
const reason = event.reason;
|
|
129
|
+
if (!reason) return;
|
|
130
|
+
// Skip rejections during page unload — aborted fetches/streams cause these
|
|
131
|
+
if (isPageUnloading()) return;
|
|
132
|
+
|
|
133
|
+
const message = reason instanceof Error ? reason.message : String(reason);
|
|
134
|
+
const stack = reason instanceof Error ? (reason.stack ?? '') : '';
|
|
135
|
+
|
|
136
|
+
hot.send('timber:client-error', {
|
|
137
|
+
message,
|
|
138
|
+
stack,
|
|
139
|
+
componentStack: null,
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
}
|