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:
Retrieve the current unique request symbol
Check whether the state has already been initialized for that request symbol, otherwise initialize a new state and associate it to the request symbol.
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.
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
For each state, we create a proxy that contains a hash or key that uniquely references the source file location, which is identical between the server and client-side code.
When hydrating, the state will search for that same hash in the server-side code in order to load the correct state, if it exists, otherwise will initialize from the provided default.
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:
Transforming the initialisation code from $state({ baz: true}) into something like $state(() => ({ baz: true})
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
}
```
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.
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 hydrationstructuredClone
or anonymous functionWe 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 ofstructuredClone
-able objects.Instead, it might be preferable to wrap the data initialization process inside of an anonymous function, either by:
$state({ baz: true})
into something like$state(() => ({ baz: true})
$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
andsetter
with an inner value on the value returned by the rune, ie: the function signature could function like: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
andunserialise
. 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