@gobing-ai/ts-utils 0.2.7 → 0.2.9
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/api-response.d.ts +10 -0
- package/dist/api-response.d.ts.map +1 -1
- package/dist/api-response.js +28 -0
- package/dist/cursor.d.ts.map +1 -1
- package/dist/cursor.js +32 -7
- package/dist/date.d.ts.map +1 -1
- package/dist/date.js +2 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/object.d.ts +19 -0
- package/dist/object.d.ts.map +1 -0
- package/dist/object.js +68 -0
- package/dist/origin.d.ts +6 -0
- package/dist/origin.d.ts.map +1 -1
- package/dist/origin.js +7 -0
- package/dist/output.d.ts.map +1 -1
- package/dist/output.js +14 -4
- package/package.json +1 -1
- package/src/api-response.ts +32 -0
- package/src/cursor.ts +34 -6
- package/src/date.ts +2 -1
- package/src/index.ts +1 -0
- package/src/object.ts +68 -0
- package/src/origin.ts +7 -0
- package/src/output.ts +15 -4
package/dist/api-response.d.ts
CHANGED
|
@@ -44,4 +44,14 @@ export declare function unauthorizedResponse(message?: string, details?: unknown
|
|
|
44
44
|
export declare function forbiddenResponse(message?: string, details?: unknown): ApiErrorEnvelope;
|
|
45
45
|
export declare function conflictResponse(message?: string, details?: unknown): ApiErrorEnvelope;
|
|
46
46
|
export declare function internalErrorResponse(message?: string, details?: unknown): ApiErrorEnvelope;
|
|
47
|
+
/**
|
|
48
|
+
* Bridge a thrown error to an API error envelope — the single mapping from the domain error layer
|
|
49
|
+
* ({@link AppError}) to the wire layer ({@link ApiErrorEnvelope}). Use this in request handlers
|
|
50
|
+
* instead of hand-mapping `catch` blocks, so HTTP codes stay consistent across endpoints.
|
|
51
|
+
*
|
|
52
|
+
* A known {@link AppError} maps to its HTTP code; client-safe codes surface their message, while
|
|
53
|
+
* `Internal` and any non-`AppError` collapse to an opaque 500 that leaks neither message nor stack.
|
|
54
|
+
* Pass `details` only when you intend it to reach the client (e.g. validation field errors).
|
|
55
|
+
*/
|
|
56
|
+
export declare function toApiResponse(error: unknown, details?: unknown): ApiErrorEnvelope;
|
|
47
57
|
//# sourceMappingURL=api-response.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"api-response.d.ts","sourceRoot":"","sources":["../src/api-response.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"api-response.d.ts","sourceRoot":"","sources":["../src/api-response.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,eAAe;;;;;;;;;CASlB,CAAC;AAEX,MAAM,MAAM,YAAY,GAAG,CAAC,OAAO,eAAe,CAAC,CAAC,MAAM,OAAO,eAAe,CAAC,CAAC;AAElF,MAAM,MAAM,iBAAiB,GAAG,SAAS,GAAG,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC;AAEtE,MAAM,WAAW,kBAAkB,CAAC,CAAC;IACjC,IAAI,EAAE,CAAC,CAAC;IACR,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,SAAS,GAAG,MAAM,CAAC;IAC3B,IAAI,EAAE,CAAC,CAAC;IACR,IAAI,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;CAC9D;AAED,MAAM,WAAW,gBAAgB;IAC7B,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,IAAI,CAAC;IACX,OAAO,CAAC,EAAE,OAAO,CAAC;CACrB;AAED,MAAM,MAAM,WAAW,CAAC,CAAC,IAAI,kBAAkB,CAAC,CAAC,CAAC,GAAG,gBAAgB,CAAC;AAEtE,wBAAgB,eAAe,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC,EAAE,OAAO,SAAY,GAAG,kBAAkB,CAAC,CAAC,CAAC,CAOtF;AAED,wBAAgB,YAAY,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC,EAAE,OAAO,SAAgC,GAAG,kBAAkB,CAAC,CAAC,CAAC,CAOvG;AAED,wBAAgB,iBAAiB,CAAC,CAAC,EAC/B,IAAI,EAAE,CAAC,EAAE,EACT,IAAI,EAAE;IAAE,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAA;CAAE,EACzD,OAAO,SAAgC,GACxC,kBAAkB,CAAC,CAAC,EAAE,CAAC,CAQzB;AAED,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,OAAO,GAAG,gBAAgB,CAahG;AAED,wBAAgB,gBAAgB,CAAC,OAAO,SAAuB,EAAE,OAAO,CAAC,EAAE,OAAO,GAAG,gBAAgB,CAEpG;AAED,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,OAAO,EAAE,OAAO,SAAsB,GAAG,gBAAgB,CAEzG;AAED,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,OAAO,GAAG,gBAAgB,CAEvF;AAED,wBAAgB,oBAAoB,CAAC,OAAO,SAA4B,EAAE,OAAO,CAAC,EAAE,OAAO,GAAG,gBAAgB,CAE7G;AAED,wBAAgB,iBAAiB,CAAC,OAAO,SAAqB,EAAE,OAAO,CAAC,EAAE,OAAO,GAAG,gBAAgB,CAEnG;AAED,wBAAgB,gBAAgB,CAAC,OAAO,SAAsB,EAAE,OAAO,CAAC,EAAE,OAAO,GAAG,gBAAgB,CAEnG;AAED,wBAAgB,qBAAqB,CAAC,OAAO,SAA0B,EAAE,OAAO,CAAC,EAAE,OAAO,GAAG,gBAAgB,CAE5G;AAcD;;;;;;;;GAQG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,OAAO,EAAE,OAAO,CAAC,EAAE,OAAO,GAAG,gBAAgB,CAOjF"}
|
package/dist/api-response.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ErrorCode, isAppError } from './errors.js';
|
|
1
2
|
export const API_ERROR_CODES = {
|
|
2
3
|
SUCCESS: 0,
|
|
3
4
|
NOT_FOUND: 404,
|
|
@@ -66,3 +67,30 @@ export function conflictResponse(message = 'Resource conflict', details) {
|
|
|
66
67
|
export function internalErrorResponse(message = 'Internal server error', details) {
|
|
67
68
|
return errorResponse(API_ERROR_CODES.INTERNAL_ERROR, message, details);
|
|
68
69
|
}
|
|
70
|
+
/** Maps each domain {@link ErrorCode} to its HTTP-layer {@link ApiErrorCode}. */
|
|
71
|
+
const ERROR_CODE_TO_HTTP = {
|
|
72
|
+
[ErrorCode.NotFound]: API_ERROR_CODES.NOT_FOUND,
|
|
73
|
+
[ErrorCode.Validation]: API_ERROR_CODES.VALIDATION_ERROR,
|
|
74
|
+
[ErrorCode.Conflict]: API_ERROR_CODES.CONFLICT,
|
|
75
|
+
[ErrorCode.Internal]: API_ERROR_CODES.INTERNAL_ERROR,
|
|
76
|
+
};
|
|
77
|
+
// Client-actionable errors whose message is safe to surface. Internal/unknown errors are opaque:
|
|
78
|
+
// their message (and any `cause`) must never reach the client, to avoid leaking implementation detail.
|
|
79
|
+
const CLIENT_SAFE_CODES = new Set([ErrorCode.NotFound, ErrorCode.Validation, ErrorCode.Conflict]);
|
|
80
|
+
/**
|
|
81
|
+
* Bridge a thrown error to an API error envelope — the single mapping from the domain error layer
|
|
82
|
+
* ({@link AppError}) to the wire layer ({@link ApiErrorEnvelope}). Use this in request handlers
|
|
83
|
+
* instead of hand-mapping `catch` blocks, so HTTP codes stay consistent across endpoints.
|
|
84
|
+
*
|
|
85
|
+
* A known {@link AppError} maps to its HTTP code; client-safe codes surface their message, while
|
|
86
|
+
* `Internal` and any non-`AppError` collapse to an opaque 500 that leaks neither message nor stack.
|
|
87
|
+
* Pass `details` only when you intend it to reach the client (e.g. validation field errors).
|
|
88
|
+
*/
|
|
89
|
+
export function toApiResponse(error, details) {
|
|
90
|
+
if (isAppError(error)) {
|
|
91
|
+
const httpCode = ERROR_CODE_TO_HTTP[error.code];
|
|
92
|
+
const message = CLIENT_SAFE_CODES.has(error.code) ? error.message : 'Internal server error';
|
|
93
|
+
return errorResponse(httpCode, message, CLIENT_SAFE_CODES.has(error.code) ? details : undefined);
|
|
94
|
+
}
|
|
95
|
+
return internalErrorResponse();
|
|
96
|
+
}
|
package/dist/cursor.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cursor.d.ts","sourceRoot":"","sources":["../src/cursor.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,UAAU;IACvB,EAAE,EAAE,MAAM,CAAC;IACX,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,MAAM,CAAC;CACnB;
|
|
1
|
+
{"version":3,"file":"cursor.d.ts","sourceRoot":"","sources":["../src/cursor.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,UAAU;IACvB,EAAE,EAAE,MAAM,CAAC;IACX,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,MAAM,CAAC;CACnB;AAQD,wBAAgB,YAAY,CAAC,EAAE,EAAE,MAAM,EAAE,SAAS,CAAC,EAAE,IAAI,GAAG,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,UAAU,CAY/F;AAED,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,UAAU,CAmB9E;AAED,wBAAgB,YAAY,CAAC,MAAM,EAAE,UAAU,GAAG,MAAM,CAEvD;AAED,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAEpD;AAED,wBAAgB,oBAAoB,CAAC,EAAE,EAAE,MAAM,EAAE,SAAS,CAAC,EAAE,IAAI,GAAG,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,CAEnG;AAED,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,MAAM,GAAG,UAAU,CAKhE;AAED,wBAAgB,eAAe,CAAC,CAAC,SAAS;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,EAC/E,KAAK,EAAE,CAAC,EAAE,EACV,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,OAAO,GACjB;IAAE,UAAU,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAc1D"}
|
package/dist/cursor.js
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import { toMs } from './date.js';
|
|
2
|
+
/**
|
|
3
|
+
* Upper bound on an encoded cursor. Cursors are short by construction (id + two numbers); a longer
|
|
4
|
+
* input is hostile (cursors are client-supplied pagination tokens) and is rejected before decode.
|
|
5
|
+
*/
|
|
6
|
+
const MAX_ENCODED_CURSOR_LENGTH = 1024;
|
|
2
7
|
export function createCursor(id, createdAt, offset) {
|
|
3
8
|
const cursor = { id };
|
|
4
9
|
if (createdAt !== undefined) {
|
|
@@ -30,20 +35,18 @@ export function parseCursor(data) {
|
|
|
30
35
|
return result;
|
|
31
36
|
}
|
|
32
37
|
export function encodeCursor(cursor) {
|
|
33
|
-
return
|
|
38
|
+
return base64UrlEncode(JSON.stringify(cursor));
|
|
34
39
|
}
|
|
35
40
|
export function decodeCursor(encoded) {
|
|
36
|
-
|
|
37
|
-
return Buffer.from(encoded, 'base64url').toString('utf-8');
|
|
38
|
-
}
|
|
39
|
-
catch (error) {
|
|
40
|
-
throw new Error(`Invalid cursor encoding: ${String(error)}`);
|
|
41
|
-
}
|
|
41
|
+
return base64UrlDecode(encoded);
|
|
42
42
|
}
|
|
43
43
|
export function encodeCursorFromItem(id, createdAt, offset) {
|
|
44
44
|
return encodeCursor(createCursor(id, createdAt, offset));
|
|
45
45
|
}
|
|
46
46
|
export function decodeAndParseCursor(encoded) {
|
|
47
|
+
if (encoded.length > MAX_ENCODED_CURSOR_LENGTH) {
|
|
48
|
+
throw new Error('Invalid cursor: exceeds maximum length');
|
|
49
|
+
}
|
|
47
50
|
return parseCursor(decodeCursor(encoded));
|
|
48
51
|
}
|
|
49
52
|
export function buildCursorMeta(items, limit, hasMore) {
|
|
@@ -59,3 +62,25 @@ export function buildCursorMeta(items, limit, hasMore) {
|
|
|
59
62
|
}
|
|
60
63
|
return meta;
|
|
61
64
|
}
|
|
65
|
+
// Web-standard base64url codec via `btoa`/`atob` (available on Node, Bun, and Cloudflare Workers) —
|
|
66
|
+
// avoids node-only `Buffer` so cursors work in every runtime that depends on ts-utils.
|
|
67
|
+
function base64UrlEncode(text) {
|
|
68
|
+
// Encode to UTF-8 bytes first, then to a binary string `btoa` accepts (it only handles latin1).
|
|
69
|
+
const bytes = new TextEncoder().encode(text);
|
|
70
|
+
let binary = '';
|
|
71
|
+
for (const byte of bytes)
|
|
72
|
+
binary += String.fromCharCode(byte);
|
|
73
|
+
return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
|
|
74
|
+
}
|
|
75
|
+
function base64UrlDecode(encoded) {
|
|
76
|
+
const base64 = encoded.replaceAll('-', '+').replaceAll('_', '/');
|
|
77
|
+
let binary;
|
|
78
|
+
try {
|
|
79
|
+
binary = atob(base64);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
throw new Error('Invalid cursor encoding: not valid base64url');
|
|
83
|
+
}
|
|
84
|
+
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
|
85
|
+
return new TextDecoder().decode(bytes);
|
|
86
|
+
}
|
package/dist/date.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"date.d.ts","sourceRoot":"","sources":["../src/date.ts"],"names":[],"mappings":"AAAA,wBAAgB,KAAK,IAAI,MAAM,CAE9B;AAED,wBAAgB,IAAI,CAAC,KAAK,EAAE,IAAI,GAAG,MAAM,GAAG,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,MAAM,GAAG,IAAI,
|
|
1
|
+
{"version":3,"file":"date.d.ts","sourceRoot":"","sources":["../src/date.ts"],"names":[],"mappings":"AAAA,wBAAgB,KAAK,IAAI,MAAM,CAE9B;AAED,wBAAgB,IAAI,CAAC,KAAK,EAAE,IAAI,GAAG,MAAM,GAAG,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,MAAM,GAAG,IAAI,CASpF;AAED,wBAAgB,MAAM,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,IAAI,GAAG,IAAI,CAGjE"}
|
package/dist/date.js
CHANGED
|
@@ -10,7 +10,8 @@ export function toMs(input) {
|
|
|
10
10
|
const parsed = new Date(input).getTime();
|
|
11
11
|
return Number.isNaN(parsed) ? null : parsed;
|
|
12
12
|
}
|
|
13
|
-
|
|
13
|
+
// Reject NaN/±Infinity rather than passing them through as a "valid" timestamp.
|
|
14
|
+
return Number.isFinite(input) ? Math.floor(input) : null;
|
|
14
15
|
}
|
|
15
16
|
export function fromMs(ms) {
|
|
16
17
|
if (ms === null || ms === undefined || Number.isNaN(ms))
|
package/dist/index.d.ts
CHANGED
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,UAAU,CAAC;AACzB,cAAc,gBAAgB,CAAC;AAC/B,cAAc,SAAS,CAAC;AACxB,cAAc,UAAU,CAAC;AACzB,cAAc,QAAQ,CAAC;AACvB,cAAc,UAAU,CAAC;AACzB,cAAc,UAAU,CAAC;AACzB,cAAc,UAAU,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,UAAU,CAAC;AACzB,cAAc,gBAAgB,CAAC;AAC/B,cAAc,SAAS,CAAC;AACxB,cAAc,UAAU,CAAC;AACzB,cAAc,QAAQ,CAAC;AACvB,cAAc,UAAU,CAAC;AACzB,cAAc,UAAU,CAAC;AACzB,cAAc,UAAU,CAAC;AACzB,cAAc,UAAU,CAAC"}
|
package/dist/index.js
CHANGED
package/dist/object.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Narrow to a non-array object literal. Excludes arrays and `null`, unlike a bare `typeof === object`. */
|
|
2
|
+
export declare function isPlainObject(value: unknown): value is Record<string, unknown>;
|
|
3
|
+
/**
|
|
4
|
+
* Recursively merge `source` into `target`, returning a new object. Nested plain objects merge;
|
|
5
|
+
* arrays and scalars from `source` replace the target wholesale (a source array overrides, never
|
|
6
|
+
* extends). Inputs are not mutated.
|
|
7
|
+
*/
|
|
8
|
+
export declare function deepMerge(target: Record<string, unknown>, source: Record<string, unknown>): Record<string, unknown>;
|
|
9
|
+
/**
|
|
10
|
+
* Flatten a nested object into dot-delimited keys with JSON-stringified leaf values — e.g.
|
|
11
|
+
* `{ a: { b: 1 } }` → `{ "a.b": "1" }`. Inverse of {@link deFlattenKeys}.
|
|
12
|
+
*/
|
|
13
|
+
export declare function flattenKeys(obj: Record<string, unknown>, prefix?: string): Record<string, string>;
|
|
14
|
+
/**
|
|
15
|
+
* Rebuild a nested object from dot-delimited keys, parsing each leaf as JSON (falling back to the
|
|
16
|
+
* raw string). Inverse of {@link flattenKeys}.
|
|
17
|
+
*/
|
|
18
|
+
export declare function deFlattenKeys(entries: Record<string, string>): Record<string, unknown>;
|
|
19
|
+
//# sourceMappingURL=object.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"object.d.ts","sourceRoot":"","sources":["../src/object.ts"],"names":[],"mappings":"AAAA,2GAA2G;AAC3G,wBAAgB,aAAa,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAE9E;AAED;;;;GAIG;AACH,wBAAgB,SAAS,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAUnH;AAED;;;GAGG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,SAAK,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAW7F;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAetF"}
|
package/dist/object.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/** Narrow to a non-array object literal. Excludes arrays and `null`, unlike a bare `typeof === object`. */
|
|
2
|
+
export function isPlainObject(value) {
|
|
3
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Recursively merge `source` into `target`, returning a new object. Nested plain objects merge;
|
|
7
|
+
* arrays and scalars from `source` replace the target wholesale (a source array overrides, never
|
|
8
|
+
* extends). Inputs are not mutated.
|
|
9
|
+
*/
|
|
10
|
+
export function deepMerge(target, source) {
|
|
11
|
+
const result = { ...target };
|
|
12
|
+
for (const [key, value] of Object.entries(source)) {
|
|
13
|
+
if (isPlainObject(value) && isPlainObject(result[key])) {
|
|
14
|
+
result[key] = deepMerge(result[key], value);
|
|
15
|
+
}
|
|
16
|
+
else {
|
|
17
|
+
result[key] = value;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return result;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Flatten a nested object into dot-delimited keys with JSON-stringified leaf values — e.g.
|
|
24
|
+
* `{ a: { b: 1 } }` → `{ "a.b": "1" }`. Inverse of {@link deFlattenKeys}.
|
|
25
|
+
*/
|
|
26
|
+
export function flattenKeys(obj, prefix = '') {
|
|
27
|
+
const result = {};
|
|
28
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
29
|
+
const fullKey = prefix ? `${prefix}.${key}` : key;
|
|
30
|
+
if (isPlainObject(value)) {
|
|
31
|
+
Object.assign(result, flattenKeys(value, fullKey));
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
result[fullKey] = JSON.stringify(value);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return result;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Rebuild a nested object from dot-delimited keys, parsing each leaf as JSON (falling back to the
|
|
41
|
+
* raw string). Inverse of {@link flattenKeys}.
|
|
42
|
+
*/
|
|
43
|
+
export function deFlattenKeys(entries) {
|
|
44
|
+
const result = {};
|
|
45
|
+
for (const [key, rawValue] of Object.entries(entries)) {
|
|
46
|
+
const parts = key.split('.');
|
|
47
|
+
let current = result;
|
|
48
|
+
for (const part of parts.slice(0, -1)) {
|
|
49
|
+
if (!isPlainObject(current[part]))
|
|
50
|
+
current[part] = {};
|
|
51
|
+
current = current[part];
|
|
52
|
+
}
|
|
53
|
+
const last = parts.at(-1);
|
|
54
|
+
if (last === undefined)
|
|
55
|
+
continue;
|
|
56
|
+
current[last] = parseJsonValue(rawValue);
|
|
57
|
+
}
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
60
|
+
/** Parse a string as JSON, returning the original string if it is not valid JSON. */
|
|
61
|
+
function parseJsonValue(value) {
|
|
62
|
+
try {
|
|
63
|
+
return JSON.parse(value);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return value;
|
|
67
|
+
}
|
|
68
|
+
}
|
package/dist/origin.d.ts
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Match an origin against a pattern. Supports exact match, the bare `*` (match-all), and a single
|
|
3
|
+
* `*` wildcard standing for one prefix/suffix gap (e.g. `https://*.example.com`). Patterns with more
|
|
4
|
+
* than one `*` are NOT glob-expanded — they fall back to exact match (so a multi-wildcard pattern
|
|
5
|
+
* matches nothing unless it equals the origin verbatim). Keep CORS patterns to a single wildcard.
|
|
6
|
+
*/
|
|
1
7
|
export declare function matchOriginPattern(origin: string, pattern: string): boolean;
|
|
2
8
|
export declare function isAllowedOrigin(origin: string | undefined | null, allowedOrigins: string[]): boolean;
|
|
3
9
|
export declare function getValidatedOrigin(origin: string | undefined | null, allowedOrigins: string[], fallback: string): string;
|
package/dist/origin.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"origin.d.ts","sourceRoot":"","sources":["../src/origin.ts"],"names":[],"mappings":"AAAA,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,
|
|
1
|
+
{"version":3,"file":"origin.d.ts","sourceRoot":"","sources":["../src/origin.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAgB3E;AAED,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,EAAE,cAAc,EAAE,MAAM,EAAE,GAAG,OAAO,CAKpG;AAED,wBAAgB,kBAAkB,CAC9B,MAAM,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,EACjC,cAAc,EAAE,MAAM,EAAE,EACxB,QAAQ,EAAE,MAAM,GACjB,MAAM,CAKR"}
|
package/dist/origin.js
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Match an origin against a pattern. Supports exact match, the bare `*` (match-all), and a single
|
|
3
|
+
* `*` wildcard standing for one prefix/suffix gap (e.g. `https://*.example.com`). Patterns with more
|
|
4
|
+
* than one `*` are NOT glob-expanded — they fall back to exact match (so a multi-wildcard pattern
|
|
5
|
+
* matches nothing unless it equals the origin verbatim). Keep CORS patterns to a single wildcard.
|
|
6
|
+
*/
|
|
1
7
|
export function matchOriginPattern(origin, pattern) {
|
|
2
8
|
if (pattern === origin)
|
|
3
9
|
return true;
|
|
@@ -6,6 +12,7 @@ export function matchOriginPattern(origin, pattern) {
|
|
|
6
12
|
if (pattern.includes('*')) {
|
|
7
13
|
const parts = pattern.split('*');
|
|
8
14
|
if (parts.length !== 2) {
|
|
15
|
+
// More than one `*`: not supported as a glob — exact-match only (see JSDoc).
|
|
9
16
|
return pattern === origin;
|
|
10
17
|
}
|
|
11
18
|
const [prefix, suffix] = parts;
|
package/dist/output.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"output.d.ts","sourceRoot":"","sources":["../src/output.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,WAAW;IACxB,KAAK,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC;CACjC;
|
|
1
|
+
{"version":3,"file":"output.d.ts","sourceRoot":"","sources":["../src/output.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,WAAW;IACxB,KAAK,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC;CACjC;AAoBD,wBAAgB,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,GAAE,WAA4D,GAAG,IAAI,CAEhH;AAED,wBAAgB,SAAS,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,GAAE,WAA4D,GAAG,IAAI,CAErH;AAED,wBAAgB,uBAAuB,CAAC,IAAI,EAAE;IAAE,MAAM,CAAC,EAAE,WAAW,CAAC;IAAC,MAAM,CAAC,EAAE,WAAW,CAAA;CAAE,GAAG,MAAM,IAAI,CASxG;AAED,MAAM,WAAW,YAAa,SAAQ,WAAW;IAC7C,QAAQ,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC;IAC1B,IAAI,IAAI,MAAM,CAAC;IACf,KAAK,IAAI,IAAI,CAAC;CACjB;AAED,wBAAgB,kBAAkB,IAAI,YAAY,CAejD"}
|
package/dist/output.js
CHANGED
|
@@ -1,12 +1,22 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
// Resolved lazily, not at module load: reading `process.std*` eagerly would throw on import in
|
|
2
|
+
// runtimes without `process` (e.g. Cloudflare Workers). `undefined` means "fall back to process".
|
|
3
|
+
let defaultStdoutTarget;
|
|
4
|
+
let defaultStderrTarget;
|
|
5
|
+
function processStream(name) {
|
|
6
|
+
const proc = globalThis.process;
|
|
7
|
+
const stream = proc?.[name];
|
|
8
|
+
if (stream === undefined) {
|
|
9
|
+
throw new Error(`No ${name} target available: set one via setDefaultOutputTargets or pass an explicit target`);
|
|
10
|
+
}
|
|
11
|
+
return stream;
|
|
12
|
+
}
|
|
3
13
|
function writeLine(message, target) {
|
|
4
14
|
target.write(`${message}\n`);
|
|
5
15
|
}
|
|
6
|
-
export function echo(message, target = defaultStdoutTarget) {
|
|
16
|
+
export function echo(message, target = defaultStdoutTarget ?? processStream('stdout')) {
|
|
7
17
|
writeLine(message, target);
|
|
8
18
|
}
|
|
9
|
-
export function echoError(message, target = defaultStderrTarget) {
|
|
19
|
+
export function echoError(message, target = defaultStderrTarget ?? processStream('stderr')) {
|
|
10
20
|
writeLine(message, target);
|
|
11
21
|
}
|
|
12
22
|
export function setDefaultOutputTargets(opts) {
|
package/package.json
CHANGED
package/src/api-response.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { type AppError, ErrorCode, isAppError } from './errors';
|
|
2
|
+
|
|
1
3
|
export const API_ERROR_CODES = {
|
|
2
4
|
SUCCESS: 0,
|
|
3
5
|
NOT_FOUND: 404,
|
|
@@ -105,3 +107,33 @@ export function conflictResponse(message = 'Resource conflict', details?: unknow
|
|
|
105
107
|
export function internalErrorResponse(message = 'Internal server error', details?: unknown): ApiErrorEnvelope {
|
|
106
108
|
return errorResponse(API_ERROR_CODES.INTERNAL_ERROR, message, details);
|
|
107
109
|
}
|
|
110
|
+
|
|
111
|
+
/** Maps each domain {@link ErrorCode} to its HTTP-layer {@link ApiErrorCode}. */
|
|
112
|
+
const ERROR_CODE_TO_HTTP: Record<ErrorCode, ApiErrorCode> = {
|
|
113
|
+
[ErrorCode.NotFound]: API_ERROR_CODES.NOT_FOUND,
|
|
114
|
+
[ErrorCode.Validation]: API_ERROR_CODES.VALIDATION_ERROR,
|
|
115
|
+
[ErrorCode.Conflict]: API_ERROR_CODES.CONFLICT,
|
|
116
|
+
[ErrorCode.Internal]: API_ERROR_CODES.INTERNAL_ERROR,
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// Client-actionable errors whose message is safe to surface. Internal/unknown errors are opaque:
|
|
120
|
+
// their message (and any `cause`) must never reach the client, to avoid leaking implementation detail.
|
|
121
|
+
const CLIENT_SAFE_CODES = new Set<ErrorCode>([ErrorCode.NotFound, ErrorCode.Validation, ErrorCode.Conflict]);
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Bridge a thrown error to an API error envelope — the single mapping from the domain error layer
|
|
125
|
+
* ({@link AppError}) to the wire layer ({@link ApiErrorEnvelope}). Use this in request handlers
|
|
126
|
+
* instead of hand-mapping `catch` blocks, so HTTP codes stay consistent across endpoints.
|
|
127
|
+
*
|
|
128
|
+
* A known {@link AppError} maps to its HTTP code; client-safe codes surface their message, while
|
|
129
|
+
* `Internal` and any non-`AppError` collapse to an opaque 500 that leaks neither message nor stack.
|
|
130
|
+
* Pass `details` only when you intend it to reach the client (e.g. validation field errors).
|
|
131
|
+
*/
|
|
132
|
+
export function toApiResponse(error: unknown, details?: unknown): ApiErrorEnvelope {
|
|
133
|
+
if (isAppError(error)) {
|
|
134
|
+
const httpCode = ERROR_CODE_TO_HTTP[error.code];
|
|
135
|
+
const message = CLIENT_SAFE_CODES.has(error.code) ? error.message : 'Internal server error';
|
|
136
|
+
return errorResponse(httpCode, message, CLIENT_SAFE_CODES.has(error.code) ? details : undefined);
|
|
137
|
+
}
|
|
138
|
+
return internalErrorResponse();
|
|
139
|
+
}
|
package/src/cursor.ts
CHANGED
|
@@ -6,6 +6,12 @@ export interface CursorData {
|
|
|
6
6
|
offset?: number;
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Upper bound on an encoded cursor. Cursors are short by construction (id + two numbers); a longer
|
|
11
|
+
* input is hostile (cursors are client-supplied pagination tokens) and is rejected before decode.
|
|
12
|
+
*/
|
|
13
|
+
const MAX_ENCODED_CURSOR_LENGTH = 1024;
|
|
14
|
+
|
|
9
15
|
export function createCursor(id: string, createdAt?: Date | number, offset?: number): CursorData {
|
|
10
16
|
const cursor: CursorData = { id };
|
|
11
17
|
if (createdAt !== undefined) {
|
|
@@ -42,15 +48,11 @@ export function parseCursor(data: string | Record<string, unknown>): CursorData
|
|
|
42
48
|
}
|
|
43
49
|
|
|
44
50
|
export function encodeCursor(cursor: CursorData): string {
|
|
45
|
-
return
|
|
51
|
+
return base64UrlEncode(JSON.stringify(cursor));
|
|
46
52
|
}
|
|
47
53
|
|
|
48
54
|
export function decodeCursor(encoded: string): string {
|
|
49
|
-
|
|
50
|
-
return Buffer.from(encoded, 'base64url').toString('utf-8');
|
|
51
|
-
} catch (error) {
|
|
52
|
-
throw new Error(`Invalid cursor encoding: ${String(error)}`);
|
|
53
|
-
}
|
|
55
|
+
return base64UrlDecode(encoded);
|
|
54
56
|
}
|
|
55
57
|
|
|
56
58
|
export function encodeCursorFromItem(id: string, createdAt?: Date | number, offset?: number): string {
|
|
@@ -58,6 +60,9 @@ export function encodeCursorFromItem(id: string, createdAt?: Date | number, offs
|
|
|
58
60
|
}
|
|
59
61
|
|
|
60
62
|
export function decodeAndParseCursor(encoded: string): CursorData {
|
|
63
|
+
if (encoded.length > MAX_ENCODED_CURSOR_LENGTH) {
|
|
64
|
+
throw new Error('Invalid cursor: exceeds maximum length');
|
|
65
|
+
}
|
|
61
66
|
return parseCursor(decodeCursor(encoded));
|
|
62
67
|
}
|
|
63
68
|
|
|
@@ -80,3 +85,26 @@ export function buildCursorMeta<T extends { id: string; createdAt?: number | Dat
|
|
|
80
85
|
|
|
81
86
|
return meta;
|
|
82
87
|
}
|
|
88
|
+
|
|
89
|
+
// Web-standard base64url codec via `btoa`/`atob` (available on Node, Bun, and Cloudflare Workers) —
|
|
90
|
+
// avoids node-only `Buffer` so cursors work in every runtime that depends on ts-utils.
|
|
91
|
+
|
|
92
|
+
function base64UrlEncode(text: string): string {
|
|
93
|
+
// Encode to UTF-8 bytes first, then to a binary string `btoa` accepts (it only handles latin1).
|
|
94
|
+
const bytes = new TextEncoder().encode(text);
|
|
95
|
+
let binary = '';
|
|
96
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
97
|
+
return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function base64UrlDecode(encoded: string): string {
|
|
101
|
+
const base64 = encoded.replaceAll('-', '+').replaceAll('_', '/');
|
|
102
|
+
let binary: string;
|
|
103
|
+
try {
|
|
104
|
+
binary = atob(base64);
|
|
105
|
+
} catch {
|
|
106
|
+
throw new Error('Invalid cursor encoding: not valid base64url');
|
|
107
|
+
}
|
|
108
|
+
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
|
109
|
+
return new TextDecoder().decode(bytes);
|
|
110
|
+
}
|
package/src/date.ts
CHANGED
|
@@ -9,7 +9,8 @@ export function toMs(input: Date | number | string | null | undefined): number |
|
|
|
9
9
|
const parsed = new Date(input).getTime();
|
|
10
10
|
return Number.isNaN(parsed) ? null : parsed;
|
|
11
11
|
}
|
|
12
|
-
|
|
12
|
+
// Reject NaN/±Infinity rather than passing them through as a "valid" timestamp.
|
|
13
|
+
return Number.isFinite(input) ? Math.floor(input) : null;
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
export function fromMs(ms: number | null | undefined): Date | null {
|
package/src/index.ts
CHANGED
package/src/object.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/** Narrow to a non-array object literal. Excludes arrays and `null`, unlike a bare `typeof === object`. */
|
|
2
|
+
export function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
3
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Recursively merge `source` into `target`, returning a new object. Nested plain objects merge;
|
|
8
|
+
* arrays and scalars from `source` replace the target wholesale (a source array overrides, never
|
|
9
|
+
* extends). Inputs are not mutated.
|
|
10
|
+
*/
|
|
11
|
+
export function deepMerge(target: Record<string, unknown>, source: Record<string, unknown>): Record<string, unknown> {
|
|
12
|
+
const result = { ...target };
|
|
13
|
+
for (const [key, value] of Object.entries(source)) {
|
|
14
|
+
if (isPlainObject(value) && isPlainObject(result[key])) {
|
|
15
|
+
result[key] = deepMerge(result[key] as Record<string, unknown>, value);
|
|
16
|
+
} else {
|
|
17
|
+
result[key] = value;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return result;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Flatten a nested object into dot-delimited keys with JSON-stringified leaf values — e.g.
|
|
25
|
+
* `{ a: { b: 1 } }` → `{ "a.b": "1" }`. Inverse of {@link deFlattenKeys}.
|
|
26
|
+
*/
|
|
27
|
+
export function flattenKeys(obj: Record<string, unknown>, prefix = ''): Record<string, string> {
|
|
28
|
+
const result: Record<string, string> = {};
|
|
29
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
30
|
+
const fullKey = prefix ? `${prefix}.${key}` : key;
|
|
31
|
+
if (isPlainObject(value)) {
|
|
32
|
+
Object.assign(result, flattenKeys(value, fullKey));
|
|
33
|
+
} else {
|
|
34
|
+
result[fullKey] = JSON.stringify(value);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return result;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Rebuild a nested object from dot-delimited keys, parsing each leaf as JSON (falling back to the
|
|
42
|
+
* raw string). Inverse of {@link flattenKeys}.
|
|
43
|
+
*/
|
|
44
|
+
export function deFlattenKeys(entries: Record<string, string>): Record<string, unknown> {
|
|
45
|
+
const result: Record<string, unknown> = {};
|
|
46
|
+
for (const [key, rawValue] of Object.entries(entries)) {
|
|
47
|
+
const parts = key.split('.');
|
|
48
|
+
let current = result;
|
|
49
|
+
for (const part of parts.slice(0, -1)) {
|
|
50
|
+
if (!isPlainObject(current[part])) current[part] = {};
|
|
51
|
+
current = current[part] as Record<string, unknown>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const last = parts.at(-1);
|
|
55
|
+
if (last === undefined) continue;
|
|
56
|
+
current[last] = parseJsonValue(rawValue);
|
|
57
|
+
}
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Parse a string as JSON, returning the original string if it is not valid JSON. */
|
|
62
|
+
function parseJsonValue(value: string): unknown {
|
|
63
|
+
try {
|
|
64
|
+
return JSON.parse(value);
|
|
65
|
+
} catch {
|
|
66
|
+
return value;
|
|
67
|
+
}
|
|
68
|
+
}
|
package/src/origin.ts
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Match an origin against a pattern. Supports exact match, the bare `*` (match-all), and a single
|
|
3
|
+
* `*` wildcard standing for one prefix/suffix gap (e.g. `https://*.example.com`). Patterns with more
|
|
4
|
+
* than one `*` are NOT glob-expanded — they fall back to exact match (so a multi-wildcard pattern
|
|
5
|
+
* matches nothing unless it equals the origin verbatim). Keep CORS patterns to a single wildcard.
|
|
6
|
+
*/
|
|
1
7
|
export function matchOriginPattern(origin: string, pattern: string): boolean {
|
|
2
8
|
if (pattern === origin) return true;
|
|
3
9
|
if (pattern === '*') return true;
|
|
@@ -5,6 +11,7 @@ export function matchOriginPattern(origin: string, pattern: string): boolean {
|
|
|
5
11
|
if (pattern.includes('*')) {
|
|
6
12
|
const parts = pattern.split('*');
|
|
7
13
|
if (parts.length !== 2) {
|
|
14
|
+
// More than one `*`: not supported as a glob — exact-match only (see JSDoc).
|
|
8
15
|
return pattern === origin;
|
|
9
16
|
}
|
|
10
17
|
const [prefix, suffix] = parts;
|
package/src/output.ts
CHANGED
|
@@ -2,18 +2,29 @@ export interface WriteTarget {
|
|
|
2
2
|
write(chunk: string): unknown;
|
|
3
3
|
}
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
// Resolved lazily, not at module load: reading `process.std*` eagerly would throw on import in
|
|
6
|
+
// runtimes without `process` (e.g. Cloudflare Workers). `undefined` means "fall back to process".
|
|
7
|
+
let defaultStdoutTarget: WriteTarget | undefined;
|
|
8
|
+
let defaultStderrTarget: WriteTarget | undefined;
|
|
9
|
+
|
|
10
|
+
function processStream(name: 'stdout' | 'stderr'): WriteTarget {
|
|
11
|
+
const proc = (globalThis as { process?: { stdout?: WriteTarget; stderr?: WriteTarget } }).process;
|
|
12
|
+
const stream = proc?.[name];
|
|
13
|
+
if (stream === undefined) {
|
|
14
|
+
throw new Error(`No ${name} target available: set one via setDefaultOutputTargets or pass an explicit target`);
|
|
15
|
+
}
|
|
16
|
+
return stream;
|
|
17
|
+
}
|
|
7
18
|
|
|
8
19
|
function writeLine(message: string, target: WriteTarget): void {
|
|
9
20
|
target.write(`${message}\n`);
|
|
10
21
|
}
|
|
11
22
|
|
|
12
|
-
export function echo(message: string, target: WriteTarget = defaultStdoutTarget): void {
|
|
23
|
+
export function echo(message: string, target: WriteTarget = defaultStdoutTarget ?? processStream('stdout')): void {
|
|
13
24
|
writeLine(message, target);
|
|
14
25
|
}
|
|
15
26
|
|
|
16
|
-
export function echoError(message: string, target: WriteTarget = defaultStderrTarget): void {
|
|
27
|
+
export function echoError(message: string, target: WriteTarget = defaultStderrTarget ?? processStream('stderr')): void {
|
|
17
28
|
writeLine(message, target);
|
|
18
29
|
}
|
|
19
30
|
|