sveltejs / svelte

web development for the rest of us
https://svelte.dev
MIT License
79.68k stars 4.23k forks source link

Make `$state` support request-level data isolation #13594

Open AlbertMarashi opened 2 weeks ago

AlbertMarashi commented 2 weeks ago

Describe the problem

Related Issue/Discussion

Currently, server-side requests leak state during SSR re-renders, leading to potential security risks from novice developers, or simple oversight and mistakes. This is currently the most upvoted issue/discussion in SvelteKit.

Describe the proposed solution

By providing a unique globally accessible request symbol for each server-side rendering context, we can provide globally unique, and globally accessible state.

By wrapping $state proxies in a proxy that clones the underlying initial object value, we should be able to safely mutate global $state

I have built a proof of concept library that achieves some of the behavior here. I believe this might be best suited to be included directly into Svelte's core, which enables far more capabilities for when we want to safely utilize global state in Svelte.

[!NOTE]

How it would work

  • For each $state that is called, we initialize a shallow proxy that maintains a reference to the original underlying initial value, OR a anonymous function wrapper that returns a new fresh value for each request.
  • For each server-side render, we create a unique request symbol that uniquely and globally identifies that server-side rendering operation utilizing AsyncLocalStorage.
  • Whenever a $state is accessed during server-side rendering, we:
    1. Retrieve the current unique request symbol
    2. Check whether the state has already been initialized for that request symbol, otherwise initialize a new state and associate it to the request symbol.
    3. The proxy will return the key being accessed, from the cloned/copied value.
  • When the server-side rendering is complete, we serialize all of the state (ie using devalue) in order to be transferred to the client for hydration of client state.
  • On the client-side when $state is called, we first check the global hydrated state to check whether the server has initialized that store, otherwise replace it with the value that we have received.

[!TIP]

What this enables and solves.

Ability to have unique global state during server-side rendering

Within load functions, or server hooks, we would be able to modify and update global state accordingly without risk of request data leakage, provided it is executed within the scope of a AsyncLocalStorage operation


Improved performance and elimination of waterfall requests.

Quite often, we may want to initialize some global state such as a user-authenticated & request-isolated database connection, which is required universally (client/server).

We could initialise this database inside of a +layout.ts file, and often we might as its the only viable option, but unfortunately, this comes with the downside that now every route that depends on the database has to await parent() leading to a nested waterfall of dependencies, significantly slowing down page load times, even when the database might've already been initialised.

Currently, the only options are to store this in request locals, but this means that the database is only accessible via the server-side +(page|layout).server.ts files, leading to complexity for backend-as-a-database paradigms.


Data serialisation for websockets data

Currently, at my company we utilize SurrealDB to load data from a websocket request, unfortunately these operations don't utilise fetch and therefore, all of these requests have to be re-loaded on client-side mount, significantly slowing down client-side interactivity, leading to effectively doubled loading times, and needless requests when data has already been serialised.


Ability to assign to state during load functions

Given that the [name].svelte.(js|ts) files are just javascript modules, it would be possible to assign state to them during


What this would/could replace

  • Kit: Any global stores such as $page could be converted to utilise runes syntax, making accessing the stores easier.

Ability to teleport state to client-side

This ability to maintain a unique file-location based hash for state could also be applied to the teleport method, of even entirely replace the need for it, since state could be assigned to a state

Implementation notes

non-primitive exports only

This would only apply to non-primitive objects as they are the only values which can be proxied, which is already an existing limitation since we cannot reassign to export const foo = "baz" values in JS modules afaik.

So I do not expect this to be a major problem.

$state identification for hydration

structuredClone or anonymous function

We could perform a structuredClone of the underlying initial value during server-side rendering, however this creates and poses some challenges, as the state needs to consist of structuredClone-able objects.

Instead, it might be preferable to wrap the data initialization process inside of an anonymous function, either by:

  1. Transforming the initialisation code from $state({ baz: true}) into something like $state(() => ({ baz: true})
  2. Introducing a $state.isolated(() => ({ baz: true }) rune.

Proxy or no proxy

It is possible to avoid the need to wrap the object in a proxy, by instead providing a getter and setter with an inner value on the value returned by the rune, ie: the function signature could function like:

function isolated<T>(init: () => T): { inner: T };

Serialisation

We would only want to transfer objects that can be properly serialised, or objects that implement a certain method on classes, such as serialise and unserialise. These could be symbols exported by svelte for inclusion in classes, and will enable developers to reinitialize a class or non-pojo object for client-side hydration.

This functionality could help solve the data teleportation issue

AsyncLocalStorage

Implementation code example

Details

`import { getRequestSymbol } from "$app"` ```ts /** * A store that returns the current ongoing request's symbol via the `getRequestSymbol()` method. */ export default { /** * Returns the current async context's request symbol. * * This is safe to call only after the svelte has initialised the symbol, and only during server-side rendering **/ getRequestSymbol(): symbol { throw new Error("AsyncLocalStorage has not been initialized") } } ``` This should technically be done more inside of the svelte's server-side rendering function, but I've used SvelteKit as an example ```ts import type { MaybePromise, RequestEvent, ResolveOptions } from "@sveltejs/kit" import { AsyncLocalStorage } from "node:async_hooks" import app from "./$app" /// Create a new AsyncLocalStorage for to isolate requests const async_local_storage = new AsyncLocalStorage() /// override the request symbol on the server-side (client-side will remain as-is, so that code can safely import the symbol without importing `node:async_hooks`) app.current = () => { const symbol = async_local_storage.getStore() if (symbol === undefined) { throw new Error("Request symbol has not been initialized") } return symbol } /** * Wraps the hooks.server.ts `handle` function to with a middleware that * creates a new AsyncLocalStorage store for each request. * * This allows us to access the current request symbol via the `safe_ssr_store.request_symbol()` method. * which can be later used to store isolated data for each request in * a WeakMap (which is garbage collected after the request is complete). * * @example * import { sequence } from '@sveltejs/kit/hooks'; * * export const handle = sequence( * // ... other hooks * safe_request_wrapper, * // ... other hooks (after this point, the request symbol is available) * ); */ export async function safe_request_wrapper({ event, resolve }: { event: RequestEvent; resolve(event: RequestEvent, opts?: ResolveOptions): MaybePromise; }): Promise { return async_local_storage.run(Symbol(), () => resolve(event)) } ``` ```ts import { browser } from "$app/environment" import { uneval } from "devalue" // this is safe to import in client-side, but not safe to call. import { getRequestSymbol } from "$app" const request_stores: WeakMap> = new WeakMap() export function serialise_state(): string { const map = get_or_init() const entries = Array.from(map).map(([key, value]) => [uneval(key), uneval(value)]) return `` } function get_or_init() { const sym = request_symbol.current() return request_stores.get(sym) ?? request_stores.set(sym, new Map()).get(sym)! } /** * @param key -The key to associate the unique store with. This is required during client-side in order to * figure out which store to retrieve from the window.__SAFE_SSR_STATE__ map. It must be unique * @param initial - the initial value. * * Behaviour: * - On the server, the initial value is cloned with [`structuredClone`](https://developer.mozilla.org/en-US/docs/Web/API/structuredClone) for every * request, and stored in a WeakMap (which is garbage collected after the request is complete, as long as * the request symbol is no longer in scope). * - On the client, we will first look to see if the server has already stored the state in the `window.__SAFE_SSR_STATE__` state * that we have serialized with the `SerialiseClientState` component, otherwise we will return the initial value. */ export function safe_state(key: string, initial: T): { inner: T } { if (browser) { // since only the client-side has reactivity, we use the $state rune // to get the reactive state const state = $state(get_client_state(key, initial)) return { get inner() { return state } } } return { get inner() { // no reactivity needed in SSR const map = get_or_init() // get the state from the map, or clone the initial value if it doesn't exist return (map.get(key) ?? map.set(key, structuredClone(initial)).get(key)!) as T } } } declare global { interface Window { __SAFE_SSR_STATE__?: Map } } function get_client_state(key: string, initial: T) { const state = window.__SAFE_SSR_STATE__?.get(key) if (state) return state as T return initial } ```

Importance

i cannot use svelte without it

Bishwas-py commented 3 days ago

A daily svelte / svelte5 user, but +1 to "i cannot use svelte without it".