@indietabletop/appkit 4.0.0-2 → 4.1.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/lib/ModernIDB/ModernIDB.ts +2 -2
- package/lib/ModernIDB/types.ts +4 -0
- package/lib/SubscribeCard/SubscribeByEmailCard.stories.tsx +0 -6
- package/lib/account/AccountIssueView.tsx +11 -7
- package/lib/account/AlreadyLoggedInView.tsx +8 -5
- package/lib/account/CurrentUserFetcher.stories.tsx +0 -28
- package/lib/account/CurrentUserFetcher.tsx +11 -32
- package/lib/account/JoinCard.stories.tsx +0 -2
- package/lib/account/JoinCard.tsx +4 -11
- package/lib/account/LoginCard.stories.tsx +0 -23
- package/lib/account/LoginCard.tsx +11 -59
- package/lib/account/LoginView.tsx +10 -7
- package/lib/account/UserMismatchView.tsx +8 -7
- package/lib/account/VerifyPage.tsx +10 -7
- package/lib/account/types.ts +5 -3
- package/lib/account/{useCurrentUserResult.tsx → useFetchCurrentUser.tsx} +1 -1
- package/lib/async-op.ts +32 -0
- package/lib/client.ts +213 -43
- package/lib/createSafeStorage.ts +91 -0
- package/lib/index.ts +5 -0
- package/lib/store/index.tsx +227 -0
- package/lib/store/store.ts +453 -0
- package/lib/store/types.ts +45 -0
- package/lib/store/utils.ts +46 -0
- package/lib/types/spacegits.ts +108 -0
- package/package.json +6 -3
|
@@ -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 {
|
|
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,
|
|
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={() =>
|
|
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?:
|
|
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?:
|
|
178
|
+
onClose?: AuthEventHandler;
|
|
176
179
|
}) {
|
|
177
180
|
const [step, setStep] = useState<VerifyStep>({ type: "INITIAL" });
|
|
178
181
|
|
package/lib/account/types.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
|
|
1
|
+
import type { CurrentUser } from "../types.ts";
|
|
2
2
|
|
|
3
|
-
export type
|
|
4
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
83
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
160
|
-
this.
|
|
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.
|
|
198
|
-
this.
|
|
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.
|
|
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.
|
|
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.
|
|
442
|
+
const result = await this.fetch(`/v1/users/me`, currentUser());
|
|
323
443
|
|
|
324
444
|
if (result.isSuccess) {
|
|
325
|
-
this.
|
|
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";
|