@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.
- package/agent-skill.md +344 -0
- package/dist/cli.d.ts +9 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +41 -4
- package/dist/cli.js.map +1 -1
- package/dist/client/browser-entry/action-dispatch.d.ts.map +1 -1
- package/dist/server/internal.js +6 -2
- package/dist/server/internal.js.map +1 -1
- package/dist/server/pipeline-metadata.d.ts.map +1 -1
- package/dist/server/pipeline-phases.d.ts.map +1 -1
- package/dist/server/sitemap-handler.d.ts.map +1 -1
- package/docs/api/30-api-server.mdx +233 -0
- package/docs/api/31-api-client.mdx +161 -0
- package/docs/api/32-api-cache.mdx +193 -0
- package/docs/api/33-api-search-params.mdx +141 -0
- package/docs/api/34-api-config.mdx +189 -0
- package/docs/api/35-api-typescript.mdx +170 -0
- package/docs/api/36-cli.mdx +143 -0
- package/docs/learn/00-introduction.mdx +81 -0
- package/docs/learn/01-quick-start.mdx +94 -0
- package/docs/learn/02-pages-and-layouts.mdx +138 -0
- package/docs/learn/03-fetching-data.mdx +211 -0
- package/docs/learn/03b-access-control.mdx +130 -0
- package/docs/learn/04-loading-states.mdx +67 -0
- package/docs/learn/04b-the-flush-point.mdx +115 -0
- package/docs/learn/05-typed-params.mdx +275 -0
- package/docs/learn/06-forms-and-actions.mdx +150 -0
- package/docs/learn/08-streaming.mdx +146 -0
- package/docs/learn/09-caching.mdx +121 -0
- package/docs/learn/10-middleware.mdx +156 -0
- package/docs/learn/11-error-handling.mdx +147 -0
- package/docs/learn/12-client-navigation.mdx +168 -0
- package/docs/learn/13-configuration.mdx +117 -0
- package/docs/learn/14-deploying.mdx +206 -0
- package/docs/more/01-advanced-routing.mdx +139 -0
- package/docs/more/02-advanced-forms.mdx +137 -0
- package/docs/more/03-coming-from-nextjs.mdx +186 -0
- package/docs/more/04-metadata-and-fonts.mdx +193 -0
- package/docs/more/04b-mdx.mdx +209 -0
- package/docs/more/05-content-collections.mdx +90 -0
- package/docs/more/06-instrumentation.mdx +188 -0
- package/docs/more/07-security.mdx +123 -0
- package/docs/more/40-why-timber.mdx +50 -0
- package/docs/more/41-timber-vs-nextjs.mdx +81 -0
- package/docs/more/42-timber-vs-others.mdx +68 -0
- package/llms.txt +55 -0
- package/package.json +6 -2
- package/src/cli.ts +62 -3
- package/src/client/browser-entry/action-dispatch.ts +14 -0
- package/src/server/pipeline-metadata.ts +1 -0
- package/src/server/pipeline-phases.ts +4 -1
- package/src/server/sitemap-handler.ts +12 -3
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: 'Streaming'
|
|
3
|
+
description: 'Suspense placement, deferSuspenseFor, and the layout footgun.'
|
|
4
|
+
slug: 'streaming'
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Streaming
|
|
8
|
+
|
|
9
|
+
```ts title="app/schema.ts" id="schema" hidden
|
|
10
|
+
import { defineSchema } from '@timber-js/app/params';
|
|
11
|
+
import { codec } from '@timber-js/app/codec';
|
|
12
|
+
|
|
13
|
+
export default defineSchema({
|
|
14
|
+
segmentParams: {
|
|
15
|
+
'[id]': codec.string,
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Streaming lets the server send HTML to the browser in chunks as it becomes ready. timber uses streaming selectively — the shell is sent as a complete block, and only `<Suspense>` boundaries stream in afterward.
|
|
21
|
+
|
|
22
|
+
## The Shell vs. Suspense Boundaries
|
|
23
|
+
|
|
24
|
+
timber divides your page into two phases:
|
|
25
|
+
|
|
26
|
+
**The shell** (before the [flush point](/docs/the-flush-point)):
|
|
27
|
+
- Layouts, navigation, page structure
|
|
28
|
+
- Primary content that defines the page
|
|
29
|
+
- Determines the HTTP status code
|
|
30
|
+
|
|
31
|
+
**Suspense boundaries** (after the flush):
|
|
32
|
+
- Secondary content wrapped in `<Suspense>`
|
|
33
|
+
- Streams in progressively as data resolves
|
|
34
|
+
- Cannot affect the status code
|
|
35
|
+
|
|
36
|
+
```tsx title="app/products/[id]/page.tsx" use="schema"
|
|
37
|
+
import { Suspense } from 'react';
|
|
38
|
+
import { SEGMENT_PATH } from './$segment';
|
|
39
|
+
import { deny, getSegmentParams } from '@timber-js/app/server';
|
|
40
|
+
|
|
41
|
+
declare function getProduct(id: string): Promise<{ id: string; name: string } | null>;
|
|
42
|
+
declare function ReviewsSkeleton(): React.ReactElement;
|
|
43
|
+
declare function ProductReviews(props: { productId: string }): React.ReactElement;
|
|
44
|
+
|
|
45
|
+
export default async function ProductPage() {
|
|
46
|
+
const { id } = getSegmentParams(SEGMENT_PATH);
|
|
47
|
+
const product = await getProduct(id);
|
|
48
|
+
if (!product) deny(404); // Real HTTP 404
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<div>
|
|
52
|
+
<h1>{product.name}</h1>
|
|
53
|
+
<Suspense fallback={<ReviewsSkeleton />}>
|
|
54
|
+
<ProductReviews productId={product.id} />
|
|
55
|
+
</Suspense>
|
|
56
|
+
</div>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
The product loads before the flush — if it doesn't exist, the server sends a real 404. Reviews stream in later.
|
|
62
|
+
|
|
63
|
+
## Choosing What to Stream
|
|
64
|
+
|
|
65
|
+
**Don't stream** (keep in the shell):
|
|
66
|
+
- Content that defines the page — the product, the article, the user profile
|
|
67
|
+
- Content that might produce a 404 or redirect
|
|
68
|
+
- Content that should be visible without JavaScript
|
|
69
|
+
|
|
70
|
+
**Do stream** (wrap in `<Suspense>`):
|
|
71
|
+
- Recommendations, related items, activity feeds
|
|
72
|
+
- Analytics widgets, personalization
|
|
73
|
+
- Anything slow that isn't essential to the page's identity
|
|
74
|
+
|
|
75
|
+
The rule: if missing content means the page shouldn't exist, don't stream it.
|
|
76
|
+
|
|
77
|
+
## No `loading.tsx`
|
|
78
|
+
|
|
79
|
+
There is no `loading.tsx` convention. timber does not insert Suspense boundaries for you. If you want a loading state, you place `<Suspense>` explicitly. This keeps you in control of what streams and what blocks.
|
|
80
|
+
|
|
81
|
+
## The Layout Suspense Footgun
|
|
82
|
+
|
|
83
|
+
Wrapping `children` in `<Suspense>` inside a layout surrenders HTTP correctness for every page in that subtree:
|
|
84
|
+
|
|
85
|
+
```tsx
|
|
86
|
+
// ⚠️ Don't do this
|
|
87
|
+
export default function Layout({ children }: { children: React.ReactNode }) {
|
|
88
|
+
return (
|
|
89
|
+
<Shell>
|
|
90
|
+
<Suspense fallback={<Skeleton />}>{children}</Suspense>
|
|
91
|
+
</Shell>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
The framework warns in dev mode when this is detected. For navigation loading states, use `usePendingNavigation()` instead.
|
|
97
|
+
|
|
98
|
+
## `deferSuspenseFor`
|
|
99
|
+
|
|
100
|
+
Sometimes Suspense content resolves fast enough that you'd rather wait for it than show a fallback. `deferSuspenseFor` holds the SSR stream for a specified duration:
|
|
101
|
+
|
|
102
|
+
```tsx title="app/products/[id]/page.tsx" use="schema"
|
|
103
|
+
import { Suspense } from 'react';
|
|
104
|
+
import { SEGMENT_PATH } from './$segment';
|
|
105
|
+
import { deny, getSegmentParams } from '@timber-js/app/server';
|
|
106
|
+
|
|
107
|
+
declare function getProduct(id: string): Promise<{ id: string; name: string } | null>;
|
|
108
|
+
declare function ProductHeader(props: { product: { name: string } }): React.ReactElement;
|
|
109
|
+
declare function ReviewsSkeleton(): React.ReactElement;
|
|
110
|
+
declare function ProductReviews(props: { productId: string }): React.ReactElement;
|
|
111
|
+
|
|
112
|
+
export const deferSuspenseFor = 200; // milliseconds
|
|
113
|
+
|
|
114
|
+
export default async function ProductPage() {
|
|
115
|
+
const { id } = getSegmentParams(SEGMENT_PATH);
|
|
116
|
+
const product = await getProduct(id);
|
|
117
|
+
if (!product) deny(404);
|
|
118
|
+
|
|
119
|
+
return (
|
|
120
|
+
<div>
|
|
121
|
+
<ProductHeader product={product} />
|
|
122
|
+
<Suspense fallback={<ReviewsSkeleton />}>
|
|
123
|
+
<ProductReviews productId={product.id} />
|
|
124
|
+
</Suspense>
|
|
125
|
+
</div>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
If reviews resolve within 200ms, they render inline — no fallback shown. If they take longer, the fallback flushes and reviews stream in when ready.
|
|
131
|
+
|
|
132
|
+
## Signal Promotion
|
|
133
|
+
|
|
134
|
+
When `deny()` or `redirect()` fires inside a `<Suspense>` boundary:
|
|
135
|
+
|
|
136
|
+
**Before the flush** — the signal is promoted to pre-flush semantics. The framework sends the correct HTTP status code and renders the appropriate status file.
|
|
137
|
+
|
|
138
|
+
**After the flush** — the status code is already committed. The signal triggers an error boundary in the client, and timber adds `<meta name="robots" content="noindex">`.
|
|
139
|
+
|
|
140
|
+
`deferSuspenseFor` extends the window where signals can be promoted.
|
|
141
|
+
|
|
142
|
+
**The rule:** don't rely on Suspense-wrapped content to control the status code. If content must drive the status code, fetch it outside `<Suspense>`. Signal promotion is a safety net, not a guarantee.
|
|
143
|
+
|
|
144
|
+
## Early Hints (103)
|
|
145
|
+
|
|
146
|
+
Before the flush, timber sends 103 Early Hints with CSS, fonts, and JS URLs. These fire at route-match time — before middleware runs — based on the build manifest. The browser starts fetching resources while the server is still working.
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: 'Caching'
|
|
3
|
+
description: 'Explicit caching with timber.cache — TTL, tags, staleWhileRevalidate, and pluggable backends.'
|
|
4
|
+
slug: 'caching'
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Caching
|
|
8
|
+
|
|
9
|
+
timber.js does not cache anything by default. No patched `fetch`, no implicit data cache. If you want caching, you ask for it explicitly.
|
|
10
|
+
|
|
11
|
+
## timber.cache vs React.cache
|
|
12
|
+
|
|
13
|
+
This is the number one confusion point. They are different things:
|
|
14
|
+
|
|
15
|
+
| | `timber.cache` | `React.cache` |
|
|
16
|
+
| ------------------- | --------------------------- | -------------------- |
|
|
17
|
+
| Scope | Cross-request (persistent) | Single request |
|
|
18
|
+
| TTL/tags | Yes | No |
|
|
19
|
+
| Works in middleware | Yes | No |
|
|
20
|
+
| Use case | Database queries, API calls | Request-scoped dedup |
|
|
21
|
+
|
|
22
|
+
`React.cache` deduplicates within a single request — if three components call `getUser(id)`, only one database query runs. `timber.cache` persists across requests with TTL — the same query serves cached results for 60 seconds.
|
|
23
|
+
|
|
24
|
+
```tsx title="lib/data.ts"
|
|
25
|
+
import { cache } from 'react';
|
|
26
|
+
import { cache as timberCache } from '@timber-js/app/cache';
|
|
27
|
+
|
|
28
|
+
declare const db: {
|
|
29
|
+
users: { findUnique(opts: { where: { id: string } }): Promise<{ id: string; name: string }> };
|
|
30
|
+
products: { findPopular(): Promise<{ id: string; name: string }[]> };
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// Deduplicates within one request
|
|
34
|
+
export const getUser = cache(async (id: string) => {
|
|
35
|
+
return db.users.findUnique({ where: { id } });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// Caches across requests for 60 seconds
|
|
39
|
+
export const getPopularProducts = timberCache(
|
|
40
|
+
async () => {
|
|
41
|
+
return db.products.findPopular();
|
|
42
|
+
},
|
|
43
|
+
{ ttl: 60, tags: ['products'] }
|
|
44
|
+
);
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## `timber.cache()` Options
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
import { cache } from '@timber-js/app/cache';
|
|
51
|
+
|
|
52
|
+
declare const db: {
|
|
53
|
+
users: { findUnique(opts: { where: { id: string } }): Promise<{ id: string; name: string }> };
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const getUser = cache(
|
|
57
|
+
async (userId: string) => {
|
|
58
|
+
return db.users.findUnique({ where: { id: userId } });
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
ttl: 60, // Cache for 60 seconds
|
|
62
|
+
key: (userId) => `user:${userId}`, // Explicit cache key (optional)
|
|
63
|
+
staleWhileRevalidate: true, // Serve stale while refetching
|
|
64
|
+
tags: (userId) => [`user:${userId}`], // For tag-based invalidation
|
|
65
|
+
}
|
|
66
|
+
);
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`tags` accepts a static array (`['products']`) or a function that receives the same arguments as the wrapped function.
|
|
70
|
+
|
|
71
|
+
## Cache Keys
|
|
72
|
+
|
|
73
|
+
Without an explicit `key`, the cache key is derived from the call site plus a deterministic serialization of the arguments. The call-site identity is content-based — it survives restarts and deploys for unchanged `cache()` calls, and changes when you edit the wrapped function or its options (which invalidates the old entries on persistent handlers like Redis). Object key order doesn't matter — `{ a: 1, b: 2 }` and `{ b: 2, a: 1 }` produce the same key. `Date`, `URL`, and anything with a `toJSON()` method serialize the way `JSON.stringify` would; `Map` and `Set` are serialized by their entries.
|
|
74
|
+
|
|
75
|
+
Arguments that can't be serialized faithfully — functions, symbols, or class instances whose data lives behind getters with no `toJSON()` — throw a `TypeError` instead of silently colliding. If you hit this, pass an explicit `key`:
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
cache(fn, { ttl: 60, key: (session) => `events:${session.userId}` });
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Invalidation
|
|
82
|
+
|
|
83
|
+
Invalidate by tag:
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
import { revalidateTag } from '@timber-js/app/server';
|
|
87
|
+
|
|
88
|
+
// In a server action after updating a product:
|
|
89
|
+
revalidateTag('products');
|
|
90
|
+
revalidateTag(`user:${userId}`);
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Invalidation wins races against in-flight cached functions: if a cached function is mid-execution when you invalidate one of its tags (the usual mutation flow — update the database, then `revalidateTag`), its result is not written back to the cache, and callers arriving after the invalidation re-execute instead of joining the superseded execution. This guard is per-process; with a shared handler (Redis/KV), an invalidation from a *different* instance can still race a concurrent write on this one.
|
|
94
|
+
|
|
95
|
+
## `staleWhileRevalidate`
|
|
96
|
+
|
|
97
|
+
When enabled, expired entries are served immediately while a background refetch runs:
|
|
98
|
+
|
|
99
|
+
- **0–60s:** Fresh. Served from cache.
|
|
100
|
+
- **60s+:** Stale. Served immediately, background refetch starts.
|
|
101
|
+
- **Next request after refetch:** Gets fresh data.
|
|
102
|
+
|
|
103
|
+
On serverless platforms (Cloudflare Workers, Lambda), the background refetch is automatically registered with the platform's `waitUntil` so it completes even after the response is sent. If the refetch fails, the stale entry continues to be served and the error is logged.
|
|
104
|
+
|
|
105
|
+
## Singleflight
|
|
106
|
+
|
|
107
|
+
When multiple concurrent requests miss the cache for the same key, only one executes the underlying function. The rest wait for the result. The result is written to the cache exactly once, by the execution that ran the function. This is built in — no opt-in needed.
|
|
108
|
+
|
|
109
|
+
## Cache Handlers
|
|
110
|
+
|
|
111
|
+
The default handler is in-memory. Configure a different backend in `timber.config.ts`:
|
|
112
|
+
|
|
113
|
+
```ts title="timber.config.ts"
|
|
114
|
+
import { MemoryCacheHandler } from '@timber-js/app/cache';
|
|
115
|
+
|
|
116
|
+
export default {
|
|
117
|
+
cacheHandler: new MemoryCacheHandler(),
|
|
118
|
+
};
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Implement the `CacheHandler` interface for Redis, Cloudflare KV, or any other backend. See the [Cache API Reference](/docs/api-cache).
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: 'Middleware'
|
|
3
|
+
description: 'proxy.ts for global concerns, middleware.ts for per-segment logic.'
|
|
4
|
+
slug: 'middleware'
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Middleware
|
|
8
|
+
|
|
9
|
+
```ts title="app/schema.ts" id="schema" hidden
|
|
10
|
+
import { defineSchema } from '@timber-js/app/params';
|
|
11
|
+
import { codec } from '@timber-js/app/codec';
|
|
12
|
+
|
|
13
|
+
export default defineSchema({
|
|
14
|
+
segmentParams: {
|
|
15
|
+
'[userId]': codec.string,
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
timber.js has two middleware layers: `proxy.ts` for global concerns and `middleware.ts` for per-segment logic.
|
|
21
|
+
|
|
22
|
+
## Middleware vs Access
|
|
23
|
+
|
|
24
|
+
Before diving in, the first question: should this be middleware or `access.ts`?
|
|
25
|
+
|
|
26
|
+
| | `middleware.ts` | `access.ts` |
|
|
27
|
+
| ------------- | -------------------------------------- | --------------------------------- |
|
|
28
|
+
| Runs | Before rendering | Inside React tree |
|
|
29
|
+
| Purpose | Headers, redirects, cache warming | Auth, data gating |
|
|
30
|
+
| `React.cache` | Not available | Active — shares scope with layout |
|
|
31
|
+
| Slots | N/A | Graceful degradation |
|
|
32
|
+
|
|
33
|
+
Use `middleware.ts` for request-level decisions: should this request proceed? What headers does it need? Use `access.ts` for auth — it has `React.cache` and supports slot degradation.
|
|
34
|
+
|
|
35
|
+
## `proxy.ts` — Global Middleware
|
|
36
|
+
|
|
37
|
+
`app/proxy.ts` runs on every request before route matching:
|
|
38
|
+
|
|
39
|
+
```ts title="app/proxy.ts"
|
|
40
|
+
export default [
|
|
41
|
+
cors({ origins: ['https://example.com'] }),
|
|
42
|
+
rateLimit({ requests: 100, window: '1m' }),
|
|
43
|
+
securityHeaders(),
|
|
44
|
+
];
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
```ts title="app/proxy.ts"
|
|
48
|
+
export default async function proxy(req: Request, next: () => Promise<Response>) {
|
|
49
|
+
const start = performance.now();
|
|
50
|
+
const res = await next();
|
|
51
|
+
console.log(`${req.method} ${req.url} — ${performance.now() - start}ms`);
|
|
52
|
+
return res;
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`proxy.ts` has `next()` — it can wrap the entire request lifecycle. This is where you observe and modify responses.
|
|
57
|
+
|
|
58
|
+
## `middleware.ts` — Per-Segment Middleware
|
|
59
|
+
|
|
60
|
+
`middleware.ts` files run after route matching, before rendering. They're scoped to their route segment.
|
|
61
|
+
|
|
62
|
+
```ts title="app/dashboard/middleware.ts"
|
|
63
|
+
import { redirect } from '@timber-js/app/server';
|
|
64
|
+
import type { MiddlewareContext } from '@timber-js/app/server';
|
|
65
|
+
|
|
66
|
+
declare function getSession(req: Request): Promise<{ userId: string } | null>;
|
|
67
|
+
|
|
68
|
+
export default async function middleware(ctx: MiddlewareContext) {
|
|
69
|
+
const session = await getSession(ctx.req);
|
|
70
|
+
if (!session) redirect('/login');
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
**Key differences from proxy.ts:** Middleware does not have `next()`. It's a gate — it either short-circuits (redirect, deny, return a Response) or passes through silently. It cannot observe or modify the render result.
|
|
75
|
+
|
|
76
|
+
**Key difference from Next.js:** Next.js has a single global `middleware.ts`. timber lets you place `middleware.ts` at any route segment.
|
|
77
|
+
|
|
78
|
+
## Middleware Context
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
export default async function middleware(ctx: MiddlewareContext) {
|
|
82
|
+
ctx.req; // The incoming Request
|
|
83
|
+
ctx.segmentParams; // Route params (already coerced by schema.ts)
|
|
84
|
+
ctx.headers; // Response headers (writable)
|
|
85
|
+
ctx.requestHeaders; // Request headers (injectable)
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Middleware can inject request headers that components read later:
|
|
90
|
+
|
|
91
|
+
```ts title="app/middleware.ts"
|
|
92
|
+
import type { MiddlewareContext } from '@timber-js/app/server';
|
|
93
|
+
|
|
94
|
+
declare function detectLocale(req: Request): string;
|
|
95
|
+
|
|
96
|
+
export async function middleware(ctx: MiddlewareContext) {
|
|
97
|
+
const locale = detectLocale(ctx.req);
|
|
98
|
+
ctx.requestHeaders.set('x-locale', locale);
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
```tsx title="app/page.tsx"
|
|
103
|
+
import { getHeaders } from '@timber-js/app/server';
|
|
104
|
+
|
|
105
|
+
declare function Content(props: { locale: string | null }): React.ReactElement;
|
|
106
|
+
|
|
107
|
+
export default async function Page() {
|
|
108
|
+
const locale = (getHeaders()).get('x-locale');
|
|
109
|
+
return <Content locale={locale} />;
|
|
110
|
+
}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Execution Order
|
|
114
|
+
|
|
115
|
+
For a request to `/dashboard/settings`:
|
|
116
|
+
|
|
117
|
+
1. `proxy.ts` (global)
|
|
118
|
+
2. `app/middleware.ts` (root, if exists)
|
|
119
|
+
3. `app/dashboard/middleware.ts` (segment)
|
|
120
|
+
4. `app/dashboard/settings/middleware.ts` (segment)
|
|
121
|
+
5. `access.ts` checks (inside React tree)
|
|
122
|
+
6. Render
|
|
123
|
+
|
|
124
|
+
## Cache Warming
|
|
125
|
+
|
|
126
|
+
Use middleware to fire off data fetches before rendering starts:
|
|
127
|
+
|
|
128
|
+
```ts title="app/users/[userId]/middleware.ts" use="schema"
|
|
129
|
+
import { SEGMENT_PATH } from './$segment';
|
|
130
|
+
import { getSegmentParams } from '@timber-js/app/server';
|
|
131
|
+
import type { MiddlewareContext } from '@timber-js/app/server';
|
|
132
|
+
|
|
133
|
+
declare function getUser(userId: string): Promise<{ id: string; name: string }>;
|
|
134
|
+
declare function getUserPosts(userId: string): Promise<{ id: string; title: string }[]>;
|
|
135
|
+
|
|
136
|
+
export async function middleware(ctx: MiddlewareContext) {
|
|
137
|
+
const { userId } = getSegmentParams(SEGMENT_PATH);
|
|
138
|
+
await Promise.all([
|
|
139
|
+
getUser(userId),
|
|
140
|
+
getUserPosts(userId),
|
|
141
|
+
]);
|
|
142
|
+
}
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
```tsx title="app/users/[userId]/page.tsx" use="schema"
|
|
146
|
+
import { SEGMENT_PATH } from './$segment';
|
|
147
|
+
|
|
148
|
+
export default async function UserPage() {
|
|
149
|
+
const { userId } = getSegmentParams(SEGMENT_PATH);
|
|
150
|
+
const user = await getUser(userId); // cache hit
|
|
151
|
+
const posts = await getUserPosts(userId); // cache hit
|
|
152
|
+
return <Profile user={user} posts={posts} />;
|
|
153
|
+
}
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Without cache warming, these fetches waterfall. With middleware, both fire in parallel before rendering begins.
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: 'Error Handling'
|
|
3
|
+
description: 'Status codes, deny(), error boundaries, and status-code files.'
|
|
4
|
+
slug: 'error-handling'
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Error Handling
|
|
8
|
+
|
|
9
|
+
```ts title="app/schema.ts" id="schema" hidden
|
|
10
|
+
import { defineSchema } from '@timber-js/app/params';
|
|
11
|
+
import { codec } from '@timber-js/app/codec';
|
|
12
|
+
|
|
13
|
+
export default defineSchema({
|
|
14
|
+
segmentParams: {
|
|
15
|
+
'[id]': codec.string,
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
timber.js distinguishes between two error phases. The phase determines how the error is handled and what HTTP status code the client sees.
|
|
21
|
+
|
|
22
|
+
## Handler-Phase Errors
|
|
23
|
+
|
|
24
|
+
Errors thrown before rendering (in middleware, access checks, or the page function before JSX) produce a clean HTTP error. No React involved — the server sends the status code and a minimal body.
|
|
25
|
+
|
|
26
|
+
## Render-Phase Errors
|
|
27
|
+
|
|
28
|
+
Errors thrown during React rendering are caught by error boundaries. Because timber buffers until `onShellReady`, errors outside `<Suspense>` still produce correct HTTP status codes.
|
|
29
|
+
|
|
30
|
+
## `deny()`
|
|
31
|
+
|
|
32
|
+
`deny()` produces the correct HTTP status and renders the matching status file:
|
|
33
|
+
|
|
34
|
+
```tsx title="app/products/[id]/page.tsx" use="schema"
|
|
35
|
+
import { SEGMENT_PATH } from './$segment';
|
|
36
|
+
import { deny, getSegmentParams } from '@timber-js/app/server';
|
|
37
|
+
|
|
38
|
+
declare function getProduct(id: string): Promise<{ id: string; name: string } | null>;
|
|
39
|
+
declare function canView(product: { id: string }): boolean;
|
|
40
|
+
declare function ProductView(props: { product: { id: string; name: string } }): React.ReactElement;
|
|
41
|
+
|
|
42
|
+
export default async function ProductPage() {
|
|
43
|
+
const { id } = getSegmentParams(SEGMENT_PATH);
|
|
44
|
+
const product = await getProduct(id);
|
|
45
|
+
if (!product) deny(404); // Real HTTP 404
|
|
46
|
+
if (!canView(product)) deny(); // Default: 403
|
|
47
|
+
|
|
48
|
+
return <ProductView product={product} />;
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`deny()` called outside `<Suspense>` produces a real HTTP status code. Called inside `<Suspense>` (after the flush), it triggers the nearest error boundary and injects `<meta name="robots" content="noindex">`.
|
|
53
|
+
|
|
54
|
+
## Status-Code Files
|
|
55
|
+
|
|
56
|
+
Place status-specific error UIs alongside your routes:
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
app/
|
|
60
|
+
dashboard/
|
|
61
|
+
page.tsx
|
|
62
|
+
error.tsx # Catches any unhandled error
|
|
63
|
+
404.tsx # "Not found" UI
|
|
64
|
+
403.tsx # "Forbidden" UI
|
|
65
|
+
5xx.tsx # Catch-all server errors
|
|
66
|
+
4xx.tsx # Catch-all client errors
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Priority order (innermost to outermost):
|
|
70
|
+
|
|
71
|
+
1. Specific status files (`403.tsx`, `503.tsx`)
|
|
72
|
+
2. Category catch-alls (`4xx.tsx`, `5xx.tsx`)
|
|
73
|
+
3. Next.js-compatible files: `not-found.tsx` (404), `forbidden.tsx` (403), `unauthorized.tsx` (401)
|
|
74
|
+
4. `error.tsx` — catches anything unmatched
|
|
75
|
+
5. Parent segments → framework default
|
|
76
|
+
|
|
77
|
+
## `error.tsx`
|
|
78
|
+
|
|
79
|
+
The general error boundary. It's a client component:
|
|
80
|
+
|
|
81
|
+
```tsx title="app/dashboard/error.tsx"
|
|
82
|
+
'use client';
|
|
83
|
+
|
|
84
|
+
export default function DashboardError({
|
|
85
|
+
error,
|
|
86
|
+
reset,
|
|
87
|
+
}: {
|
|
88
|
+
error: Error;
|
|
89
|
+
reset: () => void;
|
|
90
|
+
}) {
|
|
91
|
+
return (
|
|
92
|
+
<div>
|
|
93
|
+
<h2>Something went wrong</h2>
|
|
94
|
+
<p>{error.message}</p>
|
|
95
|
+
<button onClick={reset}>Try again</button>
|
|
96
|
+
</div>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Error boundaries wrap page content inside layouts — the layout shell (nav, sidebar) stays visible when an error fires.
|
|
102
|
+
|
|
103
|
+
When a `deny()` has no matching status-code file, it falls through to the nearest `error.tsx`. The component still receives a real `error` (its message is `Access denied with status <N>`), and additionally receives `status` and `dangerouslyPassData` so you can render denial-specific UI:
|
|
104
|
+
|
|
105
|
+
```tsx title="app/error.tsx"
|
|
106
|
+
'use client';
|
|
107
|
+
|
|
108
|
+
export default function ErrorPage({
|
|
109
|
+
error,
|
|
110
|
+
reset,
|
|
111
|
+
status,
|
|
112
|
+
}: {
|
|
113
|
+
error: Error;
|
|
114
|
+
reset: () => void;
|
|
115
|
+
status?: number; // set when this boundary is serving a deny()
|
|
116
|
+
}) {
|
|
117
|
+
if (status === 404) {
|
|
118
|
+
return <h2>Not found</h2>;
|
|
119
|
+
}
|
|
120
|
+
return (
|
|
121
|
+
<div>
|
|
122
|
+
<h2>Something went wrong</h2>
|
|
123
|
+
<p>{error.message}</p>
|
|
124
|
+
<button onClick={reset}>Try again</button>
|
|
125
|
+
</div>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
The HTTP status stays correct either way — `deny(404)` falling through to `error.tsx` still responds with a 404, never a 500.
|
|
131
|
+
|
|
132
|
+
## Structured Error Data
|
|
133
|
+
|
|
134
|
+
`deny()` accepts optional JSON-serializable data that is passed to the error page via `dangerouslyPassData`:
|
|
135
|
+
|
|
136
|
+
```tsx
|
|
137
|
+
import { deny } from '@timber-js/app/server';
|
|
138
|
+
|
|
139
|
+
if (!product) {
|
|
140
|
+
deny({
|
|
141
|
+
status: 404,
|
|
142
|
+
data: { title: 'Product not found', resourceId: params.id },
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
The error boundary receives the structured data via the `dangerouslyPassData` prop, surviving the RSC → client boundary where plain error properties are stripped.
|