@qlever-llc/trellis-svelte 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/README.md ADDED
@@ -0,0 +1,7 @@
1
+ # @qlever-llc/trellis-svelte
2
+
3
+ Svelte integration for Trellis browser applications.
4
+
5
+ Provides `TrellisProvider` for app-level wiring, along with reactive helpers for auth state, NATS connection state, and Trellis client state.
6
+
7
+ Uses the contract/runtime model from `@qlever-llc/trellis-contracts` and `@qlever-llc/trellis-trellis`.
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@qlever-llc/trellis-svelte",
3
+ "version": "0.4.0",
4
+ "type": "module",
5
+ "description": "Svelte components and state helpers for Trellis browser applications.",
6
+ "license": "Apache-2.0",
7
+ "publishConfig": {
8
+ "access": "restricted"
9
+ },
10
+ "files": [
11
+ "src",
12
+ "README.md"
13
+ ],
14
+ "exports": {
15
+ ".": {
16
+ "types": "./src/index.ts",
17
+ "svelte": "./src/index.ts",
18
+ "default": "./src/index.ts"
19
+ }
20
+ },
21
+ "dependencies": {
22
+ "@nats-io/nats-core": "^3.3.1",
23
+ "@qlever-llc/trellis-auth": "^0.4.0",
24
+ "@qlever-llc/trellis-contracts": "^0.4.0",
25
+ "@qlever-llc/trellis-result": "^0.4.0",
26
+ "@qlever-llc/trellis-sdk-auth": "^0.4.0",
27
+ "@qlever-llc/trellis-trellis": "^0.4.0",
28
+ "typebox": "^1.0.15"
29
+ },
30
+ "peerDependencies": {
31
+ "svelte": "^5.0.0"
32
+ },
33
+ "svelte": "./src/index.ts"
34
+ }
@@ -0,0 +1,166 @@
1
+ <script lang="ts">
2
+ import type { NatsConnection } from "@nats-io/nats-core";
3
+ import { AsyncResult } from "@qlever-llc/trellis-result";
4
+ import type { Snippet } from "svelte";
5
+ import { onDestroy } from "svelte";
6
+ import {
7
+ setAuthContext,
8
+ setNatsStateContext,
9
+ setTrellisContext,
10
+ } from "../context.svelte.ts";
11
+ import { type AuthState, createAuthState } from "../state/auth.svelte.ts";
12
+ import { createNatsState, type NatsState } from "../state/nats.svelte.ts";
13
+ import {
14
+ createTrellisState,
15
+ type TrellisClientContract,
16
+ type TrellisState,
17
+ } from "../state/trellis.svelte.ts";
18
+
19
+ type Props = {
20
+ children: Snippet;
21
+ loading?: Snippet;
22
+ authUrl: string;
23
+ natsServers: string[];
24
+ serviceName?: string;
25
+ contract?: TrellisClientContract;
26
+ loginPath?: string;
27
+ onAuthExpired?: () => void;
28
+ onAuthFailed?: (error: unknown) => void;
29
+ onAuthRequired?: (redirectTo: string) => void;
30
+ onNatsConnecting?: () => void;
31
+ onNatsConnected?: () => void;
32
+ onNatsDisconnect?: () => void;
33
+ onNatsReconnecting?: () => void;
34
+ onNatsReconnect?: () => void;
35
+ onNatsError?: (error: Error) => void;
36
+ };
37
+
38
+ const {
39
+ children,
40
+ loading,
41
+ authUrl,
42
+ natsServers,
43
+ serviceName = "app",
44
+ contract,
45
+ loginPath = "/login",
46
+ onAuthExpired,
47
+ onAuthFailed,
48
+ onAuthRequired,
49
+ onNatsConnecting,
50
+ onNatsConnected,
51
+ onNatsDisconnect,
52
+ onNatsReconnecting,
53
+ onNatsReconnect,
54
+ onNatsError,
55
+ }: Props = $props();
56
+
57
+ type InitContext = {
58
+ auth: AuthState;
59
+ nats: NatsState;
60
+ trellis: TrellisState;
61
+ };
62
+
63
+ class AuthRequiredError extends Error {
64
+ constructor() {
65
+ super("Authentication required");
66
+ }
67
+ }
68
+
69
+ function createProviderAuthState(): AuthState {
70
+ return createAuthState({
71
+ authUrl,
72
+ loginPath,
73
+ contract,
74
+ });
75
+ }
76
+
77
+ const authState = createProviderAuthState();
78
+
79
+ function getRedirectTo(): string {
80
+ if (typeof window === "undefined") {
81
+ return "/";
82
+ }
83
+
84
+ return window.location.pathname + window.location.search;
85
+ }
86
+
87
+ async function initialize(): Promise<InitContext> {
88
+ const result = await AsyncResult.try(async () => {
89
+ await authState.init();
90
+ await authState.handleCallback();
91
+ authState.cleanupCallbackUrl();
92
+
93
+ if (!authState.isAuthenticated) {
94
+ onAuthRequired?.(getRedirectTo());
95
+ throw new AuthRequiredError();
96
+ }
97
+
98
+ const natsState = await createNatsState(authState, {
99
+ servers: natsServers,
100
+ onConnecting: onNatsConnecting,
101
+ onConnected: onNatsConnected,
102
+ onDisconnect: onNatsDisconnect,
103
+ onReconnecting: onNatsReconnecting,
104
+ onReconnect: onNatsReconnect,
105
+ onError: onNatsError,
106
+ onAuthRequired: () => {
107
+ onAuthRequired?.(getRedirectTo());
108
+ },
109
+ });
110
+
111
+ const trellisState = await createTrellisState(authState, natsState, {
112
+ serviceName,
113
+ contract,
114
+ });
115
+
116
+ return {
117
+ auth: authState,
118
+ nats: natsState,
119
+ trellis: trellisState,
120
+ };
121
+ });
122
+
123
+ if (result.isErr()) {
124
+ authState.clearAuth();
125
+ const error = result.error;
126
+ if (!(error instanceof AuthRequiredError)) {
127
+ onAuthFailed?.(error);
128
+ }
129
+ throw error;
130
+ }
131
+
132
+ return result.match({
133
+ ok: (value) => value,
134
+ err: (error) => {
135
+ throw error;
136
+ },
137
+ });
138
+ }
139
+
140
+ const initPromise = initialize();
141
+ const natsStatePromise = initPromise.then((ctx) => ctx.nats) as Promise<NatsState>;
142
+ const trellisPromise = initPromise.then((ctx) => ctx.trellis.trellis) as Promise<unknown>;
143
+ const natsPromise = initPromise.then((ctx) => ctx.nats.nc) as Promise<NatsConnection>;
144
+
145
+ setAuthContext(authState);
146
+ setNatsStateContext(natsStatePromise);
147
+ setTrellisContext({
148
+ trellis: trellisPromise as never,
149
+ nats: natsPromise,
150
+ });
151
+
152
+ onDestroy(() => {
153
+ void initPromise.then((ctx) => {
154
+ ctx.trellis.stop();
155
+ void ctx.nats.disconnect();
156
+ });
157
+ });
158
+ </script>
159
+
160
+ {#await initPromise}
161
+ {#if loading}
162
+ {@render loading()}
163
+ {/if}
164
+ {:then}
165
+ {@render children()}
166
+ {/await}
@@ -0,0 +1,47 @@
1
+ import { getContext, setContext } from "svelte";
2
+ import type { TrellisAPI } from "@qlever-llc/trellis-contracts";
3
+ import type { Trellis } from "@qlever-llc/trellis-trellis";
4
+ import type { NatsConnection } from "@nats-io/nats-core";
5
+ import type { AuthState } from "./state/auth.svelte.ts";
6
+ import type { NatsState } from "./state/nats.svelte.ts";
7
+
8
+ const TRELLIS_KEY = Symbol("trellis");
9
+ const NATS_KEY = Symbol("nats");
10
+ const NATS_STATE_KEY = Symbol("nats-state");
11
+ const AUTH_KEY = Symbol("auth");
12
+
13
+ type TrellisContext = {
14
+ trellis: Promise<unknown>;
15
+ nats: Promise<NatsConnection>;
16
+ };
17
+
18
+ export function setTrellisContext<TA extends TrellisAPI>(
19
+ ctx: { trellis: Promise<Trellis<TA>>; nats: Promise<NatsConnection> },
20
+ ): void {
21
+ setContext(TRELLIS_KEY, ctx.trellis as unknown as Promise<unknown>);
22
+ setContext(NATS_KEY, ctx.nats);
23
+ }
24
+
25
+ export function setNatsStateContext(natsState: Promise<NatsState>): void {
26
+ setContext(NATS_STATE_KEY, natsState);
27
+ }
28
+
29
+ export function setAuthContext(auth: AuthState): void {
30
+ setContext(AUTH_KEY, auth);
31
+ }
32
+
33
+ export function getTrellis<TA extends TrellisAPI = TrellisAPI>(): Promise<Trellis<TA>> {
34
+ return getContext<Promise<unknown>>(TRELLIS_KEY) as Promise<Trellis<TA>>;
35
+ }
36
+
37
+ export function getNats(): Promise<NatsConnection> {
38
+ return getContext<Promise<NatsConnection>>(NATS_KEY);
39
+ }
40
+
41
+ export function getNatsState(): Promise<NatsState> {
42
+ return getContext<Promise<NatsState>>(NATS_STATE_KEY);
43
+ }
44
+
45
+ export function getAuth(): AuthState {
46
+ return getContext<AuthState>(AUTH_KEY);
47
+ }
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ export { default as TrellisProvider } from "./components/TrellisProvider.svelte";
2
+ export { setTrellisContext, getTrellis, getNats, getNatsState, getAuth } from "./context.svelte.ts";
3
+ export { AuthState, createAuthState } from "./state/auth.svelte.ts";
4
+ export { NatsState, createNatsState } from "./state/nats.svelte.ts";
5
+ export type { Status as NatsStatus, NatsStateConfig } from "./state/nats.svelte.ts";
6
+ export { TrellisState, createTrellisState } from "./state/trellis.svelte.ts";
@@ -0,0 +1,297 @@
1
+ import {
2
+ type BindResponse,
3
+ type BindSuccessResponse,
4
+ bindSession,
5
+ buildLoginUrl,
6
+ clearSessionKey,
7
+ extractAuthTokenFromFragment,
8
+ getOrCreateSessionKey,
9
+ getPublicSessionKey,
10
+ type SentinelCreds,
11
+ type SessionKeyHandle,
12
+ } from "@qlever-llc/trellis-auth";
13
+ import { Result } from "@qlever-llc/trellis-result";
14
+ import { SvelteDate } from "svelte/reactivity";
15
+ import { Type } from "typebox";
16
+ import { Value } from "typebox/value";
17
+
18
+ const STORAGE_KEY = "trellis_auth";
19
+
20
+ type AuthStateData = {
21
+ handle: SessionKeyHandle | null;
22
+ bindingToken: string | null;
23
+ inboxPrefix: string | null;
24
+ expiresMs: number | null;
25
+ sentinel: SentinelCreds | null;
26
+ };
27
+
28
+ type PersistedAuth = {
29
+ bindingToken: string;
30
+ inboxPrefix: string;
31
+ expires: string;
32
+ sentinel: SentinelCreds;
33
+ };
34
+
35
+ const PersistedAuthSchema = Type.Object({
36
+ bindingToken: Type.String(),
37
+ inboxPrefix: Type.String(),
38
+ expires: Type.String({ format: "date-time" }),
39
+ sentinel: Type.Object({
40
+ jwt: Type.String(),
41
+ seed: Type.String(),
42
+ }, { additionalProperties: false }),
43
+ }, { additionalProperties: false });
44
+
45
+ function loadPersistedAuth(): PersistedAuth | null {
46
+ if (typeof localStorage === "undefined") return null;
47
+ const result = Result.try(() => {
48
+ const stored = localStorage.getItem(STORAGE_KEY);
49
+ if (!stored) return null;
50
+ const parsed = Value.Parse(PersistedAuthSchema, JSON.parse(stored)) as PersistedAuth;
51
+ if (new Date(parsed.expires) < new Date()) {
52
+ localStorage.removeItem(STORAGE_KEY);
53
+ return null;
54
+ }
55
+ if (!parsed.sentinel) return null;
56
+ return parsed;
57
+ });
58
+ return result.unwrapOr(null);
59
+ }
60
+
61
+ function persistAuth(state: {
62
+ bindingToken: string;
63
+ expires: Date;
64
+ inboxPrefix: string;
65
+ sentinel: SentinelCreds;
66
+ }): void {
67
+ if (typeof localStorage === "undefined") return;
68
+ localStorage.setItem(
69
+ STORAGE_KEY,
70
+ JSON.stringify({
71
+ bindingToken: state.bindingToken,
72
+ inboxPrefix: state.inboxPrefix,
73
+ expires: state.expires.toISOString(),
74
+ sentinel: state.sentinel,
75
+ }),
76
+ );
77
+ }
78
+
79
+ function clearPersistedAuth(): void {
80
+ if (typeof localStorage === "undefined") return;
81
+ localStorage.removeItem(STORAGE_KEY);
82
+ }
83
+
84
+ export type AuthStateConfig = {
85
+ authUrl: string; // https://auth.example.com
86
+ loginPath?: string;
87
+ contract: { CONTRACT: Record<string, unknown> };
88
+ };
89
+
90
+ /**
91
+ * Svelte 5 runes-based reactive authentication state.
92
+ *
93
+ * Manages session-key based authentication including:
94
+ * - Session key generation and storage (IndexedDB/WebCrypto)
95
+ * - OAuth sign-in flow initiation (signed)
96
+ * - Callback handling (authToken in URL fragment) + bind
97
+ * - Binding token persistence (short lived)
98
+ */
99
+ export class AuthState {
100
+ #state: AuthStateData = $state({
101
+ handle: null,
102
+ bindingToken: null,
103
+ inboxPrefix: null,
104
+ expiresMs: null,
105
+ sentinel: null,
106
+ });
107
+
108
+ #config: AuthStateConfig;
109
+ #bindingInProgress: Promise<BindResponse> | null = null;
110
+
111
+ constructor(config: AuthStateConfig) {
112
+ this.#config = config;
113
+ }
114
+
115
+ get handle(): SessionKeyHandle | null {
116
+ return this.#state.handle;
117
+ }
118
+ get sessionKey(): string | null {
119
+ return this.#state.handle ? getPublicSessionKey(this.#state.handle) : null;
120
+ }
121
+ get bindingToken(): string | null {
122
+ return this.#state.bindingToken;
123
+ }
124
+ get inboxPrefix(): string | null {
125
+ return this.#state.inboxPrefix;
126
+ }
127
+ get expires(): Date | null {
128
+ return this.#state.expiresMs === null ? null : new SvelteDate(this.#state.expiresMs);
129
+ }
130
+ get sentinel(): SentinelCreds | null {
131
+ return this.#state.sentinel;
132
+ }
133
+ get isAuthenticated(): boolean {
134
+ if (!this.#state.bindingToken) return false;
135
+ if (this.#state.expiresMs === null) return false;
136
+ if (!this.#state.sentinel) return false;
137
+ return this.#state.expiresMs > Date.now();
138
+ }
139
+ /**
140
+ * Initialize the auth state by loading or creating a session key,
141
+ * and restoring any persisted binding token (if still valid).
142
+ */
143
+ async init(): Promise<SessionKeyHandle> {
144
+ if (this.#state.handle) return this.#state.handle;
145
+
146
+ const handle = await getOrCreateSessionKey();
147
+ this.#state.handle = handle;
148
+
149
+ const persisted = loadPersistedAuth();
150
+ if (persisted) {
151
+ this.#state.bindingToken = persisted.bindingToken;
152
+ this.#state.inboxPrefix = persisted.inboxPrefix;
153
+ this.#state.expiresMs = Date.parse(persisted.expires);
154
+ this.#state.sentinel = persisted.sentinel;
155
+ }
156
+
157
+ return handle;
158
+ }
159
+
160
+ /**
161
+ * Initiate OAuth sign-in flow by redirecting to the auth provider.
162
+ * This method does not return - it redirects the browser.
163
+ */
164
+ async signIn(provider: string, redirectTo: string): Promise<never> {
165
+ const handle = await this.init();
166
+ const url = await buildLoginUrl(
167
+ { authUrl: this.#config.authUrl },
168
+ provider,
169
+ redirectTo,
170
+ handle,
171
+ this.#config.contract.CONTRACT,
172
+ );
173
+ window.location.href = url;
174
+ throw new Error(`Redirecting to ${provider} for authentication`);
175
+ }
176
+
177
+ /**
178
+ * Handle OAuth callback by extracting the authToken from the URL fragment
179
+ * and binding it to the session key.
180
+ *
181
+ * Returns the bind response if a fragment exists, null otherwise.
182
+ * Includes a guard to prevent double binding when multiple components call this.
183
+ */
184
+ async handleCallback(url: string = window.location.href): Promise<BindResponse | null> {
185
+ // Guard to prevent race conditions when multiple components call handleCallback
186
+ if (this.#bindingInProgress) return this.#bindingInProgress;
187
+
188
+ const authToken = extractAuthTokenFromFragment(url);
189
+ if (!authToken) return null;
190
+
191
+ this.#bindingInProgress = this.bind(authToken);
192
+ try {
193
+ return await this.#bindingInProgress;
194
+ } finally {
195
+ this.#bindingInProgress = null;
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Clean up the callback URL by removing the authToken fragment.
201
+ */
202
+ cleanupCallbackUrl(url: string = window.location.href): void {
203
+ const parsed = new URL(url);
204
+ if (parsed.hash) {
205
+ parsed.hash = "";
206
+ window.history.replaceState({}, "", parsed.pathname + parsed.search);
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Bind an authToken (returned from OAuth callback) to the session key,
212
+ * producing a short-lived binding token and inboxPrefix for NATS.
213
+ */
214
+ async bind(authToken: string): Promise<BindResponse> {
215
+ return this.#bind(authToken);
216
+ }
217
+
218
+ async #bind(authToken: string): Promise<BindResponse> {
219
+ const handle = await this.init();
220
+ const response = await bindSession(
221
+ { authUrl: this.#config.authUrl },
222
+ handle,
223
+ authToken,
224
+ );
225
+
226
+ if (response.status === "bound") {
227
+ this.setBindingToken(response);
228
+ }
229
+
230
+ return response;
231
+ }
232
+
233
+ setBindingToken(
234
+ response: Pick<BindSuccessResponse, "bindingToken" | "inboxPrefix" | "expires"> & {
235
+ sentinel?: SentinelCreds;
236
+ },
237
+ ): void {
238
+ this.#state.bindingToken = response.bindingToken;
239
+ this.#state.inboxPrefix = response.inboxPrefix;
240
+ this.#state.expiresMs = Date.parse(String(response.expires));
241
+ // Keep existing sentinel if not provided (e.g., from RenewBindingToken)
242
+ if (response.sentinel) {
243
+ this.#state.sentinel = response.sentinel;
244
+ }
245
+
246
+ // Only persist if we have sentinel credentials
247
+ if (this.#state.sentinel) {
248
+ persistAuth({
249
+ bindingToken: response.bindingToken,
250
+ inboxPrefix: response.inboxPrefix,
251
+ expires: new SvelteDate(String(response.expires)),
252
+ sentinel: this.#state.sentinel,
253
+ });
254
+ }
255
+ }
256
+
257
+ /**
258
+ * Clear persisted auth state without clearing session key or redirecting.
259
+ * Use this when auth fails and you need to force re-authentication.
260
+ */
261
+ clearAuth(): void {
262
+ clearPersistedAuth();
263
+ this.#state.bindingToken = null;
264
+ this.#state.inboxPrefix = null;
265
+ this.#state.expiresMs = null;
266
+ this.#state.sentinel = null;
267
+ }
268
+
269
+ /**
270
+ * Sign out by clearing all credentials and redirecting to login.
271
+ * This method does not return - it redirects the browser.
272
+ */
273
+ async signOut(remoteLogout?: () => Promise<unknown> | unknown): Promise<never> {
274
+ if (remoteLogout) {
275
+ try {
276
+ await remoteLogout();
277
+ } catch {
278
+ // Best-effort remote logout; local credential cleanup still proceeds.
279
+ }
280
+ }
281
+
282
+ await clearSessionKey();
283
+ this.clearAuth();
284
+ this.#state.handle = null;
285
+
286
+ const loginPath = this.#config.loginPath ?? "/login";
287
+ window.location.href = loginPath;
288
+ throw new Error("Signed out, redirecting to login");
289
+ }
290
+ }
291
+
292
+ /**
293
+ * Factory function to create an AuthState instance.
294
+ */
295
+ export function createAuthState(config: AuthStateConfig): AuthState {
296
+ return new AuthState(config);
297
+ }
@@ -0,0 +1,350 @@
1
+ import {
2
+ jwtAuthenticator,
3
+ type NatsConnection,
4
+ wsconnect,
5
+ } from "@nats-io/nats-core";
6
+ import {
7
+ getPublicSessionKey,
8
+ natsConnectSigForBindingToken,
9
+ type SentinelCreds,
10
+ type SessionKeyHandle,
11
+ signBytes,
12
+ } from "@qlever-llc/trellis-auth";
13
+ import { AsyncResult, isErr, UnexpectedError } from "@qlever-llc/trellis-result";
14
+ import {
15
+ API as AUTH_API,
16
+ type AuthRenewBindingTokenInput,
17
+ type AuthRenewBindingTokenOutput,
18
+ } from "@qlever-llc/trellis-sdk-auth";
19
+ import { createClient } from "@qlever-llc/trellis-trellis";
20
+ import type { AuthState } from "./auth.svelte.ts";
21
+
22
+ const AUTH_RENEW_API = {
23
+ rpc: {
24
+ "Auth.RenewBindingToken": AUTH_API.owned.rpc["Auth.RenewBindingToken"],
25
+ },
26
+ events: {},
27
+ subjects: {},
28
+ } as const;
29
+
30
+ type AuthRenewClient = {
31
+ request(
32
+ method: "Auth.RenewBindingToken",
33
+ input: AuthRenewBindingTokenInput,
34
+ ): Promise<{ take(): AuthRenewBindingTokenOutput | unknown }>;
35
+ };
36
+
37
+ export type Status = "disconnected" | "connecting" | "connected" | "error";
38
+
39
+ export type NatsStateConfig = {
40
+ servers: string[];
41
+ onConnecting?: () => void;
42
+ onConnected?: () => void;
43
+ onDisconnect?: () => void;
44
+ onReconnecting?: () => void;
45
+ onReconnect?: () => void;
46
+ onError?: (error: Error) => void;
47
+ onAuthRequired?: () => void;
48
+ };
49
+
50
+ function requireBrowserAuth(authState: AuthState): {
51
+ handle: SessionKeyHandle;
52
+ bindingToken: string;
53
+ sentinel: SentinelCreds;
54
+ } {
55
+ const handle = authState.handle;
56
+ const bindingToken = authState.bindingToken;
57
+ const sentinel = authState.sentinel;
58
+
59
+ if (!handle || !bindingToken || !sentinel) {
60
+ throw new Error("Not authenticated: missing binding token or sentinel");
61
+ }
62
+
63
+ return { handle, bindingToken, sentinel };
64
+ }
65
+
66
+ function createAuthRenewClient(
67
+ nc: NatsConnection,
68
+ handle: SessionKeyHandle,
69
+ ) : AuthRenewClient {
70
+ return createClient(
71
+ { API: { trellis: AUTH_RENEW_API } },
72
+ nc,
73
+ {
74
+ sessionKey: getPublicSessionKey(handle),
75
+ sign: (data: Uint8Array) => signBytes(handle, data),
76
+ },
77
+ { name: "auth-renew" },
78
+ ) as unknown as AuthRenewClient;
79
+ }
80
+
81
+ async function buildNatsAuthToken(
82
+ handle: SessionKeyHandle,
83
+ bindingToken: string,
84
+ ): Promise<string> {
85
+ const sessionKey = getPublicSessionKey(handle);
86
+ const sig = await natsConnectSigForBindingToken(handle, bindingToken);
87
+ return JSON.stringify({ v: 1, sessionKey, bindingToken, sig });
88
+ }
89
+
90
+ /**
91
+ * Svelte 5 runes-based reactive NATS connection state.
92
+ *
93
+ * Manages WebSocket connection to NATS server including:
94
+ * - Initial connection with authentication
95
+ * - Connection status monitoring
96
+ * - Automatic reconnection (within binding token TTL)
97
+ */
98
+ export class NatsState {
99
+ nc: NatsConnection;
100
+ status: Status = $state("disconnected");
101
+
102
+ #servers: string[];
103
+ #authState: AuthState;
104
+ #config: NatsStateConfig;
105
+ #handle: SessionKeyHandle;
106
+ #tokenRef: { value: string };
107
+ #sentinel: SentinelCreds;
108
+ #trellis: AuthRenewClient;
109
+ #renewTimer: ReturnType<typeof setTimeout> | undefined;
110
+
111
+ private constructor(
112
+ nc: NatsConnection,
113
+ status: Status,
114
+ servers: string[],
115
+ authState: AuthState,
116
+ config: NatsStateConfig,
117
+ handle: SessionKeyHandle,
118
+ tokenRef: { value: string },
119
+ sentinel: SentinelCreds,
120
+ ) {
121
+ this.nc = nc;
122
+ this.status = status;
123
+ this.#servers = servers;
124
+ this.#authState = authState;
125
+ this.#config = config;
126
+ this.#handle = handle;
127
+ this.#tokenRef = tokenRef;
128
+ this.#sentinel = sentinel;
129
+ this.#trellis = createAuthRenewClient(nc, handle);
130
+ this.#monitorStatus();
131
+ this.#scheduleRenew();
132
+ }
133
+
134
+ /**
135
+ * Connect to NATS servers with authenticated credentials.
136
+ * Per ADR, browser clients use sentinel creds with jwtAuthenticator.
137
+ * The auth_token is passed via the token option for auth callout.
138
+ */
139
+ static async connect(
140
+ authState: AuthState,
141
+ config: NatsStateConfig,
142
+ ): Promise<NatsState> {
143
+ config.onConnecting?.();
144
+
145
+ await authState.init();
146
+ await authState.handleCallback();
147
+
148
+ if (!authState.isAuthenticated) {
149
+ config.onAuthRequired?.();
150
+ throw new Error("Not authenticated: missing binding token or sentinel");
151
+ }
152
+
153
+ const { handle, bindingToken, sentinel } = requireBrowserAuth(authState);
154
+ const inboxPrefix = authState.inboxPrefix ?? undefined;
155
+ const tokenRef = { value: await buildNatsAuthToken(handle, bindingToken) };
156
+
157
+ // Use jwtAuthenticator with sentinel credentials per ADR
158
+ const authenticator = jwtAuthenticator(
159
+ sentinel.jwt,
160
+ new TextEncoder().encode(sentinel.seed),
161
+ );
162
+
163
+ const nc = await wsconnect({
164
+ servers: config.servers,
165
+ authenticator,
166
+ token: tokenRef.value, // auth_token for auth callout
167
+ reconnect: true,
168
+ maxReconnectAttempts: 5,
169
+ reconnectTimeWait: 2000,
170
+ inboxPrefix,
171
+ });
172
+
173
+ config.onConnected?.();
174
+
175
+ const state = new NatsState(
176
+ nc,
177
+ "connected",
178
+ config.servers,
179
+ authState,
180
+ config,
181
+ handle,
182
+ tokenRef,
183
+ sentinel,
184
+ );
185
+
186
+ // Immediately renew binding token so localStorage has a valid (unconsumed) token.
187
+ // This prevents stale token issues on page reload.
188
+ await state.#renewBindingToken();
189
+
190
+ return state;
191
+ }
192
+
193
+ /**
194
+ * Reconnect to NATS with the existing binding token (if still valid).
195
+ * Uses stored sentinel credentials with jwtAuthenticator per ADR.
196
+ */
197
+ async reconnect(): Promise<void> {
198
+ if (this.status === "connecting") return;
199
+ this.status = "connecting";
200
+
201
+ await AsyncResult.try(() => this.nc.close());
202
+
203
+ const result = await AsyncResult.try(async () => {
204
+ await this.#authState.init();
205
+ if (!this.#authState.isAuthenticated) {
206
+ throw new UnexpectedError({
207
+ context: { message: "Not authenticated: binding token expired" },
208
+ });
209
+ }
210
+ const { handle, bindingToken } = requireBrowserAuth(this.#authState);
211
+ const inboxPrefix = this.#authState.inboxPrefix ?? undefined;
212
+ this.#tokenRef.value = await buildNatsAuthToken(handle, bindingToken);
213
+
214
+ const authenticator = jwtAuthenticator(
215
+ this.#sentinel.jwt,
216
+ new TextEncoder().encode(this.#sentinel.seed),
217
+ );
218
+
219
+ this.nc = await wsconnect({
220
+ servers: this.#servers,
221
+ authenticator,
222
+ token: this.#tokenRef.value,
223
+ reconnect: true,
224
+ maxReconnectAttempts: 5,
225
+ reconnectTimeWait: 2000,
226
+ inboxPrefix,
227
+ });
228
+
229
+ this.#trellis = createAuthRenewClient(this.nc, this.#handle);
230
+ });
231
+
232
+ if (result.isErr()) {
233
+ console.error("NATS reconnect failed:", result.error);
234
+ this.status = "error";
235
+ this.#config.onError?.(result.error);
236
+ return;
237
+ }
238
+
239
+ this.status = "connected";
240
+ this.#config.onReconnect?.();
241
+ this.#monitorStatus();
242
+
243
+ await this.#renewBindingToken();
244
+ }
245
+
246
+ #scheduleRenew(): void {
247
+ if (this.#renewTimer) clearTimeout(this.#renewTimer);
248
+
249
+ const expires = this.#authState.expires;
250
+ if (!expires) return;
251
+
252
+ const delayMs = Math.max(30_000, expires.getTime() - Date.now() - 60_000);
253
+ this.#renewTimer = setTimeout(() => {
254
+ void this.#renewBindingToken();
255
+ }, delayMs);
256
+ }
257
+
258
+ async #renewBindingToken(): Promise<void> {
259
+ const renew = async () => {
260
+ if (this.status !== "connected") return;
261
+ const res = await this.#trellis.request(
262
+ "Auth.RenewBindingToken",
263
+ {} satisfies AuthRenewBindingTokenInput,
264
+ );
265
+ const v = res.take();
266
+ if (isErr(v)) return;
267
+ const binding = v as AuthRenewBindingTokenOutput;
268
+ this.#authState.setBindingToken(binding);
269
+ this.#tokenRef.value = await buildNatsAuthToken(
270
+ this.#handle,
271
+ binding.bindingToken,
272
+ );
273
+ };
274
+
275
+ await AsyncResult.try(renew);
276
+ this.#scheduleRenew();
277
+ }
278
+
279
+ /**
280
+ * Disconnect from NATS.
281
+ */
282
+ async disconnect(): Promise<void> {
283
+ if (this.#renewTimer) clearTimeout(this.#renewTimer);
284
+ await AsyncResult.try(() => this.nc.close());
285
+ this.status = "disconnected";
286
+ }
287
+
288
+ async #monitorStatus(): Promise<void> {
289
+ for await (const s of this.nc.status()) {
290
+ switch (s.type) {
291
+ case "error": {
292
+ const data = "data" in s ? s.data : s.error;
293
+ const msg = data instanceof Error ? data.message : String(data);
294
+ const isAuthError =
295
+ msg.includes("authorization") || msg.includes("Authentication");
296
+ if (isAuthError) {
297
+ console.log(
298
+ "Auth error detected, attempting reconnect with fresh credentials",
299
+ );
300
+ void this.reconnect();
301
+ } else if (this.status !== "connected") {
302
+ this.status = "error";
303
+ }
304
+ break;
305
+ }
306
+
307
+ case "reconnect":
308
+ this.status = "connected";
309
+ this.#config.onReconnect?.();
310
+ break;
311
+
312
+ case "reconnecting":
313
+ case "forceReconnect":
314
+ case "staleConnection":
315
+ this.status = "connecting";
316
+ this.#config.onReconnecting?.();
317
+ break;
318
+
319
+ case "disconnect":
320
+ case "close":
321
+ this.status = "disconnected";
322
+ this.#config.onDisconnect?.();
323
+ void this.reconnect();
324
+ break;
325
+
326
+ case "ping":
327
+ case "update":
328
+ case "ldm":
329
+ case "slowConsumer":
330
+ // Informational events, no action needed
331
+ break;
332
+
333
+ default:
334
+ console.error("Unhandled NATS status event:", s);
335
+ this.status = "error";
336
+ break;
337
+ }
338
+ }
339
+ }
340
+ }
341
+
342
+ /**
343
+ * Factory function to create and connect a NatsState instance.
344
+ */
345
+ export async function createNatsState(
346
+ authState: AuthState,
347
+ config: NatsStateConfig,
348
+ ): Promise<NatsState> {
349
+ return NatsState.connect(authState, config);
350
+ }
@@ -0,0 +1,78 @@
1
+ import type { NatsConnection } from "@nats-io/nats-core";
2
+ import { getPublicSessionKey, signBytes } from "@qlever-llc/trellis-auth";
3
+ import { defineContract, type TrellisAPI } from "@qlever-llc/trellis-contracts";
4
+ import { type ClientOpts, createClient, type Trellis, type TrellisAuth } from "@qlever-llc/trellis-trellis";
5
+ import type { AuthState } from "./auth.svelte.ts";
6
+ import type { NatsState } from "./nats.svelte.ts";
7
+
8
+ export type TrellisClientContract<TApi extends TrellisAPI = TrellisAPI> = {
9
+ CONTRACT: Record<string, unknown>;
10
+ CONTRACT_DIGEST: string;
11
+ API: {
12
+ trellis: TApi;
13
+ };
14
+ };
15
+
16
+ export type TrellisStateConfig<TApi extends TrellisAPI = TrellisAPI> = {
17
+ serviceName: string;
18
+ contract?: TrellisClientContract<TApi>;
19
+ };
20
+
21
+ const DEFAULT_TRELLIS_CONTRACT = defineContract({
22
+ id: "trellis.svelte.browser@v1",
23
+ displayName: "Trellis Svelte Browser Client",
24
+ description: "Represent a browser client that only uses its locally declared Trellis APIs.",
25
+ kind: "browser",
26
+ });
27
+
28
+ /**
29
+ * Svelte 5 wrapper for Trellis client.
30
+ *
31
+ * Manages:
32
+ * - Trellis client instance
33
+ * - Session-key based request signing
34
+ */
35
+ export class TrellisState {
36
+ readonly trellis: Trellis<TrellisAPI>;
37
+
38
+ private constructor(trellis: Trellis<TrellisAPI>) {
39
+ this.trellis = trellis;
40
+ }
41
+
42
+ /**
43
+ * Create a TrellisState instance with proper authentication.
44
+ */
45
+ static async create<TApi extends TrellisAPI = TrellisAPI>(
46
+ authState: AuthState,
47
+ natsState: NatsState,
48
+ config: TrellisStateConfig<TApi>,
49
+ ): Promise<TrellisState> {
50
+ const handle = await authState.init();
51
+ const contract = (config.contract ?? DEFAULT_TRELLIS_CONTRACT) as TrellisClientContract<TApi>;
52
+ const trellis = createClient(
53
+ contract,
54
+ natsState.nc,
55
+ {
56
+ sessionKey: getPublicSessionKey(handle),
57
+ sign: (data: Uint8Array) => signBytes(handle, data),
58
+ },
59
+ { name: config.serviceName },
60
+ ) as unknown as Trellis<TrellisAPI>;
61
+ return new TrellisState(trellis);
62
+ }
63
+
64
+ stop(): void {
65
+ // no-op (kept for convenience)
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Factory function to create a TrellisState instance.
71
+ */
72
+ export async function createTrellisState<TApi extends TrellisAPI = TrellisAPI>(
73
+ authState: AuthState,
74
+ natsState: NatsState,
75
+ config: TrellisStateConfig<TApi>,
76
+ ): Promise<TrellisState> {
77
+ return TrellisState.create(authState, natsState, config);
78
+ }