@timber-js/app 0.1.1 → 0.1.3

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.
Files changed (143) hide show
  1. package/dist/index.d.ts.map +1 -1
  2. package/dist/index.js +11 -7
  3. package/dist/index.js.map +1 -1
  4. package/dist/plugins/dev-server.d.ts.map +1 -1
  5. package/dist/plugins/entries.d.ts.map +1 -1
  6. package/package.json +5 -4
  7. package/src/adapters/cloudflare.ts +325 -0
  8. package/src/adapters/nitro.ts +366 -0
  9. package/src/adapters/types.ts +63 -0
  10. package/src/cache/index.ts +91 -0
  11. package/src/cache/redis-handler.ts +91 -0
  12. package/src/cache/register-cached-function.ts +99 -0
  13. package/src/cache/singleflight.ts +26 -0
  14. package/src/cache/stable-stringify.ts +21 -0
  15. package/src/cache/timber-cache.ts +116 -0
  16. package/src/cli.ts +201 -0
  17. package/src/client/browser-entry.ts +663 -0
  18. package/src/client/error-boundary.tsx +209 -0
  19. package/src/client/form.tsx +200 -0
  20. package/src/client/head.ts +61 -0
  21. package/src/client/history.ts +46 -0
  22. package/src/client/index.ts +60 -0
  23. package/src/client/link-navigate-interceptor.tsx +62 -0
  24. package/src/client/link-status-provider.tsx +40 -0
  25. package/src/client/link.tsx +310 -0
  26. package/src/client/nuqs-adapter.tsx +117 -0
  27. package/src/client/router-ref.ts +25 -0
  28. package/src/client/router.ts +563 -0
  29. package/src/client/segment-cache.ts +194 -0
  30. package/src/client/segment-context.ts +57 -0
  31. package/src/client/ssr-data.ts +95 -0
  32. package/src/client/types.ts +4 -0
  33. package/src/client/unload-guard.ts +34 -0
  34. package/src/client/use-cookie.ts +122 -0
  35. package/src/client/use-link-status.ts +46 -0
  36. package/src/client/use-navigation-pending.ts +47 -0
  37. package/src/client/use-params.ts +71 -0
  38. package/src/client/use-pathname.ts +43 -0
  39. package/src/client/use-query-states.ts +133 -0
  40. package/src/client/use-router.ts +77 -0
  41. package/src/client/use-search-params.ts +74 -0
  42. package/src/client/use-selected-layout-segment.ts +110 -0
  43. package/src/content/index.ts +13 -0
  44. package/src/cookies/define-cookie.ts +137 -0
  45. package/src/cookies/index.ts +9 -0
  46. package/src/fonts/ast.ts +359 -0
  47. package/src/fonts/css.ts +68 -0
  48. package/src/fonts/fallbacks.ts +248 -0
  49. package/src/fonts/google.ts +332 -0
  50. package/src/fonts/local.ts +177 -0
  51. package/src/fonts/types.ts +88 -0
  52. package/src/index.ts +420 -0
  53. package/src/plugins/adapter-build.ts +118 -0
  54. package/src/plugins/build-manifest.ts +323 -0
  55. package/src/plugins/build-report.ts +353 -0
  56. package/src/plugins/cache-transform.ts +199 -0
  57. package/src/plugins/chunks.ts +90 -0
  58. package/src/plugins/content.ts +136 -0
  59. package/src/plugins/dev-error-overlay.ts +230 -0
  60. package/src/plugins/dev-logs.ts +280 -0
  61. package/src/plugins/dev-server.ts +391 -0
  62. package/src/plugins/dynamic-transform.ts +161 -0
  63. package/src/plugins/entries.ts +214 -0
  64. package/src/plugins/fonts.ts +581 -0
  65. package/src/plugins/mdx.ts +179 -0
  66. package/src/plugins/react-prod.ts +56 -0
  67. package/src/plugins/routing.ts +419 -0
  68. package/src/plugins/server-action-exports.ts +220 -0
  69. package/src/plugins/server-bundle.ts +113 -0
  70. package/src/plugins/shims.ts +168 -0
  71. package/src/plugins/static-build.ts +207 -0
  72. package/src/routing/codegen.ts +396 -0
  73. package/src/routing/index.ts +14 -0
  74. package/src/routing/interception.ts +173 -0
  75. package/src/routing/scanner.ts +487 -0
  76. package/src/routing/status-file-lint.ts +114 -0
  77. package/src/routing/types.ts +100 -0
  78. package/src/search-params/analyze.ts +192 -0
  79. package/src/search-params/codecs.ts +153 -0
  80. package/src/search-params/create.ts +314 -0
  81. package/src/search-params/index.ts +23 -0
  82. package/src/search-params/registry.ts +31 -0
  83. package/src/server/access-gate.tsx +142 -0
  84. package/src/server/action-client.ts +473 -0
  85. package/src/server/action-handler.ts +325 -0
  86. package/src/server/actions.ts +236 -0
  87. package/src/server/asset-headers.ts +81 -0
  88. package/src/server/body-limits.ts +102 -0
  89. package/src/server/build-manifest.ts +234 -0
  90. package/src/server/canonicalize.ts +90 -0
  91. package/src/server/client-module-map.ts +58 -0
  92. package/src/server/csrf.ts +79 -0
  93. package/src/server/deny-renderer.ts +302 -0
  94. package/src/server/dev-logger.ts +419 -0
  95. package/src/server/dev-span-processor.ts +78 -0
  96. package/src/server/dev-warnings.ts +282 -0
  97. package/src/server/early-hints-sender.ts +55 -0
  98. package/src/server/early-hints.ts +142 -0
  99. package/src/server/error-boundary-wrapper.ts +69 -0
  100. package/src/server/error-formatter.ts +184 -0
  101. package/src/server/flush.ts +182 -0
  102. package/src/server/form-data.ts +176 -0
  103. package/src/server/form-flash.ts +93 -0
  104. package/src/server/html-injectors.ts +445 -0
  105. package/src/server/index.ts +222 -0
  106. package/src/server/instrumentation.ts +136 -0
  107. package/src/server/logger.ts +145 -0
  108. package/src/server/manifest-status-resolver.ts +215 -0
  109. package/src/server/metadata-render.ts +527 -0
  110. package/src/server/metadata-routes.ts +189 -0
  111. package/src/server/metadata.ts +263 -0
  112. package/src/server/middleware-runner.ts +32 -0
  113. package/src/server/nuqs-ssr-provider.tsx +63 -0
  114. package/src/server/pipeline.ts +555 -0
  115. package/src/server/prerender.ts +139 -0
  116. package/src/server/primitives.ts +264 -0
  117. package/src/server/proxy.ts +43 -0
  118. package/src/server/request-context.ts +554 -0
  119. package/src/server/route-element-builder.ts +395 -0
  120. package/src/server/route-handler.ts +153 -0
  121. package/src/server/route-matcher.ts +316 -0
  122. package/src/server/rsc-entry/api-handler.ts +112 -0
  123. package/src/server/rsc-entry/error-renderer.ts +177 -0
  124. package/src/server/rsc-entry/helpers.ts +147 -0
  125. package/src/server/rsc-entry/index.ts +688 -0
  126. package/src/server/rsc-entry/ssr-bridge.ts +18 -0
  127. package/src/server/slot-resolver.ts +359 -0
  128. package/src/server/ssr-entry.ts +161 -0
  129. package/src/server/ssr-render.ts +200 -0
  130. package/src/server/status-code-resolver.ts +282 -0
  131. package/src/server/tracing.ts +281 -0
  132. package/src/server/tree-builder.ts +354 -0
  133. package/src/server/types.ts +150 -0
  134. package/src/shims/font-google.ts +67 -0
  135. package/src/shims/headers.ts +11 -0
  136. package/src/shims/image.ts +48 -0
  137. package/src/shims/link.ts +9 -0
  138. package/src/shims/navigation-client.ts +52 -0
  139. package/src/shims/navigation.ts +31 -0
  140. package/src/shims/server-only-noop.js +5 -0
  141. package/src/utils/directive-parser.ts +529 -0
  142. package/src/utils/format.ts +10 -0
  143. package/src/utils/startup-timer.ts +102 -0
@@ -0,0 +1,302 @@
1
+ /**
2
+ * Deny page rendering — renders status-code pages for DenySignal errors.
3
+ *
4
+ * Extracted from rsc-entry.ts to keep file sizes under 500 lines.
5
+ * Handles three rendering paths:
6
+ * 1. Component (TSX/MDX) with shell — full RSC→SSR through layout chain
7
+ * 2. Component (TSX/MDX) without shell — RSC→SSR standalone (no layouts)
8
+ * 3. JSON — raw file contents returned verbatim, no React pipeline
9
+ *
10
+ * Format selection:
11
+ * - Route handlers (route.ts) prefer JSON variants
12
+ * - Page routes prefer component variants
13
+ * - Accept: application/json on page routes falls back to JSON if no component exists
14
+ *
15
+ * See design/10-error-handling.md §"Status-Code Files"
16
+ */
17
+
18
+ import { createElement } from 'react';
19
+ import { renderToReadableStream } from '@vitejs/plugin-rsc/rsc';
20
+
21
+ import { DenySignal } from './primitives.js';
22
+ import { logRenderError } from './logger.js';
23
+ import { resolveMetadata, renderMetadataToElements } from './metadata.js';
24
+ import { resolveManifestStatusFile } from './manifest-status-resolver.js';
25
+ import type { ManifestSegmentNode } from './route-matcher.js';
26
+ import type { RouteMatch } from './pipeline.js';
27
+ import type { NavContext } from './ssr-entry.js';
28
+ import type { ClientBootstrapConfig } from './html-injectors.js';
29
+ import type { Metadata } from './types.js';
30
+
31
+ /** RSC content type for client navigation payload requests. */
32
+ const RSC_CONTENT_TYPE = 'text/x-component';
33
+
34
+ /** Layout component entry for deny page wrapping. */
35
+ export interface LayoutEntry {
36
+ component: (...args: unknown[]) => unknown;
37
+ segment: ManifestSegmentNode;
38
+ }
39
+
40
+ /** Callback to create a debug channel sink for RSC rendering. */
41
+ export type DebugChannelFactory = () => {
42
+ readable: ReadableStream;
43
+ writable: WritableStream;
44
+ };
45
+
46
+ /** Callback to pass RSC stream to SSR for HTML rendering. */
47
+ export type CallSsrFn = (
48
+ rscStream: ReadableStream<Uint8Array>,
49
+ navContext: NavContext
50
+ ) => Promise<Response>;
51
+
52
+ /**
53
+ * Check if the leaf segment is an API route (has route.ts).
54
+ */
55
+ function isApiRoute(segments: ReadonlyArray<ManifestSegmentNode>): boolean {
56
+ const leaf = segments[segments.length - 1];
57
+ return !!leaf?.route;
58
+ }
59
+
60
+ /**
61
+ * Render a status-code error page for a DenySignal.
62
+ *
63
+ * Resolves the appropriate status-code file from the segment chain based
64
+ * on format preference. Returns an HTML Response (component), JSON Response,
65
+ * or bare fallback Response.
66
+ */
67
+ export async function renderDenyPage(
68
+ deny: DenySignal,
69
+ segments: ManifestSegmentNode[],
70
+ layoutComponents: LayoutEntry[],
71
+ req: Request,
72
+ match: RouteMatch,
73
+ responseHeaders: Headers,
74
+ clientBootstrap: ClientBootstrapConfig,
75
+ createDebugChannelSink: DebugChannelFactory,
76
+ callSsr: CallSsrFn
77
+ ): Promise<Response> {
78
+ // API routes (route.ts) → JSON only, never render components
79
+ if (isApiRoute(segments)) {
80
+ const jsonResponse = await renderDenyPageJson(deny, segments, responseHeaders);
81
+ if (jsonResponse) return jsonResponse;
82
+ return bareJsonResponse(deny.status, responseHeaders);
83
+ }
84
+
85
+ // Page routes → component chain first, JSON fallback only if no component found.
86
+ const resolution = resolveManifestStatusFile(deny.status, segments, 'component');
87
+
88
+ // No component status file — try JSON chain before bare fallback
89
+ if (!resolution) {
90
+ const jsonResponse = await renderDenyPageJson(deny, segments, responseHeaders);
91
+ if (jsonResponse) return jsonResponse;
92
+ return new Response(null, { status: deny.status, headers: responseHeaders });
93
+ }
94
+
95
+ // Dev warning: JSON status file exists but is shadowed by the component chain.
96
+ // This helps developers understand why their .json file isn't being served.
97
+ if (process.env.NODE_ENV !== 'production') {
98
+ const jsonResolution = resolveManifestStatusFile(deny.status, segments, 'json');
99
+ if (jsonResolution) {
100
+ console.warn(
101
+ `[timber] ${jsonResolution.file.filePath} exists but is shadowed by ` +
102
+ `${resolution.file.filePath} (component chain). ` +
103
+ `For page routes, component status files take priority over JSON. ` +
104
+ `Remove the component file or move it to use the JSON variant.`
105
+ );
106
+ }
107
+ }
108
+
109
+ // Load the status-code page component
110
+ const mod = (await resolution.file.load()) as Record<string, unknown>;
111
+ if (!mod.default) {
112
+ return new Response(null, { status: deny.status, headers: responseHeaders });
113
+ }
114
+
115
+ const ErrorPageComponent = mod.default as (...args: unknown[]) => unknown;
116
+ const h = createElement as (...args: unknown[]) => React.ReactElement;
117
+
118
+ // Check shell opt-out: export const shell = false
119
+ const shellEnabled = mod.shell !== false;
120
+
121
+ // 4xx status-code pages receive { status, dangerouslyPassData }
122
+ // per design/10-error-handling.md §"Status-Code File Props"
123
+ let element = h(ErrorPageComponent, {
124
+ status: deny.status,
125
+ dangerouslyPassData: deny.data,
126
+ });
127
+
128
+ // Wrap in layouts unless shell is explicitly disabled
129
+ if (shellEnabled) {
130
+ const resolvedSegments = new Set(segments.slice(0, resolution.segmentIndex + 1));
131
+ const layoutsToWrap = layoutComponents.filter((lc) => resolvedSegments.has(lc.segment));
132
+ for (let i = layoutsToWrap.length - 1; i >= 0; i--) {
133
+ const { component } = layoutsToWrap[i];
134
+ element = h(component, null, element);
135
+ }
136
+ } else if (process.env.NODE_ENV !== 'production') {
137
+ // Dev-mode: warn if shell=false might conflict with Suspense
138
+ // The actual Suspense boundary check happens at render time in the pipeline.
139
+ // This is a preemptive log for developer awareness.
140
+ console.warn(
141
+ `[timber] Status-code file ${resolution.file.filePath} exports shell = false. ` +
142
+ 'If deny() fires inside a Suspense boundary, layouts are already committed and ' +
143
+ 'cannot be unwrapped. The shell opt-out will be ignored in that case.'
144
+ );
145
+ }
146
+
147
+ // Build head HTML from error page metadata (if any)
148
+ let headHtml = '';
149
+ if (mod.metadata) {
150
+ const resolvedMeta = resolveMetadata([{ metadata: mod.metadata as Metadata, isPage: true }]);
151
+ const headElements = renderMetadataToElements(resolvedMeta);
152
+ for (const el of headElements) {
153
+ if (el.tag === 'title' && el.content) {
154
+ headHtml += `<title>${escapeHtml(el.content)}</title>`;
155
+ } else if (el.attrs) {
156
+ const attrs = Object.entries(el.attrs)
157
+ .map(([k, v]) => `${k}="${escapeHtml(v)}"`)
158
+ .join(' ');
159
+ headHtml += `<${el.tag} ${attrs}>`;
160
+ }
161
+ }
162
+ }
163
+
164
+ // Render the error page to a fresh RSC Flight stream.
165
+ const rscStream = renderToReadableStream(element, {
166
+ onError(error: unknown) {
167
+ logRenderError({ method: req.method, path: new URL(req.url).pathname, error });
168
+ },
169
+ debugChannel: createDebugChannelSink(),
170
+ });
171
+
172
+ const [ssrStream, inlineStream] = rscStream.tee();
173
+
174
+ const navContext: NavContext = {
175
+ pathname: new URL(req.url).pathname,
176
+ params: match.params,
177
+ searchParams: Object.fromEntries(new URL(req.url).searchParams),
178
+ statusCode: deny.status,
179
+ responseHeaders,
180
+ headHtml,
181
+ bootstrapScriptContent: clientBootstrap.bootstrapScriptContent,
182
+ rscStream: inlineStream,
183
+ };
184
+
185
+ return callSsr(ssrStream, navContext);
186
+ }
187
+
188
+ /**
189
+ * Render a status-code error page as a raw RSC Flight stream for client navigation.
190
+ *
191
+ * Same as renderDenyPage but skips SSR — returns the RSC stream directly
192
+ * so the client can reconcile the error page into the existing DOM.
193
+ */
194
+ export async function renderDenyPageAsRsc(
195
+ deny: DenySignal,
196
+ segments: ManifestSegmentNode[],
197
+ layoutComponents: LayoutEntry[],
198
+ responseHeaders: Headers,
199
+ createDebugChannelSink: DebugChannelFactory
200
+ ): Promise<Response> {
201
+ const resolution = resolveManifestStatusFile(deny.status, segments, 'component');
202
+
203
+ if (!resolution) {
204
+ responseHeaders.set('content-type', `${RSC_CONTENT_TYPE}; charset=utf-8`);
205
+ return new Response(null, { status: deny.status, headers: responseHeaders });
206
+ }
207
+
208
+ const mod = (await resolution.file.load()) as Record<string, unknown>;
209
+ if (!mod.default) {
210
+ responseHeaders.set('content-type', `${RSC_CONTENT_TYPE}; charset=utf-8`);
211
+ return new Response(null, { status: deny.status, headers: responseHeaders });
212
+ }
213
+
214
+ const ErrorPageComponent = mod.default as (...args: unknown[]) => unknown;
215
+ const h = createElement as (...args: unknown[]) => React.ReactElement;
216
+
217
+ // Check shell opt-out
218
+ const shellEnabled = mod.shell !== false;
219
+
220
+ let element = h(ErrorPageComponent, {
221
+ status: deny.status,
222
+ dangerouslyPassData: deny.data,
223
+ });
224
+
225
+ // Wrap in layouts unless shell is explicitly disabled
226
+ if (shellEnabled) {
227
+ const resolvedSegments = new Set(segments.slice(0, resolution.segmentIndex + 1));
228
+ const layoutsToWrap = layoutComponents.filter((lc) => resolvedSegments.has(lc.segment));
229
+ for (let i = layoutsToWrap.length - 1; i >= 0; i--) {
230
+ const { component } = layoutsToWrap[i];
231
+ element = h(component, null, element);
232
+ }
233
+ }
234
+
235
+ const rscStream = renderToReadableStream(element, {
236
+ onError(error: unknown) {
237
+ console.error('[timber] Error page RSC render error:', error);
238
+ },
239
+ debugChannel: createDebugChannelSink(),
240
+ });
241
+
242
+ responseHeaders.set('content-type', `${RSC_CONTENT_TYPE}; charset=utf-8`);
243
+ responseHeaders.set('Vary', 'Accept');
244
+ return new Response(rscStream, {
245
+ status: deny.status,
246
+ headers: responseHeaders,
247
+ });
248
+ }
249
+
250
+ // ─── JSON Rendering ─────────────────────────────────────────────────────────
251
+
252
+ /**
253
+ * Render a JSON status-code file for a DenySignal.
254
+ *
255
+ * JSON status files are returned verbatim with Content-Type: application/json.
256
+ * No React rendering pipeline, no layout wrapping.
257
+ *
258
+ * Returns null if no JSON status file is found (caller should use bare JSON fallback).
259
+ */
260
+ async function renderDenyPageJson(
261
+ deny: DenySignal,
262
+ segments: ManifestSegmentNode[],
263
+ responseHeaders: Headers
264
+ ): Promise<Response | null> {
265
+ const resolution = resolveManifestStatusFile(deny.status, segments, 'json');
266
+
267
+ if (!resolution) {
268
+ return null;
269
+ }
270
+
271
+ // JSON status files are loaded as modules that export the JSON content.
272
+ // The manifest's load() imports the .json file, which Vite handles as a
273
+ // default export of the parsed JSON object.
274
+ const mod = (await resolution.file.load()) as Record<string, unknown>;
275
+ const jsonContent = mod.default ?? mod;
276
+
277
+ responseHeaders.set('content-type', 'application/json; charset=utf-8');
278
+ return new Response(JSON.stringify(jsonContent), {
279
+ status: deny.status,
280
+ headers: responseHeaders,
281
+ });
282
+ }
283
+
284
+ /**
285
+ * Return a bare JSON error response when no JSON status file exists.
286
+ * This is the framework default for JSON format requests.
287
+ */
288
+ function bareJsonResponse(status: number, responseHeaders: Headers): Response {
289
+ responseHeaders.set('content-type', 'application/json; charset=utf-8');
290
+ return new Response(JSON.stringify({ error: true, status }), {
291
+ status,
292
+ headers: responseHeaders,
293
+ });
294
+ }
295
+
296
+ function escapeHtml(str: string): string {
297
+ return str
298
+ .replace(/&/g, '&amp;')
299
+ .replace(/</g, '&lt;')
300
+ .replace(/>/g, '&gt;')
301
+ .replace(/"/g, '&quot;');
302
+ }