@timber-js/app 0.2.0-alpha.166 → 0.2.0-alpha.168
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-CDPfMp_I.js → actions-O_LsyCE4.js} +3 -3
- package/dist/_chunks/{actions-CDPfMp_I.js.map → actions-O_LsyCE4.js.map} +1 -1
- package/dist/_chunks/{cache-api-DygSeKCB.js → cache-api-B-lhk9p4.js} +107 -58
- package/dist/_chunks/cache-api-B-lhk9p4.js.map +1 -0
- package/dist/_chunks/{cli-schema-sync-EXGYPhI2.js → cli-schema-sync-B73L6pMq.js} +5 -3
- package/dist/_chunks/cli-schema-sync-B73L6pMq.js.map +1 -0
- package/dist/_chunks/cloudflare-AHoWYTYr.js +1188 -0
- package/dist/_chunks/cloudflare-AHoWYTYr.js.map +1 -0
- 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-DBVzXuWc.js → walkers-CoOC8Hga.js} +2 -2
- package/dist/_chunks/{walkers-DBVzXuWc.js.map → walkers-CoOC8Hga.js.map} +1 -1
- package/dist/adapters/cloudflare-dev.js +1 -1
- package/dist/adapters/cloudflare-kv-cache.d.ts.map +1 -1
- package/dist/adapters/cloudflare-kv-cache.js +10 -3
- package/dist/adapters/cloudflare-kv-cache.js.map +1 -1
- package/dist/adapters/cloudflare.d.ts +12 -1
- package/dist/adapters/cloudflare.d.ts.map +1 -1
- package/dist/adapters/cloudflare.js +2 -461
- package/dist/adapters/types.d.ts +2 -0
- package/dist/adapters/types.d.ts.map +1 -1
- package/dist/cache/cache-api.d.ts +33 -11
- 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/post-hydration.d.ts.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 +10 -0
- package/dist/client/history.d.ts.map +1 -1
- package/dist/client/internal.js +141 -116
- 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 +378 -109
- 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/cache.d.ts +8 -8
- package/dist/plugins/cache.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/als-registry.d.ts +7 -0
- package/dist/server/als-registry.d.ts.map +1 -1
- package/dist/server/body-limits.d.ts.map +1 -1
- package/dist/server/deny-boundary.d.ts +3 -1
- package/dist/server/deny-boundary.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 +28 -41
- 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 +9 -0
- 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/rsc-payload.d.ts.map +1 -1
- package/dist/server/rsc-entry/rsc-stream.d.ts +8 -0
- package/dist/server/rsc-entry/rsc-stream.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 +116 -4
- package/docs/api/34-api-config.mdx +0 -26
- package/docs/learn/09-caching.mdx +126 -10
- 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/03-coming-from-nextjs.mdx +2 -2
- package/docs/more/04-metadata-and-fonts.mdx +1 -1
- package/docs/more/50-ai-agent-instructions.mdx +5 -5
- package/package.json +6 -5
- package/src/adapters/cloudflare-kv-cache.ts +27 -6
- package/src/adapters/cloudflare.ts +63 -25
- package/src/adapters/types.ts +2 -0
- package/src/cache/cache-api.ts +84 -84
- 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/post-hydration.ts +16 -9
- package/src/client/browser-entry/router-init.ts +2 -0
- package/src/client/history.ts +11 -0
- package/src/client/router.ts +224 -208
- 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 +132 -13
- package/src/plugins/cache.ts +45 -30
- package/src/plugins/entries.ts +40 -43
- package/src/plugins/fonts.ts +30 -0
- package/src/plugins/server-bundle.ts +7 -15
- package/src/routing/scanner.ts +7 -0
- package/src/server/action-client.ts +1 -1
- package/src/server/als-registry.ts +7 -0
- package/src/server/deny-boundary.ts +6 -3
- 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 +59 -9
- 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 +6 -2
- package/src/server/rsc-entry/rsc-payload.ts +21 -4
- package/src/server/rsc-entry/rsc-stream.ts +8 -0
- package/src/server/rsc-entry/wrap-action-dispatch.ts +109 -372
- package/dist/_chunks/cache-api-DygSeKCB.js.map +0 -1
- package/dist/_chunks/cli-schema-sync-EXGYPhI2.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/dist/adapters/cloudflare.js.map +0 -1
package/src/cache/cache-api.ts
CHANGED
|
@@ -6,102 +6,102 @@ import { recordInvalidation } from './invalidation-epoch';
|
|
|
6
6
|
import { writeTombstonesForTag } from '../server/prebuilt/overlay.js';
|
|
7
7
|
import { getCdnPurgeHandler } from '../cdn/purge-store.js';
|
|
8
8
|
|
|
9
|
+
const componentFallbackWarned = new WeakSet<object>();
|
|
10
|
+
|
|
9
11
|
/**
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* Wraps an async function with cross-request caching. Uses the configured
|
|
13
|
-
* cache handler (defaults to MemoryCacheHandler, overridable via timber.config.ts).
|
|
12
|
+
* Cache namespace — `cache.data`, `cache.component`, `cache.invalidate`.
|
|
14
13
|
*
|
|
15
14
|
* ```ts
|
|
16
15
|
* import { cache } from '@timber-js/app/cache';
|
|
17
16
|
*
|
|
18
|
-
* const getUser = cache(
|
|
17
|
+
* const getUser = cache.data(
|
|
19
18
|
* async (id: string) => db.users.findUnique({ where: { id } }),
|
|
20
19
|
* { ttl: 60, tags: (id) => [`user:${id}`] }
|
|
21
20
|
* );
|
|
22
21
|
* ```
|
|
23
22
|
*/
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
)
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
//
|
|
34
|
-
|
|
35
|
-
|
|
23
|
+
export const cache = {
|
|
24
|
+
/**
|
|
25
|
+
* Wrap an async function with cross-request caching.
|
|
26
|
+
*
|
|
27
|
+
* The configured cache handler (defaults to MemoryCacheHandler, overridable
|
|
28
|
+
* via timber.config.ts) is re-resolved on every call, so module-scope
|
|
29
|
+
* wrappers created before `setCacheHandler()` still pick up the configured
|
|
30
|
+
* handler (TIM-1029).
|
|
31
|
+
*/
|
|
32
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
33
|
+
data<Fn extends (...args: any[]) => Promise<any>>(
|
|
34
|
+
fn: Fn,
|
|
35
|
+
opts: CacheOptions<Fn>,
|
|
36
|
+
stableId?: string
|
|
37
|
+
): Fn {
|
|
38
|
+
return createCache(fn, opts, getCacheHandler, stableId);
|
|
39
|
+
},
|
|
36
40
|
|
|
37
|
-
/**
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
41
|
+
/**
|
|
42
|
+
* Invalidate cache entries by tag or key.
|
|
43
|
+
*
|
|
44
|
+
* ```ts
|
|
45
|
+
* cache.invalidate({ tag: 'products' });
|
|
46
|
+
* cache.invalidate({ key: 'user:abc' });
|
|
47
|
+
* ```
|
|
48
|
+
*/
|
|
49
|
+
async invalidate(opts: { key?: string; tag?: string }): Promise<void> {
|
|
50
|
+
// Record before the handler delete so a cached fn in flight skips its
|
|
51
|
+
// post-resolve set instead of resurrecting the invalidated value (TIM-1028).
|
|
52
|
+
recordInvalidation(opts);
|
|
53
|
+
await getCacheHandler().invalidate(opts);
|
|
54
|
+
// Write tombstones for build-seed entries carrying the tag (design/45
|
|
55
|
+
// §ISR mechanics). Runs AFTER handler.invalidate so the tombstone
|
|
56
|
+
// overwrites the cleared overlay slot. This covers ALL invalidation
|
|
57
|
+
// paths — actions, webhooks, route handlers — not just executeAction.
|
|
58
|
+
if (opts.tag) {
|
|
59
|
+
await writeTombstonesForTag(opts.tag);
|
|
60
|
+
}
|
|
61
|
+
// Trigger CDN purge if a handler is configured (design/25 §Layer 2).
|
|
62
|
+
// Purge is best-effort: a CDN API failure or timeout must not block
|
|
63
|
+
// cache invalidation. 10s ceiling prevents a hung CDN API from stalling
|
|
64
|
+
// the entire revalidateTag/action flow.
|
|
65
|
+
if (opts.tag) {
|
|
66
|
+
const purgeHandler = getCdnPurgeHandler();
|
|
67
|
+
if (purgeHandler) {
|
|
68
|
+
try {
|
|
69
|
+
await Promise.race([
|
|
70
|
+
purgeHandler.purgeTags([opts.tag]),
|
|
71
|
+
new Promise<void>((_, reject) =>
|
|
72
|
+
setTimeout(() => reject(new Error('CDN purge timed out (10s)')), 10_000)
|
|
73
|
+
),
|
|
74
|
+
]);
|
|
75
|
+
} catch (err) {
|
|
76
|
+
console.error('[timber] CDN purge failed for tag:', err);
|
|
77
|
+
}
|
|
73
78
|
}
|
|
74
79
|
}
|
|
75
|
-
}
|
|
76
|
-
};
|
|
77
|
-
|
|
78
|
-
const componentFallbackWarned = new WeakSet<object>();
|
|
80
|
+
},
|
|
79
81
|
|
|
80
|
-
/**
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
}
|
|
106
|
-
return componentFn;
|
|
82
|
+
/**
|
|
83
|
+
* Runtime fallback for `cache.component(...)` callsites the timber-prebuilt
|
|
84
|
+
* transform does not rewrite — nested/computed/argument-position forms (see
|
|
85
|
+
* plugins/prebuilt.ts). Only module-scope `const X = cache.component(...)`
|
|
86
|
+
* declarations are transformed and eligible for prebuilding; everything else
|
|
87
|
+
* lands here, warns once per component, and renders dynamically. Same
|
|
88
|
+
* safety-net pattern as the data cache's runtime fnId fallback (TIM-1054):
|
|
89
|
+
* an untraceable callsite degrades in performance, never in correctness.
|
|
90
|
+
*/
|
|
91
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
92
|
+
component<C extends (props: any) => unknown>(
|
|
93
|
+
componentFn: C,
|
|
94
|
+
_options?: PrebuiltComponentOptions
|
|
95
|
+
): C {
|
|
96
|
+
if (!componentFallbackWarned.has(componentFn)) {
|
|
97
|
+
componentFallbackWarned.add(componentFn);
|
|
98
|
+
const name = componentFn.name || 'anonymous component';
|
|
99
|
+
console.warn(
|
|
100
|
+
`[timber] cache.component: "${name}" was not transformed at build time — ` +
|
|
101
|
+
`rendering dynamically. Only module-scope declarations ` +
|
|
102
|
+
`(const X = cache.component(...)) are prebuilt.`
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
return componentFn;
|
|
106
|
+
},
|
|
107
107
|
};
|
package/src/cache/index.ts
CHANGED
|
@@ -167,7 +167,7 @@ export class MemoryCacheHandler implements CacheHandler {
|
|
|
167
167
|
}
|
|
168
168
|
|
|
169
169
|
export { RedisCacheHandler } from './redis-handler';
|
|
170
|
-
export type { RedisClient } from './redis-handler';
|
|
170
|
+
export type { RedisClient, RedisWriteOp } from './redis-handler';
|
|
171
171
|
export { cache } from './cache-api';
|
|
172
172
|
export { setCacheHandler, getCacheHandler } from './handler-store';
|
|
173
173
|
export { setDefaultSingleflightTimeout } from './timber-cache';
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
const LOSSY_TYPES = ['Date', 'Map', 'Set', 'BigInt'] as const;
|
|
2
|
+
|
|
3
|
+
let hasWarned = false;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Dev-mode warning when a cache value contains types that lose fidelity
|
|
7
|
+
* through JSON round-trip. Only runs in development — no-op in production.
|
|
8
|
+
*
|
|
9
|
+
* Detects: Date (→ ISO string), Map/Set (→ {}), BigInt (→ throws).
|
|
10
|
+
* Emits once per process to avoid log spam.
|
|
11
|
+
*/
|
|
12
|
+
export function warnLossyJsonValue(value: unknown, cacheKey: string): void {
|
|
13
|
+
if (hasWarned) return;
|
|
14
|
+
if (process.env.NODE_ENV === 'production') return;
|
|
15
|
+
|
|
16
|
+
const lossy = findLossyTypes(value, new Set(), 0);
|
|
17
|
+
if (lossy.size === 0) return;
|
|
18
|
+
|
|
19
|
+
hasWarned = true;
|
|
20
|
+
const types = [...lossy].join(', ');
|
|
21
|
+
console.warn(
|
|
22
|
+
`[timber] cache value for key "${cacheKey}" contains ${types} — ` +
|
|
23
|
+
'these types lose fidelity through JSON serialization in Redis/KV cache handlers. ' +
|
|
24
|
+
'Date becomes an ISO string, Map/Set become {}, BigInt throws. ' +
|
|
25
|
+
'The MemoryCacheHandler preserves them. Consider transforming values ' +
|
|
26
|
+
'to JSON-safe types before caching, or use MemoryCacheHandler for ' +
|
|
27
|
+
'values that require type preservation.'
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function findLossyTypes(value: unknown, seen: Set<unknown>, depth: number): Set<string> {
|
|
32
|
+
const lossy = new Set<string>();
|
|
33
|
+
if (depth > 8 || value === null || value === undefined) return lossy;
|
|
34
|
+
if (typeof value === 'bigint') {
|
|
35
|
+
lossy.add('BigInt');
|
|
36
|
+
return lossy;
|
|
37
|
+
}
|
|
38
|
+
if (typeof value !== 'object' && typeof value !== 'function') return lossy;
|
|
39
|
+
if (seen.has(value)) return lossy;
|
|
40
|
+
seen.add(value);
|
|
41
|
+
|
|
42
|
+
if (value instanceof Date) {
|
|
43
|
+
lossy.add('Date');
|
|
44
|
+
return lossy;
|
|
45
|
+
}
|
|
46
|
+
if (value instanceof Map) {
|
|
47
|
+
lossy.add('Map');
|
|
48
|
+
return lossy;
|
|
49
|
+
}
|
|
50
|
+
if (value instanceof Set) {
|
|
51
|
+
lossy.add('Set');
|
|
52
|
+
return lossy;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (Array.isArray(value)) {
|
|
56
|
+
for (const item of value) {
|
|
57
|
+
for (const t of findLossyTypes(item, seen, depth + 1)) lossy.add(t);
|
|
58
|
+
if (lossy.size === LOSSY_TYPES.length) return lossy;
|
|
59
|
+
}
|
|
60
|
+
return lossy;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
for (const k of Object.keys(value as Record<string, unknown>)) {
|
|
64
|
+
for (const t of findLossyTypes((value as Record<string, unknown>)[k], seen, depth + 1)) {
|
|
65
|
+
lossy.add(t);
|
|
66
|
+
}
|
|
67
|
+
if (lossy.size === LOSSY_TYPES.length) return lossy;
|
|
68
|
+
}
|
|
69
|
+
return lossy;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Reset warning state — for tests only. */
|
|
73
|
+
export function _resetLossyWarning(): void {
|
|
74
|
+
hasWarned = false;
|
|
75
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { CacheHandler } from './index';
|
|
2
|
+
import { warnLossyJsonValue } from './json-lossy-check.js';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Minimal Redis client contract for `RedisCacheHandler`.
|
|
@@ -27,6 +28,19 @@ import type { CacheHandler } from './index';
|
|
|
27
28
|
* srem: (k, ...m) => redis.srem(k, ...m),
|
|
28
29
|
* smembers: (k) => redis.smembers(k),
|
|
29
30
|
* ttl: (k) => redis.ttl(k),
|
|
31
|
+
* execMulti: async (ops) => {
|
|
32
|
+
* const p = redis.multi();
|
|
33
|
+
* for (const op of ops) {
|
|
34
|
+
* switch (op.op) {
|
|
35
|
+
* case 'set': p.set(op.key, op.value, 'EX', op.ttlSeconds); break;
|
|
36
|
+
* case 'sadd': p.sadd(op.key, ...op.members); break;
|
|
37
|
+
* case 'expireNX': p.expire(op.key, op.seconds, 'NX'); break;
|
|
38
|
+
* case 'expireGT': p.expire(op.key, op.seconds, 'GT'); break;
|
|
39
|
+
* case 'del': p.del(op.key); break;
|
|
40
|
+
* }
|
|
41
|
+
* }
|
|
42
|
+
* await p.exec();
|
|
43
|
+
* },
|
|
30
44
|
* };
|
|
31
45
|
* ```
|
|
32
46
|
*
|
|
@@ -67,6 +81,13 @@ import type { CacheHandler } from './index';
|
|
|
67
81
|
* };
|
|
68
82
|
* ```
|
|
69
83
|
*/
|
|
84
|
+
export type RedisWriteOp =
|
|
85
|
+
| { op: 'set'; key: string; value: string; ttlSeconds: number }
|
|
86
|
+
| { op: 'sadd'; key: string; members: string[] }
|
|
87
|
+
| { op: 'expireNX'; key: string; seconds: number }
|
|
88
|
+
| { op: 'expireGT'; key: string; seconds: number }
|
|
89
|
+
| { op: 'del'; key: string };
|
|
90
|
+
|
|
70
91
|
export interface RedisClient {
|
|
71
92
|
/** `GET key` — the stored string, or null when absent. */
|
|
72
93
|
get(key: string): Promise<string | null>;
|
|
@@ -99,6 +120,13 @@ export interface RedisClient {
|
|
|
99
120
|
* not exist, -1 if the key exists but has no expiry.
|
|
100
121
|
*/
|
|
101
122
|
ttl(key: string): Promise<number>;
|
|
123
|
+
/**
|
|
124
|
+
* Execute a batch of write operations atomically via `MULTI/EXEC`.
|
|
125
|
+
* When provided, `set()` uses this to make tag-index writes and the data
|
|
126
|
+
* write atomic — no `invalidate({tag})` can interleave. Optional: when
|
|
127
|
+
* absent, operations run sequentially (correct but not race-free).
|
|
128
|
+
*/
|
|
129
|
+
execMulti?(ops: RedisWriteOp[]): Promise<void>;
|
|
102
130
|
}
|
|
103
131
|
|
|
104
132
|
const KEY_PREFIX = 'timber:cache:';
|
|
@@ -146,8 +174,12 @@ export class RedisCacheHandler implements CacheHandler {
|
|
|
146
174
|
value: unknown,
|
|
147
175
|
opts: { ttl: number; tags: string[]; generation?: number }
|
|
148
176
|
): Promise<void> {
|
|
177
|
+
warnLossyJsonValue(value, key);
|
|
149
178
|
const ck = this.cacheKey(key);
|
|
150
179
|
const expiresAt = Date.now() + opts.ttl * 1000;
|
|
180
|
+
|
|
181
|
+
// Serialize upfront so BigInt/non-serializable errors throw before any
|
|
182
|
+
// writes — prevents tag indexes pointing at stale data from a prior set().
|
|
151
183
|
const payload = JSON.stringify({ value, expiresAt, tags: opts.tags });
|
|
152
184
|
|
|
153
185
|
// Redis TTL with generous margin beyond the logical TTL to allow SWR reads
|
|
@@ -155,22 +187,41 @@ export class RedisCacheHandler implements CacheHandler {
|
|
|
155
187
|
// We use 2x TTL + 60s as the Redis expiry so stale entries remain
|
|
156
188
|
// available for SWR background refetches.
|
|
157
189
|
const redisTtlSeconds = Math.max(opts.ttl * 2 + 60, 120);
|
|
158
|
-
await this.client.set(ck, payload, redisTtlSeconds);
|
|
159
190
|
|
|
160
|
-
|
|
161
|
-
// outlive all its members. Only extend the TTL, never shorten — a short-TTL
|
|
162
|
-
// entry must not shrink a tag set that already contains long-TTL entries,
|
|
163
|
-
// otherwise invalidate({tag}) would miss the long-lived entries.
|
|
191
|
+
const writeOps: RedisWriteOp[] = [];
|
|
164
192
|
for (const tag of opts.tags) {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
await this.client.
|
|
173
|
-
|
|
193
|
+
writeOps.push({ op: 'sadd', key: this.tagKey(tag), members: [key] });
|
|
194
|
+
writeOps.push({ op: 'expireNX', key: this.tagKey(tag), seconds: redisTtlSeconds });
|
|
195
|
+
writeOps.push({ op: 'expireGT', key: this.tagKey(tag), seconds: redisTtlSeconds });
|
|
196
|
+
}
|
|
197
|
+
writeOps.push({ op: 'set', key: ck, value: payload, ttlSeconds: redisTtlSeconds });
|
|
198
|
+
|
|
199
|
+
if (this.client.execMulti) {
|
|
200
|
+
await this.client.execMulti(writeOps);
|
|
201
|
+
} else {
|
|
202
|
+
for (const op of writeOps) {
|
|
203
|
+
await this.execOp(op);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private async execOp(op: RedisWriteOp): Promise<void> {
|
|
209
|
+
switch (op.op) {
|
|
210
|
+
case 'set':
|
|
211
|
+
await this.client.set(op.key, op.value, op.ttlSeconds);
|
|
212
|
+
break;
|
|
213
|
+
case 'sadd':
|
|
214
|
+
await this.client.sadd(op.key, ...op.members);
|
|
215
|
+
break;
|
|
216
|
+
case 'expireNX':
|
|
217
|
+
await this.client.expireNX(op.key, op.seconds);
|
|
218
|
+
break;
|
|
219
|
+
case 'expireGT':
|
|
220
|
+
await this.client.expireGT(op.key, op.seconds);
|
|
221
|
+
break;
|
|
222
|
+
case 'del':
|
|
223
|
+
await this.client.del(op.key);
|
|
224
|
+
break;
|
|
174
225
|
}
|
|
175
226
|
}
|
|
176
227
|
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
wasInvalidatedSince,
|
|
8
8
|
} from './invalidation-epoch';
|
|
9
9
|
import { addSpanEventSync } from '../server/tracing.js';
|
|
10
|
-
import { logSwrRefetchFailed } from '../server/logger.js';
|
|
10
|
+
import { logSwrRefetchFailed, swallow } from '../server/logger.js';
|
|
11
11
|
import { getWaitUntil } from '../server/waituntil-bridge.js';
|
|
12
12
|
import { fnv1aHash } from './fast-hash.js';
|
|
13
13
|
|
|
@@ -189,7 +189,15 @@ export function createCache<Fn extends (...args: any[]) => Promise<any>>(
|
|
|
189
189
|
const generation = nextGeneration(key);
|
|
190
190
|
const result = await fn(...args);
|
|
191
191
|
if (!signal.aborted && !wasInvalidatedSince(startEpoch, key, tags)) {
|
|
192
|
-
|
|
192
|
+
try {
|
|
193
|
+
await getHandler().set(key, result, { ttl: opts.ttl, tags, generation });
|
|
194
|
+
} catch (err) {
|
|
195
|
+
// Cache-write failure must not fail the request (TIM-1046).
|
|
196
|
+
// JSON-serializing handlers (Redis, KV) throw on BigInt; network
|
|
197
|
+
// errors can surface from any shared handler. The result is valid
|
|
198
|
+
// — serve it uncached rather than rejecting a successful fn().
|
|
199
|
+
swallow(err, `cache set failed for key "${key}"`);
|
|
200
|
+
}
|
|
193
201
|
}
|
|
194
202
|
return result;
|
|
195
203
|
};
|
|
@@ -76,6 +76,8 @@ function bootstrap(runtimeConfig: typeof config): void {
|
|
|
76
76
|
// Step 3: Create router + Navigation API integration
|
|
77
77
|
const { router, navApiController } = createTimberRouter({
|
|
78
78
|
initialElement: rscResult?.element ?? undefined,
|
|
79
|
+
clientSegmentCache:
|
|
80
|
+
((runtimeConfig as Record<string, unknown>).clientSegmentCache as boolean) ?? false,
|
|
79
81
|
});
|
|
80
82
|
|
|
81
83
|
// Step 4: Pre-hydration — set params + navigation state (MUST run after router init)
|
|
@@ -38,9 +38,25 @@ export function setupPostHydration({
|
|
|
38
38
|
// runPreHydration() — without them on the entry, the cached replay on
|
|
39
39
|
// back-navigation would normalize params to {} and wipe every
|
|
40
40
|
// useSegmentParams() consumer on the initial page (TIM-1037).
|
|
41
|
+
// Populate the segment cache from server-embedded segment metadata.
|
|
42
|
+
// This enables state tree diffing from the very first client navigation.
|
|
43
|
+
// See design/19-client-navigation.md §"X-Timber-State-Tree Header"
|
|
44
|
+
const timberSegments = (self as unknown as Record<string, unknown>).__timber_segments;
|
|
45
|
+
const initialSegmentInfo = Array.isArray(timberSegments)
|
|
46
|
+
? (timberSegments as import('../segment-cache').SegmentInfo[])
|
|
47
|
+
: null;
|
|
48
|
+
if (initialSegmentInfo) {
|
|
49
|
+
router.initSegmentCache(initialSegmentInfo);
|
|
50
|
+
delete (self as unknown as Record<string, unknown>).__timber_segments;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Store the initial page in the history stack so back-button works
|
|
54
|
+
// after the first navigation. Include segmentInfo so the segment
|
|
55
|
+
// cache is restored correctly on popstate cached replay.
|
|
41
56
|
router.historyStack.push(window.location.pathname + window.location.search, {
|
|
42
57
|
payload: initialElement,
|
|
43
58
|
params: getNavigationState().params,
|
|
59
|
+
segmentInfo: initialSegmentInfo,
|
|
44
60
|
});
|
|
45
61
|
|
|
46
62
|
// Initialize scroll state for the initial entry.
|
|
@@ -52,15 +68,6 @@ export function setupPostHydration({
|
|
|
52
68
|
window.history.replaceState({ timber: true, scrollY: 0 }, '');
|
|
53
69
|
}
|
|
54
70
|
|
|
55
|
-
// Populate the segment cache from server-embedded segment metadata.
|
|
56
|
-
// This enables state tree diffing from the very first client navigation.
|
|
57
|
-
// See design/19-client-navigation.md §"X-Timber-State-Tree Header"
|
|
58
|
-
const timberSegments = (self as unknown as Record<string, unknown>).__timber_segments;
|
|
59
|
-
if (Array.isArray(timberSegments)) {
|
|
60
|
-
router.initSegmentCache(timberSegments);
|
|
61
|
-
delete (self as unknown as Record<string, unknown>).__timber_segments;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
71
|
// Register popstate handler for back/forward navigation.
|
|
65
72
|
// When Navigation API is active, the navigate event covers traversals —
|
|
66
73
|
// popstate is a no-op. When unavailable, popstate handles back/forward.
|
|
@@ -29,6 +29,7 @@ import { getScrollY, scrollToHashTarget } from './scroll.js';
|
|
|
29
29
|
|
|
30
30
|
export interface RouterInitOptions {
|
|
31
31
|
initialElement?: unknown;
|
|
32
|
+
clientSegmentCache?: boolean;
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
export interface RouterInitResult {
|
|
@@ -136,6 +137,7 @@ export function createTimberRouter(options?: RouterInitOptions): RouterInitResul
|
|
|
136
137
|
};
|
|
137
138
|
|
|
138
139
|
const deps: RouterDeps = {
|
|
140
|
+
clientSegmentCache: options?.clientSegmentCache ?? false,
|
|
139
141
|
fetch: (url, init) => window.fetch(url, init),
|
|
140
142
|
pushState: (data, unused, url) => {
|
|
141
143
|
gFlags[ROUTER_HISTORY_FLAG] = true;
|
package/src/client/history.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// History Stack — stores RSC payloads by URL for instant back/forward navigation
|
|
2
2
|
// See design/19-client-navigation.md § History Stack
|
|
3
3
|
|
|
4
|
+
import type { SegmentInfo } from './segment-cache';
|
|
5
|
+
|
|
4
6
|
// ─── Types ───────────────────────────────────────────────────────
|
|
5
7
|
|
|
6
8
|
export interface HistoryEntry {
|
|
@@ -13,6 +15,15 @@ export interface HistoryEntry {
|
|
|
13
15
|
* and revalidation overwrites preserve the current state (TIM-1037).
|
|
14
16
|
*/
|
|
15
17
|
params?: Record<string, string | string[]> | null;
|
|
18
|
+
/**
|
|
19
|
+
* Segment metadata for this page's route. Restored into the segment cache
|
|
20
|
+
* on popstate cached replay so the next forward navigation computes a
|
|
21
|
+
* correct state tree. Without this, the segment cache retains the
|
|
22
|
+
* *previous* page's segments after back-button, causing the server to
|
|
23
|
+
* skip segments that aren't mounted — and the partial payload targets
|
|
24
|
+
* a non-existent outlet.
|
|
25
|
+
*/
|
|
26
|
+
segmentInfo?: SegmentInfo[] | null;
|
|
16
27
|
}
|
|
17
28
|
|
|
18
29
|
// ─── History Stack ───────────────────────────────────────────────
|