@luckystack/api 0.10.1 → 0.11.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.
@@ -1,244 +1,5 @@
1
- # Error Handling
1
+ # API error handling
2
2
 
3
- ## Overview
3
+ Normalize failures at the transport boundary: clients need stable, localizable errors, not raw exceptions, stack traces or protected validation detail. Error tracking is for operators and must not make a failed tracker crash a request. See `../src/_shared/responseEnvelope.ts`, `../src/handleApiRequest.ts`, `../src/handleHttpApiRequest.ts`, `../../core/src/responseNormalizer.ts`.
4
4
 
5
- `@luckystack/api` produces a strict, two-shape response envelope. Every code path through the handler — malformed input, auth failure, rate-limit rejection, handler throw, handler return — ends in either `{ status: 'success', ... }` or `{ status: 'error', errorCode, errorParams?, httpStatus, message }`. The transformation from raw failure to wire envelope flows through three layers:
6
-
7
- 1. **`tryCatch`** wraps the handler's `main(...)` call. Thrown errors are captured (fanned out to every registered tracker with context), and the tuple `[error, result]` is returned to the pipeline. Raw exceptions never reach the client.
8
- 2. **`buildApiResponseEnvelope`** (socket) / **`buildNetworkError`** (HTTP) maps the raw handler result to a wire envelope, inferring `httpStatus` via `defaultHttpStatusForResponse` and routing the error fields through `normalizeErrorResponse`.
9
- 3. **`normalizeErrorResponse`** renders the localized `message` field from the `errorCode` + `errorParams`, using the project-registered translate function. Locale is extracted in priority order: `x-language` header → `accept-language` header → `user.language`.
10
-
11
- This means: a handler can return `{ status: 'error', errorCode: 'organization.notFound', errorParams: [{ key: 'id', value: orgId }] }` and the framework renders `Organization "abc123" not found` (or its translation) automatically. Business-logic errors and framework-emitted errors share the same envelope shape.
12
-
13
- Logging is uniform: every handler exception is logged via `getLogger().error(...)` with the route name and transport in the context tag, plus a stack via `tryCatch`'s capture. Dev-only warn logs are gated by `logging.devLogs`.
14
-
15
- ## API Reference
16
-
17
- ### `executeApiHandler` (internal, socket transport)
18
-
19
- **Signature**:
20
-
21
- ```ts
22
- const executeApiHandler = async (params: {
23
- apiEntry: RuntimeApiEntry;
24
- normalizedData: Record<string, unknown>;
25
- user: SessionLayout | null;
26
- functionsObject: Record<string, unknown>;
27
- resolvedName: string;
28
- name: string;
29
- emitStream: (payload?: ApiStreamPayload) => void;
30
- }): Promise<{
31
- error: Error | null;
32
- result: RuntimeApiResponse | undefined;
33
- durationMs: number;
34
- }>;
35
- ```
36
-
37
- **Behavior**:
38
-
39
- - Calls `apiEntry.main({ data, user, functions, stream })` inside `tryCatch(..., undefined, { handler, api, userId, transport })`.
40
- - The third argument to `tryCatch` is the auto-capture context — recorded on the registered error tracker alongside the exception.
41
- - Span open/close is handled by hook subscribers in `@luckystack/error-tracking` (registered on `preApiExecute` / `postApiExecute`) — NOT by a direct `startSpan` call here.
42
- - Returns `{ error, result, durationMs }`. Caller decides what to do with each.
43
-
44
- The HTTP transport has no extracted helper — the same flow is inlined.
45
-
46
- ### `tryCatch` (re-exported from `@luckystack/core`)
47
-
48
- **Signature** (paraphrased):
49
-
50
- ```ts
51
- async function tryCatch<T>(
52
- fn: () => Promise<T>,
53
- fallback?: T,
54
- captureContext?: Record<string, unknown>,
55
- ): Promise<[Error | null, T | null]>;
56
- ```
57
-
58
- **Behavior**: invokes `fn`; on throw, captures the error via `captureExceptionAcrossTrackers(error, captureContext)`, and returns `[error, null]`. On success, returns `[null, result]`. Used as the universal error boundary across the framework — never use raw `try / catch`.
59
-
60
- ### `buildApiResponseEnvelope` (internal, socket transport)
61
-
62
- **Signature**:
63
-
64
- ```ts
65
- const buildApiResponseEnvelope = (params: {
66
- resolvedName: string;
67
- error: Error | null;
68
- result: RuntimeApiResponse | undefined;
69
- preferredLocale: string | undefined;
70
- user: SessionLayout | null;
71
- }): { status: 'success' | 'error'; httpStatus?: number; [key: string]: unknown };
72
- ```
73
-
74
- **Behavior**:
75
-
76
- | Condition | Returns |
77
- | --- | --- |
78
- | `error` is non-null | Localized error envelope with `errorCode: 'api.internalServerError'`, `httpStatus: 500`. |
79
- | `result` is `null` or `undefined` | Localized error envelope with `errorCode: 'api.emptyResponse'`, `httpStatus: 500`. |
80
- | `result.status` is neither `'success'` nor `'error'` | Localized error envelope with `errorCode: 'api.invalidResponseStatus'`, `httpStatus: 500`. |
81
- | `result.status === 'error'` | Pass through `normalizeErrorResponse({ response: result, ..., fallbackHttpStatus: defaultHttpStatusForResponse(...) })`. |
82
- | `result.status === 'success'` | Spread `result`, set `httpStatus` via `defaultHttpStatusForResponse({ status: 'success', explicitHttpStatus: result.httpStatus })`. |
83
-
84
- All four error branches and the success branch produce a fully-formed envelope ready to emit.
85
-
86
- ### `buildNetworkError` (internal closure in `handleHttpApiRequest`)
87
-
88
- The HTTP-side equivalent. Same shape as the socket's localized-error path, but the return is typed `ApiNetworkResponse` (which has a non-optional `httpStatus: number`). Used at every HTTP early-return.
89
-
90
- ### `normalizeErrorResponse` (re-exported from `@luckystack/core`)
91
-
92
- **Signature**:
93
-
94
- ```ts
95
- function normalizeErrorResponse(params: {
96
- response: { status: 'error'; errorCode?: string; errorParams?: [...]; httpStatus?: number; message?: string };
97
- preferredLocale?: string | null;
98
- userLanguage?: string | null;
99
- fallbackHttpStatus?: number;
100
- }): {
101
- status: 'error';
102
- httpStatus: number;
103
- errorCode: string;
104
- errorParams?: [...];
105
- message: string;
106
- };
107
- ```
108
-
109
- **Behavior**:
110
-
111
- - Dispatches the `preErrorNormalize` sync hook so consumers can remap `errorCode` (e.g. rewrite `auth.required` → `session.expired`).
112
- - Resolves the locale: `preferredLocale ?? userLanguage ?? defaultLanguage`.
113
- - Calls the registered localized normalizer (set by `registerLocalizedNormalizer` from the project's `server/utils/responseNormalizer.ts`) with the `errorCode` + `errorParams` to render `message`.
114
- - Falls back to the raw `errorCode` string when no normalizer is registered or the key is missing from the locale.
115
- - Sets `httpStatus` to `response.httpStatus ?? fallbackHttpStatus ?? 500`.
116
- - Dispatches the `postErrorNormalize` sync hook so consumers can rewrite the final shape (e.g. strip stack details, add a correlation ID).
117
- - Returns the localized envelope.
118
-
119
- ### `defaultHttpStatusForResponse` (re-exported from `@luckystack/core`)
120
-
121
- **Signature**:
122
-
123
- ```ts
124
- function defaultHttpStatusForResponse(params: {
125
- status: 'success' | 'error';
126
- explicitHttpStatus?: number;
127
- }): number;
128
- ```
129
-
130
- **Behavior**: returns `explicitHttpStatus` when present; otherwise `200` for `'success'` and `500` for `'error'`. The handler's per-response `httpStatus` (set inside `main`) always wins.
131
-
132
- ### Error-tracker identity + tracing spans (hook-based, via `@luckystack/error-tracking`)
133
-
134
- These are no longer direct calls inside the handler. As of 0.2.x:
135
-
136
- - **Identity**: `setCurrentErrorTrackerIdentity({ id, email, username })` is called immediately after `readSession` to populate the per-request ALS scope opened by `runWithErrorTrackerIdentityScope`. Hook subscribers registered by `@luckystack/error-tracking` (on `preApiValidate` or `preApiExecute`) read this scope and forward it to the underlying tracker (Sentry, Datadog, etc.) — there is no direct `setSentryUser` call in the handler.
137
- - **Spans**: tracing spans are opened and closed by hook subscribers registered on `preApiExecute` / `postApiExecute`. The handler no longer calls `startSpan` or `span.end()` directly. This decouples the transport from the instrumentation library.
138
-
139
- ## Framework-emitted error codes
140
-
141
- | Code | Origin | HTTP status | Notes |
142
- | --- | --- | --- | --- |
143
- | `api.invalidRequest` | `validateApiMessage` (socket) | 400 | Malformed socket envelope. |
144
- | `api.invalidName` | HTTP entry validation | 400 | `name` missing or not a string. |
145
- | `api.invalidDataObject` | HTTP entry validation | 400 | `data` missing, null, or not an object. |
146
- | `routing.invalidServiceRouteName` | `parseTransportRouteName` | 400 | Route name does not match `api/<page>/<name>/v<N>`. `errorParams[0].value` echoes the original name. |
147
- | `api.notFound` | runtime map lookup | 404 | Route not registered. `errorParams[0].value` echoes the name. |
148
- | `auth.required` | `checkApiAuth` | 401 | `auth.login: true` and no session. |
149
- | `auth.forbidden` | `validateRequest` predicate | 403 | Predicate rejected. May carry custom `errorCode` from a future custom validator. |
150
- | `auth.invalidCondition` | `validateRequest` | 500 | `auth.additional[].key` is not on the session shape. Setup error. |
151
- | `api.rateLimitExceeded` | `applyApiRateLimits` | 429 | `errorParams[0]` is `{ key: 'seconds', value: resetIn }`. |
152
- | `api.methodNotAllowed` | HTTP method check | 405 | `errorParams[0]` is `{ key: 'method', value: expectedMethod }`. |
153
- | `api.invalidInputType` | `validateInputByType` | 400 | Generic code only — the raw validator message is NOT echoed to the client (it would leak the input schema to unauthenticated callers). The detailed message is routed to the `postApiValidate` hook + dev logs. |
154
- | `api.internalServerError` | `tryCatch` caught a throw | 500 | The original exception is on the registered tracker; not in the wire envelope. |
155
- | `api.emptyResponse` | `normalizeApiResponse` | 500 | Handler returned `null` or `undefined`. |
156
- | `api.invalidResponseStatus` | `normalizeApiResponse` | 500 | Handler returned an object whose `status` is not `'success'` / `'error'`. |
157
-
158
- Consumer-emitted error codes (returned from `main(...)`) flow through `normalizeErrorResponse` unchanged. Consumers should register translations for the framework codes above plus their own.
159
-
160
- ## Stop signals
161
-
162
- `preApiExecute` and `preApiRespond` hook handlers may return a `HookStopSignal`:
163
-
164
- ```ts
165
- return { stop: true, errorCode: 'organization.disabled', httpStatus: 403 };
166
- ```
167
-
168
- When the dispatcher sees a stop signal:
169
-
170
- - `preApiExecute`: the handler short-circuits before `main(...)` runs. The envelope is built as `normalizeErrorResponse({ response: { errorCode }, fallbackHttpStatus: signal.httpStatus ?? 403 })`.
171
- - `preApiRespond`: the already-built envelope is discarded; a fresh localized envelope is built from the signal. `transformApiResponse` and `postApiRespond` still fire on the replacement.
172
-
173
- Other lifecycle hooks (`preApiValidate`, `postApiValidate`, `postApiExecute`, `transformApiResponse`, `postApiRespond`) do not honor stop signals — they are pre-validation observation, post-execution observation, or pure mutation slots.
174
-
175
- ## Locale extraction
176
-
177
- Order of precedence for the `preferredLocale` argument that flows into `normalizeErrorResponse`:
178
-
179
- 1. **Socket transport**: `extractLanguageFromHeader(socket.handshake.headers['x-language'])` → `extractLanguageFromHeader(socket.handshake.headers['accept-language'])`.
180
- 2. **HTTP transport**: `extractLanguageFromHeader(params.xLanguageHeader)` → `extractLanguageFromHeader(params.acceptLanguageHeader)`. The HTTP listener in `@luckystack/server` is responsible for passing these headers in.
181
- 3. **Fallback**: `user.language` (from the session) when both headers are absent.
182
- 4. **Final fallback**: `defaultLanguage` from `projectConfig` when there is no session.
183
-
184
- `extractLanguageFromHeader` returns the first valid locale token (`en`, `nl`, `de`, `fr`) from a possibly multi-value header, or `undefined`. The localized normalizer also implements its own fallback chain.
185
-
186
- ## Logging
187
-
188
- Every error path logs at the appropriate level:
189
-
190
- | Level | When |
191
- | --- | --- |
192
- | `getLogger().debug(...)` | Successful route completion, when `logging.devLogs` is on. |
193
- | `getLogger().warn(...)` | Auth failures, rate-limit exceeded, method mismatch, validation failures, empty returns, malformed envelopes. All gated by `logging.devLogs`. |
194
- | `getLogger().error(...)` | Handler exceptions caught by `tryCatch`. Always logged regardless of `devLogs`. Carries `{ route, transport }` context. |
195
-
196
- The error tracker (registered via `@luckystack/error-tracking`) receives every `tryCatch` capture automatically with the context tags (`handler`, `api`, `userId`, `transport`).
197
-
198
- ## What is NOT handled here
199
-
200
- - **Business-logic errors inside `main(...)`.** Routes return `{ status: 'error', errorCode: '...' }` directly. The envelope normalizer renders them — no thrown exception, no error-tracker capture. This is by design: business outcomes (validation failures in domain code, "user already exists") are not "errors" in the tracking sense.
201
- - **Localization of `errorCode` strings.** That is the consumer's responsibility — `registerLocalizedNormalizer` from the project's `server/utils/responseNormalizer.ts` provides the translate function backed by JSON locale files in `src/_locales/`.
202
- - **Frontend error rendering.** The client's `apiRequest` returns the normalized envelope. UI code consumes `result.errorCode` + `result.message` and decides how to render (toast via `notify`, inline form error, redirect).
203
- - **Hook handler errors.** Failures inside a `dispatchHook` handler are isolated by the hook bus — they log via `getLogger()` + the tracker but do not affect the main pipeline.
204
-
205
- ## Hooks dispatched
206
-
207
- The error-handling layer fires:
208
-
209
- | Hook | Stoppable | Payload |
210
- | --- | --- | --- |
211
- | `preApiRespond` | yes (becomes localized error) | `{ routeName, user, response }` — last chance to mutate or replace the envelope. |
212
- | `transformApiResponse` | no (mutate `response`) | `{ routeName, user, response }` — pure mutation slot. |
213
- | `postApiRespond` | no | `{ routeName, user, response }` — observation after emit. |
214
- | `preErrorNormalize` | no (sync; mutate `response`) | `{ response: ErrorResponseInput }` — fires inside `normalizeErrorResponse`. |
215
- | `postErrorNormalize` | no (sync; mutate `normalized`) | `{ normalized: NormalizedErrorResponse }` — fires before `normalizeErrorResponse` returns. |
216
-
217
- See [`./api-request-lifecycle.md`](./api-request-lifecycle.md) for the full hook order and `@luckystack/core/docs/hooks.md` for hook bus semantics.
218
-
219
- ## Config keys
220
-
221
- | Key | Effect |
222
- | --- | --- |
223
- | `logging.devLogs` | Gates verbose per-request `debug` / `warn` logs. Handler-exception `error` logs always emit. |
224
- | `defaultLanguage` | Final fallback locale for `normalizeErrorResponse`. |
225
- | (registered via DI) | The localized normalizer is registered by the project's boot code. Without one, error responses still carry `errorCode` but `message` falls back to the raw key. |
226
-
227
- ## Edge cases
228
-
229
- - **Handler returns `{ status: 'error' }` with no `errorCode`.** `normalizeErrorResponse` treats it as having a missing key — the rendered `message` falls back to the raw value (an empty string or the key literal). Always include `errorCode`.
230
- - **Handler returns `httpStatus: 0` or a non-number.** `defaultHttpStatusForResponse` only treats `typeof === 'number'` as explicit; non-numbers fall back to 200/500 per the success/error branch.
231
- - **Stop signal with no `httpStatus`.** Falls back to `403` for both `preApiExecute` and `preApiRespond` stops.
232
- - **`tryCatch` caught a non-Error throw.** The framework wraps non-Error throws into an Error before capture; the client always gets `api.internalServerError`. The tracker receives the wrapped Error with the original `cause`.
233
- - **`preApiRespond` stop on an already-error envelope.** The original error envelope is replaced by the stop signal's error envelope. Use this pattern to remap error codes (`auth.required` → `session.expired`) — but prefer the sync `preErrorNormalize` hook for code remapping since it runs in a single place.
234
- - **HTTP request with no `requesterIp` and no token.** The per-route bucket keys on `ip:anonymous:...` (per route) and `ip:unknown:...` (global). All such requests share these buckets. Front your server with a load balancer that always sets `X-Forwarded-For`.
235
-
236
- ## Related
237
-
238
- - API request lifecycle: [`./api-request-lifecycle.md`](./api-request-lifecycle.md)
239
- - Auth flow: [`./auth-flow.md`](./auth-flow.md)
240
- - Rate limiting: [`./rate-limiting.md`](./rate-limiting.md)
241
- - Generated types: [`./generated-types.md`](./generated-types.md)
242
- - Architecture: [`/docs/ARCHITECTURE_API.md`](../../../docs/ARCHITECTURE_API.md) (see "Error Code Rules", "Framework-emitted error codes", "Response Contract")
243
- - Hooks: `@luckystack/core/docs/hooks.md`
244
- - Error-tracker registry: `@luckystack/core/docs/error-tracker-registry.md`
5
+ Observe the distinction between a pre-execution veto and a post-execution hook: a late hook cannot undo a committed mutation. Per-request tracking identity prevents cross-request attribution; log/capture contexts need credential redaction. See `../../core/src/errorTrackerIdentity.ts`, `../../core/src/redactedLogKeys.ts`, `./api-request-lifecycle.md`.
@@ -1,262 +1,5 @@
1
- # Generated Types
1
+ # Generated types
2
2
 
3
- ## Overview
3
+ Typed route maps bridge consumer route files and client calls. Keep generation as the source of type inference and runtime input schemas; a cast at the call site hides a broken map or unsupported payload and forfeits validation. When inference fails, regenerate the artifacts first, then fix the generator or declaration rather than wrapping calls in `any`/`unknown`. See repository `AGENTS.md`, `../src/_shared/inputTypeWarning.ts`, and generator code in `../../devkit/`.
4
4
 
5
- LuckyStack's API layer is fully type-safe end-to-end. Every `src/{page}/_api/{name}_v{N}.ts` file is read by `@luckystack/devkit` via the TypeScript compiler API, its `ApiParams['data']` and `main` return types are extracted, and three artifacts are emitted: `apiTypes.generated.ts` (the static type map), `apiInputSchemas.generated.ts` (runtime Zod schemas), and `apiDocs.generated.json` (introspection JSON used by `@luckystack/docs-ui`). `@luckystack/api` reads the runtime map at request time and uses the input-type text to drive `validateInputByType`. The client-side `apiRequest` from `@luckystack/core` reads the static type map to infer payload + response shapes from the route-name + version literals you pass it.
6
-
7
- This means: any change to an API file's `ApiParams['data']` interface or `main` return type propagates to the client at the next regeneration. The hot-reload watcher (devkit) triggers regeneration on save. The strict typing policy in the repository-root contract (`CLAUDE.md` rule 21) requires this inference chain to remain intact — `unknown` / `any` casts around `apiRequest` defeat the whole pipeline.
8
-
9
- This doc covers the generated type surface, the runtime validation flow that consumes it, and the per-route opt-out for cases where strict input typing is impossible (third-party webhooks).
10
-
11
- ## The two generated files
12
-
13
- | Artifact | Default path | Purpose | Consumer |
14
- | --- | --- | --- | --- |
15
- | `apiTypes.generated.ts` | `src/_sockets/apiTypes.generated.ts` | Static TS type map. Declares `ApiTypeMap`, `PagePath`, `ApiName`, `ApiVersion`, `ApiInput`, `ApiOutput`, `ApiStream`, `ApiMethod`, `FullApiPath`, plus `apiMethodMap` / `apiMetaMap` runtime tables. | Client autocomplete + compile-time `apiRequest` typing. Also module-augments `@luckystack/core`'s `ApiTypeMap` interface so framework code resolves the same shapes without deep-relative imports. |
16
- | `apiInputSchemas.generated.ts` | `src/_sockets/apiInputSchemas.generated.ts` | Runtime Zod schemas (one per route + version), keyed by `[pagePath][apiName][version]`. Falls back to `z.any()` with a TODO comment when the input type cannot be converted. | Test runner's input fuzzer. Available for consumers that want pre-handler Zod validation in custom transports. The default runtime validator (`validateInputByType`) uses the type text, not the Zod file. |
17
-
18
- Paths are resolved through `@luckystack/core`'s path registry (`getGeneratedSocketTypesPath()`, `getGeneratedApiSchemasPath()`). Override via `projectConfig.paths.*` if you ship the generated files somewhere else.
19
-
20
- ## Type Map structure
21
-
22
- `apiTypes.generated.ts` produces a nested object type, one entry per `<page>` → `<name>` → `<version>`:
23
-
24
- ```ts
25
- type _ProjectApiTypeMap = {
26
- 'settings': {
27
- 'updateUser': {
28
- 'v1': {
29
- input: { name: string };
30
- output: { status: 'success'; ok: true; httpStatus?: number } | { status: 'error'; errorCode: string; httpStatus?: number };
31
- stream: never;
32
- method: 'POST';
33
- rateLimit: 20;
34
- };
35
- };
36
- // ...
37
- };
38
- // ...
39
- };
40
-
41
- export interface ApiTypeMap extends _ProjectApiTypeMap {}
42
- ```
43
-
44
- The file also augments `@luckystack/core` so framework code can rely on the same map:
45
-
46
- ```ts
47
- declare module '@luckystack/core' {
48
- interface ApiTypeMap extends _ProjectApiTypeMap {}
49
- interface SyncTypeMap extends _ProjectSyncTypeMap {}
50
- }
51
- ```
52
-
53
- `@luckystack/core` ships a stub `interface ApiTypeMap {}` in `apiTypeStubs.ts` — the module-augmentation above replaces it with the project's concrete map on any import of the generated file.
54
-
55
- ## Helper types
56
-
57
- All exported from `apiTypes.generated.ts` (and re-exported transitively from `@luckystack/core` for framework use):
58
-
59
- | Type | Definition (paraphrased) | Use |
60
- | --- | --- | --- |
61
- | `PagePath` | `keyof ApiTypeMap` | Union of every page path with at least one route. |
62
- | `ApiName<P>` | `keyof ApiTypeMap[P]` | Union of every API name registered under page `P`. |
63
- | `ApiVersion<P, N>` | `keyof ApiTypeMap[P][N]` | Union of every version registered for route `P/N`. |
64
- | `ApiInput<P, N, V>` | `ApiTypeMap[P][N][V]['input']` | Payload shape (`data` parameter of `apiRequest`). |
65
- | `ApiOutput<P, N, V>` | `ApiTypeMap[P][N][V]['output']` | Discriminated success/error union returned by `apiRequest`. |
66
- | `ApiStream<P, N, V>` | `ApiTypeMap[P][N][V]['stream']` | Union of every `stream(...)` payload shape emitted by `main`. `never` when the route does not stream. |
67
- | `ApiMethod<P, N, V>` | `ApiTypeMap[P][N][V]['method']` | HTTP method literal (`'GET' \| 'POST' \| 'PUT' \| 'DELETE'`). |
68
- | `FullApiPath<P, N, V>` | `` `api/${P}/${N}/${V}` `` | Wire-format route string. Used internally — most code uses `name + version` literals. |
69
- | `ApiResponse<T>` | `{ status: 'success'; ...T } \| { status: 'error'; errorCode; errorParams?; httpStatus? }` | Return type for `main(...)`. |
70
- | `ApiStreamEmitter<T>` | `(payload?: T) => void \| Promise<void>` | Type of `stream` parameter passed into `main`. |
71
- | `Functions` | Project-emitted interface | The injected functions registry available to every route (`db`, `redis`, `notify`, ...). |
72
-
73
- `ApiInput` / `ApiOutput` / `ApiStream` are what `apiRequest` uses to type its arguments and return value:
74
-
75
- ```ts
76
- import { apiRequest } from '@luckystack/core/client';
77
-
78
- const result = await apiRequest({
79
- name: 'settings/updateUser', // ApiName<'settings'>
80
- version: 'v1', // ApiVersion<'settings', 'updateUser'>
81
- data: { name: 'Alice' }, // ApiInput<'settings', 'updateUser', 'v1'>
82
- });
83
-
84
- if (result.status === 'success') {
85
- // result is narrowed to ApiOutput<'settings', 'updateUser', 'v1'> success branch
86
- }
87
- ```
88
-
89
- The discriminated `status` field on `ApiOutput` lets TypeScript narrow success / error branches without `as` casts. Branch-specific literal properties (`submitted: true` vs `submitted: false`) stay discriminated in the union.
90
-
91
- ## Runtime input validation
92
-
93
- At request time, the API handler reads two fields from the runtime map:
94
-
95
- ```ts
96
- interface RuntimeApiEntry {
97
- // ...
98
- inputType?: string; // The text form of the `data` interface ("{ name: string }")
99
- inputTypeFilePath?: string; // The source file path, for cross-file type resolution
100
- // ...
101
- }
102
- ```
103
-
104
- `validateInputByType` (re-exported from `@luckystack/core`) is called with:
105
-
106
- ```ts
107
- const inputValidation = await validateInputByType({
108
- typeText: apiEntry.inputType,
109
- value: normalizedData,
110
- rootKey: 'data',
111
- filePath: apiEntry.inputTypeFilePath,
112
- });
113
- ```
114
-
115
- | Field | Type | Purpose |
116
- | --- | --- | --- |
117
- | `typeText` | `string \| undefined` | The TypeScript type text captured at generation time. May reference imports — `filePath` lets the resolver expand them. |
118
- | `value` | `unknown` | The wire-level payload. |
119
- | `rootKey` | `string` | Label used in error messages (`data` for API, `clientInput` for sync). |
120
- | `filePath` | `string \| undefined` | Source file path. The dev-mode resolver lazy-loads `@luckystack/devkit` to re-walk imports / re-exports. |
121
-
122
- **Return shape**: `{ status: 'success' } | { status: 'error'; message: string }`. On error the handler responds with the generic `api.invalidInputType` + `400`. The validator's `message` is NOT surfaced to the client (echoing it would let unauthenticated callers enumerate the input schema); it is routed to the `postApiValidate` hook payload (`validation.message`) and the dev logs instead.
123
-
124
- `validateInputByType` lazy-loads `@luckystack/devkit`'s deep resolver in development so types defined across files (re-exported aliases, utility wrappers like `Partial`/`Pick`/`Omit`/`Record`) expand correctly. In production it relies on the resolver's last-known cache — regenerate before shipping.
125
-
126
- ## Missing `inputType`
127
-
128
- When a route's input cannot be extracted (the type erased to `any`, the generator hit an unresolvable symbol), `inputType` is empty or `'any'`. Strict-mode generation will throw at devkit time — but if you have it disabled or are running on a stale build, runtime validation is effectively a no-op.
129
-
130
- The dev-only `warnIfInputTypeMissing(resolvedName, inputType)` helper emits a one-shot warning per route:
131
-
132
- ```
133
- api: route settings/updateUser/v1 has no inputType — runtime input validation is disabled. Regenerate types or set the inputType on the handler.
134
- ```
135
-
136
- Gated by `projectConfig.dev.warnOnMissingInputType`. Each route warns once per process; the module-level `warnedMissingInputType: Set<string>` prevents log spam.
137
-
138
- ## `validation: 'relaxed'` / `{ input: 'skip' }`
139
-
140
- Some endpoints receive payloads that cannot reasonably be modeled in TypeScript — public webhooks (Stripe, Slack, GitHub), legacy clients in a migration window, or untyped third-party integrations. For these, the route file can opt out:
141
-
142
- ```ts
143
- // src/integrations/_api/stripeWebhook_v1.ts
144
- export const validation = 'relaxed' as const;
145
- // or
146
- export const validation = { input: 'skip' } as const;
147
- ```
148
-
149
- When set, the socket transport skips `validateInputByType` entirely:
150
-
151
- - `warnIfInputTypeMissing` is **not** called.
152
- - `preApiValidate` still fires (handlers can inspect raw `data`).
153
- - `validateInputByType` is **not** called.
154
- - `postApiValidate` fires with `{ status: 'success' }` so audit handlers see the skip.
155
-
156
- The HTTP transport does **not** honor the flag — relax-mode endpoints typically live on the socket transport (where webhook payloads still parse as `Record<string, unknown>`). If you need relax-mode on HTTP, structure the input as `Record<string, unknown>` and validate inside `main(...)`.
157
-
158
- ## Strict typing policy
159
-
160
- Repository rule (root `CLAUDE.md` §21): **NEVER** cast typed API/sync payloads to `unknown`, `any`, or `unsafe*` wrappers when calling `apiRequest`, `syncRequest`, or `upsertSyncEventCallback`.
161
-
162
- ```ts
163
- // Bad — defeats the entire generated-types pipeline
164
- const unsafeApi = async (name: string, version: string, data: unknown) =>
165
- apiRequest({ name: name as any, version: version as any, data: data as any });
166
-
167
- // Good — direct typed call with literal route + version
168
- const response = await apiRequest({
169
- name: 'organization/settings/sendInvite',
170
- version: 'v1',
171
- data: { email: 'a@b.com' },
172
- });
173
- ```
174
-
175
- The AI workflow allows temporary casts during generator-lag windows (after a save, before types refresh), but they must be local, minimal, and removed once regeneration completes.
176
-
177
- ## Regenerating types
178
-
179
- | Trigger | What happens |
180
- | --- | --- |
181
- | Save an `_api/` file in dev | `setupWatchers` (devkit) coalesces a hot-reload + type-map regeneration. The route is re-imported into `devApis`; the type map writes if its content changed. |
182
- | `npm run build` | The build script invokes `generateTypeMapFile()` once before bundling. |
183
- | Manual: `npm run ai:index` (or the project's equivalent) | Forces a full regeneration. |
184
- | `postinstall` (in this repo) | Regenerates `apiInputSchemas.generated.ts` so fresh clones never miss the runtime Zod schemas. |
185
-
186
- If the generator throws `[TypeMapGenerator] Generated type map has unresolved type identifiers: <symbols>`, an `ApiParams['data']` or `main` return type references a symbol the resolver could not expand. Common causes: a renamed file, a barrel re-export missing the type, an import cycle. Fix the typing source — do not add casts.
187
-
188
- ## API Reference
189
-
190
- ### `validateInputByType` (re-exported from `@luckystack/core`)
191
-
192
- **Signature**
193
-
194
- ```ts
195
- async function validateInputByType(params: {
196
- typeText: string | undefined;
197
- value: unknown;
198
- rootKey: string;
199
- filePath?: string;
200
- }): Promise<{ status: 'success' } | { status: 'error'; message: string }>;
201
- ```
202
-
203
- **Behavior**
204
-
205
- - Returns `{ status: 'success' }` when `typeText` is falsy (validation effectively disabled).
206
- - In dev, lazy-loads `@luckystack/devkit`'s `resolveRuntimeTypeText` to expand cross-file aliases.
207
- - Walks the type AST recursively. Records the first mismatch and returns a path-level message (`data.users[0].email: expected string, got number`).
208
- - Caches resolved types per `filePath`; cache is invalidated by devkit on hot reload.
209
-
210
- ### `warnIfInputTypeMissing` (internal to both handlers)
211
-
212
- **Signature**
213
-
214
- ```ts
215
- const warnIfInputTypeMissing = (resolvedName: string, inputType: string | undefined): void;
216
- ```
217
-
218
- **Behavior**
219
-
220
- - No-op when `projectConfig.dev.warnOnMissingInputType` is `false`.
221
- - No-op when `inputType` is non-empty and not `'any'`.
222
- - Otherwise, logs once per route via `getLogger().warn(...)` and adds the route to a module-level `Set` so subsequent requests stay quiet.
223
-
224
- ### `RuntimeApiEntry` (internal type)
225
-
226
- The shape `getRuntimeApiMaps()` returns for each route:
227
-
228
- ```ts
229
- interface RuntimeApiEntry {
230
- auth: AuthProps;
231
- main: (params: { data, user, functions, stream }) => Promise<RuntimeApiResponse>;
232
- inputType?: string;
233
- inputTypeFilePath?: string;
234
- rateLimit?: number | false;
235
- httpMethod?: HttpMethod;
236
- validation?: 'strict' | 'relaxed' | { input: 'skip' | 'strict' };
237
- }
238
- ```
239
-
240
- Devkit emits these. The handler reads them.
241
-
242
- ## Hooks dispatched
243
-
244
- This package does not own hooks specific to type generation. The runtime validation step fires `preApiValidate` / `postApiValidate` — see [`./api-request-lifecycle.md`](./api-request-lifecycle.md).
245
-
246
- ## Config keys
247
-
248
- | Key | Effect |
249
- | --- | --- |
250
- | `dev.warnOnMissingInputType` | Toggle the dev-only `warnIfInputTypeMissing` log. |
251
- | `paths.generatedSocketTypes` | Output path for `apiTypes.generated.ts`. |
252
- | `paths.generatedApiSchemas` | Output path for `apiInputSchemas.generated.ts`. |
253
- | `paths.generatedApiDocs` | Output path for `apiDocs.generated.json`. |
254
-
255
- ## Related
256
-
257
- - API request lifecycle: [`./api-request-lifecycle.md`](./api-request-lifecycle.md)
258
- - Routing conventions: [`./routing-conventions.md`](./routing-conventions.md)
259
- - Error handling: [`./error-handling.md`](./error-handling.md)
260
- - Architecture: [`/docs/ARCHITECTURE_ROUTING.md`](../../../docs/ARCHITECTURE_ROUTING.md) (see "Generated maps")
261
- - Devkit type-map generation: `@luckystack/devkit/docs/type-map-generation.md`
262
- - Root rule on casts: [`CLAUDE.md`](../../../CLAUDE.md) §21
5
+ Runtime validation is a security boundary independent of TypeScript. An intentional skip for a dynamic payload must be explicit and reviewed; it should not become a blanket response to missing generated schemas. Inspect the route declaration, generated schema and `../src/_shared/socketValidationStage.ts` / `httpValidationStage.ts` together.