import type { Resource, UploadableAsset } from "@noya-app/noya-schemas"; import { Base64, uuid } from "@noya-app/noya-utils"; import { AssetStoreIndexedDB } from "../AssetManager"; import { NoyaManager } from "../NoyaManager"; import type { RouteKey as APIRouteKey, ExtractRequestBody, ExtractResponseBody, } from "../rpc/routes"; import { SerializableRequest, SerializableResponse } from "../rpc/types"; /** * Shared helpers for building local RPC handlers used in offline/demo modes. */ export type LocalHandler = ( req: SerializableRequest, match: RegExpMatchArray | null ) => Promise> | ExtractResponseBody; export type Registration = { key: K; method: string; pattern: RegExp; handler: LocalHandler; }; type ResourceCreateParameters = NonNullable< ExtractRequestBody<"PATCH /api/resources">["create"] >[number]; type ResourceUpdateParameters = NonNullable< ExtractRequestBody<"PATCH /api/resources">["update"] >[number]; type LocalResourceStore = { list: () => Promise; read: (id: string) => Promise; create: (resource: Resource) => Promise; update: (id: string, resource: Resource) => Promise; delete: (id: string) => Promise; replaceAll: (resources: Resource[]) => Promise; runExclusive: (callback: () => Promise) => Promise; }; const localResourceStores = new WeakMap< object, Map >(); export const createRoutePattern = (key: APIRouteKey): RegExp => { const [, path] = key.split(" "); return new RegExp( path.replace(/\//g, "\\/").replace(/:[^/]+/g, "([^/]+)") + "$" ); }; export const registerRoute = ( key: K, handler: LocalHandler ): Registration => { const [m] = key.split(" "); return { key, method: m, pattern: createRoutePattern(key), handler }; }; export const createJsonResponse = (body: unknown): SerializableResponse => ({ status: 200, statusText: "OK", headers: { "content-type": "application/json" }, body: JSON.stringify(body), }); const toDataURL = (contentType: string | undefined, data: Uint8Array) => { const ct = contentType ?? "application/octet-stream"; return `data:${ct};base64,${Base64.encode(data)}`; }; /** * Ensure assets are persisted locally when operating without a server. * Falls back to in-memory store if IndexedDB is not available. */ export function ensureLocalAssetStore< S, M extends object, E extends object, MenuT extends string, I extends Record = Record, >(noyaManager: NoyaManager, offlineStorageKey?: string) { let assetStore: AssetStoreIndexedDB | undefined; try { if (offlineStorageKey) { assetStore = new AssetStoreIndexedDB({ databaseName: `noya-assets-${offlineStorageKey}`, }); } else { assetStore = new AssetStoreIndexedDB(); } noyaManager.assetManager.assetStore = assetStore; } catch (error) { console.warn( "[localRpcHelpers] IndexedDB unavailable; using memory asset store", error ); } return assetStore; } const addResourceStoreLock = ( store: Omit ): LocalResourceStore => { let pending = Promise.resolve(); return { ...store, runExclusive: async (callback: () => Promise): Promise => { const previous = pending; let release!: () => void; pending = new Promise((resolve) => { release = resolve; }); await previous; try { return await callback(); } finally { release(); } }, }; }; const createMemoryResourceStore = (): LocalResourceStore => { let resources = new Map(); return addResourceStoreLock({ list: async () => [...resources.values()], read: async (id: string) => resources.get(id) ?? null, create: async (resource: Resource) => { resources.set(resource.id, resource); return resource; }, update: async (id: string, resource: Resource) => { if (!resources.has(id)) { return null; } resources.set(id, resource); return resource; }, delete: async (id: string) => { const existing = resources.get(id) ?? null; resources.delete(id); return existing; }, replaceAll: async (nextResources: Resource[]) => { resources = new Map( nextResources.map((resource) => [resource.id, resource]) ); }, }); }; const createIndexedDBResourceStore = ( databaseName: string, indexedDB: typeof globalThis.indexedDB ): LocalResourceStore => { const db = new Promise((resolve, reject) => { const request = indexedDB.open(databaseName, 1); request.onerror = () => reject(request.error); request.onsuccess = () => resolve(request.result); request.onupgradeneeded = (event) => { const database = (event.target as IDBOpenDBRequest).result; if (!database.objectStoreNames.contains("resources")) { database.createObjectStore("resources", { keyPath: "id" }); } }; }); const list = async (): Promise => { const database = await db; return await new Promise((resolve, reject) => { const request = database .transaction("resources", "readonly") .objectStore("resources") .getAll() as IDBRequest; request.onerror = () => reject(request.error); request.onsuccess = () => resolve(request.result ?? []); }); }; const read = async (id: string): Promise => { const database = await db; return await new Promise((resolve, reject) => { const request = database .transaction("resources", "readonly") .objectStore("resources") .get(id) as IDBRequest; request.onerror = () => reject(request.error); request.onsuccess = () => resolve(request.result ?? null); }); }; const create = async (resource: Resource): Promise => { const database = await db; await new Promise((resolve, reject) => { const request = database .transaction("resources", "readwrite") .objectStore("resources") .put(resource); request.onerror = () => reject(request.error); request.onsuccess = () => resolve(); }); return resource; }; const update = async ( id: string, resource: Resource ): Promise => { const existing = await read(id); if (!existing) { return null; } const database = await db; await new Promise((resolve, reject) => { const request = database .transaction("resources", "readwrite") .objectStore("resources") .put(resource); request.onerror = () => reject(request.error); request.onsuccess = () => resolve(); }); return resource; }; const remove = async (id: string): Promise => { const existing = await read(id); if (!existing) { return null; } const database = await db; await new Promise((resolve, reject) => { const request = database .transaction("resources", "readwrite") .objectStore("resources") .delete(id); request.onerror = () => reject(request.error); request.onsuccess = () => resolve(); }); return existing; }; const replaceAll = async (resources: Resource[]): Promise => { const database = await db; await new Promise((resolve, reject) => { const transaction = database.transaction("resources", "readwrite"); const store = transaction.objectStore("resources"); store.clear(); for (const resource of resources) { store.put(resource); } transaction.oncomplete = () => resolve(); transaction.onerror = () => reject(transaction.error ?? new Error("Resource transaction failed")); transaction.onabort = () => reject(transaction.error ?? new Error("Resource transaction aborted")); }); }; return addResourceStoreLock({ list, read, create, update, delete: remove, replaceAll, }); }; /** * Ensure resources are persisted locally when operating without a server. * Falls back to an in-memory store if IndexedDB is unavailable. */ export function ensureLocalResourceStore< S, M extends object, E extends object, MenuT extends string, I extends Record = Record, >( noyaManager: NoyaManager, offlineStorageKey?: string ): LocalResourceStore { const managerKey = noyaManager as object; const storageKey = offlineStorageKey ?? ""; let storesByKey = localResourceStores.get(managerKey); const existingStore = storesByKey?.get(storageKey); if (existingStore) { return existingStore; } let resourceStore: LocalResourceStore; try { if (typeof globalThis.indexedDB === "undefined") { throw new Error("IndexedDB is not available"); } resourceStore = createIndexedDBResourceStore( offlineStorageKey ? `noya-resources-${offlineStorageKey}` : "noya-resources", globalThis.indexedDB ); } catch (error) { console.warn( "[localRpcHelpers] IndexedDB unavailable; using memory resource store", error ); resourceStore = createMemoryResourceStore(); } if (!storesByKey) { storesByKey = new Map(); localResourceStores.set(managerKey, storesByKey); } storesByKey.set(storageKey, resourceStore); return resourceStore; } /** * Build local RPC routes that handle assets and basic lists (secrets, inputs, * outputTransforms) for offline/demo mode. */ export function buildLocalAssetRoutes< S, M extends object, E extends object, MenuT extends string, I extends Record = Record, >( noyaManager: NoyaManager, options: { offlineStorageKey: string } ): Registration[] { return [ registerRoute("PUT /api/file", async (req) => { const body = req.options?.body ?? "{}"; let parsed: { name?: string; changeId?: string }; try { parsed = JSON.parse(body); } catch { throw new Error("Invalid request body"); } const name = typeof parsed.name === "string" && parsed.name.length > 0 ? parsed.name : ""; try { if (typeof localStorage !== "undefined") { localStorage.setItem(`${options.offlineStorageKey}:name`, name); } } catch { // ignore localStorage errors } noyaManager.filePropertyManager.applyServerUpdate({ name, changeId: parsed.changeId, }); return { name, changeId: parsed.changeId, }; }), registerRoute("GET /api/secrets", async () => { return []; }), registerRoute("GET /api/inputs", async () => { return []; }), registerRoute("GET /api/outputTransforms", async () => { return []; }), registerRoute("GET /api/assets", async () => { const store = noyaManager.assetManager.assetStore; if (store._listWithBytes) { const list = await store._listWithBytes(); return list.map(({ data, ...rest }) => ({ ...rest, url: toDataURL(rest.contentType, data), })); } else { return await store.list(); } }), registerRoute("POST /api/assets", async (req) => { const contentType = req.options.headers?.["Content-Type"] || req.options.headers?.["content-type"]; const base64Body = req.options.body ?? ""; const data = Base64.decode(base64Body); const created = await noyaManager.assetManager.assetStore.create({ data, contentType, }); await noyaManager.assetManager.fetch(); return { ...created, url: toDataURL(contentType, data) }; }), registerRoute("DELETE /api/assets/:id", async (_req, match) => { const id = match?.[1]; if (!id) throw new Error("Missing asset id"); await noyaManager.assetManager.assetStore.delete(id); await noyaManager.assetManager.fetch(); return { success: true } as const; }), ]; } function decodeUploadableAsset(asset: UploadableAsset): Uint8Array { if (asset.encoding === "base64") { return Base64.decode(asset.content); } return new TextEncoder().encode(asset.content); } function getCreateResourceFileId( resource: ResourceCreateParameters ): string | undefined { if ("fileId" in resource && typeof resource.fileId === "string") { return resource.fileId; } return undefined; } const resourceTypes = new Set([ "directory", "asset", "file", "resource", ]); const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); const assertString = ( value: unknown, field: string, options: { optional?: boolean } = {} ) => { if (value === undefined && options.optional) { return; } if (typeof value !== "string") { throw new Error(`Invalid PATCH request: ${field} must be a string`); } }; const validateUploadableAsset = (value: unknown, field: string) => { if (!isRecord(value)) { throw new Error(`Invalid PATCH request: ${field} must be an object`); } assertString(value.content, `${field}.content`); assertString(value.contentType, `${field}.contentType`); if (value.encoding !== "base64" && value.encoding !== "utf-8") { throw new Error( `Invalid PATCH request: ${field}.encoding must be base64 or utf-8` ); } }; const validateResourcePatchBody = ( value: unknown ): ExtractRequestBody<"PATCH /api/resources"> => { if (!isRecord(value)) { throw new Error("Invalid PATCH request: body must be an object"); } for (const field of ["create", "update", "delete"] as const) { if (value[field] !== undefined && !Array.isArray(value[field])) { throw new Error(`Invalid PATCH request: ${field} must be an array`); } } const create = value.create as unknown[] | undefined; const update = value.update as unknown[] | undefined; const deleteParameters = value.delete as unknown[] | undefined; if ( (create?.length ?? 0) === 0 && (update?.length ?? 0) === 0 && (deleteParameters?.length ?? 0) === 0 ) { throw new Error( "PATCH body must include create, update, or delete arrays" ); } create?.forEach((parameters, index) => { const field = `create[${index}]`; if (!isRecord(parameters)) { throw new Error(`Invalid PATCH request: ${field} must be an object`); } assertString(parameters.path, `${field}.path`); if ( typeof parameters.type !== "string" || !resourceTypes.has(parameters.type as Resource["type"]) ) { throw new Error(`Invalid PATCH request: ${field}.type is invalid`); } switch (parameters.type) { case "asset": assertString(parameters.assetId, `${field}.assetId`, { optional: true, }); if (parameters.asset !== undefined) { validateUploadableAsset(parameters.asset, `${field}.asset`); } if (parameters.assetId === undefined && parameters.asset === undefined) { throw new Error( `Invalid PATCH request: ${field} requires assetId or asset` ); } break; case "file": assertString(parameters.fileId, `${field}.fileId`); break; case "resource": assertString(parameters.fileId, `${field}.fileId`); assertString(parameters.resourceId, `${field}.resourceId`); break; } }); update?.forEach((parameters, index) => { const field = `update[${index}]`; if (!isRecord(parameters)) { throw new Error(`Invalid PATCH request: ${field} must be an object`); } assertString(parameters.id, `${field}.id`); assertString(parameters.path, `${field}.path`, { optional: true }); assertString(parameters.assetId, `${field}.assetId`, { optional: true }); assertString(parameters.fileId, `${field}.fileId`, { optional: true }); assertString(parameters.fileVersionId, `${field}.fileVersionId`, { optional: true, }); assertString(parameters.resourceId, `${field}.resourceId`, { optional: true, }); if ( parameters.type !== undefined && (typeof parameters.type !== "string" || !resourceTypes.has(parameters.type as Resource["type"])) ) { throw new Error(`Invalid PATCH request: ${field}.type is invalid`); } if (parameters.asset !== undefined) { validateUploadableAsset(parameters.asset, `${field}.asset`); } }); deleteParameters?.forEach((parameters, index) => { const field = `delete[${index}]`; if (!isRecord(parameters)) { throw new Error(`Invalid PATCH request: ${field} must be an object`); } assertString(parameters.id, `${field}.id`); }); return value as ExtractRequestBody<"PATCH /api/resources">; }; function getUpdateResourceFileId( existing: Resource, update: ResourceUpdateParameters ): string | undefined { if (typeof update.fileVersionId === "string") { return update.fileVersionId; } if (typeof update.fileId === "string") { return update.fileId; } if (existing.type === "file" || existing.type === "resource") { return existing.fileId; } return undefined; } /** Build local RPC routes that handle resources for offline/demo mode. */ export function buildLocalResourceRoutes< S, M extends object, E extends object, MenuT extends string, I extends Record = Record, >( noyaManager: NoyaManager, options: { offlineStorageKey: string } ): Registration[] { return [ registerRoute("GET /api/resources", async () => { const resourceStore = ensureLocalResourceStore( noyaManager, options.offlineStorageKey ); return await resourceStore.runExclusive(async () => { const resources = await resourceStore.list(); noyaManager.resourceManager.setResources(resources); noyaManager.resourceManager.isInitialized$.set(true); return resources; }); }), registerRoute("PATCH /api/resources", async (req) => { const resourceStore = ensureLocalResourceStore( noyaManager, options.offlineStorageKey ); const bodyString = req.options?.body ?? "{}"; let parsedBody: unknown; try { parsedBody = JSON.parse(bodyString); } catch { throw new Error("Invalid request body"); } const body = validateResourcePatchBody(parsedBody); const createParameters = body.create ?? []; const updateParameters = body.update ?? []; const deleteParameters = body.delete ?? []; return await resourceStore.runExclusive(async () => { const resources = await resourceStore.list(); const resourceById = new Map( resources.map((resource) => [resource.id, resource]) ); for (const parameters of updateParameters) { const existing = resourceById.get(parameters.id); if (!existing) { throw new Error(`Resource ${parameters.id} not found`); } const type = parameters.type ?? existing.type; if ( type === "asset" && !parameters.asset && !parameters.assetId && existing.type !== "asset" ) { throw new Error(`Missing assetId for resource ${existing.id}`); } if ( (type === "file" || type === "resource") && !getUpdateResourceFileId(existing, parameters) ) { throw new Error(`Missing fileId for resource ${existing.id}`); } if ( type === "resource" && !parameters.resourceId && existing.type !== "resource" ) { throw new Error(`Missing resourceId for resource ${existing.id}`); } } for (const parameters of deleteParameters) { if (!resourceById.has(parameters.id)) { throw new Error(`Resource ${parameters.id} not found`); } } const created: Resource[] = []; const updated: Resource[] = []; const deleted: Resource[] = []; const uploadedAssetIds: string[] = []; const resolveAssetId = async ({ assetId, asset, }: { assetId?: string; asset?: UploadableAsset; }): Promise => { if (!asset) { return assetId; } const createdAsset = await noyaManager.assetManager.create({ data: decodeUploadableAsset(asset), contentType: asset.contentType, }); const resolvedId = noyaManager.assetManager._getServerAssetId(createdAsset.id) ?? createdAsset.id; uploadedAssetIds.push(resolvedId); return resolvedId; }; try { for (const parameters of createParameters) { const now = new Date().toISOString(); const base = { id: uuid(), stableId: uuid(), path: parameters.path, createdAt: now, updatedAt: now, accessibleByFileId: null, accessibleByFileVersionId: null, } as const; let resource: Resource; switch (parameters.type) { case "directory": resource = { ...base, type: "directory" }; break; case "asset": { const assetId = await resolveAssetId(parameters); if (!assetId) { throw new Error( `Missing assetId for resource ${parameters.path}` ); } resource = { ...base, type: "asset", assetId }; break; } case "file": { const fileId = getCreateResourceFileId(parameters); if (!fileId) { throw new Error( `Missing fileId for resource ${parameters.path}` ); } resource = { ...base, type: "file", fileId }; break; } case "resource": { const fileId = getCreateResourceFileId(parameters); if (!fileId) { throw new Error( `Missing fileId for resource ${parameters.path}` ); } resource = { ...base, type: "resource", fileId, resourceId: parameters.resourceId, }; break; } } resourceById.set(resource.id, resource); created.push(resource); } for (const parameters of updateParameters) { const existing = resourceById.get(parameters.id); if (!existing) { throw new Error(`Resource ${parameters.id} not found`); } const type = parameters.type ?? existing.type; const base = { id: existing.id, stableId: existing.stableId, path: parameters.path ?? existing.path, createdAt: existing.createdAt, updatedAt: new Date().toISOString(), accessibleByFileId: existing.accessibleByFileId ?? null, accessibleByFileVersionId: existing.accessibleByFileVersionId ?? null, ...(existing.url ? { url: existing.url } : {}), } as const; let next: Resource; switch (type) { case "directory": next = { ...base, type: "directory" }; break; case "asset": { const assetId = await resolveAssetId({ assetId: parameters.assetId ?? (existing.type === "asset" ? existing.assetId : undefined), asset: parameters.asset, }); if (!assetId) { throw new Error( `Missing assetId for resource ${existing.id}` ); } next = { ...base, type: "asset", assetId }; break; } case "file": { const fileId = getUpdateResourceFileId(existing, parameters); if (!fileId) { throw new Error( `Missing fileId for resource ${existing.id}` ); } next = { ...base, type: "file", fileId }; break; } case "resource": { const fileId = getUpdateResourceFileId(existing, parameters); const resourceId = parameters.resourceId ?? (existing.type === "resource" ? existing.resourceId : undefined); if (!fileId || !resourceId) { throw new Error( `Missing fileId/resourceId for resource ${existing.id}` ); } next = { ...base, type: "resource", fileId, resourceId }; break; } } resourceById.set(next.id, next); updated.push(next); } for (const parameters of deleteParameters) { const removed = resourceById.get(parameters.id); if (!removed) { throw new Error(`Resource ${parameters.id} not found`); } resourceById.delete(parameters.id); deleted.push(removed); } const latestResources = [...resourceById.values()]; await resourceStore.replaceAll(latestResources); noyaManager.resourceManager.setResources(latestResources); noyaManager.resourceManager.isInitialized$.set(true); return { created, updated, deleted }; } catch (error) { for (const assetId of uploadedAssetIds.reverse()) { try { await noyaManager.assetManager.delete(assetId); } catch (rollbackError) { console.warn( `[localRpcHelpers] Failed to roll back asset ${assetId}`, rollbackError ); } } throw error; } }); }), ]; } /** * Proactively send initialization messages to a child for local/offline mode. */ export function sendLocalInitializationMessages< S, M extends object, E extends object, MenuT extends string, I extends Record = Record, >( noyaManager: NoyaManager, send: (message: any) => void, options: { offlineStorageKey: string } ) { let fileName: string | undefined; try { if (typeof localStorage !== "undefined") { const storedName = localStorage.getItem( `${options.offlineStorageKey}:name` ); if (storedName !== null) { fileName = storedName; noyaManager.filePropertyManager.setName(storedName); } } } catch { // Ignore localStorage access failures } if (fileName === undefined) { fileName = noyaManager.filePropertyManager.optimisticValue$.get(); } if (fileName !== undefined) { send({ type: "fileName", name: fileName }); } send({ type: "secrets", secrets: [] }); send({ type: "inputs", inputs: [] }); send({ type: "outputTransforms", outputTransforms: [] }); const resourceStore = ensureLocalResourceStore( noyaManager, options.offlineStorageKey ); void resourceStore .runExclusive(async () => { const resources = await resourceStore.list(); noyaManager.resourceManager.setResources(resources); noyaManager.resourceManager.isInitialized$.set(true); send({ type: "resources", resources }); }) .catch((error) => { console.warn( "[localRpcHelpers] Failed to load local resources for initialization", error ); const resources = noyaManager.resourceManager.resources$.get(); noyaManager.resourceManager.isInitialized$.set(true); send({ type: "resources", resources }); }); }