@qlever-llc/trellis-svelte 0.10.18-rc.1 → 0.10.19

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.
@@ -1,5 +1,6 @@
1
1
  <script lang="ts" generics="TContract extends TrellisContractLike">
2
2
  import {
3
+ classifyBrowserAuthError,
3
4
  ClientAuthHandledError,
4
5
  TrellisClient,
5
6
  type ClientAuthOptions,
@@ -17,12 +18,17 @@
17
18
  client,
18
19
  children,
19
20
  loading,
21
+ recoveringAuth,
20
22
  error: errorSnippet,
21
23
  onAuthRequired,
24
+ onRecoverableAuthError,
22
25
  }: TrellisProviderProps<TContract> = $props();
23
26
 
27
+ type ProviderState = "connecting" | "connected" | "auth_handled" | "failed";
28
+
24
29
  let trellis = $state<ConnectedTrellisClient<TContract> | null>(null);
25
30
  let connectError = $state<unknown>(null);
31
+ let providerState = $state<ProviderState>("connecting");
26
32
 
27
33
  type SerializableTrellisError = {
28
34
  message?: unknown;
@@ -98,6 +104,7 @@
98
104
  connectError = new TypeError(
99
105
  "Expected trellisApp to resolve a Trellis URL",
100
106
  );
107
+ providerState = "failed";
101
108
  return;
102
109
  }
103
110
 
@@ -118,14 +125,43 @@
118
125
 
119
126
  if (active) {
120
127
  trellis = connected;
128
+ providerState = "connected";
121
129
  } else {
122
130
  await connected.connection.close();
123
131
  }
124
132
  } catch (error) {
125
133
  if (!active) return;
126
- if (error instanceof ClientAuthHandledError) return;
134
+ if (error instanceof ClientAuthHandledError) {
135
+ providerState = "auth_handled";
136
+ return;
137
+ }
138
+ const authRecovery = classifyBrowserAuthError(error);
139
+ if (authRecovery.recoverable) {
140
+ if (onRecoverableAuthError) {
141
+ providerState = "auth_handled";
142
+ try {
143
+ await onRecoverableAuthError(error);
144
+ return;
145
+ } catch (recoveryError) {
146
+ if (!active) return;
147
+ console.error("TrellisProvider auth recovery callback failed", {
148
+ recoveryError,
149
+ error,
150
+ });
151
+ logConnectionError(recoveryError);
152
+ connectError = recoveryError;
153
+ providerState = "failed";
154
+ return;
155
+ }
156
+ }
157
+ if (recoveringAuth) {
158
+ providerState = "auth_handled";
159
+ return;
160
+ }
161
+ }
127
162
  logConnectionError(error);
128
163
  connectError = error;
164
+ providerState = "failed";
129
165
  }
130
166
  })();
131
167
 
@@ -133,6 +169,7 @@
133
169
  active = false;
134
170
  const connected = trellis;
135
171
  trellis = null;
172
+ providerState = "connecting";
136
173
  if (connected) {
137
174
  void connected.connection.close();
138
175
  }
@@ -142,10 +179,12 @@
142
179
 
143
180
  {#if trellis}
144
181
  <TrellisContextProvider {trellisApp} {trellis} {children} />
145
- {:else if connectError}
182
+ {:else if providerState === "failed" && connectError}
146
183
  {#if errorSnippet}
147
184
  {@render errorSnippet(connectError)}
148
185
  {/if}
186
+ {:else if providerState === "auth_handled" && recoveringAuth}
187
+ {@render recoveringAuth()}
149
188
  {:else if loading}
150
189
  {@render loading()}
151
190
  {/if}
@@ -8,6 +8,8 @@ export type TrellisProviderProps<TContract extends TrellisContractLike = Trellis
8
8
  client?: ClientOpts;
9
9
  children: Snippet;
10
10
  loading?: Snippet;
11
+ recoveringAuth?: Snippet;
11
12
  error?: Snippet<[unknown]>;
12
13
  onAuthRequired?: (loginUrl: string, context: ClientAuthRequiredContext) => void | Promise<void>;
14
+ onRecoverableAuthError?: (error: unknown) => void | Promise<void>;
13
15
  };
@@ -2,12 +2,19 @@ import { type AuthConfig, type BrowserPortalFlowState as PortalFlowState } from
2
2
  export type CreatePortalFlowConfig = AuthConfig & {
3
3
  getUrl?: () => URL;
4
4
  };
5
+ export type PortalFlowErrorClassification = {
6
+ kind: string;
7
+ recoverable: boolean;
8
+ reason?: string;
9
+ code?: string;
10
+ };
5
11
  export declare class PortalFlowController {
6
12
  #private;
7
13
  flowId: string | null;
8
14
  state: PortalFlowState | null;
9
15
  loading: boolean;
10
16
  error: string | null;
17
+ errorClassification: PortalFlowErrorClassification | null;
11
18
  constructor(config: CreatePortalFlowConfig);
12
19
  load(): Promise<PortalFlowState | null>;
13
20
  providerUrl(providerId: string): string;
@@ -8,6 +8,116 @@ import {
8
8
  submitPortalApproval
9
9
  } from "@qlever-llc/trellis/auth/browser";
10
10
 
11
+ function isRecord(value) {
12
+ return value !== null && typeof value === "object";
13
+ }
14
+
15
+ function normalize(value) {
16
+ return value.trim().toLowerCase().replaceAll("-", "_").replaceAll(" ", "_");
17
+ }
18
+
19
+ const EXPIRED_FLOW_VALUES = new Set([
20
+ "flow_expired",
21
+ "flow_not_found",
22
+ "missing_flow",
23
+ "missing_flow_id",
24
+ "expired",
25
+ "trellis.auth.bind_expired",
26
+ "trellis.auth.flow_expired"
27
+ ]);
28
+
29
+ const AUTH_REQUIRED_VALUES = new Set([
30
+ "auth_required",
31
+ "session_not_found",
32
+ "session_expired",
33
+ "trellis.bootstrap.auth_required",
34
+ "trellis.auth.session_not_found",
35
+ "trellis.auth.session_expired"
36
+ ]);
37
+
38
+ function stringSignal(record, key) {
39
+ const value = record[key];
40
+
41
+ return typeof value === "string" ? value : undefined;
42
+ }
43
+
44
+ function collectErrorSignals(error) {
45
+ const values = [error];
46
+
47
+ if (isRecord(error) && isRecord(error.context)) values.push(error.context);
48
+
49
+ return values.flatMap((value) => {
50
+ if (typeof value === "string") return [{ message: value }];
51
+ if (!isRecord(value)) return [];
52
+
53
+ const code = stringSignal(value, "code") ?? stringSignal(value, "error");
54
+ const reason = stringSignal(value, "reason") ?? stringSignal(value, "status");
55
+
56
+ const message = value instanceof Error
57
+ ? value.message
58
+ : stringSignal(value, "causeMessage") ?? stringSignal(value, "message");
59
+
60
+ return code || reason || message
61
+ ? [
62
+ {
63
+ ...code ? { code } : {},
64
+ ...reason ? { reason } : {},
65
+ ...message ? { message } : {}
66
+ }
67
+ ]
68
+ : [];
69
+ });
70
+ }
71
+
72
+ function matchingSignal(signals, values, messages) {
73
+ return signals.find((signal) => {
74
+ const identifiers = [signal.code, signal.reason];
75
+
76
+ return identifiers.some((value) => value && values.has(normalize(value))) || messages.some((pattern) => pattern.test(signal.message?.toLowerCase() ?? ""));
77
+ });
78
+ }
79
+
80
+ function classification(kind, recoverable, signal) {
81
+ const reason = signal?.reason ?? signal?.code ?? signal?.message;
82
+
83
+ return {
84
+ kind,
85
+ recoverable,
86
+ ...reason ? { reason } : {},
87
+ ...signal?.code ? { code: signal.code } : {}
88
+ };
89
+ }
90
+
91
+ function classifyPortalFlowError(error) {
92
+ const signals = collectErrorSignals(error);
93
+
94
+ const expiredFlow = matchingSignal(signals, EXPIRED_FLOW_VALUES, [
95
+ /flow .*expired/,
96
+ /flow .*not found/,
97
+ /missing flow/,
98
+ /sign\-in .*expired/
99
+ ]);
100
+
101
+ if (expiredFlow) {
102
+ return classification("recoverable_expired_flow", true, expiredFlow);
103
+ }
104
+
105
+ const authRequired = matchingSignal(signals, AUTH_REQUIRED_VALUES, [
106
+ /auth required/,
107
+ /session .*expired/,
108
+ /session .*not found/,
109
+ /requires sign\-in/,
110
+ /requires signin/,
111
+ /requires authentication/
112
+ ]);
113
+
114
+ if (authRequired) {
115
+ return classification("recoverable_auth_required", true, authRequired);
116
+ }
117
+
118
+ return classification("unknown", false);
119
+ }
120
+
11
121
  function errorMessage(error) {
12
122
  return error instanceof Error ? error.message : String(error);
13
123
  }
@@ -57,6 +167,16 @@ export class PortalFlowController {
57
167
  $.set(this.#error, value, true);
58
168
  }
59
169
 
170
+ #errorClassification = $.state(null);
171
+
172
+ get errorClassification() {
173
+ return $.get(this.#errorClassification);
174
+ }
175
+
176
+ set errorClassification(value) {
177
+ $.set(this.#errorClassification, value, true);
178
+ }
179
+
60
180
  #config;
61
181
  #getUrl;
62
182
 
@@ -68,6 +188,7 @@ export class PortalFlowController {
68
188
  async load() {
69
189
  this.loading = true;
70
190
  this.error = null;
191
+ this.errorClassification = null;
71
192
  this.state = null;
72
193
 
73
194
  try {
@@ -88,6 +209,7 @@ export class PortalFlowController {
88
209
  return state;
89
210
  } catch(error) {
90
211
  this.error = errorMessage(error);
212
+ this.errorClassification = classifyPortalFlowError(error);
91
213
  this.state = null;
92
214
 
93
215
  return null;
@@ -115,12 +237,14 @@ export class PortalFlowController {
115
237
  async #submit(decision) {
116
238
  if (!this.flowId) {
117
239
  this.error = "Missing flow id.";
240
+ this.errorClassification = null;
118
241
 
119
242
  return null;
120
243
  }
121
244
 
122
245
  this.loading = true;
123
246
  this.error = null;
247
+ this.errorClassification = null;
124
248
 
125
249
  try {
126
250
  const state = await submitPortalApproval(this.#config, this.flowId, decision);
@@ -130,6 +254,7 @@ export class PortalFlowController {
130
254
  return state;
131
255
  } catch(error) {
132
256
  this.error = errorMessage(error);
257
+ this.errorClassification = classifyPortalFlowError(error);
133
258
 
134
259
  return null;
135
260
  } finally {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qlever-llc/trellis-svelte",
3
- "version": "0.10.18-rc.1",
3
+ "version": "0.10.19",
4
4
  "type": "module",
5
5
  "description": "Svelte components and state helpers for Trellis browser applications.",
6
6
  "license": "Apache-2.0",
@@ -30,8 +30,8 @@
30
30
  },
31
31
  "dependencies": {
32
32
  "@nats-io/nats-core": "^3.3.1",
33
- "@qlever-llc/result": "0.10.18-rc.1",
34
- "@qlever-llc/trellis": "0.10.18-rc.1",
33
+ "@qlever-llc/result": "^0.10.19",
34
+ "@qlever-llc/trellis": "^0.10.19",
35
35
  "typebox": "^1.0.15",
36
36
  "ulid": "^3.0.2"
37
37
  },
@@ -1,5 +1,6 @@
1
1
  <script lang="ts" generics="TContract extends TrellisContractLike">
2
2
  import {
3
+ classifyBrowserAuthError,
3
4
  ClientAuthHandledError,
4
5
  TrellisClient,
5
6
  type ClientAuthOptions,
@@ -17,12 +18,17 @@
17
18
  client,
18
19
  children,
19
20
  loading,
21
+ recoveringAuth,
20
22
  error: errorSnippet,
21
23
  onAuthRequired,
24
+ onRecoverableAuthError,
22
25
  }: TrellisProviderProps<TContract> = $props();
23
26
 
27
+ type ProviderState = "connecting" | "connected" | "auth_handled" | "failed";
28
+
24
29
  let trellis = $state<ConnectedTrellisClient<TContract> | null>(null);
25
30
  let connectError = $state<unknown>(null);
31
+ let providerState = $state<ProviderState>("connecting");
26
32
 
27
33
  type SerializableTrellisError = {
28
34
  message?: unknown;
@@ -98,6 +104,7 @@
98
104
  connectError = new TypeError(
99
105
  "Expected trellisApp to resolve a Trellis URL",
100
106
  );
107
+ providerState = "failed";
101
108
  return;
102
109
  }
103
110
 
@@ -118,14 +125,43 @@
118
125
 
119
126
  if (active) {
120
127
  trellis = connected;
128
+ providerState = "connected";
121
129
  } else {
122
130
  await connected.connection.close();
123
131
  }
124
132
  } catch (error) {
125
133
  if (!active) return;
126
- if (error instanceof ClientAuthHandledError) return;
134
+ if (error instanceof ClientAuthHandledError) {
135
+ providerState = "auth_handled";
136
+ return;
137
+ }
138
+ const authRecovery = classifyBrowserAuthError(error);
139
+ if (authRecovery.recoverable) {
140
+ if (onRecoverableAuthError) {
141
+ providerState = "auth_handled";
142
+ try {
143
+ await onRecoverableAuthError(error);
144
+ return;
145
+ } catch (recoveryError) {
146
+ if (!active) return;
147
+ console.error("TrellisProvider auth recovery callback failed", {
148
+ recoveryError,
149
+ error,
150
+ });
151
+ logConnectionError(recoveryError);
152
+ connectError = recoveryError;
153
+ providerState = "failed";
154
+ return;
155
+ }
156
+ }
157
+ if (recoveringAuth) {
158
+ providerState = "auth_handled";
159
+ return;
160
+ }
161
+ }
127
162
  logConnectionError(error);
128
163
  connectError = error;
164
+ providerState = "failed";
129
165
  }
130
166
  })();
131
167
 
@@ -133,6 +169,7 @@
133
169
  active = false;
134
170
  const connected = trellis;
135
171
  trellis = null;
172
+ providerState = "connecting";
136
173
  if (connected) {
137
174
  void connected.connection.close();
138
175
  }
@@ -142,10 +179,12 @@
142
179
 
143
180
  {#if trellis}
144
181
  <TrellisContextProvider {trellisApp} {trellis} {children} />
145
- {:else if connectError}
182
+ {:else if providerState === "failed" && connectError}
146
183
  {#if errorSnippet}
147
184
  {@render errorSnippet(connectError)}
148
185
  {/if}
186
+ {:else if providerState === "auth_handled" && recoveringAuth}
187
+ {@render recoveringAuth()}
149
188
  {:else if loading}
150
189
  {@render loading()}
151
190
  {/if}
@@ -18,9 +18,11 @@ export type TrellisProviderProps<
18
18
  client?: ClientOpts;
19
19
  children: Snippet;
20
20
  loading?: Snippet;
21
+ recoveringAuth?: Snippet;
21
22
  error?: Snippet<[unknown]>;
22
23
  onAuthRequired?: (
23
24
  loginUrl: string,
24
25
  context: ClientAuthRequiredContext,
25
26
  ) => void | Promise<void>;
27
+ onRecoverableAuthError?: (error: unknown) => void | Promise<void>;
26
28
  };
@@ -11,6 +11,136 @@ export type CreatePortalFlowConfig = AuthConfig & {
11
11
  getUrl?: () => URL;
12
12
  };
13
13
 
14
+ export type PortalFlowErrorClassification = {
15
+ kind: string;
16
+ recoverable: boolean;
17
+ reason?: string;
18
+ code?: string;
19
+ };
20
+
21
+ type ErrorSignal = {
22
+ code?: string;
23
+ reason?: string;
24
+ message?: string;
25
+ };
26
+
27
+ function isRecord(value: unknown): value is Record<string, unknown> {
28
+ return value !== null && typeof value === "object";
29
+ }
30
+
31
+ function normalize(value: string): string {
32
+ return value.trim().toLowerCase().replaceAll("-", "_").replaceAll(" ", "_");
33
+ }
34
+
35
+ const EXPIRED_FLOW_VALUES = new Set([
36
+ "flow_expired",
37
+ "flow_not_found",
38
+ "missing_flow",
39
+ "missing_flow_id",
40
+ "expired",
41
+ "trellis.auth.bind_expired",
42
+ "trellis.auth.flow_expired",
43
+ ]);
44
+
45
+ const AUTH_REQUIRED_VALUES = new Set([
46
+ "auth_required",
47
+ "session_not_found",
48
+ "session_expired",
49
+ "trellis.bootstrap.auth_required",
50
+ "trellis.auth.session_not_found",
51
+ "trellis.auth.session_expired",
52
+ ]);
53
+
54
+ function stringSignal(
55
+ record: Record<string, unknown>,
56
+ key: string,
57
+ ): string | undefined {
58
+ const value = record[key];
59
+ return typeof value === "string" ? value : undefined;
60
+ }
61
+
62
+ function collectErrorSignals(error: unknown): ErrorSignal[] {
63
+ const values: unknown[] = [error];
64
+ if (isRecord(error) && isRecord(error.context)) values.push(error.context);
65
+
66
+ return values.flatMap((value) => {
67
+ if (typeof value === "string") return [{ message: value }];
68
+ if (!isRecord(value)) return [];
69
+
70
+ const code = stringSignal(value, "code") ?? stringSignal(value, "error");
71
+ const reason = stringSignal(value, "reason") ??
72
+ stringSignal(value, "status");
73
+ const message = value instanceof Error
74
+ ? value.message
75
+ : stringSignal(value, "causeMessage") ?? stringSignal(value, "message");
76
+
77
+ return code || reason || message
78
+ ? [{
79
+ ...(code ? { code } : {}),
80
+ ...(reason ? { reason } : {}),
81
+ ...(message ? { message } : {}),
82
+ }]
83
+ : [];
84
+ });
85
+ }
86
+
87
+ function matchingSignal(
88
+ signals: ErrorSignal[],
89
+ values: ReadonlySet<string>,
90
+ messages: readonly RegExp[],
91
+ ): ErrorSignal | undefined {
92
+ return signals.find((signal) => {
93
+ const identifiers = [signal.code, signal.reason];
94
+ return identifiers.some((value) => value && values.has(normalize(value))) ||
95
+ messages.some((pattern) =>
96
+ pattern.test(signal.message?.toLowerCase() ?? "")
97
+ );
98
+ });
99
+ }
100
+
101
+ function classification(
102
+ kind: string,
103
+ recoverable: boolean,
104
+ signal?: ErrorSignal,
105
+ ): PortalFlowErrorClassification {
106
+ const reason = signal?.reason ?? signal?.code ?? signal?.message;
107
+ return {
108
+ kind,
109
+ recoverable,
110
+ ...(reason ? { reason } : {}),
111
+ ...(signal?.code ? { code: signal.code } : {}),
112
+ };
113
+ }
114
+
115
+ function classifyPortalFlowError(
116
+ error: unknown,
117
+ ): PortalFlowErrorClassification {
118
+ const signals = collectErrorSignals(error);
119
+ const expiredFlow = matchingSignal(signals, EXPIRED_FLOW_VALUES, [
120
+ /flow .*expired/,
121
+ /flow .*not found/,
122
+ /missing flow/,
123
+ /sign\-in .*expired/,
124
+ ]);
125
+ if (expiredFlow) {
126
+ return classification("recoverable_expired_flow", true, expiredFlow);
127
+ }
128
+
129
+ const authRequired = matchingSignal(signals, AUTH_REQUIRED_VALUES, [
130
+ /auth required/,
131
+ /session .*expired/,
132
+ /session .*not found/,
133
+ /requires sign\-in/,
134
+ /requires signin/,
135
+ /requires authentication/,
136
+ ]);
137
+ if (authRequired) {
138
+ return classification("recoverable_auth_required", true, authRequired);
139
+ }
140
+
141
+ return classification("unknown", false);
142
+ }
143
+
14
144
  function errorMessage(error: unknown): string {
15
145
  return error instanceof Error ? error.message : String(error);
16
146
  }
@@ -24,6 +154,7 @@ export class PortalFlowController {
24
154
  state: PortalFlowState | null = $state(null);
25
155
  loading = $state(false);
26
156
  error: string | null = $state(null);
157
+ errorClassification: PortalFlowErrorClassification | null = $state(null);
27
158
 
28
159
  #config: AuthConfig;
29
160
  #getUrl: () => URL;
@@ -36,6 +167,7 @@ export class PortalFlowController {
36
167
  async load(): Promise<PortalFlowState | null> {
37
168
  this.loading = true;
38
169
  this.error = null;
170
+ this.errorClassification = null;
39
171
  this.state = null;
40
172
 
41
173
  try {
@@ -51,6 +183,7 @@ export class PortalFlowController {
51
183
  return state;
52
184
  } catch (error) {
53
185
  this.error = errorMessage(error);
186
+ this.errorClassification = classifyPortalFlowError(error);
54
187
  this.state = null;
55
188
  return null;
56
189
  } finally {
@@ -79,11 +212,13 @@ export class PortalFlowController {
79
212
  ): Promise<PortalFlowState | null> {
80
213
  if (!this.flowId) {
81
214
  this.error = "Missing flow id.";
215
+ this.errorClassification = null;
82
216
  return null;
83
217
  }
84
218
 
85
219
  this.loading = true;
86
220
  this.error = null;
221
+ this.errorClassification = null;
87
222
 
88
223
  try {
89
224
  const state = await submitPortalApproval(
@@ -95,6 +230,7 @@ export class PortalFlowController {
95
230
  return state;
96
231
  } catch (error) {
97
232
  this.error = errorMessage(error);
233
+ this.errorClassification = classifyPortalFlowError(error);
98
234
  return null;
99
235
  } finally {
100
236
  this.loading = false;