@decocms/tanstack 7.78.1 → 7.79.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/tanstack",
3
- "version": "7.78.1",
3
+ "version": "7.79.0",
4
4
  "type": "module",
5
5
  "description": "Deco framework binding for TanStack Start + Cloudflare Workers",
6
6
  "repository": {
@@ -27,9 +27,9 @@
27
27
  "lint:unused": "knip"
28
28
  },
29
29
  "dependencies": {
30
- "@decocms/blocks": "7.78.1",
31
- "@decocms/blocks-admin": "7.78.1",
32
- "@decocms/blocks-cli": "7.78.1",
30
+ "@decocms/blocks": "7.79.0",
31
+ "@decocms/blocks-admin": "7.79.0",
32
+ "@decocms/blocks-cli": "7.79.0",
33
33
  "@deco-cx/warp-node": "^0.3.16",
34
34
  "fast-json-patch": "^3.1.0",
35
35
  "ws": "^8.18.0"
@@ -69,6 +69,7 @@ import {
69
69
  } from "@tanstack/react-start/server";
70
70
  import { createElement } from "react";
71
71
  import { derivePageUrl, isClientNavigation } from "./pageUrl";
72
+ import { createRequestSingleFlight } from "./requestSingleFlight";
72
73
  import { dedupeGlobals, resolveSiteGlobals } from "./withSiteGlobals";
73
74
  import type { SiteGlobalRef } from "./withSiteGlobals";
74
75
 
@@ -92,7 +93,7 @@ export function setSectionChunkMap(map: Record<string, string>): void {
92
93
  // ---------------------------------------------------------------------------
93
94
 
94
95
  type PageResult = Awaited<ReturnType<typeof loadCmsPageInternal>>;
95
- const pageInflight = new Map<string, Promise<PageResult>>();
96
+ const loadPageForRequest = createRequestSingleFlight<PageResult>();
96
97
 
97
98
  /** Same shape as `resolveSiteGlobals()`'s empty result — used when `resolveGlobals: false`. */
98
99
  const EMPTY_GLOBALS: { resolvedSections: ResolvedSection[]; rawRefs: SiteGlobalRef[] } = {
@@ -337,33 +338,15 @@ export const loadCmsPage = createServerFn({ method: "GET" })
337
338
  .handler(async (ctx) => {
338
339
  const { path: fullPath, resolveGlobals } = parseLoadCmsPageInput(ctx.data);
339
340
 
340
- // Use the full path (including query string) as the dedup key.
341
- // Using basePath only caused /s?q=a and /s?q=b to share one promise,
342
- // returning wrong/empty results for search and filtered PLPs.
343
- //
344
- // SSR and client-nav produce the same eager/deferred split now, so the
345
- // original reason for the `__nav:` bucket — a client-nav request must never
346
- // inherit an SSR response's deferredSections (#277) — no longer applies. It
347
- // is kept anyway, because this map is module-global and the payload it
348
- // shares carries `pageUrl`, `flags`, and `device` derived from whichever
349
- // request won the race. That cross-request bleed is pre-existing and wider
350
- // than this bucket (two concurrent same-path requests already collide on
351
- // cookies/UA/geo), but SSR and client-nav are exactly the pair whose
352
- // `derivePageUrl` inputs differ most, so collapsing them would widen a
353
- // known hole for no gain. `resolveGlobals` is part of the key for the same
354
- // reason: a `resolveGlobals: false` request must not share a promise with a
355
- // `resolveGlobals: true` one.
341
+ // Whole-page results contain request-specific cookies, flags, device and URL.
342
+ // A module-global URL key also lets one stalled request block later visitors.
343
+ // Dedup only within this request; keep query and globals options distinct.
356
344
  const clientNav = isClientNavigation(fullPath, getRequestUrl());
357
345
  const inflightKey =
358
346
  (clientNav ? `__nav:${fullPath}` : fullPath) + (resolveGlobals ? "" : "|noGlobals");
359
- const existing = pageInflight.get(inflightKey);
360
- if (existing) return existing;
361
-
362
- const promise = loadCmsPageInternal(fullPath, resolveGlobals).finally(() =>
363
- pageInflight.delete(inflightKey),
347
+ return loadPageForRequest(getRequest(), inflightKey, () =>
348
+ loadCmsPageInternal(fullPath, resolveGlobals),
364
349
  );
365
- pageInflight.set(inflightKey, promise);
366
- return promise;
367
350
  });
368
351
 
369
352
  /** Accepted by `loadCmsHomePage` — optional so existing no-arg callers keep working. */
@@ -0,0 +1,62 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { createRequestSingleFlight } from "./requestSingleFlight";
3
+
4
+ describe("request-scoped page single-flight", () => {
5
+ it("lets a healthy visitor complete while an earlier visitor's page is stuck", async () => {
6
+ const load = createRequestSingleFlight<string>();
7
+ const path = "/granado/sabonetes/sabonete-barra";
8
+ let release!: (value: string) => void;
9
+ const first = load(
10
+ new Request(`https://shop.test${path}`),
11
+ path,
12
+ () =>
13
+ new Promise<string>((resolve) => {
14
+ release = resolve;
15
+ }),
16
+ );
17
+ const healthy = load(
18
+ new Request(`https://shop.test${path}`),
19
+ path,
20
+ async () => "healthy visitor",
21
+ );
22
+ try {
23
+ expect(
24
+ await Promise.race([
25
+ healthy,
26
+ new Promise((resolve) => setTimeout(() => resolve("blocked"), 100)),
27
+ ]),
28
+ ).toBe("healthy visitor");
29
+ } finally {
30
+ release("earlier visitor");
31
+ await first;
32
+ }
33
+ });
34
+
35
+ it("deduplicates concurrent callers within one request and removes completed work", async () => {
36
+ const load = createRequestSingleFlight<string>();
37
+ const request = new Request("https://shop.test/category");
38
+ const run = vi.fn(async () => "page");
39
+ const first = load(request, "/category", run);
40
+ expect(load(request, "/category", run)).toBe(first);
41
+ await first;
42
+ await load(request, "/category", run);
43
+ expect(run).toHaveBeenCalledTimes(2);
44
+ });
45
+
46
+ it("keeps page options separate and allows retry after a failure", async () => {
47
+ const load = createRequestSingleFlight<string>();
48
+ const request = new Request("https://shop.test/category");
49
+ await expect(
50
+ load(request, "/category", async () => {
51
+ throw new Error("failed");
52
+ }),
53
+ ).rejects.toThrow("failed");
54
+ expect(await load(request, "/category", async () => "retry")).toBe("retry");
55
+ const results = await Promise.all([
56
+ load(request, "/category?p=1", async () => "first page"),
57
+ load(request, "/category?p=2", async () => "second page"),
58
+ load(request, "/category?p=1|noGlobals", async () => "without globals"),
59
+ ]);
60
+ expect(results).toEqual(["first page", "second page", "without globals"]);
61
+ });
62
+ });
@@ -0,0 +1,17 @@
1
+ /** Share pending work only among callers belonging to the same HTTP request. */
2
+ export function createRequestSingleFlight<T>() {
3
+ const requests = new WeakMap<Request, Map<string, Promise<T>>>();
4
+
5
+ return (request: Request, key: string, run: () => Promise<T>): Promise<T> => {
6
+ const pending = requests.get(request) ?? new Map<string, Promise<T>>();
7
+ requests.set(request, pending);
8
+ const existing = pending.get(key);
9
+ if (existing) return existing;
10
+
11
+ const promise = Promise.resolve()
12
+ .then(run)
13
+ .finally(() => pending.delete(key));
14
+ pending.set(key, promise);
15
+ return promise;
16
+ };
17
+ }
@@ -51,6 +51,8 @@ const G = globalThis as unknown as {
51
51
  kvHydrated?: boolean;
52
52
  kvHydration?: Promise<void> | null;
53
53
  kvLastPolledAt?: number;
54
+ dataVersion?: string | null;
55
+ dataVersionAt?: number;
54
56
  };
55
57
  };
56
58
  if (!G.__deco) G.__deco = {};
@@ -255,9 +257,62 @@ async function pollRevisionOnce(kv: KVNamespace, deploymentId: string): Promise<
255
257
  }
256
258
  }
257
259
 
260
+ /** KV key holding the preserved data-cache version. Writing a new value (the
261
+ * control-plane does, via the CF API) purges preserved loader/fetch data for
262
+ * every isolate within one poll — no deploy. */
263
+ export const DATA_CACHE_VERSION_KEY = "cache:data-version";
264
+
265
+ /** Deadline for one `cache:data-version` read. Calibration knob. */
266
+ export const DATA_VERSION_TIMEOUT_MS = 1_000;
267
+
268
+ /**
269
+ * Current `DATA_CACHE_VERSION_KEY` value, or "" without a `DECO_KV` binding.
270
+ * The first call per isolate awaits the read (so a cold isolate never reads
271
+ * purged keys); after that the cached value is returned and refreshed in the
272
+ * background every `POLL_INTERVAL_MS`. A failed or slow read (over
273
+ * `DATA_VERSION_TIMEOUT_MS`) keeps the last value. Only resolved strings are
274
+ * kept, never a pending read: a `DECO_KV.get` that never settles would otherwise
275
+ * hold every later request of the isolate.
276
+ */
277
+ export async function getDataCacheVersion(env: Env, ctx?: ExecutionContextLike): Promise<string> {
278
+ const kv = getKV(env);
279
+ if (!kv) return "";
280
+ const d = G.__deco!;
281
+ const read = (prev: string): Promise<string> => {
282
+ let timer: ReturnType<typeof setTimeout> | undefined;
283
+ return Promise.race([
284
+ kv.get(DATA_CACHE_VERSION_KEY).then(
285
+ (v) => v ?? "",
286
+ () => prev,
287
+ ),
288
+ new Promise<string>((resolve) => {
289
+ timer = setTimeout(() => resolve(prev), DATA_VERSION_TIMEOUT_MS);
290
+ }),
291
+ ]).finally(() => clearTimeout(timer));
292
+ };
293
+ if (d.dataVersion == null) {
294
+ d.dataVersionAt = Date.now();
295
+ d.dataVersion = await read("");
296
+ return d.dataVersion;
297
+ }
298
+ const current = d.dataVersion;
299
+ if (Date.now() - (d.dataVersionAt ?? 0) >= POLL_INTERVAL_MS) {
300
+ d.dataVersionAt = Date.now();
301
+ // Background refresh: requests keep the current value meanwhile.
302
+ const swap = read(current).then((v) => {
303
+ d.dataVersion = v;
304
+ });
305
+ if (ctx?.waitUntil) ctx.waitUntil(swap);
306
+ else void swap.catch(() => {});
307
+ }
308
+ return current;
309
+ }
310
+
258
311
  /** Test-only: reset the isolate-level hydration flags. */
259
312
  export function __resetKvHydrationStateForTests(): void {
260
313
  G.__deco!.kvHydrated = false;
261
314
  G.__deco!.kvHydration = null;
262
315
  G.__deco!.kvLastPolledAt = 0;
316
+ G.__deco!.dataVersion = null;
317
+ G.__deco!.dataVersionAt = 0;
263
318
  }
@@ -8,6 +8,15 @@ import {
8
8
  getCacheStorageContext,
9
9
  } from "@decocms/blocks/sdk/cacheStorage";
10
10
  import { beforeEach, describe, expect, it, vi } from "vitest";
11
+ import { createFetchCache } from "@decocms/blocks/sdk/fetchCache";
12
+ import { __resetKvHydrationStateForTests, DATA_VERSION_TIMEOUT_MS } from "./kvHydration";
13
+
14
+ const FETCH_KNOBS = {
15
+ maxEntries: 10,
16
+ freshTtlMs: { success: 60_000, notFound: 1_000, serverError: 0 },
17
+ staleIfErrorMs: 60_000,
18
+ inflightBackstopMs: 5_000,
19
+ };
11
20
  import { createDecoWorkerEntry } from "./workerEntry";
12
21
 
13
22
  function makeKV(): CacheKVNamespace {
@@ -91,6 +100,123 @@ describe("worker cache storage injection", () => {
91
100
  await flush();
92
101
  });
93
102
 
103
+ // A fresh fetch cache per request: its memory tier can't answer, so a hit
104
+ // proves the shared (KV) entry was found under the same key.
105
+ function fetchWorker(opts: object = {}) {
106
+ const upstream = vi.fn(async () => Response.json({ ok: true }));
107
+ const worker = createDecoWorkerEntry(
108
+ {
109
+ fetch: async () => {
110
+ const cache = createFetchCache({ provider: "test", ...FETCH_KNOBS });
111
+ const body = await cache.fetchWithCache("https://upstream.test/x", upstream);
112
+ return Response.json(body, { headers: { "cache-control": "no-store" } });
113
+ },
114
+ },
115
+ { ...options, ...opts },
116
+ );
117
+ return { worker, upstream };
118
+ }
119
+
120
+ it("DECO_DATA_CACHE_ON_DEPLOY=preserve keeps fetch results across deploys and publishes", async () => {
121
+ const { worker, upstream } = fetchWorker();
122
+ const bindings = { ...env(), DECO_DATA_CACHE_ON_DEPLOY: "preserve" };
123
+ await worker.fetch(request("/one"), bindings, ctx);
124
+ await flush();
125
+ // Distinct paths: only the fetch cache is under test, not the HTML cache.
126
+ await worker.fetch(request("/two"), { ...bindings, BUILD_HASH: "build-B" }, ctx);
127
+ // A CMS publish (new decofile revision) keeps them too.
128
+ setBlocks({ published: { __resolveType: "x" } });
129
+ await worker.fetch(request("/three"), { ...bindings, BUILD_HASH: "build-B" }, ctx);
130
+ expect(upstream).toHaveBeenCalledTimes(1);
131
+ // A new DECO_DATA_CACHE_PURGE value drops the preserved data once.
132
+ await worker.fetch(request("/four"), { ...bindings, DECO_DATA_CACHE_PURGE: "2026-09-22" }, ctx);
133
+ expect(upstream).toHaveBeenCalledTimes(2);
134
+ await flush();
135
+ });
136
+
137
+ it("loader results stay per-build even with preserve", async () => {
138
+ const upstream = vi.fn(async () => ({ n: 1 }));
139
+ const loader = createCachedLoader("worker-preserve-loader", upstream, {
140
+ policy: "no-cache",
141
+ maxAge: 60_000,
142
+ });
143
+ const worker = createDecoWorkerEntry(
144
+ {
145
+ fetch: async () =>
146
+ Response.json(await loader({}), { headers: { "cache-control": "no-store" } }),
147
+ },
148
+ options,
149
+ );
150
+ const bindings = { ...env(), DECO_DATA_CACHE_ON_DEPLOY: "preserve" };
151
+ await worker.fetch(request("/a"), bindings, ctx);
152
+ await flush();
153
+ clearLoaderCache();
154
+ await worker.fetch(request("/b"), { ...bindings, BUILD_HASH: "build-B" }, ctx);
155
+ expect(upstream).toHaveBeenCalledTimes(2);
156
+ await flush();
157
+ });
158
+
159
+ it("invalidate or unset keeps fetch results per build", async () => {
160
+ for (const extra of [{}, { DECO_DATA_CACHE_ON_DEPLOY: "invalidate" }, { DECO_DATA_CACHE_ON_DEPLOY: "yes" }]) {
161
+ const { worker, upstream } = fetchWorker();
162
+ const bindings = { ...env(), ...extra };
163
+ await worker.fetch(request("/a"), bindings, ctx);
164
+ await flush();
165
+ await worker.fetch(request("/b"), { ...bindings, BUILD_HASH: "build-B" }, ctx);
166
+ expect(upstream).toHaveBeenCalledTimes(2);
167
+ await flush();
168
+ }
169
+ });
170
+
171
+ it("a new cache:data-version in DECO_KV purges preserved data without a deploy", async () => {
172
+ __resetKvHydrationStateForTests();
173
+ const { worker, upstream } = fetchWorker();
174
+ const DECO_KV = makeKV();
175
+ const bindings = { ...env(), DECO_KV, DECO_DATA_CACHE_ON_DEPLOY: "preserve" };
176
+ await worker.fetch(request("/a"), bindings, ctx);
177
+ await flush();
178
+ await worker.fetch(request("/b"), bindings, ctx);
179
+ expect(upstream).toHaveBeenCalledTimes(1);
180
+ await DECO_KV.put("cache:data-version", "v2", { expirationTtl: 60 });
181
+ __resetKvHydrationStateForTests(); // skip the poll interval
182
+ await worker.fetch(request("/c"), bindings, ctx);
183
+ expect(upstream).toHaveBeenCalledTimes(2);
184
+ await flush();
185
+ });
186
+
187
+ it("a hung cache:data-version read falls back instead of holding the request", async () => {
188
+ vi.useFakeTimers();
189
+ try {
190
+ __resetKvHydrationStateForTests();
191
+ const { worker, upstream } = fetchWorker();
192
+ const DECO_KV = { ...makeKV(), get: () => new Promise<string | null>(() => {}) };
193
+ const bindings = { ...env(), DECO_KV, DECO_DATA_CACHE_ON_DEPLOY: "preserve" };
194
+ const pending = worker.fetch(request("/a"), bindings, ctx);
195
+ await vi.advanceTimersByTimeAsync(DATA_VERSION_TIMEOUT_MS);
196
+ expect((await pending).status).toBe(200);
197
+ expect(upstream).toHaveBeenCalledTimes(1);
198
+ } finally {
199
+ vi.useRealTimers();
200
+ await flush();
201
+ }
202
+ });
203
+
204
+ it("honours dataCacheOnDeployEnv renames and opt-out", async () => {
205
+ const run = async (opts: object, extra: Record<string, string>) => {
206
+ const { worker, upstream } = fetchWorker(opts);
207
+ const bindings = { ...env(), ...extra };
208
+ await worker.fetch(request("/a"), bindings, ctx);
209
+ await flush();
210
+ await worker.fetch(request("/b"), { ...bindings, BUILD_HASH: "build-B" }, ctx);
211
+ await flush();
212
+ return upstream.mock.calls.length;
213
+ };
214
+ expect(await run({ dataCacheOnDeployEnv: "KEEP" }, { KEEP: "preserve" })).toBe(1);
215
+ expect(
216
+ await run({ dataCacheOnDeployEnv: false }, { DECO_DATA_CACHE_ON_DEPLOY: "preserve" }),
217
+ ).toBe(2);
218
+ });
219
+
94
220
  it("stores public server-function POSTs by body and excludes unmarked responses", async () => {
95
221
  const origin = {
96
222
  fetch: vi.fn(
@@ -105,7 +105,7 @@ import {
105
105
  registerDraftOverride,
106
106
  requestCarriesDraft,
107
107
  } from "./draft";
108
- import { ensureBlocksHydrated, maybePollRevision } from "./kvHydration";
108
+ import { ensureBlocksHydrated, getDataCacheVersion, maybePollRevision } from "./kvHydration";
109
109
  import { lookupExactRedirect } from "./kvRedirects";
110
110
  import { DECO_POWERED_BY, installDefaultUserAgent } from "./outboundHeaders";
111
111
  import { type SpeculationRulesConfig, setSpeculationRules } from "./speculationRules";
@@ -423,6 +423,33 @@ export interface DecoWorkerEntryOptions {
423
423
  */
424
424
  cacheVersionEnv?: string | false;
425
425
 
426
+ /**
427
+ * Environment variable choosing what a deploy does to the `createFetchCache`
428
+ * entries (`fetch:*`, raw upstream responses) in shared storage (e.g. KV):
429
+ *
430
+ * - `"invalidate"` (or unset — the default): keyed by build and CMS revision,
431
+ * a deploy or publish starts them cold. Today's behaviour.
432
+ * - `"preserve"`: keyed without build or CMS revision, they survive both.
433
+ * A raw response doesn't depend on the code or the decofile; the new
434
+ * code re-transforms it, so a deploy can't serve an old shape.
435
+ *
436
+ * `cachedLoader` results, HTML responses and sections stay per-build in both
437
+ * modes: they hold code output. Segment/device and geo stay in every key.
438
+ * Any other value falls back to `"invalidate"`.
439
+ *
440
+ * To drop preserved responses once, set `DECO_DATA_CACHE_PURGE` to a new value
441
+ * (any string, e.g. the date), or write a new `cache:data-version` in DECO_KV.
442
+ *
443
+ * @default "DECO_DATA_CACHE_ON_DEPLOY"
444
+ *
445
+ * @example
446
+ * ```jsonc
447
+ * // wrangler.jsonc
448
+ * "vars": { "DECO_DATA_CACHE_ON_DEPLOY": "preserve" }
449
+ * ```
450
+ */
451
+ dataCacheOnDeployEnv?: string | false;
452
+
426
453
  /**
427
454
  * Security headers appended to every SSR response (HTML pages).
428
455
  * Pass `false` to disable entirely.
@@ -1186,6 +1213,7 @@ export function createDecoWorkerEntry(
1186
1213
  redirects: redirectsOpt,
1187
1214
  previewShell: customPreviewShell,
1188
1215
  cacheVersionEnv = "BUILD_HASH",
1216
+ dataCacheOnDeployEnv = "DECO_DATA_CACHE_ON_DEPLOY",
1189
1217
  securityHeaders: securityHeadersOpt,
1190
1218
  csp: cspOpt,
1191
1219
  cspMode = "report-only",
@@ -1826,7 +1854,8 @@ export function createDecoWorkerEntry(
1826
1854
  // handlePurge (Bearer `purgeTokenEnv`). Per-isolate by nature — the isolate
1827
1855
  // that serves this request clears its cache; other live isolates keep theirs
1828
1856
  // until they're recycled or hit too. For a guaranteed global flush, redeploy
1829
- // (loader cache keys are build-hash versioned).
1857
+ // (loader cache keys are build-hash versioned; preserved `fetch:*` entries are
1858
+ // not — see `dataCacheOnDeployEnv`).
1830
1859
  async function handlePurgeLoaders(
1831
1860
  request: Request,
1832
1861
  env: Record<string, unknown>,
@@ -2305,24 +2334,36 @@ export function createDecoWorkerEntry(
2305
2334
  } catch {
2306
2335
  /* Missing storage is a cache miss, never a storefront failure. */
2307
2336
  }
2337
+ const preserveData = !!dataCacheOnDeployEnv && env[dataCacheOnDeployEnv] === "preserve";
2338
+ // Purge without a deploy: bump `cache:data-version` in DECO_KV.
2339
+ const kvDataVersion = preserveData && storage ? await getDataCacheVersion(env, ctx) : "";
2340
+ // Everything but the version is shared by the build and data scopes.
2341
+ // Revision is left out of `variant`: build-scoped stores add it below, while
2342
+ // preserved fetch data skips it — a raw upstream response does not depend on
2343
+ // the decofile (a publish that changes props changes the URL, hence the key).
2344
+ const variant = [
2345
+ segment
2346
+ ? hashSegment(segment)
2347
+ : isMobileUA(request.headers.get("user-agent") ?? "")
2348
+ ? "mobile"
2349
+ : "desktop",
2350
+ buildGeoCacheParam(
2351
+ (request as unknown as { cf?: Record<string, string> }).cf,
2352
+ effectiveGeoKey(),
2353
+ ),
2354
+ privateRequest ? crypto.randomUUID() : null,
2355
+ ];
2356
+ const scopeFor = (version: string, revision: string | null = getRevision()) =>
2357
+ JSON.stringify([url.origin, version, revision, ...variant]);
2308
2358
  bindCacheStorage({
2309
2359
  storage,
2310
2360
  disabled: privateRequest || isDevMode(),
2311
- scope: JSON.stringify([
2312
- new URL(request.url).origin,
2313
- getBuildHash(env),
2314
- getRevision(),
2315
- segment
2316
- ? hashSegment(segment)
2317
- : isMobileUA(request.headers.get("user-agent") ?? "")
2318
- ? "mobile"
2319
- : "desktop",
2320
- buildGeoCacheParam(
2321
- (request as unknown as { cf?: Record<string, string> }).cf,
2322
- effectiveGeoKey(),
2323
- ),
2324
- privateRequest ? crypto.randomUUID() : null,
2325
- ]),
2361
+ scope: scopeFor(getBuildHash(env)),
2362
+ // Only `createFetchCache` (`fetch:*`) uses dataScope. Preserve keys it without
2363
+ // build or CMS revision; invalidate keeps today's per-build key.
2364
+ dataScope: preserveData
2365
+ ? scopeFor(`data:${(env.DECO_DATA_CACHE_PURGE as string) ?? ""}:${kvDataVersion}`, null)
2366
+ : undefined,
2326
2367
  waitUntil: (work) => ctx.waitUntil(work),
2327
2368
  });
2328
2369
  }