Step {number}
@@ -61,67 +50,40 @@ export function DashboardQuickstart() {
return (
-
-
-
- Start with the CLI
-
-
- Connect your account and parse a document in two commands.
-
-
-
-
- Full quickstart
-
-
-
+
+
+ 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.
+
+
+ Back to FileRouter
+
+
+
+ )
+}
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.
+
+
+ Try again
+
+ Go home
+
+
+
+
+ )
+}
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 (
-
-
-
- Docs
-
-
-
+
-
-
-
Get started
-
- {session.user.email}
-
-
-
-
-
+
+
+
-
-
- Documentation
-
- {[
- {
- href: "https://docs.filerouter.dev/quickstart",
- icon: BookOpenText,
- label: "Quickstart",
- },
- {
- href: "https://docs.filerouter.dev/sdk/parse",
- icon: BracketsCurly,
- label: "TypeScript SDK",
- },
- {
- href: "https://docs.filerouter.dev/api/overview",
- icon: CloudArrowUp,
- label: "API reference",
- },
- ].map(({ href, icon: Icon, label }) => (
-
-