Repository navigation
Expand file tree
/
Copy pathHookInboxObject.ts
More file actions
134 lines (125 loc) · 5.46 KB
/
Copy pathHookInboxObject.ts
File metadata and controls
134 lines (125 loc) · 5.46 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
import * as SqliteClient from "@effect/sql-sqlite-do/SqliteClient";
import type * as Alchemy from "alchemy";
import * as Cloudflare from "alchemy/Cloudflare";
import * as Clock from "effect/Clock";
import * as Effect from "effect/Effect";
import * as Option from "effect/Option";
import * as Result from "effect/Result";
import * as FetchHttpClient from "effect/http/FetchHttpClient";
import * as HookInboxStore from "./HookInboxStore.ts";
import { sendUpstream, TUNNEL_OFFLINE_STATUS } from "./upstream.ts";
const UNREACHABLE_STATUSES = new Set([502, 503, 504, TUNNEL_OFFLINE_STATUS]);
/** Retry queue-full (429) and handling failures (500); the environment deduplicates delivery ids. */
const BUSY_STATUSES = new Set([429, 500]);
const RUN_FAILURE_RETRY_MS = 60_000;
type Call<A> = Effect.Effect<A, never, Alchemy.RuntimeContext>;
export class HookInboxObject extends Cloudflare.DurableObject<
HookInboxObject,
{
/** Holds a request for `baseUrl`; false when the inbox is full and nothing was stored. */
readonly hold: (hook: HookInboxStore.HeldHook, baseUrl: string) => Call<boolean>;
readonly wake: (baseUrl: string) => Call<boolean>;
readonly clear: () => Call<void>;
}
>()("HookInboxObject") {}
const withInboxSpan =
(name: string, inboxId: string) =>
<A, E, R>(effect: Effect.Effect<A, E, R>) =>
effect.pipe(
Effect.withSpan(name, { root: true, attributes: { "relay.hook.endpoint_key": inboxId } }),
);
const deliver = (baseUrl: string, hook: HookInboxStore.HeldHook) =>
sendUpstream(baseUrl, hook).pipe(
Effect.result,
Effect.map((result) => {
// Timeouts may have executed; retries reuse delivery IDs for environment deduplication.
if (Result.isFailure(result)) {
return { outcome: "unreachable" as const, reason: result.failure._tag };
}
if (Option.isNone(result.success))
return { outcome: "unreachable" as const, reason: "timeout" };
const status = result.success.value.status;
const upstreamOutcome = result.success.value.outcome;
const outcome: HookInboxStore.DeliveryOutcome = UNREACHABLE_STATUSES.has(status)
? "unreachable"
: BUSY_STATUSES.has(status)
? "busy"
: "delivered";
return { outcome, reason: `status ${status}`, upstreamOutcome };
}),
Effect.tap(({ upstreamOutcome }) =>
upstreamOutcome === undefined
? Effect.void
: Effect.annotateCurrentSpan({ "relay.hook.upstream_outcome": upstreamOutcome }),
),
Effect.tap(({ outcome, reason }) =>
outcome === "delivered"
? Effect.void
: Effect.logInfo("Held webhook not delivered yet", {
outcome,
reason,
deliveryId: hook.id,
}),
),
Effect.map(({ outcome }) => outcome),
Effect.provide(FetchHttpClient.layer),
);
export const layer = HookInboxObject.make(
Effect.gen(function* () {
const state = yield* Cloudflare.DurableObjectState;
// Only the per-object inner Effect may access storage.
return Effect.gen(function* () {
const layerSql = SqliteClient.layer({ storage: state.raw.storage });
const inboxId = state.raw.id.name ?? state.raw.id.toString();
const run = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
effect.pipe(Effect.provide(layerSql), Effect.orDie);
yield* run(HookInboxStore.migrate);
const scheduleBy = Effect.fn("HookInboxObject.scheduleBy")(function* (at: number) {
const current = yield* state.storage.getAlarm();
if (current === null || current > at) yield* state.storage.setAlarm(at);
});
// withInboxSpan supplies each method's root span; avoid a second wrapper span.
return {
hold: Effect.fnUntraced(
function* (hook: HookInboxStore.HeldHook, baseUrl: string) {
const dueAt = yield* run(HookInboxStore.hold(hook, baseUrl));
yield* Effect.annotateCurrentSpan({ "relay.inbox.stored": dueAt !== null });
if (dueAt === null) return false;
yield* scheduleBy(dueAt);
return true;
},
withInboxSpan("relay.inbox.hold", inboxId),
),
wake: Effect.fnUntraced(
function* (baseUrl: string) {
const pending = yield* run(HookInboxStore.wake(baseUrl));
yield* Effect.annotateCurrentSpan({ "relay.inbox.pending": pending });
if (pending) yield* state.storage.setAlarm(yield* Clock.currentTimeMillis);
return pending;
},
withInboxSpan("relay.inbox.wake", inboxId),
),
clear: Effect.fnUntraced(
function* () {
yield* run(HookInboxStore.clear);
yield* state.storage.deleteAlarm();
},
withInboxSpan("relay.inbox.clear", inboxId),
),
alarm: () =>
run(HookInboxStore.deliverDue(deliver)).pipe(
// Backoff must not delay an earlier run requested by a concurrent wake.
Effect.flatMap((nextAt) => (nextAt === null ? Effect.void : scheduleBy(nextAt))),
Effect.catchCause((cause) =>
Effect.logWarning("Held webhook delivery run failed", { cause }).pipe(
Effect.andThen(Effect.annotateCurrentSpan({ "relay.inbox.run_result": "failed" })),
Effect.andThen(Clock.currentTimeMillis),
Effect.flatMap((now) => scheduleBy(now + RUN_FAILURE_RETRY_MS)),
),
),
withInboxSpan("relay.inbox.deliver", inboxId),
),
};
});
}),
);