@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
package/dist/index.js
ADDED
|
@@ -0,0 +1,816 @@
|
|
|
1
|
+
// src/handleApiRequest.ts
|
|
2
|
+
import { getSession, logout } from "@luckystack/login";
|
|
3
|
+
import { getProjectConfig } from "@luckystack/core";
|
|
4
|
+
import { getRuntimeApiMaps } from "@luckystack/core";
|
|
5
|
+
import {
|
|
6
|
+
validateRequest,
|
|
7
|
+
checkRateLimit,
|
|
8
|
+
tryCatch,
|
|
9
|
+
parseTransportRouteName,
|
|
10
|
+
buildApiResponseEventName,
|
|
11
|
+
buildApiStreamEventName,
|
|
12
|
+
dispatchHook,
|
|
13
|
+
validateInputByType,
|
|
14
|
+
getLogger
|
|
15
|
+
} from "@luckystack/core";
|
|
16
|
+
import {
|
|
17
|
+
defaultHttpStatusForResponse,
|
|
18
|
+
extractLanguageFromHeader,
|
|
19
|
+
normalizeErrorResponse,
|
|
20
|
+
applyErrorFormatter,
|
|
21
|
+
registerApiAbortController,
|
|
22
|
+
unregisterApiAbortController,
|
|
23
|
+
socketEventNames
|
|
24
|
+
} from "@luckystack/core";
|
|
25
|
+
var shouldLogDev = () => getProjectConfig().logging.devLogs;
|
|
26
|
+
var shouldLogStream = () => getProjectConfig().logging.stream;
|
|
27
|
+
var warnedMissingInputType = /* @__PURE__ */ new Set();
|
|
28
|
+
var warnIfInputTypeMissing = (resolvedName, inputType) => {
|
|
29
|
+
if (!getProjectConfig().dev.warnOnMissingInputType) return;
|
|
30
|
+
if (inputType && inputType.trim().length > 0 && inputType.trim() !== "any") return;
|
|
31
|
+
if (warnedMissingInputType.has(resolvedName)) return;
|
|
32
|
+
warnedMissingInputType.add(resolvedName);
|
|
33
|
+
getLogger().warn(`api: route ${resolvedName} has no inputType \u2014 runtime input validation is disabled. Regenerate types or set the inputType on the handler.`);
|
|
34
|
+
};
|
|
35
|
+
var validateApiMessage = ({ msg, emitApiError }) => {
|
|
36
|
+
const { name, data, responseIndex } = msg;
|
|
37
|
+
if (!responseIndex && typeof responseIndex !== "number") {
|
|
38
|
+
if (shouldLogDev()) {
|
|
39
|
+
getLogger().warn("api: no response index given");
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
if (!name || !data || typeof name != "string" || typeof data != "object") {
|
|
44
|
+
emitApiError({
|
|
45
|
+
response: { status: "error", errorCode: "api.invalidRequest" },
|
|
46
|
+
fallbackHttpStatus: 400
|
|
47
|
+
});
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
return { name, data };
|
|
51
|
+
};
|
|
52
|
+
var checkApiAuth = ({ apiEntry, user, name, emitApiError }) => {
|
|
53
|
+
if (apiEntry.auth.login && !user?.id) {
|
|
54
|
+
if (shouldLogDev()) {
|
|
55
|
+
getLogger().warn(`api: ${name} requires login`, { route: name });
|
|
56
|
+
}
|
|
57
|
+
emitApiError({
|
|
58
|
+
response: { status: "error", errorCode: "auth.required" },
|
|
59
|
+
fallbackHttpStatus: 401
|
|
60
|
+
});
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
const authResult = validateRequest({ auth: apiEntry.auth, user });
|
|
64
|
+
if (authResult.status === "error") {
|
|
65
|
+
if (shouldLogDev()) {
|
|
66
|
+
getLogger().warn(`api: auth failed for ${name}`, { route: name, errorCode: authResult.errorCode });
|
|
67
|
+
}
|
|
68
|
+
emitApiError({
|
|
69
|
+
response: {
|
|
70
|
+
status: "error",
|
|
71
|
+
errorCode: authResult.errorCode || "auth.forbidden",
|
|
72
|
+
errorParams: authResult.errorParams,
|
|
73
|
+
httpStatus: authResult.httpStatus
|
|
74
|
+
},
|
|
75
|
+
fallbackHttpStatus: authResult.httpStatus ?? 403
|
|
76
|
+
});
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
return true;
|
|
80
|
+
};
|
|
81
|
+
var applyApiRateLimits = async ({ apiEntry, resolvedName, token, socket, user, emitApiError }) => {
|
|
82
|
+
const apiRateLimit = apiEntry.rateLimit;
|
|
83
|
+
const effectiveApiLimit = apiRateLimit === void 0 ? getProjectConfig().rateLimiting.defaultApiLimit : apiRateLimit;
|
|
84
|
+
if (effectiveApiLimit !== false && effectiveApiLimit > 0) {
|
|
85
|
+
const requesterIdentity = token ?? socket.handshake.address ?? "unknown";
|
|
86
|
+
const keyPrefix = token ? "token" : "ip";
|
|
87
|
+
const rateLimitKey = `${keyPrefix}:${requesterIdentity}:api:${resolvedName}`;
|
|
88
|
+
const { allowed, resetIn } = await checkRateLimit({
|
|
89
|
+
key: rateLimitKey,
|
|
90
|
+
limit: effectiveApiLimit,
|
|
91
|
+
windowMs: getProjectConfig().rateLimiting.windowMs
|
|
92
|
+
});
|
|
93
|
+
if (!allowed) {
|
|
94
|
+
void dispatchHook("rateLimitExceeded", {
|
|
95
|
+
scope: token ? "user" : "route",
|
|
96
|
+
key: rateLimitKey,
|
|
97
|
+
limit: effectiveApiLimit,
|
|
98
|
+
windowMs: getProjectConfig().rateLimiting.windowMs,
|
|
99
|
+
count: effectiveApiLimit + 1,
|
|
100
|
+
route: resolvedName,
|
|
101
|
+
userId: user?.id
|
|
102
|
+
});
|
|
103
|
+
if (shouldLogDev()) {
|
|
104
|
+
getLogger().warn(`api: rate limit exceeded for ${resolvedName}`, { route: resolvedName, key: rateLimitKey });
|
|
105
|
+
}
|
|
106
|
+
emitApiError({
|
|
107
|
+
response: {
|
|
108
|
+
status: "error",
|
|
109
|
+
errorCode: "api.rateLimitExceeded",
|
|
110
|
+
errorParams: [{ key: "seconds", value: resetIn }]
|
|
111
|
+
},
|
|
112
|
+
fallbackHttpStatus: 429
|
|
113
|
+
});
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const defaultIpLimit = getProjectConfig().rateLimiting.defaultIpLimit;
|
|
118
|
+
if (defaultIpLimit !== false && defaultIpLimit > 0) {
|
|
119
|
+
const requesterIp = socket.handshake.address ?? "unknown";
|
|
120
|
+
const { allowed, resetIn } = await checkRateLimit({
|
|
121
|
+
key: `ip:${requesterIp}:api:all`,
|
|
122
|
+
limit: defaultIpLimit,
|
|
123
|
+
windowMs: getProjectConfig().rateLimiting.windowMs
|
|
124
|
+
});
|
|
125
|
+
if (!allowed) {
|
|
126
|
+
void dispatchHook("rateLimitExceeded", {
|
|
127
|
+
scope: "ip",
|
|
128
|
+
key: `ip:${requesterIp}:api:all`,
|
|
129
|
+
limit: defaultIpLimit,
|
|
130
|
+
windowMs: getProjectConfig().rateLimiting.windowMs,
|
|
131
|
+
count: defaultIpLimit + 1,
|
|
132
|
+
ip: requesterIp
|
|
133
|
+
});
|
|
134
|
+
if (shouldLogDev()) {
|
|
135
|
+
getLogger().warn(`api: global IP rate limit exceeded`, { ip: requesterIp });
|
|
136
|
+
}
|
|
137
|
+
emitApiError({
|
|
138
|
+
response: {
|
|
139
|
+
status: "error",
|
|
140
|
+
errorCode: "api.rateLimitExceeded",
|
|
141
|
+
errorParams: [{ key: "seconds", value: resetIn }]
|
|
142
|
+
},
|
|
143
|
+
fallbackHttpStatus: 429
|
|
144
|
+
});
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return true;
|
|
149
|
+
};
|
|
150
|
+
var executeApiHandler = async ({ apiEntry, normalizedData, user, functionsObject, resolvedName, emitStream, abortSignal, flushPressure }) => {
|
|
151
|
+
const executeStart = Date.now();
|
|
152
|
+
const [error, result] = await tryCatch(
|
|
153
|
+
async () => await apiEntry.main({ data: normalizedData, user, functions: functionsObject, stream: emitStream, abortSignal, flushPressure }),
|
|
154
|
+
void 0,
|
|
155
|
+
{
|
|
156
|
+
handler: "handleApiRequest",
|
|
157
|
+
api: resolvedName,
|
|
158
|
+
userId: user?.id,
|
|
159
|
+
transport: "socket"
|
|
160
|
+
}
|
|
161
|
+
);
|
|
162
|
+
return { error: error ?? null, result: result ?? void 0, durationMs: Date.now() - executeStart };
|
|
163
|
+
};
|
|
164
|
+
var buildApiResponseEnvelope = ({
|
|
165
|
+
resolvedName,
|
|
166
|
+
error,
|
|
167
|
+
result,
|
|
168
|
+
preferredLocale,
|
|
169
|
+
user
|
|
170
|
+
}) => {
|
|
171
|
+
if (error) {
|
|
172
|
+
if (shouldLogDev()) {
|
|
173
|
+
getLogger().error(`api: error in ${resolvedName}`, error, { route: resolvedName });
|
|
174
|
+
}
|
|
175
|
+
return { ...normalizeErrorResponse({
|
|
176
|
+
response: { status: "error", errorCode: "api.internalServerError" },
|
|
177
|
+
preferredLocale,
|
|
178
|
+
userLanguage: user?.language,
|
|
179
|
+
fallbackHttpStatus: 500
|
|
180
|
+
}) };
|
|
181
|
+
}
|
|
182
|
+
if (result === void 0 || result === null) {
|
|
183
|
+
if (shouldLogDev()) {
|
|
184
|
+
getLogger().warn(`api: ${resolvedName} returned nothing`);
|
|
185
|
+
}
|
|
186
|
+
return { ...normalizeErrorResponse({
|
|
187
|
+
response: { status: "error", errorCode: "api.emptyResponse" },
|
|
188
|
+
preferredLocale,
|
|
189
|
+
userLanguage: user?.language,
|
|
190
|
+
fallbackHttpStatus: 500
|
|
191
|
+
}) };
|
|
192
|
+
}
|
|
193
|
+
if (shouldLogDev()) {
|
|
194
|
+
getLogger().debug(`api: ${resolvedName} completed`);
|
|
195
|
+
}
|
|
196
|
+
if (result.status !== "success" && result.status !== "error") {
|
|
197
|
+
return { ...normalizeErrorResponse({
|
|
198
|
+
response: { status: "error", errorCode: "api.invalidResponseStatus" },
|
|
199
|
+
preferredLocale,
|
|
200
|
+
userLanguage: user?.language,
|
|
201
|
+
fallbackHttpStatus: 500
|
|
202
|
+
}) };
|
|
203
|
+
}
|
|
204
|
+
if (result.status === "error") {
|
|
205
|
+
return { ...normalizeErrorResponse({
|
|
206
|
+
response: result,
|
|
207
|
+
preferredLocale,
|
|
208
|
+
userLanguage: user?.language,
|
|
209
|
+
fallbackHttpStatus: defaultHttpStatusForResponse({
|
|
210
|
+
status: "error",
|
|
211
|
+
explicitHttpStatus: typeof result.httpStatus === "number" ? result.httpStatus : void 0
|
|
212
|
+
})
|
|
213
|
+
}) };
|
|
214
|
+
}
|
|
215
|
+
return {
|
|
216
|
+
...result,
|
|
217
|
+
status: "success",
|
|
218
|
+
httpStatus: defaultHttpStatusForResponse({
|
|
219
|
+
status: "success",
|
|
220
|
+
explicitHttpStatus: typeof result.httpStatus === "number" ? result.httpStatus : void 0
|
|
221
|
+
})
|
|
222
|
+
};
|
|
223
|
+
};
|
|
224
|
+
var emitApiResult = async ({
|
|
225
|
+
socket,
|
|
226
|
+
responseIndex,
|
|
227
|
+
resolvedName,
|
|
228
|
+
error,
|
|
229
|
+
result,
|
|
230
|
+
preferredLocale,
|
|
231
|
+
user,
|
|
232
|
+
perRouteFormatter
|
|
233
|
+
}) => {
|
|
234
|
+
const envelope = buildApiResponseEnvelope({ resolvedName, error, result, preferredLocale, user });
|
|
235
|
+
const preRespond = { routeName: resolvedName, user, response: envelope };
|
|
236
|
+
const preRespondResult = await dispatchHook("preApiRespond", preRespond);
|
|
237
|
+
const finalResponse = preRespondResult.stopped ? { ...normalizeErrorResponse({
|
|
238
|
+
response: { status: "error", errorCode: preRespondResult.signal.errorCode },
|
|
239
|
+
preferredLocale,
|
|
240
|
+
userLanguage: user?.language,
|
|
241
|
+
fallbackHttpStatus: preRespondResult.signal.httpStatus ?? 403
|
|
242
|
+
}) } : preRespond.response;
|
|
243
|
+
const transformPayload = { routeName: resolvedName, user, response: finalResponse };
|
|
244
|
+
await dispatchHook("transformApiResponse", transformPayload);
|
|
245
|
+
const formattedResponse = applyErrorFormatter({
|
|
246
|
+
response: transformPayload.response,
|
|
247
|
+
routeName: resolvedName,
|
|
248
|
+
transport: "socket",
|
|
249
|
+
userId: user?.id,
|
|
250
|
+
perRouteFormatter
|
|
251
|
+
});
|
|
252
|
+
socket.emit(buildApiResponseEventName(responseIndex), formattedResponse);
|
|
253
|
+
await dispatchHook("postApiRespond", { routeName: resolvedName, user, response: formattedResponse });
|
|
254
|
+
};
|
|
255
|
+
async function handleApiRequest({ msg, socket, token }) {
|
|
256
|
+
if (typeof msg != "object") {
|
|
257
|
+
if (shouldLogDev()) {
|
|
258
|
+
getLogger().warn("api: socket message was not a json object");
|
|
259
|
+
}
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
const { responseIndex } = msg;
|
|
263
|
+
const user = await getSession(token);
|
|
264
|
+
const preferredLocale = extractLanguageFromHeader(socket.handshake.headers["x-language"]) || extractLanguageFromHeader(socket.handshake.headers["accept-language"]) || void 0;
|
|
265
|
+
let currentRouteName;
|
|
266
|
+
let currentPerRouteFormatter;
|
|
267
|
+
const emitApiError = ({ response, fallbackHttpStatus }) => {
|
|
268
|
+
const normalized = normalizeErrorResponse({
|
|
269
|
+
response,
|
|
270
|
+
preferredLocale,
|
|
271
|
+
userLanguage: user?.language,
|
|
272
|
+
fallbackHttpStatus
|
|
273
|
+
});
|
|
274
|
+
const formatted = applyErrorFormatter({
|
|
275
|
+
response: normalized,
|
|
276
|
+
routeName: currentRouteName ?? "api/unknown",
|
|
277
|
+
transport: "socket",
|
|
278
|
+
userId: user?.id,
|
|
279
|
+
perRouteFormatter: currentPerRouteFormatter
|
|
280
|
+
});
|
|
281
|
+
socket.emit(buildApiResponseEventName(responseIndex), formatted);
|
|
282
|
+
};
|
|
283
|
+
const validated = validateApiMessage({ msg, emitApiError });
|
|
284
|
+
if (!validated) return;
|
|
285
|
+
const { name, data: normalizedData } = validated;
|
|
286
|
+
const parsedRoute = parseTransportRouteName({ value: name, prefix: "api" });
|
|
287
|
+
if (parsedRoute.status === "error") {
|
|
288
|
+
return emitApiError({
|
|
289
|
+
response: {
|
|
290
|
+
status: "error",
|
|
291
|
+
errorCode: "routing.invalidServiceRouteName",
|
|
292
|
+
errorParams: [{ key: "name", value: name }]
|
|
293
|
+
},
|
|
294
|
+
fallbackHttpStatus: 400
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
const resolvedName = parsedRoute.normalizedFullName;
|
|
298
|
+
currentRouteName = resolvedName;
|
|
299
|
+
if (parsedRoute.serviceRoute.normalizedRouteName === "system/logout") {
|
|
300
|
+
await logout({ token, socket, userId: user?.id || null });
|
|
301
|
+
return socket.emit(buildApiResponseEventName(responseIndex), {
|
|
302
|
+
status: "success",
|
|
303
|
+
httpStatus: 200,
|
|
304
|
+
result: true
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
if (shouldLogDev()) {
|
|
308
|
+
getLogger().debug(`api: ${resolvedName} called`);
|
|
309
|
+
}
|
|
310
|
+
const { apisObject, functionsObject } = await getRuntimeApiMaps();
|
|
311
|
+
if (!apisObject[resolvedName]) {
|
|
312
|
+
return emitApiError({
|
|
313
|
+
response: {
|
|
314
|
+
status: "error",
|
|
315
|
+
errorCode: "api.notFound",
|
|
316
|
+
errorParams: [{ key: "name", value: name }]
|
|
317
|
+
},
|
|
318
|
+
fallbackHttpStatus: 404
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
const apiEntry = apisObject[resolvedName];
|
|
322
|
+
currentPerRouteFormatter = apiEntry.errorFormatter;
|
|
323
|
+
const abortController = new AbortController();
|
|
324
|
+
const abortKey = registerApiAbortController(socket.id, responseIndex, abortController);
|
|
325
|
+
const onSocketDisconnect = () => {
|
|
326
|
+
abortController.abort();
|
|
327
|
+
};
|
|
328
|
+
socket.once(socketEventNames.disconnect, onSocketDisconnect);
|
|
329
|
+
let cleanupDone = false;
|
|
330
|
+
const cleanupRequest = () => {
|
|
331
|
+
if (cleanupDone) return;
|
|
332
|
+
cleanupDone = true;
|
|
333
|
+
socket.off(socketEventNames.disconnect, onSocketDisconnect);
|
|
334
|
+
unregisterApiAbortController(abortKey);
|
|
335
|
+
};
|
|
336
|
+
const emitStream = (payload = {}) => {
|
|
337
|
+
if (abortController.signal.aborted) {
|
|
338
|
+
if (shouldLogStream()) {
|
|
339
|
+
getLogger().debug(`api: ${resolvedName} stream skipped \u2014 request aborted`);
|
|
340
|
+
}
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
if (shouldLogStream()) {
|
|
344
|
+
getLogger().debug(`api: ${resolvedName} stream`, { payload });
|
|
345
|
+
}
|
|
346
|
+
socket.emit(buildApiStreamEventName(responseIndex), payload);
|
|
347
|
+
};
|
|
348
|
+
const apiFlushPressure = async ({ thresholdBytes } = {}) => {
|
|
349
|
+
if (abortController.signal.aborted) return;
|
|
350
|
+
const effectiveThresholdBytes = typeof thresholdBytes === "number" && thresholdBytes > 0 ? thresholdBytes : 1048576;
|
|
351
|
+
const packetThreshold = Math.max(1, Math.ceil(effectiveThresholdBytes / 1024));
|
|
352
|
+
while (true) {
|
|
353
|
+
if (abortController.signal.aborted) return;
|
|
354
|
+
const conn = socket.conn;
|
|
355
|
+
const packets = conn?.writeBuffer?.length ?? 0;
|
|
356
|
+
const writable = conn?.transport?.writable ?? true;
|
|
357
|
+
if (!writable) return;
|
|
358
|
+
if (packets < packetThreshold) return;
|
|
359
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
360
|
+
}
|
|
361
|
+
};
|
|
362
|
+
if (!checkApiAuth({ apiEntry, user, name, emitApiError })) {
|
|
363
|
+
cleanupRequest();
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
const rateLimitOk = await applyApiRateLimits({ apiEntry, resolvedName, token, socket, user, emitApiError });
|
|
367
|
+
if (!rateLimitOk) {
|
|
368
|
+
cleanupRequest();
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
const validationMode = (() => {
|
|
372
|
+
const v = apiEntry.validation;
|
|
373
|
+
if (!v) return "strict";
|
|
374
|
+
if (typeof v === "string") return v;
|
|
375
|
+
return v.input === "skip" ? "relaxed" : "strict";
|
|
376
|
+
})();
|
|
377
|
+
if (validationMode === "strict") {
|
|
378
|
+
warnIfInputTypeMissing(resolvedName, apiEntry.inputType);
|
|
379
|
+
}
|
|
380
|
+
await dispatchHook("preApiValidate", { routeName: resolvedName, data: normalizedData, user, transport: "socket" });
|
|
381
|
+
if (validationMode === "strict") {
|
|
382
|
+
const inputValidation = await validateInputByType({
|
|
383
|
+
typeText: apiEntry.inputType,
|
|
384
|
+
value: normalizedData,
|
|
385
|
+
rootKey: "data",
|
|
386
|
+
filePath: apiEntry.inputTypeFilePath
|
|
387
|
+
});
|
|
388
|
+
await dispatchHook("postApiValidate", {
|
|
389
|
+
routeName: resolvedName,
|
|
390
|
+
data: normalizedData,
|
|
391
|
+
user,
|
|
392
|
+
validation: inputValidation,
|
|
393
|
+
transport: "socket"
|
|
394
|
+
});
|
|
395
|
+
if (inputValidation.status === "error") {
|
|
396
|
+
cleanupRequest();
|
|
397
|
+
return emitApiError({
|
|
398
|
+
response: {
|
|
399
|
+
status: "error",
|
|
400
|
+
errorCode: "api.invalidInputType",
|
|
401
|
+
errorParams: [{ key: "message", value: inputValidation.message }]
|
|
402
|
+
},
|
|
403
|
+
fallbackHttpStatus: 400
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
} else {
|
|
407
|
+
await dispatchHook("postApiValidate", {
|
|
408
|
+
routeName: resolvedName,
|
|
409
|
+
data: normalizedData,
|
|
410
|
+
user,
|
|
411
|
+
validation: { status: "success" },
|
|
412
|
+
transport: "socket"
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
const executePayload = {
|
|
416
|
+
routeName: resolvedName,
|
|
417
|
+
data: normalizedData,
|
|
418
|
+
user,
|
|
419
|
+
transport: "socket",
|
|
420
|
+
result: void 0,
|
|
421
|
+
error: null,
|
|
422
|
+
durationMs: 0
|
|
423
|
+
};
|
|
424
|
+
const preExecuteResult = await dispatchHook("preApiExecute", executePayload);
|
|
425
|
+
if (preExecuteResult.stopped) {
|
|
426
|
+
cleanupRequest();
|
|
427
|
+
return emitApiError({
|
|
428
|
+
response: { status: "error", errorCode: preExecuteResult.signal.errorCode },
|
|
429
|
+
fallbackHttpStatus: preExecuteResult.signal.httpStatus ?? 403
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
const { error, result, durationMs } = await executeApiHandler({
|
|
433
|
+
apiEntry,
|
|
434
|
+
normalizedData,
|
|
435
|
+
user,
|
|
436
|
+
functionsObject,
|
|
437
|
+
resolvedName,
|
|
438
|
+
emitStream,
|
|
439
|
+
abortSignal: abortController.signal,
|
|
440
|
+
flushPressure: apiFlushPressure
|
|
441
|
+
});
|
|
442
|
+
executePayload.result = result;
|
|
443
|
+
executePayload.error = error ?? null;
|
|
444
|
+
executePayload.durationMs = durationMs;
|
|
445
|
+
await dispatchHook("postApiExecute", executePayload);
|
|
446
|
+
await emitApiResult({ socket, responseIndex, resolvedName, error, result, preferredLocale, user, perRouteFormatter: apiEntry.errorFormatter });
|
|
447
|
+
cleanupRequest();
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// src/handleHttpApiRequest.ts
|
|
451
|
+
import { getSession as getSession2 } from "@luckystack/login";
|
|
452
|
+
import {
|
|
453
|
+
getProjectConfig as getProjectConfig2,
|
|
454
|
+
getRuntimeApiMaps as getRuntimeApiMapsFromSource,
|
|
455
|
+
validateRequest as validateRequest2,
|
|
456
|
+
checkRateLimit as checkRateLimit2,
|
|
457
|
+
inferHttpMethod,
|
|
458
|
+
tryCatch as tryCatch2,
|
|
459
|
+
parseTransportRouteName as parseTransportRouteName2,
|
|
460
|
+
validateInputByType as validateInputByType2,
|
|
461
|
+
dispatchHook as dispatchHook2,
|
|
462
|
+
getLogger as getLogger2,
|
|
463
|
+
defaultHttpStatusForResponse as defaultHttpStatusForResponse2,
|
|
464
|
+
extractLanguageFromHeader as extractLanguageFromHeader2,
|
|
465
|
+
normalizeErrorResponse as normalizeErrorResponse2,
|
|
466
|
+
applyErrorFormatter as applyErrorFormatter2
|
|
467
|
+
} from "@luckystack/core";
|
|
468
|
+
var httpApiFlushPressureNoop = async () => {
|
|
469
|
+
};
|
|
470
|
+
var isRuntimeApiResult = (value) => {
|
|
471
|
+
if (!value || typeof value !== "object") {
|
|
472
|
+
return false;
|
|
473
|
+
}
|
|
474
|
+
const status = value.status;
|
|
475
|
+
return status === "success" || status === "error";
|
|
476
|
+
};
|
|
477
|
+
var shouldLogDev2 = () => getProjectConfig2().logging.devLogs;
|
|
478
|
+
var shouldLogStream2 = () => getProjectConfig2().logging.stream;
|
|
479
|
+
var warnedMissingInputType2 = /* @__PURE__ */ new Set();
|
|
480
|
+
var warnIfInputTypeMissing2 = (resolvedName, inputType) => {
|
|
481
|
+
if (!getProjectConfig2().dev.warnOnMissingInputType) return;
|
|
482
|
+
if (inputType && inputType.trim().length > 0 && inputType.trim() !== "any") return;
|
|
483
|
+
if (warnedMissingInputType2.has(resolvedName)) return;
|
|
484
|
+
warnedMissingInputType2.add(resolvedName);
|
|
485
|
+
getLogger2().warn(`http-api: route ${resolvedName} has no inputType \u2014 runtime input validation is disabled.`);
|
|
486
|
+
};
|
|
487
|
+
async function handleHttpApiRequest(params) {
|
|
488
|
+
const { response, user, preferredLocale, perRouteFormatter } = await runHandleHttpApiRequest(params);
|
|
489
|
+
const routeNameForHook = params.name.startsWith("api/") ? params.name : `api/${params.name}`;
|
|
490
|
+
const preRespond = { routeName: routeNameForHook, user, response };
|
|
491
|
+
const preRespondResult = await dispatchHook2("preApiRespond", preRespond);
|
|
492
|
+
const finalResponse = preRespondResult.stopped ? normalizeErrorResponse2({
|
|
493
|
+
response: { status: "error", errorCode: preRespondResult.signal.errorCode },
|
|
494
|
+
preferredLocale,
|
|
495
|
+
userLanguage: user?.language,
|
|
496
|
+
fallbackHttpStatus: preRespondResult.signal.httpStatus ?? 403
|
|
497
|
+
}) : preRespond.response;
|
|
498
|
+
const formatterInput = finalResponse;
|
|
499
|
+
const formattedResponse = applyErrorFormatter2({
|
|
500
|
+
response: formatterInput,
|
|
501
|
+
routeName: routeNameForHook,
|
|
502
|
+
transport: "http",
|
|
503
|
+
userId: user?.id,
|
|
504
|
+
perRouteFormatter
|
|
505
|
+
});
|
|
506
|
+
await dispatchHook2("postApiRespond", { routeName: routeNameForHook, user, response: formattedResponse });
|
|
507
|
+
return formattedResponse;
|
|
508
|
+
}
|
|
509
|
+
async function runHandleHttpApiRequest(params) {
|
|
510
|
+
const preferredLocale = extractLanguageFromHeader2(params.xLanguageHeader) ?? extractLanguageFromHeader2(params.acceptLanguageHeader);
|
|
511
|
+
const user = await getSession2(params.token);
|
|
512
|
+
const formatterHolder = {};
|
|
513
|
+
const response = await runHandleHttpApiRequestInner(params, user, preferredLocale, formatterHolder);
|
|
514
|
+
return { response, user, preferredLocale, perRouteFormatter: formatterHolder.current };
|
|
515
|
+
}
|
|
516
|
+
async function runHandleHttpApiRequestInner({
|
|
517
|
+
name,
|
|
518
|
+
data,
|
|
519
|
+
token,
|
|
520
|
+
requesterIp,
|
|
521
|
+
xLanguageHeader: _xLanguageHeader,
|
|
522
|
+
acceptLanguageHeader: _acceptLanguageHeader,
|
|
523
|
+
method = "POST",
|
|
524
|
+
stream,
|
|
525
|
+
abortSignal
|
|
526
|
+
}, user, preferredLocale, formatterHolder) {
|
|
527
|
+
const effectiveAbortSignal = abortSignal ?? new AbortController().signal;
|
|
528
|
+
const flushPressure = httpApiFlushPressureNoop;
|
|
529
|
+
const normalizedName = name.startsWith("api/") ? name : `api/${name}`;
|
|
530
|
+
const buildNetworkError = ({
|
|
531
|
+
response,
|
|
532
|
+
fallbackHttpStatus
|
|
533
|
+
}) => {
|
|
534
|
+
return normalizeErrorResponse2({
|
|
535
|
+
response,
|
|
536
|
+
preferredLocale,
|
|
537
|
+
userLanguage: user?.language,
|
|
538
|
+
fallbackHttpStatus
|
|
539
|
+
});
|
|
540
|
+
};
|
|
541
|
+
if (!name || typeof name !== "string") {
|
|
542
|
+
return buildNetworkError({
|
|
543
|
+
response: { status: "error", errorCode: "api.invalidName" },
|
|
544
|
+
fallbackHttpStatus: 400
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
if (typeof data !== "object" || data === null) {
|
|
548
|
+
return buildNetworkError({
|
|
549
|
+
response: { status: "error", errorCode: "api.invalidDataObject" },
|
|
550
|
+
fallbackHttpStatus: 400
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
const requestData = data ?? {};
|
|
554
|
+
const parsedRoute = parseTransportRouteName2({ value: normalizedName, prefix: "api" });
|
|
555
|
+
if (parsedRoute.status === "error") {
|
|
556
|
+
return buildNetworkError({
|
|
557
|
+
response: {
|
|
558
|
+
status: "error",
|
|
559
|
+
errorCode: "routing.invalidServiceRouteName",
|
|
560
|
+
errorParams: [{ key: "name", value: normalizedName }]
|
|
561
|
+
},
|
|
562
|
+
fallbackHttpStatus: 400
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
const resolvedName = parsedRoute.normalizedFullName;
|
|
566
|
+
if (shouldLogDev2()) {
|
|
567
|
+
getLogger2().debug(`http api: ${resolvedName} called`);
|
|
568
|
+
}
|
|
569
|
+
const { apisObject, functionsObject } = await getRuntimeApiMapsFromSource();
|
|
570
|
+
if (!apisObject[resolvedName]) {
|
|
571
|
+
return buildNetworkError({
|
|
572
|
+
response: {
|
|
573
|
+
status: "error",
|
|
574
|
+
errorCode: "api.notFound",
|
|
575
|
+
errorParams: [{ key: "name", value: resolvedName }]
|
|
576
|
+
},
|
|
577
|
+
fallbackHttpStatus: 404
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
const runtimeApiRoute = apisObject[resolvedName];
|
|
581
|
+
formatterHolder.current = runtimeApiRoute.errorFormatter;
|
|
582
|
+
const { auth, main, httpMethod: declaredMethod } = runtimeApiRoute;
|
|
583
|
+
const inputType = runtimeApiRoute.inputType;
|
|
584
|
+
const inputTypeFilePath = runtimeApiRoute.inputTypeFilePath;
|
|
585
|
+
if (auth.login && !user?.id) {
|
|
586
|
+
if (shouldLogDev2()) {
|
|
587
|
+
getLogger2().warn(`http-api: ${name} requires login`, { route: name, transport: "http" });
|
|
588
|
+
}
|
|
589
|
+
return buildNetworkError({
|
|
590
|
+
response: { status: "error", errorCode: "auth.required" },
|
|
591
|
+
fallbackHttpStatus: 401
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
const authResult = validateRequest2({ auth, user });
|
|
595
|
+
if (authResult.status === "error") {
|
|
596
|
+
if (shouldLogDev2()) {
|
|
597
|
+
getLogger2().warn(`http-api: auth failed for ${name}`, { route: name, errorCode: authResult.errorCode, transport: "http" });
|
|
598
|
+
}
|
|
599
|
+
return buildNetworkError({
|
|
600
|
+
response: {
|
|
601
|
+
status: "error",
|
|
602
|
+
errorCode: authResult.errorCode ?? "auth.forbidden",
|
|
603
|
+
errorParams: authResult.errorParams,
|
|
604
|
+
httpStatus: authResult.httpStatus
|
|
605
|
+
},
|
|
606
|
+
fallbackHttpStatus: authResult.httpStatus ?? 403
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
const apiRateLimit = runtimeApiRoute.rateLimit;
|
|
610
|
+
const effectiveApiLimit = apiRateLimit === void 0 ? getProjectConfig2().rateLimiting.defaultApiLimit : apiRateLimit;
|
|
611
|
+
if (effectiveApiLimit !== false && effectiveApiLimit > 0) {
|
|
612
|
+
const requesterIdentity = token ?? requesterIp ?? "anonymous";
|
|
613
|
+
const keyPrefix = token ? "token" : "ip";
|
|
614
|
+
const rateLimitKey = `${keyPrefix}:${requesterIdentity}:api:${resolvedName}`;
|
|
615
|
+
const { allowed, resetIn } = await checkRateLimit2({
|
|
616
|
+
key: rateLimitKey,
|
|
617
|
+
limit: effectiveApiLimit,
|
|
618
|
+
windowMs: getProjectConfig2().rateLimiting.windowMs
|
|
619
|
+
});
|
|
620
|
+
if (!allowed) {
|
|
621
|
+
void dispatchHook2("rateLimitExceeded", {
|
|
622
|
+
scope: token ? "user" : "route",
|
|
623
|
+
key: rateLimitKey,
|
|
624
|
+
limit: effectiveApiLimit,
|
|
625
|
+
windowMs: getProjectConfig2().rateLimiting.windowMs,
|
|
626
|
+
count: effectiveApiLimit + 1,
|
|
627
|
+
route: resolvedName,
|
|
628
|
+
userId: user?.id
|
|
629
|
+
});
|
|
630
|
+
if (shouldLogDev2()) {
|
|
631
|
+
getLogger2().warn(`http api: rate limit exceeded for ${resolvedName}`);
|
|
632
|
+
}
|
|
633
|
+
return buildNetworkError({
|
|
634
|
+
response: {
|
|
635
|
+
status: "error",
|
|
636
|
+
errorCode: "api.rateLimitExceeded",
|
|
637
|
+
errorParams: [{ key: "seconds", value: resetIn }]
|
|
638
|
+
},
|
|
639
|
+
fallbackHttpStatus: 429
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
const defaultIpLimit = getProjectConfig2().rateLimiting.defaultIpLimit;
|
|
644
|
+
const requesterIsLoopback = process.env.NODE_ENV !== "production" && (requesterIp === "127.0.0.1" || requesterIp === "::1" || requesterIp === "::ffff:127.0.0.1" || typeof requesterIp === "string" && requesterIp.startsWith("127."));
|
|
645
|
+
if (!requesterIsLoopback && defaultIpLimit !== false && defaultIpLimit > 0) {
|
|
646
|
+
const ipBucket = requesterIp ?? "unknown";
|
|
647
|
+
const { allowed, resetIn } = await checkRateLimit2({
|
|
648
|
+
key: `ip:${ipBucket}:api:all`,
|
|
649
|
+
limit: defaultIpLimit,
|
|
650
|
+
windowMs: getProjectConfig2().rateLimiting.windowMs
|
|
651
|
+
});
|
|
652
|
+
if (!allowed) {
|
|
653
|
+
void dispatchHook2("rateLimitExceeded", {
|
|
654
|
+
scope: "ip",
|
|
655
|
+
key: `ip:${ipBucket}:api:all`,
|
|
656
|
+
limit: defaultIpLimit,
|
|
657
|
+
windowMs: getProjectConfig2().rateLimiting.windowMs,
|
|
658
|
+
count: defaultIpLimit + 1,
|
|
659
|
+
ip: ipBucket
|
|
660
|
+
});
|
|
661
|
+
if (shouldLogDev2()) {
|
|
662
|
+
getLogger2().warn(`http api: global IP rate limit exceeded`, { ip: ipBucket });
|
|
663
|
+
}
|
|
664
|
+
return buildNetworkError({
|
|
665
|
+
response: {
|
|
666
|
+
status: "error",
|
|
667
|
+
errorCode: "api.rateLimitExceeded",
|
|
668
|
+
errorParams: [{ key: "seconds", value: resetIn }]
|
|
669
|
+
},
|
|
670
|
+
fallbackHttpStatus: 429
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
const expectedMethod = declaredMethod ?? inferHttpMethod(resolvedName);
|
|
675
|
+
if (method !== expectedMethod) {
|
|
676
|
+
if (shouldLogDev2()) {
|
|
677
|
+
getLogger2().warn(`http api: method mismatch for ${resolvedName}`, { expected: expectedMethod, got: method });
|
|
678
|
+
}
|
|
679
|
+
return buildNetworkError({
|
|
680
|
+
response: {
|
|
681
|
+
status: "error",
|
|
682
|
+
errorCode: "api.methodNotAllowed",
|
|
683
|
+
errorParams: [{ key: "method", value: expectedMethod }]
|
|
684
|
+
},
|
|
685
|
+
fallbackHttpStatus: 405
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
warnIfInputTypeMissing2(resolvedName, inputType);
|
|
689
|
+
await dispatchHook2("preApiValidate", { routeName: resolvedName, data: requestData, user, transport: "http" });
|
|
690
|
+
const inputValidation = await validateInputByType2({
|
|
691
|
+
typeText: inputType,
|
|
692
|
+
value: requestData,
|
|
693
|
+
rootKey: "data",
|
|
694
|
+
filePath: inputTypeFilePath
|
|
695
|
+
});
|
|
696
|
+
await dispatchHook2("postApiValidate", {
|
|
697
|
+
routeName: resolvedName,
|
|
698
|
+
data: requestData,
|
|
699
|
+
user,
|
|
700
|
+
validation: inputValidation,
|
|
701
|
+
transport: "http"
|
|
702
|
+
});
|
|
703
|
+
if (inputValidation.status === "error") {
|
|
704
|
+
return buildNetworkError({
|
|
705
|
+
response: {
|
|
706
|
+
status: "error",
|
|
707
|
+
errorCode: "api.invalidInputType",
|
|
708
|
+
errorParams: [{ key: "message", value: inputValidation.message }]
|
|
709
|
+
},
|
|
710
|
+
fallbackHttpStatus: 400
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
const emitApiStream = (payload = {}) => {
|
|
714
|
+
if (!stream) {
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
if (effectiveAbortSignal.aborted) {
|
|
718
|
+
if (shouldLogStream2()) {
|
|
719
|
+
getLogger2().debug(`http api: ${resolvedName} stream skipped \u2014 request aborted`);
|
|
720
|
+
}
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
if (shouldLogStream2()) {
|
|
724
|
+
getLogger2().debug(`http api: ${resolvedName} stream`, { payload });
|
|
725
|
+
}
|
|
726
|
+
stream(payload);
|
|
727
|
+
};
|
|
728
|
+
const executePayload = {
|
|
729
|
+
routeName: resolvedName,
|
|
730
|
+
data: requestData,
|
|
731
|
+
user,
|
|
732
|
+
transport: "http",
|
|
733
|
+
result: void 0,
|
|
734
|
+
error: null,
|
|
735
|
+
durationMs: 0
|
|
736
|
+
};
|
|
737
|
+
const preExecuteResult = await dispatchHook2("preApiExecute", executePayload);
|
|
738
|
+
if (preExecuteResult.stopped) {
|
|
739
|
+
return buildNetworkError({
|
|
740
|
+
response: { status: "error", errorCode: preExecuteResult.signal.errorCode },
|
|
741
|
+
fallbackHttpStatus: preExecuteResult.signal.httpStatus ?? 403
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
const executeStart = Date.now();
|
|
745
|
+
const [error, result] = await tryCatch2(
|
|
746
|
+
async () => await main({
|
|
747
|
+
data: requestData,
|
|
748
|
+
user,
|
|
749
|
+
functions: functionsObject,
|
|
750
|
+
stream: emitApiStream,
|
|
751
|
+
abortSignal: effectiveAbortSignal,
|
|
752
|
+
flushPressure
|
|
753
|
+
}),
|
|
754
|
+
void 0,
|
|
755
|
+
{
|
|
756
|
+
handler: "handleHttpApiRequest",
|
|
757
|
+
api: resolvedName,
|
|
758
|
+
userId: user?.id,
|
|
759
|
+
transport: "http"
|
|
760
|
+
}
|
|
761
|
+
);
|
|
762
|
+
executePayload.result = result;
|
|
763
|
+
executePayload.error = error ?? null;
|
|
764
|
+
executePayload.durationMs = Date.now() - executeStart;
|
|
765
|
+
await dispatchHook2("postApiExecute", executePayload);
|
|
766
|
+
if (error) {
|
|
767
|
+
if (shouldLogDev2()) {
|
|
768
|
+
getLogger2().error(`http-api: error in ${resolvedName}`, error, { route: resolvedName, transport: "http" });
|
|
769
|
+
}
|
|
770
|
+
return buildNetworkError({
|
|
771
|
+
response: { status: "error", errorCode: "api.internalServerError" },
|
|
772
|
+
fallbackHttpStatus: 500
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
if (result !== void 0 && result !== null) {
|
|
776
|
+
if (shouldLogDev2()) {
|
|
777
|
+
getLogger2().debug(`http api: ${resolvedName} completed`);
|
|
778
|
+
}
|
|
779
|
+
if (isRuntimeApiResult(result)) {
|
|
780
|
+
if (result.status === "error") {
|
|
781
|
+
return buildNetworkError({
|
|
782
|
+
response: result,
|
|
783
|
+
fallbackHttpStatus: defaultHttpStatusForResponse2({
|
|
784
|
+
status: "error",
|
|
785
|
+
explicitHttpStatus: typeof result.httpStatus === "number" ? result.httpStatus : void 0
|
|
786
|
+
})
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
const successEnvelope = {
|
|
790
|
+
...result,
|
|
791
|
+
status: "success",
|
|
792
|
+
httpStatus: defaultHttpStatusForResponse2({
|
|
793
|
+
status: "success",
|
|
794
|
+
explicitHttpStatus: typeof result.httpStatus === "number" ? result.httpStatus : void 0
|
|
795
|
+
})
|
|
796
|
+
};
|
|
797
|
+
return successEnvelope;
|
|
798
|
+
}
|
|
799
|
+
return buildNetworkError({
|
|
800
|
+
response: { status: "error", errorCode: "api.invalidResponseStatus" },
|
|
801
|
+
fallbackHttpStatus: 500
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
if (shouldLogDev2()) {
|
|
805
|
+
getLogger2().warn(`http api: ${resolvedName} returned nothing`);
|
|
806
|
+
}
|
|
807
|
+
return buildNetworkError({
|
|
808
|
+
response: { status: "error", errorCode: "api.emptyResponse" },
|
|
809
|
+
fallbackHttpStatus: 500
|
|
810
|
+
});
|
|
811
|
+
}
|
|
812
|
+
export {
|
|
813
|
+
handleApiRequest,
|
|
814
|
+
handleHttpApiRequest
|
|
815
|
+
};
|
|
816
|
+
//# sourceMappingURL=index.js.map
|