@panorama-industries/effect-vercel-queue 0.0.0-stage → 0.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/LICENSE +21 -0
- package/README.md +13 -2
- package/dist/contracts/message-handling.d.ts +128 -0
- package/dist/contracts/message-handling.d.ts.map +1 -0
- package/dist/contracts/message-handling.js +4 -0
- package/dist/contracts/message-handling.js.map +1 -0
- package/dist/contracts/queue-api.d.ts +255 -0
- package/dist/contracts/queue-api.d.ts.map +1 -0
- package/dist/contracts/queue-api.js +18 -0
- package/dist/contracts/queue-api.js.map +1 -0
- package/dist/contracts/queue-config.d.ts +87 -0
- package/dist/contracts/queue-config.d.ts.map +1 -0
- package/dist/contracts/queue-config.js +7 -0
- package/dist/contracts/queue-config.js.map +1 -0
- package/dist/contracts/queue-consumer.d.ts +30 -0
- package/dist/contracts/queue-consumer.d.ts.map +1 -0
- package/dist/contracts/queue-consumer.js +15 -0
- package/dist/contracts/queue-consumer.js.map +1 -0
- package/dist/contracts/queue-domain.d.ts +171 -0
- package/dist/contracts/queue-domain.d.ts.map +1 -0
- package/dist/contracts/queue-domain.js +137 -0
- package/dist/contracts/queue-domain.js.map +1 -0
- package/dist/contracts/queue-poller.d.ts +169 -0
- package/dist/contracts/queue-poller.d.ts.map +1 -0
- package/dist/contracts/queue-poller.js +10 -0
- package/dist/contracts/queue-poller.js.map +1 -0
- package/dist/contracts/queue-producer.d.ts +23 -0
- package/dist/contracts/queue-producer.d.ts.map +1 -0
- package/dist/contracts/queue-producer.js +5 -0
- package/dist/contracts/queue-producer.js.map +1 -0
- package/dist/contracts/topic.d.ts +47 -0
- package/dist/contracts/topic.d.ts.map +1 -0
- package/dist/contracts/topic.js +34 -0
- package/dist/contracts/topic.js.map +1 -0
- package/dist/implementation/queue-api-live.d.ts +16 -0
- package/dist/implementation/queue-api-live.d.ts.map +1 -0
- package/dist/implementation/queue-api-live.js +326 -0
- package/dist/implementation/queue-api-live.js.map +1 -0
- package/dist/implementation/queue-config-live.d.ts +37 -0
- package/dist/implementation/queue-config-live.d.ts.map +1 -0
- package/dist/implementation/queue-config-live.js +99 -0
- package/dist/implementation/queue-config-live.js.map +1 -0
- package/dist/implementation/queue-consumer-live.d.ts +14 -0
- package/dist/implementation/queue-consumer-live.d.ts.map +1 -0
- package/dist/implementation/queue-consumer-live.js +56 -0
- package/dist/implementation/queue-consumer-live.js.map +1 -0
- package/dist/implementation/queue-live.d.ts +15 -0
- package/dist/implementation/queue-live.d.ts.map +1 -0
- package/dist/implementation/queue-live.js +16 -0
- package/dist/implementation/queue-live.js.map +1 -0
- package/dist/implementation/queue-poller-live.d.ts +14 -0
- package/dist/implementation/queue-poller-live.d.ts.map +1 -0
- package/dist/implementation/queue-poller-live.js +66 -0
- package/dist/implementation/queue-poller-live.js.map +1 -0
- package/dist/implementation/queue-producer-live.d.ts +9 -0
- package/dist/implementation/queue-producer-live.d.ts.map +1 -0
- package/dist/implementation/queue-producer-live.js +54 -0
- package/dist/implementation/queue-producer-live.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/cloud-event.d.ts +46 -0
- package/dist/internal/cloud-event.d.ts.map +1 -0
- package/dist/internal/cloud-event.js +140 -0
- package/dist/internal/cloud-event.js.map +1 -0
- package/dist/internal/lease.d.ts +23 -0
- package/dist/internal/lease.d.ts.map +1 -0
- package/dist/internal/lease.js +44 -0
- package/dist/internal/lease.js.map +1 -0
- package/dist/internal/multipart.d.ts +23 -0
- package/dist/internal/multipart.d.ts.map +1 -0
- package/dist/internal/multipart.js +205 -0
- package/dist/internal/multipart.js.map +1 -0
- package/dist/internal/process.d.ts +25 -0
- package/dist/internal/process.d.ts.map +1 -0
- package/dist/internal/process.js +95 -0
- package/dist/internal/process.js.map +1 -0
- package/dist/internal/queue-protocol.d.ts +38 -0
- package/dist/internal/queue-protocol.d.ts.map +1 -0
- package/dist/internal/queue-protocol.js +119 -0
- package/dist/internal/queue-protocol.js.map +1 -0
- package/dist/internal/version.d.ts +4 -0
- package/dist/internal/version.d.ts.map +1 -0
- package/dist/internal/version.js +4 -0
- package/dist/internal/version.js.map +1 -0
- package/dist/retry-policy.d.ts +26 -0
- package/dist/retry-policy.d.ts.map +1 -0
- package/dist/retry-policy.js +34 -0
- package/dist/retry-policy.js.map +1 -0
- package/dist/testing/index.d.ts +2 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +2 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/testing/test-queue.d.ts +74 -0
- package/dist/testing/test-queue.d.ts.map +1 -0
- package/dist/testing/test-queue.js +253 -0
- package/dist/testing/test-queue.js.map +1 -0
- package/package.json +59 -3
- package/src/contracts/message-handling.ts +68 -0
- package/src/contracts/queue-api.ts +199 -0
- package/src/contracts/queue-config.ts +47 -0
- package/src/contracts/queue-consumer.ts +40 -0
- package/src/contracts/queue-domain.ts +202 -0
- package/src/contracts/queue-poller.ts +76 -0
- package/src/contracts/queue-producer.ts +40 -0
- package/src/contracts/topic.ts +76 -0
- package/src/implementation/queue-api-live.ts +637 -0
- package/src/implementation/queue-config-live.ts +178 -0
- package/src/implementation/queue-consumer-live.ts +112 -0
- package/src/implementation/queue-live.ts +32 -0
- package/src/implementation/queue-poller-live.ts +130 -0
- package/src/implementation/queue-producer-live.ts +89 -0
- package/src/index.ts +19 -0
- package/src/internal/cloud-event.ts +244 -0
- package/src/internal/lease.ts +84 -0
- package/src/internal/multipart.ts +240 -0
- package/src/internal/process.ts +194 -0
- package/src/internal/queue-protocol.ts +169 -0
- package/src/internal/version.ts +5 -0
- package/src/retry-policy.ts +48 -0
- package/src/testing/index.ts +1 -0
- package/src/testing/test-queue.ts +469 -0
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import type { Headers } from "effect/http";
|
|
2
|
+
|
|
3
|
+
import { VercelRegion } from "@panorama-industries/effect-vercel-core";
|
|
4
|
+
import { DateTime, Duration, Effect, Option, Predicate, Schema } from "effect";
|
|
5
|
+
|
|
6
|
+
import type { LeaseDeadline } from "./lease.ts";
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
ConsumerGroupName,
|
|
10
|
+
MessageHandle,
|
|
11
|
+
MessageId,
|
|
12
|
+
MessageMetadata,
|
|
13
|
+
ReceiptHandle,
|
|
14
|
+
ReceivedMessage,
|
|
15
|
+
TopicName,
|
|
16
|
+
} from "../contracts/queue-domain.ts";
|
|
17
|
+
|
|
18
|
+
export const CLOUD_EVENT_TYPE_V1BETA = "com.vercel.queue.v1beta";
|
|
19
|
+
export const CLOUD_EVENT_TYPE_V2BETA = "com.vercel.queue.v2beta";
|
|
20
|
+
|
|
21
|
+
const REGION_HEADER_PATTERN = /^[a-z]{2,5}[0-9]{1,2}$/;
|
|
22
|
+
const DEFAULT_RETENTION = Duration.hours(24);
|
|
23
|
+
|
|
24
|
+
/** A push delivery the queue could not have sent: answered with HTTP 400. */
|
|
25
|
+
export class MalformedCloudEvent extends Schema.TaggedError<MalformedCloudEvent>()(
|
|
26
|
+
"MalformedCloudEvent",
|
|
27
|
+
{ message: Schema.String },
|
|
28
|
+
) {}
|
|
29
|
+
|
|
30
|
+
export interface CallbackRoute {
|
|
31
|
+
readonly topic: TopicName;
|
|
32
|
+
readonly consumerGroup: ConsumerGroupName;
|
|
33
|
+
readonly messageId: MessageId;
|
|
34
|
+
readonly region: Option.Option<VercelRegion>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Binary-mode delivery whose lease and payload arrived with the request. */
|
|
38
|
+
export interface InlineLease {
|
|
39
|
+
readonly receiptHandle: ReceiptHandle;
|
|
40
|
+
readonly deliveryCount: Option.Option<string>;
|
|
41
|
+
readonly createdAt: Option.Option<string>;
|
|
42
|
+
readonly expiresAt: Option.Option<string>;
|
|
43
|
+
readonly contentType: Option.Option<string>;
|
|
44
|
+
readonly visibilityDeadline: Option.Option<string>;
|
|
45
|
+
readonly handoffCount: Option.Option<number>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface ParsedCallback extends CallbackRoute {
|
|
49
|
+
readonly inline: Option.Option<InlineLease>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const malformed = (message: string) => Effect.fail(new MalformedCloudEvent({ message }));
|
|
53
|
+
|
|
54
|
+
const header = (headers: Headers.Headers, name: string): Option.Option<string> =>
|
|
55
|
+
Option.fromNullishOr(headers[name]);
|
|
56
|
+
|
|
57
|
+
const nonEmptyHeader = (headers: Headers.Headers, name: string): Option.Option<string> =>
|
|
58
|
+
Option.filter(header(headers, name), (value) => value.length > 0);
|
|
59
|
+
|
|
60
|
+
/** Reads `Vqs-Experimental-Handoff-Count`-style values: only positive integers count. */
|
|
61
|
+
export const parseHandoffCount = (value: Option.Option<string>): Option.Option<number> =>
|
|
62
|
+
Option.flatMap(value, (raw) => {
|
|
63
|
+
if (raw.length === 0) return Option.none();
|
|
64
|
+
const count = Number(raw);
|
|
65
|
+
return Number.isInteger(count) && count > 0 ? Option.some(count) : Option.none();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const brand = <S extends Schema.Top & Schema.Decoder<unknown>>(
|
|
69
|
+
schema: S,
|
|
70
|
+
field: string,
|
|
71
|
+
value: string,
|
|
72
|
+
) =>
|
|
73
|
+
Schema.decodeUnknownEffect(schema)(value).pipe(
|
|
74
|
+
Effect.catch(() =>
|
|
75
|
+
malformed(`Invalid CloudEvent: ${field} ${JSON.stringify(value)} is not valid`),
|
|
76
|
+
),
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const route = Effect.fnUntraced(function* (
|
|
80
|
+
topic: string,
|
|
81
|
+
consumerGroup: string,
|
|
82
|
+
messageId: string,
|
|
83
|
+
region: Option.Option<VercelRegion>,
|
|
84
|
+
) {
|
|
85
|
+
return {
|
|
86
|
+
topic: yield* brand(TopicName, "queueName", topic),
|
|
87
|
+
consumerGroup: yield* brand(ConsumerGroupName, "consumerGroup", consumerGroup),
|
|
88
|
+
messageId: yield* brand(MessageId, "messageId", messageId),
|
|
89
|
+
region,
|
|
90
|
+
} satisfies CallbackRoute;
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const parseStructured = Effect.fnUntraced(function* (
|
|
94
|
+
body: unknown,
|
|
95
|
+
contentType: Option.Option<string>,
|
|
96
|
+
) {
|
|
97
|
+
if (Option.isNone(contentType) || !contentType.value.includes("application/cloudevents+json")) {
|
|
98
|
+
return yield* malformed("Invalid content type: expected 'application/cloudevents+json'");
|
|
99
|
+
}
|
|
100
|
+
const event = Predicate.isObjectOrArray(body) ? (body as Record<string, unknown>) : undefined;
|
|
101
|
+
const data = Predicate.isObjectOrArray(event?.["data"])
|
|
102
|
+
? (event["data"] as Record<string, unknown>)
|
|
103
|
+
: undefined;
|
|
104
|
+
if (event === undefined || !event["type"] || !event["source"] || !event["id"] || !data) {
|
|
105
|
+
return yield* malformed("Invalid CloudEvent: missing required fields");
|
|
106
|
+
}
|
|
107
|
+
if (event["type"] !== CLOUD_EVENT_TYPE_V1BETA) {
|
|
108
|
+
return yield* malformed(
|
|
109
|
+
// oxlint-disable-next-line typescript/no-base-to-string -- matches @vercel/queue's message
|
|
110
|
+
`Invalid CloudEvent type: expected '${CLOUD_EVENT_TYPE_V1BETA}', got '${String(event["type"])}'`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
const missing = ["queueName", "consumerGroup", "messageId"].filter((field) => !(field in data));
|
|
114
|
+
if (missing.length > 0) {
|
|
115
|
+
return yield* malformed(`Missing required CloudEvent data fields: ${missing.join(", ")}`);
|
|
116
|
+
}
|
|
117
|
+
const parsed = yield* route(
|
|
118
|
+
String(data["queueName"]),
|
|
119
|
+
String(data["consumerGroup"]),
|
|
120
|
+
String(data["messageId"]),
|
|
121
|
+
Option.none(),
|
|
122
|
+
);
|
|
123
|
+
return { ...parsed, inline: Option.none() } satisfies ParsedCallback;
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const parseBinary = Effect.fnUntraced(function* (headers: Headers.Headers) {
|
|
127
|
+
const queueName = nonEmptyHeader(headers, "ce-vqsqueuename");
|
|
128
|
+
const consumerGroup = nonEmptyHeader(headers, "ce-vqsconsumergroup");
|
|
129
|
+
const messageId = nonEmptyHeader(headers, "ce-vqsmessageid");
|
|
130
|
+
if (Option.isNone(queueName) || Option.isNone(consumerGroup) || Option.isNone(messageId)) {
|
|
131
|
+
const missing = [
|
|
132
|
+
Option.isNone(queueName) ? ["ce-vqsqueuename"] : [],
|
|
133
|
+
Option.isNone(consumerGroup) ? ["ce-vqsconsumergroup"] : [],
|
|
134
|
+
Option.isNone(messageId) ? ["ce-vqsmessageid"] : [],
|
|
135
|
+
].flat();
|
|
136
|
+
return yield* malformed(`Missing required CloudEvent headers: ${missing.join(", ")}`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const rawRegion = header(headers, "ce-vqsregion");
|
|
140
|
+
let region = Option.none<VercelRegion>();
|
|
141
|
+
if (Option.isSome(rawRegion)) {
|
|
142
|
+
const decoded = REGION_HEADER_PATTERN.test(rawRegion.value)
|
|
143
|
+
? Schema.decodeUnknownOption(VercelRegion)(rawRegion.value)
|
|
144
|
+
: Option.none();
|
|
145
|
+
if (Option.isNone(decoded)) {
|
|
146
|
+
return yield* malformed(
|
|
147
|
+
`Invalid ce-vqsregion header: ${JSON.stringify(rawRegion.value)}. ` +
|
|
148
|
+
`Region must match /^[a-z]{2,5}[0-9]{1,2}$/ (e.g. "iad1", "lhr1").`,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
region = decoded;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const base = yield* route(queueName.value, consumerGroup.value, messageId.value, region);
|
|
155
|
+
const receiptHandle = nonEmptyHeader(headers, "ce-vqsreceipthandle");
|
|
156
|
+
if (Option.isNone(receiptHandle)) {
|
|
157
|
+
return { ...base, inline: Option.none() } satisfies ParsedCallback;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const inline: InlineLease = {
|
|
161
|
+
receiptHandle: yield* brand(ReceiptHandle, "receiptHandle", receiptHandle.value),
|
|
162
|
+
deliveryCount: nonEmptyHeader(headers, "ce-vqsdeliverycount"),
|
|
163
|
+
createdAt: nonEmptyHeader(headers, "ce-vqscreatedat"),
|
|
164
|
+
expiresAt: nonEmptyHeader(headers, "ce-vqsexpiresat"),
|
|
165
|
+
contentType: nonEmptyHeader(headers, "content-type"),
|
|
166
|
+
visibilityDeadline: nonEmptyHeader(headers, "ce-vqsvisibilitydeadline"),
|
|
167
|
+
handoffCount: parseHandoffCount(header(headers, "ce-vqsexperimentalhandoffcount")),
|
|
168
|
+
};
|
|
169
|
+
return { ...base, inline: Option.some(inline) } satisfies ParsedCallback;
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const UnknownFromJson = Schema.fromJsonString(Schema.Unknown);
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Parses a push delivery like `@vercel/queue`'s `parseCallback`: binary mode
|
|
176
|
+
* (`com.vercel.queue.v2beta`, metadata in `ce-*` headers) or structured mode (JSON CloudEvent).
|
|
177
|
+
*/
|
|
178
|
+
export const parseCallback = (
|
|
179
|
+
headers: Headers.Headers,
|
|
180
|
+
text: Effect.Effect<string, unknown>,
|
|
181
|
+
): Effect.Effect<ParsedCallback, MalformedCloudEvent> => {
|
|
182
|
+
if (headers["ce-type"] === CLOUD_EVENT_TYPE_V2BETA) return parseBinary(headers);
|
|
183
|
+
return text.pipe(
|
|
184
|
+
Effect.flatMap(Schema.decodeUnknownEffect(UnknownFromJson)),
|
|
185
|
+
Effect.catch(() => malformed("Failed to parse CloudEvent from request body")),
|
|
186
|
+
Effect.flatMap((body) => parseStructured(body, header(headers, "content-type"))),
|
|
187
|
+
);
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const parseDate = (value: Option.Option<string>): Option.Option<DateTime.Utc> =>
|
|
191
|
+
Option.flatMap(value, (raw) => DateTime.make(raw)).pipe(Option.map(DateTime.toUtc));
|
|
192
|
+
|
|
193
|
+
/** Builds the leased message a binary-mode delivery carries inline. */
|
|
194
|
+
export const inlineDelivery = Effect.fnUntraced(function* (
|
|
195
|
+
parsed: CallbackRoute,
|
|
196
|
+
inline: InlineLease,
|
|
197
|
+
region: VercelRegion,
|
|
198
|
+
defaultContentType: string,
|
|
199
|
+
body: Uint8Array,
|
|
200
|
+
) {
|
|
201
|
+
const now = yield* DateTime.now;
|
|
202
|
+
const createdAt = Option.getOrElse(parseDate(inline.createdAt), () => now);
|
|
203
|
+
const deliveryCount = Option.getOrElse(
|
|
204
|
+
Option.filter(
|
|
205
|
+
Option.map(inline.deliveryCount, (raw) => Number.parseInt(raw, 10)),
|
|
206
|
+
(count) => !Number.isNaN(count),
|
|
207
|
+
),
|
|
208
|
+
() => 1,
|
|
209
|
+
);
|
|
210
|
+
const handle = new MessageHandle({
|
|
211
|
+
topic: parsed.topic,
|
|
212
|
+
consumerGroup: parsed.consumerGroup,
|
|
213
|
+
messageId: parsed.messageId,
|
|
214
|
+
receiptHandle: inline.receiptHandle,
|
|
215
|
+
region,
|
|
216
|
+
});
|
|
217
|
+
const metadata = new MessageMetadata({
|
|
218
|
+
messageId: parsed.messageId,
|
|
219
|
+
deliveryCount,
|
|
220
|
+
createdAt,
|
|
221
|
+
expiresAt: Option.getOrElse(parseDate(inline.expiresAt), () =>
|
|
222
|
+
DateTime.addDuration(createdAt, DEFAULT_RETENTION),
|
|
223
|
+
),
|
|
224
|
+
topic: parsed.topic,
|
|
225
|
+
consumerGroup: parsed.consumerGroup,
|
|
226
|
+
region,
|
|
227
|
+
...Option.match(inline.handoffCount, {
|
|
228
|
+
onNone: () => ({}),
|
|
229
|
+
onSome: (experimentalHandoffCount) => ({ experimentalHandoffCount }),
|
|
230
|
+
}),
|
|
231
|
+
});
|
|
232
|
+
const deadline: LeaseDeadline = Option.map(inline.visibilityDeadline, (raw) =>
|
|
233
|
+
Option.getOrElse(parseDate(Option.some(raw)), () => "expired" as const),
|
|
234
|
+
);
|
|
235
|
+
return {
|
|
236
|
+
message: new ReceivedMessage({
|
|
237
|
+
handle,
|
|
238
|
+
metadata,
|
|
239
|
+
contentType: Option.getOrElse(inline.contentType, () => defaultContentType),
|
|
240
|
+
body,
|
|
241
|
+
}),
|
|
242
|
+
deadline,
|
|
243
|
+
};
|
|
244
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { DateTime, Scope } from "effect";
|
|
2
|
+
|
|
3
|
+
import { Clock, Duration, Effect, Option } from "effect";
|
|
4
|
+
|
|
5
|
+
import type { QueueApi } from "../contracts/queue-api.ts";
|
|
6
|
+
import type { MessageHandle } from "../contracts/queue-domain.ts";
|
|
7
|
+
|
|
8
|
+
const DEFAULT_VISIBILITY_TIMEOUT = Duration.minutes(5);
|
|
9
|
+
const MIN_VISIBILITY_TIMEOUT = Duration.seconds(30);
|
|
10
|
+
const MIN_RENEWAL_INTERVAL = Duration.seconds(10);
|
|
11
|
+
const MAX_RENEWAL_INTERVAL = Duration.seconds(60);
|
|
12
|
+
const RENEWAL_RETRY_INTERVAL = Duration.seconds(3);
|
|
13
|
+
|
|
14
|
+
export const leaseVisibilityTimeout = (input: Duration.Input | undefined): Duration.Duration =>
|
|
15
|
+
Duration.max(
|
|
16
|
+
MIN_VISIBILITY_TIMEOUT,
|
|
17
|
+
input === undefined ? DEFAULT_VISIBILITY_TIMEOUT : Duration.fromInputUnsafe(input),
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
export const renewalInterval = (timeout: Duration.Duration): Duration.Duration =>
|
|
21
|
+
Duration.clamp(Duration.millis(Duration.toMillis(timeout) / 5), {
|
|
22
|
+
minimum: MIN_RENEWAL_INTERVAL,
|
|
23
|
+
maximum: MAX_RENEWAL_INTERVAL,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
export type LeaseDeadline = Option.Option<DateTime.Utc | "expired">;
|
|
27
|
+
|
|
28
|
+
const firstRenewalDelay = Effect.fnUntraced(function* (
|
|
29
|
+
timeout: Duration.Duration,
|
|
30
|
+
deadline: LeaseDeadline,
|
|
31
|
+
) {
|
|
32
|
+
if (Option.isNone(deadline)) return renewalInterval(timeout);
|
|
33
|
+
if (deadline.value === "expired") return Duration.zero;
|
|
34
|
+
const now = yield* Clock.currentTimeMillis;
|
|
35
|
+
const remaining = deadline.value.epochMilliseconds - now;
|
|
36
|
+
return remaining > 0 ? renewalInterval(Duration.millis(remaining)) : Duration.zero;
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Keeps a lease alive until the enclosing scope closes. Transient failures retry every 3 seconds;
|
|
41
|
+
* a lost or rejected lease (400, 401, 403, 404, 409) ends renewal.
|
|
42
|
+
*/
|
|
43
|
+
export const keepLeaseAlive = Effect.fnUntraced(
|
|
44
|
+
function* (
|
|
45
|
+
api: QueueApi["Service"],
|
|
46
|
+
handle: MessageHandle,
|
|
47
|
+
timeout: Duration.Duration,
|
|
48
|
+
deadline: LeaseDeadline,
|
|
49
|
+
): Effect.fn.Return<void, never, Scope.Scope> {
|
|
50
|
+
const interval = renewalInterval(timeout);
|
|
51
|
+
const stop = (error: unknown) =>
|
|
52
|
+
Effect.logError("Lease renewal failed with a client error; stopping renewal", error).pipe(
|
|
53
|
+
Effect.as(Option.none<Duration.Duration>()),
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
const renew = api.changeVisibility(handle, timeout).pipe(
|
|
57
|
+
Effect.as(Option.some(interval)),
|
|
58
|
+
Effect.catchTags({ MessageNotFound: stop, MessageNotAvailable: stop }),
|
|
59
|
+
Effect.catchReasons("VercelApiError", {
|
|
60
|
+
ApiBadRequest: (_, error) => stop(error),
|
|
61
|
+
ApiUnauthorized: (_, error) => stop(error),
|
|
62
|
+
ApiForbidden: (_, error) => stop(error),
|
|
63
|
+
}),
|
|
64
|
+
Effect.catch((error) =>
|
|
65
|
+
Effect.logError("Lease renewal failed; retrying in 3 seconds", error).pipe(
|
|
66
|
+
Effect.as(Option.some(RENEWAL_RETRY_INTERVAL)),
|
|
67
|
+
),
|
|
68
|
+
),
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
const loop = Effect.fnUntraced(function* (initialDelay: Duration.Duration) {
|
|
72
|
+
let delay: Option.Option<Duration.Duration> = Option.some(initialDelay);
|
|
73
|
+
while (Option.isSome(delay)) {
|
|
74
|
+
yield* Effect.sleep(delay.value);
|
|
75
|
+
delay = yield* renew;
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
yield* Effect.forkScoped(loop(yield* firstRenewalDelay(timeout, deadline)), {
|
|
80
|
+
startImmediately: true,
|
|
81
|
+
});
|
|
82
|
+
},
|
|
83
|
+
Effect.annotateLogs({ module: "QueueLease" }),
|
|
84
|
+
);
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { Data, Effect, Option, Stream } from "effect";
|
|
2
|
+
|
|
3
|
+
export interface MultipartPart {
|
|
4
|
+
/** Header names in lower case; repeated headers are joined with `, `. */
|
|
5
|
+
readonly headers: Readonly<Record<string, string>>;
|
|
6
|
+
readonly body: Uint8Array;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface OutgoingPart {
|
|
10
|
+
readonly headers: ReadonlyArray<readonly [name: string, value: string]>;
|
|
11
|
+
readonly body: Uint8Array;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class MultipartMixedError extends Data.TaggedError("MultipartMixedError")<{
|
|
15
|
+
readonly message: string;
|
|
16
|
+
}> {}
|
|
17
|
+
|
|
18
|
+
const maxHeaderSize = 65_536;
|
|
19
|
+
|
|
20
|
+
const encoder = new TextEncoder();
|
|
21
|
+
const latin1 = new TextDecoder("latin1");
|
|
22
|
+
|
|
23
|
+
export const boundaryFromContentType = (contentType: string): Option.Option<string> => {
|
|
24
|
+
const match = /boundary=(?:"([^"]+)"|([^;]+))/i.exec(contentType);
|
|
25
|
+
return Option.fromNullishOr(match?.[1] ?? match?.[2]);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const concatBytes = (chunks: ReadonlyArray<Uint8Array>): Uint8Array => {
|
|
29
|
+
const out = new Uint8Array(chunks.reduce((total, chunk) => total + chunk.length, 0));
|
|
30
|
+
let offset = 0;
|
|
31
|
+
for (const chunk of chunks) {
|
|
32
|
+
out.set(chunk, offset);
|
|
33
|
+
offset += chunk.length;
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const encodeMultipartMixed = (
|
|
39
|
+
boundary: string,
|
|
40
|
+
parts: ReadonlyArray<OutgoingPart>,
|
|
41
|
+
): Uint8Array =>
|
|
42
|
+
concatBytes([
|
|
43
|
+
...parts.flatMap((part) => [
|
|
44
|
+
encoder.encode(
|
|
45
|
+
`--${boundary}\r\n${part.headers.map(([name, value]) => `${name}: ${value}\r\n`).join("")}\r\n`,
|
|
46
|
+
),
|
|
47
|
+
part.body,
|
|
48
|
+
encoder.encode("\r\n"),
|
|
49
|
+
]),
|
|
50
|
+
encoder.encode(`--${boundary}--\r\n`),
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
const indexOf = (haystack: Uint8Array, needle: Uint8Array, from: number): number => {
|
|
54
|
+
const last = haystack.length - needle.length;
|
|
55
|
+
const first = needle[0];
|
|
56
|
+
outer: for (let i = Math.max(0, from); i <= last; i++) {
|
|
57
|
+
if (haystack[i] !== first) continue;
|
|
58
|
+
for (let j = 1; j < needle.length; j++) {
|
|
59
|
+
if (haystack[i + j] !== needle[j]) continue outer;
|
|
60
|
+
}
|
|
61
|
+
return i;
|
|
62
|
+
}
|
|
63
|
+
return -1;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const startsWith = (haystack: Uint8Array, needle: Uint8Array): boolean =>
|
|
67
|
+
haystack.length >= needle.length && indexOf(haystack.subarray(0, needle.length), needle, 0) === 0;
|
|
68
|
+
|
|
69
|
+
const parseHeaders = (bytes: Uint8Array): Record<string, string> => {
|
|
70
|
+
const headers: Record<string, string> = {};
|
|
71
|
+
for (const line of latin1.decode(bytes).trim().split(/\r?\n/)) {
|
|
72
|
+
const colon = line.indexOf(":");
|
|
73
|
+
if (colon <= 0) continue;
|
|
74
|
+
const name = line.slice(0, colon).trim().toLowerCase();
|
|
75
|
+
const value = line.slice(colon + 1).trim();
|
|
76
|
+
headers[name] = name in headers ? `${headers[name]}, ${value}` : value;
|
|
77
|
+
}
|
|
78
|
+
return headers;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const crlfcrlf = encoder.encode("\r\n\r\n");
|
|
82
|
+
const lflf = encoder.encode("\n\n");
|
|
83
|
+
|
|
84
|
+
type State = "start" | "afterBoundary" | "headers" | "body" | "done";
|
|
85
|
+
|
|
86
|
+
const growableBuffer = () => {
|
|
87
|
+
let storage = new Uint8Array(0);
|
|
88
|
+
let start = 0;
|
|
89
|
+
let end = 0;
|
|
90
|
+
return {
|
|
91
|
+
view: () => storage.subarray(start, end),
|
|
92
|
+
append: (chunk: Uint8Array) => {
|
|
93
|
+
const length = end - start;
|
|
94
|
+
if (end + chunk.length > storage.length) {
|
|
95
|
+
const next = new Uint8Array(Math.max(storage.length * 2, length + chunk.length, 1024));
|
|
96
|
+
next.set(storage.subarray(start, end));
|
|
97
|
+
storage = next;
|
|
98
|
+
start = 0;
|
|
99
|
+
end = length;
|
|
100
|
+
}
|
|
101
|
+
storage.set(chunk, end);
|
|
102
|
+
end += chunk.length;
|
|
103
|
+
},
|
|
104
|
+
consume: (count: number) => {
|
|
105
|
+
start += count;
|
|
106
|
+
if (start === end) {
|
|
107
|
+
start = 0;
|
|
108
|
+
end = 0;
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
clear: () => {
|
|
112
|
+
start = 0;
|
|
113
|
+
end = 0;
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const mixedParser = (boundary: string) => {
|
|
119
|
+
const opening = encoder.encode(`--${boundary}`);
|
|
120
|
+
const delimiter = encoder.encode(`\r\n--${boundary}`);
|
|
121
|
+
const buffer = growableBuffer();
|
|
122
|
+
let state: State = "start";
|
|
123
|
+
let searchFrom = 0;
|
|
124
|
+
let headers: Record<string, string> = {};
|
|
125
|
+
|
|
126
|
+
const failure = (message: string) => Effect.fail(new MultipartMixedError({ message }));
|
|
127
|
+
|
|
128
|
+
const step = (parts: Array<MultipartPart>): Option.Option<string> | "wait" => {
|
|
129
|
+
const bytes = buffer.view();
|
|
130
|
+
switch (state) {
|
|
131
|
+
case "start": {
|
|
132
|
+
if (bytes.length < opening.length) return "wait";
|
|
133
|
+
if (!startsWith(bytes, opening)) {
|
|
134
|
+
return Option.some("Invalid multipart stream: missing initial boundary");
|
|
135
|
+
}
|
|
136
|
+
buffer.consume(opening.length);
|
|
137
|
+
state = "afterBoundary";
|
|
138
|
+
return Option.none();
|
|
139
|
+
}
|
|
140
|
+
case "afterBoundary": {
|
|
141
|
+
if (bytes.length < 2) return "wait";
|
|
142
|
+
if (bytes[0] === 45 && bytes[1] === 45) {
|
|
143
|
+
state = "done";
|
|
144
|
+
buffer.clear();
|
|
145
|
+
return "wait";
|
|
146
|
+
}
|
|
147
|
+
if (bytes[0] === 13 && bytes[1] === 10) {
|
|
148
|
+
buffer.consume(2);
|
|
149
|
+
} else if (bytes[0] === 10) {
|
|
150
|
+
buffer.consume(1);
|
|
151
|
+
} else {
|
|
152
|
+
return Option.some("Invalid character after multipart boundary");
|
|
153
|
+
}
|
|
154
|
+
state = "headers";
|
|
155
|
+
return Option.none();
|
|
156
|
+
}
|
|
157
|
+
case "headers": {
|
|
158
|
+
let end = -1;
|
|
159
|
+
let skip = 0;
|
|
160
|
+
if (bytes[0] === 13 && bytes[1] === 10) {
|
|
161
|
+
end = 0;
|
|
162
|
+
skip = 2;
|
|
163
|
+
} else if (bytes[0] === 10) {
|
|
164
|
+
end = 0;
|
|
165
|
+
skip = 1;
|
|
166
|
+
} else {
|
|
167
|
+
const crlf = indexOf(bytes, crlfcrlf, 0);
|
|
168
|
+
const lf = indexOf(bytes, lflf, 0);
|
|
169
|
+
if (crlf !== -1 && (lf === -1 || crlf < lf)) {
|
|
170
|
+
end = crlf;
|
|
171
|
+
skip = 4;
|
|
172
|
+
} else {
|
|
173
|
+
end = lf;
|
|
174
|
+
skip = 2;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (end === -1) {
|
|
178
|
+
return bytes.length > maxHeaderSize
|
|
179
|
+
? Option.some("Multipart part headers are too large")
|
|
180
|
+
: "wait";
|
|
181
|
+
}
|
|
182
|
+
headers = parseHeaders(bytes.subarray(0, end));
|
|
183
|
+
buffer.consume(end + skip);
|
|
184
|
+
searchFrom = 0;
|
|
185
|
+
state = "body";
|
|
186
|
+
return Option.none();
|
|
187
|
+
}
|
|
188
|
+
case "body": {
|
|
189
|
+
const end = indexOf(bytes, delimiter, searchFrom);
|
|
190
|
+
if (end === -1) {
|
|
191
|
+
searchFrom = Math.max(0, bytes.length - delimiter.length + 1);
|
|
192
|
+
return "wait";
|
|
193
|
+
}
|
|
194
|
+
parts.push({ headers, body: bytes.slice(0, end) });
|
|
195
|
+
buffer.consume(end + delimiter.length);
|
|
196
|
+
state = "afterBoundary";
|
|
197
|
+
return Option.none();
|
|
198
|
+
}
|
|
199
|
+
case "done":
|
|
200
|
+
return "wait";
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const push = (chunk: Uint8Array): Effect.Effect<Array<MultipartPart>, MultipartMixedError> => {
|
|
205
|
+
if (state === "done") return Effect.succeed([]);
|
|
206
|
+
buffer.append(chunk);
|
|
207
|
+
const parts: Array<MultipartPart> = [];
|
|
208
|
+
while (true) {
|
|
209
|
+
const result = step(parts);
|
|
210
|
+
if (result === "wait") return Effect.succeed(parts);
|
|
211
|
+
if (Option.isSome(result)) return failure(result.value);
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const finish = Effect.suspend(() => {
|
|
216
|
+
if (state === "done") return Effect.void;
|
|
217
|
+
return failure(
|
|
218
|
+
state === "start"
|
|
219
|
+
? "Invalid multipart stream: missing initial boundary"
|
|
220
|
+
: "Unexpected end of multipart stream",
|
|
221
|
+
);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
return { push, finish };
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
/** Parses a `multipart/mixed` body as it streams in, emitting each part once it is complete. */
|
|
228
|
+
export const decodeMultipartMixed =
|
|
229
|
+
(boundary: string) =>
|
|
230
|
+
<E, R>(
|
|
231
|
+
source: Stream.Stream<Uint8Array, E, R>,
|
|
232
|
+
): Stream.Stream<MultipartPart, E | MultipartMixedError, R> =>
|
|
233
|
+
Stream.suspend(() => {
|
|
234
|
+
const parser = mixedParser(boundary);
|
|
235
|
+
return source.pipe(
|
|
236
|
+
Stream.mapEffect(parser.push),
|
|
237
|
+
Stream.flattenIterable,
|
|
238
|
+
Stream.concat(Stream.fromEffectDrain(parser.finish)),
|
|
239
|
+
);
|
|
240
|
+
});
|