@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.
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -7
- package/dist/index.js.map +1 -1
- package/dist/plugins/dev-server.d.ts.map +1 -1
- package/dist/plugins/entries.d.ts.map +1 -1
- package/package.json +5 -4
- package/src/adapters/cloudflare.ts +325 -0
- package/src/adapters/nitro.ts +366 -0
- package/src/adapters/types.ts +63 -0
- package/src/cache/index.ts +91 -0
- package/src/cache/redis-handler.ts +91 -0
- package/src/cache/register-cached-function.ts +99 -0
- package/src/cache/singleflight.ts +26 -0
- package/src/cache/stable-stringify.ts +21 -0
- package/src/cache/timber-cache.ts +116 -0
- package/src/cli.ts +201 -0
- package/src/client/browser-entry.ts +663 -0
- package/src/client/error-boundary.tsx +209 -0
- package/src/client/form.tsx +200 -0
- package/src/client/head.ts +61 -0
- package/src/client/history.ts +46 -0
- package/src/client/index.ts +60 -0
- package/src/client/link-navigate-interceptor.tsx +62 -0
- package/src/client/link-status-provider.tsx +40 -0
- package/src/client/link.tsx +310 -0
- package/src/client/nuqs-adapter.tsx +117 -0
- package/src/client/router-ref.ts +25 -0
- package/src/client/router.ts +563 -0
- package/src/client/segment-cache.ts +194 -0
- package/src/client/segment-context.ts +57 -0
- package/src/client/ssr-data.ts +95 -0
- package/src/client/types.ts +4 -0
- package/src/client/unload-guard.ts +34 -0
- package/src/client/use-cookie.ts +122 -0
- package/src/client/use-link-status.ts +46 -0
- package/src/client/use-navigation-pending.ts +47 -0
- package/src/client/use-params.ts +71 -0
- package/src/client/use-pathname.ts +43 -0
- package/src/client/use-query-states.ts +133 -0
- package/src/client/use-router.ts +77 -0
- package/src/client/use-search-params.ts +74 -0
- package/src/client/use-selected-layout-segment.ts +110 -0
- package/src/content/index.ts +13 -0
- package/src/cookies/define-cookie.ts +137 -0
- package/src/cookies/index.ts +9 -0
- package/src/fonts/ast.ts +359 -0
- package/src/fonts/css.ts +68 -0
- package/src/fonts/fallbacks.ts +248 -0
- package/src/fonts/google.ts +332 -0
- package/src/fonts/local.ts +177 -0
- package/src/fonts/types.ts +88 -0
- package/src/index.ts +420 -0
- package/src/plugins/adapter-build.ts +118 -0
- package/src/plugins/build-manifest.ts +323 -0
- package/src/plugins/build-report.ts +353 -0
- package/src/plugins/cache-transform.ts +199 -0
- package/src/plugins/chunks.ts +90 -0
- package/src/plugins/content.ts +136 -0
- package/src/plugins/dev-error-overlay.ts +230 -0
- package/src/plugins/dev-logs.ts +280 -0
- package/src/plugins/dev-server.ts +391 -0
- package/src/plugins/dynamic-transform.ts +161 -0
- package/src/plugins/entries.ts +214 -0
- package/src/plugins/fonts.ts +581 -0
- package/src/plugins/mdx.ts +179 -0
- package/src/plugins/react-prod.ts +56 -0
- package/src/plugins/routing.ts +419 -0
- package/src/plugins/server-action-exports.ts +220 -0
- package/src/plugins/server-bundle.ts +113 -0
- package/src/plugins/shims.ts +168 -0
- package/src/plugins/static-build.ts +207 -0
- package/src/routing/codegen.ts +396 -0
- package/src/routing/index.ts +14 -0
- package/src/routing/interception.ts +173 -0
- package/src/routing/scanner.ts +487 -0
- package/src/routing/status-file-lint.ts +114 -0
- package/src/routing/types.ts +100 -0
- package/src/search-params/analyze.ts +192 -0
- package/src/search-params/codecs.ts +153 -0
- package/src/search-params/create.ts +314 -0
- package/src/search-params/index.ts +23 -0
- package/src/search-params/registry.ts +31 -0
- package/src/server/access-gate.tsx +142 -0
- package/src/server/action-client.ts +473 -0
- package/src/server/action-handler.ts +325 -0
- package/src/server/actions.ts +236 -0
- package/src/server/asset-headers.ts +81 -0
- package/src/server/body-limits.ts +102 -0
- package/src/server/build-manifest.ts +234 -0
- package/src/server/canonicalize.ts +90 -0
- package/src/server/client-module-map.ts +58 -0
- package/src/server/csrf.ts +79 -0
- package/src/server/deny-renderer.ts +302 -0
- package/src/server/dev-logger.ts +419 -0
- package/src/server/dev-span-processor.ts +78 -0
- package/src/server/dev-warnings.ts +282 -0
- package/src/server/early-hints-sender.ts +55 -0
- package/src/server/early-hints.ts +142 -0
- package/src/server/error-boundary-wrapper.ts +69 -0
- package/src/server/error-formatter.ts +184 -0
- package/src/server/flush.ts +182 -0
- package/src/server/form-data.ts +176 -0
- package/src/server/form-flash.ts +93 -0
- package/src/server/html-injectors.ts +445 -0
- package/src/server/index.ts +222 -0
- package/src/server/instrumentation.ts +136 -0
- package/src/server/logger.ts +145 -0
- package/src/server/manifest-status-resolver.ts +215 -0
- package/src/server/metadata-render.ts +527 -0
- package/src/server/metadata-routes.ts +189 -0
- package/src/server/metadata.ts +263 -0
- package/src/server/middleware-runner.ts +32 -0
- package/src/server/nuqs-ssr-provider.tsx +63 -0
- package/src/server/pipeline.ts +555 -0
- package/src/server/prerender.ts +139 -0
- package/src/server/primitives.ts +264 -0
- package/src/server/proxy.ts +43 -0
- package/src/server/request-context.ts +554 -0
- package/src/server/route-element-builder.ts +395 -0
- package/src/server/route-handler.ts +153 -0
- package/src/server/route-matcher.ts +316 -0
- package/src/server/rsc-entry/api-handler.ts +112 -0
- package/src/server/rsc-entry/error-renderer.ts +177 -0
- package/src/server/rsc-entry/helpers.ts +147 -0
- package/src/server/rsc-entry/index.ts +688 -0
- package/src/server/rsc-entry/ssr-bridge.ts +18 -0
- package/src/server/slot-resolver.ts +359 -0
- package/src/server/ssr-entry.ts +161 -0
- package/src/server/ssr-render.ts +200 -0
- package/src/server/status-code-resolver.ts +282 -0
- package/src/server/tracing.ts +281 -0
- package/src/server/tree-builder.ts +354 -0
- package/src/server/types.ts +150 -0
- package/src/shims/font-google.ts +67 -0
- package/src/shims/headers.ts +11 -0
- package/src/shims/image.ts +48 -0
- package/src/shims/link.ts +9 -0
- package/src/shims/navigation-client.ts +52 -0
- package/src/shims/navigation.ts +31 -0
- package/src/shims/server-only-noop.js +5 -0
- package/src/utils/directive-parser.ts +529 -0
- package/src/utils/format.ts +10 -0
- package/src/utils/startup-timer.ts +102 -0
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
// Nitro adapter — multi-platform deployment
|
|
2
|
+
//
|
|
3
|
+
// Covers everything except Cloudflare Workers: Node.js, Bun, Vercel,
|
|
4
|
+
// Netlify, AWS Lambda, Deno Deploy, Azure Functions. Nitro handles
|
|
5
|
+
// compression, graceful shutdown, static file serving, and platform quirks.
|
|
6
|
+
// See design/11-platform.md and design/25-production-deployments.md.
|
|
7
|
+
|
|
8
|
+
import { writeFile, mkdir, cp } from 'node:fs/promises';
|
|
9
|
+
import { execFile } from 'node:child_process';
|
|
10
|
+
import { join, relative } from 'node:path';
|
|
11
|
+
import type { TimberPlatformAdapter, TimberConfig } from './types';
|
|
12
|
+
// Inlined from server/asset-headers.ts — adapters are loaded by Node at
|
|
13
|
+
// Vite startup time, before Vite's module resolver is available, so cross-
|
|
14
|
+
// directory .ts imports don't resolve.
|
|
15
|
+
const IMMUTABLE_CACHE = 'public, max-age=31536000, immutable';
|
|
16
|
+
const STATIC_CACHE = 'public, max-age=3600, must-revalidate';
|
|
17
|
+
|
|
18
|
+
function generateHeadersFile(): string {
|
|
19
|
+
return `# Auto-generated by @timber/app — static asset cache headers.
|
|
20
|
+
# See design/25-production-deployments.md §"CDN / Edge Cache"
|
|
21
|
+
|
|
22
|
+
/assets/*
|
|
23
|
+
Cache-Control: ${IMMUTABLE_CACHE}
|
|
24
|
+
|
|
25
|
+
/*
|
|
26
|
+
Cache-Control: ${STATIC_CACHE}
|
|
27
|
+
`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ─── Presets ─────────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Supported Nitro deployment presets.
|
|
34
|
+
*
|
|
35
|
+
* Each preset maps to a Nitro deployment target. The adapter generates
|
|
36
|
+
* the appropriate configuration and entry point for the selected platform.
|
|
37
|
+
*/
|
|
38
|
+
export type NitroPreset =
|
|
39
|
+
| 'vercel'
|
|
40
|
+
| 'vercel-edge'
|
|
41
|
+
| 'netlify'
|
|
42
|
+
| 'netlify-edge'
|
|
43
|
+
| 'aws-lambda'
|
|
44
|
+
| 'deno-deploy'
|
|
45
|
+
| 'azure-functions'
|
|
46
|
+
| 'node-server'
|
|
47
|
+
| 'bun';
|
|
48
|
+
|
|
49
|
+
/** Preset-specific Nitro configuration. */
|
|
50
|
+
interface PresetConfig {
|
|
51
|
+
/** Nitro preset name passed to the Nitro build. */
|
|
52
|
+
nitroPreset: string;
|
|
53
|
+
/** Output directory name within the build dir. */
|
|
54
|
+
outputDir: string;
|
|
55
|
+
/** Whether the runtime supports waitUntil. */
|
|
56
|
+
supportsWaitUntil: boolean;
|
|
57
|
+
/** Whether the runtime supports application-level 103 Early Hints. */
|
|
58
|
+
supportsEarlyHints: boolean;
|
|
59
|
+
/** Value for TIMBER_RUNTIME env var. See design/25-production-deployments.md. */
|
|
60
|
+
runtimeName: string;
|
|
61
|
+
/** Additional nitro.config fields for this preset. */
|
|
62
|
+
extraConfig?: Record<string, unknown>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const PRESET_CONFIGS: Record<NitroPreset, PresetConfig> = {
|
|
66
|
+
'vercel': {
|
|
67
|
+
nitroPreset: 'vercel',
|
|
68
|
+
outputDir: '.vercel/output',
|
|
69
|
+
supportsWaitUntil: true,
|
|
70
|
+
supportsEarlyHints: false,
|
|
71
|
+
runtimeName: 'vercel',
|
|
72
|
+
extraConfig: { vercel: { functions: { maxDuration: 30 } } },
|
|
73
|
+
},
|
|
74
|
+
'vercel-edge': {
|
|
75
|
+
nitroPreset: 'vercel-edge',
|
|
76
|
+
outputDir: '.vercel/output',
|
|
77
|
+
supportsWaitUntil: true,
|
|
78
|
+
supportsEarlyHints: false,
|
|
79
|
+
runtimeName: 'vercel-edge',
|
|
80
|
+
},
|
|
81
|
+
'netlify': {
|
|
82
|
+
nitroPreset: 'netlify',
|
|
83
|
+
outputDir: '.netlify/functions-internal',
|
|
84
|
+
supportsWaitUntil: false,
|
|
85
|
+
supportsEarlyHints: false,
|
|
86
|
+
runtimeName: 'netlify',
|
|
87
|
+
},
|
|
88
|
+
'netlify-edge': {
|
|
89
|
+
nitroPreset: 'netlify-edge',
|
|
90
|
+
outputDir: '.netlify/edge-functions',
|
|
91
|
+
supportsWaitUntil: true,
|
|
92
|
+
supportsEarlyHints: false,
|
|
93
|
+
runtimeName: 'netlify-edge',
|
|
94
|
+
},
|
|
95
|
+
'aws-lambda': {
|
|
96
|
+
nitroPreset: 'aws-lambda',
|
|
97
|
+
outputDir: '.output',
|
|
98
|
+
supportsWaitUntil: false,
|
|
99
|
+
supportsEarlyHints: false,
|
|
100
|
+
runtimeName: 'aws-lambda',
|
|
101
|
+
},
|
|
102
|
+
'deno-deploy': {
|
|
103
|
+
nitroPreset: 'deno-deploy',
|
|
104
|
+
outputDir: '.output',
|
|
105
|
+
supportsWaitUntil: true,
|
|
106
|
+
supportsEarlyHints: false,
|
|
107
|
+
runtimeName: 'deno-deploy',
|
|
108
|
+
},
|
|
109
|
+
'azure-functions': {
|
|
110
|
+
nitroPreset: 'azure-functions',
|
|
111
|
+
outputDir: '.output',
|
|
112
|
+
supportsWaitUntil: false,
|
|
113
|
+
supportsEarlyHints: false,
|
|
114
|
+
runtimeName: 'azure-functions',
|
|
115
|
+
},
|
|
116
|
+
'node-server': {
|
|
117
|
+
nitroPreset: 'node-server',
|
|
118
|
+
outputDir: '.output',
|
|
119
|
+
supportsWaitUntil: true,
|
|
120
|
+
supportsEarlyHints: true,
|
|
121
|
+
runtimeName: 'node-server',
|
|
122
|
+
},
|
|
123
|
+
'bun': {
|
|
124
|
+
nitroPreset: 'bun',
|
|
125
|
+
outputDir: '.output',
|
|
126
|
+
supportsWaitUntil: true,
|
|
127
|
+
supportsEarlyHints: true,
|
|
128
|
+
runtimeName: 'bun',
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
// ─── Options ─────────────────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
/** Options for the Nitro adapter. */
|
|
135
|
+
export interface NitroAdapterOptions {
|
|
136
|
+
/**
|
|
137
|
+
* Deployment preset. Determines the target platform.
|
|
138
|
+
* @default 'node-server'
|
|
139
|
+
*/
|
|
140
|
+
preset?: NitroPreset;
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Additional Nitro configuration to merge into the generated config.
|
|
144
|
+
* Overrides default values for the selected preset.
|
|
145
|
+
*/
|
|
146
|
+
nitroConfig?: Record<string, unknown>;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ─── Adapter ─────────────────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Create a Nitro-based adapter for multi-platform deployment.
|
|
153
|
+
*
|
|
154
|
+
* Nitro abstracts deployment targets — the same timber.js app can deploy
|
|
155
|
+
* to Vercel, Netlify, AWS, Deno Deploy, or Azure by changing the preset.
|
|
156
|
+
*
|
|
157
|
+
* @example
|
|
158
|
+
* ```ts
|
|
159
|
+
* import { nitro } from '@timber/app/adapters/nitro'
|
|
160
|
+
*
|
|
161
|
+
* export default {
|
|
162
|
+
* output: 'server',
|
|
163
|
+
* adapter: nitro({ preset: 'vercel' }),
|
|
164
|
+
* }
|
|
165
|
+
* ```
|
|
166
|
+
*/
|
|
167
|
+
export function nitro(options: NitroAdapterOptions = {}): TimberPlatformAdapter {
|
|
168
|
+
const preset = options.preset ?? 'node-server';
|
|
169
|
+
const presetConfig = PRESET_CONFIGS[preset];
|
|
170
|
+
const pendingPromises: Promise<unknown>[] = [];
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
name: `nitro-${preset}`,
|
|
174
|
+
|
|
175
|
+
async buildOutput(config: TimberConfig, buildDir: string) {
|
|
176
|
+
const outDir = join(buildDir, 'nitro');
|
|
177
|
+
await mkdir(outDir, { recursive: true });
|
|
178
|
+
|
|
179
|
+
// Copy client assets to public directory.
|
|
180
|
+
// When client JavaScript is disabled, skip .js files — only CSS,
|
|
181
|
+
// fonts, images, and other static assets are needed.
|
|
182
|
+
const clientDir = join(buildDir, 'client');
|
|
183
|
+
const publicDir = join(outDir, 'public');
|
|
184
|
+
await mkdir(publicDir, { recursive: true });
|
|
185
|
+
await cp(clientDir, publicDir, {
|
|
186
|
+
recursive: true,
|
|
187
|
+
filter: config.clientJavascriptDisabled ? (src: string) => !src.endsWith('.js') : undefined,
|
|
188
|
+
}).catch(() => {
|
|
189
|
+
// Client dir may not exist when client JavaScript is disabled
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// Write _headers file for platforms that support it (Netlify, etc.).
|
|
193
|
+
// See design/25-production-deployments.md §"CDN / Edge Cache"
|
|
194
|
+
await writeFile(join(publicDir, '_headers'), generateHeadersFile());
|
|
195
|
+
|
|
196
|
+
// Write the build manifest init module (if manifest data was produced).
|
|
197
|
+
if (config.manifestInit) {
|
|
198
|
+
await writeFile(join(outDir, '_timber-manifest-init.js'), config.manifestInit);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Generate the Nitro entry point
|
|
202
|
+
const hasManifestInit = !!config.manifestInit;
|
|
203
|
+
const entry = generateNitroEntry(buildDir, outDir, preset, hasManifestInit);
|
|
204
|
+
await writeFile(join(outDir, 'entry.ts'), entry);
|
|
205
|
+
|
|
206
|
+
// Generate the Nitro config with static asset cache rules
|
|
207
|
+
const nitroConfig = generateNitroConfig(preset, options.nitroConfig);
|
|
208
|
+
await writeFile(join(outDir, 'nitro.config.ts'), nitroConfig);
|
|
209
|
+
},
|
|
210
|
+
|
|
211
|
+
// Only presets that produce a locally-runnable server get preview().
|
|
212
|
+
// Serverless presets (vercel, netlify, aws-lambda, etc.) have no
|
|
213
|
+
// local runtime — Vite's built-in preview is the fallback.
|
|
214
|
+
preview: LOCALLY_PREVIEWABLE.has(preset)
|
|
215
|
+
? async (_config: TimberConfig, buildDir: string) => {
|
|
216
|
+
const cmd = generateNitroPreviewCommand(buildDir, preset);
|
|
217
|
+
if (!cmd) return;
|
|
218
|
+
await spawnNitroPreview(cmd.command, cmd.args, cmd.cwd);
|
|
219
|
+
}
|
|
220
|
+
: undefined,
|
|
221
|
+
|
|
222
|
+
waitUntil: presetConfig.supportsWaitUntil
|
|
223
|
+
? (promise: Promise<unknown>) => {
|
|
224
|
+
const tracked = promise.catch((err) => {
|
|
225
|
+
console.error('[timber] waitUntil promise rejected:', err);
|
|
226
|
+
});
|
|
227
|
+
pendingPromises.push(tracked);
|
|
228
|
+
}
|
|
229
|
+
: undefined,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ─── Entry Generation ────────────────────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
/** @internal Exported for testing. */
|
|
236
|
+
export function generateNitroEntry(
|
|
237
|
+
buildDir: string,
|
|
238
|
+
outDir: string,
|
|
239
|
+
preset: NitroPreset,
|
|
240
|
+
hasManifestInit = false
|
|
241
|
+
): string {
|
|
242
|
+
const serverEntryRelative = relative(outDir, join(buildDir, 'server', 'entry.js'));
|
|
243
|
+
const runtimeName = PRESET_CONFIGS[preset].runtimeName;
|
|
244
|
+
const earlyHints = PRESET_CONFIGS[preset].supportsEarlyHints;
|
|
245
|
+
|
|
246
|
+
// Build manifest init must be imported before the handler so that
|
|
247
|
+
// globalThis.__TIMBER_BUILD_MANIFEST__ is set when the virtual module evaluates.
|
|
248
|
+
const manifestImport = hasManifestInit ? "import './_timber-manifest-init.js'\n" : '';
|
|
249
|
+
|
|
250
|
+
// On node-server and bun, wrap the handler with ALS so the pipeline
|
|
251
|
+
// can send 103 Early Hints via res.writeEarlyHints(). Other presets
|
|
252
|
+
// either don't support 103 or handle it at the CDN level.
|
|
253
|
+
const earlyHintsImport = earlyHints
|
|
254
|
+
? `import { runWithEarlyHintsSender } from '${serverEntryRelative}'\n`
|
|
255
|
+
: '';
|
|
256
|
+
|
|
257
|
+
const handlerCall = earlyHints
|
|
258
|
+
? ` const nodeRes = event.node?.res
|
|
259
|
+
const earlyHintsSender = (typeof nodeRes?.writeEarlyHints === 'function')
|
|
260
|
+
? (links) => { try { nodeRes.writeEarlyHints({ link: links }) } catch {} }
|
|
261
|
+
: undefined
|
|
262
|
+
|
|
263
|
+
const webResponse = earlyHintsSender
|
|
264
|
+
? await runWithEarlyHintsSender(earlyHintsSender, () => handler(webRequest))
|
|
265
|
+
: await handler(webRequest)`
|
|
266
|
+
: ` const webResponse = await handler(webRequest)`;
|
|
267
|
+
|
|
268
|
+
return `// Generated by @timber/app/adapters/nitro
|
|
269
|
+
// Do not edit — this file is regenerated on each build.
|
|
270
|
+
|
|
271
|
+
${manifestImport}${earlyHintsImport}import { defineEventHandler, toWebRequest, sendWebResponse } from 'h3'
|
|
272
|
+
import { handler } from '${serverEntryRelative}'
|
|
273
|
+
|
|
274
|
+
// Set TIMBER_RUNTIME for instrumentation.ts conditional SDK initialization.
|
|
275
|
+
// See design/25-production-deployments.md §"TIMBER_RUNTIME".
|
|
276
|
+
process.env.TIMBER_RUNTIME = '${runtimeName}'
|
|
277
|
+
|
|
278
|
+
export default defineEventHandler(async (event) => {
|
|
279
|
+
const webRequest = toWebRequest(event)
|
|
280
|
+
${handlerCall}
|
|
281
|
+
return sendWebResponse(event, webResponse)
|
|
282
|
+
})
|
|
283
|
+
`;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** @internal Exported for testing. */
|
|
287
|
+
export function generateNitroConfig(
|
|
288
|
+
preset: NitroPreset,
|
|
289
|
+
userConfig?: Record<string, unknown>
|
|
290
|
+
): string {
|
|
291
|
+
const presetConfig = PRESET_CONFIGS[preset];
|
|
292
|
+
|
|
293
|
+
const config: Record<string, unknown> = {
|
|
294
|
+
preset: presetConfig.nitroPreset,
|
|
295
|
+
output: { dir: presetConfig.outputDir },
|
|
296
|
+
// Static asset cache headers — hashed assets are immutable, others get 1h.
|
|
297
|
+
// See design/25-production-deployments.md §"CDN / Edge Cache"
|
|
298
|
+
routeRules: {
|
|
299
|
+
'/assets/**': { headers: { 'Cache-Control': IMMUTABLE_CACHE } },
|
|
300
|
+
},
|
|
301
|
+
...presetConfig.extraConfig,
|
|
302
|
+
...userConfig,
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
const configJson = JSON.stringify(config, null, 2);
|
|
306
|
+
|
|
307
|
+
return `// Generated by @timber/app/adapters/nitro
|
|
308
|
+
// Do not edit — this file is regenerated on each build.
|
|
309
|
+
|
|
310
|
+
import { defineNitroConfig } from 'nitropack/config'
|
|
311
|
+
|
|
312
|
+
export default defineNitroConfig(${configJson})
|
|
313
|
+
`;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ─── Preview ─────────────────────────────────────────────────────────────────
|
|
317
|
+
|
|
318
|
+
/** Presets that produce a locally-runnable server entry. */
|
|
319
|
+
const LOCALLY_PREVIEWABLE = new Set<NitroPreset>(['node-server', 'bun']);
|
|
320
|
+
|
|
321
|
+
/** Command descriptor for Nitro preview — testable without spawning. */
|
|
322
|
+
export interface NitroPreviewCommand {
|
|
323
|
+
command: string;
|
|
324
|
+
args: string[];
|
|
325
|
+
cwd: string;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** @internal Exported for testing. */
|
|
329
|
+
export function generateNitroPreviewCommand(
|
|
330
|
+
buildDir: string,
|
|
331
|
+
preset: NitroPreset
|
|
332
|
+
): NitroPreviewCommand | null {
|
|
333
|
+
if (!LOCALLY_PREVIEWABLE.has(preset)) return null;
|
|
334
|
+
|
|
335
|
+
const nitroDir = join(buildDir, 'nitro');
|
|
336
|
+
const entryPath = join(nitroDir, 'entry.ts');
|
|
337
|
+
|
|
338
|
+
const command = preset === 'bun' ? 'bun' : 'node';
|
|
339
|
+
return {
|
|
340
|
+
command,
|
|
341
|
+
args: [entryPath],
|
|
342
|
+
cwd: nitroDir,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** Spawn a Nitro preview process and pipe stdio. */
|
|
347
|
+
function spawnNitroPreview(command: string, args: string[], cwd: string): Promise<void> {
|
|
348
|
+
return new Promise<void>((resolve, reject) => {
|
|
349
|
+
const child = execFile(command, args, { cwd }, (err) => {
|
|
350
|
+
if (err) reject(err);
|
|
351
|
+
else resolve();
|
|
352
|
+
});
|
|
353
|
+
child.stdout?.pipe(process.stdout);
|
|
354
|
+
child.stderr?.pipe(process.stderr);
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Get the preset configuration for a given preset name.
|
|
362
|
+
* @internal Exported for testing.
|
|
363
|
+
*/
|
|
364
|
+
export function getPresetConfig(preset: NitroPreset): PresetConfig {
|
|
365
|
+
return PRESET_CONFIGS[preset];
|
|
366
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Platform adapter interface
|
|
2
|
+
//
|
|
3
|
+
// Adapters transform build output into deployable artifacts and provide
|
|
4
|
+
// runtime hooks for platform-specific capabilities (waitUntil, etc.).
|
|
5
|
+
// See design/11-platform.md §"The Adapter Interface".
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Configuration passed to adapter lifecycle methods.
|
|
9
|
+
* A subset of the resolved timber.config.ts relevant to adapters.
|
|
10
|
+
*/
|
|
11
|
+
export interface TimberConfig {
|
|
12
|
+
output: 'server' | 'static';
|
|
13
|
+
clientJavascriptDisabled?: boolean;
|
|
14
|
+
/**
|
|
15
|
+
* JS module source that sets globalThis.__TIMBER_BUILD_MANIFEST__.
|
|
16
|
+
* Written by adapters as _timber-manifest-init.js, imported before the RSC handler.
|
|
17
|
+
* Undefined when no build manifest was produced (e.g., dev mode or no client assets).
|
|
18
|
+
*/
|
|
19
|
+
manifestInit?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The formal adapter interface. An adapter transforms the build output
|
|
24
|
+
* into a deployable artifact for a specific platform.
|
|
25
|
+
*
|
|
26
|
+
* Adapters are small: they receive the build output directory and
|
|
27
|
+
* transform or copy it into whatever shape the platform expects.
|
|
28
|
+
*/
|
|
29
|
+
export interface TimberPlatformAdapter {
|
|
30
|
+
/** Unique adapter name (e.g. 'cloudflare', 'node', 'bun'). */
|
|
31
|
+
name: string;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Transform the build output for the target platform.
|
|
35
|
+
* Called at the end of `timber build`.
|
|
36
|
+
*/
|
|
37
|
+
buildOutput(config: TimberConfig, buildDir: string): Promise<void>;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Start a local preview server for the built output.
|
|
41
|
+
* Falls back to the built-in Node.js preview server if not provided.
|
|
42
|
+
*/
|
|
43
|
+
preview?(config: TimberConfig, buildDir: string): Promise<void>;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Register a promise to be kept alive after the response is sent.
|
|
47
|
+
* Maps to platform-specific lifecycle extension (e.g. ctx.waitUntil()
|
|
48
|
+
* on Cloudflare Workers). Undefined if the platform doesn't support it.
|
|
49
|
+
*/
|
|
50
|
+
waitUntil?(promise: Promise<unknown>): void;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Send 103 Early Hints to the client before the final response.
|
|
54
|
+
*
|
|
55
|
+
* On Node.js (v18.11+) and Bun, uses `res.writeEarlyHints()` on the
|
|
56
|
+
* raw HTTP response. On Cloudflare, the CDN converts Link headers into
|
|
57
|
+
* 103 automatically — this method is not needed.
|
|
58
|
+
*
|
|
59
|
+
* Undefined if the platform doesn't support application-level 103,
|
|
60
|
+
* or if 103 is handled at the CDN level (Cloudflare).
|
|
61
|
+
*/
|
|
62
|
+
sendEarlyHints?(links: string[]): void;
|
|
63
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// @timber/app/cache — Caching primitives
|
|
2
|
+
|
|
3
|
+
export interface CacheHandler {
|
|
4
|
+
get(key: string): Promise<{ value: unknown; stale: boolean } | null>;
|
|
5
|
+
set(key: string, value: unknown, opts: { ttl: number; tags: string[] }): Promise<void>;
|
|
6
|
+
invalidate(opts: { key?: string; tag?: string }): Promise<void>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
10
|
+
export interface CacheOptions<Fn extends (...args: any[]) => any> {
|
|
11
|
+
ttl: number;
|
|
12
|
+
key?: (...args: Parameters<Fn>) => string;
|
|
13
|
+
staleWhileRevalidate?: boolean;
|
|
14
|
+
tags?: string[] | ((...args: Parameters<Fn>) => string[]);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface MemoryCacheHandlerOptions {
|
|
18
|
+
/** Maximum number of entries. Oldest accessed entries are evicted first. Default: 1000. */
|
|
19
|
+
maxSize?: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class MemoryCacheHandler implements CacheHandler {
|
|
23
|
+
private store = new Map<string, { value: unknown; expiresAt: number; tags: string[] }>();
|
|
24
|
+
private maxSize: number;
|
|
25
|
+
|
|
26
|
+
constructor(opts?: MemoryCacheHandlerOptions) {
|
|
27
|
+
this.maxSize = opts?.maxSize ?? 1000;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async get(key: string) {
|
|
31
|
+
const entry = this.store.get(key);
|
|
32
|
+
if (!entry) return null;
|
|
33
|
+
|
|
34
|
+
// Move to end of Map (most recently used) for LRU ordering
|
|
35
|
+
this.store.delete(key);
|
|
36
|
+
this.store.set(key, entry);
|
|
37
|
+
|
|
38
|
+
const stale = Date.now() > entry.expiresAt;
|
|
39
|
+
return { value: entry.value, stale };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async set(key: string, value: unknown, opts: { ttl: number; tags: string[] }) {
|
|
43
|
+
// If key already exists, delete first to refresh insertion order
|
|
44
|
+
if (this.store.has(key)) {
|
|
45
|
+
this.store.delete(key);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Evict oldest entries (front of Map) if at capacity
|
|
49
|
+
while (this.store.size >= this.maxSize) {
|
|
50
|
+
const oldest = this.store.keys().next().value;
|
|
51
|
+
if (oldest !== undefined) {
|
|
52
|
+
this.store.delete(oldest);
|
|
53
|
+
} else {
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
this.store.set(key, {
|
|
59
|
+
value,
|
|
60
|
+
expiresAt: Date.now() + opts.ttl * 1000,
|
|
61
|
+
tags: opts.tags,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async invalidate(opts: { key?: string; tag?: string }) {
|
|
66
|
+
if (opts.key) {
|
|
67
|
+
this.store.delete(opts.key);
|
|
68
|
+
}
|
|
69
|
+
if (opts.tag) {
|
|
70
|
+
for (const [key, entry] of this.store) {
|
|
71
|
+
if (entry.tags.includes(opts.tag)) {
|
|
72
|
+
this.store.delete(key);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Number of entries currently in the cache. */
|
|
79
|
+
get size(): number {
|
|
80
|
+
return this.store.size;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export { RedisCacheHandler } from './redis-handler';
|
|
85
|
+
export type { RedisClient } from './redis-handler';
|
|
86
|
+
export { createCache } from './timber-cache';
|
|
87
|
+
export { registerCachedFunction } from './register-cached-function';
|
|
88
|
+
export type { RegisterCachedFunctionOptions } from './register-cached-function';
|
|
89
|
+
export { stableStringify } from './stable-stringify';
|
|
90
|
+
export { createSingleflight } from './singleflight';
|
|
91
|
+
export type { Singleflight } from './singleflight';
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { CacheHandler } from './index';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Minimal Redis client interface — compatible with ioredis, node-redis, and
|
|
5
|
+
* Cloudflare Workers Redis bindings. We depend on the interface, not the
|
|
6
|
+
* implementation, so users bring their own Redis client.
|
|
7
|
+
*/
|
|
8
|
+
export interface RedisClient {
|
|
9
|
+
get(key: string): Promise<string | null>;
|
|
10
|
+
set(key: string, value: string, ...args: unknown[]): Promise<unknown>;
|
|
11
|
+
del(key: string | string[]): Promise<number>;
|
|
12
|
+
sadd(key: string, ...members: string[]): Promise<number>;
|
|
13
|
+
smembers(key: string): Promise<string[]>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const KEY_PREFIX = 'timber:cache:';
|
|
17
|
+
const TAG_PREFIX = 'timber:tag:';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Redis-backed CacheHandler for distributed caching.
|
|
21
|
+
*
|
|
22
|
+
* All instances sharing the same Redis see each other's cache entries and
|
|
23
|
+
* invalidations. Tag-based invalidation uses Redis Sets to track which keys
|
|
24
|
+
* belong to which tags.
|
|
25
|
+
*
|
|
26
|
+
* Bring your own Redis client — any client implementing the RedisClient
|
|
27
|
+
* interface works (ioredis, node-redis, @upstash/redis, etc.).
|
|
28
|
+
*/
|
|
29
|
+
export class RedisCacheHandler implements CacheHandler {
|
|
30
|
+
private client: RedisClient;
|
|
31
|
+
private prefix: string;
|
|
32
|
+
|
|
33
|
+
constructor(client: RedisClient, opts?: { prefix?: string }) {
|
|
34
|
+
this.client = client;
|
|
35
|
+
this.prefix = opts?.prefix ?? '';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
private cacheKey(key: string): string {
|
|
39
|
+
return `${this.prefix}${KEY_PREFIX}${key}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
private tagKey(tag: string): string {
|
|
43
|
+
return `${this.prefix}${TAG_PREFIX}${tag}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async get(key: string): Promise<{ value: unknown; stale: boolean } | null> {
|
|
47
|
+
const raw = await this.client.get(this.cacheKey(key));
|
|
48
|
+
if (raw === null) return null;
|
|
49
|
+
|
|
50
|
+
const entry = JSON.parse(raw) as { value: unknown; expiresAt: number };
|
|
51
|
+
const stale = Date.now() > entry.expiresAt;
|
|
52
|
+
return { value: entry.value, stale };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async set(key: string, value: unknown, opts: { ttl: number; tags: string[] }): Promise<void> {
|
|
56
|
+
const ck = this.cacheKey(key);
|
|
57
|
+
const expiresAt = Date.now() + opts.ttl * 1000;
|
|
58
|
+
const payload = JSON.stringify({ value, expiresAt });
|
|
59
|
+
|
|
60
|
+
// Redis TTL with generous margin beyond the logical TTL to allow SWR reads
|
|
61
|
+
// on stale entries. The logical staleness is determined by expiresAt.
|
|
62
|
+
// We use 2x TTL + 60s as the Redis expiry so stale entries remain
|
|
63
|
+
// available for SWR background refetches.
|
|
64
|
+
const redisTtlSeconds = Math.max(opts.ttl * 2 + 60, 120);
|
|
65
|
+
await this.client.set(ck, payload, 'EX', redisTtlSeconds);
|
|
66
|
+
|
|
67
|
+
// Track key membership in each tag set
|
|
68
|
+
for (const tag of opts.tags) {
|
|
69
|
+
await this.client.sadd(this.tagKey(tag), key);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async invalidate(opts: { key?: string; tag?: string }): Promise<void> {
|
|
74
|
+
if (opts.key) {
|
|
75
|
+
await this.client.del(this.cacheKey(opts.key));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (opts.tag) {
|
|
79
|
+
const tk = this.tagKey(opts.tag);
|
|
80
|
+
const keys = await this.client.smembers(tk);
|
|
81
|
+
|
|
82
|
+
if (keys.length > 0) {
|
|
83
|
+
const cacheKeys = keys.map((k) => this.cacheKey(k));
|
|
84
|
+
await this.client.del(cacheKeys);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Clean up the tag set itself
|
|
88
|
+
await this.client.del(tk);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|