@rdlabo/workers-hono-kit 0.2.1 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/firebase/firebase-verifier.d.ts +15 -0
- package/dist/firebase/identity-toolkit.d.ts +29 -0
- package/dist/firebase/identity-toolkit.js +52 -9
- package/dist/firebase/jose-firebase-verifier.d.ts +15 -0
- package/dist/firebase/jose-firebase-verifier.js +17 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +3 -0
- package/dist/queue/consumer.d.ts +112 -0
- package/dist/queue/consumer.js +80 -0
- package/dist/queue/send.d.ts +90 -0
- package/dist/queue/send.js +85 -0
- package/dist/testing/fakes.d.ts +10 -0
- package/dist/testing/fakes.js +9 -0
- package/package.json +3 -2
- package/scripts/check-subrequest-fanout.mjs +86 -0
- package/src/firebase/firebase-verifier.ts +12 -0
- package/src/firebase/identity-toolkit.ts +54 -9
- package/src/firebase/jose-firebase-verifier.ts +18 -0
- package/src/index.ts +6 -0
- package/src/queue/consumer.ts +146 -0
- package/src/queue/send.ts +129 -0
- package/src/testing/fakes.ts +10 -0
|
@@ -53,6 +53,21 @@ export interface FirebaseVerifier {
|
|
|
53
53
|
uid: string;
|
|
54
54
|
email?: string;
|
|
55
55
|
} | null>;
|
|
56
|
+
/**
|
|
57
|
+
* Look up multiple user records by uid in as few requests as the backing service allows.
|
|
58
|
+
*
|
|
59
|
+
* Batched equivalent of {@link getUser}, intended to replace N single-uid lookups with a
|
|
60
|
+
* handful of requests.
|
|
61
|
+
*
|
|
62
|
+
* @param uids - The users' unique ids to look up.
|
|
63
|
+
* @returns The `uid`/`email` of every matching user. Uids that do not resolve to a user are
|
|
64
|
+
* simply absent from the result (never `null` entries).
|
|
65
|
+
* @throws If the backing user-management service is not configured or the lookup fails.
|
|
66
|
+
*/
|
|
67
|
+
getUsers(uids: string[]): Promise<{
|
|
68
|
+
uid: string;
|
|
69
|
+
email?: string;
|
|
70
|
+
}[]>;
|
|
56
71
|
/**
|
|
57
72
|
* Delete a user by uid.
|
|
58
73
|
*
|
|
@@ -48,6 +48,17 @@ export declare class IdentityToolkit {
|
|
|
48
48
|
* @internal
|
|
49
49
|
*/
|
|
50
50
|
private getAccessToken;
|
|
51
|
+
/**
|
|
52
|
+
* Call the `accounts:lookup` endpoint for a single chunk of `localId`s.
|
|
53
|
+
*
|
|
54
|
+
* @param localIds - Up to {@link LOOKUP_CHUNK_SIZE} `localId`s to look up in one request.
|
|
55
|
+
* @param nowSeconds - The current Unix time in seconds, used for access-token caching.
|
|
56
|
+
* @returns The raw `users` entries returned by the endpoint (empty when the request is
|
|
57
|
+
* unsuccessful or no matching users are returned).
|
|
58
|
+
* @throws If acquiring an access token fails.
|
|
59
|
+
* @internal
|
|
60
|
+
*/
|
|
61
|
+
private lookupChunk;
|
|
51
62
|
/**
|
|
52
63
|
* Look up a user record by uid via the `accounts:lookup` endpoint.
|
|
53
64
|
*
|
|
@@ -61,6 +72,24 @@ export declare class IdentityToolkit {
|
|
|
61
72
|
uid: string;
|
|
62
73
|
email?: string;
|
|
63
74
|
} | null>;
|
|
75
|
+
/**
|
|
76
|
+
* Look up multiple user records by uid via the `accounts:lookup` endpoint.
|
|
77
|
+
*
|
|
78
|
+
* `uids` are chunked into groups of at most {@link LOOKUP_CHUNK_SIZE} (the maximum `localId`
|
|
79
|
+
* array size the endpoint accepts), issuing one `accounts:lookup` request per chunk. This lets
|
|
80
|
+
* callers replace N single-uid lookups with `ceil(N / LOOKUP_CHUNK_SIZE)` requests.
|
|
81
|
+
*
|
|
82
|
+
* @param uids - The users' unique ids (`localId`s) to look up.
|
|
83
|
+
* @param nowSeconds - The current Unix time in seconds, used for access-token caching.
|
|
84
|
+
* @returns The `uid`/`email` of every matching user. Uids Firebase does not recognize are
|
|
85
|
+
* simply absent from the result (never `null` entries), so callers can treat "missing from
|
|
86
|
+
* the result" as "not found/invalid".
|
|
87
|
+
* @throws If acquiring an access token fails.
|
|
88
|
+
*/
|
|
89
|
+
lookupMany(uids: string[], nowSeconds: number): Promise<{
|
|
90
|
+
uid: string;
|
|
91
|
+
email?: string;
|
|
92
|
+
}[]>;
|
|
64
93
|
/**
|
|
65
94
|
* Delete a user by uid via the `accounts:delete` endpoint.
|
|
66
95
|
*
|
|
@@ -5,6 +5,8 @@ const TOKEN_URL = 'https://oauth2.googleapis.com/token';
|
|
|
5
5
|
const IDENTITY_TOOLKIT = 'https://identitytoolkit.googleapis.com/v1';
|
|
6
6
|
/** OAuth2 scopes required for Identity Toolkit account lookup and deletion. */
|
|
7
7
|
const SCOPE = 'https://www.googleapis.com/auth/identitytoolkit https://www.googleapis.com/auth/firebase';
|
|
8
|
+
/** Maximum number of `localId`s the `accounts:lookup` endpoint accepts in a single request. */
|
|
9
|
+
const LOOKUP_CHUNK_SIZE = 100;
|
|
8
10
|
/**
|
|
9
11
|
* Minimal Google Identity Toolkit REST client for the user-management operations that token
|
|
10
12
|
* verification does not cover: `accounts:lookup` (getUser) and `accounts:delete` (deleteUser).
|
|
@@ -71,27 +73,68 @@ export class IdentityToolkit {
|
|
|
71
73
|
return json.access_token;
|
|
72
74
|
}
|
|
73
75
|
/**
|
|
74
|
-
*
|
|
76
|
+
* Call the `accounts:lookup` endpoint for a single chunk of `localId`s.
|
|
75
77
|
*
|
|
76
|
-
* @param
|
|
78
|
+
* @param localIds - Up to {@link LOOKUP_CHUNK_SIZE} `localId`s to look up in one request.
|
|
77
79
|
* @param nowSeconds - The current Unix time in seconds, used for access-token caching.
|
|
78
|
-
* @returns The
|
|
79
|
-
* or no matching
|
|
80
|
+
* @returns The raw `users` entries returned by the endpoint (empty when the request is
|
|
81
|
+
* unsuccessful or no matching users are returned).
|
|
80
82
|
* @throws If acquiring an access token fails.
|
|
83
|
+
* @internal
|
|
81
84
|
*/
|
|
82
|
-
async
|
|
85
|
+
async lookupChunk(localIds, nowSeconds) {
|
|
83
86
|
const token = await this.getAccessToken(nowSeconds);
|
|
84
87
|
const res = await fetch(`${IDENTITY_TOOLKIT}/projects/${this.sa.project_id}/accounts:lookup`, {
|
|
85
88
|
method: 'POST',
|
|
86
89
|
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
|
|
87
|
-
body: JSON.stringify({ localId:
|
|
90
|
+
body: JSON.stringify({ localId: localIds }),
|
|
88
91
|
});
|
|
89
92
|
if (!res.ok) {
|
|
90
|
-
return
|
|
93
|
+
return [];
|
|
91
94
|
}
|
|
92
95
|
const json = (await res.json());
|
|
93
|
-
|
|
94
|
-
|
|
96
|
+
return json.users ?? [];
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Look up a user record by uid via the `accounts:lookup` endpoint.
|
|
100
|
+
*
|
|
101
|
+
* @param uid - The user's unique id (`localId`).
|
|
102
|
+
* @param nowSeconds - The current Unix time in seconds, used for access-token caching.
|
|
103
|
+
* @returns The user's `uid` and optional `email`, or `null` when the request is unsuccessful
|
|
104
|
+
* or no matching user is returned.
|
|
105
|
+
* @throws If acquiring an access token fails.
|
|
106
|
+
*/
|
|
107
|
+
async lookup(uid, nowSeconds) {
|
|
108
|
+
const users = await this.lookupChunk([uid], nowSeconds);
|
|
109
|
+
return users.length > 0 ? { uid: users[0].localId, email: users[0].email } : null;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Look up multiple user records by uid via the `accounts:lookup` endpoint.
|
|
113
|
+
*
|
|
114
|
+
* `uids` are chunked into groups of at most {@link LOOKUP_CHUNK_SIZE} (the maximum `localId`
|
|
115
|
+
* array size the endpoint accepts), issuing one `accounts:lookup` request per chunk. This lets
|
|
116
|
+
* callers replace N single-uid lookups with `ceil(N / LOOKUP_CHUNK_SIZE)` requests.
|
|
117
|
+
*
|
|
118
|
+
* @param uids - The users' unique ids (`localId`s) to look up.
|
|
119
|
+
* @param nowSeconds - The current Unix time in seconds, used for access-token caching.
|
|
120
|
+
* @returns The `uid`/`email` of every matching user. Uids Firebase does not recognize are
|
|
121
|
+
* simply absent from the result (never `null` entries), so callers can treat "missing from
|
|
122
|
+
* the result" as "not found/invalid".
|
|
123
|
+
* @throws If acquiring an access token fails.
|
|
124
|
+
*/
|
|
125
|
+
async lookupMany(uids, nowSeconds) {
|
|
126
|
+
if (uids.length === 0) {
|
|
127
|
+
return [];
|
|
128
|
+
}
|
|
129
|
+
const results = [];
|
|
130
|
+
for (let i = 0; i < uids.length; i += LOOKUP_CHUNK_SIZE) {
|
|
131
|
+
const chunk = uids.slice(i, i + LOOKUP_CHUNK_SIZE);
|
|
132
|
+
const users = await this.lookupChunk(chunk, nowSeconds);
|
|
133
|
+
for (const user of users) {
|
|
134
|
+
results.push({ uid: user.localId, email: user.email });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return results;
|
|
95
138
|
}
|
|
96
139
|
/**
|
|
97
140
|
* Delete a user by uid via the `accounts:delete` endpoint.
|
|
@@ -85,6 +85,21 @@ export declare class JoseFirebaseVerifier implements FirebaseVerifier {
|
|
|
85
85
|
uid: string;
|
|
86
86
|
email?: string;
|
|
87
87
|
} | null>;
|
|
88
|
+
/**
|
|
89
|
+
* Look up multiple user records by uid via the Identity Toolkit REST API.
|
|
90
|
+
*
|
|
91
|
+
* Batches the lookups into `ceil(uids.length / 100)` `accounts:lookup` requests instead of
|
|
92
|
+
* one request per uid.
|
|
93
|
+
*
|
|
94
|
+
* @param uids - The users' unique ids to look up.
|
|
95
|
+
* @returns The `uid`/`email` of every matching user. Uids Firebase does not recognize are
|
|
96
|
+
* simply absent from the result (never `null` entries).
|
|
97
|
+
* @throws If no Identity Toolkit client was configured on this verifier.
|
|
98
|
+
*/
|
|
99
|
+
getUsers(uids: string[]): Promise<{
|
|
100
|
+
uid: string;
|
|
101
|
+
email?: string;
|
|
102
|
+
}[]>;
|
|
88
103
|
/**
|
|
89
104
|
* Delete a user by uid via the Identity Toolkit REST API.
|
|
90
105
|
*
|
|
@@ -88,6 +88,23 @@ export class JoseFirebaseVerifier {
|
|
|
88
88
|
}
|
|
89
89
|
return this.opts.identity.lookup(uid, this.nowSeconds());
|
|
90
90
|
}
|
|
91
|
+
/**
|
|
92
|
+
* Look up multiple user records by uid via the Identity Toolkit REST API.
|
|
93
|
+
*
|
|
94
|
+
* Batches the lookups into `ceil(uids.length / 100)` `accounts:lookup` requests instead of
|
|
95
|
+
* one request per uid.
|
|
96
|
+
*
|
|
97
|
+
* @param uids - The users' unique ids to look up.
|
|
98
|
+
* @returns The `uid`/`email` of every matching user. Uids Firebase does not recognize are
|
|
99
|
+
* simply absent from the result (never `null` entries).
|
|
100
|
+
* @throws If no Identity Toolkit client was configured on this verifier.
|
|
101
|
+
*/
|
|
102
|
+
async getUsers(uids) {
|
|
103
|
+
if (!this.opts.identity) {
|
|
104
|
+
throw new Error('Identity Toolkit not configured');
|
|
105
|
+
}
|
|
106
|
+
return this.opts.identity.lookupMany(uids, this.nowSeconds());
|
|
107
|
+
}
|
|
91
108
|
/**
|
|
92
109
|
* Delete a user by uid via the Identity Toolkit REST API.
|
|
93
110
|
*
|
package/dist/index.d.ts
CHANGED
|
@@ -29,6 +29,10 @@ export type { KVNamespace, KVCacheOptions } from './cache/kv-cache.js';
|
|
|
29
29
|
export { createStripeClient, verifyStripeWebhook } from './stripe/client.js';
|
|
30
30
|
export type { CreateStripeClientOptions } from './stripe/client.js';
|
|
31
31
|
export { retryWhenDeadlock } from './db/retry.js';
|
|
32
|
+
export { sendInChunks } from './queue/send.js';
|
|
33
|
+
export type { QueueLike, QueueSendMessage } from './queue/send.js';
|
|
34
|
+
export { processBatch } from './queue/consumer.js';
|
|
35
|
+
export type { QueueMessageLike, MessageBatchLike, ProcessBatchOptions, ProcessBatchResult } from './queue/consumer.js';
|
|
32
36
|
export { createAiGatewayProvider } from './ai/gateway.js';
|
|
33
37
|
export type { AiGatewayConfig, AiGatewayProvider, AiGatewayBinding, AiGateway, AiGatewayOptions, } from './ai/gateway.js';
|
|
34
38
|
export { getAuthenticationSecret } from './aws/secrets-manager.js';
|
package/dist/index.js
CHANGED
|
@@ -26,6 +26,9 @@ export { KVCache } from './cache/kv-cache.js';
|
|
|
26
26
|
export { createStripeClient, verifyStripeWebhook } from './stripe/client.js';
|
|
27
27
|
// db
|
|
28
28
|
export { retryWhenDeadlock } from './db/retry.js';
|
|
29
|
+
// queue
|
|
30
|
+
export { sendInChunks } from './queue/send.js';
|
|
31
|
+
export { processBatch } from './queue/consumer.js';
|
|
29
32
|
// ai
|
|
30
33
|
export { createAiGatewayProvider } from './ai/gateway.js';
|
|
31
34
|
// aws
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
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
|
+
* Minimal subset of `@cloudflare/workers-types`' `Queue` used by {@link sendInChunks}.
|
|
27
|
+
*
|
|
28
|
+
* Declared locally so consumers are not forced to depend on `@cloudflare/workers-types`. Only the
|
|
29
|
+
* batch-send operation the helper actually needs is modeled.
|
|
30
|
+
*
|
|
31
|
+
* @typeParam Body - Type of each message body enqueued onto this queue.
|
|
32
|
+
*/
|
|
33
|
+
export interface QueueLike<Body = unknown> {
|
|
34
|
+
/**
|
|
35
|
+
* Enqueue up to 100 messages in a single operation (one subrequest).
|
|
36
|
+
*
|
|
37
|
+
* @param messages - The message envelopes to enqueue; at most 100 per call, 256 KB per batch.
|
|
38
|
+
* @param options - Optional batch-level options, e.g. a `delaySeconds` applied to every message.
|
|
39
|
+
* @returns A promise that resolves once the batch is accepted. The resolved value is ignored, so a
|
|
40
|
+
* real `Queue` binding (whose `sendBatch` resolves to a `QueueSendBatchResponse`) is assignable.
|
|
41
|
+
*/
|
|
42
|
+
sendBatch: (messages: Iterable<QueueSendMessage<Body>>, options?: {
|
|
43
|
+
delaySeconds?: number;
|
|
44
|
+
}) => Promise<unknown>;
|
|
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
|
+
* Enqueue every item in `items` using batched sends so the producer's subrequest count stays
|
|
61
|
+
* bounded at `ceil(items.length / chunkSize)` rather than growing per item.
|
|
62
|
+
*
|
|
63
|
+
* Each element becomes one queue message (`{ body: item }`); wrap or map your rows into small,
|
|
64
|
+
* self-describing payloads (e.g. an id plus a discriminator) before calling. Keep each batch under
|
|
65
|
+
* the Queues 256 KB limit — with `chunkSize <= 100` and small id-shaped payloads this is not a
|
|
66
|
+
* concern, but large bodies may require a smaller `chunkSize`.
|
|
67
|
+
*
|
|
68
|
+
* Batches are sent sequentially so a mid-list failure surfaces promptly (the already-sent batches
|
|
69
|
+
* are durably enqueued; the throw lets the caller decide whether to retry the remainder). An empty
|
|
70
|
+
* `items` is a no-op.
|
|
71
|
+
*
|
|
72
|
+
* @typeParam Body - Type of each message body.
|
|
73
|
+
* @param queue - The producer binding to send onto.
|
|
74
|
+
* @param items - The full list of message bodies to enqueue; may be arbitrarily large.
|
|
75
|
+
* @param options - Tuning options.
|
|
76
|
+
* @param options.chunkSize - Messages per `sendBatch` call. Defaults to and is capped at 100 (the
|
|
77
|
+
* Queues per-batch maximum); values below 1 are clamped to 1.
|
|
78
|
+
* @returns The number of `sendBatch` calls issued (i.e. subrequests spent), useful for asserting the
|
|
79
|
+
* fan-out stayed bounded in tests.
|
|
80
|
+
* @example
|
|
81
|
+
* ```ts
|
|
82
|
+
* const batches = await sendInChunks(env.MY_QUEUE, ids); // one send per 100 ids
|
|
83
|
+
* const batches = await sendInChunks(env.MY_QUEUE, rows, { // custom batch size for larger bodies
|
|
84
|
+
* chunkSize: 25,
|
|
85
|
+
* });
|
|
86
|
+
* ```
|
|
87
|
+
*/
|
|
88
|
+
export declare function sendInChunks<Body>(queue: QueueLike<Body>, items: readonly Body[], options?: {
|
|
89
|
+
chunkSize?: number;
|
|
90
|
+
}): Promise<number>;
|
|
@@ -0,0 +1,85 @@
|
|
|
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
|
+
* The Cloudflare Queues hard limit on messages per {@link QueueLike.sendBatch} call.
|
|
27
|
+
*/
|
|
28
|
+
const MAX_BATCH_SIZE = 100;
|
|
29
|
+
/**
|
|
30
|
+
* Split a list into fixed-size chunks (order preserving).
|
|
31
|
+
*
|
|
32
|
+
* @typeParam T - Element type.
|
|
33
|
+
* @param items - Source list.
|
|
34
|
+
* @param size - Maximum chunk length (assumed `>= 1`).
|
|
35
|
+
* @returns An array of chunks, each at most `size` long.
|
|
36
|
+
* @internal
|
|
37
|
+
*/
|
|
38
|
+
function chunk(items, size) {
|
|
39
|
+
const result = [];
|
|
40
|
+
for (let i = 0; i < items.length; i += size) {
|
|
41
|
+
result.push(items.slice(i, i + size));
|
|
42
|
+
}
|
|
43
|
+
return result;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Enqueue every item in `items` using batched sends so the producer's subrequest count stays
|
|
47
|
+
* bounded at `ceil(items.length / chunkSize)` rather than growing per item.
|
|
48
|
+
*
|
|
49
|
+
* Each element becomes one queue message (`{ body: item }`); wrap or map your rows into small,
|
|
50
|
+
* self-describing payloads (e.g. an id plus a discriminator) before calling. Keep each batch under
|
|
51
|
+
* the Queues 256 KB limit — with `chunkSize <= 100` and small id-shaped payloads this is not a
|
|
52
|
+
* concern, but large bodies may require a smaller `chunkSize`.
|
|
53
|
+
*
|
|
54
|
+
* Batches are sent sequentially so a mid-list failure surfaces promptly (the already-sent batches
|
|
55
|
+
* are durably enqueued; the throw lets the caller decide whether to retry the remainder). An empty
|
|
56
|
+
* `items` is a no-op.
|
|
57
|
+
*
|
|
58
|
+
* @typeParam Body - Type of each message body.
|
|
59
|
+
* @param queue - The producer binding to send onto.
|
|
60
|
+
* @param items - The full list of message bodies to enqueue; may be arbitrarily large.
|
|
61
|
+
* @param options - Tuning options.
|
|
62
|
+
* @param options.chunkSize - Messages per `sendBatch` call. Defaults to and is capped at 100 (the
|
|
63
|
+
* Queues per-batch maximum); values below 1 are clamped to 1.
|
|
64
|
+
* @returns The number of `sendBatch` calls issued (i.e. subrequests spent), useful for asserting the
|
|
65
|
+
* fan-out stayed bounded in tests.
|
|
66
|
+
* @example
|
|
67
|
+
* ```ts
|
|
68
|
+
* const batches = await sendInChunks(env.MY_QUEUE, ids); // one send per 100 ids
|
|
69
|
+
* const batches = await sendInChunks(env.MY_QUEUE, rows, { // custom batch size for larger bodies
|
|
70
|
+
* chunkSize: 25,
|
|
71
|
+
* });
|
|
72
|
+
* ```
|
|
73
|
+
*/
|
|
74
|
+
export async function sendInChunks(queue, items, options) {
|
|
75
|
+
if (items.length === 0) {
|
|
76
|
+
return 0;
|
|
77
|
+
}
|
|
78
|
+
const rawChunkSize = options?.chunkSize ?? MAX_BATCH_SIZE;
|
|
79
|
+
const chunkSize = Math.min(MAX_BATCH_SIZE, Math.max(1, Math.trunc(Number.isNaN(rawChunkSize) ? MAX_BATCH_SIZE : rawChunkSize)));
|
|
80
|
+
const batches = chunk(items, chunkSize);
|
|
81
|
+
for (const batch of batches) {
|
|
82
|
+
await queue.sendBatch(batch.map((body) => ({ body })));
|
|
83
|
+
}
|
|
84
|
+
return batches.length;
|
|
85
|
+
}
|
package/dist/testing/fakes.d.ts
CHANGED
|
@@ -43,6 +43,16 @@ export declare class FakeFirebaseVerifier implements FirebaseVerifier {
|
|
|
43
43
|
uid: string;
|
|
44
44
|
email?: string;
|
|
45
45
|
} | null>;
|
|
46
|
+
/**
|
|
47
|
+
* Return minimal user records echoing each requested UID.
|
|
48
|
+
*
|
|
49
|
+
* @param uids - UIDs to look up.
|
|
50
|
+
* @returns One `{ uid }` entry per requested uid (never omits any, in this fake).
|
|
51
|
+
*/
|
|
52
|
+
getUsers(uids: string[]): Promise<{
|
|
53
|
+
uid: string;
|
|
54
|
+
email?: string;
|
|
55
|
+
}[]>;
|
|
46
56
|
/**
|
|
47
57
|
* Record a user deletion by appending the UID to {@link FakeFirebaseVerifier.deleted}.
|
|
48
58
|
*
|
package/dist/testing/fakes.js
CHANGED
|
@@ -48,6 +48,15 @@ export class FakeFirebaseVerifier {
|
|
|
48
48
|
async getUser(uid) {
|
|
49
49
|
return { uid };
|
|
50
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* Return minimal user records echoing each requested UID.
|
|
53
|
+
*
|
|
54
|
+
* @param uids - UIDs to look up.
|
|
55
|
+
* @returns One `{ uid }` entry per requested uid (never omits any, in this fake).
|
|
56
|
+
*/
|
|
57
|
+
async getUsers(uids) {
|
|
58
|
+
return uids.map((uid) => ({ uid }));
|
|
59
|
+
}
|
|
51
60
|
/**
|
|
52
61
|
* Record a user deletion by appending the UID to {@link FakeFirebaseVerifier.deleted}.
|
|
53
62
|
*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rdlabo/workers-hono-kit",
|
|
3
|
-
"version": "0.2
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"private": false,
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -40,7 +40,8 @@
|
|
|
40
40
|
"!src/**/*.spec.ts"
|
|
41
41
|
],
|
|
42
42
|
"bin": {
|
|
43
|
-
"workers-hono-kit-sync-dev-aws": "./scripts/sync-dev-aws.mjs"
|
|
43
|
+
"workers-hono-kit-sync-dev-aws": "./scripts/sync-dev-aws.mjs",
|
|
44
|
+
"workers-hono-kit-check-subrequest-fanout": "./scripts/check-subrequest-fanout.mjs"
|
|
44
45
|
},
|
|
45
46
|
"exports": {
|
|
46
47
|
".": {
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* check-subrequest-fanout — flag per-item external-call fan-outs that scale with data size.
|
|
4
|
+
*
|
|
5
|
+
* Cloudflare Workers cap subrequests per invocation (50 free / 1000 paid). Looping an external call
|
|
6
|
+
* (fetch / AI / Stripe / push / ES) once per row reintroduces an unbounded fan-out that eventually
|
|
7
|
+
* exceeds the cap as the userbase/data grows. This gate greps for the concurrency-loop markers that
|
|
8
|
+
* usually wrap such fan-outs and fails CI unless the site is explicitly annotated as safe.
|
|
9
|
+
*
|
|
10
|
+
* Markers: `runWithConcurrency(`, `PromisePool`, `.withConcurrency(`.
|
|
11
|
+
*
|
|
12
|
+
* To allow a genuinely-safe site (e.g. the loop body only writes to the DB over TCP, which is NOT a
|
|
13
|
+
* subrequest, or the iteration count is hard-capped), put `subrequest-ok` in a comment on the same
|
|
14
|
+
* line or the line immediately above. Prefer a short reason, e.g. `// subrequest-ok: DB writes only`.
|
|
15
|
+
*
|
|
16
|
+
* Usage:
|
|
17
|
+
* node node_modules/@rdlabo/workers-hono-kit/scripts/check-subrequest-fanout.mjs [dir ...]
|
|
18
|
+
* Defaults to scanning `src`. Exits 1 if any un-annotated marker is found.
|
|
19
|
+
*/
|
|
20
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
21
|
+
import { join } from 'node:path';
|
|
22
|
+
|
|
23
|
+
const MARKER = /runWithConcurrency\(|PromisePool|\.withConcurrency\(/;
|
|
24
|
+
const ALLOW = /subrequest-ok/;
|
|
25
|
+
const SKIP_DIRS = new Set(['node_modules', 'dist', '.git', 'coverage']);
|
|
26
|
+
|
|
27
|
+
/** Recursively collect .ts files (excluding *.spec.ts / *.test.ts). */
|
|
28
|
+
function collect(dir) {
|
|
29
|
+
const out = [];
|
|
30
|
+
let entries;
|
|
31
|
+
try {
|
|
32
|
+
entries = readdirSync(dir);
|
|
33
|
+
} catch {
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
for (const name of entries) {
|
|
37
|
+
const full = join(dir, name);
|
|
38
|
+
const st = statSync(full);
|
|
39
|
+
if (st.isDirectory()) {
|
|
40
|
+
if (!SKIP_DIRS.has(name)) {
|
|
41
|
+
out.push(...collect(full));
|
|
42
|
+
}
|
|
43
|
+
} else if (name.endsWith('.ts') && !name.endsWith('.spec.ts') && !name.endsWith('.test.ts')) {
|
|
44
|
+
out.push(full);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const targets = process.argv.slice(2);
|
|
51
|
+
const roots = targets.length > 0 ? targets : ['src'];
|
|
52
|
+
|
|
53
|
+
const violations = [];
|
|
54
|
+
for (const root of roots) {
|
|
55
|
+
for (const file of collect(root)) {
|
|
56
|
+
const lines = readFileSync(file, 'utf8').split('\n');
|
|
57
|
+
lines.forEach((line, i) => {
|
|
58
|
+
if (!MARKER.test(line)) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
// Only flag executable code, not porting notes / JSDoc that merely mention the markers.
|
|
62
|
+
const trimmed = line.trim();
|
|
63
|
+
if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*')) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const prev = i > 0 ? lines[i - 1] : '';
|
|
67
|
+
if (ALLOW.test(line) || ALLOW.test(prev)) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
violations.push({ file, line: i + 1, text: trimmed });
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (violations.length > 0) {
|
|
76
|
+
console.error('✖ subrequest fan-out gate: un-annotated concurrency loop(s) found.');
|
|
77
|
+
console.error(' Each may loop an external call per item (fetch/AI/Stripe/push/ES) and blow the');
|
|
78
|
+
console.error(' Workers subrequest cap as data grows. Move it behind a queue / cap it, or, if the');
|
|
79
|
+
console.error(' loop body is DB-only or hard-capped, annotate with `// subrequest-ok: <reason>`.\n');
|
|
80
|
+
for (const v of violations) {
|
|
81
|
+
console.error(` ${v.file}:${v.line} ${v.text}`);
|
|
82
|
+
}
|
|
83
|
+
process.exit(1);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
console.log('✓ subrequest fan-out gate: no un-annotated concurrency loops.');
|
|
@@ -51,6 +51,18 @@ export interface FirebaseVerifier {
|
|
|
51
51
|
* @throws If the backing user-management service is not configured or the lookup fails.
|
|
52
52
|
*/
|
|
53
53
|
getUser(uid: string): Promise<{ uid: string; email?: string } | null>;
|
|
54
|
+
/**
|
|
55
|
+
* Look up multiple user records by uid in as few requests as the backing service allows.
|
|
56
|
+
*
|
|
57
|
+
* Batched equivalent of {@link getUser}, intended to replace N single-uid lookups with a
|
|
58
|
+
* handful of requests.
|
|
59
|
+
*
|
|
60
|
+
* @param uids - The users' unique ids to look up.
|
|
61
|
+
* @returns The `uid`/`email` of every matching user. Uids that do not resolve to a user are
|
|
62
|
+
* simply absent from the result (never `null` entries).
|
|
63
|
+
* @throws If the backing user-management service is not configured or the lookup fails.
|
|
64
|
+
*/
|
|
65
|
+
getUsers(uids: string[]): Promise<{ uid: string; email?: string }[]>;
|
|
54
66
|
/**
|
|
55
67
|
* Delete a user by uid.
|
|
56
68
|
*
|
|
@@ -21,6 +21,8 @@ const TOKEN_URL = 'https://oauth2.googleapis.com/token';
|
|
|
21
21
|
const IDENTITY_TOOLKIT = 'https://identitytoolkit.googleapis.com/v1';
|
|
22
22
|
/** OAuth2 scopes required for Identity Toolkit account lookup and deletion. */
|
|
23
23
|
const SCOPE = 'https://www.googleapis.com/auth/identitytoolkit https://www.googleapis.com/auth/firebase';
|
|
24
|
+
/** Maximum number of `localId`s the `accounts:lookup` endpoint accepts in a single request. */
|
|
25
|
+
const LOOKUP_CHUNK_SIZE = 100;
|
|
24
26
|
|
|
25
27
|
/**
|
|
26
28
|
* Minimal Google Identity Toolkit REST client for the user-management operations that token
|
|
@@ -89,27 +91,70 @@ export class IdentityToolkit {
|
|
|
89
91
|
}
|
|
90
92
|
|
|
91
93
|
/**
|
|
92
|
-
*
|
|
94
|
+
* Call the `accounts:lookup` endpoint for a single chunk of `localId`s.
|
|
93
95
|
*
|
|
94
|
-
* @param
|
|
96
|
+
* @param localIds - Up to {@link LOOKUP_CHUNK_SIZE} `localId`s to look up in one request.
|
|
95
97
|
* @param nowSeconds - The current Unix time in seconds, used for access-token caching.
|
|
96
|
-
* @returns The
|
|
97
|
-
* or no matching
|
|
98
|
+
* @returns The raw `users` entries returned by the endpoint (empty when the request is
|
|
99
|
+
* unsuccessful or no matching users are returned).
|
|
98
100
|
* @throws If acquiring an access token fails.
|
|
101
|
+
* @internal
|
|
99
102
|
*/
|
|
100
|
-
async
|
|
103
|
+
private async lookupChunk(localIds: string[], nowSeconds: number): Promise<{ localId: string; email?: string }[]> {
|
|
101
104
|
const token = await this.getAccessToken(nowSeconds);
|
|
102
105
|
const res = await fetch(`${IDENTITY_TOOLKIT}/projects/${this.sa.project_id}/accounts:lookup`, {
|
|
103
106
|
method: 'POST',
|
|
104
107
|
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
|
|
105
|
-
body: JSON.stringify({ localId:
|
|
108
|
+
body: JSON.stringify({ localId: localIds }),
|
|
106
109
|
});
|
|
107
110
|
if (!res.ok) {
|
|
108
|
-
return
|
|
111
|
+
return [];
|
|
109
112
|
}
|
|
110
113
|
const json = (await res.json()) as { users?: { localId: string; email?: string }[] };
|
|
111
|
-
|
|
112
|
-
|
|
114
|
+
return json.users ?? [];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Look up a user record by uid via the `accounts:lookup` endpoint.
|
|
119
|
+
*
|
|
120
|
+
* @param uid - The user's unique id (`localId`).
|
|
121
|
+
* @param nowSeconds - The current Unix time in seconds, used for access-token caching.
|
|
122
|
+
* @returns The user's `uid` and optional `email`, or `null` when the request is unsuccessful
|
|
123
|
+
* or no matching user is returned.
|
|
124
|
+
* @throws If acquiring an access token fails.
|
|
125
|
+
*/
|
|
126
|
+
async lookup(uid: string, nowSeconds: number): Promise<{ uid: string; email?: string } | null> {
|
|
127
|
+
const users = await this.lookupChunk([uid], nowSeconds);
|
|
128
|
+
return users.length > 0 ? { uid: users[0].localId, email: users[0].email } : null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Look up multiple user records by uid via the `accounts:lookup` endpoint.
|
|
133
|
+
*
|
|
134
|
+
* `uids` are chunked into groups of at most {@link LOOKUP_CHUNK_SIZE} (the maximum `localId`
|
|
135
|
+
* array size the endpoint accepts), issuing one `accounts:lookup` request per chunk. This lets
|
|
136
|
+
* callers replace N single-uid lookups with `ceil(N / LOOKUP_CHUNK_SIZE)` requests.
|
|
137
|
+
*
|
|
138
|
+
* @param uids - The users' unique ids (`localId`s) to look up.
|
|
139
|
+
* @param nowSeconds - The current Unix time in seconds, used for access-token caching.
|
|
140
|
+
* @returns The `uid`/`email` of every matching user. Uids Firebase does not recognize are
|
|
141
|
+
* simply absent from the result (never `null` entries), so callers can treat "missing from
|
|
142
|
+
* the result" as "not found/invalid".
|
|
143
|
+
* @throws If acquiring an access token fails.
|
|
144
|
+
*/
|
|
145
|
+
async lookupMany(uids: string[], nowSeconds: number): Promise<{ uid: string; email?: string }[]> {
|
|
146
|
+
if (uids.length === 0) {
|
|
147
|
+
return [];
|
|
148
|
+
}
|
|
149
|
+
const results: { uid: string; email?: string }[] = [];
|
|
150
|
+
for (let i = 0; i < uids.length; i += LOOKUP_CHUNK_SIZE) {
|
|
151
|
+
const chunk = uids.slice(i, i + LOOKUP_CHUNK_SIZE);
|
|
152
|
+
const users = await this.lookupChunk(chunk, nowSeconds);
|
|
153
|
+
for (const user of users) {
|
|
154
|
+
results.push({ uid: user.localId, email: user.email });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return results;
|
|
113
158
|
}
|
|
114
159
|
|
|
115
160
|
/**
|
|
@@ -115,6 +115,24 @@ export class JoseFirebaseVerifier implements FirebaseVerifier {
|
|
|
115
115
|
return this.opts.identity.lookup(uid, this.nowSeconds());
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
+
/**
|
|
119
|
+
* Look up multiple user records by uid via the Identity Toolkit REST API.
|
|
120
|
+
*
|
|
121
|
+
* Batches the lookups into `ceil(uids.length / 100)` `accounts:lookup` requests instead of
|
|
122
|
+
* one request per uid.
|
|
123
|
+
*
|
|
124
|
+
* @param uids - The users' unique ids to look up.
|
|
125
|
+
* @returns The `uid`/`email` of every matching user. Uids Firebase does not recognize are
|
|
126
|
+
* simply absent from the result (never `null` entries).
|
|
127
|
+
* @throws If no Identity Toolkit client was configured on this verifier.
|
|
128
|
+
*/
|
|
129
|
+
async getUsers(uids: string[]): Promise<{ uid: string; email?: string }[]> {
|
|
130
|
+
if (!this.opts.identity) {
|
|
131
|
+
throw new Error('Identity Toolkit not configured');
|
|
132
|
+
}
|
|
133
|
+
return this.opts.identity.lookupMany(uids, this.nowSeconds());
|
|
134
|
+
}
|
|
135
|
+
|
|
118
136
|
/**
|
|
119
137
|
* Delete a user by uid via the Identity Toolkit REST API.
|
|
120
138
|
*
|
package/src/index.ts
CHANGED
|
@@ -46,6 +46,12 @@ export type { CreateStripeClientOptions } from './stripe/client.js';
|
|
|
46
46
|
// db
|
|
47
47
|
export { retryWhenDeadlock } from './db/retry.js';
|
|
48
48
|
|
|
49
|
+
// queue
|
|
50
|
+
export { sendInChunks } from './queue/send.js';
|
|
51
|
+
export type { QueueLike, QueueSendMessage } from './queue/send.js';
|
|
52
|
+
export { processBatch } from './queue/consumer.js';
|
|
53
|
+
export type { QueueMessageLike, MessageBatchLike, ProcessBatchOptions, ProcessBatchResult } from './queue/consumer.js';
|
|
54
|
+
|
|
49
55
|
// ai
|
|
50
56
|
export { createAiGatewayProvider } from './ai/gateway.js';
|
|
51
57
|
export type {
|
|
@@ -0,0 +1,146 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
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/testing/fakes.ts
CHANGED
|
@@ -56,6 +56,16 @@ export class FakeFirebaseVerifier implements FirebaseVerifier {
|
|
|
56
56
|
return { uid };
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Return minimal user records echoing each requested UID.
|
|
61
|
+
*
|
|
62
|
+
* @param uids - UIDs to look up.
|
|
63
|
+
* @returns One `{ uid }` entry per requested uid (never omits any, in this fake).
|
|
64
|
+
*/
|
|
65
|
+
async getUsers(uids: string[]): Promise<{ uid: string; email?: string }[]> {
|
|
66
|
+
return uids.map((uid) => ({ uid }));
|
|
67
|
+
}
|
|
68
|
+
|
|
59
69
|
/**
|
|
60
70
|
* Record a user deletion by appending the UID to {@link FakeFirebaseVerifier.deleted}.
|
|
61
71
|
*
|