@timber-js/app 0.2.0-alpha.56 → 0.2.0-alpha.58
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/LICENSE +8 -0
- package/dist/_chunks/als-registry-Ba7URUIn.js.map +1 -1
- package/dist/_chunks/define-D5STJpIr.js +121 -0
- package/dist/_chunks/define-D5STJpIr.js.map +1 -0
- package/dist/_chunks/{define-cookie-k9btcEfI.js → define-cookie-DtAavax4.js} +4 -4
- package/dist/_chunks/{define-cookie-k9btcEfI.js.map → define-cookie-DtAavax4.js.map} +1 -1
- package/dist/_chunks/{error-boundary-B9vT_YK_.js → error-boundary-DpZJBCqh.js} +1 -1
- package/dist/_chunks/{error-boundary-B9vT_YK_.js.map → error-boundary-DpZJBCqh.js.map} +1 -1
- package/dist/_chunks/{interception-D2djYaIm.js → interception-Cey5DCGr.js} +18 -1
- package/dist/_chunks/interception-Cey5DCGr.js.map +1 -0
- package/dist/_chunks/{request-context-0h-6Voad.js → request-context-0wfZsnhh.js} +3 -1
- package/dist/_chunks/request-context-0wfZsnhh.js.map +1 -0
- package/dist/_chunks/{segment-context-Bmugn-ao.js → segment-context-CyaM1mrD.js} +1 -1
- package/dist/_chunks/{segment-context-Bmugn-ao.js.map → segment-context-CyaM1mrD.js.map} +1 -1
- package/dist/_chunks/{stale-reload-4L-_skC7.js → stale-reload-DKN3aXxR.js} +16 -2
- package/dist/_chunks/stale-reload-DKN3aXxR.js.map +1 -0
- package/dist/_chunks/{tracing-JI4cYUdz.js → tracing-VYETCQsg.js} +1 -1
- package/dist/_chunks/{tracing-JI4cYUdz.js.map → tracing-VYETCQsg.js.map} +1 -1
- package/dist/_chunks/{wrappers-C9XPg7-U.js → wrappers-BaG1bnM3.js} +1 -1
- package/dist/_chunks/{wrappers-C9XPg7-U.js.map → wrappers-BaG1bnM3.js.map} +1 -1
- package/dist/cache/index.js +1 -1
- package/dist/client/error-boundary.js +1 -1
- package/dist/client/error-reconstituter.d.ts +54 -0
- package/dist/client/error-reconstituter.d.ts.map +1 -0
- package/dist/client/index.js +4 -4
- package/dist/client/segment-outlet.d.ts +63 -0
- package/dist/client/segment-outlet.d.ts.map +1 -0
- package/dist/client/stale-reload.d.ts.map +1 -1
- package/dist/cookies/index.js +1 -1
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +176 -1
- package/dist/index.js.map +1 -1
- package/dist/params/define.d.ts +24 -0
- package/dist/params/define.d.ts.map +1 -1
- package/dist/params/index.js +2 -103
- package/dist/plugins/dev-browser-logs.d.ts +84 -0
- package/dist/plugins/dev-browser-logs.d.ts.map +1 -0
- package/dist/routing/index.js +1 -1
- package/dist/routing/scanner.d.ts.map +1 -1
- package/dist/routing/types.d.ts +10 -0
- package/dist/routing/types.d.ts.map +1 -1
- package/dist/search-params/index.js +1 -1
- package/dist/server/als-registry.d.ts +7 -0
- package/dist/server/als-registry.d.ts.map +1 -1
- package/dist/server/deny-renderer.d.ts.map +1 -1
- package/dist/server/fallback-error.d.ts +2 -1
- package/dist/server/fallback-error.d.ts.map +1 -1
- package/dist/server/index.js +4 -4
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/route-matcher.d.ts +7 -0
- package/dist/server/route-matcher.d.ts.map +1 -1
- package/dist/server/rsc-entry/error-renderer.d.ts +19 -10
- package/dist/server/rsc-entry/error-renderer.d.ts.map +1 -1
- package/dist/server/rsc-entry/index.d.ts.map +1 -1
- package/dist/server/rsc-entry/ssr-renderer.d.ts +3 -0
- package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
- package/dist/server/stream-utils.d.ts +36 -0
- package/dist/server/stream-utils.d.ts.map +1 -0
- package/package.json +6 -7
- package/src/cli.ts +0 -0
- package/src/client/browser-entry.ts +6 -7
- package/src/client/error-reconstituter.tsx +65 -0
- package/src/client/segment-outlet.tsx +86 -0
- package/src/client/stale-reload.ts +27 -3
- package/src/index.ts +17 -0
- package/src/params/define.ts +60 -0
- package/src/plugins/dev-browser-logs.ts +274 -0
- package/src/plugins/routing.ts +8 -0
- package/src/routing/scanner.ts +30 -0
- package/src/routing/types.ts +10 -0
- package/src/server/als-registry.ts +7 -0
- package/src/server/deny-renderer.ts +2 -1
- package/src/server/fallback-error.ts +5 -2
- package/src/server/request-context.ts +6 -0
- package/src/server/route-matcher.ts +7 -0
- package/src/server/rsc-entry/error-renderer.ts +151 -113
- package/src/server/rsc-entry/index.ts +30 -14
- package/src/server/rsc-entry/ssr-renderer.ts +15 -5
- package/src/server/stream-utils.ts +209 -0
- package/dist/_chunks/interception-D2djYaIm.js.map +0 -1
- package/dist/_chunks/request-context-0h-6Voad.js.map +0 -1
- package/dist/_chunks/stale-reload-4L-_skC7.js.map +0 -1
- package/dist/params/index.js.map +0 -1
- package/dist/server/rsc-entry/ssr-error-bridge.d.ts +0 -12
- package/dist/server/rsc-entry/ssr-error-bridge.d.ts.map +0 -1
- package/dist/server/ssr-error-entry.d.ts +0 -65
- package/dist/server/ssr-error-entry.d.ts.map +0 -1
- package/src/server/rsc-entry/ssr-error-bridge.ts +0 -20
- package/src/server/ssr-error-entry.ts +0 -237
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Reconstitutes a SerializableError into a real Error instance before
|
|
5
|
+
* passing to the user's error component.
|
|
6
|
+
*
|
|
7
|
+
* TSX error pages are 'use client' components that receive { error: Error, digest, reset }.
|
|
8
|
+
* Error objects are not RSC-serializable (React Flight throws "Only plain objects
|
|
9
|
+
* can be passed to Client Components"). This wrapper receives the error as a plain
|
|
10
|
+
* SerializableError object, reconstitutes a real Error instance, and passes it
|
|
11
|
+
* to the user's error component — ensuring error instanceof Error works correctly.
|
|
12
|
+
*
|
|
13
|
+
* See design/spike-TIM-565-unify-error-pages.md §"Edge Case B"
|
|
14
|
+
* See design/10-error-handling.md §"RSC → SSR for Error Pages via SerializableError"
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { createElement, type ReactNode, type ComponentType } from 'react';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Plain-object representation of an Error that can cross the RSC → client boundary.
|
|
21
|
+
* Stack is only included in dev mode (gated by isDevMode() on the server).
|
|
22
|
+
*/
|
|
23
|
+
export interface SerializableError {
|
|
24
|
+
message: string;
|
|
25
|
+
name: string;
|
|
26
|
+
stack?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Props for the ErrorReconstituter wrapper component.
|
|
31
|
+
* All props are RSC-serializable:
|
|
32
|
+
* - error: plain object (SerializableError)
|
|
33
|
+
* - digest: plain JSON or null
|
|
34
|
+
* - reset: undefined (only meaningful on client after boundary catch)
|
|
35
|
+
* - component: client module reference (RSC Flight serializes as opaque ref)
|
|
36
|
+
*/
|
|
37
|
+
interface ErrorReconstituterProps {
|
|
38
|
+
error: SerializableError;
|
|
39
|
+
digest: { code: string; data: unknown } | null;
|
|
40
|
+
reset: undefined;
|
|
41
|
+
component: ComponentType<{
|
|
42
|
+
error: Error;
|
|
43
|
+
digest: { code: string; data: unknown } | null;
|
|
44
|
+
reset: (() => void) | undefined;
|
|
45
|
+
}>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Reconstitute a SerializableError into a real Error instance and render
|
|
50
|
+
* the user's error component with the proper props.
|
|
51
|
+
*/
|
|
52
|
+
export function ErrorReconstituter({
|
|
53
|
+
error: serialized,
|
|
54
|
+
digest,
|
|
55
|
+
reset,
|
|
56
|
+
component,
|
|
57
|
+
}: ErrorReconstituterProps): ReactNode {
|
|
58
|
+
// Reconstitute a real Error so instanceof checks work in user code
|
|
59
|
+
const error = Object.assign(new Error(serialized.message), {
|
|
60
|
+
name: serialized.name,
|
|
61
|
+
...(serialized.stack != null ? { stack: serialized.stack } : {}),
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
return createElement(component, { error, digest, reset });
|
|
65
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SegmentOutlet — client component boundary at each layout segment.
|
|
3
|
+
*
|
|
4
|
+
* Replaces the post-hoc tree walking in segment-merger.ts with an explicit
|
|
5
|
+
* client component at each segment boundary. Each outlet:
|
|
6
|
+
*
|
|
7
|
+
* 1. Knows its own segment path (prop from the server)
|
|
8
|
+
* 2. Caches its children in a ref across navigations
|
|
9
|
+
* 3. When `keepCurrent` is true (partial navigation, this segment skipped),
|
|
10
|
+
* returns the previously cached children — layout state is preserved
|
|
11
|
+
* 4. When `keepCurrent` is false (full navigation or this segment changed),
|
|
12
|
+
* stores and renders the new children
|
|
13
|
+
*
|
|
14
|
+
* This eliminates the need for client-side element tree walking, which
|
|
15
|
+
* breaks on real RSC trees due to opaque client component lazy refs,
|
|
16
|
+
* Suspense thenables, and AccessGate wrappers.
|
|
17
|
+
*
|
|
18
|
+
* Architecture is similar to Next.js's `<LayoutRouter>` client component —
|
|
19
|
+
* each layout boundary is an explicit client component that manages its
|
|
20
|
+
* own subtree. See design/19-client-navigation.md.
|
|
21
|
+
*
|
|
22
|
+
* Security: This is a performance optimization only. The server always
|
|
23
|
+
* runs all access.ts files regardless of segment skipping. A fabricated
|
|
24
|
+
* keepCurrent prop can only cause stale layouts — never auth bypass.
|
|
25
|
+
* See design/13-security.md §"State tree manipulation".
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
'use client';
|
|
29
|
+
|
|
30
|
+
import { useRef, type ReactNode } from 'react';
|
|
31
|
+
|
|
32
|
+
export interface SegmentOutletProps {
|
|
33
|
+
/**
|
|
34
|
+
* Unique identifier for this segment. For normal segments this is the
|
|
35
|
+
* urlPath (e.g., "/", "/dashboard"). For route groups this includes the
|
|
36
|
+
* group name (e.g., "/(marketing)") to distinguish siblings that share
|
|
37
|
+
* the same urlPath. Must match the segmentId used in state-tree-diff.ts.
|
|
38
|
+
*/
|
|
39
|
+
segmentPath: string;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* When true, the outlet returns its previously cached children instead
|
|
43
|
+
* of rendering the new children prop. Set by the server when this
|
|
44
|
+
* segment was skipped (the client already has the layout mounted).
|
|
45
|
+
*
|
|
46
|
+
* On the first render (SSR/hydration), this is always false — there's
|
|
47
|
+
* no cached content yet. On subsequent partial navigations, the server
|
|
48
|
+
* sets this to true for segments it skipped rendering.
|
|
49
|
+
*/
|
|
50
|
+
keepCurrent?: boolean;
|
|
51
|
+
|
|
52
|
+
/** The segment's React subtree (layout + inner content). */
|
|
53
|
+
children: ReactNode;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Client component boundary at each layout segment in the element tree.
|
|
58
|
+
*
|
|
59
|
+
* On full navigation: receives new children, stores them, renders them.
|
|
60
|
+
* On partial navigation (keepCurrent=true): ignores children prop,
|
|
61
|
+
* returns previously stored content — React reconciles the same elements,
|
|
62
|
+
* preserving all client component state in the layout subtree.
|
|
63
|
+
*
|
|
64
|
+
* React preserves the ref across `reactRoot.render()` calls because:
|
|
65
|
+
* - SegmentOutlet has a stable type (client component module reference)
|
|
66
|
+
* - It appears at the same tree position on every navigation
|
|
67
|
+
* - React reconciles same-type, same-position → instance preserved
|
|
68
|
+
*/
|
|
69
|
+
export function SegmentOutlet({
|
|
70
|
+
segmentPath: _segmentPath,
|
|
71
|
+
keepCurrent = false,
|
|
72
|
+
children,
|
|
73
|
+
}: SegmentOutletProps) {
|
|
74
|
+
// Store content in a ref to avoid triggering re-renders on cache updates.
|
|
75
|
+
// The ref persists across reactRoot.render() calls because React reconciles
|
|
76
|
+
// the same component type at the same tree position.
|
|
77
|
+
const contentRef = useRef<ReactNode>(null);
|
|
78
|
+
|
|
79
|
+
if (!keepCurrent) {
|
|
80
|
+
// Full render or this segment was re-rendered — store and render new content
|
|
81
|
+
contentRef.current = children;
|
|
82
|
+
}
|
|
83
|
+
// else: keepCurrent=true — return previously cached content
|
|
84
|
+
|
|
85
|
+
return contentRef.current;
|
|
86
|
+
}
|
|
@@ -16,6 +16,16 @@
|
|
|
16
16
|
|
|
17
17
|
const RELOAD_FLAG_KEY = '__timber_stale_reload';
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* In-memory fallback counter for environments where sessionStorage is
|
|
21
|
+
* unavailable (private browsing, storage full, extension interference).
|
|
22
|
+
* Incremented each time triggerStaleReload() falls into the catch path.
|
|
23
|
+
* If the counter exceeds 0 on a subsequent call, the reload is suppressed
|
|
24
|
+
* to prevent an infinite loop. Resets naturally on page load (module
|
|
25
|
+
* re-evaluates) and can be manually reset via clearStaleReloadFlag().
|
|
26
|
+
*/
|
|
27
|
+
let memoryReloadCount = 0;
|
|
28
|
+
|
|
19
29
|
/**
|
|
20
30
|
* Check if an error is a stale client reference error from React's
|
|
21
31
|
* Flight client. These errors have the message pattern:
|
|
@@ -93,9 +103,22 @@ export function triggerStaleReload(): boolean {
|
|
|
93
103
|
window.location.reload();
|
|
94
104
|
return true;
|
|
95
105
|
} catch {
|
|
96
|
-
// sessionStorage
|
|
97
|
-
//
|
|
98
|
-
|
|
106
|
+
// sessionStorage unavailable (private browsing, storage full, etc.)
|
|
107
|
+
// Use in-memory counter as fallback loop guard
|
|
108
|
+
if (memoryReloadCount > 0) {
|
|
109
|
+
console.warn(
|
|
110
|
+
'[timber] Stale client reference detected again after reload. ' +
|
|
111
|
+
'Not reloading to prevent infinite loop. ' +
|
|
112
|
+
'This may indicate a deployment issue — try a hard refresh.'
|
|
113
|
+
);
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
memoryReloadCount++;
|
|
118
|
+
console.warn(
|
|
119
|
+
'[timber] Stale client reference detected — the server has been ' +
|
|
120
|
+
'redeployed with new bundles. Reloading to pick up the new version.'
|
|
121
|
+
);
|
|
99
122
|
window.location.reload();
|
|
100
123
|
return true;
|
|
101
124
|
}
|
|
@@ -107,6 +130,7 @@ export function triggerStaleReload(): boolean {
|
|
|
107
130
|
* reference error should trigger a fresh reload attempt.
|
|
108
131
|
*/
|
|
109
132
|
export function clearStaleReloadFlag(): void {
|
|
133
|
+
memoryReloadCount = 0;
|
|
110
134
|
try {
|
|
111
135
|
sessionStorage.removeItem(RELOAD_FLAG_KEY);
|
|
112
136
|
} catch {
|
package/src/index.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { timberStaticBuild } from './plugins/static-build';
|
|
|
15
15
|
import { timberServerActionExports } from './plugins/server-action-exports';
|
|
16
16
|
import { timberBuildManifest } from './plugins/build-manifest';
|
|
17
17
|
import { timberDevLogs } from './plugins/dev-logs';
|
|
18
|
+
import { timberDevBrowserLogs } from './plugins/dev-browser-logs';
|
|
18
19
|
import { timberReactProd } from './plugins/react-prod';
|
|
19
20
|
import { timberChunks } from './plugins/chunks';
|
|
20
21
|
import { clientChunkGroup } from './plugins/client-chunks';
|
|
@@ -102,6 +103,21 @@ export interface TimberUserConfig {
|
|
|
102
103
|
* See design/02-rendering-pipeline.md §"Streaming Constraints".
|
|
103
104
|
*/
|
|
104
105
|
renderTimeoutMs?: number;
|
|
106
|
+
/**
|
|
107
|
+
* Forward browser console output to the server terminal in dev mode.
|
|
108
|
+
*
|
|
109
|
+
* Sets the minimum log level to forward:
|
|
110
|
+
* - `'error'` — only `console.error`
|
|
111
|
+
* - `'warn'` — `console.error` + `console.warn` (default)
|
|
112
|
+
* - `'info'` — `console.error` + `console.warn` + `console.info`
|
|
113
|
+
* - `'none'` — disabled
|
|
114
|
+
*
|
|
115
|
+
* Does not intercept `console.log` or `console.debug` (too noisy).
|
|
116
|
+
* No effect in production builds.
|
|
117
|
+
*
|
|
118
|
+
* See TIM-513.
|
|
119
|
+
*/
|
|
120
|
+
devBrowserLogs?: 'error' | 'warn' | 'info' | 'none';
|
|
105
121
|
/** Dev-mode options. These have no effect in production builds. */
|
|
106
122
|
dev?: {
|
|
107
123
|
/** Threshold in ms to highlight slow phases in dev logging output. Default: 200. */
|
|
@@ -644,6 +660,7 @@ export function timber(config?: TimberUserConfig): PluginOption[] {
|
|
|
644
660
|
timberBuildReport(ctx), // Post-build: route table with bundle sizes
|
|
645
661
|
timberAdapterBuild(ctx), // Post-build: invoke adapter.buildOutput()
|
|
646
662
|
timberDevLogs(ctx), // Dev-only: forward server console.* to browser console
|
|
663
|
+
timberDevBrowserLogs(ctx), // Dev-only: forward browser console.* to server terminal
|
|
647
664
|
timberDevServer(ctx), // Must be last — configureServer post-hook runs after all watchers
|
|
648
665
|
];
|
|
649
666
|
}
|
package/src/params/define.ts
CHANGED
|
@@ -15,6 +15,33 @@
|
|
|
15
15
|
|
|
16
16
|
import type { Codec } from '#/codec.js';
|
|
17
17
|
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Server-only ALS reference for .load()
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
// Same pattern as search-params: eagerly registered at server startup
|
|
23
|
+
// to avoid dynamic imports that lose ALS context. See TIM-523.
|
|
24
|
+
let _rawSegmentParams: (() => Promise<Record<string, string | string[]>>) | undefined;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Register the rawSegmentParams function. Called once at module load time
|
|
28
|
+
* from request-context.ts to avoid dynamic import at call time.
|
|
29
|
+
* @internal
|
|
30
|
+
*/
|
|
31
|
+
export function _setRawSegmentParamsFn(fn: () => Promise<Record<string, string | string[]>>): void {
|
|
32
|
+
_rawSegmentParams = fn;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function getRawSegmentParams(): Promise<Record<string, string | string[]>> {
|
|
36
|
+
if (!_rawSegmentParams) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
'[timber] segmentParams.load() is only available on the server. ' +
|
|
39
|
+
'Use useSegmentParams() on the client.'
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
return _rawSegmentParams();
|
|
43
|
+
}
|
|
44
|
+
|
|
18
45
|
// ---------------------------------------------------------------------------
|
|
19
46
|
// Types
|
|
20
47
|
// ---------------------------------------------------------------------------
|
|
@@ -39,6 +66,25 @@ export interface ParamsDefinition<T extends Record<string, unknown>> {
|
|
|
39
66
|
/** Serialize typed values back to strings for URL construction. */
|
|
40
67
|
serialize(values: T): Record<string, string>;
|
|
41
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Load typed segment params from the current request context (ALS).
|
|
71
|
+
*
|
|
72
|
+
* Server-only. Reads rawSegmentParams() from ALS and coerces through
|
|
73
|
+
* this definition's codecs, returning fully typed params.
|
|
74
|
+
*
|
|
75
|
+
* ```ts
|
|
76
|
+
* // app/products/[id]/params.ts
|
|
77
|
+
* export const segmentParams = defineSegmentParams({ id: z.coerce.number() })
|
|
78
|
+
*
|
|
79
|
+
* // app/products/[id]/page.tsx
|
|
80
|
+
* import { segmentParams } from './params'
|
|
81
|
+
* export default async function Page() {
|
|
82
|
+
* const { id } = await segmentParams.load() // id: number
|
|
83
|
+
* }
|
|
84
|
+
* ```
|
|
85
|
+
*/
|
|
86
|
+
load(): Promise<T>;
|
|
87
|
+
|
|
42
88
|
/** Read-only codec map. */
|
|
43
89
|
codecs: { [K in keyof T]: Codec<T[K]> };
|
|
44
90
|
}
|
|
@@ -250,9 +296,23 @@ export function defineSegmentParams<C extends Record<string, ParamField>>(
|
|
|
250
296
|
return result;
|
|
251
297
|
}
|
|
252
298
|
|
|
299
|
+
// ---- load ----
|
|
300
|
+
// ALS-backed: reads rawSegmentParams() from the current request context
|
|
301
|
+
// and parses through codecs. Server-only — throws on client.
|
|
302
|
+
async function load(): Promise<T> {
|
|
303
|
+
if (typeof window !== 'undefined') {
|
|
304
|
+
throw new Error(
|
|
305
|
+
'[timber] segmentParams.load() is server-only. ' + 'Use useSegmentParams() on the client.'
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
const raw = await getRawSegmentParams();
|
|
309
|
+
return parse(raw);
|
|
310
|
+
}
|
|
311
|
+
|
|
253
312
|
const definition: ParamsDefinition<T> = {
|
|
254
313
|
parse,
|
|
255
314
|
serialize,
|
|
315
|
+
load,
|
|
256
316
|
codecs: resolvedCodecs as { [K in keyof T]: Codec<T[K]> },
|
|
257
317
|
};
|
|
258
318
|
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* timber-dev-browser-logs — Forwards browser console output to the server terminal.
|
|
3
|
+
*
|
|
4
|
+
* Injects a small inline script in dev mode that intercepts browser
|
|
5
|
+
* `console.error`, `console.warn`, and `console.info`, then forwards
|
|
6
|
+
* messages to the server via Vite's HMR WebSocket.
|
|
7
|
+
*
|
|
8
|
+
* The server-side listener formats and prints the messages with color-coded
|
|
9
|
+
* prefixes: [browser:error], [browser:warn], [browser:info].
|
|
10
|
+
*
|
|
11
|
+
* Dev-only: this plugin only runs during `vite dev`.
|
|
12
|
+
* No runtime overhead in production.
|
|
13
|
+
*
|
|
14
|
+
* See TIM-513 for design context.
|
|
15
|
+
* See design/18-build-system.md §"Dev Server" for sub-plugin architecture.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { Plugin, ViteDevServer } from 'vite';
|
|
19
|
+
import type { PluginContext } from '#/index.js';
|
|
20
|
+
|
|
21
|
+
// ─── Types ───────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
/** Log levels forwarded from the browser. */
|
|
24
|
+
export type BrowserLogLevel = 'error' | 'warn' | 'info';
|
|
25
|
+
|
|
26
|
+
/** Configuration value for devBrowserLogs in timber.config.ts. */
|
|
27
|
+
export type DevBrowserLogsConfig = BrowserLogLevel | 'none' | undefined;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Payload sent from browser to server via Vite's HMR WebSocket.
|
|
31
|
+
* Kept small — truncated before sending.
|
|
32
|
+
*/
|
|
33
|
+
export interface BrowserLogPayload {
|
|
34
|
+
level: BrowserLogLevel;
|
|
35
|
+
/** Serialized message string. */
|
|
36
|
+
message: string;
|
|
37
|
+
/** Error stack trace, if the first argument was an Error. */
|
|
38
|
+
stack: string | null;
|
|
39
|
+
/** Source URL and line number, if available. */
|
|
40
|
+
source: string | null;
|
|
41
|
+
/** Timestamp in ms (Date.now()) from the browser. */
|
|
42
|
+
timestamp: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ─── Constants ───────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
/** Max message size in bytes before truncation. */
|
|
48
|
+
const MAX_MESSAGE_BYTES = 2048;
|
|
49
|
+
|
|
50
|
+
/** HMR event name for browser→server log forwarding. */
|
|
51
|
+
const HMR_EVENT = 'timber:browser-log';
|
|
52
|
+
|
|
53
|
+
/** ANSI color codes for terminal output. */
|
|
54
|
+
const COLORS = {
|
|
55
|
+
red: '\x1b[31m',
|
|
56
|
+
yellow: '\x1b[33m',
|
|
57
|
+
blue: '\x1b[34m',
|
|
58
|
+
dim: '\x1b[2m',
|
|
59
|
+
reset: '\x1b[0m',
|
|
60
|
+
} as const;
|
|
61
|
+
|
|
62
|
+
/** Level severity ordering for threshold comparison. */
|
|
63
|
+
const LEVEL_SEVERITY: Record<BrowserLogLevel | 'none', number> = {
|
|
64
|
+
none: 0,
|
|
65
|
+
info: 1,
|
|
66
|
+
warn: 2,
|
|
67
|
+
error: 3,
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// ─── Formatting ──────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Format a browser log payload for server terminal output.
|
|
74
|
+
*
|
|
75
|
+
* Produces color-coded output like:
|
|
76
|
+
* [browser:error] Uncaught TypeError: x is not a function
|
|
77
|
+
* at App (app.tsx:10:5)
|
|
78
|
+
* [browser:warn] Deprecation warning (app/page.tsx:42:12)
|
|
79
|
+
*/
|
|
80
|
+
export function formatBrowserLog(payload: BrowserLogPayload): string {
|
|
81
|
+
const { level, message, stack, source } = payload;
|
|
82
|
+
|
|
83
|
+
// Color-coded prefix
|
|
84
|
+
const color = level === 'error' ? COLORS.red : level === 'warn' ? COLORS.yellow : COLORS.blue;
|
|
85
|
+
const prefix = `${color}[browser:${level}]${COLORS.reset}`;
|
|
86
|
+
|
|
87
|
+
// Source suffix
|
|
88
|
+
const sourceSuffix = source ? ` ${COLORS.dim}(${source})${COLORS.reset}` : '';
|
|
89
|
+
|
|
90
|
+
let output = `${prefix} ${message}${sourceSuffix}`;
|
|
91
|
+
|
|
92
|
+
// Append stack trace indented
|
|
93
|
+
if (stack) {
|
|
94
|
+
const indented = stack
|
|
95
|
+
.split('\n')
|
|
96
|
+
.map((line) => ` ${COLORS.dim}${line}${COLORS.reset}`)
|
|
97
|
+
.join('\n');
|
|
98
|
+
output += `\n${indented}`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return output;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ─── Level Filtering ─────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Check if a log at `level` should be forwarded given the configured threshold.
|
|
108
|
+
*
|
|
109
|
+
* The threshold acts as a minimum severity:
|
|
110
|
+
* - 'error' → only errors
|
|
111
|
+
* - 'warn' → errors + warnings
|
|
112
|
+
* - 'info' → errors + warnings + info
|
|
113
|
+
* - 'none' → nothing
|
|
114
|
+
*/
|
|
115
|
+
export function shouldForwardLevel(
|
|
116
|
+
level: BrowserLogLevel,
|
|
117
|
+
threshold: BrowserLogLevel | 'none'
|
|
118
|
+
): boolean {
|
|
119
|
+
if (threshold === 'none') return false;
|
|
120
|
+
return LEVEL_SEVERITY[level] >= LEVEL_SEVERITY[threshold];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ─── Truncation ──────────────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Truncate a message to `maxBytes` to avoid flooding the terminal.
|
|
127
|
+
* Appends a suffix indicating how many bytes were dropped.
|
|
128
|
+
*/
|
|
129
|
+
export function truncateMessage(message: string, maxBytes: number): string {
|
|
130
|
+
if (message.length <= maxBytes) return message;
|
|
131
|
+
|
|
132
|
+
const truncated = message.slice(0, maxBytes);
|
|
133
|
+
const droppedBytes = message.length - maxBytes;
|
|
134
|
+
return `${truncated}… [truncated ${droppedBytes} bytes]`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ─── Client Injection Script ─────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Generate the inline script injected into the browser in dev mode.
|
|
141
|
+
*
|
|
142
|
+
* This script:
|
|
143
|
+
* 1. Saves references to the original console methods
|
|
144
|
+
* 2. Wraps `console.error`, `console.warn`, `console.info`
|
|
145
|
+
* 3. Calls the original first (browser devtools still work)
|
|
146
|
+
* 4. Serializes and forwards via `import.meta.hot.send()`
|
|
147
|
+
* 5. Truncates messages to MAX_MESSAGE_BYTES
|
|
148
|
+
*
|
|
149
|
+
* The script is minimal and self-contained — no imports, no dependencies.
|
|
150
|
+
*/
|
|
151
|
+
export function generateClientScript(threshold: BrowserLogLevel | 'none'): string {
|
|
152
|
+
if (threshold === 'none') return '';
|
|
153
|
+
|
|
154
|
+
// Only intercept levels that meet the threshold
|
|
155
|
+
const levels: BrowserLogLevel[] = ['error', 'warn', 'info'].filter((l) =>
|
|
156
|
+
shouldForwardLevel(l as BrowserLogLevel, threshold)
|
|
157
|
+
) as BrowserLogLevel[];
|
|
158
|
+
|
|
159
|
+
if (levels.length === 0) return '';
|
|
160
|
+
|
|
161
|
+
return `
|
|
162
|
+
(function() {
|
|
163
|
+
if (!import.meta.hot) return;
|
|
164
|
+
var MAX_BYTES = ${MAX_MESSAGE_BYTES};
|
|
165
|
+
var levels = ${JSON.stringify(levels)};
|
|
166
|
+
var originals = {};
|
|
167
|
+
|
|
168
|
+
function serialize(arg) {
|
|
169
|
+
if (arg === null) return 'null';
|
|
170
|
+
if (arg === undefined) return 'undefined';
|
|
171
|
+
if (arg instanceof Error) return arg.stack || arg.message || String(arg);
|
|
172
|
+
if (typeof arg === 'object') {
|
|
173
|
+
try { return JSON.stringify(arg); } catch(e) { return String(arg); }
|
|
174
|
+
}
|
|
175
|
+
return String(arg);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function truncate(s) {
|
|
179
|
+
if (s.length <= MAX_BYTES) return s;
|
|
180
|
+
return s.slice(0, MAX_BYTES) + '... [truncated ' + (s.length - MAX_BYTES) + ' bytes]';
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
levels.forEach(function(level) {
|
|
184
|
+
originals[level] = console[level];
|
|
185
|
+
console[level] = function() {
|
|
186
|
+
originals[level].apply(console, arguments);
|
|
187
|
+
try {
|
|
188
|
+
var args = Array.prototype.slice.call(arguments);
|
|
189
|
+
var firstArg = args[0];
|
|
190
|
+
var stack = null;
|
|
191
|
+
var source = null;
|
|
192
|
+
|
|
193
|
+
if (firstArg instanceof Error) {
|
|
194
|
+
stack = firstArg.stack || null;
|
|
195
|
+
source = null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
var message = args.map(serialize).join(' ');
|
|
199
|
+
message = truncate(message);
|
|
200
|
+
|
|
201
|
+
import.meta.hot.send('${HMR_EVENT}', {
|
|
202
|
+
level: level,
|
|
203
|
+
message: message,
|
|
204
|
+
stack: stack,
|
|
205
|
+
source: source,
|
|
206
|
+
timestamp: Date.now()
|
|
207
|
+
});
|
|
208
|
+
} catch(e) {
|
|
209
|
+
// Never let log forwarding break the page
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
});
|
|
213
|
+
})();
|
|
214
|
+
`.trim();
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ─── Plugin ──────────────────────────────────────────────────────────────
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Create the timber-dev-browser-logs Vite plugin.
|
|
221
|
+
*
|
|
222
|
+
* - `configureServer`: Listens for HMR messages and prints them to the terminal
|
|
223
|
+
* - `transformIndexHtml`: Injects the client-side interception script
|
|
224
|
+
*
|
|
225
|
+
* Only active during `vite dev` (apply: 'serve').
|
|
226
|
+
*/
|
|
227
|
+
export function timberDevBrowserLogs(ctx: PluginContext): Plugin {
|
|
228
|
+
return {
|
|
229
|
+
name: 'timber-dev-browser-logs',
|
|
230
|
+
apply: 'serve',
|
|
231
|
+
|
|
232
|
+
configureServer(server: ViteDevServer) {
|
|
233
|
+
const threshold = ctx.config.devBrowserLogs ?? 'warn';
|
|
234
|
+
if (threshold === 'none') return;
|
|
235
|
+
|
|
236
|
+
// Listen for browser log messages via HMR WebSocket
|
|
237
|
+
server.hot.on(HMR_EVENT, (payload: BrowserLogPayload) => {
|
|
238
|
+
try {
|
|
239
|
+
// Validate level
|
|
240
|
+
if (!shouldForwardLevel(payload.level, threshold)) return;
|
|
241
|
+
|
|
242
|
+
// Truncate server-side too (defense in depth)
|
|
243
|
+
payload.message = truncateMessage(payload.message, MAX_MESSAGE_BYTES);
|
|
244
|
+
|
|
245
|
+
const formatted = formatBrowserLog(payload);
|
|
246
|
+
|
|
247
|
+
// Use the correct console method for the log level
|
|
248
|
+
if (payload.level === 'error') {
|
|
249
|
+
process.stderr.write(formatted + '\n');
|
|
250
|
+
} else {
|
|
251
|
+
process.stdout.write(formatted + '\n');
|
|
252
|
+
}
|
|
253
|
+
} catch {
|
|
254
|
+
// Never let log forwarding crash the server
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
},
|
|
258
|
+
|
|
259
|
+
transformIndexHtml() {
|
|
260
|
+
const threshold = ctx.config.devBrowserLogs ?? 'warn';
|
|
261
|
+
const script = generateClientScript(threshold);
|
|
262
|
+
if (!script) return [];
|
|
263
|
+
|
|
264
|
+
return [
|
|
265
|
+
{
|
|
266
|
+
tag: 'script',
|
|
267
|
+
attrs: { type: 'module' },
|
|
268
|
+
children: script,
|
|
269
|
+
injectTo: 'head' as const,
|
|
270
|
+
},
|
|
271
|
+
];
|
|
272
|
+
},
|
|
273
|
+
};
|
|
274
|
+
}
|
package/src/plugins/routing.ts
CHANGED
|
@@ -423,6 +423,13 @@ function generateManifestModule(tree: RouteTree): string {
|
|
|
423
423
|
proxyLine = ` proxy: { load: ${v}, filePath: ${JSON.stringify(tree.proxy.filePath)} },`;
|
|
424
424
|
}
|
|
425
425
|
|
|
426
|
+
// Global error page (Tier 2)
|
|
427
|
+
let globalErrorLine = '';
|
|
428
|
+
if (tree.globalError) {
|
|
429
|
+
const v = addImport(tree.globalError);
|
|
430
|
+
globalErrorLine = ` globalError: { load: ${v}, filePath: ${JSON.stringify(tree.globalError.filePath)} },`;
|
|
431
|
+
}
|
|
432
|
+
|
|
426
433
|
// Interception rewrites — computed at build time from the route tree.
|
|
427
434
|
// Only interceptedPattern and interceptingPrefix are needed at runtime.
|
|
428
435
|
const rewrites = collectInterceptionRewrites(tree.root);
|
|
@@ -439,6 +446,7 @@ function generateManifestModule(tree: RouteTree): string {
|
|
|
439
446
|
'',
|
|
440
447
|
'const manifest = {',
|
|
441
448
|
proxyLine,
|
|
449
|
+
globalErrorLine,
|
|
442
450
|
rewritesLine,
|
|
443
451
|
` root: ${rootSerialized},`,
|
|
444
452
|
'};',
|
package/src/routing/scanner.ts
CHANGED
|
@@ -83,6 +83,14 @@ export function scanRoutes(appDir: string, config: ScannerConfig = {}): RouteTre
|
|
|
83
83
|
tree.proxy = proxyFile;
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
// Check for global-error.{tsx,ts,jsx,js} at app root.
|
|
87
|
+
// Tier 2 error page — renders standalone (no layouts) when no segment-level
|
|
88
|
+
// error file is found. See design/10-error-handling.md §"Tier 2".
|
|
89
|
+
const globalErrorFile = findPageExtFile(appDir, 'global-error', extSet);
|
|
90
|
+
if (globalErrorFile) {
|
|
91
|
+
tree.globalError = globalErrorFile;
|
|
92
|
+
}
|
|
93
|
+
|
|
86
94
|
// Scan the root directory's files
|
|
87
95
|
scanSegmentFiles(appDir, tree.root, extSet);
|
|
88
96
|
|
|
@@ -547,3 +555,25 @@ function findFixedFile(dirPath: string, name: string): RouteFile | undefined {
|
|
|
547
555
|
}
|
|
548
556
|
return undefined;
|
|
549
557
|
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Find a file using the configured page extensions (tsx, ts, jsx, js, mdx, etc.).
|
|
561
|
+
* Used for app-root conventions like global-error that aren't per-segment.
|
|
562
|
+
*/
|
|
563
|
+
function findPageExtFile(
|
|
564
|
+
dirPath: string,
|
|
565
|
+
name: string,
|
|
566
|
+
extSet: Set<string>
|
|
567
|
+
): RouteFile | undefined {
|
|
568
|
+
for (const ext of extSet) {
|
|
569
|
+
const fullPath = join(dirPath, `${name}.${ext}`);
|
|
570
|
+
try {
|
|
571
|
+
if (statSync(fullPath).isFile()) {
|
|
572
|
+
return { filePath: fullPath, extension: ext };
|
|
573
|
+
}
|
|
574
|
+
} catch {
|
|
575
|
+
// File doesn't exist
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
return undefined;
|
|
579
|
+
}
|
package/src/routing/types.ts
CHANGED
|
@@ -91,6 +91,16 @@ export interface RouteTree {
|
|
|
91
91
|
root: SegmentNode;
|
|
92
92
|
/** All discovered proxy.ts files (should be at most one, in app/) */
|
|
93
93
|
proxy?: RouteFile;
|
|
94
|
+
/**
|
|
95
|
+
* Global error page: app/global-error.{tsx,ts,jsx,js}
|
|
96
|
+
*
|
|
97
|
+
* Rendered as a standalone full-page replacement (no layout wrapping)
|
|
98
|
+
* when no segment-level error file is found. SSR-only render path.
|
|
99
|
+
* Must provide its own <html> and <body>.
|
|
100
|
+
*
|
|
101
|
+
* See design/10-error-handling.md §"Tier 2 — Global Error Page"
|
|
102
|
+
*/
|
|
103
|
+
globalError?: RouteFile;
|
|
94
104
|
}
|
|
95
105
|
|
|
96
106
|
/** Configuration passed to the scanner */
|