From 5a7bcedd839d92edd5ebea63c5dfe703c0a3b399 Mon Sep 17 00:00:00 2001 From: Siddhant Gupta Date: Sun, 7 Dec 2025 11:09:06 +0200 Subject: [PATCH 01/15] feat: add metadata prop to db+storage adapter - change fs adapters to func instead of class - update models to use DBdoc as base --- apps/website/docs/core/bun.md | 8 +- apps/website/docs/core/deno.md | 8 +- apps/website/docs/core/index.md | 8 +- apps/website/docs/core/node.md | 8 +- packages/core/README.md | 16 +- packages/core/src/adapters/_fs-adapters.ts | 527 ++++++++++----------- packages/core/src/adapters/database.ts | 30 +- packages/core/src/adapters/storage.ts | 5 + packages/core/src/models/builds-model.ts | 9 +- packages/core/src/models/projects-model.ts | 8 +- packages/core/src/models/tags-model.ts | 2 +- packages/core/src/models/~model.ts | 13 +- scripts/server.ts | 18 +- 13 files changed, 327 insertions(+), 333 deletions(-) diff --git a/apps/website/docs/core/bun.md b/apps/website/docs/core/bun.md index 394aeac..0987eab 100644 --- a/apps/website/docs/core/bun.md +++ b/apps/website/docs/core/bun.md @@ -14,17 +14,17 @@ Run following with `bun run --hot server.ts` import { createHonoRouter } from "@storybooker/core"; import { - LocalFileDatabase, - LocalFileStorage, + createLocalFileDatabaseAdapter, + createLocalFileStorageAdapter, } from "@storybooker/core/adapter/fs"; import { createBasicUIAdapter } from "@storybooker/ui"; // Create StoryBooker router const router = createHonoRouter({ // provide a supported database service adapter - database: new LocalFileDatabase(), + database: createLocalFileDatabaseAdapter(), // provide a supported storage service adapter - storage: new LocalFileStorage(), + storage: createLocalFileStorageAdapter(), // provide a supported UI adapter ui: createBasicUIAdapter(), }); diff --git a/apps/website/docs/core/deno.md b/apps/website/docs/core/deno.md index 89704d1..3fed356 100644 --- a/apps/website/docs/core/deno.md +++ b/apps/website/docs/core/deno.md @@ -16,17 +16,17 @@ Run following with `deno serve -REW server.ts` import { createHonoRouter } from "jsr:@storybooker/core"; import { - LocalFileDatabase, - LocalFileStorage, + createLocalFileDatabaseAdapter, + createLocalFileStorageAdapter, } from "jsr:@storybooker/core/adapter/fs"; import { createBasicUIAdapter } from "npm:@storybooker/ui"; // Create StoryBooker router const router = createHonoRouter({ // provide a supported database service adapter - database: new LocalFileDatabase(), + database: createLocalFileDatabaseAdapter(), // provide a supported storage service adapter - storage: new LocalFileStorage(), + storage: createLocalFileStorageAdapter(), // provide a supported UI adapter ui: createBasicUIAdapter(), }); diff --git a/apps/website/docs/core/index.md b/apps/website/docs/core/index.md index e7c60e9..5f31d76 100644 --- a/apps/website/docs/core/index.md +++ b/apps/website/docs/core/index.md @@ -39,15 +39,15 @@ Purging deletes all builds older than certain days based on Project's configurat ## Adapters (for testing) -### `LocalFileDatabase` +### `createLocalFileStorageAdapter` -> [API Docs](https://jsr.io/@storybooker/core/doc/adapters/~/LocalFileDatabase) +> [API Docs](https://jsr.io/@storybooker/core/doc/adapter/fs/~/createLocalFileStorageAdapter) A simple database adapter that uses local file to store data. Defaults to `./db.json` file. -### `LocalFileStorage` +### `createLocalFileStorageAdapter` -> [API Docs](https://jsr.io/@storybooker/core/doc/adapters/~/LocalFileStorage) +> [API Docs](https://jsr.io/@storybooker/core/doc/adapter/fs/~/createLocalFileStorageAdapter) A simple storage adapter that uses local folder to store files. Defaults to current folder. diff --git a/apps/website/docs/core/node.md b/apps/website/docs/core/node.md index a02d60a..a044afa 100644 --- a/apps/website/docs/core/node.md +++ b/apps/website/docs/core/node.md @@ -17,17 +17,17 @@ Run following with `node server.mjs` import { serve } from "@hono/node-server"; import { createHonoRouter } from "@storybooker/core"; import { - LocalFileDatabase, - LocalFileStorage, + createLocalFileDatabaseAdapter, + createLocalFileStorageAdapter, } from "@storybooker/core/adapter/fs"; import { createBasicUIAdapter } from "npm:@storybooker/ui"; // Create StoryBooker Hono router const router = createHonoRouter({ // provide a supported database service adapter - database: new LocalFileDatabase(), + database: createLocalFileDatabaseAdapter(), // provide a supported storage service adapter - storage: new LocalFileStorage(), + storage: createLocalFileStorageAdapter(), // provide a supported UI adapter ui: createBasicUIAdapter(), }); diff --git a/packages/core/README.md b/packages/core/README.md index d4b2266..33ef5f1 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -13,14 +13,14 @@ Core Docs: https://storybooker.js.org/docs/core import { serve } from "@hono/node-server"; import { createHonoRouter } from "@storybooker/core"; import { - LocalFileDatabase, - LocalFileStorage, + createLocalFileDatabaseAdapter, + createLocalFileStorageAdapter, } from "@storybooker/core/adapter/fs"; import { createBasicUIAdapter } from "@storybooker/ui"; const app = createHonoRouter({ - database: new LocalFileDatabase(), - storage: new LocalFileStorage(), + database: createLocalFileDatabaseAdapter(), + storage: createLocalFileStorageAdapter(), ui: createBasicUIAdapter(), // remove to create headless service }); @@ -34,14 +34,14 @@ serve(app); ```js import { createHonoRouter } from "jsr:@storybooker/core"; import { - LocalFileDatabase, - LocalFileStorage, + createLocalFileDatabaseAdapter, + createLocalFileStorageAdapter, } from "jsr:@storybooker/core/adapter/fs"; import { createBasicUIAdapter } from "npm:@storybooker/ui"; const app = createHonoRouter({ - database: new LocalFileDatabase(), - storage: new LocalFileStorage(), + database: createLocalFileDatabaseAdapter(), + storage: createLocalFileStorageAdapter(), ui: createBasicUIAdapter(), // remove to create headless service }); diff --git a/packages/core/src/adapters/_fs-adapters.ts b/packages/core/src/adapters/_fs-adapters.ts index 65e96b7..bf1810d 100644 --- a/packages/core/src/adapters/_fs-adapters.ts +++ b/packages/core/src/adapters/_fs-adapters.ts @@ -1,5 +1,4 @@ // oxlint-disable no-await-in-loop -// oxlint-disable max-classes-per-file // oxlint-disable max-params // oxlint-disable require-await // oxlint-disable no-unsafe-assignment @@ -15,7 +14,6 @@ import { StorageAdapterErrors, type DatabaseAdapter, type DatabaseAdapterOptions, - type DatabaseDocumentListOptions, type StorageAdapter, type StoryBookerDatabaseDocument, } from "./index"; @@ -30,231 +28,219 @@ import { * * Usage: * ```ts - * const database = new LocalFileStorage("./db.json"); + * const database = createLocalFileDatabaseAdapter("./db.json"); * ``` */ -export class LocalFileDatabase implements DatabaseAdapter { - #filename: string; - #db: Record> | undefined; +export function createLocalFileDatabaseAdapter(filename = "db.json"): DatabaseAdapter { + const filepath = path.resolve(filename); + let db: Record> | undefined = undefined; - constructor(filename = "db.json") { - this.#filename = filename; - } - - init: DatabaseAdapter["init"] = async (options) => { - if (fs.existsSync(this.#filename)) { - await this.#readFromFile(options); - } else { - const basedir = path.dirname(this.#filename); - await fsp.mkdir(basedir, { recursive: true }); - await fsp.writeFile(this.#filename, "{}", { + const readFromFile = async (options: DatabaseAdapterOptions): Promise => { + try { + const newDB = await fsp.readFile(filepath, { encoding: "utf8", signal: options.abortSignal, }); + db = newDB ? JSON.parse(newDB) : {}; + } catch { + db = {}; } }; - listCollections: DatabaseAdapter["listCollections"] = async () => { - if (!this.#db) { + const saveToFile = async (options: DatabaseAdapterOptions): Promise => { + if (!db) { throw new DatabaseAdapterErrors.DatabaseNotInitializedError(); } - return Object.keys(this.#db); + await fsp.writeFile(filepath, JSON.stringify(db, null, 2), { + encoding: "utf8", + signal: options.abortSignal, + }); }; - createCollection: DatabaseAdapter["createCollection"] = async ( - collectionId, - options, - ): Promise => { - if (!this.#db) { - throw new DatabaseAdapterErrors.DatabaseNotInitializedError(); - } + return { + metadata: { name: "LocalFileDatabaseAdapter" }, + + async init(options) { + if (fs.existsSync(filepath)) { + const stat = await fsp.stat(filepath); + if (stat.isFile()) { + await readFromFile(options); + } else { + throw new DatabaseAdapterErrors.DatabaseNotInitializedError( + `Path "${filepath}" is not a file`, + ); + } + } else { + db = {}; // Initialize empty DB + const basedir = path.dirname(filepath); + await fsp.mkdir(basedir, { recursive: true }).catch(() => { + // ignore error + }); + await saveToFile(options); + } + }, - if (Object.hasOwn(this.#db, collectionId)) { - throw new DatabaseAdapterErrors.CollectionAlreadyExistsError(collectionId); - } + // Collections - if (!this.#db[collectionId]) { - this.#db[collectionId] = {}; - } - await this.#saveToFile(options); - }; + async createCollection(collectionId, options) { + if (!db) { + throw new DatabaseAdapterErrors.DatabaseNotInitializedError(); + } - deleteCollection: DatabaseAdapter["deleteCollection"] = async ( - collectionId, - options, - ): Promise => { - if (!this.#db) { - throw new DatabaseAdapterErrors.DatabaseNotInitializedError(); - } + if (Object.hasOwn(db, collectionId)) { + throw new DatabaseAdapterErrors.CollectionAlreadyExistsError(collectionId); + } - if (!Object.hasOwn(this.#db, collectionId)) { - throw new DatabaseAdapterErrors.CollectionDoesNotExistError(collectionId); - } + if (!db[collectionId]) { + db[collectionId] = {}; + } + await saveToFile(options); + }, - // oxlint-disable-next-line no-dynamic-delete - delete this.#db[collectionId]; - await this.#saveToFile(options); - }; + async listCollections() { + if (!db) { + throw new DatabaseAdapterErrors.DatabaseNotInitializedError(); + } - hasCollection: DatabaseAdapter["hasCollection"] = async (collectionId, _options) => { - if (!this.#db) { - throw new DatabaseAdapterErrors.DatabaseNotInitializedError(); - } + return Object.keys(db); + }, - return Object.hasOwn(this.#db, collectionId); - }; + async deleteCollection(collectionId, options) { + if (!db) { + throw new DatabaseAdapterErrors.DatabaseNotInitializedError(); + } - listDocuments: DatabaseAdapter["listDocuments"] = async < - Document extends StoryBookerDatabaseDocument, - >( - collectionId: string, - listOptions: DatabaseDocumentListOptions, - _options: DatabaseAdapterOptions, - ): Promise => { - if (!this.#db) { - throw new DatabaseAdapterErrors.DatabaseNotInitializedError(); - } + if (!Object.hasOwn(db, collectionId)) { + throw new DatabaseAdapterErrors.CollectionDoesNotExistError(collectionId); + } - if (!Object.hasOwn(this.#db, collectionId)) { - throw new DatabaseAdapterErrors.CollectionDoesNotExistError(collectionId); - } + // oxlint-disable-next-line no-dynamic-delete + delete db[collectionId]; + await saveToFile(options); + }, - const { limit = Number.POSITIVE_INFINITY, sort, filter } = listOptions || {}; + async hasCollection(collectionId, _options) { + if (!db) { + throw new DatabaseAdapterErrors.DatabaseNotInitializedError(); + } - // oxlint-disable-next-line no-non-null-assertion - const collection = this.#db[collectionId]!; - const items = Object.values(collection) as Document[]; - if (sort && typeof sort === "function") { - items.sort(sort); - } - if (filter && typeof filter === "function") { - return items.filter((item) => filter(item)).slice(0, limit); - } + return Object.hasOwn(db, collectionId); + }, - return items.slice(0, limit); - }; + // Documents - getDocument: DatabaseAdapter["getDocument"] = async < - Document extends StoryBookerDatabaseDocument, - >( - collectionId: string, - documentId: string, - _options: DatabaseAdapterOptions, - ): Promise => { - if (!this.#db) { - throw new DatabaseAdapterErrors.DatabaseNotInitializedError(); - } + async listDocuments(collectionId, listOptions, _options) { + if (!db) { + throw new DatabaseAdapterErrors.DatabaseNotInitializedError(); + } - if (!Object.hasOwn(this.#db, collectionId)) { - throw new DatabaseAdapterErrors.CollectionDoesNotExistError(collectionId); - } + if (!Object.hasOwn(db, collectionId)) { + throw new DatabaseAdapterErrors.CollectionDoesNotExistError(collectionId); + } - const item = this.#db[collectionId]?.[documentId]; - if (!item) { - throw new DatabaseAdapterErrors.DocumentDoesNotExistError(collectionId, documentId); - } + const { limit = Number.POSITIVE_INFINITY, sort, filter } = listOptions || {}; + + // oxlint-disable-next-line no-non-null-assertion + const collection = db[collectionId]!; + const items = Object.values(collection); + if (sort) { + if (typeof sort === "function") { + items.sort(sort); + } else if (sort === "latest") { + items.sort((itemA, itemB) => { + return new Date(itemB.updatedAt).getTime() - new Date(itemA.updatedAt).getTime(); + }); + } + } - return item as Document; - }; + if (filter && typeof filter === "function") { + return items.filter((item) => filter(item)).slice(0, limit); + } - hasDocument: DatabaseAdapter["hasDocument"] = async (collectionId, documentId, options) => { - return !!(await this.getDocument(collectionId, documentId, options)); - }; + return items.slice(0, limit); + }, - createDocument: DatabaseAdapter["createDocument"] = async ( - collectionId, - documentData, - options, - ): Promise => { - if (!this.#db) { - throw new DatabaseAdapterErrors.DatabaseNotInitializedError(); - } + async getDocument(collectionId, documentId, _options) { + if (!db) { + throw new DatabaseAdapterErrors.DatabaseNotInitializedError(); + } - if (!Object.hasOwn(this.#db, collectionId)) { - throw new DatabaseAdapterErrors.CollectionDoesNotExistError(collectionId); - } + if (!Object.hasOwn(db, collectionId)) { + throw new DatabaseAdapterErrors.CollectionDoesNotExistError(collectionId); + } - // oxlint-disable-next-line no-non-null-assertion - const collection = this.#db[collectionId]!; - if (collection[documentData.id]) { - throw new DatabaseAdapterErrors.DocumentAlreadyExistsError(collectionId, documentData.id); - } + const item = db[collectionId]?.[documentId]; + if (!item) { + throw new DatabaseAdapterErrors.DocumentDoesNotExistError(collectionId, documentId); + } - collection[documentData.id] = documentData; - await this.#saveToFile(options); - }; + return item; + }, - deleteDocument: DatabaseAdapter["deleteDocument"] = async ( - collectionId, - documentId, - options, - ): Promise => { - if (!this.#db) { - throw new DatabaseAdapterErrors.DatabaseNotInitializedError(); - } + async hasDocument(collectionId, documentId, options) { + return !!(await this.getDocument(collectionId, documentId, options)); + }, - if (!Object.hasOwn(this.#db, collectionId)) { - throw new DatabaseAdapterErrors.CollectionDoesNotExistError(collectionId); - } + async createDocument(collectionId, documentData, options) { + if (!db) { + throw new DatabaseAdapterErrors.DatabaseNotInitializedError(); + } - if (!(await this.hasDocument(collectionId, documentId, options))) { - throw new DatabaseAdapterErrors.DocumentDoesNotExistError(collectionId, documentId); - } + if (!Object.hasOwn(db, collectionId)) { + throw new DatabaseAdapterErrors.CollectionDoesNotExistError(collectionId); + } - // oxlint-disable-next-line no-non-null-assertion - const collection = this.#db[collectionId]!; - // oxlint-disable-next-line no-dynamic-delete - delete collection[documentId]; - await this.#saveToFile(options); - }; + // oxlint-disable-next-line no-non-null-assertion + const collection = db[collectionId]!; + if (collection[documentData.id]) { + throw new DatabaseAdapterErrors.DocumentAlreadyExistsError(collectionId, documentData.id); + } - updateDocument: DatabaseAdapter["updateDocument"] = async ( - collectionId, - documentId, - documentData, - options, - ): Promise => { - if (!this.#db) { - throw new DatabaseAdapterErrors.DatabaseNotInitializedError(); - } + collection[documentData.id] = documentData; + await saveToFile(options); + }, - if (!Object.hasOwn(this.#db, collectionId)) { - throw new DatabaseAdapterErrors.CollectionDoesNotExistError(collectionId); - } + async deleteDocument(collectionId, documentId, options) { + if (!db) { + throw new DatabaseAdapterErrors.DatabaseNotInitializedError(); + } - const prevItem = await this.getDocument(collectionId, documentId, options); - if (!prevItem) { - throw new DatabaseAdapterErrors.DocumentDoesNotExistError(collectionId, documentId); - } + if (!Object.hasOwn(db, collectionId)) { + throw new DatabaseAdapterErrors.CollectionDoesNotExistError(collectionId); + } - // oxlint-disable-next-line no-non-null-assertion - const collection = this.#db[collectionId]!; - collection[documentId] = { ...prevItem, ...documentData, id: documentId }; - await this.#saveToFile(options); - }; + if (!(await this.hasDocument(collectionId, documentId, options))) { + throw new DatabaseAdapterErrors.DocumentDoesNotExistError(collectionId, documentId); + } - async #readFromFile(options: { abortSignal?: AbortSignal }): Promise { - try { - const db = await fsp.readFile(this.#filename, { - encoding: "utf8", - signal: options.abortSignal, - }); - this.#db = db ? JSON.parse(db) : {}; - } catch { - this.#db = {}; - } - } - async #saveToFile(options: { abortSignal?: AbortSignal }): Promise { - if (!this.#db) { - throw new DatabaseAdapterErrors.DatabaseNotInitializedError(); - } + // oxlint-disable-next-line no-non-null-assertion + const collection = db[collectionId]!; + // oxlint-disable-next-line no-dynamic-delete + delete collection[documentId]; + await saveToFile(options); + }, - await fsp.writeFile(this.#filename, JSON.stringify(this.#db, null, 2), { - encoding: "utf8", - signal: options.abortSignal, - }); - } + async updateDocument(collectionId, documentId, documentData, options) { + if (!db) { + throw new DatabaseAdapterErrors.DatabaseNotInitializedError(); + } + + if (!Object.hasOwn(db, collectionId)) { + throw new DatabaseAdapterErrors.CollectionDoesNotExistError(collectionId); + } + + const prevItem = await this.getDocument(collectionId, documentId, options); + if (!prevItem) { + throw new DatabaseAdapterErrors.DocumentDoesNotExistError(collectionId, documentId); + } + + // oxlint-disable-next-line no-non-null-assertion + const collection = db[collectionId]!; + collection[documentId] = { ...prevItem, ...documentData, id: documentId }; + await saveToFile(options); + }, + }; } /** @@ -267,114 +253,121 @@ export class LocalFileDatabase implements DatabaseAdapter { * * Usage: * ```ts - * const storage = new LocalFileStorage("./store/"); + * const storage = createLocalFileStorageAdapter("./store/"); * ``` */ -export class LocalFileStorage implements StorageAdapter { - #basePath: string; +export function createLocalFileStorageAdapter(pathPrefix = "."): StorageAdapter { + const basePath = path.resolve(pathPrefix); - constructor(pathPrefix = ".") { - this.#basePath = path.resolve(pathPrefix); - } - - #genPath(...pathParts: (string | undefined)[]): string { - return path.join(this.#basePath, ...pathParts.filter((part) => part !== undefined)); + function genPath(...pathParts: (string | undefined)[]): string { + return path.join(basePath, ...pathParts.filter((part) => part !== undefined)); } // Containers - createContainer: StorageAdapter["createContainer"] = async (containerId, options) => { - if (await this.hasContainer(containerId, options)) { - throw new StorageAdapterErrors.ContainerAlreadyExistsError(containerId); - } + return { + metadata: { name: "LocalFileStorageAdapter" }, - await fsp.mkdir(this.#genPath(containerId), { recursive: true }); - }; + init: async (_options) => { + try { + await fsp.mkdir(basePath, { recursive: true }); + } catch (error) { + throw new StorageAdapterErrors.StorageNotInitializedError({ cause: error }); + } + }, - deleteContainer: StorageAdapter["deleteContainer"] = async (containerId, options) => { - if (!(await this.hasContainer(containerId, options))) { - throw new StorageAdapterErrors.ContainerDoesNotExistError(containerId); - } + async createContainer(containerId, options) { + if (await this.hasContainer(containerId, options)) { + throw new StorageAdapterErrors.ContainerAlreadyExistsError(containerId); + } - await fsp.rm(this.#genPath(containerId), { force: true, recursive: true }); - }; + await fsp.mkdir(genPath(containerId), { recursive: true }); + }, - hasContainer: StorageAdapter["hasContainer"] = async (containerId) => { - return fs.existsSync(this.#genPath(containerId)); - }; + async deleteContainer(containerId, options) { + if (!(await this.hasContainer(containerId, options))) { + throw new StorageAdapterErrors.ContainerDoesNotExistError(containerId); + } - listContainers: StorageAdapter["listContainers"] = async () => { - const dirPath = this.#genPath(); - if (!fs.existsSync(dirPath)) { - throw new StorageAdapterErrors.StorageNotInitializedError(`Dir "${dirPath}" does not exist`); - } + await fsp.rm(genPath(containerId), { force: true, recursive: true }); + }, - const containers: string[] = []; - const entries = await fsp.readdir(dirPath, { - withFileTypes: true, - }); - for (const entry of entries) { - if (entry.isDirectory()) { - containers.push(entry.name); - } - } - return containers; - }; + async hasContainer(containerId) { + return fs.existsSync(genPath(containerId)); + }, - // Files + async listContainers() { + const dirPath = genPath(); + if (!fs.existsSync(dirPath)) { + throw new StorageAdapterErrors.StorageNotInitializedError( + `Dir "${dirPath}" does not exist`, + ); + } - deleteFiles: StorageAdapter["deleteFiles"] = async ( - containerId, - filePathsOrPrefix, - ): Promise => { - if (typeof filePathsOrPrefix === "string") { - await fsp.rm(this.#genPath(containerId, filePathsOrPrefix), { - force: true, - recursive: true, + const containers: string[] = []; + const entries = await fsp.readdir(dirPath, { + withFileTypes: true, }); - } else { - for (const filepath of filePathsOrPrefix) { - // oxlint-disable-next-line no-await-in-loop - await fsp.rm(filepath, { force: true, recursive: true }); + for (const entry of entries) { + if (entry.isDirectory()) { + containers.push(entry.name); + } } - } - }; + return containers; + }, - hasFile: StorageAdapter["hasFile"] = async (containerId, filepath) => { - const path = this.#genPath(containerId, filepath); - return fs.existsSync(path); - }; + // Files - downloadFile: StorageAdapter["downloadFile"] = async (containerId, filepath, options) => { - if (!(await this.hasFile(containerId, filepath, options))) { - throw new StorageAdapterErrors.FileDoesNotExistError(containerId, filepath); - } - - const path = this.#genPath(containerId, filepath); - const buffer = await fsp.readFile(path); - const content = new Blob([buffer as Buffer]); - return { content, path }; - }; + async deleteFiles(containerId, filePathsOrPrefix) { + if (typeof filePathsOrPrefix === "string") { + await fsp.rm(genPath(containerId, filePathsOrPrefix), { + force: true, + recursive: true, + }); + } else { + for (const filepath of filePathsOrPrefix) { + // oxlint-disable-next-line no-await-in-loop + await fsp.rm(filepath, { force: true, recursive: true }); + } + } + }, - uploadFiles: StorageAdapter["uploadFiles"] = async (containerId, files, options) => { - for (const file of files) { - const filepath = this.#genPath(containerId, file.path); - const dirpath = path.dirname(filepath); + async hasFile(containerId, filepath) { + const path = genPath(containerId, filepath); + return fs.existsSync(path); + }, - await fsp.mkdir(dirpath, { recursive: true }); - if (file.content instanceof ReadableStream) { - await writeWebStreamToFile(file.content, filepath); - } else { - const data: string | Stream = - // oxlint-disable-next-line no-nested-ternary - typeof file.content === "string" ? file.content : await file.content.text(); + async downloadFile(containerId, filepath, options) { + if (!(await this.hasFile(containerId, filepath, options))) { + throw new StorageAdapterErrors.FileDoesNotExistError(containerId, filepath); + } - await fsp.writeFile(filepath, data, { - encoding: "utf8", - signal: options.abortSignal, - }); + const path = genPath(containerId, filepath); + const buffer = await fsp.readFile(path); + const content = new Blob([buffer as Buffer]); + return { content, path }; + }, + + async uploadFiles(containerId, files, options) { + for (const file of files) { + const filepath = genPath(containerId, file.path); + const dirpath = path.dirname(filepath); + + await fsp.mkdir(dirpath, { recursive: true }); + if (file.content instanceof ReadableStream) { + await writeWebStreamToFile(file.content, filepath); + } else { + const data: string | Stream = + // oxlint-disable-next-line no-nested-ternary + typeof file.content === "string" ? file.content : await file.content.text(); + + await fsp.writeFile(filepath, data, { + encoding: "utf8", + signal: options.abortSignal, + }); + } } - } + }, }; } diff --git a/packages/core/src/adapters/database.ts b/packages/core/src/adapters/database.ts index d1f3b3e..fb5e10b 100644 --- a/packages/core/src/adapters/database.ts +++ b/packages/core/src/adapters/database.ts @@ -21,7 +21,14 @@ import type { LoggerAdapter } from "./logger"; * @throws {DocumentAlreadyExistsError} if the document already exists in the collection. * @throws {DocumentDoesNotExistError} if the document does not exist in the collection. */ -export interface DatabaseAdapter { +export interface DatabaseAdapter< + DbDocument extends StoryBookerDatabaseDocument = StoryBookerDatabaseDocument, +> { + /** + * Metadata about the adapter. + */ + get metadata(): { name: string }; + /** * An optional method that is called on app boot-up * to run async setup functions. @@ -77,11 +84,11 @@ export interface DatabaseAdapter { * @returns List of documents * @throws if the collection does not exist. */ - listDocuments: ( + listDocuments: ( collectionId: string, - listOptions: DatabaseDocumentListOptions, + listOptions: DatabaseDocumentListOptions, options: DatabaseAdapterOptions, - ) => Promise; + ) => Promise; /** * Create a new document in the collection. @@ -90,9 +97,9 @@ export interface DatabaseAdapter { * @param options Common options like abortSignal. * @throws if the collection does not exist. */ - createDocument: ( + createDocument: ( collectionId: string, - documentData: Document, + documentData: DbDocument, options: DatabaseAdapterOptions, ) => Promise; @@ -104,11 +111,11 @@ export interface DatabaseAdapter { * @returns Data of the document * @throws if the collection or document does not exist. */ - getDocument: ( + getDocument: ( collectionId: string, documentId: string, options: DatabaseAdapterOptions, - ) => Promise; + ) => Promise; /** * Check matching document data available in the requested collection. @@ -132,10 +139,10 @@ export interface DatabaseAdapter { * @param options Common options like abortSignal. * @throws if the collection or document does not exist. */ - updateDocument: ( + updateDocument: ( collectionId: string, documentId: string, - documentData: Partial>, + documentData: Partial>, options: DatabaseAdapterOptions, ) => Promise; @@ -159,6 +166,9 @@ export interface DatabaseAdapter { */ export interface StoryBookerDatabaseDocument { id: string; + updatedAt: string; + createdAt: string; + [key: string]: unknown; } /** Common Database adapter options. */ diff --git a/packages/core/src/adapters/storage.ts b/packages/core/src/adapters/storage.ts index 91884d0..e04b616 100644 --- a/packages/core/src/adapters/storage.ts +++ b/packages/core/src/adapters/storage.ts @@ -20,6 +20,11 @@ import type { LoggerAdapter } from "./logger"; * @throws {FileDoesNotExistError} if the file does not exist in the container. */ export interface StorageAdapter { + /** + * Metadata about the adapter. + */ + get metadata(): { name: string }; + /** * An optional method that is called on app boot-up * to run async setup functions. diff --git a/packages/core/src/models/builds-model.ts b/packages/core/src/models/builds-model.ts index 63f00aa..d6ec0d8 100644 --- a/packages/core/src/models/builds-model.ts +++ b/packages/core/src/models/builds-model.ts @@ -35,12 +35,7 @@ export class BuildsModel extends Model { const items = await this.database.listDocuments( this.collectionId, - { - sort: (itemA, itemB) => { - return new Date(itemB.updatedAt).getTime() - new Date(itemA.updatedAt).getTime(); - }, - ...options, - }, + { sort: "latest", ...options }, this.dbOptions, ); @@ -79,7 +74,7 @@ export class BuildsModel extends Model { tagIds: tagIds.filter(Boolean).join(","), updatedAt: now, }; - await this.database.createDocument(this.collectionId, build, this.dbOptions); + await this.database.createDocument(this.collectionId, build, this.dbOptions); try { const projectsModel = new ProjectsModel(); diff --git a/packages/core/src/models/projects-model.ts b/packages/core/src/models/projects-model.ts index fa940e2..a14f09d 100644 --- a/packages/core/src/models/projects-model.ts +++ b/packages/core/src/models/projects-model.ts @@ -20,11 +20,7 @@ export class ProjectsModel extends Model { this.log("List projects..."); try { - const items = await this.database.listDocuments( - this.collectionId, - options, - this.dbOptions, - ); + const items = await this.database.listDocuments(this.collectionId, options, this.dbOptions); return items; } catch (error) { @@ -86,7 +82,7 @@ export class ProjectsModel extends Model { createdAt: now, updatedAt: now, }; - await this.database.createDocument(this.collectionId, project, this.dbOptions); + await this.database.createDocument(this.collectionId, project, this.dbOptions); return project; } catch (error) { diff --git a/packages/core/src/models/tags-model.ts b/packages/core/src/models/tags-model.ts index 2479704..0c58a8b 100644 --- a/packages/core/src/models/tags-model.ts +++ b/packages/core/src/models/tags-model.ts @@ -39,7 +39,7 @@ export class TagsModel extends Model { id: id, updatedAt: now, }; - await this.database.createDocument(this.collectionId, tag, this.dbOptions); + await this.database.createDocument(this.collectionId, tag, this.dbOptions); return tag; } catch (error) { diff --git a/packages/core/src/models/~model.ts b/packages/core/src/models/~model.ts index 77bbedf..e684919 100644 --- a/packages/core/src/models/~model.ts +++ b/packages/core/src/models/~model.ts @@ -4,13 +4,12 @@ import type { LoggerAdapter, StorageAdapter, StorageAdapterOptions, + StoryBookerDatabaseDocument, StoryBookerPermissionAction, } from "../adapters"; import { parseErrorMessage } from "../utils/error"; import { getStore } from "../utils/store"; -type Obj = Record; - export interface ListOptions> { limit?: number; filter?: string | ((item: Item) => boolean); @@ -18,10 +17,10 @@ export interface ListOptions> { sort?: "latest" | ((item1: Item, item2: Item) => number); } -export abstract class Model implements BaseModel { +export abstract class Model implements BaseModel { projectId: string; collectionId: string; - database: DatabaseAdapter; + database: DatabaseAdapter; storage: StorageAdapter; logger: LoggerAdapter; dbOptions: DatabaseAdapterOptions; @@ -31,7 +30,7 @@ export abstract class Model implements BaseModel { const { abortSignal, database, storage, logger } = getStore(); this.projectId = projectId || ""; this.collectionId = collectionId; - this.database = database; + this.database = database as unknown as DatabaseAdapter; this.storage = storage; this.logger = logger; this.dbOptions = { abortSignal, logger }; @@ -59,7 +58,7 @@ export abstract class Model implements BaseModel { abstract checkAuth(action: StoryBookerPermissionAction): Promise; } -export interface BaseModel { +export interface BaseModel { list(options?: ListOptions): Promise; create(data: unknown): Promise; get(id: string): Promise; @@ -70,7 +69,7 @@ export interface BaseModel { id: (id: string) => BaseIdModel; } -export interface BaseIdModel { +export interface BaseIdModel { id: string; get(): Promise; has(): Promise; diff --git a/scripts/server.ts b/scripts/server.ts index 932788f..6be2770 100644 --- a/scripts/server.ts +++ b/scripts/server.ts @@ -11,8 +11,8 @@ import type { StoryBookerUser, } from "../packages/core/dist/adapter.d.ts"; import { - LocalFileDatabase, - LocalFileStorage, + createLocalFileDatabaseAdapter, + createLocalFileStorageAdapter, } from "../packages/core/dist/adapter/fs.js"; import { createHonoRouter } from "../packages/core/dist/index.js"; import { createBasicUIAdapter } from "../packages/ui/dist/index.js"; @@ -25,8 +25,7 @@ class LocalAuthAdapter implements AuthAdapter { this.#user = { displayName: "Test User name", id: "user", - imageUrl: - "https://upload.wikimedia.org/wikipedia/commons/8/89/Portrait_Placeholder.png", + imageUrl: "https://upload.wikimedia.org/wikipedia/commons/8/89/Portrait_Placeholder.png", title: "testAdmin", }; }; @@ -46,10 +45,7 @@ class LocalAuthAdapter implements AuthAdapter { status: 302, }); }; - logout = async ( - _user: StoryBookerUser, - { request }: AuthAdapterOptions, - ): Promise => { + logout = async (_user: StoryBookerUser, { request }: AuthAdapterOptions): Promise => { this.#auth = false; const url = new URL(request.url); return new Response(null, { @@ -59,7 +55,7 @@ class LocalAuthAdapter implements AuthAdapter { }; renderAccountDetails = (user: StoryBookerUser): string => { return `

Place anything in this iFrame about the user

-
${JSON.stringify({ user }, null, 2)}${JSON.stringify({ user }, null, 2)}
`; }; } @@ -69,8 +65,8 @@ const app = createHonoRouter({ middlewares: [logger(), poweredBy({ serverName: "SBR" })], queueLargeZipFileProcessing: true, }, - database: new LocalFileDatabase(".server/db.json"), - storage: new LocalFileStorage(".server"), + database: createLocalFileDatabaseAdapter(".server/db.json"), + storage: createLocalFileStorageAdapter(".server"), ui: createBasicUIAdapter({ logo: "/SBR_white_128.jpg", staticDirs: [".server"], From beef0c80a3732a9cbdab4251428973f0dd12ada4 Mon Sep 17 00:00:00 2001 From: Siddhant Gupta Date: Sun, 7 Dec 2025 11:09:26 +0200 Subject: [PATCH 02/15] feat: update all adapter pkg to export metadata --- packages/aws/src/dynamo-db.ts | 2 ++ packages/aws/src/s3.ts | 2 ++ packages/azure/src/blob-storage.ts | 2 ++ packages/azure/src/cosmos-db.ts | 2 ++ packages/azure/src/data-tables.ts | 2 ++ packages/gcp/src/big-table.ts | 2 ++ packages/gcp/src/firestore.ts | 2 ++ packages/gcp/src/storage.ts | 2 ++ packages/redis/src/index.ts | 2 ++ packages/sql/src/mysql.ts | 2 ++ 10 files changed, 20 insertions(+) diff --git a/packages/aws/src/dynamo-db.ts b/packages/aws/src/dynamo-db.ts index 856c9b5..ab3cc08 100644 --- a/packages/aws/src/dynamo-db.ts +++ b/packages/aws/src/dynamo-db.ts @@ -16,6 +16,8 @@ export class AwsDynamoDatabaseService implements DatabaseAdapter { this.#client = client; } + metadata: DatabaseAdapter["metadata"] = { name: "AwsDynamoDatabaseService" }; + listCollections: DatabaseAdapter["listCollections"] = async (options) => { const response = await this.#client.send(new Dynamo.ListTablesCommand({}), { abortSignal: options.abortSignal, diff --git a/packages/aws/src/s3.ts b/packages/aws/src/s3.ts index 2a71263..a23d547 100644 --- a/packages/aws/src/s3.ts +++ b/packages/aws/src/s3.ts @@ -9,6 +9,8 @@ export class AwsS3StorageService implements StorageAdapter { this.#client = client; } + metadata: StorageAdapter["metadata"] = { name: "AwsS3StorageService" }; + createContainer: StorageAdapter["createContainer"] = async (containerId, options) => { try { await this.#client.send( diff --git a/packages/azure/src/blob-storage.ts b/packages/azure/src/blob-storage.ts index 61a5fdc..44c29ec 100644 --- a/packages/azure/src/blob-storage.ts +++ b/packages/azure/src/blob-storage.ts @@ -10,6 +10,8 @@ export class AzureBlobStorageService implements StorageAdapter { this.#client = client; } + metadata: StorageAdapter["metadata"] = { name: "AzureBlobStorageService" }; + createContainer: StorageAdapter["createContainer"] = async (containerId, options) => { try { const containerName = genContainerNameFromContainerId(containerId); diff --git a/packages/azure/src/cosmos-db.ts b/packages/azure/src/cosmos-db.ts index 2388bf8..1b82ac5 100644 --- a/packages/azure/src/cosmos-db.ts +++ b/packages/azure/src/cosmos-db.ts @@ -14,6 +14,8 @@ export class AzureCosmosDatabaseService implements DatabaseAdapter { this.#db = client.database(dbName); } + metadata: DatabaseAdapter["metadata"] = { name: "AzureCosmosDatabaseService" }; + init: DatabaseAdapter["init"] = async (options) => { await this.#db.client.databases.createIfNotExists( { id: this.#db.id }, diff --git a/packages/azure/src/data-tables.ts b/packages/azure/src/data-tables.ts index 0d136b5..87f7663 100644 --- a/packages/azure/src/data-tables.ts +++ b/packages/azure/src/data-tables.ts @@ -18,6 +18,8 @@ export class AzureDataTablesDatabaseService implements DatabaseAdapter { this.#tableClientGenerator = tableClientGenerator; } + metadata: DatabaseAdapter["metadata"] = { name: "AzureDataTablesDatabaseService" }; + listCollections: DatabaseAdapter["listCollections"] = async (options) => { const collections: string[] = []; for await (const table of this.#serviceClient.listTables({ diff --git a/packages/gcp/src/big-table.ts b/packages/gcp/src/big-table.ts index 7b04405..5fec900 100644 --- a/packages/gcp/src/big-table.ts +++ b/packages/gcp/src/big-table.ts @@ -17,6 +17,8 @@ export class GcpBigtableDatabaseAdapter implements DatabaseAdapter { this.#instance = client.instance(instanceName); } + metadata: DatabaseAdapter["metadata"] = { name: "GcpBigtableDatabaseAdapter" }; + init: DatabaseAdapter["init"] = async (_options) => { // Bigtable instances are typically created outside of app code (via console/IaC) // Optionally, check if instance exists diff --git a/packages/gcp/src/firestore.ts b/packages/gcp/src/firestore.ts index 40075e4..755bf45 100644 --- a/packages/gcp/src/firestore.ts +++ b/packages/gcp/src/firestore.ts @@ -14,6 +14,8 @@ export class GcpFirestoreDatabaseAdapter implements DatabaseAdapter { this.#instance = instance; } + metadata: DatabaseAdapter["metadata"] = { name: "GcpFirestoreDatabaseAdapter" }; + listCollections: DatabaseAdapter["listCollections"] = async (_options) => { try { const collections = await this.#instance.listCollections(); diff --git a/packages/gcp/src/storage.ts b/packages/gcp/src/storage.ts index 36a3ea1..3a16a67 100644 --- a/packages/gcp/src/storage.ts +++ b/packages/gcp/src/storage.ts @@ -11,6 +11,8 @@ export class GcpGcsStorageService implements StorageAdapter { this.#client = client; } + metadata: StorageAdapter["metadata"] = { name: "GcpGcsStorageService" }; + createContainer: StorageAdapter["createContainer"] = async (containerId, _options) => { try { const bucketName = genBucketNameFromContainerId(containerId); diff --git a/packages/redis/src/index.ts b/packages/redis/src/index.ts index 7a2a309..751de53 100644 --- a/packages/redis/src/index.ts +++ b/packages/redis/src/index.ts @@ -28,6 +28,8 @@ export class RedisDatabaseAdapter implements DatabaseAdapter { this.#keyPrefix = keyPrefix; } + metadata: DatabaseAdapter["metadata"] = { name: "RedisDatabaseAdapter" }; + init: DatabaseAdapter["init"] = async (_options) => { // Ensure Redis connection is ready if (!this.#client.isReady) { diff --git a/packages/sql/src/mysql.ts b/packages/sql/src/mysql.ts index 9f38108..d85ccc5 100644 --- a/packages/sql/src/mysql.ts +++ b/packages/sql/src/mysql.ts @@ -37,6 +37,8 @@ export class MySQLDatabaseAdapter implements DatabaseAdapter { this.#tablePrefix = tablePrefix; } + metadata: DatabaseAdapter["metadata"] = { name: "MySQLDatabaseAdapter" }; + init: DatabaseAdapter["init"] = async (_options) => { // Create collections metadata table try { From 53c8d390d79b41ee814e13e9f9ab2c697d340360 Mon Sep 17 00:00:00 2001 From: Siddhant Gupta Date: Sun, 7 Dec 2025 11:18:19 +0200 Subject: [PATCH 03/15] feat: update all adapter pkg to export metadata + auth, ui --- packages/aws/src/dynamo-db.ts | 2 +- packages/aws/src/s3.ts | 2 +- packages/azure/src/blob-storage.ts | 2 +- packages/azure/src/cosmos-db.ts | 2 +- packages/azure/src/data-tables.ts | 2 +- packages/azure/src/easy-auth.ts | 2 ++ packages/core/src/adapters/auth.ts | 5 +++++ packages/core/src/adapters/database.ts | 2 +- packages/core/src/adapters/logger.ts | 4 ++++ packages/core/src/adapters/queue.ts | 5 +++++ packages/core/src/adapters/storage.ts | 2 +- packages/core/src/adapters/ui.ts | 5 +++++ packages/core/src/mocks/mock-auth-service.ts | 2 ++ packages/gcp/src/big-table.ts | 2 +- packages/gcp/src/firestore.ts | 2 +- packages/gcp/src/storage.ts | 2 +- packages/redis/src/index.ts | 2 +- packages/sql/src/mysql.ts | 2 +- packages/ui/src/index.tsx | 2 ++ 19 files changed, 37 insertions(+), 12 deletions(-) diff --git a/packages/aws/src/dynamo-db.ts b/packages/aws/src/dynamo-db.ts index ab3cc08..fde7445 100644 --- a/packages/aws/src/dynamo-db.ts +++ b/packages/aws/src/dynamo-db.ts @@ -16,7 +16,7 @@ export class AwsDynamoDatabaseService implements DatabaseAdapter { this.#client = client; } - metadata: DatabaseAdapter["metadata"] = { name: "AwsDynamoDatabaseService" }; + metadata: DatabaseAdapter["metadata"] = { name: "AWS DynamoDB" }; listCollections: DatabaseAdapter["listCollections"] = async (options) => { const response = await this.#client.send(new Dynamo.ListTablesCommand({}), { diff --git a/packages/aws/src/s3.ts b/packages/aws/src/s3.ts index a23d547..8eda386 100644 --- a/packages/aws/src/s3.ts +++ b/packages/aws/src/s3.ts @@ -9,7 +9,7 @@ export class AwsS3StorageService implements StorageAdapter { this.#client = client; } - metadata: StorageAdapter["metadata"] = { name: "AwsS3StorageService" }; + metadata: StorageAdapter["metadata"] = { name: "AWS S3" }; createContainer: StorageAdapter["createContainer"] = async (containerId, options) => { try { diff --git a/packages/azure/src/blob-storage.ts b/packages/azure/src/blob-storage.ts index 44c29ec..647e8e4 100644 --- a/packages/azure/src/blob-storage.ts +++ b/packages/azure/src/blob-storage.ts @@ -10,7 +10,7 @@ export class AzureBlobStorageService implements StorageAdapter { this.#client = client; } - metadata: StorageAdapter["metadata"] = { name: "AzureBlobStorageService" }; + metadata: StorageAdapter["metadata"] = { name: "Azure Blob Storage" }; createContainer: StorageAdapter["createContainer"] = async (containerId, options) => { try { diff --git a/packages/azure/src/cosmos-db.ts b/packages/azure/src/cosmos-db.ts index 1b82ac5..2e3cd32 100644 --- a/packages/azure/src/cosmos-db.ts +++ b/packages/azure/src/cosmos-db.ts @@ -14,7 +14,7 @@ export class AzureCosmosDatabaseService implements DatabaseAdapter { this.#db = client.database(dbName); } - metadata: DatabaseAdapter["metadata"] = { name: "AzureCosmosDatabaseService" }; + metadata: DatabaseAdapter["metadata"] = { name: "Azure Cosmos DB" }; init: DatabaseAdapter["init"] = async (options) => { await this.#db.client.databases.createIfNotExists( diff --git a/packages/azure/src/data-tables.ts b/packages/azure/src/data-tables.ts index 87f7663..87f3373 100644 --- a/packages/azure/src/data-tables.ts +++ b/packages/azure/src/data-tables.ts @@ -18,7 +18,7 @@ export class AzureDataTablesDatabaseService implements DatabaseAdapter { this.#tableClientGenerator = tableClientGenerator; } - metadata: DatabaseAdapter["metadata"] = { name: "AzureDataTablesDatabaseService" }; + metadata: DatabaseAdapter["metadata"] = { name: "Azure Tables" }; listCollections: DatabaseAdapter["listCollections"] = async (options) => { const collections: string[] = []; diff --git a/packages/azure/src/easy-auth.ts b/packages/azure/src/easy-auth.ts index cbb668d..1b649bd 100644 --- a/packages/azure/src/easy-auth.ts +++ b/packages/azure/src/easy-auth.ts @@ -64,6 +64,8 @@ export class AzureEasyAuthService implements AuthAdapter { authorise: AuthAdapter["authorise"]; modifyUserDetails: ModifyUserDetails; + metadata: AuthAdapter["metadata"] = { name: "Azure Easy Auth" }; + constructor(options?: { /** * Custom function to authorise permission for user diff --git a/packages/core/src/adapters/auth.ts b/packages/core/src/adapters/auth.ts index 9dbe995..99cf36d 100644 --- a/packages/core/src/adapters/auth.ts +++ b/packages/core/src/adapters/auth.ts @@ -7,6 +7,11 @@ import type { LoggerAdapter } from "./logger"; * accessing the app. */ export interface AuthAdapter { + /** + * Metadata about the adapter. + */ + metadata: { name: string }; + /** * An optional method that is called on app boot-up * to run async setup functions. diff --git a/packages/core/src/adapters/database.ts b/packages/core/src/adapters/database.ts index fb5e10b..c9b271e 100644 --- a/packages/core/src/adapters/database.ts +++ b/packages/core/src/adapters/database.ts @@ -27,7 +27,7 @@ export interface DatabaseAdapter< /** * Metadata about the adapter. */ - get metadata(): { name: string }; + metadata: { name: string }; /** * An optional method that is called on app boot-up diff --git a/packages/core/src/adapters/logger.ts b/packages/core/src/adapters/logger.ts index e239a41..849ee24 100644 --- a/packages/core/src/adapters/logger.ts +++ b/packages/core/src/adapters/logger.ts @@ -7,6 +7,10 @@ * @default NodeJS.console */ export interface LoggerAdapter { + /** + * Metadata about the adapter. + */ + metadata?: { name: string }; /** * Optional debug logs */ diff --git a/packages/core/src/adapters/queue.ts b/packages/core/src/adapters/queue.ts index f3e83be..ea97513 100644 --- a/packages/core/src/adapters/queue.ts +++ b/packages/core/src/adapters/queue.ts @@ -12,6 +12,11 @@ import type { LoggerAdapter } from "./logger"; * Each message has a unique identifier. */ export interface QueueAdapter { + /** + * Metadata about the adapter. + */ + metadata: { name: string }; + /** * An optional method that is called on app boot-up * to run async setup functions. diff --git a/packages/core/src/adapters/storage.ts b/packages/core/src/adapters/storage.ts index e04b616..505e8d4 100644 --- a/packages/core/src/adapters/storage.ts +++ b/packages/core/src/adapters/storage.ts @@ -23,7 +23,7 @@ export interface StorageAdapter { /** * Metadata about the adapter. */ - get metadata(): { name: string }; + metadata: { name: string }; /** * An optional method that is called on app boot-up diff --git a/packages/core/src/adapters/ui.ts b/packages/core/src/adapters/ui.ts index f237989..c5c1e83 100644 --- a/packages/core/src/adapters/ui.ts +++ b/packages/core/src/adapters/ui.ts @@ -16,6 +16,11 @@ type RenderedContent = string | Promise; * The render methods are called asynchronously and can return promise of HTML. */ export interface UIAdapter { + /** + * Metadata about the adapter. + */ + metadata: { name: string }; + /** * A special handler that is invoked when no existing StoryBooker route is matched. * diff --git a/packages/core/src/mocks/mock-auth-service.ts b/packages/core/src/mocks/mock-auth-service.ts index 0a9128b..4dbd418 100644 --- a/packages/core/src/mocks/mock-auth-service.ts +++ b/packages/core/src/mocks/mock-auth-service.ts @@ -11,6 +11,8 @@ export const mockUser: StoryBookerUser = { }; export const mockAuthService: AuthAdapter = { + metadata: { name: "MockAuthService" }, + init: async (_options) => { // Mock init logic if needed }, diff --git a/packages/gcp/src/big-table.ts b/packages/gcp/src/big-table.ts index 5fec900..cce40fb 100644 --- a/packages/gcp/src/big-table.ts +++ b/packages/gcp/src/big-table.ts @@ -17,7 +17,7 @@ export class GcpBigtableDatabaseAdapter implements DatabaseAdapter { this.#instance = client.instance(instanceName); } - metadata: DatabaseAdapter["metadata"] = { name: "GcpBigtableDatabaseAdapter" }; + metadata: DatabaseAdapter["metadata"] = { name: "Google Cloud Bigtable" }; init: DatabaseAdapter["init"] = async (_options) => { // Bigtable instances are typically created outside of app code (via console/IaC) diff --git a/packages/gcp/src/firestore.ts b/packages/gcp/src/firestore.ts index 755bf45..850d2d1 100644 --- a/packages/gcp/src/firestore.ts +++ b/packages/gcp/src/firestore.ts @@ -14,7 +14,7 @@ export class GcpFirestoreDatabaseAdapter implements DatabaseAdapter { this.#instance = instance; } - metadata: DatabaseAdapter["metadata"] = { name: "GcpFirestoreDatabaseAdapter" }; + metadata: DatabaseAdapter["metadata"] = { name: "Google Cloud Firestore" }; listCollections: DatabaseAdapter["listCollections"] = async (_options) => { try { diff --git a/packages/gcp/src/storage.ts b/packages/gcp/src/storage.ts index 3a16a67..6128a2d 100644 --- a/packages/gcp/src/storage.ts +++ b/packages/gcp/src/storage.ts @@ -11,7 +11,7 @@ export class GcpGcsStorageService implements StorageAdapter { this.#client = client; } - metadata: StorageAdapter["metadata"] = { name: "GcpGcsStorageService" }; + metadata: StorageAdapter["metadata"] = { name: "Google Cloud Storage" }; createContainer: StorageAdapter["createContainer"] = async (containerId, _options) => { try { diff --git a/packages/redis/src/index.ts b/packages/redis/src/index.ts index 751de53..cac3d84 100644 --- a/packages/redis/src/index.ts +++ b/packages/redis/src/index.ts @@ -28,7 +28,7 @@ export class RedisDatabaseAdapter implements DatabaseAdapter { this.#keyPrefix = keyPrefix; } - metadata: DatabaseAdapter["metadata"] = { name: "RedisDatabaseAdapter" }; + metadata: DatabaseAdapter["metadata"] = { name: "Redis" }; init: DatabaseAdapter["init"] = async (_options) => { // Ensure Redis connection is ready diff --git a/packages/sql/src/mysql.ts b/packages/sql/src/mysql.ts index d85ccc5..f9a8b42 100644 --- a/packages/sql/src/mysql.ts +++ b/packages/sql/src/mysql.ts @@ -37,7 +37,7 @@ export class MySQLDatabaseAdapter implements DatabaseAdapter { this.#tablePrefix = tablePrefix; } - metadata: DatabaseAdapter["metadata"] = { name: "MySQLDatabaseAdapter" }; + metadata: DatabaseAdapter["metadata"] = { name: "MySQL" }; init: DatabaseAdapter["init"] = async (_options) => { // Create collections metadata table diff --git a/packages/ui/src/index.tsx b/packages/ui/src/index.tsx index 96aaf78..369ad06 100644 --- a/packages/ui/src/index.tsx +++ b/packages/ui/src/index.tsx @@ -41,6 +41,8 @@ export function createBasicUIAdapter(options: BasicUIOptions = {}): UIAdapter { } = options; const adapter: UIAdapter = { + metadata: { name: "Basic UI" }, + handleUnhandledRoute: (filepath, adapterOptions) => { uiStore.enterWith(createUIStore(adapterOptions, options)); return handleStaticFileRoute(filepath, { From 9f830bab0e10189459db4439f80b2bf919546861 Mon Sep 17 00:00:00 2001 From: Siddhant Gupta Date: Sun, 7 Dec 2025 18:15:13 +0200 Subject: [PATCH 04/15] fix: auth error handling --- .gitignore | 3 +- package.json | 2 +- packages/core/deno.json | 5 + packages/core/package.json | 25 +++++ packages/core/src/adapters/_fs-adapters.ts | 4 +- packages/core/src/adapters/database.ts | 1 + packages/core/src/adapters/storage.ts | 1 + packages/core/src/adapters/ui.ts | 6 +- packages/core/src/index.ts | 9 +- packages/core/src/models/builds-model.ts | 8 +- packages/core/src/models/projects-model.ts | 8 +- packages/core/src/models/tags-model.ts | 2 +- packages/core/src/routers/account-router.ts | 8 +- packages/core/src/routers/root-router.ts | 29 +++++- packages/core/src/utils/auth.ts | 8 +- packages/core/src/utils/error.ts | 25 ++++- packages/core/src/utils/openapi-utils.ts | 4 +- packages/core/src/utils/response.ts | 19 ++-- packages/core/tsdown.config.ts | 5 + packages/ui/src/pages/error-page.tsx | 6 +- scripts/server.ts | 101 ++++++++++---------- 21 files changed, 178 insertions(+), 101 deletions(-) diff --git a/.gitignore b/.gitignore index 747e6e4..e28cabf 100644 --- a/.gitignore +++ b/.gitignore @@ -43,4 +43,5 @@ storybook-static *.zip test-report coverage -openapi.json \ No newline at end of file +openapi.json +lgtm \ No newline at end of file diff --git a/package.json b/package.json index 613ab0c..bd7befe 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "functions": "yarn workspace azure-functions-app dev", "lint": "turbo lint", "release": "node ./scripts/release.ts", - "server": "NODE_ENV=development deno serve -REW --unstable-sloppy-imports --watch ./scripts/server.ts", + "server": "NODE_ENV=development OTEL_DENO=true deno serve -REW --unstable-sloppy-imports --watch ./scripts/server.ts", "test": "turbo test", "verify": "turbo verify --filter='./packages/*'" }, diff --git a/packages/core/deno.json b/packages/core/deno.json index 2402bdd..30c1da1 100644 --- a/packages/core/deno.json +++ b/packages/core/deno.json @@ -18,7 +18,12 @@ "exports": { ".": "./src/index.ts", "./adapter": "./src/adapters/index.ts", + "./adapter/auth": "./src/adapters/auth.ts", + "./adapter/database": "./src/adapters/database.ts", "./adapter/fs": "./src/adapters/_fs-adapters.ts", + "./adapter/logger": "./src/adapters/logger.ts", + "./adapter/storage": "./src/adapters/storage.ts", + "./adapter/ui": "./src/adapters/ui.ts", "./constants": "./src/utils/constants.ts", "./mimes": "./src/utils/mime-utils.ts", "./router": "./src/routers/_app-router.ts", diff --git a/packages/core/package.json b/packages/core/package.json index 694804d..b5d8d9a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -66,10 +66,30 @@ "source": "./src/adapters/index.ts", "default": "./dist/adapter.js" }, + "./adapter/auth": { + "source": "./src/adapters/auth.ts", + "default": "./dist/adapter/auth.js" + }, + "./adapter/database": { + "source": "./src/adapters/database.ts", + "default": "./dist/adapter/database.js" + }, "./adapter/fs": { "source": "./src/adapters/_fs-adapters.ts", "default": "./dist/adapter/fs.js" }, + "./adapter/logger": { + "source": "./src/adapters/logger.ts", + "default": "./dist/adapter/logger.js" + }, + "./adapter/storage": { + "source": "./src/adapters/storage.ts", + "default": "./dist/adapter/storage.js" + }, + "./adapter/ui": { + "source": "./src/adapters/ui.ts", + "default": "./dist/adapter/ui.js" + }, "./constants": { "source": "./src/utils/constants.ts", "default": "./dist/constants.js" @@ -101,7 +121,12 @@ "exports": { ".": "./dist/index.js", "./adapter": "./dist/adapter.js", + "./adapter/auth": "./dist/adapter/auth.js", + "./adapter/database": "./dist/adapter/database.js", "./adapter/fs": "./dist/adapter/fs.js", + "./adapter/logger": "./dist/adapter/logger.js", + "./adapter/storage": "./dist/adapter/storage.js", + "./adapter/ui": "./dist/adapter/ui.js", "./constants": "./dist/constants.js", "./mimes": "./dist/mimes.js", "./router": "./dist/router.js", diff --git a/packages/core/src/adapters/_fs-adapters.ts b/packages/core/src/adapters/_fs-adapters.ts index bf1810d..0764e75 100644 --- a/packages/core/src/adapters/_fs-adapters.ts +++ b/packages/core/src/adapters/_fs-adapters.ts @@ -59,7 +59,7 @@ export function createLocalFileDatabaseAdapter(filename = "db.json"): DatabaseAd }; return { - metadata: { name: "LocalFileDatabaseAdapter" }, + metadata: { name: "Local File" }, async init(options) { if (fs.existsSync(filepath)) { @@ -266,7 +266,7 @@ export function createLocalFileStorageAdapter(pathPrefix = "."): StorageAdapter // Containers return { - metadata: { name: "LocalFileStorageAdapter" }, + metadata: { name: "Local File System" }, init: async (_options) => { try { diff --git a/packages/core/src/adapters/database.ts b/packages/core/src/adapters/database.ts index c9b271e..69890d1 100644 --- a/packages/core/src/adapters/database.ts +++ b/packages/core/src/adapters/database.ts @@ -20,6 +20,7 @@ import type { LoggerAdapter } from "./logger"; * @throws {CollectionDoesNotExistError} if the collection does not exist. * @throws {DocumentAlreadyExistsError} if the document already exists in the collection. * @throws {DocumentDoesNotExistError} if the document does not exist in the collection. + * @throws {CustomError} if some other error occurs. */ export interface DatabaseAdapter< DbDocument extends StoryBookerDatabaseDocument = StoryBookerDatabaseDocument, diff --git a/packages/core/src/adapters/storage.ts b/packages/core/src/adapters/storage.ts index 505e8d4..0e28931 100644 --- a/packages/core/src/adapters/storage.ts +++ b/packages/core/src/adapters/storage.ts @@ -18,6 +18,7 @@ import type { LoggerAdapter } from "./logger"; * @throws {ContainerAlreadyExistsError} if the container already exists. * @throws {ContainerDoesNotExistError} if the container does not exist. * @throws {FileDoesNotExistError} if the file does not exist in the container. + * @throws {CustomError} if some other error occurs. */ export interface StorageAdapter { /** diff --git a/packages/core/src/adapters/ui.ts b/packages/core/src/adapters/ui.ts index c5c1e83..7febf4f 100644 --- a/packages/core/src/adapters/ui.ts +++ b/packages/core/src/adapters/ui.ts @@ -2,6 +2,7 @@ import type { BuildStoryType, BuildType, BuildUploadVariant, + ParsedError, ProjectType, StoryBookerUser, TagType, @@ -32,10 +33,7 @@ export interface UIAdapter { ) => Response | Promise; renderHomePage(props: { projects: ProjectType[] }, options: UIAdapterOptions): RenderedContent; - renderErrorPage( - props: { title: string; message: string; status: number }, - options: UIAdapterOptions, - ): RenderedContent; + renderErrorPage(props: ParsedError, options: UIAdapterOptions): RenderedContent; renderAccountsPage( props: { children: string | undefined }, options: UIAdapterOptions, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 41fcb61..7703754 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,6 +1,5 @@ -import { OpenAPIHono } from "@hono/zod-openapi"; import { SuperHeaders } from "@remix-run/headers"; -import type { Hono } from "hono"; +import { Hono } from "hono"; import { logger as loggerMiddleware } from "hono/logger"; import { timing, type TimingVariables } from "hono/timing"; import type { StoryBookerUser } from "./adapters"; @@ -35,7 +34,7 @@ export function createHonoRouter( options.storage.init?.({ logger }).catch(logger.error), ]); - return new OpenAPIHono<{ Variables: TimingVariables }>({ strict: false }) + return new Hono<{ Variables: TimingVariables }>({ strict: false }) .use( prettifyZodValidationErrorMiddleware, timing(), @@ -43,7 +42,7 @@ export function createHonoRouter( ...middlewares, ) .route("/", appRouter) - .onError(onUnhandledErrorHandler(logger)); + .onError(onUnhandledErrorHandler()); } /** @@ -74,7 +73,7 @@ export function createPurgeHandler(options: PurgeHandlerOptions): HandlePurge { await handlePurge(...params); return; } catch (error) { - logger.error(parseErrorMessage(error, options.errorParser).errorMessage); + logger.error("PurgeError", parseErrorMessage(error, options.errorParser).errorMessage); } }; } diff --git a/packages/core/src/models/builds-model.ts b/packages/core/src/models/builds-model.ts index d6ec0d8..39972b0 100644 --- a/packages/core/src/models/builds-model.ts +++ b/packages/core/src/models/builds-model.ts @@ -83,7 +83,7 @@ export class BuildsModel extends Model { await projectsModel.update(this.projectId, { latestBuildId: id }); } } catch (error) { - this.error(error); + this.error("Error updating project with latest build ID:", error); } return build; @@ -197,7 +197,7 @@ export class BuildsModel extends Model { // Automatically process zip if feature is enabled and size is below limit if (size !== undefined && size <= maxInlineUploadProcessingSizeInBytes) { await handleProcessZip(this.projectId, buildId, variant).catch((error: unknown) => { - this.error(error); + this.error("Error processing zip file:", error); }); return; } @@ -208,7 +208,7 @@ export class BuildsModel extends Model { const url = urlBuilder.taskProcessZip(this.projectId, buildId, variant); // Do not await fetch to avoid blocking fetch(url, { headers: request.headers, method: "POST" }).catch((error: unknown) => { - this.error(error); + this.error("Error queuing zip file processing:", error); }); } @@ -271,7 +271,7 @@ export class BuildsModel extends Model { return Object.values(data.entries) as BuildStoryType[]; } catch (error) { - this.error(error); + this.error("Error getting stories:", error); return null; } } diff --git a/packages/core/src/models/projects-model.ts b/packages/core/src/models/projects-model.ts index a14f09d..03ad065 100644 --- a/packages/core/src/models/projects-model.ts +++ b/packages/core/src/models/projects-model.ts @@ -24,7 +24,7 @@ export class ProjectsModel extends Model { return items; } catch (error) { - this.error(error); + this.error("Error listing projects:", error); return []; } } @@ -51,7 +51,7 @@ export class ProjectsModel extends Model { .createCollection(this.collectionId, this.dbOptions) .catch((error: unknown) => { // ignore error if collection already exists since there can only be one projects collection - this.error(error); + this.error("Error creating projects collection:", error); }); await this.database.createCollection( @@ -72,7 +72,7 @@ export class ProjectsModel extends Model { }) .catch((error: unknown) => { // log error but continue since project creation should not fail because of tag creation - this.error(error); + this.error("Error creating default branch tag:", error); }); this.debug("Creating project entry '%s' in collection", projectId); @@ -129,7 +129,7 @@ export class ProjectsModel extends Model { value: data.gitHubDefaultBranch, }); } catch (error) { - this.error(error); + this.error("Error creating default branch tag:", error); } } diff --git a/packages/core/src/models/tags-model.ts b/packages/core/src/models/tags-model.ts index 0c58a8b..ec9b7b7 100644 --- a/packages/core/src/models/tags-model.ts +++ b/packages/core/src/models/tags-model.ts @@ -96,7 +96,7 @@ export class TagsModel extends Model { this.debug("Delete builds associated with tag '%s'...", id); await new BuildsModel(this.projectId).deleteByTag(id, false); } catch (error) { - this.error(error); + this.error("Error deleting builds associated with tag:", error); } return; diff --git a/packages/core/src/routers/account-router.ts b/packages/core/src/routers/account-router.ts index 5267f1d..66cbd0f 100644 --- a/packages/core/src/routers/account-router.ts +++ b/packages/core/src/routers/account-router.ts @@ -120,8 +120,12 @@ export const accountRouter = new OpenAPIHono() return response; } - return responseRedirect(urlBuilder.homepage(), { - headers: response.headers, + const responseHeaders = new Headers(response.headers); + const responseLocation = responseHeaders.get("location"); + responseHeaders.delete("location"); + + return responseRedirect(responseLocation || urlBuilder.homepage(), { + headers: responseHeaders, status: 302, }); }, diff --git a/packages/core/src/routers/root-router.ts b/packages/core/src/routers/root-router.ts index 7609dd8..9709f53 100644 --- a/packages/core/src/routers/root-router.ts +++ b/packages/core/src/routers/root-router.ts @@ -2,6 +2,8 @@ import { createRoute, OpenAPIHono } from "@hono/zod-openapi"; import z from "zod"; import { handleServeStoryBook } from "../handlers/handle-serve-storybook"; import { ProjectsModel } from "../models/projects-model"; +import { SERVICE_NAME } from "../utils"; +import { authenticateOrThrow } from "../utils/auth"; import { mimes } from "../utils/mime-utils"; import { openapiResponsesHtml } from "../utils/openapi-utils"; import { checkIsJSONRequest } from "../utils/request"; @@ -20,24 +22,45 @@ export const rootRouter = new OpenAPIHono() responses: { 200: { content: { + [mimes.json]: { + schema: z.object({ + name: z.string(), + metadata: z.object({ + database: z.string(), + storage: z.string(), + auth: z.string().optional(), + logger: z.string().optional(), + ui: z.string().optional(), + }), + }), + }, ...openapiResponsesHtml, - [mimes.json]: { schema: z.object({}) }, }, description: "Render homepage or return a list of endpoint-urls.", }, }, }), async (context) => { - const { ui } = getStore(); + const { auth, database, logger, storage, ui } = getStore(); if (checkIsJSONRequest()) { - return context.json({}); + return context.json({ + name: SERVICE_NAME, + metadata: { + auth: auth?.metadata.name, + database: database.metadata.name, + logger: logger.metadata?.name, + storage: storage.metadata.name, + ui: ui?.metadata.name, + }, + }); } if (!ui) { return context.notFound(); } + await authenticateOrThrow({ action: "read", resource: "project", projectId: undefined }); const projects = await new ProjectsModel().list({ limit: 5 }); return context.html(ui.renderHomePage({ projects }, createUIAdapterOptions())); diff --git a/packages/core/src/utils/auth.ts b/packages/core/src/utils/auth.ts index d704e0b..498dd7c 100644 --- a/packages/core/src/utils/auth.ts +++ b/packages/core/src/utils/auth.ts @@ -1,6 +1,6 @@ +import { HTTPException } from "hono/http-exception"; import type { StoryBookerPermission, StoryBookerPermissionKey } from "../adapters/auth"; import { getStore } from "../utils/store"; -import { responseError } from "./response"; export async function authenticateOrThrow(permission: StoryBookerPermission): Promise { const { abortSignal, auth, logger, request, user } = getStore(); @@ -10,7 +10,7 @@ export async function authenticateOrThrow(permission: StoryBookerPermission): Pr } if (!user) { - throw await responseError("Unauthenticated access", 401); + throw new HTTPException(401, { message: "Unauthenticated. Please log in to continue." }); } const key: StoryBookerPermissionKey = `${permission.resource}:${permission.action}:${permission.projectId || ""}`; @@ -28,12 +28,12 @@ export async function authenticateOrThrow(permission: StoryBookerPermission): Pr } if (response === false) { - throw await responseError(`Permission denied [${key}]`, 403); + throw new HTTPException(403, { message: `Permission denied [${key}]` }); } throw response; } catch (error) { - throw await responseError(error, 403); + throw new HTTPException(403, { cause: error }); } } diff --git a/packages/core/src/utils/error.ts b/packages/core/src/utils/error.ts index ce31585..38f406a 100644 --- a/packages/core/src/utils/error.ts +++ b/packages/core/src/utils/error.ts @@ -1,8 +1,10 @@ import type { ErrorHandler, MiddlewareHandler } from "hono"; import { HTTPException } from "hono/http-exception"; import { z } from "zod"; -import type { LoggerAdapter } from "../adapters"; -import { getStoreOrNull } from "../utils/store"; +import { getStore, getStoreOrNull } from "../utils/store"; +import { mimes } from "./mime-utils"; +import { checkIsHTMLRequest } from "./request"; +import { createUIAdapterOptions } from "./ui-utils"; /** * A function type for parsing custom errors. @@ -76,12 +78,25 @@ export const prettifyZodValidationErrorMiddleware: MiddlewareHandler = async (ct } }; -export function onUnhandledErrorHandler(logger: LoggerAdapter): ErrorHandler { +export function onUnhandledErrorHandler(): ErrorHandler { return (error) => { - const { errorMessage, errorType, errorStatus } = parseErrorMessage(error); + if (error instanceof Response) { + return error; + } + const { logger, ui } = getStore(); + + const parsedError = parseErrorMessage(error); + const { errorMessage, errorStatus, errorType } = parsedError; logger.error(`[${errorType}:${errorStatus}] ${errorMessage}`); - return new Response(errorMessage, { status: errorStatus || 500 }); + if (ui?.renderErrorPage && checkIsHTMLRequest(true)) { + return new Response(ui.renderErrorPage(parsedError, createUIAdapterOptions()).toString(), { + headers: { "Content-Type": mimes.html }, + status: errorStatus || 500, + }); + } + + return new Response(errorMessage, { status: errorStatus || 500, statusText: errorType }); }; } diff --git a/packages/core/src/utils/openapi-utils.ts b/packages/core/src/utils/openapi-utils.ts index 2422774..4385d08 100644 --- a/packages/core/src/utils/openapi-utils.ts +++ b/packages/core/src/utils/openapi-utils.ts @@ -2,9 +2,9 @@ import type { ResponseConfig, ZodContentObject } from "@asteasolutions/zod-to-op import { z } from "@hono/zod-openapi"; import { mimes } from "./mime-utils"; -export const openapiResponsesHtml: ZodContentObject = { +export const openapiResponsesHtml = { [mimes.html]: { schema: z.string().openapi({ example: "" }) }, -} as const; +} as const satisfies ZodContentObject; export function openapiResponseRedirect(description: string): ResponseConfig { return { diff --git a/packages/core/src/utils/response.ts b/packages/core/src/utils/response.ts index cc7e876..6e58e38 100644 --- a/packages/core/src/utils/response.ts +++ b/packages/core/src/utils/response.ts @@ -81,17 +81,16 @@ async function handleErrorResponseForHTMLRequest( return new Response(errorMessage, { headers, status }); } - return await responseHTML( - ui.renderErrorPage( - { - message: errorMessage, - title: `Error ${status}`, - status, - }, - createUIAdapterOptions(), - ), - { headers, status }, + const content = ui.renderErrorPage( + { + message: errorMessage, + title: `Error ${status}`, + status, + }, + createUIAdapterOptions(), ); + + return await responseHTML(content, { headers, status }); } async function responseHTML( diff --git a/packages/core/tsdown.config.ts b/packages/core/tsdown.config.ts index 57668fd..d132a7e 100644 --- a/packages/core/tsdown.config.ts +++ b/packages/core/tsdown.config.ts @@ -8,6 +8,11 @@ export default defineConfig({ entry: { adapter: "./src/adapters/index.ts", "adapter/fs": "./src/adapters/_fs-adapters.ts", + "adapter/auth": "./src/adapters/auth.ts", + "adapter/database": "./src/adapters/database.ts", + "adapter/logger": "./src/adapters/logger.ts", + "adapter/storage": "./src/adapters/storage.ts", + "adapter/ui": "./src/adapters/ui.ts", index: "./src/index.ts", constants: "./src/utils/constants.ts", mimes: "./src/utils/mime-utils.ts", diff --git a/packages/ui/src/pages/error-page.tsx b/packages/ui/src/pages/error-page.tsx index 1122b4b..651b25a 100644 --- a/packages/ui/src/pages/error-page.tsx +++ b/packages/ui/src/pages/error-page.tsx @@ -1,3 +1,4 @@ +import type { ParsedError } from "@storybooker/core/types"; import { DocumentHeader, DocumentLayout, @@ -7,7 +8,8 @@ import { } from "../components/document"; import { ErrorMessage } from "../components/error-message"; -export function ErrorPage({ title, message }: { title: string; message: string }): JSXElement { +export function ErrorPage({ errorMessage, errorType, errorStatus }: ParsedError): JSXElement { + const title = `${errorType} ${errorStatus ? `- ${errorStatus}` : ""}`; return ( - {message} + {errorMessage} diff --git a/scripts/server.ts b/scripts/server.ts index 6be2770..224137c 100644 --- a/scripts/server.ts +++ b/scripts/server.ts @@ -1,15 +1,11 @@ +// oxlint-disable sort-keys // oxlint-disable no-console // oxlint-disable class-methods-use-this // oxlint-disable require-await import { logger } from "hono/logger"; import { poweredBy } from "hono/powered-by"; -import type { - AuthAdapter, - AuthAdapterAuthorise, - AuthAdapterOptions, - StoryBookerUser, -} from "../packages/core/dist/adapter.d.ts"; +import type { AuthAdapter, StoryBookerUser } from "../packages/core/dist/adapter.d.ts"; import { createLocalFileDatabaseAdapter, createLocalFileStorageAdapter, @@ -17,50 +13,8 @@ import { import { createHonoRouter } from "../packages/core/dist/index.js"; import { createBasicUIAdapter } from "../packages/ui/dist/index.js"; -class LocalAuthAdapter implements AuthAdapter { - #auth = true; - #user: StoryBookerUser | null = null; - - init = async (): Promise => { - this.#user = { - displayName: "Test User name", - id: "user", - imageUrl: "https://upload.wikimedia.org/wikipedia/commons/8/89/Portrait_Placeholder.png", - title: "testAdmin", - }; - }; - - authorise: AuthAdapterAuthorise = () => true; - getUserDetails = async (): Promise => { - if (!this.#auth) { - return null; - } - return this.#user; - }; - login = async ({ request }: AuthAdapterOptions): Promise => { - this.#auth = true; - const url = new URL(request.url); - return new Response(null, { - headers: { Location: url.origin }, - status: 302, - }); - }; - logout = async (_user: StoryBookerUser, { request }: AuthAdapterOptions): Promise => { - this.#auth = false; - const url = new URL(request.url); - return new Response(null, { - headers: { Location: url.origin }, - status: 302, - }); - }; - renderAccountDetails = (user: StoryBookerUser): string => { - return `

Place anything in this iFrame about the user

-
${JSON.stringify({ user }, null, 2)}
`; - }; -} - -const app = createHonoRouter({ - auth: new LocalAuthAdapter(), +export default createHonoRouter({ + auth: createLocalAuthAdapter(), config: { middlewares: [logger(), poweredBy({ serverName: "SBR" })], queueLargeZipFileProcessing: true, @@ -73,4 +27,49 @@ const app = createHonoRouter({ }), }); -export default app; +function createLocalAuthAdapter(): AuthAdapter { + let auth = false; + let user: StoryBookerUser | null = null; + + return { + metadata: { name: "Local Auth" }, + + async init() { + user = { + displayName: "Test User name", + id: "user", + imageUrl: "https://upload.wikimedia.org/wikipedia/commons/8/89/Portrait_Placeholder.png", + title: "testAdmin", + }; + }, + + authorise: () => true, + + async getUserDetails() { + return auth ? user : null; + }, + + async login({ request }) { + auth = true; + const url = new URL(request.url); + return new Response(null, { + headers: { Location: url.origin }, + status: 302, + }); + }, + + async logout(_user, { request }) { + auth = false; + const url = new URL(request.url); + return new Response(null, { + headers: { Location: url.origin }, + status: 302, + }); + }, + + renderAccountDetails(user) { + return `

Place anything in this iFrame about the user

+
${JSON.stringify({ user }, null, 2)}
`; + }, + }; +} From 1aba0e5f21bc22e60dd0f33248aea74853440ad8 Mon Sep 17 00:00:00 2001 From: Siddhant Gupta Date: Sun, 7 Dec 2025 19:12:06 +0200 Subject: [PATCH 05/15] feat: throw http-exception instead of creating early response + handle htmx responses in middleware --- packages/core/src/adapters/auth.ts | 4 +- .../src/handlers/handle-serve-storybook.ts | 6 +- packages/core/src/index.ts | 6 +- packages/core/src/routers/account-router.ts | 34 +++-- packages/core/src/routers/builds-router.ts | 42 +++--- packages/core/src/routers/projects-router.ts | 7 +- packages/core/src/routers/tags-router.ts | 10 +- packages/core/src/routers/tasks-router.ts | 9 +- packages/core/src/utils/error.ts | 62 +++++---- packages/core/src/utils/request.ts | 5 +- packages/core/src/utils/response.ts | 128 +++--------------- packages/ui/src/components/document.tsx | 9 +- packages/ui/src/components/project-form.tsx | 6 +- packages/ui/src/pages/project-create-page.tsx | 2 +- packages/ui/src/pages/project-update-page.tsx | 2 +- scripts/server.ts | 2 +- 16 files changed, 132 insertions(+), 202 deletions(-) diff --git a/packages/core/src/adapters/auth.ts b/packages/core/src/adapters/auth.ts index 99cf36d..2f1b3d3 100644 --- a/packages/core/src/adapters/auth.ts +++ b/packages/core/src/adapters/auth.ts @@ -46,7 +46,7 @@ export interface AuthAdapter * * @param options Common options like abortSignal. */ - login?: (options: AuthAdapterOptions) => Promise | Response; + login: (options: AuthAdapterOptions) => Promise | Response; /** * Get user to logout from UI. The returning response should clear auth session. @@ -54,7 +54,7 @@ export interface AuthAdapter * * @param options Common options like abortSignal. */ - logout?: (user: AuthUser, options: AuthAdapterOptions) => Promise | Response; + logout: (user: AuthUser, options: AuthAdapterOptions) => Promise | Response; /** * Render custom HTML in account page. Must return valid HTML string; diff --git a/packages/core/src/handlers/handle-serve-storybook.ts b/packages/core/src/handlers/handle-serve-storybook.ts index a9c74a5..aa83cee 100644 --- a/packages/core/src/handlers/handle-serve-storybook.ts +++ b/packages/core/src/handlers/handle-serve-storybook.ts @@ -1,11 +1,11 @@ import path from "node:path"; import { SuperHeaders } from "@remix-run/headers"; +import { HTTPException } from "hono/http-exception"; import { urlBuilder } from "../urls"; import { generateStorageContainerId } from "../utils/adapter-utils"; import { authenticateOrThrow } from "../utils/auth"; import { CACHE_CONTROL_PUBLIC_YEAR, SERVICE_NAME } from "../utils/constants"; import { getMimeType } from "../utils/mime-utils"; -import { responseError } from "../utils/response"; import { getStore } from "../utils/store"; export async function handleServeStoryBook({ @@ -29,7 +29,7 @@ export async function handleServeStoryBook({ ); if (!content) { - return await responseError("File does not contain any content", 404); + throw new HTTPException(404, { message: "File does not contain any content" }); } const headers = new SuperHeaders(); @@ -74,7 +74,7 @@ ${relativeHrefScripts} return new Response(content, { headers, status: 200 }); } catch (error) { - return await responseError(error, 404); + throw new HTTPException(404, { message: "File not found", cause: error }); } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7703754..9161573 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -12,6 +12,7 @@ import { parseErrorMessage, prettifyZodValidationErrorMiddleware, } from "./utils/error"; +import { htmxRedirectResponse } from "./utils/response"; import { localStore, setupStore } from "./utils/store"; if ("setEncoding" in process.stdout) { @@ -36,13 +37,14 @@ export function createHonoRouter( return new Hono<{ Variables: TimingVariables }>({ strict: false }) .use( - prettifyZodValidationErrorMiddleware, + prettifyZodValidationErrorMiddleware(logger), timing(), setupStore(options, initPromises), + htmxRedirectResponse(), ...middlewares, ) .route("/", appRouter) - .onError(onUnhandledErrorHandler()); + .onError(onUnhandledErrorHandler(options)); } /** diff --git a/packages/core/src/routers/account-router.ts b/packages/core/src/routers/account-router.ts index 66cbd0f..7a61c3a 100644 --- a/packages/core/src/routers/account-router.ts +++ b/packages/core/src/routers/account-router.ts @@ -1,4 +1,5 @@ import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi"; +import { HTTPException } from "hono/http-exception"; import { urlBuilder } from "../urls"; import { QUERY_PARAMS } from "../utils/constants"; import { @@ -6,7 +7,6 @@ import { openapiResponseRedirect, openapiResponsesHtml, } from "../utils/openapi-utils"; -import { responseError, responseRedirect } from "../utils/response"; import { getStore } from "../utils/store"; import { createUIAdapterOptions } from "../utils/ui-utils"; @@ -33,17 +33,17 @@ export const accountRouter = new OpenAPIHono() async (context) => { const { abortSignal, auth, logger, request, user, ui } = getStore(); - if (!auth || !ui) { - return await responseError("Auth is not setup", 500); + if (!auth) { + throw new HTTPException(500, { message: "Auth is not setup" }); } if (!user) { - if (auth.login) { - const { pathname } = new URL(urlBuilder.account()); - return responseRedirect(urlBuilder.login(pathname), 302); - } + const { pathname } = new URL(urlBuilder.account()); + return context.redirect(urlBuilder.login(pathname), 302); + } - return responseRedirect(urlBuilder.homepage(), 401); + if (!ui) { + throw new HTTPException(500, { message: "UI is not available for this route." }); } const children = await auth.renderAccountDetails?.(user, { @@ -75,8 +75,8 @@ export const accountRouter = new OpenAPIHono() async (context) => { const { abortSignal, auth, logger, request } = getStore(); - if (!auth?.login) { - return await responseError("Auth is not setup", 500); + if (!auth) { + throw new HTTPException(500, { message: "Auth is not setup" }); } const response = await auth.login({ abortSignal, logger, request }); @@ -88,7 +88,7 @@ export const accountRouter = new OpenAPIHono() const { redirect = "" } = context.req.valid("query"); const location = new URL(redirect, urlBuilder.homepage()); - return responseRedirect(location.toString(), { + return context.redirect(location.toString(), { headers: response.headers, status: 302, }); @@ -105,10 +105,14 @@ export const accountRouter = new OpenAPIHono() ...openapiCommonErrorResponses, }, }), - async () => { + async (ctx) => { const { abortSignal, auth, logger, request, user } = getStore(); - if (!auth?.logout || !user) { - return await responseError("Auth is not setup", 500); + if (!auth) { + throw new HTTPException(500, { message: "Auth is not setup" }); + } + + if (!user) { + throw new HTTPException(401, { message: "User is not authenticated" }); } const response = await auth.logout(user, { @@ -124,7 +128,7 @@ export const accountRouter = new OpenAPIHono() const responseLocation = responseHeaders.get("location"); responseHeaders.delete("location"); - return responseRedirect(responseLocation || urlBuilder.homepage(), { + return ctx.redirect(responseLocation || urlBuilder.homepage(), { headers: responseHeaders, status: 302, }); diff --git a/packages/core/src/routers/builds-router.ts b/packages/core/src/routers/builds-router.ts index 1b96fdf..852f6b4 100644 --- a/packages/core/src/routers/builds-router.ts +++ b/packages/core/src/routers/builds-router.ts @@ -1,5 +1,7 @@ import { createRoute, OpenAPIHono } from "@hono/zod-openapi"; import { SuperHeaders } from "@remix-run/headers"; +import { HTTPException } from "hono/http-exception"; +import type { ContentfulStatusCode } from "hono/utils/http-status"; import z from "zod"; import { BuildsModel } from "../models/builds-model"; import { @@ -24,7 +26,6 @@ import { openapiResponsesHtml, } from "../utils/openapi-utils"; import { checkIsHTMLRequest, validateBuildUploadZipBody } from "../utils/request"; -import { responseError, responseRedirect } from "../utils/response"; import { getStore } from "../utils/store"; import { createUIAdapterOptions } from "../utils/ui-utils"; @@ -153,7 +154,7 @@ export const buildsRouter = new OpenAPIHono() const projectModel = new ProjectsModel().id(projectId); if (!(await projectModel.has())) { - return await responseError(`The project '${projectId}' does not exist.`, 404); + throw new HTTPException(404, { message: `The project '${projectId}' does not exist.` }); } await authenticateOrThrow({ @@ -167,7 +168,7 @@ export const buildsRouter = new OpenAPIHono() const url = urlBuilder.buildDetails(projectId, build.id); if (checkIsHTMLRequest(true)) { - return responseRedirect(url, 303); + return context.redirect(url, 303); } return context.json({ build, url }, 201); @@ -262,7 +263,7 @@ export const buildsRouter = new OpenAPIHono() await new BuildsModel(projectId).delete(buildId, true); if (checkIsHTMLRequest(true)) { - return responseRedirect(urlBuilder.buildsList(projectId), 303); + return context.redirect(urlBuilder.buildsList(projectId), 303); } return new Response(null, { status: 204 }); @@ -301,10 +302,9 @@ export const buildsRouter = new OpenAPIHono() const buildsModel = new BuildsModel(projectId); if (!(await buildsModel.has(buildId))) { - return await responseError( - `The build '${buildId}' does not exist in project '${projectId}'.`, - 404, - ); + throw new HTTPException(404, { + message: `The build '${buildId}' does not exist in project '${projectId}'.`, + }); } await authenticateOrThrow({ @@ -317,7 +317,7 @@ export const buildsRouter = new OpenAPIHono() await buildsModel.update(buildId, data); if (checkIsHTMLRequest(true)) { - return responseRedirect(urlBuilder.buildDetails(projectId, buildId), 303); + return context.redirect(urlBuilder.buildDetails(projectId, buildId), 303); } return new Response(null, { status: 202 }); @@ -409,10 +409,9 @@ export const buildsRouter = new OpenAPIHono() const buildsModel = new BuildsModel(projectId); if (!(await buildsModel.has(buildId))) { - return await responseError( - `The build '${buildId}' does not exist in project '${projectId}'.`, - 404, - ); + throw new HTTPException(404, { + message: `The build '${buildId}' does not exist in project '${projectId}'.`, + }); } await authenticateOrThrow({ @@ -424,7 +423,7 @@ export const buildsRouter = new OpenAPIHono() const { contentType } = new SuperHeaders(context.req.header()); if (!contentType.toString()) { - return await responseError("Content-Type header is required", 400); + throw new HTTPException(400, { message: "Content-Type header is required" }); } const redirectUrl = urlBuilder.buildDetails(projectId, buildId); @@ -436,7 +435,7 @@ export const buildsRouter = new OpenAPIHono() await buildsModel.upload(buildId, variant, file); if (checkIsHTMLRequest(true)) { - return responseRedirect(redirectUrl, 303); + return context.redirect(redirectUrl, 303); } return new Response(null, { status: 204 }); @@ -445,7 +444,9 @@ export const buildsRouter = new OpenAPIHono() if (contentType.mediaType?.startsWith(mimes.zip)) { const bodyError = validateBuildUploadZipBody(context.req.raw); if (bodyError) { - return await responseError(bodyError.message, bodyError.status); + throw new HTTPException(bodyError.status as ContentfulStatusCode, { + message: bodyError.message, + }); } const { variant } = context.req.valid("query"); @@ -453,15 +454,14 @@ export const buildsRouter = new OpenAPIHono() await buildsModel.upload(buildId, variant); if (checkIsHTMLRequest(true)) { - return responseRedirect(redirectUrl, 303); + return context.redirect(redirectUrl, 303); } return new Response(null, { status: 204 }); } - return await responseError( - `Invalid content type, expected ${mimes.zip} or ${mimes.formMultipart}.`, - 415, - ); + throw new HTTPException(415, { + message: `Invalid content type, expected ${mimes.zip} or ${mimes.formMultipart}.`, + }); }, ); diff --git a/packages/core/src/routers/projects-router.ts b/packages/core/src/routers/projects-router.ts index c36153d..2964c1e 100644 --- a/packages/core/src/routers/projects-router.ts +++ b/packages/core/src/routers/projects-router.ts @@ -20,7 +20,6 @@ import { openapiResponsesHtml, } from "../utils/openapi-utils"; import { checkIsHTMLRequest } from "../utils/request"; -import { responseRedirect } from "../utils/response"; import { getStore } from "../utils/store"; import { createUIAdapterOptions } from "../utils/ui-utils"; @@ -134,7 +133,7 @@ export const projectsRouter = new OpenAPIHono() const project = await new ProjectsModel().create(data); if (checkIsHTMLRequest(true)) { - return responseRedirect(urlBuilder.projectDetails(project.id), 303); + return context.redirect(urlBuilder.projectDetails(project.id), 303); } return context.json({ project }); @@ -219,7 +218,7 @@ export const projectsRouter = new OpenAPIHono() await new ProjectsModel().delete(projectId); if (checkIsHTMLRequest(true)) { - return responseRedirect(urlBuilder.projectsList(), 303); + return context.redirect(urlBuilder.projectsList(), 303); } return new Response(null, { status: 204 }); @@ -302,7 +301,7 @@ export const projectsRouter = new OpenAPIHono() await new ProjectsModel().update(projectId, data); if (checkIsHTMLRequest(true)) { - return responseRedirect(urlBuilder.projectDetails(projectId), 303); + return context.redirect(urlBuilder.projectDetails(projectId), 303); } return new Response(null, { status: 202 }); diff --git a/packages/core/src/routers/tags-router.ts b/packages/core/src/routers/tags-router.ts index dc6a692..4b557cf 100644 --- a/packages/core/src/routers/tags-router.ts +++ b/packages/core/src/routers/tags-router.ts @@ -1,4 +1,5 @@ import { createRoute, OpenAPIHono } from "@hono/zod-openapi"; +import { HTTPException } from "hono/http-exception"; import z from "zod"; import { BuildsModel } from "../models/builds-model"; import { ProjectsModel } from "../models/projects-model"; @@ -22,7 +23,6 @@ import { openapiResponsesHtml, } from "../utils/openapi-utils"; import { checkIsHTMLRequest } from "../utils/request"; -import { responseError, responseRedirect } from "../utils/response"; import { getStore } from "../utils/store"; import { createUIAdapterOptions } from "../utils/ui-utils"; @@ -152,7 +152,7 @@ export const tagsRouter = new OpenAPIHono() const projectModel = new ProjectsModel().id(projectId); if (!(await projectModel.has())) { - return await responseError(`The project '${projectId}' does not exist.`, 404); + throw new HTTPException(404, { message: `The project '${projectId}' does not exist.` }); } await authenticateOrThrow({ @@ -165,7 +165,7 @@ export const tagsRouter = new OpenAPIHono() const tag = await new TagsModel(projectId).create(data); if (checkIsHTMLRequest(true)) { - return responseRedirect(urlBuilder.tagDetails(projectId, tag.id), 303); + return context.redirect(urlBuilder.tagDetails(projectId, tag.id), 303); } return context.json({ tag }, 201); @@ -245,7 +245,7 @@ export const tagsRouter = new OpenAPIHono() await new TagsModel(projectId).delete(tagId); if (checkIsHTMLRequest(true)) { - return responseRedirect(urlBuilder.tagsList(projectId), 303); + return context.redirect(urlBuilder.tagsList(projectId), 303); } return new Response(null, { status: 204 }); @@ -331,7 +331,7 @@ export const tagsRouter = new OpenAPIHono() await tagsModel.update(tagId, data); if (checkIsHTMLRequest(true)) { - return responseRedirect(urlBuilder.tagDetails(projectId, tagId), 303); + return context.redirect(urlBuilder.tagDetails(projectId, tagId), 303); } return new Response(null, { status: 202 }); diff --git a/packages/core/src/routers/tasks-router.ts b/packages/core/src/routers/tasks-router.ts index 569b683..c167c85 100644 --- a/packages/core/src/routers/tasks-router.ts +++ b/packages/core/src/routers/tasks-router.ts @@ -1,11 +1,11 @@ import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi"; +import { HTTPException } from "hono/http-exception"; import { handleProcessZip } from "../handlers/handle-process-zip"; import { handlePurge } from "../handlers/handle-purge"; import { buildUploadVariants } from "../models/builds-schema"; import { urlBuilder } from "../urls"; import { authenticateOrThrow } from "../utils/auth"; import { checkIsHTMLRequest } from "../utils/request"; -import { responseError, responseRedirect } from "../utils/response"; const tasksTag = "Tasks"; @@ -67,13 +67,16 @@ export const tasksRouter = new OpenAPIHono() await handleProcessZip(projectId, buildId, variant); if (checkIsHTMLRequest(true)) { - return responseRedirect(urlBuilder.buildDetails(projectId, buildId), 303); + return context.redirect(urlBuilder.buildDetails(projectId, buildId), 303); } context.status(204); return context.res; } catch (error) { - return responseError(error); + throw new HTTPException(500, { + cause: error, + message: "Failed to process uploaded zip file.", + }); } }, ); diff --git a/packages/core/src/utils/error.ts b/packages/core/src/utils/error.ts index 38f406a..5f15b69 100644 --- a/packages/core/src/utils/error.ts +++ b/packages/core/src/utils/error.ts @@ -1,10 +1,12 @@ import type { ErrorHandler, MiddlewareHandler } from "hono"; import { HTTPException } from "hono/http-exception"; +import type { ContentfulStatusCode } from "hono/utils/http-status"; import { z } from "zod"; -import { getStore, getStoreOrNull } from "../utils/store"; -import { mimes } from "./mime-utils"; +import type { LoggerAdapter } from "../adapters"; +import type { RouterOptions, StoryBookerUser } from "../types"; +import { getStoreOrNull } from "../utils/store"; +import { DEFAULT_LOCALE } from "./constants"; import { checkIsHTMLRequest } from "./request"; -import { createUIAdapterOptions } from "./ui-utils"; /** * A function type for parsing custom errors. @@ -64,36 +66,46 @@ const zodValidationErrorSchema = z.object({ message: z.string(), }), }); -export const prettifyZodValidationErrorMiddleware: MiddlewareHandler = async (ctx, next) => { - await next(); - - const resContentType = ctx.res.headers.get("Content-Type") || ""; - if (ctx.res.status === 400 && resContentType.startsWith("application/json")) { - const result = zodValidationErrorSchema.safeParse(await ctx.res.clone().json()); - if (result.success) { - const issues = JSON.parse(result.data.error.message) as z.core.$ZodIssue[]; - const message = `Validation error:\n${z.prettifyError({ issues })}`; - throw new HTTPException(400, { message, res: ctx.res }); +export function prettifyZodValidationErrorMiddleware(logger: LoggerAdapter): MiddlewareHandler { + return async (ctx, next) => { + await next(); + + const resContentType = ctx.res.headers.get("Content-Type") || ""; + if (ctx.res.status === 400 && resContentType.startsWith("application/json")) { + const result = zodValidationErrorSchema.safeParse(await ctx.res.clone().json()); + if (result.success) { + const issues = JSON.parse(result.data.error.message) as z.core.$ZodIssue[]; + const message = `Validation error:\n${z.prettifyError({ issues })}`; + logger.error(`[Zod] ${message}`); + throw new HTTPException(400, { message, res: ctx.res }); + } } - } -}; + }; +} -export function onUnhandledErrorHandler(): ErrorHandler { - return (error) => { +export function onUnhandledErrorHandler( + options: RouterOptions, +): ErrorHandler { + return (error, ctx) => { if (error instanceof Response) { return error; } - const { logger, ui } = getStore(); const parsedError = parseErrorMessage(error); const { errorMessage, errorStatus, errorType } = parsedError; - logger.error(`[${errorType}:${errorStatus}] ${errorMessage}`); - - if (ui?.renderErrorPage && checkIsHTMLRequest(true)) { - return new Response(ui.renderErrorPage(parsedError, createUIAdapterOptions()).toString(), { - headers: { "Content-Type": mimes.html }, - status: errorStatus || 500, - }); + options.logger?.error(`[${errorType}:${errorStatus}] ${errorMessage}`); + + if (options?.ui?.renderErrorPage && checkIsHTMLRequest(false, ctx.req.raw)) { + return ctx.html( + options.ui.renderErrorPage(parsedError, { + isAuthEnabled: !!options.auth, + locale: DEFAULT_LOCALE, + logger: options.logger || console, + url: ctx.req.url, + user: null, + }), + (errorStatus as ContentfulStatusCode) || 500, + ); } return new Response(errorMessage, { status: errorStatus || 500, statusText: errorType }); diff --git a/packages/core/src/utils/request.ts b/packages/core/src/utils/request.ts index 37c146b..680e60c 100644 --- a/packages/core/src/utils/request.ts +++ b/packages/core/src/utils/request.ts @@ -12,8 +12,9 @@ export function checkIsHXRequest(request?: Request): boolean { return req.headers.get("hx-request") === "true"; } -export function checkIsHTMLRequest(checkHX?: boolean): boolean { - const req = getStore().request; +export function checkIsHTMLRequest(checkHX?: boolean, request?: Request): boolean { + const req = request || getStore().request; + const accept = req.headers.get("accept"); if (checkHX && checkIsHXRequest(req)) { return true; diff --git a/packages/core/src/utils/response.ts b/packages/core/src/utils/response.ts index 6e58e38..56c7b47 100644 --- a/packages/core/src/utils/response.ts +++ b/packages/core/src/utils/response.ts @@ -1,114 +1,20 @@ -import { SuperHeaders } from "@remix-run/headers"; -import { checkIsHTMLRequest, checkIsHXRequest } from "../utils/request"; -import { getStore } from "../utils/store"; -import { parseErrorMessage } from "./error"; -import { mimes } from "./mime-utils"; -import { createUIAdapterOptions } from "./ui-utils"; - -export function responseRedirect(location: string, init: ResponseInit | number): Response { - const status = typeof init === "number" ? init : (init?.status ?? 303); - const headers = new Headers(typeof init === "number" ? {} : init?.headers); - - if (checkIsHXRequest()) { - headers.set("HX-redirect", location); - } else { - headers.set("Location", location); - } - - return new Response(null, { headers, status }); -} - -export async function responseError( - error: unknown, - init?: ResponseInit | number, -): Promise { - if (error instanceof Response) { - return error; - } - - const { logger } = getStore(); - - try { - const { errorMessage, errorStatus, errorType } = parseErrorMessage(error); - logger.error(`[${errorType}]`, errorMessage, error instanceof Error ? error.stack : ""); - - const status = errorStatus ?? (typeof init === "number" ? init : (init?.status ?? 500)); - const headers = new Headers(typeof init === "number" ? {} : init?.headers); - - if (checkIsHXRequest()) { - return handleErrorResponseForHxRequest(errorMessage, headers, status); +import type { MiddlewareHandler } from "hono"; +import { checkIsHXRequest } from "../utils/request"; + +/** + * Middleware to handle htmx redirects. + * If the response status is a redirect (3xx) and the request is an htmx request, + * it sets the "HX-redirect" header with the redirect location and removes the "Location" header. + */ +export function htmxRedirectResponse(): MiddlewareHandler { + return async (ctx, next) => { + await next(); + if (ctx.res.status >= 300 && ctx.res.status < 400) { + const location = ctx.res.headers.get("Location"); + if (location && checkIsHXRequest(ctx.req.raw)) { + ctx.res.headers.set("HX-redirect", location); + ctx.res.headers.delete("Location"); + } } - - if (checkIsHTMLRequest()) { - return await handleErrorResponseForHTMLRequest( - errorType === "string" ? errorMessage : JSON.stringify(errorMessage), - headers, - status, - ); - } - - headers.set("Content-Type", "application/json"); - - return Response.json({ errorMessage }, { headers, status }); - } catch (error) { - logger.error(`[ErrOnErr]`, error); - const { errorMessage } = parseErrorMessage(error); - return new Response(errorMessage, { status: 500 }); - } -} - -function handleErrorResponseForHxRequest( - errorMessage: string, - headers: Headers, - status: number, -): Response { - try { - headers.set("HXToaster-Type", "error"); - headers.set("HXToaster-Body", errorMessage); - } catch { - // Ignore the errors if error message is not serialisable - } - return new Response(errorMessage, { headers, status }); -} - -async function handleErrorResponseForHTMLRequest( - errorMessage: string, - headers: Headers, - status: number, -): Promise { - const { ui } = getStore(); - if (!ui) { - return new Response(errorMessage, { headers, status }); - } - - const content = ui.renderErrorPage( - { - message: errorMessage, - title: `Error ${status}`, - status, - }, - createUIAdapterOptions(), - ); - - return await responseHTML(content, { headers, status }); -} - -async function responseHTML( - html: string | Promise, - init?: ResponseInit, -): Promise { - const headers = new SuperHeaders(init?.headers); - headers.contentType = mimes.html; - - const responseInit: ResponseInit = { - ...init, - headers, - status: init?.status || 200, }; - - if (html instanceof Promise) { - return new Response(await html, responseInit); - } - - return new Response(html, responseInit); } diff --git a/packages/ui/src/components/document.tsx b/packages/ui/src/components/document.tsx index 4557d78..1d96a28 100644 --- a/packages/ui/src/components/document.tsx +++ b/packages/ui/src/components/document.tsx @@ -73,8 +73,6 @@ export function DocumentLayout({ )} - -