diff --git a/.dev.vars.example b/.dev.vars.example index f6b200d..067f20d 100644 --- a/.dev.vars.example +++ b/.dev.vars.example @@ -13,3 +13,11 @@ BETTER_AUTH_URL="http://localhost:3000" # LLAMA_CLOUD_API_KEY="" # MISTRAL_API_KEY="" # DATALAB_API_KEY="" + +# Autumn usage metering. Use a sandbox key for local development. +# AUTUMN_SECRET_KEY="am_sk_test_..." + +# Optional product analytics and error tracking. The project token is public; +# leave it unset to disable PostHog without changing application behavior. +# POSTHOG_PROJECT_TOKEN="" +# POSTHOG_HOST="https://us.i.posthog.com" diff --git a/README.md b/README.md index 2785260..8d38741 100644 --- a/README.md +++ b/README.md @@ -77,11 +77,14 @@ npx @file_router/cli@latest parse report.pdf | | Hosted API | Direct with your keys | | --------------------- | -------------------------------------- | -------------------------------------- | | Best for | The fastest setup | Keeping provider calls in your runtime | -| Authentication | One FileRouter API key | Your provider API keys | +| Authentication | A FileRouter API key | Your provider API keys | | Document sent through | FileRouter, then the selected provider | The selected provider only | +| Billing | FileRouter credits | Provider billing | | TypeScript | `FileRouterClient` | `FileRouter` | | CLI | Default after `filerouter login` | Add `--local` | +Credits pay for hosted processing. Each account receives 5,000 free credits each month, purchased credits never expire, and direct requests do not use FileRouter credits. + Direct TypeScript example: ```ts @@ -103,9 +106,24 @@ const result = await router.parse("./report.pdf", { - **LlamaParse** for layout-aware document parsing. - **Mistral OCR** for OCR with structured document output. - **Datalab** for document conversion and extraction. +- **LiteParse** for open-source parsing with optional OCR, screenshots, and + Office conversion. +- **PDF Inspector** for fast PDF classification and text-layer inspection. More adapters are coming. +
+Tech stack + +- **App:** TypeScript, React 19, TanStack Start, Router, and Query. +- **API and auth:** Hono with OpenAPI and Zod, plus Better Auth. +- **Durable backend:** Cloudflare Workers and Workflows, with D1 through + Drizzle ORM and R2 for documents and results. +- **SDK and CLI:** A pnpm workspace with provider-neutral TypeScript packages. +- **UI and tooling:** Tailwind CSS 4, Radix UI, Vite+, and Vitest. + +
+ ## Development FileRouter requires Node.js 22.14 or newer. diff --git a/docs/api/overview.mdx b/docs/api/overview.mdx index c11c0eb..2ad572e 100644 --- a/docs/api/overview.mdx +++ b/docs/api/overview.mdx @@ -40,3 +40,5 @@ API errors use `application/problem+json` and include a stable code, HTTP status "request_id": "..." } ``` + +A `402 Payment Required` response with code `insufficient_credits` means the account is out of credits. Add credits from the [dashboard](https://filerouter.dev/dashboard) before retrying the request. See [How credits work](/concepts/processing-modes#how-credits-work) for pricing details. diff --git a/docs/assets/favicon.svg b/docs/assets/favicon.svg index 7455f89..67756bd 100644 --- a/docs/assets/favicon.svg +++ b/docs/assets/favicon.svg @@ -1,3 +1,3 @@ - + diff --git a/docs/assets/filerouter-logo.svg b/docs/assets/filerouter-logo.svg index be19dc3..e907d56 100644 --- a/docs/assets/filerouter-logo.svg +++ b/docs/assets/filerouter-logo.svg @@ -1,3 +1,3 @@ - + diff --git a/docs/assets/filerouter-wordmark-dark.svg b/docs/assets/filerouter-wordmark-dark.svg index 9c543de..240e184 100644 --- a/docs/assets/filerouter-wordmark-dark.svg +++ b/docs/assets/filerouter-wordmark-dark.svg @@ -1,6 +1,6 @@ - + - + diff --git a/docs/assets/filerouter-wordmark-light.svg b/docs/assets/filerouter-wordmark-light.svg index fdf8f6f..e27ff9c 100644 --- a/docs/assets/filerouter-wordmark-light.svg +++ b/docs/assets/filerouter-wordmark-light.svg @@ -1,6 +1,6 @@ - + - + diff --git a/docs/cli/overview.mdx b/docs/cli/overview.mdx index b814a5b..e638047 100644 --- a/docs/cli/overview.mdx +++ b/docs/cli/overview.mdx @@ -1,5 +1,6 @@ --- title: "CLI" +sidebarTitle: "Overview" description: "Parse and compare documents from the terminal." --- diff --git a/docs/concepts/how-it-works.mdx b/docs/concepts/how-it-works.mdx index 7b9c2e2..104f46f 100644 --- a/docs/concepts/how-it-works.mdx +++ b/docs/concepts/how-it-works.mdx @@ -34,6 +34,10 @@ Processes a document with one provider and returns a `ParseResult`. Processes the same resolved document across several providers concurrently. The returned `CompareResult` retains a status and duration for every provider, including failures and unsupported output combinations. -## Built-in adapters +## Built-in providers -The SDK currently includes adapters for `llamaparse`, `mistral-ocr`, and `datalab`. Each adapter advertises its supported outputs and execution model through the provider catalog. +Hosted jobs can select `llamaparse`, `mistral-ocr`, `datalab`, `liteparse`, or +`pdf-inspector`. LiteParse and PDF Inspector run in separate private parser +pools so lightweight PDF inspection does not inherit the OCR and Office +conversion footprint. Every provider advertises its supported outputs and +execution model through its runtime adapter. diff --git a/docs/concepts/processing-modes.mdx b/docs/concepts/processing-modes.mdx index 1267828..7651dda 100644 --- a/docs/concepts/processing-modes.mdx +++ b/docs/concepts/processing-modes.mdx @@ -11,14 +11,30 @@ FileRouter supports two explicit processing modes. Neither mode silently falls b | Authentication | FileRouter API key | Provider API keys | | Document route | Your app → FileRouter → provider | Your app → provider | | Job orchestration | Managed by FileRouter | Runs in your process | +| Billing | FileRouter credits | Provider billing | | CLI | Default after `login` | Add `--local` | ## Hosted Hosted processing is the shortest path to production. FileRouter accepts the input, creates a durable job, submits it to the selected provider, polls long-running provider jobs, stores the result, and cleans up uploaded source documents after the job reaches a terminal state. +Credits pay for FileRouter-hosted processing. Each account receives **5,000 free credits each month**, and purchased credits never expire. Direct or BYOK requests do not use FileRouter credits. + +## How credits work + +**1,000 credits equal $1 of FileRouter-hosted usage.** Credit usage is based on the selected parser's upstream price and the infrastructure needed to run the job, with a 10% FileRouter beta margin included. Native parser usage depends on active processing time and allocated compute. + +A comparison uses credits for each provider that successfully returns a parsed result. Providers that fail before returning a result do not consume credits. Direct or BYOK requests are billed by the provider and never use FileRouter credits. + +Free credits reset each month. Purchased credits never expire and remain available after the monthly allowance resets. + Hosted binary uploads are limited to **100 MB**. Completed results expire after **7 days**, and job records are retained for **30 days**. +Public URL inputs are fetched once into temporary FileRouter storage before any +provider runs. This gives comparisons one immutable source and avoids sending a +user-controlled URL to private native parsers. Source objects are deleted when +the job reaches a terminal state. + ## Direct / BYOK Direct processing uses provider adapters from `@file_router/sdk` inside your runtime. Your document and provider credential do not pass through FileRouter, but the selected provider still receives the document. diff --git a/docs/docs.json b/docs/docs.json index ff1d865..2db9f59 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -2,13 +2,19 @@ "$schema": "https://mintlify.com/docs.json", "name": "FileRouter", "description": "Durable document parsing across providers.", - "theme": "luma", + "theme": "mint", "logo": { - "light": "/assets/filerouter-logo.svg", - "dark": "/assets/filerouter-logo.svg", + "light": "/assets/filerouter-wordmark-light.svg", + "dark": "/assets/filerouter-wordmark-dark.svg", "href": "https://filerouter.dev" }, "favicon": "/assets/favicon.svg", + "appearance": { + "default": "system" + }, + "fonts": { + "family": "Geist" + }, "colors": { "primary": "#00bdf7", "light": "#00bdf7", @@ -22,13 +28,12 @@ { "label": "GitHub", "href": "https://github.com/ThinkEx-OSS/filerouter" + }, + { + "label": "Dashboard", + "href": "https://filerouter.dev/dashboard" } - ], - "primary": { - "type": "button", - "label": "Dashboard", - "href": "https://filerouter.dev/dashboard" - } + ] }, "footer": { "socials": { @@ -37,40 +42,30 @@ } }, "navigation": { - "tabs": [ + "groups": [ { - "tab": "Documentation", - "groups": [ - { - "group": "Start", - "pages": ["index", "quickstart"] - }, - { - "group": "Concepts", - "pages": [ - "concepts/how-it-works", - "concepts/processing-modes", - "concepts/results" - ] - }, - { - "group": "TypeScript SDK", - "pages": ["sdk/parse", "sdk/compare", "sdk/provider-options"] - }, - { - "group": "CLI", - "pages": ["cli/overview"] - } - ] + "group": "Get started", + "pages": ["index", "quickstart"] }, { - "tab": "API Reference", - "groups": [ - { - "group": "Hosted API", - "pages": ["api/overview", "api/jobs"] - } + "group": "Concepts", + "pages": [ + "concepts/how-it-works", + "concepts/processing-modes", + "concepts/results" ] + }, + { + "group": "TypeScript SDK", + "pages": ["sdk/parse", "sdk/compare", "sdk/provider-options"] + }, + { + "group": "Command line", + "pages": ["cli/overview"] + }, + { + "group": "API reference", + "pages": ["api/overview", "api/jobs"] } ] } diff --git a/docs/index.mdx b/docs/index.mdx index c5ddbd0..e64082d 100644 --- a/docs/index.mdx +++ b/docs/index.mdx @@ -1,10 +1,9 @@ --- -title: "FileRouter" +title: "One API for document parsing" +sidebarTitle: "Overview" description: "Durable document parsing across providers through one API." --- -# One API for document parsing - FileRouter gives your application one contract for sending documents to parsing providers. Start with one provider, compare outputs when you need to, and keep provider-specific uploads, async jobs, and result shapes out of your application code. @@ -32,7 +31,7 @@ FileRouter gives your application one contract for sending documents to parsing - Provider submission and polling for long-running jobs - Consistent page numbers, outputs, timing, warnings, and errors - Side-by-side provider comparisons that preserve individual failures -- Hosted processing with one FileRouter key or direct processing with your provider keys +- Hosted processing with a FileRouter API key or direct processing with your provider keys FileRouter standardizes the workflow around document parsers. Provider diff --git a/docs/sdk/provider-options.mdx b/docs/sdk/provider-options.mdx index 521f6a7..a3ca12b 100644 --- a/docs/sdk/provider-options.mdx +++ b/docs/sdk/provider-options.mdx @@ -22,6 +22,8 @@ Each provider reads only its own namespace: - `providerOptions.llamaparse` - `providerOptions["mistral-ocr"]` - `providerOptions.datalab` +- `providerOptions.liteparse` +- `providerOptions["pdf-inspector"]` This keeps native settings available without flattening incompatible provider APIs into one shared object. @@ -32,3 +34,9 @@ This keeps native settings available without flattening incompatible provider AP Hosted requests encode provider options as JSON. The encoded options are limited to **64 KiB**. + +Hosted LiteParse supports curated options for managed OCR, Office conversion, +image handling, complexity analysis, and screenshots. Transport credentials, +custom OCR server URLs, passwords, and filesystem paths are rejected. FileRouter +never silently disables a requested capability; unsupported options fail the +job explicitly. diff --git a/package.json b/package.json index 4cfec9d..330f4a7 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "db:migrate": "wrangler d1 migrations apply DB --remote", "db:check": "drizzle-kit check", "r2:lifecycle": "wrangler r2 bucket lifecycle set filerouter-files --file infra/r2-lifecycle.json --force", - "test": "vp test run --passWithNoTests && pnpm test:worker && pnpm --filter @file_router/sdk test && pnpm --filter @file_router/cli test", + "test": "vp test run --passWithNoTests && pnpm test:worker && pnpm --filter @file_router/sdk test && pnpm --filter @file_router/cli test && pnpm --filter @file_router/native-parsers test", "test:worker": "vitest run --config vitest.worker.config.ts", "lint": "vp lint", "format": "vp fmt", @@ -43,12 +43,15 @@ "@tanstack/react-router-devtools": "1.167.0", "@tanstack/react-router-ssr-query": "1.167.1", "@tanstack/react-start": "1.168.30", + "autumn-js": "1.2.43", "better-auth": "^1.6.23", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "drizzle-orm": "^0.45.2", "hast-util-to-string": "^3.0.1", "hono": "^4.12.31", + "posthog-js": "1.404.1", + "posthog-node": "5.45.2", "prism-react-renderer": "^2.4.1", "radix-ui": "^1.6.1", "react": "^19.2.6", diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts index 059e316..c269483 100644 --- a/packages/cli/src/commands.ts +++ b/packages/cli/src/commands.ts @@ -1,6 +1,10 @@ import { defineCommand } from "citty" import { DEFAULT_PARSE_OUTPUT } from "@file_router/sdk" -import { DEFAULT_PROVIDER_ID, providerIds } from "@file_router/sdk/catalog" +import { + DEFAULT_PROVIDER_ID, + localProviderIds, + providerIds, +} from "@file_router/sdk/catalog" import { login } from "./login" import { @@ -52,6 +56,7 @@ export function createParseCommand(runtime: CliRuntime) { }, }, async run({ args }) { + assertProviderAvailable(args.provider, args.local) const outputs = parseOutputs(args.outputs) const result = await ( await runtime.createRouter(args.local) @@ -84,7 +89,6 @@ export function createCompareCommand(runtime: CliRuntime) { type: "string", alias: ["p"], description: "Comma-separated provider IDs.", - default: providerIds.join(","), }, outputs: { type: "string", @@ -108,11 +112,19 @@ export function createCompareCommand(runtime: CliRuntime) { }, }, async run({ args }) { + const providers = args.providers + ? parseProviders(args.providers) + : args.local + ? [...localProviderIds] + : [...providerIds] + providers.forEach((provider) => + assertProviderAvailable(provider, args.local) + ) const result = await ( await runtime.createRouter(args.local) ).compare(args.input, { outputs: parseOutputs(args.outputs), - providers: parseProviders(args.providers), + providers, }) await writeOutput( runtime, @@ -123,6 +135,16 @@ export function createCompareCommand(runtime: CliRuntime) { }) } +const localProviderIdSet = new Set(localProviderIds) + +function assertProviderAvailable(provider: string, local: boolean): void { + if (local && !localProviderIdSet.has(provider)) { + throw new Error( + `${provider} is hosted-only in this CLI build. Remove --local or choose ${localProviderIds.join(", ")}.` + ) + } +} + export function createProvidersCommand(runtime: CliRuntime) { return defineCommand({ meta: { diff --git a/packages/cli/test/cli.test.ts b/packages/cli/test/cli.test.ts index 5369331..736ded4 100644 --- a/packages/cli/test/cli.test.ts +++ b/packages/cli/test/cli.test.ts @@ -18,7 +18,9 @@ function createRuntime() { providers: { datalab: fakeProvider({ id: "datalab" }), llamaparse: fakeProvider({ id: "llamaparse" }), + liteparse: fakeProvider({ id: "liteparse" }), "mistral-ocr": fakeProvider({ id: "mistral-ocr" }), + "pdf-inspector": fakeProvider({ id: "pdf-inspector" }), }, }) const runtime: CliRuntime = { @@ -99,7 +101,7 @@ describe("FileRouter CLI", () => { }) const result = JSON.parse(stdout.join("")) - expect(result.providers).toHaveLength(3) + expect(result.providers).toHaveLength(5) expect( result.providers.every( ({ status }: { status: string }) => status === "parsed" @@ -107,6 +109,19 @@ describe("FileRouter CLI", () => { ).toBe(true) }) + test("uses only directly configured providers for local comparisons", async () => { + const { runtime, stdout } = createRuntime() + + await runCommand(createMainCommand(runtime), { + rawArgs: ["compare", reportPath, "--local", "--json"], + }) + + const result = JSON.parse(stdout.join("")) + expect( + result.providers.map(({ provider }: { provider: string }) => provider) + ).toEqual(["llamaparse", "mistral-ocr", "datalab"]) + }) + test("writes output files without also writing to stdout", async () => { const { files, runtime, stdout } = createRuntime() diff --git a/packages/filerouter/package.json b/packages/filerouter/package.json index 39bb02e..8cf6372 100644 --- a/packages/filerouter/package.json +++ b/packages/filerouter/package.json @@ -38,7 +38,8 @@ }, "./hosted": { "types": "./dist/hosted.d.ts", - "import": "./dist/hosted.js" + "import": "./dist/hosted.js", + "default": "./dist/hosted.js" }, "./llamaparse": { "types": "./dist/llamaparse.d.ts", diff --git a/packages/filerouter/src/catalog.ts b/packages/filerouter/src/catalog.ts index f8a44e3..2eca838 100644 --- a/packages/filerouter/src/catalog.ts +++ b/packages/filerouter/src/catalog.ts @@ -3,12 +3,26 @@ import { llamaparse } from "./llamaparse" import { mistralOcr } from "./mistral" import type { ProviderMap } from "./types" -export const providerIds = ["llamaparse", "mistral-ocr", "datalab"] as const +export const providerIds = [ + "llamaparse", + "mistral-ocr", + "datalab", + "liteparse", + "pdf-inspector", +] as const export type ProviderId = (typeof providerIds)[number] export const DEFAULT_PROVIDER_ID = "llamaparse" satisfies ProviderId +export const localProviderIds = [ + "llamaparse", + "mistral-ocr", + "datalab", +] as const satisfies ReadonlyArray + +export type LocalProviderId = (typeof localProviderIds)[number] + export interface BuiltInProviderOptions { datalabApiKey?: string llamaCloudApiKey?: string @@ -17,7 +31,7 @@ export interface BuiltInProviderOptions { export function builtInProviders( options: BuiltInProviderOptions = {} -): Record { +): Pick { return { llamaparse: llamaparse({ ...(options.llamaCloudApiKey && { apiKey: options.llamaCloudApiKey }), diff --git a/packages/filerouter/src/client.ts b/packages/filerouter/src/client.ts index 1bf8916..a55795f 100644 --- a/packages/filerouter/src/client.ts +++ b/packages/filerouter/src/client.ts @@ -1,24 +1,16 @@ import { FileRouterError } from "./errors" -import { - FILEROUTER_DEFAULT_API_URL, - HOSTED_JOB_HEADERS, - HOSTED_JOBS_PATH, - MAX_HOSTED_PROVIDER_OPTIONS_HEADER_BYTES, -} from "./hosted" -import type { HostedJobAccepted, HostedJobResponse } from "./hosted" +import { FILEROUTER_DEFAULT_API_URL } from "./hosted" import { readEnv, trimTrailingSlash } from "./internal/env" -import { requestJson } from "./internal/http" -import { resolveParseInput } from "./internal/input" -import { assertTimeoutMs } from "./internal/provider-options" -import { abortableSleep } from "./internal/sleep" -import { DEFAULT_PARSE_OUTPUT } from "./types" +import { withTimeout } from "./internal/timeout" +import { DEFAULT_HOSTED_JOB_TIMEOUT_MS, HostedJobs } from "./jobs" import type { - CompareOptions, - CompareResult, - ParseInput, - ParseOptions, - ParseResult, -} from "./types" + FileRouterJobs, + HostedCompareJobOptions, + HostedCompareOptions, + HostedParseJobOptions, + HostedParseOptions, +} from "./jobs" +import type { CompareResult, ParseInput, ParseResult } from "./types" export interface FileRouterClientOptions { apiKey?: string @@ -27,29 +19,8 @@ export interface FileRouterClientOptions { pollingIntervalMs?: number } -export interface HostedParseOptions extends ParseOptions { - idempotencyKey?: string -} - -export interface HostedCompareOptions extends CompareOptions { - idempotencyKey?: string -} - -interface JobRequest { - operation: "compare" | "parse" - includeRaw?: boolean - outputs: Array - pages?: Array - provider?: string - providerOptions?: ParseOptions["providerOptions"] - providers?: Array -} - export class FileRouterClient { - readonly #apiKey: string - readonly #baseURL: string - readonly #fetch: typeof globalThis.fetch | undefined - readonly #pollingIntervalMs: number + readonly jobs: FileRouterJobs constructor(options: FileRouterClientOptions = {}) { const apiKey = options.apiKey ?? readEnv("FILEROUTER_API_KEY") @@ -59,210 +30,71 @@ export class FileRouterClient { }) } - this.#apiKey = apiKey - this.#baseURL = trimTrailingSlash( - options.baseURL ?? - readEnv("FILEROUTER_API_URL") ?? - FILEROUTER_DEFAULT_API_URL - ) - this.#fetch = options.fetch - this.#pollingIntervalMs = options.pollingIntervalMs ?? 1000 + this.jobs = new HostedJobs({ + apiKey, + baseURL: trimTrailingSlash( + options.baseURL ?? + readEnv("FILEROUTER_API_URL") ?? + FILEROUTER_DEFAULT_API_URL + ), + ...(options.fetch && { fetch: options.fetch }), + ...(options.pollingIntervalMs !== undefined && { + pollingIntervalMs: options.pollingIntervalMs, + }), + }) } async parse( input: ParseInput, options: HostedParseOptions = {} ): Promise { - return this.#runJob( - input, - { + const { timeoutMs = DEFAULT_HOSTED_JOB_TIMEOUT_MS, ...jobOptions } = options + const startedAt = Date.now() + + const job = await withTimeout(timeoutMs, options.signal, (signal) => + this.jobs.create(input, { + ...jobOptions, operation: "parse", - ...(options.includeRaw !== undefined && { - includeRaw: options.includeRaw, - }), - outputs: options.outputs ?? [DEFAULT_PARSE_OUTPUT], - ...(options.pages && { pages: options.pages }), - ...(options.provider && { provider: options.provider }), - ...(options.providerOptions && { - providerOptions: options.providerOptions, - }), - }, - options + signal, + } satisfies HostedParseJobOptions) ) + return this.jobs.wait(job, { + timeoutMs: remainingTimeout(timeoutMs, startedAt), + ...(options.signal && { signal: options.signal }), + }) } async compare( input: ParseInput, options: HostedCompareOptions = {} ): Promise { - return this.#runJob( - input, - { - operation: "compare", - ...(options.includeRaw !== undefined && { - includeRaw: options.includeRaw, - }), - outputs: options.outputs ?? [DEFAULT_PARSE_OUTPUT], - ...(options.pages && { pages: options.pages }), - ...(options.providerOptions && { - providerOptions: options.providerOptions, - }), - ...(options.providers && { providers: options.providers }), - }, - options - ) - } - - async #runJob( - input: ParseInput, - request: JobRequest, - options: { - idempotencyKey?: string - signal?: AbortSignal - timeoutMs?: number - } - ): Promise { - const timeoutController = new AbortController() - const timeoutMs = options.timeoutMs ?? 10 * 60 * 1000 - assertTimeoutMs(timeoutMs) - const timeout = setTimeout(() => timeoutController.abort(), timeoutMs) - if (timeoutMs <= 0) { - timeoutController.abort() - } - const signal = options.signal - ? AbortSignal.any([options.signal, timeoutController.signal]) - : timeoutController.signal - - try { - const job = await this.#createJob( - input, - request, - signal, - options.idempotencyKey - ) - return await this.#waitForJob(job.id, signal) - } catch (error) { - if (timeoutController.signal.aborted && !options.signal?.aborted) { - throw new FileRouterError("FileRouter job timed out.", { - code: "Timeout", - }) - } - throw error - } finally { - clearTimeout(timeout) - } - } - - async #createJob( - input: ParseInput, - request: JobRequest, - signal: AbortSignal, - idempotencyKey: string = crypto.randomUUID() - ): Promise { - const serializedProviderOptions = request.providerOptions - ? stringifyJson(request.providerOptions, "Hosted provider options") - : undefined - const resolved = await resolveParseInput(input, signal) - const headers = this.#headers() - headers.set("Idempotency-Key", idempotencyKey) - let body: BodyInit + const { timeoutMs = DEFAULT_HOSTED_JOB_TIMEOUT_MS, ...jobOptions } = options + const startedAt = Date.now() - if (resolved.kind === "url") { - headers.set("Content-Type", "application/json") - body = stringifyJson( - { ...request, source: { url: resolved.url } }, - "Hosted job request" - ) - } else { - headers.set("Content-Type", "application/octet-stream") - headers.set(HOSTED_JOB_HEADERS.contentType, resolved.mimeType) - headers.set( - HOSTED_JOB_HEADERS.fileName, - encodeURIComponent(resolved.name) - ) - headers.set(HOSTED_JOB_HEADERS.operation, request.operation) - headers.set(HOSTED_JOB_HEADERS.outputs, request.outputs.join(",")) - if (request.pages) { - headers.set(HOSTED_JOB_HEADERS.pages, request.pages.join(",")) - } - if (request.includeRaw !== undefined) { - headers.set(HOSTED_JOB_HEADERS.includeRaw, String(request.includeRaw)) - } - if (serializedProviderOptions !== undefined) { - const encodedProviderOptions = encodeURIComponent( - serializedProviderOptions - ) - if ( - encodedProviderOptions.length > - MAX_HOSTED_PROVIDER_OPTIONS_HEADER_BYTES - ) { - throw new FileRouterError( - `Hosted provider options exceed the ${MAX_HOSTED_PROVIDER_OPTIONS_HEADER_BYTES / 1024} KiB header limit.`, - { code: "InvalidInput" } - ) - } - headers.set(HOSTED_JOB_HEADERS.providerOptions, encodedProviderOptions) - } - if (request.provider) { - headers.set(HOSTED_JOB_HEADERS.provider, request.provider) - } - if (request.providers) { - headers.set(HOSTED_JOB_HEADERS.providers, request.providers.join(",")) - } - body = resolved.data - } - - return requestJson( - `${this.#baseURL}${HOSTED_JOBS_PATH}`, - { - body, - fetch: this.#fetch, - headers, - method: "POST", - providerId: "filerouter", + const job = await withTimeout(timeoutMs, options.signal, (signal) => + this.jobs.create(input, { + ...jobOptions, + operation: "compare", signal, - } + } satisfies HostedCompareJobOptions) ) - } - - async #waitForJob(id: string, signal: AbortSignal): Promise { - while (true) { - const job = await requestJson>( - `${this.#baseURL}${HOSTED_JOBS_PATH}/${encodeURIComponent(id)}`, - { - fetch: this.#fetch, - headers: this.#headers(), - providerId: "filerouter", - signal, - } - ) - - if (job.status === "complete") { - return job.result - } - if (job.status === "failed") { - throw new FileRouterError(job.error, { code: "ParseFailed" }) - } - await abortableSleep(this.#pollingIntervalMs, signal) - } - } - - #headers(): Headers { - return new Headers({ Authorization: `Bearer ${this.#apiKey}` }) + return this.jobs.wait(job, { + timeoutMs: remainingTimeout(timeoutMs, startedAt), + ...(options.signal && { signal: options.signal }), + }) } } -function stringifyJson(value: unknown, label: string): string { - try { - const serialized = JSON.stringify(value) - if (serialized === undefined) { - throw new TypeError("Value is not JSON serializable.") - } - return serialized - } catch (cause) { - throw new FileRouterError(`${label} could not be serialized as JSON.`, { - cause, - code: "InvalidInput", - }) - } +function remainingTimeout(timeoutMs: number, startedAt: number): number { + return Math.max(0, timeoutMs - (Date.now() - startedAt)) } + +export type { + FileRouterJobs, + HostedCompareJobOptions, + HostedCompareOptions, + HostedJobGetOptions, + HostedJobWaitOptions, + HostedParseJobOptions, + HostedParseOptions, +} from "./jobs" diff --git a/packages/filerouter/src/datalab.ts b/packages/filerouter/src/datalab.ts index cc8e10d..873ecd7 100644 --- a/packages/filerouter/src/datalab.ts +++ b/packages/filerouter/src/datalab.ts @@ -1,9 +1,10 @@ import { FileRouterError } from "./errors" import { readEnv, trimTrailingSlash } from "./internal/env" -import { isRecord, requestJson } from "./internal/http" +import { requestJson } from "./internal/http" import { selectOutputs } from "./internal/outputs" import { providerOptions } from "./internal/provider-options" import { waitForProviderJob } from "./internal/polling" +import { isRecord } from "./internal/record" import { DEFAULT_PARSE_OUTPUT } from "./types" import type { FileRouterProvider, @@ -275,15 +276,17 @@ function normalizeDatalab( const images = normalizeImages(raw.images) const pageCount = readNumber(raw.page_count) ?? pages.length const qualityScore = readNumber(raw.parse_quality_score) - const totalCost = readNumber(raw.total_cost) + const costBreakdown = isRecord(raw.cost_breakdown) + ? raw.cost_breakdown + : undefined + const totalCost = + readNumber(costBreakdown?.total) ?? readNumber(raw.total_cost) const metadata = { ...(isRecord(raw.metadata) ? raw.metadata : {}), ...(typeof raw.checkpoint_id === "string" && { checkpointId: raw.checkpoint_id, }), - ...(isRecord(raw.cost_breakdown) && { - costBreakdown: raw.cost_breakdown, - }), + ...(costBreakdown && { costBreakdown }), ...(typeof raw.output_format === "string" && { outputFormat: raw.output_format, }), diff --git a/packages/filerouter/src/errors.ts b/packages/filerouter/src/errors.ts index 7587cdb..8b3ebba 100644 --- a/packages/filerouter/src/errors.ts +++ b/packages/filerouter/src/errors.ts @@ -4,6 +4,7 @@ export type FileRouterErrorCode = | "ProviderUnavailable" | "InvalidInput" | "Auth" + | "PaymentRequired" | "RateLimit" | "Timeout" | "ParseFailed" diff --git a/packages/filerouter/src/hosted.ts b/packages/filerouter/src/hosted.ts index 1ac1943..396fb7f 100644 --- a/packages/filerouter/src/hosted.ts +++ b/packages/filerouter/src/hosted.ts @@ -27,8 +27,21 @@ export const hostedJobStatuses = [ export type HostedJobStatus = (typeof hostedJobStatuses)[number] +export type HostedJobOperation = "compare" | "parse" + export type HostedJobAccepted = { id: string; status: HostedJobStatus } +export interface HostedJobHandle< + Operation extends HostedJobOperation = HostedJobOperation, +> extends HostedJobAccepted { + idempotencyKey: string + operation: Operation +} + +export type HostedParseJob = HostedJobHandle<"parse"> +export type HostedCompareJob = HostedJobHandle<"compare"> +export type HostedJob = HostedCompareJob | HostedParseJob + export type HostedJobResponse = | { error: string; id: string; status: "failed" } | { id: string; result: Result; status: "complete" } diff --git a/packages/filerouter/src/index.ts b/packages/filerouter/src/index.ts index 8fb2fb5..0d59052 100644 --- a/packages/filerouter/src/index.ts +++ b/packages/filerouter/src/index.ts @@ -8,7 +8,12 @@ export { export { FileRouterClient, type FileRouterClientOptions, + type FileRouterJobs, + type HostedCompareJobOptions, type HostedCompareOptions, + type HostedJobGetOptions, + type HostedJobWaitOptions, + type HostedParseJobOptions, type HostedParseOptions, } from "./client" export { @@ -25,19 +30,27 @@ export { HOSTED_JOBS_PATH, MAX_HOSTED_PROVIDER_OPTIONS_HEADER_BYTES, hostedJobStatuses, + type HostedCompareJob, + type HostedJob, type HostedJobAccepted, + type HostedJobHandle, + type HostedJobOperation, type HostedJobResponse, type HostedJobStatus, + type HostedParseJob, } from "./hosted" export { normalizeDocumentFileName, resolveDocumentMimeType, } from "./internal/input" +export { selectOutputs as selectParseOutputs } from "./internal/outputs" export type { CompareOptions, CompareProviderResult, CompareResult, FileRouterProvider, + LiteParseImageMode, + LiteParseParseOptions, ParseInput, ParseOptions, ParseOutput, @@ -47,6 +60,7 @@ export type { ParsedImage, ParsedTable, ParseWarning, + PdfInspectorParseOptions, ProviderCapabilities, ProviderInput, ProviderJobReference, diff --git a/packages/filerouter/src/internal/http.ts b/packages/filerouter/src/internal/http.ts index bc8e320..2b0b45f 100644 --- a/packages/filerouter/src/internal/http.ts +++ b/packages/filerouter/src/internal/http.ts @@ -1,5 +1,6 @@ import { FileRouterError, toFileRouterError } from "../errors" import type { FileRouterErrorCode } from "../errors" +import { isRecord } from "./record" export interface JsonRequestOptions extends RequestInit { fetch?: typeof globalThis.fetch | undefined @@ -46,6 +47,9 @@ function errorCodeForStatus(status: number): FileRouterErrorCode { if (status === 401 || status === 403) { return "Auth" } + if (status === 402) { + return "PaymentRequired" + } if (status === 408 || status === 504) { return "Timeout" } @@ -92,7 +96,3 @@ async function readJson(response: Response): Promise { return response.json() } - -export function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null -} diff --git a/packages/filerouter/src/internal/record.ts b/packages/filerouter/src/internal/record.ts new file mode 100644 index 0000000..c8dc521 --- /dev/null +++ b/packages/filerouter/src/internal/record.ts @@ -0,0 +1,3 @@ +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} diff --git a/packages/filerouter/src/internal/timeout.ts b/packages/filerouter/src/internal/timeout.ts new file mode 100644 index 0000000..6970a82 --- /dev/null +++ b/packages/filerouter/src/internal/timeout.ts @@ -0,0 +1,45 @@ +import { FileRouterError } from "../errors" +import { assertTimeoutMs } from "./provider-options" + +export async function withTimeout( + timeoutMs: number, + signal: AbortSignal | undefined, + operation: (signal: AbortSignal) => Promise +): Promise { + assertTimeoutMs(timeoutMs) + + const timeoutController = new AbortController() + const timeoutError = new FileRouterError("FileRouter job timed out.", { + code: "Timeout", + }) + const timeout = setTimeout( + () => timeoutController.abort(timeoutError), + timeoutMs + ) + if (timeoutMs === 0) { + timeoutController.abort(timeoutError) + } + const operationSignal = signal + ? AbortSignal.any([signal, timeoutController.signal]) + : timeoutController.signal + let rejectOnAbort: (() => void) | undefined + + try { + operationSignal.throwIfAborted() + const aborted = new Promise((_, reject) => { + rejectOnAbort = () => reject(operationSignal.reason) + operationSignal.addEventListener("abort", rejectOnAbort, { once: true }) + }) + return await Promise.race([operation(operationSignal), aborted]) + } catch (error) { + if (timeoutController.signal.aborted && !signal?.aborted) { + throw timeoutError + } + throw error + } finally { + clearTimeout(timeout) + if (rejectOnAbort) { + operationSignal.removeEventListener("abort", rejectOnAbort) + } + } +} diff --git a/packages/filerouter/src/jobs.ts b/packages/filerouter/src/jobs.ts new file mode 100644 index 0000000..9679644 --- /dev/null +++ b/packages/filerouter/src/jobs.ts @@ -0,0 +1,375 @@ +import { FileRouterError } from "./errors" +import { + HOSTED_JOB_HEADERS, + HOSTED_JOBS_PATH, + MAX_HOSTED_PROVIDER_OPTIONS_HEADER_BYTES, +} from "./hosted" +import type { + HostedCompareJob, + HostedJob, + HostedJobAccepted, + HostedJobHandle, + HostedJobOperation, + HostedJobResponse, + HostedParseJob, +} from "./hosted" +import { requestJson } from "./internal/http" +import { resolveParseInput } from "./internal/input" +import { assertPages } from "./internal/provider-options" +import { abortableSleep } from "./internal/sleep" +import { withTimeout } from "./internal/timeout" +import { DEFAULT_PARSE_OUTPUT } from "./types" +import type { + CompareOptions, + CompareResult, + ParseInput, + ParseOptions, + ParseOutput, + ParseResult, +} from "./types" + +export const DEFAULT_HOSTED_JOB_TIMEOUT_MS = 10 * 60 * 1000 + +const MAX_TRANSIENT_ATTEMPTS = 3 +const INITIAL_RETRY_DELAY_MS = 250 +const MAX_RETRY_DELAY_MS = 10_000 + +export interface HostedParseOptions extends ParseOptions { + idempotencyKey?: string +} + +export interface HostedCompareOptions extends CompareOptions { + idempotencyKey?: string +} + +export interface HostedParseJobOptions extends Omit< + HostedParseOptions, + "timeoutMs" +> { + operation?: "parse" +} + +export interface HostedCompareJobOptions extends Omit< + HostedCompareOptions, + "timeoutMs" +> { + operation: "compare" +} + +export interface HostedJobGetOptions { + signal?: AbortSignal +} + +export interface HostedJobWaitOptions< + Result = ParseResult | CompareResult, +> extends HostedJobGetOptions { + onStatus?: (job: HostedJobResponse) => void + timeoutMs?: number +} + +type HostedResultFor = + Operation extends "parse" ? ParseResult : CompareResult + +export interface FileRouterJobs { + create( + input: ParseInput, + options?: HostedParseJobOptions + ): Promise + create( + input: ParseInput, + options: HostedCompareJobOptions + ): Promise + + get( + job: HostedJobHandle, + options?: HostedJobGetOptions + ): Promise>> + get( + id: string, + options?: HostedJobGetOptions + ): Promise> + + wait( + job: HostedJobHandle, + options?: HostedJobWaitOptions> + ): Promise> + wait( + id: string, + options?: HostedJobWaitOptions + ): Promise +} + +interface HostedJobsOptions { + apiKey: string + baseURL: string + fetch?: typeof globalThis.fetch + pollingIntervalMs?: number +} + +interface JobRequest { + operation: "compare" | "parse" + includeRaw?: boolean + outputs: Array + pages?: Array + provider?: string + providerOptions?: ParseOptions["providerOptions"] + providers?: Array +} + +export class HostedJobs implements FileRouterJobs { + readonly #apiKey: string + readonly #baseURL: string + readonly #fetch: typeof globalThis.fetch | undefined + readonly #pollingIntervalMs: number + + constructor(options: HostedJobsOptions) { + this.#apiKey = options.apiKey + this.#baseURL = options.baseURL + this.#fetch = options.fetch + this.#pollingIntervalMs = options.pollingIntervalMs ?? 1000 + } + + create( + input: ParseInput, + options?: HostedParseJobOptions + ): Promise + create( + input: ParseInput, + options: HostedCompareJobOptions + ): Promise + async create( + input: ParseInput, + options: HostedParseJobOptions | HostedCompareJobOptions = {} + ): Promise { + assertPages(options.pages) + const idempotencyKey = options.idempotencyKey ?? crypto.randomUUID() + const request = createJobRequest(options) + const resolved = await resolveParseInput(input, options.signal) + const headers = this.#headers() + headers.set("Idempotency-Key", idempotencyKey) + const body = createRequestBody(headers, resolved, request) + + const accepted = await retryTransient( + () => + requestJson(`${this.#baseURL}${HOSTED_JOBS_PATH}`, { + body, + headers, + method: "POST", + providerId: "filerouter", + ...(this.#fetch && { fetch: this.#fetch }), + ...(options.signal && { signal: options.signal }), + }), + options.signal + ) + + return { ...accepted, idempotencyKey, operation: request.operation } + } + + get( + job: HostedJobHandle, + options?: HostedJobGetOptions + ): Promise>> + get( + id: string, + options?: HostedJobGetOptions + ): Promise> + get( + job: HostedJob | string, + options: HostedJobGetOptions = {} + ): Promise> { + return this.#get(jobId(job), options.signal) + } + + wait( + job: HostedJobHandle, + options?: HostedJobWaitOptions> + ): Promise> + wait( + id: string, + options?: HostedJobWaitOptions + ): Promise + wait( + job: HostedJob | string, + options: HostedJobWaitOptions = {} + ): Promise { + return withTimeout( + options.timeoutMs ?? DEFAULT_HOSTED_JOB_TIMEOUT_MS, + options.signal, + (signal) => this.#wait(jobId(job), signal, options.onStatus) + ) + } + + async #get( + id: string, + signal?: AbortSignal + ): Promise> { + return requestJson>( + `${this.#baseURL}${HOSTED_JOBS_PATH}/${encodeURIComponent(id)}`, + { + headers: this.#headers(), + providerId: "filerouter", + ...(this.#fetch && { fetch: this.#fetch }), + ...(signal && { signal }), + } + ) + } + + async #wait( + id: string, + signal: AbortSignal, + onStatus?: (job: HostedJobResponse) => void + ): Promise { + let previousStatus: HostedJobResponse["status"] | undefined + + while (true) { + const job = await retryTransient( + () => this.#get(id, signal), + signal + ) + + if (job.status !== previousStatus) { + onStatus?.(job) + previousStatus = job.status + } + if (job.status === "complete") { + return job.result + } + if (job.status === "failed") { + throw new FileRouterError(job.error, { + code: "ParseFailed", + providerId: "filerouter", + }) + } + await abortableSleep(this.#pollingIntervalMs, signal) + } + } + + #headers(): Headers { + return new Headers({ Authorization: `Bearer ${this.#apiKey}` }) + } +} + +function createJobRequest( + options: HostedParseJobOptions | HostedCompareJobOptions +): JobRequest { + const operation = options.operation ?? "parse" + const request: JobRequest = { + operation, + outputs: options.outputs ?? [DEFAULT_PARSE_OUTPUT], + ...(options.includeRaw !== undefined && { + includeRaw: options.includeRaw, + }), + ...(options.pages && { pages: options.pages }), + ...(options.providerOptions && { + providerOptions: options.providerOptions, + }), + } + + if (options.operation === "compare") { + return { + ...request, + ...(options.providers && { providers: options.providers }), + } + } + + return { + ...request, + ...(options.provider && { provider: options.provider }), + } +} + +function createRequestBody( + headers: Headers, + input: Awaited>, + request: JobRequest +): BodyInit { + if (input.kind === "url") { + headers.set("Content-Type", "application/json") + return stringifyJson( + { ...request, source: { url: input.url } }, + "Hosted job request" + ) + } + + headers.set("Content-Type", "application/octet-stream") + headers.set(HOSTED_JOB_HEADERS.contentType, input.mimeType) + headers.set(HOSTED_JOB_HEADERS.fileName, encodeURIComponent(input.name)) + headers.set(HOSTED_JOB_HEADERS.operation, request.operation) + headers.set(HOSTED_JOB_HEADERS.outputs, request.outputs.join(",")) + if (request.pages) { + headers.set(HOSTED_JOB_HEADERS.pages, request.pages.join(",")) + } + if (request.includeRaw !== undefined) { + headers.set(HOSTED_JOB_HEADERS.includeRaw, String(request.includeRaw)) + } + if (request.providerOptions) { + const providerOptions = encodeURIComponent( + stringifyJson(request.providerOptions, "Hosted provider options") + ) + if (providerOptions.length > MAX_HOSTED_PROVIDER_OPTIONS_HEADER_BYTES) { + throw new FileRouterError( + `Hosted provider options exceed the ${MAX_HOSTED_PROVIDER_OPTIONS_HEADER_BYTES / 1024} KiB header limit.`, + { code: "InvalidInput" } + ) + } + headers.set(HOSTED_JOB_HEADERS.providerOptions, providerOptions) + } + if (request.provider) { + headers.set(HOSTED_JOB_HEADERS.provider, request.provider) + } + if (request.providers) { + headers.set(HOSTED_JOB_HEADERS.providers, request.providers.join(",")) + } + return input.data +} + +async function retryTransient( + operation: () => Promise, + signal?: AbortSignal +): Promise { + for (let attempt = 1; ; attempt += 1) { + try { + return await operation() + } catch (error) { + if ( + attempt >= MAX_TRANSIENT_ATTEMPTS || + !FileRouterError.isInstance(error) || + !error.retryable + ) { + throw error + } + + await abortableSleep(retryDelay(error, attempt), signal) + } + } +} + +function retryDelay(error: FileRouterError, attempt: number): number { + if (error.retryAfterMs !== undefined) { + return error.retryAfterMs + } + const ceiling = Math.min( + INITIAL_RETRY_DELAY_MS * 2 ** (attempt - 1), + MAX_RETRY_DELAY_MS + ) + return Math.round(ceiling * (0.5 + Math.random() * 0.5)) +} + +function jobId(job: HostedJob | string): string { + return typeof job === "string" ? job : job.id +} + +function stringifyJson(value: unknown, label: string): string { + try { + const serialized = JSON.stringify(value) + if (serialized === undefined) { + throw new TypeError("Value is not JSON serializable.") + } + return serialized + } catch (cause) { + throw new FileRouterError(`${label} could not be serialized as JSON.`, { + cause, + code: "InvalidInput", + }) + } +} diff --git a/packages/filerouter/src/llamaparse.ts b/packages/filerouter/src/llamaparse.ts index 9f2ffaf..6c76399 100644 --- a/packages/filerouter/src/llamaparse.ts +++ b/packages/filerouter/src/llamaparse.ts @@ -3,6 +3,7 @@ import { readEnv } from "./internal/env" import { selectOutputs } from "./internal/outputs" import { waitForProviderJob } from "./internal/polling" import { providerOptions } from "./internal/provider-options" +import { isRecord } from "./internal/record" import { DEFAULT_PARSE_OUTPUT } from "./types" import type { ClientOptions, LlamaCloud } from "@llamaindex/llama-cloud" import type { @@ -220,7 +221,7 @@ const resolveClient = async ( } const outputsToExpand = (outputs: Array): Array => { - const expand = new Set() + const expand = new Set(["job_metadata"]) if (outputs.includes("markdown") || outputs.includes("pages")) { expand.add("markdown") @@ -241,7 +242,6 @@ const outputsToExpand = (outputs: Array): Array => { } if (outputs.includes("metadata") || outputs.includes("pages")) { expand.add("metadata") - expand.add("job_metadata") } return Array.from(expand) @@ -539,8 +539,5 @@ const stringField = (value: unknown): string | undefined => const numberField = (value: unknown): number | undefined => typeof value === "number" && Number.isFinite(value) ? value : undefined -const isRecord = (value: unknown): value is Record => - typeof value === "object" && value !== null - const objectField = (value: unknown): Record => isRecord(value) ? value : {} diff --git a/packages/filerouter/src/mistral.ts b/packages/filerouter/src/mistral.ts index c594399..dbaee3a 100644 --- a/packages/filerouter/src/mistral.ts +++ b/packages/filerouter/src/mistral.ts @@ -26,6 +26,11 @@ import type { } from "./types" const PROVIDER_ID = "mistral-ocr" +const OCR_MODEL_RATES = { + "mistral-ocr-2512": { annotated: 0.003, standard: 0.002 }, + "mistral-ocr-4-0": { annotated: 0.005, standard: 0.004 }, + "mistral-ocr-latest": { annotated: 0.005, standard: 0.004 }, +} as const const OUTPUTS = [ "images", "json", @@ -100,28 +105,28 @@ async function parseMistral( uploadedFileId = document.fileId } - const response = await client.ocr.process( - { - ...native, - document, - includeImageBase64: - outputs.includes("images") || native.includeImageBase64 === true, - model: native.model ?? options.model ?? "mistral-ocr-latest", - ...(parseOptions.pages && { - pages: parseOptions.pages.map((page) => page - 1), - }), - ...(outputs.includes("tables") && { - tableFormat: native.tableFormat ?? "markdown", - }), - }, - requestOptions - ) + const request: OCRRequest = { + ...native, + document, + includeImageBase64: + outputs.includes("images") || native.includeImageBase64 === true, + model: native.model ?? options.model ?? "mistral-ocr-4-0", + ...(parseOptions.pages && { + pages: parseOptions.pages.map((page) => page - 1), + }), + ...(outputs.includes("tables") && { + tableFormat: native.tableFormat ?? "markdown", + }), + } + const response = await client.ocr.process(request, requestOptions) return normalizeMistral( response, outputs, parseOptions.includeRaw === true, - startedAt + startedAt, + request.bboxAnnotationFormat != null || + request.documentAnnotationFormat != null ) } finally { if (uploadedFileId) { @@ -216,7 +221,8 @@ function normalizeMistral( raw: OCRResponse, requestedOutputs: Array, includeRaw: boolean, - startedAt: Date + startedAt: Date, + annotated: boolean ): ParseResult { const pages = raw.pages.map(normalizePage) const markdown = pages.map((page) => page.markdown ?? "").join("\n\n---\n\n") @@ -228,6 +234,11 @@ function normalizeMistral( usage: raw.usageInfo, } const completedAt = new Date() + const costUsd = mistralOcrCostUsd( + raw.model, + raw.usageInfo.pagesProcessed, + annotated + ) return { id: crypto.randomUUID(), outputs: selectOutputs(requestedOutputs, { @@ -246,11 +257,25 @@ function normalizeMistral( durationMs: completedAt.getTime() - startedAt.getTime(), startedAt: startedAt.toISOString(), }, - usage: { pages: raw.usageInfo.pagesProcessed }, + usage: { + ...(costUsd !== undefined && { costUsd }), + pages: raw.usageInfo.pagesProcessed, + }, warnings: [], } } +function mistralOcrCostUsd( + model: string, + pages: number, + annotated: boolean +): number | undefined { + const rates = OCR_MODEL_RATES[model as keyof typeof OCR_MODEL_RATES] + return rates + ? pages * (annotated ? rates.annotated : rates.standard) + : undefined +} + function normalizePage(raw: OCRPageObject): ParsePage { const pageNumber = raw.index + 1 return { diff --git a/packages/filerouter/src/types.ts b/packages/filerouter/src/types.ts index aece5e8..584c74d 100644 --- a/packages/filerouter/src/types.ts +++ b/packages/filerouter/src/types.ts @@ -2,6 +2,30 @@ import type { DatalabParseOptions } from "./datalab" import type { LlamaParseParseOptions } from "./llamaparse" import type { MistralOcrParseOptions } from "./mistral" +export type LiteParseImageMode = "embed" | "off" | "placeholder" + +export interface LiteParseParseOptions { + convertOffice?: boolean + imageMode?: LiteParseImageMode + includeComplexity?: boolean + ocr?: "auto" | "off" + /** The hosted image currently includes English OCR data. */ + ocrLanguage?: "eng" + raw?: { + cropBox?: { bottom: number; left: number; right: number; top: number } + dpi?: number + emitWordBoxes?: boolean + extractLinks?: boolean + ocrFailureFatal?: boolean + preserveVerySmallText?: boolean + quiet?: boolean + skipDiagonalText?: boolean + } + screenshots?: boolean +} + +export type PdfInspectorParseOptions = Record + export const parseOutputIds = [ "chunks", "html", @@ -43,7 +67,9 @@ export type ProviderInput = export interface ProviderParseOptions { datalab?: DatalabParseOptions llamaparse?: LlamaParseParseOptions + liteparse?: LiteParseParseOptions "mistral-ocr"?: MistralOcrParseOptions + "pdf-inspector"?: PdfInspectorParseOptions [providerId: string]: unknown } @@ -172,7 +198,11 @@ export interface ProviderCapabilities { | "blocks" | "cancel" | "confidence" + | "classification" + | "ocr" + | "office-conversion" | "page-selection" + | "screenshots" | "structured-extraction" > outputs: Array diff --git a/packages/filerouter/test/catalog.test.ts b/packages/filerouter/test/catalog.test.ts index f44b028..8c69d93 100644 --- a/packages/filerouter/test/catalog.test.ts +++ b/packages/filerouter/test/catalog.test.ts @@ -3,12 +3,18 @@ import { describe, expect, test } from "vite-plus/test" import { builtInProviders, DEFAULT_PROVIDER_ID, + localProviderIds, providerIds, } from "../src/catalog" describe("built-in provider catalog", () => { test("keeps the default and provider order canonical", () => { expect(DEFAULT_PROVIDER_ID).toBe(providerIds[0]) - expect(Object.keys(builtInProviders())).toEqual(providerIds) + expect(Object.keys(builtInProviders())).toEqual([ + "llamaparse", + "mistral-ocr", + "datalab", + ]) + expect(Object.keys(builtInProviders())).toEqual(localProviderIds) }) }) diff --git a/packages/filerouter/test/client.test.ts b/packages/filerouter/test/client.test.ts index 11be6d5..1541755 100644 --- a/packages/filerouter/test/client.test.ts +++ b/packages/filerouter/test/client.test.ts @@ -197,7 +197,7 @@ describe("FileRouterClient", () => { fetch: fetchMock, }) - const request = client.parse("https://example.com/report.pdf") + const request = client.jobs.get("job-rate-limited") await expect(request).rejects.toMatchObject({ code: "RateLimit", @@ -211,6 +211,27 @@ describe("FileRouterClient", () => { }) }) + test("identifies an empty hosted credit balance", async () => { + const client = new FileRouterClient({ + apiKey: "fr_test_key", + baseURL: "https://example.com", + fetch: vi + .fn() + .mockResolvedValue( + Response.json({ detail: "You're out of credits." }, { status: 402 }) + ), + }) + + await expect(client.jobs.get("job-payment-required")).rejects.toMatchObject( + { + code: "PaymentRequired", + providerId: "filerouter", + retryable: false, + statusCode: 402, + } + ) + }) + test("normalizes hosted network failures", async () => { const client = new FileRouterClient({ apiKey: "fr_test_key", diff --git a/packages/filerouter/test/datalab.test.ts b/packages/filerouter/test/datalab.test.ts index c5e2272..e816303 100644 --- a/packages/filerouter/test/datalab.test.ts +++ b/packages/filerouter/test/datalab.test.ts @@ -18,13 +18,12 @@ describe("Datalab provider", () => { Response.json({ images: { "figure.png": "base64-image" }, checkpoint_id: "checkpoint-1", - cost_breakdown: { conversion: 7 }, + cost_breakdown: { conversion: 7, total: 7 }, markdown: "# Done", output_format: "markdown,json", page_count: 2, parse_quality_score: 4.5, status: "complete", - total_cost: 7, }) ) const provider = datalab({ diff --git a/packages/filerouter/test/jobs.test.ts b/packages/filerouter/test/jobs.test.ts new file mode 100644 index 0000000..da66099 --- /dev/null +++ b/packages/filerouter/test/jobs.test.ts @@ -0,0 +1,227 @@ +import { describe, expect, expectTypeOf, test, vi } from "vite-plus/test" + +import { FileRouterClient } from "../src/client" +import type { HostedJobResponse } from "../src/hosted" +import type { CompareResult, ParseResult } from "../src/types" + +describe("FileRouterClient jobs", () => { + test("creates a recoverable parse job without polling", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(Response.json({ id: "job-1", status: "queued" })) + const client = createClient(fetchMock) + + const job = await client.jobs.create("https://example.com/report.pdf", { + idempotencyKey: "stable-job-key", + provider: "llamaparse", + }) + + expect(job).toEqual({ + id: "job-1", + idempotencyKey: "stable-job-key", + operation: "parse", + status: "queued", + }) + expect(fetchMock).toHaveBeenCalledOnce() + expect(readJsonBody(fetchMock, 0)).toEqual({ + operation: "parse", + outputs: ["markdown"], + provider: "llamaparse", + source: { url: "https://example.com/report.pdf" }, + }) + }) + + test("creates typed comparison jobs", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(Response.json({ id: "job-2", status: "queued" })) + const client = createClient(fetchMock) + + const job = await client.jobs.create("https://example.com/report.pdf", { + operation: "compare", + providers: ["llamaparse", "mistral-ocr"], + }) + + expect(job.operation).toBe("compare") + expectTypeOf(() => client.jobs.wait(job)).returns.toEqualTypeOf< + Promise + >() + expect(readJsonBody(fetchMock, 0)).toMatchObject({ + operation: "compare", + providers: ["llamaparse", "mistral-ocr"], + }) + }) + + test("reads a job without waiting", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(Response.json({ id: "job-3", status: "running" })) + const client = createClient(fetchMock) + + await expect(client.jobs.get("job-3")).resolves.toEqual({ + id: "job-3", + status: "running", + }) + expect(requestUrl(fetchMock, 0)).toBe( + "https://example.com/api/v1/jobs/job-3" + ) + }) + + test("waits on an existing job and reports status changes once", async () => { + const result = parseResult("job-4") + const fetchMock = vi + .fn() + .mockResolvedValueOnce(Response.json({ id: "job-4", status: "queued" })) + .mockResolvedValueOnce(Response.json({ id: "job-4", status: "queued" })) + .mockResolvedValueOnce(Response.json({ id: "job-4", status: "running" })) + .mockResolvedValueOnce( + Response.json({ id: "job-4", result, status: "complete" }) + ) + const client = createClient(fetchMock) + const statuses: Array["status"]> = [] + + await expect( + client.jobs.wait("job-4", { + onStatus: (job) => statuses.push(job.status), + }) + ).resolves.toEqual(result) + expect(statuses).toEqual(["queued", "running", "complete"]) + }) + + test("retries ambiguous creation failures with one idempotency key", async () => { + const fetchMock = vi + .fn() + .mockRejectedValueOnce(new TypeError("connection reset")) + .mockResolvedValueOnce(Response.json({ id: "job-5", status: "queued" })) + const client = createClient(fetchMock) + + const job = await client.jobs.create("https://example.com/report.pdf") + + expect(job.id).toBe("job-5") + expect(fetchMock).toHaveBeenCalledTimes(2) + const firstKey = requestHeaders(fetchMock, 0).get("idempotency-key") + expect(firstKey).toBeTruthy() + expect(requestHeaders(fetchMock, 1).get("idempotency-key")).toBe(firstKey) + expect(job.idempotencyKey).toBe(firstKey) + }) + + test("retries transient polling failures", async () => { + const result = parseResult("job-6") + const fetchMock = vi + .fn() + .mockRejectedValueOnce(new TypeError("temporary network failure")) + .mockResolvedValueOnce( + Response.json({ id: "job-6", result, status: "complete" }) + ) + const client = createClient(fetchMock) + + await expect(client.jobs.wait("job-6")).resolves.toEqual( + result + ) + expect(fetchMock).toHaveBeenCalledTimes(2) + }) + + test("surfaces terminal job failures", async () => { + const fetchMock = vi.fn().mockResolvedValue( + Response.json({ + error: "The provider rejected the document.", + id: "job-7", + status: "failed", + }) + ) + const client = createClient(fetchMock) + + await expect(client.jobs.wait("job-7")).rejects.toMatchObject({ + code: "ParseFailed", + message: "The provider rejected the document.", + providerId: "filerouter", + }) + }) + + test("applies wait timeouts before polling", async () => { + const fetchMock = vi.fn() + const client = createClient(fetchMock) + + await expect( + client.jobs.wait("job-8", { timeoutMs: 0 }) + ).rejects.toMatchObject({ code: "Timeout" }) + expect(fetchMock).not.toHaveBeenCalled() + }) + + test("enforces wait timeouts when custom fetch ignores abort signals", async () => { + const fetchMock = vi.fn( + () => new Promise(() => undefined) + ) + const client = createClient(fetchMock) + + await expect( + client.jobs.wait("job-9", { timeoutMs: 10 }) + ).rejects.toMatchObject({ code: "Timeout" }) + expect(fetchMock).toHaveBeenCalledOnce() + }) + + test("validates pages before resolving file input", async () => { + const fetchMock = vi.fn() + const client = createClient(fetchMock) + + await expect( + client.jobs.create( + { kind: "file", path: "/file-that-must-not-be-read.pdf" }, + { pages: [0] } + ) + ).rejects.toMatchObject({ + message: "Pages must be positive, one-based integers.", + }) + expect(fetchMock).not.toHaveBeenCalled() + }) +}) + +function createClient(fetchMock: typeof fetch): FileRouterClient { + return new FileRouterClient({ + apiKey: "fr_test_key", + baseURL: "https://example.com", + fetch: fetchMock, + pollingIntervalMs: 0, + }) +} + +function parseResult(id: string): ParseResult { + return { + id, + outputs: { markdown: "# Parsed" }, + pageCount: 1, + provider: "llamaparse", + timing: { + completedAt: "2026-07-21T00:00:01.000Z", + durationMs: 1000, + startedAt: "2026-07-21T00:00:00.000Z", + }, + warnings: [], + } +} + +function readJsonBody( + fetchMock: ReturnType, + call: number +): unknown { + const body = fetchMock.mock.calls[call]?.[1]?.body + if (typeof body !== "string") { + throw new TypeError("Expected a JSON request body.") + } + return JSON.parse(body) +} + +function requestHeaders( + fetchMock: ReturnType, + call: number +): Headers { + return new Headers(fetchMock.mock.calls[call]?.[1]?.headers) +} + +function requestUrl(fetchMock: ReturnType, call: number): string { + const input = fetchMock.mock.calls[call]?.[0] + if (typeof input === "string") { + return input + } + return input instanceof URL ? input.href : input.url +} diff --git a/packages/filerouter/test/llamaparse.test.ts b/packages/filerouter/test/llamaparse.test.ts index 7012aa4..be73c82 100644 --- a/packages/filerouter/test/llamaparse.test.ts +++ b/packages/filerouter/test/llamaparse.test.ts @@ -40,7 +40,7 @@ describe("llamaparse", () => { expect(status.result.raw).toBeUndefined() expect(get).toHaveBeenCalledWith( "parse-job", - expect.objectContaining({ expand: ["markdown"] }), + expect.objectContaining({ expand: ["job_metadata", "markdown"] }), undefined ) }) diff --git a/packages/filerouter/test/mistral.test.ts b/packages/filerouter/test/mistral.test.ts index c71c9ae..c7725a3 100644 --- a/packages/filerouter/test/mistral.test.ts +++ b/packages/filerouter/test/mistral.test.ts @@ -32,13 +32,14 @@ describe("Mistral OCR provider", () => { expect(result.outputs.pages?.map((page) => page.pageNumber)).toEqual([1, 2]) expect(result.outputs.markdown).toContain("# Page one") expect(result.raw).toBeUndefined() - expect(result.usage?.pages).toBe(2) + expect(result.usage).toEqual({ costUsd: 0.008, pages: 2 }) expect(process).toHaveBeenCalledWith( expect.objectContaining({ document: { documentUrl: "https://example.com/report.pdf", type: "document_url", }, + model: "mistral-ocr-4-0", }), expect.any(Object) ) @@ -48,6 +49,7 @@ describe("Mistral OCR provider", () => { const upload = vi.fn().mockResolvedValue({ id: "file-1" }) const deleteFile = vi.fn().mockResolvedValue({ deleted: true }) const process = vi.fn().mockResolvedValue({ + model: "mistral-ocr-2512", pages: [ { blocks: [{ type: "text" }], @@ -92,6 +94,7 @@ describe("Mistral OCR provider", () => { pages: [1], providerOptions: { "mistral-ocr": { + bboxAnnotationFormat: { type: "json_schema" }, confidenceScoresGranularity: "page", includeImageBase64: true, tableFormat: "html", @@ -102,6 +105,7 @@ describe("Mistral OCR provider", () => { expect(process).toHaveBeenCalledWith( expect.objectContaining({ + bboxAnnotationFormat: { type: "json_schema" }, confidenceScoresGranularity: "page", document: { fileId: "file-1", type: "file" }, includeImageBase64: true, @@ -125,6 +129,7 @@ describe("Mistral OCR provider", () => { html: "
", }), ]) + expect(result.usage).toEqual({ costUsd: 0.003, pages: 1 }) expect(result.raw).toBeDefined() }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e5ab36f..a192918 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,7 +20,7 @@ importers: dependencies: '@better-auth/api-key': specifier: ^1.6.23 - version: 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.4)(nanostores@1.4.0))(@better-auth/utils@0.4.2)(better-auth@1.6.23(@opentelemetry/api@1.9.1)(@tanstack/react-start@1.168.30(@voidzero-dev/vite-plus-core@0.2.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(rolldown@1.1.5)(rollup@4.62.2))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(kysely@0.29.4))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(solid-js@1.9.14)(vitest@4.1.10))(better-call@1.3.7(zod@4.4.3)) + version: 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260721.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.4)(nanostores@1.4.0))(@better-auth/utils@0.4.2)(better-auth@1.6.23(@cloudflare/workers-types@5.20260721.1)(@opentelemetry/api@1.9.1)(@tanstack/react-start@1.168.30(@voidzero-dev/vite-plus-core@0.2.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(rolldown@1.1.5)(rollup@4.62.2))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@5.20260721.1)(@opentelemetry/api@1.9.1)(kysely@0.29.4))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(solid-js@1.9.14)(vitest@4.1.10))(better-call@1.3.7(zod@4.4.3)) '@calcom/embed-react': specifier: ^1.5.3 version: 1.5.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -69,9 +69,12 @@ importers: '@tanstack/react-start': specifier: 1.168.30 version: 1.168.30(@voidzero-dev/vite-plus-core@0.2.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(rolldown@1.1.5)(rollup@4.62.2) + autumn-js: + specifier: 1.2.43 + version: 1.2.43(better-auth@1.6.23(@cloudflare/workers-types@5.20260721.1)(@opentelemetry/api@1.9.1)(@tanstack/react-start@1.168.30(@voidzero-dev/vite-plus-core@0.2.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(rolldown@1.1.5)(rollup@4.62.2))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@5.20260721.1)(@opentelemetry/api@1.9.1)(kysely@0.29.4))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(solid-js@1.9.14)(vitest@4.1.10))(better-call@1.3.7(zod@4.4.3))(hono@4.12.31)(react@19.2.7) better-auth: specifier: ^1.6.23 - version: 1.6.23(@opentelemetry/api@1.9.1)(@tanstack/react-start@1.168.30(@voidzero-dev/vite-plus-core@0.2.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(rolldown@1.1.5)(rollup@4.62.2))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(kysely@0.29.4))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(solid-js@1.9.14)(vitest@4.1.10) + version: 1.6.23(@cloudflare/workers-types@5.20260721.1)(@opentelemetry/api@1.9.1)(@tanstack/react-start@1.168.30(@voidzero-dev/vite-plus-core@0.2.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(rolldown@1.1.5)(rollup@4.62.2))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@5.20260721.1)(@opentelemetry/api@1.9.1)(kysely@0.29.4))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(solid-js@1.9.14)(vitest@4.1.10) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -80,13 +83,19 @@ importers: version: 2.1.1 drizzle-orm: specifier: ^0.45.2 - version: 0.45.2(@opentelemetry/api@1.9.1)(kysely@0.29.4) + version: 0.45.2(@cloudflare/workers-types@5.20260721.1)(@opentelemetry/api@1.9.1)(kysely@0.29.4) hast-util-to-string: specifier: ^3.0.1 version: 3.0.1 hono: specifier: ^4.12.31 version: 4.12.31 + posthog-js: + specifier: 1.404.1 + version: 1.404.1 + posthog-node: + specifier: 5.45.2 + version: 5.45.2 prism-react-renderer: specifier: ^2.4.1 version: 2.4.1(react@19.2.7) @@ -138,10 +147,10 @@ importers: devDependencies: '@cloudflare/vite-plugin': specifier: ^1.43.0 - version: 1.45.1(@voidzero-dev/vite-plus-core@0.2.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(typescript@6.0.3)(yaml@2.9.0))(wrangler@4.112.0) + version: 1.45.1(@voidzero-dev/vite-plus-core@0.2.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(typescript@6.0.3)(yaml@2.9.0))(wrangler@4.112.0(@cloudflare/workers-types@5.20260721.1)) '@cloudflare/vitest-pool-workers': specifier: 0.18.6 - version: 0.18.6(@vitest/runner@4.1.10)(@vitest/snapshot@4.1.10)(vitest@4.1.10) + version: 0.18.6(@cloudflare/workers-types@5.20260721.1)(@vitest/runner@4.1.10)(@vitest/snapshot@4.1.10)(vitest@4.1.10) '@commitlint/cli': specifier: ^21.2.1 version: 21.2.1(@types/node@22.20.1)(conventional-commits-parser@7.1.0)(typescript@6.0.3) @@ -180,7 +189,7 @@ importers: version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(@vitest/browser-preview@4.1.10)(@voidzero-dev/vite-plus-core@0.2.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(typescript@6.0.3)(yaml@2.9.0))(jsdom@28.1.0(@noble/hashes@2.2.0)) wrangler: specifier: ^4.107.0 - version: 4.112.0 + version: 4.112.0(@cloudflare/workers-types@5.20260721.1) packages/cli: dependencies: @@ -235,6 +244,54 @@ importers: specifier: 4.1.10 version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(@vitest/browser-preview@4.1.10)(@voidzero-dev/vite-plus-core@0.2.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(typescript@6.0.3)(yaml@2.9.0))(jsdom@28.1.0(@noble/hashes@2.2.0)) + services/native-parsers: + dependencies: + '@cloudflare/containers': + specifier: 0.3.7 + version: 0.3.7 + devDependencies: + '@cloudflare/workers-types': + specifier: 5.20260721.1 + version: 5.20260721.1 + '@types/node': + specifier: ^22 + version: 22.20.1 + typescript: + specifier: ^6 + version: 6.0.3 + vite-plus: + specifier: 'catalog:' + version: 0.2.5(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(@voidzero-dev/vite-plus-core@0.2.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(jiti@2.7.0)(jsdom@28.1.0(@noble/hashes@2.2.0))(tsx@4.23.1)(typescript@6.0.3)(yaml@2.9.0) + wrangler: + specifier: ^4.112.0 + version: 4.112.0(@cloudflare/workers-types@5.20260721.1) + + services/native-parsers/engines/liteparse: + dependencies: + '@llamaindex/liteparse': + specifier: 2.8.0 + version: 2.8.0 + devDependencies: + '@types/node': + specifier: ^22 + version: 22.20.1 + typescript: + specifier: ^6 + version: 6.0.3 + + services/native-parsers/engines/pdf-inspector: + dependencies: + '@firecrawl/pdf-inspector': + specifier: 1.11.1 + version: 1.11.1 + devDependencies: + '@types/node': + specifier: ^22 + version: 22.20.1 + typescript: + specifier: ^6 + version: 6.0.3 + packages: '@acemir/cssom@0.9.31': @@ -443,6 +500,9 @@ packages: '@calcom/embed-snippet@1.3.3': resolution: {integrity: sha512-pqqKaeLB8R6BvyegcpI9gAyY6Xyx1bKYfWvIGOvIbTpguWyM1BBBVcT9DCeGe8Zw7Ujp5K56ci7isRUrT2Uadg==} + '@cloudflare/containers@0.3.7': + resolution: {integrity: sha512-DM9dm3FnIBSyiSJ1FLavKwl/lk3oAmTaynCzZQ9pZR0ncRPquSxkxd8Nu2MFILxmDDsPkxKsSNEh9mHHMty4Fw==} + '@cloudflare/kv-asset-handler@0.5.0': resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==} engines: {node: '>=22.0.0'} @@ -500,6 +560,9 @@ packages: cpu: [x64] os: [win32] + '@cloudflare/workers-types@5.20260721.1': + resolution: {integrity: sha512-J6HZRuQOP3gVe9G5rxHQVS8kQRjo7NIaF+Lz4kO2lVmaCkQ1E78APOOthaI7KyCQzp+A2NbXBQI6QLRIswdWbA==} + '@commitlint/cli@21.2.1': resolution: {integrity: sha512-blsZGe29hJ72VGEFVl72IVYX+1vsfINpjA9yWQA6i7OKD/McGEOXg08sKIRKjFk4JvzhV/9n0l3i6NooPLTNfg==} engines: {node: '>=22.12.0'} @@ -1276,6 +1339,29 @@ packages: '@noble/hashes': optional: true + '@firecrawl/pdf-inspector-darwin-arm64@1.11.1': + resolution: {integrity: sha512-MYworvuQ84dRxMhVmhSuSP8gVdrxqKfnTC+spGc+OIEaJGHNuzgBUzgi9O7p3wSP/hkK9QdCDEbl6wdBO9a4rA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@firecrawl/pdf-inspector-linux-x64-gnu@1.11.1': + resolution: {integrity: sha512-Vyp7EpmplnWFsyi7YGK1yKE97PIrueIpo7jJx9zfU5wVAjSQuBRwykYbSUywsS3GPEbfgFlMDAW0Cls4lbtPqw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@firecrawl/pdf-inspector-win32-x64-msvc@1.11.1': + resolution: {integrity: sha512-B3hpMRHlJFaOdZaWiG0wyGcfVkf3AQh232tRd0kN4EpAIyo/sEU6+cbEarH1Z+ypd7QRNlO6fcnSYHelZaqS0A==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@firecrawl/pdf-inspector@1.11.1': + resolution: {integrity: sha512-v4SinJp+lJg3QYK5uJwkQoVm2pifU8aS1umtKsNvZepIp7ce76fyht8us8Ll3pJjAwUJtO2qEcgMmLaV4T749g==} + hasBin: true + '@floating-ui/core@1.8.0': resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==} @@ -1479,6 +1565,49 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@llamaindex/liteparse-darwin-arm64@2.8.0': + resolution: {integrity: sha512-b6GMRC1s+7iQMvXyeS7Hz3pLU1C0D7tyxXQa1isLEwvTaC5sA+vIsUxc51CT364VAEWdNtx/OifCY83SEfkDdQ==} + cpu: [arm64] + os: [darwin] + + '@llamaindex/liteparse-darwin-x64@2.8.0': + resolution: {integrity: sha512-XTPYt2JGY/ylsDMnhLvngNwt0E/YNiDH8X4B9pWyRrfqbdzNMDphKWxyiLR4lljzT25jwOW6MDPNsU2ew2/o/Q==} + cpu: [x64] + os: [darwin] + + '@llamaindex/liteparse-linux-arm64-gnu@2.8.0': + resolution: {integrity: sha512-9fewsJyfzmhXuURZskgsPruXfn1RuQHN7OV7VFwGUrdCULovijpE3DoZU8CJ8FTgI+eTV+7Di4L23lMujhM7aQ==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@llamaindex/liteparse-linux-x64-gnu@2.8.0': + resolution: {integrity: sha512-fl3VgDnkiPXdFY0bPwfFGRFnZaklL8dPYM5T1sIjWkYcJAYxMa13/xsso1Co47SNJSDPzgpaOEYcZpQAu3b0Pg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@llamaindex/liteparse-linux-x64-musl@2.8.0': + resolution: {integrity: sha512-zf7R2CSpXhba4G+8H9vut9/OqFwif3HSC/6uhfR2ZusqiwAqd+FOss2g0RHgJjuJej46E/ek/iUiFPiFWMe9Mg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@llamaindex/liteparse-win32-arm64-msvc@2.8.0': + resolution: {integrity: sha512-LTgSjjBVe1UcGGMQ67+zK9kzN7gFl8WzXAhWH/WiG9uhhXllyNFdR4Yq0gXCIGjcihcGqtIIBxI57jlD7f80/g==} + cpu: [arm64] + os: [win32] + + '@llamaindex/liteparse-win32-x64-msvc@2.8.0': + resolution: {integrity: sha512-HnAQSXUp/xiAD2/I1RQfrPP2K1u2zdJuc0wODDb7sap/KSGNiRZfRQErj+WeYwkxBRgwI9/e7Rq1vCNn/w3OQw==} + cpu: [x64] + os: [win32] + + '@llamaindex/liteparse@2.8.0': + resolution: {integrity: sha512-xAAoSxYJsJPGm9OPvWKZFq4u5O0d1i/7ClgeXfjNhancurif2NZ4NQsQzoxmM9RG5Aa5f9pCFEMGkrRJTw2d/w==} + engines: {node: '>=18.0.0'} + hasBin: true + '@llamaindex/llama-cloud@2.11.0': resolution: {integrity: sha512-NcztAf1YnKyuN7N5E9cqzk0RVtLKwH3N4nf0h85tT9Sq2uE+1j1nSn2ozVntSflkKllmlb767izIvi8+L1XsPA==} @@ -1962,6 +2091,12 @@ packages: '@poppinss/exception@1.2.3': resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} + '@posthog/core@1.43.1': + resolution: {integrity: sha512-hGM8f5sp3we6Em/RQHXbmyYm554hUx9+9jhf92ZQgDS4/xW72KHyZiO9wcFye+qAx2cJAOhOCDIznmO1FV8IbA==} + + '@posthog/types@1.397.0': + resolution: {integrity: sha512-Pa7FtsBo3V0XrhY4y8Xquwp8U07syuZ2IGQdlsRyxhz0yejQLceFjI58UssCXE5zDCDj3km5l7H3ufD6rHChCg==} + '@radix-ui/number@1.1.2': resolution: {integrity: sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig==} @@ -3310,6 +3445,9 @@ packages: '@types/react@19.2.17': resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} @@ -3658,6 +3796,29 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + autumn-js@1.2.43: + resolution: {integrity: sha512-ZKlOrUJqPXPJOdY6OchvlSznJwb9NyLLmdaMriJfz9gLQBpOCV3ZWvIYK8Ut3v/J0BBdVTlzDHeoYU4hBZTHxw==} + peerDependencies: + better-auth: ^1.6.0 + better-call: ^1.0.12 + express: ^5.2.1 + hono: ^4.0.0 + next: ^14.0.0 || ^15.0.0 || ^16.0.0 + react: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + better-auth: + optional: true + better-call: + optional: true + express: + optional: true + hono: + optional: true + next: + optional: true + react: + optional: true + babel-dead-code-elimination@1.0.12: resolution: {integrity: sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==} @@ -3822,6 +3983,10 @@ packages: comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + commander@12.1.0: + resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} + engines: {node: '>=18'} + commander@4.1.1: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} @@ -3856,6 +4021,9 @@ packages: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} + core-js@3.49.0: + resolution: {integrity: sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==} + cosmiconfig-typescript-loader@6.3.0: resolution: {integrity: sha512-Akr82WH1Wfqatyiqpj8HDkO2o2KmJRu1FhKfSNJP3K4IdXwHfEyL7MOb62i1AGQVLtIQM+iCE9CGOtrfhR+mmA==} engines: {node: '>=v18'} @@ -3906,6 +4074,10 @@ packages: decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + decode-uri-component@0.4.1: + resolution: {integrity: sha512-+8VxcR21HhTy8nOt6jf20w0c9CADrw1O8d+VZ/YzzCt4bJ3uBjw+D1q2osAB8RnpwwaeYBxy0HyKQxD5JBMuuQ==} + engines: {node: '>=14.16'} + defu@6.1.7: resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} @@ -3930,6 +4102,9 @@ packages: dom-accessibility-api@0.5.16: resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + dompurify@3.4.12: + resolution: {integrity: sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==} + drizzle-kit@0.31.10: resolution: {integrity: sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==} hasBin: true @@ -4124,10 +4299,17 @@ packages: fetchdts@0.1.7: resolution: {integrity: sha512-YoZjBdafyLIop9lSxXVI33oLD5kN31q4Td+CasofLLYeLXRFeOsuOw0Uo+XNRi9PZlbfdlN2GmRtm4tCEQ9/KA==} + fflate@0.4.9: + resolution: {integrity: sha512-zdxgIEddhfsyCaWpJ2SdXEP8ZMrKJ6+5jl4OupODcywU0IhRk6gdXuVGcPICyfx2H97hVK7xmJtRLPjkxAX8Vw==} + file-type@22.0.1: resolution: {integrity: sha512-ww5Mhre0EE+jmBvOXTmXAbEMuZE7uX4a3+oRCQFNj8w++g3ev913N6tXQz0XTXbueQ5TWQfm6BdaViEHHn8bhA==} engines: {node: '>=22'} + filter-obj@5.1.0: + resolution: {integrity: sha512-qWeTREPoT7I0bifpPUXtxkZJ1XJzxWtfoWWkdVGqa+eCr3SHW/Ocp89o8vLvbUuQnadybJpjOKu4V+RwO6sGng==} + engines: {node: '>=14.16'} + fix-dts-default-cjs-exports@1.0.1: resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} @@ -4682,6 +4864,26 @@ packages: resolution: {integrity: sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==} engines: {node: ^10 || ^12 || >=14} + posthog-js@1.404.1: + resolution: {integrity: sha512-ck/HOMKuZ6yefUk5OX+h2s9mUWBsnu0mGDUAWjIwAmQQrloZPShzOhOWuEuZQuMnCKORBFRVGsBAuzsl66cBJA==} + + posthog-node@5.45.2: + resolution: {integrity: sha512-QhHw/xL0ntMG/DzqA/qdiKu8JY8ChK5Obm8m1nMJor5sjJ7+pfVJSx1yAynC7iUAJfJ9V+OhBNhDCyi5IFWC0Q==} + engines: {node: ^20.20.0 || >=22.22.0} + peerDependencies: + rxjs: ^7.0.0 + peerDependenciesMeta: + rxjs: + optional: true + + preact@10.29.7: + resolution: {integrity: sha512-DCHYrK/B10yUD3ZjLfhZ3WIE/9Vf9VFUODcRE2dRomTYDpJk6z6L9wecSfhfE6M9ZTHUdyQkoC46arIDhEV84Q==} + peerDependencies: + preact-render-to-string: '>=5' + peerDependenciesMeta: + preact-render-to-string: + optional: true + prettier@3.9.5: resolution: {integrity: sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==} engines: {node: '>=14'} @@ -4703,6 +4905,13 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + query-selector-shadow-dom@1.0.1: + resolution: {integrity: sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==} + + query-string@9.4.1: + resolution: {integrity: sha512-lSyJeN3RuaG7DZGWThtYRhk96+kEyZ/+doZpERuWbjeFL+Ok3vEat/swU498rAI0NcVt5/RJp8UDuLz7FckxrA==} + engines: {node: '>=18'} + radix-ui@1.6.2: resolution: {integrity: sha512-OwYUjzMwiInCUxgAWpPsavXC3Kh4iyi/49uU1/qZTG3RQDlvegyk1GOMiGvSkjua1RDb3JD3fo3eroL9FV4GQw==} peerDependencies: @@ -4812,6 +5021,9 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + rou3@0.6.3: + resolution: {integrity: sha512-1HSG1ENTj7Kkm5muMnXuzzfdDOf7CFnbSYFA+H3Fp/rB9lOCxCPgy1jlZxTKyFoC5jJay8Mmc+VbPLYRjzYLrA==} + rou3@0.7.12: resolution: {integrity: sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==} @@ -4891,6 +5103,10 @@ packages: space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + split-on-first@3.0.0: + resolution: {integrity: sha512-qxQJTx2ryR0Dw0ITYyekNQWpz6f8dGd7vffGNflQQ3Iqj9NJ6qiZ7ELpZsJ/QBhIVAiDfXdag3+Gp8RvWa62AA==} + engines: {node: '>=12'} + sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} @@ -5217,6 +5433,9 @@ packages: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} + web-vitals@5.3.0: + resolution: {integrity: sha512-q6LWsLatGYZp5VGBIOvbTj6JBV2nOmC8KvWztXBmwJcfFAzhwKwbOxhUH306XY3CcaZDUlSmSuNPBsCn0bFu+g==} + webidl-conversions@8.0.1: resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} engines: {node: '>=20'} @@ -5483,15 +5702,15 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - '@better-auth/api-key@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.4)(nanostores@1.4.0))(@better-auth/utils@0.4.2)(better-auth@1.6.23(@opentelemetry/api@1.9.1)(@tanstack/react-start@1.168.30(@voidzero-dev/vite-plus-core@0.2.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(rolldown@1.1.5)(rollup@4.62.2))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(kysely@0.29.4))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(solid-js@1.9.14)(vitest@4.1.10))(better-call@1.3.7(zod@4.4.3))': + '@better-auth/api-key@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260721.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.4)(nanostores@1.4.0))(@better-auth/utils@0.4.2)(better-auth@1.6.23(@cloudflare/workers-types@5.20260721.1)(@opentelemetry/api@1.9.1)(@tanstack/react-start@1.168.30(@voidzero-dev/vite-plus-core@0.2.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(rolldown@1.1.5)(rollup@4.62.2))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@5.20260721.1)(@opentelemetry/api@1.9.1)(kysely@0.29.4))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(solid-js@1.9.14)(vitest@4.1.10))(better-call@1.3.7(zod@4.4.3))': dependencies: - '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.4)(nanostores@1.4.0) + '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260721.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.4)(nanostores@1.4.0) '@better-auth/utils': 0.4.2 - better-auth: 1.6.23(@opentelemetry/api@1.9.1)(@tanstack/react-start@1.168.30(@voidzero-dev/vite-plus-core@0.2.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(rolldown@1.1.5)(rollup@4.62.2))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(kysely@0.29.4))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(solid-js@1.9.14)(vitest@4.1.10) + better-auth: 1.6.23(@cloudflare/workers-types@5.20260721.1)(@opentelemetry/api@1.9.1)(@tanstack/react-start@1.168.30(@voidzero-dev/vite-plus-core@0.2.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(rolldown@1.1.5)(rollup@4.62.2))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@5.20260721.1)(@opentelemetry/api@1.9.1)(kysely@0.29.4))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(solid-js@1.9.14)(vitest@4.1.10) better-call: 1.3.7(zod@4.4.3) zod: 4.4.3 - '@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.4)(nanostores@1.4.0)': + '@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260721.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.4)(nanostores@1.4.0)': dependencies: '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 @@ -5503,40 +5722,41 @@ snapshots: nanostores: 1.4.0 zod: 4.4.3 optionalDependencies: + '@cloudflare/workers-types': 5.20260721.1 '@opentelemetry/api': 1.9.1 - '@better-auth/drizzle-adapter@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.4)(nanostores@1.4.0))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(kysely@0.29.4))': + '@better-auth/drizzle-adapter@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260721.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.4)(nanostores@1.4.0))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.2(@cloudflare/workers-types@5.20260721.1)(@opentelemetry/api@1.9.1)(kysely@0.29.4))': dependencies: - '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.4)(nanostores@1.4.0) + '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260721.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.4)(nanostores@1.4.0) '@better-auth/utils': 0.4.2 optionalDependencies: - drizzle-orm: 0.45.2(@opentelemetry/api@1.9.1)(kysely@0.29.4) + drizzle-orm: 0.45.2(@cloudflare/workers-types@5.20260721.1)(@opentelemetry/api@1.9.1)(kysely@0.29.4) - '@better-auth/kysely-adapter@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.4)(nanostores@1.4.0))(@better-auth/utils@0.4.2)(kysely@0.29.4)': + '@better-auth/kysely-adapter@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260721.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.4)(nanostores@1.4.0))(@better-auth/utils@0.4.2)(kysely@0.29.4)': dependencies: - '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.4)(nanostores@1.4.0) + '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260721.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.4)(nanostores@1.4.0) '@better-auth/utils': 0.4.2 optionalDependencies: kysely: 0.29.4 - '@better-auth/memory-adapter@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.4)(nanostores@1.4.0))(@better-auth/utils@0.4.2)': + '@better-auth/memory-adapter@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260721.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.4)(nanostores@1.4.0))(@better-auth/utils@0.4.2)': dependencies: - '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.4)(nanostores@1.4.0) + '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260721.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.4)(nanostores@1.4.0) '@better-auth/utils': 0.4.2 - '@better-auth/mongo-adapter@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.4)(nanostores@1.4.0))(@better-auth/utils@0.4.2)': + '@better-auth/mongo-adapter@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260721.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.4)(nanostores@1.4.0))(@better-auth/utils@0.4.2)': dependencies: - '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.4)(nanostores@1.4.0) + '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260721.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.4)(nanostores@1.4.0) '@better-auth/utils': 0.4.2 - '@better-auth/prisma-adapter@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.4)(nanostores@1.4.0))(@better-auth/utils@0.4.2)': + '@better-auth/prisma-adapter@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260721.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.4)(nanostores@1.4.0))(@better-auth/utils@0.4.2)': dependencies: - '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.4)(nanostores@1.4.0) + '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260721.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.4)(nanostores@1.4.0) '@better-auth/utils': 0.4.2 - '@better-auth/telemetry@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.4)(nanostores@1.4.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)': + '@better-auth/telemetry@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260721.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.4)(nanostores@1.4.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)': dependencies: - '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.4)(nanostores@1.4.0) + '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260721.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.4)(nanostores@1.4.0) '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 @@ -5568,6 +5788,8 @@ snapshots: dependencies: '@calcom/embed-core': 1.5.3 + '@cloudflare/containers@0.3.7': {} + '@cloudflare/kv-asset-handler@0.5.0': {} '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260714.1)': @@ -5576,20 +5798,20 @@ snapshots: optionalDependencies: workerd: 1.20260714.1 - '@cloudflare/vite-plugin@1.45.1(@voidzero-dev/vite-plus-core@0.2.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(typescript@6.0.3)(yaml@2.9.0))(wrangler@4.112.0)': + '@cloudflare/vite-plugin@1.45.1(@voidzero-dev/vite-plus-core@0.2.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(typescript@6.0.3)(yaml@2.9.0))(wrangler@4.112.0(@cloudflare/workers-types@5.20260721.1))': dependencies: '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260714.1) miniflare: 4.20260714.0 unenv: 2.0.0-rc.24 vite: '@voidzero-dev/vite-plus-core@0.2.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(typescript@6.0.3)(yaml@2.9.0)' workerd: 1.20260714.1 - wrangler: 4.112.0 + wrangler: 4.112.0(@cloudflare/workers-types@5.20260721.1) ws: 8.21.0 transitivePeerDependencies: - bufferutil - utf-8-validate - '@cloudflare/vitest-pool-workers@0.18.6(@vitest/runner@4.1.10)(@vitest/snapshot@4.1.10)(vitest@4.1.10)': + '@cloudflare/vitest-pool-workers@0.18.6(@cloudflare/workers-types@5.20260721.1)(@vitest/runner@4.1.10)(@vitest/snapshot@4.1.10)(vitest@4.1.10)': dependencies: '@vitest/runner': 4.1.10 '@vitest/snapshot': 4.1.10 @@ -5597,7 +5819,7 @@ snapshots: esbuild: 0.28.1 miniflare: 4.20260714.0 vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(@vitest/browser-preview@4.1.10)(@voidzero-dev/vite-plus-core@0.2.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(typescript@6.0.3)(yaml@2.9.0))(jsdom@28.1.0(@noble/hashes@2.2.0)) - wrangler: 4.112.0 + wrangler: 4.112.0(@cloudflare/workers-types@5.20260721.1) zod: 3.25.76 transitivePeerDependencies: - '@cloudflare/workers-types' @@ -5619,6 +5841,8 @@ snapshots: '@cloudflare/workerd-windows-64@1.20260714.1': optional: true + '@cloudflare/workers-types@5.20260721.1': {} + '@commitlint/cli@21.2.1(@types/node@22.20.1)(conventional-commits-parser@7.1.0)(typescript@6.0.3)': dependencies: '@commitlint/config-conventional': 21.2.0 @@ -6141,6 +6365,21 @@ snapshots: '@noble/hashes': 2.2.0 optional: true + '@firecrawl/pdf-inspector-darwin-arm64@1.11.1': + optional: true + + '@firecrawl/pdf-inspector-linux-x64-gnu@1.11.1': + optional: true + + '@firecrawl/pdf-inspector-win32-x64-msvc@1.11.1': + optional: true + + '@firecrawl/pdf-inspector@1.11.1': + optionalDependencies: + '@firecrawl/pdf-inspector-darwin-arm64': 1.11.1 + '@firecrawl/pdf-inspector-linux-x64-gnu': 1.11.1 + '@firecrawl/pdf-inspector-win32-x64-msvc': 1.11.1 + '@floating-ui/core@1.8.0': dependencies: '@floating-ui/utils': 0.2.12 @@ -6293,6 +6532,39 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@llamaindex/liteparse-darwin-arm64@2.8.0': + optional: true + + '@llamaindex/liteparse-darwin-x64@2.8.0': + optional: true + + '@llamaindex/liteparse-linux-arm64-gnu@2.8.0': + optional: true + + '@llamaindex/liteparse-linux-x64-gnu@2.8.0': + optional: true + + '@llamaindex/liteparse-linux-x64-musl@2.8.0': + optional: true + + '@llamaindex/liteparse-win32-arm64-msvc@2.8.0': + optional: true + + '@llamaindex/liteparse-win32-x64-msvc@2.8.0': + optional: true + + '@llamaindex/liteparse@2.8.0': + dependencies: + commander: 12.1.0 + optionalDependencies: + '@llamaindex/liteparse-darwin-arm64': 2.8.0 + '@llamaindex/liteparse-darwin-x64': 2.8.0 + '@llamaindex/liteparse-linux-arm64-gnu': 2.8.0 + '@llamaindex/liteparse-linux-x64-gnu': 2.8.0 + '@llamaindex/liteparse-linux-x64-musl': 2.8.0 + '@llamaindex/liteparse-win32-arm64-msvc': 2.8.0 + '@llamaindex/liteparse-win32-x64-msvc': 2.8.0 + '@llamaindex/llama-cloud@2.11.0': {} '@mistralai/mistralai@2.5.0(@opentelemetry/api@1.9.1)': @@ -6571,6 +6843,12 @@ snapshots: '@poppinss/exception@1.2.3': {} + '@posthog/core@1.43.1': + dependencies: + '@posthog/types': 1.397.0 + + '@posthog/types@1.397.0': {} + '@radix-ui/number@1.1.2': {} '@radix-ui/primitive@1.1.5': {} @@ -7950,6 +8228,9 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/trusted-types@2.0.7': + optional: true + '@types/unist@3.0.3': {} '@ungap/structured-clone@1.3.3': {} @@ -8236,6 +8517,17 @@ snapshots: assertion-error@2.0.1: {} + autumn-js@1.2.43(better-auth@1.6.23(@cloudflare/workers-types@5.20260721.1)(@opentelemetry/api@1.9.1)(@tanstack/react-start@1.168.30(@voidzero-dev/vite-plus-core@0.2.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(rolldown@1.1.5)(rollup@4.62.2))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@5.20260721.1)(@opentelemetry/api@1.9.1)(kysely@0.29.4))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(solid-js@1.9.14)(vitest@4.1.10))(better-call@1.3.7(zod@4.4.3))(hono@4.12.31)(react@19.2.7): + dependencies: + query-string: 9.4.1 + rou3: 0.6.3 + zod: 4.4.3 + optionalDependencies: + better-auth: 1.6.23(@cloudflare/workers-types@5.20260721.1)(@opentelemetry/api@1.9.1)(@tanstack/react-start@1.168.30(@voidzero-dev/vite-plus-core@0.2.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(rolldown@1.1.5)(rollup@4.62.2))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@5.20260721.1)(@opentelemetry/api@1.9.1)(kysely@0.29.4))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(solid-js@1.9.14)(vitest@4.1.10) + better-call: 1.3.7(zod@4.4.3) + hono: 4.12.31 + react: 19.2.7 + babel-dead-code-elimination@1.0.12: dependencies: '@babel/core': 7.29.7 @@ -8249,15 +8541,15 @@ snapshots: baseline-browser-mapping@2.10.43: {} - better-auth@1.6.23(@opentelemetry/api@1.9.1)(@tanstack/react-start@1.168.30(@voidzero-dev/vite-plus-core@0.2.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(rolldown@1.1.5)(rollup@4.62.2))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(kysely@0.29.4))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(solid-js@1.9.14)(vitest@4.1.10): + better-auth@1.6.23(@cloudflare/workers-types@5.20260721.1)(@opentelemetry/api@1.9.1)(@tanstack/react-start@1.168.30(@voidzero-dev/vite-plus-core@0.2.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(rolldown@1.1.5)(rollup@4.62.2))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@5.20260721.1)(@opentelemetry/api@1.9.1)(kysely@0.29.4))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(solid-js@1.9.14)(vitest@4.1.10): dependencies: - '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.4)(nanostores@1.4.0) - '@better-auth/drizzle-adapter': 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.4)(nanostores@1.4.0))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(kysely@0.29.4)) - '@better-auth/kysely-adapter': 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.4)(nanostores@1.4.0))(@better-auth/utils@0.4.2)(kysely@0.29.4) - '@better-auth/memory-adapter': 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.4)(nanostores@1.4.0))(@better-auth/utils@0.4.2) - '@better-auth/mongo-adapter': 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.4)(nanostores@1.4.0))(@better-auth/utils@0.4.2) - '@better-auth/prisma-adapter': 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.4)(nanostores@1.4.0))(@better-auth/utils@0.4.2) - '@better-auth/telemetry': 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.4)(nanostores@1.4.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1) + '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260721.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.4)(nanostores@1.4.0) + '@better-auth/drizzle-adapter': 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260721.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.4)(nanostores@1.4.0))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.2(@cloudflare/workers-types@5.20260721.1)(@opentelemetry/api@1.9.1)(kysely@0.29.4)) + '@better-auth/kysely-adapter': 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260721.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.4)(nanostores@1.4.0))(@better-auth/utils@0.4.2)(kysely@0.29.4) + '@better-auth/memory-adapter': 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260721.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.4)(nanostores@1.4.0))(@better-auth/utils@0.4.2) + '@better-auth/mongo-adapter': 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260721.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.4)(nanostores@1.4.0))(@better-auth/utils@0.4.2) + '@better-auth/prisma-adapter': 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260721.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.4)(nanostores@1.4.0))(@better-auth/utils@0.4.2) + '@better-auth/telemetry': 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260721.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.4)(nanostores@1.4.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1) '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 '@noble/ciphers': 2.2.0 @@ -8271,7 +8563,7 @@ snapshots: optionalDependencies: '@tanstack/react-start': 1.168.30(@voidzero-dev/vite-plus-core@0.2.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(rolldown@1.1.5)(rollup@4.62.2) drizzle-kit: 0.31.10 - drizzle-orm: 0.45.2(@opentelemetry/api@1.9.1)(kysely@0.29.4) + drizzle-orm: 0.45.2(@cloudflare/workers-types@5.20260721.1)(@opentelemetry/api@1.9.1)(kysely@0.29.4) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) solid-js: 1.9.14 @@ -8357,6 +8649,8 @@ snapshots: comma-separated-tokens@2.0.3: {} + commander@12.1.0: {} + commander@4.1.1: {} confbox@0.1.8: {} @@ -8382,6 +8676,8 @@ snapshots: cookie@1.1.1: {} + core-js@3.49.0: {} + cosmiconfig-typescript-loader@6.3.0(@types/node@22.20.1)(cosmiconfig@9.0.2(typescript@6.0.3))(typescript@6.0.3): dependencies: '@types/node': 22.20.1 @@ -8435,6 +8731,8 @@ snapshots: dependencies: character-entities: 2.0.2 + decode-uri-component@0.4.1: {} + defu@6.1.7: {} dequal@2.0.3: {} @@ -8451,6 +8749,10 @@ snapshots: dom-accessibility-api@0.5.16: {} + dompurify@3.4.12: + optionalDependencies: + '@types/trusted-types': 2.0.7 + drizzle-kit@0.31.10: dependencies: '@drizzle-team/brocli': 0.10.2 @@ -8458,8 +8760,9 @@ snapshots: esbuild: 0.25.12 tsx: 4.23.1 - drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(kysely@0.29.4): + drizzle-orm@0.45.2(@cloudflare/workers-types@5.20260721.1)(@opentelemetry/api@1.9.1)(kysely@0.29.4): optionalDependencies: + '@cloudflare/workers-types': 5.20260721.1 '@opentelemetry/api': 1.9.1 kysely: 0.29.4 @@ -8629,6 +8932,8 @@ snapshots: fetchdts@0.1.7: {} + fflate@0.4.9: {} + file-type@22.0.1: dependencies: '@tokenizer/inflate': 0.4.1 @@ -8638,6 +8943,8 @@ snapshots: transitivePeerDependencies: - supports-color + filter-obj@5.1.0: {} + fix-dts-default-cjs-exports@1.0.1: dependencies: magic-string: 0.30.21 @@ -9390,6 +9697,25 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + posthog-js@1.404.1: + dependencies: + '@posthog/core': 1.43.1 + '@posthog/types': 1.397.0 + core-js: 3.49.0 + dompurify: 3.4.12 + fflate: 0.4.9 + preact: 10.29.7 + query-selector-shadow-dom: 1.0.1 + web-vitals: 5.3.0 + transitivePeerDependencies: + - preact-render-to-string + + posthog-node@5.45.2: + dependencies: + '@posthog/core': 1.43.1 + + preact@10.29.7: {} + prettier@3.9.5: {} pretty-format@27.5.1: @@ -9409,6 +9735,14 @@ snapshots: punycode@2.3.1: optional: true + query-selector-shadow-dom@1.0.1: {} + + query-string@9.4.1: + dependencies: + decode-uri-component: 0.4.1 + filter-obj: 5.1.0 + split-on-first: 3.0.0 + radix-ui@1.6.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: '@radix-ui/primitive': 1.1.5 @@ -9630,6 +9964,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.62.2 fsevents: 2.3.3 + rou3@0.6.3: {} + rou3@0.7.12: {} rou3@0.8.1: {} @@ -9720,6 +10056,8 @@ snapshots: space-separated-tokens@2.0.2: {} + split-on-first@3.0.0: {} + sprintf-js@1.0.3: {} srvx@0.11.22: {} @@ -10085,6 +10423,8 @@ snapshots: xml-name-validator: 5.0.0 optional: true + web-vitals@5.3.0: {} + webidl-conversions@8.0.1: optional: true @@ -10115,7 +10455,7 @@ snapshots: '@cloudflare/workerd-linux-arm64': 1.20260714.1 '@cloudflare/workerd-windows-64': 1.20260714.1 - wrangler@4.112.0: + wrangler@4.112.0(@cloudflare/workers-types@5.20260721.1): dependencies: '@cloudflare/kv-asset-handler': 0.5.0 '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260714.1) @@ -10126,6 +10466,7 @@ snapshots: unenv: 2.0.0-rc.24 workerd: 1.20260714.1 optionalDependencies: + '@cloudflare/workers-types': 5.20260721.1 fsevents: 2.3.3 transitivePeerDependencies: - bufferutil diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ff9bfb6..3b29265 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,7 +1,10 @@ packages: - packages/* + - services/* + - services/*/engines/* allowBuilds: + core-js: false esbuild: true lightningcss: true msw: false @@ -38,3 +41,14 @@ peerDependencyRules: allowedVersions: vite: "*" vitest: "*" + +minimumReleaseAgeExclude: + - "@cloudflare/workers-types@5.20260721.1" + - "@llamaindex/liteparse-darwin-arm64@2.8.0" + - "@llamaindex/liteparse-darwin-x64@2.8.0" + - "@llamaindex/liteparse-linux-arm64-gnu@2.8.0" + - "@llamaindex/liteparse-linux-x64-gnu@2.8.0" + - "@llamaindex/liteparse-linux-x64-musl@2.8.0" + - "@llamaindex/liteparse-win32-arm64-msvc@2.8.0" + - "@llamaindex/liteparse-win32-x64-msvc@2.8.0" + - "@llamaindex/liteparse@2.8.0" diff --git a/public/app-icon-maskable.svg b/public/app-icon-maskable.svg index 70028a6..a29233b 100644 --- a/public/app-icon-maskable.svg +++ b/public/app-icon-maskable.svg @@ -1,4 +1,4 @@ - + diff --git a/public/app-icon.svg b/public/app-icon.svg index 6416da9..8e6b48d 100644 --- a/public/app-icon.svg +++ b/public/app-icon.svg @@ -1,4 +1,4 @@ - + diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png index d32a66d..8a57e03 100644 Binary files a/public/apple-touch-icon.png and b/public/apple-touch-icon.png differ diff --git a/public/favicon.ico b/public/favicon.ico index 2a0c1f9..043b049 100644 Binary files a/public/favicon.ico and b/public/favicon.ico differ diff --git a/public/favicon.svg b/public/favicon.svg index 7455f89..67756bd 100644 --- a/public/favicon.svg +++ b/public/favicon.svg @@ -1,3 +1,3 @@ - + diff --git a/public/icon-192.png b/public/icon-192.png index 226db60..6c623b5 100644 Binary files a/public/icon-192.png and b/public/icon-192.png differ diff --git a/public/icon-512.png b/public/icon-512.png index ce688ba..c8b6fd9 100644 Binary files a/public/icon-512.png and b/public/icon-512.png differ diff --git a/public/icon-maskable-512.png b/public/icon-maskable-512.png index 28afd44..0f9b601 100644 Binary files a/public/icon-maskable-512.png and b/public/icon-maskable-512.png differ diff --git a/public/logo.svg b/public/logo.svg index 48a4639..3b33914 100644 --- a/public/logo.svg +++ b/public/logo.svg @@ -1,3 +1,3 @@ - + diff --git a/scripts/generate-logo-assets.mjs b/scripts/generate-logo-assets.mjs index 378f414..9e55d1f 100644 --- a/scripts/generate-logo-assets.mjs +++ b/scripts/generate-logo-assets.mjs @@ -8,15 +8,15 @@ const MASTER_HEIGHT = 920 const ROWS = 9 const STEP = MASTER_HEIGHT / ROWS -// Approved 9-row, pattern 1 FileRouter mark. Keep this map as the canonical +// Approved 9-row FileRouter mark. Keep this map as the canonical // brand source; every SVG, PNG, ICO, React, and README asset derives from it. const CELL_MAP = [ "###.#..", "###.##.", "###.###", - ".##....", + "###....", + "###.###", "###.###", - "###.#.#", "###.###", ".##.###", "#.#.###", @@ -71,6 +71,13 @@ function logoSvg() { ` } +function docsLogoSvg() { + return ` + + +` +} + function faviconSvg() { return ` @@ -110,14 +117,18 @@ function reactComponent() { } function updateWordmark(source) { + const sizedSource = source.replace( + /width="430" height="84" viewBox="0 10 430 84"/, + 'width="382" height="84" viewBox="34 10 382 84"' + ) const mark = ` ` const pattern = / ]*>[\s\S]*? <\/g>/ - if (!pattern.test(source)) { + if (!pattern.test(sizedSource)) { throw new Error("Could not locate README wordmark logo") } - return source.replace(pattern, mark) + return sizedSource.replace(pattern, mark) } function makeCanvas(width, height, background = [0, 0, 0, 0]) { @@ -271,6 +282,7 @@ async function main() { await Promise.all([ writeFile("public/logo.svg", logo), writeFile("src/logo.svg", logo), + writeFile("docs/assets/filerouter-logo.svg", docsLogoSvg()), writeFile("public/favicon.svg", favicon), writeFile("docs/assets/favicon.svg", favicon), writeFile("public/app-icon.svg", appIcon), diff --git a/services/native-parsers/engines/liteparse/Dockerfile b/services/native-parsers/engines/liteparse/Dockerfile new file mode 100644 index 0000000..b620e2e --- /dev/null +++ b/services/native-parsers/engines/liteparse/Dockerfile @@ -0,0 +1,28 @@ +FROM node:24-bookworm-slim@sha256:6f7b03f7c2c8e2e784dcf9295400527b9b1270fd37b7e9a7285cf83b6951452d + +WORKDIR /app +ENV NODE_ENV=production +ENV TESSDATA_PREFIX=/usr/share/tesseract-ocr/5/tessdata + +RUN DEBIAN_FRONTEND=noninteractive apt-get update \ + && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + ca-certificates \ + imagemagick \ + libvips42 \ + libreoffice-calc \ + libreoffice-core \ + libreoffice-impress \ + libreoffice-writer \ + tesseract-ocr \ + tesseract-ocr-eng \ + && rm -rf /var/lib/apt/lists/* /var/cache/apt/* + +COPY liteparse/package.json liteparse/package-lock.json ./liteparse/ +RUN cd liteparse && npm ci --omit=dev --no-audit --no-fund + +COPY shared ./shared +COPY liteparse/server.ts ./liteparse/server.ts + +USER node +EXPOSE 8080 +CMD ["node", "liteparse/server.ts"] diff --git a/services/native-parsers/engines/liteparse/package-lock.json b/services/native-parsers/engines/liteparse/package-lock.json new file mode 100644 index 0000000..4230a2a --- /dev/null +++ b/services/native-parsers/engines/liteparse/package-lock.json @@ -0,0 +1,182 @@ +{ + "name": "@file_router/liteparse-runtime", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@file_router/liteparse-runtime", + "dependencies": { + "@llamaindex/liteparse": "2.8.0" + }, + "devDependencies": { + "@types/node": "^22", + "typescript": "^6" + } + }, + "node_modules/@llamaindex/liteparse": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@llamaindex/liteparse/-/liteparse-2.8.0.tgz", + "integrity": "sha512-xAAoSxYJsJPGm9OPvWKZFq4u5O0d1i/7ClgeXfjNhancurif2NZ4NQsQzoxmM9RG5Aa5f9pCFEMGkrRJTw2d/w==", + "license": "Apache-2.0", + "dependencies": { + "commander": "^12.0.0" + }, + "bin": { + "lit": "dist/cli.js", + "liteparse": "dist/cli.js" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "@llamaindex/liteparse-darwin-arm64": "2.8.0", + "@llamaindex/liteparse-darwin-x64": "2.8.0", + "@llamaindex/liteparse-linux-arm64-gnu": "2.8.0", + "@llamaindex/liteparse-linux-x64-gnu": "2.8.0", + "@llamaindex/liteparse-linux-x64-musl": "2.8.0", + "@llamaindex/liteparse-win32-arm64-msvc": "2.8.0", + "@llamaindex/liteparse-win32-x64-msvc": "2.8.0" + } + }, + "node_modules/@llamaindex/liteparse-darwin-arm64": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@llamaindex/liteparse-darwin-arm64/-/liteparse-darwin-arm64-2.8.0.tgz", + "integrity": "sha512-b6GMRC1s+7iQMvXyeS7Hz3pLU1C0D7tyxXQa1isLEwvTaC5sA+vIsUxc51CT364VAEWdNtx/OifCY83SEfkDdQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@llamaindex/liteparse-darwin-x64": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@llamaindex/liteparse-darwin-x64/-/liteparse-darwin-x64-2.8.0.tgz", + "integrity": "sha512-XTPYt2JGY/ylsDMnhLvngNwt0E/YNiDH8X4B9pWyRrfqbdzNMDphKWxyiLR4lljzT25jwOW6MDPNsU2ew2/o/Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@llamaindex/liteparse-linux-arm64-gnu": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@llamaindex/liteparse-linux-arm64-gnu/-/liteparse-linux-arm64-gnu-2.8.0.tgz", + "integrity": "sha512-9fewsJyfzmhXuURZskgsPruXfn1RuQHN7OV7VFwGUrdCULovijpE3DoZU8CJ8FTgI+eTV+7Di4L23lMujhM7aQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@llamaindex/liteparse-linux-x64-gnu": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@llamaindex/liteparse-linux-x64-gnu/-/liteparse-linux-x64-gnu-2.8.0.tgz", + "integrity": "sha512-fl3VgDnkiPXdFY0bPwfFGRFnZaklL8dPYM5T1sIjWkYcJAYxMa13/xsso1Co47SNJSDPzgpaOEYcZpQAu3b0Pg==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@llamaindex/liteparse-linux-x64-musl": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@llamaindex/liteparse-linux-x64-musl/-/liteparse-linux-x64-musl-2.8.0.tgz", + "integrity": "sha512-zf7R2CSpXhba4G+8H9vut9/OqFwif3HSC/6uhfR2ZusqiwAqd+FOss2g0RHgJjuJej46E/ek/iUiFPiFWMe9Mg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@llamaindex/liteparse-win32-arm64-msvc": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@llamaindex/liteparse-win32-arm64-msvc/-/liteparse-win32-arm64-msvc-2.8.0.tgz", + "integrity": "sha512-LTgSjjBVe1UcGGMQ67+zK9kzN7gFl8WzXAhWH/WiG9uhhXllyNFdR4Yq0gXCIGjcihcGqtIIBxI57jlD7f80/g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@llamaindex/liteparse-win32-x64-msvc": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@llamaindex/liteparse-win32-x64-msvc/-/liteparse-win32-x64-msvc-2.8.0.tgz", + "integrity": "sha512-HnAQSXUp/xiAD2/I1RQfrPP2K1u2zdJuc0wODDb7sap/KSGNiRZfRQErj+WeYwkxBRgwI9/e7Rq1vCNn/w3OQw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/services/native-parsers/engines/liteparse/package.json b/services/native-parsers/engines/liteparse/package.json new file mode 100644 index 0000000..ec4d300 --- /dev/null +++ b/services/native-parsers/engines/liteparse/package.json @@ -0,0 +1,16 @@ +{ + "name": "@file_router/liteparse-runtime", + "private": true, + "type": "module", + "scripts": { + "start": "node server.ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@llamaindex/liteparse": "2.8.0" + }, + "devDependencies": { + "@types/node": "^22", + "typescript": "^6" + } +} diff --git a/services/native-parsers/engines/liteparse/server.ts b/services/native-parsers/engines/liteparse/server.ts new file mode 100644 index 0000000..7bb0614 --- /dev/null +++ b/services/native-parsers/engines/liteparse/server.ts @@ -0,0 +1,360 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { extname, join } from "node:path" + +import LiteParse from "@llamaindex/liteparse" +import type { + ImageMode, + LiteParseConfig, + ParseResult, +} from "@llamaindex/liteparse" + +import runtimePackage from "./package.json" with { type: "json" } + +import type { NativeParserResult } from "../shared/contracts.ts" +import { ParserRequestError, startParserServer } from "../shared/http.ts" +import { + assertAllowedKeys, + invalidOptions, + readNativeParserOptions, + readObject, + readOptionalBoolean, +} from "../shared/options.ts" + +const ENGINE_VERSION = runtimePackage.dependencies["@llamaindex/liteparse"] +const MAX_INPUT_BYTES = 100 * 1024 * 1024 +const MAX_OUTPUT_BYTES = 64 * 1024 * 1024 +const MAX_PAGES = 500 +const MAX_DPI = 300 +const OFFICE_EXTENSIONS = new Set([ + ".csv", + ".doc", + ".docx", + ".odp", + ".ods", + ".odt", + ".ppt", + ".pptx", + ".rtf", + ".tsv", + ".xls", + ".xlsx", +]) + +type LiteParseHostedOptions = { + convertOffice?: boolean + imageMode?: ImageMode + includeComplexity?: boolean + ocr?: "auto" | "off" + ocrLanguage?: string + raw?: Partial< + Pick< + LiteParseConfig, + | "cropBox" + | "dpi" + | "emitWordBoxes" + | "extractLinks" + | "ocrFailureFatal" + | "preserveVerySmallText" + | "quiet" + | "skipDiagonalText" + > + > + screenshots?: boolean +} + +startParserServer({ + handler: ({ bytes, contentType, fileName, options }) => + parseDocument(bytes, contentType, fileName, options), + maxBytes: MAX_INPUT_BYTES, + maxConcurrency: 1, + maxResponseBytes: MAX_OUTPUT_BYTES, + parserId: "liteparse", +}) + +async function parseDocument( + bytes: Buffer, + contentType: string, + fileName: string, + value: unknown +): Promise { + const request = readNativeParserOptions(value) + const providerOptions = readProviderOptions(request.providerOptions) + const pages = request.pages + if (pages?.some((page) => page > MAX_PAGES)) { + throw new ParserRequestError( + 413, + "provider_limit_exceeded", + `LiteParse supports pages up to ${MAX_PAGES}.` + ) + } + const parser = new LiteParse({ + ...providerOptions.raw, + imageMode: providerOptions.imageMode ?? "placeholder", + includeComplexity: providerOptions.includeComplexity ?? true, + maxPages: MAX_PAGES, + ocrEnabled: providerOptions.ocr !== "off", + ocrLanguage: providerOptions.ocrLanguage ?? "eng", + outputFormat: "markdown", + ...(pages && { targetPages: pages.join(",") }), + }) + const source = await prepareSource( + bytes, + contentType, + fileName, + providerOptions.convertOffice === true + ) + + try { + const result = await parser.parse(source.input) + const screenshots = providerOptions.screenshots + ? await parser.screenshot(source.input, pages) + : [] + return normalizeResult(result, screenshots, parser.getConfig()) + } finally { + await source.cleanup() + } +} + +function normalizeResult( + result: ParseResult, + screenshots: Awaited>, + config: LiteParseConfig +): NativeParserResult { + const pages = result.pages.map((page) => ({ + dimensions: { height: page.height, width: page.width }, + markdown: page.markdown, + metadata: { + ...(page.complexity && { complexity: page.complexity }), + textItems: page.textItems, + }, + pageNumber: page.pageNum, + text: page.text, + })) + const warnings = result.pages.flatMap((page) => + page.complexity?.needsOcr && !config.ocrEnabled + ? [ + { + code: "ocr_required", + message: `Page requires OCR: ${page.complexity.reasons.join(", ")}.`, + pageNumber: page.pageNum, + }, + ] + : [] + ) + const embeddedImages = result.images.map((image) => ({ + data: image.bytes.toString("base64"), + id: image.id, + mimeType: image.format.includes("/") + ? image.format + : `image/${image.format}`, + pageNumber: image.page, + })) + const screenshotImages = screenshots.map((image) => ({ + data: image.imageBuffer.toString("base64"), + id: `screenshot-${image.pageNum}`, + mimeType: "image/png", + pageNumber: image.pageNum, + })) + + return { + engine: { id: "liteparse", version: ENGINE_VERSION }, + ...(embeddedImages.length > 0 || screenshotImages.length > 0 + ? { images: [...embeddedImages, ...screenshotImages] } + : {}), + markdown: pages.map((page) => page.markdown).join("\n\n"), + metadata: { + imageMode: config.imageMode, + ocrEnabled: config.ocrEnabled, + ocrLanguage: config.ocrLanguage, + }, + pageCount: pages.length, + pages, + text: result.text, + warnings, + } +} + +async function prepareSource( + bytes: Buffer, + contentType: string, + fileName: string, + convertOffice: boolean +): Promise<{ cleanup: () => Promise; input: Buffer | string }> { + const extension = extname(fileName).toLowerCase() + const isPdf = contentType === "application/pdf" || extension === ".pdf" + if (isPdf) { + return { cleanup: async () => undefined, input: bytes } + } + if (!convertOffice || !OFFICE_EXTENSIONS.has(extension)) { + throw new ParserRequestError( + 400, + "unsupported_document_type", + "Non-PDF documents require convertOffice: true and a supported Office format." + ) + } + + const directory = await mkdtemp(join(tmpdir(), "filerouter-liteparse-")) + const input = join(directory, `document${extension}`) + await writeFile(input, bytes) + return { + cleanup: () => rm(directory, { force: true, recursive: true }), + input, + } +} + +function readProviderOptions(value: unknown): LiteParseHostedOptions { + if (value === undefined) { + return {} + } + const options = readObject(value) + assertAllowedKeys(options, [ + "convertOffice", + "imageMode", + "includeComplexity", + "ocr", + "ocrLanguage", + "raw", + "screenshots", + ]) + const convertOffice = readOptionalBoolean( + options.convertOffice, + "convertOffice" + ) + const includeComplexity = readOptionalBoolean( + options.includeComplexity, + "includeComplexity" + ) + const screenshots = readOptionalBoolean(options.screenshots, "screenshots") + const imageMode = readImageMode(options.imageMode) + const ocr = readOcrMode(options.ocr) + const ocrLanguage = readOcrLanguage(options.ocrLanguage) + const raw = readRawOptions(options.raw) + return { + ...(convertOffice !== undefined && { convertOffice }), + ...(imageMode && { imageMode }), + ...(includeComplexity !== undefined && { includeComplexity }), + ...(ocr && { ocr }), + ...(ocrLanguage && { ocrLanguage }), + ...(raw && { raw }), + ...(screenshots !== undefined && { screenshots }), + } +} + +function readImageMode(value: unknown): ImageMode | undefined { + if (value === undefined) { + return undefined + } + if (value === "embed" || value === "off" || value === "placeholder") { + return value + } + throw invalidOptions("imageMode is invalid.") +} + +function readOcrMode(value: unknown): "auto" | "off" | undefined { + if (value === undefined || value === "auto" || value === "off") { + return value + } + throw invalidOptions("ocr must be auto or off.") +} + +function readOcrLanguage(value: unknown): string | undefined { + if (value === undefined) { + return undefined + } + if (value === "eng") { + return "eng" + } + throw invalidOptions("ocrLanguage must be eng.") +} + +function readRawOptions(value: unknown): LiteParseHostedOptions["raw"] { + if (value === undefined) { + return undefined + } + const raw = readObject(value) + assertAllowedKeys(raw, [ + "cropBox", + "dpi", + "emitWordBoxes", + "extractLinks", + "ocrFailureFatal", + "preserveVerySmallText", + "quiet", + "skipDiagonalText", + ]) + const emitWordBoxes = readOptionalBoolean( + raw.emitWordBoxes, + "raw.emitWordBoxes" + ) + const extractLinks = readOptionalBoolean(raw.extractLinks, "raw.extractLinks") + const ocrFailureFatal = readOptionalBoolean( + raw.ocrFailureFatal, + "raw.ocrFailureFatal" + ) + const preserveVerySmallText = readOptionalBoolean( + raw.preserveVerySmallText, + "raw.preserveVerySmallText" + ) + const quiet = readOptionalBoolean(raw.quiet, "raw.quiet") + const skipDiagonalText = readOptionalBoolean( + raw.skipDiagonalText, + "raw.skipDiagonalText" + ) + const dpi = readPositiveNumber(raw.dpi, "raw.dpi", MAX_DPI) + const cropBox = readCropBox(raw.cropBox) + return { + ...(cropBox && { cropBox }), + ...(dpi !== undefined && { dpi }), + ...(emitWordBoxes !== undefined && { emitWordBoxes }), + ...(extractLinks !== undefined && { extractLinks }), + ...(ocrFailureFatal !== undefined && { ocrFailureFatal }), + ...(preserveVerySmallText !== undefined && { preserveVerySmallText }), + ...(quiet !== undefined && { quiet }), + ...(skipDiagonalText !== undefined && { skipDiagonalText }), + } +} + +function readPositiveNumber( + value: unknown, + name: string, + max: number +): number | undefined { + if (value === undefined) { + return undefined + } + if ( + typeof value === "number" && + Number.isFinite(value) && + value > 0 && + value <= max + ) { + return value + } + throw invalidOptions(`${name} must be greater than 0 and at most ${max}.`) +} + +function readCropBox(value: unknown): LiteParseConfig["cropBox"] | undefined { + if (value === undefined) { + return undefined + } + const cropBox = readObject(value, "raw.cropBox must be an object.") + assertAllowedKeys(cropBox, ["bottom", "left", "right", "top"]) + const bottom = readFraction(cropBox.bottom, "raw.cropBox.bottom") + const left = readFraction(cropBox.left, "raw.cropBox.left") + const right = readFraction(cropBox.right, "raw.cropBox.right") + const top = readFraction(cropBox.top, "raw.cropBox.top") + return { bottom, left, right, top } +} + +function readFraction(value: unknown, name: string): number { + if ( + typeof value !== "number" || + !Number.isFinite(value) || + value < 0 || + value > 1 + ) { + throw invalidOptions(`${name} must be between 0 and 1.`) + } + return value +} diff --git a/services/native-parsers/engines/liteparse/tsconfig.json b/services/native-parsers/engines/liteparse/tsconfig.json new file mode 100644 index 0000000..a74c34b --- /dev/null +++ b/services/native-parsers/engines/liteparse/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "allowImportingTsExtensions": true, + "exactOptionalPropertyTypes": true, + "lib": ["ES2022"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noEmit": true, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true, + "target": "ES2022", + "types": ["node"], + "verbatimModuleSyntax": true + }, + "include": ["server.ts", "../shared/**/*.ts"] +} diff --git a/services/native-parsers/engines/pdf-inspector/Dockerfile b/services/native-parsers/engines/pdf-inspector/Dockerfile new file mode 100644 index 0000000..27f859d --- /dev/null +++ b/services/native-parsers/engines/pdf-inspector/Dockerfile @@ -0,0 +1,14 @@ +FROM node:24-bookworm-slim@sha256:6f7b03f7c2c8e2e784dcf9295400527b9b1270fd37b7e9a7285cf83b6951452d + +WORKDIR /app +ENV NODE_ENV=production + +COPY pdf-inspector/package.json pdf-inspector/package-lock.json ./pdf-inspector/ +RUN cd pdf-inspector && npm ci --omit=dev --no-audit --no-fund + +COPY shared ./shared +COPY pdf-inspector/*.ts ./pdf-inspector/ + +USER node +EXPOSE 8080 +CMD ["node", "pdf-inspector/server.ts"] diff --git a/services/native-parsers/engines/pdf-inspector/options.ts b/services/native-parsers/engines/pdf-inspector/options.ts new file mode 100644 index 0000000..2730cd9 --- /dev/null +++ b/services/native-parsers/engines/pdf-inspector/options.ts @@ -0,0 +1,13 @@ +import { ParserRequestError } from "../shared/http.ts" + +export const MAX_PDF_INSPECTOR_PAGES = 1_000 + +export function assertPdfInspectorPageLimit(pageCount: number): void { + if (pageCount > MAX_PDF_INSPECTOR_PAGES) { + throw new ParserRequestError( + 413, + "provider_limit_exceeded", + `PDF Inspector supports at most ${MAX_PDF_INSPECTOR_PAGES} pages per request.` + ) + } +} diff --git a/services/native-parsers/engines/pdf-inspector/package-lock.json b/services/native-parsers/engines/pdf-inspector/package-lock.json new file mode 100644 index 0000000..4c8c585 --- /dev/null +++ b/services/native-parsers/engines/pdf-inspector/package-lock.json @@ -0,0 +1,113 @@ +{ + "name": "@file_router/pdf-inspector-runtime", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@file_router/pdf-inspector-runtime", + "dependencies": { + "@firecrawl/pdf-inspector": "1.11.1" + }, + "devDependencies": { + "@types/node": "^22", + "typescript": "^6" + } + }, + "node_modules/@firecrawl/pdf-inspector": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@firecrawl/pdf-inspector/-/pdf-inspector-1.11.1.tgz", + "integrity": "sha512-v4SinJp+lJg3QYK5uJwkQoVm2pifU8aS1umtKsNvZepIp7ce76fyht8us8Ll3pJjAwUJtO2qEcgMmLaV4T749g==", + "license": "MIT", + "bin": { + "pdf-inspector": "bin/pdf-inspector.mjs" + }, + "optionalDependencies": { + "@firecrawl/pdf-inspector-darwin-arm64": "1.11.1", + "@firecrawl/pdf-inspector-linux-x64-gnu": "1.11.1", + "@firecrawl/pdf-inspector-win32-x64-msvc": "1.11.1" + } + }, + "node_modules/@firecrawl/pdf-inspector-darwin-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@firecrawl/pdf-inspector-darwin-arm64/-/pdf-inspector-darwin-arm64-1.11.1.tgz", + "integrity": "sha512-MYworvuQ84dRxMhVmhSuSP8gVdrxqKfnTC+spGc+OIEaJGHNuzgBUzgi9O7p3wSP/hkK9QdCDEbl6wdBO9a4rA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@firecrawl/pdf-inspector-linux-x64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@firecrawl/pdf-inspector-linux-x64-gnu/-/pdf-inspector-linux-x64-gnu-1.11.1.tgz", + "integrity": "sha512-Vyp7EpmplnWFsyi7YGK1yKE97PIrueIpo7jJx9zfU5wVAjSQuBRwykYbSUywsS3GPEbfgFlMDAW0Cls4lbtPqw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@firecrawl/pdf-inspector-win32-x64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@firecrawl/pdf-inspector-win32-x64-msvc/-/pdf-inspector-win32-x64-msvc-1.11.1.tgz", + "integrity": "sha512-B3hpMRHlJFaOdZaWiG0wyGcfVkf3AQh232tRd0kN4EpAIyo/sEU6+cbEarH1Z+ypd7QRNlO6fcnSYHelZaqS0A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/services/native-parsers/engines/pdf-inspector/package.json b/services/native-parsers/engines/pdf-inspector/package.json new file mode 100644 index 0000000..955f424 --- /dev/null +++ b/services/native-parsers/engines/pdf-inspector/package.json @@ -0,0 +1,16 @@ +{ + "name": "@file_router/pdf-inspector-runtime", + "private": true, + "type": "module", + "scripts": { + "start": "node server.ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@firecrawl/pdf-inspector": "1.11.1" + }, + "devDependencies": { + "@types/node": "^22", + "typescript": "^6" + } +} diff --git a/services/native-parsers/engines/pdf-inspector/server.ts b/services/native-parsers/engines/pdf-inspector/server.ts new file mode 100644 index 0000000..d330395 --- /dev/null +++ b/services/native-parsers/engines/pdf-inspector/server.ts @@ -0,0 +1,85 @@ +import { classifyPdf, extractPagesMarkdown } from "@firecrawl/pdf-inspector" + +import runtimePackage from "./package.json" with { type: "json" } +import { assertPdfInspectorPageLimit } from "./options.ts" + +import type { NativeParserResult } from "../shared/contracts.ts" +import { ParserRequestError, startParserServer } from "../shared/http.ts" +import { readNativeParserOptions, readObject } from "../shared/options.ts" + +const ENGINE_VERSION = runtimePackage.dependencies["@firecrawl/pdf-inspector"] +const MAX_INPUT_BYTES = 50 * 1024 * 1024 +const MAX_OUTPUT_BYTES = 32 * 1024 * 1024 + +startParserServer({ + handler: async ({ bytes, options }) => parsePdf(bytes, options), + maxBytes: MAX_INPUT_BYTES, + maxConcurrency: 4, + maxResponseBytes: MAX_OUTPUT_BYTES, + parserId: "pdf-inspector", +}) + +function parsePdf(bytes: Buffer, value: unknown): NativeParserResult { + const options = readNativeParserOptions(value) + const requestedPages = options.pages + if ( + options.providerOptions !== undefined && + Object.keys(readObject(options.providerOptions)).length > 0 + ) { + throw new ParserRequestError( + 400, + "invalid_provider_options", + "PDF Inspector does not accept provider options." + ) + } + const classification = classifyPdf(bytes) + assertPdfInspectorPageLimit( + requestedPages?.length ?? classification.pageCount + ) + if (requestedPages?.some((page) => page > classification.pageCount)) { + throw new ParserRequestError( + 400, + "invalid_pages", + "Requested page exceeds the document page count." + ) + } + const selectedPages = requestedPages?.map((page) => page - 1) + const extraction = extractPagesMarkdown(bytes, selectedPages) + const pages = extraction.pages.map((page) => ({ + markdown: page.markdown, + metadata: { + ...(page.needsOcr && { needsOcr: true }), + ...(page.ocrReason && { ocrReason: page.ocrReason }), + }, + pageNumber: page.page + 1, + })) + const warnings = extraction.pages.flatMap((page) => + page.needsOcr + ? [ + { + code: "ocr_required", + message: page.ocrReason + ? `Page requires OCR: ${page.ocrReason}.` + : "Page requires OCR.", + pageNumber: page.page + 1, + }, + ] + : [] + ) + + return { + engine: { id: "pdf-inspector", version: ENGINE_VERSION }, + markdown: pages.map((page) => page.markdown ?? "").join("\n\n"), + metadata: { + confidence: classification.confidence, + isComplex: extraction.isComplex, + pagesNeedingOcr: extraction.pagesNeedingOcr, + pagesWithColumns: extraction.pagesWithColumns, + pagesWithTables: extraction.pagesWithTables, + pdfType: classification.pdfType, + }, + pageCount: classification.pageCount, + pages, + warnings, + } +} diff --git a/services/native-parsers/engines/pdf-inspector/tsconfig.json b/services/native-parsers/engines/pdf-inspector/tsconfig.json new file mode 100644 index 0000000..a74c34b --- /dev/null +++ b/services/native-parsers/engines/pdf-inspector/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "allowImportingTsExtensions": true, + "exactOptionalPropertyTypes": true, + "lib": ["ES2022"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noEmit": true, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true, + "target": "ES2022", + "types": ["node"], + "verbatimModuleSyntax": true + }, + "include": ["server.ts", "../shared/**/*.ts"] +} diff --git a/services/native-parsers/engines/shared/contracts.ts b/services/native-parsers/engines/shared/contracts.ts new file mode 100644 index 0000000..78203f2 --- /dev/null +++ b/services/native-parsers/engines/shared/contracts.ts @@ -0,0 +1,41 @@ +export type NativeParserId = "liteparse" | "pdf-inspector" + +export type NativeParserWarning = { + code: string + message: string + pageNumber?: number +} + +export type NativeParserPage = { + dimensions?: { height: number; width: number } + markdown?: string + metadata?: Record + pageNumber: number + text?: string +} + +export type NativeParserImage = { + data: string + id: string + mimeType: string + pageNumber: number +} + +export type NativeParserResult = { + engine: { + id: NativeParserId + version: string + } + images?: Array + markdown?: string + metadata: Record + pageCount: number + pages: Array + text?: string + warnings: Array +} + +export type NativeParserOptions = { + pages?: Array + providerOptions?: unknown +} diff --git a/services/native-parsers/engines/shared/http.ts b/services/native-parsers/engines/shared/http.ts new file mode 100644 index 0000000..accc7b4 --- /dev/null +++ b/services/native-parsers/engines/shared/http.ts @@ -0,0 +1,262 @@ +import { createServer } from "node:http" +import type { IncomingMessage, ServerResponse } from "node:http" + +import { + JOB_ID_HEADER, + RELEASE_ID_HEADER, + REQUEST_ID_HEADER, + emitWideEvent, + serializeError, +} from "./observability.ts" + +export const ENGINE_OPTIONS_HEADER = "x-filerouter-engine-options" +export const FILE_NAME_HEADER = "x-filerouter-file-name" + +export type ParserRequest = { + bytes: Buffer + contentType: string + fileName: string + options: unknown +} + +export type ParserHandler = (request: ParserRequest) => Promise + +export type ParserServerOptions = { + handler: ParserHandler + maxBytes: number + maxConcurrency: number + maxResponseBytes: number + parserId: string + port?: number +} + +export function startParserServer(options: ParserServerOptions): void { + let activeRequests = 0 + const server = createServer(async (request, response) => { + const startedAt = Date.now() + const requestId = + singleHeader(request.headers[REQUEST_ID_HEADER]) ?? crypto.randomUUID() + const path = new URL(request.url ?? "/", "http://container").pathname + response.setHeader("X-Request-Id", requestId) + let bodyBytes: number | undefined + let errorCode: string | undefined + let failure: unknown + let requestActive = false + let status = 500 + + try { + if (request.method === "GET" && path === "/health") { + status = sendJson(response, 200, { + parser: options.parserId, + status: "ok", + }) + return + } + if (request.method !== "POST" || path !== "/parse") { + errorCode = "route_not_found" + status = sendJson(response, 404, { + error: { code: errorCode, message: "Parser route not found." }, + }) + return + } + if (activeRequests >= options.maxConcurrency) { + errorCode = "capacity_exceeded" + status = sendJson(response, 429, { + error: { + code: errorCode, + message: `${options.parserId} is at capacity.`, + }, + }) + return + } + + activeRequests += 1 + requestActive = true + const bytes = await readBody(request, options.maxBytes) + bodyBytes = bytes.byteLength + const engineOptions = parseOptions( + singleHeader(request.headers[ENGINE_OPTIONS_HEADER]) + ) + const result = await options.handler({ + bytes, + contentType: + singleHeader(request.headers["content-type"]) ?? + "application/octet-stream", + fileName: + decodeHeader(singleHeader(request.headers[FILE_NAME_HEADER])) ?? + "document", + options: engineOptions, + }) + status = sendJson(response, 200, result, options.maxResponseBytes) + if (status >= 400) { + errorCode = "provider_limit_exceeded" + } + } catch (error) { + failure = error + const parserFailure = toParserFailure(error) + errorCode = parserFailure.error.code + status = sendJson(response, parserFailure.status, { + error: parserFailure.error, + }) + } finally { + if (requestActive) { + activeRequests -= 1 + } + emitWideEvent( + status >= 500 ? "error" : "info", + { + active_requests: activeRequests, + body_bytes: bodyBytes, + content_length: numericHeader( + singleHeader(request.headers["content-length"]) + ), + content_type: + singleHeader(request.headers["content-type"]) ?? undefined, + duration_ms: Date.now() - startedAt, + error_code: errorCode, + event: "parser_engine_request_completed", + job_id: singleHeader(request.headers[JOB_ID_HEADER]), + max_concurrency: options.maxConcurrency, + method: request.method, + outcome: + status >= 500 ? "error" : status >= 400 ? "rejected" : "success", + parser: options.parserId, + path, + release_id: singleHeader(request.headers[RELEASE_ID_HEADER]), + request_id: requestId, + service: `filerouter-${options.parserId}`, + status_code: status, + ...(failure ? serializeError(failure) : {}), + }, + process.env.NODE_ENV ?? "production" + ) + } + }) + + server.listen(options.port ?? 8080, "0.0.0.0") +} + +export class ParserRequestError extends Error { + readonly code: string + readonly status: number + + constructor(status: number, code: string, message: string) { + super(message) + this.name = "ParserRequestError" + this.code = code + this.status = status + } +} + +async function readBody( + request: IncomingMessage, + maxBytes: number +): Promise { + const contentLength = Number(request.headers["content-length"]) + if (Number.isFinite(contentLength) && contentLength > maxBytes) { + throw new ParserRequestError( + 413, + "provider_limit_exceeded", + `Document exceeds the ${maxBytes}-byte parser limit.` + ) + } + + const chunks: Array = [] + let size = 0 + for await (const chunk of request) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) + size += buffer.byteLength + if (size > maxBytes) { + throw new ParserRequestError( + 413, + "provider_limit_exceeded", + `Document exceeds the ${maxBytes}-byte parser limit.` + ) + } + chunks.push(buffer) + } + if (size === 0) { + throw new ParserRequestError(400, "empty_document", "Document is empty.") + } + return Buffer.concat(chunks, size) +} + +function parseOptions(value: string | undefined): unknown { + if (!value) { + return {} + } + try { + return JSON.parse(decodeURIComponent(value)) as unknown + } catch { + throw new ParserRequestError( + 400, + "invalid_provider_options", + "Parser options are invalid." + ) + } +} + +function decodeHeader(value: string | undefined): string | undefined { + if (!value) { + return undefined + } + try { + return decodeURIComponent(value) + } catch { + throw new ParserRequestError( + 400, + "invalid_file_name", + "Document filename is invalid." + ) + } +} + +function singleHeader( + value: string | Array | undefined +): string | undefined { + return Array.isArray(value) ? value[0] : value +} + +function sendJson( + response: ServerResponse, + status: number, + value: unknown, + maxBytes?: number +): number { + const body = JSON.stringify(value) + if (maxBytes && Buffer.byteLength(body) > maxBytes) { + return sendJson(response, 413, { + error: { + code: "provider_limit_exceeded", + message: `Parser response exceeds the ${maxBytes}-byte output limit.`, + }, + }) + } + response.writeHead(status, { + "Content-Length": Buffer.byteLength(body), + "Content-Type": "application/json; charset=utf-8", + }) + response.end(body) + return status +} + +function numericHeader(value: string | undefined): number | undefined { + const number = Number(value) + return Number.isFinite(number) ? number : undefined +} + +function toParserFailure(error: unknown): { + error: { code: string; message: string } + status: number +} { + if (error instanceof ParserRequestError) { + return { + error: { code: error.code, message: error.message }, + status: error.status, + } + } + return { + error: { code: "parse_failed", message: "Document parsing failed." }, + status: 500, + } +} diff --git a/services/native-parsers/engines/shared/observability.ts b/services/native-parsers/engines/shared/observability.ts new file mode 100644 index 0000000..e58ff84 --- /dev/null +++ b/services/native-parsers/engines/shared/observability.ts @@ -0,0 +1,22 @@ +export const REQUEST_ID_HEADER = "x-request-id" +export const JOB_ID_HEADER = "x-filerouter-job-id" +export const RELEASE_ID_HEADER = "x-filerouter-release-id" + +export function emitWideEvent( + level: "error" | "info", + event: Record & { event: string; service: string }, + environment = "production" +): void { + const record = JSON.stringify({ + timestamp: new Date().toISOString(), + environment, + ...event, + }) + console[level](record) +} + +export function serializeError(error: unknown) { + return error instanceof Error + ? { error_message: error.message, error_type: error.name } + : { error_message: "Unknown error", error_type: "UnknownError" } +} diff --git a/services/native-parsers/engines/shared/options.ts b/services/native-parsers/engines/shared/options.ts new file mode 100644 index 0000000..3d23584 --- /dev/null +++ b/services/native-parsers/engines/shared/options.ts @@ -0,0 +1,75 @@ +import type { NativeParserOptions } from "./contracts.ts" +import { ParserRequestError } from "./http.ts" + +export function readNativeParserOptions(value: unknown): NativeParserOptions { + const options = readObject(value, "Parser options must be an object.") + assertAllowedKeys(options, ["pages", "providerOptions"]) + const pages = readPages(options.pages) + return { + ...(pages && { pages }), + ...(options.providerOptions !== undefined && { + providerOptions: options.providerOptions, + }), + } +} + +export function readObject( + value: unknown, + message = "Provider options must be an object." +): Record { + if (!isRecord(value)) { + throw invalidOptions(message) + } + return value +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +export function assertAllowedKeys( + value: Record, + allowed: ReadonlyArray +): void { + const allowedKeys = new Set(allowed) + const unsupported = Object.keys(value).filter((key) => !allowedKeys.has(key)) + if (unsupported.length > 0) { + throw invalidOptions(`Unsupported options: ${unsupported.join(", ")}.`) + } +} + +export function readOptionalBoolean( + value: unknown, + name: string +): boolean | undefined { + if (value === undefined || typeof value === "boolean") { + return value + } + throw invalidOptions(`${name} must be a boolean.`) +} + +function readPages(value: unknown): Array | undefined { + if (value === undefined) { + return undefined + } + if ( + !Array.isArray(value) || + value.length === 0 || + value.some((page) => !Number.isSafeInteger(page) || page < 1) + ) { + throw new ParserRequestError( + 400, + "invalid_pages", + "Pages must be positive, one-based integers." + ) + } + return [ + ...new Set( + value.filter((page): page is number => typeof page === "number") + ), + ] +} + +export function invalidOptions(message: string): ParserRequestError { + return new ParserRequestError(400, "invalid_provider_options", message) +} diff --git a/services/native-parsers/engines/shared/tsconfig.json b/services/native-parsers/engines/shared/tsconfig.json new file mode 100644 index 0000000..71f3967 --- /dev/null +++ b/services/native-parsers/engines/shared/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "allowImportingTsExtensions": true, + "exactOptionalPropertyTypes": true, + "lib": ["ES2022"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noEmit": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "skipLibCheck": true, + "strict": true, + "target": "ES2022", + "types": ["node"], + "verbatimModuleSyntax": true + }, + "include": ["*.ts"] +} diff --git a/services/native-parsers/package.json b/services/native-parsers/package.json new file mode 100644 index 0000000..f86ced5 --- /dev/null +++ b/services/native-parsers/package.json @@ -0,0 +1,22 @@ +{ + "name": "@file_router/native-parsers", + "private": true, + "type": "module", + "scripts": { + "cf-typegen": "wrangler types worker-configuration.d.ts --include-runtime false", + "deploy": "wrangler deploy --config wrangler.jsonc", + "deploy:dry-run": "wrangler deploy --config wrangler.jsonc --dry-run", + "test": "vp test run --config vitest.config.ts", + "typecheck": "tsc --noEmit && tsc -p engines/liteparse && tsc -p engines/pdf-inspector && tsc -p engines/shared" + }, + "dependencies": { + "@cloudflare/containers": "0.3.7" + }, + "devDependencies": { + "@cloudflare/workers-types": "5.20260721.1", + "@types/node": "^22", + "typescript": "^6", + "vite-plus": "catalog:", + "wrangler": "^4.112.0" + } +} diff --git a/services/native-parsers/src/index.ts b/services/native-parsers/src/index.ts new file mode 100644 index 0000000..6c8c5b5 --- /dev/null +++ b/services/native-parsers/src/index.ts @@ -0,0 +1,197 @@ +import { Container, getRandom } from "@cloudflare/containers" + +import { + JOB_ID_HEADER, + RELEASE_ID_HEADER, + REQUEST_ID_HEADER, + emitWideEvent, + responseWithRequestId, + serializeError, +} from "./observability" + +const PDF_INSPECTOR_POOL_SIZE = 2 +const LITEPARSE_POOL_SIZE = 1 + +export class PdfInspectorContainer extends Container { + defaultPort = 8080 + sleepAfter = "1m" +} + +export class LiteParseContainer extends Container { + defaultPort = 8080 + sleepAfter = "1m" +} + +type ParserPool = { + binding: + | DurableObjectNamespace + | DurableObjectNamespace + parser: "liteparse" | "pdf-inspector" + size: number +} + +export default { + async fetch(request: Request, env: Cloudflare.Env): Promise { + const startedAt = Date.now() + const url = new URL(request.url) + const requestId = + request.headers.get(REQUEST_ID_HEADER)?.trim() || crypto.randomUUID() + const headers = new Headers(request.headers) + headers.set(REQUEST_ID_HEADER, requestId) + headers.set(RELEASE_ID_HEADER, env.WORKER_VERSION.id) + const routedRequest = new Request(request, { headers }) + const pool = parserPool(url.pathname, env) + let response: Response | undefined + let failure: unknown + + try { + if (!pool) { + response = jsonError(404, "parser_not_found", "Parser route not found.") + } else if (request.method !== "POST") { + response = jsonError(405, "method_not_allowed", "POST is required.", { + Allow: "POST", + }) + } else { + const parserRequest = await resolveParserRequest( + routedRequest, + env.SOURCE_ORIGIN + ) + if (parserRequest instanceof Response) { + response = parserRequest + } else { + const container = await getRandom(pool.binding, pool.size) + const target = new URL("/parse", routedRequest.url) + response = await container.fetch(new Request(target, parserRequest)) + } + } + return responseWithRequestId(response, requestId) + } catch (error) { + failure = error + throw error + } finally { + const status = response?.status ?? 500 + emitWideEvent(env, status >= 500 ? "error" : "info", { + content_length: numericHeader(request.headers.get("content-length")), + content_type: request.headers.get("content-type") ?? undefined, + duration_ms: Date.now() - startedAt, + event: "native_parser_request_completed", + job_id: request.headers.get(JOB_ID_HEADER) ?? undefined, + method: request.method, + outcome: + status >= 500 ? "error" : status >= 400 ? "rejected" : "success", + parser: pool?.parser, + path: url.pathname, + request_id: requestId, + service: "filerouter-native-parsers", + status_code: status, + ...(failure ? serializeError(failure) : {}), + }) + } + }, +} satisfies ExportedHandler + +function parserPool( + pathname: string, + env: Cloudflare.Env +): ParserPool | undefined { + if (pathname === "/v1/pdf-inspector/parse") { + return { + binding: env.PDF_INSPECTOR_CONTAINERS, + parser: "pdf-inspector", + size: PDF_INSPECTOR_POOL_SIZE, + } + } + if (pathname === "/v1/liteparse/parse") { + return { + binding: env.LITEPARSE_CONTAINERS, + parser: "liteparse", + size: LITEPARSE_POOL_SIZE, + } + } + return undefined +} + +async function resolveParserRequest( + request: Request, + sourceOrigin: string +): Promise { + const sourceValue = request.headers.get("x-filerouter-source-url") + if (!sourceValue) { + return request + } + let sourceUrl: URL + try { + sourceUrl = new URL(sourceValue) + const allowedOrigin = new URL(sourceOrigin).origin + if ( + sourceUrl.origin !== allowedOrigin || + !sourceUrl.pathname.startsWith("/api/v1/sources/") || + !sourceUrl.searchParams.has("expires") || + !sourceUrl.searchParams.has("token") + ) { + throw new Error("not a FileRouter source URL") + } + } catch { + return jsonError(400, "invalid_source_url", "Parser source URL is invalid.") + } + + const source = await fetch(sourceUrl, { + headers: correlationHeaders(request.headers), + redirect: "manual", + }) + if (!source.ok || !source.body) { + return jsonError(400, "source_unavailable", "Parser source is unavailable.") + } + const headers = new Headers(request.headers) + headers.delete("x-filerouter-source-url") + const fileName = sourceUrl.pathname.split("/").at(-1) + if (fileName) { + headers.set("x-filerouter-file-name", fileName) + } + headers.set( + "Content-Type", + source.headers.get("Content-Type") ?? "application/octet-stream" + ) + const length = source.headers.get("Content-Length") + if (length) { + headers.set("Content-Length", length) + } else { + headers.delete("Content-Length") + } + return new Request(request.url, { + body: source.body, + headers, + method: "POST", + }) +} + +function correlationHeaders(source: Headers): Headers { + const headers = new Headers() + for (const name of [JOB_ID_HEADER, REQUEST_ID_HEADER]) { + const value = source.get(name) + if (value) { + headers.set(name, value) + } + } + return headers +} + +function numericHeader(value: string | null): number | undefined { + if (!value) { + return undefined + } + const number = Number(value) + return Number.isFinite(number) ? number : undefined +} + +function jsonError( + status: number, + code: string, + message: string, + headers?: HeadersInit +): Response { + return Response.json( + { error: { code, message } }, + { ...(headers && { headers }), status } + ) +} diff --git a/services/native-parsers/src/observability.ts b/services/native-parsers/src/observability.ts new file mode 100644 index 0000000..7429478 --- /dev/null +++ b/services/native-parsers/src/observability.ts @@ -0,0 +1,34 @@ +export { + JOB_ID_HEADER, + RELEASE_ID_HEADER, + REQUEST_ID_HEADER, + serializeError, +} from "../engines/shared/observability" + +export function emitWideEvent( + env: Cloudflare.Env, + level: "error" | "info", + event: Record & { event: string; service: string } +): void { + console[level]({ + timestamp: new Date().toISOString(), + environment: "production", + release_id: env.WORKER_VERSION.id, + release_tag: env.WORKER_VERSION.tag, + release_uploaded_at: env.WORKER_VERSION.timestamp, + ...event, + }) +} + +export function responseWithRequestId( + response: Response, + requestId: string +): Response { + const headers = new Headers(response.headers) + headers.set("X-Request-Id", requestId) + return new Response(response.body, { + headers, + status: response.status, + statusText: response.statusText, + }) +} diff --git a/services/native-parsers/test/options.test.ts b/services/native-parsers/test/options.test.ts new file mode 100644 index 0000000..fa7fd4a --- /dev/null +++ b/services/native-parsers/test/options.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, test } from "vite-plus/test" + +import { + assertPdfInspectorPageLimit, + MAX_PDF_INSPECTOR_PAGES, +} from "../engines/pdf-inspector/options.ts" + +describe("native parser limits", () => { + test("caps selected PDF Inspector pages", () => { + expect(() => + assertPdfInspectorPageLimit(MAX_PDF_INSPECTOR_PAGES) + ).not.toThrow() + expect(() => + assertPdfInspectorPageLimit(MAX_PDF_INSPECTOR_PAGES + 1) + ).toThrow("PDF Inspector supports at most 1000 pages per request.") + }) +}) diff --git a/services/native-parsers/tsconfig.json b/services/native-parsers/tsconfig.json new file mode 100644 index 0000000..c184daf --- /dev/null +++ b/services/native-parsers/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "exactOptionalPropertyTypes": true, + "lib": ["ES2022", "WebWorker"], + "module": "ESNext", + "moduleResolution": "bundler", + "noEmit": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "skipLibCheck": true, + "strict": true, + "target": "ES2022", + "types": ["@cloudflare/workers-types"], + "verbatimModuleSyntax": true + }, + "include": ["src/**/*.ts", "worker-configuration.d.ts"] +} diff --git a/services/native-parsers/vitest.config.ts b/services/native-parsers/vitest.config.ts new file mode 100644 index 0000000..828c22b --- /dev/null +++ b/services/native-parsers/vitest.config.ts @@ -0,0 +1,5 @@ +import { defineConfig } from "vite-plus" + +export default defineConfig({ + test: { include: ["test/**/*.test.ts"] }, +}) diff --git a/services/native-parsers/worker-configuration.d.ts b/services/native-parsers/worker-configuration.d.ts new file mode 100644 index 0000000..83ca6e8 --- /dev/null +++ b/services/native-parsers/worker-configuration.d.ts @@ -0,0 +1,30 @@ +/* eslint-disable */ +// Generated by Wrangler by running `wrangler types --include-runtime=false` (hash: 376018f7ef354f389a3eb45ad22db42a) +interface __BaseEnv_Env { + WORKER_VERSION: WorkerVersionMetadata + SOURCE_ORIGIN: "https://filerouter.dev" + PDF_INSPECTOR_CONTAINERS: DurableObjectNamespace< + import("./src/index").PdfInspectorContainer + > + LITEPARSE_CONTAINERS: DurableObjectNamespace< + import("./src/index").LiteParseContainer + > +} +declare namespace Cloudflare { + interface GlobalProps { + mainModule: typeof import("./src/index") + durableNamespaces: "PdfInspectorContainer" | "LiteParseContainer" + } + interface Env extends __BaseEnv_Env {} +} +interface Env extends __BaseEnv_Env {} +type StringifyValues> = { + [Binding in keyof EnvType]: EnvType[Binding] extends string + ? EnvType[Binding] + : string +} +declare namespace NodeJS { + interface ProcessEnv extends StringifyValues< + Pick + > {} +} diff --git a/services/native-parsers/wrangler.jsonc b/services/native-parsers/wrangler.jsonc new file mode 100644 index 0000000..97e5b9b --- /dev/null +++ b/services/native-parsers/wrangler.jsonc @@ -0,0 +1,63 @@ +{ + "$schema": "../../node_modules/wrangler/config-schema.json", + "name": "filerouter-native-parsers", + "main": "src/index.ts", + "compatibility_date": "2026-07-21", + "compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"], + "workers_dev": false, + "preview_urls": false, + "observability": { + "enabled": true, + "logs": { + "enabled": true, + "head_sampling_rate": 1, + "invocation_logs": true, + }, + "traces": { + "enabled": true, + "head_sampling_rate": 1, + }, + }, + "version_metadata": { + "binding": "WORKER_VERSION", + }, + "vars": { + "SOURCE_ORIGIN": "https://filerouter.dev", + }, + "containers": [ + { + "name": "filerouter-pdf-inspector", + "class_name": "PdfInspectorContainer", + "image": "./engines/pdf-inspector/Dockerfile", + "image_build_context": "./engines", + "instance_type": "basic", + "max_instances": 4, + }, + { + "name": "filerouter-liteparse", + "class_name": "LiteParseContainer", + "image": "./engines/liteparse/Dockerfile", + "image_build_context": "./engines", + "instance_type": "standard-1", + "max_instances": 2, + }, + ], + "durable_objects": { + "bindings": [ + { + "name": "PDF_INSPECTOR_CONTAINERS", + "class_name": "PdfInspectorContainer", + }, + { + "name": "LITEPARSE_CONTAINERS", + "class_name": "LiteParseContainer", + }, + ], + }, + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": ["PdfInspectorContainer", "LiteParseContainer"], + }, + ], +} diff --git a/src/api/app.ts b/src/api/app.ts index 7f2a071..845b761 100644 --- a/src/api/app.ts +++ b/src/api/app.ts @@ -8,6 +8,11 @@ import { import { createJobRoute, getJobRoute, JobIdSchema } from "@/api/contracts" import { problemResponse } from "@/api/problem" +import { + captureServerTelemetry, + postHogDistinctId, + postHogTraceProperties, +} from "@/integrations/posthog/server" import type { ApiPrincipal } from "@/lib/api-auth.server" import { requireApiPrincipal } from "@/lib/api-auth.server" import { @@ -16,16 +21,45 @@ import { } from "@/lib/document-jobs.server" import { HttpError } from "@/lib/http.server" import { getProviderSourceResponse } from "@/lib/document-source.server" +import { + emitWideEvent, + serializeError, + type WideEvent, +} from "@/observability/log" + +type ApiRequestEvent = Partial & { + credential_id?: string + error_code?: string + job_id?: string + request_id: string + user_id?: string +} type ApiBindings = { Bindings: Cloudflare.Env - Variables: { principal: ApiPrincipal } + Variables: { principal: ApiPrincipal; requestEvent: ApiRequestEvent } +} + +function scheduleBackgroundTask( + context: { executionCtx: { waitUntil(task: Promise): void } }, + task: Promise +): void { + try { + context.executionCtx.waitUntil(task) + } catch { + // Direct Hono invocations do not have a Cloudflare ExecutionContext. + // The task is already running and telemetry delivery handles its own errors. + } } function requireApiKey(permission: "create" | "read") { return createMiddleware(async (context, next) => { const principal = await requireApiPrincipal(context.req.raw, permission) context.set("principal", principal) + Object.assign(context.get("requestEvent"), { + credential_id: principal.credentialId, + user_id: principal.userId, + }) await next() }) } @@ -43,18 +77,73 @@ export const api = new OpenAPIHono({ }) api.use("*", requestId({ limitLength: 128 })) +api.use("*", async (context, next) => { + const startedAt = Date.now() + const requestEvent: ApiRequestEvent = { + request_id: context.get("requestId"), + } + context.set("requestEvent", requestEvent) + context.header("X-Request-Id", requestEvent.request_id) + + let failure: unknown + try { + await next() + } catch (error) { + failure = error + if (error instanceof HttpError) { + requestEvent.error_code = error.code ?? "http_error" + } + throw error + } finally { + const status = + failure instanceof HttpError + ? failure.status + : failure + ? 500 + : context.res.status + emitWideEvent(context.env, status >= 500 ? "error" : "info", { + ...requestEvent, + colo: context.req.raw.cf?.colo, + duration_ms: Date.now() - startedAt, + event: "api_request_completed", + method: context.req.method, + outcome: + status >= 500 + ? "error" + : status === 404 + ? "not_found" + : status >= 400 + ? "rejected" + : "success", + path: context.req.path, + service: "filerouter-api", + status_code: status, + ...(failure ? serializeError(failure) : {}), + }) + } +}) api.onError((error, context) => { if (error instanceof HttpError) { return problemResponse(context, error) } - console.error( - JSON.stringify({ - error: error.message, - method: context.req.method, - path: context.req.path, - requestId: context.get("requestId"), + const requestEvent = context.get("requestEvent") + scheduleBackgroundTask( + context, + captureServerTelemetry(context.env, { + distinctId: + requestEvent.user_id ?? + postHogDistinctId(context.req.raw, requestEvent.request_id), + exception: error, + properties: { + ...postHogTraceProperties(context.req.raw), + job_id: requestEvent.job_id, + method: context.req.method, + path: context.req.path, + request_id: requestEvent.request_id, + service: "filerouter-api", + }, }) ) return problemResponse( @@ -63,12 +152,13 @@ api.onError((error, context) => { ) }) -api.notFound((context) => - problemResponse( +api.notFound((context) => { + context.get("requestEvent").error_code = "route_not_found" + return problemResponse( context, new HttpError(404, "API route not found.", { code: "route_not_found" }) ) -) +}) api.use(HOSTED_JOBS_PATH, requireApiKey("create")) api.use(`${HOSTED_JOBS_PATH}/:jobId`, requireApiKey("read")) @@ -86,10 +176,28 @@ api.openapi(createJobRoute, async (context) => { context.get("principal").userId, context.env, idempotencyKey, + context.get("requestId"), context.req.header("content-type")?.includes("application/json") ? context.req.valid("json") : undefined ) + Object.assign(context.get("requestEvent"), { + job_id: result.job.id, + replayed: result.replayed, + }) + scheduleBackgroundTask( + context, + captureServerTelemetry(context.env, { + distinctId: context.get("principal").userId, + event: "document_job_submitted", + properties: { + ...postHogTraceProperties(context.req.raw), + job_id: result.job.id, + replayed: result.replayed, + request_id: context.get("requestId"), + }, + }) + ) if (result.replayed) { context.header("Idempotent-Replayed", "true") return context.json(result.job, 200) @@ -102,10 +210,12 @@ api.get(`${HOSTED_JOBS_PATH}/:jobId`, async (context) => { if (!jobId.success) { throw new HttpError(400, "Invalid job id.", { code: "invalid_job_id" }) } + context.get("requestEvent").job_id = jobId.data const response = await getDocumentJobResponse( jobId.data, context.get("principal").userId, - context.env + context.env, + context.get("requestId") ) return response instanceof Response ? response : context.json(response, 200) }) @@ -118,6 +228,7 @@ api.on(["GET", "HEAD"], "/api/v1/sources/:jobId/:fileName", async (context) => { code: "source_not_found", }) } + context.get("requestEvent").job_id = jobId.data return getProviderSourceResponse( context.req.raw, context.env, diff --git a/src/api/contracts.ts b/src/api/contracts.ts index 3f0a61b..bde4830 100644 --- a/src/api/contracts.ts +++ b/src/api/contracts.ts @@ -135,6 +135,7 @@ export const createJobRoute = createRoute({ }, 400: problem, 401: problem, + 402: problem, 409: problem, 413: problem, 429: problem, diff --git a/src/api/problem.ts b/src/api/problem.ts index 3f4347c..2a0df04 100644 --- a/src/api/problem.ts +++ b/src/api/problem.ts @@ -6,6 +6,7 @@ import type { HttpError } from "@/lib/http.server" const statusMetadata = { 400: { code: "bad_request", title: "Bad Request" }, 401: { code: "unauthorized", title: "Unauthorized" }, + 402: { code: "payment_required", title: "Payment Required" }, 403: { code: "forbidden", title: "Forbidden" }, 404: { code: "not_found", title: "Not Found" }, 409: { code: "conflict", title: "Conflict" }, diff --git a/src/components/api-keys.tsx b/src/components/api-keys.tsx index d94f7ad..b990f60 100644 --- a/src/components/api-keys.tsx +++ b/src/components/api-keys.tsx @@ -1,8 +1,7 @@ import type { ApiKey } from "@better-auth/api-key" -import { Check, Copy, Key, Plus, Trash } from "@phosphor-icons/react" +import { Check, Copy, Trash } from "@phosphor-icons/react" import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { useState } from "react" -import { FILEROUTER_API_KEY_PREFIX } from "@file_router/sdk/hosted" import { Button } from "@/components/ui/button" import { @@ -18,6 +17,10 @@ import { } from "@/components/ui/alert-dialog" import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" import { Input } from "@/components/ui/input" +import { + captureBrowserEvent, + captureBrowserException, +} from "@/integrations/posthog/browser" import { authClient } from "@/lib/auth-client" type ApiKeySummary = Omit @@ -46,19 +49,24 @@ export function ApiKeys() { }) const createKeyMutation = useMutation({ - mutationFn: async (keyName: string) => { - const result = await authClient.apiKey.create({ name: keyName }) + mutationFn: async (keyName?: string) => { + const result = await authClient.apiKey.create( + keyName ? { name: keyName } : {} + ) if (result.error) { throw new Error(result.error.message ?? "Could not create API key.") } return result.data.key }, onSuccess: async (key) => { + captureBrowserEvent("api_key_created", { named: Boolean(name.trim()) }) setName("") setCopied(false) setCreatedKey(key) await queryClient.invalidateQueries({ queryKey: apiKeysQueryKey }) }, + onError: (error) => + captureBrowserException(error, { operation: "api_key_create" }), }) const revokeKeyMutation = useMutation({ @@ -71,12 +79,14 @@ export function ApiKeys() { onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: apiKeysQueryKey }) }, + onError: (error) => + captureBrowserException(error, { operation: "api_key_revoke" }), }) function createKey(event: React.FormEvent) { event.preventDefault() const keyName = name.trim() - if (keyName) createKeyMutation.mutate(keyName) + createKeyMutation.mutate(keyName || undefined) } async function copyCreatedKey() { @@ -90,16 +100,13 @@ export function ApiKeys() { return (
-
- -

- API keys -

-
+

+ API keys +

Create a key for the TypeScript SDK or hosted HTTP API.

@@ -115,7 +122,7 @@ export function ApiKeys() { setName(event.target.value)} maxLength={64} @@ -123,19 +130,18 @@ export function ApiKeys() { {createdKey ? ( - + Copy this key now It will not be shown again. -
- +
+ {createdKey} + + + +

How credits work

+

+ 1,000 credits equal $1 of hosted usage. Usage varies by parser, + pages, and processing time. Comparisons use credits for each + successful provider. Direct requests use no FileRouter credits. +

+
+
+ +
+

+ {remaining} +

+

+ {summary.data + ? `${formatCredits(summary.data.includedCredits)} free credits each month. Purchased credits never expire.` + : "Loading balance…"} +

+ + {checkout.isError ? ( +

+ Could not open checkout. Try again. +

+ ) : null} + + ) +} + +function formatCredits(value: number): string { + return creditFormatter.format(value) +} diff --git a/src/components/dashboard-quickstart.tsx b/src/components/dashboard-quickstart.tsx index 36ef61a..80953fc 100644 --- a/src/components/dashboard-quickstart.tsx +++ b/src/components/dashboard-quickstart.tsx @@ -1,10 +1,4 @@ -import { - ArrowUpRight, - Check, - Copy, - Play, - TerminalWindow, -} from "@phosphor-icons/react" +import { Check, Copy } from "@phosphor-icons/react" import { Button } from "@/components/ui/button" import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard" @@ -32,20 +26,15 @@ function CopyButton({ label, value }: { label: string; value: string }) { function Step({ children, - icon, number, title, }: { children: React.ReactNode - icon: React.ReactNode number: string title: string }) { return ( -
  • -
    - {icon} -
    +
  • Step {number} @@ -61,67 +50,40 @@ export function DashboardQuickstart() { return (

    -
    -
    -

    - Start with the CLI -

    -

    - Connect your account and parse a document in two commands. -

    -
    - +
    +

    + Start with the CLI +

    +

    + Connect your account and parse a document in two commands. +

    -
      - } - number="01" - title="Connect your account" - > +
        +

        Approve the browser prompt. The CLI creates and stores its own key.

        -
        - +
        + {loginCommand}
        - } - number="02" - title="Parse a document" - > -
        - + +
        + {parseCommand}
      - -

      - Building an integration?{" "} - - Use the TypeScript SDK - - . -

    ) } diff --git a/src/components/file-router-logo.tsx b/src/components/file-router-logo.tsx index adf1cf8..7c4e42b 100644 --- a/src/components/file-router-logo.tsx +++ b/src/components/file-router-logo.tsx @@ -9,7 +9,7 @@ export function FileRouterLogo({ className }: { className?: string }) { > ) diff --git a/src/components/not-found-page.tsx b/src/components/not-found-page.tsx new file mode 100644 index 0000000..717938e --- /dev/null +++ b/src/components/not-found-page.tsx @@ -0,0 +1,26 @@ +import { Link } from "@tanstack/react-router" + +import { FileRouterBrand } from "@/components/file-router-brand" +import { Button } from "@/components/ui/button" + +export function NotFoundPage() { + return ( +
    + +
    +

    + 404 +

    +

    + Page not found +

    +

    + The page may have moved, or the address may be incorrect. +

    + +
    +
    + ) +} diff --git a/src/components/posthog-bootstrap.tsx b/src/components/posthog-bootstrap.tsx new file mode 100644 index 0000000..a9686c9 --- /dev/null +++ b/src/components/posthog-bootstrap.tsx @@ -0,0 +1,18 @@ +import { useEffect } from "react" + +import type { PublicPostHogConfig } from "@/integrations/posthog/config" +import { initializePostHog } from "@/integrations/posthog/browser" + +export function PostHogBootstrap({ + config, +}: { + config: PublicPostHogConfig | undefined +}) { + useEffect(() => { + if (config) { + initializePostHog(config) + } + }, [config]) + + return null +} diff --git a/src/components/pricing-section.tsx b/src/components/pricing-section.tsx index e455bc0..b5c8fae 100644 --- a/src/components/pricing-section.tsx +++ b/src/components/pricing-section.tsx @@ -8,11 +8,11 @@ import { cn } from "@/lib/utils" const pricingPlans = [ { cta: "Read the docs", - description: "Run providers from your environment with your own keys.", + description: "Call providers from your own runtime with your own keys.", emphasized: false, features: [ - "No FileRouter usage fee", - "Provider-native billing", + "No FileRouter credits", + "Pay providers directly", "Typed SDK and CLI", "Parse and compare locally", ], @@ -22,17 +22,17 @@ const pricingPlans = [ }, { cta: "Start for free", - description: "Let FileRouter handle the durable execution layer.", + description: "Send documents through FileRouter for hosted processing.", emphasized: true, features: [ - "One FileRouter API key", + "FileRouter API key", + "5,000 free credits each month", "Durable jobs and retries", - "Managed uploads and cleanup", - "Normalized provider results", + "Uploads and cleanup included", ], id: "hosted", name: "Hosted", - price: "Coming soon", + price: "Pay as you go", }, { cta: "Talk to us", @@ -62,8 +62,8 @@ export function PricingSection() { Simple, flexible pricing

    - Call providers directly with your own keys, or let FileRouter handle - uploads, polling, retries, and cleanup. + Use your provider keys for free, or use FileRouter credits for hosted + processing.

    diff --git a/src/components/root-error.tsx b/src/components/root-error.tsx new file mode 100644 index 0000000..1b8d6d0 --- /dev/null +++ b/src/components/root-error.tsx @@ -0,0 +1,40 @@ +import { Link } from "@tanstack/react-router" +import type { ErrorComponentProps } from "@tanstack/react-router" +import { useEffect } from "react" + +import { FileRouterBrand } from "@/components/file-router-brand" +import { Button } from "@/components/ui/button" +import { captureBrowserException } from "@/integrations/posthog/browser" + +export function RootError({ error, reset }: ErrorComponentProps) { + useEffect(() => { + captureBrowserException(error, { + path: + typeof window === "undefined" ? undefined : window.location.pathname, + }) + }, [error]) + + return ( +
    + +
    +

    + Something went wrong +

    +

    + FileRouter hit an unexpected error +

    +

    + Try the request again. If it keeps failing, contact the team with the + time it happened. +

    +
    + + +
    +
    +
    + ) +} diff --git a/src/integrations/autumn/billing.functions.ts b/src/integrations/autumn/billing.functions.ts new file mode 100644 index 0000000..0d87f7e --- /dev/null +++ b/src/integrations/autumn/billing.functions.ts @@ -0,0 +1,45 @@ +import { env as workerEnv } from "cloudflare:workers" +import { createServerFn } from "@tanstack/react-start" +import { getRequestHeaders } from "@tanstack/react-start/server" + +import { + createHostedCreditCheckout, + getHostedBillingSummary, +} from "@/integrations/autumn/billing.server" +import { getSessionFromHeaders } from "@/lib/auth-queries.server" + +export const getBillingSummary = createServerFn({ method: "GET" }).handler( + async () => { + const account = await requireAccount() + return getHostedBillingSummary(workerEnv, account) + } +) + +export const startCreditCheckout = createServerFn({ method: "POST" }).handler( + async () => { + const account = await requireAccount() + const baseUrl = workerEnv.BETTER_AUTH_URL?.trim() + if (!baseUrl) { + throw new Error("BETTER_AUTH_URL is not configured.") + } + return { + paymentUrl: await createHostedCreditCheckout( + workerEnv, + account, + new URL("/dashboard?credits=added", baseUrl).toString() + ), + } + } +) + +async function requireAccount() { + const session = await getSessionFromHeaders(getRequestHeaders()) + if (!session) { + throw new Error("Authentication required.") + } + return { + email: session.user.email, + id: session.user.id, + name: session.user.name, + } +} diff --git a/src/integrations/autumn/billing.server.test.ts b/src/integrations/autumn/billing.server.test.ts new file mode 100644 index 0000000..2a597ff --- /dev/null +++ b/src/integrations/autumn/billing.server.test.ts @@ -0,0 +1,113 @@ +import type { Balance, Customer } from "autumn-js" +import { describe, expect, test, vi } from "vite-plus/test" + +import { + createHostedCreditCheckout, + getHostedBillingSummary, + requireHostedCredit, +} from "@/integrations/autumn/billing.server" +import type { + AutumnAccount, + AutumnBillingClient, +} from "@/integrations/autumn/billing.server" +import { + HOSTED_CREDIT_FEATURE_ID, + HOSTED_CREDIT_TOP_UP_PLAN_ID, + HOSTED_TOP_UP_CREDITS, +} from "@/integrations/autumn/config" + +const account: AutumnAccount = { + email: "dev@example.com", + id: "user-123", + name: "Developer", +} +const env = { + AUTUMN_SECRET_KEY: "test", + HOSTED_BILLING_ENABLED: "true", +} + +describe("hosted billing", () => { + test("summarizes the account-level hosted credit balance", async () => { + const client = autumnClient({ + [HOSTED_CREDIT_FEATURE_ID]: balance({ + remaining: 12_345.5, + }), + }) + + await expect( + getHostedBillingSummary(env, account, client) + ).resolves.toMatchObject({ + enabled: true, + includedCredits: 5_000, + remainingCredits: 12_345.5, + topUpCredits: 10_000, + topUpPriceUsd: 10, + }) + }) + + test("rejects hosted work when the account has no credits", async () => { + const client = autumnClient({ + [HOSTED_CREDIT_FEATURE_ID]: balance({ remaining: 0 }), + }) + vi.mocked(client.check).mockResolvedValue({ allowed: false }) + + await expect( + requireHostedCredit(env, account, client) + ).rejects.toMatchObject({ code: "insufficient_credits", status: 402 }) + }) + + test("creates the fixed Stripe top-up checkout", async () => { + const client = autumnClient({}) + vi.mocked(client.billing.attach).mockResolvedValue({ + paymentUrl: "https://checkout.stripe.com/test", + }) + + await expect( + createHostedCreditCheckout( + env, + account, + "https://filerouter.dev/dashboard?credits=added", + client + ) + ).resolves.toBe("https://checkout.stripe.com/test") + expect(client.billing.attach).toHaveBeenCalledWith( + expect.objectContaining({ + customerId: account.id, + featureQuantities: [ + { + featureId: HOSTED_CREDIT_FEATURE_ID, + quantity: HOSTED_TOP_UP_CREDITS, + }, + ], + planId: HOSTED_CREDIT_TOP_UP_PLAN_ID, + redirectMode: "always", + }) + ) + }) +}) + +function autumnClient(balances: Record): AutumnBillingClient { + return { + billing: { attach: vi.fn() }, + check: vi.fn().mockResolvedValue({ allowed: true }), + customers: { + getOrCreate: vi.fn().mockResolvedValue({ balances } as Customer), + }, + } +} + +function balance(overrides: Partial): Balance { + return { + breakdown: [], + featureId: HOSTED_CREDIT_FEATURE_ID, + granted: 5_000, + maxPurchase: null, + nextResetAt: null, + overageAllowed: false, + remaining: 5_000, + rollovers: [], + unlimited: false, + usage: 0, + ...overrides, + } +} diff --git a/src/integrations/autumn/billing.server.ts b/src/integrations/autumn/billing.server.ts new file mode 100644 index 0000000..63bdda1 --- /dev/null +++ b/src/integrations/autumn/billing.server.ts @@ -0,0 +1,197 @@ +import type { Balance, Customer } from "autumn-js" +import { eq } from "drizzle-orm" + +import { user } from "@/db/schema" +import { createDb } from "@/db/server" +import { + HOSTED_CREDIT_FEATURE_ID, + HOSTED_CREDIT_TOP_UP_PLAN_ID, + HOSTED_FREE_PLAN_ID, + HOSTED_MONTHLY_CREDITS, + HOSTED_TOP_UP_CREDITS, + HOSTED_TOP_UP_PRICE_USD, +} from "@/integrations/autumn/config" +import { createAutumnClient } from "@/integrations/autumn/client.server" +import { HttpError } from "@/lib/http.server" + +export interface AutumnAccount { + email: string + id: string + name: string +} + +interface AutumnBillingEnv { + AUTUMN_SECRET_KEY?: string + HOSTED_BILLING_ENABLED?: string +} + +export interface AutumnBillingClient { + billing: { + attach: (request: { + customerId: string + enablePlanImmediately: boolean + featureQuantities: Array<{ featureId: string; quantity: number }> + metadata: Record + planId: string + redirectMode: "always" + successUrl: string + }) => Promise<{ paymentUrl: string | null }> + } + check: (request: { + customerId: string + featureId: string + requiredBalance: number + }) => Promise<{ allowed: boolean }> + customers: { + getOrCreate: (request: { + autoEnablePlanId: string + customerId: string + email: string + metadata: Record + name: string + }) => Promise + } +} + +export interface HostedBillingSummary { + enabled: boolean + includedCredits: number + remainingCredits: number | null + topUpCredits: number + topUpPriceUsd: number +} + +export async function getHostedBillingSummary( + env: AutumnBillingEnv, + account: AutumnAccount, + client: AutumnBillingClient | undefined = createAutumnClient(env) +): Promise { + if (!client) { + return emptySummary() + } + + const customer = await ensureAutumnCustomer(client, account) + const balance = customer.balances[HOSTED_CREDIT_FEATURE_ID] + return { + enabled: true, + includedCredits: HOSTED_MONTHLY_CREDITS, + remainingCredits: normalizeCredits(balance), + topUpCredits: HOSTED_TOP_UP_CREDITS, + topUpPriceUsd: HOSTED_TOP_UP_PRICE_USD, + } +} + +export async function requireHostedCredit( + env: AutumnBillingEnv, + account: AutumnAccount, + client: AutumnBillingClient | undefined = createAutumnClient(env) +): Promise { + if (!client) { + return + } + + await ensureAutumnCustomer(client, account) + const access = await client.check({ + customerId: account.id, + featureId: HOSTED_CREDIT_FEATURE_ID, + requiredBalance: 1, + }) + if (!access.allowed) { + throw new HttpError( + 402, + "You're out of credits. Add credits in the dashboard to continue.", + { code: "insufficient_credits" } + ) + } +} + +export async function requireHostedCreditForUser( + env: AutumnBillingEnv & { DB: D1Database }, + userId: string, + client: AutumnBillingClient | undefined = createAutumnClient(env) +): Promise { + if (!client) { + return + } + const account = await findAutumnAccount(env.DB, userId) + await requireHostedCredit(env, account, client) +} + +export async function createHostedCreditCheckout( + env: AutumnBillingEnv, + account: AutumnAccount, + successUrl: string, + client: AutumnBillingClient | undefined = createAutumnClient(env) +): Promise { + if (!client) { + throw new Error("Hosted billing is not enabled.") + } + + await ensureAutumnCustomer(client, account) + const checkout = await client.billing.attach({ + customerId: account.id, + enablePlanImmediately: true, + featureQuantities: [ + { + featureId: HOSTED_CREDIT_FEATURE_ID, + quantity: HOSTED_TOP_UP_CREDITS, + }, + ], + metadata: { product: "filerouter", purchase: "hosted_credits" }, + planId: HOSTED_CREDIT_TOP_UP_PLAN_ID, + redirectMode: "always", + successUrl, + }) + if (!checkout.paymentUrl) { + throw new Error("Autumn did not return a checkout URL.") + } + return checkout.paymentUrl +} + +export function ensureAutumnCustomer( + client: Pick, + account: AutumnAccount +): Promise { + return client.customers.getOrCreate({ + autoEnablePlanId: HOSTED_FREE_PLAN_ID, + customerId: account.id, + email: account.email, + metadata: { account_type: "developer", product: "filerouter" }, + name: account.name, + }) +} + +export async function findAutumnAccount( + database: D1Database, + userId: string +): Promise { + const account = await createDb(database) + .select({ email: user.email, id: user.id, name: user.name }) + .from(user) + .where(eq(user.id, userId)) + .get() + if (!account) { + throw new Error(`Cannot find Autumn account for user ${userId}.`) + } + return account +} + +function emptySummary(): HostedBillingSummary { + return { + enabled: false, + includedCredits: HOSTED_MONTHLY_CREDITS, + remainingCredits: 0, + topUpCredits: HOSTED_TOP_UP_CREDITS, + topUpPriceUsd: HOSTED_TOP_UP_PRICE_USD, + } +} + +function normalizeCredits(balance: Balance | undefined): number | null { + if (balance?.unlimited) { + return null + } + if (!balance) { + return 0 + } + return Math.max(0, balance.remaining) +} diff --git a/src/integrations/autumn/client.server.ts b/src/integrations/autumn/client.server.ts new file mode 100644 index 0000000..7e15614 --- /dev/null +++ b/src/integrations/autumn/client.server.ts @@ -0,0 +1,21 @@ +import { Autumn } from "autumn-js" + +interface AutumnRuntimeEnv { + AUTUMN_SECRET_KEY?: string + HOSTED_BILLING_ENABLED?: string +} + +export function createAutumnClient(env: AutumnRuntimeEnv): Autumn | undefined { + if (env.HOSTED_BILLING_ENABLED !== "true") { + return undefined + } + + const secretKey = env.AUTUMN_SECRET_KEY?.trim() + if (!secretKey) { + throw new Error( + "HOSTED_BILLING_ENABLED is true but AUTUMN_SECRET_KEY is not configured." + ) + } + + return new Autumn({ failOpen: false, secretKey, timeoutMs: 10_000 }) +} diff --git a/src/integrations/autumn/config.ts b/src/integrations/autumn/config.ts new file mode 100644 index 0000000..4d9f836 --- /dev/null +++ b/src/integrations/autumn/config.ts @@ -0,0 +1,10 @@ +export const HOSTED_CREDIT_FEATURE_ID = "managed_execution_units" +export const HOSTED_FREE_PLAN_ID = "free" +export const HOSTED_CREDIT_TOP_UP_PLAN_ID = "credits_10k" + +export const HOSTED_MONTHLY_CREDITS = 5_000 +export const HOSTED_TOP_UP_CREDITS = 10_000 +export const HOSTED_TOP_UP_PRICE_USD = 10 + +export const HOSTED_RATE_CARD_VERSION = "2026-07-21.v1" +export const HOSTED_BETA_MARGIN_MULTIPLIER = 1.1 diff --git a/src/integrations/autumn/managed-execution-cost.ts b/src/integrations/autumn/managed-execution-cost.ts new file mode 100644 index 0000000..262e2e7 --- /dev/null +++ b/src/integrations/autumn/managed-execution-cost.ts @@ -0,0 +1,94 @@ +import { HOSTED_BETA_MARGIN_MULTIPLIER } from "@/integrations/autumn/config" +import type { ProviderOutcome } from "@/workflows/document-results" + +const MILLIUSD_PER_USD = 1_000 + +// Provider list prices verified 2026-07-21: +// https://www.llamaindex.ai/pricing +// https://mistral.ai/pricing/api/ +// https://www.datalab.to/pricing +// Prefer provider-reported cost so discounts and option-specific pricing flow +// through unchanged. +const LLAMAPARSE_USD_PER_CREDIT = 0.00125 + +// Covers the Worker, Workflow, D1, and R2 work around one provider execution. +const PLATFORM_USD_PER_EXECUTION = 0.00025 + +// Native parser estimates use https://developers.cloudflare.com/containers/pricing/. +// Shared idle time belongs to the container lifecycle, not an individual job. +const CONTAINER_CPU_USD_PER_VCPU_SECOND = 0.00002 +const CONTAINER_MEMORY_USD_PER_GIB_SECOND = 0.0000025 +const CONTAINER_DISK_USD_PER_GB_SECOND = 0.00000007 + +export type ParsedProviderOutcome = Extract< + ProviderOutcome, + { status: "parsed" } +> + +export interface ManagedExecutionEstimate { + credits: number + rawCostUsd: number +} + +export function estimateManagedExecution( + provider: ParsedProviderOutcome +): ManagedExecutionEstimate | undefined { + const providerCost = providerCostUsd(provider) + if (providerCost === undefined) { + return undefined + } + const rawCostUsd = providerCost + PLATFORM_USD_PER_EXECUTION + return { + credits: rawCostUsd * MILLIUSD_PER_USD * HOSTED_BETA_MARGIN_MULTIPLIER, + rawCostUsd, + } +} + +function providerCostUsd(provider: ParsedProviderOutcome): number | undefined { + const reportedCost = provider.usage?.costUsd + if (isNonNegativeFinite(reportedCost)) { + return reportedCost + } + + switch (provider.provider) { + case "llamaparse": { + const credits = provider.usage?.credits + return isNonNegativeFinite(credits) + ? credits * LLAMAPARSE_USD_PER_CREDIT + : undefined + } + case "mistral-ocr": + case "datalab": + return undefined + case "liteparse": + return containerCostUsd(provider.durationMs, { + diskGb: 8, + memoryGib: 4, + vcpu: 0.5, + }) + case "pdf-inspector": + return containerCostUsd(provider.durationMs, { + diskGb: 4, + memoryGib: 1, + vcpu: 0.25, + }) + default: + throw new Error(`Cannot price unknown provider: ${provider.provider}`) + } +} + +function containerCostUsd( + durationMs: number, + resources: { diskGb: number; memoryGib: number; vcpu: number } +): number { + const activeSeconds = Math.max(0, durationMs) / 1_000 + const memoryAndDiskPerSecond = + resources.memoryGib * CONTAINER_MEMORY_USD_PER_GIB_SECOND + + resources.diskGb * CONTAINER_DISK_USD_PER_GB_SECOND + const cpuPerSecond = resources.vcpu * CONTAINER_CPU_USD_PER_VCPU_SECOND + return activeSeconds * (memoryAndDiskPerSecond + cpuPerSecond) +} + +function isNonNegativeFinite(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value) && value >= 0 +} diff --git a/src/integrations/autumn/managed-execution.test.ts b/src/integrations/autumn/managed-execution.test.ts new file mode 100644 index 0000000..bf4d46c --- /dev/null +++ b/src/integrations/autumn/managed-execution.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, test, vi } from "vite-plus/test" + +import { estimateManagedExecution } from "@/integrations/autumn/managed-execution-cost" +import { trackManagedExecution } from "@/integrations/autumn/managed-execution" +import { HOSTED_CREDIT_FEATURE_ID } from "@/integrations/autumn/config" +import type { ProviderOutcome } from "@/workflows/document-results" + +function parsed( + input: Partial> & + Pick, "provider"> +): Extract { + return { + durationMs: 1_000, + pageCount: 1, + resultKey: "jobs/test/result.json", + status: "parsed", + ...input, + } +} + +describe("managed execution pricing", () => { + test("uses provider-reported dollar cost when available", () => { + const estimate = estimateManagedExecution( + parsed({ + pageCount: 10, + provider: "datalab", + usage: { costUsd: 0.04, pages: 10 }, + }) + ) + + expect(estimate?.credits).toBeCloseTo(44.275) + expect(estimate?.rawCostUsd).toBeCloseTo(0.04025) + }) + + test("converts LlamaParse credits into upstream cost", () => { + const estimate = estimateManagedExecution( + parsed({ + pageCount: 10, + provider: "llamaparse", + usage: { credits: 30, pages: 10 }, + }) + ) + + expect(estimate?.credits).toBeCloseTo(41.525) + expect(estimate?.rawCostUsd).toBeCloseTo(0.03775) + }) + + test("prices Mistral OCR from processed pages", () => { + const estimate = estimateManagedExecution( + parsed({ + pageCount: 10, + provider: "mistral-ocr", + usage: { costUsd: 0.04, pages: 10 }, + }) + ) + + expect(estimate?.credits).toBeCloseTo(44.275) + expect(estimate?.rawCostUsd).toBeCloseTo(0.04025) + }) + + test("does not guess mode-dependent provider prices", () => { + expect( + estimateManagedExecution( + parsed({ pageCount: 10, provider: "llamaparse", usage: { pages: 10 } }) + ) + ).toBeUndefined() + expect( + estimateManagedExecution( + parsed({ pageCount: 10, provider: "datalab", usage: { pages: 10 } }) + ) + ).toBeUndefined() + expect( + estimateManagedExecution( + parsed({ pageCount: 10, provider: "mistral-ocr", usage: { pages: 10 } }) + ) + ).toBeUndefined() + }) + + test("estimates native parser compute from its provisioned resources", () => { + const estimate = estimateManagedExecution( + parsed({ durationMs: 10_000, provider: "liteparse" }) + ) + + expect(estimate).toBeDefined() + if (!estimate) { + throw new Error("Expected a native parser estimate.") + } + expect(estimate.rawCostUsd).toBeCloseTo(0.0004556) + expect(estimate.credits).toBeCloseTo(0.50116) + }) + + test("records idempotent provider events", async () => { + const track = vi.fn().mockResolvedValue({}) + + await expect( + trackManagedExecution( + { track }, + { + jobId: "job-123", + operation: "compare", + providers: [ + parsed({ + pageCount: 2, + provider: "mistral-ocr", + usage: { costUsd: 0.008, pages: 2 }, + }), + { + durationMs: 100, + error: { message: "failed" }, + provider: "datalab", + status: "failed", + }, + ], + userId: "user-123", + } + ) + ).resolves.toEqual({ trackedProviders: 1, unpricedProviders: [] }) + + expect(track).toHaveBeenCalledWith( + expect.objectContaining({ + customerId: "user-123", + featureId: HOSTED_CREDIT_FEATURE_ID, + overageBehavior: "overflow", + }), + { + headers: { + "Idempotency-Key": "document-job:job-123:mistral-ocr", + }, + } + ) + expect(track.mock.calls[0]?.[0].value).toBeCloseTo(9.075) + }) +}) diff --git a/src/integrations/autumn/managed-execution.ts b/src/integrations/autumn/managed-execution.ts new file mode 100644 index 0000000..08ad724 --- /dev/null +++ b/src/integrations/autumn/managed-execution.ts @@ -0,0 +1,112 @@ +import { createAutumnClient } from "@/integrations/autumn/client.server" +import { + HOSTED_CREDIT_FEATURE_ID, + HOSTED_RATE_CARD_VERSION, +} from "@/integrations/autumn/config" +import { estimateManagedExecution } from "@/integrations/autumn/managed-execution-cost" +import type { + ManagedExecutionEstimate, + ParsedProviderOutcome, +} from "@/integrations/autumn/managed-execution-cost" +import type { ProviderOutcome } from "@/workflows/document-results" + +export interface AutumnUsageClient { + track: ( + request: { + customerId: string + featureId: string + properties: Record + value: number + overageBehavior: "overflow" + }, + options?: RequestInit + ) => Promise +} + +interface AutumnUsageEnv { + AUTUMN_SECRET_KEY?: string + HOSTED_BILLING_ENABLED?: string +} + +export interface TrackManagedExecutionInput { + jobId: string + operation: "compare" | "parse" + providers: Array + userId: string +} + +export async function trackManagedExecutionUsage( + env: AutumnUsageEnv, + input: TrackManagedExecutionInput, + client = createAutumnClient(env) +): Promise< + | { skipped: true } + | { trackedProviders: number; unpricedProviders: Array } +> { + if (!client) { + return { skipped: true } + } + + return trackManagedExecution(client, input) +} + +export async function trackManagedExecution( + client: AutumnUsageClient, + input: TrackManagedExecutionInput +): Promise<{ trackedProviders: number; unpricedProviders: Array }> { + const parsedProviders = input.providers.filter( + (provider): provider is ParsedProviderOutcome => + provider.status === "parsed" + ) + const unpricedProviders: Array = [] + const pricedProviders = parsedProviders.flatMap((provider) => { + const estimate = estimateManagedExecution(provider) + if (!estimate) { + unpricedProviders.push(provider.provider) + return [] + } + return [{ estimate, provider }] + }) + await Promise.all( + pricedProviders.map(({ estimate, provider }) => { + return client.track( + { + customerId: input.userId, + featureId: HOSTED_CREDIT_FEATURE_ID, + overageBehavior: "overflow", + properties: usageProperties(input, provider, estimate), + value: estimate.credits, + }, + { + headers: { + "Idempotency-Key": `document-job:${input.jobId}:${provider.provider}`, + }, + } + ) + }) + ) + + return { trackedProviders: pricedProviders.length, unpricedProviders } +} + +function usageProperties( + input: TrackManagedExecutionInput, + provider: ParsedProviderOutcome, + estimate: ManagedExecutionEstimate +): Record { + return { + duration_ms: provider.durationMs, + job_id: input.jobId, + operation: input.operation, + pages: provider.usage?.pages ?? provider.pageCount, + provider: provider.provider, + rate_card_version: HOSTED_RATE_CARD_VERSION, + raw_cost_usd: estimate.rawCostUsd, + ...(provider.usage?.costUsd !== undefined && { + provider_cost_usd: provider.usage.costUsd, + }), + ...(provider.usage?.credits !== undefined && { + provider_credits: provider.usage.credits, + }), + } +} diff --git a/src/integrations/posthog/browser.ts b/src/integrations/posthog/browser.ts new file mode 100644 index 0000000..cc0e6c8 --- /dev/null +++ b/src/integrations/posthog/browser.ts @@ -0,0 +1,105 @@ +import type { BeforeSendFn, Properties } from "posthog-js" + +import type { PublicPostHogConfig } from "@/integrations/posthog/config" + +type PostHogClient = (typeof import("posthog-js/dist/module.slim"))["default"] + +let clientPromise: Promise | undefined + +export function initializePostHog(config: PublicPostHogConfig): void { + if (clientPromise || typeof window === "undefined") { + return + } + + clientPromise = import("posthog-js/dist/module.slim") + .then(({ default: posthog }) => { + posthog.init(config.token, { + api_host: config.host, + autocapture: false, + before_send: sanitizeEventUrls, + capture_exceptions: true, + capture_pageleave: false, + capture_pageview: "history_change", + defaults: "2026-05-30", + person_profiles: "identified_only", + session_recording: { + maskAllInputs: true, + maskCapturedNetworkRequestFn: sanitizeRecordedRequest, + }, + tracing_headers: [window.location.hostname], + }) + return posthog + }) + .catch(() => undefined) +} + +export function captureBrowserEvent( + event: string, + properties?: Properties +): void { + withClient((posthog) => posthog.capture(event, properties)) +} + +export function captureBrowserException( + error: unknown, + properties?: Properties +): void { + withClient((posthog) => posthog.captureException(error, properties)) +} + +export function identifyBrowserUser(userId: string): void { + withClient((posthog) => posthog.identify(userId)) +} + +export function resetBrowserUser(): void { + withClient((posthog) => posthog.reset()) +} + +function withClient(action: (posthog: PostHogClient) => void): void { + void clientPromise?.then((posthog) => { + if (posthog) { + action(posthog) + } + }) +} + +function sanitizeEventUrls(event: Parameters[0]) { + if (!event?.properties) { + return event + } + return { + ...event, + properties: { + ...event.properties, + ...sanitizeUrlProperty(event.properties, "$current_url"), + ...sanitizeUrlProperty(event.properties, "$referrer"), + }, + } +} + +function sanitizeUrlProperty( + properties: Properties, + key: "$current_url" | "$referrer" +): Properties { + const value = properties[key] + if (typeof value !== "string" || !value) { + return {} + } + return { [key]: stripUrlDetails(value) } +} + +function sanitizeRecordedRequest(request: T): T { + if (request.name) { + request.name = stripUrlDetails(request.name) + } + return request +} + +function stripUrlDetails(value: string): string { + try { + const url = new URL(value) + return `${url.origin}${url.pathname}` + } catch { + return value.split("?")[0]?.split("#")[0] ?? "" + } +} diff --git a/src/integrations/posthog/config.functions.ts b/src/integrations/posthog/config.functions.ts new file mode 100644 index 0000000..51f5e7d --- /dev/null +++ b/src/integrations/posthog/config.functions.ts @@ -0,0 +1,8 @@ +import { env as workerEnv } from "cloudflare:workers" +import { createServerFn } from "@tanstack/react-start" + +import { getPostHogConfig } from "@/integrations/posthog/config" + +export const getPublicPostHogConfig = createServerFn({ method: "GET" }).handler( + () => getPostHogConfig(workerEnv) +) diff --git a/src/integrations/posthog/config.ts b/src/integrations/posthog/config.ts new file mode 100644 index 0000000..ddd9966 --- /dev/null +++ b/src/integrations/posthog/config.ts @@ -0,0 +1,24 @@ +export const DEFAULT_POSTHOG_HOST = "https://us.i.posthog.com" + +export interface PostHogEnv { + POSTHOG_HOST?: string + POSTHOG_PROJECT_TOKEN?: string +} + +export interface PublicPostHogConfig { + host: string + token: string +} + +export function getPostHogConfig( + env: PostHogEnv +): PublicPostHogConfig | undefined { + const token = env.POSTHOG_PROJECT_TOKEN?.trim() + if (!token) { + return undefined + } + return { + host: env.POSTHOG_HOST?.trim() || DEFAULT_POSTHOG_HOST, + token, + } +} diff --git a/src/integrations/posthog/server.ts b/src/integrations/posthog/server.ts new file mode 100644 index 0000000..578a123 --- /dev/null +++ b/src/integrations/posthog/server.ts @@ -0,0 +1,91 @@ +import { PostHog } from "posthog-node/edge" + +import { + getPostHogConfig, + type PostHogEnv, +} from "@/integrations/posthog/config" +import { + emitWideEvent, + serializeError, + type ObservabilityEnv, +} from "@/observability/log" + +type ServerTelemetryEnv = PostHogEnv & ObservabilityEnv + +const DISTINCT_ID_HEADER = "x-posthog-distinct-id" +const SESSION_ID_HEADER = "x-posthog-session-id" +const WINDOW_ID_HEADER = "x-posthog-window-id" + +type ServerTelemetry = { + distinctId: string + properties?: Record +} & ( + | { event: string; exception?: unknown } + | { event?: string; exception: unknown } +) + +export function captureServerTelemetry( + env: ServerTelemetryEnv, + telemetry: ServerTelemetry +): Promise { + const config = getPostHogConfig(env) + if (!config) { + return Promise.resolve() + } + + return deliver(env, config, telemetry) +} + +export function postHogDistinctId(request: Request, fallback: string): string { + return request.headers.get(DISTINCT_ID_HEADER) ?? fallback +} + +export function postHogTraceProperties( + request: Request +): Record { + return Object.fromEntries( + [ + ["$session_id", request.headers.get(SESSION_ID_HEADER)], + ["$window_id", request.headers.get(WINDOW_ID_HEADER)], + ].filter((entry): entry is [string, string] => Boolean(entry[1])) + ) +} + +async function deliver( + env: ServerTelemetryEnv, + config: NonNullable>, + telemetry: ServerTelemetry +): Promise { + const client = new PostHog(config.token, { + flushAt: 1, + flushInterval: 0, + host: config.host, + }) + try { + if (telemetry.event) { + await client.captureImmediate({ + distinctId: telemetry.distinctId, + event: telemetry.event, + properties: telemetry.properties, + }) + } + if (telemetry.exception !== undefined) { + client.captureException( + telemetry.exception, + telemetry.distinctId, + telemetry.properties + ) + } + await client.shutdown() + } catch (error) { + emitWideEvent(env, "error", { + attempted_event: telemetry.event ?? "$exception", + event: "telemetry_delivery_failed", + job_id: telemetry.properties?.job_id, + provider: "posthog", + request_id: telemetry.properties?.request_id, + service: "filerouter", + ...serializeError(error), + }) + } +} diff --git a/src/lib/api-auth.server.ts b/src/lib/api-auth.server.ts index d447295..d42fa9b 100644 --- a/src/lib/api-auth.server.ts +++ b/src/lib/api-auth.server.ts @@ -1,5 +1,6 @@ import { withAuth } from "@/lib/auth.server" import { HttpError } from "@/lib/http.server" +import { isRecord } from "@/lib/record" export interface ApiPrincipal { credentialId: string @@ -83,7 +84,3 @@ function getApiKeyLimitError(error: unknown): HttpError | undefined { }), }) } - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null -} diff --git a/src/lib/auth.functions.ts b/src/lib/auth.functions.ts index 51b7ae6..0eada3f 100644 --- a/src/lib/auth.functions.ts +++ b/src/lib/auth.functions.ts @@ -1,12 +1,10 @@ import { createServerFn } from "@tanstack/react-start" import { getRequestHeaders } from "@tanstack/react-start/server" -export const getSession = createServerFn({ method: "GET" }).handler( - async () => { - const { getSessionFromHeaders } = await import("@/lib/auth-queries.server") +import { getSessionFromHeaders } from "@/lib/auth-queries.server" - return getSessionFromHeaders(getRequestHeaders()) - } +export const getSession = createServerFn({ method: "GET" }).handler(() => + getSessionFromHeaders(getRequestHeaders()) ) export type AuthSession = Awaited> diff --git a/src/lib/auth.server.ts b/src/lib/auth.server.ts index 8f17ba4..bece239 100644 --- a/src/lib/auth.server.ts +++ b/src/lib/auth.server.ts @@ -104,7 +104,7 @@ function createAuth(database: Db, env: AuthRuntimeEnv) { maxRequests: 300, timeWindow: 60 * 1000, }, - requireName: true, + requireName: false, }), bearer(), deviceAuthorization({ diff --git a/src/lib/document-job-input.server.test.ts b/src/lib/document-job-input.server.test.ts index 98b09cc..248db88 100644 --- a/src/lib/document-job-input.server.test.ts +++ b/src/lib/document-job-input.server.test.ts @@ -40,6 +40,22 @@ describe("document job input", () => { }) }) + test("rejects private hosted source URLs before creating a job", async () => { + await expect( + readDocumentJobInput( + new Request("https://filerouter.test/api/v1/jobs", { + body: JSON.stringify({ + operation: "parse", + outputs: ["markdown"], + source: { url: "http://169.254.169.254/latest/meta-data" }, + }), + headers: { "Content-Type": "application/json" }, + method: "POST", + }) + ) + ).rejects.toMatchObject({ code: "invalid_source_url", status: 400 }) + }) + test("rejects invalid JSON as a client error", async () => { await expect( readDocumentJobInput( @@ -71,6 +87,66 @@ describe("document job input", () => { ).rejects.toMatchObject({ status: 400 }) }) + test("keeps hosted LiteParse on curated managed OCR options", async () => { + const request = (providerOptions: unknown) => + readDocumentJobInput( + new Request("https://filerouter.test/api/v1/jobs", { + body: JSON.stringify({ + operation: "parse", + outputs: ["markdown"], + providerOptions, + provider: "liteparse", + source: { url: "https://example.com/report.pdf" }, + }), + headers: { "Content-Type": "application/json" }, + method: "POST", + }) + ) + + await expect( + request({ liteparse: { raw: { ocrServerUrl: "http://localhost" } } }) + ).rejects.toMatchObject({ status: 400 }) + await expect( + request({ liteparse: { inventedOption: true } }) + ).rejects.toMatchObject({ status: 400 }) + await expect( + request({ liteparse: { raw: "not-an-object" } }) + ).rejects.toMatchObject({ status: 400 }) + await expect( + request({ liteparse: { raw: { dpi: 301 } } }) + ).rejects.toMatchObject({ status: 400 }) + await expect( + request({ + liteparse: { + raw: { cropBox: { bottom: 0, left: -0.1, right: 1, top: 1 } }, + }, + }) + ).rejects.toMatchObject({ status: 400 }) + await expect( + request({ + liteparse: { + ocr: "auto", + raw: { + cropBox: { bottom: 0, left: 0, right: 1, top: 1 }, + dpi: 300, + }, + screenshots: true, + }, + }) + ).resolves.toMatchObject({ + providerOptions: { + liteparse: { + ocr: "auto", + raw: { + cropBox: { bottom: 0, left: 0, right: 1, top: 1 }, + dpi: 300, + }, + screenshots: true, + }, + }, + }) + }) + test("passes provider-native options through hosted jobs", async () => { const input = await readDocumentJobInput( new Request("https://filerouter.test/api/v1/jobs", { @@ -92,6 +168,26 @@ describe("document job input", () => { }) }) + test("rejects Mistral models missing from the hosted rate card", async () => { + await expect( + readDocumentJobInput( + new Request("https://filerouter.test/api/v1/jobs", { + body: JSON.stringify({ + operation: "parse", + outputs: ["markdown"], + provider: "mistral-ocr", + providerOptions: { + "mistral-ocr": { model: "unpriced-preview-model" }, + }, + source: { url: "https://example.com/report.pdf" }, + }), + headers: { "Content-Type": "application/json" }, + method: "POST", + }) + ) + ).rejects.toMatchObject({ status: 400 }) + }) + test("infers upload MIME type from its filename", async () => { const input = await readDocumentJobInput( new Request("https://filerouter.test/api/v1/jobs", { diff --git a/src/lib/document-job-input.server.ts b/src/lib/document-job-input.server.ts index f4f1a6c..9e4b7b9 100644 --- a/src/lib/document-job-input.server.ts +++ b/src/lib/document-job-input.server.ts @@ -20,31 +20,11 @@ import { MAX_HOSTED_UPLOAD_BYTES, MAX_HOSTED_UPLOAD_LABEL, } from "@/lib/document-limits" +import { validateHostedProviderOptions } from "@/lib/hosted-providers.server" import { HttpError } from "@/lib/http.server" +import { readPublicHttpUrl } from "@/lib/public-url" const providerIds = ProviderIdSchema.options -const blockedHostedOptions: Record> = { - datalab: new Set([ - "checkpoint_id", - "eval_rubric_id", - "file", - "file_url", - "webhook_url", - "workflowstepdata_id", - ]), - llamaparse: new Set([ - "configuration_id", - "file_id", - "http_proxy", - "organization_id", - "project_id", - "source_url", - "upload_file", - "webhook_configuration_ids", - "webhook_configurations", - ]), - "mistral-ocr": new Set(["document"]), -} export type DocumentJobInput = { includeRaw: boolean @@ -130,14 +110,14 @@ function readJsonJobInput(value: unknown): DocumentJobInput { } const { includeRaw, operation, outputs, pages, providerOptions, source } = result.data - const url = new URL(source.url).toString() + const url = readHostedSourceUrl(source.url) return { includeRaw: includeRaw ?? false, operation, outputs: [...new Set(outputs)], ...(pages && { pages: [...new Set(pages)] }), ...(providerOptions && { - providerOptions: validateHostedProviderOptions(providerOptions), + providerOptions: validateProviderOptions(providerOptions), }), providers: operation === "parse" @@ -147,6 +127,16 @@ function readJsonJobInput(value: unknown): DocumentJobInput { } } +function readHostedSourceUrl(value: string): string { + try { + return readPublicHttpUrl(value).toString() + } catch { + throw new HttpError(400, "Document URL must be publicly reachable.", { + code: "invalid_source_url", + }) + } +} + function parseHeaderOptions( request: Request ): Pick { @@ -159,7 +149,6 @@ function parseHeaderOptions( if (pages?.some((page) => !Number.isSafeInteger(page) || page < 1)) { throw new HttpError(400, "Pages must be positive, one-based integers.") } - if (!optionsHeader) { return pages ? { pages: [...new Set(pages)] } : {} } @@ -182,7 +171,7 @@ function parseHeaderOptions( return { ...(pages && { pages: [...new Set(pages)] }), - providerOptions: validateHostedProviderOptions(value), + providerOptions: validateProviderOptions(value), } } @@ -247,46 +236,8 @@ function isProviderId(value: string): value is ProviderId { return ProviderIdSchema.safeParse(value).success } -function validateHostedProviderOptions(value: unknown): ProviderParseOptions { - if (!isRecord(value)) { - throw new HttpError(400, "Provider options must be an object.") - } - - for (const [providerId, options] of Object.entries(value)) { - if (!isProviderId(providerId)) { - throw new HttpError(400, `Unsupported provider options: ${providerId}`) - } - if (!isRecord(options)) { - throw new HttpError( - 400, - `Provider options for ${providerId} must be an object.` - ) - } - assertHostedOptionsAreSafe(providerId, options) - if (providerId === "datalab" && isRecord(options.raw)) { - assertHostedOptionsAreSafe(providerId, options.raw) - } - } - return value as ProviderParseOptions -} - -function assertHostedOptionsAreSafe( - providerId: ProviderId, - options: Record -): void { - const blocked = Object.keys(options).filter((key) => - blockedHostedOptions[providerId].has(key) - ) - if (blocked.length > 0) { - throw new HttpError( - 400, - `Hosted ${providerId} options cannot set: ${blocked.join(", ")}.` - ) - } -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value) +function validateProviderOptions(value: unknown): ProviderParseOptions { + return validateHostedProviderOptions(value, isProviderId) } async function readJson(request: Request): Promise { diff --git a/src/lib/document-jobs.server.ts b/src/lib/document-jobs.server.ts index d350f7d..7102bdf 100644 --- a/src/lib/document-jobs.server.ts +++ b/src/lib/document-jobs.server.ts @@ -1,15 +1,17 @@ -import { and, eq } from "drizzle-orm" +import { and, eq, inArray } from "drizzle-orm" import type { HostedJobStatus } from "@file_router/sdk/hosted" import { documentJob } from "@/db/schema" import { createDb } from "@/db/server" import { readDocumentJobInput } from "@/lib/document-job-input.server" +import { requireHostedCreditForUser } from "@/integrations/autumn/billing.server" import { MAX_HOSTED_UPLOAD_BYTES, MAX_HOSTED_UPLOAD_LABEL, } from "@/lib/document-limits" import { HttpError } from "@/lib/http.server" import { hashToken } from "@/lib/tokens.server" +import { emitWideEvent, serializeError } from "@/observability/log" import type { DocumentWorkflowParams } from "@/workflows/document-workflow" type CreateDocumentJobResult = { @@ -25,16 +27,17 @@ export async function createDocumentJob( userId: string, env: Cloudflare.Env, idempotencyKey: string, + requestId: string, validatedJson?: unknown ): Promise { const input = await readDocumentJobInput(request, validatedJson) const db = createDb(env.DB) const id = crypto.randomUUID() - const workflowSource: DocumentWorkflowParams["source"] = - input.source.kind === "url" - ? { kind: "url", url: input.source.url } - : { key: `jobs/${id}/source`, kind: "upload" } - const sourceKey = workflowSource.kind === "upload" ? workflowSource.key : null + const sourceKey = `jobs/${id}/source` + const workflowSource: DocumentWorkflowParams["source"] = { + key: sourceKey, + ...(input.source.kind === "url" && { url: input.source.url }), + } let sourceOwnedByJob = false try { const [idempotencyKeyHash, storedSource] = await Promise.all([ @@ -79,6 +82,8 @@ export async function createDocumentJob( return replay } + await requireHostedCreditForUser(env, userId) + try { await db.insert(documentJob).values({ createdAt: new Date(), @@ -116,18 +121,13 @@ export async function createDocumentJob( outputs: input.outputs, ...(input.pages && { pages: input.pages }), providers: input.providers, + requestId, source: workflowSource, + userId, ...(input.providerOptions && { providerOptions: input.providerOptions }), } try { - await env.DOCUMENT_WORKFLOW.create({ - id, - params, - retention: { - errorRetention: "7 days", - successRetention: "1 day", - }, - }) + await startDocumentWorkflow(env.DOCUMENT_WORKFLOW, id, params) } catch (error) { await db.delete(documentJob).where(eq(documentJob.id, id)) throw error @@ -136,23 +136,51 @@ export async function createDocumentJob( sourceOwnedByJob = true return { job: { id, status: "queued" }, replayed: false } } finally { - if (sourceKey && !sourceOwnedByJob) { + if (!sourceOwnedByJob) { await env.FILEROUTER_FILES.delete(sourceKey).catch((error: unknown) => { - console.error("Failed to remove unowned document source", { - error, - sourceKey, + emitWideEvent(env, "error", { + event: "document_source_cleanup_failed", + job_id: id, + request_id: requestId, + service: "filerouter-api", + ...serializeError(error), }) }) } } } +async function startDocumentWorkflow( + workflow: Cloudflare.Env["DOCUMENT_WORKFLOW"], + id: string, + params: DocumentWorkflowParams +): Promise { + try { + await workflow.createBatch([ + { + id, + params, + retention: { + errorRetention: "7 days", + successRetention: "1 day", + }, + }, + ]) + } catch (error) { + try { + await workflow.get(id) + } catch { + throw error + } + } +} + async function storeUploadedSource( input: Awaited>, - sourceKey: string | null, + sourceKey: string, bucket: R2Bucket ): Promise<{ checksum: string; size: number } | undefined> { - if (input.source.kind !== "upload" || !sourceKey) { + if (input.source.kind !== "upload") { return undefined } @@ -177,7 +205,8 @@ function bytesToHex(value: ArrayBuffer): string { export async function getDocumentJobResponse( id: string, userId: string, - env: Cloudflare.Env + env: Cloudflare.Env, + requestId: string ) { const job = await createDb(env.DB) .select() @@ -207,10 +236,12 @@ export async function getDocumentJobResponse( .set({ resultKey: null }) .where(eq(documentJob.id, job.id)) } catch (error) { - console.error("Failed to remove expired document result", { - error, - jobId: job.id, - resultKey: job.resultKey, + emitWideEvent(env, "error", { + event: "document_result_cleanup_failed", + job_id: job.id, + request_id: requestId, + service: "filerouter-api", + ...serializeError(error), }) } } @@ -229,6 +260,26 @@ export async function getDocumentJobResponse( return streamCompletedJob(id, result) } +export async function failDocumentJob( + database: D1Database, + options: { clearSource: boolean; error: string; jobId: string } +): Promise { + await createDb(database) + .update(documentJob) + .set({ + error: options.error, + ...(options.clearSource && { sourceKey: null }), + status: "failed", + updatedAt: new Date(), + }) + .where( + and( + eq(documentJob.id, options.jobId), + inArray(documentJob.status, ["queued", "running"]) + ) + ) +} + function streamCompletedJob(id: string, result: R2ObjectBody): Response { const encoder = new TextEncoder() const prefix = encoder.encode(`{"id":${JSON.stringify(id)},"result":`) diff --git a/src/lib/document-limits.ts b/src/lib/document-limits.ts index bf1a311..bd0a652 100644 --- a/src/lib/document-limits.ts +++ b/src/lib/document-limits.ts @@ -1,6 +1,3 @@ /** Cloudflare Free and Pro plans accept request bodies up to 100 MB. */ export const MAX_HOSTED_UPLOAD_BYTES = 100_000_000 export const MAX_HOSTED_UPLOAD_LABEL = "100 MB" - -/** Local provider testing cannot expose an R2-backed source URL externally. */ -export const MAX_BUFFERED_PROVIDER_BYTES = 25 * 1024 * 1024 diff --git a/src/lib/document-source.server.test.ts b/src/lib/document-source.server.test.ts deleted file mode 100644 index 5e8f79d..0000000 --- a/src/lib/document-source.server.test.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { describe, expect, test } from "vite-plus/test" - -import { canProvidersReachSourceUrl } from "@/lib/document-source.server" - -describe("provider document sources", () => { - test("distinguishes public origins from local development", () => { - expect(canProvidersReachSourceUrl("https://filerouter.dev")).toBe(true) - expect(canProvidersReachSourceUrl("http://localhost:3000")).toBe(false) - expect(canProvidersReachSourceUrl("http://192.168.1.10:3000")).toBe(false) - }) -}) diff --git a/src/lib/document-source.server.ts b/src/lib/document-source.server.ts index 31ad867..4b71279 100644 --- a/src/lib/document-source.server.ts +++ b/src/lib/document-source.server.ts @@ -28,21 +28,6 @@ export async function createProviderSourceUrl( return url.toString() } -export function canProvidersReachSourceUrl(baseUrl: string): boolean { - try { - const hostname = new URL(baseUrl).hostname.toLowerCase() - return !( - hostname === "localhost" || - hostname === "0.0.0.0" || - hostname === "::1" || - hostname.endsWith(".local") || - isPrivateIpv4(hostname) - ) - } catch { - return false - } -} - export async function getProviderSourceResponse( request: Request, env: Cloudflare.Env, @@ -251,19 +236,3 @@ function fromBase64Url(value: string): Uint8Array { .padEnd(Math.ceil(value.length / 4) * 4, "=") return Uint8Array.from(atob(padded), (character) => character.charCodeAt(0)) } - -function isPrivateIpv4(hostname: string): boolean { - const parts = hostname.split(".").map(Number) - if ( - parts.length !== 4 || - parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255) - ) { - return false - } - return ( - parts[0] === 10 || - (parts[0] === 172 && parts[1]! >= 16 && parts[1]! <= 31) || - (parts[0] === 192 && parts[1] === 168) || - parts[0] === 127 - ) -} diff --git a/src/lib/hosted-providers.server.ts b/src/lib/hosted-providers.server.ts new file mode 100644 index 0000000..5e766c0 --- /dev/null +++ b/src/lib/hosted-providers.server.ts @@ -0,0 +1,242 @@ +import { builtInProviders } from "@file_router/sdk/catalog" +import type { ProviderId } from "@file_router/sdk/catalog" +import type { FileRouterProvider, ProviderParseOptions } from "@file_router/sdk" + +import { HttpError } from "@/lib/http.server" +import { createNativeParserProvider } from "@/lib/native-parser.server" +import { isRecord } from "@/lib/record" +import { JOB_ID_HEADER, REQUEST_ID_HEADER } from "@/observability/log" + +const blockedTransportOptions: Record> = { + datalab: new Set([ + "checkpoint_id", + "eval_rubric_id", + "file", + "file_url", + "webhook_url", + "workflowstepdata_id", + ]), + llamaparse: new Set([ + "configuration_id", + "file_id", + "http_proxy", + "organization_id", + "project_id", + "source_url", + "upload_file", + "webhook_configuration_ids", + "webhook_configurations", + ]), + liteparse: new Set(), + "mistral-ocr": new Set(["document"]), + "pdf-inspector": new Set(), +} + +const liteParseOptions = new Set([ + "convertOffice", + "imageMode", + "includeComplexity", + "ocr", + "ocrLanguage", + "raw", + "screenshots", +]) + +const liteParseRawOptions = new Set([ + "cropBox", + "dpi", + "emitWordBoxes", + "extractLinks", + "ocrFailureFatal", + "preserveVerySmallText", + "quiet", + "skipDiagonalText", +]) + +const liteParseCropBoxOptions = new Set(["bottom", "left", "right", "top"]) +const MAX_LITEPARSE_DPI = 300 +const HOSTED_MISTRAL_OCR_MODELS = new Set([ + "mistral-ocr-2512", + "mistral-ocr-4-0", + "mistral-ocr-latest", +]) + +const validateProviderOptions: Record< + ProviderId, + (options: Record) => void +> = { + datalab(options) { + assertNoBlockedOptions("datalab", options) + if (isRecord(options.raw)) { + assertNoBlockedOptions("datalab", options.raw) + } + }, + llamaparse: (options) => assertNoBlockedOptions("llamaparse", options), + liteparse(options) { + assertOnlyOptions("liteparse", options, liteParseOptions) + validateLiteParseRawOptions(options.raw) + }, + "mistral-ocr"(options) { + assertNoBlockedOptions("mistral-ocr", options) + if ( + options.model !== undefined && + (typeof options.model !== "string" || + !HOSTED_MISTRAL_OCR_MODELS.has(options.model)) + ) { + throw new HttpError( + 400, + "Hosted mistral-ocr only accepts models on the published rate card." + ) + } + }, + "pdf-inspector"(options) { + if (Object.keys(options).length > 0) { + throw new HttpError(400, "Hosted pdf-inspector accepts no options.") + } + }, +} + +function validateLiteParseRawOptions(value: unknown): void { + if (value === undefined) { + return + } + if (!isRecord(value)) { + throw new HttpError(400, "Hosted liteparse raw options must be an object.") + } + assertOnlyOptions("liteparse", value, liteParseRawOptions) + + if ( + value.dpi !== undefined && + (typeof value.dpi !== "number" || + !Number.isFinite(value.dpi) || + value.dpi <= 0 || + value.dpi > MAX_LITEPARSE_DPI) + ) { + throw new HttpError( + 400, + `Hosted liteparse dpi must be greater than 0 and at most ${MAX_LITEPARSE_DPI}.` + ) + } + + if (value.cropBox === undefined) { + return + } + if (!isRecord(value.cropBox)) { + throw new HttpError(400, "Hosted liteparse cropBox must be an object.") + } + assertOnlyOptions("liteparse", value.cropBox, liteParseCropBoxOptions) + for (const side of liteParseCropBoxOptions) { + const fraction = value.cropBox[side] + if ( + typeof fraction !== "number" || + !Number.isFinite(fraction) || + fraction < 0 || + fraction > 1 + ) { + throw new HttpError( + 400, + `Hosted liteparse cropBox.${side} must be between 0 and 1.` + ) + } + } +} + +export function createHostedProviders( + env: Cloudflare.Env, + context: { jobId: string; requestId: string } +): Record { + const managed = builtInProviders({ + datalabApiKey: env.DATALAB_API_KEY, + llamaCloudApiKey: env.LLAMA_CLOUD_API_KEY, + mistralApiKey: env.MISTRAL_API_KEY, + }) + const nativeFetch = (request: Request) => { + const headers = new Headers(request.headers) + headers.set(JOB_ID_HEADER, context.jobId) + headers.set(REQUEST_ID_HEADER, context.requestId) + return env.NATIVE_PARSERS.fetch(new Request(request, { headers })) + } + return { + ...managed, + liteparse: createNativeParserProvider({ + capabilities: { + execution: "sync", + features: [ + "classification", + "ocr", + "office-conversion", + "page-selection", + "screenshots", + ], + outputs: ["images", "markdown", "metadata", "pages", "text"], + }, + fetch: nativeFetch, + id: "liteparse", + name: "LiteParse", + }), + "pdf-inspector": createNativeParserProvider({ + capabilities: { + execution: "sync", + features: ["classification", "page-selection"], + outputs: ["markdown", "metadata", "pages"], + }, + fetch: nativeFetch, + id: "pdf-inspector", + name: "PDF Inspector", + }), + } +} + +export function validateHostedProviderOptions( + value: unknown, + isProviderId: (value: string) => value is ProviderId +): ProviderParseOptions { + if (!isRecord(value)) { + throw new HttpError(400, "Provider options must be an object.") + } + + for (const [providerId, options] of Object.entries(value)) { + if (!isProviderId(providerId)) { + throw new HttpError(400, `Unsupported provider options: ${providerId}`) + } + if (!isRecord(options)) { + throw new HttpError( + 400, + `Provider options for ${providerId} must be an object.` + ) + } + validateProviderOptions[providerId](options) + } + + return value +} + +function assertOnlyOptions( + providerId: ProviderId, + options: Record, + allowedOptions: ReadonlySet +): void { + const unsupported = Object.keys(options).filter( + (key) => !allowedOptions.has(key) + ) + if (unsupported.length > 0) { + throw new HttpError( + 400, + `Hosted ${providerId} options do not support: ${unsupported.join(", ")}.` + ) + } +} + +function assertNoBlockedOptions( + providerId: ProviderId, + options: Record +): void { + const blockedOptions = blockedTransportOptions[providerId] + const blocked = Object.keys(options).filter((key) => blockedOptions.has(key)) + if (blocked.length > 0) { + throw new HttpError( + 400, + `Hosted ${providerId} options cannot set: ${blocked.join(", ")}.` + ) + } +} diff --git a/src/lib/http.server.ts b/src/lib/http.server.ts index ac06483..0b4532f 100644 --- a/src/lib/http.server.ts +++ b/src/lib/http.server.ts @@ -1,6 +1,7 @@ export type HttpErrorStatus = | 400 | 401 + | 402 | 403 | 404 | 409 diff --git a/src/lib/native-parser.server.test.ts b/src/lib/native-parser.server.test.ts new file mode 100644 index 0000000..493f7a0 --- /dev/null +++ b/src/lib/native-parser.server.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, test, vi } from "vite-plus/test" + +import { createNativeParserProvider } from "@/lib/native-parser.server" + +const nativeResult = { + engine: { id: "liteparse", version: "2.8.0" }, + markdown: "# Parsed", + metadata: { ocrEnabled: true }, + pageCount: 1, + pages: [{ markdown: "# Parsed", pageNumber: 1, text: "Parsed" }], + text: "Parsed", + warnings: [], +} + +function liteParseProvider(fetch: (request: Request) => Promise) { + return createNativeParserProvider({ + capabilities: { + execution: "sync", + outputs: ["markdown", "metadata", "pages", "text"], + }, + fetch, + id: "liteparse", + name: "LiteParse", + }) +} + +describe("hosted native parser transport", () => { + test("passes signed document URLs to the private parser service", async () => { + const requests: Array = [] + const fetch = vi.fn(async (request: Request) => { + requests.push(request) + return Response.json(nativeResult) + }) + const provider = liteParseProvider(fetch) + + const result = await provider.parse( + { + kind: "url", + url: "https://filerouter.dev/api/v1/sources/job/report.pdf?expires=1&token=x", + }, + { + outputs: ["markdown", "metadata"], + providerOptions: { liteparse: { ocr: "auto" } }, + } + ) + + expect(requests[0]?.headers.get("x-filerouter-source-url")).toContain( + "/api/v1/sources/job/report.pdf" + ) + expect(result).toMatchObject({ + outputs: { + markdown: "# Parsed", + metadata: { + engine: { id: "liteparse", version: "2.8.0" }, + ocrEnabled: true, + }, + }, + pageCount: 1, + provider: "liteparse", + usage: { pages: 1 }, + }) + }) + + test("rejects malformed private parser responses", async () => { + const provider = liteParseProvider(async () => + Response.json({ pageCount: 1 }) + ) + + await expect( + provider.parse( + { + kind: "url", + url: "https://filerouter.dev/api/v1/sources/job/report.pdf?expires=1&token=x", + }, + { outputs: ["markdown"] } + ) + ).rejects.toMatchObject({ code: "ParseFailed" }) + }) +}) diff --git a/src/lib/native-parser.server.ts b/src/lib/native-parser.server.ts new file mode 100644 index 0000000..2e37a67 --- /dev/null +++ b/src/lib/native-parser.server.ts @@ -0,0 +1,178 @@ +import { z } from "zod" +import { FileRouterError, selectParseOutputs } from "@file_router/sdk" +import type { + FileRouterProvider, + ParseResult, + ProviderInput, +} from "@file_router/sdk" +import type { ProviderId } from "@file_router/sdk/catalog" + +const nativeWarningSchema = z + .object({ + code: z.string(), + message: z.string(), + pageNumber: z.number().int().positive().optional(), + }) + .strict() + +const nativePageSchema = z + .object({ + dimensions: z + .object({ height: z.number(), width: z.number() }) + .strict() + .optional(), + markdown: z.string().optional(), + metadata: z.record(z.string(), z.unknown()).optional(), + pageNumber: z.number().int().positive(), + text: z.string().optional(), + }) + .strict() + +const nativeImageSchema = z + .object({ + data: z.string(), + id: z.string(), + mimeType: z.string(), + pageNumber: z.number().int().positive(), + }) + .strict() + +const nativeResultSchema = z + .object({ + engine: z + .object({ + id: z.enum(["liteparse", "pdf-inspector"]), + version: z.string(), + }) + .strict(), + images: z.array(nativeImageSchema).optional(), + markdown: z.string().optional(), + metadata: z.record(z.string(), z.unknown()), + pageCount: z.number().int().nonnegative(), + pages: z.array(nativePageSchema), + text: z.string().optional(), + warnings: z.array(nativeWarningSchema), + }) + .strict() + +const nativeFailureSchema = z + .object({ + error: z.object({ code: z.string(), message: z.string() }).passthrough(), + }) + .passthrough() + +type NativeParserId = Extract + +type NativeParserConfig = { + capabilities: FileRouterProvider["capabilities"] + fetch: (request: Request) => Promise + id: NativeParserId + name: string +} + +export function createNativeParserProvider( + config: NativeParserConfig +): FileRouterProvider { + return { + capabilities: config.capabilities, + id: config.id, + name: config.name, + parse: (input, options) => parseNative(config, input, options), + } +} + +async function parseNative( + config: NativeParserConfig, + input: ProviderInput, + options: Parameters[1] +): Promise { + if (input.kind !== "url") { + throw new FileRouterError("Hosted parsers require a document source URL.", { + code: "InvalidInput", + providerId: config.id, + }) + } + + const startedAt = new Date() + const response = await config.fetch( + new Request(`https://native-parsers.internal/v1/${config.id}/parse`, { + headers: { + "x-filerouter-engine-options": encodeURIComponent( + JSON.stringify({ + ...(options.pages && { pages: options.pages }), + ...(options.providerOptions?.[config.id] && { + providerOptions: options.providerOptions[config.id], + }), + }) + ), + "x-filerouter-source-url": input.url, + }, + method: "POST", + ...(options.signal && { signal: options.signal }), + }) + ) + const value: unknown = await response.json().catch(() => undefined) + if (!response.ok) { + throw nativeParserError(config.id, response.status, value) + } + + const parsed = nativeResultSchema.safeParse(value) + if (!parsed.success || parsed.data.engine.id !== config.id) { + throw new FileRouterError("Native parser returned an invalid response.", { + code: "ParseFailed", + providerId: config.id, + }) + } + + const native = parsed.data + const completedAt = new Date() + const pages = native.pages.map((page) => ({ ...page, warnings: [] })) + const metadata = { engine: native.engine, ...native.metadata } + const confidence = native.metadata.confidence + return { + id: crypto.randomUUID(), + outputs: selectParseOutputs(options.outputs ?? ["markdown"], { + images: native.images, + markdown: native.markdown, + metadata, + pages, + text: native.text, + }), + pageCount: native.pageCount, + provider: config.id, + ...(typeof confidence === "number" && { + quality: { scale: 1, score: confidence }, + }), + ...(options.includeRaw && { raw: native }), + timing: { + completedAt: completedAt.toISOString(), + durationMs: completedAt.getTime() - startedAt.getTime(), + startedAt: startedAt.toISOString(), + }, + usage: { pages: pages.length }, + warnings: native.warnings, + } +} + +function nativeParserError( + providerId: NativeParserId, + status: number, + value: unknown +): FileRouterError { + const parsed = nativeFailureSchema.safeParse(value) + const failure = parsed.success ? parsed.data.error : undefined + return new FileRouterError( + failure?.message ?? "Native parser request failed.", + { + code: + status === 429 + ? "ProviderUnavailable" + : status === 400 || status === 413 + ? "InvalidInput" + : "ParseFailed", + providerId, + retryable: failure?.code === "capacity_exceeded", + statusCode: status, + } + ) +} diff --git a/src/lib/public-url.ts b/src/lib/public-url.ts new file mode 100644 index 0000000..914fd88 --- /dev/null +++ b/src/lib/public-url.ts @@ -0,0 +1,55 @@ +export function readPublicHttpUrl(value: string): URL { + const url = new URL(value) + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error("URLs must use HTTP or HTTPS.") + } + const hostname = url.hostname + .toLowerCase() + .replace(/^\[|\]$/g, "") + .replace(/\.$/, "") + if ( + hostname === "localhost" || + hostname === "0.0.0.0" || + hostname === "::" || + hostname === "::1" || + hostname.endsWith(".local") || + isPrivateIpv4(hostname) || + isPrivateIpv6(hostname) + ) { + throw new Error("URL must resolve on the public Internet.") + } + url.username = "" + url.password = "" + return url +} + +function isPrivateIpv4(hostname: string): boolean { + const parts = hostname.split(".").map(Number) + if ( + parts.length !== 4 || + parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255) + ) { + return false + } + const first = parts[0]! + const second = parts[1]! + return ( + first === 0 || + first === 10 || + first === 127 || + (first === 169 && second === 254) || + (first === 172 && second >= 16 && second <= 31) || + (first === 192 && second === 168) || + first >= 224 + ) +} + +function isPrivateIpv6(hostname: string): boolean { + const normalized = hostname.toLowerCase() + return ( + normalized.startsWith("fc") || + normalized.startsWith("fd") || + /^fe[89ab]/.test(normalized) || + normalized.startsWith("::ffff:") + ) +} diff --git a/src/lib/record.ts b/src/lib/record.ts new file mode 100644 index 0000000..c8dc521 --- /dev/null +++ b/src/lib/record.ts @@ -0,0 +1,3 @@ +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} diff --git a/src/logo.svg b/src/logo.svg index 48a4639..3b33914 100644 --- a/src/logo.svg +++ b/src/logo.svg @@ -1,3 +1,3 @@ - + diff --git a/src/observability/log.ts b/src/observability/log.ts new file mode 100644 index 0000000..6703359 --- /dev/null +++ b/src/observability/log.ts @@ -0,0 +1,80 @@ +export const REQUEST_ID_HEADER = "x-request-id" +export const JOB_ID_HEADER = "x-filerouter-job-id" + +type LogLevel = "error" | "info" + +type VersionMetadata = { + id: string + tag: string + timestamp: string +} + +export interface ObservabilityEnv { + ENVIRONMENT?: string + WORKER_VERSION?: VersionMetadata +} + +export type WideEvent = Record & { + event: string + service: string +} + +export function emitWideEvent( + env: ObservabilityEnv, + level: LogLevel, + event: WideEvent +): void { + const release = env.WORKER_VERSION + const record = { + timestamp: new Date().toISOString(), + environment: + env.ENVIRONMENT ?? (import.meta.env.DEV ? "development" : "production"), + ...(release && { + release_id: release.id, + release_tag: release.tag, + release_uploaded_at: release.timestamp, + }), + ...event, + } + + console[level](record) +} + +export function serializeError(error: unknown): { + error_message: string + error_type: string +} { + return error instanceof Error + ? { error_message: error.message, error_type: error.name } + : { error_message: "Unknown error", error_type: "UnknownError" } +} + +export function requestIdFrom(request: Request): string { + const value = request.headers.get(REQUEST_ID_HEADER)?.trim() + return value && value.length <= 128 ? value : crypto.randomUUID() +} + +export function withRequestId( + request: Request, + requestId = requestIdFrom(request) +): Request { + if (request.headers.get(REQUEST_ID_HEADER) === requestId) { + return request + } + const headers = new Headers(request.headers) + headers.set(REQUEST_ID_HEADER, requestId) + return new Request(request, { headers }) +} + +export function withResponseRequestId( + response: Response, + requestId: string +): Response { + const headers = new Headers(response.headers) + headers.set("X-Request-Id", requestId) + return new Response(response.body, { + headers, + status: response.status, + statusText: response.statusText, + }) +} diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index c9662b7..cb5ad12 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -10,6 +10,10 @@ import type { QueryClient } from "@tanstack/react-query" import type { ReactNode } from "react" import { ThemeProvider } from "@/components/theme-provider" +import { NotFoundPage } from "@/components/not-found-page" +import { PostHogBootstrap } from "@/components/posthog-bootstrap" +import { RootError } from "@/components/root-error" +import { getPublicPostHogConfig } from "@/integrations/posthog/config.functions" import type { AuthSession } from "@/lib/session-query" import appCss from "../styles.css?url" @@ -20,6 +24,7 @@ interface RouterContext { } export const Route = createRootRouteWithContext()({ + loader: () => getPublicPostHogConfig(), head: () => ({ meta: [ { @@ -98,16 +103,14 @@ export const Route = createRootRouteWithContext()({ }, ], }), - notFoundComponent: () => ( -
    -

    404

    -

    The requested page could not be found.

    -
    - ), + errorComponent: RootError, + notFoundComponent: NotFoundPage, shellComponent: RootDocument, }) function RootDocument({ children }: { children: ReactNode }) { + const postHogConfig = Route.useLoaderData() + return ( @@ -115,6 +118,7 @@ function RootDocument({ children }: { children: ReactNode }) { + {children} {import.meta.env.DEV ? ( { + identifyBrowserUser(session.user.id) + }, [session.user.id]) + async function signOut() { setSigningOut(true) - await authClient.signOut() - await router.invalidate() - await router.navigate({ to: "/" }) + try { + const result = await authClient.signOut() + if (result.error) { + throw new Error(result.error.message) + } + resetBrowserUser() + await router.invalidate() + await router.navigate({ to: "/" }) + } catch (error) { + captureBrowserException(error, { operation: "sign_out" }) + setSigningOut(false) + } } return (
    - -
    +