diff --git a/patches/nitropack@2.13.4.patch b/patches/nitropack@2.13.4.patch new file mode 100644 index 00000000..0f555e34 --- /dev/null +++ b/patches/nitropack@2.13.4.patch @@ -0,0 +1,37 @@ +diff --git a/dist/runtime/internal/cache.mjs b/dist/runtime/internal/cache.mjs +index ecbf53cfa514c2fbd3f7dc88f0de97990f7deb12..786ca2dfeaea7cb62d78d00ac0d9c6f00935a6f5 100644 +--- a/dist/runtime/internal/cache.mjs ++++ b/dist/runtime/internal/cache.mjs +@@ -20,12 +20,31 @@ function defaultCacheOptions() { + } + export function defineCachedFunction(fn, opts = {}) { + opts = { ...defaultCacheOptions(), ...opts }; +- const pending = {}; ++ // PATCH: keep each resolve in flight with the request that started it. ++ // workerd ties I/O to the request that started it. When that request ends ++ // first, its resolve never settles. A `pending` map shared by every request ++ // made each later request on that key await the dead resolve forever. ++ // Requests now share only finished entries, through storage. Reads inside ++ // one request still share one resolve. A call without an H3 event resolves ++ // alone. See cloudflare/workers-sdk#12731 and opennextjs/opennextjs-aws#1218. ++ const pendingByEvent = /* @__PURE__ */ new WeakMap(); ++ function pendingFor(event) { ++ if (!event) { ++ return {}; ++ } ++ let pending = pendingByEvent.get(event); ++ if (!pending) { ++ pending = {}; ++ pendingByEvent.set(event, pending); ++ } ++ return pending; ++ } + const group = opts.group || "nitro/functions"; + const name = opts.name || fn.name || "_"; + const integrity = opts.integrity || hash([fn, opts]); + const validate = opts.validate || ((entry) => entry.value !== void 0); + async function get(key, resolver, shouldInvalidateCache, event) { ++ const pending = pendingFor(event); + const cacheKey = [opts.base, group, name, key + ".json"].filter(Boolean).join(":").replace(/:\/$/, ":index"); + let entry = await useStorage().getItem(cacheKey).catch((error) => { + console.error(`[cache] Cache read error.`, error); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 678a8ea2..a8f2b901 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -389,6 +389,7 @@ patchedDependencies: hookable@5.5.3: 3b44da13a743bfa2eab02aef7246065e4dfd56e9878c206f34b51322832cfb2c hookable@6.1.1: 7530364d493fa95257cb863d105ed55a1bb4b46d5c9a178a6d64cb73028ed344 hookable@6.1.2: 7530364d493fa95257cb863d105ed55a1bb4b46d5c9a178a6d64cb73028ed344 + nitropack@2.13.4: a99fd975a7a90d0358937d8bdc06a0df631a088825383710a2e3875c7cc0051a importers: @@ -441,7 +442,7 @@ importers: version: 4.11.2(@oxc-project/types@0.144.0)(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))(aws4fetch@1.0.20)(change-case@5.4.4)(confbox@0.3.1)(db0@0.3.4(better-sqlite3@13.0.3)(drizzle-orm@1.0.0-rc.4(@cloudflare/workers-types@5.20260901.1)(@opentelemetry/api@1.9.1)(better-sqlite3@13.0.3)(valibot@1.4.2(typescript@6.0.3))(zod@4.5.4)))(dotenv@17.4.2)(embla-carousel@8.6.0)(esbuild@0.28.2)(giget@3.3.1)(ioredis@5.11.1(supports-color@10.2.2))(jiti@2.7.0)(lightningcss@1.33.0)(oxc-parser@0.143.0)(rolldown@1.2.12)(rollup@4.62.4)(tailwindcss@4.3.3)(typescript@6.0.3)(valibot@1.4.2(typescript@6.0.3))(vite@8.3.2(@types/node@26.4.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.13)(yaml@2.9.1))(vue-router@5.3.1(@vue/compiler-sfc@3.5.43)(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.62.4)(vite@8.3.2(@types/node@26.4.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.13)(yaml@2.9.1))(vue@3.5.43(typescript@6.0.3)))(vue@3.5.43(typescript@6.0.3))(zod@4.5.4) '@nuxtjs/seo': specifier: 'catalog:' - version: 5.3.16(eae50dd195020819cb2a642de7fca934) + version: 5.3.16(2d445d29e6039d0794d5ace877c5a168) '@resvg/resvg-js': specifier: 'catalog:' version: 2.6.2 @@ -495,7 +496,7 @@ importers: version: 0.5.30(@nuxt/schema@4.6.0)(confbox@0.3.1)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@1.2.3)(mlly@1.8.2)(oxc-parser@0.143.0)(rolldown@1.2.12)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.62.4)(vite@8.3.2(@types/node@26.4.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.13)(yaml@2.9.1))) nuxt-og-image: specifier: 'catalog:' - version: 6.9.1(b3506779ace0e3d593a5d6501a4378ab) + version: 6.9.1(729b832d8d3f6efae544af83c9199115) rangi: specifier: 'catalog:' version: 2.2.0 @@ -10793,7 +10794,7 @@ snapshots: confbox: 0.2.4 consola: 3.4.2 h3: 2.0.1-rc.25(crossws@0.4.10(srvx@0.12.5)) - nitropack: 2.13.4(aws4fetch@1.0.20)(better-sqlite3@13.0.3)(drizzle-orm@1.0.0-rc.4(@cloudflare/workers-types@5.20260901.1)(@opentelemetry/api@1.9.1)(better-sqlite3@13.0.3)(valibot@1.4.2(typescript@6.0.3))(zod@4.5.4))(oxc-parser@0.143.0)(rolldown@1.2.12)(srvx@0.12.5)(supports-color@10.2.2)(vite@8.3.2(@types/node@26.4.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.13)(yaml@2.9.1)) + nitropack: 2.13.4(patch_hash=a99fd975a7a90d0358937d8bdc06a0df631a088825383710a2e3875c7cc0051a)(aws4fetch@1.0.20)(better-sqlite3@13.0.3)(drizzle-orm@1.0.0-rc.4(@cloudflare/workers-types@5.20260901.1)(@opentelemetry/api@1.9.1)(better-sqlite3@13.0.3)(valibot@1.4.2(typescript@6.0.3))(zod@4.5.4))(oxc-parser@0.143.0)(rolldown@1.2.12)(srvx@0.12.5)(supports-color@10.2.2)(vite@8.3.2(@types/node@26.4.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.13)(yaml@2.9.1)) nuxt: 4.6.0(4e2b9b3b32f3bd83160e11d3f6093f13) pathe: 2.0.3 unstorage: 1.17.5(aws4fetch@1.0.20)(db0@0.3.4(better-sqlite3@13.0.3)(drizzle-orm@1.0.0-rc.4(@cloudflare/workers-types@5.20260901.1)(@opentelemetry/api@1.9.1)(better-sqlite3@13.0.3)(valibot@1.4.2(typescript@6.0.3))(zod@4.5.4)))(ioredis@5.11.1(supports-color@10.2.2)) @@ -10898,7 +10899,7 @@ snapshots: '@nuxt/kit': 4.6.0(@nuxt/schema@4.6.0)(confbox@0.3.1)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@1.2.3)(mlly@1.8.2)(oxc-parser@0.143.0)(rolldown@1.2.12)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.62.4)(vite@8.3.2(@types/node@26.4.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.13)(yaml@2.9.1))) '@vueuse/core': 14.4.0(vue@3.5.43(typescript@6.0.3)) consola: 3.4.2 - nitropack: 2.13.4(aws4fetch@1.0.20)(better-sqlite3@13.0.3)(drizzle-orm@1.0.0-rc.4(@cloudflare/workers-types@5.20260901.1)(@opentelemetry/api@1.9.1)(better-sqlite3@13.0.3)(valibot@1.4.2(typescript@6.0.3))(zod@4.5.4))(oxc-parser@0.143.0)(rolldown@1.2.12)(srvx@0.12.5)(supports-color@10.2.2)(vite@8.3.2(@types/node@26.4.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.13)(yaml@2.9.1)) + nitropack: 2.13.4(patch_hash=a99fd975a7a90d0358937d8bdc06a0df631a088825383710a2e3875c7cc0051a)(aws4fetch@1.0.20)(better-sqlite3@13.0.3)(drizzle-orm@1.0.0-rc.4(@cloudflare/workers-types@5.20260901.1)(@opentelemetry/api@1.9.1)(better-sqlite3@13.0.3)(valibot@1.4.2(typescript@6.0.3))(zod@4.5.4))(oxc-parser@0.143.0)(rolldown@1.2.12)(srvx@0.12.5)(supports-color@10.2.2)(vite@8.3.2(@types/node@26.4.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.13)(yaml@2.9.1)) nuxt: 4.6.0(4e2b9b3b32f3bd83160e11d3f6093f13) pathe: 2.0.3 vite: 8.3.2(@types/node@26.4.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.13)(yaml@2.9.1) @@ -12326,7 +12327,7 @@ snapshots: klona: 2.0.6 mocked-exports: 0.1.1 my-bad: 0.2.10(vite@8.3.2(@types/node@26.4.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.13)(yaml@2.9.1)) - nitropack: 2.13.4(aws4fetch@1.0.20)(better-sqlite3@13.0.3)(drizzle-orm@1.0.0-rc.4(@cloudflare/workers-types@5.20260901.1)(@opentelemetry/api@1.9.1)(better-sqlite3@13.0.3)(valibot@1.4.2(typescript@6.0.3))(zod@4.5.4))(oxc-parser@0.143.0)(rolldown@1.2.12)(srvx@0.12.5)(supports-color@10.2.2)(vite@8.3.2(@types/node@26.4.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.13)(yaml@2.9.1)) + nitropack: 2.13.4(patch_hash=a99fd975a7a90d0358937d8bdc06a0df631a088825383710a2e3875c7cc0051a)(aws4fetch@1.0.20)(better-sqlite3@13.0.3)(drizzle-orm@1.0.0-rc.4(@cloudflare/workers-types@5.20260901.1)(@opentelemetry/api@1.9.1)(better-sqlite3@13.0.3)(valibot@1.4.2(typescript@6.0.3))(zod@4.5.4))(oxc-parser@0.143.0)(rolldown@1.2.12)(srvx@0.12.5)(supports-color@10.2.2)(vite@8.3.2(@types/node@26.4.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.13)(yaml@2.9.1)) nostics: 1.3.0 nuxt: 4.6.0(4e2b9b3b32f3bd83160e11d3f6093f13) ohash: 2.0.12 @@ -12895,14 +12896,14 @@ snapshots: - vite - vue - '@nuxtjs/seo@5.3.16(eae50dd195020819cb2a642de7fca934)': + '@nuxtjs/seo@5.3.16(2d445d29e6039d0794d5ace877c5a168)': dependencies: '@nuxt/kit': 4.6.0(@nuxt/schema@4.6.0)(confbox@0.3.1)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@1.2.3)(mlly@1.8.2)(oxc-parser@0.143.0)(rolldown@1.2.12)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.62.4)(vite@8.3.2(@types/node@26.4.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.13)(yaml@2.9.1))) '@nuxtjs/robots': 6.2.0(@nuxt/schema@4.6.0)(confbox@0.3.1)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@1.2.3)(mlly@1.8.2)(nuxt@4.6.0(4e2b9b3b32f3bd83160e11d3f6093f13))(oxc-parser@0.143.0)(rolldown@1.2.12)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.62.4)(vite@8.3.2(@types/node@26.4.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.13)(yaml@2.9.1)))(vite@8.3.2(@types/node@26.4.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.13)(yaml@2.9.1))(vue@3.5.43(typescript@6.0.3))(zod@4.5.4) '@nuxtjs/sitemap': 8.5.0(@nuxt/schema@4.6.0)(confbox@0.3.1)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@1.2.3)(mlly@1.8.2)(nuxt@4.6.0(4e2b9b3b32f3bd83160e11d3f6093f13))(oxc-parser@0.143.0)(rolldown@1.2.12)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.62.4)(vite@8.3.2(@types/node@26.4.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.13)(yaml@2.9.1)))(vite@8.3.2(@types/node@26.4.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.13)(yaml@2.9.1))(vue@3.5.43(typescript@6.0.3))(zod@4.5.4) nuxt: 4.6.0(4e2b9b3b32f3bd83160e11d3f6093f13) nuxt-link-checker: 5.3.0(@nuxt/schema@4.6.0)(aws4fetch@1.0.20)(confbox@0.3.1)(db0@0.3.4(better-sqlite3@13.0.3)(drizzle-orm@1.0.0-rc.4(@cloudflare/workers-types@5.20260901.1)(@opentelemetry/api@1.9.1)(better-sqlite3@13.0.3)(valibot@1.4.2(typescript@6.0.3))(zod@4.5.4)))(dotenv@17.4.2)(giget@3.3.1)(ioredis@5.11.1(supports-color@10.2.2))(jiti@2.7.0)(mlly@1.8.2)(nuxt@4.6.0(4e2b9b3b32f3bd83160e11d3f6093f13))(oxc-parser@0.143.0)(rolldown@1.2.12)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.62.4)(vite@8.3.2(@types/node@26.4.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.13)(yaml@2.9.1)))(vite@8.3.2(@types/node@26.4.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.13)(yaml@2.9.1))(vue@3.5.43(typescript@6.0.3))(zod@4.5.4) - nuxt-og-image: 6.9.1(b3506779ace0e3d593a5d6501a4378ab) + nuxt-og-image: 6.9.1(729b832d8d3f6efae544af83c9199115) nuxt-schema-org: 6.2.9(@nuxt/schema@4.6.0)(@unhead/vue@3.4.2(@oxc-project/types@0.144.0)(esbuild@0.28.2)(lightningcss@1.33.0)(oxc-parser@0.143.0)(rolldown@1.2.12)(rollup@4.62.4)(vite@8.3.2(@types/node@26.4.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.13)(yaml@2.9.1))(vue@3.5.43(typescript@6.0.3)))(confbox@0.3.1)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@1.2.3)(mlly@1.8.2)(nuxt@4.6.0(4e2b9b3b32f3bd83160e11d3f6093f13))(oxc-parser@0.143.0)(rolldown@1.2.12)(unhead@3.4.1(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.62.4)(vite@8.3.2(@types/node@26.4.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.13)(yaml@2.9.1)))(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.62.4)(vite@8.3.2(@types/node@26.4.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.13)(yaml@2.9.1)))(vite@8.3.2(@types/node@26.4.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.13)(yaml@2.9.1))(vue@3.5.43(typescript@6.0.3))(zod@4.5.4) nuxt-seo-utils: 8.4.3(@nuxt/schema@4.6.0)(@types/node@26.4.0)(@unhead/vue@3.4.2(@oxc-project/types@0.144.0)(esbuild@0.28.2)(lightningcss@1.33.0)(oxc-parser@0.143.0)(rolldown@1.2.12)(rollup@4.62.4)(vite@8.3.2(@types/node@26.4.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.13)(yaml@2.9.1))(vue@3.5.43(typescript@6.0.3)))(confbox@0.3.1)(dotenv@17.4.2)(esbuild@0.28.2)(giget@3.3.1)(jiti@2.7.0)(lightningcss@1.33.0)(magic-string@1.2.3)(mlly@1.8.2)(nuxt@4.6.0(4e2b9b3b32f3bd83160e11d3f6093f13))(oxc-parser@0.143.0)(rolldown@1.2.12)(unhead@3.4.1(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.62.4)(vite@8.3.2(@types/node@26.4.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.13)(yaml@2.9.1)))(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.62.4)(vite@8.3.2(@types/node@26.4.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.13)(yaml@2.9.1)))(vite@8.3.2(@types/node@26.4.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.13)(yaml@2.9.1))(vue@3.5.43(typescript@6.0.3))(zod@4.5.4) nuxt-site-config: 4.2.3(@nuxt/schema@4.6.0)(confbox@0.3.1)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@1.2.3)(mlly@1.8.2)(nuxt@4.6.0(4e2b9b3b32f3bd83160e11d3f6093f13))(oxc-parser@0.143.0)(rolldown@1.2.12)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.62.4)(vite@8.3.2(@types/node@26.4.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.13)(yaml@2.9.1)))(vite@8.3.2(@types/node@26.4.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.13)(yaml@2.9.1))(vue@3.5.43(typescript@6.0.3))(zod@4.5.4) @@ -17405,7 +17406,7 @@ snapshots: negotiator@1.0.0: {} - nitropack@2.13.4(aws4fetch@1.0.20)(better-sqlite3@13.0.3)(drizzle-orm@1.0.0-rc.4(@cloudflare/workers-types@5.20260901.1)(@opentelemetry/api@1.9.1)(better-sqlite3@13.0.3)(valibot@1.4.2(typescript@6.0.3))(zod@4.5.4))(oxc-parser@0.143.0)(rolldown@1.2.12)(srvx@0.12.5)(supports-color@10.2.2)(vite@8.3.2(@types/node@26.4.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.13)(yaml@2.9.1)): + nitropack@2.13.4(patch_hash=a99fd975a7a90d0358937d8bdc06a0df631a088825383710a2e3875c7cc0051a)(aws4fetch@1.0.20)(better-sqlite3@13.0.3)(drizzle-orm@1.0.0-rc.4(@cloudflare/workers-types@5.20260901.1)(@opentelemetry/api@1.9.1)(better-sqlite3@13.0.3)(valibot@1.4.2(typescript@6.0.3))(zod@4.5.4))(oxc-parser@0.143.0)(rolldown@1.2.12)(srvx@0.12.5)(supports-color@10.2.2)(vite@8.3.2(@types/node@26.4.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.13)(yaml@2.9.1)): dependencies: '@cloudflare/kv-asset-handler': 0.4.2 '@rollup/plugin-alias': 6.0.0(rollup@4.62.4) @@ -17722,7 +17723,7 @@ snapshots: - vue - zod - nuxt-og-image@6.9.1(b3506779ace0e3d593a5d6501a4378ab): + nuxt-og-image@6.9.1(729b832d8d3f6efae544af83c9199115): dependencies: '@clack/prompts': 1.8.1 '@nuxt/kit': 4.6.0(@nuxt/schema@4.6.0)(confbox@0.3.1)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@1.4.2)(mlly@1.8.2)(oxc-parser@0.151.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.62.4)(vite@8.3.2(@types/node@26.4.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.13)(yaml@2.9.1))) @@ -17761,7 +17762,7 @@ snapshots: '@takumi-rs/core': 2.13.3(csstype@3.2.3) '@takumi-rs/wasm': 2.13.3(csstype@3.2.3) fontless: 0.2.1(aws4fetch@1.0.20)(db0@0.3.4(better-sqlite3@13.0.3)(drizzle-orm@1.0.0-rc.4(@cloudflare/workers-types@5.20260901.1)(@opentelemetry/api@1.9.1)(better-sqlite3@13.0.3)(valibot@1.4.2(typescript@6.0.3))(zod@4.5.4)))(ioredis@5.11.1(supports-color@10.2.2))(vite@8.3.2(@types/node@26.4.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.13)(yaml@2.9.1)) - nitropack: 2.13.4(aws4fetch@1.0.20)(better-sqlite3@13.0.3)(drizzle-orm@1.0.0-rc.4(@cloudflare/workers-types@5.20260901.1)(@opentelemetry/api@1.9.1)(better-sqlite3@13.0.3)(valibot@1.4.2(typescript@6.0.3))(zod@4.5.4))(oxc-parser@0.143.0)(rolldown@1.2.12)(srvx@0.12.5)(supports-color@10.2.2)(vite@8.3.2(@types/node@26.4.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.13)(yaml@2.9.1)) + nitropack: 2.13.4(patch_hash=a99fd975a7a90d0358937d8bdc06a0df631a088825383710a2e3875c7cc0051a)(aws4fetch@1.0.20)(better-sqlite3@13.0.3)(drizzle-orm@1.0.0-rc.4(@cloudflare/workers-types@5.20260901.1)(@opentelemetry/api@1.9.1)(better-sqlite3@13.0.3)(valibot@1.4.2(typescript@6.0.3))(zod@4.5.4))(oxc-parser@0.143.0)(rolldown@1.2.12)(srvx@0.12.5)(supports-color@10.2.2)(vite@8.3.2(@types/node@26.4.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.13)(yaml@2.9.1)) playwright-core: 1.62.1 sharp: 0.35.4(@types/node@26.4.0) tailwindcss: 4.3.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index dd187619..3f814b7b 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -227,6 +227,7 @@ patchedDependencies: hookable@5.5.3: patches/hookable@5.5.3.patch hookable@6.1.1: patches/hookable@6.patch hookable@6.1.2: patches/hookable@6.patch + nitropack@2.13.4: patches/nitropack@2.13.4.patch catalog: '@antfu/eslint-config': ^9.3.0 '@cloudflare/sandbox': 1.0.0 diff --git a/shared/server/cache.ts b/shared/server/cache.ts index 2aec4772..69ab9b07 100644 --- a/shared/server/cache.ts +++ b/shared/server/cache.ts @@ -64,37 +64,56 @@ export interface CachedOptions { now?: () => number } -/** The per-key dedup behind {@link singleFlight}. */ -const inflightComputes = new Map>() +/** + * How long one request's claim on refreshing a key stands. A Worker keeps a + * `waitUntil` task at most 30 seconds after its response, so a refresh that + * still runs past its claim was cut off. + */ +export const REFRESH_CLAIM_SECONDS = 30 + +/** A request's claim on refreshing one stale key in this isolate. */ +interface RefreshClaim { + /** Epoch milliseconds. */ + until: number +} + +/** Refresh claims by cache key, for {@link claimRefresh}. */ +const refreshClaims = new Map() /** - * One in-flight computation per cache key, shared by {@link cached} and - * {@link readThroughCache}. + * Claim the refresh of a stale `key` for this request. Answers null when + * another request in this isolate holds a claim that still stands; that + * request serves the stale value instead of computing again. * * A bare read-through cache turns every TTL expiry into a thundering herd: - * after the entry dies, each concurrent request re-runs the whole compute - * until the first write lands, and on these routes a compute is about six D1 - * queries plus a possible live GitHub render. The ops triage ledger + * after the entry goes stale, each concurrent request re-runs the whole + * compute until the first write lands, and on these routes a compute is about + * six D1 queries plus a possible live GitHub render. The ops triage ledger * attributes recurring D1 overload bursts (Sentry SKILLD-G/H/J/K/M/N/P/Q) to - * exactly that shape, and Nitro's `defineCachedEventHandler` had the same - * hole covered by `swr: true` + `staleMaxAge` before the detail route left it - * (Sentry SKILLD-1V). This helper covers both helpers without the bare - * `setItem` that made a KV 429 a 500: concurrent callers on one key share a - * single computation, a single D1 pass, and a single KV write, which also - * keeps the write under KV's one-write-per-second limit. The map is per - * isolate, so the guarantee is per isolate; that is still the difference - * between N computes and one. + * exactly that shape. One claim per key keeps a stale key to one compute and + * one KV write per isolate, which also keeps the write under KV's + * one-write-per-second limit. + * + * The claim is a finished value, never a promise. workerd ties a compute's + * I/O to the request that started it, and drops it when that request ends. + * A request that awaited a compute another request started would then wait + * forever, and so would every later request on that key in the isolate. So a + * compute stays with its own request: a cold or dead key computes in each + * request that reads it. A claim whose request ended lapses after + * {@link REFRESH_CLAIM_SECONDS}, and the next stale read refreshes. */ -function singleFlight(key: string, run: () => Promise): Promise { - const pending = inflightComputes.get(key) - if (pending) - return pending as Promise - const promise = run() - inflightComputes.set(key, promise) - // Both callbacks return normally, so this derived promise never rejects - // and the original rejection still reaches every caller. - void promise.then(() => inflightComputes.delete(key), () => inflightComputes.delete(key)) - return promise +function claimRefresh(key: string, nowMs: number): RefreshClaim | null { + const held = refreshClaims.get(key) + if (held && held.until > nowMs) + return null + const claim = { until: nowMs + REFRESH_CLAIM_SECONDS * 1000 } + refreshClaims.set(key, claim) + return claim +} + +function releaseRefresh(key: string, claim: RefreshClaim): void { + if (refreshClaims.get(key) === claim) + refreshClaims.delete(key) } /** @@ -102,8 +121,9 @@ function singleFlight(key: string, run: () => Promise): Promise { * * - fresh (`age < ttlSeconds`): serve the stored value. * - stale (`age < ttlSeconds + staleSeconds`): serve the stored value and - * refresh in the background through `schedule`. - * - dead or absent: recompute, awaited. + * refresh in the background through `schedule`, unless another request in + * this isolate holds the refresh claim. + * - dead or absent: recompute in this request, awaited. */ export function cached(options: CachedOptions): Promise { const { key, compute } = options @@ -111,37 +131,41 @@ export function cached(options: CachedOptions): Promise { const maxAgeSeconds = freshSeconds + (options.staleSeconds ?? 0) const nowSeconds = options.now ?? (() => Math.floor(Date.now() / 1000)) - function computeOnce(): Promise { - return singleFlight(key, async () => { - const value = await compute() - await writeCache(options.storage, key, { v: value, t: nowSeconds() } satisfies SwrEntry, { ttl: maxAgeSeconds }) - return value - }) + async function computeAndStore(): Promise { + const value = await compute() + await writeCache(options.storage, key, { v: value, t: nowSeconds() } satisfies SwrEntry, { ttl: maxAgeSeconds }) + return value } return (async () => { const entry = await readCache>(options.storage, key) if (isSwrEntry(entry)) { - const age = nowSeconds() - entry.t + const now = nowSeconds() + const age = now - entry.t if (age < freshSeconds) return entry.v if (age < maxAgeSeconds) { - const refresh = computeOnce().catch((error: unknown) => { - emitOperationalEvent(createWideEvent({ - 'operation': 'swr-refresh', - 'outcome': 'failed', - 'cache.key': key, - 'reason': error instanceof Error ? error.message : String(error), - })) - }) - if (options.schedule) - options.schedule(refresh) - else - void refresh + const claim = claimRefresh(key, now * 1000) + if (claim) { + const refresh = computeAndStore() + .then(() => {}, (error: unknown) => { + emitOperationalEvent(createWideEvent({ + 'operation': 'swr-refresh', + 'outcome': 'failed', + 'cache.key': key, + 'reason': error instanceof Error ? error.message : String(error), + })) + }) + .finally(() => releaseRefresh(key, claim)) + if (options.schedule) + options.schedule(refresh) + else + void refresh + } return entry.v } } - return computeOnce() + return computeAndStore() })() } @@ -222,10 +246,10 @@ export interface ReadThroughOptions { /** Seconds a stored value stays fresh and is served without a recompute. */ ttl: number /** - * Extra seconds a stale value stays readable when its recompute fails. - * Defaults to `ttl`. The KV entry is stored with `ttl + staleTtl`, so a - * value that leaves the stale window is physically gone and cannot be - * served by accident. + * Extra seconds a stale value stays readable when its recompute fails, or + * while another request recomputes it. Defaults to `ttl`. The KV entry is + * stored with `ttl + staleTtl`, so a value that leaves the stale window is + * physically gone and cannot be served by accident. */ staleTtl?: number /** @@ -255,7 +279,8 @@ function resolveWindows(options: ReadThroughOptions, value: T): ReadThroug /** * Read-through with a stale fallback: serve the cached value while it is * fresh, recompute when it is stale or missing, and serve the stale value - * when that recompute fails. + * when that recompute fails. A stale read that meets another request's + * refresh claim serves the stale value without a recompute. * * `readCache` already treats a failed read as a miss (Sentry SKILLD-S). The * remaining gap is the recompute behind the miss: when the cached entry @@ -297,6 +322,10 @@ export async function readThroughCache( if (ageSeconds < windows.ttl) return entry.value if (ageSeconds < windows.ttl + windows.staleTtl) { + const claim = claimRefresh(key, now) + // Another request in this isolate is recomputing this key. + if (!claim) + return entry.value try { return await storeComputed(storage, key, compute, options) } @@ -310,6 +339,9 @@ export async function readThroughCache( })) return entry.value } + finally { + releaseRefresh(key, claim) + } } } @@ -326,10 +358,8 @@ async function storeComputed( compute: () => Promise, options: ReadThroughOptions, ): Promise { - return singleFlight(key, async () => { - const value = await compute() - const windows = resolveWindows(options, value) - await writeCache(storage, key, { storedAt: Date.now(), value } satisfies CacheEnvelope, { ttl: windows.ttl + windows.staleTtl }) - return value - }) + const value = await compute() + const windows = resolveWindows(options, value) + await writeCache(storage, key, { storedAt: Date.now(), value } satisfies CacheEnvelope, { ttl: windows.ttl + windows.staleTtl }) + return value } diff --git a/test/unit/cache-in-flight.test.ts b/test/unit/cache-in-flight.test.ts new file mode 100644 index 00000000..5ffe7294 --- /dev/null +++ b/test/unit/cache-in-flight.test.ts @@ -0,0 +1,127 @@ +import type { ReadThroughCache } from '../../shared/server/cache' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cached, readThroughCache, REFRESH_CLAIM_SECONDS } from '../../shared/server/cache' + +const HOUR = 60 * 60 + +/** + * A compute whose request ended while it ran. workerd drops the answer to + * I/O of a request that has ended, so the compute never settles. + */ +function cutOff(): Promise { + return new Promise(() => {}) +} + +afterEach(() => { + vi.useRealTimers() +}) + +// A compute in flight belongs to the request that started it. A request that +// awaited another request's compute waited forever once that request ended, +// and so did every later request on that key in the isolate. +describe('a compute cut off with its request', () => { + it('leaves a cold read to compute its own value', async () => { + const storage = memoryStorage() + + void cached({ storage, key: 'cold:cached', ttlSeconds: 60, compute: cutOff }) + const second = cached({ storage, key: 'cold:cached', ttlSeconds: 60, compute: async () => 'second' }) + + await expect(within(second, 1_000)).resolves.toBe('second') + }) + + it('leaves a cold read through to compute its own value', async () => { + const storage = memoryStorage() + + void readThroughCache(storage, 'cold:read-through', cutOff, { ttl: 60 }) + const second = readThroughCache(storage, 'cold:read-through', async () => 'second', { ttl: 60 }) + + await expect(within(second, 1_000)).resolves.toBe('second') + }) + + it('lets a stale read refresh once the refresh claim lapses', async () => { + let now = 1_000_000 + const storage = memoryStorage({ 'stale:cached': { v: 'old', t: now - 120 } }) + const computes: string[] = [] + const read = (label: string, compute: () => Promise) => cached({ + storage, + key: 'stale:cached', + ttlSeconds: 60, + staleSeconds: HOUR, + compute: () => { + computes.push(label) + return compute() + }, + now: () => now, + }) + + expect(await read('first', cutOff)).toBe('old') + expect(await read('second', async () => 'new')).toBe('old') + expect(computes).toEqual(['first']) + + now += REFRESH_CLAIM_SECONDS + 1 + expect(await read('third', async () => 'new')).toBe('old') + await vi.waitFor(() => expect(storage.entries.get('stale:cached')).toEqual({ v: 'new', t: now })) + expect(computes).toEqual(['first', 'third']) + }) + + it('lets a stale read through recompute once the refresh claim lapses', async () => { + vi.useFakeTimers({ toFake: ['Date'] }) + const storage = memoryStorage({ 'stale:read-through': { storedAt: Date.now() - (HOUR + 60) * 1000, value: 'old' } }) + const computes: string[] = [] + const read = (label: string, compute: () => Promise) => readThroughCache(storage, 'stale:read-through', () => { + computes.push(label) + return compute() + }, { ttl: HOUR, staleTtl: HOUR }) + + void read('first', cutOff) + await vi.waitFor(() => expect(computes).toEqual(['first'])) + expect(await read('second', async () => 'new')).toBe('old') + + vi.setSystemTime(Date.now() + (REFRESH_CLAIM_SECONDS + 1) * 1000) + expect(await read('third', async () => 'new')).toBe('new') + expect(computes).toEqual(['first', 'third']) + }) +}) + +describe('stale reads in one isolate', () => { + it('start one refresh and serve the stale value meanwhile', async () => { + const storage = memoryStorage({ 'stale:burst': { storedAt: Date.now() - (HOUR + 60) * 1000, value: 'old' } }) + let release = () => {} + const released = new Promise((resolve) => { + release = resolve + }) + const compute = vi.fn(async () => { + await released + return 'new' + }) + + const first = readThroughCache(storage, 'stale:burst', compute, { ttl: HOUR, staleTtl: HOUR }) + const rest = await Promise.all(Array.from({ length: 7 }, () => readThroughCache(storage, 'stale:burst', compute, { ttl: HOUR, staleTtl: HOUR }))) + release() + + expect(rest).toEqual(Array.from({ length: 7 }).fill('old')) + expect(await first).toBe('new') + expect(compute).toHaveBeenCalledOnce() + }) +}) + +/** A cache storage over a Map, which the test can read back. */ +function memoryStorage(initial: Record = {}): ReadThroughCache & { entries: Map } { + const entries = new Map(Object.entries(initial)) + return { + entries, + getItem: async (key: string) => (entries.get(key) ?? null) as T | null, + setItem: async (key: string, value: never) => { + entries.set(key, value) + }, + } +} + +/** The promise's value, or a rejection if it takes longer than `ms`. */ +async function within(promise: Promise, ms: number): Promise { + let timer: ReturnType | undefined + const limit = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`still waiting after ${ms} ms`)), ms) + }) + return await Promise.race([promise, limit]).finally(() => clearTimeout(timer)) +} diff --git a/test/unit/cache-read-through.test.ts b/test/unit/cache-read-through.test.ts index 68d1aac2..316375ee 100644 --- a/test/unit/cache-read-through.test.ts +++ b/test/unit/cache-read-through.test.ts @@ -88,26 +88,6 @@ describe('readThroughCache', () => { expect(setItem).not.toHaveBeenCalled() }) - it('shares one computation between concurrent callers on one expired key', async () => { - // Each TTL expiry boundary is a thundering-herd candidate: N concurrent - // requests on one expired key must await one compute and one KV write, - // the same guarantee cached() already gives. - const stale = { commits: ['old'] } - const fresh = { commits: ['fresh'] } - const { storage, setItem } = fakeStorage({ storedAt: Date.now() - (HOUR + 60) * 1000, value: stale }) - const compute = vi.fn(async () => { - await new Promise(resolve => setTimeout(resolve, 20)) - return fresh - }) - - const results = await Promise.all(Array.from({ length: 8 }, () => - readThroughCache(storage, 'skills:related:v3:acme/skills/deploy', compute, { ttl: HOUR, staleTtl: HOUR }))) - - expect(compute).toHaveBeenCalledOnce() - expect(setItem).toHaveBeenCalledOnce() - expect(results.every(result => result === fresh)).toBe(true) - }) - it('stops serving a value once it passes the stale window', async () => { const { storage, setItem } = fakeStorage({ storedAt: Date.now() - (HOUR * 2 + 60) * 1000, value: { commits: ['ancient'] } }) const compute = vi.fn(async () => { diff --git a/test/unit/nitro-cache-request-scope.test.ts b/test/unit/nitro-cache-request-scope.test.ts new file mode 100644 index 00000000..99cfe5d4 --- /dev/null +++ b/test/unit/nitro-cache-request-scope.test.ts @@ -0,0 +1,118 @@ +import { defineCachedFunction } from 'nitropack/runtime/internal/cache' +import { describe, expect, it, vi } from 'vitest' + +// Nitro's cache reads its storage and its error sink through virtual modules. +// The test gives it an in-memory storage and drops captured errors. +const { store } = vi.hoisted(() => ({ store: new Map() })) + +vi.mock('nitropack/runtime/internal/storage', () => ({ + useStorage: () => ({ + getItem: async (key: string) => store.get(key) ?? null, + setItem: async (key: string, value: unknown) => { + store.set(key, structuredClone(value)) + }, + }), +})) + +vi.mock('nitropack/runtime/internal/app', () => ({ + useNitroApp: () => ({ captureError: () => {} }), +})) + +// workerd ties I/O to the request that started it. Once that request ends, +// the answer to its I/O never arrives. A request that awaited another +// request's resolve then waited forever, and so did every later request on +// that key in the isolate, because the resolve never settled to clear itself. +describe('nitro cache resolves', () => { + it('give a request its own value when the request whose resolve it met has ended', async () => { + const origin = slowOrigin() + const read = defineCachedFunction((event: RequestEvent) => origin.read(event.context.request), { + name: 'request-scope-ended', + maxAge: 60, + getKey: () => 'key', + }) + const first = workerRequest('first') + const second = workerRequest('second') + + void read(first.event) + await vi.waitFor(() => expect(origin.calls).toEqual(['first'])) + const value = read(second.event) + first.end() + origin.release() + + await expect(within(value, 1_000)).resolves.toBe('value of second') + // The resolve that never answered holds no later request either. + await expect(within(read(workerRequest('third').event), 1_000)).resolves.toBe('value of second') + expect(origin.calls).toEqual(['first', 'second']) + }) + + it('share one resolve between reads inside one request', async () => { + const origin = slowOrigin() + const read = defineCachedFunction((event: RequestEvent) => origin.read(event.context.request), { + name: 'request-scope-shared', + maxAge: 60, + getKey: () => 'key', + }) + const only = workerRequest('only') + + const values = Promise.all([read(only.event), read(only.event)]) + await vi.waitFor(() => expect(origin.calls).toEqual(['only'])) + origin.release() + + expect(await values).toEqual(['value of only', 'value of only']) + expect(origin.calls).toEqual(['only']) + }) +}) + +interface WorkerRequest { + name: string + ended: boolean +} + +/** The part of an H3 event Nitro's cache reads. */ +interface RequestEvent { + __is_event__: true + context: { request: WorkerRequest } +} + +function workerRequest(name: string) { + const request: WorkerRequest = { name, ended: false } + const event: RequestEvent = { __is_event__: true, context: { request } } + return { + event, + end: () => { + request.ended = true + }, + } +} + +/** + * An upstream that holds every answer until `release`. workerd drops the + * answer to a request that ended before it arrived. + */ +function slowOrigin() { + let release = () => {} + const released = new Promise((resolve) => { + release = resolve + }) + const calls: string[] = [] + return { + calls, + release: () => release(), + read: async (request: WorkerRequest): Promise => { + calls.push(request.name) + await released + if (request.ended) + await new Promise(() => {}) + return `value of ${request.name}` + }, + } +} + +/** The promise's value, or a rejection if it takes longer than `ms`. */ +async function within(promise: Promise, ms: number): Promise { + let timer: ReturnType | undefined + const limit = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`still waiting after ${ms} ms`)), ms) + }) + return await Promise.race([promise, limit]).finally(() => clearTimeout(timer)) +} diff --git a/test/unit/org-profile-cache.test.ts b/test/unit/org-profile-cache.test.ts index d96acd7f..8886bb76 100644 --- a/test/unit/org-profile-cache.test.ts +++ b/test/unit/org-profile-cache.test.ts @@ -9,8 +9,7 @@ import { allMigrations, createSqliteD1 } from './helpers/d1-sqlite' // (SKILLD-1M, SKILLD-1N and the 2026-08-05 orgs family). These tests pin the // read-through SWR cache that stands in front of it: one D1 pass per owner // while the entry is fresh, stale entries served while exactly one refresh -// recomputes in the background, and concurrent cold misses sharing a single -// computation. +// recomputes in the background. describe('org profile SWR cache', () => { const NOW_SEC = Math.floor(Date.now() / 1000) const owner = 'acme' @@ -111,27 +110,6 @@ describe('org profile SWR cache', () => { }) }) - it('shares one computation between concurrent requests on a cold miss', async () => { - const setItem = vi.fn(async () => {}) - vi.stubGlobal('useStorage', () => ({ - getItem: async () => null, - setItem, - })) - - await handler(event()) - const singleRunQueries = prepareCalls - const writesBeforeBurst = setItem.mock.calls.filter(([key]) => key === cacheKey).length - - const beforeBurst = prepareCalls - const results = await Promise.all(Array.from({ length: 8 }, () => handler(event()))) - const burstQueries = prepareCalls - beforeBurst - - expect(new Set(results).size).toBe(1) - expect(burstQueries).toBeLessThanOrEqual(singleRunQueries) - const profileWrites = setItem.mock.calls.filter(([key]) => key === cacheKey).length - expect(profileWrites - writesBeforeBurst).toBe(1) - }) - it('gives the ungh read a timeout and caches nothing when it fails', async () => { const cacheMap = new Map() vi.stubGlobal('useStorage', () => ({ diff --git a/test/unit/skill-detail-swr.test.ts b/test/unit/skill-detail-swr.test.ts index 26f3255f..c557f513 100644 --- a/test/unit/skill-detail-swr.test.ts +++ b/test/unit/skill-detail-swr.test.ts @@ -9,8 +9,8 @@ import { allMigrations, createSqliteD1 } from './helpers/d1-sqlite' // TTL expires re-runs the full handler, about six D1 queries plus a possible // live GitHub render, and the ops triage ledger attributes recurring D1 // overload bursts (SKILLD-G/H/J/K/M/N/P/Q) to exactly this shape on these -// routes. These tests pin the replacement behaviour: one computation per key -// at a time, with stale entries served while a single refresh recomputes. +// routes. These tests pin the replacement behaviour: stale entries served +// while a single refresh recomputes. describe('skill detail SWR cache', () => { const NOW_SEC = Math.floor(Date.now() / 1000) const slug = 'ericzakariasson/scandinavian-design/alpha' @@ -46,28 +46,6 @@ describe('skill detail SWR cache', () => { handler = (await import('../../layers/registry/server/api/skills/[...slug].get')).default }) - it('shares one computation between concurrent requests when the entry has expired', async () => { - // The 60s TTL has passed: KV answers nothing, every request is a miss. - const setItem = vi.fn(async () => {}) - vi.stubGlobal('useStorage', () => ({ - getItem: async () => null, - setItem, - })) - const detailWrites = () => setItem.mock.calls.filter(([key]) => String(key).startsWith('skills:detail:')).length - - await handler(event()) - const singleRunQueries = prepareCalls - - const writesBeforeBurst = detailWrites() - const beforeBurst = prepareCalls - const results = await Promise.all(Array.from({ length: 8 }, () => handler(event()))) - const burstQueries = prepareCalls - beforeBurst - - expect(new Set(results).size).toBe(1) - expect(burstQueries).toBeLessThanOrEqual(singleRunQueries) - expect(detailWrites() - writesBeforeBurst).toBe(1) - }) - it('serves a stale entry while exactly one refresh recomputes in the background', async () => { const cacheMap = new Map() const setItem = vi.fn(async (key: string, value: unknown) => {