@timber-js/app 0.2.0-alpha.167 → 0.2.0-alpha.169
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/_chunks/{actions-TSxpXLHJ.js → actions-O_LsyCE4.js} +3 -3
- package/dist/_chunks/{actions-TSxpXLHJ.js.map → actions-O_LsyCE4.js.map} +1 -1
- package/dist/_chunks/{cache-api-DzpQQOEx.js → cache-api-B-lhk9p4.js} +56 -11
- package/dist/_chunks/cache-api-B-lhk9p4.js.map +1 -0
- package/dist/_chunks/{cli-schema-sync-wX-i90Og.js → cli-schema-sync-B73L6pMq.js} +2 -2
- package/dist/_chunks/{cli-schema-sync-wX-i90Og.js.map → cli-schema-sync-B73L6pMq.js.map} +1 -1
- package/dist/_chunks/json-lossy-check-ClNvBM_3.js +63 -0
- package/dist/_chunks/json-lossy-check-ClNvBM_3.js.map +1 -0
- package/dist/_chunks/{logger-t3uxAmbX.js → logger-AWfuX-KJ.js} +2 -19
- package/dist/_chunks/logger-AWfuX-KJ.js.map +1 -0
- package/dist/_chunks/{walkers-Cfwvl-UC.js → walkers-CoOC8Hga.js} +2 -2
- package/dist/_chunks/{walkers-Cfwvl-UC.js.map → walkers-CoOC8Hga.js.map} +1 -1
- package/dist/adapters/cloudflare-kv-cache.d.ts.map +1 -1
- package/dist/adapters/cloudflare-kv-cache.js +9 -2
- package/dist/adapters/cloudflare-kv-cache.js.map +1 -1
- package/dist/cache/cache-api.d.ts.map +1 -1
- package/dist/cache/index.d.ts +1 -1
- package/dist/cache/index.d.ts.map +1 -1
- package/dist/cache/index.js +1 -1
- package/dist/cache/json-lossy-check.d.ts +11 -0
- package/dist/cache/json-lossy-check.d.ts.map +1 -0
- package/dist/cache/redis-handler.d.ts +42 -0
- package/dist/cache/redis-handler.d.ts.map +1 -1
- package/dist/cache/singleflight.d.ts.map +1 -1
- package/dist/cache/stores/cloudflare-kv.d.ts +1 -1
- package/dist/cache/stores/cloudflare-kv.d.ts.map +1 -1
- package/dist/cache/stores/memory.d.ts +1 -1
- package/dist/cache/stores/memory.d.ts.map +1 -1
- package/dist/cache/stores/redis.d.ts +1 -1
- package/dist/cache/stores/redis.d.ts.map +1 -1
- package/dist/cache/stores/vercel.d.ts +1 -1
- package/dist/cache/stores/vercel.d.ts.map +1 -1
- package/dist/cache/tag-aware-handler.d.ts.map +1 -1
- package/dist/cache/timber-cache.d.ts.map +1 -1
- package/dist/cdn/cloudflare-purge.d.ts.map +1 -1
- package/dist/cdn/fastly-purge.d.ts.map +1 -1
- package/dist/cdn/workers-cache-purge.d.ts.map +1 -1
- package/dist/cli.d.ts +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +3 -2
- package/dist/cli.js.map +1 -1
- package/dist/client/browser-entry/router-init.d.ts +1 -0
- package/dist/client/browser-entry/router-init.d.ts.map +1 -1
- package/dist/client/child-segment-context.d.ts +2 -2
- package/dist/client/child-segment-context.d.ts.map +1 -1
- package/dist/client/error-boundary.d.ts.map +1 -1
- package/dist/client/history.d.ts.map +1 -1
- package/dist/client/internal.js +4 -3
- package/dist/client/internal.js.map +1 -1
- package/dist/client/router.d.ts +6 -0
- package/dist/client/router.d.ts.map +1 -1
- package/dist/client/rsc-fetch.d.ts.map +1 -1
- package/dist/client/segment-cache.d.ts.map +1 -1
- package/dist/client/segment-update-context.d.ts +3 -3
- package/dist/client/segment-update-context.d.ts.map +1 -1
- package/dist/client/slot-outlet.d.ts +1 -1
- package/dist/codec.d.ts.map +1 -1
- package/dist/codec.js +2 -1
- package/dist/codec.js.map +1 -1
- package/dist/config-types.d.ts +16 -37
- package/dist/config-types.d.ts.map +1 -1
- package/dist/config-validation.d.ts.map +1 -1
- package/dist/dev-tools/instrumentation.d.ts.map +1 -1
- package/dist/fonts/pipeline.d.ts +19 -0
- package/dist/fonts/pipeline.d.ts.map +1 -1
- package/dist/fonts/transform.d.ts.map +1 -1
- package/dist/fonts/virtual-modules.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +344 -84
- package/dist/index.js.map +1 -1
- package/dist/plugin-context.d.ts.map +1 -1
- package/dist/plugins/adapter-build.d.ts +1 -0
- package/dist/plugins/adapter-build.d.ts.map +1 -1
- package/dist/plugins/entries.d.ts +27 -3
- package/dist/plugins/entries.d.ts.map +1 -1
- package/dist/plugins/fonts.d.ts.map +1 -1
- package/dist/plugins/server-bundle.d.ts.map +1 -1
- package/dist/routing/index.js +2 -2
- package/dist/server/action-client.d.ts +1 -1
- package/dist/server/action-client.d.ts.map +1 -1
- package/dist/server/body-limits.d.ts.map +1 -1
- package/dist/server/form-data.d.ts.map +1 -1
- package/dist/server/html-injector-core.d.ts.map +1 -1
- package/dist/server/index.js +20 -3
- package/dist/server/index.js.map +1 -1
- package/dist/server/internal.js +24 -40
- package/dist/server/internal.js.map +1 -1
- package/dist/server/middleware-runner.d.ts +0 -17
- package/dist/server/middleware-runner.d.ts.map +1 -1
- package/dist/server/param-coercion.d.ts.map +1 -1
- package/dist/server/pipeline-phases.d.ts +6 -3
- package/dist/server/pipeline-phases.d.ts.map +1 -1
- package/dist/server/pipeline.d.ts +18 -0
- package/dist/server/pipeline.d.ts.map +1 -1
- package/dist/server/prebuilt/capture-state.d.ts.map +1 -1
- package/dist/server/prebuilt/synthetic-store.d.ts.map +1 -1
- package/dist/server/primitives.d.ts.map +1 -1
- package/dist/server/render-timeout.d.ts.map +1 -1
- package/dist/server/route-element-builder.d.ts.map +1 -1
- package/dist/server/rsc-entry/action-middleware-runner.d.ts +14 -14
- package/dist/server/rsc-entry/index.d.ts.map +1 -1
- package/dist/server/rsc-entry/render-route.d.ts +1 -0
- package/dist/server/rsc-entry/render-route.d.ts.map +1 -1
- package/dist/server/rsc-entry/revalidate-renderer.d.ts.map +1 -1
- package/dist/server/rsc-entry/wrap-action-dispatch.d.ts +44 -74
- package/dist/server/rsc-entry/wrap-action-dispatch.d.ts.map +1 -1
- package/dist/server/safe-load.d.ts.map +1 -1
- package/dist/server/ssr-entry.d.ts.map +1 -1
- package/dist/shared/redirect-type.d.ts +2 -2
- package/dist/shared/redirect-type.d.ts.map +1 -1
- package/dist/shims/font-google.d.ts.map +1 -1
- package/dist/shims/image.d.ts +120 -120
- package/dist/shims/image.d.ts.map +1 -1
- package/docs/api/32-api-cache.mdx +112 -0
- package/docs/api/34-api-config.mdx +0 -26
- package/docs/learn/09-caching.mdx +121 -5
- package/docs/learn/12-client-navigation.mdx +9 -1
- package/docs/learn/13-configuration.mdx +6 -8
- package/docs/learn/14-deploying.mdx +18 -18
- package/docs/more/04-metadata-and-fonts.mdx +1 -1
- package/package.json +6 -6
- package/src/adapters/cloudflare-kv-cache.ts +27 -6
- package/src/cache/index.ts +1 -1
- package/src/cache/json-lossy-check.ts +75 -0
- package/src/cache/redis-handler.ts +65 -14
- package/src/cache/timber-cache.ts +10 -2
- package/src/client/browser-entry/index.ts +2 -0
- package/src/client/browser-entry/router-init.ts +2 -0
- package/src/client/router.ts +13 -3
- package/src/codec.ts +3 -1
- package/src/config-types.ts +16 -37
- package/src/config-validation.ts +7 -5
- package/src/fonts/pipeline.ts +32 -0
- package/src/fonts/transform.ts +30 -24
- package/src/plugins/adapter-build.ts +131 -13
- package/src/plugins/entries.ts +40 -43
- package/src/plugins/fonts.ts +30 -0
- package/src/plugins/server-bundle.ts +7 -15
- package/src/server/action-client.ts +1 -1
- package/src/server/middleware-runner.ts +0 -44
- package/src/server/param-coercion.ts +1 -0
- package/src/server/pipeline-phases.ts +11 -13
- package/src/server/pipeline.ts +51 -3
- package/src/server/route-element-builder.ts +10 -1
- package/src/server/rsc-entry/action-middleware-runner.ts +14 -14
- package/src/server/rsc-entry/index.ts +30 -40
- package/src/server/rsc-entry/render-route.ts +3 -1
- package/src/server/rsc-entry/wrap-action-dispatch.ts +109 -372
- package/dist/_chunks/cache-api-DzpQQOEx.js.map +0 -1
- package/dist/_chunks/logger-t3uxAmbX.js.map +0 -1
- package/dist/_chunks/tree-match-D2l830j2.js +0 -102
- package/dist/_chunks/tree-match-D2l830j2.js.map +0 -1
package/src/fonts/transform.ts
CHANGED
|
@@ -213,7 +213,11 @@ export function runFontsTransform(
|
|
|
213
213
|
|
|
214
214
|
const s = new MagicString(code);
|
|
215
215
|
|
|
216
|
-
//
|
|
216
|
+
// Extract and validate all call sites before mutating the pipeline.
|
|
217
|
+
// If any extraction fails (emitError throws), the pipeline is untouched
|
|
218
|
+
// so the previous valid CSS stays intact (TIM-1087).
|
|
219
|
+
const pending: { extracted: ExtractedFont; site: (typeof analysis.callSites)[0] }[] = [];
|
|
220
|
+
|
|
217
221
|
for (const site of analysis.callSites) {
|
|
218
222
|
if (site.binding.provider === 'google') {
|
|
219
223
|
const family = site.binding.importedName.replace(/_/g, ' ');
|
|
@@ -232,24 +236,22 @@ export function runFontsTransform(
|
|
|
232
236
|
const fontStack = buildFontStack(family);
|
|
233
237
|
const display = config!.display ?? 'swap';
|
|
234
238
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
s.overwrite(site.replaceStart, site.replaceEnd, buildFontResultLiteral(extracted));
|
|
239
|
+
pending.push({
|
|
240
|
+
site,
|
|
241
|
+
extracted: {
|
|
242
|
+
id: fontId,
|
|
243
|
+
family,
|
|
244
|
+
provider: 'google',
|
|
245
|
+
weights: normalizeWeightArray(config!.weight),
|
|
246
|
+
styles: normalizeStyleArray(config!.style),
|
|
247
|
+
subsets: config!.subsets ?? ['latin'],
|
|
248
|
+
display,
|
|
249
|
+
variable: config!.variable,
|
|
250
|
+
className,
|
|
251
|
+
fontFamily: fontStack,
|
|
252
|
+
importer: id,
|
|
253
|
+
},
|
|
254
|
+
});
|
|
253
255
|
} else {
|
|
254
256
|
// local font
|
|
255
257
|
const config = extractLocalFontConfigFromNode(site.configNode);
|
|
@@ -261,14 +263,18 @@ export function runFontsTransform(
|
|
|
261
263
|
);
|
|
262
264
|
}
|
|
263
265
|
|
|
264
|
-
|
|
265
|
-
pipeline.pruneFor(id, extracted.family);
|
|
266
|
-
pipeline.register(extracted);
|
|
267
|
-
|
|
268
|
-
s.overwrite(site.replaceStart, site.replaceEnd, buildFontResultLiteral(extracted));
|
|
266
|
+
pending.push({ site, extracted: processLocalFont(config!, id) });
|
|
269
267
|
}
|
|
270
268
|
}
|
|
271
269
|
|
|
270
|
+
// All extractions succeeded — now prune stale entries and register.
|
|
271
|
+
// Handles family renames and deleted font calls (TIM-1087).
|
|
272
|
+
pipeline.pruneImporter(id);
|
|
273
|
+
for (const { extracted, site } of pending) {
|
|
274
|
+
pipeline.register(extracted);
|
|
275
|
+
s.overwrite(site.replaceStart, site.replaceEnd, buildFontResultLiteral(extracted));
|
|
276
|
+
}
|
|
277
|
+
|
|
272
278
|
// Remove font import declarations. Use a Set to avoid double-removing
|
|
273
279
|
// when multiple bindings come from the same ImportDeclaration.
|
|
274
280
|
const removedRanges = new Set<string>();
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
import type { Plugin } from 'vite';
|
|
17
17
|
import { join } from 'node:path';
|
|
18
|
+
import { pathToFileURL } from 'node:url';
|
|
18
19
|
import { readFile, writeFile } from 'node:fs/promises';
|
|
19
20
|
import type { PluginContext } from '../plugin-context.js';
|
|
20
21
|
import type { TimberPlatformAdapter, TimberConfig } from '../adapters/types.js';
|
|
@@ -95,6 +96,116 @@ export function timberAdapterBuild(ctx: PluginContext): Plugin {
|
|
|
95
96
|
};
|
|
96
97
|
}
|
|
97
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Parse the RSC assets manifest at `manifestPath`. Tries JSON.parse first
|
|
101
|
+
* (fast path for plain-string manifests); falls back to a dynamic import()
|
|
102
|
+
* for manifests that contain runtime expressions from renderBuiltUrl.
|
|
103
|
+
* Returns null if both paths fail.
|
|
104
|
+
*/
|
|
105
|
+
async function parseRscManifest(
|
|
106
|
+
manifestPath: string,
|
|
107
|
+
content: string
|
|
108
|
+
): Promise<Record<string, unknown> | null> {
|
|
109
|
+
const jsonStr = content.replace(/^export default\s*/, '').replace(/;?\s*$/, '');
|
|
110
|
+
try {
|
|
111
|
+
return JSON.parse(jsonStr);
|
|
112
|
+
} catch {
|
|
113
|
+
// Not strict JSON — manifest likely contains renderBuiltUrl runtime
|
|
114
|
+
// expressions. Fall back to evaluating the module directly.
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// When renderBuiltUrl's runtime expressions reference external helpers
|
|
118
|
+
// not defined in the manifest file, this import will fail. That's an
|
|
119
|
+
// inherent limitation: runtime-computed URLs can't be resolved at build
|
|
120
|
+
// time for <link> tag injection. The warning below provides observability.
|
|
121
|
+
try {
|
|
122
|
+
const url = pathToFileURL(manifestPath).href;
|
|
123
|
+
const mod = await import(`${url}?t=${Date.now()}`);
|
|
124
|
+
return mod.default ?? null;
|
|
125
|
+
} catch (err) {
|
|
126
|
+
swallow(err, `failed to evaluate RSC assets manifest: ${manifestPath}`, { level: 'warn' });
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Replace all `"js": [...]` array contents with `"js": []` using balanced
|
|
133
|
+
* bracket matching. Handles nested brackets from runtime expressions like
|
|
134
|
+
* `__rbu("path/to/[slug]/file.js")`.
|
|
135
|
+
*/
|
|
136
|
+
function clearJsArrays(src: string): string {
|
|
137
|
+
const pattern = /"js"\s*:\s*\[/g;
|
|
138
|
+
let result = '';
|
|
139
|
+
let lastEnd = 0;
|
|
140
|
+
let match: RegExpExecArray | null;
|
|
141
|
+
|
|
142
|
+
while ((match = pattern.exec(src)) !== null) {
|
|
143
|
+
const arrayStart = match.index + match[0].length;
|
|
144
|
+
let depth = 1;
|
|
145
|
+
let i = arrayStart;
|
|
146
|
+
while (i < src.length && depth > 0) {
|
|
147
|
+
if (src[i] === '[') depth++;
|
|
148
|
+
else if (src[i] === ']') depth--;
|
|
149
|
+
i++;
|
|
150
|
+
}
|
|
151
|
+
result += src.slice(lastEnd, match.index) + '"js": []';
|
|
152
|
+
lastEnd = i;
|
|
153
|
+
}
|
|
154
|
+
result += src.slice(lastEnd);
|
|
155
|
+
return result;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Replace `"bootstrapScriptContent": <value>` with an empty string. The
|
|
160
|
+
* value may be a JS expression (e.g., concatenation with runtime helpers),
|
|
161
|
+
* not a simple string literal. Scans forward from the colon, tracking
|
|
162
|
+
* balanced parens and brackets, and stops at the next unbalanced `,` or `}`.
|
|
163
|
+
*/
|
|
164
|
+
function clearBootstrapScriptContent(src: string): string {
|
|
165
|
+
const key = '"bootstrapScriptContent"';
|
|
166
|
+
const idx = src.indexOf(key);
|
|
167
|
+
if (idx === -1) return src;
|
|
168
|
+
|
|
169
|
+
// Find the colon after the key
|
|
170
|
+
const colonIdx = src.indexOf(':', idx + key.length);
|
|
171
|
+
if (colonIdx === -1) return src;
|
|
172
|
+
|
|
173
|
+
// Skip whitespace after colon
|
|
174
|
+
let valStart = colonIdx + 1;
|
|
175
|
+
while (valStart < src.length && /\s/.test(src[valStart])) valStart++;
|
|
176
|
+
|
|
177
|
+
// Scan forward to find the end of the value expression
|
|
178
|
+
let i = valStart;
|
|
179
|
+
let depth = 0;
|
|
180
|
+
let inString = false;
|
|
181
|
+
let stringChar = '';
|
|
182
|
+
|
|
183
|
+
while (i < src.length) {
|
|
184
|
+
const ch = src[i];
|
|
185
|
+
if (inString) {
|
|
186
|
+
if (ch === '\\') {
|
|
187
|
+
i += 2;
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (ch === stringChar) inString = false;
|
|
191
|
+
} else {
|
|
192
|
+
if (ch === '"' || ch === "'") {
|
|
193
|
+
inString = true;
|
|
194
|
+
stringChar = ch;
|
|
195
|
+
} else if (ch === '(' || ch === '[') {
|
|
196
|
+
depth++;
|
|
197
|
+
} else if (ch === ')' || ch === ']') {
|
|
198
|
+
depth--;
|
|
199
|
+
} else if (depth === 0 && (ch === ',' || ch === '}' || ch === '\n')) {
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
i++;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return src.slice(0, idx) + '"bootstrapScriptContent": ""' + src.slice(i);
|
|
207
|
+
}
|
|
208
|
+
|
|
98
209
|
/**
|
|
99
210
|
* Strip JS references from the RSC plugin's assets manifest files.
|
|
100
211
|
*
|
|
@@ -126,13 +237,8 @@ export async function mergeRscAssetsManifest(
|
|
|
126
237
|
return;
|
|
127
238
|
}
|
|
128
239
|
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
try {
|
|
132
|
-
manifest = JSON.parse(jsonStr);
|
|
133
|
-
} catch {
|
|
134
|
-
return;
|
|
135
|
-
}
|
|
240
|
+
const manifest = await parseRscManifest(manifestPath, content);
|
|
241
|
+
if (!manifest) return;
|
|
136
242
|
|
|
137
243
|
// Merge serverResources CSS into buildManifest.css.
|
|
138
244
|
// Keys in serverResources are root-relative (e.g., "app/layout.tsx" or
|
|
@@ -175,7 +281,7 @@ export async function mergeRscAssetsManifest(
|
|
|
175
281
|
}
|
|
176
282
|
}
|
|
177
283
|
|
|
178
|
-
async function stripJsFromRscAssetsManifests(buildDir: string): Promise<void> {
|
|
284
|
+
export async function stripJsFromRscAssetsManifests(buildDir: string): Promise<void> {
|
|
179
285
|
const manifestName = '__vite_rsc_assets_manifest.js';
|
|
180
286
|
const paths = [join(buildDir, 'rsc', manifestName), join(buildDir, 'ssr', manifestName)];
|
|
181
287
|
|
|
@@ -187,17 +293,29 @@ async function stripJsFromRscAssetsManifests(buildDir: string): Promise<void> {
|
|
|
187
293
|
continue;
|
|
188
294
|
}
|
|
189
295
|
|
|
296
|
+
// Try JSON.parse to check if we can safely re-serialize
|
|
190
297
|
const jsonStr = content.replace(/^export default\s*/, '').replace(/;?\s*$/, '');
|
|
191
|
-
let
|
|
298
|
+
let isStrictJson = true;
|
|
299
|
+
let manifest: Record<string, unknown> | null = null;
|
|
192
300
|
try {
|
|
193
301
|
manifest = JSON.parse(jsonStr);
|
|
194
|
-
} catch
|
|
195
|
-
|
|
302
|
+
} catch {
|
|
303
|
+
isStrictJson = false;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (!isStrictJson) {
|
|
307
|
+
// Manifest contains renderBuiltUrl runtime expressions — cannot
|
|
308
|
+
// safely re-serialize with JSON.stringify. Use targeted text
|
|
309
|
+
// transforms to clear JS arrays and bootstrapScriptContent.
|
|
310
|
+
let rewritten = content;
|
|
311
|
+
rewritten = clearJsArrays(rewritten);
|
|
312
|
+
rewritten = clearBootstrapScriptContent(rewritten);
|
|
313
|
+
await writeFile(path, rewritten);
|
|
196
314
|
continue;
|
|
197
315
|
}
|
|
198
316
|
|
|
199
317
|
// Clear JS from clientReferenceDeps — preserves CSS
|
|
200
|
-
const deps = manifest
|
|
318
|
+
const deps = manifest?.clientReferenceDeps as
|
|
201
319
|
| Record<string, { js: string[]; css: string[] }>
|
|
202
320
|
| undefined;
|
|
203
321
|
if (deps) {
|
|
@@ -206,7 +324,7 @@ async function stripJsFromRscAssetsManifests(buildDir: string): Promise<void> {
|
|
|
206
324
|
}
|
|
207
325
|
}
|
|
208
326
|
|
|
209
|
-
manifest.bootstrapScriptContent = '';
|
|
327
|
+
if (manifest) manifest.bootstrapScriptContent = '';
|
|
210
328
|
|
|
211
329
|
await writeFile(path, `export default ${JSON.stringify(manifest, null, 2)};\n`);
|
|
212
330
|
}
|
package/src/plugins/entries.ts
CHANGED
|
@@ -154,6 +154,7 @@ function generateConfigModule(ctx: PluginContext): string {
|
|
|
154
154
|
// Per-build deployment ID for version skew detection (TIM-446).
|
|
155
155
|
// Null in dev mode — HMR handles code updates without full reloads.
|
|
156
156
|
deploymentId: ctx.deploymentId ?? null,
|
|
157
|
+
clientSegmentCache: ctx.config.clientSegmentCache ?? false,
|
|
157
158
|
// Browser log forwarding threshold — used by buildClientScripts to
|
|
158
159
|
// decide whether /@vite/client is needed when clientJavascript is
|
|
159
160
|
// disabled. See TIM-1152.
|
|
@@ -174,23 +175,39 @@ function generateConfigModule(ctx: PluginContext): string {
|
|
|
174
175
|
}
|
|
175
176
|
|
|
176
177
|
/**
|
|
177
|
-
* Detect
|
|
178
|
+
* Detect a user convention file at both root and src/.
|
|
178
179
|
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
180
|
+
* Root takes precedence over src/, extension priority: .ts > .js > .mjs.
|
|
181
|
+
* Used for instrumentation.ts, timber.cache.ts, and similar file conventions.
|
|
181
182
|
*/
|
|
182
|
-
export function
|
|
183
|
+
export function detectConventionFile(root: string, basename: string): string | null {
|
|
183
184
|
const extensions = ['.ts', '.js', '.mjs'];
|
|
184
185
|
const dirs = [root, resolve(root, 'src')];
|
|
185
186
|
for (const dir of dirs) {
|
|
186
187
|
for (const ext of extensions) {
|
|
187
|
-
const candidate = resolve(dir,
|
|
188
|
+
const candidate = resolve(dir, `${basename}${ext}`);
|
|
188
189
|
if (existsSync(candidate)) return candidate;
|
|
189
190
|
}
|
|
190
191
|
}
|
|
191
192
|
return null;
|
|
192
193
|
}
|
|
193
194
|
|
|
195
|
+
export function detectInstrumentationFile(root: string): string | null {
|
|
196
|
+
return detectConventionFile(root, 'instrumentation');
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Detect the user's timber.cache file.
|
|
201
|
+
*
|
|
202
|
+
* This file holds runtime cache instances (cacheHandler, cdnPurge)
|
|
203
|
+
* that cannot be JSON-serialized. Separating them from timber.config.ts
|
|
204
|
+
* prevents the RSC build from pulling build-time deps (adapters, MDX,
|
|
205
|
+
* rollup) into the runtime bundle. See TIM-1211.
|
|
206
|
+
*/
|
|
207
|
+
export function detectCacheFile(root: string): string | null {
|
|
208
|
+
return detectConventionFile(root, 'timber.cache');
|
|
209
|
+
}
|
|
210
|
+
|
|
194
211
|
/**
|
|
195
212
|
* Generate the virtual:timber-instrumentation module source.
|
|
196
213
|
*
|
|
@@ -202,9 +219,6 @@ export function detectInstrumentationFile(root: string): string | null {
|
|
|
202
219
|
*/
|
|
203
220
|
export function generateInstrumentationModule(instrumentationPath: string | null): string {
|
|
204
221
|
if (instrumentationPath) {
|
|
205
|
-
// Use the absolute path so the bundler can resolve and include it.
|
|
206
|
-
// The dynamic import ensures the file is loaded lazily and errors
|
|
207
|
-
// in the user's code don't prevent the module from being parsed.
|
|
208
222
|
return [
|
|
209
223
|
'// Auto-generated instrumentation loader — do not edit.',
|
|
210
224
|
'// Generated by timber-entries plugin.',
|
|
@@ -226,48 +240,33 @@ export function generateInstrumentationModule(instrumentationPath: string | null
|
|
|
226
240
|
].join('\n');
|
|
227
241
|
}
|
|
228
242
|
|
|
229
|
-
/**
|
|
230
|
-
* Detect the user's timber.config file at the project root.
|
|
231
|
-
* Returns the absolute path or null if not found.
|
|
232
|
-
*/
|
|
233
|
-
function detectConfigFile(root: string): string | null {
|
|
234
|
-
const names = ['timber.config.ts', 'timber.config.js', 'timber.config.mjs'];
|
|
235
|
-
for (const name of names) {
|
|
236
|
-
const candidate = resolve(root, name);
|
|
237
|
-
if (existsSync(candidate)) return candidate;
|
|
238
|
-
}
|
|
239
|
-
return null;
|
|
240
|
-
}
|
|
241
|
-
|
|
242
243
|
/**
|
|
243
244
|
* Generate the virtual:timber-cache-handler module source.
|
|
244
245
|
*
|
|
245
|
-
* When the user's
|
|
246
|
-
* dynamically imports
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
246
|
+
* When the user's timber.cache.ts exists, generates a module that
|
|
247
|
+
* dynamically imports it and extracts cacheHandler and cdnPurge.
|
|
248
|
+
* These are class instances (e.g. RedisCacheHandler) that cannot be
|
|
249
|
+
* JSON-serialized into virtual:timber-config, so they must be loaded
|
|
250
|
+
* at runtime via dynamic import.
|
|
251
|
+
*
|
|
252
|
+
* timber.cache.ts exports: default cacheHandler, named cdnPurge.
|
|
253
|
+
*
|
|
254
|
+
* See TIM-1211, TIM-599.
|
|
250
255
|
*/
|
|
251
|
-
function generateCacheHandlerModule(
|
|
252
|
-
|
|
253
|
-
hasCacheHandler: boolean,
|
|
254
|
-
hasCdnPurge: boolean
|
|
255
|
-
): string {
|
|
256
|
-
if (configPath && (hasCacheHandler || hasCdnPurge)) {
|
|
256
|
+
export function generateCacheHandlerModule(cachePath: string | null): string {
|
|
257
|
+
if (cachePath) {
|
|
257
258
|
return [
|
|
258
259
|
'// Auto-generated cache handler loader — do not edit.',
|
|
259
260
|
'// Generated by timber-entries plugin.',
|
|
260
261
|
'',
|
|
261
262
|
`export default async function loadCacheHandler() {`,
|
|
262
|
-
` const mod = await import(${JSON.stringify(
|
|
263
|
-
`
|
|
264
|
-
` return config.cacheHandler ?? null;`,
|
|
263
|
+
` const mod = await import(${JSON.stringify(cachePath)});`,
|
|
264
|
+
` return mod.default ?? null;`,
|
|
265
265
|
`}`,
|
|
266
266
|
'',
|
|
267
267
|
`export async function loadCdnPurgeHandler() {`,
|
|
268
|
-
` const mod = await import(${JSON.stringify(
|
|
269
|
-
`
|
|
270
|
-
` return config.cdnPurge ?? null;`,
|
|
268
|
+
` const mod = await import(${JSON.stringify(cachePath)});`,
|
|
269
|
+
` return mod.cdnPurge ?? null;`,
|
|
271
270
|
`}`,
|
|
272
271
|
].join('\n');
|
|
273
272
|
}
|
|
@@ -275,7 +274,7 @@ function generateCacheHandlerModule(
|
|
|
275
274
|
return [
|
|
276
275
|
'// Auto-generated cache handler loader — do not edit.',
|
|
277
276
|
'// Generated by timber-entries plugin.',
|
|
278
|
-
'// No
|
|
277
|
+
'// No timber.cache.ts found.',
|
|
279
278
|
'',
|
|
280
279
|
`export default async function loadCacheHandler() {`,
|
|
281
280
|
` return null;`,
|
|
@@ -352,10 +351,8 @@ export function timberEntries(ctx: PluginContext): Plugin {
|
|
|
352
351
|
return generateInstrumentationModule(instrumentationPath);
|
|
353
352
|
}
|
|
354
353
|
if (id === RESOLVED_CACHE_HANDLER_ID) {
|
|
355
|
-
const
|
|
356
|
-
|
|
357
|
-
const hasCdnPurge = ctx.config.cdnPurge != null;
|
|
358
|
-
return generateCacheHandlerModule(configPath, hasCacheHandler, hasCdnPurge);
|
|
354
|
+
const cachePath = detectCacheFile(ctx.root);
|
|
355
|
+
return generateCacheHandlerModule(cachePath);
|
|
359
356
|
}
|
|
360
357
|
return null;
|
|
361
358
|
},
|
package/src/plugins/fonts.ts
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* Design doc: 24-fonts.md
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
+
import { parseAst } from 'vite';
|
|
21
22
|
import type { Plugin, ResolvedConfig, ViteDevServer } from 'vite';
|
|
22
23
|
import type { PluginContext } from '../plugin-context.js';
|
|
23
24
|
import {
|
|
@@ -224,6 +225,35 @@ export function timberFonts(ctx: PluginContext): Plugin {
|
|
|
224
225
|
*/
|
|
225
226
|
buildStart() {},
|
|
226
227
|
|
|
228
|
+
/**
|
|
229
|
+
* Prune stale font entries when a module that previously imported fonts
|
|
230
|
+
* no longer contains any font import. The transform hook's `filter.code`
|
|
231
|
+
* skips such modules, so without this hook the old entries would persist
|
|
232
|
+
* until dev restart. See TIM-1087.
|
|
233
|
+
*/
|
|
234
|
+
async hotUpdate({ type, file, read }) {
|
|
235
|
+
if (!pipeline.hasImporter(file)) return;
|
|
236
|
+
if (type === 'delete') {
|
|
237
|
+
pipeline.pruneImporter(file);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
const code = await read();
|
|
241
|
+
const hasFontImport =
|
|
242
|
+
code.includes('@timber/fonts/') ||
|
|
243
|
+
code.includes('@timber-js/app/fonts/') ||
|
|
244
|
+
code.includes('next/font/');
|
|
245
|
+
if (hasFontImport) return;
|
|
246
|
+
// Only prune if the new source parses successfully. A syntax error
|
|
247
|
+
// means the HMR update will fail and the old module stays active —
|
|
248
|
+
// pruning would leave it without font CSS until the next valid save.
|
|
249
|
+
try {
|
|
250
|
+
parseAst(code, { lang: 'tsx' });
|
|
251
|
+
} catch {
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
pipeline.pruneImporter(file);
|
|
255
|
+
},
|
|
256
|
+
|
|
227
257
|
/**
|
|
228
258
|
* Scan source files for font function calls and extract static config.
|
|
229
259
|
*
|
|
@@ -19,23 +19,15 @@ import type { Plugin } from 'vite';
|
|
|
19
19
|
// Listing explicitly silences Vite's "Automatically externalized" warnings.
|
|
20
20
|
const NODE_BUILTINS: string[] = builtinModules.flatMap((m) => [m, `node:${m}`]);
|
|
21
21
|
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
// `virtual:timber-cache-handler` dynamic import) into adapter code, dragging
|
|
26
|
-
// build tooling into the prod server bundle. The most visible symptom is
|
|
27
|
-
// rolldown trying to parse `fsevents.node` as JS and failing with
|
|
28
|
-
// `[UNLOADABLE_DEPENDENCY] … stream did not contain valid UTF-8`.
|
|
22
|
+
// Packages that must never be bundled into the runtime server environments.
|
|
23
|
+
// With `noExternal: true`, rolldown follows all transitive imports. These
|
|
24
|
+
// are build-time-only tools that have no runtime use in a server bundle.
|
|
29
25
|
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
// - `fsevents`, `chokidar` — native/optional deps of
|
|
35
|
-
// rollup/vite. They are macOS-only file watchers with no runtime use
|
|
36
|
-
// in a server bundle.
|
|
26
|
+
// The primary leak path (TIM-988) — `virtual:timber-cache-handler` importing
|
|
27
|
+
// `timber.config.ts` and pulling in adapter/MDX deps — was eliminated by
|
|
28
|
+
// TIM-1211: the cache handler virtual module now imports `timber.cache.ts`
|
|
29
|
+
// instead. These externals are retained as defense-in-depth.
|
|
37
30
|
//
|
|
38
|
-
// Mirrored on both rsc and ssr environments. See TIM-988.
|
|
39
31
|
// Cloudflare runtime modules (cloudflare:workers, cloudflare:sockets, etc.)
|
|
40
32
|
// available at runtime on Workers but must not be bundled.
|
|
41
33
|
const CLOUDFLARE_BUILTINS = [
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* 1. Direct call: action(input) → Promise<ActionResult>
|
|
9
9
|
* 2. React useActionState: (prevState, formData) => Promise<ActionResult>
|
|
10
10
|
*
|
|
11
|
-
* See design/08-forms-and-actions.md §"Middleware
|
|
11
|
+
* See design/08-forms-and-actions.md §"Middleware and Server Actions"
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
// ─── ActionError ─────────────────────────────────────────────────────────
|
|
@@ -57,47 +57,3 @@ export async function runMiddlewareChain(
|
|
|
57
57
|
}
|
|
58
58
|
return undefined;
|
|
59
59
|
}
|
|
60
|
-
|
|
61
|
-
// ─── Per-Request Middleware Bypass ─────────────────────────────────────────
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Per-request marker for synthetic re-render requests that should NOT
|
|
65
|
-
* re-execute `middleware.ts`. The action-dispatch wrapper runs middleware
|
|
66
|
-
* once on the inbound action POST; when validation fails on the no-JS
|
|
67
|
-
* path, it builds a synthetic GET that flows through the normal pipeline
|
|
68
|
-
* to render the page with `getFormFlash()` data. Without this marker, the
|
|
69
|
-
* pipeline would run middleware a second time on that synthetic GET.
|
|
70
|
-
*
|
|
71
|
-
* The set is keyed by the synthetic Request object itself, so the entry
|
|
72
|
-
* lives exactly as long as the request and is garbage-collected with it.
|
|
73
|
-
* Cannot be set or detected by user code — there is no header, no URL
|
|
74
|
-
* parameter, nothing on the wire that an attacker could spoof.
|
|
75
|
-
*
|
|
76
|
-
* See TIM-871.
|
|
77
|
-
*
|
|
78
|
-
* @internal — framework use only.
|
|
79
|
-
*/
|
|
80
|
-
const middlewareBypassRequests = new WeakSet<Request>();
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* Mark a request so the pipeline skips its middleware phase.
|
|
84
|
-
*
|
|
85
|
-
* Used by `wrap-action-dispatch.ts` for the no-JS form-rerender path.
|
|
86
|
-
*
|
|
87
|
-
* @internal
|
|
88
|
-
*/
|
|
89
|
-
export function markRequestBypassMiddleware(req: Request): void {
|
|
90
|
-
middlewareBypassRequests.add(req);
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* Check whether a request was marked to bypass middleware.
|
|
95
|
-
*
|
|
96
|
-
* Called by `handleRequest` in pipeline-phases.ts before invoking the
|
|
97
|
-
* middleware phase. Returns false for any request not explicitly marked.
|
|
98
|
-
*
|
|
99
|
-
* @internal
|
|
100
|
-
*/
|
|
101
|
-
export function shouldBypassMiddleware(req: Request): boolean {
|
|
102
|
-
return middlewareBypassRequests.has(req);
|
|
103
|
-
}
|
|
@@ -132,6 +132,7 @@ export async function coerceSegmentParams(match: RouteMatch): Promise<void> {
|
|
|
132
132
|
if (!codec) continue; // no codec in schema → keep raw string
|
|
133
133
|
|
|
134
134
|
const key = segment.paramName;
|
|
135
|
+
if (!(key in mergeTarget)) continue;
|
|
135
136
|
try {
|
|
136
137
|
mergeTarget[key] = sanitizeParamValue(codec.parse(mergeTarget[key] as string | string[]));
|
|
137
138
|
} catch (err) {
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
import { canonicalize } from './canonicalize.js';
|
|
16
16
|
import { runProxy } from './proxy.js';
|
|
17
|
-
import { runMiddlewareChain
|
|
17
|
+
import { runMiddlewareChain } from './middleware-runner.js';
|
|
18
18
|
import { withTiming } from './server-timing.js';
|
|
19
19
|
import {
|
|
20
20
|
applyRequestHeaderOverlay,
|
|
@@ -66,8 +66,11 @@ function validateInterceptionHeader(raw: string, stripTrailingSlash: boolean): s
|
|
|
66
66
|
// ─── Phase: Proxy ──────────────────────────────────────────────────────────
|
|
67
67
|
|
|
68
68
|
/**
|
|
69
|
-
* Run the proxy.ts phase. Calls user proxy code
|
|
70
|
-
*
|
|
69
|
+
* Run the proxy.ts phase. Calls user proxy code with a `next()` continuation.
|
|
70
|
+
*
|
|
71
|
+
* When `next` is provided, it replaces the default `handleRequest` as the
|
|
72
|
+
* continuation — this lets the pipeline inject action dispatch between proxy
|
|
73
|
+
* and route matching (TIM-1213). The proxy resolver was picked at pipeline
|
|
71
74
|
* construction time so the hot path sees no per-request branching on the
|
|
72
75
|
* `ProxyConfig` discriminant.
|
|
73
76
|
*/
|
|
@@ -76,13 +79,14 @@ export async function runProxyPhase(
|
|
|
76
79
|
getProxy: ProxyResolver,
|
|
77
80
|
req: Request,
|
|
78
81
|
method: string,
|
|
79
|
-
path: string
|
|
82
|
+
path: string,
|
|
83
|
+
next?: () => Promise<Response>
|
|
80
84
|
): Promise<PhaseOutcome> {
|
|
81
85
|
const detailed = config.serverTiming === 'detailed';
|
|
82
86
|
try {
|
|
83
87
|
const proxyExport = await getProxy();
|
|
84
|
-
const
|
|
85
|
-
|
|
88
|
+
const continuation = next ?? (() => handleRequest(config, req, method, path, true));
|
|
89
|
+
const proxyFn = () => runProxy(proxyExport, req, continuation);
|
|
86
90
|
const response = await withSpan('timber.proxy', {}, () =>
|
|
87
91
|
detailed ? withTiming('proxy', 'proxy.ts', proxyFn) : proxyFn()
|
|
88
92
|
);
|
|
@@ -514,14 +518,8 @@ export async function handleRequest(
|
|
|
514
518
|
.join('/') || '/';
|
|
515
519
|
setMatchedSegmentPath(segmentPath.startsWith('/') ? segmentPath : `/${segmentPath}`);
|
|
516
520
|
|
|
517
|
-
// Bypass middleware for synthetic re-render requests built by the
|
|
518
|
-
// action-dispatch wrapper after a no-JS form validation failure.
|
|
519
|
-
// The wrapper has already executed middleware once on the inbound POST;
|
|
520
|
-
// running it again on the rerender GET would double-execute auth, rate
|
|
521
|
-
// limiting, and request-header injection. See TIM-871.
|
|
522
|
-
const skipMiddleware = shouldBypassMiddleware(req);
|
|
523
521
|
const outcome =
|
|
524
|
-
|
|
522
|
+
match.middlewareChain.length > 0
|
|
525
523
|
? await runMiddlewarePhase(config, req, match, responseHeaders, requestHeaderOverlay, {
|
|
526
524
|
canonicalPathname,
|
|
527
525
|
interception,
|