@openstax/ts-utils 1.39.1 → 1.40.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.
@@ -0,0 +1 @@
1
+ content
@@ -0,0 +1,24 @@
1
+ type PromiseInvoker<T> = () => Promise<T>;
2
+ type UnwrapPromiseInvoker<T> = T extends PromiseInvoker<infer U> ? U : never;
3
+ type UnwrapPromiseInvokers<T extends readonly PromiseInvoker<any>[]> = {
4
+ [K in keyof T]: UnwrapPromiseInvoker<T[K]>;
5
+ };
6
+ /**
7
+ * Executes a tuple of promise invokers with a concurrency limit.
8
+ * Similar to Promise.all but limits the number of in-flight promises.
9
+ *
10
+ * @param invokers Tuple of functions that return promises
11
+ * @param concurrency Maximum number of promises to execute concurrently
12
+ * @returns Promise that resolves with a tuple of results in the same order as the invokers
13
+ * @throws Rejects with the first error encountered (similar to Promise.all behavior)
14
+ *
15
+ * @example
16
+ * const results = await asyncPool([
17
+ * () => fetch('/api/1').then(r => r.json() as Promise<number>),
18
+ * () => fetch('/api/2').then(r => r.text()),
19
+ * () => fetch('/api/3').then(r => r.json() as Promise<boolean>),
20
+ * ], 2);
21
+ * // results has type [number, string, boolean]
22
+ */
23
+ export declare const asyncPool: <T extends readonly PromiseInvoker<any>[]>(invokers: T, concurrency: number) => Promise<UnwrapPromiseInvokers<T>>;
24
+ export {};
@@ -0,0 +1,58 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.asyncPool = void 0;
4
+ /**
5
+ * Executes a tuple of promise invokers with a concurrency limit.
6
+ * Similar to Promise.all but limits the number of in-flight promises.
7
+ *
8
+ * @param invokers Tuple of functions that return promises
9
+ * @param concurrency Maximum number of promises to execute concurrently
10
+ * @returns Promise that resolves with a tuple of results in the same order as the invokers
11
+ * @throws Rejects with the first error encountered (similar to Promise.all behavior)
12
+ *
13
+ * @example
14
+ * const results = await asyncPool([
15
+ * () => fetch('/api/1').then(r => r.json() as Promise<number>),
16
+ * () => fetch('/api/2').then(r => r.text()),
17
+ * () => fetch('/api/3').then(r => r.json() as Promise<boolean>),
18
+ * ], 2);
19
+ * // results has type [number, string, boolean]
20
+ */
21
+ const asyncPool = async (invokers, concurrency) => {
22
+ if (concurrency <= 0) {
23
+ throw new Error('Concurrency must be greater than 0');
24
+ }
25
+ const results = new Array(invokers.length);
26
+ let rejected = false;
27
+ let currentIndex = 0;
28
+ let completedCount = 0;
29
+ return new Promise((resolve, reject) => {
30
+ const executeNext = () => {
31
+ if (completedCount === invokers.length) {
32
+ resolve(results);
33
+ return;
34
+ }
35
+ while (currentIndex < invokers.length && (currentIndex - completedCount) < concurrency) {
36
+ const index = currentIndex++;
37
+ const invoker = invokers[index];
38
+ invoker()
39
+ .then((result) => {
40
+ if (rejected) {
41
+ return;
42
+ }
43
+ results[index] = result;
44
+ completedCount++;
45
+ executeNext();
46
+ })
47
+ .catch((error) => {
48
+ if (!rejected) {
49
+ rejected = true;
50
+ reject(error);
51
+ }
52
+ });
53
+ }
54
+ };
55
+ executeNext();
56
+ });
57
+ };
58
+ exports.asyncPool = asyncPool;
@@ -1,4 +1,4 @@
1
- export type HashValue = string | number | boolean | null | HashCompoundValue;
1
+ export type HashValue = undefined | string | number | boolean | null | HashCompoundValue;
2
2
  export type HashCompoundValue = Array<HashValue> | {
3
3
  [key: string]: HashValue;
4
4
  };
@@ -8,10 +8,11 @@ const crypto_1 = require("crypto");
8
8
  * @example hashValue({someKey: 'someValue'})
9
9
  */
10
10
  const hashValue = (value) => {
11
+ var _a;
11
12
  // hack for sorting keys https://stackoverflow.com/a/53593328/14809536
12
13
  const allKeys = new Set();
13
14
  JSON.stringify(value, (k, v) => (allKeys.add(k), v));
14
- const strValue = JSON.stringify(value, Array.from(allKeys).sort());
15
+ const strValue = (_a = JSON.stringify(value, Array.from(allKeys).sort())) !== null && _a !== void 0 ? _a : 'undefined';
15
16
  return (0, crypto_1.createHash)('sha1').update(strValue).digest('hex');
16
17
  };
17
18
  exports.hashValue = hashValue;
@@ -187,6 +187,22 @@ type RequestResponder<Sa, Ri, Ro> = {
187
187
  logger: Logger;
188
188
  }) => RoF): (request: Ri) => RoF;
189
189
  };
190
+ export type BatchItem<Ro> = {
191
+ id: string;
192
+ makeRequest: () => undefined | Ro;
193
+ };
194
+ export type BatchHandler<Sa, Ri, Ro> = (services: CompatibleServices<Sa>) => ({
195
+ isBatchRequest: (request: Ri) => boolean;
196
+ extractBatchRequests: (request: Ri) => {
197
+ id: string;
198
+ request: Ri;
199
+ }[];
200
+ responseMiddleware?: (app: Sa) => (response: Ro | undefined, request: {
201
+ request: Ri;
202
+ logger: Logger;
203
+ }) => Ro;
204
+ composeBatchResponse: (items: BatchItem<Ro>[]) => Ro;
205
+ });
190
206
  /**
191
207
  * A factory factory for creating request responders (functions that take a request and return a
192
208
  * response -- these functions let us implement Lambda `handler` functions).
@@ -217,12 +233,13 @@ type RequestResponder<Sa, Ri, Ro> = {
217
233
  * );
218
234
  * ```
219
235
  */
220
- export declare const makeGetRequestResponder: <Sa, Ru, Ri, Ro>() => ({ routes, pathExtractor, routeMatcher, errorHandler, logExtractor }: {
236
+ export declare const makeGetRequestResponder: <Sa, Ru, Ri, Ro>() => ({ routes, pathExtractor, routeMatcher, errorHandler, logExtractor, batch }: {
221
237
  routes: () => AnySpecificRoute<Ru, Sa, Ri, Ro>[];
222
238
  pathExtractor: RequestPathExtractor<Ri>;
223
239
  logExtractor?: RequestLogExtractor<Ri>;
224
240
  routeMatcher?: RequestRouteMatcher<Ri, AnySpecificRoute<Ru, Sa, Ri, Ro>>;
225
241
  errorHandler?: (e: Error, logger: Logger) => Ro;
242
+ batch?: BatchHandler<Sa, Ri, Ro>;
226
243
  }) => RequestResponder<Sa, Ri, Ro>;
227
244
  /** HTTP Headers */
228
245
  export type HttpHeaders = {
@@ -195,11 +195,13 @@ const bindRoute = (services, appBinder, pathExtractor, matcher) => (route) => {
195
195
  * );
196
196
  * ```
197
197
  */
198
- const makeGetRequestResponder = () => ({ routes, pathExtractor, routeMatcher, errorHandler, logExtractor }) => (services, responseMiddleware) => {
198
+ const makeGetRequestResponder = () => ({ routes, pathExtractor, routeMatcher, errorHandler, logExtractor, batch }) => (services, responseMiddleware) => {
199
199
  const appBinderImpl = (app, middleware) => middleware(app, appBinder);
200
200
  const appBinder = (0, helpers_1.memoize)(appBinderImpl);
201
201
  const boundRoutes = routes().map(bindRoute(services, appBinder, pathExtractor, routeMatcher));
202
202
  const boundResponseMiddleware = responseMiddleware ? responseMiddleware(services) : undefined;
203
+ const boundBatch = batch ? batch(services) : undefined;
204
+ const boundBatchResponseMiddleware = (boundBatch === null || boundBatch === void 0 ? void 0 : boundBatch.responseMiddleware) ? boundBatch.responseMiddleware(services) : undefined;
203
205
  const appLogger = services.logger || (0, console_1.createConsoleLogger)();
204
206
  // *note* this opaque promise guard is less generic than i hoped so
205
207
  // i'm leaving it here instead of the guards file.
@@ -209,42 +211,58 @@ const makeGetRequestResponder = () => ({ routes, pathExtractor, routeMatcher, er
209
211
  // promise or a non-promise value and the promise figures it out, but those
210
212
  // types are getting complicated quickly here.
211
213
  const isPromise = (thing) => thing instanceof Promise;
212
- return (request) => {
213
- const logger = appLogger.createSubContext();
214
- if (logExtractor) {
215
- logger.setContext(logExtractor(request));
216
- }
217
- logger.log('begin request');
214
+ const requestResponder = (request, logger) => {
215
+ const boundErrorHandler = errorHandler ? (e) => errorHandler(e, logger) : undefined;
218
216
  try {
219
217
  const route = (0, helpers_1.mapFind)(boundRoutes, (route) => route(request, logger));
220
218
  if (route) {
221
219
  logger.log(`route matched ${route.name}`);
222
- const result = boundResponseMiddleware ?
223
- boundResponseMiddleware(route.executor(), { request, logger }) : route.executor();
224
- if (isPromise(result) && errorHandler) {
225
- const errorHandlerWithMiddleware = (e) => boundResponseMiddleware ?
226
- boundResponseMiddleware(errorHandler(e, logger), { request, logger }) : errorHandler(e, logger);
227
- return result.catch(errorHandlerWithMiddleware);
220
+ const result = route.executor();
221
+ if (isPromise(result) && boundErrorHandler) {
222
+ return result.catch(boundErrorHandler);
228
223
  }
229
224
  else {
230
225
  return result;
231
226
  }
232
227
  }
233
- else if (boundResponseMiddleware) {
234
- logger.log('no route matched, returning 404');
235
- return boundResponseMiddleware(undefined, { request, logger });
236
- }
237
228
  }
238
229
  catch (e) {
239
- if (errorHandler && e instanceof Error) {
240
- return boundResponseMiddleware
241
- ? boundResponseMiddleware(errorHandler(e, logger), { request, logger })
242
- : errorHandler(e, logger);
230
+ if (boundErrorHandler && e instanceof Error) {
231
+ return boundErrorHandler(e);
243
232
  }
244
233
  throw e;
245
234
  }
246
235
  return undefined;
247
236
  };
237
+ return (request) => {
238
+ const logger = appLogger.createSubContext();
239
+ if (logExtractor)
240
+ logger.setContext(logExtractor(request));
241
+ logger.log('begin request');
242
+ let response = undefined;
243
+ if (boundBatch === null || boundBatch === void 0 ? void 0 : boundBatch.isBatchRequest(request)) {
244
+ const batchRequests = boundBatch.extractBatchRequests(request);
245
+ const batchResponses = [];
246
+ for (const batchRequest of batchRequests) {
247
+ const subRequestLogger = logger.createSubContext();
248
+ subRequestLogger.setContext({ batchRequestId: batchRequest.id });
249
+ if (logExtractor)
250
+ subRequestLogger.setContext(logExtractor(batchRequest.request));
251
+ batchResponses.push({
252
+ id: batchRequest.id,
253
+ makeRequest: () => {
254
+ const batchItemResponse = requestResponder(batchRequest.request, subRequestLogger);
255
+ return boundBatchResponseMiddleware ? boundBatchResponseMiddleware(batchItemResponse, { request: batchRequest.request, logger: subRequestLogger }) : batchItemResponse;
256
+ }
257
+ });
258
+ }
259
+ response = boundBatch.composeBatchResponse(batchResponses);
260
+ }
261
+ else {
262
+ response = requestResponder(request, logger);
263
+ }
264
+ return boundResponseMiddleware ? boundResponseMiddleware(response, { request, logger }) : response;
265
+ };
248
266
  };
249
267
  exports.makeGetRequestResponder = makeGetRequestResponder;
250
268
  /**
@@ -0,0 +1,13 @@
1
+ import { APIGatewayProxyEventV2 } from 'aws-lambda';
2
+ import { Logger } from '../services/logger';
3
+ import { ApiResponse, BatchHandler } from '.';
4
+ type Ro = Promise<ApiResponse<number, any>>;
5
+ export declare const lambdaBatchRequestHandler: <Sa, Ri extends APIGatewayProxyEventV2>(config: {
6
+ batchPath: string;
7
+ concurrency: number;
8
+ responseMiddleware?: (app: Sa) => (response: Ro | undefined, request: {
9
+ request: Ri;
10
+ logger: Logger;
11
+ }) => Ro;
12
+ }) => BatchHandler<Sa, Ri, Ro>;
13
+ export {};
@@ -0,0 +1,71 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.lambdaBatchRequestHandler = void 0;
4
+ const zod_1 = require("zod");
5
+ const asyncPool_1 = require("../misc/asyncPool");
6
+ const helpers_1 = require("./helpers");
7
+ const _1 = require(".");
8
+ const BatchItemSchema = zod_1.z.object({
9
+ id: zod_1.z.string(),
10
+ path: zod_1.z.string(),
11
+ method: zod_1.z.string(),
12
+ queryParams: zod_1.z.record(zod_1.z.string(), zod_1.z.string()).optional(),
13
+ body: zod_1.z.string().optional(),
14
+ headers: zod_1.z.record(zod_1.z.string(), zod_1.z.string()).optional(),
15
+ });
16
+ const BatchRequestSchema = zod_1.z.object({
17
+ requests: zod_1.z.array(BatchItemSchema),
18
+ });
19
+ const lambdaBatchRequestHandler = (config) => {
20
+ const { batchPath, concurrency, responseMiddleware } = config;
21
+ return () => ({
22
+ responseMiddleware,
23
+ isBatchRequest: (request) => {
24
+ return request.requestContext.http.path === batchPath;
25
+ },
26
+ extractBatchRequests: (request) => {
27
+ // Parse and validate payload
28
+ const payload = (0, helpers_1.getRequestBody)(request);
29
+ const parsed = BatchRequestSchema.parse(payload);
30
+ // Create modified requests for each batch item
31
+ return parsed.requests.map(item => {
32
+ var _a;
33
+ return ({
34
+ id: item.id,
35
+ request: {
36
+ ...request,
37
+ requestContext: {
38
+ ...request.requestContext,
39
+ http: {
40
+ ...request.requestContext.http,
41
+ path: item.path,
42
+ method: item.method,
43
+ }
44
+ },
45
+ headers: { ...request.headers, ...item.headers },
46
+ body: (_a = item.body) !== null && _a !== void 0 ? _a : request.body,
47
+ rawQueryString: item.queryParams
48
+ ? new URLSearchParams(item.queryParams).toString()
49
+ : request.rawQueryString,
50
+ }
51
+ });
52
+ });
53
+ },
54
+ composeBatchResponse: async (items) => {
55
+ const results = await (0, asyncPool_1.asyncPool)(items.map(({ id, makeRequest }) => async () => {
56
+ const response = await makeRequest();
57
+ return { id, response };
58
+ }), concurrency);
59
+ const data = {
60
+ results: results.map(({ id, response }) => ({
61
+ id,
62
+ statusCode: (response === null || response === void 0 ? void 0 : response.statusCode) || 500,
63
+ body: (response === null || response === void 0 ? void 0 : response.body) || '',
64
+ headers: (response === null || response === void 0 ? void 0 : response.headers) || {}
65
+ }))
66
+ };
67
+ return (0, _1.apiJsonResponse)(200, data);
68
+ }
69
+ });
70
+ };
71
+ exports.lambdaBatchRequestHandler = lambdaBatchRequestHandler;
@@ -27,10 +27,12 @@ const subrequestAuthProvider = (initializer) => (configProvider) => {
27
27
  return undefined;
28
28
  }
29
29
  const user = await (0, userSubrequest_1.loadUserData)(initializer.fetch, await accountsBase(), resolvedCookieName, token);
30
- if (user) {
30
+ // this returns `{"error_id":null}` when the token is invalid
31
+ if (user.uuid) {
31
32
  logger.setContext({ user: user.uuid });
33
+ return user;
32
34
  }
33
- return user;
35
+ return undefined;
34
36
  };
35
37
  const getUser = async () => {
36
38
  if (!user) {
@@ -1,3 +1,3 @@
1
1
  import { ApiUser } from '..';
2
2
  import { GenericFetch } from '../../../fetch';
3
- export declare const loadUserData: (fetch: GenericFetch, accountsBase: string, cookieName: string, token: string) => Promise<ApiUser | undefined>;
3
+ export declare const loadUserData: (fetch: GenericFetch, accountsBase: string, cookieName: string, token: string) => Promise<ApiUser>;