@gleanql/client 0.1.0
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/LICENSE +21 -0
- package/README.md +39 -0
- package/dist/index.d.mts +954 -0
- package/dist/index.mjs +1412 -0
- package/package.json +57 -0
- package/src/adapter-shared.ts +76 -0
- package/src/adapter-ws.ts +134 -0
- package/src/adapter.ts +152 -0
- package/src/cache-resolve.ts +98 -0
- package/src/cache.ts +341 -0
- package/src/context.ts +67 -0
- package/src/glue-client.ts +1000 -0
- package/src/glue-server.ts +46 -0
- package/src/index.ts +30 -0
- package/src/integration.ts +201 -0
- package/src/mutation.ts +171 -0
- package/src/mutator.ts +58 -0
- package/src/normalize.ts +101 -0
- package/src/paginate.ts +361 -0
- package/src/persisted.ts +73 -0
- package/src/proxy.ts +288 -0
- package/src/reactivity.ts +149 -0
- package/src/route.ts +84 -0
- package/src/runtime.ts +212 -0
- package/src/scope.ts +97 -0
- package/src/serialize.ts +175 -0
- package/src/testing.ts +220 -0
package/src/cache.ts
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Graph cache with two storage identities (per the brief):
|
|
3
|
+
* - Normalized entity storage, keyed by `__typename + id`.
|
|
4
|
+
* - Operation/path storage, keyed by `root + args + path`, for objects with
|
|
5
|
+
* no `id`.
|
|
6
|
+
* Two query paths returning the same `__typename + id` resolve to one record,
|
|
7
|
+
* so an update through any path is visible through all of them.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** A reference to a cached record: an identified entity or a path-anchored object. */
|
|
11
|
+
export interface GraphRef {
|
|
12
|
+
readonly __typename?: string;
|
|
13
|
+
readonly id?: string | number;
|
|
14
|
+
/** Path identity, e.g. `Query.product(handle).featuredImage`. */
|
|
15
|
+
readonly path?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type FieldValue = unknown;
|
|
19
|
+
|
|
20
|
+
export type FieldLookup =
|
|
21
|
+
| { readonly status: "ready"; readonly value: FieldValue }
|
|
22
|
+
| { readonly status: "missing" };
|
|
23
|
+
|
|
24
|
+
/** Separator joining a record key + field key into one field-tracking key (NUL — never in a key). */
|
|
25
|
+
const FIELD_SEP = "\u0000";
|
|
26
|
+
|
|
27
|
+
function fieldTrackingKey(recordKey: string, fieldKey: string): string {
|
|
28
|
+
return recordKey + FIELD_SEP + fieldKey;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class GraphCache {
|
|
32
|
+
private readonly records = new Map<string, Map<string, FieldValue>>();
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Optional LRU cap. The client cache accumulates entities across navigations; a
|
|
36
|
+
* long session would otherwise grow without bound. When set, the least-recently
|
|
37
|
+
* used records are evicted past the cap. Unset (default) = unbounded, so the
|
|
38
|
+
* server's per-request cache and existing callers are unchanged.
|
|
39
|
+
*/
|
|
40
|
+
constructor(private readonly maxRecords?: number) {}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Reactivity substrate. Every write bumps `version` and notifies listeners, so
|
|
44
|
+
* UI can re-render after a mutation, refetch, or peer-tab/subscription update.
|
|
45
|
+
* `version` + `subscribe` are exactly the `useSyncExternalStore` contract.
|
|
46
|
+
*/
|
|
47
|
+
private _version = 0;
|
|
48
|
+
private readonly listeners = new Set<() => void>();
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Version counters for fine-grained reactivity, at two granularities. A component
|
|
52
|
+
* tracks the keys it read during render; its `useSyncExternalStore` snapshot is a
|
|
53
|
+
* digest of those keys' versions, so a global notify only re-renders the components
|
|
54
|
+
* whose keys actually changed (valtio's approach — no per-key subscription fan-out).
|
|
55
|
+
*
|
|
56
|
+
* - `recordVersions` bumps on ANY write to a record (record-level trackers, e.g.
|
|
57
|
+
* `usePaginated` watching a connection).
|
|
58
|
+
* - `fieldVersions` bumps only the written field (field-level trackers, e.g.
|
|
59
|
+
* `useGlean`, so reading `product.title` ignores a write to `product.views`).
|
|
60
|
+
*
|
|
61
|
+
* The global `version`/`subscribe` stay the notify channel; both granularities are
|
|
62
|
+
* resolved through {@link trackedVersion}.
|
|
63
|
+
*/
|
|
64
|
+
private readonly recordVersions = new Map<string, number>();
|
|
65
|
+
private readonly fieldVersions = new Map<string, number>();
|
|
66
|
+
|
|
67
|
+
/** Current version of a record (0 if never written). */
|
|
68
|
+
recordVersion(key: string): number {
|
|
69
|
+
return this.recordVersions.get(key) ?? 0;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Current version of a single field on a record (0 if never written). */
|
|
73
|
+
fieldVersion(recordKey: string, fieldKey: string): number {
|
|
74
|
+
return this.fieldVersions.get(fieldTrackingKey(recordKey, fieldKey)) ?? 0;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The opaque tracking key a field read records; resolve it with {@link trackedVersion}. */
|
|
78
|
+
fieldTrackingKey(recordKey: string, fieldKey: string): string {
|
|
79
|
+
return fieldTrackingKey(recordKey, fieldKey);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Version of a tracked key: a bare record key, or `record\0field` for a single field. */
|
|
83
|
+
trackedVersion(trackingKey: string): number {
|
|
84
|
+
return trackingKey.includes(FIELD_SEP)
|
|
85
|
+
? this.fieldVersions.get(trackingKey) ?? 0
|
|
86
|
+
: this.recordVersion(trackingKey);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Bump a record's version + (optionally) one of its fields' versions. */
|
|
90
|
+
private bumpRecord(key: string, fieldKey?: string): void {
|
|
91
|
+
this.recordVersions.set(key, (this.recordVersions.get(key) ?? 0) + 1);
|
|
92
|
+
if (fieldKey !== undefined) this.bumpField(key, fieldKey);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private bumpField(recordKey: string, fieldKey: string): void {
|
|
96
|
+
const k = fieldTrackingKey(recordKey, fieldKey);
|
|
97
|
+
this.fieldVersions.set(k, (this.fieldVersions.get(k) ?? 0) + 1);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
get version(): number {
|
|
101
|
+
return this._version;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
subscribe(listener: () => void): () => void {
|
|
105
|
+
this.listeners.add(listener);
|
|
106
|
+
return () => this.listeners.delete(listener);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private bump(): void {
|
|
110
|
+
this._version++;
|
|
111
|
+
for (const listener of this.listeners) listener();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Public notify: bump the version + run listeners (e.g. after `absorbRecords`). */
|
|
115
|
+
notify(): void {
|
|
116
|
+
this.bump();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Stable storage key for a ref: entity identity wins over path identity. */
|
|
120
|
+
recordKey(ref: GraphRef): string {
|
|
121
|
+
if (ref.__typename != null && ref.id != null) return `${ref.__typename}:${ref.id}`;
|
|
122
|
+
if (ref.path != null) return `path:${ref.path}`;
|
|
123
|
+
throw new Error("GraphRef requires either (__typename + id) or path");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Reference-counted retention (Relay-style). A mounted reader retains the
|
|
128
|
+
* records it displays; retained records are never LRU-evicted and survive
|
|
129
|
+
* {@link gc}. The tracking hooks do this automatically — each component
|
|
130
|
+
* retains what it read while mounted — so `gc()` is safe to call any time
|
|
131
|
+
* (e.g. on navigation): it can only drop records nothing on screen reads.
|
|
132
|
+
*/
|
|
133
|
+
private readonly retainCounts = new Map<string, number>();
|
|
134
|
+
|
|
135
|
+
/** Pin a record. Returns the matching release; calling it twice is a no-op. */
|
|
136
|
+
retain(key: string): () => void {
|
|
137
|
+
this.stamp(key); // a mount counts as activity for staleness-aware gc
|
|
138
|
+
this.retainCounts.set(key, (this.retainCounts.get(key) ?? 0) + 1);
|
|
139
|
+
let released = false;
|
|
140
|
+
return () => {
|
|
141
|
+
if (released) return;
|
|
142
|
+
released = true;
|
|
143
|
+
const n = this.retainCounts.get(key) ?? 0;
|
|
144
|
+
if (n <= 1) this.retainCounts.delete(key);
|
|
145
|
+
else this.retainCounts.set(key, n - 1);
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
isRetained(key: string): boolean {
|
|
150
|
+
return this.retainCounts.has(key);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The record key a tracked key belongs to (strips the `\0field` part, if any). */
|
|
154
|
+
trackedRecordKey(trackingKey: string): string {
|
|
155
|
+
const i = trackingKey.indexOf(FIELD_SEP);
|
|
156
|
+
return i === -1 ? trackingKey : trackingKey.slice(0, i);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Generation clock for staleness-aware GC. The glue advances it on each page
|
|
161
|
+
* navigation; every read/write/retain stamps the record with the current
|
|
162
|
+
* epoch. "Unretained" alone is NOT a reason to drop data (a back-navigation
|
|
163
|
+
* should hit a warm cache) — `gc({ keepEpochs })` drops only records that are
|
|
164
|
+
* unretained AND haven't been touched for that many generations.
|
|
165
|
+
*/
|
|
166
|
+
private epoch = 0;
|
|
167
|
+
private readonly lastActive = new Map<string, number>();
|
|
168
|
+
|
|
169
|
+
/** Advance the generation clock (call on navigation). Returns the new epoch. */
|
|
170
|
+
advanceEpoch(): number {
|
|
171
|
+
return ++this.epoch;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private stamp(key: string): void {
|
|
175
|
+
this.lastActive.set(key, this.epoch);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Drop unretained records; returns how many were dropped. Version counters
|
|
180
|
+
* survive, so if a dropped record is refetched its trackers still see
|
|
181
|
+
* monotonic versions.
|
|
182
|
+
*
|
|
183
|
+
* - `gc()` — drop EVERY unretained record (a full reset, e.g. logout).
|
|
184
|
+
* - `gc({ keepEpochs: N })` — drop only records also untouched for ≥ N
|
|
185
|
+
* generations (see {@link advanceEpoch}); recently-used data stays warm
|
|
186
|
+
* for back-navigation even though nothing on screen retains it.
|
|
187
|
+
*/
|
|
188
|
+
gc(options: { keepEpochs?: number } = {}): number {
|
|
189
|
+
const { keepEpochs } = options;
|
|
190
|
+
let dropped = 0;
|
|
191
|
+
for (const key of [...this.records.keys()]) {
|
|
192
|
+
if (this.retainCounts.has(key)) continue;
|
|
193
|
+
if (keepEpochs != null && this.epoch - (this.lastActive.get(key) ?? 0) < keepEpochs) continue;
|
|
194
|
+
this.records.delete(key);
|
|
195
|
+
this.lastActive.delete(key);
|
|
196
|
+
dropped++;
|
|
197
|
+
}
|
|
198
|
+
if (dropped > 0) this.bump();
|
|
199
|
+
return dropped;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
hasRecord(ref: GraphRef): boolean {
|
|
203
|
+
return this.records.has(this.recordKey(ref));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Mark a key most-recently-used (Map keeps insertion order; re-insert to bump). No-op when unbounded. */
|
|
207
|
+
private touch(key: string): void {
|
|
208
|
+
if (!this.maxRecords) return;
|
|
209
|
+
const rec = this.records.get(key);
|
|
210
|
+
if (rec) {
|
|
211
|
+
this.records.delete(key);
|
|
212
|
+
this.records.set(key, rec);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Evict least-recently-used records past the cap (Map's first key is the
|
|
218
|
+
* oldest), skipping retained records — a record someone on screen reads is
|
|
219
|
+
* never the eviction victim, even if it's the coldest. If retained records
|
|
220
|
+
* alone exceed the cap, the cache temporarily runs over it.
|
|
221
|
+
*/
|
|
222
|
+
private evict(): void {
|
|
223
|
+
if (!this.maxRecords) return;
|
|
224
|
+
let over = this.records.size - this.maxRecords;
|
|
225
|
+
if (over <= 0) return;
|
|
226
|
+
for (const key of [...this.records.keys()]) {
|
|
227
|
+
if (over <= 0) break;
|
|
228
|
+
if (this.retainCounts.has(key)) continue;
|
|
229
|
+
this.records.delete(key);
|
|
230
|
+
over--;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Get-or-create the record map for a storage key. */
|
|
235
|
+
private ensureRecord(key: string): Map<string, FieldValue> {
|
|
236
|
+
this.stamp(key); // every write is activity
|
|
237
|
+
let rec = this.records.get(key);
|
|
238
|
+
if (!rec) {
|
|
239
|
+
rec = new Map();
|
|
240
|
+
this.records.set(key, rec);
|
|
241
|
+
} else {
|
|
242
|
+
this.touch(key);
|
|
243
|
+
}
|
|
244
|
+
return rec;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
getField(ref: GraphRef, fieldKey: string): FieldLookup {
|
|
248
|
+
const key = this.recordKey(ref);
|
|
249
|
+
const rec = this.records.get(key);
|
|
250
|
+
if (rec && rec.has(fieldKey)) {
|
|
251
|
+
this.touch(key); // a read marks the record recently-used (LRU)
|
|
252
|
+
this.stamp(key); // ...and current-generation (staleness-aware gc)
|
|
253
|
+
return { status: "ready", value: rec.get(fieldKey) };
|
|
254
|
+
}
|
|
255
|
+
return { status: "missing" };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
setField(ref: GraphRef, fieldKey: string, value: FieldValue): void {
|
|
259
|
+
const key = this.recordKey(ref);
|
|
260
|
+
this.ensureRecord(key).set(fieldKey, value);
|
|
261
|
+
this.bumpRecord(key, fieldKey);
|
|
262
|
+
this.evict();
|
|
263
|
+
this.bump();
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Merge a flat record of fields into the entity/path record. */
|
|
267
|
+
merge(ref: GraphRef, fields: Readonly<Record<string, FieldValue>>): void {
|
|
268
|
+
const key = this.recordKey(ref);
|
|
269
|
+
const rec = this.ensureRecord(key);
|
|
270
|
+
for (const [k, v] of Object.entries(fields)) {
|
|
271
|
+
rec.set(k, v);
|
|
272
|
+
this.bumpField(key, k);
|
|
273
|
+
}
|
|
274
|
+
this.bumpRecord(key);
|
|
275
|
+
this.evict();
|
|
276
|
+
this.bump();
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Drop a whole record (mutation invalidation). */
|
|
280
|
+
invalidate(ref: GraphRef): void {
|
|
281
|
+
const key = this.recordKey(ref);
|
|
282
|
+
// Bump every field a reader might have tracked before dropping it, so field-level
|
|
283
|
+
// trackers re-render (the data is gone → the next read re-fetches).
|
|
284
|
+
for (const fieldKey of this.records.get(key)?.keys() ?? []) this.bumpField(key, fieldKey);
|
|
285
|
+
this.records.delete(key);
|
|
286
|
+
this.bumpRecord(key);
|
|
287
|
+
this.bump();
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Drop a single field so the next read re-fetches it. */
|
|
291
|
+
invalidateField(ref: GraphRef, fieldKey: string): void {
|
|
292
|
+
const key = this.recordKey(ref);
|
|
293
|
+
this.records.get(key)?.delete(fieldKey);
|
|
294
|
+
this.bumpRecord(key, fieldKey);
|
|
295
|
+
this.bump();
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Fold a serialized snapshot into THIS cache, field-by-field, WITHOUT replacing
|
|
300
|
+
* existing records and WITHOUT notifying. Returns whether anything was
|
|
301
|
+
* added/changed. The caller decides when to `notify()` — so a render-phase merge
|
|
302
|
+
* can write records (visible to synchronous reads) yet defer the subscriber bump
|
|
303
|
+
* to a commit-phase effect. Idempotent: re-absorbing the same snapshot is a no-op.
|
|
304
|
+
*/
|
|
305
|
+
absorbRecords(snapshot: Record<string, Record<string, FieldValue>>): boolean {
|
|
306
|
+
let changed = false;
|
|
307
|
+
for (const [key, fields] of Object.entries(snapshot)) {
|
|
308
|
+
const rec = this.ensureRecord(key);
|
|
309
|
+
let recordChanged = false;
|
|
310
|
+
for (const [k, v] of Object.entries(fields)) {
|
|
311
|
+
if (!rec.has(k) || rec.get(k) !== v) {
|
|
312
|
+
rec.set(k, v);
|
|
313
|
+
this.bumpField(key, k);
|
|
314
|
+
recordChanged = true;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
if (recordChanged) {
|
|
318
|
+
this.bumpRecord(key); // version bumps now; the caller decides when to notify()
|
|
319
|
+
changed = true;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
this.evict();
|
|
323
|
+
return changed;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Serialize the whole cache (for hydration). */
|
|
327
|
+
snapshot(): Record<string, Record<string, FieldValue>> {
|
|
328
|
+
const out: Record<string, Record<string, FieldValue>> = {};
|
|
329
|
+
for (const [key, rec] of this.records) out[key] = Object.fromEntries(rec);
|
|
330
|
+
return out;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
static fromSnapshot(snapshot: Record<string, Record<string, FieldValue>>, maxRecords?: number): GraphCache {
|
|
334
|
+
const cache = new GraphCache(maxRecords);
|
|
335
|
+
for (const [key, rec] of Object.entries(snapshot)) {
|
|
336
|
+
cache.records.set(key, new Map(Object.entries(rec)));
|
|
337
|
+
}
|
|
338
|
+
cache.evict();
|
|
339
|
+
return cache;
|
|
340
|
+
}
|
|
341
|
+
}
|
package/src/context.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { GraphRequestContext } from "./adapter.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Structural RWSDK types.
|
|
5
|
+
*
|
|
6
|
+
* This package never imports `rwsdk` — like `@gleanql/vite`, it is decoupled from
|
|
7
|
+
* the host framework and matches its shapes structurally, so it can be tested in
|
|
8
|
+
* isolation and won't pin a framework version. A RedwoodSDK route handler / Page
|
|
9
|
+
* receives a `RequestInfo`:
|
|
10
|
+
* route("/product/:handle", ({ request, params, ctx }) => <ProductRoute ... />)
|
|
11
|
+
*/
|
|
12
|
+
export interface RequestInfo<Ctx extends Record<string, unknown> = Record<string, unknown>> {
|
|
13
|
+
readonly request: Request;
|
|
14
|
+
/** Dynamic route segments, e.g. `params.handle`, `params.$0`. */
|
|
15
|
+
readonly params: Record<string, string>;
|
|
16
|
+
/** Per-request mutable app context populated by middleware. */
|
|
17
|
+
readonly ctx: Ctx;
|
|
18
|
+
/** RedwoodSDK-specific context (opaque here). */
|
|
19
|
+
readonly rw?: unknown;
|
|
20
|
+
/** Cloudflare ExecutionContext. */
|
|
21
|
+
readonly cf?: unknown;
|
|
22
|
+
/** Mutable ResponseInit (status/headers). */
|
|
23
|
+
readonly response?: ResponseInit;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The route context object handed to the compiled variables factory *and* used
|
|
28
|
+
* as the transport `GraphRequestContext`. The compiler emits factories that read
|
|
29
|
+
* `ctx.params.handle`, `ctx.search.get(...)`, etc., so this shape is the contract
|
|
30
|
+
* between the generated code and the adapter.
|
|
31
|
+
*/
|
|
32
|
+
export interface GraphRouteContext extends GraphRequestContext {
|
|
33
|
+
readonly params: Record<string, string>;
|
|
34
|
+
readonly search: URLSearchParams;
|
|
35
|
+
readonly request: Request;
|
|
36
|
+
/** Application context contributed by `options.context` (auth, locale, env, ...). */
|
|
37
|
+
readonly [key: string]: unknown;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface BuildRouteContextOptions<Ctx extends Record<string, unknown>> {
|
|
41
|
+
/**
|
|
42
|
+
* Contribute application context (shop domain, access token, locale, market,
|
|
43
|
+
* preview mode, Cloudflare env). Anything returned here is available to the
|
|
44
|
+
* variables factory and to the transport adapter's header builder.
|
|
45
|
+
*/
|
|
46
|
+
readonly context?: (requestInfo: RequestInfo<Ctx>) => Record<string, unknown>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Build the route/request context from a RequestInfo. `params` and `search` come
|
|
51
|
+
* from the URL; everything else is contributed by `options.context`. The raw
|
|
52
|
+
* `request` is included for header derivation but is *not* serialized to the
|
|
53
|
+
* client (see `serializeGraph`).
|
|
54
|
+
*/
|
|
55
|
+
export function buildRouteContext<Ctx extends Record<string, unknown>>(
|
|
56
|
+
requestInfo: RequestInfo<Ctx>,
|
|
57
|
+
options: BuildRouteContextOptions<Ctx> = {},
|
|
58
|
+
): GraphRouteContext {
|
|
59
|
+
const url = new URL(requestInfo.request.url);
|
|
60
|
+
const app = options.context?.(requestInfo) ?? {};
|
|
61
|
+
return {
|
|
62
|
+
...app,
|
|
63
|
+
params: requestInfo.params,
|
|
64
|
+
search: url.searchParams,
|
|
65
|
+
request: requestInfo.request,
|
|
66
|
+
};
|
|
67
|
+
}
|