/** * Nexus Server Actions — type-safe, race-condition-safe server mutations. * * Race Condition Problem: * User clicks "Save" three times in rapid succession. * Request 1 arrives, starts processing (200ms). * Request 2 arrives, starts processing (180ms) — finishes FIRST. * Request 3 arrives, starts processing (150ms) — finishes SECOND. * Request 1 finishes LAST — overwrites the results of 2 and 3. 💥 * * Solutions implemented: * * 1. Idempotency key deduplication: * Client sends X-Nexus-Idempotency: with each action call. * If the same key arrives again while the first is in flight, * the server returns the SAME response (cached for 30s). * * 2. Per-island action mutex: * Each island tracks its in-flight actions per action name. * Configurable behavior: 'cancel' | 'queue' | 'reject' | 'ignore'. * * 3. AbortController propagation: * The signal is passed into the action context. Actions that * call external APIs should check ctx.signal.aborted. * If the client disconnects, the signal fires automatically. * * 4. Client-side: $optimistic with built-in race guard. * The createOptimistic() pending flag blocks double-submit. */ import type { NexusContext } from './context.js'; import { type RateLimitConfig } from './rate-limit.js'; /** * Zod-compatible schema interface. * Supports `.parse()` (throws on failure) and optionally `.safeParse()` (returns structured errors). * Works with Zod, Valibot, ArkType, Superstruct, and any schema library following this contract. */ export interface NexusSchema { parse(data: unknown): T; /** Optional — when present, used to extract structured field errors (Zod format). */ safeParse?: (data: unknown) => { success: boolean; error?: { issues?: Array<{ path: Array; message: string; }>; }; data?: T; }; } export type ActionFn = (input: TInput, ctx: NexusContext & { signal: AbortSignal; }) => Promise; export type RaceStrategy = 'cancel' | 'queue' | 'reject' | 'ignore'; export interface ActionOptions { /** * How to handle concurrent calls to the same action from the same client. * 'cancel' — abort the previous call, run the new one (default for mutations) * 'queue' — run calls sequentially in order * 'reject' — reject the new call if one is already in flight * 'ignore' — let all calls run in parallel (default for idempotent reads) */ race?: RaceStrategy; /** * Mark as idempotent — same idempotency key returns cached result. * Set to true for safe retries (GET-like mutations). */ idempotent?: boolean; /** * Timeout in ms. Aborts the action if it takes too long. * Default: 30000 (30s) */ timeout?: number; /** * Retry on network failure (not on logic errors). * Default: 0 */ retries?: number; /** * Per-action rate limiting. * Example: { window: '1m', max: 3 } → 3 calls per minute per IP. * Override the key with keyFn: { window: '1m', max: 3, keyFn: r => userId } */ rateLimit?: RateLimitConfig; /** * Require a valid CSRF action token in the x-nexus-action-token header. * Default: true for all state-mutating actions. * Set to false for read-only or public actions. */ csrf?: boolean; /** * Zod-compatible schema for input validation. * The action rejects invalid input **before** calling the handler — * preventing SQL injection, type coercion attacks, and untrusted data reaching business logic. * * Accepts any object with a `.parse()` method (Zod, Valibot, ArkType, etc.) * or `.safeParse()` for structured error extraction. * * @example * ```ts * import { z } from 'zod'; * export const updateUser = createAction({ * schema: z.object({ name: z.string().min(1).max(100), age: z.number().int().min(0) }), * handler: async ({ name, age }, ctx) => { ... }, * }); * ``` */ schema?: NexusSchema; /** * Maximum request body size in bytes. Default: 10 MB. * Lower this for actions that only receive small form payloads (e.g. login forms). * Set to 0 to disable the limit (not recommended). */ maxBodyBytes?: number; } export interface ActionResult { data?: T; error?: string; status: number; /** Echoed back to client for deduplication */ idempotencyKey?: string; /** Server-side execution time in ms */ duration?: number; } /** * Verifies an action name signature. Returns true if the signature is valid or * if we are in dev mode (NODE_ENV !== 'production' — signature is optional in dev). */ export declare function verifyActionSig(name: string, sig: string | null): boolean; /** * Defines a Server Action with integrated security, rate limiting, and * race-condition management. The returned object is registered automatically * and ready to be called by the client. * * Security layers applied (in order): * 1. CSRF: custom header `x-nexus-action: 1` (Tier 1) + optional HMAC token (Tier 2) * 2. Rate limiting (sliding window, per-IP or per-user) * 3. Input schema validation (Zod or any .parse() compatible schema) * 4. AbortController (client disconnect + timeout) * 5. Idempotency deduplication * 6. Race condition strategy (cancel | queue | reject | ignore) * * @example * export const capture = createAction({ * rateLimit: { window: '1m', max: 3, keyFn: (req) => req.headers.get('x-user-id') ?? extractIP(req) }, * schema: z.object({ pokemonId: z.number().int().min(1).max(1010) }), * race: 'cancel', * async handler(data, ctx) { * const { pokemonId } = data; * await db.captures.create({ userId: ctx.user.id, pokemonId }); * }, * }); */ export declare function createAction(optsOrFn: ActionFn | (ActionOptions & { handler: ActionFn; }), legacyOpts?: ActionOptions): ActionFn; export declare function registerAction(name: string, fn: ActionFn, opts?: ActionOptions): void; /** Names of all registered server actions (after preload). Used by Shield-lite allowlists. */ export declare function getRegisteredActionNames(): ReadonlySet; export declare class ActionError extends Error { readonly status: number; readonly code?: string; readonly fieldErrors?: Record; constructor(message: string, optionsOrStatus?: number | { status?: number; code?: string; fieldErrors?: Record; }, code?: string, fieldErrors?: Record); } export declare class ActionAbortedError extends ActionError { constructor(); } /** * Main HTTP handler for /_nexus/action/:name * This is where all the race-condition logic runs. */ export declare function handleActionRequest(request: Request): Promise; /** * Validates that a request comes from a trusted Nexus client (inner CSRF check * used by `createAction` wrappers). Verifies: * 1. `x-nexus-action` custom header — cross-origin requests cannot add this * without a CORS preflight the server will reject. * 2. `Origin` / `Referer` header sanity check — additional signal against * misconfigured CORS or non-standard clients. */ export declare function validateRequest(ctx: NexusContext): Promise; export { generateActionToken, validateActionToken, extractSessionId, generateSessionId } from './csrf.js'; export { createRateLimiter, RateLimitError, parseWindow } from './rate-limit.js'; export type { RateLimitConfig, RateLimitResult, RateLimiter } from './rate-limit.js'; /** * Returns `true` when `url` is a safe **public** `http:` / `https:` target for * server-side `fetch` (not loopback, RFC1918, link-local, metadata IPs, etc.). * Use before `fetch(userUrl)` to reduce blind SSRF risk. */ export declare function isSafeUrl(url: string): boolean; /** * Returns `true` when a URL resolves to a private, loopback, or link-local * address. Inverse of {@link isSafeUrl} for `http:` / `https:`. */ export declare function isInternalUrl(url: string): boolean; /** * Client-side AbortController factory. * Use this in island code to cancel in-flight action fetches * when the user triggers a new one. * * @example * const guard = createActionGuard('save', 'cancel'); * async function save(formData) { * const signal = guard.arm(); * const result = await callAction('savePost', formData, { signal }); * if (!guard.aborted) updateUI(result); * } */ export declare function createActionGuard(name: string, strategy?: RaceStrategy): { arm: () => AbortSignal; abort: () => void; aborted: boolean; pending: boolean; }; //# sourceMappingURL=actions.d.ts.map