@rdlabo/workers-hono-kit 0.2.0 → 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.
Files changed (104) hide show
  1. package/README.md +126 -11
  2. package/dist/ai/gateway.d.ts +54 -16
  3. package/dist/ai/gateway.js +37 -12
  4. package/dist/aws/cloudfront.d.ts +23 -5
  5. package/dist/aws/cloudfront.js +45 -6
  6. package/dist/aws/secrets-manager.d.ts +38 -4
  7. package/dist/aws/secrets-manager.js +48 -3
  8. package/dist/cache/kv-cache.d.ts +173 -10
  9. package/dist/cache/kv-cache.js +139 -7
  10. package/dist/db/connection.d.ts +56 -14
  11. package/dist/db/connection.js +39 -13
  12. package/dist/db/database.d.ts +159 -23
  13. package/dist/db/database.js +49 -5
  14. package/dist/db/index.d.ts +11 -0
  15. package/dist/db/index.js +11 -2
  16. package/dist/db/jst.d.ts +89 -6
  17. package/dist/db/jst.js +89 -23
  18. package/dist/db/orm-config.d.ts +61 -19
  19. package/dist/db/orm-config.js +43 -14
  20. package/dist/db/retry.d.ts +25 -3
  21. package/dist/db/retry.js +25 -3
  22. package/dist/db/write-result.d.ts +27 -4
  23. package/dist/db/write-result.js +22 -1
  24. package/dist/firebase/firebase-verifier.d.ts +53 -4
  25. package/dist/firebase/identity-toolkit.d.ts +54 -5
  26. package/dist/firebase/identity-toolkit.js +51 -0
  27. package/dist/firebase/jose-firebase-verifier.d.ts +79 -7
  28. package/dist/firebase/jose-firebase-verifier.js +68 -7
  29. package/dist/firebase/remote-verifier.d.ts +42 -4
  30. package/dist/firebase/remote-verifier.js +58 -9
  31. package/dist/http/app-env.d.ts +41 -8
  32. package/dist/http/app-env.js +38 -8
  33. package/dist/http/app-info.d.ts +25 -3
  34. package/dist/http/app-info.js +16 -2
  35. package/dist/http/http-status.d.ts +12 -3
  36. package/dist/http/http-status.js +12 -3
  37. package/dist/http/nest-error.d.ts +90 -29
  38. package/dist/http/nest-error.js +59 -18
  39. package/dist/http/user-protocol.d.ts +23 -3
  40. package/dist/http/user-protocol.js +14 -2
  41. package/dist/index.d.ts +15 -0
  42. package/dist/index.js +14 -3
  43. package/dist/middleware/auth.d.ts +74 -13
  44. package/dist/middleware/auth.js +30 -6
  45. package/dist/middleware/finalize-response.d.ts +30 -0
  46. package/dist/middleware/finalize-response.js +41 -12
  47. package/dist/middleware/validation.d.ts +83 -9
  48. package/dist/middleware/validation.js +52 -9
  49. package/dist/middleware/zod-coerce.d.ts +56 -2
  50. package/dist/middleware/zod-coerce.js +68 -9
  51. package/dist/queue/consumer.d.ts +112 -0
  52. package/dist/queue/consumer.js +80 -0
  53. package/dist/queue/send.d.ts +90 -0
  54. package/dist/queue/send.js +85 -0
  55. package/dist/stripe/client.d.ts +46 -9
  56. package/dist/stripe/client.js +41 -3
  57. package/dist/testing/auth.d.ts +58 -10
  58. package/dist/testing/auth.js +58 -10
  59. package/dist/testing/configurable-fake.d.ts +20 -9
  60. package/dist/testing/configurable-fake.js +23 -11
  61. package/dist/testing/db.d.ts +81 -12
  62. package/dist/testing/db.js +23 -1
  63. package/dist/testing/fakes.d.ts +77 -9
  64. package/dist/testing/fakes.js +69 -7
  65. package/dist/testing/index.d.ts +7 -0
  66. package/dist/testing/index.js +10 -5
  67. package/dist/testing/stripe-fixtures.d.ts +93 -3
  68. package/dist/testing/stripe-fixtures.js +93 -3
  69. package/package.json +3 -2
  70. package/scripts/check-subrequest-fanout.mjs +86 -0
  71. package/src/ai/gateway.ts +66 -27
  72. package/src/aws/cloudfront.ts +46 -6
  73. package/src/aws/secrets-manager.ts +56 -7
  74. package/src/cache/kv-cache.ts +194 -12
  75. package/src/db/connection.ts +56 -14
  76. package/src/db/database.ts +160 -24
  77. package/src/db/index.ts +11 -2
  78. package/src/db/jst.ts +89 -23
  79. package/src/db/orm-config.ts +61 -19
  80. package/src/db/retry.ts +25 -3
  81. package/src/db/write-result.ts +27 -4
  82. package/src/firebase/firebase-verifier.ts +53 -4
  83. package/src/firebase/identity-toolkit.ts +57 -5
  84. package/src/firebase/jose-firebase-verifier.ts +79 -9
  85. package/src/firebase/remote-verifier.ts +58 -9
  86. package/src/http/app-env.ts +41 -8
  87. package/src/http/app-info.ts +25 -3
  88. package/src/http/http-status.ts +12 -3
  89. package/src/http/nest-error.ts +106 -37
  90. package/src/http/user-protocol.ts +23 -3
  91. package/src/index.ts +17 -3
  92. package/src/middleware/auth.ts +77 -15
  93. package/src/middleware/finalize-response.ts +41 -12
  94. package/src/middleware/validation.ts +89 -15
  95. package/src/middleware/zod-coerce.ts +68 -9
  96. package/src/queue/consumer.ts +146 -0
  97. package/src/queue/send.ts +129 -0
  98. package/src/stripe/client.ts +46 -9
  99. package/src/testing/auth.ts +58 -10
  100. package/src/testing/configurable-fake.ts +23 -11
  101. package/src/testing/db.ts +82 -13
  102. package/src/testing/fakes.ts +77 -9
  103. package/src/testing/index.ts +10 -5
  104. package/src/testing/stripe-fixtures.ts +93 -3
@@ -1,14 +1,13 @@
1
1
  /**
2
- * レスポンス最終化ミドルウェア。Express/Nest(移植元 `../api`)との byte 一致のため 2 点を行う
3
- * (フリート共通仕様 = receptray/winecode hono と同一):
2
+ * Compute an Express `etag`-package compatible weak ETag for a response body.
4
3
  *
5
- * 1. **JSON charset**: Express res.json `application/json; charset=utf-8` を返すが、
6
- * Hono c.json `application/json`(charset 無し)なので合わせる。
7
- * 2. **weak ETag**: Express/`etag` パッケージ互換の
8
- * `W/"<byteLength(16進)>-<sha1(body)をbase64して先頭27文字>"`。Express(=Nest) は GET 等のレスポンスに
9
- * 既定で付与するため、hono/etag の独自形式ではなく Express の算法に厳密一致させる。
4
+ * The format is `W/"<byteLength-in-hex>-<first 27 chars of base64(sha1(body))>"`, byte-for-byte
5
+ * identical to the weak ETag produced by the Express `etag` package. This deliberately differs
6
+ * from `hono/etag`'s own format so responses match an Express/Nest backend exactly.
10
7
  *
11
- * SSE(text/event-stream)はストリームを buffer できないため両方スキップ。
8
+ * @param body - The raw response body bytes to hash.
9
+ * @returns The weak ETag header value (e.g. `W/"1a-Qwerty..."`).
10
+ * @internal
12
11
  */
13
12
  async function weakEtag(body) {
14
13
  const digest = await crypto.subtle.digest('SHA-1', body);
@@ -20,18 +19,48 @@ async function weakEtag(body) {
20
19
  const b64 = btoa(bin).substring(0, 27);
21
20
  return `W/"${body.byteLength.toString(16)}-${b64}"`;
22
21
  }
22
+ /**
23
+ * Create a Hono middleware that finalizes responses for byte-parity with an Express/Nest backend.
24
+ *
25
+ * After the downstream handler runs, it performs two adjustments:
26
+ *
27
+ * 1. **JSON charset**: Express's `res.json` emits `application/json; charset=utf-8`, whereas Hono's
28
+ * `c.json` emits a bare `application/json`. A bare `application/json` content type is rewritten
29
+ * to include `; charset=utf-8`.
30
+ * 2. **Weak ETag**: An Express `etag`-package compatible weak ETag is added, matching the format an
31
+ * Express/Nest backend applies to responses by default. See {@link weakEtag} for the exact format.
32
+ *
33
+ * Server-Sent Events (`text/event-stream`) are skipped entirely because the stream cannot be
34
+ * buffered. ETag generation is also skipped for `204`/`304` responses, responses that already carry
35
+ * an `etag` header, and responses without a body.
36
+ *
37
+ * @returns A {@link MiddlewareHandler} that rewrites the response headers (and body, when an ETag
38
+ * must be computed) in place.
39
+ *
40
+ * @example
41
+ * ```ts
42
+ * import { Hono } from 'hono';
43
+ * import { finalizeResponse } from '@rdlabo/workers-hono-kit';
44
+ *
45
+ * const app = new Hono();
46
+ * app.use('*', finalizeResponse());
47
+ * app.get('/users', (c) => c.json({ ok: true }));
48
+ * // → Content-Type: application/json; charset=utf-8
49
+ * // → ETag: W/"b-..." (b = 0xb = 11 bytes, the length of `{"ok":true}`)
50
+ * ```
51
+ */
23
52
  export function finalizeResponse() {
24
53
  return async (c, next) => {
25
54
  await next();
26
55
  const status = c.res.status;
27
56
  const contentType = c.res.headers.get('content-type') ?? '';
28
- // SSE / ストリームは触らない。
57
+ // Leave SSE / streaming responses untouched.
29
58
  if (contentType.includes('text/event-stream')) {
30
59
  return;
31
60
  }
32
- // charset 補正対象(JSON charset 未指定なら付与)。
61
+ // Charset target: add the charset when a JSON response leaves it unspecified.
33
62
  const needsCharset = contentType === 'application/json';
34
- // ETag 対象(Express 204/304 では付けない・既存 ETag は尊重)。
63
+ // ETag target: Express omits it on 204/304 and respects an existing ETag.
35
64
  const needsEtag = status !== 204 && status !== 304 && !c.res.headers.has('etag') && !!c.res.body;
36
65
  if (!needsCharset && !needsEtag) {
37
66
  return;
@@ -46,7 +75,7 @@ export function finalizeResponse() {
46
75
  c.res = new Response(buf, { status, statusText: c.res.statusText, headers });
47
76
  }
48
77
  else {
49
- // body を読まずヘッダだけ差し替え。
78
+ // Swap only the headers without reading the body.
50
79
  c.res = new Response(c.res.body, { status, statusText: c.res.statusText, headers });
51
80
  }
52
81
  };
@@ -1,21 +1,65 @@
1
1
  import type { Context } from 'hono';
2
2
  import type { ZodType } from 'zod';
3
- /** zod v3(ZodError) / v4(core $ZodError) どちらの error でも受けられる最小形 */
3
+ /**
4
+ * Minimal structural shape that accepts either a zod v3 `ZodError` or a zod v4 core `$ZodError`.
5
+ *
6
+ * @remarks
7
+ * Declaring only the `issues` array (with `path` and `message`) keeps the kit independent of a
8
+ * specific zod major version while still exposing enough to format human-readable messages.
9
+ */
4
10
  export interface ZodErrorLike {
11
+ /** The validation issues reported by zod, each with a property path and a message. */
5
12
  issues: readonly {
6
13
  path: PropertyKey[];
7
14
  message: string;
8
15
  }[];
9
16
  }
17
+ /** Request part that a validator inspects, mirroring the targets supported by `@hono/zod-validator`. */
10
18
  export type ValidationTarget = 'json' | 'query' | 'param' | 'header' | 'cookie' | 'form';
19
+ /** Options controlling the optional side effects of {@link validate}. */
11
20
  export interface ValidateOptions {
12
21
  /**
13
- * 検証失敗時のフック(Sentry 通報など)。**検証の挙動は変えない** レスポンスは常に
14
- * NestJS ValidationPipe 同形の 400 を返す。例外を投げても握り潰す。
15
- * 既定は no-op(receptray 互換 = 4xx を通報しない)。foodlabel は Sentry 実通報を差し込む。
22
+ * Hook invoked when validation fails (e.g. to report to Sentry).
23
+ *
24
+ * @remarks
25
+ * This never changes validation behavior — the response is always a NestJS `ValidationPipe`-shaped
26
+ * 400. Exceptions thrown by the hook are swallowed. The default is a no-op (4xx errors are not
27
+ * reported); pass a hook to forward failures to an error tracker.
28
+ *
29
+ * @param error - The zod error describing the failed validation.
30
+ * @param c - The Hono context for the failing request.
16
31
  */
17
32
  onValidationError?: (error: ZodErrorLike, c: Context) => void;
18
33
  }
34
+ /**
35
+ * Create a zod validation middleware that returns a NestJS `ValidationPipe`-shaped 400 on failure.
36
+ *
37
+ * On success the parsed value is made available through `@hono/zod-validator` as usual. On failure
38
+ * the response body is `{ statusCode: 400, message: string[], error: 'Bad Request' }`, the failing
39
+ * field paths are logged via `console.warn` (so 400s are visible in `wrangler dev`/Workers logs),
40
+ * and {@link ValidateOptions.onValidationError} is invoked if provided.
41
+ *
42
+ * @remarks
43
+ * The exact message strings differ from class-validator because they are produced by zod, but the
44
+ * envelope shape matches NestJS so clients can parse failures identically.
45
+ *
46
+ * @typeParam T - The type produced by the zod schema.
47
+ * @param target - The request part to validate.
48
+ * @param schema - The zod schema to validate the target against.
49
+ * @param options - Optional hooks; see {@link ValidateOptions}.
50
+ * @returns A Hono middleware that validates `target` and short-circuits with a 400 on failure.
51
+ *
52
+ * @example
53
+ * ```ts
54
+ * import { z } from 'zod';
55
+ * import { validate } from '@rdlabo/workers-hono-kit';
56
+ *
57
+ * app.post('/users', validate('json', z.object({ name: z.string() })), (c) => {
58
+ * const { name } = c.req.valid('json');
59
+ * return c.json({ name });
60
+ * });
61
+ * ```
62
+ */
19
63
  export declare function validate<T>(target: ValidationTarget, schema: ZodType<T>, options?: ValidateOptions): import("hono").MiddlewareHandler<import("hono").Env, string, {
20
64
  in: {
21
65
  json?: unknown;
@@ -38,20 +82,50 @@ export declare function validate<T>(target: ValidationTarget, schema: ZodType<T>
38
82
  message: string[];
39
83
  error: string;
40
84
  }, 400, "json">>;
41
- /** Sentry の最小形(`@sentry/cloudflare` 等への直接依存を避けるための構造型)。 */
85
+ /**
86
+ * Minimal structural shape of a Sentry scope, covering only the methods this kit calls.
87
+ *
88
+ * @remarks
89
+ * Declaring the shape structurally avoids a direct dependency on `@sentry/cloudflare`; any object
90
+ * exposing these methods (such as the real Sentry scope) is accepted.
91
+ */
42
92
  export interface SentryScopeLike {
93
+ /** Attach a string tag to the current scope. */
43
94
  setTag(key: string, value: string): void;
95
+ /** Attach (or clear, with `null`) a structured context entry on the current scope. */
44
96
  setContext(key: string, context: Record<string, unknown> | null): void;
45
97
  }
98
+ /**
99
+ * Minimal structural shape of the Sentry client, covering only the methods this kit calls.
100
+ *
101
+ * @remarks
102
+ * Like {@link SentryScopeLike}, this avoids a hard dependency on `@sentry/cloudflare`.
103
+ */
46
104
  export interface SentryLike {
105
+ /** Run a callback with an isolated scope, restoring the previous scope afterward. */
47
106
  withScope(callback: (scope: SentryScopeLike) => void): void;
107
+ /** Report an exception to Sentry. */
48
108
  captureException(error: unknown): void;
49
109
  }
50
110
  /**
51
- * DTO 検証 400 Sentry に通報する `validate` を返すファクトリ(foodlabel/tipsys/winecode で重複していた
52
- * reportValidationToSentry を集約)。kit は `@sentry/cloudflare` に依存せず、Sentry モジュールを注入する。
53
- * 通報は `tag error.type=dto_validation` `context validation={errorCount, errors}`(各 /api
54
- * sentry-validation.pipe.ts 準拠)。検証の挙動・レスポンスは `validate` と同一(通報は副作用のみ)。
111
+ * Create a {@link validate}-like factory that additionally reports DTO validation 400s to Sentry.
112
+ *
113
+ * The returned function has the same signature and behavior as {@link validate}; reporting is a pure
114
+ * side effect that does not alter validation behavior or the response. Each report is tagged with
115
+ * `error.type=dto_validation` and carries a `validation` context of `{ errorCount, errors }`.
116
+ *
117
+ * @param sentry - A Sentry-like client used to capture validation failures; see {@link SentryLike}.
118
+ * @returns A function `(target, schema) => MiddlewareHandler` mirroring {@link validate}.
119
+ *
120
+ * @example
121
+ * ```ts
122
+ * import * as Sentry from '@sentry/cloudflare';
123
+ * import { z } from 'zod';
124
+ * import { createSentryValidate } from '@rdlabo/workers-hono-kit';
125
+ *
126
+ * const validate = createSentryValidate(Sentry);
127
+ * app.post('/users', validate('json', z.object({ name: z.string() })), handler);
128
+ * ```
55
129
  */
56
130
  export declare function createSentryValidate(sentry: SentryLike): <T>(target: ValidationTarget, schema: ZodType<T>) => import("hono").MiddlewareHandler<import("hono").Env, string, {
57
131
  in: {
@@ -1,11 +1,11 @@
1
1
  import { zValidator } from '@hono/zod-validator';
2
2
  /**
3
- * Zod 検証ミドルウェア(フリート共通 = receptray/winecode hono と同一仕様)。
4
- * 失敗時は NestJS の ValidationPipe と同形の body を返す:
5
- * { statusCode: 400, message: string[], error: 'Bad Request' }
3
+ * Convert a {@link ZodErrorLike} into NestJS `ValidationPipe`-style message strings.
6
4
  *
7
- * NOTE(parity): message の文字列内容は class-validator Zod で異なる。各 repo の固定 app の
8
- * 正常系では DTO 検証 400 は発生しない前提(ビジネス 400 は各エンドポイントで HttpError 忠実再現)。
5
+ * Each issue becomes `"<dotted.path>: <message>"`, or just `"<message>"` when the path is empty.
6
+ *
7
+ * @param error - The zod error to flatten.
8
+ * @returns One message string per issue.
9
9
  */
10
10
  function zodToMessages(error) {
11
11
  return error.issues.map((issue) => {
@@ -13,6 +13,35 @@ function zodToMessages(error) {
13
13
  return path ? `${path}: ${issue.message}` : issue.message;
14
14
  });
15
15
  }
16
+ /**
17
+ * Create a zod validation middleware that returns a NestJS `ValidationPipe`-shaped 400 on failure.
18
+ *
19
+ * On success the parsed value is made available through `@hono/zod-validator` as usual. On failure
20
+ * the response body is `{ statusCode: 400, message: string[], error: 'Bad Request' }`, the failing
21
+ * field paths are logged via `console.warn` (so 400s are visible in `wrangler dev`/Workers logs),
22
+ * and {@link ValidateOptions.onValidationError} is invoked if provided.
23
+ *
24
+ * @remarks
25
+ * The exact message strings differ from class-validator because they are produced by zod, but the
26
+ * envelope shape matches NestJS so clients can parse failures identically.
27
+ *
28
+ * @typeParam T - The type produced by the zod schema.
29
+ * @param target - The request part to validate.
30
+ * @param schema - The zod schema to validate the target against.
31
+ * @param options - Optional hooks; see {@link ValidateOptions}.
32
+ * @returns A Hono middleware that validates `target` and short-circuits with a 400 on failure.
33
+ *
34
+ * @example
35
+ * ```ts
36
+ * import { z } from 'zod';
37
+ * import { validate } from '@rdlabo/workers-hono-kit';
38
+ *
39
+ * app.post('/users', validate('json', z.object({ name: z.string() })), (c) => {
40
+ * const { name } = c.req.valid('json');
41
+ * return c.json({ name });
42
+ * });
43
+ * ```
44
+ */
16
45
  export function validate(target, schema, options) {
17
46
  return zValidator(target, schema, (result, c) => {
18
47
  if (!result.success) {
@@ -35,10 +64,24 @@ export function validate(target, schema, options) {
35
64
  });
36
65
  }
37
66
  /**
38
- * DTO 検証 400 Sentry に通報する `validate` を返すファクトリ(foodlabel/tipsys/winecode で重複していた
39
- * reportValidationToSentry を集約)。kit は `@sentry/cloudflare` に依存せず、Sentry モジュールを注入する。
40
- * 通報は `tag error.type=dto_validation` `context validation={errorCount, errors}`(各 /api
41
- * sentry-validation.pipe.ts 準拠)。検証の挙動・レスポンスは `validate` と同一(通報は副作用のみ)。
67
+ * Create a {@link validate}-like factory that additionally reports DTO validation 400s to Sentry.
68
+ *
69
+ * The returned function has the same signature and behavior as {@link validate}; reporting is a pure
70
+ * side effect that does not alter validation behavior or the response. Each report is tagged with
71
+ * `error.type=dto_validation` and carries a `validation` context of `{ errorCount, errors }`.
72
+ *
73
+ * @param sentry - A Sentry-like client used to capture validation failures; see {@link SentryLike}.
74
+ * @returns A function `(target, schema) => MiddlewareHandler` mirroring {@link validate}.
75
+ *
76
+ * @example
77
+ * ```ts
78
+ * import * as Sentry from '@sentry/cloudflare';
79
+ * import { z } from 'zod';
80
+ * import { createSentryValidate } from '@rdlabo/workers-hono-kit';
81
+ *
82
+ * const validate = createSentryValidate(Sentry);
83
+ * app.post('/users', validate('json', z.object({ name: z.string() })), handler);
84
+ * ```
42
85
  */
43
86
  export function createSentryValidate(sentry) {
44
87
  const onValidationError = (error) => {
@@ -1,9 +1,63 @@
1
1
  import { z } from 'zod';
2
2
  /**
3
- * 数値強制スキーマ。`inner` `z.number().int()` 等を渡して制約を足せる(既定は z.number())。
4
- * 例: zNum(z.number().int()) で整数必須。
3
+ * Build a required number schema that coerces string input to a number.
4
+ *
5
+ * @param inner - The inner number schema to apply after coercion; pass e.g. `z.number().int()` to add
6
+ * constraints. Defaults to `z.number()`.
7
+ * @returns A zod schema yielding a `number`.
8
+ *
9
+ * @example
10
+ * ```ts
11
+ * // Require an integer path param:
12
+ * const schema = z.object({ id: zNum(z.number().int()) });
13
+ * // '42' → 42, '' / ' ' → NaN (rejected)
14
+ * ```
5
15
  */
6
16
  export declare const zNum: (inner?: z.ZodNumber) => z.ZodType<number>;
17
+ /**
18
+ * Build a number schema that coerces string input and substitutes a default for missing values.
19
+ *
20
+ * `undefined` or an empty string yields `defaultValue`; a whitespace-only string yields `NaN`
21
+ * (rejected by the inner schema); anything else is passed through `Number`.
22
+ *
23
+ * @param defaultValue - The value used when the input is `undefined` or an empty string.
24
+ * @param inner - The inner number schema applied after coercion. Defaults to `z.number()`.
25
+ * @returns A zod schema yielding a `number`.
26
+ *
27
+ * @example
28
+ * ```ts
29
+ * // Default page to 1 when the query param is absent:
30
+ * const schema = z.object({ page: zNumWithDefault(1) });
31
+ * ```
32
+ */
7
33
  export declare const zNumWithDefault: (defaultValue: number, inner?: z.ZodNumber) => z.ZodType<number>;
34
+ /**
35
+ * Build an optional number schema that coerces string input.
36
+ *
37
+ * `undefined`, `null`, or an empty string yields `undefined`; a whitespace-only string yields `NaN`
38
+ * (rejected); anything else is passed through `Number`.
39
+ *
40
+ * @param inner - The inner number schema applied after coercion. Defaults to `z.number()`.
41
+ * @returns A zod schema yielding `number | undefined`.
42
+ *
43
+ * @example
44
+ * ```ts
45
+ * const schema = z.object({ limit: zNumOptional(z.number().int()) });
46
+ * ```
47
+ */
8
48
  export declare const zNumOptional: (inner?: z.ZodNumber) => z.ZodType<number | undefined>;
49
+ /**
50
+ * Build a nullable, optional number schema that coerces string input.
51
+ *
52
+ * `null`/`undefined` pass through unchanged; an empty string yields `undefined`; a whitespace-only
53
+ * string yields `NaN` (rejected); anything else is passed through `Number`.
54
+ *
55
+ * @param inner - The inner number schema applied after coercion. Defaults to `z.number()`.
56
+ * @returns A zod schema yielding `number | null | undefined`.
57
+ *
58
+ * @example
59
+ * ```ts
60
+ * const schema = z.object({ parentId: zNumNullable(z.number().int()) });
61
+ * ```
62
+ */
9
63
  export declare const zNumNullable: (inner?: z.ZodNumber) => z.ZodType<number | null | undefined>;
@@ -1,13 +1,18 @@
1
1
  import { z } from 'zod';
2
2
  /**
3
- * `/api` class-transformer @Transform(number-transform.util.ts)を Zod preprocess で忠実再現する。
4
- * path/query は常に文字列で来るため数値強制が必要。空白のみ文字列は NaN にして後段の数値スキーマで弾く
5
- * (class-validator の @IsInt/@IsNumber が NaN を拒否する挙動と一致。zod v4 は z.number() が NaN を既定拒否)。
3
+ * Zod preprocessors that coerce string path/query parameters into numbers.
4
+ *
5
+ * @remarks
6
+ * These mirror a class-transformer `@Transform(() => Number)` coercion: path and query parameters
7
+ * always arrive as strings, so they must be forced to numbers before number validation. Whitespace-only
8
+ * strings are mapped to `NaN` so the downstream number schema rejects them (matching class-validator's
9
+ * `@IsInt`/`@IsNumber`, which reject `NaN`; zod v4's `z.number()` also rejects `NaN` by default).
6
10
  */
11
+ /** Return `true` when `value` is a string that is empty or contains only whitespace. */
7
12
  const isBlankString = (value) => typeof value === 'string' && value.trim() === '';
8
- // toNumber: 空白文字列 → NaN、それ以外 → Number(value)
13
+ // toNumber: blank string → NaN, otherwise → Number(value).
9
14
  const rawToNumber = (value) => (isBlankString(value) ? Number(undefined) : Number(value));
10
- // toNumberWithDefault: undefined/'' → default、空白 → NaNelse Number
15
+ // toNumberWithDefault: undefined/'' → default, blank → NaN, else Number.
11
16
  const rawToNumberWithDefault = (defaultValue) => (value) => {
12
17
  if (value === undefined || value === '') {
13
18
  return defaultValue;
@@ -17,7 +22,7 @@ const rawToNumberWithDefault = (defaultValue) => (value) => {
17
22
  }
18
23
  return Number(value);
19
24
  };
20
- // toOptionalNumber: undefined/null/'' → undefined、空白 → NaNelse Number
25
+ // toOptionalNumber: undefined/null/'' → undefined, blank → NaN, else Number.
21
26
  const rawToOptionalNumber = (value) => {
22
27
  if (value === undefined || value === null || value === '') {
23
28
  return undefined;
@@ -27,7 +32,7 @@ const rawToOptionalNumber = (value) => {
27
32
  }
28
33
  return Number(value);
29
34
  };
30
- // toNullableNumber: null/undefined → passthrough'' → undefined、空白 → NaNelse Number
35
+ // toNullableNumber: null/undefined → passthrough, '' → undefined, blank → NaN, else Number.
31
36
  const rawToNullableNumber = (value) => {
32
37
  if (value === null || value === undefined) {
33
38
  return value;
@@ -41,10 +46,64 @@ const rawToNullableNumber = (value) => {
41
46
  return Number(value);
42
47
  };
43
48
  /**
44
- * 数値強制スキーマ。`inner` `z.number().int()` 等を渡して制約を足せる(既定は z.number())。
45
- * 例: zNum(z.number().int()) で整数必須。
49
+ * Build a required number schema that coerces string input to a number.
50
+ *
51
+ * @param inner - The inner number schema to apply after coercion; pass e.g. `z.number().int()` to add
52
+ * constraints. Defaults to `z.number()`.
53
+ * @returns A zod schema yielding a `number`.
54
+ *
55
+ * @example
56
+ * ```ts
57
+ * // Require an integer path param:
58
+ * const schema = z.object({ id: zNum(z.number().int()) });
59
+ * // '42' → 42, '' / ' ' → NaN (rejected)
60
+ * ```
46
61
  */
47
62
  export const zNum = (inner = z.number()) => z.preprocess(rawToNumber, inner);
63
+ /**
64
+ * Build a number schema that coerces string input and substitutes a default for missing values.
65
+ *
66
+ * `undefined` or an empty string yields `defaultValue`; a whitespace-only string yields `NaN`
67
+ * (rejected by the inner schema); anything else is passed through `Number`.
68
+ *
69
+ * @param defaultValue - The value used when the input is `undefined` or an empty string.
70
+ * @param inner - The inner number schema applied after coercion. Defaults to `z.number()`.
71
+ * @returns A zod schema yielding a `number`.
72
+ *
73
+ * @example
74
+ * ```ts
75
+ * // Default page to 1 when the query param is absent:
76
+ * const schema = z.object({ page: zNumWithDefault(1) });
77
+ * ```
78
+ */
48
79
  export const zNumWithDefault = (defaultValue, inner = z.number()) => z.preprocess(rawToNumberWithDefault(defaultValue), inner);
80
+ /**
81
+ * Build an optional number schema that coerces string input.
82
+ *
83
+ * `undefined`, `null`, or an empty string yields `undefined`; a whitespace-only string yields `NaN`
84
+ * (rejected); anything else is passed through `Number`.
85
+ *
86
+ * @param inner - The inner number schema applied after coercion. Defaults to `z.number()`.
87
+ * @returns A zod schema yielding `number | undefined`.
88
+ *
89
+ * @example
90
+ * ```ts
91
+ * const schema = z.object({ limit: zNumOptional(z.number().int()) });
92
+ * ```
93
+ */
49
94
  export const zNumOptional = (inner = z.number()) => z.preprocess(rawToOptionalNumber, inner.optional());
95
+ /**
96
+ * Build a nullable, optional number schema that coerces string input.
97
+ *
98
+ * `null`/`undefined` pass through unchanged; an empty string yields `undefined`; a whitespace-only
99
+ * string yields `NaN` (rejected); anything else is passed through `Number`.
100
+ *
101
+ * @param inner - The inner number schema applied after coercion. Defaults to `z.number()`.
102
+ * @returns A zod schema yielding `number | null | undefined`.
103
+ *
104
+ * @example
105
+ * ```ts
106
+ * const schema = z.object({ parentId: zNumNullable(z.number().int()) });
107
+ * ```
108
+ */
50
109
  export const zNumNullable = (inner = z.number()) => z.preprocess(rawToNullableNumber, inner.nullish());
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Consumer-side helper for processing a Cloudflare Queues `MessageBatch` with per-message success and
3
+ * failure handling.
4
+ *
5
+ * A queue consumer invocation receives at most `max_batch_size` messages (configured in
6
+ * `wrangler.toml`), which is precisely the mechanism that bounds its subrequest budget: with a small
7
+ * `max_batch_size`, each invocation performs a fixed, small number of external calls no matter how
8
+ * many messages are backed up in the queue. {@link processBatch} applies the standard
9
+ * ack-on-success / retry-on-failure discipline so one poison message does not fail its whole batch.
10
+ *
11
+ * Messages are processed sequentially. This keeps the number of *simultaneously open* subrequests at
12
+ * one, staying well clear of the Workers concurrent-connection ceiling, and makes the per-invocation
13
+ * subrequest count deterministic (`<= max_batch_size`). For a queue consumer — which is not on a
14
+ * user-facing latency path — sequential processing is the safer default.
15
+ *
16
+ * @example
17
+ * ```ts
18
+ * // Worker `queue` handler
19
+ * export default {
20
+ * async queue(batch: MessageBatchLike<{ userId: number }>, env: Env) {
21
+ * await processBatch(batch, async ({ userId }) => {
22
+ * await reloadOneCustomer(env, userId); // exactly one external payment call
23
+ * });
24
+ * },
25
+ * };
26
+ * ```
27
+ *
28
+ * @packageDocumentation
29
+ */
30
+ /**
31
+ * Minimal subset of `@cloudflare/workers-types`' `Message` used by {@link processBatch}.
32
+ *
33
+ * Declared locally so consumers are not forced to depend on `@cloudflare/workers-types`.
34
+ *
35
+ * @typeParam Body - Type of the message body.
36
+ */
37
+ export interface QueueMessageLike<Body = unknown> {
38
+ /** Unique id assigned by the Queues runtime. */
39
+ readonly id: string;
40
+ /** Number of delivery attempts so far (starts at 1 on first delivery). */
41
+ readonly attempts: number;
42
+ /** The message payload. */
43
+ readonly body: Body;
44
+ /** Explicitly acknowledge this message so it is not redelivered. */
45
+ ack: () => void;
46
+ /** Mark this message for redelivery, optionally after a delay. */
47
+ retry: (options?: {
48
+ delaySeconds?: number;
49
+ }) => void;
50
+ }
51
+ /**
52
+ * Minimal subset of `@cloudflare/workers-types`' `MessageBatch` used by {@link processBatch}.
53
+ *
54
+ * @typeParam Body - Type of each message body in the batch.
55
+ */
56
+ export interface MessageBatchLike<Body = unknown> {
57
+ /** Name of the queue this batch was delivered from. */
58
+ readonly queue: string;
59
+ /** The messages in this batch; length is bounded by the consumer's `max_batch_size`. */
60
+ readonly messages: readonly QueueMessageLike<Body>[];
61
+ }
62
+ /**
63
+ * Options for {@link processBatch}.
64
+ *
65
+ * @typeParam Body - Type of each message body.
66
+ */
67
+ export interface ProcessBatchOptions<Body = unknown> {
68
+ /**
69
+ * Invoked when `handler` throws for a message, immediately before the message is marked for retry.
70
+ * Use it to log or report; it must not throw. Defaults to `console.error`.
71
+ */
72
+ onError?: (error: unknown, message: QueueMessageLike<Body>) => void;
73
+ /**
74
+ * Delay, in seconds, applied when re-queuing a failed message. Omit to retry with the queue's
75
+ * default backoff.
76
+ */
77
+ retryDelaySeconds?: number;
78
+ }
79
+ /**
80
+ * Outcome counts returned by {@link processBatch}.
81
+ */
82
+ export interface ProcessBatchResult {
83
+ /** Messages whose handler completed successfully and were acked. */
84
+ processed: number;
85
+ /** Messages whose handler threw and were marked for retry. */
86
+ failed: number;
87
+ }
88
+ /**
89
+ * Process every message in `batch` sequentially, acking on success and retrying on failure.
90
+ *
91
+ * Each message is passed to `handler`; if it resolves the message is acked, and if it throws the
92
+ * error is routed to {@link ProcessBatchOptions.onError} and the message is marked for retry (honoring
93
+ * {@link ProcessBatchOptions.retryDelaySeconds}). One failing message never affects the others, and
94
+ * the returned counts let tests assert that the per-invocation workload — and therefore the
95
+ * subrequest count — stayed bounded by the batch size.
96
+ *
97
+ * @typeParam Body - Type of each message body.
98
+ * @param batch - The delivered message batch.
99
+ * @param handler - Async work for a single message; performs the bounded external call(s). Receives
100
+ * the decoded `body` and the raw message (for `attempts`, `id`, etc.).
101
+ * @param options - Error reporting and retry tuning; see {@link ProcessBatchOptions}.
102
+ * @returns The number of processed and failed messages.
103
+ * @example
104
+ * ```ts
105
+ * const { processed, failed } = await processBatch(
106
+ * batch,
107
+ * async ({ id }) => sendOneMail(id),
108
+ * { retryDelaySeconds: 30, onError: (e, m) => report(e, m.id) },
109
+ * );
110
+ * ```
111
+ */
112
+ export declare function processBatch<Body>(batch: MessageBatchLike<Body>, handler: (body: Body, message: QueueMessageLike<Body>) => Promise<void>, options?: ProcessBatchOptions<Body>): Promise<ProcessBatchResult>;
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Consumer-side helper for processing a Cloudflare Queues `MessageBatch` with per-message success and
3
+ * failure handling.
4
+ *
5
+ * A queue consumer invocation receives at most `max_batch_size` messages (configured in
6
+ * `wrangler.toml`), which is precisely the mechanism that bounds its subrequest budget: with a small
7
+ * `max_batch_size`, each invocation performs a fixed, small number of external calls no matter how
8
+ * many messages are backed up in the queue. {@link processBatch} applies the standard
9
+ * ack-on-success / retry-on-failure discipline so one poison message does not fail its whole batch.
10
+ *
11
+ * Messages are processed sequentially. This keeps the number of *simultaneously open* subrequests at
12
+ * one, staying well clear of the Workers concurrent-connection ceiling, and makes the per-invocation
13
+ * subrequest count deterministic (`<= max_batch_size`). For a queue consumer — which is not on a
14
+ * user-facing latency path — sequential processing is the safer default.
15
+ *
16
+ * @example
17
+ * ```ts
18
+ * // Worker `queue` handler
19
+ * export default {
20
+ * async queue(batch: MessageBatchLike<{ userId: number }>, env: Env) {
21
+ * await processBatch(batch, async ({ userId }) => {
22
+ * await reloadOneCustomer(env, userId); // exactly one external payment call
23
+ * });
24
+ * },
25
+ * };
26
+ * ```
27
+ *
28
+ * @packageDocumentation
29
+ */
30
+ /**
31
+ * Process every message in `batch` sequentially, acking on success and retrying on failure.
32
+ *
33
+ * Each message is passed to `handler`; if it resolves the message is acked, and if it throws the
34
+ * error is routed to {@link ProcessBatchOptions.onError} and the message is marked for retry (honoring
35
+ * {@link ProcessBatchOptions.retryDelaySeconds}). One failing message never affects the others, and
36
+ * the returned counts let tests assert that the per-invocation workload — and therefore the
37
+ * subrequest count — stayed bounded by the batch size.
38
+ *
39
+ * @typeParam Body - Type of each message body.
40
+ * @param batch - The delivered message batch.
41
+ * @param handler - Async work for a single message; performs the bounded external call(s). Receives
42
+ * the decoded `body` and the raw message (for `attempts`, `id`, etc.).
43
+ * @param options - Error reporting and retry tuning; see {@link ProcessBatchOptions}.
44
+ * @returns The number of processed and failed messages.
45
+ * @example
46
+ * ```ts
47
+ * const { processed, failed } = await processBatch(
48
+ * batch,
49
+ * async ({ id }) => sendOneMail(id),
50
+ * { retryDelaySeconds: 30, onError: (e, m) => report(e, m.id) },
51
+ * );
52
+ * ```
53
+ */
54
+ export async function processBatch(batch, handler, options) {
55
+ const onError = options?.onError ??
56
+ ((error, message) => {
57
+ console.error(`[queue:${batch.queue}] message ${message.id} failed`, error);
58
+ });
59
+ const retryOptions = options?.retryDelaySeconds === undefined ? undefined : { delaySeconds: options.retryDelaySeconds };
60
+ let processed = 0;
61
+ let failed = 0;
62
+ for (const message of batch.messages) {
63
+ try {
64
+ await handler(message.body, message);
65
+ message.ack();
66
+ processed++;
67
+ }
68
+ catch (error) {
69
+ try {
70
+ onError(error, message);
71
+ }
72
+ catch {
73
+ // onError contract says "must not throw"; guard defensively so retry() and remaining messages still run.
74
+ }
75
+ message.retry(retryOptions);
76
+ failed++;
77
+ }
78
+ }
79
+ return { processed, failed };
80
+ }