ClassY
ClassY

Reputation: 655

access current request event cookie in a module other than server load functions

In svelte, I'm trying to create a module that that gives you an Storage api that is the same for ssr and csr. For instance you can't use localStorage in a server-side component, so calling that method during ssr is basically a NO-OP.

This is the current implementaion

import { browser } from '$app/environment';
import Cookies from 'js-cookie';

type CookieManager = {
    set: (key: string, value: string, opts: { expires?: Date; path: string }) => void;
    get: (key: string) => string | undefined;
};

/**
 * the purpose of this module to be ssr compliant of storage types that differ in ssr/csr
 */
export class StorageTypes {
    static _cookieManager: CookieManager | null = null;

    /**
     * nothing is httpOnly here because its a shared module between ssr/csr
     */
    static get cookies() {
        if (!this._cookieManager && browser) {
            this._cookieManager = {
                get: Cookies.get,
                set: Cookies.set
            };
        }

        if (!this._cookieManager) {
            throw new Error('accessing StorageTypes.cookies before assigning it a value');
        }

        return this._cookieManager;
    }

    static set cookieManager(cookieManager: CookieManager) {
        this._cookieManager = cookieManager;
    }

    static get localStorage() {
        return browser
            ? window.localStorage
            : {
                    // eslint-disable-next-line @typescript-eslint/no-unused-vars
                    getItem(key: string) {
                        return null;
                    },
                    // eslint-disable-next-line @typescript-eslint/no-unused-vars
                    setItem(key: string, value: string | null) {}
                };
    }
}

For localStorage its straight forward, during SSR I just do nothing, but for cookies, we need to have access to the current response stream to be able to add them. In svelte this can only be done in server-load functions and hooks.server.ts (as far as I know). But since I need it in this module, this is what I've done (in hooks.server.ts):

export const setServerSideCookieManager: Handle = async ({ event, resolve }) => {
    StorageTypes.cookieManager = {
        set: event.cookies.set,
        get: event.cookies.get
    };
    return await resolve(event);
};

export const handle: Handle = sequence(setServerSideCookieManager, ...otherstuff);

This will add the current cookie manager that svelte gives us for the current request. But this is the worst solution someone could comeup with (I mean me). What I want to know is that if ther are any better ways to achieve what I'm trying to do (or if this is good enough?).

If you are wondering this is my use case:

function cookie$<T extends ObjectStorageTypes>(key: string, options?: Options<T>) {
    const storage = StorageTypes.cookies.get(key);
    const parsed: T = storage ? JSON.parse(storage) : options?.default;
    let reactiveValue = $state<T>(parsed ?? options?.initializer);

    $effect.root(() => {
        $effect(() => {
            const expirationDate = new Date(Date.now());
            expirationDate.setSeconds(
                expirationDate.getSeconds() + parseInt(PUBLIC_COOKIES_EXPIRATION_SPAN_SECONDS)
            );
            StorageTypes.cookies.set(key, JSON.stringify(reactiveValue), {
                expires: expirationDate,
                path: '/'
            });
        });
    });

    return {
        get value$(): T {
            return reactiveValue;
        },
        set value$(newValue: T) {
            reactiveValue = newValue;
        }
    };
}

This gives us an object that by changing it, it will automatically update the cookie (I freaking love svelte bro)! For example we can call it like

Persisted.cookie$<{ value: Theme }>(THEME_COOKIE_KEY, {
        initializer: { value: 'dark' }
});

I expected to have a way to access the server-side cookie in more idiomatic way (we can simply check if we are in server-side by using browser variable).

Upvotes: 0

Views: 122

Answers (1)

Peppe L-G
Peppe L-G

Reputation: 8345

I haven't looked too closely at your code (so I can be wrong), but you basically want to store cookie info from a user in a global variable on the server-side? This is problematic, since the server can receive requests from multiple different users at the same time, so if the server ever performs an asynchronous operation, you may need to remember cookie info from multiple users at the same.

Obtaining the cookie info in hooks.server.ts is OK, but you can't store it in a global variable. Instead, you rather need to pass along it (for example as an argument) to all the functions you call/objects you create from there, so they can do asynchronous work and still have access to the cookie info from the right user.

But you can also consider to not have server-side rendering support for the pages that uses user-specific information like this.


Using cookieManager on the server-side

On the server-side, to get access to your cookieManager in handlers and load() functions, create your cookieManager and assign it to event.locals in your first handler:

$lib/cookie-manager.js

export class CookieManager {
    
    cookies = null
    
    constructor(cookies){
        this.cookies = cookies
    }
    
    log(){
        console.log(`Cookies`, this.cookies)
    }
    
}

src/hooks.js

import { CookieManager } from '$lib/CookieManager.js';

/** @type {import('@sveltejs/kit').Handle} */
function addCookieManagerHandle({ event, resolve }) {
    
    // This way, the cookie manager becomes available
    // in all other handles and load() functions.
    event.locals.cookieManager = new CookieManager(event.cookies)
    
    return await resolve(event);
    
}

export const handle: Handle = sequence(addCookieManagerHandle, ...otherstuff);

You can also add the following to get type info:

src/app.d.ts

declare class CookieManager {
    constructor(cookies: any);
    log(): void;
}

declare namespace App {
    
    interface Locals {
        
        cookieManager: CookieManager;
        
    }
    
}

Using cookieManager on the client-side

On the client-side, create the cookieManager in the root +layout.svelte file, and use the Context API to make it available to other Svelte components:

src/routes/+layout.svelte

<script>
    import { CookieManager } from '$lib/CookieManager.js';
    import { setContext } from 'svelte';
    
    setContext(
        'cookieManager',
        new CookieManager(document.cookie),
    );
    
</script>

<slot />

(since the client-side and the server-side works with different sources for the cookies, it's probably a good idea to have a ServerSideCookieManger and a ClientSideCookieManager (same interface, but different implementations))

src/routes/+page.svelte

<script>
    import { getContext } from 'svelte';
    
    const cookieManager = getContext(
        'cookieManager',
    );
    
</script>

<slot />

Using cookieManager on the server-side in Svelte components

When using server-side rendering, I'm guessing you can't use document.cookie in Svelte components to obtain the user's cookies. So a workaround here is to pass the cookies from event.cookies to the root +layout.svelte component using the load() function, and then using the Context API to make it available to other Svelte components:

src/routes/+layout.server.js

/** @type {import('./$types').LayoutServerLoad} */
export async function load(event) {
    return {
        cookies: event.cookies.getAll(),
    };
}

src/routes/+layout.svelte

<script>
    import { CookieManager } from '$lib/CookieManager.js';
    import { setContext } from 'svelte';
    
    /** @type {import('./$types').LayoutData} */
    export let data;
    
    setContext(
        'cookieManager',
        new CookieManager(data.cookies), // A third source of cookies, so maybe you need a third implementation of CookieManager to work with this source.
    );
    
</script>

<slot />

Upvotes: 0

Related Questions