@indietabletop/appkit 4.0.0 → 4.1.1

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.
@@ -17,20 +17,21 @@ import {
17
17
  LetterheadSubmitError,
18
18
  LetterheadTextField,
19
19
  } from "../LetterheadForm/index.tsx";
20
+ import { useAppActions } from "../store/index.tsx";
20
21
  import type { CurrentUser } from "../types.ts";
21
22
  import { useForm } from "../use-form.ts";
22
- import type { EventHandler, EventHandlerWithReload } from "./types.ts";
23
+ import type { AuthEventHandler } from "./types.ts";
23
24
 
24
25
  type SetStep = Dispatch<SetStateAction<VerifyStep>>;
25
26
 
26
27
  function InitialStep(props: {
27
28
  setStep: SetStep;
28
29
  currentUser: CurrentUser;
29
- onLogout: EventHandlerWithReload;
30
30
  reload: () => void;
31
31
  }) {
32
- const { setStep, onLogout, currentUser, reload } = props;
32
+ const { setStep, currentUser, reload } = props;
33
33
  const { email } = currentUser;
34
+ const { logout } = useAppActions();
34
35
 
35
36
  const client = useClient();
36
37
  const { form, submitName } = useForm({
@@ -62,7 +63,10 @@ function InitialStep(props: {
62
63
  Cannot complete verification?{" "}
63
64
  <Button
64
65
  className={interactiveText}
65
- onClick={() => void onLogout({ reload })}
66
+ onClick={async () => {
67
+ await logout();
68
+ reload();
69
+ }}
66
70
  >
67
71
  Log out
68
72
  </Button>
@@ -130,7 +134,7 @@ function SubmitCodeStep(props: {
130
134
  );
131
135
  }
132
136
 
133
- function SuccessStep(props: { onClose?: EventHandler }) {
137
+ function SuccessStep(props: { onClose?: AuthEventHandler }) {
134
138
  const { onClose } = props;
135
139
  const { hrefs } = useAppConfig();
136
140
 
@@ -163,7 +167,6 @@ type VerifyStep =
163
167
 
164
168
  export function VerifyAccountView(props: {
165
169
  currentUser: CurrentUser;
166
- onLogout: EventHandlerWithReload;
167
170
  reload: () => void;
168
171
 
169
172
  /**
@@ -172,7 +175,7 @@ export function VerifyAccountView(props: {
172
175
  *
173
176
  * This is useful if this view is used within a modal dialog context.
174
177
  */
175
- onClose?: EventHandler;
178
+ onClose?: AuthEventHandler;
176
179
  }) {
177
180
  const [step, setStep] = useState<VerifyStep>({ type: "INITIAL" });
178
181
 
@@ -1,7 +1,9 @@
1
- export type EventHandler = () => Promise<void> | void;
1
+ import type { CurrentUser } from "../types.ts";
2
2
 
3
- export type EventHandlerWithReload = (options: {
4
- reload: () => void;
3
+ export type AuthEventHandler = () => Promise<void> | void;
4
+
5
+ export type ClientLogoutHandler = (options: {
6
+ serverUser: CurrentUser;
5
7
  }) => Promise<void> | void;
6
8
 
7
9
  export type DefaultFormValues = {
@@ -3,7 +3,7 @@ import { useClient } from "../AppConfig/AppConfig.tsx";
3
3
  import { type Failure, Pending, type Success } from "../async-op.ts";
4
4
  import type { CurrentUser, FailurePayload } from "../types.ts";
5
5
 
6
- export function useCurrentUserResult(options?: {
6
+ export function useFetchCurrentUser(options?: {
7
7
  /**
8
8
  * Optionally disable immediate fetch action.
9
9
  *
package/lib/async-op.ts CHANGED
@@ -33,6 +33,8 @@ interface Operation<SuccessValue, FailureValue> {
33
33
  mapF: (failure: FailureValue) => F,
34
34
  mapP: () => P,
35
35
  ): S | F | P;
36
+
37
+ toJSON(): object;
36
38
  }
37
39
 
38
40
  export class Pending implements Operation<never, never> {
@@ -50,6 +52,7 @@ export class Pending implements Operation<never, never> {
50
52
  valueOrThrow(): never {
51
53
  throw new Error(
52
54
  `AsyncOp value was accessed but the op is in Pending state.`,
55
+ { cause: this },
53
56
  );
54
57
  }
55
58
 
@@ -64,6 +67,7 @@ export class Pending implements Operation<never, never> {
64
67
  failureValueOrThrow(): never {
65
68
  throw new Error(
66
69
  `AsyncOp failure value was accessed but the op is in Pending state.`,
70
+ { cause: this },
67
71
  );
68
72
  }
69
73
 
@@ -86,6 +90,10 @@ export class Pending implements Operation<never, never> {
86
90
  ): S | F | P {
87
91
  return mapP();
88
92
  }
93
+
94
+ toJSON() {
95
+ return { type: this.type };
96
+ }
89
97
  }
90
98
 
91
99
  export class Success<SuccessValue> implements Operation<SuccessValue, never> {
@@ -120,6 +128,7 @@ export class Success<SuccessValue> implements Operation<SuccessValue, never> {
120
128
  failureValueOrThrow(): never {
121
129
  throw new Error(
122
130
  `AsyncOp failure value was accessed but the op is in Success state.`,
131
+ { cause: this },
123
132
  );
124
133
  }
125
134
 
@@ -144,6 +153,13 @@ export class Success<SuccessValue> implements Operation<SuccessValue, never> {
144
153
  ): S | F | P {
145
154
  return mapS(this.value);
146
155
  }
156
+
157
+ toJSON() {
158
+ return {
159
+ type: this.type,
160
+ value: this.value,
161
+ };
162
+ }
147
163
  }
148
164
 
149
165
  export class Failure<FailureValue> implements Operation<never, FailureValue> {
@@ -166,6 +182,7 @@ export class Failure<FailureValue> implements Operation<never, FailureValue> {
166
182
  valueOrThrow(): never {
167
183
  throw new Error(
168
184
  `AsyncOp value was accessed but the op is in Failure state.`,
185
+ { cause: this },
169
186
  );
170
187
  }
171
188
 
@@ -200,6 +217,13 @@ export class Failure<FailureValue> implements Operation<never, FailureValue> {
200
217
  ): S | F | P {
201
218
  return mapF(this.failure);
202
219
  }
220
+
221
+ toJSON() {
222
+ return {
223
+ type: this.type,
224
+ failure: this.failure,
225
+ };
226
+ }
203
227
  }
204
228
 
205
229
  /**
@@ -252,3 +276,11 @@ export type AsyncOp<SuccessValue, FailureValue> =
252
276
  | Pending
253
277
  | Success<SuccessValue>
254
278
  | Failure<FailureValue>;
279
+
280
+ export function fromTryCatch<T>(callback: () => T) {
281
+ try {
282
+ return new Success(callback());
283
+ } catch (cause) {
284
+ return new Failure(cause);
285
+ }
286
+ }
package/lib/client.ts CHANGED
@@ -2,6 +2,48 @@ import { type Infer, mask, object, string, Struct, unknown } from "superstruct";
2
2
  import { Failure, Success } from "./async-op.js";
3
3
  import { currentUser, redeemedPledge, sessionInfo } from "./structs.js";
4
4
  import type { CurrentUser, FailurePayload, SessionInfo } from "./types.js";
5
+ import type { GangData, GangTombstone } from "./types/spacegits.ts";
6
+
7
+ export type UserGameData = {
8
+ spacegits?: {
9
+ gangs?: (GangData | GangTombstone)[];
10
+ };
11
+ };
12
+
13
+ export type GameCode = keyof UserGameData;
14
+
15
+ export type ClientEventType = keyof ClientEventMap;
16
+
17
+ type ClientEventMap = {
18
+ /**
19
+ * Triggered every time currentUser is received.
20
+ */
21
+ currentUser: { currentUser: CurrentUser };
22
+
23
+ /**
24
+ * Triggered when new session info is received.
25
+ */
26
+ sessionInfo: { sessionInfo: SessionInfo };
27
+
28
+ /**
29
+ * Triggered when token refresh fails due to a 401 error.
30
+ */
31
+ sessionExpired: undefined;
32
+ };
33
+
34
+ type ClientEventArgs<T extends ClientEventType> =
35
+ ClientEventMap[T] extends undefined
36
+ ? [type: T]
37
+ : [type: T, detail: ClientEventMap[T]];
38
+
39
+ export class ClientEvent<T extends keyof ClientEventMap> extends CustomEvent<
40
+ ClientEventMap[T]
41
+ > {
42
+ constructor(...args: ClientEventArgs<T>) {
43
+ const [type, detail] = args;
44
+ super(type, { detail });
45
+ }
46
+ }
5
47
 
6
48
  const logLevelToInt = {
7
49
  off: 0,
@@ -12,15 +54,31 @@ const logLevelToInt = {
12
54
 
13
55
  type LogLevel = keyof typeof logLevelToInt;
14
56
 
57
+ type Primitives = string | boolean | number;
58
+
59
+ function toParams(init: Record<string, Primitives | Array<Primitives>>) {
60
+ const params = new URLSearchParams();
61
+
62
+ const entries = Object.entries(init).flatMap(([key, value]) => {
63
+ return Array.isArray(value)
64
+ ? value.map((v) => [key, v] as const)
65
+ : [[key, value] as const];
66
+ });
67
+
68
+ for (const [key, value] of entries) {
69
+ params.append(key, value.toString());
70
+ }
71
+
72
+ return params;
73
+ }
74
+
15
75
  export class IndieTabletopClient {
16
76
  origin: string;
17
- private onCurrentUser?: (currentUser: CurrentUser) => void;
18
- private onSessionInfo?: (sessionInfo: SessionInfo) => void;
19
- private onSessionExpired?: () => void;
20
77
  private refreshTokenPromise?: Promise<
21
78
  Success<{ sessionInfo: SessionInfo }> | Failure<FailurePayload>
22
79
  >;
23
80
  private maxLogLevel: number;
81
+ private eventTarget: EventTarget;
24
82
 
25
83
  constructor(props: {
26
84
  apiOrigin: string;
@@ -52,11 +110,55 @@ export class IndieTabletopClient {
52
110
  */
53
111
  logLevel?: LogLevel;
54
112
  }) {
113
+ this.eventTarget = new EventTarget();
55
114
  this.origin = props.apiOrigin;
56
- this.onCurrentUser = props.onCurrentUser;
57
- this.onSessionInfo = props.onSessionInfo;
58
- this.onSessionExpired = props.onSessionExpired;
59
115
  this.maxLogLevel = props.logLevel ? logLevelToInt[props.logLevel] : 1;
116
+
117
+ // If handlers were passed to the constructor, we set them up here. No need
118
+ // to clean them up, as if the instance is destroyed, the listeners will
119
+ // go with it.
120
+ const { onCurrentUser, onSessionInfo, onSessionExpired } = props;
121
+ if (onCurrentUser) {
122
+ this.addEventListener("currentUser", (event) => {
123
+ return onCurrentUser(event.detail.currentUser);
124
+ });
125
+ }
126
+ if (onSessionInfo) {
127
+ this.addEventListener("sessionInfo", (event) => {
128
+ return onSessionInfo(event.detail.sessionInfo);
129
+ });
130
+ }
131
+ if (onSessionExpired) {
132
+ this.addEventListener("sessionExpired", () => {
133
+ return onSessionExpired();
134
+ });
135
+ }
136
+ }
137
+
138
+ private dispatchEvent<T extends ClientEventType>(
139
+ ...args: ClientEventArgs<T>
140
+ ) {
141
+ this.eventTarget.dispatchEvent(new ClientEvent(...(args as [any, any])));
142
+ }
143
+
144
+ public addEventListener<T extends ClientEventType>(
145
+ type: T,
146
+ callback: (event: ClientEvent<T>) => void,
147
+ options?: boolean | AddEventListenerOptions,
148
+ ): void {
149
+ this.eventTarget.addEventListener(type, callback as EventListener, options);
150
+ }
151
+
152
+ public removeEventListener<T extends ClientEventType>(
153
+ type: T,
154
+ callback: (event: ClientEvent<T>) => void,
155
+ options?: boolean | AddEventListenerOptions,
156
+ ): void {
157
+ this.eventTarget.removeEventListener(
158
+ type,
159
+ callback as EventListener,
160
+ options,
161
+ );
60
162
  }
61
163
 
62
164
  private log(level: Exclude<LogLevel, "off">, ...messages: unknown[]) {
@@ -65,22 +167,55 @@ export class IndieTabletopClient {
65
167
  }
66
168
  }
67
169
 
68
- protected async fetch<T, S>(
170
+ private async fetchWithTokenRefresh(...params: Parameters<typeof fetch>) {
171
+ const response = await fetch(...params);
172
+
173
+ if (response.status !== 401) {
174
+ return response;
175
+ }
176
+
177
+ const authHeader = response.headers.get("WWW-Authenticate");
178
+ if (!authHeader) {
179
+ return response;
180
+ }
181
+
182
+ if (
183
+ authHeader.includes("invalid_token") ||
184
+ authHeader.includes("invalid_request")
185
+ ) {
186
+ this.log("info", "Request failed due to a missing or expired token.");
187
+
188
+ const refresh = await this.refreshTokens();
189
+ if (refresh.isFailure) {
190
+ this.log("info", "Token refresh failed.");
191
+
192
+ // If refresh failed, return the original response.
193
+ return response;
194
+ }
195
+
196
+ // Tokens were refreshed, let's give it another go...
197
+ return await fetch(...params);
198
+ }
199
+
200
+ return response;
201
+ }
202
+
203
+ async fetch<T, S>(
69
204
  path: string,
70
205
  struct: Struct<T, S>,
71
206
  init?: RequestInit & { json?: object },
72
207
  ): Promise<Success<Infer<Struct<T, S>>> | Failure<FailurePayload>> {
73
- // If json was provided, we stringify it. Otherwise we use body.
208
+ // If json was provided, we stringify it. Otherwise we use body as is.
74
209
  const body = init?.json ? JSON.stringify(init.json) : init?.body;
75
210
 
76
- // If json was provided, we make sure that content type is correctly set.
77
- const headers = init?.json
78
- ? { ...init?.headers, "Content-Type": "application/json" }
79
- : init?.headers;
211
+ const headers = new Headers(init?.headers);
212
+ if (init?.json) {
213
+ headers.set("Content-Type", "application/json");
214
+ }
80
215
 
81
216
  try {
82
- const res = await fetch(`${this.origin}${path}`, {
83
- // Defaults
217
+ const url = new URL(path, this.origin);
218
+ const res = await this.fetchWithTokenRefresh(url, {
84
219
  credentials: "include",
85
220
 
86
221
  // Overrides
@@ -113,33 +248,15 @@ export class IndieTabletopClient {
113
248
  }
114
249
 
115
250
  /**
116
- * Fetches data and retries 401 failures after attempting to refresh tokens.
251
+ * @deprecated Use the instance `fetch` method instead. Token refresh
252
+ * is determined dynamically by server response.
117
253
  */
118
254
  protected async fetchWithAuth<T, S>(
119
255
  path: string,
120
256
  struct: Struct<T, S>,
121
257
  init?: RequestInit & { json?: object },
122
258
  ): Promise<Success<Infer<Struct<T, S>>> | Failure<FailurePayload>> {
123
- const op = await this.fetch(path, struct, init);
124
-
125
- if (op.isSuccess) {
126
- return op;
127
- }
128
-
129
- if (op.failure.type === "API_ERROR" && op.failure.code === 401) {
130
- this.log("info", "API request failed with error 401. Refreshing tokens.");
131
-
132
- const refreshOp = await this.refreshTokens();
133
-
134
- if (refreshOp.isSuccess) {
135
- this.log("info", "Tokens refreshed. Retrying request.");
136
- return await this.fetch(path, struct, init);
137
- } else {
138
- this.log("info", "Could not refresh tokens.");
139
- }
140
- }
141
-
142
- return op;
259
+ return this.fetch(path, struct, init);
143
260
  }
144
261
 
145
262
  async login(payload: { email: string; password: string }) {
@@ -156,8 +273,9 @@ export class IndieTabletopClient {
156
273
  );
157
274
 
158
275
  if (result.isSuccess) {
159
- this.onCurrentUser?.(result.value.currentUser);
160
- this.onSessionInfo?.(result.value.sessionInfo);
276
+ const { currentUser, sessionInfo } = result.value;
277
+ this.dispatchEvent("currentUser", { currentUser });
278
+ this.dispatchEvent("sessionInfo", { sessionInfo });
161
279
  }
162
280
 
163
281
  return result;
@@ -194,8 +312,8 @@ export class IndieTabletopClient {
194
312
  );
195
313
 
196
314
  if (res.isSuccess) {
197
- this.onCurrentUser?.(res.value.currentUser);
198
- this.onSessionInfo?.(res.value.sessionInfo);
315
+ this.dispatchEvent("currentUser", { currentUser: res.value.currentUser });
316
+ this.dispatchEvent("sessionInfo", { sessionInfo: res.value.sessionInfo });
199
317
  }
200
318
 
201
319
  return res;
@@ -230,7 +348,9 @@ export class IndieTabletopClient {
230
348
  const result = await this.refreshTokenPromise;
231
349
 
232
350
  if (result.isSuccess) {
233
- this.onSessionInfo?.(result.value.sessionInfo);
351
+ this.dispatchEvent("sessionInfo", {
352
+ sessionInfo: result.value.sessionInfo,
353
+ });
234
354
  }
235
355
 
236
356
  if (
@@ -238,7 +358,7 @@ export class IndieTabletopClient {
238
358
  result.failure.type === "API_ERROR" &&
239
359
  result.failure.code === 401
240
360
  ) {
241
- this.onSessionExpired?.();
361
+ this.dispatchEvent("sessionExpired");
242
362
  }
243
363
 
244
364
  // Make sure to reset the shared reference so that subsequent invocations
@@ -319,10 +439,20 @@ export class IndieTabletopClient {
319
439
  }
320
440
 
321
441
  async getCurrentUser() {
322
- const result = await this.fetchWithAuth(`/v1/users/me`, currentUser());
442
+ const result = await this.fetch(`/v1/users/me`, currentUser());
323
443
 
324
444
  if (result.isSuccess) {
325
- this.onCurrentUser?.(result.value);
445
+ this.dispatchEvent("currentUser", { currentUser: result.value });
446
+ }
447
+
448
+ if (
449
+ result.isFailure &&
450
+ result.failure.type === "API_ERROR" &&
451
+ result.failure.code === 404
452
+ ) {
453
+ // The user no longer exists, so even though they might have a temporarily
454
+ // valid session, we want to behave as if the session has expired.
455
+ this.dispatchEvent("sessionExpired");
326
456
  }
327
457
 
328
458
  return result;
@@ -401,4 +531,44 @@ export class IndieTabletopClient {
401
531
  { method: "PUT" },
402
532
  );
403
533
  }
534
+
535
+ async pullUserData(props: {
536
+ sinceTs: number | null;
537
+ include: GameCode | GameCode[];
538
+ expectCurrentUserId: string;
539
+ }) {
540
+ const params = toParams({
541
+ sinceTs: props.sinceTs ?? 0,
542
+ include: props.include,
543
+ omitDeleted: !props.sinceTs,
544
+ expectCurrentUserId: props.expectCurrentUserId,
545
+ });
546
+
547
+ return await this.fetch(
548
+ `/v1/me/data?${params}`,
549
+ unknown() as Struct<UserGameData, null>,
550
+ );
551
+ }
552
+
553
+ async pushUserData(props: {
554
+ currentSyncTs: number;
555
+ pullSinceTs: number | null;
556
+ data: UserGameData;
557
+ expectCurrentUserId: string | null | undefined;
558
+ }) {
559
+ return await this.fetch(
560
+ `/v1/me/data`,
561
+ unknown() as Struct<UserGameData, null>,
562
+ {
563
+ method: "PATCH",
564
+ json: {
565
+ data: props.data,
566
+ syncedTs: props.currentSyncTs,
567
+ pullSinceTs: props.pullSinceTs ?? 0,
568
+ pullOmitDeleted: !props.pullSinceTs,
569
+ expectCurrentUserId: props.expectCurrentUserId,
570
+ },
571
+ },
572
+ );
573
+ }
404
574
  }
@@ -0,0 +1,91 @@
1
+ import { type Struct, validate } from "superstruct";
2
+ import { fromTryCatch } from "./async-op.ts";
3
+
4
+ type AnyStruct = Struct<any, any>;
5
+
6
+ type StructsConfig = Record<string, AnyStruct>;
7
+
8
+ type SafeStorage<T extends Record<string, AnyStruct>> = ReturnType<
9
+ typeof createSafeStorage<T>
10
+ >;
11
+
12
+ export type SafeStorageKey<T extends SafeStorage<StructsConfig>> = ReturnType<
13
+ T["keys"]
14
+ >[number];
15
+
16
+ type StructValue<T> = T extends Struct<infer V> ? V : never;
17
+
18
+ /**
19
+ * Creates an object with an interface similar to localStorage, but that
20
+ * enforces that values are parsed and validated before being retrieved,
21
+ * stringified when being set, and that both keys and values typecheck.
22
+ *
23
+ * @example
24
+ * ```ts
25
+ * const safeStorage = createSafeStorage({
26
+ * currentUser: currentUser(),
27
+ * sessionInfo: sessionInfo(),
28
+ * lastSuccessfulSyncTs: number(),
29
+ * });
30
+ *
31
+ * safeStorage.getItem("currentUser") // Will be valid current user or `null`
32
+ * safeStorage.setItem("currentUser", { ... }) // Typechecked key and value
33
+ * safeStorage.removeItem("currentUser") // Typechecked key
34
+ * safeStorage.clear() // Removes all "owned" keys
35
+ *
36
+ * ```
37
+ */
38
+ export function createSafeStorage<T extends StructsConfig>(structs: T) {
39
+ return {
40
+ getItem<K extends keyof T & string>(key: K) {
41
+ const struct = structs[key];
42
+ if (!struct) {
43
+ throw new Error(`No struct found for ${key}`);
44
+ }
45
+
46
+ const storedValue = localStorage.getItem(key);
47
+
48
+ if (storedValue === null) {
49
+ return null;
50
+ }
51
+
52
+ const parsedResult = fromTryCatch<unknown>(() => JSON.parse(storedValue));
53
+
54
+ if (parsedResult.isFailure) {
55
+ console.warn(`Could not parse localStorage value at key '${key}'.`);
56
+ return null;
57
+ }
58
+
59
+ const [error, value] = validate(parsedResult.value, struct, {
60
+ coerce: true,
61
+ mask: true,
62
+ });
63
+
64
+ if (error) {
65
+ console.warn(
66
+ `Validation failed for localStorage value at key '${key}'. ${error.message}`,
67
+ );
68
+ }
69
+
70
+ return value as StructValue<T[K]> | null;
71
+ },
72
+
73
+ setItem<K extends keyof T & string>(key: K, value: StructValue<T[K]>) {
74
+ localStorage.setItem(key, JSON.stringify(value));
75
+ },
76
+
77
+ removeItem(key: keyof T & string) {
78
+ localStorage.removeItem(key);
79
+ },
80
+
81
+ clear() {
82
+ for (const key in structs) {
83
+ localStorage.removeItem(key);
84
+ }
85
+ },
86
+
87
+ keys() {
88
+ return Object.keys(structs) as (keyof T)[];
89
+ },
90
+ };
91
+ }
package/lib/index.ts CHANGED
@@ -43,6 +43,7 @@ export * from "./caught-value.ts";
43
43
  export * from "./class-names.ts";
44
44
  export * from "./client.ts";
45
45
  export * from "./copyrightRange.ts";
46
+ export * from "./createSafeStorage.ts";
46
47
  export * from "./failureMessages.ts";
47
48
  export * from "./groupBy.ts";
48
49
  export * from "./HistoryState.ts";
@@ -61,5 +62,9 @@ export * from "./unique.ts";
61
62
  export * from "./utm.ts";
62
63
  export * from "./validations.ts";
63
64
 
65
+ // Structs
66
+ export * from "./types/spacegits.ts";
67
+
64
68
  // Other
65
69
  export * from "./ModernIDB/index.ts";
70
+ export * from "./store/index.tsx";