@riducms/sveltekit 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Haniel Ubogu
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,44 @@
1
+ # `@riducms/sveltekit`
2
+
3
+ SvelteKit integration for a Ridu application's generated client. Browsers call Ridu directly;
4
+ server loads and form actions use a request-scoped client. Both authenticate with one session token
5
+ kept in a cookie on the application's own domain, so the frontend and Ridu can run on unrelated
6
+ domains with third-party cookies blocked.
7
+
8
+ ```ts
9
+ // src/lib/ridu.ts
10
+ import { PUBLIC_RIDU_URL } from "$env/static/public";
11
+ import { defineRidu, type InferClient } from "@riducms/sveltekit";
12
+ import { createClient } from "$lib/ridu.generated";
13
+
14
+ export const ridu = defineRidu({ createClient, baseURL: PUBLIC_RIDU_URL, authCollection: "users" });
15
+ export type Client = InferClient<typeof ridu>;
16
+ ```
17
+
18
+ ```ts
19
+ // src/hooks.server.ts
20
+ import { createRiduHandle } from "@riducms/sveltekit/server";
21
+ import { ridu } from "$lib/ridu";
22
+
23
+ export const handle = createRiduHandle(ridu);
24
+ ```
25
+
26
+ Type `App.Locals.ridu` as `Client`, return `await locals.ridu.auth.getSession()` from the root
27
+ layout's server load with `depends(AUTH_DEPENDENCY)`, and call
28
+ `ridu.provide({ session: () => data.session })` in the root layout. Components then use
29
+ `ridu.use()`; `client.auth.session` is reactive.
30
+
31
+ The [SvelteKit guide](../../website/src/content/docs/sveltekit.md) covers loads, components,
32
+ private images, and the security trade-off: the token cookie is readable by the application's
33
+ scripts so the browser can send it to Ridu.
34
+
35
+ ## Ownership
36
+
37
+ - `defineRidu` holds configuration and types. It never creates a shared authenticated client.
38
+ - `createRiduHandle` creates one client per request. `auth.getSession()` verifies once per request;
39
+ a server-side login, logout, or rejected token writes the cookie on the response.
40
+ - `provide` creates one browser client per mounted application and an isolated, credential-free
41
+ client for each server render. `BrowserSessionStore` owns the cookie, reactive session, tab
42
+ synchronization, and stale-event rules.
43
+ - Transport, errors, and token handling stay in `@riducms/sdk`. This package only supplies
44
+ SvelteKit lifecycle, context, and reactivity.
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "bugs": {
3
+ "url": "https://github.com/riducms/ridu/issues"
4
+ },
5
+ "description": "SvelteKit integration for Ridu: direct browser requests, server rendering, and one session owner.",
6
+ "devDependencies": {
7
+ "@riducms/sdk": "0.4.0",
8
+ "@sveltejs/kit": "^2.48.0"
9
+ },
10
+ "exports": {
11
+ ".": {
12
+ "default": "./src/index.ts",
13
+ "svelte": "./src/index.ts",
14
+ "types": "./src/index.ts"
15
+ },
16
+ "./server": {
17
+ "default": "./src/server.ts",
18
+ "svelte": "./src/server.ts",
19
+ "types": "./src/server.ts"
20
+ }
21
+ },
22
+ "files": [
23
+ "src",
24
+ "LICENSE"
25
+ ],
26
+ "homepage": "https://riducms.com",
27
+ "license": "MIT",
28
+ "name": "@riducms/sveltekit",
29
+ "peerDependencies": {
30
+ "@riducms/sdk": "0.4.0",
31
+ "@sveltejs/kit": "^2.20.0",
32
+ "svelte": "^5.29.0"
33
+ },
34
+ "publishConfig": {
35
+ "access": "public",
36
+ "provenance": true
37
+ },
38
+ "repository": {
39
+ "directory": "packages/sveltekit",
40
+ "type": "git",
41
+ "url": "git+https://github.com/riducms/ridu.git"
42
+ },
43
+ "scripts": {
44
+ "check": "tsc -p tsconfig.json",
45
+ "test": "bun test"
46
+ },
47
+ "type": "module",
48
+ "version": "0.4.0"
49
+ }
@@ -0,0 +1,184 @@
1
+ import type { SessionTokenStore } from "@riducms/sdk";
2
+
3
+ import { expiredTokenCookie, readCookie, tokenCookie } from "./cookie.js";
4
+
5
+ /** The safe session snapshot shared by tabs and rendered pages. It never contains the token. */
6
+ export interface SessionSnapshot {
7
+ id: string;
8
+ expiresAt: string;
9
+ }
10
+
11
+ /** Tabs share one cookie jar; a message only tells other tabs to adopt the change. */
12
+ interface SessionMessage<Session> {
13
+ session: Session | null;
14
+ }
15
+
16
+ interface MessageChannel<Session> {
17
+ postMessage(message: SessionMessage<Session>): void;
18
+ addEventListener(type: "message", listener: (event: MessageEvent) => void): void;
19
+ removeEventListener(type: "message", listener: (event: MessageEvent) => void): void;
20
+ close(): void;
21
+ }
22
+
23
+ export interface BrowserSessionOptions<Session> {
24
+ cookieName: string;
25
+ /** The document whose cookie jar holds the token. */
26
+ document: { cookie: string };
27
+ secure: boolean;
28
+ /**
29
+ * Server-rendered session snapshots, usually `() => data.session` from the root layout. They seed
30
+ * hydration and refresh the user; a stale snapshot never replaces newer browser state.
31
+ */
32
+ snapshot?: (() => Session | null | undefined) | undefined;
33
+ /** Cross-tab notifications. Tokens are never posted, only safe snapshots. */
34
+ channel?: MessageChannel<Session> | undefined;
35
+ /** Record a reactive read, such as a Svelte subscriber. */
36
+ track?: (() => void) | undefined;
37
+ /** The signed-in identity changed: SvelteKit loads that depend on auth must rerun. */
38
+ onAuthChange(): void;
39
+ /** Another tab or a server response replaced the token; verify the new one with Ridu. */
40
+ onExternalToken(): void;
41
+ }
42
+
43
+ /**
44
+ * Owns a token-transport session in the browser. The token lives in a frontend-domain cookie that
45
+ * server rendering also receives; this store reads it on every request and never caches it.
46
+ */
47
+ export class BrowserSessionStore<
48
+ Session extends SessionSnapshot,
49
+ > implements SessionTokenStore<Session> {
50
+ readonly #options: BrowserSessionOptions<Session>;
51
+ readonly #listeners = new Set<() => void>();
52
+ #session: Session | null = null;
53
+ /** The token the current snapshot belongs to. Any other cookie value arrived out of band. */
54
+ #knownToken: string | null = null;
55
+ #lastSnapshot: Session | null | undefined;
56
+ readonly #receive = (event: MessageEvent) => this.#adopt(event.data as SessionMessage<Session>);
57
+
58
+ constructor(options: BrowserSessionOptions<Session>) {
59
+ this.#options = options;
60
+ options.channel?.addEventListener("message", this.#receive);
61
+ }
62
+
63
+ get token() {
64
+ return readCookie(this.#options.document.cookie, this.#options.cookieName);
65
+ }
66
+
67
+ get session() {
68
+ this.#options.track?.();
69
+ this.#reconcile();
70
+ return this.#session;
71
+ }
72
+
73
+ subscribe(listener: () => void) {
74
+ this.#listeners.add(listener);
75
+ return () => this.#listeners.delete(listener);
76
+ }
77
+
78
+ issued(token: string, session: Session) {
79
+ this.#options.document.cookie = tokenCookie(this.#options.cookieName, token, {
80
+ expires: new Date(session.expiresAt),
81
+ secure: this.#options.secure,
82
+ });
83
+ this.#session = session;
84
+ this.#knownToken = token;
85
+ this.#changed(true);
86
+ }
87
+
88
+ verified(token: string, session: Session) {
89
+ // A late verification for a replaced token must not restore the previous account.
90
+ if (this.token !== token) return;
91
+ this.#session = session;
92
+ this.#knownToken = token;
93
+ this.#emit();
94
+ }
95
+
96
+ cleared(token: string) {
97
+ // A late rejection or logout must not clear a newer login.
98
+ if (this.token !== token) return;
99
+ this.#options.document.cookie = expiredTokenCookie(
100
+ this.#options.cookieName,
101
+ this.#options.secure
102
+ );
103
+ this.#session = null;
104
+ this.#knownToken = null;
105
+ this.#changed(true);
106
+ }
107
+
108
+ /**
109
+ * Detect a token that changed without this tab's involvement, such as a server action login, an
110
+ * expired cookie, or a tab without BroadcastChannel. Call it when the page becomes visible.
111
+ */
112
+ checkExternalChange() {
113
+ const token = this.token;
114
+ if (token === this.#knownToken) return;
115
+ this.#knownToken = token;
116
+ this.#session = null;
117
+ this.#emit();
118
+ if (token === null) this.#options.onAuthChange();
119
+ else this.#options.onExternalToken();
120
+ }
121
+
122
+ /**
123
+ * Call once after hydration. A broadcast sent while this page was loading was missed, so compare
124
+ * the rendered session with the cookie: a session that ended or a token that appeared meanwhile
125
+ * reruns auth-dependent loads.
126
+ */
127
+ hydrated() {
128
+ const rendered = this.#options.snapshot?.() ?? null;
129
+ const token = this.token;
130
+ if (rendered !== null && token === null) this.#options.onAuthChange();
131
+ else if (rendered === null && token !== null) this.checkExternalChange();
132
+ }
133
+
134
+ dispose() {
135
+ this.#options.channel?.removeEventListener("message", this.#receive);
136
+ this.#options.channel?.close();
137
+ this.#listeners.clear();
138
+ }
139
+
140
+ #reconcile() {
141
+ const incoming = this.#options.snapshot?.();
142
+ if (incoming === undefined || incoming === this.#lastSnapshot) return;
143
+ this.#lastSnapshot = incoming;
144
+ const token = this.token;
145
+ if (incoming === null) {
146
+ // The server saw no session. Accept that only if this tab agrees there is no token.
147
+ if (token === null) {
148
+ this.#session = null;
149
+ this.#knownToken = null;
150
+ }
151
+ return;
152
+ }
153
+ if (token === null) return;
154
+ // Accept the first snapshot, a refreshed user for the same session, or a token that changed
155
+ // out of band. A snapshot for another session than this tab's newer login is stale.
156
+ const outOfBand = token !== this.#knownToken;
157
+ if (outOfBand || this.#session === null || this.#session.id === incoming.id) {
158
+ this.#session = incoming;
159
+ this.#knownToken = token;
160
+ }
161
+ }
162
+
163
+ #adopt(message: SessionMessage<Session>) {
164
+ const token = this.token;
165
+ if (message.session === null ? token !== null : token === null) {
166
+ // The cookie already moved on; let the visible state follow the cookie instead.
167
+ this.checkExternalChange();
168
+ return;
169
+ }
170
+ this.#session = message.session;
171
+ this.#knownToken = token;
172
+ this.#changed(false);
173
+ }
174
+
175
+ #changed(broadcast: boolean) {
176
+ this.#emit();
177
+ if (broadcast) this.#options.channel?.postMessage({ session: this.#session });
178
+ this.#options.onAuthChange();
179
+ }
180
+
181
+ #emit() {
182
+ for (const listener of this.#listeners) listener();
183
+ }
184
+ }
package/src/cookie.ts ADDED
@@ -0,0 +1,47 @@
1
+ /** Frontend-domain cookie that holds a Ridu session token. */
2
+ export const DEFAULT_COOKIE_NAME = "ridu_token";
3
+
4
+ /** Read one cookie value from a `document.cookie` or `Cookie` header string. */
5
+ export function readCookie(header: string, name: string): string | null {
6
+ for (const part of header.split(";")) {
7
+ const separator = part.indexOf("=");
8
+ if (separator < 0 || part.slice(0, separator).trim() !== name) continue;
9
+ const value = part.slice(separator + 1).trim();
10
+ if (value === "") return null;
11
+ try {
12
+ return decodeURIComponent(value);
13
+ } catch {
14
+ return null;
15
+ }
16
+ }
17
+ return null;
18
+ }
19
+
20
+ export interface TokenCookieOptions {
21
+ /** Absolute session expiry; the browser drops the cookie when the session ends. */
22
+ expires: Date;
23
+ /** Send only over HTTPS. Plain-HTTP development cannot keep a Secure cookie. */
24
+ secure: boolean;
25
+ }
26
+
27
+ /**
28
+ * Serialize the token cookie for `document.cookie`. It is deliberately readable by scripts, so the
29
+ * browser client can send it directly to Ridu; SameSite=Lax keeps it off cross-site subrequests.
30
+ */
31
+ export function tokenCookie(name: string, token: string, options: TokenCookieOptions): string {
32
+ const attributes = [
33
+ `${name}=${encodeURIComponent(token)}`,
34
+ "Path=/",
35
+ `Expires=${options.expires.toUTCString()}`,
36
+ "SameSite=Lax",
37
+ ];
38
+ if (options.secure) attributes.push("Secure");
39
+ return attributes.join("; ");
40
+ }
41
+
42
+ /** Serialize an expired token cookie that removes the stored token. */
43
+ export function expiredTokenCookie(name: string, secure: boolean): string {
44
+ const attributes = [`${name}=`, "Path=/", "Max-Age=0", "SameSite=Lax"];
45
+ if (secure) attributes.push("Secure");
46
+ return attributes.join("; ");
47
+ }
@@ -0,0 +1,77 @@
1
+ import type {
2
+ AuthCollectionSlug,
3
+ ClientOptions,
4
+ RiduClient,
5
+ RiduConfigShape,
6
+ SessionFor,
7
+ SessionTokenStore,
8
+ } from "@riducms/sdk";
9
+
10
+ import { DEFAULT_COOKIE_NAME } from "./cookie.js";
11
+
12
+ /**
13
+ * The generated `createClient` from `ridu.generated.ts`. Its auth parameter is generic, so `any`
14
+ * is the only parameter type every generated signature accepts.
15
+ */
16
+ export type ClientFactory = (options: ClientOptions<any>) => RiduClient<any, any>;
17
+
18
+ /** The application contract bound by a generated factory. */
19
+ export type ConfigOf<Factory extends ClientFactory> =
20
+ ReturnType<Factory> extends RiduClient<infer Config, infer _Default> ? Config : never;
21
+
22
+ export interface RiduDefinitionOptions<Factory extends ClientFactory, Slug extends string> {
23
+ /** The generated factory, which binds the application's types. */
24
+ createClient: Factory;
25
+ /** Public Ridu origin, such as `PUBLIC_RIDU_URL`. Browsers call it directly. */
26
+ baseURL: string;
27
+ /** The auth collection whose users sign in to this application. */
28
+ authCollection: Slug;
29
+ /** Frontend-domain cookie that stores the session token. Defaults to `ridu_token`. */
30
+ cookieName?: string;
31
+ /** Fetch used by server-side clients. Defaults to the platform fetch. */
32
+ fetch?: ClientOptions["fetch"];
33
+ }
34
+
35
+ export const definitionKey: unique symbol = Symbol("ridu.sveltekit.definition");
36
+
37
+ /** Internal binding shared by the browser provider and the server hook. */
38
+ export interface RiduDefinition<
39
+ Config extends RiduConfigShape,
40
+ Slug extends AuthCollectionSlug<Config>,
41
+ > {
42
+ readonly baseURL: string;
43
+ readonly authCollection: Slug;
44
+ readonly cookieName: string;
45
+ client(
46
+ token: SessionTokenStore<SessionFor<Config, Slug>>,
47
+ options?: { fetch?: ClientOptions["fetch"]; memoizeSession?: boolean }
48
+ ): RiduClient<Config, Slug>;
49
+ }
50
+
51
+ export function createDefinition<
52
+ Factory extends ClientFactory,
53
+ Slug extends AuthCollectionSlug<ConfigOf<Factory>>,
54
+ >(options: RiduDefinitionOptions<Factory, Slug>): RiduDefinition<ConfigOf<Factory>, Slug> {
55
+ const baseURL = new URL(options.baseURL).href.replace(/\/$/, "");
56
+ const cookieName = options.cookieName ?? DEFAULT_COOKIE_NAME;
57
+ if (!/^[A-Za-z0-9_-]+$/.test(cookieName)) {
58
+ throw new TypeError("cookieName may contain only letters, digits, underscores, and hyphens");
59
+ }
60
+ return {
61
+ baseURL,
62
+ authCollection: options.authCollection,
63
+ cookieName,
64
+ client(token, clientOptions = {}) {
65
+ const fetch = clientOptions.fetch ?? options.fetch;
66
+ return options.createClient({
67
+ baseURL,
68
+ ...(fetch === undefined ? {} : { fetch }),
69
+ auth: {
70
+ collection: options.authCollection,
71
+ token: token as SessionTokenStore,
72
+ memoizeSession: clientOptions.memoizeSession === true,
73
+ },
74
+ }) as RiduClient<ConfigOf<Factory>, Slug>;
75
+ },
76
+ };
77
+ }
package/src/index.ts ADDED
@@ -0,0 +1,156 @@
1
+ import type { AuthCollectionSlug, RiduClient, RiduConfigShape, SessionFor } from "@riducms/sdk";
2
+ import { getContext, onDestroy, onMount, setContext } from "svelte";
3
+ import { createSubscriber } from "svelte/reactivity";
4
+ import { browser } from "$app/environment";
5
+ import { invalidate } from "$app/navigation";
6
+
7
+ import { BrowserSessionStore } from "./browser-session.js";
8
+ import {
9
+ createDefinition,
10
+ definitionKey,
11
+ type ClientFactory,
12
+ type ConfigOf,
13
+ type RiduDefinition,
14
+ type RiduDefinitionOptions,
15
+ } from "./definition.js";
16
+
17
+ export { DEFAULT_COOKIE_NAME } from "./cookie.js";
18
+ export type { ClientFactory, ConfigOf, RiduDefinitionOptions } from "./definition.js";
19
+
20
+ /** The dependency every auth-sensitive load should declare with `depends(AUTH_DEPENDENCY)`. */
21
+ export const AUTH_DEPENDENCY = "ridu:auth";
22
+
23
+ export interface ProvideOptions<Session> {
24
+ /**
25
+ * The session rendered by the root layout, usually `() => data.session`. It seeds hydration and
26
+ * later refreshes the user; it never overrides a newer sign-in or sign-out in this tab.
27
+ */
28
+ session?: Session | null | (() => Session | null);
29
+ }
30
+
31
+ /** One application's Ridu binding. It holds configuration, never an authenticated client. */
32
+ export interface Ridu<Config extends RiduConfigShape, Slug extends AuthCollectionSlug<Config>> {
33
+ readonly baseURL: string;
34
+ readonly authCollection: Slug;
35
+ readonly cookieName: string;
36
+ /**
37
+ * Create the client for this mounted application and share it with descendants. Call it once,
38
+ * during initialization of the root layout. Server renders get an isolated, credential-free
39
+ * client whose `auth.session` is the rendered snapshot.
40
+ */
41
+ provide(options?: ProvideOptions<SessionFor<Config, Slug>>): RiduClient<Config, Slug>;
42
+ /** Read the client provided by the root layout. Call it during component initialization. */
43
+ use(): RiduClient<Config, Slug>;
44
+ /** @internal */
45
+ readonly [definitionKey]: RiduDefinition<Config, Slug>;
46
+ }
47
+
48
+ /** The client type of a `defineRidu` result, for `App.Locals`. */
49
+ export type InferClient<Binding> =
50
+ Binding extends Ridu<infer Config, infer Slug> ? RiduClient<Config, Slug> : never;
51
+
52
+ /** The session type of a `defineRidu` result. */
53
+ export type InferSession<Binding> =
54
+ Binding extends Ridu<infer Config, infer Slug> ? SessionFor<Config, Slug> : never;
55
+
56
+ const clientKey = Symbol("ridu.sveltekit.client");
57
+
58
+ /**
59
+ * Bind an application's generated client to SvelteKit.
60
+ *
61
+ * ```ts
62
+ * export const ridu = defineRidu({ createClient, baseURL: PUBLIC_RIDU_URL, authCollection: "users" });
63
+ * ```
64
+ */
65
+ export function defineRidu<
66
+ Factory extends ClientFactory,
67
+ const Slug extends AuthCollectionSlug<ConfigOf<Factory>>,
68
+ >(options: RiduDefinitionOptions<Factory, Slug>): Ridu<ConfigOf<Factory>, Slug> {
69
+ type Config = ConfigOf<Factory>;
70
+ type Session = SessionFor<Config, Slug>;
71
+ const definition = createDefinition<Factory, Slug>(options);
72
+ return {
73
+ baseURL: definition.baseURL,
74
+ authCollection: definition.authCollection,
75
+ cookieName: definition.cookieName,
76
+ [definitionKey]: definition,
77
+ provide(provideOptions = {}) {
78
+ const provided = provideOptions.session;
79
+ const snapshot =
80
+ typeof provided === "function" ? provided : () => provided as Session | null | undefined;
81
+ const client = browser
82
+ ? browserClient(definition, snapshot)
83
+ : renderClient(definition, snapshot);
84
+ setContext(clientKey, client);
85
+ return client;
86
+ },
87
+ use() {
88
+ const client = getContext<RiduClient<Config, Slug> | undefined>(clientKey);
89
+ if (client === undefined) {
90
+ throw new Error("ridu.use() needs ridu.provide() in the root layout");
91
+ }
92
+ return client;
93
+ },
94
+ };
95
+ }
96
+
97
+ function browserClient<Config extends RiduConfigShape, Slug extends AuthCollectionSlug<Config>>(
98
+ definition: RiduDefinition<Config, Slug>,
99
+ snapshot: () => SessionFor<Config, Slug> | null | undefined
100
+ ) {
101
+ type Session = SessionFor<Config, Slug>;
102
+ let client: RiduClient<Config, Slug> | undefined;
103
+ const channel =
104
+ typeof BroadcastChannel === "undefined"
105
+ ? undefined
106
+ : new BroadcastChannel(`ridu-auth:${definition.cookieName}`);
107
+ const store: BrowserSessionStore<Session> = new BrowserSessionStore<Session>({
108
+ cookieName: definition.cookieName,
109
+ document,
110
+ secure: location.protocol === "https:",
111
+ snapshot,
112
+ channel,
113
+ track: createSubscriber((update) => store.subscribe(update)),
114
+ onAuthChange: refreshAuthLoads,
115
+ onExternalToken: () => {
116
+ // getSession reports the verified snapshot back to the store.
117
+ client?.auth.getSession().then(refreshAuthLoads, refreshAuthLoads);
118
+ },
119
+ });
120
+ client = definition.client(store);
121
+ const visible = () => {
122
+ if (document.visibilityState === "visible") store.checkExternalChange();
123
+ };
124
+ document.addEventListener("visibilitychange", visible);
125
+ onMount(() => store.hydrated());
126
+ onDestroy(() => {
127
+ document.removeEventListener("visibilitychange", visible);
128
+ store.dispose();
129
+ });
130
+ return client;
131
+ }
132
+
133
+ /** Rerun loads that declared `depends(AUTH_DEPENDENCY)`. Ordinary mutations never do this. */
134
+ function refreshAuthLoads() {
135
+ invalidate(AUTH_DEPENDENCY).catch((error: unknown) => {
136
+ // A failed load renders SvelteKit's error page; keep the failure visible to developers.
137
+ console.error("Ridu could not refresh auth-dependent loads", error);
138
+ });
139
+ }
140
+
141
+ /** A server render never holds a credential; components see the rendered snapshot only. */
142
+ function renderClient<Config extends RiduConfigShape, Slug extends AuthCollectionSlug<Config>>(
143
+ definition: RiduDefinition<Config, Slug>,
144
+ snapshot: () => SessionFor<Config, Slug> | null | undefined
145
+ ) {
146
+ return definition.client({
147
+ token: null,
148
+ get session() {
149
+ return snapshot() ?? null;
150
+ },
151
+ issued() {
152
+ throw new Error("Sign in from a browser event, a form action, or a server load");
153
+ },
154
+ cleared() {},
155
+ });
156
+ }
@@ -0,0 +1,54 @@
1
+ import type { SessionTokenStore } from "@riducms/sdk";
2
+ import type { Cookies } from "@sveltejs/kit";
3
+
4
+ import type { SessionSnapshot } from "./browser-session.js";
5
+
6
+ /**
7
+ * Owns the token for one SvelteKit request. It reads the same frontend cookie the browser client
8
+ * writes, and a server-side login, logout, or rejected token updates that cookie on the response.
9
+ */
10
+ export class RequestSessionStore<
11
+ Session extends SessionSnapshot,
12
+ > implements SessionTokenStore<Session> {
13
+ #session: Session | null = null;
14
+
15
+ constructor(
16
+ private readonly cookies: Cookies,
17
+ private readonly cookieName: string,
18
+ private readonly secure: boolean
19
+ ) {}
20
+
21
+ get token() {
22
+ return this.cookies.get(this.cookieName) ?? null;
23
+ }
24
+
25
+ get session() {
26
+ return this.#session;
27
+ }
28
+
29
+ issued(token: string, session: Session) {
30
+ this.cookies.set(this.cookieName, token, {
31
+ path: "/",
32
+ expires: new Date(session.expiresAt),
33
+ httpOnly: false,
34
+ sameSite: "lax",
35
+ secure: this.secure,
36
+ });
37
+ this.#session = session;
38
+ }
39
+
40
+ verified(token: string, session: Session) {
41
+ if (this.token === token) this.#session = session;
42
+ }
43
+
44
+ cleared(token: string) {
45
+ if (this.token !== token) return;
46
+ this.cookies.delete(this.cookieName, {
47
+ path: "/",
48
+ httpOnly: false,
49
+ sameSite: "lax",
50
+ secure: this.secure,
51
+ });
52
+ this.#session = null;
53
+ }
54
+ }
package/src/server.ts ADDED
@@ -0,0 +1,60 @@
1
+ import type {
2
+ AuthCollectionSlug,
3
+ ClientOptions,
4
+ RiduClient,
5
+ RiduConfigShape,
6
+ SessionFor,
7
+ } from "@riducms/sdk";
8
+ import type { Handle, RequestEvent } from "@sveltejs/kit";
9
+
10
+ import { definitionKey, type RiduDefinition } from "./definition.js";
11
+ import { RequestSessionStore } from "./request-session.js";
12
+
13
+ interface Binding<Config extends RiduConfigShape, Slug extends AuthCollectionSlug<Config>> {
14
+ readonly [definitionKey]: RiduDefinition<Config, Slug>;
15
+ }
16
+
17
+ export interface ServerClientOptions {
18
+ /** Fetch for server requests to Ridu. Defaults to the definition's fetch or the platform fetch. */
19
+ fetch?: ClientOptions["fetch"];
20
+ }
21
+
22
+ /**
23
+ * Create the Ridu client for one SvelteKit request. It authenticates with the request's frontend
24
+ * cookie, verifies `auth.getSession()` at most once, and writes the cookie on the response after a
25
+ * server-side login, logout, or rejected token.
26
+ */
27
+ export function createServerClient<
28
+ Config extends RiduConfigShape,
29
+ Slug extends AuthCollectionSlug<Config>,
30
+ >(
31
+ ridu: Binding<Config, Slug>,
32
+ event: Pick<RequestEvent, "cookies" | "url">,
33
+ options: ServerClientOptions = {}
34
+ ): RiduClient<Config, Slug> {
35
+ const definition = ridu[definitionKey];
36
+ const store = new RequestSessionStore<SessionFor<Config, Slug>>(
37
+ event.cookies,
38
+ definition.cookieName,
39
+ event.url.protocol === "https:"
40
+ );
41
+ return definition.client(store, {
42
+ ...(options.fetch === undefined ? {} : { fetch: options.fetch }),
43
+ memoizeSession: true,
44
+ });
45
+ }
46
+
47
+ /**
48
+ * A SvelteKit `handle` that sets `event.locals.ridu` for each request. Compose it with other hooks
49
+ * through `sequence`.
50
+ */
51
+ export function createRiduHandle<
52
+ Config extends RiduConfigShape,
53
+ Slug extends AuthCollectionSlug<Config>,
54
+ >(ridu: Binding<Config, Slug>, options: ServerClientOptions = {}): Handle {
55
+ return ({ event, resolve }) => {
56
+ // Applications type `App.Locals.ridu` with their own client; assign without widening it.
57
+ Object.assign(event.locals, { ridu: createServerClient(ridu, event, options) });
58
+ return resolve(event);
59
+ };
60
+ }