@rdlabo/workers-hono-kit 0.3.7 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -1
- package/dist/business-time/index.d.ts +49 -0
- package/dist/business-time/index.js +149 -0
- package/dist/business-time/types.d.ts +9 -0
- package/dist/business-time/types.js +5 -0
- package/dist/db/columns.d.ts +46 -0
- package/dist/db/columns.js +36 -0
- package/dist/db/connection.js +2 -1
- package/dist/db/decimal.d.ts +27 -0
- package/dist/db/decimal.js +50 -0
- package/dist/db/index.d.ts +4 -1
- package/dist/db/index.js +3 -1
- package/dist/db/jst.d.ts +10 -72
- package/dist/db/jst.js +10 -82
- package/package.json +7 -3
- package/src/ai/gateway.ts +0 -120
- package/src/aws/cloudfront.ts +0 -105
- package/src/aws/secrets-manager.ts +0 -112
- package/src/cache/kv-cache.ts +0 -316
- package/src/db/connection.ts +0 -107
- package/src/db/database.ts +0 -269
- package/src/db/index.ts +0 -39
- package/src/db/jst.ts +0 -122
- package/src/db/migrate.ts +0 -155
- package/src/db/orm-config.ts +0 -171
- package/src/db/retry.ts +0 -43
- package/src/db/write-result.ts +0 -46
- package/src/firebase/firebase-verifier.ts +0 -76
- package/src/firebase/identity-toolkit.ts +0 -179
- package/src/firebase/jose-firebase-verifier.ts +0 -159
- package/src/firebase/remote-verifier.ts +0 -98
- package/src/http/app-env.ts +0 -53
- package/src/http/app-info.ts +0 -38
- package/src/http/execution-context.ts +0 -11
- package/src/http/http-status.ts +0 -71
- package/src/http/nest-error.ts +0 -207
- package/src/http/trailing-slash.ts +0 -28
- package/src/http/user-protocol.ts +0 -36
- package/src/index.ts +0 -77
- package/src/middleware/auth.ts +0 -129
- package/src/middleware/finalize-response.ts +0 -90
- package/src/middleware/validation.ts +0 -158
- package/src/middleware/zod-coerce.ts +0 -124
- package/src/queue/consumer.ts +0 -146
- package/src/queue/send.ts +0 -129
- package/src/stripe/client.ts +0 -85
- package/src/testing/auth.ts +0 -110
- package/src/testing/configurable-fake.ts +0 -45
- package/src/testing/db.ts +0 -194
- package/src/testing/fakes.ts +0 -153
- package/src/testing/index.ts +0 -31
- package/src/testing/stripe-fixtures.ts +0 -175
|
@@ -1,158 +0,0 @@
|
|
|
1
|
-
import { zValidator } from '@hono/zod-validator';
|
|
2
|
-
import type { Context } from 'hono';
|
|
3
|
-
import type { ZodType } from 'zod';
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Minimal structural shape that accepts either a zod v3 `ZodError` or a zod v4 core `$ZodError`.
|
|
7
|
-
*
|
|
8
|
-
* @remarks
|
|
9
|
-
* Declaring only the `issues` array (with `path` and `message`) keeps the kit independent of a
|
|
10
|
-
* specific zod major version while still exposing enough to format human-readable messages.
|
|
11
|
-
*/
|
|
12
|
-
export interface ZodErrorLike {
|
|
13
|
-
/** The validation issues reported by zod, each with a property path and a message. */
|
|
14
|
-
issues: readonly { path: PropertyKey[]; message: string }[];
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/** Request part that a validator inspects, mirroring the targets supported by `@hono/zod-validator`. */
|
|
18
|
-
export type ValidationTarget = 'json' | 'query' | 'param' | 'header' | 'cookie' | 'form';
|
|
19
|
-
|
|
20
|
-
/** Options controlling the optional side effects of {@link validate}. */
|
|
21
|
-
export interface ValidateOptions {
|
|
22
|
-
/**
|
|
23
|
-
* Hook invoked when validation fails (e.g. to report to Sentry).
|
|
24
|
-
*
|
|
25
|
-
* @remarks
|
|
26
|
-
* This never changes validation behavior — the response is always a NestJS `ValidationPipe`-shaped
|
|
27
|
-
* 400. Exceptions thrown by the hook are swallowed. The default is a no-op (4xx errors are not
|
|
28
|
-
* reported); pass a hook to forward failures to an error tracker.
|
|
29
|
-
*
|
|
30
|
-
* @param error - The zod error describing the failed validation.
|
|
31
|
-
* @param c - The Hono context for the failing request.
|
|
32
|
-
*/
|
|
33
|
-
onValidationError?: (error: ZodErrorLike, c: Context) => void;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Convert a {@link ZodErrorLike} into NestJS `ValidationPipe`-style message strings.
|
|
38
|
-
*
|
|
39
|
-
* Each issue becomes `"<dotted.path>: <message>"`, or just `"<message>"` when the path is empty.
|
|
40
|
-
*
|
|
41
|
-
* @param error - The zod error to flatten.
|
|
42
|
-
* @returns One message string per issue.
|
|
43
|
-
*/
|
|
44
|
-
function zodToMessages(error: ZodErrorLike): string[] {
|
|
45
|
-
return error.issues.map((issue) => {
|
|
46
|
-
const path = issue.path.map(String).join('.');
|
|
47
|
-
return path ? `${path}: ${issue.message}` : issue.message;
|
|
48
|
-
});
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Create a zod validation middleware that returns a NestJS `ValidationPipe`-shaped 400 on failure.
|
|
53
|
-
*
|
|
54
|
-
* On success the parsed value is made available through `@hono/zod-validator` as usual. On failure
|
|
55
|
-
* the response body is `{ statusCode: 400, message: string[], error: 'Bad Request' }`, the failing
|
|
56
|
-
* field paths are logged via `console.warn` (so 400s are visible in `wrangler dev`/Workers logs),
|
|
57
|
-
* and {@link ValidateOptions.onValidationError} is invoked if provided.
|
|
58
|
-
*
|
|
59
|
-
* @remarks
|
|
60
|
-
* The exact message strings differ from class-validator because they are produced by zod, but the
|
|
61
|
-
* envelope shape matches NestJS so clients can parse failures identically.
|
|
62
|
-
*
|
|
63
|
-
* @typeParam T - The type produced by the zod schema.
|
|
64
|
-
* @param target - The request part to validate.
|
|
65
|
-
* @param schema - The zod schema to validate the target against.
|
|
66
|
-
* @param options - Optional hooks; see {@link ValidateOptions}.
|
|
67
|
-
* @returns A Hono middleware that validates `target` and short-circuits with a 400 on failure.
|
|
68
|
-
*
|
|
69
|
-
* @example
|
|
70
|
-
* ```ts
|
|
71
|
-
* import { z } from 'zod';
|
|
72
|
-
* import { validate } from '@rdlabo/workers-hono-kit';
|
|
73
|
-
*
|
|
74
|
-
* app.post('/users', validate('json', z.object({ name: z.string() })), (c) => {
|
|
75
|
-
* const { name } = c.req.valid('json');
|
|
76
|
-
* return c.json({ name });
|
|
77
|
-
* });
|
|
78
|
-
* ```
|
|
79
|
-
*/
|
|
80
|
-
export function validate<T>(target: ValidationTarget, schema: ZodType<T>, options?: ValidateOptions) {
|
|
81
|
-
return zValidator(target, schema, (result, c) => {
|
|
82
|
-
if (!result.success) {
|
|
83
|
-
const messages = zodToMessages(result.error);
|
|
84
|
-
// Surface the failing fields in the runtime log. Without this the 400 is
|
|
85
|
-
// invisible in `wrangler dev`/Workers logs (Sentry is the only sink, and
|
|
86
|
-
// it is not visible locally), so a field-level type mismatch — e.g. a
|
|
87
|
-
// string sent to `z.number()` — dies silently and is painful to diagnose.
|
|
88
|
-
// Paths + zod messages only; no request values are logged.
|
|
89
|
-
console.warn(`[validation] ${c.req.method} ${c.req.path} (${target}) → 400: ${messages.join('; ')}`);
|
|
90
|
-
try {
|
|
91
|
-
options?.onValidationError?.(result.error, c);
|
|
92
|
-
} catch {
|
|
93
|
-
// Reporting must never change validation error behavior.
|
|
94
|
-
}
|
|
95
|
-
return c.json({ statusCode: 400, message: messages, error: 'Bad Request' }, 400);
|
|
96
|
-
}
|
|
97
|
-
return undefined;
|
|
98
|
-
});
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* Minimal structural shape of a Sentry scope, covering only the methods this kit calls.
|
|
103
|
-
*
|
|
104
|
-
* @remarks
|
|
105
|
-
* Declaring the shape structurally avoids a direct dependency on `@sentry/cloudflare`; any object
|
|
106
|
-
* exposing these methods (such as the real Sentry scope) is accepted.
|
|
107
|
-
*/
|
|
108
|
-
export interface SentryScopeLike {
|
|
109
|
-
/** Attach a string tag to the current scope. */
|
|
110
|
-
setTag(key: string, value: string): void;
|
|
111
|
-
/** Attach (or clear, with `null`) a structured context entry on the current scope. */
|
|
112
|
-
setContext(key: string, context: Record<string, unknown> | null): void;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/**
|
|
116
|
-
* Minimal structural shape of the Sentry client, covering only the methods this kit calls.
|
|
117
|
-
*
|
|
118
|
-
* @remarks
|
|
119
|
-
* Like {@link SentryScopeLike}, this avoids a hard dependency on `@sentry/cloudflare`.
|
|
120
|
-
*/
|
|
121
|
-
export interface SentryLike {
|
|
122
|
-
/** Run a callback with an isolated scope, restoring the previous scope afterward. */
|
|
123
|
-
withScope(callback: (scope: SentryScopeLike) => void): void;
|
|
124
|
-
/** Report an exception to Sentry. */
|
|
125
|
-
captureException(error: unknown): void;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/**
|
|
129
|
-
* Create a {@link validate}-like factory that additionally reports DTO validation 400s to Sentry.
|
|
130
|
-
*
|
|
131
|
-
* The returned function has the same signature and behavior as {@link validate}; reporting is a pure
|
|
132
|
-
* side effect that does not alter validation behavior or the response. Each report is tagged with
|
|
133
|
-
* `error.type=dto_validation` and carries a `validation` context of `{ errorCount, errors }`.
|
|
134
|
-
*
|
|
135
|
-
* @param sentry - A Sentry-like client used to capture validation failures; see {@link SentryLike}.
|
|
136
|
-
* @returns A function `(target, schema) => MiddlewareHandler` mirroring {@link validate}.
|
|
137
|
-
*
|
|
138
|
-
* @example
|
|
139
|
-
* ```ts
|
|
140
|
-
* import * as Sentry from '@sentry/cloudflare';
|
|
141
|
-
* import { z } from 'zod';
|
|
142
|
-
* import { createSentryValidate } from '@rdlabo/workers-hono-kit';
|
|
143
|
-
*
|
|
144
|
-
* const validate = createSentryValidate(Sentry);
|
|
145
|
-
* app.post('/users', validate('json', z.object({ name: z.string() })), handler);
|
|
146
|
-
* ```
|
|
147
|
-
*/
|
|
148
|
-
export function createSentryValidate(sentry: SentryLike) {
|
|
149
|
-
const onValidationError = (error: ZodErrorLike): void => {
|
|
150
|
-
const messages = zodToMessages(error);
|
|
151
|
-
sentry.withScope((scope) => {
|
|
152
|
-
scope.setTag('error.type', 'dto_validation');
|
|
153
|
-
scope.setContext('validation', { errorCount: messages.length, errors: messages });
|
|
154
|
-
sentry.captureException(error);
|
|
155
|
-
});
|
|
156
|
-
};
|
|
157
|
-
return <T>(target: ValidationTarget, schema: ZodType<T>) => validate(target, schema, { onValidationError });
|
|
158
|
-
}
|
|
@@ -1,124 +0,0 @@
|
|
|
1
|
-
import { z } from 'zod';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Zod preprocessors that coerce string path/query parameters into numbers.
|
|
5
|
-
*
|
|
6
|
-
* @remarks
|
|
7
|
-
* These mirror a class-transformer `@Transform(() => Number)` coercion: path and query parameters
|
|
8
|
-
* always arrive as strings, so they must be forced to numbers before number validation. Whitespace-only
|
|
9
|
-
* strings are mapped to `NaN` so the downstream number schema rejects them (matching class-validator's
|
|
10
|
-
* `@IsInt`/`@IsNumber`, which reject `NaN`; zod v4's `z.number()` also rejects `NaN` by default).
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
/** Return `true` when `value` is a string that is empty or contains only whitespace. */
|
|
14
|
-
const isBlankString = (value: unknown): value is string => typeof value === 'string' && value.trim() === '';
|
|
15
|
-
|
|
16
|
-
// toNumber: blank string → NaN, otherwise → Number(value).
|
|
17
|
-
const rawToNumber = (value: unknown): unknown => (isBlankString(value) ? Number(undefined) : Number(value));
|
|
18
|
-
|
|
19
|
-
// toNumberWithDefault: undefined/'' → default, blank → NaN, else Number.
|
|
20
|
-
const rawToNumberWithDefault =
|
|
21
|
-
(defaultValue: number) =>
|
|
22
|
-
(value: unknown): unknown => {
|
|
23
|
-
if (value === undefined || value === '') {
|
|
24
|
-
return defaultValue;
|
|
25
|
-
}
|
|
26
|
-
if (isBlankString(value)) {
|
|
27
|
-
return Number(undefined);
|
|
28
|
-
}
|
|
29
|
-
return Number(value);
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
// toOptionalNumber: undefined/null/'' → undefined, blank → NaN, else Number.
|
|
33
|
-
const rawToOptionalNumber = (value: unknown): unknown => {
|
|
34
|
-
if (value === undefined || value === null || value === '') {
|
|
35
|
-
return undefined;
|
|
36
|
-
}
|
|
37
|
-
if (isBlankString(value)) {
|
|
38
|
-
return Number(undefined);
|
|
39
|
-
}
|
|
40
|
-
return Number(value);
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
// toNullableNumber: null/undefined → passthrough, '' → undefined, blank → NaN, else Number.
|
|
44
|
-
const rawToNullableNumber = (value: unknown): unknown => {
|
|
45
|
-
if (value === null || value === undefined) {
|
|
46
|
-
return value;
|
|
47
|
-
}
|
|
48
|
-
if (value === '') {
|
|
49
|
-
return undefined;
|
|
50
|
-
}
|
|
51
|
-
if (isBlankString(value)) {
|
|
52
|
-
return Number(undefined);
|
|
53
|
-
}
|
|
54
|
-
return Number(value);
|
|
55
|
-
};
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Build a required number schema that coerces string input to a number.
|
|
59
|
-
*
|
|
60
|
-
* @param inner - The inner number schema to apply after coercion; pass e.g. `z.number().int()` to add
|
|
61
|
-
* constraints. Defaults to `z.number()`.
|
|
62
|
-
* @returns A zod schema yielding a `number`.
|
|
63
|
-
*
|
|
64
|
-
* @example
|
|
65
|
-
* ```ts
|
|
66
|
-
* // Require an integer path param:
|
|
67
|
-
* const schema = z.object({ id: zNum(z.number().int()) });
|
|
68
|
-
* // '42' → 42, '' / ' ' → NaN (rejected)
|
|
69
|
-
* ```
|
|
70
|
-
*/
|
|
71
|
-
export const zNum = (inner: z.ZodNumber = z.number()): z.ZodType<number> => z.preprocess(rawToNumber, inner);
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* Build a number schema that coerces string input and substitutes a default for missing values.
|
|
75
|
-
*
|
|
76
|
-
* `undefined` or an empty string yields `defaultValue`; a whitespace-only string yields `NaN`
|
|
77
|
-
* (rejected by the inner schema); anything else is passed through `Number`.
|
|
78
|
-
*
|
|
79
|
-
* @param defaultValue - The value used when the input is `undefined` or an empty string.
|
|
80
|
-
* @param inner - The inner number schema applied after coercion. Defaults to `z.number()`.
|
|
81
|
-
* @returns A zod schema yielding a `number`.
|
|
82
|
-
*
|
|
83
|
-
* @example
|
|
84
|
-
* ```ts
|
|
85
|
-
* // Default page to 1 when the query param is absent:
|
|
86
|
-
* const schema = z.object({ page: zNumWithDefault(1) });
|
|
87
|
-
* ```
|
|
88
|
-
*/
|
|
89
|
-
export const zNumWithDefault = (defaultValue: number, inner: z.ZodNumber = z.number()): z.ZodType<number> =>
|
|
90
|
-
z.preprocess(rawToNumberWithDefault(defaultValue), inner);
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Build an optional number schema that coerces string input.
|
|
94
|
-
*
|
|
95
|
-
* `undefined`, `null`, or an empty string yields `undefined`; a whitespace-only string yields `NaN`
|
|
96
|
-
* (rejected); anything else is passed through `Number`.
|
|
97
|
-
*
|
|
98
|
-
* @param inner - The inner number schema applied after coercion. Defaults to `z.number()`.
|
|
99
|
-
* @returns A zod schema yielding `number | undefined`.
|
|
100
|
-
*
|
|
101
|
-
* @example
|
|
102
|
-
* ```ts
|
|
103
|
-
* const schema = z.object({ limit: zNumOptional(z.number().int()) });
|
|
104
|
-
* ```
|
|
105
|
-
*/
|
|
106
|
-
export const zNumOptional = (inner: z.ZodNumber = z.number()): z.ZodType<number | undefined> =>
|
|
107
|
-
z.preprocess(rawToOptionalNumber, inner.optional());
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* Build a nullable, optional number schema that coerces string input.
|
|
111
|
-
*
|
|
112
|
-
* `null`/`undefined` pass through unchanged; an empty string yields `undefined`; a whitespace-only
|
|
113
|
-
* string yields `NaN` (rejected); anything else is passed through `Number`.
|
|
114
|
-
*
|
|
115
|
-
* @param inner - The inner number schema applied after coercion. Defaults to `z.number()`.
|
|
116
|
-
* @returns A zod schema yielding `number | null | undefined`.
|
|
117
|
-
*
|
|
118
|
-
* @example
|
|
119
|
-
* ```ts
|
|
120
|
-
* const schema = z.object({ parentId: zNumNullable(z.number().int()) });
|
|
121
|
-
* ```
|
|
122
|
-
*/
|
|
123
|
-
export const zNumNullable = (inner: z.ZodNumber = z.number()): z.ZodType<number | null | undefined> =>
|
|
124
|
-
z.preprocess(rawToNullableNumber, inner.nullish());
|
package/src/queue/consumer.ts
DELETED
|
@@ -1,146 +0,0 @@
|
|
|
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
|
-
/**
|
|
32
|
-
* Minimal subset of `@cloudflare/workers-types`' `Message` used by {@link processBatch}.
|
|
33
|
-
*
|
|
34
|
-
* Declared locally so consumers are not forced to depend on `@cloudflare/workers-types`.
|
|
35
|
-
*
|
|
36
|
-
* @typeParam Body - Type of the message body.
|
|
37
|
-
*/
|
|
38
|
-
export interface QueueMessageLike<Body = unknown> {
|
|
39
|
-
/** Unique id assigned by the Queues runtime. */
|
|
40
|
-
readonly id: string;
|
|
41
|
-
/** Number of delivery attempts so far (starts at 1 on first delivery). */
|
|
42
|
-
readonly attempts: number;
|
|
43
|
-
/** The message payload. */
|
|
44
|
-
readonly body: Body;
|
|
45
|
-
/** Explicitly acknowledge this message so it is not redelivered. */
|
|
46
|
-
ack: () => void;
|
|
47
|
-
/** Mark this message for redelivery, optionally after a delay. */
|
|
48
|
-
retry: (options?: { delaySeconds?: number }) => void;
|
|
49
|
-
}
|
|
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
|
-
/**
|
|
64
|
-
* Options for {@link processBatch}.
|
|
65
|
-
*
|
|
66
|
-
* @typeParam Body - Type of each message body.
|
|
67
|
-
*/
|
|
68
|
-
export interface ProcessBatchOptions<Body = unknown> {
|
|
69
|
-
/**
|
|
70
|
-
* Invoked when `handler` throws for a message, immediately before the message is marked for retry.
|
|
71
|
-
* Use it to log or report; it must not throw. Defaults to `console.error`.
|
|
72
|
-
*/
|
|
73
|
-
onError?: (error: unknown, message: QueueMessageLike<Body>) => void;
|
|
74
|
-
/**
|
|
75
|
-
* Delay, in seconds, applied when re-queuing a failed message. Omit to retry with the queue's
|
|
76
|
-
* default backoff.
|
|
77
|
-
*/
|
|
78
|
-
retryDelaySeconds?: number;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Outcome counts returned by {@link processBatch}.
|
|
83
|
-
*/
|
|
84
|
-
export interface ProcessBatchResult {
|
|
85
|
-
/** Messages whose handler completed successfully and were acked. */
|
|
86
|
-
processed: number;
|
|
87
|
-
/** Messages whose handler threw and were marked for retry. */
|
|
88
|
-
failed: number;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* Process every message in `batch` sequentially, acking on success and retrying on failure.
|
|
93
|
-
*
|
|
94
|
-
* Each message is passed to `handler`; if it resolves the message is acked, and if it throws the
|
|
95
|
-
* error is routed to {@link ProcessBatchOptions.onError} and the message is marked for retry (honoring
|
|
96
|
-
* {@link ProcessBatchOptions.retryDelaySeconds}). One failing message never affects the others, and
|
|
97
|
-
* the returned counts let tests assert that the per-invocation workload — and therefore the
|
|
98
|
-
* subrequest count — stayed bounded by the batch size.
|
|
99
|
-
*
|
|
100
|
-
* @typeParam Body - Type of each message body.
|
|
101
|
-
* @param batch - The delivered message batch.
|
|
102
|
-
* @param handler - Async work for a single message; performs the bounded external call(s). Receives
|
|
103
|
-
* the decoded `body` and the raw message (for `attempts`, `id`, etc.).
|
|
104
|
-
* @param options - Error reporting and retry tuning; see {@link ProcessBatchOptions}.
|
|
105
|
-
* @returns The number of processed and failed messages.
|
|
106
|
-
* @example
|
|
107
|
-
* ```ts
|
|
108
|
-
* const { processed, failed } = await processBatch(
|
|
109
|
-
* batch,
|
|
110
|
-
* async ({ id }) => sendOneMail(id),
|
|
111
|
-
* { retryDelaySeconds: 30, onError: (e, m) => report(e, m.id) },
|
|
112
|
-
* );
|
|
113
|
-
* ```
|
|
114
|
-
*/
|
|
115
|
-
export async function processBatch<Body>(
|
|
116
|
-
batch: MessageBatchLike<Body>,
|
|
117
|
-
handler: (body: Body, message: QueueMessageLike<Body>) => Promise<void>,
|
|
118
|
-
options?: ProcessBatchOptions<Body>,
|
|
119
|
-
): Promise<ProcessBatchResult> {
|
|
120
|
-
const onError =
|
|
121
|
-
options?.onError ??
|
|
122
|
-
((error, message) => {
|
|
123
|
-
console.error(`[queue:${batch.queue}] message ${message.id} failed`, error);
|
|
124
|
-
});
|
|
125
|
-
const retryOptions =
|
|
126
|
-
options?.retryDelaySeconds === undefined ? undefined : { delaySeconds: options.retryDelaySeconds };
|
|
127
|
-
|
|
128
|
-
let processed = 0;
|
|
129
|
-
let failed = 0;
|
|
130
|
-
for (const message of batch.messages) {
|
|
131
|
-
try {
|
|
132
|
-
await handler(message.body, message);
|
|
133
|
-
message.ack();
|
|
134
|
-
processed++;
|
|
135
|
-
} catch (error) {
|
|
136
|
-
try {
|
|
137
|
-
onError(error, message);
|
|
138
|
-
} catch {
|
|
139
|
-
// onError contract says "must not throw"; guard defensively so retry() and remaining messages still run.
|
|
140
|
-
}
|
|
141
|
-
message.retry(retryOptions);
|
|
142
|
-
failed++;
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
return { processed, failed };
|
|
146
|
-
}
|
package/src/queue/send.ts
DELETED
|
@@ -1,129 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Producer-side helper for fanning a large list of items into a Cloudflare Queue without letting the
|
|
3
|
-
* producer's own subrequest count scale linearly with the list.
|
|
4
|
-
*
|
|
5
|
-
* A Worker may issue at most 50 (free) / 1000 (paid) subrequests per invocation, and each
|
|
6
|
-
* {@link QueueLike.send} counts as one subrequest. Enqueuing `N` items with per-item `send()` calls
|
|
7
|
-
* therefore reintroduces the very unbounded fan-out that queues exist to remove. {@link sendInChunks}
|
|
8
|
-
* instead groups items into batches and issues one {@link QueueLike.sendBatch} per batch, so the
|
|
9
|
-
* producer spends `ceil(N / chunkSize)` subrequests regardless of how large `N` grows.
|
|
10
|
-
*
|
|
11
|
-
* The heavy per-item work (external API calls, etc.) is expected to run in the queue *consumer*,
|
|
12
|
-
* where each invocation processes only `max_batch_size` messages and thus enjoys its own bounded
|
|
13
|
-
* subrequest budget. See {@link processBatch} for the consumer side.
|
|
14
|
-
*
|
|
15
|
-
* @example
|
|
16
|
-
* ```ts
|
|
17
|
-
* // In a Cron Trigger `scheduled` handler: enqueue every billing user, then let the consumer
|
|
18
|
-
* // re-derive payment state one user per message.
|
|
19
|
-
* const userIds = await query.getUserIdForReloadCustomer(); // DB read — not a subrequest
|
|
20
|
-
* await sendInChunks(env.PAYMENT_RELOAD_QUEUE, userIds); // ceil(N / 100) subrequests
|
|
21
|
-
* ```
|
|
22
|
-
*
|
|
23
|
-
* @packageDocumentation
|
|
24
|
-
*/
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Minimal subset of `@cloudflare/workers-types`' `Queue` used by {@link sendInChunks}.
|
|
28
|
-
*
|
|
29
|
-
* Declared locally so consumers are not forced to depend on `@cloudflare/workers-types`. Only the
|
|
30
|
-
* batch-send operation the helper actually needs is modeled.
|
|
31
|
-
*
|
|
32
|
-
* @typeParam Body - Type of each message body enqueued onto this queue.
|
|
33
|
-
*/
|
|
34
|
-
export interface QueueLike<Body = unknown> {
|
|
35
|
-
/**
|
|
36
|
-
* Enqueue up to 100 messages in a single operation (one subrequest).
|
|
37
|
-
*
|
|
38
|
-
* @param messages - The message envelopes to enqueue; at most 100 per call, 256 KB per batch.
|
|
39
|
-
* @param options - Optional batch-level options, e.g. a `delaySeconds` applied to every message.
|
|
40
|
-
* @returns A promise that resolves once the batch is accepted. The resolved value is ignored, so a
|
|
41
|
-
* real `Queue` binding (whose `sendBatch` resolves to a `QueueSendBatchResponse`) is assignable.
|
|
42
|
-
*/
|
|
43
|
-
sendBatch: (messages: Iterable<QueueSendMessage<Body>>, options?: { delaySeconds?: number }) => Promise<unknown>;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* A single message envelope passed to {@link QueueLike.sendBatch}.
|
|
48
|
-
*
|
|
49
|
-
* @typeParam Body - Type of the message body.
|
|
50
|
-
*/
|
|
51
|
-
export interface QueueSendMessage<Body = unknown> {
|
|
52
|
-
/** The message payload; structured-cloned by the Queues runtime. */
|
|
53
|
-
body: Body;
|
|
54
|
-
/** Optional content type hint (`'json'` by default for object bodies). */
|
|
55
|
-
contentType?: 'text' | 'bytes' | 'json' | 'v8';
|
|
56
|
-
/** Optional per-message delivery delay, in seconds. */
|
|
57
|
-
delaySeconds?: number;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* The Cloudflare Queues hard limit on messages per {@link QueueLike.sendBatch} call.
|
|
62
|
-
*/
|
|
63
|
-
const MAX_BATCH_SIZE = 100;
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Split a list into fixed-size chunks (order preserving).
|
|
67
|
-
*
|
|
68
|
-
* @typeParam T - Element type.
|
|
69
|
-
* @param items - Source list.
|
|
70
|
-
* @param size - Maximum chunk length (assumed `>= 1`).
|
|
71
|
-
* @returns An array of chunks, each at most `size` long.
|
|
72
|
-
* @internal
|
|
73
|
-
*/
|
|
74
|
-
function chunk<T>(items: readonly T[], size: number): T[][] {
|
|
75
|
-
const result: T[][] = [];
|
|
76
|
-
for (let i = 0; i < items.length; i += size) {
|
|
77
|
-
result.push(items.slice(i, i + size));
|
|
78
|
-
}
|
|
79
|
-
return result;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* Enqueue every item in `items` using batched sends so the producer's subrequest count stays
|
|
84
|
-
* bounded at `ceil(items.length / chunkSize)` rather than growing per item.
|
|
85
|
-
*
|
|
86
|
-
* Each element becomes one queue message (`{ body: item }`); wrap or map your rows into small,
|
|
87
|
-
* self-describing payloads (e.g. an id plus a discriminator) before calling. Keep each batch under
|
|
88
|
-
* the Queues 256 KB limit — with `chunkSize <= 100` and small id-shaped payloads this is not a
|
|
89
|
-
* concern, but large bodies may require a smaller `chunkSize`.
|
|
90
|
-
*
|
|
91
|
-
* Batches are sent sequentially so a mid-list failure surfaces promptly (the already-sent batches
|
|
92
|
-
* are durably enqueued; the throw lets the caller decide whether to retry the remainder). An empty
|
|
93
|
-
* `items` is a no-op.
|
|
94
|
-
*
|
|
95
|
-
* @typeParam Body - Type of each message body.
|
|
96
|
-
* @param queue - The producer binding to send onto.
|
|
97
|
-
* @param items - The full list of message bodies to enqueue; may be arbitrarily large.
|
|
98
|
-
* @param options - Tuning options.
|
|
99
|
-
* @param options.chunkSize - Messages per `sendBatch` call. Defaults to and is capped at 100 (the
|
|
100
|
-
* Queues per-batch maximum); values below 1 are clamped to 1.
|
|
101
|
-
* @returns The number of `sendBatch` calls issued (i.e. subrequests spent), useful for asserting the
|
|
102
|
-
* fan-out stayed bounded in tests.
|
|
103
|
-
* @example
|
|
104
|
-
* ```ts
|
|
105
|
-
* const batches = await sendInChunks(env.MY_QUEUE, ids); // one send per 100 ids
|
|
106
|
-
* const batches = await sendInChunks(env.MY_QUEUE, rows, { // custom batch size for larger bodies
|
|
107
|
-
* chunkSize: 25,
|
|
108
|
-
* });
|
|
109
|
-
* ```
|
|
110
|
-
*/
|
|
111
|
-
export async function sendInChunks<Body>(
|
|
112
|
-
queue: QueueLike<Body>,
|
|
113
|
-
items: readonly Body[],
|
|
114
|
-
options?: { chunkSize?: number },
|
|
115
|
-
): Promise<number> {
|
|
116
|
-
if (items.length === 0) {
|
|
117
|
-
return 0;
|
|
118
|
-
}
|
|
119
|
-
const rawChunkSize = options?.chunkSize ?? MAX_BATCH_SIZE;
|
|
120
|
-
const chunkSize = Math.min(
|
|
121
|
-
MAX_BATCH_SIZE,
|
|
122
|
-
Math.max(1, Math.trunc(Number.isNaN(rawChunkSize) ? MAX_BATCH_SIZE : rawChunkSize)),
|
|
123
|
-
);
|
|
124
|
-
const batches = chunk(items, chunkSize);
|
|
125
|
-
for (const batch of batches) {
|
|
126
|
-
await queue.sendBatch(batch.map((body) => ({ body })));
|
|
127
|
-
}
|
|
128
|
-
return batches.length;
|
|
129
|
-
}
|
package/src/stripe/client.ts
DELETED
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
import Stripe from 'stripe';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Options for {@link createStripeClient}.
|
|
5
|
-
*/
|
|
6
|
-
export interface CreateStripeClientOptions {
|
|
7
|
-
/**
|
|
8
|
-
* Stripe API version to pin the client to. When omitted, the SDK's built-in default is used.
|
|
9
|
-
* Pin it when you need stable, reproducible API behavior independent of SDK upgrades.
|
|
10
|
-
*/
|
|
11
|
-
apiVersion?: string;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Create a Stripe client configured to run on Cloudflare Workers.
|
|
16
|
-
*
|
|
17
|
-
* @remarks
|
|
18
|
-
* Workers has no Node.js `http` stack, so the client is built with `Stripe.createFetchHttpClient()`
|
|
19
|
-
* (a `fetch`-based HTTP client) instead of the SDK's default Node transport.
|
|
20
|
-
*
|
|
21
|
-
* @param secret - Stripe secret API key.
|
|
22
|
-
* @param options - Optional client configuration; see {@link CreateStripeClientOptions}.
|
|
23
|
-
* @returns A configured {@link Stripe} instance.
|
|
24
|
-
* @throws Error when `secret` is empty.
|
|
25
|
-
* @example
|
|
26
|
-
* ```ts
|
|
27
|
-
* const stripe = createStripeClient(env.STRIPE_SECRET, { apiVersion: '2024-04-10' });
|
|
28
|
-
* const customer = await stripe.customers.retrieve(customerId);
|
|
29
|
-
* ```
|
|
30
|
-
*/
|
|
31
|
-
export function createStripeClient(secret: string, options: CreateStripeClientOptions = {}): Stripe {
|
|
32
|
-
if (!secret) {
|
|
33
|
-
throw new Error('Stripe secret is not set');
|
|
34
|
-
}
|
|
35
|
-
const config: Stripe.StripeConfig = { httpClient: Stripe.createFetchHttpClient() };
|
|
36
|
-
if (options.apiVersion) {
|
|
37
|
-
config.apiVersion = options.apiVersion as Stripe.StripeConfig['apiVersion'];
|
|
38
|
-
}
|
|
39
|
-
return new Stripe(secret, config);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Verify a Stripe webhook signature and return the parsed event.
|
|
44
|
-
*
|
|
45
|
-
* @remarks
|
|
46
|
-
* Uses `constructEventAsync` together with `Stripe.createSubtleCryptoProvider()` because the Workers
|
|
47
|
-
* crypto API (SubtleCrypto) is asynchronous and the synchronous `constructEvent` is unavailable.
|
|
48
|
-
* The `secret` is not used by signature verification itself, but a client must be constructed to
|
|
49
|
-
* perform the check; no Stripe API call is made.
|
|
50
|
-
*
|
|
51
|
-
* @param secret - Stripe secret API key, used only to construct the verifying client.
|
|
52
|
-
* @param webhookSecret - Endpoint signing secret used to validate the signature.
|
|
53
|
-
* @param payload - Raw request body exactly as received (string or `ArrayBuffer`).
|
|
54
|
-
* @param signature - Value of the `Stripe-Signature` request header.
|
|
55
|
-
* @returns The verified {@link Stripe.Event}.
|
|
56
|
-
* @throws Error when `webhookSecret` is empty, or when `secret` is empty (the verifying client cannot be constructed).
|
|
57
|
-
* @throws Stripe.errors.StripeSignatureVerificationError when the signature does not match.
|
|
58
|
-
* @example
|
|
59
|
-
* ```ts
|
|
60
|
-
* const event = await verifyStripeWebhook(
|
|
61
|
-
* env.STRIPE_SECRET,
|
|
62
|
-
* env.STRIPE_WEBHOOK_SECRET,
|
|
63
|
-
* await request.text(),
|
|
64
|
-
* request.headers.get('stripe-signature')!,
|
|
65
|
-
* );
|
|
66
|
-
* ```
|
|
67
|
-
*/
|
|
68
|
-
export function verifyStripeWebhook(
|
|
69
|
-
secret: string,
|
|
70
|
-
webhookSecret: string,
|
|
71
|
-
payload: string | ArrayBuffer,
|
|
72
|
-
signature: string,
|
|
73
|
-
): Promise<Stripe.Event> {
|
|
74
|
-
if (!webhookSecret) {
|
|
75
|
-
throw new Error('Stripe webhook secret is not set');
|
|
76
|
-
}
|
|
77
|
-
const stripe = createStripeClient(secret);
|
|
78
|
-
return stripe.webhooks.constructEventAsync(
|
|
79
|
-
payload as string,
|
|
80
|
-
signature,
|
|
81
|
-
webhookSecret,
|
|
82
|
-
undefined,
|
|
83
|
-
Stripe.createSubtleCryptoProvider(),
|
|
84
|
-
);
|
|
85
|
-
}
|