@timber-js/app 0.2.0-alpha.157 → 0.2.0-alpha.159
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-CqE7SW7E.js → actions-CBbP1hHO.js} +22 -6
- package/dist/_chunks/actions-CBbP1hHO.js.map +1 -0
- package/dist/_chunks/{build-output-helper-C8q4Zpf5.js → build-output-helper-DF5uScqS.js} +4 -1
- package/dist/_chunks/{build-output-helper-C8q4Zpf5.js.map → build-output-helper-DF5uScqS.js.map} +1 -1
- package/dist/_chunks/cache-api-DsWEYwVZ.js +1120 -0
- package/dist/_chunks/cache-api-DsWEYwVZ.js.map +1 -0
- package/dist/_chunks/{cli-schema-sync-DcmQrzJG.js → cli-schema-sync-Bivx4Rn7.js} +4 -3
- package/dist/_chunks/{cli-schema-sync-DcmQrzJG.js.map → cli-schema-sync-Bivx4Rn7.js.map} +1 -1
- package/dist/_chunks/dist-BA3u1z3W.js +423 -0
- package/dist/_chunks/dist-BA3u1z3W.js.map +1 -0
- package/dist/_chunks/{logger-C5v1IMuh.js → logger-CrRmZMlo.js} +81 -56
- package/dist/_chunks/logger-CrRmZMlo.js.map +1 -0
- package/dist/_chunks/{plugin-context-DUl3dnCF.js → plugin-context-DeAxFRMq.js} +3 -36
- package/dist/_chunks/plugin-context-DeAxFRMq.js.map +1 -0
- package/dist/_chunks/sizeof-lXffQ1Vr.js +35 -0
- package/dist/_chunks/sizeof-lXffQ1Vr.js.map +1 -0
- package/dist/_chunks/{walkers-D5jCGPum.js → walkers-DXbJkeCY.js} +217 -19
- package/dist/_chunks/walkers-DXbJkeCY.js.map +1 -0
- package/dist/adapters/build-output-helper.d.ts.map +1 -1
- package/dist/adapters/cloudflare.d.ts +28 -0
- package/dist/adapters/cloudflare.d.ts.map +1 -1
- package/dist/adapters/cloudflare.js +143 -9
- package/dist/adapters/cloudflare.js.map +1 -1
- package/dist/adapters/nitro.js +1 -1
- package/dist/cache/cache-api.d.ts +2 -0
- package/dist/cache/cache-api.d.ts.map +1 -1
- package/dist/cache/handler-store.d.ts +2 -0
- package/dist/cache/handler-store.d.ts.map +1 -1
- package/dist/cache/index.d.ts +2 -0
- package/dist/cache/index.d.ts.map +1 -1
- package/dist/cache/index.js +3 -186
- package/dist/cache/redis-handler.d.ts +17 -5
- package/dist/cache/redis-handler.d.ts.map +1 -1
- package/dist/cache/singleflight.d.ts +1 -1
- package/dist/cache/singleflight.d.ts.map +1 -1
- package/dist/cache/store.d.ts +53 -0
- package/dist/cache/store.d.ts.map +1 -0
- package/dist/cache/stores/cloudflare-kv.d.ts +38 -0
- package/dist/cache/stores/cloudflare-kv.d.ts.map +1 -0
- package/dist/cache/stores/cloudflare-kv.js +29 -0
- package/dist/cache/stores/cloudflare-kv.js.map +1 -0
- package/dist/cache/stores/memory.d.ts +42 -0
- package/dist/cache/stores/memory.d.ts.map +1 -0
- package/dist/cache/stores/memory.js +90 -0
- package/dist/cache/stores/memory.js.map +1 -0
- package/dist/cache/stores/redis.d.ts +69 -0
- package/dist/cache/stores/redis.d.ts.map +1 -0
- package/dist/cache/stores/redis.js +35 -0
- package/dist/cache/stores/redis.js.map +1 -0
- package/dist/cache/stores/vercel.d.ts +36 -0
- package/dist/cache/stores/vercel.d.ts.map +1 -0
- package/dist/cache/stores/vercel.js +33 -0
- package/dist/cache/stores/vercel.js.map +1 -0
- package/dist/cache/tag-aware-handler.d.ts +51 -0
- package/dist/cache/tag-aware-handler.d.ts.map +1 -0
- package/dist/cache/timber-cache.d.ts.map +1 -1
- package/dist/cdn/cloudflare-purge.d.ts +23 -0
- package/dist/cdn/cloudflare-purge.d.ts.map +1 -0
- package/dist/cdn/fastly-purge.d.ts +25 -0
- package/dist/cdn/fastly-purge.d.ts.map +1 -0
- package/dist/cdn/headers.d.ts +82 -0
- package/dist/cdn/headers.d.ts.map +1 -0
- package/dist/cdn/index.d.ts +5 -0
- package/dist/cdn/index.d.ts.map +1 -0
- package/dist/cdn/purge-store.d.ts +23 -0
- package/dist/cdn/purge-store.d.ts.map +1 -0
- package/dist/cli.js +2 -2
- package/dist/client/browser-entry/action-dispatch.d.ts.map +1 -1
- package/dist/client/browser-entry/post-hydration.d.ts.map +1 -1
- package/dist/client/browser-entry/router-init.d.ts.map +1 -1
- package/dist/client/history.d.ts +0 -3
- package/dist/client/history.d.ts.map +1 -1
- package/dist/client/index.js +0 -1
- package/dist/client/index.js.map +1 -1
- package/dist/client/internal.js +11 -86
- package/dist/client/internal.js.map +1 -1
- package/dist/client/navigation-api.d.ts +1 -3
- package/dist/client/navigation-api.d.ts.map +1 -1
- package/dist/client/router.d.ts +1 -4
- package/dist/client/router.d.ts.map +1 -1
- package/dist/client/rsc-fetch.d.ts +1 -11
- package/dist/client/rsc-fetch.d.ts.map +1 -1
- package/dist/client/segment-cache.d.ts +1 -3
- package/dist/client/segment-cache.d.ts.map +1 -1
- package/dist/client/use-cookie.d.ts.map +1 -1
- package/dist/config-types.d.ts +3 -1
- package/dist/config-types.d.ts.map +1 -1
- package/dist/config-validation.d.ts.map +1 -1
- package/dist/cookies/index.js +15 -11
- package/dist/cookies/index.js.map +1 -1
- package/dist/dev-tools/browser-logs.d.ts +28 -72
- package/dist/dev-tools/browser-logs.d.ts.map +1 -1
- package/dist/dev-tools/index.d.ts +1 -1
- package/dist/dev-tools/index.d.ts.map +1 -1
- package/dist/dev-tools/logger.d.ts.map +1 -1
- package/dist/dev-tools/terminal.d.ts.map +1 -1
- package/dist/fonts/transform.d.ts +4 -0
- package/dist/fonts/transform.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1269 -359
- package/dist/index.js.map +1 -1
- package/dist/plugin-context.d.ts +36 -0
- package/dist/plugin-context.d.ts.map +1 -1
- package/dist/plugins/build-report.d.ts +21 -0
- package/dist/plugins/build-report.d.ts.map +1 -1
- package/dist/plugins/cache.d.ts +5 -14
- package/dist/plugins/cache.d.ts.map +1 -1
- package/dist/plugins/callsite-ast.d.ts +95 -0
- package/dist/plugins/callsite-ast.d.ts.map +1 -0
- package/dist/plugins/callsite-jsx.d.ts +35 -0
- package/dist/plugins/callsite-jsx.d.ts.map +1 -0
- package/dist/plugins/entries.d.ts.map +1 -1
- package/dist/plugins/fonts.d.ts.map +1 -1
- package/dist/plugins/prebuilt-capture.d.ts +60 -0
- package/dist/plugins/prebuilt-capture.d.ts.map +1 -0
- package/dist/plugins/prebuilt.d.ts +82 -0
- package/dist/plugins/prebuilt.d.ts.map +1 -0
- package/dist/plugins/prerender-sugar.d.ts +42 -0
- package/dist/plugins/prerender-sugar.d.ts.map +1 -0
- package/dist/plugins/server-bundle.d.ts.map +1 -1
- package/dist/plugins/shims.d.ts.map +1 -1
- package/dist/plugins/static-build.d.ts.map +1 -1
- package/dist/routing/convention-lint.d.ts.map +1 -1
- package/dist/routing/export-detect.d.ts +56 -0
- package/dist/routing/export-detect.d.ts.map +1 -0
- package/dist/routing/index.js +2 -2
- package/dist/routing/manifest-codegen.d.ts.map +1 -1
- package/dist/rsc-runtime/rsc.d.ts +1 -1
- package/dist/rsc-runtime/rsc.d.ts.map +1 -1
- package/dist/server/action-handler.d.ts.map +1 -1
- package/dist/server/actions.d.ts +0 -3
- package/dist/server/actions.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/cookie-parsing.d.ts +15 -1
- package/dist/server/cookie-parsing.d.ts.map +1 -1
- package/dist/server/deny-renderer.d.ts.map +1 -1
- package/dist/server/form-data.d.ts +12 -0
- package/dist/server/form-data.d.ts.map +1 -1
- package/dist/server/html-injectors.d.ts +1 -0
- package/dist/server/html-injectors.d.ts.map +1 -1
- package/dist/server/index.js +2 -2
- package/dist/server/internal.d.ts +6 -1
- package/dist/server/internal.d.ts.map +1 -1
- package/dist/server/internal.js +563 -74
- package/dist/server/internal.js.map +1 -1
- package/dist/server/prebuilt/cache-key.d.ts +58 -0
- package/dist/server/prebuilt/cache-key.d.ts.map +1 -0
- package/dist/server/prebuilt/capture-state.d.ts +57 -0
- package/dist/server/prebuilt/capture-state.d.ts.map +1 -0
- package/dist/server/prebuilt/manifest-tag-index.d.ts +27 -0
- package/dist/server/prebuilt/manifest-tag-index.d.ts.map +1 -0
- package/dist/server/prebuilt/overlay.d.ts +100 -0
- package/dist/server/prebuilt/overlay.d.ts.map +1 -0
- package/dist/server/prebuilt/payload-source.d.ts +75 -0
- package/dist/server/prebuilt/payload-source.d.ts.map +1 -0
- package/dist/server/prebuilt/synthetic-store.d.ts +64 -0
- package/dist/server/prebuilt/synthetic-store.d.ts.map +1 -0
- package/dist/server/prebuilt-builder.d.ts +110 -0
- package/dist/server/prebuilt-builder.d.ts.map +1 -0
- package/dist/server/prebuilt-runtime.d.ts +101 -0
- package/dist/server/prebuilt-runtime.d.ts.map +1 -0
- package/dist/server/route-element-builder.d.ts +10 -8
- package/dist/server/route-element-builder.d.ts.map +1 -1
- package/dist/server/rsc-entry/helpers.d.ts +0 -5
- package/dist/server/rsc-entry/helpers.d.ts.map +1 -1
- package/dist/server/rsc-entry/index.d.ts +10 -0
- package/dist/server/rsc-entry/index.d.ts.map +1 -1
- 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 +3 -4
- package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
- package/dist/server/sitemap-generator.d.ts +6 -6
- package/dist/server/sitemap-generator.d.ts.map +1 -1
- package/dist/server/tracing.d.ts +37 -3
- package/dist/server/tracing.d.ts.map +1 -1
- package/docs/api/32-api-cache.mdx +10 -4
- package/docs/learn/00-introduction.mdx +8 -13
- package/docs/learn/02-pages-and-layouts.mdx +9 -0
- package/docs/learn/06-forms-and-actions.mdx +0 -4
- package/docs/learn/07-typed-routes.mdx +127 -0
- package/docs/learn/09-caching.mdx +12 -1
- package/docs/learn/10-middleware.mdx +1 -0
- package/docs/learn/11-error-handling.mdx +9 -0
- package/docs/learn/13-configuration.mdx +28 -0
- package/docs/learn/14-deploying.mdx +52 -0
- package/docs/more/06-instrumentation.mdx +27 -1
- package/docs/more/07-security.mdx +8 -2
- package/docs/more/08-developer-experience.mdx +134 -0
- package/package.json +31 -2
- package/src/adapters/build-output-helper.ts +10 -0
- package/src/adapters/cloudflare.ts +178 -4
- package/src/cache/cache-api.ts +60 -0
- package/src/cache/handler-store.ts +8 -0
- package/src/cache/index.ts +4 -0
- package/src/cache/redis-handler.ts +25 -13
- package/src/cache/singleflight.ts +11 -11
- package/src/cache/store.ts +71 -0
- package/src/cache/stores/cloudflare-kv.ts +52 -0
- package/src/cache/stores/memory.ts +125 -0
- package/src/cache/stores/redis.ts +97 -0
- package/src/cache/stores/vercel.ts +59 -0
- package/src/cache/tag-aware-handler.ts +233 -0
- package/src/cache/timber-cache.ts +17 -7
- package/src/cdn/cloudflare-purge.ts +50 -0
- package/src/cdn/fastly-purge.ts +63 -0
- package/src/cdn/headers.ts +127 -0
- package/src/cdn/index.ts +16 -0
- package/src/cdn/purge-store.ts +34 -0
- package/src/client/browser-entry/action-dispatch.ts +1 -10
- package/src/client/browser-entry/post-hydration.ts +0 -1
- package/src/client/browser-entry/router-init.ts +0 -5
- package/src/client/history.ts +0 -4
- package/src/client/navigation-api.ts +5 -11
- package/src/client/router.ts +9 -43
- package/src/client/rsc-fetch.ts +5 -27
- package/src/client/segment-cache.ts +1 -4
- package/src/client/use-cookie.ts +18 -12
- package/src/config-types.ts +3 -1
- package/src/config-validation.ts +1 -0
- package/src/dev-tools/browser-logs.ts +37 -273
- package/src/dev-tools/index.ts +2 -2
- package/src/dev-tools/instrumentation.ts +1 -1
- package/src/dev-tools/logger.ts +33 -51
- package/src/dev-tools/terminal.ts +21 -22
- package/src/fonts/transform.ts +4 -43
- package/src/index.ts +16 -3
- package/src/plugin-context.ts +36 -0
- package/src/plugins/build-report.ts +133 -1
- package/src/plugins/cache.ts +32 -131
- package/src/plugins/callsite-ast.ts +167 -0
- package/src/plugins/callsite-jsx.ts +243 -0
- package/src/plugins/entries.ts +23 -4
- package/src/plugins/fonts.ts +16 -2
- package/src/plugins/prebuilt-capture.ts +208 -0
- package/src/plugins/prebuilt.ts +673 -0
- package/src/plugins/prerender-sugar.ts +329 -0
- package/src/plugins/server-bundle.ts +12 -0
- package/src/plugins/shims.ts +9 -0
- package/src/plugins/static-build.ts +0 -1
- package/src/routing/codegen.ts +1 -21
- package/src/routing/convention-lint.ts +85 -49
- package/src/routing/export-detect.ts +304 -0
- package/src/routing/manifest-codegen.ts +2 -11
- package/src/rsc-runtime/rsc.ts +7 -0
- package/src/server/action-handler.ts +11 -27
- package/src/server/actions.ts +3 -4
- package/src/server/als-registry.ts +7 -0
- package/src/server/cookie-parsing.ts +75 -59
- package/src/server/deny-renderer.ts +7 -15
- package/src/server/form-data.ts +23 -7
- package/src/server/html-injectors.ts +11 -3
- package/src/server/internal.ts +17 -1
- package/src/server/prebuilt/cache-key.ts +224 -0
- package/src/server/prebuilt/capture-state.ts +89 -0
- package/src/server/prebuilt/manifest-tag-index.ts +46 -0
- package/src/server/prebuilt/overlay.ts +256 -0
- package/src/server/prebuilt/payload-source.ts +192 -0
- package/src/server/prebuilt/synthetic-store.ts +108 -0
- package/src/server/prebuilt-builder.ts +640 -0
- package/src/server/prebuilt-runtime.ts +706 -0
- package/src/server/route-element-builder.ts +40 -12
- package/src/server/rsc-entry/helpers.ts +0 -17
- package/src/server/rsc-entry/index.ts +83 -0
- package/src/server/rsc-entry/render-route.ts +6 -33
- package/src/server/rsc-entry/revalidate-renderer.ts +16 -11
- package/src/server/rsc-entry/rsc-payload.ts +5 -11
- package/src/server/sitemap-generator.ts +16 -10
- package/src/server/tracing.ts +88 -3
- package/dist/_chunks/actions-CqE7SW7E.js.map +0 -1
- package/dist/_chunks/cache-api-Cbyh3RW4.js +0 -401
- package/dist/_chunks/cache-api-Cbyh3RW4.js.map +0 -1
- package/dist/_chunks/logger-C5v1IMuh.js.map +0 -1
- package/dist/_chunks/plugin-context-DUl3dnCF.js.map +0 -1
- package/dist/_chunks/validation-CnKhGJsp.js +0 -113
- package/dist/_chunks/validation-CnKhGJsp.js.map +0 -1
- package/dist/_chunks/walkers-D5jCGPum.js.map +0 -1
- package/dist/cache/index.js.map +0 -1
- package/dist/client/head.d.ts +0 -16
- package/dist/client/head.d.ts.map +0 -1
- package/dist/client/navigation-api-types.d.ts +0 -90
- package/dist/client/navigation-api-types.d.ts.map +0 -1
- package/dist/server/client-module-map.d.ts +0 -47
- package/dist/server/client-module-map.d.ts.map +0 -1
- package/dist/shared/head-header.d.ts +0 -54
- package/dist/shared/head-header.d.ts.map +0 -1
- package/src/client/head.ts +0 -61
- package/src/client/navigation-api-types.ts +0 -112
- package/src/server/client-module-map.ts +0 -58
- package/src/shared/head-header.ts +0 -69
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TagAwareCacheHandler — wraps any CacheStore and adds tag-based invalidation.
|
|
3
|
+
*
|
|
4
|
+
* Three strategies, selected by store capabilities (best available wins):
|
|
5
|
+
*
|
|
6
|
+
* 1. **Native** (store has setWithTags + invalidateTag): Delegates entirely.
|
|
7
|
+
* The store owns tag indexing. Used for Vercel Runtime Cache, DO-backed
|
|
8
|
+
* stores, or any platform with first-class tag support.
|
|
9
|
+
*
|
|
10
|
+
* 2. **Eager** (store has sadd/smembers/srem/sdel): Maintains an inverted
|
|
11
|
+
* index via atomic set operations. Invalidation enumerates the set and
|
|
12
|
+
* deletes entries. Used for Redis.
|
|
13
|
+
*
|
|
14
|
+
* 3. **Lazy** (bare get/set/del only): Version-stamps entries with tag
|
|
15
|
+
* counters. Invalidation bumps a counter (O(1)). Reads validate stored
|
|
16
|
+
* versions against current. Race-free, correct under eventual consistency.
|
|
17
|
+
* Used for Cloudflare KV.
|
|
18
|
+
*
|
|
19
|
+
* Implements the CacheHandler interface consumed by timber-cache.ts.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { CacheHandler } from './index';
|
|
23
|
+
import type { CacheStore } from './store';
|
|
24
|
+
|
|
25
|
+
const DATA_PREFIX = 'd:';
|
|
26
|
+
const TAG_SET_PREFIX = 'ts:';
|
|
27
|
+
const TAG_VERSION_PREFIX = 'tv:';
|
|
28
|
+
|
|
29
|
+
type Strategy = 'native' | 'eager' | 'lazy';
|
|
30
|
+
|
|
31
|
+
/** Stored entry format for eager strategy. */
|
|
32
|
+
interface EagerEntry {
|
|
33
|
+
value: unknown;
|
|
34
|
+
expiresAt: number;
|
|
35
|
+
tags: string[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Stored entry format for lazy (version-stamp) strategy. */
|
|
39
|
+
interface LazyEntry {
|
|
40
|
+
value: unknown;
|
|
41
|
+
expiresAt: number;
|
|
42
|
+
tags: string[];
|
|
43
|
+
tagVersions: Record<string, number>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function detectStrategy(store: CacheStore): Strategy {
|
|
47
|
+
if (store.setWithTags && store.invalidateTag) return 'native';
|
|
48
|
+
if (store.sadd && store.smembers && store.srem && store.sdel) return 'eager';
|
|
49
|
+
return 'lazy';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export class TagAwareCacheHandler implements CacheHandler {
|
|
53
|
+
private store: CacheStore;
|
|
54
|
+
private prefix: string;
|
|
55
|
+
private strategy: Strategy;
|
|
56
|
+
|
|
57
|
+
constructor(store: CacheStore, opts?: { prefix?: string }) {
|
|
58
|
+
this.store = store;
|
|
59
|
+
this.prefix = opts?.prefix ?? 'timber:';
|
|
60
|
+
this.strategy = detectStrategy(store);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
get consistency(): 'strong' | 'eventual' | 'local' {
|
|
64
|
+
return this.store.consistency;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
private dataKey(key: string): string {
|
|
68
|
+
return `${this.prefix}${DATA_PREFIX}${key}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Namespace tags for native stores so prefix isolates shared stores. */
|
|
72
|
+
private prefixTags(tags: string[]): string[] {
|
|
73
|
+
return tags.map((t) => `${this.prefix}${t}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private tagSetKey(tag: string): string {
|
|
77
|
+
return `${this.prefix}${TAG_SET_PREFIX}${tag}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private tagVersionKey(tag: string): string {
|
|
81
|
+
return `${this.prefix}${TAG_VERSION_PREFIX}${tag}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async get(key: string): Promise<{ value: unknown; stale: boolean } | null> {
|
|
85
|
+
const raw = await this.store.get(this.dataKey(key));
|
|
86
|
+
if (raw === null) return null;
|
|
87
|
+
|
|
88
|
+
if (this.strategy === 'native' || this.strategy === 'eager') {
|
|
89
|
+
const entry = JSON.parse(raw) as EagerEntry;
|
|
90
|
+
const stale = Date.now() > entry.expiresAt;
|
|
91
|
+
return { value: entry.value, stale };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Lazy: validate tag versions before returning
|
|
95
|
+
const entry = JSON.parse(raw) as LazyEntry;
|
|
96
|
+
if (entry.tags.length > 0) {
|
|
97
|
+
const currentVersions = await this.getTagVersions(entry.tags);
|
|
98
|
+
for (const tag of entry.tags) {
|
|
99
|
+
const stored = entry.tagVersions[tag] ?? 0;
|
|
100
|
+
const current = currentVersions[tag] ?? 0;
|
|
101
|
+
if (current > stored) return null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const stale = Date.now() > entry.expiresAt;
|
|
105
|
+
return { value: entry.value, stale };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async set(key: string, value: unknown, opts: { ttl: number; tags: string[] }): Promise<void> {
|
|
109
|
+
const physicalTtl = Math.max(opts.ttl * 2 + 60, 120);
|
|
110
|
+
|
|
111
|
+
if (this.strategy === 'native') {
|
|
112
|
+
const entry: EagerEntry = {
|
|
113
|
+
value,
|
|
114
|
+
expiresAt: Date.now() + opts.ttl * 1000,
|
|
115
|
+
tags: opts.tags,
|
|
116
|
+
};
|
|
117
|
+
const payload = JSON.stringify(entry);
|
|
118
|
+
if (opts.tags.length > 0) {
|
|
119
|
+
await this.store.setWithTags!(
|
|
120
|
+
this.dataKey(key),
|
|
121
|
+
payload,
|
|
122
|
+
physicalTtl,
|
|
123
|
+
this.prefixTags(opts.tags)
|
|
124
|
+
);
|
|
125
|
+
} else {
|
|
126
|
+
await this.store.set(this.dataKey(key), payload, physicalTtl);
|
|
127
|
+
}
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (this.strategy === 'eager') {
|
|
132
|
+
const entry: EagerEntry = {
|
|
133
|
+
value,
|
|
134
|
+
expiresAt: Date.now() + opts.ttl * 1000,
|
|
135
|
+
tags: opts.tags,
|
|
136
|
+
};
|
|
137
|
+
await this.store.set(this.dataKey(key), JSON.stringify(entry), physicalTtl);
|
|
138
|
+
if (opts.tags.length > 0) {
|
|
139
|
+
await Promise.all(
|
|
140
|
+
opts.tags.map((tag) => this.store.sadd!(this.tagSetKey(tag), [key], physicalTtl))
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Lazy: snapshot current tag versions into the entry
|
|
147
|
+
const tagVersions = opts.tags.length > 0 ? await this.getTagVersions(opts.tags) : {};
|
|
148
|
+
const entry: LazyEntry = {
|
|
149
|
+
value,
|
|
150
|
+
expiresAt: Date.now() + opts.ttl * 1000,
|
|
151
|
+
tags: opts.tags,
|
|
152
|
+
tagVersions,
|
|
153
|
+
};
|
|
154
|
+
await this.store.set(this.dataKey(key), JSON.stringify(entry), physicalTtl);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async invalidate(opts: { key?: string; tag?: string }): Promise<void> {
|
|
158
|
+
if (opts.key) {
|
|
159
|
+
if (this.strategy === 'eager') {
|
|
160
|
+
const raw = await this.store.get(this.dataKey(opts.key));
|
|
161
|
+
if (raw !== null) {
|
|
162
|
+
const entry = JSON.parse(raw) as EagerEntry;
|
|
163
|
+
if (entry.tags.length > 0) {
|
|
164
|
+
await Promise.all(
|
|
165
|
+
entry.tags.map((tag) => this.store.srem!(this.tagSetKey(tag), [opts.key!]))
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
await this.store.del(this.dataKey(opts.key));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (opts.tag) {
|
|
174
|
+
const tag = opts.tag;
|
|
175
|
+
if (this.strategy === 'native') {
|
|
176
|
+
await this.store.invalidateTag!(`${this.prefix}${tag}`);
|
|
177
|
+
} else if (this.strategy === 'eager') {
|
|
178
|
+
const members = await this.store.smembers!(this.tagSetKey(tag));
|
|
179
|
+
// Verify each member still carries the tag before deleting — the set
|
|
180
|
+
// can contain stale memberships from overwritten entries or LRU eviction.
|
|
181
|
+
// Remove only the snapshot members (via srem, not sdel) so concurrent
|
|
182
|
+
// writes that add new members between smembers() and cleanup are preserved.
|
|
183
|
+
await Promise.all(
|
|
184
|
+
members.map(async (k) => {
|
|
185
|
+
const raw = await this.store.get(this.dataKey(k));
|
|
186
|
+
if (raw === null) {
|
|
187
|
+
await this.store.srem!(this.tagSetKey(tag), [k]);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
const entry = JSON.parse(raw) as EagerEntry;
|
|
191
|
+
if (entry.tags.includes(tag)) {
|
|
192
|
+
await this.store.del(this.dataKey(k));
|
|
193
|
+
await this.store.srem!(this.tagSetKey(tag), [k]);
|
|
194
|
+
} else {
|
|
195
|
+
await this.store.srem!(this.tagSetKey(tag), [k]);
|
|
196
|
+
}
|
|
197
|
+
})
|
|
198
|
+
);
|
|
199
|
+
} else {
|
|
200
|
+
await this.bumpTagVersion(opts.tag);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// --- Lazy strategy helpers ---
|
|
206
|
+
|
|
207
|
+
private async getTagVersions(tags: string[]): Promise<Record<string, number>> {
|
|
208
|
+
const versions: Record<string, number> = {};
|
|
209
|
+
await Promise.all(
|
|
210
|
+
tags.map(async (tag) => {
|
|
211
|
+
const raw = await this.store.get(this.tagVersionKey(tag));
|
|
212
|
+
versions[tag] = raw !== null ? parseInt(raw, 10) : 0;
|
|
213
|
+
})
|
|
214
|
+
);
|
|
215
|
+
return versions;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
private async bumpTagVersion(tag: string): Promise<void> {
|
|
219
|
+
const key = this.tagVersionKey(tag);
|
|
220
|
+
const raw = await this.store.get(key);
|
|
221
|
+
const stored = raw !== null ? parseInt(raw, 10) : 0;
|
|
222
|
+
// max(stored, now) + 1: strictly advancing even within the same
|
|
223
|
+
// millisecond (two bumps in 1ms produce N, N+1 not N, N). On
|
|
224
|
+
// eventually-consistent stores the read can be stale, but Date.now()
|
|
225
|
+
// provides a floor — regression only if BOTH the read is stale AND the
|
|
226
|
+
// clock is behind, bounded by the store's propagation window (~60s).
|
|
227
|
+
const version = Math.max(stored, Date.now()) + 1;
|
|
228
|
+
// Version keys must outlive all data entries stamped with them. Data
|
|
229
|
+
// entries use physical TTL of max(ttl*2+60, 120); 365 days exceeds any
|
|
230
|
+
// practical cache lifetime.
|
|
231
|
+
await this.store.set(key, String(version), 365 * 86400);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
@@ -160,14 +160,24 @@ export function createCache<Fn extends (...args: any[]) => Promise<any>>(
|
|
|
160
160
|
|
|
161
161
|
/**
|
|
162
162
|
* Run fn and store the result — exactly once per coalesced execution,
|
|
163
|
-
* inside the singleflight callback. The write is skipped when
|
|
164
|
-
*
|
|
165
|
-
*
|
|
163
|
+
* inside the singleflight callback. The write is skipped when:
|
|
164
|
+
* - the key/tags were invalidated after fn started (TIM-1028), or
|
|
165
|
+
* - the singleflight timed out (signal aborted) — the timed-out
|
|
166
|
+
* flight's write could overwrite a newer value from a subsequent
|
|
167
|
+
* flight (TIM-1092).
|
|
168
|
+
*
|
|
169
|
+
* Residual race: if fn() completes within the timeout but
|
|
170
|
+
* handler.set() is slow enough that the timeout fires during the
|
|
171
|
+
* set, the write lands with stale data. This window is narrow
|
|
172
|
+
* (requires set latency to straddle the timeout boundary) and
|
|
173
|
+
* self-healing (next cache miss triggers a fresh write). Fully
|
|
174
|
+
* closing it requires CAS/versioned writes on CacheHandler —
|
|
175
|
+
* tracked in TIM-1158.
|
|
166
176
|
*/
|
|
167
|
-
const executeAndStore = async (): Promise<Awaited<ReturnType<Fn>>> => {
|
|
177
|
+
const executeAndStore = async (signal: AbortSignal): Promise<Awaited<ReturnType<Fn>>> => {
|
|
168
178
|
const startEpoch = currentInvalidationEpoch();
|
|
169
179
|
const result = await fn(...args);
|
|
170
|
-
if (!wasInvalidatedSince(startEpoch, key, tags)) {
|
|
180
|
+
if (!signal.aborted && !wasInvalidatedSince(startEpoch, key, tags)) {
|
|
171
181
|
await getHandler().set(key, result, { ttl: opts.ttl, tags });
|
|
172
182
|
}
|
|
173
183
|
return result;
|
|
@@ -182,9 +192,9 @@ export function createCache<Fn extends (...args: any[]) => Promise<any>>(
|
|
|
182
192
|
});
|
|
183
193
|
// Serve stale immediately, trigger background refetch
|
|
184
194
|
const refetch = sf
|
|
185
|
-
.do(`swr:${flightKey}`, async () => {
|
|
195
|
+
.do(`swr:${flightKey}`, async (signal) => {
|
|
186
196
|
try {
|
|
187
|
-
await executeAndStore();
|
|
197
|
+
await executeAndStore(signal);
|
|
188
198
|
} catch (err) {
|
|
189
199
|
// Failed refetch — stale entry continues to be served.
|
|
190
200
|
logSwrRefetchFailed({ cacheKey: key, error: err });
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloudflare CDN purge handler — purges by cache tag via the Cloudflare API.
|
|
3
|
+
*
|
|
4
|
+
* Requires Cloudflare Enterprise for tag-based purging.
|
|
5
|
+
* See https://developers.cloudflare.com/cache/how-to/purge-cache/purge-by-tags/
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { CdnPurgeHandler } from './purge-store';
|
|
9
|
+
|
|
10
|
+
export interface CloudflarePurgeOptions {
|
|
11
|
+
/** Cloudflare zone ID. */
|
|
12
|
+
zoneId: string;
|
|
13
|
+
/** API token with Cache Purge permission. */
|
|
14
|
+
apiToken: string;
|
|
15
|
+
/** Override fetch for testing. */
|
|
16
|
+
fetch?: typeof globalThis.fetch;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class CloudflarePurge implements CdnPurgeHandler {
|
|
20
|
+
private readonly zoneId: string;
|
|
21
|
+
private readonly apiToken: string;
|
|
22
|
+
private readonly _fetch: typeof globalThis.fetch;
|
|
23
|
+
|
|
24
|
+
constructor(opts: CloudflarePurgeOptions) {
|
|
25
|
+
this.zoneId = opts.zoneId;
|
|
26
|
+
this.apiToken = opts.apiToken;
|
|
27
|
+
this._fetch = opts.fetch ?? globalThis.fetch;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async purgeTags(tags: string[]): Promise<void> {
|
|
31
|
+
if (tags.length === 0) return;
|
|
32
|
+
|
|
33
|
+
const res = await this._fetch(
|
|
34
|
+
`https://api.cloudflare.com/client/v4/zones/${this.zoneId}/purge_cache`,
|
|
35
|
+
{
|
|
36
|
+
method: 'POST',
|
|
37
|
+
headers: {
|
|
38
|
+
'Authorization': `Bearer ${this.apiToken}`,
|
|
39
|
+
'Content-Type': 'application/json',
|
|
40
|
+
},
|
|
41
|
+
body: JSON.stringify({ tags }),
|
|
42
|
+
}
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
if (!res.ok) {
|
|
46
|
+
const body = await res.text();
|
|
47
|
+
throw new Error(`Cloudflare purge failed (${res.status}): ${body}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fastly CDN purge handler — purges by surrogate key via the Fastly API.
|
|
3
|
+
*
|
|
4
|
+
* See https://www.fastly.com/documentation/reference/api/purging/
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { CdnPurgeHandler } from './purge-store';
|
|
8
|
+
|
|
9
|
+
export interface FastlyPurgeOptions {
|
|
10
|
+
/** Fastly service ID. */
|
|
11
|
+
serviceId: string;
|
|
12
|
+
/** Fastly API token with purge permission. */
|
|
13
|
+
apiToken: string;
|
|
14
|
+
/** Use soft purge (marks as stale instead of immediate delete). Default: false. */
|
|
15
|
+
softPurge?: boolean;
|
|
16
|
+
/** Override fetch for testing. */
|
|
17
|
+
fetch?: typeof globalThis.fetch;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class FastlyPurge implements CdnPurgeHandler {
|
|
21
|
+
private readonly serviceId: string;
|
|
22
|
+
private readonly apiToken: string;
|
|
23
|
+
private readonly softPurge: boolean;
|
|
24
|
+
private readonly _fetch: typeof globalThis.fetch;
|
|
25
|
+
|
|
26
|
+
constructor(opts: FastlyPurgeOptions) {
|
|
27
|
+
this.serviceId = opts.serviceId;
|
|
28
|
+
this.apiToken = opts.apiToken;
|
|
29
|
+
this.softPurge = opts.softPurge ?? false;
|
|
30
|
+
this._fetch = opts.fetch ?? globalThis.fetch;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async purgeTags(tags: string[]): Promise<void> {
|
|
34
|
+
if (tags.length === 0) return;
|
|
35
|
+
|
|
36
|
+
const results = await Promise.allSettled(
|
|
37
|
+
tags.map((tag) =>
|
|
38
|
+
this._fetch(
|
|
39
|
+
`https://api.fastly.com/service/${this.serviceId}/purge/${encodeURIComponent(tag)}`,
|
|
40
|
+
{
|
|
41
|
+
method: 'POST',
|
|
42
|
+
headers: {
|
|
43
|
+
'Fastly-Key': this.apiToken,
|
|
44
|
+
...(this.softPurge ? { 'Fastly-Soft-Purge': '1' } : {}),
|
|
45
|
+
},
|
|
46
|
+
}
|
|
47
|
+
).then(async (res) => {
|
|
48
|
+
if (!res.ok) {
|
|
49
|
+
const body = await res.text();
|
|
50
|
+
throw new Error(`Fastly purge failed for tag "${tag}" (${res.status}): ${body}`);
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
)
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
const failures = results.filter((r): r is PromiseRejectedResult => r.status === 'rejected');
|
|
57
|
+
if (failures.length > 0) {
|
|
58
|
+
throw new Error(
|
|
59
|
+
`Fastly purge failed for ${failures.length}/${tags.length} tags: ${failures.map((f) => f.reason?.message).join('; ')}`
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CDN header helpers — pure functions that build Cache-Control,
|
|
3
|
+
* CDN-Cache-Control, Surrogate-Control, and Surrogate-Key header values.
|
|
4
|
+
*
|
|
5
|
+
* These are developer-elected utilities for wiring CDN caching in
|
|
6
|
+
* proxy.ts / middleware.ts. The framework never sets these automatically.
|
|
7
|
+
* See design/25-production-deployments.md §Layer 2.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface CacheControlOptions {
|
|
11
|
+
/** Mark the response as publicly cacheable (CDN, shared caches). */
|
|
12
|
+
public?: boolean;
|
|
13
|
+
/** Mark the response as private (browser cache only, not CDN). */
|
|
14
|
+
private?: boolean;
|
|
15
|
+
/** Max freshness in seconds for the browser/client. */
|
|
16
|
+
maxAge?: number;
|
|
17
|
+
/** Max freshness in seconds for shared caches (CDN, proxy). Overrides maxAge for CDNs. */
|
|
18
|
+
sMaxAge?: number;
|
|
19
|
+
/** Serve stale while revalidating in the background (seconds). */
|
|
20
|
+
staleWhileRevalidate?: number;
|
|
21
|
+
/** Serve stale on upstream error (seconds). */
|
|
22
|
+
staleIfError?: number;
|
|
23
|
+
/** Response must not be stored by any cache. */
|
|
24
|
+
noStore?: boolean;
|
|
25
|
+
/** Caches must revalidate before using a stored response. */
|
|
26
|
+
noCache?: boolean;
|
|
27
|
+
/** Caches must revalidate once stale — no serving stale without checking origin. */
|
|
28
|
+
mustRevalidate?: boolean;
|
|
29
|
+
/** Response will not change — allows aggressive caching. Pair with hashed URLs. */
|
|
30
|
+
immutable?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Build a `Cache-Control` header value.
|
|
35
|
+
*
|
|
36
|
+
* ```ts
|
|
37
|
+
* ctx.headers.set('Cache-Control', cacheControl({
|
|
38
|
+
* public: true,
|
|
39
|
+
* maxAge: 300,
|
|
40
|
+
* staleWhileRevalidate: 60,
|
|
41
|
+
* }));
|
|
42
|
+
* ```
|
|
43
|
+
*/
|
|
44
|
+
export function cacheControl(opts: CacheControlOptions): string {
|
|
45
|
+
if (opts.public && opts.private) {
|
|
46
|
+
throw new Error('cacheControl: public and private are mutually exclusive');
|
|
47
|
+
}
|
|
48
|
+
if (opts.noStore && opts.maxAge !== undefined) {
|
|
49
|
+
throw new Error('cacheControl: noStore makes maxAge meaningless — remove one');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const parts: string[] = [];
|
|
53
|
+
|
|
54
|
+
parts.push(opts.public ? 'public' : 'private');
|
|
55
|
+
|
|
56
|
+
if (opts.noStore) parts.push('no-store');
|
|
57
|
+
if (opts.noCache) parts.push('no-cache');
|
|
58
|
+
if (opts.maxAge !== undefined) parts.push(`max-age=${opts.maxAge}`);
|
|
59
|
+
if (opts.sMaxAge !== undefined) parts.push(`s-maxage=${opts.sMaxAge}`);
|
|
60
|
+
if (opts.staleWhileRevalidate !== undefined)
|
|
61
|
+
parts.push(`stale-while-revalidate=${opts.staleWhileRevalidate}`);
|
|
62
|
+
if (opts.staleIfError !== undefined) parts.push(`stale-if-error=${opts.staleIfError}`);
|
|
63
|
+
if (opts.mustRevalidate) parts.push('must-revalidate');
|
|
64
|
+
if (opts.immutable) parts.push('immutable');
|
|
65
|
+
|
|
66
|
+
return parts.join(', ');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface SurrogateControlOptions {
|
|
70
|
+
/** Max freshness in seconds for the surrogate (CDN). */
|
|
71
|
+
maxAge?: number;
|
|
72
|
+
/** Do not store in surrogate cache. */
|
|
73
|
+
noStore?: boolean;
|
|
74
|
+
/** Serve stale while revalidating (seconds). */
|
|
75
|
+
staleWhileRevalidate?: number;
|
|
76
|
+
/** Serve stale on error (seconds). */
|
|
77
|
+
staleIfError?: number;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Build a `Surrogate-Control` header value (Fastly, Akamai, Varnish).
|
|
82
|
+
*
|
|
83
|
+
* ```ts
|
|
84
|
+
* ctx.headers.set('Surrogate-Control', surrogateControl({ maxAge: 3600 }));
|
|
85
|
+
* ```
|
|
86
|
+
*/
|
|
87
|
+
export function surrogateControl(opts: SurrogateControlOptions): string {
|
|
88
|
+
const parts: string[] = [];
|
|
89
|
+
if (opts.noStore) parts.push('no-store');
|
|
90
|
+
if (opts.maxAge !== undefined) parts.push(`max-age=${opts.maxAge}`);
|
|
91
|
+
if (opts.staleWhileRevalidate !== undefined)
|
|
92
|
+
parts.push(`stale-while-revalidate=${opts.staleWhileRevalidate}`);
|
|
93
|
+
if (opts.staleIfError !== undefined) parts.push(`stale-if-error=${opts.staleIfError}`);
|
|
94
|
+
return parts.join(', ');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Build a `CDN-Cache-Control` header value (Cloudflare).
|
|
99
|
+
* Same directive syntax as Surrogate-Control but recognized by Cloudflare.
|
|
100
|
+
*
|
|
101
|
+
* ```ts
|
|
102
|
+
* ctx.headers.set('CDN-Cache-Control', cdnCacheControl({ maxAge: 3600 }));
|
|
103
|
+
* ```
|
|
104
|
+
*/
|
|
105
|
+
export function cdnCacheControl(opts: SurrogateControlOptions): string {
|
|
106
|
+
return surrogateControl(opts);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Build a `Surrogate-Key` header value — space-separated tags for CDN
|
|
111
|
+
* tag-based purging (Fastly, Cloudflare Enterprise, Akamai).
|
|
112
|
+
*
|
|
113
|
+
* Tags are deduplicated. Tags must not contain spaces or double quotes
|
|
114
|
+
* (these are separators/delimiters in the header format).
|
|
115
|
+
*
|
|
116
|
+
* ```ts
|
|
117
|
+
* ctx.headers.set('Surrogate-Key', surrogateKey(['products', `product:${id}`]));
|
|
118
|
+
* ```
|
|
119
|
+
*/
|
|
120
|
+
export function surrogateKey(tags: string[]): string {
|
|
121
|
+
for (const tag of tags) {
|
|
122
|
+
if (tag.includes(' ') || tag.includes('"')) {
|
|
123
|
+
throw new Error(`surrogateKey: tag "${tag}" must not contain spaces or double quotes`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return [...new Set(tags)].join(' ');
|
|
127
|
+
}
|
package/src/cdn/index.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// @timber-js/app/cdn — CDN integration helpers
|
|
2
|
+
//
|
|
3
|
+
// Header utilities and purge handler for CDN caching. Developer-elected,
|
|
4
|
+
// never automatic. See design/25-production-deployments.md §Layer 2.
|
|
5
|
+
|
|
6
|
+
export { cacheControl, surrogateKey, surrogateControl, cdnCacheControl } from './headers';
|
|
7
|
+
export type { CacheControlOptions, SurrogateControlOptions } from './headers';
|
|
8
|
+
|
|
9
|
+
export { setCdnPurgeHandler, getCdnPurgeHandler } from './purge-store';
|
|
10
|
+
export type { CdnPurgeHandler } from './purge-store';
|
|
11
|
+
|
|
12
|
+
// Platform-specific purge handlers are at their own entry points:
|
|
13
|
+
// @timber-js/app/cdn/cloudflare → CloudflarePurge
|
|
14
|
+
// @timber-js/app/cdn/fastly → FastlyPurge
|
|
15
|
+
// Same pattern as @timber-js/app/cache/stores/* — keeps platform
|
|
16
|
+
// dependencies out of the main CDN import.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CDN purge handler singleton — same pattern as cache/handler-store.ts.
|
|
3
|
+
*
|
|
4
|
+
* Set via timber.config.ts or setCdnPurgeHandler() at boot. When set,
|
|
5
|
+
* cache.invalidate({ tag }) also triggers CDN purge for the invalidated
|
|
6
|
+
* tags, so revalidateTag() composes with CDN caching out of the box.
|
|
7
|
+
*
|
|
8
|
+
* See design/25-production-deployments.md §Layer 2.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface CdnPurgeHandler {
|
|
12
|
+
/**
|
|
13
|
+
* Purge the given cache tags from the CDN.
|
|
14
|
+
* Called by cache.invalidate when a tag is invalidated.
|
|
15
|
+
*/
|
|
16
|
+
purgeTags(tags: string[]): Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
let handler: CdnPurgeHandler | undefined;
|
|
20
|
+
|
|
21
|
+
/** Install a CDN purge handler. Called by the framework at boot from timber.config.ts. */
|
|
22
|
+
export function setCdnPurgeHandler(h: CdnPurgeHandler): void {
|
|
23
|
+
handler = h;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Get the current CDN purge handler, if configured. */
|
|
27
|
+
export function getCdnPurgeHandler(): CdnPurgeHandler | undefined {
|
|
28
|
+
return handler;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** @internal — reset for tests only. */
|
|
32
|
+
export function _resetCdnPurgeHandler(): void {
|
|
33
|
+
handler = undefined;
|
|
34
|
+
}
|
|
@@ -18,9 +18,6 @@ import { setServerCallback, encodeReply, createFromFetch } from '../../rsc-runti
|
|
|
18
18
|
import { getRouter, getRouterOrNull } from '#client-internal';
|
|
19
19
|
import { setHardNavigating } from '../navigation-root.js';
|
|
20
20
|
import { getClientDeploymentId, DEPLOYMENT_ID_HEADER, RELOAD_HEADER } from '../rsc-fetch.js';
|
|
21
|
-
import { decodeHeadHeader, HEAD_HEADER } from '../../shared/head-header.js';
|
|
22
|
-
import type { HeadElement } from '../head.js';
|
|
23
|
-
|
|
24
21
|
export function setupServerActions(): void {
|
|
25
22
|
setServerCallback(async (id: string, args: unknown[]) => {
|
|
26
23
|
const body = await encodeReply(args);
|
|
@@ -30,7 +27,6 @@ export function setupServerActions(): void {
|
|
|
30
27
|
// consumes the body stream.
|
|
31
28
|
let hasRevalidation = false;
|
|
32
29
|
let hasRedirect = false;
|
|
33
|
-
let headElementsJson: string | null = null;
|
|
34
30
|
|
|
35
31
|
// Build action request headers. Include deployment ID for version
|
|
36
32
|
// skew detection (TIM-446) — the server rejects stale actions gracefully.
|
|
@@ -56,7 +52,6 @@ export function setupServerActions(): void {
|
|
|
56
52
|
}
|
|
57
53
|
hasRevalidation = res.headers.get('X-Timber-Revalidation') === '1';
|
|
58
54
|
hasRedirect = res.headers.get('X-Timber-Redirect') != null;
|
|
59
|
-
headElementsJson = res.headers.get(HEAD_HEADER);
|
|
60
55
|
return res;
|
|
61
56
|
});
|
|
62
57
|
|
|
@@ -104,11 +99,7 @@ export function setupServerActions(): void {
|
|
|
104
99
|
const router = getRouterOrNull();
|
|
105
100
|
if (router) {
|
|
106
101
|
try {
|
|
107
|
-
|
|
108
|
-
// A malformed header decodes to null (head update skipped), it
|
|
109
|
-
// does not fail the revalidation. See TIM-1058.
|
|
110
|
-
const headElements = decodeHeadHeader(headElementsJson) as HeadElement[] | null;
|
|
111
|
-
router.applyRevalidation(wrapper._tree, headElements);
|
|
102
|
+
router.applyRevalidation(wrapper._tree);
|
|
112
103
|
} catch (e) {
|
|
113
104
|
console.error('[timber] applyRevalidation failed after server action', e);
|
|
114
105
|
}
|
|
@@ -40,7 +40,6 @@ export function setupPostHydration({
|
|
|
40
40
|
// useSegmentParams() consumer on the initial page (TIM-1037).
|
|
41
41
|
router.historyStack.push(window.location.pathname + window.location.search, {
|
|
42
42
|
payload: initialElement,
|
|
43
|
-
headElements: null, // SSR already set the correct head
|
|
44
43
|
params: getNavigationState().params,
|
|
45
44
|
});
|
|
46
45
|
|
|
@@ -12,7 +12,6 @@ import { createFromFetch } from '../../rsc-runtime/browser.js';
|
|
|
12
12
|
import { createRouter, setGlobalRouter } from '#client-internal';
|
|
13
13
|
import type { RouterDeps, RouterInstance } from '#client-internal';
|
|
14
14
|
import type { NavigationState } from '#client-internal';
|
|
15
|
-
import { applyHeadElements } from '../head.js';
|
|
16
15
|
import { TimberNuqsAdapter } from '../nuqs-adapter.js';
|
|
17
16
|
import {
|
|
18
17
|
NavigationProvider,
|
|
@@ -243,10 +242,6 @@ export function createTimberRouter(options?: RouterInitOptions): RouterInitResul
|
|
|
243
242
|
requestAnimationFrame(callback);
|
|
244
243
|
});
|
|
245
244
|
},
|
|
246
|
-
|
|
247
|
-
// Apply resolved head elements (title, meta tags) to the DOM after
|
|
248
|
-
// SPA navigation. See design/16-metadata.md.
|
|
249
|
-
applyHead: applyHeadElements,
|
|
250
245
|
};
|
|
251
246
|
|
|
252
247
|
const router = createRouter(deps);
|
package/src/client/history.ts
CHANGED
|
@@ -1,15 +1,11 @@
|
|
|
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 { HeadElement } from './head';
|
|
5
|
-
|
|
6
4
|
// ─── Types ───────────────────────────────────────────────────────
|
|
7
5
|
|
|
8
6
|
export interface HistoryEntry {
|
|
9
7
|
/** The complete segment tree payload at the time of navigation */
|
|
10
8
|
payload: unknown;
|
|
11
|
-
/** Resolved head elements for this page (title, meta tags). Null for SSR'd initial page. */
|
|
12
|
-
headElements?: HeadElement[] | null;
|
|
13
9
|
/**
|
|
14
10
|
* Route params for this page (for useSegmentParams). Every entry that can
|
|
15
11
|
* be replayed must carry them — the initial SSR entry gets them from the
|