bridgex 2.0.1 → 2.1.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/dist/HttpClient.js +30 -3
- package/dist/SMSClient.d.ts +10 -11
- package/dist/SMSClient.js +76 -19
- package/dist/SMSQueue.d.ts +59 -29
- package/dist/SMSQueue.js +123 -40
- package/dist/errors.d.ts +3 -0
- package/dist/errors.js +5 -0
- package/dist/main.d.ts +3 -3
- package/dist/main.js +2 -2
- package/dist/types.d.ts +17 -10
- package/package.json +1 -1
- package/src/HttpClient.ts +49 -5
- package/src/SMSClient.ts +84 -20
- package/src/SMSQueue.ts +178 -46
- package/src/errors.ts +6 -0
- package/src/main.ts +12 -3
- package/src/types.ts +19 -2
package/dist/HttpClient.js
CHANGED
|
@@ -1,4 +1,15 @@
|
|
|
1
|
-
import { NetworkError, ServerError } from "./errors.js";
|
|
1
|
+
import { NetworkError, ServerError, RateLimitError, MaxLimitError, } from "./errors.js";
|
|
2
|
+
// Patterns that indicate a "max messages sent" quota error from the server
|
|
3
|
+
const MAX_LIMIT_PATTERNS = [
|
|
4
|
+
/max.{0,20}(messages?|sms|send)/i,
|
|
5
|
+
/quota.{0,20}(exceeded|reached|exhausted)/i,
|
|
6
|
+
/limit.{0,20}reached/i,
|
|
7
|
+
/daily.{0,20}(limit|cap)/i,
|
|
8
|
+
/monthly.{0,20}(limit|cap)/i,
|
|
9
|
+
];
|
|
10
|
+
function isMaxLimitBody(text) {
|
|
11
|
+
return MAX_LIMIT_PATTERNS.some((p) => p.test(text));
|
|
12
|
+
}
|
|
2
13
|
export default class HttpClient {
|
|
3
14
|
constructor(options) {
|
|
4
15
|
this.options = options;
|
|
@@ -19,17 +30,33 @@ export default class HttpClient {
|
|
|
19
30
|
signal: controller.signal,
|
|
20
31
|
});
|
|
21
32
|
clearTimeout(timeoutId);
|
|
33
|
+
if (response.status === 429) {
|
|
34
|
+
const retryAfter = Number(response.headers.get("Retry-After")) || undefined;
|
|
35
|
+
throw new RateLimitError("Rate limit exceeded", retryAfter);
|
|
36
|
+
}
|
|
22
37
|
if (!response.ok) {
|
|
23
38
|
const text = await response.text();
|
|
24
|
-
|
|
39
|
+
// Detect "max messages sent reached" specifically — not retryable
|
|
40
|
+
if (response.status === 402 ||
|
|
41
|
+
response.status === 403 ||
|
|
42
|
+
isMaxLimitBody(text)) {
|
|
43
|
+
throw new MaxLimitError(`Message quota exceeded (HTTP ${response.status})`, { status: response.status, body: text });
|
|
44
|
+
}
|
|
45
|
+
throw new ServerError(`Server responded with ${response.status}`, {
|
|
46
|
+
status: response.status,
|
|
47
|
+
body: text,
|
|
48
|
+
});
|
|
25
49
|
}
|
|
26
50
|
return await response.json();
|
|
27
51
|
}
|
|
28
52
|
catch (error) {
|
|
53
|
+
clearTimeout(timeoutId);
|
|
29
54
|
if (error.name === "AbortError") {
|
|
30
55
|
throw new NetworkError("Request timed out");
|
|
31
56
|
}
|
|
32
|
-
if (error instanceof ServerError
|
|
57
|
+
if (error instanceof ServerError ||
|
|
58
|
+
error instanceof RateLimitError ||
|
|
59
|
+
error instanceof MaxLimitError) {
|
|
33
60
|
throw error;
|
|
34
61
|
}
|
|
35
62
|
throw new NetworkError("Network request failed", error);
|
package/dist/SMSClient.d.ts
CHANGED
|
@@ -25,19 +25,22 @@ export default class SMSClient {
|
|
|
25
25
|
private concurrency;
|
|
26
26
|
private batchSize;
|
|
27
27
|
constructor(options: SMSClientOptions);
|
|
28
|
-
/** Attach a plugin at any time. */
|
|
29
28
|
use(plugin: Plugin): this;
|
|
30
29
|
private _send;
|
|
31
|
-
/**
|
|
32
|
-
* Send a single SMS message.
|
|
33
|
-
* Returns a Result — never throws.
|
|
34
|
-
*/
|
|
30
|
+
/** Send a single SMS. Returns a Result — never throws. */
|
|
35
31
|
send(params: SendParams & {
|
|
36
32
|
_meta?: any;
|
|
37
33
|
}): Promise<Result<any>>;
|
|
38
34
|
/**
|
|
39
35
|
* Send the same template to many recipients.
|
|
40
|
-
*
|
|
36
|
+
*
|
|
37
|
+
* Detailed batch report:
|
|
38
|
+
* - succeeded[]: index, to, data (the server's response JSON)
|
|
39
|
+
* - failed[]: index, to, error (typed ErrorLog), originalParams (full params preserved)
|
|
40
|
+
* - hitMaxLimit: true if the server signalled quota exhausted — stops sending immediately
|
|
41
|
+
*
|
|
42
|
+
* Failed recipients' originalParams can be passed straight to queue.enqueue()
|
|
43
|
+
* so nothing is lost.
|
|
41
44
|
*/
|
|
42
45
|
sendMany(recipients: Array<{
|
|
43
46
|
to: string;
|
|
@@ -51,15 +54,11 @@ export default class SMSClient {
|
|
|
51
54
|
sendObject<T extends Record<string, unknown>>(params: SendObjectParams<T> & {
|
|
52
55
|
_meta?: any;
|
|
53
56
|
}): Promise<Result<any>>;
|
|
54
|
-
/**
|
|
55
|
-
* Send object-derived messages to many recipients.
|
|
56
|
-
*/
|
|
57
|
+
/** Send object-derived messages to many recipients. */
|
|
57
58
|
sendObjectMany<T extends Record<string, unknown>>(items: Array<SendObjectParams<T> & {
|
|
58
59
|
_meta?: any;
|
|
59
60
|
}>): Promise<BatchResult<any>>;
|
|
60
|
-
/** Current circuit breaker state. */
|
|
61
61
|
get circuitState(): "CLOSED" | "OPEN" | "HALF_OPEN";
|
|
62
|
-
/** Manually reset the circuit breaker (e.g. after fixing a downstream issue). */
|
|
63
62
|
resetCircuit(): void;
|
|
64
63
|
private validateOptions;
|
|
65
64
|
}
|
package/dist/SMSClient.js
CHANGED
|
@@ -50,13 +50,12 @@ export default class SMSClient {
|
|
|
50
50
|
this.batchSize = options.batchSize ?? 50;
|
|
51
51
|
options.plugins?.forEach((p) => this.plugins.use(p));
|
|
52
52
|
}
|
|
53
|
-
/** Attach a plugin at any time. */
|
|
54
53
|
use(plugin) {
|
|
55
54
|
this.plugins.use(plugin);
|
|
56
55
|
return this;
|
|
57
56
|
}
|
|
58
57
|
// ─────────────────────────────────────────────────────────────────────────
|
|
59
|
-
// Core internal send
|
|
58
|
+
// Core internal send
|
|
60
59
|
// ─────────────────────────────────────────────────────────────────────────
|
|
61
60
|
async _send(to, message, tags = []) {
|
|
62
61
|
const start = Date.now();
|
|
@@ -86,10 +85,7 @@ export default class SMSClient {
|
|
|
86
85
|
// ─────────────────────────────────────────────────────────────────────────
|
|
87
86
|
// Public API
|
|
88
87
|
// ─────────────────────────────────────────────────────────────────────────
|
|
89
|
-
/**
|
|
90
|
-
* Send a single SMS message.
|
|
91
|
-
* Returns a Result — never throws.
|
|
92
|
-
*/
|
|
88
|
+
/** Send a single SMS. Returns a Result — never throws. */
|
|
93
89
|
async send(params) {
|
|
94
90
|
const { to, template, variables = {} } = params;
|
|
95
91
|
const tags = params._meta?.tags ?? [];
|
|
@@ -106,7 +102,14 @@ export default class SMSClient {
|
|
|
106
102
|
}
|
|
107
103
|
/**
|
|
108
104
|
* Send the same template to many recipients.
|
|
109
|
-
*
|
|
105
|
+
*
|
|
106
|
+
* Detailed batch report:
|
|
107
|
+
* - succeeded[]: index, to, data (the server's response JSON)
|
|
108
|
+
* - failed[]: index, to, error (typed ErrorLog), originalParams (full params preserved)
|
|
109
|
+
* - hitMaxLimit: true if the server signalled quota exhausted — stops sending immediately
|
|
110
|
+
*
|
|
111
|
+
* Failed recipients' originalParams can be passed straight to queue.enqueue()
|
|
112
|
+
* so nothing is lost.
|
|
110
113
|
*/
|
|
111
114
|
async sendMany(recipients, template, sharedVariables = {}) {
|
|
112
115
|
const result = {
|
|
@@ -115,23 +118,29 @@ export default class SMSClient {
|
|
|
115
118
|
total: recipients.length,
|
|
116
119
|
successCount: 0,
|
|
117
120
|
failureCount: 0,
|
|
121
|
+
hitMaxLimit: false,
|
|
118
122
|
};
|
|
119
123
|
const chunks = chunkArray(recipients, this.batchSize);
|
|
120
|
-
for (const chunk of chunks) {
|
|
124
|
+
outer: for (const chunk of chunks) {
|
|
125
|
+
// Shared flag: if one worker hits MAX_LIMIT, all workers stop
|
|
126
|
+
let maxLimitHit = false;
|
|
121
127
|
const queue = [...chunk.entries()];
|
|
122
128
|
await Promise.all(new Array(this.concurrency).fill(null).map(async () => {
|
|
123
129
|
while (queue.length > 0) {
|
|
130
|
+
if (maxLimitHit)
|
|
131
|
+
break;
|
|
124
132
|
const entry = queue.shift();
|
|
125
133
|
if (!entry)
|
|
126
134
|
break;
|
|
127
135
|
const [chunkIndex, recipient] = entry;
|
|
128
136
|
const globalIndex = chunks.indexOf(chunk) * this.batchSize + chunkIndex;
|
|
129
137
|
const variables = { ...sharedVariables, ...recipient.variables };
|
|
130
|
-
const
|
|
138
|
+
const originalParams = {
|
|
131
139
|
to: recipient.to,
|
|
132
140
|
template,
|
|
133
141
|
variables,
|
|
134
|
-
}
|
|
142
|
+
};
|
|
143
|
+
const res = await this.send({ ...originalParams });
|
|
135
144
|
if (res.ok) {
|
|
136
145
|
result.succeeded.push({
|
|
137
146
|
index: globalIndex,
|
|
@@ -141,15 +150,51 @@ export default class SMSClient {
|
|
|
141
150
|
result.successCount++;
|
|
142
151
|
}
|
|
143
152
|
else {
|
|
144
|
-
|
|
153
|
+
const failedItem = {
|
|
145
154
|
index: globalIndex,
|
|
146
155
|
to: recipient.to,
|
|
147
156
|
error: res.error,
|
|
148
|
-
|
|
157
|
+
originalParams,
|
|
158
|
+
};
|
|
159
|
+
result.failed.push(failedItem);
|
|
149
160
|
result.failureCount++;
|
|
161
|
+
// Quota exhausted — stop sending the rest immediately
|
|
162
|
+
if (res.error.code === "MAX_LIMIT_ERROR") {
|
|
163
|
+
maxLimitHit = true;
|
|
164
|
+
result.hitMaxLimit = true;
|
|
165
|
+
}
|
|
150
166
|
}
|
|
151
167
|
}
|
|
152
168
|
}));
|
|
169
|
+
// Also break the outer chunk loop if quota hit
|
|
170
|
+
if (result.hitMaxLimit) {
|
|
171
|
+
// Mark remaining recipients as failed with the quota error — data is NOT lost
|
|
172
|
+
const processedIndices = new Set([
|
|
173
|
+
...result.succeeded.map((s) => s.index),
|
|
174
|
+
...result.failed.map((f) => f.index),
|
|
175
|
+
]);
|
|
176
|
+
for (let i = 0; i < recipients.length; i++) {
|
|
177
|
+
if (!processedIndices.has(i)) {
|
|
178
|
+
const r = recipients[i];
|
|
179
|
+
const variables = { ...sharedVariables, ...r.variables };
|
|
180
|
+
result.failed.push({
|
|
181
|
+
index: i,
|
|
182
|
+
to: r.to,
|
|
183
|
+
error: {
|
|
184
|
+
name: "MaxLimitError",
|
|
185
|
+
message: "Skipped — server quota was exhausted before this recipient",
|
|
186
|
+
code: "MAX_LIMIT_ERROR",
|
|
187
|
+
isClientError: false,
|
|
188
|
+
isServerError: true,
|
|
189
|
+
timestamp: new Date().toISOString(),
|
|
190
|
+
},
|
|
191
|
+
originalParams: { to: r.to, template, variables },
|
|
192
|
+
});
|
|
193
|
+
result.failureCount++;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
break outer;
|
|
197
|
+
}
|
|
153
198
|
}
|
|
154
199
|
return result;
|
|
155
200
|
}
|
|
@@ -172,9 +217,7 @@ export default class SMSClient {
|
|
|
172
217
|
return fail(error);
|
|
173
218
|
}
|
|
174
219
|
}
|
|
175
|
-
/**
|
|
176
|
-
* Send object-derived messages to many recipients.
|
|
177
|
-
*/
|
|
220
|
+
/** Send object-derived messages to many recipients. */
|
|
178
221
|
async sendObjectMany(items) {
|
|
179
222
|
const result = {
|
|
180
223
|
succeeded: [],
|
|
@@ -182,12 +225,16 @@ export default class SMSClient {
|
|
|
182
225
|
total: items.length,
|
|
183
226
|
successCount: 0,
|
|
184
227
|
failureCount: 0,
|
|
228
|
+
hitMaxLimit: false,
|
|
185
229
|
};
|
|
186
230
|
const chunks = chunkArray(items, this.batchSize);
|
|
187
|
-
for (const chunk of chunks) {
|
|
231
|
+
outer: for (const chunk of chunks) {
|
|
232
|
+
let maxLimitHit = false;
|
|
188
233
|
const queue = [...chunk.entries()];
|
|
189
234
|
await Promise.all(new Array(this.concurrency).fill(null).map(async () => {
|
|
190
235
|
while (queue.length > 0) {
|
|
236
|
+
if (maxLimitHit)
|
|
237
|
+
break;
|
|
191
238
|
const entry = queue.shift();
|
|
192
239
|
if (!entry)
|
|
193
240
|
break;
|
|
@@ -203,27 +250,37 @@ export default class SMSClient {
|
|
|
203
250
|
result.successCount++;
|
|
204
251
|
}
|
|
205
252
|
else {
|
|
253
|
+
// For object sends, preserve what we can
|
|
254
|
+
const originalParams = {
|
|
255
|
+
to: item.to,
|
|
256
|
+
template: item.template ?? JSON.stringify(item.object),
|
|
257
|
+
variables: {},
|
|
258
|
+
};
|
|
206
259
|
result.failed.push({
|
|
207
260
|
index: globalIndex,
|
|
208
261
|
to: item.to,
|
|
209
262
|
error: res.error,
|
|
263
|
+
originalParams,
|
|
210
264
|
});
|
|
211
265
|
result.failureCount++;
|
|
266
|
+
if (res.error.code === "MAX_LIMIT_ERROR") {
|
|
267
|
+
maxLimitHit = true;
|
|
268
|
+
result.hitMaxLimit = true;
|
|
269
|
+
}
|
|
212
270
|
}
|
|
213
271
|
}
|
|
214
272
|
}));
|
|
273
|
+
if (result.hitMaxLimit)
|
|
274
|
+
break outer;
|
|
215
275
|
}
|
|
216
276
|
return result;
|
|
217
277
|
}
|
|
218
|
-
/** Current circuit breaker state. */
|
|
219
278
|
get circuitState() {
|
|
220
279
|
return this.circuit.currentState;
|
|
221
280
|
}
|
|
222
|
-
/** Manually reset the circuit breaker (e.g. after fixing a downstream issue). */
|
|
223
281
|
resetCircuit() {
|
|
224
282
|
this.circuit.reset();
|
|
225
283
|
}
|
|
226
|
-
// ─────────────────────────────────────────────────────────────────────────
|
|
227
284
|
validateOptions(options) {
|
|
228
285
|
const { baseUrl, apiKey, projectKey } = options;
|
|
229
286
|
if (!baseUrl)
|
package/dist/SMSQueue.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type SMSClient from "./SMSClient.js";
|
|
2
2
|
import type PluginManager from "./PluginManager.js";
|
|
3
|
-
import type { SendParams } from "./types.js";
|
|
3
|
+
import type { SendParams, ErrorLog } from "./types.js";
|
|
4
4
|
import type { SendMeta } from "./SendConfig.js";
|
|
5
5
|
export interface QueueJob {
|
|
6
6
|
id: string;
|
|
@@ -8,75 +8,105 @@ export interface QueueJob {
|
|
|
8
8
|
meta: SendMeta["_meta"];
|
|
9
9
|
scheduledAt: number;
|
|
10
10
|
enqueuedAt: number;
|
|
11
|
+
/** How many times this job has been attempted (including the current one) */
|
|
11
12
|
attempts: number;
|
|
13
|
+
/** Max retry attempts before giving up (default: 3) */
|
|
14
|
+
maxAttempts: number;
|
|
15
|
+
/** Full history of every attempt result for this job */
|
|
16
|
+
history: AttemptRecord[];
|
|
17
|
+
}
|
|
18
|
+
export interface AttemptRecord {
|
|
19
|
+
attempt: number;
|
|
20
|
+
timestamp: string;
|
|
21
|
+
ok: boolean;
|
|
22
|
+
durationMs: number;
|
|
23
|
+
error?: ErrorLog;
|
|
24
|
+
data?: unknown;
|
|
12
25
|
}
|
|
13
26
|
export interface QueueOptions {
|
|
14
|
-
/** Max concurrent workers
|
|
27
|
+
/** Max concurrent workers (default: 3) */
|
|
15
28
|
concurrency?: number;
|
|
16
|
-
/**
|
|
29
|
+
/** Poll interval ms (default: 1000) */
|
|
17
30
|
pollInterval?: number;
|
|
18
|
-
/**
|
|
31
|
+
/** Auto-start on first enqueue (default: true) */
|
|
19
32
|
autoStart?: boolean;
|
|
33
|
+
/** Default max retry attempts per job (default: 3) */
|
|
34
|
+
maxAttempts?: number;
|
|
35
|
+
/** Base retry delay ms for failed jobs (default: 2000) */
|
|
36
|
+
retryDelay?: number;
|
|
20
37
|
}
|
|
21
38
|
export interface QueueStats {
|
|
22
39
|
pending: number;
|
|
23
40
|
running: number;
|
|
24
41
|
completed: number;
|
|
42
|
+
failed: number;
|
|
25
43
|
dropped: number;
|
|
26
44
|
}
|
|
45
|
+
export interface FailedJob {
|
|
46
|
+
job: QueueJob;
|
|
47
|
+
finalError: ErrorLog;
|
|
48
|
+
/** All attempt records so nothing is lost */
|
|
49
|
+
history: AttemptRecord[];
|
|
50
|
+
}
|
|
27
51
|
/**
|
|
28
|
-
* SMSQueue —
|
|
52
|
+
* SMSQueue — priority job queue with full retry, failure history, and data preservation.
|
|
29
53
|
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* - Scheduled sends (send at a future timestamp)
|
|
35
|
-
* - Concurrency-limited workers
|
|
36
|
-
* - Plugin hooks (onDrop, onSuccess, onError, onRetry)
|
|
54
|
+
* When a job fails:
|
|
55
|
+
* - If the error is retryable (network, server, rate-limit) → re-queued with backoff
|
|
56
|
+
* - If the error is permanent (validation, quota exhausted) → moved to dead-letter list
|
|
57
|
+
* - The full attempt history is kept on every job so no data is ever silently lost
|
|
37
58
|
*
|
|
38
59
|
* @example
|
|
39
|
-
* const queue = new SMSQueue(client, { concurrency: 5 });
|
|
60
|
+
* const queue = new SMSQueue(client, { concurrency: 5, maxAttempts: 4 });
|
|
40
61
|
* queue.start();
|
|
62
|
+
* queue.on("failed", ({ job, finalError }) => saveToDb(job, finalError));
|
|
41
63
|
*
|
|
42
|
-
*
|
|
43
|
-
* await queue.enqueue(cfg.for(phone, { code }));
|
|
44
|
-
*
|
|
45
|
-
* // Schedule for later
|
|
46
|
-
* await queue.enqueueAt(cfg.for(phone, { code }), Date.now() + 60_000);
|
|
64
|
+
* queue.enqueue(otpConfig.for(phone, { code }));
|
|
47
65
|
*/
|
|
48
66
|
export default class SMSQueue {
|
|
49
67
|
private client;
|
|
50
68
|
private jobs;
|
|
69
|
+
private deadLetters;
|
|
51
70
|
private dedupSet;
|
|
71
|
+
private listeners;
|
|
52
72
|
private running;
|
|
53
73
|
private completed;
|
|
74
|
+
private failedCount;
|
|
54
75
|
private dropped;
|
|
55
76
|
private pollTimer?;
|
|
56
77
|
private readonly concurrency;
|
|
57
78
|
private readonly pollInterval;
|
|
58
79
|
private readonly autoStart;
|
|
80
|
+
private readonly defaultMaxAttempts;
|
|
81
|
+
private readonly retryDelay;
|
|
59
82
|
private plugins?;
|
|
60
83
|
constructor(client: SMSClient, options?: QueueOptions, plugins?: PluginManager);
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
enqueueAt(params: SendParams & Partial<SendMeta>, timestamp: number): string;
|
|
65
|
-
|
|
66
|
-
enqueueAfter(params: SendParams & Partial<SendMeta>, delayMs: number): string;
|
|
84
|
+
on(event: "failed" | "completed" | "dropped", listener: (data: any) => void): this;
|
|
85
|
+
private emit;
|
|
86
|
+
enqueue(params: SendParams & Partial<SendMeta>, maxAttempts?: number): string;
|
|
87
|
+
enqueueAt(params: SendParams & Partial<SendMeta>, timestamp: number, maxAttempts?: number): string;
|
|
88
|
+
enqueueAfter(params: SendParams & Partial<SendMeta>, delayMs: number, maxAttempts?: number): string;
|
|
67
89
|
private _enqueue;
|
|
68
90
|
private _insertByPriority;
|
|
69
|
-
/**
|
|
91
|
+
/**
|
|
92
|
+
* Re-enqueue jobs that previously failed.
|
|
93
|
+
* Pass the array from `queue.getDeadLetters()` or `batchResult.failed` re-wrapped.
|
|
94
|
+
* Clears them from the dead-letter list.
|
|
95
|
+
*/
|
|
96
|
+
requeueFailed(jobs: FailedJob[]): string[];
|
|
70
97
|
start(): this;
|
|
71
|
-
/** Drain the queue (process remaining jobs) then stop. */
|
|
72
98
|
drain(): Promise<void>;
|
|
73
|
-
/** Stop polling. In-flight jobs still complete. */
|
|
74
99
|
stop(): this;
|
|
75
100
|
private _tick;
|
|
76
101
|
private _process;
|
|
77
102
|
stats(): QueueStats;
|
|
78
|
-
/**
|
|
103
|
+
/**
|
|
104
|
+
* Returns all jobs that exhausted their retries or hit a permanent error.
|
|
105
|
+
* Each entry includes the full attempt history — no data is lost.
|
|
106
|
+
*/
|
|
107
|
+
getDeadLetters(): FailedJob[];
|
|
108
|
+
/** Clear the dead-letter list (e.g. after you've processed/saved them). */
|
|
109
|
+
clearDeadLetters(): FailedJob[];
|
|
79
110
|
cancel(id: string): boolean;
|
|
80
|
-
/** Clear all pending (not in-flight) jobs. */
|
|
81
111
|
clear(): number;
|
|
82
112
|
}
|