@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,264 @@
|
|
|
1
|
+
# Generated Types
|
|
2
|
+
|
|
3
|
+
> Last updated: 2026-05-20
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
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.
|
|
8
|
+
|
|
9
|
+
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 repo root contract (`.claude/CLAUDE.md` rule 16) requires this inference chain to remain intact — `unknown` / `any` casts around `apiRequest` defeat the whole pipeline.
|
|
10
|
+
|
|
11
|
+
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).
|
|
12
|
+
|
|
13
|
+
## The two generated files
|
|
14
|
+
|
|
15
|
+
| Artifact | Default path | Purpose | Consumer |
|
|
16
|
+
| --- | --- | --- | --- |
|
|
17
|
+
| `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. |
|
|
18
|
+
| `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. |
|
|
19
|
+
|
|
20
|
+
Paths are resolved through `@luckystack/core`'s path registry (`getGeneratedSocketTypesPath()`, `getGeneratedApiSchemasPath()`). Override via `projectConfig.paths.*` if you ship the generated files somewhere else.
|
|
21
|
+
|
|
22
|
+
## Type Map structure
|
|
23
|
+
|
|
24
|
+
`apiTypes.generated.ts` produces a nested object type, one entry per `<page>` → `<name>` → `<version>`:
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
type _ProjectApiTypeMap = {
|
|
28
|
+
'settings': {
|
|
29
|
+
'updateUser': {
|
|
30
|
+
'v1': {
|
|
31
|
+
input: { name: string };
|
|
32
|
+
output: { status: 'success'; ok: true; httpStatus?: number } | { status: 'error'; errorCode: string; httpStatus?: number };
|
|
33
|
+
stream: never;
|
|
34
|
+
method: 'POST';
|
|
35
|
+
rateLimit: 20;
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
// ...
|
|
39
|
+
};
|
|
40
|
+
// ...
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export interface ApiTypeMap extends _ProjectApiTypeMap {}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
The file also augments `@luckystack/core` so framework code can rely on the same map:
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
declare module '@luckystack/core' {
|
|
50
|
+
interface ApiTypeMap extends _ProjectApiTypeMap {}
|
|
51
|
+
interface SyncTypeMap extends _ProjectSyncTypeMap {}
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`@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.
|
|
56
|
+
|
|
57
|
+
## Helper types
|
|
58
|
+
|
|
59
|
+
All exported from `apiTypes.generated.ts` (and re-exported transitively from `@luckystack/core` for framework use):
|
|
60
|
+
|
|
61
|
+
| Type | Definition (paraphrased) | Use |
|
|
62
|
+
| --- | --- | --- |
|
|
63
|
+
| `PagePath` | `keyof ApiTypeMap` | Union of every page path with at least one route. |
|
|
64
|
+
| `ApiName<P>` | `keyof ApiTypeMap[P]` | Union of every API name registered under page `P`. |
|
|
65
|
+
| `ApiVersion<P, N>` | `keyof ApiTypeMap[P][N]` | Union of every version registered for route `P/N`. |
|
|
66
|
+
| `ApiInput<P, N, V>` | `ApiTypeMap[P][N][V]['input']` | Payload shape (`data` parameter of `apiRequest`). |
|
|
67
|
+
| `ApiOutput<P, N, V>` | `ApiTypeMap[P][N][V]['output']` | Discriminated success/error union returned by `apiRequest`. |
|
|
68
|
+
| `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. |
|
|
69
|
+
| `ApiMethod<P, N, V>` | `ApiTypeMap[P][N][V]['method']` | HTTP method literal (`'GET' \| 'POST' \| 'PUT' \| 'DELETE'`). |
|
|
70
|
+
| `FullApiPath<P, N, V>` | `` `api/${P}/${N}/${V}` `` | Wire-format route string. Used internally — most code uses `name + version` literals. |
|
|
71
|
+
| `ApiResponse<T>` | `{ status: 'success'; ...T } \| { status: 'error'; errorCode; errorParams?; httpStatus? }` | Return type for `main(...)`. |
|
|
72
|
+
| `ApiStreamEmitter<T>` | `(payload?: T) => void \| Promise<void>` | Type of `stream` parameter passed into `main`. |
|
|
73
|
+
| `Functions` | Project-emitted interface | The injected functions registry available to every route (`db`, `redis`, `notify`, ...). |
|
|
74
|
+
|
|
75
|
+
`ApiInput` / `ApiOutput` / `ApiStream` are what `apiRequest` uses to type its arguments and return value:
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
import { apiRequest } from '@luckystack/core/client';
|
|
79
|
+
|
|
80
|
+
const result = await apiRequest({
|
|
81
|
+
name: 'settings/updateUser', // ApiName<'settings'>
|
|
82
|
+
version: 'v1', // ApiVersion<'settings', 'updateUser'>
|
|
83
|
+
data: { name: 'Alice' }, // ApiInput<'settings', 'updateUser', 'v1'>
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
if (result.status === 'success') {
|
|
87
|
+
// result is narrowed to ApiOutput<'settings', 'updateUser', 'v1'> success branch
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
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.
|
|
92
|
+
|
|
93
|
+
## Runtime input validation
|
|
94
|
+
|
|
95
|
+
At request time, the API handler reads two fields from the runtime map:
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
interface RuntimeApiEntry {
|
|
99
|
+
// ...
|
|
100
|
+
inputType?: string; // The text form of the `data` interface ("{ name: string }")
|
|
101
|
+
inputTypeFilePath?: string; // The source file path, for cross-file type resolution
|
|
102
|
+
// ...
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
`validateInputByType` (re-exported from `@luckystack/core`) is called with:
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
const inputValidation = await validateInputByType({
|
|
110
|
+
typeText: apiEntry.inputType,
|
|
111
|
+
value: normalizedData,
|
|
112
|
+
rootKey: 'data',
|
|
113
|
+
filePath: apiEntry.inputTypeFilePath,
|
|
114
|
+
});
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
| Field | Type | Purpose |
|
|
118
|
+
| --- | --- | --- |
|
|
119
|
+
| `typeText` | `string \| undefined` | The TypeScript type text captured at generation time. May reference imports — `filePath` lets the resolver expand them. |
|
|
120
|
+
| `value` | `unknown` | The wire-level payload. |
|
|
121
|
+
| `rootKey` | `string` | Label used in error messages (`data` for API, `clientInput` for sync). |
|
|
122
|
+
| `filePath` | `string \| undefined` | Source file path. The dev-mode resolver lazy-loads `@luckystack/devkit` to re-walk imports / re-exports. |
|
|
123
|
+
|
|
124
|
+
**Return shape**: `{ status: 'success' } | { status: 'error'; message: string }`. On error the handler responds with `api.invalidInputType` + `400`, surfacing the validator's `message` as `errorParams[0].value`.
|
|
125
|
+
|
|
126
|
+
`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.
|
|
127
|
+
|
|
128
|
+
## Missing `inputType`
|
|
129
|
+
|
|
130
|
+
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.
|
|
131
|
+
|
|
132
|
+
The dev-only `warnIfInputTypeMissing(resolvedName, inputType)` helper emits a one-shot warning per route:
|
|
133
|
+
|
|
134
|
+
```
|
|
135
|
+
api: route settings/updateUser/v1 has no inputType — runtime input validation is disabled. Regenerate types or set the inputType on the handler.
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Gated by `projectConfig.dev.warnOnMissingInputType`. Each route warns once per process; the module-level `warnedMissingInputType: Set<string>` prevents log spam.
|
|
139
|
+
|
|
140
|
+
## `validation: 'relaxed'` / `{ input: 'skip' }`
|
|
141
|
+
|
|
142
|
+
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:
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
// src/integrations/_api/stripeWebhook_v1.ts
|
|
146
|
+
export const validation = 'relaxed' as const;
|
|
147
|
+
// or
|
|
148
|
+
export const validation = { input: 'skip' } as const;
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
When set, the socket transport skips `validateInputByType` entirely:
|
|
152
|
+
|
|
153
|
+
- `warnIfInputTypeMissing` is **not** called.
|
|
154
|
+
- `preApiValidate` still fires (handlers can inspect raw `data`).
|
|
155
|
+
- `validateInputByType` is **not** called.
|
|
156
|
+
- `postApiValidate` fires with `{ status: 'success' }` so audit handlers see the skip.
|
|
157
|
+
|
|
158
|
+
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(...)`.
|
|
159
|
+
|
|
160
|
+
## Strict typing policy
|
|
161
|
+
|
|
162
|
+
Repository rule (root `.claude/CLAUDE.md` §16): **NEVER** cast typed API/sync payloads to `unknown`, `any`, or `unsafe*` wrappers when calling `apiRequest`, `syncRequest`, or `upsertSyncEventCallback`.
|
|
163
|
+
|
|
164
|
+
```ts
|
|
165
|
+
// Bad — defeats the entire generated-types pipeline
|
|
166
|
+
const unsafeApi = async (name: string, version: string, data: unknown) =>
|
|
167
|
+
apiRequest({ name: name as any, version: version as any, data: data as any });
|
|
168
|
+
|
|
169
|
+
// Good — direct typed call with literal route + version
|
|
170
|
+
const response = await apiRequest({
|
|
171
|
+
name: 'organization/settings/sendInvite',
|
|
172
|
+
version: 'v1',
|
|
173
|
+
data: { email: 'a@b.com' },
|
|
174
|
+
});
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
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.
|
|
178
|
+
|
|
179
|
+
## Regenerating types
|
|
180
|
+
|
|
181
|
+
| Trigger | What happens |
|
|
182
|
+
| --- | --- |
|
|
183
|
+
| 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. |
|
|
184
|
+
| `npm run build` | The build script invokes `generateTypeMapFile()` once before bundling. |
|
|
185
|
+
| Manual: `npm run ai:index` (or the project's equivalent) | Forces a full regeneration. |
|
|
186
|
+
| `postinstall` (in this repo) | Regenerates `apiInputSchemas.generated.ts` so fresh clones never miss the runtime Zod schemas. |
|
|
187
|
+
|
|
188
|
+
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.
|
|
189
|
+
|
|
190
|
+
## API Reference
|
|
191
|
+
|
|
192
|
+
### `validateInputByType` (re-exported from `@luckystack/core`)
|
|
193
|
+
|
|
194
|
+
**Signature**
|
|
195
|
+
|
|
196
|
+
```ts
|
|
197
|
+
async function validateInputByType(params: {
|
|
198
|
+
typeText: string | undefined;
|
|
199
|
+
value: unknown;
|
|
200
|
+
rootKey: string;
|
|
201
|
+
filePath?: string;
|
|
202
|
+
}): Promise<{ status: 'success' } | { status: 'error'; message: string }>;
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
**Behavior**
|
|
206
|
+
|
|
207
|
+
- Returns `{ status: 'success' }` when `typeText` is falsy (validation effectively disabled).
|
|
208
|
+
- In dev, lazy-loads `@luckystack/devkit`'s `resolveRuntimeTypeText` to expand cross-file aliases.
|
|
209
|
+
- Walks the type AST recursively. Records the first mismatch and returns a path-level message (`data.users[0].email: expected string, got number`).
|
|
210
|
+
- Caches resolved types per `filePath`; cache is invalidated by devkit on hot reload.
|
|
211
|
+
|
|
212
|
+
### `warnIfInputTypeMissing` (internal to both handlers)
|
|
213
|
+
|
|
214
|
+
**Signature**
|
|
215
|
+
|
|
216
|
+
```ts
|
|
217
|
+
const warnIfInputTypeMissing = (resolvedName: string, inputType: string | undefined): void;
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
**Behavior**
|
|
221
|
+
|
|
222
|
+
- No-op when `projectConfig.dev.warnOnMissingInputType` is `false`.
|
|
223
|
+
- No-op when `inputType` is non-empty and not `'any'`.
|
|
224
|
+
- Otherwise, logs once per route via `getLogger().warn(...)` and adds the route to a module-level `Set` so subsequent requests stay quiet.
|
|
225
|
+
|
|
226
|
+
### `RuntimeApiEntry` (internal type)
|
|
227
|
+
|
|
228
|
+
The shape `getRuntimeApiMaps()` returns for each route:
|
|
229
|
+
|
|
230
|
+
```ts
|
|
231
|
+
interface RuntimeApiEntry {
|
|
232
|
+
auth: AuthProps;
|
|
233
|
+
main: (params: { data, user, functions, stream }) => Promise<RuntimeApiResponse>;
|
|
234
|
+
inputType?: string;
|
|
235
|
+
inputTypeFilePath?: string;
|
|
236
|
+
rateLimit?: number | false;
|
|
237
|
+
httpMethod?: HttpMethod;
|
|
238
|
+
validation?: 'strict' | 'relaxed' | { input: 'skip' | 'strict' };
|
|
239
|
+
}
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
Devkit emits these. The handler reads them.
|
|
243
|
+
|
|
244
|
+
## Hooks dispatched
|
|
245
|
+
|
|
246
|
+
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).
|
|
247
|
+
|
|
248
|
+
## Config keys
|
|
249
|
+
|
|
250
|
+
| Key | Effect |
|
|
251
|
+
| --- | --- |
|
|
252
|
+
| `dev.warnOnMissingInputType` | Toggle the dev-only `warnIfInputTypeMissing` log. |
|
|
253
|
+
| `paths.generatedSocketTypes` | Output path for `apiTypes.generated.ts`. |
|
|
254
|
+
| `paths.generatedApiSchemas` | Output path for `apiInputSchemas.generated.ts`. |
|
|
255
|
+
| `paths.generatedApiDocs` | Output path for `apiDocs.generated.json`. |
|
|
256
|
+
|
|
257
|
+
## Related
|
|
258
|
+
|
|
259
|
+
- API request lifecycle: [`./api-request-lifecycle.md`](./api-request-lifecycle.md)
|
|
260
|
+
- Routing conventions: [`./routing-conventions.md`](./routing-conventions.md)
|
|
261
|
+
- Error handling: [`./error-handling.md`](./error-handling.md)
|
|
262
|
+
- Architecture: [`/docs/ARCHITECTURE_ROUTING.md`](../../../docs/ARCHITECTURE_ROUTING.md) (see "Generated maps")
|
|
263
|
+
- Devkit type-map generation: `@luckystack/devkit/docs/type-map-generation.md`
|
|
264
|
+
- Root rule on casts: [`/.claude/CLAUDE.md`](../../../.claude/CLAUDE.md) §16
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# Rate Limiting
|
|
2
|
+
|
|
3
|
+
> Last updated: 2026-05-20
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
Every API request runs through two rate-limit buckets, in order. The per-route bucket protects an individual endpoint from a single caller; the global per-IP bucket protects the server as a whole from a single source hitting many endpoints. Both buckets use the same strategy backend (configurable via `registerRateLimitStrategy` in `@luckystack/core`; defaults to memory-or-redis depending on `rateLimiting.store`), share the same `windowMs`, and emit the same `rateLimitExceeded` hook on rejection.
|
|
8
|
+
|
|
9
|
+
Rate-limit logic is identical on both transports — `handleApiRequest` and `handleHttpApiRequest` apply the same buckets in the same order. The only transport-specific difference is where the IP comes from: the socket transport reads `socket.handshake.address`; the HTTP transport takes a `requesterIp` parameter (the HTTP listener in `@luckystack/server` derives it from `req.socket.remoteAddress` + optional forwarded-header parsing).
|
|
10
|
+
|
|
11
|
+
Rejection responds with `api.rateLimitExceeded` + HTTP `429`, and includes `errorParams: [{ key: 'seconds', value: resetIn }]` so consumers can render a localized "try again in N seconds" message. The hook payload exposes the full key, scope, and counts for dashboards / alerting.
|
|
12
|
+
|
|
13
|
+
## The two buckets
|
|
14
|
+
|
|
15
|
+
### 1. Per-route bucket
|
|
16
|
+
|
|
17
|
+
Keyed per-caller per-route. Drops requests that exceed `apiEntry.rateLimit` (per route) or `rateLimiting.defaultApiLimit` (fallback) within `windowMs`.
|
|
18
|
+
|
|
19
|
+
**Key format:**
|
|
20
|
+
|
|
21
|
+
| Caller | Key |
|
|
22
|
+
| --- | --- |
|
|
23
|
+
| Authenticated (`token` present) | `token:<token>:api:<resolvedName>` |
|
|
24
|
+
| Anonymous (no token) | `ip:<requesterIp>:api:<resolvedName>` |
|
|
25
|
+
|
|
26
|
+
The `<resolvedName>` is the normalized route name returned by `parseTransportRouteName` (e.g. `settings/updateUser/v1`). Using the token (not user ID) as the key ensures the bucket sticks to the session even if multiple sessions exist for the same user under `session.perUser === 'multiple'`.
|
|
27
|
+
|
|
28
|
+
**Per-route override:** a route file can declare its own limit (or disable it):
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
// src/settings/_api/updateUser_v1.ts
|
|
32
|
+
export const rateLimit: number | false = 30; // 30 requests per window
|
|
33
|
+
// or:
|
|
34
|
+
export const rateLimit = false; // No per-route bucket
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
When `rateLimit === false` or `0`, the per-route bucket is skipped entirely — the global IP bucket still applies.
|
|
38
|
+
|
|
39
|
+
When `rateLimit === undefined` (the export is absent), the handler falls back to `rateLimiting.defaultApiLimit`. If that is also `false`, the per-route bucket is skipped.
|
|
40
|
+
|
|
41
|
+
### 2. Global per-IP bucket
|
|
42
|
+
|
|
43
|
+
Keyed only by IP, shared across every route. Drops requests from a single IP that exceed `rateLimiting.defaultIpLimit` in `windowMs`, regardless of which routes they hit.
|
|
44
|
+
|
|
45
|
+
**Key format:** `ip:<requesterIp>:api:all`
|
|
46
|
+
|
|
47
|
+
When `defaultIpLimit === false` or `0`, the global bucket is skipped entirely. Per-route limits still apply.
|
|
48
|
+
|
|
49
|
+
The global bucket runs **after** the per-route bucket — a route that has burned through its per-route allowance returns `api.rateLimitExceeded` without ever touching the global bucket, so the per-route violator does not also consume global capacity.
|
|
50
|
+
|
|
51
|
+
## Bucket evaluation order
|
|
52
|
+
|
|
53
|
+
Both transports execute exactly:
|
|
54
|
+
|
|
55
|
+
1. Compute `effectiveApiLimit = apiEntry.rateLimit ?? rateLimiting.defaultApiLimit`.
|
|
56
|
+
2. If `effectiveApiLimit !== false && effectiveApiLimit > 0`:
|
|
57
|
+
- Build the per-route key.
|
|
58
|
+
- `await checkRateLimit({ key, limit, windowMs })`.
|
|
59
|
+
- On `!allowed`, dispatch `rateLimitExceeded` with `scope: token ? 'user' : 'route'`, respond `api.rateLimitExceeded` + `429`, abort.
|
|
60
|
+
3. Read `defaultIpLimit = rateLimiting.defaultIpLimit`.
|
|
61
|
+
4. If `defaultIpLimit !== false && defaultIpLimit > 0`:
|
|
62
|
+
- Build the global IP key.
|
|
63
|
+
- `await checkRateLimit(...)`.
|
|
64
|
+
- On `!allowed`, dispatch `rateLimitExceeded` with `scope: 'ip'`, respond `api.rateLimitExceeded` + `429`, abort.
|
|
65
|
+
|
|
66
|
+
The rate-limit step sits between auth and validation. Auth runs first so unauthenticated probes can't burn through anonymous IP buckets enumerating routes. Validation runs after rate limit so a flood of malformed payloads from a single caller is throttled the same way as well-formed traffic.
|
|
67
|
+
|
|
68
|
+
## API Reference
|
|
69
|
+
|
|
70
|
+
### `applyApiRateLimits` (internal, socket transport)
|
|
71
|
+
|
|
72
|
+
**Signature** (paraphrased):
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
const applyApiRateLimits = async (params: {
|
|
76
|
+
apiEntry: RuntimeApiEntry;
|
|
77
|
+
resolvedName: string;
|
|
78
|
+
token: string | null;
|
|
79
|
+
socket: Socket;
|
|
80
|
+
user: SessionLayout | null;
|
|
81
|
+
emitApiError: EmitApiError;
|
|
82
|
+
}): Promise<boolean>;
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
| Param | Notes |
|
|
86
|
+
| --- | --- |
|
|
87
|
+
| `apiEntry` | Read `rateLimit` field. |
|
|
88
|
+
| `resolvedName` | Used to build the per-route key. |
|
|
89
|
+
| `token` | When non-null, drives `token:<token>:...` key + `scope: 'user'`. |
|
|
90
|
+
| `socket` | Read `socket.handshake.address` for the IP fallback. |
|
|
91
|
+
| `user` | Surfaced as `userId` in the `rateLimitExceeded` payload. |
|
|
92
|
+
| `emitApiError` | Closure that emits the localized error envelope on the response channel. |
|
|
93
|
+
|
|
94
|
+
**Returns**: `Promise<boolean>` — `true` if both buckets allowed the request, `false` if either rejected.
|
|
95
|
+
|
|
96
|
+
### Inline rate-limit block in `handleHttpApiRequest`
|
|
97
|
+
|
|
98
|
+
Same logic, inlined rather than extracted into a helper. Reads `requesterIp` (or `'anonymous'` for the per-route bucket key, `'unknown'` for the global bucket key) instead of `socket.handshake.address`.
|
|
99
|
+
|
|
100
|
+
### `checkRateLimit({ key, limit, windowMs })` (re-exported from `@luckystack/core`)
|
|
101
|
+
|
|
102
|
+
The underlying strategy call.
|
|
103
|
+
|
|
104
|
+
**Signature**:
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
async function checkRateLimit(params: {
|
|
108
|
+
key: string;
|
|
109
|
+
limit: number;
|
|
110
|
+
windowMs: number;
|
|
111
|
+
}): Promise<{ allowed: boolean; resetIn: number; current: number }>;
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
**Behavior**:
|
|
115
|
+
|
|
116
|
+
- Delegates to the registered strategy (`registerRateLimitStrategy`). Default strategy uses Redis when `rateLimiting.store === 'redis'`, otherwise an in-memory token-bucket.
|
|
117
|
+
- `resetIn` is seconds until the window resets, used as the `seconds` error param.
|
|
118
|
+
- Backend implementation lives in `@luckystack/core/src/rateLimiter.ts`; see its docs for sliding-window semantics and the `redisKeyPrefix` config option.
|
|
119
|
+
|
|
120
|
+
## Hooks dispatched
|
|
121
|
+
|
|
122
|
+
| Hook | When | Payload (key fields) |
|
|
123
|
+
| --- | --- | --- |
|
|
124
|
+
| `rateLimitExceeded` | A bucket rejects | `scope: 'user' \| 'route' \| 'ip'`, `key: string`, `limit: number`, `windowMs: number`, `count: number` (always `limit + 1` here), `route?: string`, `userId?: string`, `ip?: string` |
|
|
125
|
+
|
|
126
|
+
The hook fires before the error response is emitted, so handlers that want to escalate (alerting, IP-banning) can act on the same request. Handlers run inside `void dispatchHook(...)` — they cannot block or alter the rejection.
|
|
127
|
+
|
|
128
|
+
`scope` distinguishes the three cases:
|
|
129
|
+
|
|
130
|
+
- `'user'` — per-route bucket rejected an authenticated caller (`token` was non-null).
|
|
131
|
+
- `'route'` — per-route bucket rejected an anonymous caller (no token; keyed by IP).
|
|
132
|
+
- `'ip'` — global per-IP bucket rejected.
|
|
133
|
+
|
|
134
|
+
## Error response
|
|
135
|
+
|
|
136
|
+
Both transports return the same envelope on rejection:
|
|
137
|
+
|
|
138
|
+
```jsonc
|
|
139
|
+
{
|
|
140
|
+
"status": "error",
|
|
141
|
+
"httpStatus": 429,
|
|
142
|
+
"errorCode": "api.rateLimitExceeded",
|
|
143
|
+
"errorParams": [{ "key": "seconds", "value": 42 }],
|
|
144
|
+
"message": "Too many requests. Try again in 42 seconds."
|
|
145
|
+
// message comes from the registered localized normalizer using errorCode + errorParams
|
|
146
|
+
}
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
`resetIn` reflects the strategy's view of when the bucket frees up. The localized normalizer renders the message via the consumer's translate function — see [`./error-handling.md`](./error-handling.md).
|
|
150
|
+
|
|
151
|
+
## Config keys
|
|
152
|
+
|
|
153
|
+
All read at request time via `getProjectConfig()`.
|
|
154
|
+
|
|
155
|
+
| Key | Type | Effect |
|
|
156
|
+
| --- | --- | --- |
|
|
157
|
+
| `rateLimiting.defaultApiLimit` | `number \| false` | Fallback per-route limit when the route does not export `rateLimit`. `false` disables. |
|
|
158
|
+
| `rateLimiting.defaultIpLimit` | `number \| false` | Global per-IP limit across all routes. `false` disables. |
|
|
159
|
+
| `rateLimiting.windowMs` | `number` | Window size for both buckets, in ms. Default 60000 (one minute). |
|
|
160
|
+
| `rateLimiting.store` | `'memory' \| 'redis'` | Backend for the default strategy. `'redis'` is required for multi-instance consistency. |
|
|
161
|
+
| `rateLimiting.redisKeyPrefix` | `string` | Prefix prepended to keys when using the Redis backend. |
|
|
162
|
+
| `rateLimiting.enabled` | `boolean` | Master switch on the default strategy. When `false`, every `checkRateLimit` call returns `{ allowed: true }`. |
|
|
163
|
+
|
|
164
|
+
## Disabling rate limiting
|
|
165
|
+
|
|
166
|
+
| Scope | How |
|
|
167
|
+
| --- | --- |
|
|
168
|
+
| One route, per-route bucket only | `export const rateLimit = false;` in the route file. |
|
|
169
|
+
| One route, both buckets | `export const rateLimit = false;` + run the consumer behind a load balancer that absorbs the IP bucket. There is no per-route opt-out for the global bucket. |
|
|
170
|
+
| Globally, per-route bucket | `rateLimiting.defaultApiLimit: false` in `registerProjectConfig`. |
|
|
171
|
+
| Globally, IP bucket | `rateLimiting.defaultIpLimit: false`. |
|
|
172
|
+
| Globally, everything | `rateLimiting.enabled: false` (default strategy honors this and short-circuits). |
|
|
173
|
+
| Replace backend | `registerRateLimitStrategy(myStrategy)` from `@luckystack/core` — useful for edge-KV-backed limits, per-tier quotas, or no-op test strategies. |
|
|
174
|
+
|
|
175
|
+
## Edge cases
|
|
176
|
+
|
|
177
|
+
- **Anonymous request with no IP** — the socket transport falls back to `'unknown'` for `socket.handshake.address`; the HTTP transport falls back to `'anonymous'` for the per-route bucket and `'unknown'` for the global bucket. All anonymous callers that lack an IP share the same bucket. Front your server with a load balancer that always supplies `X-Forwarded-For` (parsed by `@luckystack/server`'s request adapter) to avoid this collision.
|
|
178
|
+
- **Token present but session deleted** — the bucket is still keyed by the token string; subsequent requests with the same expired token still throttle. Once the session is fully GC'd from `getSession`, the auth gate rejects with `auth.required` before rate-limit checks run (auth runs first).
|
|
179
|
+
- **Race within `windowMs`** — `checkRateLimit` is atomic on the strategy backend (`INCR` + `EXPIRE` for Redis, lock-free counter for memory). Two requests arriving at the same millisecond both count toward the limit.
|
|
180
|
+
- **Per-route `rateLimit = 0`** — equivalent to `false` (bucket skipped). The handler explicitly guards on `effectiveApiLimit > 0`.
|
|
181
|
+
- **Custom strategy returns `allowed: false` with `resetIn: 0`** — the response still echoes `seconds: 0`. Renderers should treat `seconds <= 0` as "right now" / "very soon".
|
|
182
|
+
|
|
183
|
+
## Related
|
|
184
|
+
|
|
185
|
+
- API request lifecycle: [`./api-request-lifecycle.md`](./api-request-lifecycle.md)
|
|
186
|
+
- Error handling: [`./error-handling.md`](./error-handling.md)
|
|
187
|
+
- Rate-limit strategy: `@luckystack/core/docs/rate-limit-strategy.md`
|
|
188
|
+
- Architecture: [`/docs/ARCHITECTURE_API.md`](../../../docs/ARCHITECTURE_API.md) (see "Rate Limiting")
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# Routing Conventions
|
|
2
|
+
|
|
3
|
+
> Last updated: 2026-05-20
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
`@luckystack/api` is transport-agnostic — it does not own file-based routing. Discovery of `_api/<name>_v<N>.ts` files lives in `@luckystack/devkit`, which walks `src/`, parses each file's exports, and emits two artifacts the API handler reads at runtime: the route map (`getRuntimeApiMaps()`) and the generated type map (`apiTypes.generated.ts`). This document describes the route-name conventions both handlers enforce, the per-route exports the runtime map carries, and the HTTP-method inference contract the HTTP transport applies.
|
|
8
|
+
|
|
9
|
+
If a route name on the wire does not match the conventions below, both `handleApiRequest` and `handleHttpApiRequest` reject with `routing.invalidServiceRouteName` before any other side effect. If a name parses but does not match a registered route, both return `api.notFound`.
|
|
10
|
+
|
|
11
|
+
## Wire-level route names
|
|
12
|
+
|
|
13
|
+
Both transports accept the same shape:
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
api/<page>/<name>/v<N>
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
| Segment | Constraints |
|
|
20
|
+
| --- | --- |
|
|
21
|
+
| `api/` | Literal prefix. The HTTP transport accepts `name` with or without it and normalizes; the socket transport requires the prefix to be present in `msg.name`. |
|
|
22
|
+
| `<page>` | Slash-separated page path. May be deeply nested (`organization/settings`). Top-level system APIs use `system` as the page (e.g. `api/system/session/v1`). |
|
|
23
|
+
| `<name>` | Endpoint name. Free-form identifier; influences HTTP method inference (see below). |
|
|
24
|
+
| `v<N>` | Version token (`v1`, `v2`, ...). Required. Files that do not end in `_v<N>.ts` are skipped by the discovery walker. |
|
|
25
|
+
|
|
26
|
+
`parseTransportRouteName({ value, prefix: 'api' })` (exported from `@luckystack/core`) is the single source of truth. The returned `normalizedFullName` is what the handler uses for runtime-map lookup, rate-limit keying, and hook payloads.
|
|
27
|
+
|
|
28
|
+
## File-system convention
|
|
29
|
+
|
|
30
|
+
Discovery is owned by `@luckystack/devkit`. Files are picked up when they live under `src/` and match `**/_api/<name>_v<N>.ts` (or any subdirectory, including `_api/<sub>/<name>_v<N>.ts` for nested routes). The marker segment `_api` is configurable via `registerRoutingRules` but defaults to `_api`.
|
|
31
|
+
|
|
32
|
+
| Path | Resolved route |
|
|
33
|
+
| --- | --- |
|
|
34
|
+
| `src/_api/session_v1.ts` | `api/system/session/v1` (top-level APIs are mapped under the `system` service) |
|
|
35
|
+
| `src/settings/_api/updateUser_v1.ts` | `api/settings/updateUser/v1` |
|
|
36
|
+
| `src/organization/_api/settings/sendInvite_v1.ts` | `api/organization/settings/sendInvite/v1` |
|
|
37
|
+
| `src/playground/_api/streamCounter_v1.ts` | `api/playground/streamCounter/v1` |
|
|
38
|
+
|
|
39
|
+
Files that do not match the `_v<N>` suffix are not discovered. The version digit must be a positive integer. Versions are sorted numerically when emitted into the type map.
|
|
40
|
+
|
|
41
|
+
Folders prefixed with `_` are private — they may sit alongside `_api/`, `_sync/`, `_components/`, etc. without being treated as page routes by the frontend router.
|
|
42
|
+
|
|
43
|
+
## Per-route exports
|
|
44
|
+
|
|
45
|
+
Each route file declares its behavior via exports. The discovery walker captures these via the TypeScript compiler API and emits them into the runtime map.
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
// src/settings/_api/updateUser_v1.ts
|
|
49
|
+
import type { AuthProps } from '@luckystack/core';
|
|
50
|
+
import type { ApiResponse } from 'src/_sockets/apiTypes.generated';
|
|
51
|
+
|
|
52
|
+
export const rateLimit: number | false = 20;
|
|
53
|
+
export const httpMethod = 'POST' as const;
|
|
54
|
+
export const auth: AuthProps = { login: true };
|
|
55
|
+
|
|
56
|
+
export interface ApiParams {
|
|
57
|
+
data: { name: string };
|
|
58
|
+
user: SessionLayout;
|
|
59
|
+
functions: Functions;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const main = async ({ data, user, functions }: ApiParams): Promise<ApiResponse> => {
|
|
63
|
+
// ...
|
|
64
|
+
return { status: 'success', ok: true };
|
|
65
|
+
};
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
| Export | Type | Default | Effect |
|
|
69
|
+
| --- | --- | --- | --- |
|
|
70
|
+
| `main` | `(params) => Promise<ApiResponse>` | required | Handler invoked by `executeApiHandler` inside the `tryCatch` + Sentry span. |
|
|
71
|
+
| `auth` | `AuthProps` | required | Auth gate (see [`./auth-flow.md`](./auth-flow.md)). |
|
|
72
|
+
| `rateLimit` | `number \| false` | `rateLimiting.defaultApiLimit` from config | Per-route requests-per-window cap. `false` disables the per-route bucket; the global IP bucket still applies. See [`./rate-limiting.md`](./rate-limiting.md). |
|
|
73
|
+
| `httpMethod` | `'GET' \| 'POST' \| 'PUT' \| 'DELETE'` | inferred from name | Overrides `inferHttpMethod` for the HTTP transport. Socket transport ignores this field. |
|
|
74
|
+
| `ApiParams` interface | `{ data, user, functions, stream? }` | required | TypeScript-only. The `data` field's type is what `validateInputByType` checks at runtime via the generated Zod schema. |
|
|
75
|
+
| `validation` | `'strict' \| 'relaxed' \| { input: 'skip' \| 'strict' }` | `'strict'` | Socket-only escape hatch for public webhooks where the third-party payload is not modelable in TypeScript. See "Input validation" below. |
|
|
76
|
+
|
|
77
|
+
`ApiResponse` is exact: either `{ status: 'success'; httpStatus?: number; ...payload }` or `{ status: 'error'; errorCode: string; errorParams?: [...]; httpStatus?: number }`. Returning anything else is a runtime error (`api.invalidResponseStatus`).
|
|
78
|
+
|
|
79
|
+
## HTTP-method inference
|
|
80
|
+
|
|
81
|
+
Used by `handleHttpApiRequest` when the route file does not export `httpMethod`. Implemented in `inferHttpMethod` (from `@luckystack/core/src/httpApiUtils.ts`):
|
|
82
|
+
|
|
83
|
+
| Name prefix (case-insensitive, last `/`-segment) | Inferred method |
|
|
84
|
+
| --- | --- |
|
|
85
|
+
| `get*`, `fetch*`, `list*` | `GET` |
|
|
86
|
+
| `delete*`, `remove*` | `DELETE` |
|
|
87
|
+
| `update*`, `edit*`, `patch*` | `PUT` |
|
|
88
|
+
| Everything else (`create*`, `send*`, `sync*`, bare verbs) | `POST` |
|
|
89
|
+
|
|
90
|
+
The inference reads the last segment before the version token. `api/admin/users/getList/v1` infers `GET`; `api/admin/getUsers/v2` also infers `GET`.
|
|
91
|
+
|
|
92
|
+
The socket transport does not validate HTTP method — only the HTTP transport does. Mismatches return `api.methodNotAllowed` + `405` with `errorParams: [{ key: 'method', value: expectedMethod }]`.
|
|
93
|
+
|
|
94
|
+
## Input validation
|
|
95
|
+
|
|
96
|
+
The discovery walker reads the `data` field of the `ApiParams` interface (or whatever the `main` parameter's `data` member is named) and:
|
|
97
|
+
|
|
98
|
+
1. Emits the **text form** of the type into the runtime map (`apiEntry.inputType`).
|
|
99
|
+
2. Emits a **Zod schema** for the same type into `apiInputSchemas.generated.ts` for runtime checking + test-runner fuzz.
|
|
100
|
+
3. Emits the **structural type** into `apiTypes.generated.ts` so `apiRequest({ name, version, data })` on the client gets full autocomplete + a compile-time error if `data` does not match.
|
|
101
|
+
|
|
102
|
+
At request time, `validateInputByType({ typeText, value, rootKey: 'data', filePath })` matches the payload against the stored type. The validator lazy-loads `@luckystack/devkit`'s deep resolver in dev so imported / re-exported aliases expand correctly; in production it falls back to a static resolver.
|
|
103
|
+
|
|
104
|
+
The per-route `validation` flag turns the validator off:
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
// Public webhook receiving a payload from Stripe / Slack / GitHub
|
|
108
|
+
export const validation = 'relaxed' as const;
|
|
109
|
+
|
|
110
|
+
// Or, equivalent:
|
|
111
|
+
export const validation = { input: 'skip' } as const;
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Use sparingly. The skip suppresses both `api.invalidInputType` rejections and the dev-only "no inputType" warning. `postApiValidate` still fires with `{ status: 'success' }` so audit handlers see the skip.
|
|
115
|
+
|
|
116
|
+
## Wire-level route examples
|
|
117
|
+
|
|
118
|
+
```
|
|
119
|
+
api/system/session/v1 ← src/_api/session_v1.ts
|
|
120
|
+
api/system/logout/v1 ← src/_api/logout_v1.ts (built-in socket shortcut)
|
|
121
|
+
api/settings/updateUser/v1 ← src/settings/_api/updateUser_v1.ts
|
|
122
|
+
api/organization-settings/sendInvite/v1
|
|
123
|
+
← src/organization-settings/_api/sendInvite_v1.ts
|
|
124
|
+
api/admin/users/listAll/v3 ← src/admin/_api/users/listAll_v3.ts
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Reserved routes
|
|
128
|
+
|
|
129
|
+
| Route | Owner | Notes |
|
|
130
|
+
| --- | --- | --- |
|
|
131
|
+
| `api/system/logout/v1` | `handleApiRequest` (socket only) | Short-circuited before runtime map lookup. Calls `logout(...)` from `@luckystack/login`. HTTP requests for this route go through the normal pipeline. |
|
|
132
|
+
| `api/system/session/v1` | Project | Conventional location for the session-fetch endpoint. Not framework-reserved. |
|
|
133
|
+
|
|
134
|
+
Consumer routes whose final segment happens to be `logout` (`admin/logout/v1`) are NOT hijacked — the short-circuit matches the full normalized route `system/logout`, not just the trailing segment.
|
|
135
|
+
|
|
136
|
+
## Hooks dispatched
|
|
137
|
+
|
|
138
|
+
Discovery / route-naming hooks live in `@luckystack/devkit`. The runtime API handler does not emit routing-level hooks beyond the standard `pre/postApiValidate` / `pre/postApiExecute` / `pre/postApiRespond` / `transformApiResponse` / `rateLimitExceeded` set. See [`./api-request-lifecycle.md`](./api-request-lifecycle.md).
|
|
139
|
+
|
|
140
|
+
## Config keys
|
|
141
|
+
|
|
142
|
+
| Key | Effect |
|
|
143
|
+
| --- | --- |
|
|
144
|
+
| `paths.srcDir` | Root directory for `_api/` discovery (devkit). |
|
|
145
|
+
| `paths.generatedSocketTypes` | Output path for `apiTypes.generated.ts` (devkit). |
|
|
146
|
+
| `paths.generatedApiSchemas` | Output path for `apiInputSchemas.generated.ts` (devkit). |
|
|
147
|
+
| `paths.generatedApiDocs` | Output path for `apiDocs.generated.json` (devkit). |
|
|
148
|
+
|
|
149
|
+
No routing-specific keys are read at request time by `@luckystack/api`.
|
|
150
|
+
|
|
151
|
+
## Edge cases
|
|
152
|
+
|
|
153
|
+
- **Two files normalize to the same route key.** `assertNoDuplicateNormalizedRouteKeys` (devkit) throws at generation time. The runtime map will reflect whichever file was loaded last; treat the throw as a build-time guard.
|
|
154
|
+
- **File saved without a version suffix.** Discovery skips the file. The route never registers; `apiRequest` calls fail with `api.notFound` at runtime.
|
|
155
|
+
- **`auth` export missing.** TypeScript will not compile; if compilation is bypassed, the runtime map's `apiEntry.auth` is `undefined` and the auth gate treats it as `{ login: false }`. Do not rely on this — declare `auth` explicitly.
|
|
156
|
+
- **Non-`_api/` folders matching the regex.** Discovery only descends through folders; the marker segment `_api` is required, and folders without the underscore prefix are ignored.
|
|
157
|
+
|
|
158
|
+
## Related
|
|
159
|
+
|
|
160
|
+
- API request lifecycle: [`./api-request-lifecycle.md`](./api-request-lifecycle.md)
|
|
161
|
+
- Generated types: [`./generated-types.md`](./generated-types.md)
|
|
162
|
+
- Architecture: [`/docs/ARCHITECTURE_ROUTING.md`](../../../docs/ARCHITECTURE_ROUTING.md)
|
|
163
|
+
- Devkit type-map: `@luckystack/devkit/docs/type-map-generation.md`
|
|
164
|
+
- Devkit loader pipeline: `@luckystack/devkit/docs/loader-pipeline.md`
|