diff --git a/.changeset/chatty-bananas-thank.md b/.changeset/chatty-bananas-thank.md new file mode 100644 index 000000000000..0a70936d1c4d --- /dev/null +++ b/.changeset/chatty-bananas-thank.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': minor +--- + +Add dedupe function diff --git a/packages/kit/src/exports/public.d.ts b/packages/kit/src/exports/public.d.ts index 732df205d0ae..77b1cebd6202 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -19,6 +19,7 @@ import { } from '../types/private.js'; import { BuildData, SSRNodeLoader, SSRRoute, ValidatedConfig } from 'types'; import type { PluginOptions } from '@sveltejs/vite-plugin-svelte'; +import type { DedupeCache } from '../runtime/app/server/dedupe.js'; export { PrerenderOption } from '../types/private.js'; @@ -1250,6 +1251,10 @@ export interface RequestEvent< * `true` for `+server.js` calls coming from SvelteKit without the overhead of actually making an HTTP request. This happens when you make same-origin `fetch` requests on the server. */ isSubRequest: boolean; + /** + * The cache responsible for deduplicating function calls. + */ + dedupe: DedupeCache; } /** diff --git a/packages/kit/src/runtime/app/server/dedupe.js b/packages/kit/src/runtime/app/server/dedupe.js new file mode 100644 index 000000000000..482d89fbc891 --- /dev/null +++ b/packages/kit/src/runtime/app/server/dedupe.js @@ -0,0 +1,136 @@ +import { getRequestEvent } from './event.js'; +import { deep_equal } from './deep_equal.js'; + +/** + * Internal function to find a function call in the cache. + * @param {Array>} cache_usages - All the cache usages in the request. + * @param {Array} arg_array - The arguments to find. + * @returns The function call and the index of the cache usage, or undefined if not found. + */ +function find(cache_usages, arg_array) { + for (let i = 0; i < cache_usages.length; i++) { + const usage = cache_usages[i]; + if (usage.length !== arg_array.length + 1) { + continue; + } + let j = 1; + for (; j < usage.length; j++) { + if (!deep_equal(usage[j], arg_array[j - 1])) { + break; + } + } + if (j === usage.length) { + return [usage[0], i]; + } + } +} + +const underlying_fn = Symbol('underlyingFn'); + +/** + * Gets the underlying function that was turned into a proxy. + * @template {(...args: any[]) => any} F + * @param {F} fn - The function to get the underlying function from. + * @returns {F} The underlying function. + */ +export function getUnderlyingDedupeFunction(fn) { + // @ts-expect-error: This is a magic value + return fn[underlying_fn] || fn; +} + +/** + * Creates a deduplicated function. This means that within a request, if multiple + * calls are made with the same arguments, the underlying function will only be + * called once and the result will be cached and returned for all subsequent calls. + * + * @template {(...args: any[]) => any} F + * @param {F} fn - The function to deduplicate. + * @returns {F} The deduplicated function. + */ +export function dedupe(fn) { + // @ts-expect-error: Our internal magic value + if (fn[underlying_fn] === fn) { + throw new Error('Cannot dedupe a function that is already deduplicated'); + } + return new Proxy(fn, { + get(target, prop, receiver) { + if (prop === underlying_fn) { + // Magic value to get the underlying function + return target; + } + + return Reflect.get(target, prop, receiver); + }, + apply(target, this_arg, arg_array) { + const ev = getRequestEvent(); + if (!ev) { + // No active Svelte request, so we can't dedupe + return Reflect.apply(target, this_arg, arg_array); + } + + // Find our cache for this function + // @ts-expect-error: We are accessing the private _values property + let values = ev.dedupe._values.get(target); + if (!values) { + values = []; + // @ts-expect-error: We are accessing the private _values property + ev.dedupe._values.set(target, values); + } + + // Check if we have a cached result for these arguments + const res = find(values, arg_array); + if (res) { + return res[0]; + } + + // Call the function and cache the result + const result = Reflect.apply(target, this_arg, arg_array); + arg_array.unshift(result); + values.push(arg_array); + return result; + } + }); +} + +/** Defines the cache of functions for this request. */ +export class DedupeCache { + constructor() { + /** @private */ + this._values = new Map(); + } + + /** + * Check if a given function call is cached. + * @template {(...args: any[]) => any} F + * @param {F} fn - The function to check. + * @param {Parameters} args - The arguments to check. + * @returns {boolean} - Whether the function call is cached. + */ + has(fn, ...args) { + const items = this._values.get(getUnderlyingDedupeFunction(fn)); + if (!items) { + return false; + } + return !!find(items, args); + } + + /** + * Remove a function call from the cache. + * @template {(...args: any[]) => any} F + * @param {F} fn - The function to remove. + * @param {Parameters} args - The arguments to remove. + * @returns {boolean} - Whether the function call was removed. + */ + remove(fn, ...args) { + const items = this._values.get(getUnderlyingDedupeFunction(fn)); + if (!items) { + return false; + } + const res = find(items, args); + if (!res) { + return false; + } + items.splice(res[1], 1); + return true; + } +} diff --git a/packages/kit/src/runtime/app/server/deep_equal.js b/packages/kit/src/runtime/app/server/deep_equal.js new file mode 100644 index 000000000000..f388d606d467 --- /dev/null +++ b/packages/kit/src/runtime/app/server/deep_equal.js @@ -0,0 +1,78 @@ +// @ts-nocheck: This file is NOT type-safe, be careful. + +const binary_constructors = [ + Uint8Array, + Uint16Array, + Uint32Array, + Uint8ClampedArray, + Float32Array, + Float64Array +]; + +/** + * Internal function to check if two values are deeply equal. + * @param {*} a - The first value. + * @param {*} b - The second value. + * @returns {boolean} - Whether the two values are deeply equal. + */ +export function deep_equal(a, b) { + if (a === b) { + // Fast path for identical references + return true; + } + if (typeof a !== typeof b) { + // Fast path for different types + return false; + } + if (typeof a !== 'object') { + // Fast path for types that equality alone works for + return false; + } + + if (a instanceof Date) { + // Technically, the JSON check will handle this, but we can make it more accurate + if (!(b instanceof Date)) return false; + return a.getTime() === b.getTime(); + } + + if (a instanceof Map) { + if (!(b instanceof Map)) return false; + for (const [key, value] of a) { + if (!b.has(key)) return false; + if (!deep_equal(value, b.get(key))) return false; + } + return a.size === b.size; + } + + if (a instanceof Set) { + if (!(b instanceof Set)) return false; + for (const value of a) { + if (!b.has(value)) return false; + if (!deep_equal(value, b.get(value))) return false; + } + return a.size === b.size; + } + + const a_is_array = Array.isArray(a); + const b_is_array = Array.isArray(b); + if (a_is_array !== b_is_array) { + // One is an array, the other is not + return false; + } + + if (a_is_array) { + return a.every((value, index) => deep_equal(value, b[index])); + } + + if (binary_constructors.includes(a.constructor)) { + return a.length === b.length && a.every((value, index) => value === b[index]); + } + + try { + return JSON.stringify(a) === JSON.stringify(b); + } catch { + // This means they cannot be JSON-ified. This is annoying. + } + + return Object.values(a).every((value, index) => deep_equal(value, b[index])); +} diff --git a/packages/kit/src/runtime/app/server/index.js b/packages/kit/src/runtime/app/server/index.js index 19c384932107..73ddf4349379 100644 --- a/packages/kit/src/runtime/app/server/index.js +++ b/packages/kit/src/runtime/app/server/index.js @@ -73,3 +73,4 @@ export function read(asset) { } export { getRequestEvent } from './event.js'; +export { getUnderlyingDedupeFunction, dedupe, DedupeCache } from './dedupe.js'; diff --git a/packages/kit/src/runtime/server/respond.js b/packages/kit/src/runtime/server/respond.js index 81b30e0756a5..f08b5ce64527 100644 --- a/packages/kit/src/runtime/server/respond.js +++ b/packages/kit/src/runtime/server/respond.js @@ -34,6 +34,7 @@ import { strip_resolution_suffix } from '../pathname.js'; import { with_event } from '../app/server/event.js'; +import { DedupeCache } from '../app/server/dedupe.js'; /* global __SVELTEKIT_ADAPTER_NAME__ */ /* global __SVELTEKIT_DEV__ */ @@ -136,6 +137,7 @@ export async function respond(request, options, manifest, state) { platform: state.platform, request, route: { id: null }, + dedupe: state.dedupe ?? new DedupeCache(), setHeaders: (new_headers) => { if (__SVELTEKIT_DEV__) { validateHeaders(new_headers); @@ -165,6 +167,10 @@ export async function respond(request, options, manifest, state) { isSubRequest: state.depth > 0 }; + if (!state.dedupe) { + state.dedupe = event.dedupe; + } + event.fetch = create_fetch({ event, options, diff --git a/packages/kit/src/types/internal.d.ts b/packages/kit/src/types/internal.d.ts index 2d54b37ac145..b741a315eea8 100644 --- a/packages/kit/src/types/internal.d.ts +++ b/packages/kit/src/types/internal.d.ts @@ -29,6 +29,7 @@ import { RequestOptions, TrailingSlash } from './private.js'; +import type { DedupeCache } from '../runtime/app/server/dedupe.js'; export interface ServerModule { Server: typeof InternalServer; @@ -497,6 +498,7 @@ export interface SSRState { */ before_handle?: (event: RequestEvent, config: any, prerender: PrerenderOption) => void; emulator?: Emulator; + dedupe?: DedupeCache; } export type StrictBody = string | ArrayBufferView; diff --git a/packages/kit/test/apps/basics/src/routes/dedupe/async/api/+server.js b/packages/kit/test/apps/basics/src/routes/dedupe/async/api/+server.js new file mode 100644 index 000000000000..bc0e15458526 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/dedupe/async/api/+server.js @@ -0,0 +1,22 @@ +import { async_dedupe } from '../../dedupe.js'; + +export async function GET({ fetch }) { + // Make sure de-duping works in the page + let [count, a1, a2] = await async_dedupe('foo', 'bar'); + if (a1 !== 'foo' || a2 !== 'bar') { + throw new Error('Invalid response'); + } + let newCount; + [newCount, a1, a2] = await async_dedupe('foo', 'bar'); + if (newCount !== count) { + throw new Error('Invalid count'); + } + + // Make sure de-duping works in sub-requests + const res = await fetch('/dedupe/async/api/server'); + [newCount, a1, a2] = await res.json(); + if (newCount !== count) { + throw new Error('Invalid count in sub-request'); + } + return new Response(null, { status: 204 }); +} diff --git a/packages/kit/test/apps/basics/src/routes/dedupe/async/api/server/+server.js b/packages/kit/test/apps/basics/src/routes/dedupe/async/api/server/+server.js new file mode 100644 index 000000000000..144980566741 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/dedupe/async/api/server/+server.js @@ -0,0 +1,10 @@ +import { async_dedupe } from '../../../dedupe.js'; + +export async function GET() { + const a = await async_dedupe('foo', 'bar'); + return new Response(JSON.stringify(a), { + headers: { + 'Content-Type': 'application/json' + } + }); +} diff --git a/packages/kit/test/apps/basics/src/routes/dedupe/async/page/+page.server.js b/packages/kit/test/apps/basics/src/routes/dedupe/async/page/+page.server.js new file mode 100644 index 000000000000..97b39ebe3df2 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/dedupe/async/page/+page.server.js @@ -0,0 +1,22 @@ +import { async_dedupe } from '../../dedupe.js'; + +export async function load({ fetch }) { + // Make sure de-duping works in the page + let [count, a1, a2] = await async_dedupe('foo', 'bar'); + if (a1 !== 'foo' || a2 !== 'bar') { + throw new Error('Invalid response'); + } + let newCount; + [newCount, a1, a2] = await async_dedupe('foo', 'bar'); + if (newCount !== count) { + throw new Error('Invalid count'); + } + + // Make sure de-duping works in sub-requests + const res = await fetch('/dedupe/async/page/server'); + [newCount, a1, a2] = await res.json(); + if (newCount !== count) { + throw new Error('Invalid count in sub-request'); + } + return {}; +} diff --git a/packages/kit/test/apps/basics/src/routes/dedupe/async/page/+page.svelte b/packages/kit/test/apps/basics/src/routes/dedupe/async/page/+page.svelte new file mode 100644 index 000000000000..5d4473f5123a --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/dedupe/async/page/+page.svelte @@ -0,0 +1 @@ +

async dedupe on page route

diff --git a/packages/kit/test/apps/basics/src/routes/dedupe/async/page/server/+server.js b/packages/kit/test/apps/basics/src/routes/dedupe/async/page/server/+server.js new file mode 100644 index 000000000000..144980566741 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/dedupe/async/page/server/+server.js @@ -0,0 +1,10 @@ +import { async_dedupe } from '../../../dedupe.js'; + +export async function GET() { + const a = await async_dedupe('foo', 'bar'); + return new Response(JSON.stringify(a), { + headers: { + 'Content-Type': 'application/json' + } + }); +} diff --git a/packages/kit/test/apps/basics/src/routes/dedupe/dedupe.js b/packages/kit/test/apps/basics/src/routes/dedupe/dedupe.js new file mode 100644 index 000000000000..93b48365e152 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/dedupe/dedupe.js @@ -0,0 +1,23 @@ +import { dedupe } from '$app/server'; + +let count = 0; + +/** + * @template {any[]} A + * @param {...A} args + * @returns {[number, ...A]} + */ +export const sync_dedupe = dedupe((...args) => { + count++; + return [count, ...args]; +}); + +/** + * @template {any[]} A + * @param {...A} args + * @returns {Promise<[number, ...A]>} + */ +export const async_dedupe = dedupe(async (...args) => { + count++; + return [count, ...args]; +}); diff --git a/packages/kit/test/apps/basics/src/routes/dedupe/sync/api/+server.js b/packages/kit/test/apps/basics/src/routes/dedupe/sync/api/+server.js new file mode 100644 index 000000000000..a4d85fbab330 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/dedupe/sync/api/+server.js @@ -0,0 +1,22 @@ +import { sync_dedupe } from '../../dedupe.js'; + +export async function GET({ fetch }) { + // Make sure de-duping works in the page + let [count, a1, a2] = sync_dedupe('foo', 'bar'); + if (a1 !== 'foo' || a2 !== 'bar') { + throw new Error('Invalid response'); + } + let newCount; + [newCount, a1, a2] = sync_dedupe('foo', 'bar'); + if (newCount !== count) { + throw new Error('Invalid count'); + } + + // Make sure de-duping works in sub-requests + const res = await fetch('/dedupe/sync/api/server'); + [newCount, a1, a2] = await res.json(); + if (newCount !== count) { + throw new Error('Invalid count in sub-request'); + } + return new Response(null, { status: 204 }); +} diff --git a/packages/kit/test/apps/basics/src/routes/dedupe/sync/api/server/+server.js b/packages/kit/test/apps/basics/src/routes/dedupe/sync/api/server/+server.js new file mode 100644 index 000000000000..91cac914651d --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/dedupe/sync/api/server/+server.js @@ -0,0 +1,10 @@ +import { sync_dedupe } from '../../../dedupe.js'; + +export async function GET() { + const a = sync_dedupe('foo', 'bar'); + return new Response(JSON.stringify(a), { + headers: { + 'Content-Type': 'application/json' + } + }); +} diff --git a/packages/kit/test/apps/basics/src/routes/dedupe/sync/page/+page.server.js b/packages/kit/test/apps/basics/src/routes/dedupe/sync/page/+page.server.js new file mode 100644 index 000000000000..394add4c11c1 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/dedupe/sync/page/+page.server.js @@ -0,0 +1,22 @@ +import { sync_dedupe } from '../../dedupe.js'; + +export async function load({ fetch }) { + // Make sure de-duping works in the page + let [count, a1, a2] = sync_dedupe('foo', 'bar'); + if (a1 !== 'foo' || a2 !== 'bar') { + throw new Error('Invalid response'); + } + let newCount; + [newCount, a1, a2] = sync_dedupe('foo', 'bar'); + if (newCount !== count) { + throw new Error('Invalid count'); + } + + // Make sure de-duping works in sub-requests + const res = await fetch('/dedupe/sync/page/server'); + [newCount, a1, a2] = await res.json(); + if (newCount !== count) { + throw new Error('Invalid count in sub-request'); + } + return {}; +} diff --git a/packages/kit/test/apps/basics/src/routes/dedupe/sync/page/+page.svelte b/packages/kit/test/apps/basics/src/routes/dedupe/sync/page/+page.svelte new file mode 100644 index 000000000000..0fa42535ba71 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/dedupe/sync/page/+page.svelte @@ -0,0 +1 @@ +

sync dedupe on page route

diff --git a/packages/kit/test/apps/basics/src/routes/dedupe/sync/page/server/+server.js b/packages/kit/test/apps/basics/src/routes/dedupe/sync/page/server/+server.js new file mode 100644 index 000000000000..91cac914651d --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/dedupe/sync/page/server/+server.js @@ -0,0 +1,10 @@ +import { sync_dedupe } from '../../../dedupe.js'; + +export async function GET() { + const a = sync_dedupe('foo', 'bar'); + return new Response(JSON.stringify(a), { + headers: { + 'Content-Type': 'application/json' + } + }); +} diff --git a/packages/kit/test/apps/basics/src/routes/dedupe/types/Float32Array/+server.js b/packages/kit/test/apps/basics/src/routes/dedupe/types/Float32Array/+server.js new file mode 100644 index 000000000000..962d1b51db6f --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/dedupe/types/Float32Array/+server.js @@ -0,0 +1,6 @@ +import test_type from '../base.js'; + +const f1 = new Float32Array([1, 2, 3]); +const f2 = new Float32Array([4, 5, 6]); + +export const GET = test_type(f1, f2); diff --git a/packages/kit/test/apps/basics/src/routes/dedupe/types/Float64Array/+server.js b/packages/kit/test/apps/basics/src/routes/dedupe/types/Float64Array/+server.js new file mode 100644 index 000000000000..807e5cd380bf --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/dedupe/types/Float64Array/+server.js @@ -0,0 +1,6 @@ +import test_type from '../base.js'; + +const f1 = new Float64Array([1, 2, 3]); +const f2 = new Float64Array([4, 5, 6]); + +export const GET = test_type(f1, f2); diff --git a/packages/kit/test/apps/basics/src/routes/dedupe/types/Uint16Array/+server.js b/packages/kit/test/apps/basics/src/routes/dedupe/types/Uint16Array/+server.js new file mode 100644 index 000000000000..20727d2eed08 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/dedupe/types/Uint16Array/+server.js @@ -0,0 +1,6 @@ +import test_type from '../base.js'; + +const u1 = new Uint16Array([1, 2, 3]); +const u2 = new Uint16Array([4, 5, 6]); + +export const GET = test_type(u1, u2); diff --git a/packages/kit/test/apps/basics/src/routes/dedupe/types/Uint32Array/+server.js b/packages/kit/test/apps/basics/src/routes/dedupe/types/Uint32Array/+server.js new file mode 100644 index 000000000000..d94373cdb117 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/dedupe/types/Uint32Array/+server.js @@ -0,0 +1,6 @@ +import test_type from '../base.js'; + +const u1 = new Uint32Array([1, 2, 3]); +const u2 = new Uint32Array([4, 5, 6]); + +export const GET = test_type(u1, u2); diff --git a/packages/kit/test/apps/basics/src/routes/dedupe/types/Uint8Array/+server.js b/packages/kit/test/apps/basics/src/routes/dedupe/types/Uint8Array/+server.js new file mode 100644 index 000000000000..0a84c9c36ee8 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/dedupe/types/Uint8Array/+server.js @@ -0,0 +1,6 @@ +import test_type from '../base.js'; + +const u1 = new Uint8Array([1, 2, 3]); +const u2 = new Uint8Array([4, 5, 6]); + +export const GET = test_type(u1, u2); diff --git a/packages/kit/test/apps/basics/src/routes/dedupe/types/Uint8ClampedArray/+server.js b/packages/kit/test/apps/basics/src/routes/dedupe/types/Uint8ClampedArray/+server.js new file mode 100644 index 000000000000..4b85751f3e5d --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/dedupe/types/Uint8ClampedArray/+server.js @@ -0,0 +1,6 @@ +import test_type from '../base.js'; + +const u1 = new Uint8ClampedArray([1, 2, 3]); +const u2 = new Uint8ClampedArray([4, 5, 6]); + +export const GET = test_type(u1, u2); diff --git a/packages/kit/test/apps/basics/src/routes/dedupe/types/array/+server.js b/packages/kit/test/apps/basics/src/routes/dedupe/types/array/+server.js new file mode 100644 index 000000000000..09e014528713 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/dedupe/types/array/+server.js @@ -0,0 +1,6 @@ +import test_type from '../base.js'; + +const a1 = [() => {}]; +const a2 = [() => {}]; + +export const GET = test_type(a1, a2); diff --git a/packages/kit/test/apps/basics/src/routes/dedupe/types/base.js b/packages/kit/test/apps/basics/src/routes/dedupe/types/base.js new file mode 100644 index 000000000000..3a6934d4b58d --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/dedupe/types/base.js @@ -0,0 +1,43 @@ +import { sync_dedupe } from '../dedupe.js'; + +/** + * Internal function to test that types are deduped correctly + * @param {*} a + * @param {*} ne_to_a + * @returns + */ +export default function test_type(a, ne_to_a) { + return () => { + const totally_different_type = typeof a === 'string' ? 1 : 'foo'; + const [count, eq_to_a] = sync_dedupe(a); + if (eq_to_a !== a) { + return new Response('Invalid dedupe', { status: 500 }); + } + + const [new_count, eq_to_a_too] = sync_dedupe(a); + if (new_count !== count) { + return new Response('Value was not deduped', { status: 500 }); + } + if (eq_to_a_too !== a) { + return new Response('Invalid dedupe', { status: 500 }); + } + + const [ne_count, eq_to_ne_to_a] = sync_dedupe(ne_to_a); + if (ne_count === count) { + return new Response('Value used previous dedupe', { status: 500 }); + } + if (eq_to_ne_to_a !== ne_to_a) { + return new Response('Invalid dedupe', { status: 500 }); + } + + const [new_ne_count, eq_to_ne_to_a_too] = sync_dedupe(totally_different_type); + if (new_ne_count === ne_count || new_ne_count === count) { + return new Response('Value was not deduped', { status: 500 }); + } + if (eq_to_ne_to_a_too !== totally_different_type) { + return new Response('Invalid dedupe', { status: 500 }); + } + + return new Response(null, { status: 204 }); + }; +} diff --git a/packages/kit/test/apps/basics/src/routes/dedupe/types/bigint/+server.js b/packages/kit/test/apps/basics/src/routes/dedupe/types/bigint/+server.js new file mode 100644 index 000000000000..893e97247b04 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/dedupe/types/bigint/+server.js @@ -0,0 +1,3 @@ +import test_type from '../base.js'; + +export const GET = test_type(1n, 2n); diff --git a/packages/kit/test/apps/basics/src/routes/dedupe/types/boolean/+server.js b/packages/kit/test/apps/basics/src/routes/dedupe/types/boolean/+server.js new file mode 100644 index 000000000000..8616b37cc6fb --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/dedupe/types/boolean/+server.js @@ -0,0 +1,3 @@ +import test_type from '../base.js'; + +export const GET = test_type(true, false); diff --git a/packages/kit/test/apps/basics/src/routes/dedupe/types/function/+server.js b/packages/kit/test/apps/basics/src/routes/dedupe/types/function/+server.js new file mode 100644 index 000000000000..42a31ba8c04f --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/dedupe/types/function/+server.js @@ -0,0 +1,6 @@ +import test_type from '../base.js'; + +export const GET = test_type( + () => {}, + () => {} +); diff --git a/packages/kit/test/apps/basics/src/routes/dedupe/types/map/+server.js b/packages/kit/test/apps/basics/src/routes/dedupe/types/map/+server.js new file mode 100644 index 000000000000..61143bcb8643 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/dedupe/types/map/+server.js @@ -0,0 +1,6 @@ +import test_type from '../base.js'; + +const m1 = new Map([[1, () => {}]]); +const m2 = new Map([[1, () => {}]]); + +export const GET = test_type(m1, m2); diff --git a/packages/kit/test/apps/basics/src/routes/dedupe/types/null/+server.js b/packages/kit/test/apps/basics/src/routes/dedupe/types/null/+server.js new file mode 100644 index 000000000000..ace0764eb409 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/dedupe/types/null/+server.js @@ -0,0 +1,3 @@ +import test_type from '../base.js'; + +export const GET = test_type(null, undefined); diff --git a/packages/kit/test/apps/basics/src/routes/dedupe/types/number/+server.js b/packages/kit/test/apps/basics/src/routes/dedupe/types/number/+server.js new file mode 100644 index 000000000000..f8c81e182961 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/dedupe/types/number/+server.js @@ -0,0 +1,3 @@ +import test_type from '../base.js'; + +export const GET = test_type(1, 2); diff --git a/packages/kit/test/apps/basics/src/routes/dedupe/types/object/+server.js b/packages/kit/test/apps/basics/src/routes/dedupe/types/object/+server.js new file mode 100644 index 000000000000..f3c64a16ff30 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/dedupe/types/object/+server.js @@ -0,0 +1,6 @@ +import test_type from '../base.js'; + +const o1 = { a: 1 }; +const o2 = { a: 2 }; + +export const GET = test_type(o1, o2); diff --git a/packages/kit/test/apps/basics/src/routes/dedupe/types/set/+server.js b/packages/kit/test/apps/basics/src/routes/dedupe/types/set/+server.js new file mode 100644 index 000000000000..bee0debfcf9d --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/dedupe/types/set/+server.js @@ -0,0 +1,6 @@ +import test_type from '../base.js'; + +const s1 = new Set([() => {}]); +const s2 = new Set([() => {}]); + +export const GET = test_type(s1, s2); diff --git a/packages/kit/test/apps/basics/src/routes/dedupe/types/string/+server.js b/packages/kit/test/apps/basics/src/routes/dedupe/types/string/+server.js new file mode 100644 index 000000000000..8ac1a475458d --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/dedupe/types/string/+server.js @@ -0,0 +1,3 @@ +import test_type from '../base.js'; + +export const GET = test_type('foo', 'bar'); diff --git a/packages/kit/test/apps/basics/src/routes/dedupe/types/symbol/+server.js b/packages/kit/test/apps/basics/src/routes/dedupe/types/symbol/+server.js new file mode 100644 index 000000000000..106b3c1585bd --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/dedupe/types/symbol/+server.js @@ -0,0 +1,3 @@ +import test_type from '../base.js'; + +export const GET = test_type(Symbol('foo'), Symbol('foo')); diff --git a/packages/kit/test/apps/basics/src/routes/dedupe/types/undefined/+server.js b/packages/kit/test/apps/basics/src/routes/dedupe/types/undefined/+server.js new file mode 100644 index 000000000000..a2c2223ca63c --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/dedupe/types/undefined/+server.js @@ -0,0 +1,3 @@ +import test_type from '../base.js'; + +export const GET = test_type(undefined, null); diff --git a/packages/kit/test/apps/basics/src/routes/dedupe/types/void/+server.js b/packages/kit/test/apps/basics/src/routes/dedupe/types/void/+server.js new file mode 100644 index 000000000000..a49d2a3ba0a8 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/dedupe/types/void/+server.js @@ -0,0 +1,18 @@ +import { sync_dedupe } from '../../dedupe.js'; + +export async function GET() { + let a = sync_dedupe(); + if (a.length !== 1) { + return new Response('Invalid response', { status: 500 }); + } + const [count] = a; + a = sync_dedupe(); + if (a.length !== 1) { + return new Response('Invalid response', { status: 500 }); + } + const [newCount] = a; + if (newCount !== count) { + return new Response('Invalid count', { status: 500 }); + } + return new Response(null, { status: 204 }); +} diff --git a/packages/kit/test/apps/basics/test/server.test.js b/packages/kit/test/apps/basics/test/server.test.js index 1a6f709bf535..23278301c15c 100644 --- a/packages/kit/test/apps/basics/test/server.test.js +++ b/packages/kit/test/apps/basics/test/server.test.js @@ -729,3 +729,54 @@ test.describe('getRequestEvent', () => { expect(await response.text()).toBe('hello from hooks.server.js'); }); }); + +test.describe('dedupe', () => { + test('sync dedupe on api route', async ({ request }) => { + const response = await request.get('/dedupe/sync/api'); + expect(response.status()).toBe(204); + }); + + test('async dedupe on api route', async ({ request }) => { + const response = await request.get('/dedupe/async/api'); + expect(response.status()).toBe(204); + }); + + test('sync dedupe on page route', async ({ page }) => { + await page.goto('/dedupe/sync/page'); + expect(await page.textContent('h1')).toContain('sync dedupe on page route'); + }); + + test('async dedupe on page route', async ({ page }) => { + await page.goto('/dedupe/async/page'); + expect(await page.textContent('h1')).toContain('async dedupe on page route'); + }); + + const test_types = [ + 'array', + 'bigint', + 'boolean', + 'function', + 'map', + 'null', + 'number', + 'object', + 'set', + 'string', + 'symbol', + 'Uint8Array', + 'Uint16Array', + 'Uint32Array', + 'Uint8ClampedArray', + 'Float32Array', + 'Float64Array', + 'undefined', + 'void' + ]; + for (const type of test_types) { + test(`sync dedupe on ${type} route`, async ({ request }) => { + const response = await request.get(`/dedupe/types/${type}`); + const error = response.status() === 204 ? null : await response.text(); + expect(error).toBeNull(); + }); + } +}); diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index 23bb6d7287a4..a9122a7a4baf 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -1232,6 +1232,10 @@ declare module '@sveltejs/kit' { * `true` for `+server.js` calls coming from SvelteKit without the overhead of actually making an HTTP request. This happens when you make same-origin `fetch` requests on the server. */ isSubRequest: boolean; + /** + * The cache responsible for deduplicating function calls. + */ + dedupe: DedupeCache; } /** @@ -1935,6 +1939,25 @@ declare module '@sveltejs/kit' { type ValidatedKitConfig = Omit, 'adapter'> & { adapter?: Adapter; }; + /** Defines the cache of functions for this request. */ + class DedupeCache { + + private _values; + /** + * Check if a given function call is cached. + * @param fn - The function to check. + * @param args - The arguments to check. + * @returns - Whether the function call is cached. + */ + has any>(fn: F, ...args: Parameters): boolean; + /** + * Remove a function call from the cache. + * @param fn - The function to remove. + * @param args - The arguments to remove. + * @returns - Whether the function call was removed. + */ + remove any>(fn: F, ...args: Parameters): boolean; + } /** * Throws an error with a HTTP status code and an optional message. * When called during request handling, this will cause SvelteKit to @@ -2423,6 +2446,40 @@ declare module '$app/server' { * @since 2.20.0 */ export function getRequestEvent(): RequestEvent>, string | null>; + /** + * Gets the underlying function that was turned into a proxy. + * @param fn - The function to get the underlying function from. + * @returns The underlying function. + */ + export function getUnderlyingDedupeFunction any>(fn: F): F; + /** + * Creates a deduplicated function. This means that within a request, if multiple + * calls are made with the same arguments, the underlying function will only be + * called once and the result will be cached and returned for all subsequent calls. + * + * @param fn - The function to deduplicate. + * @returns The deduplicated function. + */ + export function dedupe any>(fn: F): F; + /** Defines the cache of functions for this request. */ + export class DedupeCache { + + private _values; + /** + * Check if a given function call is cached. + * @param fn - The function to check. + * @param args - The arguments to check. + * @returns - Whether the function call is cached. + */ + has any>(fn: F, ...args: Parameters): boolean; + /** + * Remove a function call from the cache. + * @param fn - The function to remove. + * @param args - The arguments to remove. + * @returns - Whether the function call was removed. + */ + remove any>(fn: F, ...args: Parameters): boolean; + } export {}; }