Skip to content

feat: Add dedupe functions #13758

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/chatty-bananas-thank.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': minor
---

Add dedupe function
5 changes: 5 additions & 0 deletions packages/kit/src/exports/public.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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;
}

/**
Expand Down
136 changes: 136 additions & 0 deletions packages/kit/src/runtime/app/server/dedupe.js
Original file line number Diff line number Diff line change
@@ -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<Array<any>>} cache_usages - All the cache usages in the request.
* @param {Array<any>} 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<F>} 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<F>} 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;
}
}
78 changes: 78 additions & 0 deletions packages/kit/src/runtime/app/server/deep_equal.js
Original file line number Diff line number Diff line change
@@ -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]));
}
1 change: 1 addition & 0 deletions packages/kit/src/runtime/app/server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,4 @@ export function read(asset) {
}

export { getRequestEvent } from './event.js';
export { getUnderlyingDedupeFunction, dedupe, DedupeCache } from './dedupe.js';
6 changes: 6 additions & 0 deletions packages/kit/src/runtime/server/respond.js
Original file line number Diff line number Diff line change
Expand Up @@ -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__ */
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions packages/kit/src/types/internal.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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 });
}
Original file line number Diff line number Diff line change
@@ -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'
}
});
}
Original file line number Diff line number Diff line change
@@ -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 {};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<h1>async dedupe on page route</h1>
Original file line number Diff line number Diff line change
@@ -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'
}
});
}
23 changes: 23 additions & 0 deletions packages/kit/test/apps/basics/src/routes/dedupe/dedupe.js
Original file line number Diff line number Diff line change
@@ -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];
});
Loading
Loading