@timber-js/app 0.2.0-alpha.153 → 0.2.0-alpha.155

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 (52) hide show
  1. package/agent-skill.md +344 -0
  2. package/dist/cli.d.ts +9 -1
  3. package/dist/cli.d.ts.map +1 -1
  4. package/dist/cli.js +41 -4
  5. package/dist/cli.js.map +1 -1
  6. package/dist/client/browser-entry/action-dispatch.d.ts.map +1 -1
  7. package/dist/server/internal.js +6 -2
  8. package/dist/server/internal.js.map +1 -1
  9. package/dist/server/pipeline-metadata.d.ts.map +1 -1
  10. package/dist/server/pipeline-phases.d.ts.map +1 -1
  11. package/dist/server/sitemap-handler.d.ts.map +1 -1
  12. package/docs/api/30-api-server.mdx +233 -0
  13. package/docs/api/31-api-client.mdx +161 -0
  14. package/docs/api/32-api-cache.mdx +193 -0
  15. package/docs/api/33-api-search-params.mdx +141 -0
  16. package/docs/api/34-api-config.mdx +189 -0
  17. package/docs/api/35-api-typescript.mdx +170 -0
  18. package/docs/api/36-cli.mdx +143 -0
  19. package/docs/learn/00-introduction.mdx +81 -0
  20. package/docs/learn/01-quick-start.mdx +94 -0
  21. package/docs/learn/02-pages-and-layouts.mdx +138 -0
  22. package/docs/learn/03-fetching-data.mdx +211 -0
  23. package/docs/learn/03b-access-control.mdx +130 -0
  24. package/docs/learn/04-loading-states.mdx +67 -0
  25. package/docs/learn/04b-the-flush-point.mdx +115 -0
  26. package/docs/learn/05-typed-params.mdx +275 -0
  27. package/docs/learn/06-forms-and-actions.mdx +150 -0
  28. package/docs/learn/08-streaming.mdx +146 -0
  29. package/docs/learn/09-caching.mdx +121 -0
  30. package/docs/learn/10-middleware.mdx +156 -0
  31. package/docs/learn/11-error-handling.mdx +147 -0
  32. package/docs/learn/12-client-navigation.mdx +168 -0
  33. package/docs/learn/13-configuration.mdx +117 -0
  34. package/docs/learn/14-deploying.mdx +206 -0
  35. package/docs/more/01-advanced-routing.mdx +139 -0
  36. package/docs/more/02-advanced-forms.mdx +137 -0
  37. package/docs/more/03-coming-from-nextjs.mdx +186 -0
  38. package/docs/more/04-metadata-and-fonts.mdx +193 -0
  39. package/docs/more/04b-mdx.mdx +209 -0
  40. package/docs/more/05-content-collections.mdx +90 -0
  41. package/docs/more/06-instrumentation.mdx +188 -0
  42. package/docs/more/07-security.mdx +123 -0
  43. package/docs/more/40-why-timber.mdx +50 -0
  44. package/docs/more/41-timber-vs-nextjs.mdx +81 -0
  45. package/docs/more/42-timber-vs-others.mdx +68 -0
  46. package/llms.txt +55 -0
  47. package/package.json +6 -2
  48. package/src/cli.ts +62 -3
  49. package/src/client/browser-entry/action-dispatch.ts +14 -0
  50. package/src/server/pipeline-metadata.ts +1 -0
  51. package/src/server/pipeline-phases.ts +4 -1
  52. package/src/server/sitemap-handler.ts +12 -3
@@ -0,0 +1,209 @@
1
+ ---
2
+ title: 'MDX'
3
+ description: 'Use MDX to write pages in Markdown with embedded React components — server-rendered by default, zero client JS.'
4
+ ---
5
+
6
+ # MDX
7
+
8
+ MDX lets you write pages in Markdown with embedded React components. In timber.js, MDX pages are **server components by default** — they render on the server with zero client JavaScript unless a component explicitly opts in with `'use client'`.
9
+
10
+ ## Setup
11
+
12
+ Add `'mdx'` to `pageExtensions` in your config:
13
+
14
+ ```ts title="timber.config.ts"
15
+ export default {
16
+ pageExtensions: ['tsx', 'ts', 'jsx', 'js', 'mdx'],
17
+ };
18
+ ```
19
+
20
+ Install the MDX compiler and frontmatter plugins:
21
+
22
+ ```bash title="Terminal"
23
+ pnpm add -D @mdx-js/rollup remark-frontmatter remark-mdx-frontmatter
24
+ ```
25
+
26
+ That's it. Any `page.mdx` file in your `app/` directory is now a route.
27
+
28
+ ## Page-Level MDX
29
+
30
+ A `page.mdx` works exactly like `page.tsx` — it becomes a route based on its directory:
31
+
32
+ ```
33
+ app/
34
+ docs/
35
+ getting-started/
36
+ page.mdx # /docs/getting-started
37
+ api-reference/
38
+ page.mdx # /docs/api-reference
39
+ layout.tsx # shared docs layout
40
+ ```
41
+
42
+ MDX pages support `export const metadata` for page titles and descriptions:
43
+
44
+ ````mdx title="app/docs/getting-started/page.mdx"
45
+ export const metadata = { title: 'Getting Started' }
46
+
47
+ # Getting Started
48
+
49
+ Install timber.js:
50
+
51
+ ```bash
52
+ pnpm add @timber-js/app
53
+ ```
54
+
55
+ This page is a **server component** — no JavaScript shipped to the browser.
56
+ ````
57
+
58
+ ## Frontmatter
59
+
60
+ timber.js automatically registers `remark-frontmatter` and `remark-mdx-frontmatter` when installed. Frontmatter is exported as a single `frontmatter` object:
61
+
62
+ ````mdx title="app/blog/hello/page.mdx"
63
+ ---
64
+ title: Hello World
65
+ description: My first post
66
+ ---
67
+
68
+ # {frontmatter.title}
69
+
70
+ {frontmatter.description}
71
+ ````
72
+
73
+ You can import the frontmatter object from another MDX file:
74
+
75
+ ```tsx title="app/blog/layout.tsx"
76
+ import { frontmatter } from './page.mdx';
77
+
78
+ // frontmatter.title, frontmatter.description, etc.
79
+ ```
80
+
81
+ For page metadata, use an explicit `export const metadata` — frontmatter alone does not set metadata automatically:
82
+
83
+ ````mdx title="app/docs/getting-started/page.mdx"
84
+ ---
85
+ title: Getting Started
86
+ ---
87
+
88
+ export const metadata = { title: 'Getting Started' }
89
+
90
+ # {frontmatter.title}
91
+ ````
92
+
93
+ ## Custom Components
94
+
95
+ Create `mdx-components.tsx` at the project root to customize how Markdown elements render:
96
+
97
+ ```tsx title="mdx-components.tsx"
98
+ import type { MDXComponents } from 'mdx/types';
99
+
100
+ export function useMDXComponents(components: MDXComponents): MDXComponents {
101
+ return {
102
+ ...components,
103
+ h1: (props) => <h1 className="text-3xl font-bold mt-8 mb-4" {...props} />,
104
+ pre: (props) => <pre className="bg-gray-900 text-gray-100 p-4 rounded-lg" {...props} />,
105
+ code: (props) => <code className="bg-gray-100 px-1.5 py-0.5 rounded text-sm" {...props} />,
106
+ };
107
+ }
108
+ ```
109
+
110
+ timber.js detects this file at startup and applies it to all MDX files automatically. The file can also live in `src/`.
111
+
112
+ ## Using Client Components in MDX
113
+
114
+ MDX files stay on the server. To add interactivity, import a `'use client'` component:
115
+
116
+ ````mdx title="app/docs/getting-started/page.mdx"
117
+ import { CopyButton } from '../../components/copy-button'
118
+
119
+ # Getting Started
120
+
121
+ ```bash
122
+ pnpm add @timber-js/app
123
+ ```
124
+
125
+ <CopyButton text="pnpm add @timber-js/app" />
126
+ ````
127
+
128
+ Only the `CopyButton` ships JavaScript. The rest of the page renders as static HTML.
129
+
130
+ ## Remark and Rehype Plugins
131
+
132
+ Configure remark and rehype plugins through the `mdx` key in your config:
133
+
134
+ ```ts title="timber.config.ts"
135
+ import remarkGfm from 'remark-gfm';
136
+ import rehypeShiki from '@shikijs/rehype';
137
+
138
+ export default {
139
+ pageExtensions: ['tsx', 'ts', 'jsx', 'js', 'mdx'],
140
+ mdx: {
141
+ remarkPlugins: [remarkGfm],
142
+ rehypePlugins: [[rehypeShiki, { theme: 'monokai' }]],
143
+ },
144
+ };
145
+ ```
146
+
147
+ The `mdx` config maps directly to `@mdx-js/rollup` options. Available fields:
148
+
149
+ | Option | Type | Description |
150
+ | --------------------- | --------------- | ------------------------------------------ |
151
+ | `remarkPlugins` | `PluggableList` | remark plugins for Markdown AST transforms |
152
+ | `rehypePlugins` | `PluggableList` | rehype plugins for HTML AST transforms |
153
+ | `recmaPlugins` | `PluggableList` | recma plugins for ESTree transforms |
154
+ | `remarkRehypeOptions` | `object` | Options passed to `remark-rehype` |
155
+
156
+ Plugins like `remark-gfm` and `rehype-shiki` are your choice to install — timber.js doesn't bundle them.
157
+
158
+ ## Dynamic MDX Loading
159
+
160
+ For content-driven pages like a blog, use `import.meta.glob()` to build a map of MDX modules, then load the right one based on the URL:
161
+
162
+ ```tsx title="app/blog/[slug]/page.tsx"
163
+ import { allBlogs } from 'content-collections';
164
+ import { deny, getSegmentParams } from '@timber-js/app/server';
165
+ import { SEGMENT_PATH } from './$segment';
166
+
167
+ const mdxModules = import.meta.glob<{ default: React.ComponentType }>(
168
+ '../../../content/blog/*.mdx'
169
+ );
170
+
171
+ export default async function BlogPost() {
172
+ const { slug } = getSegmentParams(SEGMENT_PATH);
173
+ const post = allBlogs.find((p) => p.slug === slug);
174
+ if (!post) deny(404);
175
+
176
+ const key = `../../../content/blog/${post._meta.fileName}`;
177
+ const loader = mdxModules[key];
178
+ if (!loader) deny(404);
179
+
180
+ const { default: MdxComponent } = await loader();
181
+
182
+ return (
183
+ <article>
184
+ <h1>{post.title}</h1>
185
+ <MdxComponent />
186
+ </article>
187
+ );
188
+ }
189
+ ```
190
+
191
+ `import.meta.glob()` is resolved at build time by Vite — each `.mdx` file becomes a real ES module in the bundle with no `eval` or `new Function()` at runtime. The glob path is relative to the file that calls it.
192
+
193
+ This pattern pairs with [content collections](/docs/content-collections) — the collection provides typed metadata (title, date, tags), while `import.meta.glob()` loads the MDX body as a React component.
194
+
195
+ An alternative is `compileMDX()` from `@content-collections/mdx`, which compiles MDX to a serialized string during the content collection transform step. You then render it at runtime with `useMDXComponent(code)`. This avoids glob paths but trades them for a runtime eval of the compiled output. `import.meta.glob()` keeps everything as real ES modules resolved at build time.
196
+
197
+ ## Content Collections
198
+
199
+ For structured content outside the route tree (blog posts, docs, changelogs), use [content collections](/docs/content-collections). Collections give you typed schemas, slug generation, and build-time validation — while MDX page routes are for pages that live directly in `app/`.
200
+
201
+ ## Coming from Next.js
202
+
203
+ | Next.js | timber.js |
204
+ | ----------------------------------------- | --------------------------------------------- |
205
+ | `@next/mdx` wrapper package | Built-in — just add `'mdx'` to pageExtensions |
206
+ | `next.config.mjs` `withMDX()` wrapper | `timber.config.ts` `mdx` key |
207
+ | MDX pages are client components by default | MDX pages are server components by default |
208
+ | Custom loader for `.md` files | `@mdx-js/rollup` handles both `.mdx` and `.md` |
209
+ | `mdx-components.tsx` at project root | Same convention |
@@ -0,0 +1,90 @@
1
+ ---
2
+ title: 'Content Collections'
3
+ description: 'Typed file-based content with Zod schemas, MDX rendering, and virtual module access.'
4
+ ---
5
+
6
+ # Content Collections
7
+
8
+ Content collections let you define typed, file-based content in a `content/` directory. Each collection has a Zod schema for frontmatter validation and a transform for processing (like MDX compilation).
9
+
10
+ ## Defining a Collection
11
+
12
+ ```ts
13
+ // content-collections.ts
14
+ import { defineCollection, defineConfig } from '@content-collections/core';
15
+ import { compileMDX } from '@content-collections/mdx';
16
+ import { z } from 'zod/v4';
17
+
18
+ const docs = defineCollection({
19
+ name: 'docs',
20
+ directory: 'content/docs',
21
+ include: '**/*.{mdx,md}',
22
+ schema: z.object({
23
+ title: z.string(),
24
+ description: z.string(),
25
+ order: z.number(),
26
+ section: z.string().optional(),
27
+ }),
28
+ transform: async (document, context) => {
29
+ const mdx = await compileMDX(context, document);
30
+ return { ...document, mdx };
31
+ },
32
+ });
33
+
34
+ export default defineConfig({ collections: [docs] });
35
+ ```
36
+
37
+ ## Using Collections
38
+
39
+ Import the generated collection and query it:
40
+
41
+ ```tsx
42
+ import { allDocs } from 'content-collections';
43
+ import { getSegmentParams, deny } from '@timber-js/app/server';
44
+
45
+ declare const allDocs: { title: string; _meta: { fileName: string }; mdx: { default: React.ComponentType } }[];
46
+
47
+ export default async function DocsPage() {
48
+ const { slug } = getSegmentParams();
49
+ const doc = allDocs.find((d) => d._meta.fileName === slug);
50
+ if (!doc) deny(404);
51
+
52
+ return (
53
+ <article className="prose">
54
+ <h1>{doc.title}</h1>
55
+ <doc.mdx.default />
56
+ </article>
57
+ );
58
+ }
59
+ ```
60
+
61
+ ## Content Directory Structure
62
+
63
+ ```tsx
64
+ content / docs / v1 / getting - started.mdx;
65
+ routing.mdx;
66
+ blog / hello - world.mdx;
67
+ ```
68
+
69
+ Each file's `_meta` includes `path`, `fileName`, and `directory` — useful for filtering by version, building sidebar navigation, or generating static params.
70
+
71
+ ## MDX Components
72
+
73
+ Customize MDX rendering with `mdx-components.tsx`:
74
+
75
+ ```tsx
76
+ // mdx-components.tsx
77
+ import type { MDXComponents } from 'mdx/types';
78
+
79
+ export function useMDXComponents(components: MDXComponents): MDXComponents {
80
+ return {
81
+ ...components,
82
+ h1: ({ children }) => <h1 className="text-3xl font-bold">{children}</h1>,
83
+ code: ({ children }) => <code className="bg-gray-100 rounded px-1">{children}</code>,
84
+ };
85
+ }
86
+ ```
87
+
88
+ ## Type Generation
89
+
90
+ Content collections generate TypeScript types at build time. Every document is fully typed — frontmatter fields, computed properties from transforms, and `_meta` information.
@@ -0,0 +1,188 @@
1
+ ---
2
+ title: 'Instrumentation & Tracing'
3
+ description: 'Server instrumentation, OTEL tracing, custom logging, and Server-Timing headers.'
4
+ slug: 'instrumentation'
5
+ ---
6
+
7
+ # Instrumentation & Tracing
8
+
9
+ timber.js provides structured observability out of the box: per-request trace IDs, OTEL span integration, pluggable logging, and Server-Timing headers.
10
+
11
+ ## `instrumentation.ts`
12
+
13
+ Create an `instrumentation.ts` file at your project root. It runs once at server startup, before the first request is handled.
14
+
15
+ ```ts
16
+ // instrumentation.ts
17
+
18
+ // Called once at startup. Initialize your OTEL SDK, database pools, etc.
19
+ export async function register() {
20
+ // e.g. initialize OpenTelemetry
21
+ const { NodeSDK } = await import('@opentelemetry/sdk-node');
22
+ const sdk = new NodeSDK({
23
+ /* ... */
24
+ });
25
+ sdk.start();
26
+ }
27
+
28
+ // Called on every unhandled server error.
29
+ export function onRequestError(error, request, context) {
30
+ // Send to Sentry, Datadog, etc.
31
+ console.error(`[${context.phase}] ${request.method} ${request.path}`, error);
32
+ }
33
+
34
+ // Optional: replace the default logger.
35
+ export { logger } from './lib/logger';
36
+ ```
37
+
38
+ ### `register()`
39
+
40
+ Called once before the server starts accepting requests. Use it to:
41
+
42
+ - Initialize an OpenTelemetry SDK
43
+ - Set up database connection pools
44
+ - Configure external services
45
+
46
+ The server blocks until `register()` resolves.
47
+
48
+ ### `onRequestError(error, request, context)`
49
+
50
+ Called for every unhandled error in the server pipeline. The handler receives:
51
+
52
+ | Parameter | Type | Description |
53
+ | ------------------- | ------------------------ | ----------------------------------------------------- |
54
+ | `error` | `unknown` | The thrown error |
55
+ | `request.method` | `string` | HTTP method (`'GET'`, `'POST'`, etc.) |
56
+ | `request.path` | `string` | Request path (`'/dashboard/projects/123'`) |
57
+ | `request.headers` | `Record<string, string>` | Request headers |
58
+ | `context.phase` | `string` | Pipeline phase: `'proxy'`, `'handler'`, or `'render'` |
59
+ | `context.routePath` | `string` | Request pathname (`'/dashboard/projects/123'`) |
60
+ | `context.routeType` | `string` | Currently always `'page'` |
61
+ | `context.traceId` | `string` | 32-char hex trace ID for correlation |
62
+
63
+ The handler must not affect the response. Errors thrown by the handler are caught and logged.
64
+
65
+ ### `logger`
66
+
67
+ Export a logger object to replace the default logger:
68
+
69
+ ```ts
70
+ // instrumentation.ts
71
+ import pino from 'pino';
72
+
73
+ export const logger = pino({ level: 'info' });
74
+ ```
75
+
76
+ Any object with `info`, `warn`, `error`, and `debug` methods works:
77
+
78
+ ```ts
79
+ interface TimberLogger {
80
+ info(msg: string, data?: Record<string, unknown>): void;
81
+ warn(msg: string, data?: Record<string, unknown>): void;
82
+ error(msg: string, data?: Record<string, unknown>): void;
83
+ debug(msg: string, data?: Record<string, unknown>): void;
84
+ }
85
+ ```
86
+
87
+ The default logger writes human-readable lines to stderr with automatic trace ID injection.
88
+
89
+ ## Tracing
90
+
91
+ ### `getTraceId()`
92
+
93
+ Returns the current request's trace ID — always a 32-char lowercase hex string. Available in middleware, access checks, server components, and server actions.
94
+
95
+ ```ts
96
+ import { getTraceId } from '@timber-js/app/server';
97
+
98
+ const id = getTraceId(); // "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6"
99
+ ```
100
+
101
+ - **With OTEL**: returns the real OTEL trace ID (matches Jaeger, Honeycomb, Datadog)
102
+ - **Without OTEL**: returns a `crypto.randomUUID()`-derived fallback in the same format
103
+
104
+ Use `getTraceId()` to correlate logs, errors, and external service calls for a single request.
105
+
106
+ ### `getSpanId()`
107
+
108
+ Returns the current OTEL span ID if available, `undefined` otherwise.
109
+
110
+ ```ts
111
+ import { getSpanId } from '@timber-js/app/server';
112
+
113
+ const sid = getSpanId(); // "1234567890abcdef" or undefined
114
+ ```
115
+
116
+ ### `withSpan(name, attributes, fn)`
117
+
118
+ Run a function within an OTEL span. If OTEL is not available, the function runs directly with zero overhead.
119
+
120
+ ```ts
121
+ import { withSpan } from '@timber-js/app/server';
122
+
123
+ declare const userId: string;
124
+ declare const db: { users: { findUnique(opts: { where: { id: string } }): Promise<{ id: string; name: string }> } };
125
+
126
+ const user = await withSpan('db.getUser', { userId }, async () => {
127
+ return db.users.findUnique({ where: { id: userId } });
128
+ });
129
+ ```
130
+
131
+ The span automatically:
132
+
133
+ - Creates as a child of the current active span
134
+ - Records exceptions on error
135
+ - Ends when the function completes
136
+
137
+ ### `addSpanEvent(name, attributes?)`
138
+
139
+ Add an event to the current active span. Used for recording cache hits/misses, checkpoints, etc.
140
+
141
+ ```ts
142
+ import { addSpanEvent } from '@timber-js/app/server';
143
+
144
+ await addSpanEvent('cache.hit', { key: 'user:123' });
145
+ ```
146
+
147
+ ## Server-Timing
148
+
149
+ timber.js can emit `Server-Timing` headers for browser DevTools performance inspection.
150
+
151
+ ```ts
152
+ // timber.config.ts
153
+ export default {
154
+ serverTiming: 'detailed', // 'detailed' | 'total' | false
155
+ };
156
+ ```
157
+
158
+ | Value | Description |
159
+ | ------------ | -------------------------------------------------------------------------- |
160
+ | `'detailed'` | Per-phase timing (proxy, middleware, access, render). Default in dev mode. |
161
+ | `'total'` | Total request duration only. Default in production. |
162
+ | `false` | No Server-Timing header. |
163
+
164
+ View the timings in Chrome DevTools under Network > Timing.
165
+
166
+ ## Log–Trace Correlation
167
+
168
+ All framework log messages automatically include `trace_id` and `span_id` (when OTEL is active). Custom loggers receive these in the `data` parameter:
169
+
170
+ ```json
171
+ {
172
+ "msg": "request completed",
173
+ "method": "GET",
174
+ "path": "/dashboard",
175
+ "status": 200,
176
+ "durationMs": 42,
177
+ "trace_id": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
178
+ "span_id": "1234567890abcdef"
179
+ }
180
+ ```
181
+
182
+ This enables filtering logs by trace ID in your logging backend to see all events for a single request.
183
+
184
+ ## Dev Mode
185
+
186
+ In development, timber automatically initializes a minimal OTEL SDK with a `DevSpanProcessor` that outputs span information to the dev log. No configuration needed — just run `pnpm dev`.
187
+
188
+ In production, OTEL spans are only emitted when you initialize an SDK in `register()`.
@@ -0,0 +1,123 @@
1
+ ---
2
+ title: 'Security'
3
+ description: 'Built-in CSRF protection, action encryption, redirect safety, and header immutability.'
4
+ slug: 'security'
5
+ ---
6
+
7
+ # Security
8
+
9
+ timber.js ships with several security protections enabled by default. You don't need to configure most of them — they're structural, built into how the framework handles requests.
10
+
11
+ ## CSRF Protection
12
+
13
+ All server actions validate the `Origin` header automatically. The `Origin` is compared against the request's full origin (scheme + host + port) — not just the hostname. Behind a reverse proxy, the scheme is derived from `X-Forwarded-Proto`. If the origin doesn't match, the action is rejected with 403. No configuration required.
14
+
15
+ This protects against cross-site request forgery attacks where a malicious page submits a form to your server. The protection works for both JavaScript-enhanced actions and plain HTML form submissions.
16
+
17
+ ## Server Action Encryption
18
+
19
+ When a server action captures variables from its closure (bound args), those values are serialized into the RSC payload sent to the client. timber encrypts them with AES-256-GCM before they leave the server.
20
+
21
+ ```tsx
22
+ import { SEGMENT_PATH } from './$segment';
23
+ import { getSegmentParams } from '@timber-js/app/server';
24
+
25
+ declare const db: {
26
+ products: { find(id: string): Promise<{ id: string; name: string }> };
27
+ cart: { add(productId: string): Promise<void> };
28
+ };
29
+ declare function AddToCartButton(props: { action: () => Promise<void> }): React.ReactElement;
30
+
31
+ export default async function ProductPage() {
32
+ const { id } = getSegmentParams(SEGMENT_PATH);
33
+ const product = await db.products.find(id);
34
+
35
+ async function addToCart() {
36
+ 'use server';
37
+ // `product.id` is a bound arg — encrypted in the client payload
38
+ await db.cart.add(product.id);
39
+ }
40
+
41
+ return <AddToCartButton action={addToCart} />;
42
+ }
43
+ ```
44
+
45
+ The client receives an opaque, encrypted blob. It can't read or tamper with the bound args — the GCM authentication tag ensures integrity.
46
+
47
+ ### Encryption Key for Multi-Deploy
48
+
49
+ By default, timber generates a random encryption key per build. This means server actions created by one build can't be decrypted by another. For rolling or blue-green deployments where two builds serve traffic simultaneously, set a shared key:
50
+
51
+ ```bash
52
+ TIMBER_ACTIONS_ENCRYPTION_KEY=<base64-encoded-32-byte-key>
53
+ ```
54
+
55
+ Generate a key:
56
+
57
+ ```bash
58
+ node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
59
+ ```
60
+
61
+ ## Redirect Safety
62
+
63
+ ### `redirect()` is relative-only
64
+
65
+ The `redirect()` function only accepts relative paths. Absolute URLs and protocol-relative URLs are rejected:
66
+
67
+ ```ts
68
+ import { redirect } from '@timber-js/app/server';
69
+
70
+ redirect('/dashboard'); // ✅ relative path
71
+ redirect('https://evil.com'); // ❌ throws — absolute URL
72
+ redirect('//evil.com'); // ❌ throws — protocol-relative
73
+ ```
74
+
75
+ This prevents open redirect vulnerabilities where an attacker tricks your app into redirecting users to a malicious site.
76
+
77
+ ### `redirectExternal()` with allow-list
78
+
79
+ When you need to redirect to an external URL, use `redirectExternal()` with an explicit origin allow-list. Only `http:` and `https:` schemes are permitted:
80
+
81
+ ```ts
82
+ import { redirectExternal } from '@timber-js/app/server';
83
+
84
+ redirectExternal('https://auth.example.com/login', ['https://auth.example.com']);
85
+
86
+ // With a custom status code
87
+ redirectExternal('https://docs.example.com/guide', ['https://docs.example.com'], 301);
88
+ ```
89
+
90
+ If the target origin isn't in the allow-list, the scheme isn't http(s), or the URL is invalid, the call throws. This gives you external redirects without the open redirect risk.
91
+
92
+ ## Header Immutability
93
+
94
+ Request headers are immutable after the request enters the pipeline:
95
+
96
+ - `getHeaders()` returns a frozen, read-only `Headers` object — calling `.set()` or `.delete()` throws
97
+ - Middleware can add headers via `ctx.requestHeaders` (an additive overlay), but cannot delete or modify the original request headers
98
+ - The original `Request` object is never mutated
99
+
100
+ This prevents a class of middleware bypass attacks where a malicious or buggy middleware could delete authentication headers before they reach access checks.
101
+
102
+ ## Link Safety
103
+
104
+ The `<Link>` component rejects dangerous URL schemes. Links with `javascript:`, `data:`, or `vbscript:` protocols are blocked at render time, preventing XSS via URL injection.
105
+
106
+ ## Error Information
107
+
108
+ Unexpected server errors return `{ code: 'INTERNAL_ERROR' }` to the client with no stack trace or internal details. To send structured error data across the boundary, use `deny()` with explicit opt-in:
109
+
110
+ ```ts
111
+ import { deny } from '@timber-js/app/server';
112
+
113
+ // Only the data you explicitly pass crosses the boundary
114
+ deny({ status: 404, data: { productId: id } });
115
+ ```
116
+
117
+ In development, error details are shown in the browser error overlay. In production, they're suppressed.
118
+
119
+ ## What's Next
120
+
121
+ - [Authorization](/docs/access-control) — per-segment access control with `access.ts`
122
+ - [Forms & Server Actions](/docs/forms-and-actions) — action clients, validation, and progressive enhancement
123
+ - [Error Handling](/docs/error-handling) — status codes, deny(), and error boundaries
@@ -0,0 +1,50 @@
1
+ ---
2
+ title: 'Why timber.js?'
3
+ description: 'The design decisions behind timber.js and why they matter.'
4
+ slug: 'why-timber'
5
+ ---
6
+
7
+ # Why timber.js?
8
+
9
+ timber.js exists because we think the current generation of React frameworks made a wrong turn on streaming.
10
+
11
+ ## The Premise
12
+
13
+ Most React frameworks stream HTML as fast as possible. The moment the server starts rendering, bytes go to the browser. This sounds great in theory — faster Time to First Byte, progressive rendering, the user sees _something_ sooner.
14
+
15
+ The cost is real:
16
+
17
+ - **Every page returns HTTP 200.** A 404? That's a 200 with an error boundary. A redirect? That's a 200 with client-side navigation. A 500? Also 200. Once you start streaming, you've committed the status code.
18
+ - **Pages don't work without JavaScript.** The client needs JS to resolve suspense boundaries, handle error states, and execute redirects that the server couldn't express through HTTP.
19
+ - **Loading states everywhere.** `loading.tsx` exists because the framework sends the shell before data is ready. Now you're designing skeleton states for every route, managing layout shift, and adding perceived complexity.
20
+
21
+ ## What timber.js Does Differently
22
+
23
+ timber.js holds the response until the shell is ready. That's the content outside `<Suspense>` boundaries — the stuff that actually determines the page's HTTP status, headers, and primary content.
24
+
25
+ The trade-off is roughly 20ms of server-side buffering before the first byte. In exchange:
26
+
27
+ - **Real status codes.** `deny(404)` sends a genuine HTTP 404. CDNs cache it correctly. Search engines deindex it. `curl` sees it. APM tools report it.
28
+ - **Pages work without JavaScript.** The initial render arrives complete. No JS needed to show the primary content.
29
+ - **No implicit loading states.** There's no `loading.tsx`. If you want a loading state, you place a `<Suspense>` boundary explicitly around the slow content — and you choose where.
30
+
31
+ Secondary content (reviews on a product page, comments on a post) can still stream via `<Suspense>`. You decide where the flush boundary sits. The framework doesn't decide for you.
32
+
33
+ ## What timber.js is Not
34
+
35
+ This is not a framework for every use case. It's opinionated about a few things:
36
+
37
+ - **Server-first rendering.** If you're building a single-page app with no server, this isn't the right tool.
38
+ - **Explicit over implicit.** There's no magic caching, no implicit data fetching, no hidden loading states. You opt into each behavior.
39
+ - **Smaller API surface.** We'd rather have fewer features that work correctly than many features with edge cases.
40
+
41
+ Next.js is more mature, more battle-tested, and supports more use cases. If you're happy with it, there's no reason to switch. timber.js is for people who've run into the limitations of streaming-first and want HTTP semantics that work.
42
+
43
+ ## Built on Vite
44
+
45
+ timber.js is a Vite plugin, not a standalone build system. You get Vite's ecosystem: sub-second HMR, the plugin ecosystem, native ESM, and Rolldown for production builds. Your `vite.config.ts` stays normal — timber.js adds to it, it doesn't replace it.
46
+
47
+ ## What's Next
48
+
49
+ - [Getting Started](/docs/quick-start) — create a project in under five minutes
50
+ - [timber.js vs Next.js](/docs/timber-vs-nextjs) — an honest comparison