@timber-js/app 0.2.0-alpha.145 → 0.2.0-alpha.146
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-DzFMnUAR.js → actions-OIZ8siQu.js} +28 -43
- package/dist/_chunks/actions-OIZ8siQu.js.map +1 -0
- package/dist/_chunks/{handler-store-DjISKYEB.js → handler-store-BVDgyTh7.js} +26 -2
- package/dist/_chunks/handler-store-BVDgyTh7.js.map +1 -0
- package/dist/_chunks/{href-validation-EZktELp8.js → href-validation-DyZIzfMJ.js} +18 -2
- package/dist/_chunks/{href-validation-EZktELp8.js.map → href-validation-DyZIzfMJ.js.map} +1 -1
- package/dist/_chunks/{navigation-root-DLc4s4ZC.js → navigation-root-bUDZLw74.js} +23 -2
- package/dist/_chunks/navigation-root-bUDZLw74.js.map +1 -0
- package/dist/_chunks/tracing-M_zKubhk.js.map +1 -1
- package/dist/adapters/cloudflare.d.ts +1 -15
- package/dist/adapters/cloudflare.d.ts.map +1 -1
- package/dist/adapters/cloudflare.js +10 -28
- package/dist/adapters/cloudflare.js.map +1 -1
- package/dist/adapters/compress-module.d.ts.map +1 -1
- package/dist/adapters/nitro.d.ts.map +1 -1
- package/dist/adapters/nitro.js +18 -5
- package/dist/adapters/nitro.js.map +1 -1
- package/dist/cache/index.js +39 -12
- package/dist/cache/index.js.map +1 -1
- package/dist/cache/redis-handler.d.ts +72 -8
- package/dist/cache/redis-handler.d.ts.map +1 -1
- package/dist/cache/stable-stringify.d.ts +9 -0
- package/dist/cache/stable-stringify.d.ts.map +1 -1
- package/dist/cache/timber-cache.d.ts +3 -1
- package/dist/cache/timber-cache.d.ts.map +1 -1
- package/dist/client/browser-entry/action-dispatch.d.ts.map +1 -1
- package/dist/client/browser-entry/router-init.d.ts.map +1 -1
- package/dist/client/browser-entry/scroll.d.ts +9 -0
- package/dist/client/browser-entry/scroll.d.ts.map +1 -1
- package/dist/client/error-boundary.d.ts.map +1 -1
- package/dist/client/error-boundary.js +11 -6
- package/dist/client/error-boundary.js.map +1 -1
- package/dist/client/index.js +3 -3
- package/dist/client/index.js.map +1 -1
- package/dist/client/internal.js +99 -24
- package/dist/client/internal.js.map +1 -1
- package/dist/client/link.d.ts.map +1 -1
- package/dist/client/navigation-api.d.ts.map +1 -1
- package/dist/client/navigation-root.d.ts +13 -0
- package/dist/client/navigation-root.d.ts.map +1 -1
- package/dist/client/router.d.ts +7 -0
- package/dist/client/router.d.ts.map +1 -1
- package/dist/client/rsc-fetch.d.ts.map +1 -1
- package/dist/dev-tools/404-page.d.ts +6 -2
- package/dist/dev-tools/404-page.d.ts.map +1 -1
- package/dist/dev-tools/dev-page-shell.d.ts +77 -0
- package/dist/dev-tools/dev-page-shell.d.ts.map +1 -0
- package/dist/dev-tools/error-page.d.ts +4 -30
- package/dist/dev-tools/error-page.d.ts.map +1 -1
- package/dist/dev-tools/index.d.ts +2 -1
- package/dist/dev-tools/index.d.ts.map +1 -1
- package/dist/dev-tools/instrumentation.d.ts +11 -1
- package/dist/dev-tools/instrumentation.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +430 -253
- package/dist/index.js.map +1 -1
- package/dist/plugins/cache.d.ts +43 -9
- package/dist/plugins/cache.d.ts.map +1 -1
- package/dist/plugins/cloudflare-wasm.d.ts +4 -1
- package/dist/plugins/cloudflare-wasm.d.ts.map +1 -1
- package/dist/plugins/dev-server.d.ts +16 -0
- package/dist/plugins/dev-server.d.ts.map +1 -1
- package/dist/plugins/server-bundle.d.ts.map +1 -1
- package/dist/plugins/shims.d.ts +23 -0
- package/dist/plugins/shims.d.ts.map +1 -1
- package/dist/server/action-handler.d.ts.map +1 -1
- package/dist/server/actions.d.ts +3 -2
- package/dist/server/actions.d.ts.map +1 -1
- package/dist/server/cookie-context.d.ts.map +1 -1
- package/dist/server/cookie-parsing.d.ts.map +1 -1
- package/dist/server/fallback-error.d.ts +2 -2
- package/dist/server/fallback-error.d.ts.map +1 -1
- package/dist/server/index.js +1 -1
- package/dist/server/internal.js +1 -1
- package/dist/server/internal.js.map +1 -1
- package/dist/server/node-stream-transforms.d.ts +24 -6
- package/dist/server/node-stream-transforms.d.ts.map +1 -1
- package/dist/server/pipeline.d.ts +11 -0
- package/dist/server/pipeline.d.ts.map +1 -1
- package/dist/server/primitives.d.ts +1 -11
- package/dist/server/primitives.d.ts.map +1 -1
- package/dist/server/rsc-entry/index.d.ts.map +1 -1
- package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
- package/dist/server/slot-resolver.d.ts +6 -4
- package/dist/server/slot-resolver.d.ts.map +1 -1
- package/dist/server/ssr-entry.d.ts.map +1 -1
- package/dist/server/tracing.d.ts +2 -1
- package/dist/server/tracing.d.ts.map +1 -1
- package/dist/shared/head-header.d.ts +54 -0
- package/dist/shared/head-header.d.ts.map +1 -0
- package/dist/shared/href-validation.d.ts +11 -0
- package/dist/shared/href-validation.d.ts.map +1 -1
- package/dist/shared/redirect-type.d.ts +17 -0
- package/dist/shared/redirect-type.d.ts.map +1 -0
- package/dist/shims/headers.d.ts +52 -1
- package/dist/shims/headers.d.ts.map +1 -1
- package/dist/shims/navigation-client.d.ts +33 -11
- package/dist/shims/navigation-client.d.ts.map +1 -1
- package/package.json +10 -9
- package/src/adapters/cloudflare.ts +13 -47
- package/src/adapters/compress-module.ts +9 -3
- package/src/adapters/nitro.ts +9 -2
- package/src/cache/redis-handler.ts +77 -13
- package/src/cache/stable-stringify.ts +65 -2
- package/src/cache/timber-cache.ts +25 -13
- package/src/cli.ts +0 -0
- package/src/client/browser-entry/action-dispatch.ts +7 -2
- package/src/client/browser-entry/router-init.ts +2 -1
- package/src/client/browser-entry/scroll.ts +23 -0
- package/src/client/error-boundary.tsx +19 -5
- package/src/client/link.tsx +4 -1
- package/src/client/navigation-api.ts +5 -1
- package/src/client/navigation-root.tsx +16 -0
- package/src/client/router.ts +107 -29
- package/src/client/rsc-fetch.ts +6 -6
- package/src/dev-tools/404-page.ts +22 -123
- package/src/dev-tools/dev-page-shell.ts +256 -0
- package/src/dev-tools/error-page.ts +27 -203
- package/src/dev-tools/index.ts +7 -1
- package/src/dev-tools/instrumentation.ts +39 -2
- package/src/index.ts +1 -0
- package/src/plugins/cache.ts +79 -21
- package/src/plugins/cloudflare-wasm.ts +48 -10
- package/src/plugins/dev-server.ts +32 -8
- package/src/plugins/server-bundle.ts +25 -9
- package/src/plugins/shims.ts +94 -52
- package/src/server/action-handler.ts +10 -2
- package/src/server/actions.ts +3 -2
- package/src/server/compress.ts +11 -2
- package/src/server/cookie-context.ts +11 -0
- package/src/server/cookie-parsing.ts +16 -4
- package/src/server/fallback-error.ts +9 -4
- package/src/server/node-stream-transforms.ts +61 -24
- package/src/server/pipeline.ts +12 -0
- package/src/server/primitives.ts +10 -31
- package/src/server/rsc-entry/index.ts +7 -2
- package/src/server/rsc-entry/rsc-payload.ts +4 -3
- package/src/server/slot-resolver.ts +27 -14
- package/src/server/ssr-entry.ts +17 -28
- package/src/server/tracing.ts +17 -1
- package/src/shared/head-header.ts +69 -0
- package/src/shared/href-validation.ts +24 -0
- package/src/shared/redirect-type.ts +17 -0
- package/src/shims/headers.ts +119 -2
- package/src/shims/navigation-client.ts +48 -27
- package/LICENSE +0 -8
- package/dist/_chunks/actions-DzFMnUAR.js.map +0 -1
- package/dist/_chunks/handler-store-DjISKYEB.js.map +0 -1
- package/dist/_chunks/navigation-root-DLc4s4ZC.js.map +0 -1
package/src/adapters/nitro.ts
CHANGED
|
@@ -471,7 +471,14 @@ const host = process.env.HOST || process.env.HOSTNAME || 'localhost';
|
|
|
471
471
|
let boundPort = startPort;
|
|
472
472
|
|
|
473
473
|
const server = createServer(async (req, res) => {
|
|
474
|
-
|
|
474
|
+
// Scheme from the socket's TLS state — a hardcoded http:// breaks CSRF
|
|
475
|
+
// origin comparison and absolute-URL construction when TLS is terminated
|
|
476
|
+
// at this server (TIM-1073; mirrors the dev-server fix in TIM-1067).
|
|
477
|
+
// X-Forwarded-Proto is intentionally not consulted here: on a direct
|
|
478
|
+
// connection it would be attacker-controlled. The CSRF layer honors it
|
|
479
|
+
// separately for reverse-proxy deployments.
|
|
480
|
+
const scheme = req.socket?.encrypted ? 'https' : 'http';
|
|
481
|
+
const url = new URL(req.url || '/', \`\${scheme}://\${host}:\${boundPort}\`);
|
|
475
482
|
|
|
476
483
|
// Try serving static files from the public directory first.
|
|
477
484
|
const filePath = join(publicDir, url.pathname);
|
|
@@ -557,7 +564,7 @@ const server = createServer(async (req, res) => {
|
|
|
557
564
|
// is silently dropped: defineCookie().setCookie() in actions, middleware
|
|
558
565
|
// cookie writes, the framework's own session cookies, all of it. See
|
|
559
566
|
// LOCAL-741.
|
|
560
|
-
const responseHeaders
|
|
567
|
+
const responseHeaders = {};
|
|
561
568
|
webResponse.headers.forEach((value, key) => {
|
|
562
569
|
if (key.toLowerCase() !== 'set-cookie') {
|
|
563
570
|
responseHeaders[key] = value;
|
|
@@ -1,15 +1,78 @@
|
|
|
1
1
|
import type { CacheHandler } from './index';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Minimal Redis client
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Minimal Redis client contract for `RedisCacheHandler`.
|
|
5
|
+
*
|
|
6
|
+
* This is deliberately NOT the native shape of any client library. ioredis,
|
|
7
|
+
* node-redis v4, and @upstash/redis all disagree on TTL argument shapes
|
|
8
|
+
* (positional `'EX'` vs `{ EX }` vs `{ ex }`), method casing (`sadd` vs
|
|
9
|
+
* `sAdd`), and array handling in `del` — a contract that pattern-matches one
|
|
10
|
+
* client lets another silently drop the expiry (TIM-1030: node-redis ignored
|
|
11
|
+
* the positional `'EX'` and wrote keys with no TTL). Instead, TTL semantics
|
|
12
|
+
* are explicit in the signature and you adapt your client with a thin
|
|
13
|
+
* wrapper, so a mismatch is a type error rather than silent unbounded
|
|
14
|
+
* keyspace growth.
|
|
15
|
+
*
|
|
16
|
+
* @example ioredis
|
|
17
|
+
* ```ts
|
|
18
|
+
* import Redis from 'ioredis';
|
|
19
|
+
* const redis = new Redis(process.env.REDIS_URL);
|
|
20
|
+
* const client: RedisClient = {
|
|
21
|
+
* get: (k) => redis.get(k),
|
|
22
|
+
* set: (k, v, ttl) => redis.set(k, v, 'EX', ttl),
|
|
23
|
+
* expire: (k, s) => redis.expire(k, s),
|
|
24
|
+
* del: (k) => redis.del(k),
|
|
25
|
+
* sadd: (k, ...m) => redis.sadd(k, ...m),
|
|
26
|
+
* smembers: (k) => redis.smembers(k),
|
|
27
|
+
* };
|
|
28
|
+
* ```
|
|
29
|
+
*
|
|
30
|
+
* @example node-redis v4
|
|
31
|
+
* ```ts
|
|
32
|
+
* import { createClient } from 'redis';
|
|
33
|
+
* const redis = createClient({ url: process.env.REDIS_URL });
|
|
34
|
+
* await redis.connect();
|
|
35
|
+
* const client: RedisClient = {
|
|
36
|
+
* get: (k) => redis.get(k),
|
|
37
|
+
* set: (k, v, ttl) => redis.set(k, v, { EX: ttl }),
|
|
38
|
+
* expire: (k, s) => redis.expire(k, s),
|
|
39
|
+
* del: (k) => redis.del(k),
|
|
40
|
+
* sadd: (k, ...m) => redis.sAdd(k, m),
|
|
41
|
+
* smembers: (k) => redis.sMembers(k),
|
|
42
|
+
* };
|
|
43
|
+
* ```
|
|
44
|
+
*
|
|
45
|
+
* @example @upstash/redis
|
|
46
|
+
* ```ts
|
|
47
|
+
* import { Redis } from '@upstash/redis';
|
|
48
|
+
* // automaticDeserialization must be off: the handler stores JSON strings
|
|
49
|
+
* // and parses them itself — auto-parsing would hand back objects.
|
|
50
|
+
* const redis = new Redis({ url, token, automaticDeserialization: false });
|
|
51
|
+
* const client: RedisClient = {
|
|
52
|
+
* get: (k) => redis.get(k),
|
|
53
|
+
* set: (k, v, ttl) => redis.set(k, v, { ex: ttl }),
|
|
54
|
+
* expire: (k, s) => redis.expire(k, s),
|
|
55
|
+
* del: (k) => redis.del(k),
|
|
56
|
+
* sadd: (k, ...m) => redis.sadd(k, m[0], ...m.slice(1)),
|
|
57
|
+
* smembers: (k) => redis.smembers(k),
|
|
58
|
+
* };
|
|
59
|
+
* ```
|
|
7
60
|
*/
|
|
8
61
|
export interface RedisClient {
|
|
62
|
+
/** `GET key` — the stored string, or null when absent. */
|
|
9
63
|
get(key: string): Promise<string | null>;
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
64
|
+
/**
|
|
65
|
+
* `SET key value EX ttlSeconds` — store with an expiry. The TTL is a
|
|
66
|
+
* required positional argument so no adapter can forget it.
|
|
67
|
+
*/
|
|
68
|
+
set(key: string, value: string, ttlSeconds: number): Promise<unknown>;
|
|
69
|
+
/** `EXPIRE key seconds` — set/refresh a key's TTL (used for tag sets). */
|
|
70
|
+
expire(key: string, seconds: number): Promise<unknown>;
|
|
71
|
+
/** `DEL key` — delete a single key. Multi-key deletes loop per key. */
|
|
72
|
+
del(key: string): Promise<unknown>;
|
|
73
|
+
/** `SADD key member [member ...]` — add members to a set. */
|
|
74
|
+
sadd(key: string, ...members: string[]): Promise<unknown>;
|
|
75
|
+
/** `SMEMBERS key` — all members of a set. */
|
|
13
76
|
smembers(key: string): Promise<string[]>;
|
|
14
77
|
}
|
|
15
78
|
|
|
@@ -23,8 +86,9 @@ const TAG_PREFIX = 'timber:tag:';
|
|
|
23
86
|
* invalidations. Tag-based invalidation uses Redis Sets to track which keys
|
|
24
87
|
* belong to which tags.
|
|
25
88
|
*
|
|
26
|
-
* Bring your own Redis client
|
|
27
|
-
* interface
|
|
89
|
+
* Bring your own Redis client wrapped to the {@link RedisClient} contract —
|
|
90
|
+
* see the adapter examples on the interface for ioredis, node-redis v4, and
|
|
91
|
+
* @upstash/redis.
|
|
28
92
|
*/
|
|
29
93
|
export class RedisCacheHandler implements CacheHandler {
|
|
30
94
|
private client: RedisClient;
|
|
@@ -62,7 +126,7 @@ export class RedisCacheHandler implements CacheHandler {
|
|
|
62
126
|
// We use 2x TTL + 60s as the Redis expiry so stale entries remain
|
|
63
127
|
// available for SWR background refetches.
|
|
64
128
|
const redisTtlSeconds = Math.max(opts.ttl * 2 + 60, 120);
|
|
65
|
-
await this.client.set(ck, payload,
|
|
129
|
+
await this.client.set(ck, payload, redisTtlSeconds);
|
|
66
130
|
|
|
67
131
|
// Track key membership in each tag set
|
|
68
132
|
for (const tag of opts.tags) {
|
|
@@ -79,10 +143,10 @@ export class RedisCacheHandler implements CacheHandler {
|
|
|
79
143
|
const tk = this.tagKey(opts.tag);
|
|
80
144
|
const keys = await this.client.smembers(tk);
|
|
81
145
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
146
|
+
// One del per key: the only multi-key shape every client accepts.
|
|
147
|
+
// ioredis takes del(...keys), node-redis takes del(key | key[]), and
|
|
148
|
+
// @upstash/redis takes del(...keys) but breaks on an array argument.
|
|
149
|
+
await Promise.all(keys.map((k) => this.client.del(this.cacheKey(k))));
|
|
86
150
|
|
|
87
151
|
// Clean up the tag set itself
|
|
88
152
|
await this.client.del(tk);
|
|
@@ -2,20 +2,83 @@
|
|
|
2
2
|
* Deterministic JSON serialization with sorted object keys.
|
|
3
3
|
* Used for cache key generation — ensures { a: 1, b: 2 } and { b: 2, a: 1 }
|
|
4
4
|
* produce the same string.
|
|
5
|
+
*
|
|
6
|
+
* Matches JSON.stringify semantics for toJSON: Date serializes to its ISO
|
|
7
|
+
* string, URL to its href, and class instances with a custom toJSON use it.
|
|
8
|
+
* Map and Set serialize their entries explicitly (insertion-order
|
|
9
|
+
* independent). Values that would serialize lossily — functions, symbols,
|
|
10
|
+
* and non-plain objects with no enumerable own state (RegExp, class
|
|
11
|
+
* instances holding data behind getters or internal slots) — throw instead:
|
|
12
|
+
* every such value would collapse to the same cache key, silently returning
|
|
13
|
+
* one argument's cached data for another (TIM-1020).
|
|
5
14
|
*/
|
|
6
15
|
export function stableStringify(value: unknown): string {
|
|
7
|
-
if (value === null || value === undefined) return
|
|
8
|
-
|
|
16
|
+
if (value === null || value === undefined) return String(value);
|
|
17
|
+
|
|
18
|
+
const type = typeof value;
|
|
19
|
+
if (type === 'function' || type === 'symbol') {
|
|
20
|
+
throw new TypeError(
|
|
21
|
+
`stableStringify: cannot serialize a ${type} into a stable cache key — ` +
|
|
22
|
+
'all such values would collide. Provide an explicit `key` option to cache().'
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
if (type !== 'object') return JSON.stringify(value);
|
|
26
|
+
|
|
9
27
|
if (Array.isArray(value)) {
|
|
10
28
|
return '[' + value.map((item) => stableStringify(item)).join(',') + ']';
|
|
11
29
|
}
|
|
12
30
|
|
|
31
|
+
// Honor toJSON like JSON.stringify does (Date → ISO string, URL → href,
|
|
32
|
+
// custom class serialization). JSON.stringify does not re-invoke toJSON
|
|
33
|
+
// on the value it returns, so a toJSON that returns `this` falls through
|
|
34
|
+
// to plain own-property serialization instead of recursing forever.
|
|
35
|
+
const withToJSON = value as { toJSON?: () => unknown };
|
|
36
|
+
if (typeof withToJSON.toJSON === 'function') {
|
|
37
|
+
const json = withToJSON.toJSON();
|
|
38
|
+
if (json !== value) return stableStringify(json);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Map/Set have no enumerable own properties — serialize entries explicitly.
|
|
42
|
+
// Entries are sorted after serialization so insertion order doesn't change
|
|
43
|
+
// the key. The Map(/Set( prefixes can't collide with object ('{'), array
|
|
44
|
+
// ('['), or string ('"') output.
|
|
45
|
+
if (value instanceof Map) {
|
|
46
|
+
const entries = [...value.entries()].map(
|
|
47
|
+
([k, v]) => stableStringify(k) + '=>' + stableStringify(v)
|
|
48
|
+
);
|
|
49
|
+
return 'Map(' + entries.sort().join(',') + ')';
|
|
50
|
+
}
|
|
51
|
+
if (value instanceof Set) {
|
|
52
|
+
const members = [...value].map((member) => stableStringify(member));
|
|
53
|
+
return 'Set(' + members.sort().join(',') + ')';
|
|
54
|
+
}
|
|
55
|
+
|
|
13
56
|
const obj = value as Record<string, unknown>;
|
|
14
57
|
const keys = Object.keys(obj).sort();
|
|
15
58
|
const pairs: string[] = [];
|
|
16
59
|
for (const key of keys) {
|
|
17
60
|
if (obj[key] === undefined) continue;
|
|
61
|
+
// A callable own toJSON only reaches this loop via the returns-`this`
|
|
62
|
+
// fall-through above. It's the serialization protocol, not data —
|
|
63
|
+
// JSON.stringify omits it too.
|
|
64
|
+
if (key === 'toJSON' && typeof obj[key] === 'function') continue;
|
|
18
65
|
pairs.push(JSON.stringify(key) + ':' + stableStringify(obj[key]));
|
|
19
66
|
}
|
|
67
|
+
|
|
68
|
+
// A non-plain object that serializes to '{}' carries its state behind
|
|
69
|
+
// getters or internal slots (RegExp, sessions, ORM entities). Serializing
|
|
70
|
+
// it would give every instance the same cache key.
|
|
71
|
+
if (pairs.length === 0) {
|
|
72
|
+
const proto = Object.getPrototypeOf(value);
|
|
73
|
+
if (proto !== Object.prototype && proto !== null) {
|
|
74
|
+
const name = (value as object).constructor?.name || 'Object';
|
|
75
|
+
throw new TypeError(
|
|
76
|
+
`stableStringify: ${name} instance has no serializable own state — ` +
|
|
77
|
+
'all instances would share one cache key. Provide an explicit `key` ' +
|
|
78
|
+
'option to cache(), or add a toJSON() method.'
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
20
83
|
return '{' + pairs.join(',') + '}';
|
|
21
84
|
}
|
|
@@ -3,6 +3,7 @@ import { stableStringify } from './stable-stringify';
|
|
|
3
3
|
import { createSingleflight } from './singleflight';
|
|
4
4
|
import { addSpanEventSync } from '../server/tracing.js';
|
|
5
5
|
import { logSwrRefetchFailed } from '../server/logger.js';
|
|
6
|
+
import { getWaitUntil } from '../server/waituntil-bridge.js';
|
|
6
7
|
import { fnv1aHash } from './fast-hash.js';
|
|
7
8
|
|
|
8
9
|
const defaultSingleflight = createSingleflight();
|
|
@@ -44,7 +45,9 @@ let hasWarnedUnstableId = false;
|
|
|
44
45
|
* - Singleflight: concurrent misses → single execution
|
|
45
46
|
* - SWR: serve stale immediately, background refetch
|
|
46
47
|
* - Tags as string[] or function of args
|
|
47
|
-
* - No ALS dependency
|
|
48
|
+
* - No ALS dependency for correctness — cache() works outside a request
|
|
49
|
+
* context. The waitUntil ALS bridge is read optionally so the SWR
|
|
50
|
+
* background refetch survives handler return on Workers/Lambda.
|
|
48
51
|
*
|
|
49
52
|
* Cache hits/misses are recorded as OTEL span events on the enclosing
|
|
50
53
|
* span (not child spans). The DevSpanProcessor reads these for dev log output.
|
|
@@ -110,18 +113,27 @@ export function createCache<Fn extends (...args: any[]) => Promise<any>>(
|
|
|
110
113
|
stale: true,
|
|
111
114
|
});
|
|
112
115
|
// Serve stale immediately, trigger background refetch
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
116
|
+
const refetch = sf
|
|
117
|
+
.do(`swr:${key}`, async () => {
|
|
118
|
+
try {
|
|
119
|
+
const fresh = await fn(...args);
|
|
120
|
+
const tags = resolveTags(opts, args);
|
|
121
|
+
await handler.set(key, fresh, { ttl: opts.ttl, tags });
|
|
122
|
+
} catch (err) {
|
|
123
|
+
// Failed refetch — stale entry continues to be served.
|
|
124
|
+
logSwrRefetchFailed({ cacheKey: key, error: err });
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
.catch(() => {
|
|
128
|
+
// Singleflight promise rejection handled — stale continues.
|
|
129
|
+
});
|
|
130
|
+
// Platforms that cancel work when the handler returns (Cloudflare
|
|
131
|
+
// Workers, Lambda) would otherwise kill the refetch mid-flight —
|
|
132
|
+
// stale data re-served forever. Register the (never-rejecting)
|
|
133
|
+
// refetch with the request's waitUntil; undefined outside a request
|
|
134
|
+
// context or on platforms without lifecycle extension (Node servers
|
|
135
|
+
// keep floating promises alive, so behavior there is unchanged).
|
|
136
|
+
getWaitUntil()?.(refetch);
|
|
125
137
|
return cached.value as Awaited<ReturnType<Fn>>;
|
|
126
138
|
}
|
|
127
139
|
|
package/src/cli.ts
CHANGED
|
File without changes
|
|
@@ -18,6 +18,8 @@ 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';
|
|
21
23
|
|
|
22
24
|
export function setupServerActions(): void {
|
|
23
25
|
setServerCallback(async (id: string, args: unknown[]) => {
|
|
@@ -54,7 +56,7 @@ export function setupServerActions(): void {
|
|
|
54
56
|
}
|
|
55
57
|
hasRevalidation = res.headers.get('X-Timber-Revalidation') === '1';
|
|
56
58
|
hasRedirect = res.headers.get('X-Timber-Redirect') != null;
|
|
57
|
-
headElementsJson = res.headers.get(
|
|
59
|
+
headElementsJson = res.headers.get(HEAD_HEADER);
|
|
58
60
|
return res;
|
|
59
61
|
});
|
|
60
62
|
|
|
@@ -88,7 +90,10 @@ export function setupServerActions(): void {
|
|
|
88
90
|
const router = getRouterOrNull();
|
|
89
91
|
if (router) {
|
|
90
92
|
try {
|
|
91
|
-
|
|
93
|
+
// Percent-encoded JSON — same wire format as the navigation path.
|
|
94
|
+
// A malformed header decodes to null (head update skipped), it
|
|
95
|
+
// does not fail the revalidation. See TIM-1058.
|
|
96
|
+
const headElements = decodeHeadHeader(headElementsJson) as HeadElement[] | null;
|
|
92
97
|
router.applyRevalidation(wrapper._tree, headElements);
|
|
93
98
|
} catch (e) {
|
|
94
99
|
console.error('[timber] applyRevalidation failed after server action', e);
|
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
setupNavigationApi,
|
|
23
23
|
type NavigationApiController,
|
|
24
24
|
} from '../navigation-api.js';
|
|
25
|
-
import { getScrollY } from './scroll.js';
|
|
25
|
+
import { getScrollY, scrollToHashTarget } from './scroll.js';
|
|
26
26
|
|
|
27
27
|
export interface RouterInitOptions {
|
|
28
28
|
initialElement?: unknown;
|
|
@@ -77,6 +77,7 @@ export function createTimberRouter(options?: RouterInitOptions): RouterInitResul
|
|
|
77
77
|
},
|
|
78
78
|
getCurrentUrl: () => window.location.pathname + window.location.search,
|
|
79
79
|
getScrollY,
|
|
80
|
+
scrollToHash: scrollToHashTarget,
|
|
80
81
|
|
|
81
82
|
decodeRsc: (fetchPromise: Promise<Response>) => {
|
|
82
83
|
return createFromFetch(fetchPromise);
|
|
@@ -16,6 +16,29 @@
|
|
|
16
16
|
* naturally. We don't need to auto-detect overflow containers; only
|
|
17
17
|
* explicitly marked containers are tracked.
|
|
18
18
|
*/
|
|
19
|
+
/**
|
|
20
|
+
* Scroll the element matching a URL #fragment into view.
|
|
21
|
+
*
|
|
22
|
+
* Mirrors browser anchor behavior: looks up the decoded fragment by id,
|
|
23
|
+
* then by anchor name. Returns true when a matching element was found and
|
|
24
|
+
* scrolled; false otherwise (the router falls back to scroll-to-top, same
|
|
25
|
+
* as a full page load with an unknown fragment). See TIM-1035.
|
|
26
|
+
*/
|
|
27
|
+
export function scrollToHashTarget(hash: string): boolean {
|
|
28
|
+
const fragment = hash.startsWith('#') ? hash.slice(1) : hash;
|
|
29
|
+
if (!fragment) return false;
|
|
30
|
+
let id: string;
|
|
31
|
+
try {
|
|
32
|
+
id = decodeURIComponent(fragment);
|
|
33
|
+
} catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
const target = document.getElementById(id) ?? document.getElementsByName(id)[0];
|
|
37
|
+
if (!target) return false;
|
|
38
|
+
target.scrollIntoView();
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
|
|
19
42
|
export function getScrollY(): number {
|
|
20
43
|
if (window.scrollY || document.documentElement.scrollTop || document.body.scrollTop) {
|
|
21
44
|
return window.scrollY || document.documentElement.scrollTop || document.body.scrollTop;
|
|
@@ -101,6 +101,13 @@ interface TimberErrorBoundaryState {
|
|
|
101
101
|
// duplicate router.navigate() calls.
|
|
102
102
|
const _handledRedirects = new WeakSet<object>();
|
|
103
103
|
|
|
104
|
+
// Companion guard keyed by location: client-render redirect() (the
|
|
105
|
+
// next/navigation shim) throws a FRESH error instance on every render
|
|
106
|
+
// attempt, and React replays the errored render — so the WeakSet alone
|
|
107
|
+
// would schedule the navigation twice. While a navigation to a location
|
|
108
|
+
// is pending, further redirect errors for the same location are no-ops.
|
|
109
|
+
let _pendingRedirectLocation: string | null = null;
|
|
110
|
+
|
|
104
111
|
// ─── Component ───────────────────────────────────────────────────────────────
|
|
105
112
|
|
|
106
113
|
export class TimberErrorBoundary extends Component<
|
|
@@ -167,18 +174,25 @@ export class TimberErrorBoundary extends Component<
|
|
|
167
174
|
// than once for a single caught error (dev-mode double render, and
|
|
168
175
|
// additional boundaries above us in the tree). Only schedule the
|
|
169
176
|
// navigation on the first render that sees this error instance.
|
|
170
|
-
const alreadyHandled =
|
|
177
|
+
const alreadyHandled =
|
|
178
|
+
_handledRedirects.has(error) || _pendingRedirectLocation === parsed.location;
|
|
171
179
|
if (!alreadyHandled) {
|
|
172
180
|
_handledRedirects.add(error);
|
|
173
181
|
const router = getRouterOrNull();
|
|
174
182
|
if (router) {
|
|
183
|
+
_pendingRedirectLocation = parsed.location;
|
|
175
184
|
// Schedule the navigation outside the render phase to avoid
|
|
176
185
|
// setState-during-render warnings from router state updates.
|
|
177
186
|
queueMicrotask(() => {
|
|
178
|
-
router
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
187
|
+
router
|
|
188
|
+
.navigate(parsed.location, { replace: true })
|
|
189
|
+
.catch(() => {
|
|
190
|
+
// Fall back to a hard navigation if the soft navigation fails.
|
|
191
|
+
window.location.href = parsed.location;
|
|
192
|
+
})
|
|
193
|
+
.finally(() => {
|
|
194
|
+
_pendingRedirectLocation = null;
|
|
195
|
+
});
|
|
182
196
|
});
|
|
183
197
|
} else {
|
|
184
198
|
// No router available (shouldn't happen post-bootstrap) — fall
|
package/src/client/link.tsx
CHANGED
|
@@ -536,7 +536,10 @@ export const Link: LinkFunction = function LinkImpl(props: any) {
|
|
|
536
536
|
event.preventDefault();
|
|
537
537
|
|
|
538
538
|
const shouldScroll = scroll !== false;
|
|
539
|
-
|
|
539
|
+
// Keep the #fragment — the router commits it to the address bar and
|
|
540
|
+
// scrolls to the matching element after render. The hash is stripped
|
|
541
|
+
// from the RSC fetch URL inside the router (TIM-1035).
|
|
542
|
+
const absoluteHref = resolved.pathname + resolved.search + resolved.hash;
|
|
540
543
|
|
|
541
544
|
startTransition(async () => {
|
|
542
545
|
await router.navigate(absoluteHref, { scroll: shouldScroll });
|
|
@@ -266,7 +266,11 @@ export function setupNavigationApi(callbacks: NavigationApiCallbacks): Navigatio
|
|
|
266
266
|
scroll: 'manual',
|
|
267
267
|
focusReset: 'manual',
|
|
268
268
|
async handler() {
|
|
269
|
-
|
|
269
|
+
// Keep the #fragment — router.navigate splits it back off for the
|
|
270
|
+
// RSC fetch and scrolls to the matching element after render
|
|
271
|
+
// (TIM-1035). Traversals above stay hash-less: history-stack keys
|
|
272
|
+
// use pathname + search.
|
|
273
|
+
await callbacks.onExternalNavigate(url + destUrl.hash, {
|
|
270
274
|
replace: event.navigationType === 'replace',
|
|
271
275
|
signal: event.signal,
|
|
272
276
|
scroll: linkMeta?.scroll,
|
|
@@ -58,6 +58,22 @@ function getTransitionCounter(): { id: number } {
|
|
|
58
58
|
return g[NAV_TRANSITION_KEY] as { id: number };
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Invalidate all in-flight navigation transitions. Any navigateTransition()
|
|
63
|
+
* call whose perform() has not yet committed will reject with AbortError
|
|
64
|
+
* instead of committing its element.
|
|
65
|
+
*
|
|
66
|
+
* Called by the router when a render supersedes in-flight navigations
|
|
67
|
+
* WITHOUT going through navigateTransition() — the cached popstate replay
|
|
68
|
+
* renders via transitionRender(), which doesn't bump the counter, so a
|
|
69
|
+
* stale forward navigation's setElement would otherwise pass the
|
|
70
|
+
* `counter.id !== transId` guard and commit the forward page over the
|
|
71
|
+
* replayed back page (TIM-1022).
|
|
72
|
+
*/
|
|
73
|
+
export function supersedeNavigationTransitions(): void {
|
|
74
|
+
getTransitionCounter().id += 1;
|
|
75
|
+
}
|
|
76
|
+
|
|
61
77
|
// ─── Hard Navigation Guard ──────────────────────────────────────
|
|
62
78
|
|
|
63
79
|
/**
|