@gobing-ai/ts-utils 0.2.8 → 0.3.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.
@@ -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":"AAAA,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"}
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"}
@@ -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
+ }
@@ -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;AAED,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,CAMpD;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,CAEhE;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"}
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 Buffer.from(JSON.stringify(cursor)).toString('base64url');
38
+ return base64UrlEncode(JSON.stringify(cursor));
34
39
  }
35
40
  export function decodeCursor(encoded) {
36
- try {
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
+ }
@@ -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,CAQpF;AAED,wBAAgB,MAAM,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,IAAI,GAAG,IAAI,CAGjE"}
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
- return Math.floor(input);
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
@@ -4,6 +4,7 @@ export * from './const';
4
4
  export * from './cursor';
5
5
  export * from './date';
6
6
  export * from './errors';
7
+ export * from './object';
7
8
  export * from './origin';
8
9
  export * from './output';
9
10
  //# sourceMappingURL=index.d.ts.map
@@ -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
@@ -4,5 +4,6 @@ export * from './const.js';
4
4
  export * from './cursor.js';
5
5
  export * from './date.js';
6
6
  export * from './errors.js';
7
+ export * from './object.js';
7
8
  export * from './origin.js';
8
9
  export * from './output.js';
@@ -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;
@@ -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,CAe3E;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"}
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;
@@ -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;AASD,wBAAgB,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,GAAE,WAAiC,GAAG,IAAI,CAErF;AAED,wBAAgB,SAAS,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,GAAE,WAAiC,GAAG,IAAI,CAE1F;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"}
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
- let defaultStdoutTarget = process.stdout;
2
- let defaultStderrTarget = process.stderr;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gobing-ai/ts-utils",
3
- "version": "0.2.8",
3
+ "version": "0.3.0",
4
4
  "description": "Zero-dependency TypeScript utilities for dates, cursors, errors, output, origins, roles, and API responses.",
5
5
  "keywords": [
6
6
  "typescript",
@@ -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 Buffer.from(JSON.stringify(cursor)).toString('base64url');
51
+ return base64UrlEncode(JSON.stringify(cursor));
46
52
  }
47
53
 
48
54
  export function decodeCursor(encoded: string): string {
49
- try {
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
- return Math.floor(input);
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
@@ -4,5 +4,6 @@ export * from './const';
4
4
  export * from './cursor';
5
5
  export * from './date';
6
6
  export * from './errors';
7
+ export * from './object';
7
8
  export * from './origin';
8
9
  export * from './output';
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
- let defaultStdoutTarget: WriteTarget = process.stdout;
6
- let defaultStderrTarget: WriteTarget = process.stderr;
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