import { currentUser, featureUnlock, ownedProduct, redeemedPledge, sessionInfo, type FeatureUnlock, type UserGameData, } from "@indietabletop/types"; import { array, mask, object, partial, string, Struct, type Infer, } from "superstruct"; import { Failure, Success } from "./async-op.ts"; import type { CurrentUser, FailurePayload, SessionInfo } from "./types.ts"; export type GameCode = keyof UserGameData; export type ClientEventType = keyof ClientEventMap; type ClientEventMap = { /** * Triggered every time currentUser is received. */ currentUser: { currentUser: CurrentUser }; /** * Triggered when new session info is received. */ sessionInfo: { sessionInfo: SessionInfo }; /** * Triggered when token refresh fails due to a 401 error. */ sessionExpired: undefined; }; type ClientEventArgs = ClientEventMap[T] extends undefined ? [type: T] : [type: T, detail: ClientEventMap[T]]; export class ClientEvent extends CustomEvent< ClientEventMap[T] > { constructor(...args: ClientEventArgs) { const [type, detail] = args; super(type, { detail }); } } const logLevelToInt = { off: 0, error: 1, warn: 2, info: 3, }; type LogLevel = keyof typeof logLevelToInt; type Primitives = string | boolean | number; function toParams(init: Record>) { const params = new URLSearchParams(); const entries = Object.entries(init).flatMap(([key, value]) => { return Array.isArray(value) ? value.map((v) => [key, v] as const) : [[key, value] as const]; }); for (const [key, value] of entries) { params.append(key, value.toString()); } return params; } export class IndieTabletopClient< SnapshotPayload = any, GameDataPayload = any, RulesetPayload = any, > { origin: string; private refreshTokenPromise?: Promise< Success<{ sessionInfo: SessionInfo }> | Failure >; private maxLogLevel: number; private eventTarget: EventTarget; private validation: { snapshot: Struct; gameData: Struct; ruleset: Struct; }; constructor(props: { apiOrigin: string; /** * Runs every time the current user is fetched from the API. Typically, this * happens during login, signup, and when the current user is fetched. */ onCurrentUser?: (currentUser: CurrentUser) => void; /** * Runs ever time new session info is fetched from the API. Typically, this * happends during login, signup, and when tokens are refreshed. */ onSessionInfo?: (sessionInfo: SessionInfo) => void; /** * Runs when token refresh is attempted, but fails due to 401 error. */ onSessionExpired?: () => void; /** * Controls how much to log to the console. * * This is useful e.g. in Storybook, where errors are reported by MSW, and * we don't want to pollute the console with duplicate data. * * @default 'info' */ logLevel?: LogLevel; validation: { snapshot: Struct; gameData: Struct; ruleset: Struct; }; }) { this.eventTarget = new EventTarget(); this.origin = props.apiOrigin; this.maxLogLevel = props.logLevel ? logLevelToInt[props.logLevel] : 1; this.validation = props.validation; // If handlers were passed to the constructor, we set them up here. No need // to clean them up, as if the instance is destroyed, the listeners will // go with it. const { onCurrentUser, onSessionInfo, onSessionExpired } = props; if (onCurrentUser) { this.addEventListener("currentUser", (event) => { return onCurrentUser(event.detail.currentUser); }); } if (onSessionInfo) { this.addEventListener("sessionInfo", (event) => { return onSessionInfo(event.detail.sessionInfo); }); } if (onSessionExpired) { this.addEventListener("sessionExpired", () => { return onSessionExpired(); }); } } private dispatchEvent( ...args: ClientEventArgs ) { this.eventTarget.dispatchEvent(new ClientEvent(...(args as [any, any]))); } public addEventListener( type: T, callback: (event: ClientEvent) => void, options?: boolean | AddEventListenerOptions, ): void { this.eventTarget.addEventListener(type, callback as EventListener, options); } public removeEventListener( type: T, callback: (event: ClientEvent) => void, options?: boolean | AddEventListenerOptions, ): void { this.eventTarget.removeEventListener( type, callback as EventListener, options, ); } private log(level: Exclude, ...messages: unknown[]) { if (logLevelToInt[level] <= this.maxLogLevel) { console[level](...messages); } } private async fetchWithTokenRefresh(...params: Parameters) { const response = await fetch(...params); if (response.status !== 401) { return response; } const authHeader = response.headers.get("WWW-Authenticate"); if (!authHeader) { return response; } if ( authHeader.includes("invalid_token") || authHeader.includes("invalid_request") ) { this.log("info", "Request failed due to a missing or expired token."); const refresh = await this.refreshTokens(); if (refresh.isFailure) { this.log("info", "Token refresh failed."); // If refresh failed, return the original response. return response; } // Tokens were refreshed, let's give it another go... return await fetch(...params); } return response; } async fetch( path: string, struct: Struct, init?: RequestInit & { json?: object }, ): Promise>> | Failure> { // If json was provided, we stringify it. Otherwise we use body as is. const body = init?.json ? JSON.stringify(init.json) : init?.body; const headers = new Headers(init?.headers); if (init?.json) { headers.set("Content-Type", "application/json"); } try { const url = new URL(path, this.origin); const res = await this.fetchWithTokenRefresh(url, { credentials: "include", // Overrides ...init, body, headers, }); if (!res.ok) { return new Failure({ type: "API_ERROR", code: res.status }); } try { const data = mask(await res.json(), struct); return new Success(data); } catch (error) { this.log("error", error); return new Failure({ type: "VALIDATION_ERROR" }); } } catch (error) { this.log("error", error); if (error instanceof Error) { return new Failure({ type: "NETWORK_ERROR" }); } return new Failure({ type: "UNKNOWN_ERROR" }); } } /** * @deprecated Use the instance `fetch` method instead. Token refresh * is determined dynamically by server response. */ protected async fetchWithAuth( path: string, struct: Struct, init?: RequestInit & { json?: object }, ): Promise>> | Failure> { return this.fetch(path, struct, init); } async login(payload: { email: string; password: string }) { const result = await this.fetch( "/v1/sessions", object({ currentUser: currentUser(), sessionInfo: sessionInfo(), }), { method: "POST", json: { email: payload.email, plaintextPassword: payload.password }, }, ); if (result.isSuccess) { const { currentUser, sessionInfo } = result.value; this.dispatchEvent("currentUser", { currentUser }); this.dispatchEvent("sessionInfo", { sessionInfo }); } return result; } async userAgent() { return await this.fetch( "/ua", partial({ browser: partial({ name: string(), version: string(), major: string(), }), device: partial({ type: string(), model: string(), vendor: string(), }), engine: partial({ name: string(), version: string(), }), os: partial({ name: string(), version: string(), }), }), ); } async logout() { const result = await this.fetch( "/v1/sessions", object({ message: string() }), { method: "DELETE" }, ); // Emit sessionExpired to make sure that relevant listeners are run as if the // session was expired. this.dispatchEvent("sessionExpired"); return result; } async join(payload: { email: string; password: string; acceptedTos: boolean; subscribedToNewsletter: boolean; }) { const res = await this.fetch( "/v1/users", object({ currentUser: currentUser(), sessionInfo: sessionInfo(), tokenId: string(), }), { method: "POST", json: { email: payload.email, plaintextPassword: payload.password, acceptedTos: payload.acceptedTos, subscribedToNewsletter: payload.subscribedToNewsletter, }, }, ); if (res.isSuccess) { this.dispatchEvent("currentUser", { currentUser: res.value.currentUser }); this.dispatchEvent("sessionInfo", { sessionInfo: res.value.sessionInfo }); } return res; } /** * Triggers token refresh process. * * Note that we do not want to perform multiple concurrent token refresh * actions, as that will result in unnecessary 401s. For this reason, a * reference to t */ async refreshTokens() { // If there is an ongoing token refresh in progress return that. This should // only deal the response payload, none of the side-effects and cleanup, // which will be handled by the initial invocation. const ongoingRequest = this.refreshTokenPromise; if (ongoingRequest) { this.log("info", "Token refresh ongoing. Reusing existing promise."); return await ongoingRequest; } // Cache the promise on an instance property to share a reference from // other potential invocations. this.refreshTokenPromise = this.fetch( "/v1/sessions/access-tokens", object({ sessionInfo: sessionInfo() }), { method: "POST" }, ); const result = await this.refreshTokenPromise; if (result.isSuccess) { this.dispatchEvent("sessionInfo", { sessionInfo: result.value.sessionInfo, }); } if ( result.isFailure && result.failure.type === "API_ERROR" && result.failure.code === 401 ) { this.dispatchEvent("sessionExpired"); } // Make sure to reset the shared reference so that subsequent invocations // once again initiate token refresh. delete this.refreshTokenPromise; return result; } async requestPasswordReset(payload: { email: string }) { return await this.fetch( `/v1/password-reset-tokens`, object({ message: string(), tokenId: string() }), { method: "POST", json: payload }, ); } async checkPasswordResetCode(payload: { tokenId: string; code: string }) { const queryParams = new URLSearchParams({ plaintextCode: payload.code }); return await this.fetch( `/v1/password-reset-tokens/${payload.tokenId}?${queryParams}`, object({ message: string() }), { method: "GET" }, ); } async setNewPassword(payload: { tokenId: string; code: string; password: string; }) { const queryParams = new URLSearchParams({ plaintextCode: payload.code }); return await this.fetch( `/v1/password-reset-tokens/${payload.tokenId}?${queryParams}`, object({ message: string() }), { method: "PUT", json: { plaintextPassword: payload.password } }, ); } async requestUserVerification() { return await this.fetch( `/v1/user-verification-tokens`, object({ message: string(), tokenId: string() }), { method: "POST" }, ); } async verifyUser(payload: { tokenId: string; code: string }) { const queryParams = new URLSearchParams({ plaintextCode: payload.code }); const req = await this.fetch( `/v1/user-verification-tokens/${payload.tokenId}?${queryParams}`, object({ message: string() }), { method: "PUT" }, ); if (req.isSuccess) { await this.refreshTokens(); await this.getCurrentUser(); } return req; } async getSnapshot(gameCode: string, snapshotId: string) { return await this.fetch( `/v1/snapshots/${gameCode}/${snapshotId}`, this.validation.snapshot, ); } async createSnapshot(gameCode: string, payload: object) { return await this.fetch( `/v1/snapshots/${gameCode}`, object({ snapshotId: string() }), { method: "POST", json: payload }, ); } async getCurrentUser() { const result = await this.fetch(`/v1/users/me`, currentUser()); if (result.isSuccess) { this.dispatchEvent("currentUser", { currentUser: result.value }); } if ( result.isFailure && result.failure.type === "API_ERROR" && result.failure.code === 404 ) { // The user no longer exists, so even though they might have a temporarily // valid session, we want to behave as if the session has expired. this.dispatchEvent("sessionExpired"); } return result; } getRuleset(game: string, version: string) { return this.fetch( `/v1/rulesets/${game}/${version}`, this.validation.ruleset, ); } /** * Uploads a file given S3 presigned config. */ async uploadFile( file: File, presigned: { url: string; key: string; fields: Record }, ): Promise | Failure> { const formData = new FormData(); for (const [key, value] of Object.entries(presigned.fields)) { formData.append(key, value); } formData.append("file", file); try { const upload = await fetch(presigned.url, { method: "POST", body: formData, }); if (!upload.ok) { return new Failure({ type: "API_ERROR", code: upload.status }); } return new Success(`/${presigned.key}`); } catch { return new Failure({ type: "NETWORK_ERROR" }); } } async redeemPledge(campaignCode: string, id: string) { return await this.fetch( `/v1/redemptions/${campaignCode}/pledges/${id}`, redeemedPledge(), ); } async subscribeToNewsletterByPledgeId( newsletterCode: string, pledgeId: string, ) { return await this.fetch( `/v1/newsletters/${newsletterCode}/subscriptions`, object({ message: string() }), { method: "POST", json: { pledgeId, type: "PLEDGE" } }, ); } async subscribeToNewsletterByEmail(newsletterCode: string, email: string) { return await this.fetch( `/v1/newsletters/${newsletterCode}/subscriptions`, object({ message: string(), tokenId: string() }), { method: "POST", json: { type: "EMAIL", email } }, ); } async confirmNewsletterSignup( newsletterCode: string, tokenId: string, plaintextCode: string, ) { const queryParams = new URLSearchParams({ plaintextCode }); return await this.fetch( `/v1/newsletters/${newsletterCode}/newsletter-signup-tokens/${tokenId}?${queryParams}`, object({ message: string() }), { method: "PUT" }, ); } async pullUserData(props: { sinceTs: number | null; include: | (string & keyof GameDataPayload) | (string & keyof GameDataPayload)[]; expectCurrentUserId: string; }) { const params = toParams({ sinceTs: props.sinceTs ?? 0, include: props.include, omitDeleted: !props.sinceTs, expectCurrentUserId: props.expectCurrentUserId, }); return await this.fetch( `/v1/me/game-data?${params}`, this.validation.gameData, ); } async pushUserData(props: { currentSyncTs: number; pullSinceTs: number | null; data: GameDataPayload; expectCurrentUserId: string | null | undefined; }) { return await this.fetch(`/v1/me/game-data`, this.validation.gameData, { method: "PATCH", json: { data: props.data, syncedTs: props.currentSyncTs, pullSinceTs: props.pullSinceTs ?? 0, pullOmitDeleted: !props.pullSinceTs, expectCurrentUserId: props.expectCurrentUserId, }, }); } async getUnlocks(gameCode: T) { return this.fetch( `/v1/me/unlocks/${gameCode}`, object({ unlocks: array( featureUnlock() as unknown as Struct< Extract, null >, ), }), ); } async getOwnedProduct(productCode: string) { return await this.fetch(`/v1/me/products/${productCode}`, ownedProduct()); } async startCheckoutSession(payload: { products: { code: string; quantity: number }[]; successUrl: string; cancelUrl: string; }) { return await this.fetch( `/v1/stripe/sessions`, object({ redirectTo: string() }), { method: "POST", json: payload, }, ); } async getCheckoutSession(sessionId: string) { return await this.fetch( `/v1/stripe/sessions/${sessionId}`, object({ status: string(), orderRef: string(), products: array(ownedProduct()), }), ); } }