@rangojs/router 0.0.0-experimental.121 → 0.0.0-experimental.124
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/bin/rango.js +7 -2
- package/dist/vite/index.js +47 -6
- package/package.json +61 -21
- package/skills/cache-guide/SKILL.md +8 -6
- package/skills/caching/SKILL.md +148 -1
- package/skills/hooks/SKILL.md +38 -27
- package/skills/host-router/SKILL.md +16 -2
- package/skills/intercept/SKILL.md +4 -2
- package/skills/layout/SKILL.md +11 -6
- package/skills/loader/SKILL.md +6 -2
- package/skills/middleware/SKILL.md +4 -2
- package/skills/migrate-nextjs/SKILL.md +38 -16
- package/skills/parallel/SKILL.md +9 -4
- package/skills/rango/SKILL.md +27 -15
- package/skills/route/SKILL.md +4 -2
- package/skills/testing/SKILL.md +129 -0
- package/skills/testing/bindings.md +89 -0
- package/skills/testing/cache-prerender.md +98 -0
- package/skills/testing/client-components.md +122 -0
- package/skills/testing/e2e-parity.md +125 -0
- package/skills/testing/flight.md +89 -0
- package/skills/testing/handles.md +129 -0
- package/skills/testing/loader.md +128 -0
- package/skills/testing/middleware.md +99 -0
- package/skills/testing/render-handler.md +118 -0
- package/skills/testing/response-routes.md +95 -0
- package/skills/testing/reverse-and-types.md +84 -0
- package/skills/testing/server-actions.md +107 -0
- package/skills/testing/server-tree.md +128 -0
- package/skills/testing/setup.md +120 -0
- package/skills/use-cache/SKILL.md +9 -7
- package/src/browser/action-fence.ts +37 -0
- package/src/browser/cookie-name.ts +140 -0
- package/src/browser/invalidate-client-cache.ts +52 -0
- package/src/browser/navigation-bridge.ts +14 -1
- package/src/browser/navigation-client.ts +14 -1
- package/src/browser/navigation-store-handle.ts +39 -0
- package/src/browser/navigation-store.ts +26 -12
- package/src/browser/prefetch/fetch.ts +7 -0
- package/src/browser/rango-state.ts +176 -97
- package/src/browser/react/index.ts +0 -6
- package/src/browser/rsc-router.tsx +12 -4
- package/src/browser/server-action-bridge.ts +77 -15
- package/src/browser/types.ts +7 -1
- package/src/cache/cache-error.ts +104 -0
- package/src/cache/cache-policy.ts +95 -1
- package/src/cache/cache-runtime.ts +79 -13
- package/src/cache/cache-scope.ts +55 -4
- package/src/cache/cache-tag.ts +135 -0
- package/src/cache/cf/cf-cache-store.ts +2080 -224
- package/src/cache/cf/index.ts +15 -1
- package/src/cache/document-cache.ts +74 -7
- package/src/cache/index.ts +17 -0
- package/src/cache/memory-segment-store.ts +164 -14
- package/src/cache/tag-invalidation.ts +230 -0
- package/src/cache/types.ts +27 -0
- package/src/client.rsc.tsx +1 -1
- package/src/client.tsx +0 -6
- package/src/component-utils.ts +19 -0
- package/src/handle.ts +29 -9
- package/src/host/testing.ts +43 -14
- package/src/index.rsc.ts +29 -1
- package/src/index.ts +43 -1
- package/src/loader.rsc.ts +24 -3
- package/src/loader.ts +16 -2
- package/src/prerender.ts +24 -3
- package/src/router/basename.ts +14 -0
- package/src/router/match-handlers.ts +62 -20
- package/src/router/prerender-match.ts +6 -0
- package/src/router/router-interfaces.ts +7 -0
- package/src/router/router-options.ts +30 -0
- package/src/router/segment-resolution/loader-cache.ts +8 -17
- package/src/router/state-cookie-name.ts +33 -0
- package/src/router/telemetry.ts +99 -0
- package/src/router.ts +36 -7
- package/src/rsc/handler.ts +13 -1
- package/src/rsc/helpers.ts +19 -0
- package/src/rsc/progressive-enhancement.ts +2 -0
- package/src/rsc/response-route-handler.ts +8 -1
- package/src/rsc/rsc-rendering.ts +2 -0
- package/src/rsc/types.ts +2 -0
- package/src/runtime-env.ts +18 -0
- package/src/server/cookie-store.ts +52 -1
- package/src/server/request-context.ts +105 -2
- package/src/static-handler.ts +25 -3
- package/src/testing/cache-status.ts +166 -0
- package/src/testing/collect-handle.ts +63 -0
- package/src/testing/dispatch.ts +581 -0
- package/src/testing/dom.entry.ts +22 -0
- package/src/testing/e2e/fixture.ts +188 -0
- package/src/testing/e2e/index.ts +149 -0
- package/src/testing/e2e/matchers.ts +51 -0
- package/src/testing/e2e/page-helpers.ts +272 -0
- package/src/testing/e2e/parity.ts +387 -0
- package/src/testing/e2e/server.ts +195 -0
- package/src/testing/flight-matchers.ts +110 -0
- package/src/testing/flight-normalize.ts +38 -0
- package/src/testing/flight-runtime.d.ts +57 -0
- package/src/testing/flight-tree.ts +682 -0
- package/src/testing/flight.entry.ts +52 -0
- package/src/testing/flight.ts +234 -0
- package/src/testing/generated-routes.ts +223 -0
- package/src/testing/index.ts +119 -0
- package/src/testing/internal/context.ts +390 -0
- package/src/testing/internal/flight-client-globals.ts +30 -0
- package/src/testing/internal/seed-vars.ts +80 -0
- package/src/testing/render-handler.ts +360 -0
- package/src/testing/render-route.tsx +594 -0
- package/src/testing/run-loader.ts +474 -0
- package/src/testing/run-middleware.ts +231 -0
- package/src/testing/vitest-stubs/cloudflare-email.ts +9 -0
- package/src/testing/vitest-stubs/cloudflare-workers.ts +21 -0
- package/src/testing/vitest-stubs/plugin-rsc.ts +16 -0
- package/src/testing/vitest-stubs/version.ts +5 -0
- package/src/testing/vitest.ts +305 -0
- package/src/types/cache-types.ts +13 -4
- package/src/types/error-types.ts +5 -1
- package/src/types/global-namespace.ts +11 -1
- package/src/types/handler-context.ts +16 -5
- package/src/browser/react/use-client-cache.ts +0 -58
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cache Tag Invalidation API
|
|
3
|
+
*
|
|
4
|
+
* Two on-demand invalidation verbs, mirroring the distinction popularized by
|
|
5
|
+
* Next.js so consumers can pick the right consistency model:
|
|
6
|
+
*
|
|
7
|
+
* - updateTag(...tags): read-your-own-writes. Awaitable - resolves only after
|
|
8
|
+
* in-process invalidation across every configured store completes. Use in a
|
|
9
|
+
* Server Action and `await` it before the action re-renders, so the action's
|
|
10
|
+
* own response reflects the mutation.
|
|
11
|
+
*
|
|
12
|
+
* - revalidateTag(...tags): fire-and-forget via waitUntil - the response is not
|
|
13
|
+
* blocked. Use in Route Handlers / webhooks. NOTE: both verbs hard-purge; the
|
|
14
|
+
* only difference is awaitability. revalidateTag does NOT serve stale content -
|
|
15
|
+
* the next read after the invalidation lands is a hard miss that re-renders.
|
|
16
|
+
* (The name mirrors Next.js, where it is SWR; here it is background-purge.)
|
|
17
|
+
*
|
|
18
|
+
* Both fan out across the app-level store (ctx._cacheStore) and every explicit
|
|
19
|
+
* per-scope store from cache({ store }) registered for this handler
|
|
20
|
+
* (ctx._explicitTaggedStores), calling the store-level invalidateTags()
|
|
21
|
+
* primitive for each tag. A single configured store (the common case) owns its
|
|
22
|
+
* own tag index and distributed invalidation - there is no separate
|
|
23
|
+
* tag-invalidation store.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { _getRequestContext } from "../server/request-context.js";
|
|
27
|
+
import { reportingAsync } from "./cache-error.js";
|
|
28
|
+
import { normalizeTags } from "./cache-tag.js";
|
|
29
|
+
import type { SegmentCacheStore } from "./types.js";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Collect every store that may hold entries tagged for this request's handler:
|
|
33
|
+
* the app-level store plus all explicit per-scope stores (deduplicated). Splits
|
|
34
|
+
* them into tag-capable (implement invalidateTags()) and not, so callers can
|
|
35
|
+
* warn about configured stores whose tagged entries will NOT be invalidated.
|
|
36
|
+
*
|
|
37
|
+
* `hasContext` reports whether an ALS request context existed at all. Without one
|
|
38
|
+
* (e.g. a queue consumer or cron job calling updateTag/revalidateTag) no stores
|
|
39
|
+
* are reachable, and the empty-capable case is a missing-context problem, not a
|
|
40
|
+
* store-config problem - callers branch on this to warn about the right cause.
|
|
41
|
+
*/
|
|
42
|
+
function collectStores(): {
|
|
43
|
+
capable: SegmentCacheStore[];
|
|
44
|
+
incapable: number;
|
|
45
|
+
hasContext: boolean;
|
|
46
|
+
} {
|
|
47
|
+
const ctx = _getRequestContext();
|
|
48
|
+
const stores = new Set<SegmentCacheStore>();
|
|
49
|
+
if (ctx?._cacheStore) stores.add(ctx._cacheStore);
|
|
50
|
+
if (ctx?._explicitTaggedStores) {
|
|
51
|
+
for (const store of ctx._explicitTaggedStores) stores.add(store);
|
|
52
|
+
}
|
|
53
|
+
const capable: SegmentCacheStore[] = [];
|
|
54
|
+
let incapable = 0;
|
|
55
|
+
for (const store of stores) {
|
|
56
|
+
if (typeof store.invalidateTags === "function") capable.push(store);
|
|
57
|
+
else incapable++;
|
|
58
|
+
}
|
|
59
|
+
return { capable, incapable, hasContext: ctx != null };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Production-visible warning. A misconfigured store silently dropping
|
|
64
|
+
* invalidations is a data-correctness footgun, so this surfaces in every
|
|
65
|
+
* environment (not dev-only).
|
|
66
|
+
*/
|
|
67
|
+
function warnNoTagStore(fn: string, tags: string[]): void {
|
|
68
|
+
console.warn(
|
|
69
|
+
`[${fn}] No tag-capable cache store is configured; tags ` +
|
|
70
|
+
`[${tags.join(", ")}] were not invalidated. The configured store must ` +
|
|
71
|
+
`implement invalidateTags() (the built-in MemorySegmentCacheStore and ` +
|
|
72
|
+
`CFCacheStore do).`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Production-visible warning for the no-request-context case. Distinct from
|
|
78
|
+
* warnNoTagStore: the stores are not unreachable because they are misconfigured,
|
|
79
|
+
* but because there is no ALS request context to reach them through (e.g. a queue
|
|
80
|
+
* consumer or scheduled job). Naming the real cause keeps consumers from chasing
|
|
81
|
+
* a store-config red herring.
|
|
82
|
+
*/
|
|
83
|
+
function warnNoRequestContext(fn: string, tags: string[]): void {
|
|
84
|
+
console.warn(
|
|
85
|
+
`[${fn}] Called outside a request context (e.g. from a queue consumer or ` +
|
|
86
|
+
`scheduled job); no cache stores are reachable and tags ` +
|
|
87
|
+
`[${tags.join(", ")}] were not invalidated. Invoke it within a request ` +
|
|
88
|
+
`(Server Action or route handler).`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Production-visible warning for mixed-store configs: at least one configured
|
|
94
|
+
* store does not support tag invalidation, so its tagged entries (if any) are
|
|
95
|
+
* left stale even though other stores were invalidated.
|
|
96
|
+
*/
|
|
97
|
+
function warnPartialTagStore(fn: string, incapable: number): void {
|
|
98
|
+
console.warn(
|
|
99
|
+
`[${fn}] ${incapable} configured cache store(s) do not implement ` +
|
|
100
|
+
`invalidateTags(); their tagged entries were NOT invalidated. Use a ` +
|
|
101
|
+
`tag-capable store (e.g. MemorySegmentCacheStore / CFCacheStore) for any ` +
|
|
102
|
+
`cache({ store }) boundary whose entries you invalidate by tag.`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function invalidateAcross(
|
|
107
|
+
stores: SegmentCacheStore[],
|
|
108
|
+
tags: string[],
|
|
109
|
+
): Promise<void> {
|
|
110
|
+
// One invalidateTags(tags) call per store: the store receives the whole tag
|
|
111
|
+
// batch so it can do a single CDN purge request rather than one per tag.
|
|
112
|
+
//
|
|
113
|
+
// allSettled, not all: a store's invalidateTags() can reject (e.g. CFCacheStore
|
|
114
|
+
// surfaces a failed durable KV marker write). With Promise.all, the first
|
|
115
|
+
// rejection would short-circuit and the other stores' outcomes would go
|
|
116
|
+
// unobserved. Attempt every store, then surface a combined error so an awaited
|
|
117
|
+
// updateTag() still rejects (read-your-own-writes honesty) without masking the
|
|
118
|
+
// stores that did succeed.
|
|
119
|
+
const results = await Promise.allSettled(
|
|
120
|
+
stores.map((store) => store.invalidateTags!(tags)),
|
|
121
|
+
);
|
|
122
|
+
const errors = results
|
|
123
|
+
.filter((r): r is PromiseRejectedResult => r.status === "rejected")
|
|
124
|
+
.map((r) => r.reason);
|
|
125
|
+
if (errors.length > 0) {
|
|
126
|
+
const err = new Error(
|
|
127
|
+
`[tag invalidation] ${errors.length}/${stores.length} store(s) failed to ` +
|
|
128
|
+
`invalidate tags [${tags.join(", ")}]; their entries may still serve ` +
|
|
129
|
+
`stale data. Retry the invalidation.`,
|
|
130
|
+
);
|
|
131
|
+
(err as Error & { cause?: unknown }).cause = errors[0];
|
|
132
|
+
throw err;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Immediately expire every cache entry tagged with any of `tags`, resolving
|
|
138
|
+
* once in-process invalidation across all configured stores completes.
|
|
139
|
+
*
|
|
140
|
+
* Read-your-own-writes: because the returned promise resolves before you return
|
|
141
|
+
* from a Server Action, awaiting it guarantees the action's own re-render (and
|
|
142
|
+
* any subsequent read) sees fresh data.
|
|
143
|
+
*
|
|
144
|
+
* @example
|
|
145
|
+
* ```typescript
|
|
146
|
+
* async function updateProduct(formData: FormData) {
|
|
147
|
+
* "use server";
|
|
148
|
+
* await db.updateProduct(formData);
|
|
149
|
+
* await updateTag("products"); // next render is fresh
|
|
150
|
+
* }
|
|
151
|
+
* ```
|
|
152
|
+
*/
|
|
153
|
+
export async function updateTag(...tags: string[]): Promise<void> {
|
|
154
|
+
const valid = normalizeTags(tags);
|
|
155
|
+
if (valid.length === 0) return;
|
|
156
|
+
|
|
157
|
+
const { capable, incapable, hasContext } = collectStores();
|
|
158
|
+
if (capable.length === 0) {
|
|
159
|
+
if (hasContext) warnNoTagStore("updateTag", valid);
|
|
160
|
+
else warnNoRequestContext("updateTag", valid);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (incapable > 0) warnPartialTagStore("updateTag", incapable);
|
|
164
|
+
|
|
165
|
+
await invalidateAcross(capable, valid);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Invalidate every cache entry tagged with any of `tags` in the background,
|
|
170
|
+
* without blocking the current response (fire-and-forget via waitUntil).
|
|
171
|
+
*
|
|
172
|
+
* This is NOT stale-while-revalidate: like updateTag() it hard-purges, so the
|
|
173
|
+
* next read after the invalidation lands is a miss that re-renders fresh. The
|
|
174
|
+
* only difference from updateTag() is awaitability - revalidateTag() defers the
|
|
175
|
+
* purge off the response path and is not awaited.
|
|
176
|
+
*
|
|
177
|
+
* Use in Route Handlers / webhooks. For read-your-own-writes inside a Server
|
|
178
|
+
* Action, use updateTag() instead so the action's own response is fresh.
|
|
179
|
+
*
|
|
180
|
+
* Fire-and-forget: because this returns void and runs in the background, a
|
|
181
|
+
* failed durable marker write (e.g. a transient KV outage) is NOT surfaced to
|
|
182
|
+
* the caller. It IS reported - logged loudly and routed through the router's
|
|
183
|
+
* `onError` callback (phase `cache`, `metadata.category === "cache-invalidate"`)
|
|
184
|
+
* via reportingAsync - so the failure is observable in telemetry even though it
|
|
185
|
+
* cannot be awaited. If you need the invalidation to be CONFIRMED (and to retry
|
|
186
|
+
* on failure), use `await updateTag()` instead, which rejects when a store's
|
|
187
|
+
* durable write fails.
|
|
188
|
+
*
|
|
189
|
+
* @example
|
|
190
|
+
* ```typescript
|
|
191
|
+
* // route handler invoked by an external webhook
|
|
192
|
+
* export async function POST() {
|
|
193
|
+
* "use server";
|
|
194
|
+
* revalidateTag("products");
|
|
195
|
+
* return new Response("ok");
|
|
196
|
+
* }
|
|
197
|
+
* ```
|
|
198
|
+
*/
|
|
199
|
+
export function revalidateTag(...tags: string[]): void {
|
|
200
|
+
const valid = normalizeTags(tags);
|
|
201
|
+
if (valid.length === 0) return;
|
|
202
|
+
|
|
203
|
+
const { capable, incapable, hasContext } = collectStores();
|
|
204
|
+
if (capable.length === 0) {
|
|
205
|
+
if (hasContext) warnNoTagStore("revalidateTag", valid);
|
|
206
|
+
else warnNoRequestContext("revalidateTag", valid);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (incapable > 0) warnPartialTagStore("revalidateTag", incapable);
|
|
210
|
+
|
|
211
|
+
const ctx = _getRequestContext();
|
|
212
|
+
// reportingAsync never rejects: it catches a failed durable write and routes
|
|
213
|
+
// it through reportCacheError (loud log + onError). This is the only place a
|
|
214
|
+
// revalidateTag failure can be observed, since it is not awaitable. Pass ctx
|
|
215
|
+
// explicitly - the run executes in a detached waitUntil where the ALS context
|
|
216
|
+
// is gone, so onError fires only if we hand it the captured context.
|
|
217
|
+
const run = () =>
|
|
218
|
+
reportingAsync(
|
|
219
|
+
() => invalidateAcross(capable, valid),
|
|
220
|
+
"cache-invalidate",
|
|
221
|
+
"[revalidateTag] background invalidation",
|
|
222
|
+
ctx,
|
|
223
|
+
);
|
|
224
|
+
if (ctx?.waitUntil) {
|
|
225
|
+
ctx.waitUntil(run);
|
|
226
|
+
} else {
|
|
227
|
+
// No request context (e.g. called outside ALS): best-effort background run.
|
|
228
|
+
void run();
|
|
229
|
+
}
|
|
230
|
+
}
|
package/src/cache/types.ts
CHANGED
|
@@ -136,12 +136,14 @@ export interface SegmentCacheStore<TEnv = unknown> {
|
|
|
136
136
|
* @param response - Response to cache (will be cloned)
|
|
137
137
|
* @param ttl - Time-to-live in seconds
|
|
138
138
|
* @param swr - Optional stale-while-revalidate window in seconds
|
|
139
|
+
* @param tags - Optional cache tags for invalidation
|
|
139
140
|
*/
|
|
140
141
|
putResponse?(
|
|
141
142
|
key: string,
|
|
142
143
|
response: Response,
|
|
143
144
|
ttl: number,
|
|
144
145
|
swr?: number,
|
|
146
|
+
tags?: string[],
|
|
145
147
|
): Promise<void>;
|
|
146
148
|
|
|
147
149
|
// ============================================================================
|
|
@@ -167,6 +169,20 @@ export interface SegmentCacheStore<TEnv = unknown> {
|
|
|
167
169
|
value: string,
|
|
168
170
|
options?: CacheItemOptions,
|
|
169
171
|
): Promise<void>;
|
|
172
|
+
|
|
173
|
+
// ============================================================================
|
|
174
|
+
// Tag-based Invalidation (optional)
|
|
175
|
+
// ============================================================================
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Invalidate every cache entry (segment, response, item) tagged with any of
|
|
179
|
+
* `tags`. Store-level primitive that the public updateTag()/revalidateTag()
|
|
180
|
+
* APIs delegate to. Receives ALL of one invalidation call's tags at once so
|
|
181
|
+
* stores can batch their work (e.g. a single CDN purge request rather than
|
|
182
|
+
* one per tag). Stores that do not support tags simply omit this method.
|
|
183
|
+
* @param tags - The cache tags to invalidate
|
|
184
|
+
*/
|
|
185
|
+
invalidateTags?(tags: string[]): Promise<void>;
|
|
170
186
|
}
|
|
171
187
|
|
|
172
188
|
/**
|
|
@@ -181,6 +197,13 @@ export interface CacheItemResult {
|
|
|
181
197
|
handles?: string;
|
|
182
198
|
/** Whether the entry is stale and should be revalidated */
|
|
183
199
|
shouldRevalidate: boolean;
|
|
200
|
+
/**
|
|
201
|
+
* The entry's cache tags (including runtime cacheTag() tags), surfaced on read
|
|
202
|
+
* so a "use cache" HIT can still contribute its tags to the request-scoped tag
|
|
203
|
+
* set used by document-level caching. On a hit the cached function is not
|
|
204
|
+
* re-run, so its runtime tags are only available here, not from re-execution.
|
|
205
|
+
*/
|
|
206
|
+
tags?: string[];
|
|
184
207
|
}
|
|
185
208
|
|
|
186
209
|
/**
|
|
@@ -235,6 +258,10 @@ export interface CachedEntryData {
|
|
|
235
258
|
handles: string;
|
|
236
259
|
/** Expiration timestamp (ms since epoch) */
|
|
237
260
|
expiresAt: number;
|
|
261
|
+
/** Cache tags for invalidation */
|
|
262
|
+
tags?: string[];
|
|
263
|
+
/** Timestamp (ms since epoch) when tags were attached, for distributed invalidation */
|
|
264
|
+
taggedAt?: number;
|
|
238
265
|
}
|
|
239
266
|
|
|
240
267
|
// ============================================================================
|
package/src/client.rsc.tsx
CHANGED
|
@@ -50,7 +50,7 @@ export { href } from "./href-client.js";
|
|
|
50
50
|
// Mount context re-exports (useMount is client-only, but MountContext can be referenced)
|
|
51
51
|
export { MountContext } from "./browser/react/mount-context.js";
|
|
52
52
|
|
|
53
|
-
// Note: useNavigation
|
|
53
|
+
// Note: useNavigation and useAction are NOT re-exported here
|
|
54
54
|
// because they use client-side state and should only be used in client components
|
|
55
55
|
|
|
56
56
|
// Handle API - for accumulating data across route segments
|
package/src/client.tsx
CHANGED
|
@@ -358,12 +358,6 @@ export {
|
|
|
358
358
|
type SegmentsState,
|
|
359
359
|
} from "./browser/react/use-segments.js";
|
|
360
360
|
|
|
361
|
-
// Client cache controls hook
|
|
362
|
-
export {
|
|
363
|
-
useClientCache,
|
|
364
|
-
type ClientCacheControls,
|
|
365
|
-
} from "./browser/react/use-client-cache.js";
|
|
366
|
-
|
|
367
361
|
// Provider
|
|
368
362
|
export {
|
|
369
363
|
NavigationProvider,
|
package/src/component-utils.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { ComponentType } from "react";
|
|
8
|
+
import { isUnderTestRunner } from "./runtime-env.js";
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Symbol used by React to mark client component references.
|
|
@@ -48,11 +49,21 @@ export function isClientComponent(
|
|
|
48
49
|
*
|
|
49
50
|
* @param component - The component to check
|
|
50
51
|
* @param name - Name to use in error message (e.g., "document")
|
|
52
|
+
* @param opts.allowServerInTest - When true AND running under a test runner
|
|
53
|
+
* (`isUnderTestRunner()`), relax ONLY the "use client" requirement: a server
|
|
54
|
+
* component is accepted. The plugin's "use client" transform does not run in a
|
|
55
|
+
* bare unit test, so a real exported `document` (almost every app sets one) has
|
|
56
|
+
* no client marker and would otherwise throw at `createRouter`, blocking
|
|
57
|
+
* `dispatch`/`assertGeneratedRoutesMatch` against the real router. The document
|
|
58
|
+
* reference is irrelevant to those (no Flight render). The "not a JSX element"
|
|
59
|
+
* guard still fires, and a real dev/build still throws (mirrors the runtime
|
|
60
|
+
* fallback-id gating in handle.ts/loader.ts).
|
|
51
61
|
* @throws Error if the component is not a client component
|
|
52
62
|
*/
|
|
53
63
|
export function assertClientComponent(
|
|
54
64
|
component: ComponentType<unknown> | unknown,
|
|
55
65
|
name: string,
|
|
66
|
+
opts?: { allowServerInTest?: boolean },
|
|
56
67
|
): asserts component is ComponentType<unknown> {
|
|
57
68
|
if (typeof component !== "function") {
|
|
58
69
|
throw new Error(
|
|
@@ -62,6 +73,14 @@ export function assertClientComponent(
|
|
|
62
73
|
);
|
|
63
74
|
}
|
|
64
75
|
|
|
76
|
+
// Under a test runner the "use client" transform did not run, so a real
|
|
77
|
+
// server-rendered `document` has no client marker; accept it (the reference is
|
|
78
|
+
// never serialized in dispatch/route-map checks). Outside a test runner this
|
|
79
|
+
// still throws — the build-time safety net is preserved.
|
|
80
|
+
if (opts?.allowServerInTest && isUnderTestRunner()) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
65
84
|
if (!isClientComponent(component)) {
|
|
66
85
|
throw new Error(
|
|
67
86
|
`${name} must be a client component with "use client" directive at the top of the file. ` +
|
package/src/handle.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { missingInjectedIdError } from "./missing-id-error.js";
|
|
2
|
+
import { isUnderTestRunner } from "./runtime-env.js";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Handle definition for accumulating data across route segments.
|
|
@@ -45,6 +46,11 @@ function defaultCollect<T>(segments: T[][]): T[] {
|
|
|
45
46
|
// Used by useHandle() to recover collect when handle is deserialized from RSC prop.
|
|
46
47
|
const collectRegistry = new Map<string, (segments: unknown[][]) => unknown>();
|
|
47
48
|
|
|
49
|
+
// Monotonic counter for runtime fallback ids (see createHandle). Module-scoped
|
|
50
|
+
// and deterministic, so each createHandle() call gets a stable, unique id within
|
|
51
|
+
// the process. Only used when no build id was injected (a bare unit test).
|
|
52
|
+
let runtimeHandleIdCounter = 0;
|
|
53
|
+
|
|
48
54
|
/**
|
|
49
55
|
* Look up a collect function from the registry by handle $$id.
|
|
50
56
|
* Returns undefined if not registered (falls back to defaultCollect in useHandle).
|
|
@@ -95,10 +101,26 @@ export function createHandle<TData, TAccumulated = TData[]>(
|
|
|
95
101
|
collect?: (segments: TData[][]) => TAccumulated,
|
|
96
102
|
__injectedId?: string,
|
|
97
103
|
): Handle<TData, TAccumulated> {
|
|
98
|
-
|
|
104
|
+
let handleId = __injectedId ?? "";
|
|
99
105
|
|
|
100
|
-
|
|
101
|
-
|
|
106
|
+
// No build-injected id. Under a test runner: fall back to a synthetic id so the
|
|
107
|
+
// collect registers below and the handle is exercisable in tests (useHandle,
|
|
108
|
+
// collectHandle, renderRoute's `handles` run the REAL collect). Otherwise (dev
|
|
109
|
+
// or a real build) it means an UNSUPPORTED handler shape the plugin skipped —
|
|
110
|
+
// fail loud. The rich, stack-parsing diagnostic stays behind the NODE_ENV check
|
|
111
|
+
// so a production build folds it away and tree-shakes missing-id-error.ts out,
|
|
112
|
+
// shipping the small throw instead. isUnderTestRunner() is runtime-safe.
|
|
113
|
+
if (!handleId) {
|
|
114
|
+
if (isUnderTestRunner()) {
|
|
115
|
+
handleId = `__rango_runtime_handle_${runtimeHandleIdCounter++}`;
|
|
116
|
+
} else if (process.env.NODE_ENV !== "production") {
|
|
117
|
+
throw missingInjectedIdError("Handle", "createHandle");
|
|
118
|
+
} else {
|
|
119
|
+
throw new Error(
|
|
120
|
+
"[rango] Handle is missing $$id — the build plugin did not inject one. " +
|
|
121
|
+
"Export it as `export const X = createHandle(...)`.",
|
|
122
|
+
);
|
|
123
|
+
}
|
|
102
124
|
}
|
|
103
125
|
|
|
104
126
|
const collectFn =
|
|
@@ -107,12 +129,10 @@ export function createHandle<TData, TAccumulated = TData[]>(
|
|
|
107
129
|
|
|
108
130
|
// Register collect in module-level registry so useHandle() can recover it
|
|
109
131
|
// when the handle is deserialized from RSC props (toJSON strips collect).
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
);
|
|
115
|
-
}
|
|
132
|
+
collectRegistry.set(
|
|
133
|
+
handleId,
|
|
134
|
+
collectFn as (segments: unknown[][]) => unknown,
|
|
135
|
+
);
|
|
116
136
|
|
|
117
137
|
return {
|
|
118
138
|
__brand: "handle" as const,
|
package/src/host/testing.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Helper functions for testing host routing.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { matchPattern } from "./pattern-matcher.js";
|
|
7
|
+
import { matchPattern, parseRequest } from "./pattern-matcher.js";
|
|
8
8
|
|
|
9
9
|
export interface CreateTestRequestOptions {
|
|
10
10
|
host: string;
|
|
@@ -52,28 +52,57 @@ export function createTestRequest(options: CreateTestRequestOptions): Request {
|
|
|
52
52
|
});
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
// Try each pattern (a single pattern or any in an array) against the already
|
|
56
|
+
// parsed host + path. Shared by testPattern and matchesHost so the
|
|
57
|
+
// normalize-and-loop lives once.
|
|
58
|
+
function matchPatterns(
|
|
59
|
+
pattern: string | string[],
|
|
60
|
+
hostname: string,
|
|
61
|
+
pathname: string,
|
|
62
|
+
parts: string[],
|
|
63
|
+
): boolean {
|
|
64
|
+
const patterns = Array.isArray(pattern) ? pattern : [pattern];
|
|
65
|
+
return patterns.some((p) => matchPattern(p, hostname, pathname, parts));
|
|
66
|
+
}
|
|
67
|
+
|
|
55
68
|
/**
|
|
56
|
-
* Test if a pattern matches a hostname
|
|
69
|
+
* Test if a pattern matches a hostname (and, for path-based patterns, a pathname).
|
|
70
|
+
*
|
|
71
|
+
* `pathname` defaults to `"/"`, so a host-only pattern works with two args. Pass
|
|
72
|
+
* the third arg to test a path-based pattern (`**.workers.dev/admin`,
|
|
73
|
+
* `localhost/shop`) — without it those patterns can never match.
|
|
57
74
|
*
|
|
58
75
|
* @example
|
|
59
76
|
* ```ts
|
|
60
|
-
* expect(testPattern(
|
|
61
|
-
* expect(testPattern([
|
|
77
|
+
* expect(testPattern("admin.*", "admin.example.com")).toBe(true);
|
|
78
|
+
* expect(testPattern(["*", "www.*"], "example.com")).toBe(true);
|
|
79
|
+
* expect(testPattern("**.workers.dev/admin", "foo.workers.dev", "/admin")).toBe(true);
|
|
62
80
|
* ```
|
|
63
81
|
*/
|
|
64
82
|
export function testPattern(
|
|
65
83
|
pattern: string | string[],
|
|
66
84
|
hostname: string,
|
|
85
|
+
pathname: string = "/",
|
|
67
86
|
): boolean {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const pathname = "/";
|
|
71
|
-
|
|
72
|
-
for (const p of patterns) {
|
|
73
|
-
if (matchPattern(p, hostname, pathname, parts)) {
|
|
74
|
-
return true;
|
|
75
|
-
}
|
|
76
|
-
}
|
|
87
|
+
return matchPatterns(pattern, hostname, pathname, hostname.split("."));
|
|
88
|
+
}
|
|
77
89
|
|
|
78
|
-
|
|
90
|
+
/**
|
|
91
|
+
* Test if a pattern matches a `Request` — the hostname AND pathname are taken
|
|
92
|
+
* from the request URL (via the same `parseRequest` the host router uses), so a
|
|
93
|
+
* path-based pattern is tested against a real request without splitting the URL
|
|
94
|
+
* by hand.
|
|
95
|
+
*
|
|
96
|
+
* @example
|
|
97
|
+
* ```ts
|
|
98
|
+
* const req = new Request("https://foo.workers.dev/admin");
|
|
99
|
+
* expect(matchesHost("**.workers.dev/admin", req)).toBe(true);
|
|
100
|
+
* ```
|
|
101
|
+
*/
|
|
102
|
+
export function matchesHost(
|
|
103
|
+
pattern: string | string[],
|
|
104
|
+
request: Request,
|
|
105
|
+
): boolean {
|
|
106
|
+
const { hostname, pathname, parts } = parseRequest(request);
|
|
107
|
+
return matchPatterns(pattern, hostname, pathname, parts);
|
|
79
108
|
}
|
package/src/index.rsc.ts
CHANGED
|
@@ -184,11 +184,20 @@ export const getRequestContext: <
|
|
|
184
184
|
export {
|
|
185
185
|
cookies,
|
|
186
186
|
headers,
|
|
187
|
+
invalidateClientCache,
|
|
188
|
+
keepClientCache,
|
|
187
189
|
type CookieStore,
|
|
188
190
|
type Cookie,
|
|
189
191
|
type ReadonlyHeaders,
|
|
190
192
|
} from "./server/cookie-store.js";
|
|
191
193
|
|
|
194
|
+
// Cache tag APIs (server-only)
|
|
195
|
+
// cacheTag: tag the current "use cache" entry at runtime.
|
|
196
|
+
// updateTag: read-your-own-writes invalidation (awaitable, for Server Actions).
|
|
197
|
+
// revalidateTag: background hard-purge invalidation (not awaited, for route handlers / webhooks).
|
|
198
|
+
export { cacheTag } from "./cache/cache-tag.js";
|
|
199
|
+
export { updateTag, revalidateTag } from "./cache/tag-invalidation.js";
|
|
200
|
+
|
|
192
201
|
// Meta types
|
|
193
202
|
export type { MetaDescriptor, MetaDescriptorBase } from "./router/types.js";
|
|
194
203
|
|
|
@@ -228,7 +237,26 @@ export {
|
|
|
228
237
|
export { createConsoleSink } from "./router/telemetry.js";
|
|
229
238
|
export { createOTelSink } from "./router/telemetry-otel.js";
|
|
230
239
|
export type { OTelTracer, OTelSpan } from "./router/telemetry-otel.js";
|
|
231
|
-
|
|
240
|
+
// The full TelemetryEvent union PLUS its member types, so a consumer writing a
|
|
241
|
+
// TelemetrySink can annotate a per-`type` handler (or construct an event literal
|
|
242
|
+
// in a test) instead of only narrowing the opaque union.
|
|
243
|
+
export type {
|
|
244
|
+
TelemetrySink,
|
|
245
|
+
TelemetryEvent,
|
|
246
|
+
RequestStartEvent,
|
|
247
|
+
RequestEndEvent,
|
|
248
|
+
RequestErrorEvent,
|
|
249
|
+
LoaderStartEvent,
|
|
250
|
+
LoaderEndEvent,
|
|
251
|
+
LoaderErrorEvent,
|
|
252
|
+
HandlerErrorEvent,
|
|
253
|
+
CacheSegmentStatus,
|
|
254
|
+
CacheSegmentSignal,
|
|
255
|
+
CacheDecisionEvent,
|
|
256
|
+
RevalidationDecisionEvent,
|
|
257
|
+
RequestTimeoutEvent,
|
|
258
|
+
OriginCheckRejectedEvent,
|
|
259
|
+
} from "./router/telemetry.js";
|
|
232
260
|
|
|
233
261
|
// Timeout types and error class
|
|
234
262
|
export { RouterTimeoutError } from "./router/timeout.js";
|
package/src/index.ts
CHANGED
|
@@ -217,6 +217,17 @@ export function headers(): never {
|
|
|
217
217
|
throw serverOnlyStubError("headers");
|
|
218
218
|
}
|
|
219
219
|
|
|
220
|
+
/**
|
|
221
|
+
* Client implementation of `invalidateClientCache()`. Unlike the server-only
|
|
222
|
+
* stubs above this is a REAL function under the `default` condition (it marks
|
|
223
|
+
* the client's caches stale); the `react-server` condition (index.rsc.ts)
|
|
224
|
+
* selects the server implementation that writes a rotated `Set-Cookie`.
|
|
225
|
+
*/
|
|
226
|
+
export {
|
|
227
|
+
invalidateClientCache,
|
|
228
|
+
keepClientCache,
|
|
229
|
+
} from "./browser/invalidate-client-cache.js";
|
|
230
|
+
|
|
220
231
|
/**
|
|
221
232
|
* Error-throwing stub for server-only `createReverse` function.
|
|
222
233
|
*/
|
|
@@ -237,6 +248,18 @@ export function middleware(): never {
|
|
|
237
248
|
export function revalidate(): never {
|
|
238
249
|
throw serverOnlyStubError("revalidate");
|
|
239
250
|
}
|
|
251
|
+
// Cache tag APIs are server-only (real implementations in index.rsc.ts). These
|
|
252
|
+
// stubs keep the named-export shape identical under the default/non-react-server
|
|
253
|
+
// condition so SSR/client/default bundles that encounter the import link cleanly.
|
|
254
|
+
export function cacheTag(): never {
|
|
255
|
+
throw serverOnlyStubError("cacheTag");
|
|
256
|
+
}
|
|
257
|
+
export function updateTag(): never {
|
|
258
|
+
throw serverOnlyStubError("updateTag");
|
|
259
|
+
}
|
|
260
|
+
export function revalidateTag(): never {
|
|
261
|
+
throw serverOnlyStubError("revalidateTag");
|
|
262
|
+
}
|
|
240
263
|
export function loader(): never {
|
|
241
264
|
throw serverOnlyStubError("loader");
|
|
242
265
|
}
|
|
@@ -315,7 +338,26 @@ export {
|
|
|
315
338
|
// who need the values in non-RSC contexts can import from
|
|
316
339
|
// `@rangojs/router/server`.
|
|
317
340
|
export type { OTelTracer, OTelSpan } from "./router/telemetry-otel.js";
|
|
318
|
-
|
|
341
|
+
// The full TelemetryEvent union PLUS its member types, so a consumer writing a
|
|
342
|
+
// TelemetrySink can annotate a per-`type` handler (or construct an event literal
|
|
343
|
+
// in a test) instead of only narrowing the opaque union.
|
|
344
|
+
export type {
|
|
345
|
+
TelemetrySink,
|
|
346
|
+
TelemetryEvent,
|
|
347
|
+
RequestStartEvent,
|
|
348
|
+
RequestEndEvent,
|
|
349
|
+
RequestErrorEvent,
|
|
350
|
+
LoaderStartEvent,
|
|
351
|
+
LoaderEndEvent,
|
|
352
|
+
LoaderErrorEvent,
|
|
353
|
+
HandlerErrorEvent,
|
|
354
|
+
CacheSegmentStatus,
|
|
355
|
+
CacheSegmentSignal,
|
|
356
|
+
CacheDecisionEvent,
|
|
357
|
+
RevalidationDecisionEvent,
|
|
358
|
+
RequestTimeoutEvent,
|
|
359
|
+
OriginCheckRejectedEvent,
|
|
360
|
+
} from "./router/telemetry.js";
|
|
319
361
|
|
|
320
362
|
// Timeout types and error class
|
|
321
363
|
export { RouterTimeoutError } from "./router/timeout.js";
|
package/src/loader.rsc.ts
CHANGED
|
@@ -22,9 +22,14 @@ import {
|
|
|
22
22
|
getFetchableLoader,
|
|
23
23
|
} from "./server/fetchable-loader-store.js";
|
|
24
24
|
import { missingInjectedIdError } from "./missing-id-error.js";
|
|
25
|
+
import { isUnderTestRunner } from "./runtime-env.js";
|
|
25
26
|
|
|
26
27
|
export { getFetchableLoader };
|
|
27
28
|
|
|
29
|
+
// Counter for runtime-fallback loader ids assigned only in a bare unit test
|
|
30
|
+
// (no Vite plugin to inject one). Process-stable; never reached in a real build.
|
|
31
|
+
let runtimeLoaderIdCounter = 0;
|
|
32
|
+
|
|
28
33
|
// Overload 1: With function only (not fetchable)
|
|
29
34
|
export function createLoader<T>(
|
|
30
35
|
fn: LoaderFn<T, Record<string, string | undefined>, any>,
|
|
@@ -51,10 +56,26 @@ export function createLoader<T>(
|
|
|
51
56
|
): LoaderDefinition<Awaited<T>, Record<string, string | undefined>> {
|
|
52
57
|
// The $$id will be set on the returned object by Vite plugin
|
|
53
58
|
// For fetchable loaders, __injectedId is also passed as a parameter
|
|
54
|
-
|
|
59
|
+
let loaderId = __injectedId || "";
|
|
55
60
|
|
|
56
|
-
|
|
57
|
-
|
|
61
|
+
// No build-injected id. Under a test runner: fall back to a synthetic id so the
|
|
62
|
+
// fn registers below and the loader is exercisable via runLoader(loaderHandle)
|
|
63
|
+
// (it recovers the fn from the registry by $$id). Otherwise (dev or a real
|
|
64
|
+
// build) it means an UNSUPPORTED shape (e.g. a namespace import
|
|
65
|
+
// `rango.createLoader(...)`) the plugin skipped — fail loud. The rich
|
|
66
|
+
// diagnostic stays behind the NODE_ENV check so production folds it away and
|
|
67
|
+
// ships the small throw. isUnderTestRunner() is runtime-safe. Mirrors createHandle.
|
|
68
|
+
if (!loaderId) {
|
|
69
|
+
if (isUnderTestRunner()) {
|
|
70
|
+
loaderId = `__rango_runtime_loader_${runtimeLoaderIdCounter++}`;
|
|
71
|
+
} else if (process.env.NODE_ENV !== "production") {
|
|
72
|
+
throw missingInjectedIdError("Loader", "createLoader");
|
|
73
|
+
} else {
|
|
74
|
+
throw new Error(
|
|
75
|
+
"[rango] Loader is missing $$id — the build plugin did not inject one. " +
|
|
76
|
+
"Export it as `export const X = createLoader(...)`.",
|
|
77
|
+
);
|
|
78
|
+
}
|
|
58
79
|
}
|
|
59
80
|
|
|
60
81
|
// If not fetchable, store fn in registry (for SSR ctx.use() resolution)
|