@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/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