@riducms/sveltekit 0.16.2 → 0.18.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.
@@ -0,0 +1,64 @@
1
+ import type { SessionTokenStore } from "@riducms/sdk";
2
+ /** The safe session snapshot shared by tabs and rendered pages. It never contains the token. */
3
+ export interface SessionSnapshot {
4
+ id: string;
5
+ expiresAt: string;
6
+ }
7
+ /** Tabs share one cookie jar; a message only tells other tabs to adopt the change. */
8
+ interface SessionMessage<Session> {
9
+ session: Session | null;
10
+ }
11
+ interface MessageChannel<Session> {
12
+ postMessage(message: SessionMessage<Session>): void;
13
+ addEventListener(type: "message", listener: (event: MessageEvent) => void): void;
14
+ removeEventListener(type: "message", listener: (event: MessageEvent) => void): void;
15
+ close(): void;
16
+ }
17
+ export interface BrowserSessionOptions<Session> {
18
+ cookieName: string;
19
+ /** The document whose cookie jar holds the token. */
20
+ document: {
21
+ cookie: string;
22
+ };
23
+ secure: boolean;
24
+ /**
25
+ * Server-rendered session snapshots, usually `() => data.session` from the root layout. They seed
26
+ * hydration and refresh the user; a stale snapshot never replaces newer browser state.
27
+ */
28
+ snapshot?: (() => Session | null | undefined) | undefined;
29
+ /** Cross-tab notifications. Tokens are never posted, only safe snapshots. */
30
+ channel?: MessageChannel<Session> | undefined;
31
+ /** Record a reactive read, such as a Svelte subscriber. */
32
+ track?: (() => void) | undefined;
33
+ /** The signed-in identity changed: SvelteKit loads that depend on auth must rerun. */
34
+ onAuthChange(): void;
35
+ /** Another tab or a server response replaced the token; verify the new one with Ridu. */
36
+ onExternalToken(): void;
37
+ }
38
+ /**
39
+ * Owns a token-transport session in the browser. The token lives in a frontend-domain cookie that
40
+ * server rendering also receives; this store reads it on every request and never caches it.
41
+ */
42
+ export declare class BrowserSessionStore<Session extends SessionSnapshot> implements SessionTokenStore<Session> {
43
+ #private;
44
+ constructor(options: BrowserSessionOptions<Session>);
45
+ get token(): string | null;
46
+ get session(): Session | null;
47
+ subscribe(listener: () => void): () => boolean;
48
+ issued(token: string, session: Session): void;
49
+ verified(token: string, session: Session): void;
50
+ cleared(token: string): void;
51
+ /**
52
+ * Detect a token that changed without this tab's involvement, such as a server action login, an
53
+ * expired cookie, or a tab without BroadcastChannel. Call it when the page becomes visible.
54
+ */
55
+ checkExternalChange(): void;
56
+ /**
57
+ * Call once after hydration. A broadcast sent while this page was loading was missed, so compare
58
+ * the rendered session with the cookie: a session that ended or a token that appeared meanwhile
59
+ * reruns auth-dependent loads.
60
+ */
61
+ hydrated(): void;
62
+ dispose(): void;
63
+ }
64
+ export {};
@@ -0,0 +1,135 @@
1
+ import { expiredTokenCookie, readCookie, tokenCookie } from "./cookie.js";
2
+ /**
3
+ * Owns a token-transport session in the browser. The token lives in a frontend-domain cookie that
4
+ * server rendering also receives; this store reads it on every request and never caches it.
5
+ */
6
+ export class BrowserSessionStore {
7
+ #options;
8
+ #listeners = new Set();
9
+ #session = null;
10
+ /** The token the current snapshot belongs to. Any other cookie value arrived out of band. */
11
+ #knownToken = null;
12
+ #lastSnapshot;
13
+ #receive = (event) => this.#adopt(event.data);
14
+ constructor(options) {
15
+ this.#options = options;
16
+ options.channel?.addEventListener("message", this.#receive);
17
+ }
18
+ get token() {
19
+ return readCookie(this.#options.document.cookie, this.#options.cookieName);
20
+ }
21
+ get session() {
22
+ this.#options.track?.();
23
+ this.#reconcile();
24
+ return this.#session;
25
+ }
26
+ subscribe(listener) {
27
+ this.#listeners.add(listener);
28
+ return () => this.#listeners.delete(listener);
29
+ }
30
+ issued(token, session) {
31
+ this.#options.document.cookie = tokenCookie(this.#options.cookieName, token, {
32
+ expires: new Date(session.expiresAt),
33
+ secure: this.#options.secure,
34
+ });
35
+ this.#session = session;
36
+ this.#knownToken = token;
37
+ this.#changed(true);
38
+ }
39
+ verified(token, session) {
40
+ // A late verification for a replaced token must not restore the previous account.
41
+ if (this.token !== token)
42
+ return;
43
+ this.#session = session;
44
+ this.#knownToken = token;
45
+ this.#emit();
46
+ }
47
+ cleared(token) {
48
+ // A late rejection or logout must not clear a newer login.
49
+ if (this.token !== token)
50
+ return;
51
+ this.#options.document.cookie = expiredTokenCookie(this.#options.cookieName, this.#options.secure);
52
+ this.#session = null;
53
+ this.#knownToken = null;
54
+ this.#changed(true);
55
+ }
56
+ /**
57
+ * Detect a token that changed without this tab's involvement, such as a server action login, an
58
+ * expired cookie, or a tab without BroadcastChannel. Call it when the page becomes visible.
59
+ */
60
+ checkExternalChange() {
61
+ const token = this.token;
62
+ if (token === this.#knownToken)
63
+ return;
64
+ this.#knownToken = token;
65
+ this.#session = null;
66
+ this.#emit();
67
+ if (token === null)
68
+ this.#options.onAuthChange();
69
+ else
70
+ this.#options.onExternalToken();
71
+ }
72
+ /**
73
+ * Call once after hydration. A broadcast sent while this page was loading was missed, so compare
74
+ * the rendered session with the cookie: a session that ended or a token that appeared meanwhile
75
+ * reruns auth-dependent loads.
76
+ */
77
+ hydrated() {
78
+ const rendered = this.#options.snapshot?.() ?? null;
79
+ const token = this.token;
80
+ if (rendered !== null && token === null)
81
+ this.#options.onAuthChange();
82
+ else if (rendered === null && token !== null)
83
+ this.checkExternalChange();
84
+ }
85
+ dispose() {
86
+ this.#options.channel?.removeEventListener("message", this.#receive);
87
+ this.#options.channel?.close();
88
+ this.#listeners.clear();
89
+ }
90
+ #reconcile() {
91
+ const incoming = this.#options.snapshot?.();
92
+ if (incoming === undefined || incoming === this.#lastSnapshot)
93
+ return;
94
+ this.#lastSnapshot = incoming;
95
+ const token = this.token;
96
+ if (incoming === null) {
97
+ // The server saw no session. Accept that only if this tab agrees there is no token.
98
+ if (token === null) {
99
+ this.#session = null;
100
+ this.#knownToken = null;
101
+ }
102
+ return;
103
+ }
104
+ if (token === null)
105
+ return;
106
+ // Accept the first snapshot, a refreshed user for the same session, or a token that changed
107
+ // out of band. A snapshot for another session than this tab's newer login is stale.
108
+ const outOfBand = token !== this.#knownToken;
109
+ if (outOfBand || this.#session === null || this.#session.id === incoming.id) {
110
+ this.#session = incoming;
111
+ this.#knownToken = token;
112
+ }
113
+ }
114
+ #adopt(message) {
115
+ const token = this.token;
116
+ if (message.session === null ? token !== null : token === null) {
117
+ // The cookie already moved on; let the visible state follow the cookie instead.
118
+ this.checkExternalChange();
119
+ return;
120
+ }
121
+ this.#session = message.session;
122
+ this.#knownToken = token;
123
+ this.#changed(false);
124
+ }
125
+ #changed(broadcast) {
126
+ this.#emit();
127
+ if (broadcast)
128
+ this.#options.channel?.postMessage({ session: this.#session });
129
+ this.#options.onAuthChange();
130
+ }
131
+ #emit() {
132
+ for (const listener of this.#listeners)
133
+ listener();
134
+ }
135
+ }
@@ -0,0 +1,17 @@
1
+ /** Frontend-domain cookie that holds a Ridu session token. */
2
+ export declare const DEFAULT_COOKIE_NAME = "ridu_token";
3
+ /** Read one cookie value from a `document.cookie` or `Cookie` header string. */
4
+ export declare function readCookie(header: string, name: string): string | null;
5
+ export interface TokenCookieOptions {
6
+ /** Absolute session expiry; the browser drops the cookie when the session ends. */
7
+ expires: Date;
8
+ /** Send only over HTTPS. Plain-HTTP development cannot keep a Secure cookie. */
9
+ secure: boolean;
10
+ }
11
+ /**
12
+ * Serialize the token cookie for `document.cookie`. It is deliberately readable by scripts, so the
13
+ * browser client can send it directly to Ridu; SameSite=Lax keeps it off cross-site subrequests.
14
+ */
15
+ export declare function tokenCookie(name: string, token: string, options: TokenCookieOptions): string;
16
+ /** Serialize an expired token cookie that removes the stored token. */
17
+ export declare function expiredTokenCookie(name: string, secure: boolean): string;
package/dist/cookie.js ADDED
@@ -0,0 +1,42 @@
1
+ /** Frontend-domain cookie that holds a Ridu session token. */
2
+ export const DEFAULT_COOKIE_NAME = "ridu_token";
3
+ /** Read one cookie value from a `document.cookie` or `Cookie` header string. */
4
+ export function readCookie(header, name) {
5
+ for (const part of header.split(";")) {
6
+ const separator = part.indexOf("=");
7
+ if (separator < 0 || part.slice(0, separator).trim() !== name)
8
+ continue;
9
+ const value = part.slice(separator + 1).trim();
10
+ if (value === "")
11
+ return null;
12
+ try {
13
+ return decodeURIComponent(value);
14
+ }
15
+ catch {
16
+ return null;
17
+ }
18
+ }
19
+ return null;
20
+ }
21
+ /**
22
+ * Serialize the token cookie for `document.cookie`. It is deliberately readable by scripts, so the
23
+ * browser client can send it directly to Ridu; SameSite=Lax keeps it off cross-site subrequests.
24
+ */
25
+ export function tokenCookie(name, token, options) {
26
+ const attributes = [
27
+ `${name}=${encodeURIComponent(token)}`,
28
+ "Path=/",
29
+ `Expires=${options.expires.toUTCString()}`,
30
+ "SameSite=Lax",
31
+ ];
32
+ if (options.secure)
33
+ attributes.push("Secure");
34
+ return attributes.join("; ");
35
+ }
36
+ /** Serialize an expired token cookie that removes the stored token. */
37
+ export function expiredTokenCookie(name, secure) {
38
+ const attributes = [`${name}=`, "Path=/", "Max-Age=0", "SameSite=Lax"];
39
+ if (secure)
40
+ attributes.push("Secure");
41
+ return attributes.join("; ");
42
+ }
@@ -0,0 +1,34 @@
1
+ import type { AuthCollectionSlug, ClientOptions, RiduClient, RiduConfigShape, SessionFor, SessionTokenStore } from "@riducms/sdk";
2
+ /**
3
+ * The generated `createClient` from `ridu.generated.ts`. Its auth parameter is generic, so `any`
4
+ * is the only parameter type every generated signature accepts.
5
+ */
6
+ export type ClientFactory = (options: ClientOptions<any>) => RiduClient<any, any>;
7
+ /** The application contract bound by a generated factory. */
8
+ export type ConfigOf<Factory extends ClientFactory> = ReturnType<Factory> extends RiduClient<infer Config, infer _Default> ? Config : never;
9
+ export interface RiduDefinitionOptions<Factory extends ClientFactory, Slug extends string> {
10
+ /** The generated factory, which binds the application's types. */
11
+ createClient: Factory;
12
+ /** Public Ridu origin, such as `PUBLIC_RIDU_URL`. Browsers call it directly. */
13
+ baseURL: string;
14
+ /** The auth collection whose users sign in to this application. */
15
+ authCollection: Slug;
16
+ /** Frontend-domain cookie that stores the session token. Defaults to `ridu_token`. */
17
+ cookieName?: string;
18
+ /** Fetch used by server-side clients. Defaults to SvelteKit's `event.fetch`. */
19
+ fetch?: ClientOptions["fetch"];
20
+ }
21
+ export declare const definitionKey: unique symbol;
22
+ /** Internal binding shared by the browser provider and the server hook. */
23
+ export interface RiduDefinition<Config extends RiduConfigShape, Slug extends AuthCollectionSlug<Config>> {
24
+ readonly baseURL: string;
25
+ readonly authCollection: Slug;
26
+ readonly cookieName: string;
27
+ client(token: SessionTokenStore<SessionFor<Config, Slug>>, options?: {
28
+ fetch?: ClientOptions["fetch"];
29
+ /** Used when neither the call nor the definition supplies a fetch. */
30
+ fallbackFetch?: ClientOptions["fetch"];
31
+ memoizeSession?: boolean;
32
+ }): RiduClient<Config, Slug>;
33
+ }
34
+ export declare function createDefinition<Factory extends ClientFactory, Slug extends AuthCollectionSlug<ConfigOf<Factory>>>(options: RiduDefinitionOptions<Factory, Slug>): RiduDefinition<ConfigOf<Factory>, Slug>;
@@ -0,0 +1,26 @@
1
+ import { DEFAULT_COOKIE_NAME } from "./cookie.js";
2
+ export const definitionKey = Symbol("ridu.sveltekit.definition");
3
+ export function createDefinition(options) {
4
+ const baseURL = new URL(options.baseURL).href.replace(/\/$/, "");
5
+ const cookieName = options.cookieName ?? DEFAULT_COOKIE_NAME;
6
+ if (!/^[A-Za-z0-9_-]+$/.test(cookieName)) {
7
+ throw new TypeError("cookieName may contain only letters, digits, underscores, and hyphens");
8
+ }
9
+ return {
10
+ baseURL,
11
+ authCollection: options.authCollection,
12
+ cookieName,
13
+ client(token, clientOptions = {}) {
14
+ const fetch = clientOptions.fetch ?? options.fetch ?? clientOptions.fallbackFetch;
15
+ return options.createClient({
16
+ baseURL,
17
+ ...(fetch === undefined ? {} : { fetch }),
18
+ auth: {
19
+ collection: options.authCollection,
20
+ token: token,
21
+ memoizeSession: clientOptions.memoizeSession === true,
22
+ },
23
+ });
24
+ },
25
+ };
26
+ }
@@ -0,0 +1,41 @@
1
+ import type { AuthCollectionSlug, RiduClient, RiduConfigShape, SessionFor } from "@riducms/sdk";
2
+ import { definitionKey, type ClientFactory, type ConfigOf, type RiduDefinition, type RiduDefinitionOptions } from "./definition.js";
3
+ export { DEFAULT_COOKIE_NAME } from "./cookie.js";
4
+ export type { ClientFactory, ConfigOf, RiduDefinitionOptions } from "./definition.js";
5
+ /** The dependency every auth-sensitive load should declare with `depends(AUTH_DEPENDENCY)`. */
6
+ export declare const AUTH_DEPENDENCY = "ridu:auth";
7
+ export interface ProvideOptions<Session> {
8
+ /**
9
+ * The session rendered by the root layout, usually `() => data.session`. It seeds hydration and
10
+ * later refreshes the user; it never overrides a newer sign-in or sign-out in this tab.
11
+ */
12
+ session?: Session | null | (() => Session | null);
13
+ }
14
+ /** One application's Ridu binding. It holds configuration, never an authenticated client. */
15
+ export interface Ridu<Config extends RiduConfigShape, Slug extends AuthCollectionSlug<Config>> {
16
+ readonly baseURL: string;
17
+ readonly authCollection: Slug;
18
+ readonly cookieName: string;
19
+ /**
20
+ * Create the client for this mounted application and share it with descendants. Call it once,
21
+ * during initialization of the root layout. Server renders get an isolated, credential-free
22
+ * client whose `auth.session` is the rendered snapshot.
23
+ */
24
+ provide(options?: ProvideOptions<SessionFor<Config, Slug>>): RiduClient<Config, Slug>;
25
+ /** Read the client provided by the root layout. Call it during component initialization. */
26
+ use(): RiduClient<Config, Slug>;
27
+ /** @internal */
28
+ readonly [definitionKey]: RiduDefinition<Config, Slug>;
29
+ }
30
+ /** The client type of a `defineRidu` result, for `App.Locals`. */
31
+ export type InferClient<Binding> = Binding extends Ridu<infer Config, infer Slug> ? RiduClient<Config, Slug> : never;
32
+ /** The session type of a `defineRidu` result. */
33
+ export type InferSession<Binding> = Binding extends Ridu<infer Config, infer Slug> ? SessionFor<Config, Slug> : never;
34
+ /**
35
+ * Bind an application's generated client to SvelteKit.
36
+ *
37
+ * ```ts
38
+ * export const ridu = defineRidu({ createClient, baseURL: PUBLIC_RIDU_URL, authCollection: "users" });
39
+ * ```
40
+ */
41
+ export declare function defineRidu<Factory extends ClientFactory, const Slug extends AuthCollectionSlug<ConfigOf<Factory>>>(options: RiduDefinitionOptions<Factory, Slug>): Ridu<ConfigOf<Factory>, Slug>;
package/dist/index.js ADDED
@@ -0,0 +1,94 @@
1
+ import { getContext, onDestroy, onMount, setContext } from "svelte";
2
+ import { createSubscriber } from "svelte/reactivity";
3
+ import { invalidate } from "$app/navigation";
4
+ // SvelteKit 2 re-exports this from $app/environment, which SvelteKit 3 deprecates.
5
+ import { BROWSER } from "esm-env";
6
+ import { BrowserSessionStore } from "./browser-session.js";
7
+ import { createDefinition, definitionKey, } from "./definition.js";
8
+ export { DEFAULT_COOKIE_NAME } from "./cookie.js";
9
+ /** The dependency every auth-sensitive load should declare with `depends(AUTH_DEPENDENCY)`. */
10
+ export const AUTH_DEPENDENCY = "ridu:auth";
11
+ const clientKey = Symbol("ridu.sveltekit.client");
12
+ /**
13
+ * Bind an application's generated client to SvelteKit.
14
+ *
15
+ * ```ts
16
+ * export const ridu = defineRidu({ createClient, baseURL: PUBLIC_RIDU_URL, authCollection: "users" });
17
+ * ```
18
+ */
19
+ export function defineRidu(options) {
20
+ const definition = createDefinition(options);
21
+ return {
22
+ baseURL: definition.baseURL,
23
+ authCollection: definition.authCollection,
24
+ cookieName: definition.cookieName,
25
+ [definitionKey]: definition,
26
+ provide(provideOptions = {}) {
27
+ const provided = provideOptions.session;
28
+ const snapshot = typeof provided === "function" ? provided : () => provided;
29
+ const client = BROWSER
30
+ ? browserClient(definition, snapshot)
31
+ : renderClient(definition, snapshot);
32
+ setContext(clientKey, client);
33
+ return client;
34
+ },
35
+ use() {
36
+ const client = getContext(clientKey);
37
+ if (client === undefined) {
38
+ throw new Error("ridu.use() needs ridu.provide() in the root layout");
39
+ }
40
+ return client;
41
+ },
42
+ };
43
+ }
44
+ function browserClient(definition, snapshot) {
45
+ let client;
46
+ const channel = typeof BroadcastChannel === "undefined"
47
+ ? undefined
48
+ : new BroadcastChannel(`ridu-auth:${definition.cookieName}`);
49
+ const store = new BrowserSessionStore({
50
+ cookieName: definition.cookieName,
51
+ document,
52
+ secure: location.protocol === "https:",
53
+ snapshot,
54
+ channel,
55
+ track: createSubscriber((update) => store.subscribe(update)),
56
+ onAuthChange: refreshAuthLoads,
57
+ onExternalToken: () => {
58
+ // getSession reports the verified snapshot back to the store.
59
+ client?.auth.getSession().then(refreshAuthLoads, refreshAuthLoads);
60
+ },
61
+ });
62
+ client = definition.client(store);
63
+ const visible = () => {
64
+ if (document.visibilityState === "visible")
65
+ store.checkExternalChange();
66
+ };
67
+ document.addEventListener("visibilitychange", visible);
68
+ onMount(() => store.hydrated());
69
+ onDestroy(() => {
70
+ document.removeEventListener("visibilitychange", visible);
71
+ store.dispose();
72
+ });
73
+ return client;
74
+ }
75
+ /** Rerun loads that declared `depends(AUTH_DEPENDENCY)`. Ordinary mutations never do this. */
76
+ function refreshAuthLoads() {
77
+ invalidate(AUTH_DEPENDENCY).catch((error) => {
78
+ // A failed load renders SvelteKit's error page; keep the failure visible to developers.
79
+ console.error("Ridu could not refresh auth-dependent loads", error);
80
+ });
81
+ }
82
+ /** A server render never holds a credential; components see the rendered snapshot only. */
83
+ function renderClient(definition, snapshot) {
84
+ return definition.client({
85
+ token: null,
86
+ get session() {
87
+ return snapshot() ?? null;
88
+ },
89
+ issued() {
90
+ throw new Error("Sign in from a browser event, a form action, or a server load");
91
+ },
92
+ cleared() { },
93
+ });
94
+ }
@@ -0,0 +1,19 @@
1
+ import type { SessionTokenStore } from "@riducms/sdk";
2
+ import type { Cookies } from "@sveltejs/kit";
3
+ import type { SessionSnapshot } from "./browser-session.js";
4
+ /**
5
+ * Owns the token for one SvelteKit request. It reads the same frontend cookie the browser client
6
+ * writes, and a server-side login, logout, or rejected token updates that cookie on the response.
7
+ */
8
+ export declare class RequestSessionStore<Session extends SessionSnapshot> implements SessionTokenStore<Session> {
9
+ #private;
10
+ private readonly cookies;
11
+ private readonly cookieName;
12
+ private readonly secure;
13
+ constructor(cookies: Cookies, cookieName: string, secure: boolean);
14
+ get token(): string | null;
15
+ get session(): Session | null;
16
+ issued(token: string, session: Session): void;
17
+ verified(token: string, session: Session): void;
18
+ cleared(token: string): void;
19
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Owns the token for one SvelteKit request. It reads the same frontend cookie the browser client
3
+ * writes, and a server-side login, logout, or rejected token updates that cookie on the response.
4
+ */
5
+ export class RequestSessionStore {
6
+ cookies;
7
+ cookieName;
8
+ secure;
9
+ #session = null;
10
+ constructor(cookies, cookieName, secure) {
11
+ this.cookies = cookies;
12
+ this.cookieName = cookieName;
13
+ this.secure = secure;
14
+ }
15
+ get token() {
16
+ return this.cookies.get(this.cookieName) ?? null;
17
+ }
18
+ get session() {
19
+ return this.#session;
20
+ }
21
+ issued(token, session) {
22
+ this.cookies.set(this.cookieName, token, {
23
+ path: "/",
24
+ expires: new Date(session.expiresAt),
25
+ httpOnly: false,
26
+ sameSite: "lax",
27
+ secure: this.secure,
28
+ });
29
+ this.#session = session;
30
+ }
31
+ verified(token, session) {
32
+ if (this.token === token)
33
+ this.#session = session;
34
+ }
35
+ cleared(token) {
36
+ if (this.token !== token)
37
+ return;
38
+ this.cookies.delete(this.cookieName, {
39
+ path: "/",
40
+ httpOnly: false,
41
+ sameSite: "lax",
42
+ secure: this.secure,
43
+ });
44
+ this.#session = null;
45
+ }
46
+ }
@@ -0,0 +1,34 @@
1
+ import type { AuthCollectionSlug, ClientOptions, RiduClient, RiduConfigShape } from "@riducms/sdk";
2
+ import type { RequestEvent } from "@sveltejs/kit";
3
+ import { definitionKey, type RiduDefinition } from "./definition.js";
4
+ interface Binding<Config extends RiduConfigShape, Slug extends AuthCollectionSlug<Config>> {
5
+ readonly [definitionKey]: RiduDefinition<Config, Slug>;
6
+ }
7
+ export interface ServerClientOptions {
8
+ /**
9
+ * Fetch for server requests to Ridu. Defaults to the definition's fetch, then to SvelteKit's
10
+ * `event.fetch`. The client sends its token in a header with `credentials: "omit"`, so
11
+ * `event.fetch` does not forward the web app's cookies to Ridu.
12
+ */
13
+ fetch?: ClientOptions["fetch"];
14
+ }
15
+ /**
16
+ * Create the Ridu client for one SvelteKit request. It authenticates with the request's frontend
17
+ * cookie, verifies `auth.getSession()` at most once, and writes the cookie on the response after a
18
+ * server-side login, logout, or rejected token.
19
+ */
20
+ export declare function createServerClient<Config extends RiduConfigShape, Slug extends AuthCollectionSlug<Config>>(ridu: Binding<Config, Slug>, event: Pick<RequestEvent, "cookies" | "url"> & Partial<Pick<RequestEvent, "fetch">>, options?: ServerClientOptions): RiduClient<Config, Slug>;
21
+ /**
22
+ * A server `handle` hook. SvelteKit 2 exports `Handle` from `@sveltejs/kit` and SvelteKit 3 from
23
+ * `@sveltejs/kit/hooks`; this shape is assignable to both.
24
+ */
25
+ export type RiduHandle = (input: {
26
+ event: RequestEvent;
27
+ resolve: (event: RequestEvent) => Response | Promise<Response>;
28
+ }) => Response | Promise<Response>;
29
+ /**
30
+ * A SvelteKit `handle` that sets `event.locals.ridu` for each request. Compose it with other hooks
31
+ * through `sequence`.
32
+ */
33
+ export declare function createRiduHandle<Config extends RiduConfigShape, Slug extends AuthCollectionSlug<Config>>(ridu: Binding<Config, Slug>, options?: ServerClientOptions): RiduHandle;
34
+ export {};
package/dist/server.js ADDED
@@ -0,0 +1,27 @@
1
+ import { definitionKey } from "./definition.js";
2
+ import { RequestSessionStore } from "./request-session.js";
3
+ /**
4
+ * Create the Ridu client for one SvelteKit request. It authenticates with the request's frontend
5
+ * cookie, verifies `auth.getSession()` at most once, and writes the cookie on the response after a
6
+ * server-side login, logout, or rejected token.
7
+ */
8
+ export function createServerClient(ridu, event, options = {}) {
9
+ const definition = ridu[definitionKey];
10
+ const store = new RequestSessionStore(event.cookies, definition.cookieName, event.url.protocol === "https:");
11
+ return definition.client(store, {
12
+ ...(options.fetch === undefined ? {} : { fetch: options.fetch }),
13
+ ...(event.fetch === undefined ? {} : { fallbackFetch: event.fetch }),
14
+ memoizeSession: true,
15
+ });
16
+ }
17
+ /**
18
+ * A SvelteKit `handle` that sets `event.locals.ridu` for each request. Compose it with other hooks
19
+ * through `sequence`.
20
+ */
21
+ export function createRiduHandle(ridu, options = {}) {
22
+ return ({ event, resolve }) => {
23
+ // Applications type `App.Locals.ridu` with their own client; assign without widening it.
24
+ Object.assign(event.locals, { ridu: createServerClient(ridu, event, options) });
25
+ return resolve(event);
26
+ };
27
+ }
package/package.json CHANGED
@@ -6,31 +6,33 @@
6
6
  "esm-env": "^1.2.2"
7
7
  },
8
8
  "description": "SvelteKit integration for Ridu: direct browser requests, server rendering, and one session owner.",
9
- "devDependencies": {
10
- "@riducms/sdk": "0.16.2",
11
- "@sveltejs/kit": "^2.48.0"
12
- },
13
9
  "exports": {
14
10
  ".": {
15
- "default": "./src/index.ts",
16
- "svelte": "./src/index.ts",
17
- "types": "./src/index.ts"
11
+ "default": "./dist/index.js",
12
+ "svelte": "./dist/index.js",
13
+ "types": "./dist/index.d.ts"
18
14
  },
19
15
  "./server": {
20
- "default": "./src/server.ts",
21
- "svelte": "./src/server.ts",
22
- "types": "./src/server.ts"
16
+ "default": "./dist/server.js",
17
+ "svelte": "./dist/server.js",
18
+ "types": "./dist/server.d.ts"
23
19
  }
24
20
  },
25
21
  "files": [
26
- "src",
22
+ "dist",
23
+ "!dist/**/*.test.*",
24
+ "!dist/**/*.spec.*",
27
25
  "LICENSE"
28
26
  ],
29
27
  "homepage": "https://riducms.com",
28
+ "imports": {
29
+ "#lib": "./src/lib/index.js",
30
+ "#lib/*": "./src/lib/*"
31
+ },
30
32
  "license": "MIT",
31
33
  "name": "@riducms/sveltekit",
32
34
  "peerDependencies": {
33
- "@riducms/sdk": "0.16.2",
35
+ "@riducms/sdk": "0.18.0",
34
36
  "@sveltejs/kit": "^2.20.0 || ^3.0.0",
35
37
  "svelte": "^5.29.0"
36
38
  },
@@ -44,9 +46,18 @@
44
46
  "url": "git+https://github.com/riducms/ridu.git"
45
47
  },
46
48
  "scripts": {
47
- "check": "tsc -p tsconfig.json",
48
- "test": "bun test"
49
- },
49
+ "build": "bun run package",
50
+ "check": "svelte-kit sync && tsc -p tsconfig.json",
51
+ "dev": "svelte-kit sync && bun run --parallel dev:package",
52
+ "dev:package": "svelte-package --watch",
53
+ "package": "svelte-kit sync && svelte-package && publint",
54
+ "test": "svelte-kit sync && bun test"
55
+ },
56
+ "sideEffects": [
57
+ "**/*.css"
58
+ ],
59
+ "svelte": "./dist/index.js",
50
60
  "type": "module",
51
- "version": "0.16.2"
61
+ "types": "./dist/index.d.ts",
62
+ "version": "0.18.0"
52
63
  }
@@ -1,184 +0,0 @@
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 DELETED
@@ -1,47 +0,0 @@
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
- }
package/src/definition.ts DELETED
@@ -1,82 +0,0 @@
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 SvelteKit's `event.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?: {
48
- fetch?: ClientOptions["fetch"];
49
- /** Used when neither the call nor the definition supplies a fetch. */
50
- fallbackFetch?: ClientOptions["fetch"];
51
- memoizeSession?: boolean;
52
- }
53
- ): RiduClient<Config, Slug>;
54
- }
55
-
56
- export function createDefinition<
57
- Factory extends ClientFactory,
58
- Slug extends AuthCollectionSlug<ConfigOf<Factory>>,
59
- >(options: RiduDefinitionOptions<Factory, Slug>): RiduDefinition<ConfigOf<Factory>, Slug> {
60
- const baseURL = new URL(options.baseURL).href.replace(/\/$/, "");
61
- const cookieName = options.cookieName ?? DEFAULT_COOKIE_NAME;
62
- if (!/^[A-Za-z0-9_-]+$/.test(cookieName)) {
63
- throw new TypeError("cookieName may contain only letters, digits, underscores, and hyphens");
64
- }
65
- return {
66
- baseURL,
67
- authCollection: options.authCollection,
68
- cookieName,
69
- client(token, clientOptions = {}) {
70
- const fetch = clientOptions.fetch ?? options.fetch ?? clientOptions.fallbackFetch;
71
- return options.createClient({
72
- baseURL,
73
- ...(fetch === undefined ? {} : { fetch }),
74
- auth: {
75
- collection: options.authCollection,
76
- token: token as SessionTokenStore,
77
- memoizeSession: clientOptions.memoizeSession === true,
78
- },
79
- }) as RiduClient<ConfigOf<Factory>, Slug>;
80
- },
81
- };
82
- }
package/src/index.ts DELETED
@@ -1,157 +0,0 @@
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 { invalidate } from "$app/navigation";
5
- // SvelteKit 2 re-exports this from $app/environment, which SvelteKit 3 deprecates.
6
- import { BROWSER } from "esm-env";
7
-
8
- import { BrowserSessionStore } from "./browser-session.js";
9
- import {
10
- createDefinition,
11
- definitionKey,
12
- type ClientFactory,
13
- type ConfigOf,
14
- type RiduDefinition,
15
- type RiduDefinitionOptions,
16
- } from "./definition.js";
17
-
18
- export { DEFAULT_COOKIE_NAME } from "./cookie.js";
19
- export type { ClientFactory, ConfigOf, RiduDefinitionOptions } from "./definition.js";
20
-
21
- /** The dependency every auth-sensitive load should declare with `depends(AUTH_DEPENDENCY)`. */
22
- export const AUTH_DEPENDENCY = "ridu:auth";
23
-
24
- export interface ProvideOptions<Session> {
25
- /**
26
- * The session rendered by the root layout, usually `() => data.session`. It seeds hydration and
27
- * later refreshes the user; it never overrides a newer sign-in or sign-out in this tab.
28
- */
29
- session?: Session | null | (() => Session | null);
30
- }
31
-
32
- /** One application's Ridu binding. It holds configuration, never an authenticated client. */
33
- export interface Ridu<Config extends RiduConfigShape, Slug extends AuthCollectionSlug<Config>> {
34
- readonly baseURL: string;
35
- readonly authCollection: Slug;
36
- readonly cookieName: string;
37
- /**
38
- * Create the client for this mounted application and share it with descendants. Call it once,
39
- * during initialization of the root layout. Server renders get an isolated, credential-free
40
- * client whose `auth.session` is the rendered snapshot.
41
- */
42
- provide(options?: ProvideOptions<SessionFor<Config, Slug>>): RiduClient<Config, Slug>;
43
- /** Read the client provided by the root layout. Call it during component initialization. */
44
- use(): RiduClient<Config, Slug>;
45
- /** @internal */
46
- readonly [definitionKey]: RiduDefinition<Config, Slug>;
47
- }
48
-
49
- /** The client type of a `defineRidu` result, for `App.Locals`. */
50
- export type InferClient<Binding> =
51
- Binding extends Ridu<infer Config, infer Slug> ? RiduClient<Config, Slug> : never;
52
-
53
- /** The session type of a `defineRidu` result. */
54
- export type InferSession<Binding> =
55
- Binding extends Ridu<infer Config, infer Slug> ? SessionFor<Config, Slug> : never;
56
-
57
- const clientKey = Symbol("ridu.sveltekit.client");
58
-
59
- /**
60
- * Bind an application's generated client to SvelteKit.
61
- *
62
- * ```ts
63
- * export const ridu = defineRidu({ createClient, baseURL: PUBLIC_RIDU_URL, authCollection: "users" });
64
- * ```
65
- */
66
- export function defineRidu<
67
- Factory extends ClientFactory,
68
- const Slug extends AuthCollectionSlug<ConfigOf<Factory>>,
69
- >(options: RiduDefinitionOptions<Factory, Slug>): Ridu<ConfigOf<Factory>, Slug> {
70
- type Config = ConfigOf<Factory>;
71
- type Session = SessionFor<Config, Slug>;
72
- const definition = createDefinition<Factory, Slug>(options);
73
- return {
74
- baseURL: definition.baseURL,
75
- authCollection: definition.authCollection,
76
- cookieName: definition.cookieName,
77
- [definitionKey]: definition,
78
- provide(provideOptions = {}) {
79
- const provided = provideOptions.session;
80
- const snapshot =
81
- typeof provided === "function" ? provided : () => provided as Session | null | undefined;
82
- const client = BROWSER
83
- ? browserClient(definition, snapshot)
84
- : renderClient(definition, snapshot);
85
- setContext(clientKey, client);
86
- return client;
87
- },
88
- use() {
89
- const client = getContext<RiduClient<Config, Slug> | undefined>(clientKey);
90
- if (client === undefined) {
91
- throw new Error("ridu.use() needs ridu.provide() in the root layout");
92
- }
93
- return client;
94
- },
95
- };
96
- }
97
-
98
- function browserClient<Config extends RiduConfigShape, Slug extends AuthCollectionSlug<Config>>(
99
- definition: RiduDefinition<Config, Slug>,
100
- snapshot: () => SessionFor<Config, Slug> | null | undefined
101
- ) {
102
- type Session = SessionFor<Config, Slug>;
103
- let client: RiduClient<Config, Slug> | undefined;
104
- const channel =
105
- typeof BroadcastChannel === "undefined"
106
- ? undefined
107
- : new BroadcastChannel(`ridu-auth:${definition.cookieName}`);
108
- const store: BrowserSessionStore<Session> = new BrowserSessionStore<Session>({
109
- cookieName: definition.cookieName,
110
- document,
111
- secure: location.protocol === "https:",
112
- snapshot,
113
- channel,
114
- track: createSubscriber((update) => store.subscribe(update)),
115
- onAuthChange: refreshAuthLoads,
116
- onExternalToken: () => {
117
- // getSession reports the verified snapshot back to the store.
118
- client?.auth.getSession().then(refreshAuthLoads, refreshAuthLoads);
119
- },
120
- });
121
- client = definition.client(store);
122
- const visible = () => {
123
- if (document.visibilityState === "visible") store.checkExternalChange();
124
- };
125
- document.addEventListener("visibilitychange", visible);
126
- onMount(() => store.hydrated());
127
- onDestroy(() => {
128
- document.removeEventListener("visibilitychange", visible);
129
- store.dispose();
130
- });
131
- return client;
132
- }
133
-
134
- /** Rerun loads that declared `depends(AUTH_DEPENDENCY)`. Ordinary mutations never do this. */
135
- function refreshAuthLoads() {
136
- invalidate(AUTH_DEPENDENCY).catch((error: unknown) => {
137
- // A failed load renders SvelteKit's error page; keep the failure visible to developers.
138
- console.error("Ridu could not refresh auth-dependent loads", error);
139
- });
140
- }
141
-
142
- /** A server render never holds a credential; components see the rendered snapshot only. */
143
- function renderClient<Config extends RiduConfigShape, Slug extends AuthCollectionSlug<Config>>(
144
- definition: RiduDefinition<Config, Slug>,
145
- snapshot: () => SessionFor<Config, Slug> | null | undefined
146
- ) {
147
- return definition.client({
148
- token: null,
149
- get session() {
150
- return snapshot() ?? null;
151
- },
152
- issued() {
153
- throw new Error("Sign in from a browser event, a form action, or a server load");
154
- },
155
- cleared() {},
156
- });
157
- }
@@ -1,54 +0,0 @@
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 DELETED
@@ -1,74 +0,0 @@
1
- import type {
2
- AuthCollectionSlug,
3
- ClientOptions,
4
- RiduClient,
5
- RiduConfigShape,
6
- SessionFor,
7
- } from "@riducms/sdk";
8
- import type { 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
- /**
19
- * Fetch for server requests to Ridu. Defaults to the definition's fetch, then to SvelteKit's
20
- * `event.fetch`. The client sends its token in a header with `credentials: "omit"`, so
21
- * `event.fetch` does not forward the web app's cookies to Ridu.
22
- */
23
- fetch?: ClientOptions["fetch"];
24
- }
25
-
26
- /**
27
- * Create the Ridu client for one SvelteKit request. It authenticates with the request's frontend
28
- * cookie, verifies `auth.getSession()` at most once, and writes the cookie on the response after a
29
- * server-side login, logout, or rejected token.
30
- */
31
- export function createServerClient<
32
- Config extends RiduConfigShape,
33
- Slug extends AuthCollectionSlug<Config>,
34
- >(
35
- ridu: Binding<Config, Slug>,
36
- event: Pick<RequestEvent, "cookies" | "url"> & Partial<Pick<RequestEvent, "fetch">>,
37
- options: ServerClientOptions = {}
38
- ): RiduClient<Config, Slug> {
39
- const definition = ridu[definitionKey];
40
- const store = new RequestSessionStore<SessionFor<Config, Slug>>(
41
- event.cookies,
42
- definition.cookieName,
43
- event.url.protocol === "https:"
44
- );
45
- return definition.client(store, {
46
- ...(options.fetch === undefined ? {} : { fetch: options.fetch }),
47
- ...(event.fetch === undefined ? {} : { fallbackFetch: event.fetch }),
48
- memoizeSession: true,
49
- });
50
- }
51
-
52
- /**
53
- * A server `handle` hook. SvelteKit 2 exports `Handle` from `@sveltejs/kit` and SvelteKit 3 from
54
- * `@sveltejs/kit/hooks`; this shape is assignable to both.
55
- */
56
- export type RiduHandle = (input: {
57
- event: RequestEvent;
58
- resolve: (event: RequestEvent) => Response | Promise<Response>;
59
- }) => Response | Promise<Response>;
60
-
61
- /**
62
- * A SvelteKit `handle` that sets `event.locals.ridu` for each request. Compose it with other hooks
63
- * through `sequence`.
64
- */
65
- export function createRiduHandle<
66
- Config extends RiduConfigShape,
67
- Slug extends AuthCollectionSlug<Config>,
68
- >(ridu: Binding<Config, Slug>, options: ServerClientOptions = {}): RiduHandle {
69
- return ({ event, resolve }) => {
70
- // Applications type `App.Locals.ridu` with their own client; assign without widening it.
71
- Object.assign(event.locals, { ridu: createServerClient(ridu, event, options) });
72
- return resolve(event);
73
- };
74
- }