@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
|
@@ -65,15 +65,19 @@ For caching, use `CloudflareKVCacheHandler`:
|
|
|
65
65
|
|
|
66
66
|
```ts title="timber.config.ts"
|
|
67
67
|
import { cloudflare } from '@timber-js/app/adapters/cloudflare';
|
|
68
|
-
import { CloudflareKVCacheHandler } from '@timber-js/app/adapters/cloudflare/cache';
|
|
69
68
|
|
|
70
69
|
export default {
|
|
71
70
|
output: 'server',
|
|
72
71
|
adapter: cloudflare(),
|
|
73
|
-
cacheHandler: new CloudflareKVCacheHandler({ bindingName: 'TIMBER_CACHE' }),
|
|
74
72
|
};
|
|
75
73
|
```
|
|
76
74
|
|
|
75
|
+
```ts title="timber.cache.ts"
|
|
76
|
+
import { CloudflareKVCacheHandler } from '@timber-js/app/adapters/cloudflare/cache';
|
|
77
|
+
|
|
78
|
+
export default new CloudflareKVCacheHandler({ bindingName: 'TIMBER_CACHE' });
|
|
79
|
+
```
|
|
80
|
+
|
|
77
81
|
**Constraints:** no file system, 10ms/30s CPU time limits, 10MB/25MB bundle size.
|
|
78
82
|
|
|
79
83
|
## Node.js & Docker
|
|
@@ -226,30 +230,26 @@ When using the Cloudflare adapter, CDN purge is **automatic** — no configurati
|
|
|
226
230
|
|
|
227
231
|
For non-Workers Cloudflare zones (Enterprise), or other CDNs, configure a purge handler manually:
|
|
228
232
|
|
|
229
|
-
```ts title="timber.
|
|
233
|
+
```ts title="timber.cache.ts"
|
|
230
234
|
import { CloudflarePurge } from '@timber-js/app/cdn/cloudflare';
|
|
231
235
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
}),
|
|
238
|
-
};
|
|
236
|
+
// Only needed for non-Workers Cloudflare zones (Enterprise)
|
|
237
|
+
export const cdnPurge = new CloudflarePurge({
|
|
238
|
+
zoneId: process.env.CF_ZONE_ID!,
|
|
239
|
+
apiToken: process.env.CF_API_TOKEN!,
|
|
240
|
+
});
|
|
239
241
|
```
|
|
240
242
|
|
|
241
243
|
Fastly:
|
|
242
244
|
|
|
243
|
-
```ts title="timber.
|
|
245
|
+
```ts title="timber.cache.ts"
|
|
244
246
|
import { FastlyPurge } from '@timber-js/app/cdn/fastly';
|
|
245
247
|
|
|
246
|
-
export
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
}),
|
|
252
|
-
};
|
|
248
|
+
export const cdnPurge = new FastlyPurge({
|
|
249
|
+
serviceId: process.env.FASTLY_SERVICE_ID!,
|
|
250
|
+
apiToken: process.env.FASTLY_API_TOKEN!,
|
|
251
|
+
softPurge: true,
|
|
252
|
+
});
|
|
253
253
|
```
|
|
254
254
|
|
|
255
255
|
When `revalidateTag('products')` fires, it clears the application cache **and** calls the CDN purge handler. CDN purge is best-effort — failures are logged but don't break cache invalidation.
|
|
@@ -87,7 +87,7 @@ Special files in your route tree generate metadata endpoints:
|
|
|
87
87
|
|
|
88
88
|
When `opengraph-image.tsx` exists in a segment, timber automatically emits both `<meta property="og:image">` and `<meta name="twitter:image">` in `<head>` — no need to declare them in your `metadata` export. The auto-linked URL includes a cache-busting hash so social platforms pick up changes on redeploy.
|
|
89
89
|
|
|
90
|
-
If you declare `openGraph.images` in your metadata,
|
|
90
|
+
If you declare `openGraph.images` in your metadata, both auto-linked tags are suppressed. If you declare only `twitter.images`, the auto-linked `twitter:image` is suppressed but `og:image` is still emitted. User-declared metadata always wins.
|
|
91
91
|
|
|
92
92
|
`favicon.tsx` lets you generate favicons dynamically (e.g. tenant branding). The URL stays `/favicon.ico` for browser compatibility.
|
|
93
93
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@timber-js/app",
|
|
3
|
-
"version": "0.2.0-alpha.
|
|
3
|
+
"version": "0.2.0-alpha.169",
|
|
4
4
|
"description": "Vite-native React framework built for Servers and Serverless Platforms — correct HTTP semantics, real status codes, pages that work without JavaScript",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cloudflare-workers",
|
|
@@ -158,13 +158,13 @@
|
|
|
158
158
|
},
|
|
159
159
|
"dependencies": {
|
|
160
160
|
"@opentelemetry/api": "^1.9.1",
|
|
161
|
-
"@opentelemetry/context-async-hooks": "^2.
|
|
162
|
-
"@opentelemetry/sdk-trace-base": "^2.
|
|
161
|
+
"@opentelemetry/context-async-hooks": "^2.9.0",
|
|
162
|
+
"@opentelemetry/sdk-trace-base": "^2.9.0",
|
|
163
163
|
"cookie": "^1.1.1",
|
|
164
|
+
"jsonc-parser": "^3.3.1",
|
|
164
165
|
"magic-string": "^0.30.21",
|
|
165
166
|
"nitro": "3.0.260610-beta",
|
|
166
|
-
"srvx": "^0.11.
|
|
167
|
-
"jsonc-parser": "^3.3.1"
|
|
167
|
+
"srvx": "^0.11.22"
|
|
168
168
|
},
|
|
169
169
|
"peerDependencies": {
|
|
170
170
|
"@content-collections/core": "^0.14.0 || ^0.15.0",
|
|
@@ -176,7 +176,7 @@
|
|
|
176
176
|
"nuqs": "^2.0.0",
|
|
177
177
|
"react": "19.2.7",
|
|
178
178
|
"react-dom": "19.2.7",
|
|
179
|
-
"vite": "
|
|
179
|
+
"vite": "8.1.0",
|
|
180
180
|
"wrangler": "^4.0.0",
|
|
181
181
|
"zod": "^3.22.0 || ^4.0.0"
|
|
182
182
|
},
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { CacheHandler } from '../cache/index';
|
|
2
2
|
import { getCloudflareBindings } from './cloudflare';
|
|
3
|
+
import { warnLossyJsonValue } from '../cache/json-lossy-check.js';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Cloudflare Workers KV interface — the subset of KVNamespace we depend on.
|
|
@@ -117,6 +118,7 @@ export class CloudflareKVCacheHandler implements CacheHandler {
|
|
|
117
118
|
value: unknown,
|
|
118
119
|
opts: { ttl: number; tags: string[]; generation?: number }
|
|
119
120
|
): Promise<void> {
|
|
121
|
+
warnLossyJsonValue(value, key);
|
|
120
122
|
const kv = this.getKV();
|
|
121
123
|
const entry: KVCacheEntry = {
|
|
122
124
|
value,
|
|
@@ -127,12 +129,16 @@ export class CloudflareKVCacheHandler implements CacheHandler {
|
|
|
127
129
|
// KV expirationTtl minimum is 60s. Use 2× TTL (min 60s) so stale entries
|
|
128
130
|
// survive for staleWhileRevalidate background refetches.
|
|
129
131
|
const kvTtl = Math.max(opts.ttl * 2, 60);
|
|
130
|
-
await kv.put(this.dataKey(key), JSON.stringify(entry), { expirationTtl: kvTtl });
|
|
131
132
|
|
|
132
|
-
//
|
|
133
|
-
// indexes
|
|
134
|
-
|
|
135
|
-
|
|
133
|
+
// Serialize upfront so BigInt/non-serializable errors throw before any
|
|
134
|
+
// writes — prevents tag indexes pointing at stale data from a prior set().
|
|
135
|
+
const payload = JSON.stringify(entry);
|
|
136
|
+
|
|
137
|
+
// Tag indexes are written BEFORE the data key so that a partial network
|
|
138
|
+
// failure on the data write never leaves an entry invisible to
|
|
139
|
+
// invalidate({tag}). A dangling tag-index reference to a non-existent
|
|
140
|
+
// data key is harmless — invalidate() re-checks stored tags and cleans
|
|
141
|
+
// up on the next pass.
|
|
136
142
|
const now = Date.now();
|
|
137
143
|
const kvExpiresAt = now + kvTtl * 1000;
|
|
138
144
|
await Promise.all(
|
|
@@ -145,6 +151,8 @@ export class CloudflareKVCacheHandler implements CacheHandler {
|
|
|
145
151
|
});
|
|
146
152
|
})
|
|
147
153
|
);
|
|
154
|
+
|
|
155
|
+
await kv.put(this.dataKey(key), payload, { expirationTtl: kvTtl });
|
|
148
156
|
}
|
|
149
157
|
|
|
150
158
|
async invalidate(opts: { key?: string; tag?: string }): Promise<void> {
|
|
@@ -175,7 +183,20 @@ export class CloudflareKVCacheHandler implements CacheHandler {
|
|
|
175
183
|
if (opts.tag) {
|
|
176
184
|
const entries = parseTagIndex(await kv.get(this.tagKey(opts.tag), { type: 'json' }));
|
|
177
185
|
if (entries.length > 0) {
|
|
178
|
-
|
|
186
|
+
// Re-check each member's stored tags before deleting — the tag index
|
|
187
|
+
// can contain stale references from partial writes or entries whose
|
|
188
|
+
// tags changed since they were indexed. Mirrors Redis handler behavior.
|
|
189
|
+
await Promise.all(
|
|
190
|
+
entries.map(async (e) => {
|
|
191
|
+
const raw = (await kv.get(this.dataKey(e.key), {
|
|
192
|
+
type: 'json',
|
|
193
|
+
})) as KVCacheEntry | null;
|
|
194
|
+
if (!raw) return;
|
|
195
|
+
if (raw.tags?.includes(opts.tag!)) {
|
|
196
|
+
await kv.delete(this.dataKey(e.key));
|
|
197
|
+
}
|
|
198
|
+
})
|
|
199
|
+
);
|
|
179
200
|
}
|
|
180
201
|
await kv.delete(this.tagKey(opts.tag));
|
|
181
202
|
}
|
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)
|
|
@@ -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/router.ts
CHANGED
|
@@ -155,6 +155,13 @@ export interface RouterDeps {
|
|
|
155
155
|
* as a full page load with an unknown fragment landing at the top.
|
|
156
156
|
*/
|
|
157
157
|
scrollToHash?: (hash: string) => boolean;
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Whether the client segment cache is enabled. When false (the default),
|
|
161
|
+
* the router does not send X-Timber-State-Tree headers and does not
|
|
162
|
+
* populate the segment cache. Every navigation gets a full RSC payload.
|
|
163
|
+
*/
|
|
164
|
+
clientSegmentCache?: boolean;
|
|
158
165
|
}
|
|
159
166
|
|
|
160
167
|
export interface RouterInstance {
|
|
@@ -292,6 +299,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
292
299
|
|
|
293
300
|
/** Update the segment cache from server-provided segment metadata. */
|
|
294
301
|
function updateSegmentCache(segmentInfo: SegmentInfo[] | null | undefined): void {
|
|
302
|
+
if (!deps.clientSegmentCache) return;
|
|
295
303
|
if (!segmentInfo || segmentInfo.length === 0) return;
|
|
296
304
|
const tree = buildSegmentTree(segmentInfo);
|
|
297
305
|
if (tree) {
|
|
@@ -540,7 +548,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
540
548
|
if (result === undefined) {
|
|
541
549
|
// Fetch RSC payload with state tree for partial rendering.
|
|
542
550
|
// Send current URL for intercepting route resolution (modal pattern).
|
|
543
|
-
const stateTree = segmentCache.serializeStateTree();
|
|
551
|
+
const stateTree = deps.clientSegmentCache ? segmentCache.serializeStateTree() : undefined;
|
|
544
552
|
const rawCurrentUrl = deps.getCurrentUrl();
|
|
545
553
|
const currentUrl = rawCurrentUrl.startsWith('http')
|
|
546
554
|
? new URL(rawCurrentUrl).pathname
|
|
@@ -734,7 +742,9 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
734
742
|
url,
|
|
735
743
|
async (navAbort) => {
|
|
736
744
|
await renderViaTransition(url, async () => {
|
|
737
|
-
const stateTree =
|
|
745
|
+
const stateTree = deps.clientSegmentCache
|
|
746
|
+
? segmentCache.serializeStateTree()
|
|
747
|
+
: undefined;
|
|
738
748
|
const result = await fetchRscPayload(url, deps, stateTree, undefined, navAbort.signal);
|
|
739
749
|
const payload = await resolveForFallback(result.payload);
|
|
740
750
|
const navState = commitNavigation(url, {
|
|
@@ -767,7 +777,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
767
777
|
if (historyStack.has(fetchUrl)) return;
|
|
768
778
|
|
|
769
779
|
// Fire-and-forget fetch
|
|
770
|
-
const stateTree = segmentCache.serializeStateTree();
|
|
780
|
+
const stateTree = deps.clientSegmentCache ? segmentCache.serializeStateTree() : undefined;
|
|
771
781
|
void fetchRscPayload(fetchUrl, deps, stateTree).then(
|
|
772
782
|
(result) => {
|
|
773
783
|
result.decodePromise?.catch(() => {});
|
package/src/codec.ts
CHANGED
|
@@ -80,8 +80,10 @@ export const codec = {
|
|
|
80
80
|
integer: {
|
|
81
81
|
parse(value: string | string[] | undefined): number {
|
|
82
82
|
const str = Array.isArray(value) ? value[0] : value;
|
|
83
|
+
if (!str || !/^(?:0|-?[1-9]\d*)$/.test(str))
|
|
84
|
+
throw new Error(`Expected integer, got '${str}'`);
|
|
83
85
|
const n = Number(str);
|
|
84
|
-
if (!Number.
|
|
86
|
+
if (!Number.isSafeInteger(n)) throw new Error(`Expected integer, got '${str}'`);
|
|
85
87
|
return n;
|
|
86
88
|
},
|
|
87
89
|
serialize(value: number): string | null {
|
package/src/config-types.ts
CHANGED
|
@@ -46,9 +46,6 @@ export interface TimberUserConfig {
|
|
|
46
46
|
*/
|
|
47
47
|
clientJavascript?: boolean | ClientJavascriptConfig;
|
|
48
48
|
adapter?: unknown;
|
|
49
|
-
cacheHandler?: unknown;
|
|
50
|
-
/** CDN purge handler — called on revalidateTag/cache.invalidate to purge CDN-cached responses. */
|
|
51
|
-
cdnPurge?: unknown;
|
|
52
49
|
allowedOrigins?: string[];
|
|
53
50
|
csrf?: boolean;
|
|
54
51
|
limits?: {
|
|
@@ -56,40 +53,6 @@ export interface TimberUserConfig {
|
|
|
56
53
|
uploadBodySize?: string;
|
|
57
54
|
maxFields?: number;
|
|
58
55
|
};
|
|
59
|
-
/**
|
|
60
|
-
* Server-action form handling.
|
|
61
|
-
*
|
|
62
|
-
* See design/08-forms-and-actions.md §"Validation errors" and
|
|
63
|
-
* design/13-security.md §"Sensitive field stripping".
|
|
64
|
-
*/
|
|
65
|
-
/**
|
|
66
|
-
* Server action runtime behavior.
|
|
67
|
-
*
|
|
68
|
-
* See design/08-forms-and-actions.md §"Middleware for Server Actions".
|
|
69
|
-
*/
|
|
70
|
-
actions?: {
|
|
71
|
-
/**
|
|
72
|
-
* Run `middleware.ts` on server action requests before dispatching.
|
|
73
|
-
*
|
|
74
|
-
* **Default: `true`** — middleware runs on every action POST so
|
|
75
|
-
* authentication, rate limiting, tenant isolation, IP allow-listing,
|
|
76
|
-
* and request-header injection apply uniformly to page renders, route
|
|
77
|
-
* handlers, and server actions. This closes the auth-bypass class of
|
|
78
|
-
* issue identified by Next.js CVE-2025-29927: developers can put a
|
|
79
|
-
* single auth check in `middleware.ts` and trust that it gates every
|
|
80
|
-
* unsafe-method request.
|
|
81
|
-
*
|
|
82
|
-
* Set to `false` to restore the legacy behavior where actions skip
|
|
83
|
-
* middleware entirely. This is **not recommended** outside of niche
|
|
84
|
-
* cases (e.g. middleware that rewrites POST bodies and would corrupt
|
|
85
|
-
* action submissions). When false, you are responsible for placing
|
|
86
|
-
* auth, rate limiting, and other cross-cutting checks inside every
|
|
87
|
-
* action — typically via `createActionClient({ middleware: ... })`.
|
|
88
|
-
*
|
|
89
|
-
* See TIM-871.
|
|
90
|
-
*/
|
|
91
|
-
runMiddleware?: boolean;
|
|
92
|
-
};
|
|
93
56
|
forms?: {
|
|
94
57
|
/**
|
|
95
58
|
* Strip sensitive fields (passwords, tokens, CVV, SSN, etc.) from the
|
|
@@ -286,6 +249,22 @@ export interface TimberUserConfig {
|
|
|
286
249
|
* ```
|
|
287
250
|
*/
|
|
288
251
|
buildDir?: string;
|
|
252
|
+
/**
|
|
253
|
+
* Enable the client-side segment cache for partial navigation.
|
|
254
|
+
*
|
|
255
|
+
* When enabled, the router serializes the mounted segment tree into an
|
|
256
|
+
* `X-Timber-State-Tree` header on every client navigation. The server
|
|
257
|
+
* skips re-rendering unchanged sync layouts and sends a partial RSC
|
|
258
|
+
* payload. The client merges the partial payload with its cached segments.
|
|
259
|
+
*
|
|
260
|
+
* When disabled (the default), every navigation gets a full RSC payload.
|
|
261
|
+
*
|
|
262
|
+
* This is a performance optimization only — not a security boundary.
|
|
263
|
+
* The server always runs all `access.ts` files regardless.
|
|
264
|
+
*
|
|
265
|
+
* See design/19-client-navigation.md §"X-Timber-State-Tree Header"
|
|
266
|
+
*/
|
|
267
|
+
clientSegmentCache?: boolean;
|
|
289
268
|
topLoader?: {
|
|
290
269
|
/** Whether the top-loader is enabled. Default: true. */
|
|
291
270
|
enabled?: boolean;
|
package/src/config-validation.ts
CHANGED
|
@@ -30,12 +30,9 @@ const KNOWN_CONFIG_KEYS = {
|
|
|
30
30
|
debug: true,
|
|
31
31
|
clientJavascript: true,
|
|
32
32
|
adapter: true,
|
|
33
|
-
cacheHandler: true,
|
|
34
|
-
cdnPurge: true,
|
|
35
33
|
allowedOrigins: true,
|
|
36
34
|
csrf: true,
|
|
37
35
|
limits: true,
|
|
38
|
-
actions: true,
|
|
39
36
|
forms: true,
|
|
40
37
|
pageExtensions: true,
|
|
41
38
|
slowRequestMs: true,
|
|
@@ -49,6 +46,7 @@ const KNOWN_CONFIG_KEYS = {
|
|
|
49
46
|
reactCompiler: true,
|
|
50
47
|
sitemap: true,
|
|
51
48
|
buildDir: true,
|
|
49
|
+
clientSegmentCache: true,
|
|
52
50
|
topLoader: true,
|
|
53
51
|
budget: true,
|
|
54
52
|
} satisfies Record<keyof TimberUserConfig, true>;
|
|
@@ -197,12 +195,16 @@ export function validateConfig(config: TimberUserConfig): ConfigError[] {
|
|
|
197
195
|
}
|
|
198
196
|
}
|
|
199
197
|
|
|
198
|
+
const MOVED_TO_CACHE_FILE = new Set(['cacheHandler', 'cdnPurge']);
|
|
199
|
+
|
|
200
200
|
for (const key of Object.keys(config)) {
|
|
201
201
|
if (!knownKeys.has(key)) {
|
|
202
202
|
errors.push({
|
|
203
203
|
field: key,
|
|
204
204
|
message: `Unknown config option: "${key}".`,
|
|
205
|
-
suggestion:
|
|
205
|
+
suggestion: MOVED_TO_CACHE_FILE.has(key)
|
|
206
|
+
? `"${key}" has moved to timber.cache.ts. Export cacheHandler as default, cdnPurge as a named export.`
|
|
207
|
+
: `Check for typos. Known options: ${[...knownKeys].sort().join(', ')}.`,
|
|
206
208
|
});
|
|
207
209
|
}
|
|
208
210
|
}
|
|
@@ -248,7 +250,7 @@ const VIRTUAL_MODULE_NAMES: Record<string, string> = {
|
|
|
248
250
|
'virtual:timber-config': 'Runtime config (timber.config.ts)',
|
|
249
251
|
'virtual:timber-route-manifest': 'Route manifest (app/ file tree)',
|
|
250
252
|
'virtual:timber-instrumentation': 'Instrumentation (instrumentation.ts)',
|
|
251
|
-
'virtual:timber-cache-handler': 'Cache handler (
|
|
253
|
+
'virtual:timber-cache-handler': 'Cache handler (timber.cache.ts)',
|
|
252
254
|
'virtual:timber-build-manifest': 'Build manifest (asset mapping)',
|
|
253
255
|
};
|
|
254
256
|
|
package/src/fonts/pipeline.ts
CHANGED
|
@@ -208,6 +208,38 @@ export class FontPipeline {
|
|
|
208
208
|
}
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
+
/** True if at least one entry is registered under `importer`. */
|
|
212
|
+
hasImporter(importer: string): boolean {
|
|
213
|
+
for (const entry of this._entries.values()) {
|
|
214
|
+
if (entry.importer === importer) return true;
|
|
215
|
+
}
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Drop **every** registry entry that originated from `importer`,
|
|
221
|
+
* regardless of family.
|
|
222
|
+
*
|
|
223
|
+
* Called at the start of the transform hook before re-registering the
|
|
224
|
+
* current extraction set. This handles the cases `pruneFor` misses:
|
|
225
|
+
*
|
|
226
|
+
* - **Family rename** (Inter → Roboto): the old family's entries are
|
|
227
|
+
* cleared because we prune by importer, not by (importer, family).
|
|
228
|
+
* - **Font call deleted**: the old entries are cleared even though no
|
|
229
|
+
* new call provides a family to prune against.
|
|
230
|
+
*
|
|
231
|
+
* Other importers' fonts are untouched.
|
|
232
|
+
*
|
|
233
|
+
* See TIM-1087.
|
|
234
|
+
*/
|
|
235
|
+
pruneImporter(importer: string): void {
|
|
236
|
+
for (const [id, entry] of this._entries) {
|
|
237
|
+
if (entry.importer === importer) {
|
|
238
|
+
this._entries.delete(id);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
211
243
|
/** Drop every registered font. Used by tests and rebuild flows. */
|
|
212
244
|
clear(): void {
|
|
213
245
|
this._entries.clear();
|