@luckystack/api 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/CLAUDE.md +106 -0
- package/LICENSE +21 -0
- package/README.md +106 -0
- package/dist/index.d.ts +70 -0
- package/dist/index.js +816 -0
- package/dist/index.js.map +1 -0
- package/docs/api-request-lifecycle.md +280 -0
- package/docs/auth-flow.md +232 -0
- package/docs/error-handling.md +247 -0
- package/docs/generated-types.md +264 -0
- package/docs/rate-limiting.md +188 -0
- package/docs/routing-conventions.md +164 -0
- package/package.json +66 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/handleApiRequest.ts","../src/handleHttpApiRequest.ts"],"sourcesContent":["/* eslint-disable unicorn/no-abusive-eslint-disable */\r\n/* eslint-disable */\r\nimport type { apiMessage, PostApiExecutePayload } from '@luckystack/core';\r\nimport { getSession, logout } from '@luckystack/login';\r\nimport type { BaseSessionLayout as SessionLayout } from '@luckystack/login';\r\nimport { getProjectConfig } from '@luckystack/core';\r\nimport type { AuthProps } from '@luckystack/login';\r\nimport { Socket } from 'socket.io';\r\nimport { getRuntimeApiMaps } from '@luckystack/core';\r\nimport {\r\n validateRequest,\r\n checkRateLimit,\r\n tryCatch,\r\n parseTransportRouteName,\r\n buildApiResponseEventName,\r\n buildApiStreamEventName,\r\n dispatchHook,\r\n validateInputByType,\r\n getLogger,\r\n} from '@luckystack/core';\r\nimport {\r\n defaultHttpStatusForResponse,\r\n extractLanguageFromHeader,\r\n normalizeErrorResponse,\r\n applyErrorFormatter,\r\n registerApiAbortController,\r\n unregisterApiAbortController,\r\n socketEventNames,\r\n} from '@luckystack/core';\r\nimport type { ErrorFormatter } from '@luckystack/core';\r\n\r\ninterface handleApiRequestType {\r\n msg: apiMessage,\r\n socket: Socket,\r\n token: string | null,\r\n}\r\n\r\ntype ApiStreamPayload = Record<string, unknown>;\r\n\r\ninterface RuntimeErrorResponse {\r\n status: 'error';\r\n errorCode?: string;\r\n errorParams?: { key: string; value: string | number | boolean; }[];\r\n httpStatus?: number;\r\n message?: string;\r\n [key: string]: unknown;\r\n}\r\n\r\ninterface RuntimeSuccessResponse {\r\n status: 'success';\r\n message?: string;\r\n httpStatus?: number;\r\n [key: string]: unknown;\r\n}\r\n\r\ntype RuntimeApiResponse = RuntimeSuccessResponse | RuntimeErrorResponse;\r\n\r\n//? B2 — backpressure helper for API handlers. Same opt-in pattern as\r\n//? sync; resolves once the originator socket's pending write buffer drops\r\n//? below the threshold. Default 1 MB.\r\nexport interface ApiFlushPressureOptions {\r\n thresholdBytes?: number;\r\n}\r\nexport type ApiFlushPressure = (options?: ApiFlushPressureOptions) => Promise<void>;\r\n\r\ninterface RuntimeApiEntry {\r\n auth: AuthProps;\r\n main: (params: {\r\n data: Record<string, unknown>;\r\n user: SessionLayout | null;\r\n functions: Record<string, unknown>;\r\n stream: (payload?: ApiStreamPayload) => void;\r\n //? B1 — aborts on `apiCancel` or socket disconnect.\r\n abortSignal: AbortSignal;\r\n //? B2 — backpressure helper bound to the originator socket.\r\n flushPressure: ApiFlushPressure;\r\n }) => Promise<RuntimeApiResponse>;\r\n inputType?: string;\r\n inputTypeFilePath?: string;\r\n rateLimit?: number | false;\r\n /**\r\n * Per-route validation strictness.\r\n * `'strict'` (default): runtime Zod validation runs, mismatched payloads\r\n * are rejected with `api.invalidInputType`.\r\n * `'relaxed'` / `{ input: 'skip' }`: skip the validate step. Use for public\r\n * webhooks that receive third-party-shaped payloads you can't model in TS,\r\n * or for migration windows when input shapes are in flux.\r\n */\r\n validation?: 'strict' | 'relaxed' | { input: 'skip' | 'strict' };\r\n /**\r\n * Per-route error response formatter. Receives the normalized error\r\n * envelope + context and returns the shape to emit. Falls back to the\r\n * global formatter from `registerErrorFormatter(...)`, then to the\r\n * framework default `normalizeErrorResponse`. See\r\n * docs/ARCHITECTURE_EXTENSION_POINTS.md for the resolution order.\r\n */\r\n errorFormatter?: ErrorFormatter;\r\n}\r\n\r\ninterface ApiErrorResponse {\r\n status: 'error';\r\n httpStatus?: number;\r\n errorCode?: string;\r\n errorParams?: { key: string; value: string | number | boolean; }[];\r\n}\r\n\r\ntype EmitApiError = (args: { response: ApiErrorResponse; fallbackHttpStatus?: number; }) => void;\r\n\r\nconst shouldLogDev = () => getProjectConfig().logging.devLogs;\r\nconst shouldLogStream = () => getProjectConfig().logging.stream;\r\n\r\n//? Track routes we've already warned about so we don't spam the log every\r\n//? request. Set is module-level — fine for the dev-only warning.\r\nconst warnedMissingInputType = new Set<string>();\r\nconst warnIfInputTypeMissing = (resolvedName: string, inputType: string | undefined): void => {\r\n if (!getProjectConfig().dev.warnOnMissingInputType) return;\r\n if (inputType && inputType.trim().length > 0 && inputType.trim() !== 'any') return;\r\n if (warnedMissingInputType.has(resolvedName)) return;\r\n warnedMissingInputType.add(resolvedName);\r\n getLogger().warn(`api: route ${resolvedName} has no inputType — runtime input validation is disabled. Regenerate types or set the inputType on the handler.`);\r\n};\r\n\r\nconst validateApiMessage = ({ msg, emitApiError }: {\r\n msg: apiMessage;\r\n emitApiError: EmitApiError;\r\n}): { name: string; data: Record<string, unknown> } | null => {\r\n const { name, data, responseIndex } = msg;\r\n\r\n if (!responseIndex && typeof responseIndex !== 'number') {\r\n if (shouldLogDev()) {\r\n getLogger().warn('api: no response index given');\r\n }\r\n return null;\r\n }\r\n\r\n if (!name || !data || typeof name != 'string' || typeof data != 'object') {\r\n emitApiError({\r\n response: { status: 'error', errorCode: 'api.invalidRequest' },\r\n fallbackHttpStatus: 400,\r\n });\r\n return null;\r\n }\r\n\r\n return { name, data: data as Record<string, unknown> };\r\n};\r\n\r\nconst checkApiAuth = ({ apiEntry, user, name, emitApiError }: {\r\n apiEntry: RuntimeApiEntry;\r\n user: SessionLayout | null;\r\n name: string;\r\n emitApiError: EmitApiError;\r\n}): boolean => {\r\n if (apiEntry.auth.login && !user?.id) {\r\n if (shouldLogDev()) {\r\n getLogger().warn(`api: ${name} requires login`, { route: name });\r\n }\r\n emitApiError({\r\n response: { status: 'error', errorCode: 'auth.required' },\r\n fallbackHttpStatus: 401,\r\n });\r\n return false;\r\n }\r\n\r\n const authResult = validateRequest({ auth: apiEntry.auth, user: user! });\r\n if (authResult.status === 'error') {\r\n if (shouldLogDev()) {\r\n getLogger().warn(`api: auth failed for ${name}`, { route: name, errorCode: authResult.errorCode });\r\n }\r\n emitApiError({\r\n response: {\r\n status: 'error',\r\n errorCode: authResult.errorCode || 'auth.forbidden',\r\n errorParams: authResult.errorParams,\r\n httpStatus: authResult.httpStatus,\r\n },\r\n fallbackHttpStatus: authResult.httpStatus ?? 403,\r\n });\r\n return false;\r\n }\r\n\r\n return true;\r\n};\r\n\r\nconst applyApiRateLimits = async ({ apiEntry, resolvedName, token, socket, user, emitApiError }: {\r\n apiEntry: RuntimeApiEntry;\r\n resolvedName: string;\r\n token: string | null;\r\n socket: Socket;\r\n user: SessionLayout | null;\r\n emitApiError: EmitApiError;\r\n}): Promise<boolean> => {\r\n const apiRateLimit = apiEntry.rateLimit;\r\n const effectiveApiLimit = apiRateLimit === undefined\r\n ? getProjectConfig().rateLimiting.defaultApiLimit\r\n : apiRateLimit;\r\n\r\n if (effectiveApiLimit !== false && effectiveApiLimit > 0) {\r\n const requesterIdentity = token ?? socket.handshake.address ?? 'unknown';\r\n const keyPrefix = token ? 'token' : 'ip';\r\n const rateLimitKey = `${keyPrefix}:${requesterIdentity}:api:${resolvedName}`;\r\n\r\n const { allowed, resetIn } = await checkRateLimit({\r\n key: rateLimitKey,\r\n limit: effectiveApiLimit,\r\n windowMs: getProjectConfig().rateLimiting.windowMs,\r\n });\r\n\r\n if (!allowed) {\r\n void dispatchHook('rateLimitExceeded', {\r\n scope: token ? 'user' : 'route',\r\n key: rateLimitKey,\r\n limit: effectiveApiLimit,\r\n windowMs: getProjectConfig().rateLimiting.windowMs,\r\n count: effectiveApiLimit + 1,\r\n route: resolvedName,\r\n userId: user?.id,\r\n });\r\n if (shouldLogDev()) {\r\n getLogger().warn(`api: rate limit exceeded for ${resolvedName}`, { route: resolvedName, key: rateLimitKey });\r\n }\r\n emitApiError({\r\n response: {\r\n status: 'error',\r\n errorCode: 'api.rateLimitExceeded',\r\n errorParams: [{ key: 'seconds', value: resetIn }],\r\n },\r\n fallbackHttpStatus: 429,\r\n });\r\n return false;\r\n }\r\n }\r\n\r\n const defaultIpLimit = getProjectConfig().rateLimiting.defaultIpLimit;\r\n if (defaultIpLimit !== false && defaultIpLimit > 0) {\r\n const requesterIp = socket.handshake.address ?? 'unknown';\r\n\r\n const { allowed, resetIn } = await checkRateLimit({\r\n key: `ip:${requesterIp}:api:all`,\r\n limit: defaultIpLimit,\r\n windowMs: getProjectConfig().rateLimiting.windowMs,\r\n });\r\n\r\n if (!allowed) {\r\n void dispatchHook('rateLimitExceeded', {\r\n scope: 'ip',\r\n key: `ip:${requesterIp}:api:all`,\r\n limit: defaultIpLimit,\r\n windowMs: getProjectConfig().rateLimiting.windowMs,\r\n count: defaultIpLimit + 1,\r\n ip: requesterIp,\r\n });\r\n if (shouldLogDev()) {\r\n getLogger().warn(`api: global IP rate limit exceeded`, { ip: requesterIp });\r\n }\r\n emitApiError({\r\n response: {\r\n status: 'error',\r\n errorCode: 'api.rateLimitExceeded',\r\n errorParams: [{ key: 'seconds', value: resetIn }],\r\n },\r\n fallbackHttpStatus: 429,\r\n });\r\n return false;\r\n }\r\n }\r\n\r\n return true;\r\n};\r\n\r\nconst executeApiHandler = async ({ apiEntry, normalizedData, user, functionsObject, resolvedName, emitStream, abortSignal, flushPressure }: {\r\n apiEntry: RuntimeApiEntry;\r\n normalizedData: Record<string, unknown>;\r\n user: SessionLayout | null;\r\n functionsObject: Record<string, unknown>;\r\n resolvedName: string;\r\n emitStream: (payload?: ApiStreamPayload) => void;\r\n abortSignal: AbortSignal;\r\n flushPressure: ApiFlushPressure;\r\n}): Promise<{ error: Error | null; result: RuntimeApiResponse | undefined; durationMs: number }> => {\r\n //? Span open/close + identity propagation moved to hook subscribers in\r\n //? `@luckystack/error-tracking` (preApiExecute / postApiExecute). This\r\n //? handler is now transport-agnostic instrumentation-wise.\r\n const executeStart = Date.now();\r\n const [error, result] = await tryCatch(\r\n async () => await apiEntry.main({ data: normalizedData, user, functions: functionsObject, stream: emitStream, abortSignal, flushPressure }),\r\n undefined,\r\n {\r\n handler: 'handleApiRequest',\r\n api: resolvedName,\r\n userId: user?.id,\r\n transport: 'socket',\r\n },\r\n );\r\n return { error: error ?? null, result: result ?? undefined, durationMs: Date.now() - executeStart };\r\n};\r\n\r\nconst buildApiResponseEnvelope = ({\r\n resolvedName,\r\n error,\r\n result,\r\n preferredLocale,\r\n user,\r\n}: {\r\n resolvedName: string;\r\n error: Error | null;\r\n result: RuntimeApiResponse | undefined;\r\n preferredLocale: string | undefined;\r\n user: SessionLayout | null;\r\n}): { status: 'success' | 'error'; httpStatus?: number; [key: string]: unknown } => {\r\n if (error) {\r\n if (shouldLogDev()) {\r\n getLogger().error(`api: error in ${resolvedName}`, error, { route: resolvedName });\r\n }\r\n return { ...normalizeErrorResponse({\r\n response: { status: 'error', errorCode: 'api.internalServerError' },\r\n preferredLocale,\r\n userLanguage: user?.language,\r\n fallbackHttpStatus: 500,\r\n }) };\r\n }\r\n\r\n if (result === undefined || result === null) {\r\n if (shouldLogDev()) {\r\n getLogger().warn(`api: ${resolvedName} returned nothing`);\r\n }\r\n return { ...normalizeErrorResponse({\r\n response: { status: 'error', errorCode: 'api.emptyResponse' },\r\n preferredLocale,\r\n userLanguage: user?.language,\r\n fallbackHttpStatus: 500,\r\n }) };\r\n }\r\n\r\n if (shouldLogDev()) {\r\n getLogger().debug(`api: ${resolvedName} completed`);\r\n }\r\n\r\n if (result.status !== 'success' && result.status !== 'error') {\r\n return { ...normalizeErrorResponse({\r\n response: { status: 'error', errorCode: 'api.invalidResponseStatus' },\r\n preferredLocale,\r\n userLanguage: user?.language,\r\n fallbackHttpStatus: 500,\r\n }) };\r\n }\r\n\r\n if (result.status === 'error') {\r\n return { ...normalizeErrorResponse({\r\n response: result,\r\n preferredLocale,\r\n userLanguage: user?.language,\r\n fallbackHttpStatus: defaultHttpStatusForResponse({\r\n status: 'error',\r\n explicitHttpStatus: typeof result.httpStatus === 'number' ? result.httpStatus : undefined,\r\n }),\r\n }) };\r\n }\r\n\r\n return {\r\n ...result,\r\n status: 'success',\r\n httpStatus: defaultHttpStatusForResponse({\r\n status: 'success',\r\n explicitHttpStatus: typeof result.httpStatus === 'number' ? result.httpStatus : undefined,\r\n }),\r\n };\r\n};\r\n\r\nconst emitApiResult = async ({\r\n socket,\r\n responseIndex,\r\n resolvedName,\r\n error,\r\n result,\r\n preferredLocale,\r\n user,\r\n perRouteFormatter,\r\n}: {\r\n socket: Socket;\r\n responseIndex: number;\r\n resolvedName: string;\r\n error: Error | null;\r\n result: RuntimeApiResponse | undefined;\r\n preferredLocale: string | undefined;\r\n user: SessionLayout | null;\r\n perRouteFormatter?: ErrorFormatter;\r\n}): Promise<void> => {\r\n const envelope = buildApiResponseEnvelope({ resolvedName, error, result, preferredLocale, user });\r\n\r\n //? `preApiRespond` handlers may mutate `payload.response` to swap or rewrite\r\n //? the outgoing envelope (PII redaction, response signing, schema injection).\r\n //? A handler may also return a stop signal — when that happens we build a\r\n //? fresh error envelope from the signal's `errorCode` / `httpStatus` and\r\n //? emit that instead of the original.\r\n const preRespond = { routeName: resolvedName, user, response: envelope as { status: 'success' | 'error'; httpStatus?: number; [key: string]: unknown } };\r\n const preRespondResult = await dispatchHook('preApiRespond', preRespond);\r\n\r\n const finalResponse = preRespondResult.stopped\r\n ? { ...normalizeErrorResponse({\r\n response: { status: 'error', errorCode: preRespondResult.signal.errorCode },\r\n preferredLocale,\r\n userLanguage: user?.language,\r\n fallbackHttpStatus: preRespondResult.signal.httpStatus ?? 403,\r\n }) }\r\n : preRespond.response;\r\n\r\n //? `transformApiResponse` fires AFTER preApiRespond and BEFORE socket emit.\r\n //? Designed for response mutation — header injection, body transformation,\r\n //? response signing — that's awkward inside preApiRespond. Handlers mutate\r\n //? `payload.response` in place (same shape as preApiRespond.response).\r\n const transformPayload = { routeName: resolvedName, user, response: finalResponse };\r\n await dispatchHook('transformApiResponse', transformPayload);\r\n\r\n //? Per-route → global → identity error formatter chain. No-op when status\r\n //? is success. Runs AFTER transformApiResponse so consumer hooks can still\r\n //? mutate the envelope before the final shape lands.\r\n const formattedResponse = applyErrorFormatter({\r\n response: transformPayload.response,\r\n routeName: resolvedName,\r\n transport: 'socket',\r\n userId: user?.id,\r\n perRouteFormatter,\r\n }) as { status: 'success' | 'error'; httpStatus?: number; [key: string]: unknown };\r\n\r\n socket.emit(buildApiResponseEventName(responseIndex), formattedResponse);\r\n\r\n //? `postApiRespond` is observation-only — the response is already on the\r\n //? wire. Use it for audit logging, metrics, dual-write replication.\r\n await dispatchHook('postApiRespond', { routeName: resolvedName, user, response: formattedResponse });\r\n};\r\n\r\nexport default async function handleApiRequest({ msg, socket, token }: handleApiRequestType) {\r\n //? This event gets triggered when the client uses the apiRequest function.\r\n //? Validate the message, check auth, then execute the registered handler.\r\n\r\n if (typeof msg != 'object') {\r\n if (shouldLogDev()) {\r\n getLogger().warn('api: socket message was not a json object');\r\n }\r\n return;\r\n }\r\n\r\n const { responseIndex } = msg;\r\n const user = await getSession(token);\r\n //? Identity propagation now flows via the `preApiExecute` hook subscriber\r\n //? registered by `@luckystack/error-tracking`'s `enableErrorTrackingAutoInstrumentation()`.\r\n //? Direct `setSentryUser` removed from this handler — see migration doc.\r\n const preferredLocale =\r\n extractLanguageFromHeader(socket.handshake.headers['x-language'])\r\n || extractLanguageFromHeader(socket.handshake.headers['accept-language'])\r\n || undefined;\r\n\r\n //? Per-route formatter ref + resolved-name ref. Both start undefined and\r\n //? get set once the route is parsed + the apiEntry looked up — pre-lookup\r\n //? errors (invalid message shape, unknown route) emit with global formatter\r\n //? only because there's no apiEntry to read per-route formatter from. After\r\n //? lookup, every emit through `emitApiError` flows through the same chain.\r\n let currentRouteName: string | undefined;\r\n let currentPerRouteFormatter: ErrorFormatter | undefined;\r\n\r\n const emitApiError: EmitApiError = ({ response, fallbackHttpStatus }) => {\r\n const normalized = normalizeErrorResponse({\r\n response,\r\n preferredLocale,\r\n userLanguage: user?.language,\r\n fallbackHttpStatus,\r\n });\r\n const formatted = applyErrorFormatter({\r\n response: normalized as unknown as Record<string, unknown> & { status?: string },\r\n routeName: currentRouteName ?? 'api/unknown',\r\n transport: 'socket',\r\n userId: user?.id,\r\n perRouteFormatter: currentPerRouteFormatter,\r\n });\r\n socket.emit(buildApiResponseEventName(responseIndex), formatted);\r\n };\r\n\r\n const validated = validateApiMessage({ msg, emitApiError });\r\n if (!validated) return;\r\n const { name, data: normalizedData } = validated;\r\n\r\n const parsedRoute = parseTransportRouteName({ value: name, prefix: 'api' });\r\n if (parsedRoute.status === 'error') {\r\n return emitApiError({\r\n response: {\r\n status: 'error',\r\n errorCode: 'routing.invalidServiceRouteName',\r\n errorParams: [{ key: 'name', value: name }],\r\n },\r\n fallbackHttpStatus: 400,\r\n });\r\n }\r\n\r\n const resolvedName = parsedRoute.normalizedFullName;\r\n currentRouteName = resolvedName;\r\n\r\n //? Built-in 'system/logout' needs special handling since it requires socket access.\r\n //? Match the full normalized route to avoid hijacking consumer routes whose final\r\n //? segment happens to be 'logout' (e.g. 'admin/logout/vN').\r\n if (parsedRoute.serviceRoute.normalizedRouteName === 'system/logout') {\r\n await logout({ token, socket, userId: user?.id || null });\r\n return socket.emit(buildApiResponseEventName(responseIndex), {\r\n status: 'success',\r\n httpStatus: 200,\r\n result: true,\r\n });\r\n }\r\n\r\n if (shouldLogDev()) {\r\n getLogger().debug(`api: ${resolvedName} called`);\r\n }\r\n\r\n const { apisObject, functionsObject } = await getRuntimeApiMaps();\r\n\r\n if (!apisObject[resolvedName]) {\r\n return emitApiError({\r\n response: {\r\n status: 'error',\r\n errorCode: 'api.notFound',\r\n errorParams: [{ key: 'name', value: name }],\r\n },\r\n fallbackHttpStatus: 404,\r\n });\r\n }\r\n\r\n const apiEntry = apisObject[resolvedName] as RuntimeApiEntry;\r\n currentPerRouteFormatter = apiEntry.errorFormatter;\r\n\r\n //? B1 — per-request AbortController. Aborts on `apiCancel { responseIndex }`\r\n //? from the originator OR socket disconnect. Cleanup runs in every exit\r\n //? path (errors, validation rejects, completion) to remove the disconnect\r\n //? listener and drop the cancel-registry entry. The signal is also handed\r\n //? to `emitStream` so chunks queued after an abort never hit the wire.\r\n const abortController = new AbortController();\r\n const abortKey = registerApiAbortController(socket.id, responseIndex, abortController);\r\n const onSocketDisconnect = () => { abortController.abort(); };\r\n socket.once(socketEventNames.disconnect, onSocketDisconnect);\r\n let cleanupDone = false;\r\n const cleanupRequest = () => {\r\n if (cleanupDone) return;\r\n cleanupDone = true;\r\n socket.off(socketEventNames.disconnect, onSocketDisconnect);\r\n unregisterApiAbortController(abortKey);\r\n };\r\n\r\n const emitStream = (payload: ApiStreamPayload = {}) => {\r\n if (abortController.signal.aborted) {\r\n if (shouldLogStream()) {\r\n getLogger().debug(`api: ${resolvedName} stream skipped — request aborted`);\r\n }\r\n return;\r\n }\r\n if (shouldLogStream()) {\r\n getLogger().debug(`api: ${resolvedName} stream`, { payload });\r\n }\r\n socket.emit(buildApiStreamEventName(responseIndex), payload);\r\n };\r\n\r\n //? B2 — backpressure helper for API handlers. Same shape as the sync\r\n //? variant but always scoped to a single originator socket. Polls the\r\n //? engine.io writeBuffer length every 10ms until the buffer drains below\r\n //? threshold (default 1 MB ≈ 1024 packets at ~1KB each).\r\n const apiFlushPressure: ApiFlushPressure = async ({ thresholdBytes } = {}) => {\r\n if (abortController.signal.aborted) return;\r\n const effectiveThresholdBytes = typeof thresholdBytes === 'number' && thresholdBytes > 0\r\n ? thresholdBytes\r\n : 1_048_576;\r\n const packetThreshold = Math.max(1, Math.ceil(effectiveThresholdBytes / 1024));\r\n interface EngineIoConnLike {\r\n writeBuffer?: { length: number };\r\n transport?: { writable?: boolean };\r\n }\r\n // eslint-disable-next-line no-constant-condition\r\n while (true) {\r\n if (abortController.signal.aborted) return;\r\n const conn = (socket as unknown as { conn?: EngineIoConnLike }).conn;\r\n const packets = conn?.writeBuffer?.length ?? 0;\r\n const writable = conn?.transport?.writable ?? true;\r\n if (!writable) return;\r\n if (packets < packetThreshold) return;\r\n await new Promise<void>((resolve) => setTimeout(resolve, 10));\r\n }\r\n };\r\n\r\n //? Auth → rate-limit → validate → execute → respond.\r\n //? Auth runs before validate so unauthenticated probes can't enumerate\r\n //? routes or learn input shape from `inputValidation.message`.\r\n if (!checkApiAuth({ apiEntry, user, name, emitApiError })) { cleanupRequest(); return; }\r\n\r\n const rateLimitOk = await applyApiRateLimits({ apiEntry, resolvedName, token, socket, user, emitApiError });\r\n if (!rateLimitOk) { cleanupRequest(); return; }\r\n\r\n //? Per-route validation toggle. `'relaxed'` or `{ input: 'skip' }` skips\r\n //? runtime input validation entirely — useful for public webhooks (Stripe,\r\n //? Slack, GitHub) where the third party's payload shape isn't reasonable\r\n //? to model in TypeScript. Default `'strict'`.\r\n const validationMode = (() => {\r\n const v = apiEntry.validation;\r\n if (!v) return 'strict';\r\n if (typeof v === 'string') return v;\r\n return v.input === 'skip' ? 'relaxed' : 'strict';\r\n })();\r\n\r\n if (validationMode === 'strict') {\r\n warnIfInputTypeMissing(resolvedName, apiEntry.inputType);\r\n }\r\n await dispatchHook('preApiValidate', { routeName: resolvedName, data: normalizedData, user, transport: 'socket' });\r\n\r\n if (validationMode === 'strict') {\r\n const inputValidation = await validateInputByType({\r\n typeText: apiEntry.inputType,\r\n value: normalizedData,\r\n rootKey: 'data',\r\n filePath: apiEntry.inputTypeFilePath,\r\n });\r\n\r\n await dispatchHook('postApiValidate', {\r\n routeName: resolvedName,\r\n data: normalizedData,\r\n user,\r\n validation: inputValidation,\r\n transport: 'socket',\r\n });\r\n\r\n if (inputValidation.status === 'error') {\r\n cleanupRequest();\r\n return emitApiError({\r\n response: {\r\n status: 'error',\r\n errorCode: 'api.invalidInputType',\r\n errorParams: [{ key: 'message', value: inputValidation.message }],\r\n },\r\n fallbackHttpStatus: 400,\r\n });\r\n }\r\n } else {\r\n //? Relaxed: surface the skip via postApiValidate so audit handlers see it.\r\n await dispatchHook('postApiValidate', {\r\n routeName: resolvedName,\r\n data: normalizedData,\r\n user,\r\n validation: { status: 'success' },\r\n transport: 'socket',\r\n });\r\n }\r\n\r\n //? Single payload reference reused by pre/post — auto-instrumentation\r\n //? subscribers in `@luckystack/error-tracking` pin spans via WeakMap on\r\n //? this object. Mutating `result/error/durationMs` is safe because\r\n //? handlers observe by-name (not by snapshot).\r\n const executePayload: PostApiExecutePayload = {\r\n routeName: resolvedName,\r\n data: normalizedData,\r\n user,\r\n transport: 'socket',\r\n result: undefined,\r\n error: null,\r\n durationMs: 0,\r\n };\r\n const preExecuteResult = await dispatchHook('preApiExecute', executePayload);\r\n if (preExecuteResult.stopped) {\r\n cleanupRequest();\r\n return emitApiError({\r\n response: { status: 'error', errorCode: preExecuteResult.signal.errorCode },\r\n fallbackHttpStatus: preExecuteResult.signal.httpStatus ?? 403,\r\n });\r\n }\r\n\r\n const { error, result, durationMs } = await executeApiHandler({\r\n apiEntry,\r\n normalizedData,\r\n user,\r\n functionsObject,\r\n resolvedName,\r\n emitStream,\r\n abortSignal: abortController.signal,\r\n flushPressure: apiFlushPressure,\r\n });\r\n\r\n executePayload.result = result;\r\n executePayload.error = error ?? null;\r\n executePayload.durationMs = durationMs;\r\n await dispatchHook('postApiExecute', executePayload);\r\n\r\n await emitApiResult({ socket, responseIndex, resolvedName, error, result, preferredLocale, user, perRouteFormatter: apiEntry.errorFormatter });\r\n cleanupRequest();\r\n}\r\n","/* eslint-disable @typescript-eslint/no-unnecessary-condition, @typescript-eslint/restrict-template-expressions, @typescript-eslint/prefer-nullish-coalescing */\r\n\r\nimport { getSession } from '@luckystack/login';\r\nimport type { BaseSessionLayout as SessionLayout, AuthProps } from '@luckystack/login';\r\nimport type { PostApiExecutePayload, ErrorFormatter } from '@luckystack/core';\r\nimport { getProjectConfig, getRuntimeApiMaps as getRuntimeApiMapsFromSource ,\r\n validateRequest,\r\n checkRateLimit,\r\n inferHttpMethod,\r\n HttpMethod,\r\n tryCatch,\r\n parseTransportRouteName,\r\n validateInputByType,\r\n dispatchHook,\r\n getLogger, defaultHttpStatusForResponse, extractLanguageFromHeader, normalizeErrorResponse, applyErrorFormatter } from '@luckystack/core';\r\n\r\n\r\n\r\n\r\n\r\n\r\n/**\r\n * HTTP API Request Handler\r\n * \r\n * Handles API requests coming via HTTP (instead of WebSocket).\r\n * Reuses existing API handlers but returns results as HTTP response.\r\n * \r\n * Payload format:\r\n * ```json\r\n * {\r\n * \"name\": \"api/examples/publicApi\",\r\n * \"data\": { \"message\": \"hello\" }\r\n * }\r\n * ```\r\n * \r\n * @example\r\n * ```typescript\r\n * // In server.ts\r\n * if (pathname.startsWith('/api/')) {\r\n * const token = extractTokenFromRequest(req);\r\n * const body = await parseJsonBody(req);\r\n * const result = await handleHttpApiRequest({ name: body.name, data: body.data, token });\r\n * res.end(JSON.stringify(result));\r\n * }\r\n * ```\r\n */\r\n\r\ninterface HttpApiRequestParams {\r\n name: string;\r\n data: Record<string, unknown>;\r\n token: string | null;\r\n requesterIp?: string;\r\n xLanguageHeader?: string | string[];\r\n acceptLanguageHeader?: string | string[];\r\n /** HTTP method from the request */\r\n method?: HttpMethod;\r\n stream?: (payload: ApiHttpStreamEvent) => void;\r\n /**\r\n * Optional AbortSignal. The HTTP server (`@luckystack/server`) wires this\r\n * to `req.on('close', ...)` so a closed SSE/HTTP connection aborts in-flight\r\n * stream emits. Handlers receive it as `abortSignal` in params.\r\n */\r\n abortSignal?: AbortSignal;\r\n}\r\n\r\ntype ApiNetworkResponse<T = Record<string, unknown>> =\r\n | ({ status: 'success'; httpStatus: number } & T)\r\n | {\r\n status: 'error';\r\n httpStatus: number;\r\n message: string;\r\n errorCode: string;\r\n errorParams?: {\r\n key: string;\r\n value: string | number | boolean;\r\n }[];\r\n };\r\n\r\ntype ApiStreamPayload = Record<string, unknown>;\r\n\r\n//? B2 — backpressure helper for HTTP API handlers. SSE has no socket\r\n//? write-buffer, so this is a no-op for HTTP transport — but the shape\r\n//? matches the socket variant so handlers don't branch by transport.\r\nexport interface HttpApiFlushPressureOptions {\r\n thresholdBytes?: number;\r\n}\r\n\r\n//? No-op shared at module scope so per-request allocation stays cheap and\r\n//? the consistent-function-scoping lint rule doesn't trip.\r\nconst httpApiFlushPressureNoop: HttpApiFlushPressure = async () => {\r\n /* SSE backpressure is the caller's responsibility (res.write returns bool) */\r\n};\r\nexport type HttpApiFlushPressure = (options?: HttpApiFlushPressureOptions) => Promise<void>;\r\n\r\ninterface RuntimeApiRoute {\r\n auth: AuthProps;\r\n main: (params: {\r\n data: Record<string, unknown>;\r\n user: SessionLayout | null;\r\n functions: Record<string, unknown>;\r\n stream: (payload?: ApiStreamPayload) => void;\r\n abortSignal: AbortSignal;\r\n flushPressure: HttpApiFlushPressure;\r\n }) => Promise<RuntimeApiResult> | RuntimeApiResult;\r\n inputType?: string;\r\n inputTypeFilePath?: string;\r\n rateLimit?: number | false;\r\n httpMethod?: HttpMethod;\r\n validation?: 'strict' | 'relaxed' | { input: 'skip' | 'strict' };\r\n errorFormatter?: ErrorFormatter;\r\n}\r\n\r\ntype RuntimeApiResult =\r\n | {\r\n status: 'success';\r\n httpStatus?: number;\r\n message?: string;\r\n [key: string]: unknown;\r\n }\r\n | {\r\n status: 'error';\r\n httpStatus?: number;\r\n errorCode?: string;\r\n errorParams?: { key: string; value: string | number | boolean }[];\r\n message?: string;\r\n [key: string]: unknown;\r\n };\r\n\r\nconst isRuntimeApiResult = (value: unknown): value is RuntimeApiResult => {\r\n if (!value || typeof value !== 'object') {\r\n return false;\r\n }\r\n\r\n const status = (value as { status?: unknown }).status;\r\n return status === 'success' || status === 'error';\r\n};\r\n\r\nconst shouldLogDev = () => getProjectConfig().logging.devLogs;\r\nconst shouldLogStream = () => getProjectConfig().logging.stream;\r\n\r\nconst warnedMissingInputType = new Set<string>();\r\nconst warnIfInputTypeMissing = (resolvedName: string, inputType: string | undefined): void => {\r\n if (!getProjectConfig().dev.warnOnMissingInputType) return;\r\n if (inputType && inputType.trim().length > 0 && inputType.trim() !== 'any') return;\r\n if (warnedMissingInputType.has(resolvedName)) return;\r\n warnedMissingInputType.add(resolvedName);\r\n getLogger().warn(`http-api: route ${resolvedName} has no inputType — runtime input validation is disabled.`);\r\n};\r\n\r\nexport type ApiHttpStreamEvent = ApiStreamPayload;\r\n\r\nexport async function handleHttpApiRequest(params: HttpApiRequestParams): Promise<ApiNetworkResponse> {\r\n const { response, user, preferredLocale, perRouteFormatter } = await runHandleHttpApiRequest(params);\r\n\r\n //? `name` may not parse to a valid route — pass the original raw name as\r\n //? routeName so hook handlers can still correlate the rejection.\r\n const routeNameForHook = params.name.startsWith('api/') ? params.name : `api/${params.name}`;\r\n\r\n const preRespond = { routeName: routeNameForHook, user, response: response as { status: 'success' | 'error'; httpStatus?: number; [key: string]: unknown } };\r\n const preRespondResult = await dispatchHook('preApiRespond', preRespond);\r\n\r\n //? Honor a stop signal from `preApiRespond`: rewrite the envelope into a\r\n //? localized error response per the signal's errorCode/httpStatus instead\r\n //? of letting the original through. Same contract as the socket variant.\r\n const finalResponse = preRespondResult.stopped\r\n ? normalizeErrorResponse({\r\n response: { status: 'error', errorCode: preRespondResult.signal.errorCode },\r\n preferredLocale,\r\n userLanguage: user?.language,\r\n fallbackHttpStatus: preRespondResult.signal.httpStatus ?? 403,\r\n }) as ApiNetworkResponse\r\n : preRespond.response as ApiNetworkResponse;\r\n\r\n //? Per-route → global → identity error formatter chain. Mirrors the socket\r\n //? handler so HTTP + WebSocket responses honor the same `errorFormatter`\r\n //? export on each route. No-op on success envelopes.\r\n //? `ApiNetworkResponse` is a discriminated union; `applyErrorFormatter`\r\n //? accepts the wider `Record<string, unknown> & { status?: string }` shape\r\n //? that union doesn't structurally satisfy. The double-cast is the\r\n //? documented framework boundary between the union and the formatter input.\r\n // eslint-disable-next-line no-restricted-syntax -- formatter boundary cast\r\n const formatterInput = finalResponse as unknown as Record<string, unknown> & { status?: string };\r\n const formattedResponse = applyErrorFormatter({\r\n response: formatterInput,\r\n routeName: routeNameForHook,\r\n transport: 'http',\r\n userId: user?.id,\r\n perRouteFormatter,\r\n }) as ApiNetworkResponse;\r\n\r\n await dispatchHook('postApiRespond', { routeName: routeNameForHook, user, response: formattedResponse });\r\n\r\n return formattedResponse;\r\n}\r\n\r\ninterface RunHandleHttpApiRequestResult {\r\n response: ApiNetworkResponse;\r\n user: SessionLayout | null;\r\n preferredLocale: string | null | undefined;\r\n perRouteFormatter?: ErrorFormatter;\r\n}\r\n\r\nasync function runHandleHttpApiRequest(params: HttpApiRequestParams): Promise<RunHandleHttpApiRequestResult> {\r\n const preferredLocale =\r\n extractLanguageFromHeader(params.xLanguageHeader)\r\n ?? extractLanguageFromHeader(params.acceptLanguageHeader);\r\n const user = await getSession(params.token);\r\n const formatterHolder: { current?: ErrorFormatter } = {};\r\n const response = await runHandleHttpApiRequestInner(params, user, preferredLocale, formatterHolder);\r\n return { response, user, preferredLocale, perRouteFormatter: formatterHolder.current };\r\n}\r\n\r\nasync function runHandleHttpApiRequestInner(\r\n {\r\n name,\r\n data,\r\n token,\r\n requesterIp,\r\n xLanguageHeader: _xLanguageHeader,\r\n acceptLanguageHeader: _acceptLanguageHeader,\r\n method = 'POST',\r\n stream,\r\n abortSignal,\r\n }: HttpApiRequestParams,\r\n user: SessionLayout | null,\r\n preferredLocale: string | null | undefined,\r\n formatterHolder: { current?: ErrorFormatter },\r\n): Promise<ApiNetworkResponse> {\r\n //? B1 — HTTP/SSE transport. The caller (typically `@luckystack/server`'s\r\n //? SSE bridge) wires `req.on('close', ...)` to a controller and passes its\r\n //? signal in. If no signal was provided we synthesize a never-aborting one\r\n //? so the handler param shape stays consistent.\r\n const effectiveAbortSignal = abortSignal ?? new AbortController().signal;\r\n //? SSE has no socket write-buffer to measure — `flushPressure` is a no-op\r\n //? on HTTP transport. Kept for handler-shape parity with the socket path.\r\n const flushPressure = httpApiFlushPressureNoop;\r\n\r\n const normalizedName = name.startsWith('api/') ? name : `api/${name}`;\r\n //? Identity propagation now flows via the `preApiExecute` hook subscriber\r\n //? registered by `@luckystack/error-tracking`'s `enableErrorTrackingAutoInstrumentation()`.\r\n //? Direct `setSentryUser` removed from this handler — see migration doc.\r\n\r\n const buildNetworkError = ({\r\n response,\r\n fallbackHttpStatus,\r\n }: {\r\n response: { status: 'error'; httpStatus?: number; errorCode?: string; errorParams?: { key: string; value: string | number | boolean; }[] };\r\n fallbackHttpStatus?: number;\r\n }): ApiNetworkResponse => {\r\n return normalizeErrorResponse({\r\n response,\r\n preferredLocale,\r\n userLanguage: user?.language,\r\n fallbackHttpStatus,\r\n }) as ApiNetworkResponse;\r\n };\r\n\r\n // Validate request format\r\n if (!name || typeof name !== 'string') {\r\n return buildNetworkError({\r\n response: { status: 'error', errorCode: 'api.invalidName' },\r\n fallbackHttpStatus: 400,\r\n });\r\n }\r\n\r\n if (typeof data !== 'object' || data === null) {\r\n return buildNetworkError({\r\n response: { status: 'error', errorCode: 'api.invalidDataObject' },\r\n fallbackHttpStatus: 400,\r\n });\r\n }\r\n\r\n const requestData = data ?? {};\r\n\r\n const parsedRoute = parseTransportRouteName({ value: normalizedName, prefix: 'api' });\r\n if (parsedRoute.status === 'error') {\r\n return buildNetworkError({\r\n response: {\r\n status: 'error',\r\n errorCode: 'routing.invalidServiceRouteName',\r\n errorParams: [{ key: 'name', value: normalizedName }],\r\n },\r\n fallbackHttpStatus: 400,\r\n });\r\n }\r\n\r\n const resolvedName = parsedRoute.normalizedFullName;\r\n\r\n if (shouldLogDev()) {\r\n getLogger().debug(`http api: ${resolvedName} called`);\r\n }\r\n\r\n const { apisObject, functionsObject } = await getRuntimeApiMapsFromSource();\r\n\r\n // Check if API exists\r\n if (!apisObject[resolvedName]) {\r\n return buildNetworkError({\r\n response: {\r\n status: 'error',\r\n errorCode: 'api.notFound',\r\n errorParams: [{ key: 'name', value: resolvedName }],\r\n },\r\n fallbackHttpStatus: 404,\r\n });\r\n }\r\n\r\n const runtimeApiRoute = apisObject[resolvedName] as RuntimeApiRoute;\r\n formatterHolder.current = runtimeApiRoute.errorFormatter;\r\n const { auth, main, httpMethod: declaredMethod } = runtimeApiRoute;\r\n const inputType = runtimeApiRoute.inputType;\r\n const inputTypeFilePath = runtimeApiRoute.inputTypeFilePath;\r\n\r\n //? Pipeline order: auth → rate-limit → method → validate → execute → respond.\r\n //? Auth runs first so unauthenticated probes can't enumerate routes or\r\n //? learn input shape from `inputValidation.message` / method-mismatch params.\r\n\r\n // Auth validation: check login requirement\r\n if (auth.login && !user?.id) {\r\n if (shouldLogDev()) {\r\n getLogger().warn(`http-api: ${name} requires login`, { route: name, transport: 'http' });\r\n }\r\n return buildNetworkError({\r\n response: { status: 'error', errorCode: 'auth.required' },\r\n fallbackHttpStatus: 401,\r\n });\r\n }\r\n\r\n //? NO bare `if (!user) -> auth.forbidden` here. Public routes\r\n //? (auth.login: false) must be callable without a session — exactly like the\r\n //? socket API handler (handleApiRequest) and both sync handlers.\r\n //? `validateRequest` is the single source of truth for `additional` auth\r\n //? predicates. (This handler previously forbade ALL anonymous HTTP calls,\r\n //? diverging from the other three transports and making public routes\r\n //? unreachable over HTTP — e.g. the test-runner contract/rate-limit sweeps.)\r\n //? `user` may be null on a public route; validateRequest tolerates it (and\r\n //? only the login/additional predicates read it). Same `user!` shape the\r\n //? socket API handler and both sync handlers use.\r\n // eslint-disable-next-line @typescript-eslint/no-non-null-assertion\r\n const authResult = validateRequest({ auth, user: user! });\r\n if (authResult.status === 'error') {\r\n if (shouldLogDev()) {\r\n getLogger().warn(`http-api: auth failed for ${name}`, { route: name, errorCode: authResult.errorCode, transport: 'http' });\r\n }\r\n return buildNetworkError({\r\n response: {\r\n status: 'error',\r\n errorCode: authResult.errorCode ?? 'auth.forbidden',\r\n errorParams: authResult.errorParams,\r\n httpStatus: authResult.httpStatus,\r\n },\r\n fallbackHttpStatus: authResult.httpStatus ?? 403,\r\n });\r\n }\r\n\r\n // Rate limiting check: per-API bucket (custom rateLimit or defaultApiLimit fallback)\r\n const apiRateLimit = runtimeApiRoute.rateLimit;\r\n const effectiveApiLimit = apiRateLimit === undefined\r\n ? getProjectConfig().rateLimiting.defaultApiLimit\r\n : apiRateLimit;\r\n\r\n if (effectiveApiLimit !== false && effectiveApiLimit > 0) {\r\n const requesterIdentity = token ?? requesterIp ?? 'anonymous';\r\n const keyPrefix = token ? 'token' : 'ip';\r\n const rateLimitKey = `${keyPrefix}:${requesterIdentity}:api:${resolvedName}`;\r\n\r\n const { allowed, resetIn } = await checkRateLimit({\r\n key: rateLimitKey,\r\n limit: effectiveApiLimit,\r\n windowMs: getProjectConfig().rateLimiting.windowMs\r\n });\r\n\r\n if (!allowed) {\r\n void dispatchHook('rateLimitExceeded', {\r\n scope: token ? 'user' : 'route',\r\n key: rateLimitKey,\r\n limit: effectiveApiLimit,\r\n windowMs: getProjectConfig().rateLimiting.windowMs,\r\n count: effectiveApiLimit + 1,\r\n route: resolvedName,\r\n userId: user?.id,\r\n });\r\n if (shouldLogDev()) {\r\n getLogger().warn(`http api: rate limit exceeded for ${resolvedName}`);\r\n }\r\n return buildNetworkError({\r\n response: {\r\n status: 'error',\r\n errorCode: 'api.rateLimitExceeded',\r\n errorParams: [{ key: 'seconds', value: resetIn }],\r\n },\r\n fallbackHttpStatus: 429,\r\n });\r\n }\r\n }\r\n\r\n // Global per-IP bucket across all APIs\r\n const defaultIpLimit = getProjectConfig().rateLimiting.defaultIpLimit;\r\n //? Skip the global per-IP ABUSE limit for loopback traffic in non-production.\r\n //? A developer (and the test suite) hammering localhost should not rate-limit\r\n //? itself — this keeps the test runner scalable to any number of cases. Note\r\n //? this only skips the cross-route `:api:all` bucket; PER-ROUTE limits still\r\n //? apply, and production (NODE_ENV=production) is unaffected.\r\n const requesterIsLoopback = process.env.NODE_ENV !== 'production'\r\n && (requesterIp === '127.0.0.1' || requesterIp === '::1' || requesterIp === '::ffff:127.0.0.1'\r\n || (typeof requesterIp === 'string' && requesterIp.startsWith('127.')));\r\n if (!requesterIsLoopback && defaultIpLimit !== false && defaultIpLimit > 0) {\r\n const ipBucket = requesterIp ?? 'unknown';\r\n const { allowed, resetIn } = await checkRateLimit({\r\n key: `ip:${ipBucket}:api:all`,\r\n limit: defaultIpLimit,\r\n windowMs: getProjectConfig().rateLimiting.windowMs\r\n });\r\n\r\n if (!allowed) {\r\n void dispatchHook('rateLimitExceeded', {\r\n scope: 'ip',\r\n key: `ip:${ipBucket}:api:all`,\r\n limit: defaultIpLimit,\r\n windowMs: getProjectConfig().rateLimiting.windowMs,\r\n count: defaultIpLimit + 1,\r\n ip: ipBucket,\r\n });\r\n if (shouldLogDev()) {\r\n getLogger().warn(`http api: global IP rate limit exceeded`, { ip: ipBucket });\r\n }\r\n return buildNetworkError({\r\n response: {\r\n status: 'error',\r\n errorCode: 'api.rateLimitExceeded',\r\n errorParams: [{ key: 'seconds', value: resetIn }],\r\n },\r\n fallbackHttpStatus: 429,\r\n });\r\n }\r\n }\r\n\r\n // HTTP method validation (post-auth so unauthenticated probes don't learn the route exists)\r\n const expectedMethod = declaredMethod ?? inferHttpMethod(resolvedName);\r\n if (method !== expectedMethod) {\r\n if (shouldLogDev()) {\r\n getLogger().warn(`http api: method mismatch for ${resolvedName}`, { expected: expectedMethod, got: method });\r\n }\r\n return buildNetworkError({\r\n response: {\r\n status: 'error',\r\n errorCode: 'api.methodNotAllowed',\r\n errorParams: [{ key: 'method', value: expectedMethod }],\r\n },\r\n fallbackHttpStatus: 405,\r\n });\r\n }\r\n\r\n // Input-type validation (post-auth so unauthenticated probes don't get input-shape leaks)\r\n warnIfInputTypeMissing(resolvedName, inputType);\r\n await dispatchHook('preApiValidate', { routeName: resolvedName, data: requestData, user, transport: 'http' });\r\n\r\n const inputValidation = await validateInputByType({\r\n typeText: inputType,\r\n value: requestData,\r\n rootKey: 'data',\r\n filePath: inputTypeFilePath,\r\n });\r\n\r\n await dispatchHook('postApiValidate', {\r\n routeName: resolvedName,\r\n data: requestData,\r\n user,\r\n validation: inputValidation,\r\n transport: 'http',\r\n });\r\n\r\n if (inputValidation.status === 'error') {\r\n return buildNetworkError({\r\n response: {\r\n status: 'error',\r\n errorCode: 'api.invalidInputType',\r\n errorParams: [{ key: 'message', value: inputValidation.message }],\r\n },\r\n fallbackHttpStatus: 400,\r\n });\r\n }\r\n\r\n // Execute the API handler\r\n const emitApiStream = (payload: ApiStreamPayload = {}) => {\r\n if (!stream) {\r\n return;\r\n }\r\n if (effectiveAbortSignal.aborted) {\r\n if (shouldLogStream()) {\r\n getLogger().debug(`http api: ${resolvedName} stream skipped — request aborted`);\r\n }\r\n return;\r\n }\r\n\r\n if (shouldLogStream()) {\r\n getLogger().debug(`http api: ${resolvedName} stream`, { payload });\r\n }\r\n\r\n stream(payload);\r\n };\r\n\r\n //? Single payload reference reused by pre/post — auto-instrumentation\r\n //? subscribers in `@luckystack/error-tracking` pin spans via WeakMap on\r\n //? this object. Mutating `result/error/durationMs` is safe because\r\n //? handlers observe by-name (not by snapshot).\r\n const executePayload: PostApiExecutePayload = {\r\n routeName: resolvedName,\r\n data: requestData,\r\n user,\r\n transport: 'http',\r\n result: undefined,\r\n error: null,\r\n durationMs: 0,\r\n };\r\n const preExecuteResult = await dispatchHook('preApiExecute', executePayload);\r\n if (preExecuteResult.stopped) {\r\n return buildNetworkError({\r\n response: { status: 'error', errorCode: preExecuteResult.signal.errorCode },\r\n fallbackHttpStatus: preExecuteResult.signal.httpStatus ?? 403,\r\n });\r\n }\r\n\r\n const executeStart = Date.now();\r\n const [error, result] = await tryCatch(\r\n async () => await main({\r\n data: requestData,\r\n user,\r\n functions: functionsObject,\r\n stream: emitApiStream,\r\n abortSignal: effectiveAbortSignal,\r\n flushPressure,\r\n }),\r\n undefined,\r\n {\r\n handler: 'handleHttpApiRequest',\r\n api: resolvedName,\r\n userId: user?.id,\r\n transport: 'http',\r\n },\r\n );\r\n\r\n executePayload.result = result;\r\n executePayload.error = error ?? null;\r\n executePayload.durationMs = Date.now() - executeStart;\r\n await dispatchHook('postApiExecute', executePayload);\r\n\r\n if (error) {\r\n if (shouldLogDev()) {\r\n getLogger().error(`http-api: error in ${resolvedName}`, error, { route: resolvedName, transport: 'http' });\r\n }\r\n return buildNetworkError({\r\n response: { status: 'error', errorCode: 'api.internalServerError' },\r\n fallbackHttpStatus: 500,\r\n });\r\n }\r\n\r\n if (result !== undefined && result !== null) {\r\n if (shouldLogDev()) {\r\n getLogger().debug(`http api: ${resolvedName} completed`);\r\n }\r\n\r\n // Check if result is already formatted as ApiResponse\r\n if (isRuntimeApiResult(result)) {\r\n if (result.status === 'error') {\r\n return buildNetworkError({\r\n response: result,\r\n fallbackHttpStatus: defaultHttpStatusForResponse({\r\n status: 'error',\r\n explicitHttpStatus: typeof result.httpStatus === 'number' ? result.httpStatus : undefined,\r\n }),\r\n });\r\n }\r\n\r\n const successEnvelope: ApiNetworkResponse = {\r\n ...result,\r\n status: 'success',\r\n httpStatus: defaultHttpStatusForResponse({\r\n status: 'success',\r\n explicitHttpStatus: typeof result.httpStatus === 'number' ? result.httpStatus : undefined,\r\n }),\r\n };\r\n return successEnvelope;\r\n }\r\n\r\n return buildNetworkError({\r\n response: { status: 'error', errorCode: 'api.invalidResponseStatus' },\r\n fallbackHttpStatus: 500,\r\n });\r\n }\r\n\r\n if (shouldLogDev()) {\r\n getLogger().warn(`http api: ${resolvedName} returned nothing`);\r\n }\r\n return buildNetworkError({\r\n response: { status: 'error', errorCode: 'api.emptyResponse' },\r\n fallbackHttpStatus: 500,\r\n });\r\n}\r\n"],"mappings":"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","names":["getSession","getProjectConfig","validateRequest","checkRateLimit","tryCatch","parseTransportRouteName","validateInputByType","dispatchHook","getLogger","defaultHttpStatusForResponse","extractLanguageFromHeader","normalizeErrorResponse","applyErrorFormatter","shouldLogDev","shouldLogStream","warnedMissingInputType","warnIfInputTypeMissing"]}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
# API Request Lifecycle
|
|
2
|
+
|
|
3
|
+
> Last updated: 2026-05-20
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
`@luckystack/api` exposes two transport adapters — `handleApiRequest` (socket.io) and `handleHttpApiRequest` (raw HTTP) — that share an identical 13-step pipeline. The pipeline takes a transport payload, resolves it to a registered route in the runtime maps emitted by `@luckystack/devkit`, runs authentication / rate-limit / input-validation gates, dispatches a fixed set of lifecycle hooks, executes the route's `main(...)` function under a `tryCatch` boundary, then normalizes the result into a localized envelope before emitting it on the wire.
|
|
8
|
+
|
|
9
|
+
Both transports are pure functions over the runtime maps + session state. Neither one mutates module state or constructs the io server — the socket and HTTP listeners in `@luckystack/server` import these handlers and feed them messages. Consumers building a third transport (queue worker, gRPC bridge, Lambda adapter) should call the same handlers with a synthetic `Socket` or with the HTTP signature so they get the identical contract.
|
|
10
|
+
|
|
11
|
+
The full ordering is authoritative. Deviating in a fork breaks every downstream consumer that relies on the hook payloads firing in a known order (audit handlers, rate-limit dashboards, response-mutating middleware).
|
|
12
|
+
|
|
13
|
+
## Pipeline order (13 steps)
|
|
14
|
+
|
|
15
|
+
Both transports execute the same sequence. The HTTP transport adds one extra step (method check).
|
|
16
|
+
|
|
17
|
+
1. **Parse message / payload shape.** Socket transport calls `validateApiMessage` to confirm `responseIndex` is present and `name` / `data` are well-typed. HTTP transport inline-checks `name` (string) and `data` (object); rejects with `api.invalidName` / `api.invalidDataObject` if either is malformed.
|
|
18
|
+
2. **`getSession(token)` + `setSentryUser(...)`.** Reads the session from `@luckystack/login`, identifies the user to every registered error tracker via `setSentryUser`. Preferred locale is also extracted here from `x-language` / `accept-language` headers using `extractLanguageFromHeader`.
|
|
19
|
+
3. **`parseTransportRouteName({ value, prefix: 'api' })`.** Rejects names that are not of the form `api/<page>/<name>/v<N>`. Returns the normalized name used downstream as `resolvedName`. Bad shapes return `routing.invalidServiceRouteName` with the original `name` echoed back as `errorParams[0].value`.
|
|
20
|
+
4. **Built-in `system/logout` shortcut (socket only).** When the parsed route normalizes to `system/logout`, the handler calls `logout({ token, socket, userId })` from `@luckystack/login` and short-circuits with a `{ status: 'success', httpStatus: 200, result: true }` envelope. HTTP transport does not have this shortcut — logout over HTTP goes through the normal route execution.
|
|
21
|
+
5. **`getRuntimeApiMaps()` + route lookup.** Reads the registered `apisObject` / `functionsObject`. Missing routes return `api.notFound` with `404`.
|
|
22
|
+
6. **`checkApiAuth({ apiEntry, user, name })`.** Two-stage auth gate: first checks `auth.login` (returns `auth.required` + `401` if missing), then runs `validateRequest({ auth, user })` which evaluates `auth.additional[]` predicates. Failures return `auth.forbidden` (or the predicate's overridden `errorCode`) with the predicate's `httpStatus` or `403` fallback. See [`./auth-flow.md`](./auth-flow.md).
|
|
23
|
+
7. **`applyApiRateLimits(...)`.** Two-bucket rate limit: per-route (uses the route's `rateLimit` export, or `rateLimiting.defaultApiLimit` fallback) keyed by `<token|ip>:<id>:api:<route>`, then a global per-IP bucket keyed by `ip:<ip>:api:all` (uses `rateLimiting.defaultIpLimit`). Either rejection emits `rateLimitExceeded` and returns `api.rateLimitExceeded` + `429`. See [`./rate-limiting.md`](./rate-limiting.md).
|
|
24
|
+
8. **HTTP-only: method check.** `expectedMethod = apiEntry.httpMethod ?? inferHttpMethod(resolvedName)`. Mismatched methods return `api.methodNotAllowed` + `405` with `errorParams: [{ key: 'method', value: expectedMethod }]`. Method check runs after auth + rate limit, on purpose — unauthenticated callers should not learn whether a method-incorrect route exists.
|
|
25
|
+
9. **`preApiValidate` hook → `validateInputByType(...)` → `postApiValidate` hook.** Hook fires before validation runs (handlers can mutate `data`, can NOT stop). `validateInputByType` checks the payload against the Zod-equivalent schema derived from the route's `inputType` text. On the socket transport only, the per-route `validation: 'relaxed'` or `validation: { input: 'skip' }` flag skips validation entirely (useful for third-party webhooks). Validation failures return `api.invalidInputType` with the validator's message echoed in `errorParams[0].value`. `postApiValidate` always fires with the resulting validation result, even on skip.
|
|
26
|
+
10. **`preApiExecute` hook.** Last gate before execution. A handler may return a `HookStopSignal` (`{ stop: true, errorCode, httpStatus? }`) to abort. Stop signals are normalized into a localized error envelope using the signal's `errorCode` (or `403` if no `httpStatus`).
|
|
27
|
+
11. **`executeApiHandler(...)`.** Wraps `apiEntry.main({ data, user, functions, stream })` in `tryCatch` (which captures any thrown error to Sentry via the active `@luckystack/error-tracking` adapter) and in a `startSpan(name, 'api.request')` (HTTP variant uses `'api.request.http'`). Records `durationMs`.
|
|
28
|
+
12. **`postApiExecute` hook.** Observation point. Receives `result`, `error`, and `durationMs`. Use for audit logging, metrics, or post-run side effects. Cannot mutate the response.
|
|
29
|
+
13. **`buildApiResponseEnvelope` → `preApiRespond` → `transformApiResponse` → emit → `postApiRespond`.** The envelope normalizer maps the raw handler result to a wire envelope, inferring `httpStatus` via `defaultHttpStatusForResponse`. `preApiRespond` lets handlers mutate `payload.response` in place or return a stop signal that converts to a localized error envelope. `transformApiResponse` is the dedicated mutation hook (PII redaction, response signing, schema injection) that fires after `preApiRespond` but before emit. Emit happens via `socket.emit(buildApiResponseEventName(responseIndex), ...)` (socket) or as the HTTP response body (HTTP). `postApiRespond` is observation-only and fires after emit.
|
|
30
|
+
|
|
31
|
+
## API Reference
|
|
32
|
+
|
|
33
|
+
### `handleApiRequest({ msg, socket, token })`
|
|
34
|
+
|
|
35
|
+
Socket.io transport adapter. Default export.
|
|
36
|
+
|
|
37
|
+
**Signature**
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
import handleApiRequest from '@luckystack/api';
|
|
41
|
+
import type { apiMessage } from '@luckystack/core';
|
|
42
|
+
import type { Socket } from 'socket.io';
|
|
43
|
+
|
|
44
|
+
export default async function handleApiRequest(args: {
|
|
45
|
+
msg: apiMessage;
|
|
46
|
+
socket: Socket;
|
|
47
|
+
token: string | null;
|
|
48
|
+
}): Promise<void>;
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
**Parameters**
|
|
52
|
+
|
|
53
|
+
| Field | Type | Notes |
|
|
54
|
+
| --- | --- | --- |
|
|
55
|
+
| `msg` | `apiMessage` | The raw transport envelope. Required fields: `name` (string, `api/<page>/<name>/v<N>`), `data` (object), `responseIndex` (number, used to build the response event name). |
|
|
56
|
+
| `socket` | `Socket` | Live socket.io socket. Used for `socket.emit`, `socket.handshake.headers`, and `socket.handshake.address` (for the global IP rate-limit bucket). |
|
|
57
|
+
| `token` | `string \| null` | Session token from the socket handshake (`extractTokenFromSocket`). Drives `getSession`, the per-route rate-limit bucket key, and the `system/logout` shortcut. |
|
|
58
|
+
|
|
59
|
+
**Returns**
|
|
60
|
+
|
|
61
|
+
`Promise<void>`. The response is delivered by `socket.emit(buildApiResponseEventName(responseIndex), envelope)`. The function resolves once `postApiRespond` has completed.
|
|
62
|
+
|
|
63
|
+
**Behavior highlights**
|
|
64
|
+
|
|
65
|
+
- Treats `responseIndex` as the response correlation token; clients listen on `buildApiResponseEventName(responseIndex)`.
|
|
66
|
+
- Reads `x-language` / `accept-language` from `socket.handshake.headers` for locale extraction.
|
|
67
|
+
- Honors a per-route `validation: 'relaxed'` / `{ input: 'skip' }` flag (HTTP transport does not — webhook-style endpoints typically live on HTTP and pre-date this flag).
|
|
68
|
+
- Built-in route: `system/logout` exits with a success envelope after calling `logout(...)` from `@luckystack/login`.
|
|
69
|
+
- Stream emission: routes that call `stream(payload)` from their `main(...)` cause `socket.emit(buildApiStreamEventName(responseIndex), payload)`. Stream payloads are typed as `Record<string, unknown>` at the transport layer; consumers narrow against the generated `ApiStream<P, N, V>` type.
|
|
70
|
+
|
|
71
|
+
**Errors / framework-emitted error codes**
|
|
72
|
+
|
|
73
|
+
| `errorCode` | When | HTTP status |
|
|
74
|
+
| --- | --- | --- |
|
|
75
|
+
| `api.invalidRequest` | `name` or `data` missing / wrong type. | 400 |
|
|
76
|
+
| `routing.invalidServiceRouteName` | `name` is not `api/<page>/<name>/v<N>`. | 400 |
|
|
77
|
+
| `api.notFound` | Route not registered in runtime maps. | 404 |
|
|
78
|
+
| `auth.required` | `auth.login` is `true` and `user?.id` is missing. | 401 |
|
|
79
|
+
| `auth.forbidden` | `auth.additional[]` predicate rejects. | 403 |
|
|
80
|
+
| `auth.invalidCondition` | `auth.additional[]` references a session key that is not on the session shape. Setup error. | 500 |
|
|
81
|
+
| `api.rateLimitExceeded` | Per-route or per-IP bucket rejects. `errorParams[0].value` is `resetIn` (seconds). | 429 |
|
|
82
|
+
| `api.invalidInputType` | `validateInputByType` rejects the payload. `errorParams[0].value` is the validator's message. | 400 |
|
|
83
|
+
| `api.internalServerError` | `main(...)` threw. | 500 |
|
|
84
|
+
| `api.emptyResponse` | `main(...)` returned `null` or `undefined`. | 500 |
|
|
85
|
+
| `api.invalidResponseStatus` | Handler returned an object whose `status` is neither `'success'` nor `'error'`. | 500 |
|
|
86
|
+
|
|
87
|
+
**Example**
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
import handleApiRequest from '@luckystack/api';
|
|
91
|
+
import { extractTokenFromSocket } from '@luckystack/core';
|
|
92
|
+
|
|
93
|
+
io.on('connection', (socket) => {
|
|
94
|
+
socket.on('apiRequest', (msg) => {
|
|
95
|
+
void handleApiRequest({
|
|
96
|
+
msg,
|
|
97
|
+
socket,
|
|
98
|
+
token: extractTokenFromSocket(socket),
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### `handleHttpApiRequest({ name, data, token, ... })`
|
|
105
|
+
|
|
106
|
+
HTTP transport adapter. Same pipeline as the socket variant plus HTTP-method validation.
|
|
107
|
+
|
|
108
|
+
**Signature**
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
import { handleHttpApiRequest } from '@luckystack/api';
|
|
112
|
+
import type { HttpMethod } from '@luckystack/core';
|
|
113
|
+
|
|
114
|
+
export type ApiHttpStreamEvent = Record<string, unknown>;
|
|
115
|
+
|
|
116
|
+
export async function handleHttpApiRequest(params: {
|
|
117
|
+
name: string;
|
|
118
|
+
data: Record<string, unknown>;
|
|
119
|
+
token: string | null;
|
|
120
|
+
requesterIp?: string;
|
|
121
|
+
xLanguageHeader?: string | string[];
|
|
122
|
+
acceptLanguageHeader?: string | string[];
|
|
123
|
+
method?: HttpMethod;
|
|
124
|
+
stream?: (payload: ApiHttpStreamEvent) => void;
|
|
125
|
+
}): Promise<ApiNetworkResponse>;
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
**Parameters**
|
|
129
|
+
|
|
130
|
+
| Field | Type | Notes |
|
|
131
|
+
| --- | --- | --- |
|
|
132
|
+
| `name` | `string` | Route name. May be passed with or without the `api/` prefix; the handler normalizes both. |
|
|
133
|
+
| `data` | `Record<string, unknown>` | Request payload. Must be an object (arrays, primitives rejected). |
|
|
134
|
+
| `token` | `string \| null` | Session token from cookie or `Authorization: Bearer`. |
|
|
135
|
+
| `requesterIp` | `string` (optional) | IP used by the global per-IP rate-limit bucket. Falls back to `'unknown'` when omitted; production callers should always pass it. |
|
|
136
|
+
| `xLanguageHeader` | `string \| string[]` (optional) | Forwarded to `extractLanguageFromHeader`. Takes precedence over `acceptLanguageHeader`. |
|
|
137
|
+
| `acceptLanguageHeader` | `string \| string[]` (optional) | Standard `Accept-Language` header. |
|
|
138
|
+
| `method` | `'GET' \| 'POST' \| 'PUT' \| 'DELETE'` (default `'POST'`) | HTTP method on the wire. Compared against the route's declared `httpMethod` or the inferred method from `inferHttpMethod`. |
|
|
139
|
+
| `stream` | `(payload) => void` (optional) | Callback invoked once per `stream(payload)` call from the route's `main`. The server's HTTP listener wraps this into SSE events (`event: stream`). Streaming endpoints emit a final `event: final` carrying the full envelope. |
|
|
140
|
+
|
|
141
|
+
**Returns**
|
|
142
|
+
|
|
143
|
+
`Promise<ApiNetworkResponse>` — exactly one of:
|
|
144
|
+
|
|
145
|
+
```ts
|
|
146
|
+
type ApiNetworkResponse =
|
|
147
|
+
| ({ status: 'success'; httpStatus: number } & Record<string, unknown>)
|
|
148
|
+
| {
|
|
149
|
+
status: 'error';
|
|
150
|
+
httpStatus: number;
|
|
151
|
+
message: string;
|
|
152
|
+
errorCode: string;
|
|
153
|
+
errorParams?: { key: string; value: string | number | boolean }[];
|
|
154
|
+
};
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
The `httpStatus` is guaranteed to be a number (the socket variant returns the envelope with `httpStatus` as a hint; the HTTP variant materializes it for the response writer to use).
|
|
158
|
+
|
|
159
|
+
**Behavior highlights**
|
|
160
|
+
|
|
161
|
+
- Same auth → rate-limit → validate → execute → respond order as the socket variant, with method check inserted between rate-limit and validate.
|
|
162
|
+
- Method inference (`inferHttpMethod`): names starting with `get*` / `fetch*` / `list*` → `GET`, `delete*` / `remove*` → `DELETE`, `update*` / `edit*` / `patch*` → `PUT`, everything else → `POST`. Per-route `httpMethod` export overrides the inference.
|
|
163
|
+
- Does NOT honor the per-route `validation: 'relaxed'` flag. Public webhooks that need looser validation must use the socket variant or model their inputs structurally.
|
|
164
|
+
- Streaming over SSE: the `stream` callback is invoked synchronously each time the route's `main` calls `stream(payload)`. The server-side SSE writer (in `@luckystack/server`) emits each callback as `event: stream\ndata: <json>` and the final envelope as `event: final\ndata: <json>`.
|
|
165
|
+
- The function dispatches `preApiRespond` and `postApiRespond` in `handleHttpApiRequest` itself (wrapping the inner runner), so the same response-mutation contract applies to both transports.
|
|
166
|
+
|
|
167
|
+
**Errors / framework-emitted error codes**
|
|
168
|
+
|
|
169
|
+
Same set as the socket variant, plus:
|
|
170
|
+
|
|
171
|
+
| `errorCode` | When | HTTP status |
|
|
172
|
+
| --- | --- | --- |
|
|
173
|
+
| `api.invalidName` | `name` missing or not a string. | 400 |
|
|
174
|
+
| `api.invalidDataObject` | `data` missing, null, or not an object. | 400 |
|
|
175
|
+
| `api.methodNotAllowed` | Request method does not match the route's declared or inferred HTTP method. `errorParams[0].value` is the expected method. | 405 |
|
|
176
|
+
|
|
177
|
+
**Example**
|
|
178
|
+
|
|
179
|
+
```ts
|
|
180
|
+
import { handleHttpApiRequest } from '@luckystack/api';
|
|
181
|
+
|
|
182
|
+
httpServer.on('request', async (req, res) => {
|
|
183
|
+
if (!req.url?.startsWith('/api/')) return;
|
|
184
|
+
|
|
185
|
+
const token = extractTokenFromRequest(req);
|
|
186
|
+
const body = await readJson(req);
|
|
187
|
+
const name = req.url.slice('/api/'.length).replace(/\?.*$/, '');
|
|
188
|
+
|
|
189
|
+
const result = await handleHttpApiRequest({
|
|
190
|
+
name,
|
|
191
|
+
data: body,
|
|
192
|
+
token,
|
|
193
|
+
requesterIp: req.socket.remoteAddress ?? undefined,
|
|
194
|
+
xLanguageHeader: req.headers['x-language'],
|
|
195
|
+
acceptLanguageHeader: req.headers['accept-language'],
|
|
196
|
+
method: req.method as HttpMethod,
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
res.statusCode = result.httpStatus;
|
|
200
|
+
res.setHeader('content-type', 'application/json');
|
|
201
|
+
res.end(JSON.stringify(result));
|
|
202
|
+
});
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
### Type: `ApiHttpStreamEvent`
|
|
206
|
+
|
|
207
|
+
```ts
|
|
208
|
+
export type ApiHttpStreamEvent = Record<string, unknown>;
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
Wire-level shape of payloads emitted from `stream(...)` over the HTTP transport. Routes narrow this against the generated `ApiStream<P, N, V>` type at the call site. Bridge code (SSE writers, custom queue adapters) should treat it as opaque key/value.
|
|
212
|
+
|
|
213
|
+
## Internal pipeline helpers
|
|
214
|
+
|
|
215
|
+
Not exported. Listed for traceability against the source.
|
|
216
|
+
|
|
217
|
+
| Helper | File | Role |
|
|
218
|
+
| --- | --- | --- |
|
|
219
|
+
| `validateApiMessage` | `handleApiRequest.ts` | Rejects malformed socket envelopes. |
|
|
220
|
+
| `checkApiAuth` | `handleApiRequest.ts` | Wraps `auth.login` gate + `validateRequest`. |
|
|
221
|
+
| `applyApiRateLimits` | `handleApiRequest.ts` | Per-route + global per-IP bucket. Fires `rateLimitExceeded` on reject. |
|
|
222
|
+
| `executeApiHandler` | `handleApiRequest.ts` | `tryCatch` + `startSpan` wrap around `apiEntry.main(...)`. |
|
|
223
|
+
| `buildApiResponseEnvelope` | `handleApiRequest.ts` | Normalizes success / error result to wire envelope with inferred `httpStatus`. |
|
|
224
|
+
| `emitApiResult` | `handleApiRequest.ts` | Runs `preApiRespond` / `transformApiResponse` / `postApiRespond` hooks and emits on the socket. |
|
|
225
|
+
| `warnIfInputTypeMissing` | both | Dev-only one-shot warning when a route lacks a generated `inputType`. |
|
|
226
|
+
| `runHandleHttpApiRequestInner` | `handleHttpApiRequest.ts` | The core inner pipeline for the HTTP transport. |
|
|
227
|
+
| `isRuntimeApiResult` | `handleHttpApiRequest.ts` | Type guard rejecting handler returns whose `status` is not `'success'` / `'error'`. |
|
|
228
|
+
|
|
229
|
+
## Hooks dispatched
|
|
230
|
+
|
|
231
|
+
In pipeline order:
|
|
232
|
+
|
|
233
|
+
| Hook | Stoppable | Payload (key fields) |
|
|
234
|
+
| --- | --- | --- |
|
|
235
|
+
| `preApiValidate` | yes | `routeName`, `data`, `user` |
|
|
236
|
+
| `postApiValidate` | no | `routeName`, `data`, `user`, `validation: { status, message? }` |
|
|
237
|
+
| `preApiExecute` | yes | `routeName`, `data`, `user` |
|
|
238
|
+
| `postApiExecute` | no | `routeName`, `data`, `user`, `result`, `error`, `durationMs` |
|
|
239
|
+
| `preApiRespond` | no (mutates `response`) | `routeName`, `user`, `response: ApiResponseEnvelope` |
|
|
240
|
+
| `transformApiResponse` | no (mutates `response`) | `routeName`, `user`, `response` |
|
|
241
|
+
| `postApiRespond` | no | `routeName`, `user`, `response` |
|
|
242
|
+
| `rateLimitExceeded` | no | `scope: 'route' \| 'user' \| 'ip'`, `key`, `limit`, `windowMs`, `count`, `route?`, `userId?`, `ip?` |
|
|
243
|
+
|
|
244
|
+
Handlers register via `registerHook(name, handler)` from `@luckystack/core`. Stop signals are `{ stop: true, errorCode, httpStatus? }`. For deeper hook contract details see `@luckystack/core/docs/hooks.md`.
|
|
245
|
+
|
|
246
|
+
## Config keys
|
|
247
|
+
|
|
248
|
+
Read at request time via `getProjectConfig()`. Never captured at module load.
|
|
249
|
+
|
|
250
|
+
| Key | Type | Effect |
|
|
251
|
+
| --- | --- | --- |
|
|
252
|
+
| `logging.devLogs` | `boolean` | Gates verbose per-request `getLogger().debug/warn/error` output. |
|
|
253
|
+
| `logging.stream` | `boolean` | Gates `emitStream` payload logging. |
|
|
254
|
+
| `dev.warnOnMissingInputType` | `boolean` | Toggles the one-shot `warnIfInputTypeMissing` developer hint. |
|
|
255
|
+
| `rateLimiting.defaultApiLimit` | `number \| false` | Per-route fallback when the route does not export `rateLimit`. `false` disables the per-route bucket. |
|
|
256
|
+
| `rateLimiting.defaultIpLimit` | `number \| false` | Global per-IP bucket across all API routes. `false` disables. |
|
|
257
|
+
| `rateLimiting.windowMs` | `number` | Window size for both buckets. |
|
|
258
|
+
|
|
259
|
+
No environment variables are read directly by this package. Secrets and toggles travel through `@luckystack/core`'s config registry.
|
|
260
|
+
|
|
261
|
+
## Edge cases
|
|
262
|
+
|
|
263
|
+
- **Missing `responseIndex` (socket).** Returns silently after a dev-log warning. The client will never get a response — by design, since we have no channel to send it on.
|
|
264
|
+
- **Malformed `msg` (socket).** Returns silently if `msg` is not an object. Logs to dev-log.
|
|
265
|
+
- **Unknown route after `parseTransportRouteName` succeeds.** Returns `api.notFound` with the original (pre-normalization) name in `errorParams[0].value` so audit handlers can correlate.
|
|
266
|
+
- **Empty handler return.** Treated as an error: `api.emptyResponse` + `500`. Handlers must return an `{ status: 'success' }` or `{ status: 'error' }` object.
|
|
267
|
+
- **Invalid `status` from handler.** Treated as an error: `api.invalidResponseStatus` + `500`.
|
|
268
|
+
- **`preApiRespond` stop signal.** The original envelope is discarded; a fresh error envelope is built from the signal's `errorCode` + `httpStatus`. The replacement envelope still flows through `transformApiResponse` and `postApiRespond`.
|
|
269
|
+
- **Streaming + error.** If `main(...)` throws after one or more `stream(...)` calls, the stream events were already on the wire — the final envelope is the error response. SSE clients must handle the mixed sequence.
|
|
270
|
+
|
|
271
|
+
## Related
|
|
272
|
+
|
|
273
|
+
- README: [`../README.md`](../README.md)
|
|
274
|
+
- Routing conventions: [`./routing-conventions.md`](./routing-conventions.md)
|
|
275
|
+
- Auth flow: [`./auth-flow.md`](./auth-flow.md)
|
|
276
|
+
- Rate limiting: [`./rate-limiting.md`](./rate-limiting.md)
|
|
277
|
+
- Error handling: [`./error-handling.md`](./error-handling.md)
|
|
278
|
+
- Generated types: [`./generated-types.md`](./generated-types.md)
|
|
279
|
+
- Architecture deep-dive: [`/docs/ARCHITECTURE_API.md`](../../../docs/ARCHITECTURE_API.md)
|
|
280
|
+
- Hook contract: `@luckystack/core/docs/hooks.md`
|