@timber-js/app 0.2.0-alpha.154 → 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/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
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: 'API: @timber-js/app/server'
|
|
3
|
+
description: 'Server-side exports — deny, redirect, getHeaders, getCookieJar, createActionClient, and context types.'
|
|
4
|
+
slug: 'api-server'
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# @timber-js/app/server
|
|
8
|
+
|
|
9
|
+
Server-side utilities and types. Import from `@timber-js/app/server`.
|
|
10
|
+
|
|
11
|
+
## Navigation & Status
|
|
12
|
+
|
|
13
|
+
### `deny(statusOrOptions?, data?)`
|
|
14
|
+
|
|
15
|
+
Universal denial/error primitive. Produces an HTTP error status and renders the matching status file. Accepts any 4xx or 5xx status code. Default status is 403.
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { deny } from '@timber-js/app/server';
|
|
19
|
+
|
|
20
|
+
deny(); // 403 (default)
|
|
21
|
+
deny(404); // 404 Not Found
|
|
22
|
+
deny(503, { retry: true }); // 503 with data
|
|
23
|
+
deny({ status: 500, data: { title: 'Error' } }); // object form
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
### `redirect(url, type?)`
|
|
27
|
+
|
|
28
|
+
Redirect to a URL. Sends HTTP 307 (temporary) by default.
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
import { redirect } from '@timber-js/app/server';
|
|
32
|
+
|
|
33
|
+
redirect('/login'); // 302 Temporary Redirect
|
|
34
|
+
redirect('/new-page', { permanent: true }); // 308 Permanent Redirect
|
|
35
|
+
redirect('/new-page', { status: 301 }); // 301 (explicit status)
|
|
36
|
+
redirect('/new-page', 301); // 301 (shorthand)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
The `permanent` option defaults to status **308** (preserves HTTP method). If you need 301 specifically, pass `status: 301`. When both `permanent` and `status` are provided, `status` wins.
|
|
40
|
+
|
|
41
|
+
### `redirectExternal(url, allowList, status?)`
|
|
42
|
+
|
|
43
|
+
Redirect to an external URL. The target origin must be in the provided allow-list. Only `http:` and `https:` schemes are permitted — this prevents open redirect vulnerabilities and blocks `javascript:` scheme injection.
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
import { redirectExternal } from '@timber-js/app/server';
|
|
47
|
+
|
|
48
|
+
// Redirect to an allowed external domain
|
|
49
|
+
redirectExternal('https://auth.example.com/login', ['https://auth.example.com']);
|
|
50
|
+
|
|
51
|
+
// With a custom status code
|
|
52
|
+
redirectExternal('https://docs.example.com', ['https://docs.example.com'], 301);
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
| Parameter | Type | Default | Description |
|
|
56
|
+
| ----------- | ---------- | ------- | ---------------------------------------------------------- |
|
|
57
|
+
| `url` | `string` | — | Absolute URL to redirect to |
|
|
58
|
+
| `allowList` | `string[]` | — | Allowed origins (e.g. `['https://example.com']`) |
|
|
59
|
+
| `status` | `number` | `302` | HTTP redirect status code (3xx) |
|
|
60
|
+
|
|
61
|
+
Throws if the origin is not in the allow-list, the scheme isn't `http:` or `https:`, or the URL is invalid.
|
|
62
|
+
|
|
63
|
+
## Request Context
|
|
64
|
+
|
|
65
|
+
### `getHeaders()`
|
|
66
|
+
|
|
67
|
+
Returns the incoming request headers. ALS-backed — works in server components, middleware, access checks.
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
import { getHeaders } from '@timber-js/app/server';
|
|
71
|
+
|
|
72
|
+
const h = getHeaders();
|
|
73
|
+
const auth = h.get('authorization');
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Returns `ReadonlyHeaders` — a read-only `Headers` interface (sync).
|
|
77
|
+
|
|
78
|
+
### `getCookieJar()`
|
|
79
|
+
|
|
80
|
+
Returns the request cookies with mutation methods.
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
import { getCookieJar } from '@timber-js/app/server';
|
|
84
|
+
|
|
85
|
+
const c = getCookieJar();
|
|
86
|
+
c.get('session'); // Read
|
|
87
|
+
c.set('theme', 'dark'); // Set (before flush only)
|
|
88
|
+
c.delete('session'); // Delete
|
|
89
|
+
c.clear(); // Clear all
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Mutations are only allowed before the response flushes. Attempting to set a cookie after flush throws in dev mode.
|
|
93
|
+
|
|
94
|
+
For typed cookies with validation and a client React hook, see [`defineCookie()`](/docs/typed-params) from `@timber-js/app/cookies`.
|
|
95
|
+
|
|
96
|
+
## Actions
|
|
97
|
+
|
|
98
|
+
### `createActionClient(options)`
|
|
99
|
+
|
|
100
|
+
Create a reusable action client with middleware for auth and validation.
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
import { createActionClient, ActionError } from '@timber-js/app/server';
|
|
104
|
+
import { getUser } from '@/lib/auth';
|
|
105
|
+
|
|
106
|
+
const action = createActionClient({
|
|
107
|
+
middleware: async () => {
|
|
108
|
+
const user = await getUser();
|
|
109
|
+
if (!user) throw new ActionError('UNAUTHORIZED');
|
|
110
|
+
return { user };
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// Usage:
|
|
115
|
+
const myAction = action.schema(z.object({ title: z.string() })).action(async ({ input, ctx }) => {
|
|
116
|
+
// ctx.user is typed from middleware return
|
|
117
|
+
});
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### `ActionError`
|
|
121
|
+
|
|
122
|
+
Throw from action middleware or bodies to produce a typed error result.
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
throw new ActionError('UNAUTHORIZED');
|
|
126
|
+
throw new ActionError('VALIDATION_FAILED', { field: 'email' });
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### `revalidatePath(path)`
|
|
130
|
+
|
|
131
|
+
Re-render a path and return the RSC payload as part of the action response.
|
|
132
|
+
|
|
133
|
+
### `revalidateTag(tag)`
|
|
134
|
+
|
|
135
|
+
Invalidate all cache entries with the given tag.
|
|
136
|
+
|
|
137
|
+
## Segment Params
|
|
138
|
+
|
|
139
|
+
### `getSegmentParams(segmentPath?)`
|
|
140
|
+
|
|
141
|
+
Returns route path params for the current request. Pass a `SEGMENT_PATH` string from `$segment` for exact types, or omit for `Partial<AllSegmentParams>`. ALS-backed — works in server components, middleware, access checks, and server actions.
|
|
142
|
+
|
|
143
|
+
```ts
|
|
144
|
+
import { SEGMENT_PATH } from './$segment';
|
|
145
|
+
import { getSegmentParams } from '@timber-js/app/server';
|
|
146
|
+
|
|
147
|
+
const { id } = getSegmentParams(SEGMENT_PATH); // Exact types from schema
|
|
148
|
+
const params = getSegmentParams(); // Partial<AllSegmentParams>
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
For typed coercion, define codecs in `app/schema.ts` with `defineSchema()`. See [Typed Params](/docs/typed-params).
|
|
152
|
+
|
|
153
|
+
## Types
|
|
154
|
+
|
|
155
|
+
### `RouteContext`
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
interface RouteContext {
|
|
159
|
+
req: Request;
|
|
160
|
+
headers: Headers; // Response headers (writable)
|
|
161
|
+
}
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Segment params and query params are **not** on `RouteContext`. Read them via the same ALS-backed `.get()` accessors pages use — `segmentParams.get()` from `./params` and `searchParams.get()` from `./search-params`. For the raw query string, use `new URL(ctx.req.url).searchParams`.
|
|
165
|
+
|
|
166
|
+
### `MiddlewareContext`
|
|
167
|
+
|
|
168
|
+
```ts
|
|
169
|
+
interface MiddlewareContext {
|
|
170
|
+
req: Request;
|
|
171
|
+
segmentParams: Record<string, string | string[]>;
|
|
172
|
+
headers: Headers; // Response headers (writable)
|
|
173
|
+
requestHeaders: Headers; // Request headers (injectable)
|
|
174
|
+
}
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
### `AccessContext`
|
|
178
|
+
|
|
179
|
+
Empty interface — `access.ts` takes no arguments. Use ALS-backed imports like `getSegmentParams(SEGMENT_PATH)`.
|
|
180
|
+
|
|
181
|
+
### `Metadata`
|
|
182
|
+
|
|
183
|
+
The full metadata type — see [Metadata & SEO](/docs/metadata-and-fonts) for usage.
|
|
184
|
+
|
|
185
|
+
### `MetadataRoute`
|
|
186
|
+
|
|
187
|
+
Type for metadata route files (sitemap, robots, etc.).
|
|
188
|
+
|
|
189
|
+
## Tracing
|
|
190
|
+
|
|
191
|
+
### `getTraceId()`
|
|
192
|
+
|
|
193
|
+
Returns the current request's 32-char hex trace ID. Works in middleware, access checks, server components, and server actions.
|
|
194
|
+
|
|
195
|
+
```ts
|
|
196
|
+
import { getTraceId } from '@timber-js/app/server';
|
|
197
|
+
|
|
198
|
+
const id = getTraceId();
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
### `getSpanId()`
|
|
202
|
+
|
|
203
|
+
Returns the current OTEL span ID, or `undefined` if OTEL is not active.
|
|
204
|
+
|
|
205
|
+
### `withSpan(name, attributes, fn)`
|
|
206
|
+
|
|
207
|
+
Run a function within an OTEL span. No-op passthrough if OTEL is not available.
|
|
208
|
+
|
|
209
|
+
```ts
|
|
210
|
+
import { withSpan } from '@timber-js/app/server';
|
|
211
|
+
|
|
212
|
+
declare const db: { query(...args: unknown[]): Promise<unknown> };
|
|
213
|
+
|
|
214
|
+
const result = await withSpan('db.query', { table: 'users' }, () => db.query());
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
### `addSpanEvent(name, attributes?)`
|
|
218
|
+
|
|
219
|
+
Add an event to the current active span.
|
|
220
|
+
|
|
221
|
+
See [Instrumentation & Tracing](/docs/instrumentation) for full documentation.
|
|
222
|
+
|
|
223
|
+
## Lifecycle
|
|
224
|
+
|
|
225
|
+
### `waitUntil(promise)`
|
|
226
|
+
|
|
227
|
+
Register a promise to be kept alive after the response is sent. Platform-dependent — works on Cloudflare Workers and other platforms that support it.
|
|
228
|
+
|
|
229
|
+
```ts
|
|
230
|
+
import { waitUntil } from '@timber-js/app/server';
|
|
231
|
+
|
|
232
|
+
waitUntil(analytics.track(event));
|
|
233
|
+
```
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: 'API: @timber-js/app/client'
|
|
3
|
+
description: 'Client-side exports — Link, useRouter, usePathname, navigation hooks, and form utilities.'
|
|
4
|
+
slug: 'api-client'
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# @timber-js/app/client
|
|
8
|
+
|
|
9
|
+
Client-side components and hooks. Import from `@timber-js/app/client`.
|
|
10
|
+
|
|
11
|
+
## Components
|
|
12
|
+
|
|
13
|
+
### `<Link>`
|
|
14
|
+
|
|
15
|
+
Client-side navigation link. Type-checks `href` against the generated route map.
|
|
16
|
+
|
|
17
|
+
```tsx
|
|
18
|
+
import { Link } from '@timber-js/app/client';
|
|
19
|
+
|
|
20
|
+
<Link href="/about">About</Link>
|
|
21
|
+
<Link href="/products/123" replace>Product</Link>
|
|
22
|
+
<Link href="/gallery" onNavigate={(e) => { /* intercept */ }}>Gallery</Link>
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
#### Props
|
|
26
|
+
|
|
27
|
+
| Prop | Type | Description |
|
|
28
|
+
| ------------ | ------------------- | ------------------------------------------- |
|
|
29
|
+
| `href` | `string` | Target URL (type-checked against route map) |
|
|
30
|
+
| `replace` | `boolean` | Use `replaceState` instead of `pushState` |
|
|
31
|
+
| `prefetch` | `boolean` | Prefetch on hover/viewport |
|
|
32
|
+
| `onNavigate` | `OnNavigateHandler` | Intercept navigation before routing |
|
|
33
|
+
|
|
34
|
+
### `<TimberErrorBoundary>`
|
|
35
|
+
|
|
36
|
+
Error boundary component with reset support.
|
|
37
|
+
|
|
38
|
+
## Hooks
|
|
39
|
+
|
|
40
|
+
### `useRouter()`
|
|
41
|
+
|
|
42
|
+
Programmatic navigation.
|
|
43
|
+
|
|
44
|
+
```tsx
|
|
45
|
+
const router = useRouter();
|
|
46
|
+
router.push('/dashboard');
|
|
47
|
+
router.replace('/settings');
|
|
48
|
+
router.refresh();
|
|
49
|
+
router.back();
|
|
50
|
+
router.forward();
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
#### `RouterInstance`
|
|
54
|
+
|
|
55
|
+
| Method | Description |
|
|
56
|
+
| --------------- | ---------------------------------- |
|
|
57
|
+
| `push(href)` | Navigate, add history entry |
|
|
58
|
+
| `replace(href)` | Navigate, replace history entry |
|
|
59
|
+
| `refresh()` | Re-fetch current route RSC payload |
|
|
60
|
+
| `back()` | Go back |
|
|
61
|
+
| `forward()` | Go forward |
|
|
62
|
+
|
|
63
|
+
### `usePathname()`
|
|
64
|
+
|
|
65
|
+
Returns the current pathname as a string.
|
|
66
|
+
|
|
67
|
+
```tsx
|
|
68
|
+
const pathname = usePathname(); // "/dashboard/settings"
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### `useSelectedLayoutSegment()`
|
|
72
|
+
|
|
73
|
+
Returns the active child segment within a layout.
|
|
74
|
+
|
|
75
|
+
```tsx
|
|
76
|
+
const segment = useSelectedLayoutSegment(); // "settings"
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### `useSelectedLayoutSegments()`
|
|
80
|
+
|
|
81
|
+
Returns all active segments below the current layout.
|
|
82
|
+
|
|
83
|
+
```tsx
|
|
84
|
+
const segments = useSelectedLayoutSegments(); // ["settings", "profile"]
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### `usePendingNavigation()`
|
|
88
|
+
|
|
89
|
+
Returns whether a navigation is in flight. Use instead of wrapping `children` in `<Suspense>`.
|
|
90
|
+
|
|
91
|
+
```tsx
|
|
92
|
+
const pending = usePendingNavigation();
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### `useLinkStatus()`
|
|
96
|
+
|
|
97
|
+
Per-link pending state. Must be used inside a `<Link>` component's children.
|
|
98
|
+
|
|
99
|
+
```tsx
|
|
100
|
+
const { pending } = useLinkStatus();
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### `useSegmentParams(segmentPath?)`
|
|
104
|
+
|
|
105
|
+
Returns the current route segment params. Pass `SEGMENT_PATH` from `$segment` for exact types.
|
|
106
|
+
|
|
107
|
+
```tsx
|
|
108
|
+
import { SEGMENT_PATH } from './$segment';
|
|
109
|
+
import { useSegmentParams } from '@timber-js/app/client';
|
|
110
|
+
|
|
111
|
+
const { id } = useSegmentParams(SEGMENT_PATH); // Exact types from schema
|
|
112
|
+
const params = useSegmentParams(); // Partial<AllSegmentParams>
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Form Hooks
|
|
116
|
+
|
|
117
|
+
### `useActionState(action, initialState)`
|
|
118
|
+
|
|
119
|
+
Typed wrapper around React 19's `useActionState`. Returns `[result, formAction, isPending, errors]` — the 4th element auto-derives field and form-level errors from the result.
|
|
120
|
+
|
|
121
|
+
```tsx
|
|
122
|
+
const [result, action, isPending, errors] = useActionState(createTodo, flash);
|
|
123
|
+
|
|
124
|
+
errors.hasErrors // boolean
|
|
125
|
+
errors.fieldErrors // Record<string, string[]>
|
|
126
|
+
errors.formErrors // string[]
|
|
127
|
+
errors.serverError // { code, data? } | null
|
|
128
|
+
errors.getFieldError('title') // string | null
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### `useFormAction(action)`
|
|
132
|
+
|
|
133
|
+
Bind a server action to a form with pending state.
|
|
134
|
+
|
|
135
|
+
## Utilities
|
|
136
|
+
|
|
137
|
+
### `interpolateParams(pattern, params)`
|
|
138
|
+
|
|
139
|
+
Fill a route pattern with param values.
|
|
140
|
+
|
|
141
|
+
```ts
|
|
142
|
+
interpolateParams('/products/[id]', { id: '123' }); // "/products/123"
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### `defineSearchParams().useQueryStates()`
|
|
146
|
+
|
|
147
|
+
Sync typed search params to the URL. Called on a `defineSearchParams()` definition. See [Search Params](/docs/typed-params).
|
|
148
|
+
|
|
149
|
+
## Cookie Hooks
|
|
150
|
+
|
|
151
|
+
### `defineCookie().useCookie()`
|
|
152
|
+
|
|
153
|
+
Reactive cookie access in client components. Define a typed cookie with `defineCookie()` from `@timber-js/app/cookies`, then call `.useCookie()` in client components. Returns `[value, setter, deleter]`.
|
|
154
|
+
|
|
155
|
+
```tsx
|
|
156
|
+
import { themeCookie } from '../lib/cookies';
|
|
157
|
+
|
|
158
|
+
const [theme, setTheme, deleteTheme] = themeCookie.useCookie();
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
See [Cookies](/docs/typed-params) for full documentation.
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: 'API: @timber-js/app/cache'
|
|
3
|
+
description: 'Cache exports — cache, CacheHandler, MemoryCacheHandler, and invalidation.'
|
|
4
|
+
slug: 'api-cache'
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# @timber-js/app/cache
|
|
8
|
+
|
|
9
|
+
Caching primitives and cache handler interface. Import from `@timber-js/app/cache`.
|
|
10
|
+
|
|
11
|
+
## `cache(fn, options)`
|
|
12
|
+
|
|
13
|
+
Wrap an async function with cross-request caching.
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { cache } from '@timber-js/app/cache';
|
|
17
|
+
|
|
18
|
+
declare const db: { users: { findUnique(opts: { where: { id: string } }): Promise<{ id: string; name: string }> } };
|
|
19
|
+
|
|
20
|
+
const getUser = cache(
|
|
21
|
+
async (userId: string) => {
|
|
22
|
+
return db.users.findUnique({ where: { id: userId } });
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
ttl: 60,
|
|
26
|
+
key: (userId) => `user:${userId}`,
|
|
27
|
+
staleWhileRevalidate: true,
|
|
28
|
+
tags: (userId) => [`user:${userId}`],
|
|
29
|
+
}
|
|
30
|
+
);
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### `CacheOptions<Fn>`
|
|
34
|
+
|
|
35
|
+
| Option | Type | Description |
|
|
36
|
+
| ---------------------- | ----------------------------------- | ------------------------------------------ |
|
|
37
|
+
| `ttl` | `number` | Time-to-live in seconds |
|
|
38
|
+
| `key` | `(...args) => string` | Custom cache key function (optional) |
|
|
39
|
+
| `staleWhileRevalidate` | `boolean` | Serve stale while refetching in background |
|
|
40
|
+
| `tags` | `string[] \| (...args) => string[]` | Tags for invalidation |
|
|
41
|
+
|
|
42
|
+
Default key: FNV-1a hash of function identity + deterministic JSON serialization of arguments.
|
|
43
|
+
|
|
44
|
+
The function identity is derived at build time from the source text of the `cache(...)` call, so it is stable across restarts, instances, and deploys. With a persistent shared handler (Redis/KV), cached entries survive deploys as long as the call itself is unchanged — reordering calls or editing unrelated code preserves the cache, while editing the wrapped function or its options gives the call a fresh identity (old entries simply expire via TTL). Renaming or moving the file also resets identity for every call in it.
|
|
45
|
+
|
|
46
|
+
Build-time identity covers calls the bundler can see statically: named imports (including aliases like `import { cache as c }`) and namespace imports (`import * as timber` + `timber.cache(fn, opts)`). Calls it can't trace — a local alias like `const c = cache`, or `cache` re-exported through your own wrapper module — fall back to a hash of the function's source text at runtime. The fallback is still deterministic across instances of the same build, but it resets across deploys, and byte-identical closures produced by a factory can collide — pass an explicit `key` in those cases (timber logs a warning when the fallback is used).
|
|
47
|
+
|
|
48
|
+
### `cache.invalidate(options)`
|
|
49
|
+
|
|
50
|
+
Invalidate cache entries by tag or key:
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
import { cache } from '@timber-js/app/cache';
|
|
54
|
+
|
|
55
|
+
cache.invalidate({ tag: 'products' });
|
|
56
|
+
cache.invalidate({ key: 'user:abc' });
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## `CacheHandler` Interface
|
|
60
|
+
|
|
61
|
+
Implement this to plug in a custom cache backend.
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
interface CacheHandler {
|
|
65
|
+
get(key: string): Promise<{ value: unknown; tags: string[] } | null>;
|
|
66
|
+
set(key: string, value: unknown, options: { ttl: number; tags: string[] }): Promise<void>;
|
|
67
|
+
invalidate(options: { tag: string }): Promise<void>;
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## `MemoryCacheHandler`
|
|
72
|
+
|
|
73
|
+
In-memory cache with LRU eviction. The default handler.
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
import { MemoryCacheHandler } from '@timber-js/app/cache';
|
|
77
|
+
|
|
78
|
+
const handler = new MemoryCacheHandler({ maxSize: 1000 });
|
|
79
|
+
handler.size; // Current number of entries
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### Options
|
|
83
|
+
|
|
84
|
+
| Option | Type | Default | Description |
|
|
85
|
+
| --------- | -------- | ------- | ----------------------------------- |
|
|
86
|
+
| `maxSize` | `number` | `1000` | Maximum entries before LRU eviction |
|
|
87
|
+
|
|
88
|
+
## `RedisCacheHandler`
|
|
89
|
+
|
|
90
|
+
Redis-backed cache handler. Takes a client wrapped to timber's `RedisClient` contract — TTL is a required positional argument, so an adapter can't silently drop the expiry:
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
interface RedisClient {
|
|
94
|
+
get(key: string): Promise<string | null>;
|
|
95
|
+
set(key: string, value: string, ttlSeconds: number): Promise<unknown>;
|
|
96
|
+
expire(key: string, seconds: number): Promise<unknown>;
|
|
97
|
+
del(key: string): Promise<unknown>; // single key
|
|
98
|
+
sadd(key: string, ...members: string[]): Promise<unknown>;
|
|
99
|
+
srem(key: string, ...members: string[]): Promise<unknown>;
|
|
100
|
+
smembers(key: string): Promise<string[]>;
|
|
101
|
+
ttl(key: string): Promise<number>;
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
This is deliberately not the native shape of any client library — ioredis, node-redis, and @upstash/redis disagree on TTL argument shapes and method casing, and a contract that pattern-matches one client lets another silently write keys with no expiry. Wrap your client with one of these adapters:
|
|
106
|
+
|
|
107
|
+
```ts title="ioredis"
|
|
108
|
+
import { RedisCacheHandler } from '@timber-js/app/cache';
|
|
109
|
+
import Redis from 'ioredis';
|
|
110
|
+
|
|
111
|
+
const redis = new Redis(process.env.REDIS_URL!);
|
|
112
|
+
const handler = new RedisCacheHandler({
|
|
113
|
+
get: (k) => redis.get(k),
|
|
114
|
+
set: (k, v, ttl) => redis.set(k, v, 'EX', ttl),
|
|
115
|
+
expire: (k, s) => redis.expire(k, s),
|
|
116
|
+
del: (k) => redis.del(k),
|
|
117
|
+
sadd: (k, ...m) => redis.sadd(k, ...m),
|
|
118
|
+
srem: (k, ...m) => redis.srem(k, ...m),
|
|
119
|
+
smembers: (k) => redis.smembers(k),
|
|
120
|
+
ttl: (k) => redis.ttl(k),
|
|
121
|
+
});
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
```ts title="node-redis"
|
|
125
|
+
import { RedisCacheHandler } from '@timber-js/app/cache';
|
|
126
|
+
import { createClient } from 'redis';
|
|
127
|
+
|
|
128
|
+
const redis = createClient({ url: process.env.REDIS_URL });
|
|
129
|
+
await redis.connect();
|
|
130
|
+
const handler = new RedisCacheHandler({
|
|
131
|
+
get: (k) => redis.get(k),
|
|
132
|
+
set: (k, v, ttl) => redis.set(k, v, { EX: ttl }),
|
|
133
|
+
expire: (k, s) => redis.expire(k, s),
|
|
134
|
+
del: (k) => redis.del(k),
|
|
135
|
+
sadd: (k, ...m) => redis.sAdd(k, m),
|
|
136
|
+
srem: (k, ...m) => redis.sRem(k, m),
|
|
137
|
+
smembers: (k) => redis.sMembers(k),
|
|
138
|
+
ttl: (k) => redis.ttl(k),
|
|
139
|
+
});
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
```ts title="@upstash/redis"
|
|
143
|
+
import { RedisCacheHandler } from '@timber-js/app/cache';
|
|
144
|
+
import { Redis } from '@upstash/redis';
|
|
145
|
+
|
|
146
|
+
// automaticDeserialization must be off: the handler stores JSON strings
|
|
147
|
+
// and parses them itself.
|
|
148
|
+
const redis = new Redis({
|
|
149
|
+
url: process.env.UPSTASH_REDIS_REST_URL!,
|
|
150
|
+
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
|
|
151
|
+
automaticDeserialization: false,
|
|
152
|
+
});
|
|
153
|
+
const handler = new RedisCacheHandler({
|
|
154
|
+
get: (k) => redis.get(k),
|
|
155
|
+
set: (k, v, ttl) => redis.set(k, v, { ex: ttl }),
|
|
156
|
+
expire: (k, s) => redis.expire(k, s),
|
|
157
|
+
del: (k) => redis.del(k),
|
|
158
|
+
sadd: (k, ...m) => redis.sadd(k, m[0], ...m.slice(1)),
|
|
159
|
+
srem: (k, ...m) => redis.srem(k, m[0], ...m.slice(1)),
|
|
160
|
+
smembers: (k) => redis.smembers(k),
|
|
161
|
+
ttl: (k) => redis.ttl(k),
|
|
162
|
+
});
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
## `CloudflareKVCacheHandler`
|
|
166
|
+
|
|
167
|
+
Cloudflare Workers KV–backed cache handler. Resolves the KV binding per-request via `getCloudflareBindings()`. Uses tag index keys for tag-based invalidation. Eventually consistent — TTL is the primary expiry mechanism.
|
|
168
|
+
|
|
169
|
+
```ts
|
|
170
|
+
import { CloudflareKVCacheHandler } from '@timber-js/app/adapters/cloudflare/cache';
|
|
171
|
+
|
|
172
|
+
const handler = new CloudflareKVCacheHandler({
|
|
173
|
+
bindingName: 'TIMBER_CACHE',
|
|
174
|
+
prefix: 'tc:', // optional, default 'tc:'
|
|
175
|
+
});
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
## Invalidation
|
|
179
|
+
|
|
180
|
+
### `revalidateTag(tag)`
|
|
181
|
+
|
|
182
|
+
Invalidate all cache entries with the given tag. Re-exported from `@timber-js/app/server` for convenience.
|
|
183
|
+
|
|
184
|
+
```ts
|
|
185
|
+
import { revalidateTag } from '@timber-js/app/server';
|
|
186
|
+
|
|
187
|
+
revalidateTag('products');
|
|
188
|
+
revalidateTag(`user:${userId}`);
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
## Singleflight
|
|
192
|
+
|
|
193
|
+
Request coalescing is built into `cache()` automatically — when multiple concurrent calls miss the cache for the same key, only one executes the underlying function. The singleflight and stable-stringify utilities are internal implementation details and not part of the public API.
|