import { decodeSchema } from "@noya-app/noya-schemas"; import { uuid } from "@noya-app/noya-utils"; import { diff } from "../diff"; import { InputQueueStore, drainInputQueue, enqueueInput } from "../inputQueue"; import { LocalAssetStorageAdapter } from "../LocalAssetStorageAdapter"; import { ClientToServerMessage } from "../multiplayer"; import type { ExtractRequestBody, ExtractResponseBody, GitFilePatchRequestBody, } from "../rpc/routes"; import { SerializableRequest } from "../rpc/types"; import { ServerScriptManager, ServerScriptManagerOptions, serializeServerScriptLogValue, } from "../serverScripts"; import { requestTranscription } from "../TranscriptionManager"; import { createValue } from "../valueUtils"; import { clientId$, clientImage$, clientName$ } from "./clientId"; import type { GitInterface, StorageAdapter } from "./gitTypes"; import { LocalGitFileEditor } from "./LocalGitFileEditor"; import { buildLocalResourceRoutes, Registration, createJsonResponse, ensureLocalAssetStore, ensureLocalResourceStore, registerRoute, } from "./localRpcHelpers"; import { processLocalStorageMessage } from "./localStorageServer"; import { MultiplayerUser, SyncAdapter } from "./types"; const localGeneratedImageBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGNgAAIAAAUAAXpeqz8AAAAASUVORK5CYII="; export type LocalStorageSyncOptions = { key: string; serverScripts?: Pick< ServerScriptManagerOptions, "getQuickJSModule" | "randomUUID" >; identity?: { userId?: string; name?: string | null; image?: string | null; clientId?: string; }; /** * Optional factory function that returns a git interface for handling git RPC calls locally. * If not provided, git routes will not be available. * * The factory receives a StorageAdapter that persists git bundles as assets. * Using a factory function allows for dynamic imports: * * ```ts * localStorageSync({ * key: "my-key", * git: async (storageAdapter) => { * const { createGitInterface } = await import("@noya-app/git-remote/git"); * return createGitInterface({ storageAdapter }); * }, * }); * ``` */ git?: (storageAdapter: StorageAdapter) => Promise; }; export function localStorageSync< S, M extends object, E extends object, MenuT extends string = string, I extends Record = Record, >(options: LocalStorageSyncOptions): SyncAdapter { return ({ noyaManager }) => { const key = options.key; const nameKey = `${key}:name`; const serverScriptOptions = options.serverScripts; let serverScriptManager: ServerScriptManager | null = null; let sharedConnectionData: Record = {}; const inputQueues: InputQueueStore = new Map(); const enqueueSharedInput = ( connectionId: string, queue: string, payload: unknown ) => { enqueueInput({ store: inputQueues, connectionId, queue, payload, onQueueActivity: (queueName) => serverScriptManager?.handleInputQueueActivity(queueName), }); }; const drainQueuedInputs = ( options?: Parameters[1] ) => drainInputQueue(inputQueues, options); const getRandomId = () => { if (serverScriptOptions?.randomUUID) return serverScriptOptions.randomUUID(); if ( typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" ) { return crypto.randomUUID(); } return uuid(); }; const authId = options.identity?.userId; const clientId = options.identity?.clientId ?? clientId$.get() ?? uuid(); const connectionId = authId ?? clientId ?? "me"; const localUser: MultiplayerUser = { id: authId ?? connectionId, authId: authId ?? undefined, connectionId, clientId, name: options.identity?.name ?? clientName$.get() ?? "Me", image: options.identity?.image ?? clientImage$.get(), }; noyaManager.userManager.setAllUsers([localUser]); noyaManager.userManager.setCurrentConnectionId(connectionId); noyaManager.userManager.setCurrentUserId(authId ?? connectionId); noyaManager.sharedConnectionDataManager.setCurrentConnectionId( connectionId ); // Ensure a persistent local asset store per-key const assetStore = ensureLocalAssetStore( noyaManager, key ); const resourceStore = ensureLocalResourceStore( noyaManager, key ); noyaManager.secretManager.isInitialized$.set(true); noyaManager.ioManager.inputsInitialized$.set(true); noyaManager.ioManager.outputTransformsInitialized$.set(true); void resourceStore .runExclusive(async () => { const resources = await resourceStore.list(); noyaManager.resourceManager.setResources(resources); noyaManager.resourceManager.isInitialized$.set(true); }) .catch((error) => { console.warn( "[localStorageSync] Failed to load local resources from IndexedDB", error ); noyaManager.resourceManager.isInitialized$.set(true); }); // Mark RPC as connected so local handlers can respond noyaManager.rpcManager.setIsConnected(true); if (serverScriptOptions) { const managerOptions: ServerScriptManagerOptions = { getQuickJSModule: serverScriptOptions.getQuickJSModule, randomUUID: serverScriptOptions.randomUUID, environment: { target: "local-storage", mode: "live", label: "server", }, getState: () => noyaManager.multiplayerStateManager.getConfirmedState(), getSharedConnectionData: () => sharedConnectionData, setSharedConnectionData: (data) => { sharedConnectionData = data; }, drainInputQueue: (options) => drainQueuedInputs(options), applyState: async ({ scriptId, nextState, invocation, timestamp }) => { const currentState = noyaManager.multiplayerStateManager.getConfirmedState(); if (currentState === undefined) return; const [patches] = diff(currentState, nextState, { useExtendedPatches: true, }); if (patches.length === 0) return; noyaManager.multiplayerStateManager.incomingMessages.push({ type: "acceptPatch", id: `serverScript:${scriptId}:${getRandomId()}`, patches, serverInvocation: invocation !== undefined && timestamp !== undefined ? { scriptId, invocation, timestamp, } : undefined, } as any); noyaManager.multiplayerStateManager.processIncomingMessages(); try { if (typeof localStorage !== "undefined") { const updatedState = noyaManager.multiplayerStateManager.getConfirmedState(); if (updatedState !== undefined) { localStorage.setItem(key, JSON.stringify(updatedState)); } } } catch { // } serverScriptManager?.handleStateChange(patches); }, log: (entry) => { const entryValues = entry.values.map((value) => serializeServerScriptLogValue(value) ); console[entry.level]("[server]", entry.scriptId, ...entryValues); noyaManager.logManager.append({ id: getRandomId(), scriptId: entry.scriptId, level: entry.level, values: entryValues, timestamp: Date.now(), origin: entry.origin, target: entry.target, mode: entry.mode, }); }, }; serverScriptManager = new ServerScriptManager(managerOptions); serverScriptManager.setIsHot(true); void serverScriptManager.updateSchema( noyaManager.multiplayerStateManager.schema ); } // Initialize file name from localStorage if available try { if (typeof localStorage !== "undefined") { const persistedName = localStorage.getItem(nameKey); if (persistedName !== null) { noyaManager.filePropertyManager.setName(persistedName); } } } catch (e) { // Ignore localStorage access errors (e.g., in non-browser environments) } // Git file editor instance (created lazily when git is enabled) let gitFileEditor: LocalGitFileEditor | undefined; const handleMessage = async (message: ClientToServerMessage) => { if (message.type === "enqueueInput") { const connectionId = message.connectionId ?? localUser.connectionId; enqueueSharedInput(connectionId, message.queue, message.payload); return; } // Handle git file edit messages if (gitFileEditor) { const handled = await gitFileEditor.handleMessage(message); if (handled) { return; } } const response = await processLocalStorageMessage(key, message, { onSchemaMigration: async ({ fromVersion, toVersion, currentState, newSchema }) => { if (!serverScriptManager) return undefined; // Update the script manager with the new schema first await serverScriptManager.updateSchema(newSchema); // Run migration scripts return serverScriptManager.handleSchemaMigration(fromVersion, toVersion, currentState); }, }); if (response) { noyaManager.multiplayerStateManager.incomingMessages.push(response); noyaManager.multiplayerStateManager.processIncomingMessages(); if (serverScriptManager) { if (response.type === "acceptPatch") { serverScriptManager.handleStateChange(response.patches); } void serverScriptManager.updateSchema( noyaManager.multiplayerStateManager.schema ); } } }; const unsubscribeMessage = noyaManager.multiplayerStateManager.messageEmitter.addListener( handleMessage ); const unsubscribeError = noyaManager.multiplayerStateManager.errorEmitter.addListener((error) => { noyaManager.connectionEventManager.addEvent({ type: "error", error }); }); const routes: Registration[] = [ registerRoute("PUT /api/file", async (req) => { const body = req.options?.body ?? "{}"; const parsed = JSON.parse(body) as { name?: string; changeId?: string }; const name = typeof parsed.name === "string" ? parsed.name : ""; // Persist to localStorage try { if (typeof localStorage !== "undefined") { localStorage.setItem(nameKey, name); } } catch { // } // Apply to manager immediately (parity with server update) noyaManager.filePropertyManager.applyServerUpdate({ name, changeId: parsed.changeId, }); return { name, changeId: parsed.changeId, } as ExtractResponseBody<"PUT /api/file">; }), registerRoute("POST /api/ai/generate", async (req) => { const body = req.options?.body ?? "{}"; const options = JSON.parse( body ) as ExtractRequestBody<"POST /api/ai/generate">; if (!options.encodedSchema) { throw new Error("Missing encoded schema for local AI generation"); } return { object: createValue(decodeSchema(options.encodedSchema)), } as ExtractResponseBody<"POST /api/ai/generate">; }), registerRoute("POST /api/ai/generate/text", async () => { return { text: "", } as ExtractResponseBody<"POST /api/ai/generate/text">; }), registerRoute("POST /api/ai/generate/image", async () => { return { base64: localGeneratedImageBase64, mediaType: "image/png", } as ExtractResponseBody<"POST /api/ai/generate/image">; }), registerRoute("POST /api/transcriptions", async (req) => { const body = req.options?.body ?? "{}"; const options = JSON.parse( body ) as ExtractRequestBody<"POST /api/transcriptions">; const endpoint = options.endpoint; if (!endpoint) { throw new Error("Transcription is only supported on noya.io"); } return await requestTranscription( { ...options, endpoint }, { resolveAsset: async (assetId: string) => { const asset = await assetStore?._readWithBytes(assetId); if (!asset) { throw new Error(`Asset ${assetId} not found`); } return { data: asset.data.buffer, contentType: asset.contentType, }; }, } ); }), ...buildLocalResourceRoutes(noyaManager, { offlineStorageKey: key, }), ]; // Add git routes if git factory is provided const gitFactory = options.git; // Track pending persist timeouts for git file edits const gitFileEditPendingPersist = new Map>(); const GIT_FILE_EDIT_PERSIST_DEBOUNCE_MS = 5000; if (gitFactory) { // Create storage adapter that persists git bundles as assets const storageAdapter = new LocalAssetStorageAdapter( noyaManager.assetManager ); // Lazily initialize the git interface on first use let gitInterface: GitInterface | undefined; const getGit = async () => { if (!gitInterface) { await storageAdapter.initialize(); gitInterface = await gitFactory(storageAdapter); } return gitInterface; }; // Create git file editor for handling file edit sessions gitFileEditor = new LocalGitFileEditor({ getGit, sendMessage: (message) => { noyaManager.connectionEventManager.addEvent({ type: "receive", message, }); }, onPersistNeeded: (repoId, ref, filePath) => { const persistKey = `${repoId}:${ref}:${filePath}`; // Clear any existing timeout const existingTimeout = gitFileEditPendingPersist.get(persistKey); if (existingTimeout) { clearTimeout(existingTimeout); } // Set a new debounced persist const timeout = setTimeout(async () => { gitFileEditPendingPersist.delete(persistKey); try { await gitFileEditor?.persist(repoId, filePath, ref); } catch { // Ignore persist errors - they will be retried on next edit or close } }, GIT_FILE_EDIT_PERSIST_DEBOUNCE_MS); gitFileEditPendingPersist.set(persistKey, timeout); }, }); routes.push( registerRoute("POST /api/git/repos", async (req) => { const git = await getGit(); const body = req.options?.body ? JSON.parse(req.options.body) : undefined; return await git.createRepo(body); }), registerRoute("GET /api/git/repos/:id/files", async (req, match) => { const repoId = match?.[1]; if (!repoId) { throw new Error("Repository ID is required"); } const git = await getGit(); const url = new URL(req.url, "http://localhost"); const ref = url.searchParams.get("ref") ?? undefined; const includeContent = url.searchParams.get("includeContent") === "true"; return await git.listFiles(repoId, ref, includeContent); }), registerRoute("GET /api/git/repos/:id/branches", async (_req, match) => { const repoId = match?.[1]; if (!repoId) { throw new Error("Repository ID is required"); } const git = await getGit(); return await git.listBranches(repoId); }), registerRoute("PATCH /api/git/repos/:id/files", async (req, match) => { const repoId = match?.[1]; if (!repoId) { throw new Error("Repository ID is required"); } const git = await getGit(); const body = req.options?.body ?? "{}"; const patch = JSON.parse(body) as GitFilePatchRequestBody; const url = new URL(req.url, "http://localhost"); const ref = url.searchParams.get("ref") ?? undefined; const includeContent = url.searchParams.get("includeContent") === "true"; return await git.patchFiles(repoId, patch, ref, includeContent); }) ); } const unsubscribeRpc = noyaManager.rpcManager.addListener( async (request: SerializableRequest) => { const method = request.options?.method?.toUpperCase(); const pathname = request.url.split("?")[0]; for (const r of routes) { if (r.method === method && r.pattern.test(pathname)) { try { const match = pathname.match(r.pattern); const body = await r.handler(request, match); noyaManager.rpcManager.handleMessage({ type: "end", id: request.id!, response: createJsonResponse(body), }); } catch (error) { noyaManager.rpcManager.handleMessage({ type: "error", id: request.id!, error: error instanceof Error ? error.message : String(error ?? "error"), }); } return; } } noyaManager.rpcManager.handleMessage({ type: "error", id: request.id!, error: `RPC route is not available in local mode: ${method} ${pathname}`, }); } ); noyaManager.multiplayerStateManager.connect(); return () => { unsubscribeMessage(); unsubscribeError(); unsubscribeRpc(); serverScriptManager?.setIsHot(false); serverScriptManager?.dispose(); serverScriptManager = null; sharedConnectionData = {}; inputQueues.clear(); // Clear pending git file edit persist timeouts for (const timeout of gitFileEditPendingPersist.values()) { clearTimeout(timeout); } gitFileEditPendingPersist.clear(); gitFileEditor = undefined; noyaManager.multiplayerStateManager.disconnect(); noyaManager.rpcManager.setIsConnected(false); }; }; } export function populateLocalStorageSync( entries: [fileId: string, document: string][] ) { if (typeof localStorage === "undefined") { return; } for (const [fileId, document] of entries) { localStorage.setItem(fileId, document); } }