From c32480a089e161290ee6c53f6997d54adf7e077a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 15 May 2026 18:17:46 +0000 Subject: [PATCH] impl(server,web,cli): extract @forager/server (tauri port phase 1) Carve the RPC endpoint, media-file streaming, thumbnail streaming, and the Forager config schema out of the SvelteKit app into a new framework-agnostic @forager/server package. @forager/server - api.ts (moved from packages/web/src/lib/api.ts) - config.ts (moved from packages/web/src/lib/server/config.ts) - handlers/rpc.ts (wraps ts-rpc's SvelteKit adapter with a Request stub until ts-rpc publishes a Request adapter; module is internal) - handlers/media_file.ts, handlers/thumbnail.ts (pure Request -> Response) - ForagerServer class with try_handle_request(request) so embedders can mount it ahead of their own routes, plus a standalone start() for the upcoming 'forager serve' command. @forager/web - SvelteKit +server.ts routes become thin delegates so dev mode (vite + sveltekit hooks) keeps working unchanged. - The published web.Server now accepts forager + config directly and dispatches /rpc/* and /files/* through ForagerServer, bypassing SvelteKit. Static assets and HTML routes still flow through SvelteKit. - $lib/api.ts and $lib/server/config.ts become shims re-exporting from @forager/server so frontend imports don't need to move yet. @forager/cli - Pass forager + config into web.Server in both 'init' and 'gui' so the bypass kicks in. No frontend behaviour changes. Same URLs, same response shapes. Co-authored-by: andykais-claude --- deno.json | 1 + deno.lock | 6 + packages/cli/src/cli.ts | 4 + packages/server/LICENSE | 21 ++ packages/server/README.md | 27 +++ packages/server/deno.json | 25 +++ packages/server/src/api.ts | 40 ++++ packages/server/src/config.ts | 106 +++++++++ packages/server/src/handlers/media_file.ts | 184 ++++++++++++++++ packages/server/src/handlers/mod.ts | 3 + packages/server/src/handlers/rpc.ts | 22 ++ packages/server/src/handlers/thumbnail.ts | 90 ++++++++ packages/server/src/mod.ts | 39 ++++ packages/server/src/server.ts | 125 +++++++++++ packages/web/adapter/lib/deno.json | 1 - packages/web/adapter/lib/mod.ts | 25 +++ packages/web/src/lib/api.ts | 45 +--- packages/web/src/lib/server/config.ts | 114 +--------- .../routes/files/media_file/[id]/+server.ts | 202 +----------------- .../routes/files/thumbnail/[id]/+server.ts | 98 +-------- .../web/src/routes/rpc/[signature]/+server.ts | 25 ++- 21 files changed, 753 insertions(+), 450 deletions(-) create mode 100644 packages/server/LICENSE create mode 100644 packages/server/README.md create mode 100644 packages/server/deno.json create mode 100644 packages/server/src/api.ts create mode 100644 packages/server/src/config.ts create mode 100644 packages/server/src/handlers/media_file.ts create mode 100644 packages/server/src/handlers/mod.ts create mode 100644 packages/server/src/handlers/rpc.ts create mode 100644 packages/server/src/handlers/thumbnail.ts create mode 100644 packages/server/src/mod.ts create mode 100644 packages/server/src/server.ts diff --git a/deno.json b/deno.json index eee795aa..118d062f 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,7 @@ { "workspace": [ "./packages/core", + "./packages/server", "./packages/web", "./packages/web/adapter/lib", "./packages/cli" diff --git a/deno.lock b/deno.lock index e55205d4..4507636e 100644 --- a/deno.lock +++ b/deno.lock @@ -1544,6 +1544,12 @@ "jsr:@cliffy/command@1.0.0-rc.8" ] }, + "packages/server": { + "dependencies": [ + "jsr:@andykais/ts-rpc@~0.2.2", + "npm:@opentelemetry/api@1.9.0" + ] + }, "packages/web": { "packageJson": { "dependencies": [ diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index f1d55daa..0c62cbb7 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -29,6 +29,8 @@ const cli = new cliffy.Command() asset_folder: forager_helpers.config.web.asset_folder, port: forager_helpers.config.web.port, logger: forager_helpers.config.web.logger, + forager, + config: forager_helpers.config, kit: { env: { FORAGER_INSTANCE: forager as any, @@ -111,6 +113,8 @@ const cli = new cliffy.Command() asset_folder: forager_helpers.config.web.asset_folder, port: forager_helpers.config.web.port, logger: forager_helpers.config.web.logger, + forager, + config: forager_helpers.config, kit: { env: { FORAGER_INSTANCE: forager as any, diff --git a/packages/server/LICENSE b/packages/server/LICENSE new file mode 100644 index 00000000..6ad9a163 --- /dev/null +++ b/packages/server/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Andrew Kaiser + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/server/README.md b/packages/server/README.md new file mode 100644 index 00000000..39c641f3 --- /dev/null +++ b/packages/server/README.md @@ -0,0 +1,27 @@ +# @forager/server + +Framework-agnostic HTTP server for [Forager](https://github.com/andykais/forager). Owns the RPC endpoint and the `/files/media_file` / `/files/thumbnail` streaming routes, and is intended to be embedded by `@forager/web` (SvelteKit + browser frontend) and the upcoming `@forager/desktop` Tauri shell. + +The server is implemented as a request handler over plain `Request` / `Response`, with no dependency on SvelteKit, Oak, or any other framework. + +## Usage + +```ts +import { Forager } from '@forager/core' +import { ForagerServer } from '@forager/server' +import { load_config } from '@forager/server/config' + +const config = await load_config('./forager.yml') +const forager = new Forager(config.core) +forager.init() + +const server = new ForagerServer({ forager, config }) +await server.start({ hostname: '127.0.0.1', port: 8000 }) +``` + +## Exports + +- `@forager/server` — `ForagerServer`, `Api`, `ApiSpec`, request handlers, types. +- `@forager/server/api` — RPC `Api` class (used by SvelteKit and Tauri client type-only imports). +- `@forager/server/config` — `PackagesConfig` Zod schema and `load_config` helper. +- `@forager/server/handlers` — Pure `(request, ctx) => Response` handler functions for `/rpc` and `/files/*`. diff --git a/packages/server/deno.json b/packages/server/deno.json new file mode 100644 index 00000000..547d4b58 --- /dev/null +++ b/packages/server/deno.json @@ -0,0 +1,25 @@ +{ + "name": "@forager/server", + "version": "0.0.1", + "license": "MIT", + + "imports": { + "@andykais/ts-rpc": "jsr:@andykais/ts-rpc@^0.2.2", + "@opentelemetry/api": "npm:@opentelemetry/api@1.9.0" + }, + + "exports": { + ".": "./src/mod.ts", + "./api": "./src/api.ts", + "./config": "./src/config.ts", + "./handlers": "./src/handlers/mod.ts" + }, + + "publish": { + "include": [ + "README.md", + "LICENSE", + "src/**/*.ts" + ] + } +} diff --git a/packages/server/src/api.ts b/packages/server/src/api.ts new file mode 100644 index 00000000..567e3827 --- /dev/null +++ b/packages/server/src/api.ts @@ -0,0 +1,40 @@ +import { type Forager } from '@forager/core' +import * as rpc from '@andykais/ts-rpc/adapters/sveltekit.ts' +import type { Config } from './config.ts' + + +export interface ApiContext { + forager: Forager + config: Config +} + +class ForagerTagApi extends rpc.ApiController { + search = this.context.forager.tag.search + get = this.context.forager.tag.get + update = this.context.forager.tag.update + alias_create = this.context.forager.tag.alias_create + alias_delete = this.context.forager.tag.alias_delete + parent_create = this.context.forager.tag.parent_create + parent_delete = this.context.forager.tag.parent_delete +} + +class ForagerApi extends rpc.ApiController { + media = this.context.forager.media + series = this.context.forager.series + tag = this.module(ForagerTagApi) + views = this.context.forager.views +} + +export class Api extends rpc.ApiController { + forager = this.module(ForagerApi) + + config(): Config { + return this.context.config + } + + server_time(): Date { + return new Date() + } +} + +export type ApiSpec = rpc.InferSpec diff --git a/packages/server/src/config.ts b/packages/server/src/config.ts new file mode 100644 index 00000000..c3d83c0f --- /dev/null +++ b/packages/server/src/config.ts @@ -0,0 +1,106 @@ +import * as yaml from '@std/yaml' +import z from 'zod' +import * as forager from '@forager/core' + + +const LogLevel = z.enum(['SILENT', 'ERROR', 'WARN', 'INFO', 'DEBUG']) + +const Keybind = (default_keybind: string) => z.union([z.string(), z.string().array()]).default(default_keybind).transform(keybind => { + if (Array.isArray(keybind)) return keybind + else return [keybind] +}) + +export const PackagesConfig = z.object({ + core: forager.parsers.ForagerConfig, + + web: z.object({ + + // TODO reuse core editing config parser + editing: z.object({ + editor: z.string(), + overwrite: z.boolean().default(true), + }).strict().optional(), + + port: z.number().default(8000), + asset_folder: z.string(), + + logger: z.object({ + level: LogLevel.default('INFO'), + }).strict().prefault({}), + + ui_defaults: z.object({ + + search: z.object({ + advanced_filters: z.object({ + hide: z.boolean().default(true) + }).strict().prefault({}) + }).strict().prefault({}), + + media_list: z.object({ + thumbnail_size: z.number().default(110), + thumbnail_shape: z.enum(['square', 'original']).prefault('original') + }).strict().prefault({}), + + sidebar: z.object({ + hide: z.boolean().default(true), + size: z.number().default(200), + tags: z.object({ + order: z.object({ + group: z.string(), + }).array().prefault([]) + }).strict().prefault({}), + }).strict().prefault({}), + + media_view: z.object({ + filmstrip: z.object({ + enabled: z.boolean().default(false), + thumbnail_size: z.number().default(100), + }).strict().optional().transform(f => f ?? {enabled: false}) + }).strict().prefault({}), + }).strict().prefault({}), + + shortcuts: z.object({ + OpenMedia: Keybind('Enter'), + Escape: Keybind('Escape'), + + NextMedia: Keybind('ArrowRight'), + PrevMedia: Keybind('ArrowLeft'), + + NextTagSuggestion: Keybind('ArrowDown'), + PrevTagSuggestion: Keybind('ArrowUp'), + + CopyMedia: Keybind('Ctrl-C'), + + ToggleSidebar: Keybind('Ctrl-Backslash'), + + // DownMedia: Keybind('ArrowDown'), + // UpMedia: Keybind('ArrowUp'), + ToggleFitMedia: Keybind('Ctrl-Space'), + ToggleFullScreen: Keybind('KeyF'), + PlayPauseMedia: Keybind('Space'), + ToggleVideoMute: Keybind('KeyM'), + ToggleMediaControls: Keybind('Ctrl-C'), + + Search: Keybind('Slash'), + AddTag: Keybind('Ctrl-M'), + + // ToggleVideoPreviewVsThumbails: Keybind('KeyT'), + + Star0: Keybind('Digit0'), + Star1: Keybind('Digit1'), + Star2: Keybind('Digit2'), + Star3: Keybind('Digit3'), + Star4: Keybind('Digit4'), + Star5: Keybind('Digit5'), + }).strict().prefault({}), + }).strict(), +}).strict() + +export type Config = z.infer + + +export async function load_config(config_filepath: string): Promise { + const file_contents_string = await Deno.readTextFile(config_filepath) + const file_contents = yaml.parse(file_contents_string) + return PackagesConfig.parse(file_contents) +} diff --git a/packages/server/src/handlers/media_file.ts b/packages/server/src/handlers/media_file.ts new file mode 100644 index 00000000..473af67c --- /dev/null +++ b/packages/server/src/handlers/media_file.ts @@ -0,0 +1,184 @@ +import { errors } from '@forager/core' +import { z } from 'zod' +import * as path from 'node:path' +import type { ApiContext } from '../api.ts' + + +const ParamsSchema = z.object({ + id: z.coerce.number(), +}) + + +/** + * Serves media files with proper HTTP range request support for video seeking. + * + * Security: Only serves files registered in the Forager database (path comes + * from the corresponding `media_file` row, not from request params). + * Performance: Supports byte-range requests for efficient video streaming. + */ +export async function handle_media_file_request( + request: Request, + context: ApiContext, + raw_params: { id: string | number }, +): Promise { + const { id: media_reference_id } = ParamsSchema.parse(raw_params) + + let media + try { + media = await context.forager.media.get({ media_reference_id }) + } catch (err) { + if (err instanceof errors.NotFoundError) { + return new Response('Media not found', { status: 404 }) + } + throw err + } + + if (media.media_type !== 'media_file') { + return new Response('Media reference is not a file', { status: 400 }) + } + + // narrowed by the check above + const filepath = media.media_file.filepath + const absolute_path = path.resolve(filepath) + + let stat: Deno.FileInfo + try { + stat = await Deno.stat(absolute_path) + if (!stat.isFile) { + return new Response('Path is not a file', { status: 403 }) + } + } catch { + return new Response('File not found', { status: 404 }) + } + + const mime_type = get_mime_type(media.media_file) + + const range_header = request.headers.get('range') + if (range_header) { + return await handle_range_request(absolute_path, stat, mime_type, range_header) + } + + const file = await Deno.open(absolute_path, { read: true }) + return new Response(file.readable, { + status: 200, + headers: { + 'Content-Type': mime_type, + 'Content-Length': stat.size.toString(), + 'Accept-Ranges': 'bytes', + 'Cache-Control': 'public, max-age=31536000, immutable', + }, + }) +} + + +async function handle_range_request( + filepath: string, + stat: Deno.FileInfo, + mime_type: string, + range_header: string, +): Promise { + const match = range_header.match(/bytes=(\d+)-(\d*)/) + if (!match) { + return new Response('Invalid range header', { status: 416 }) + } + + const file_size = stat.size + const start = parseInt(match[1]) + const end = match[2] ? parseInt(match[2]) : file_size - 1 + + if (start >= file_size || end >= file_size || start > end) { + return new Response(null, { + status: 416, + headers: { 'Content-Range': `bytes */${file_size}` }, + }) + } + + const chunk_size = end - start + 1 + + const file = await Deno.open(filepath, { read: true }) + await file.seek(start, Deno.SeekMode.Start) + + const stream = new ReadableStream({ + async start(controller) { + const buffer = new Uint8Array(64 * 1024) + let bytes_read = 0 + try { + while (bytes_read < chunk_size) { + const to_read = Math.min(buffer.length, chunk_size - bytes_read) + const n = await file.read(buffer.subarray(0, to_read)) + if (n === null) break + controller.enqueue(buffer.slice(0, n)) + bytes_read += n + } + } finally { + file.close() + controller.close() + } + }, + }) + + return new Response(stream, { + status: 206, + headers: { + 'Content-Type': mime_type, + 'Content-Length': chunk_size.toString(), + 'Content-Range': `bytes ${start}-${end}/${file_size}`, + 'Accept-Ranges': 'bytes', + 'Cache-Control': 'public, max-age=31536000', + }, + }) +} + + +function get_mime_type(media_file: { media_type: string; codec: string }): string { + const { media_type, codec } = media_file + + if (media_type === 'VIDEO') { + switch (codec) { + case 'h264': + case 'hevc': + case 'av1': + return 'video/mp4' + case 'vp8': + case 'vp9': + return 'video/webm' + default: + throw new Error(`Unexpected code path. Unknown ${media_type} codec '${codec}'`) + } + } + + if (media_type === 'IMAGE') { + switch (codec) { + case 'png': + case 'apng': + return 'image/png' + case 'webp': + return 'image/webp' + case 'gif': + return 'image/gif' + case 'tiff': + return 'image/tiff' + case 'mjpeg': + return 'image/jpeg' + default: + throw new Error(`Unexpected code path. Unknown ${media_type} codec '${codec}'`) + } + } + + if (media_type === 'AUDIO') { + switch (codec) { + case 'aac': + return 'audio/aac' + case 'mp3': + return 'audio/mpeg' + case 'opus': + return 'audio/opus' + case 'vorbis': + return 'audio/ogg' + default: + throw new Error(`Unexpected code path. Unknown ${media_type} codec '${codec}'`) + } + } + + return 'application/octet-stream' +} diff --git a/packages/server/src/handlers/mod.ts b/packages/server/src/handlers/mod.ts new file mode 100644 index 00000000..1a9febe4 --- /dev/null +++ b/packages/server/src/handlers/mod.ts @@ -0,0 +1,3 @@ +export { handle_media_file_request } from './media_file.ts' +export { handle_thumbnail_request } from './thumbnail.ts' +export { create_rpc_handler } from './rpc.ts' diff --git a/packages/server/src/handlers/rpc.ts b/packages/server/src/handlers/rpc.ts new file mode 100644 index 00000000..3e38fe5d --- /dev/null +++ b/packages/server/src/handlers/rpc.ts @@ -0,0 +1,22 @@ +import * as rpc_sveltekit from '@andykais/ts-rpc/adapters/sveltekit.ts' +import { Api, type ApiContext } from '../api.ts' + + +/** + * Build a request handler for the RPC endpoint. The returned function expects + * a plain `Request` whose body is the rpc `RequestContract` (same shape as + * what `@andykais/ts-rpc` already serializes on the client). + * + * Implementation note: ts-rpc's public JSR exports only include framework + * adapters (Oak, SvelteKit) and the base adapter is internal. The SvelteKit + * adapter only ever reads `event.request` off its argument, so we wrap it + * with a minimal stub event. This is intentionally tactical — once ts-rpc + * ships a `Request → Response` adapter, swap this back to that. + */ +export function create_rpc_handler(context: ApiContext): (request: Request) => Promise { + const sveltekit_handler = rpc_sveltekit.adapt(Api, context) + return async (request: Request): Promise => { + // deno-lint-ignore no-explicit-any + return await sveltekit_handler({ request } as any) + } +} diff --git a/packages/server/src/handlers/thumbnail.ts b/packages/server/src/handlers/thumbnail.ts new file mode 100644 index 00000000..14af4b46 --- /dev/null +++ b/packages/server/src/handlers/thumbnail.ts @@ -0,0 +1,90 @@ +import { errors } from '@forager/core' +import { z } from 'zod' +import * as path from 'node:path' +import type { ApiContext } from '../api.ts' + + +const ParamsSchema = z.object({ + id: z.coerce.number(), +}) + +const QuerySchema = z.object({ + index: z.coerce.number().nullish().transform(val => val ?? 0), +}) + + +/** + * Serves thumbnail images with aggressive caching. + * + * Security: Only serves files within the configured thumbnail directory. + * Performance: Immutable cache headers since thumbnails never change. + */ +export async function handle_thumbnail_request( + request: Request, + context: ApiContext, + raw_params: { id: string | number }, +): Promise { + const { id: thumbnail_id } = ParamsSchema.parse(raw_params) + const url = new URL(request.url) + // `index` is currently unused (kept for backwards-compatible URLs) + QuerySchema.parse({ index: url.searchParams.get('index') }) + + let thumbnail + try { + thumbnail = await context.forager.media.thumbnail({ thumbnail_id }) + } catch (err) { + if (err instanceof errors.NotFoundError) { + return new Response('Thumbnail not found', { status: 404 }) + } + throw err + } + + const thumbnail_path = thumbnail.filepath + const absolute_path = path.resolve(thumbnail_path) + const thumbnail_dir = path.resolve(context.config.core.thumbnails.folder) + + if (!absolute_path.startsWith(thumbnail_dir)) { + return new Response('Invalid thumbnail path', { status: 403 }) + } + + let stat: Deno.FileInfo + try { + stat = await Deno.stat(absolute_path) + if (!stat.isFile) { + return new Response('Path is not a file', { status: 403 }) + } + } catch { + return new Response('Thumbnail file not found', { status: 404 }) + } + + const file = await Deno.open(absolute_path, { read: true }) + const mime_type = get_thumbnail_mime_type(absolute_path) + + return new Response(file.readable, { + status: 200, + headers: { + 'Content-Type': mime_type, + 'Content-Length': stat.size.toString(), + 'Cache-Control': 'public, max-age=31536000, immutable', + ETag: `"${stat.mtime?.getTime() ?? 0}-${stat.size}"`, + }, + }) +} + + +function get_thumbnail_mime_type(filepath: string): string { + const ext = path.extname(filepath).toLowerCase() + switch (ext) { + case '.jpg': + case '.jpeg': + return 'image/jpeg' + case '.png': + return 'image/png' + case '.webp': + return 'image/webp' + case '.gif': + return 'image/gif' + default: + return 'image/jpeg' + } +} diff --git a/packages/server/src/mod.ts b/packages/server/src/mod.ts new file mode 100644 index 00000000..30485adf --- /dev/null +++ b/packages/server/src/mod.ts @@ -0,0 +1,39 @@ +/** + * @module + * + * Framework-agnostic HTTP server for Forager. Provides: + * + * - {@linkcode ForagerServer}: a standalone server that owns the RPC endpoint + * and media/thumbnail streaming, suitable for `forager serve`. + * - {@linkcode try_handle_request}-style handlers for embedding into an + * existing HTTP server (e.g. SvelteKit) that wants Forager routes mounted + * on the same origin. + * + * @example + * ```ts + * import { Forager } from '@forager/core' + * import { ForagerServer, load_config } from '@forager/server' + * + * const config = await load_config('./forager.yml') + * const forager = new Forager(config.core) + * forager.init() + * + * const server = new ForagerServer({ forager, config }) + * await server.start({ port: 8000 }) + * ``` + */ + +export { ForagerServer } from './server.ts' +export type { ForagerServerOptions, ForagerServerListenOptions } from './server.ts' + +export { Api } from './api.ts' +export type { ApiContext, ApiSpec } from './api.ts' + +export { PackagesConfig, load_config } from './config.ts' +export type { Config } from './config.ts' + +export { + create_rpc_handler, + handle_media_file_request, + handle_thumbnail_request, +} from './handlers/mod.ts' diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts new file mode 100644 index 00000000..c2c76c5f --- /dev/null +++ b/packages/server/src/server.ts @@ -0,0 +1,125 @@ +import { type Forager } from '@forager/core' +import { Logger, type LogLevel } from '@forager/core/logger' +import { trace } from '@opentelemetry/api' +import { create_rpc_handler } from './handlers/rpc.ts' +import { handle_media_file_request } from './handlers/media_file.ts' +import { handle_thumbnail_request } from './handlers/thumbnail.ts' +import type { Config } from './config.ts' +import type { ApiContext } from './api.ts' + + +export interface ForagerServerOptions { + forager: Forager + config: Config + logger?: { level: LogLevel } +} + +export interface ForagerServerListenOptions { + hostname?: string + port?: number +} + + +/** + * Framework-agnostic Forager HTTP server. + * + * Owns the RPC endpoint (`/rpc/*`) and the media/thumbnail streaming routes + * (`/files/media_file/:id`, `/files/thumbnail/:id`). Everything else is + * outside its scope — embedders (e.g. `@forager/web`'s SvelteKit shell) are + * expected to call {@linkcode ForagerServer.try_handle_request} first and + * fall back to their own handling when it returns `null`. + */ +export class ForagerServer { + readonly #context: ApiContext + readonly #logger: Logger + readonly #rpc_handler: (request: Request) => Promise + readonly #routes: { + rpc: URLPattern + media_file: URLPattern + thumbnail: URLPattern + } + #server?: Deno.HttpServer + + constructor(options: ForagerServerOptions) { + this.#logger = new Logger('forager.server', options.logger?.level) + this.#context = { forager: options.forager, config: options.config } + this.#rpc_handler = create_rpc_handler(this.#context) + this.#routes = { + rpc: new URLPattern({ pathname: '/rpc/:signature' }), + media_file: new URLPattern({ pathname: '/files/media_file/:id' }), + thumbnail: new URLPattern({ pathname: '/files/thumbnail/:id' }), + } + } + + /** + * Returns a `Response` if the request matches one of this server's owned + * routes, otherwise `null` so the embedder can route it elsewhere + * (e.g. SvelteKit's HTML routes). + */ + try_handle_request = async (request: Request): Promise => { + const url = new URL(request.url) + + const span = trace.getActiveSpan() + if (span) span.updateName(`${request.method} ${url.pathname}`) + + if (this.#routes.rpc.test(url)) { + this.#logger.debug(() => `${request.method} ${url.pathname} → rpc`) + return await this.#rpc_handler(request) + } + + const media_file_match = this.#routes.media_file.exec(url) + if (media_file_match) { + const id = media_file_match.pathname.groups.id ?? '' + this.#logger.debug(() => `${request.method} ${url.pathname} → media_file(${id})`) + return await handle_media_file_request(request, this.#context, { id }) + } + + const thumbnail_match = this.#routes.thumbnail.exec(url) + if (thumbnail_match) { + const id = thumbnail_match.pathname.groups.id ?? '' + this.#logger.debug(() => `${request.method} ${url.pathname} → thumbnail(${id})`) + return await handle_thumbnail_request(request, this.#context, { id }) + } + + return null + } + + /** + * Start a standalone HTTP server bound to `hostname:port`. Suitable for the + * upcoming `forager serve` command; not used by the SvelteKit-embedded path + * (which calls {@linkcode ForagerServer.try_handle_request} directly). + */ + async start(options: ForagerServerListenOptions = {}): Promise { + const hostname = options.hostname ?? '127.0.0.1' + const port = options.port ?? 8000 + + this.#server = Deno.serve({ + hostname, + port, + onError: (error) => { + this.#logger.error(`An error occurred on the server: ${error}`) + return new Response('Internal Error', { status: 500 }) + }, + onListen: (addr) => { + this.#logger.info(`Listening on ${addr.hostname}:${addr.port}`) + }, + }, async (request) => { + const response = await this.try_handle_request(request) + if (response) return response + return new Response('Not Found', { status: 404 }) + }) + + await this.#server.finished + } + + get status(): Promise | undefined { + return this.#server?.finished + } + + async shutdown(): Promise { + if (this.#server === undefined) { + throw new Error('Server has not been started yet') + } + await this.#server.shutdown() + } +} diff --git a/packages/web/adapter/lib/deno.json b/packages/web/adapter/lib/deno.json index c2ba1552..49e464d9 100644 --- a/packages/web/adapter/lib/deno.json +++ b/packages/web/adapter/lib/deno.json @@ -12,7 +12,6 @@ "@andykais/ts-rpc": "jsr:@andykais/ts-rpc@^0.2.2", "@std/http/file-server": "jsr:@std/http@0.224.5/file-server", "svelte": "npm:svelte@~5.39.8" - }, "exports": { diff --git a/packages/web/adapter/lib/mod.ts b/packages/web/adapter/lib/mod.ts index 28a9a431..b557e707 100644 --- a/packages/web/adapter/lib/mod.ts +++ b/packages/web/adapter/lib/mod.ts @@ -2,6 +2,8 @@ import {serveDir, serveFile, type ServeDirOptions} from '@std/http/file-server'; import * as path from '@std/path' import {Forager} from '@forager/core' import {Logger, type LogLevel} from '@forager/core/logger' +import { ForagerServer } from '@forager/server' +import { type Config } from '@forager/server/config' import { trace } from "@opentelemetry/api" // these relative imports are generated by the adapter in the build step @@ -28,6 +30,12 @@ interface ServerOptions { // special flag used for development. Disables fetching of assets from jsr.io preview?: boolean + // When provided, /rpc/* and /files/* are dispatched through @forager/server + // directly, bypassing SvelteKit. SvelteKit still handles all other (HTML) + // routes. When omitted, requests fall through to SvelteKit as before. + forager?: Forager + config?: Config + kit?: { // the key/value pair of environment variables handed to svelte kit server env?: Record @@ -43,6 +51,7 @@ class Server { #rootDir: string #kitServerInitialized!: Promise #logger: Logger + #forager_server?: ForagerServer #routes: { favicon: URLPattern immutable_asset: URLPattern @@ -76,6 +85,13 @@ class Server { fsRoot: this.#rootDir, quiet: this.#options.logger?.level !== 'DEBUG', } + if (options.forager && options.config) { + this.#forager_server = new ForagerServer({ + forager: options.forager, + config: options.config, + logger: options.logger, + }) + } } async init() { @@ -238,6 +254,13 @@ class Server { } } + // When the embedder gave us a Forager instance, dispatch /rpc/* and + // /files/* through @forager/server directly, bypassing SvelteKit. + if (this.#forager_server) { + const forager_response = await this.#forager_server.try_handle_request(request) + if (forager_response) return forager_response + } + this.#logger.debug(`respond with @sveltekit server`) // Pass to the SvelteKit server await this.#kitServerInitialized @@ -269,6 +292,8 @@ if (import.meta.main) { logger: config.web.logger, asset_folder: path.join(Deno.cwd(), 'static_assets'), preview: true, + forager, + config, kit: { env, } diff --git a/packages/web/src/lib/api.ts b/packages/web/src/lib/api.ts index 039ddc95..d820b74c 100644 --- a/packages/web/src/lib/api.ts +++ b/packages/web/src/lib/api.ts @@ -1,41 +1,4 @@ -import { type Forager } from '@forager/core' -import * as rpc from '@andykais/ts-rpc/adapters/sveltekit.ts' -import { type Config } from '$lib/server/config.ts' - - -interface Context { - forager: Forager - config: Config -} - -class ForagerTagApi extends rpc.ApiController { - search = this.context.forager.tag.search - get = this.context.forager.tag.get - update = this.context.forager.tag.update - alias_create = this.context.forager.tag.alias_create - alias_delete = this.context.forager.tag.alias_delete - parent_create = this.context.forager.tag.parent_create - parent_delete = this.context.forager.tag.parent_delete -} - -class ForagerApi extends rpc.ApiController { - media = this.context.forager.media - series = this.context.forager.series - tag = this.module(ForagerTagApi) - views = this.context.forager.views - -} - -export class Api extends rpc.ApiController { - forager = this.module(ForagerApi) - - config(): Config { - return this.context.config - } - - server_time(): Date { - return new Date() - } -} - -export type ApiSpec = rpc.InferSpec +// `@forager/server` owns the RPC `Api` definition as of Phase 1 of the +// Tauri port. This file remains as a re-export so existing `$lib/api.ts` +// imports (e.g. `BaseController`'s `rpc.create(...)`) keep working. +export { Api, type ApiSpec, type ApiContext } from '@forager/server/api' diff --git a/packages/web/src/lib/server/config.ts b/packages/web/src/lib/server/config.ts index c3d83c0f..21dc55ae 100644 --- a/packages/web/src/lib/server/config.ts +++ b/packages/web/src/lib/server/config.ts @@ -1,106 +1,8 @@ -import * as yaml from '@std/yaml' -import z from 'zod' -import * as forager from '@forager/core' - - -const LogLevel = z.enum(['SILENT', 'ERROR', 'WARN', 'INFO', 'DEBUG']) - -const Keybind = (default_keybind: string) => z.union([z.string(), z.string().array()]).default(default_keybind).transform(keybind => { - if (Array.isArray(keybind)) return keybind - else return [keybind] -}) - -export const PackagesConfig = z.object({ - core: forager.parsers.ForagerConfig, - - web: z.object({ - - // TODO reuse core editing config parser - editing: z.object({ - editor: z.string(), - overwrite: z.boolean().default(true), - }).strict().optional(), - - port: z.number().default(8000), - asset_folder: z.string(), - - logger: z.object({ - level: LogLevel.default('INFO'), - }).strict().prefault({}), - - ui_defaults: z.object({ - - search: z.object({ - advanced_filters: z.object({ - hide: z.boolean().default(true) - }).strict().prefault({}) - }).strict().prefault({}), - - media_list: z.object({ - thumbnail_size: z.number().default(110), - thumbnail_shape: z.enum(['square', 'original']).prefault('original') - }).strict().prefault({}), - - sidebar: z.object({ - hide: z.boolean().default(true), - size: z.number().default(200), - tags: z.object({ - order: z.object({ - group: z.string(), - }).array().prefault([]) - }).strict().prefault({}), - }).strict().prefault({}), - - media_view: z.object({ - filmstrip: z.object({ - enabled: z.boolean().default(false), - thumbnail_size: z.number().default(100), - }).strict().optional().transform(f => f ?? {enabled: false}) - }).strict().prefault({}), - }).strict().prefault({}), - - shortcuts: z.object({ - OpenMedia: Keybind('Enter'), - Escape: Keybind('Escape'), - - NextMedia: Keybind('ArrowRight'), - PrevMedia: Keybind('ArrowLeft'), - - NextTagSuggestion: Keybind('ArrowDown'), - PrevTagSuggestion: Keybind('ArrowUp'), - - CopyMedia: Keybind('Ctrl-C'), - - ToggleSidebar: Keybind('Ctrl-Backslash'), - - // DownMedia: Keybind('ArrowDown'), - // UpMedia: Keybind('ArrowUp'), - ToggleFitMedia: Keybind('Ctrl-Space'), - ToggleFullScreen: Keybind('KeyF'), - PlayPauseMedia: Keybind('Space'), - ToggleVideoMute: Keybind('KeyM'), - ToggleMediaControls: Keybind('Ctrl-C'), - - Search: Keybind('Slash'), - AddTag: Keybind('Ctrl-M'), - - // ToggleVideoPreviewVsThumbails: Keybind('KeyT'), - - Star0: Keybind('Digit0'), - Star1: Keybind('Digit1'), - Star2: Keybind('Digit2'), - Star3: Keybind('Digit3'), - Star4: Keybind('Digit4'), - Star5: Keybind('Digit5'), - }).strict().prefault({}), - }).strict(), -}).strict() - -export type Config = z.infer - - -export async function load_config(config_filepath: string): Promise { - const file_contents_string = await Deno.readTextFile(config_filepath) - const file_contents = yaml.parse(file_contents_string) - return PackagesConfig.parse(file_contents) -} +// `@forager/server` owns the config schema as of Phase 1 of the Tauri port. +// This file remains as a re-export so all the `$lib/server/config.ts` +// imports throughout the frontend keep working with no changes. +export { + PackagesConfig, + load_config, + type Config, +} from '@forager/server/config' diff --git a/packages/web/src/routes/files/media_file/[id]/+server.ts b/packages/web/src/routes/files/media_file/[id]/+server.ts index 190378e9..49c37364 100644 --- a/packages/web/src/routes/files/media_file/[id]/+server.ts +++ b/packages/web/src/routes/files/media_file/[id]/+server.ts @@ -1,203 +1,9 @@ import type { RequestHandler } from '@sveltejs/kit' -import { error } from '@sveltejs/kit' -import { errors } from '@forager/core' -import { z } from 'zod' -import path from 'node:path' +import { handle_media_file_request } from '@forager/server/handlers' +import type { ApiContext } from '@forager/server' -const ParamsSchema = z.object({ - id: z.coerce.number() -}) -/** - * Serves media files with proper HTTP range request support for video seeking. - * - * Security: Only serves files from configured media directories. - * Performance: Supports byte-range requests for efficient video streaming. - */ export const GET: RequestHandler = async ({ params, request, locals }) => { - // Validate params with Zod - const { id: media_reference_id } = ParamsSchema.parse(params) - - // Fetch media record from database (throws NotFoundError if not found) - let media - try { - media = await locals.forager.media.get({ media_reference_id }) - } catch (err) { - if (err instanceof errors.NotFoundError) { - throw error(404, 'Media not found') - } - throw err - } - - const filepath = media.media_file.filepath - - // Security: Validate file path is within allowed directories - const absolute_path = path.resolve(filepath) - // TODO: Add proper directory validation against config - // const allowed_dirs = [locals.config.core.media_paths] - // if (!allowed_dirs.some(dir => absolute_path.startsWith(path.resolve(dir)))) { - // throw error(403, 'Forbidden') - // } - - // Check file exists and is readable - let stat: Deno.FileInfo - try { - stat = await Deno.stat(absolute_path) - if (!stat.isFile) { - throw error(403, 'Path is not a file') - } - } catch { - throw error(404, 'File not found') - } - - // Get MIME type from codec/media_type - const mime_type = get_mime_type(media.media_file) - - // Handle Range Requests (crucial for video seeking) - const range_header = request.headers.get('range') - - if (range_header) { - return await handle_range_request(absolute_path, stat, mime_type, range_header) - } - - // Standard request - send entire file - const file = await Deno.open(absolute_path, { read: true }) - - return new Response(file.readable, { - status: 200, - headers: { - 'Content-Type': mime_type, - 'Content-Length': stat.size.toString(), - 'Accept-Ranges': 'bytes', - 'Cache-Control': 'public, max-age=31536000, immutable', - }, - }) -} - -/** - * Handle HTTP range requests for video seeking - * - * Example: Range: bytes=1024-2048 - */ -async function handle_range_request( - filepath: string, - stat: Deno.FileInfo, - mime_type: string, - range_header: string -): Promise { - // Parse range header: "bytes=start-end" - const match = range_header.match(/bytes=(\d+)-(\d*)/) - if (!match) { - throw error(416, 'Invalid range header') - } - - const file_size = stat.size - const start = parseInt(match[1]) - const end = match[2] ? parseInt(match[2]) : file_size - 1 - - // Validate range - if (start >= file_size || end >= file_size || start > end) { - return new Response(null, { - status: 416, - headers: { - 'Content-Range': `bytes */${file_size}`, - }, - }) - } - - const chunk_size = end - start + 1 - - // Open file and seek to start position - const file = await Deno.open(filepath, { read: true }) - await file.seek(start, Deno.SeekMode.Start) - - // Create a readable stream that only reads the requested range - const stream = new ReadableStream({ - async start(controller) { - const buffer = new Uint8Array(64 * 1024) // 64KB chunks - let bytes_read = 0 - - try { - while (bytes_read < chunk_size) { - const to_read = Math.min(buffer.length, chunk_size - bytes_read) - const n = await file.read(buffer.subarray(0, to_read)) - - if (n === null) break // EOF - - controller.enqueue(buffer.slice(0, n)) - bytes_read += n - } - } finally { - file.close() - controller.close() - } - }, - }) - - return new Response(stream, { - status: 206, // Partial Content - headers: { - 'Content-Type': mime_type, - 'Content-Length': chunk_size.toString(), - 'Content-Range': `bytes ${start}-${end}/${file_size}`, - 'Accept-Ranges': 'bytes', - 'Cache-Control': 'public, max-age=31536000', - }, - }) -} - -/** - * Map media codec to MIME type - */ -function get_mime_type(media_file: { media_type: string; codec: string }): string { - const { media_type, codec } = media_file - - if (media_type === 'VIDEO') { - switch (codec) { - case 'h264': - case 'hevc': - case 'av1': - return 'video/mp4' - case 'vp8': - case 'vp9': - return 'video/webm' - default: - throw new Error(`Unexpected code path. Unknown ${media_type} codec '${codec}'`) - } - } - - if (media_type === 'IMAGE') { - switch (codec) { - case 'png': - case 'apng': - return 'image/png' - case 'webp': - return 'image/webp' - case 'gif': - return 'image/gif' - case 'tiff': - return 'image/tiff' - case 'mjpeg': - return 'image/jpeg' - default: - throw new Error(`Unexpected code path. Unknown ${media_type} codec '${codec}'`) - } - } - - if (media_type === 'AUDIO') { - switch (codec) { - case 'aac': - return 'audio/aac' - case 'mp3': - return 'audio/mpeg' - case 'opus': - return 'audio/opus' - case 'vorbis': - return 'audio/ogg' - default: - throw new Error(`Unexpected code path. Unknown ${media_type} codec '${codec}'`) - } - } - - return 'application/octet-stream' + const context: ApiContext = locals as unknown as ApiContext + return await handle_media_file_request(request, context, { id: params.id ?? '' }) } diff --git a/packages/web/src/routes/files/thumbnail/[id]/+server.ts b/packages/web/src/routes/files/thumbnail/[id]/+server.ts index 91120492..20b9fe00 100644 --- a/packages/web/src/routes/files/thumbnail/[id]/+server.ts +++ b/packages/web/src/routes/files/thumbnail/[id]/+server.ts @@ -1,97 +1,9 @@ import type { RequestHandler } from '@sveltejs/kit' -import { error } from '@sveltejs/kit' -import { errors } from '@forager/core' -import { z } from 'zod' -import path from 'node:path' +import { handle_thumbnail_request } from '@forager/server/handlers' +import type { ApiContext } from '@forager/server' -const ParamsSchema = z.object({ - id: z.coerce.number() -}) -const QuerySchema = z.object({ - index: z.coerce.number().nullish().transform(val => val ?? 0) -}) - -/** - * Serves thumbnail images with aggressive caching. - * - * Security: Only serves files from the configured thumbnail directory. - * Performance: Immutable cache headers since thumbnails never change. - */ -export const GET: RequestHandler = async ({ params, url, locals }) => { - // Validate params with Zod - const { id: thumbnail_id } = ParamsSchema.parse(params) - const { index: thumbnail_index } = QuerySchema.parse({ - index: url.searchParams.get('index'), - }) - - // Fetch media record (throws NotFoundError if not found) - let thumbnail - try { - thumbnail = await locals.forager.media.thumbnail({ thumbnail_id }) - } catch (err) { - if (err instanceof errors.NotFoundError) { - throw error(404, 'Thumbnail not found') - } - throw err - } - - const thumbnail_path = thumbnail.filepath - - // Security: Validate thumbnail is in configured directory - const absolute_path = path.resolve(thumbnail_path) - const thumbnail_dir = path.resolve(locals.config.core.thumbnails.folder) - - if (!absolute_path.startsWith(thumbnail_dir)) { - throw error(403, 'Invalid thumbnail path') - } - - // Check file exists - let stat: Deno.FileInfo - try { - stat = await Deno.stat(absolute_path) - if (!stat.isFile) { - throw error(403, 'Path is not a file') - } - } catch { - throw error(404, 'Thumbnail file not found') - } - - // Open and stream the file - const file = await Deno.open(absolute_path, { read: true }) - - // Determine MIME type from file extension - const mime_type = get_thumbnail_mime_type(absolute_path) - - return new Response(file.readable, { - status: 200, - headers: { - 'Content-Type': mime_type, - 'Content-Length': stat.size.toString(), - 'Cache-Control': 'public, max-age=31536000, immutable', - // Add ETag for better caching - ETag: `"${stat.mtime?.getTime() ?? 0}-${stat.size}"`, - }, - }) -} - -/** - * Determine MIME type from thumbnail file extension - */ -function get_thumbnail_mime_type(filepath: string): string { - const ext = path.extname(filepath).toLowerCase() - - switch (ext) { - case '.jpg': - case '.jpeg': - return 'image/jpeg' - case '.png': - return 'image/png' - case '.webp': - return 'image/webp' - case '.gif': - return 'image/gif' - default: - return 'image/jpeg' - } +export const GET: RequestHandler = async ({ params, request, locals }) => { + const context: ApiContext = locals as unknown as ApiContext + return await handle_thumbnail_request(request, context, { id: params.id ?? '' }) } diff --git a/packages/web/src/routes/rpc/[signature]/+server.ts b/packages/web/src/routes/rpc/[signature]/+server.ts index d08bd725..e3d13bfb 100644 --- a/packages/web/src/routes/rpc/[signature]/+server.ts +++ b/packages/web/src/routes/rpc/[signature]/+server.ts @@ -1,17 +1,20 @@ -import type { RequestHandler } from "@sveltejs/kit" -import * as rpc from '@andykais/ts-rpc/adapters/sveltekit.ts' -import {Api} from '$lib/api.ts' +import type { RequestHandler } from '@sveltejs/kit' +import { create_rpc_handler } from '@forager/server/handlers' +import type { ApiContext } from '@forager/server' +// Cache the rpc handler per context (forager + config) so realtime/SSE state +// can be shared across requests. In practice context is a stable singleton +// for the lifetime of the SvelteKit server. +let cached_handler: ((req: Request) => Promise) | undefined +let cached_context: ApiContext | undefined -// this adapter stores stateful information like realtime connections. It should be instantiated once and reused -let API_SINGLETON: rpc.ServerAdapter | undefined - -export const PUT: RequestHandler = async (params) => { - if (API_SINGLETON === undefined) { - const context = params.locals - API_SINGLETON = rpc.adapt(Api, context) +export const PUT: RequestHandler = async ({ request, locals }) => { + const context: ApiContext = locals as unknown as ApiContext + if (!cached_handler || cached_context !== context) { + cached_handler = create_rpc_handler(context) + cached_context = context } - return await API_SINGLETON(params) + return await cached_handler(request) }