import { TSchema } from "@sinclair/typebox"; import { decodeSchema } from "@noya-app/noya-schemas"; import { applyExtendedPatch } from "../extended"; import { ClientToServerMessage, ServerToClientMessage } from "../multiplayer"; import { castValue } from "../valueUtils"; export type LocalStorageMigrationOptions = { fromVersion: number; toVersion: number; currentState: S; newSchema: TSchema; }; export type ProcessLocalStorageMessageOptions = { onSchemaMigration?: ( options: LocalStorageMigrationOptions ) => Promise | S | undefined; }; /** * A small helper that processes ClientToServer multiplayer messages * against localStorage and returns the ServerToClient reply. * * This mirrors the behavior of localStorageSync so callers can reuse logic * (e.g. embedded parent frames handling child messages). */ export async function processLocalStorageMessage( key: string, message: ClientToServerMessage, options?: ProcessLocalStorageMessageOptions ): Promise | null> { switch (message.type) { case "init": { let item = typeof localStorage !== "undefined" ? localStorage.getItem(key) : null; const schemaVersionKey = `${key}:schemaVersion`; let object: S; let schema: TSchema | undefined = message.schema ? decodeSchema(message.schema) : undefined; if (message.force || !item) { // Write our state to local storage if (typeof localStorage !== "undefined") { localStorage.setItem(key, JSON.stringify(message.object)); if (schema && typeof schema.version === "number") { localStorage.setItem(schemaVersionKey, String(schema.version)); } } object = message.object; } else { object = JSON.parse(item!); // Check for schema version upgrade if (schema && typeof schema.version === "number") { const storedVersionStr = typeof localStorage !== "undefined" ? localStorage.getItem(schemaVersionKey) : null; const storedVersion = storedVersionStr ? parseInt(storedVersionStr, 10) : 0; const newVersion = schema.version; if (newVersion > storedVersion && options?.onSchemaMigration) { // Run migration callback before typebox cast try { const migratedState = await Promise.resolve( options.onSchemaMigration({ fromVersion: storedVersion, toVersion: newVersion, currentState: object, newSchema: schema, }) ); if (migratedState !== undefined) { object = migratedState; } } catch (error) { console.error("Schema migration failed:", error); // Continue with original state, typebox cast will handle defaults } } // Update stored version if (typeof localStorage !== "undefined") { localStorage.setItem(schemaVersionKey, String(newVersion)); } } } if (schema) { object = castValue(schema, undefined, object) as S; } // Persist the final state after migration and cast if (typeof localStorage !== "undefined" && item) { localStorage.setItem(key, JSON.stringify(object)); } return { type: "object", object, ...(schema ? { schema: message.schema } : {}), } as ServerToClientMessage; } case "patch": { const item = typeof localStorage !== "undefined" ? localStorage.getItem(key) : null; if (!item) { return { id: message.id, type: "rejectPatch", reason: "serverStateNotInitialized", } as ServerToClientMessage; } let state = JSON.parse(item) as S; try { state = applyExtendedPatch(state, message.patches); } catch (error) { return { id: message.id, type: "rejectPatch", reason: "errorApplyingPatch", } as ServerToClientMessage; } if (typeof localStorage !== "undefined") { localStorage.setItem(key, JSON.stringify(state)); } return { type: "acceptPatch", id: message.id, patches: message.patches, } as ServerToClientMessage; } default: { return null; } } }