@fluid-app/droplet-sdk 0.2.70 → 0.2.71
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +86 -6
- package/dist/health.d.mts +22 -0
- package/dist/health.mjs +28 -0
- package/dist/index.d.mts +3 -1
- package/dist/index.mjs +3 -1
- package/dist/installation.d.mts +106 -0
- package/dist/installation.mjs +122 -0
- package/dist/next/reconcile.d.mts +75 -0
- package/dist/next/reconcile.mjs +212 -0
- package/dist/next.d.mts +2 -1
- package/dist/next.mjs +2 -1
- package/dist/redact.mjs +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -167,14 +167,94 @@ resolve: async ({ dri }) => {
|
|
|
167
167
|
},
|
|
168
168
|
```
|
|
169
169
|
|
|
170
|
+
## Reading status and receiving reconcile events
|
|
171
|
+
|
|
172
|
+
```ts
|
|
173
|
+
import { createFluidInstallationClient } from "@fluid-app/droplet-sdk";
|
|
174
|
+
|
|
175
|
+
const client = createFluidInstallationClient({ installToken, baseUrl });
|
|
176
|
+
const result = await client.getInstallationStatus();
|
|
177
|
+
if (result.ok) console.log(result.status.droplet_installation_status);
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
```ts
|
|
181
|
+
import {
|
|
182
|
+
createInMemoryReconcileDedupe,
|
|
183
|
+
withFluidReconcileEvents,
|
|
184
|
+
} from "@fluid-app/droplet-sdk/next";
|
|
185
|
+
export const POST = withFluidReconcileEvents(
|
|
186
|
+
async (event) => {
|
|
187
|
+
await handleReconcile(event);
|
|
188
|
+
},
|
|
189
|
+
{ secret: webhookSecret, dedupe: createInMemoryReconcileDedupe() },
|
|
190
|
+
);
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Use the droplet's own webhook secret. `dedupe` is required; there is no default
|
|
194
|
+
store. The explicit in-memory store above is per-process and suitable for a
|
|
195
|
+
single instance. Across instances, a shared store implementing atomic
|
|
196
|
+
`claim`, `complete` and `release` is required (for example Redis `SET NX PX`
|
|
197
|
+
with atomic token checks for completion and release).
|
|
198
|
+
The claim methods may return synchronously or as promises:
|
|
199
|
+
|
|
200
|
+
```ts
|
|
201
|
+
type ReconcileClaim =
|
|
202
|
+
| { status: "claimed"; token: string }
|
|
203
|
+
| { status: "in_flight" }
|
|
204
|
+
| { status: "done" };
|
|
205
|
+
|
|
206
|
+
claim(id: string, ttlMs: number): ReconcileClaim | Promise<ReconcileClaim>;
|
|
207
|
+
complete(id: string, token: string, ttlMs: number): void | Promise<void>;
|
|
208
|
+
release(id: string, token: string): void | Promise<void>;
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
`claim` must atomically reserve an available id with a fresh token. Its `ttlMs`
|
|
212
|
+
is the in-flight lease only: `Math.min(inFlightTtlMs, dedupeTtlMs)`.
|
|
213
|
+
The wrapper's `inFlightTtlMs` defaults to 60,000 ms and must be positive and finite; keep it under about 10 minutes, the span of Fluid's
|
|
214
|
+
retries, or a crashed instance's lease can outlast every retry. `dedupeTtlMs` must
|
|
215
|
+
also be positive and finite.
|
|
216
|
+
An active
|
|
217
|
+
claim returns `in_flight`: the wrapper responds 503 (a 4xx would be recorded by Fluid as a permanent rejection) `{ error: "in flight" }`
|
|
218
|
+
without calling the handler, so Fluid retries later. A completed delivery returns
|
|
219
|
+
`done`: the wrapper responds 200 `{ ok: true, duplicate: true }`.
|
|
220
|
+
A throwing claim returns a retryable 500 without running the handler.
|
|
221
|
+
|
|
222
|
+
Implement `complete` and `release` alongside `claim`. After an ok handler response,
|
|
223
|
+
`complete(id, token, ttlMs)` receives `dedupeTtlMs` for done retention from
|
|
224
|
+
completion (default 86,400,000 ms). On a throw
|
|
225
|
+
or non-ok response, `release(id, token)` frees the claim so a retry can run.
|
|
226
|
+
Both operations must atomically check that the token still owns an unexpired
|
|
227
|
+
claim; otherwise they do nothing. After expiry, a new delivery can claim the id
|
|
228
|
+
with a new token, and the old handler cannot complete or release its claim.
|
|
229
|
+
Completion and release failures are logged with the event id only and preserve
|
|
230
|
+
the handler's response (or 500 for a thrown handler).
|
|
231
|
+
|
|
232
|
+
The explicit in-memory store implements all three operations. It also caps leases
|
|
233
|
+
by its own `inFlightTtlMs` option (default 60,000 ms). For longer handlers, set
|
|
234
|
+
`inFlightTtlMs` on both the wrapper and `createInMemoryReconcileDedupe`.
|
|
235
|
+
|
|
236
|
+
After signature verification and JSON parsing, unknown `reconcile.*` event kinds
|
|
237
|
+
return 200 `{ ok: true, ignored: true }` without claiming or calling the handler.
|
|
238
|
+
Only the event name is logged. Non-reconcile or missing event names still return
|
|
239
|
+
400 for invalid payloads.
|
|
240
|
+
|
|
241
|
+
Only atomic stores are accepted: `seen`/`mark` stores are not supported, because
|
|
242
|
+
they cannot exclude concurrent deliveries of one event id. A shared atomic store
|
|
243
|
+
(for example Redis or a database row with a unique id) is required for
|
|
244
|
+
multi-instance deployments; `createInMemoryReconcileDedupe()` is per process and
|
|
245
|
+
suits a single instance only. Keep handlers idempotent, including for retries after
|
|
246
|
+
TTL expiry or bounded in-memory eviction.
|
|
247
|
+
Bodies are limited to 1,048,576 bytes before signature verification; configure
|
|
248
|
+
`maxBodyBytes` to change this limit.
|
|
249
|
+
|
|
170
250
|
## Exports
|
|
171
251
|
|
|
172
|
-
| Subpath | Contents
|
|
173
|
-
| ---------- |
|
|
174
|
-
| `.` | `verifySignature`, `tokenDigest`, `backfillCallbackTokens`, `describeError`, store types
|
|
175
|
-
| `/next` | `withFluidCallback`, `withFluidWebhook`
|
|
176
|
-
| `/prisma` | `createPrismaCallbackStore`
|
|
177
|
-
| `/drizzle` | `createDrizzleCallbackStore` — structurally typed, takes `eq` injected, so this package never depends on `drizzle-orm`
|
|
252
|
+
| Subpath | Contents |
|
|
253
|
+
| ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
254
|
+
| `.` | `verifySignature`, `tokenDigest`, `backfillCallbackTokens`, `describeError`, `createFluidInstallationClient`, status schemas/types, health helpers, store types |
|
|
255
|
+
| `/next` | `withFluidCallback`, `withFluidWebhook`, `withFluidReconcileEvents`, `createInMemoryReconcileDedupe`, reconcile schemas/types |
|
|
256
|
+
| `/prisma` | `createPrismaCallbackStore` |
|
|
257
|
+
| `/drizzle` | `createDrizzleCallbackStore` — structurally typed, takes `eq` injected, so this package never depends on `drizzle-orm` |
|
|
178
258
|
|
|
179
259
|
## License
|
|
180
260
|
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Logger } from "./redact.mjs";
|
|
2
|
+
import { InstallationHealth } from "./installation.mjs";
|
|
3
|
+
|
|
4
|
+
//#region src/health.d.ts
|
|
5
|
+
/** Describes changes to the health verdict and signals. */
|
|
6
|
+
declare function describeHealthTransition(previous: InstallationHealth, current: InstallationHealth): {
|
|
7
|
+
from: string;
|
|
8
|
+
to: string;
|
|
9
|
+
changedSignals: (keyof InstallationHealth["signals"])[];
|
|
10
|
+
} | null;
|
|
11
|
+
/** Logs a redacted health transition at its severity. */
|
|
12
|
+
declare function logHealthTransition({
|
|
13
|
+
previous,
|
|
14
|
+
current,
|
|
15
|
+
logger
|
|
16
|
+
}: {
|
|
17
|
+
previous: InstallationHealth;
|
|
18
|
+
current: InstallationHealth;
|
|
19
|
+
logger?: Logger;
|
|
20
|
+
}): void;
|
|
21
|
+
//#endregion
|
|
22
|
+
export { describeHealthTransition, logHealthTransition };
|
package/dist/health.mjs
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { consoleLogger, redactValue } from "./redact.mjs";
|
|
2
|
+
//#region src/health.ts
|
|
3
|
+
function describeHealthTransition(previous, current) {
|
|
4
|
+
const changedSignals = Object.keys(current.signals).filter((key) => JSON.stringify(previous.signals[key]) !== JSON.stringify(current.signals[key]));
|
|
5
|
+
return previous.verdict === current.verdict && changedSignals.length === 0 ? null : {
|
|
6
|
+
from: previous.verdict,
|
|
7
|
+
to: current.verdict,
|
|
8
|
+
changedSignals
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
function logHealthTransition({ previous, current, logger = consoleLogger }) {
|
|
12
|
+
const transition = describeHealthTransition(previous, current);
|
|
13
|
+
if (!transition) return;
|
|
14
|
+
const severity = {
|
|
15
|
+
unknown: 2,
|
|
16
|
+
idle: 2,
|
|
17
|
+
healthy: 2,
|
|
18
|
+
degraded: 3,
|
|
19
|
+
failing: 4
|
|
20
|
+
};
|
|
21
|
+
logger[(severity[current.verdict] ?? 1) > (severity[previous.verdict] ?? 1) ? "warn" : "info"]("[fluid-health] health changed", redactValue({
|
|
22
|
+
...transition,
|
|
23
|
+
previous,
|
|
24
|
+
current
|
|
25
|
+
}));
|
|
26
|
+
}
|
|
27
|
+
//#endregion
|
|
28
|
+
export { describeHealthTransition, logHealthTransition };
|
package/dist/index.d.mts
CHANGED
|
@@ -3,4 +3,6 @@ import { MAX_SIGNATURE_AGE_SECONDS, SignatureFailureReason, SignatureResult, Ver
|
|
|
3
3
|
import { Logger, consoleLogger, describeError, redactValue } from "./redact.mjs";
|
|
4
4
|
import { BackfillResult, CallbackListingClient, backfillCallbackTokens } from "./backfill.mjs";
|
|
5
5
|
import { CallbackVerificationReadiness, reportCallbackVerificationReadiness } from "./readiness.mjs";
|
|
6
|
-
|
|
6
|
+
import { FluidInstallationClient, FluidInstallationClientOptions, InstallationHealth, InstallationStatusResult, ParsedStatus, ReconcileResource, createFluidInstallationClient, installationHealthSchema, installationStatusSchema, reconcileResourceSchema } from "./installation.mjs";
|
|
7
|
+
import { describeHealthTransition, logHealthTransition } from "./health.mjs";
|
|
8
|
+
export { type BackfillResult, type CallbackListingClient, type CallbackTokenStore, type CallbackVerificationReadiness, FluidInstallationClient, FluidInstallationClientOptions, InstallationHealth, InstallationStatusResult, type Logger, MAX_SIGNATURE_AGE_SECONDS, ParsedStatus, ReconcileResource, type SignatureFailureReason, type SignatureResult, type StoredRegistration, type VerifySignatureInput, backfillCallbackTokens, consoleLogger, createFluidInstallationClient, describeError, describeHealthTransition, installationHealthSchema, installationStatusSchema, logHealthTransition, reconcileResourceSchema, redactValue, reportCallbackVerificationReadiness, tokenDigest, verifySignature };
|
package/dist/index.mjs
CHANGED
|
@@ -2,4 +2,6 @@ import { MAX_SIGNATURE_AGE_SECONDS, tokenDigest, verifySignature } from "./signa
|
|
|
2
2
|
import { consoleLogger, describeError, redactValue } from "./redact.mjs";
|
|
3
3
|
import { backfillCallbackTokens } from "./backfill.mjs";
|
|
4
4
|
import { reportCallbackVerificationReadiness } from "./readiness.mjs";
|
|
5
|
-
|
|
5
|
+
import { createFluidInstallationClient, installationHealthSchema, installationStatusSchema, reconcileResourceSchema } from "./installation.mjs";
|
|
6
|
+
import { describeHealthTransition, logHealthTransition } from "./health.mjs";
|
|
7
|
+
export { MAX_SIGNATURE_AGE_SECONDS, backfillCallbackTokens, consoleLogger, createFluidInstallationClient, describeError, describeHealthTransition, installationHealthSchema, installationStatusSchema, logHealthTransition, reconcileResourceSchema, redactValue, reportCallbackVerificationReadiness, tokenDigest, verifySignature };
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
//#region src/installation.d.ts
|
|
4
|
+
declare const installationHealthSchema: z.ZodType<InstallationHealth>;
|
|
5
|
+
declare const reconcileResourceSchema: z.ZodType<ReconcileResource>;
|
|
6
|
+
declare const installationStatusSchema: z.ZodType<ParsedStatus>;
|
|
7
|
+
type StatusSection<T> = {
|
|
8
|
+
available: boolean;
|
|
9
|
+
error: {
|
|
10
|
+
type: string;
|
|
11
|
+
description: string;
|
|
12
|
+
} | null;
|
|
13
|
+
data: T | null;
|
|
14
|
+
};
|
|
15
|
+
type StateSignal = {
|
|
16
|
+
state: string;
|
|
17
|
+
observed_at: string | null;
|
|
18
|
+
};
|
|
19
|
+
type InstallationHealth = {
|
|
20
|
+
verdict: string;
|
|
21
|
+
signals: {
|
|
22
|
+
callback: {
|
|
23
|
+
verdict: string;
|
|
24
|
+
};
|
|
25
|
+
breaker: StateSignal;
|
|
26
|
+
lifecycle: StateSignal;
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
type ReconcileResource = {
|
|
30
|
+
kind: string;
|
|
31
|
+
key: string;
|
|
32
|
+
status: string;
|
|
33
|
+
error_code: string | null;
|
|
34
|
+
};
|
|
35
|
+
type ParsedStatus = {
|
|
36
|
+
droplet_installation_status: {
|
|
37
|
+
generated_at: string;
|
|
38
|
+
sections: {
|
|
39
|
+
install_status: StatusSection<{
|
|
40
|
+
status: string;
|
|
41
|
+
delivery_state: string | null;
|
|
42
|
+
}>;
|
|
43
|
+
lifecycle: StatusSection<{
|
|
44
|
+
deliveries: {
|
|
45
|
+
[key: string]: unknown;
|
|
46
|
+
event: string;
|
|
47
|
+
trigger: string;
|
|
48
|
+
state: string;
|
|
49
|
+
attempts: number;
|
|
50
|
+
last_status: number | string | null;
|
|
51
|
+
last_error_category?: string | null;
|
|
52
|
+
created_at?: string;
|
|
53
|
+
updated_at?: string;
|
|
54
|
+
}[];
|
|
55
|
+
}>;
|
|
56
|
+
health: StatusSection<InstallationHealth>;
|
|
57
|
+
reconcile: StatusSection<{
|
|
58
|
+
pinned_version: number | null;
|
|
59
|
+
target_version: number | null;
|
|
60
|
+
state: string;
|
|
61
|
+
resources: ReconcileResource[];
|
|
62
|
+
truncated: boolean;
|
|
63
|
+
grants: {
|
|
64
|
+
count: number;
|
|
65
|
+
};
|
|
66
|
+
pending: {
|
|
67
|
+
count: number;
|
|
68
|
+
};
|
|
69
|
+
}>;
|
|
70
|
+
};
|
|
71
|
+
};
|
|
72
|
+
status: 200;
|
|
73
|
+
meta: {
|
|
74
|
+
request_uuid: string | null;
|
|
75
|
+
timestamp: string;
|
|
76
|
+
};
|
|
77
|
+
};
|
|
78
|
+
type InstallationStatusResult = {
|
|
79
|
+
ok: true;
|
|
80
|
+
status: ParsedStatus;
|
|
81
|
+
} | {
|
|
82
|
+
ok: false;
|
|
83
|
+
error: {
|
|
84
|
+
code: "unauthorized" | "forbidden" | "invalid_response" | "http_error" | "network" | "timeout";
|
|
85
|
+
httpStatus?: number;
|
|
86
|
+
message: string;
|
|
87
|
+
};
|
|
88
|
+
};
|
|
89
|
+
interface FluidInstallationClientOptions {
|
|
90
|
+
installToken: string;
|
|
91
|
+
baseUrl: string;
|
|
92
|
+
fetch?: typeof fetch;
|
|
93
|
+
timeoutMs?: number;
|
|
94
|
+
}
|
|
95
|
+
interface FluidInstallationClient {
|
|
96
|
+
getInstallationStatus(): Promise<InstallationStatusResult>;
|
|
97
|
+
}
|
|
98
|
+
/** Creates a client for the calling installation's status. */
|
|
99
|
+
declare function createFluidInstallationClient({
|
|
100
|
+
installToken,
|
|
101
|
+
baseUrl,
|
|
102
|
+
fetch: fetcher,
|
|
103
|
+
timeoutMs
|
|
104
|
+
}: FluidInstallationClientOptions): FluidInstallationClient;
|
|
105
|
+
//#endregion
|
|
106
|
+
export { FluidInstallationClient, FluidInstallationClientOptions, InstallationHealth, InstallationStatusResult, ParsedStatus, ReconcileResource, createFluidInstallationClient, installationHealthSchema, installationStatusSchema, reconcileResourceSchema };
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
//#region src/installation.ts
|
|
3
|
+
const section = (data) => z.object({
|
|
4
|
+
available: z.boolean(),
|
|
5
|
+
error: z.object({
|
|
6
|
+
type: z.string(),
|
|
7
|
+
description: z.string()
|
|
8
|
+
}).nullable(),
|
|
9
|
+
data: data.nullable()
|
|
10
|
+
});
|
|
11
|
+
const stateSignal = z.object({
|
|
12
|
+
state: z.string(),
|
|
13
|
+
observed_at: z.string().nullable()
|
|
14
|
+
});
|
|
15
|
+
const installationHealthSchema = z.object({
|
|
16
|
+
verdict: z.string(),
|
|
17
|
+
signals: z.object({
|
|
18
|
+
callback: z.object({ verdict: z.string() }),
|
|
19
|
+
breaker: stateSignal,
|
|
20
|
+
lifecycle: stateSignal
|
|
21
|
+
})
|
|
22
|
+
});
|
|
23
|
+
const reconcileResourceSchema = z.object({
|
|
24
|
+
kind: z.string(),
|
|
25
|
+
key: z.string(),
|
|
26
|
+
status: z.string(),
|
|
27
|
+
error_code: z.string().nullable()
|
|
28
|
+
});
|
|
29
|
+
const installationStatusSchema = z.object({
|
|
30
|
+
droplet_installation_status: z.object({
|
|
31
|
+
generated_at: z.string(),
|
|
32
|
+
sections: z.object({
|
|
33
|
+
install_status: section(z.object({
|
|
34
|
+
status: z.string(),
|
|
35
|
+
delivery_state: z.string().nullable()
|
|
36
|
+
})),
|
|
37
|
+
lifecycle: section(z.object({ deliveries: z.array(z.looseObject({
|
|
38
|
+
event: z.string(),
|
|
39
|
+
trigger: z.string(),
|
|
40
|
+
state: z.string(),
|
|
41
|
+
attempts: z.number().int(),
|
|
42
|
+
last_status: z.union([z.number().int(), z.string()]).nullable(),
|
|
43
|
+
last_error_category: z.string().nullable().optional(),
|
|
44
|
+
created_at: z.string().optional(),
|
|
45
|
+
updated_at: z.string().optional()
|
|
46
|
+
})) })),
|
|
47
|
+
health: section(installationHealthSchema),
|
|
48
|
+
reconcile: section(z.object({
|
|
49
|
+
pinned_version: z.number().int().nullable(),
|
|
50
|
+
target_version: z.number().int().nullable(),
|
|
51
|
+
state: z.string(),
|
|
52
|
+
resources: z.array(reconcileResourceSchema),
|
|
53
|
+
truncated: z.boolean(),
|
|
54
|
+
grants: z.object({ count: z.number().int() }),
|
|
55
|
+
pending: z.object({ count: z.number().int() })
|
|
56
|
+
}))
|
|
57
|
+
})
|
|
58
|
+
}),
|
|
59
|
+
status: z.literal(200),
|
|
60
|
+
meta: z.object({
|
|
61
|
+
request_uuid: z.string().nullable(),
|
|
62
|
+
timestamp: z.string()
|
|
63
|
+
})
|
|
64
|
+
});
|
|
65
|
+
function createFluidInstallationClient({ installToken, baseUrl, fetch: fetcher = globalThis.fetch, timeoutMs = 1e4 }) {
|
|
66
|
+
let url;
|
|
67
|
+
try {
|
|
68
|
+
url = new URL(baseUrl);
|
|
69
|
+
} catch {
|
|
70
|
+
throw new TypeError("Invalid baseUrl");
|
|
71
|
+
}
|
|
72
|
+
if (url.username || url.password || url.search || url.hash || !(url.protocol === "https:" || url.protocol === "http:" && ["localhost", "127.0.0.1"].includes(url.hostname))) throw new TypeError("baseUrl must use HTTPS or local HTTP");
|
|
73
|
+
if (typeof installToken !== "string" || !installToken.trim()) throw new TypeError("installToken must be non-empty");
|
|
74
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new TypeError("timeoutMs must be positive");
|
|
75
|
+
const endpoint = `${url.toString().replace(/\/+$/, "")}/api/droplets/v2026-10/installation`;
|
|
76
|
+
return { async getInstallationStatus() {
|
|
77
|
+
const controller = new AbortController();
|
|
78
|
+
let timer;
|
|
79
|
+
const failure = (code, httpStatus) => ({
|
|
80
|
+
ok: false,
|
|
81
|
+
error: {
|
|
82
|
+
code,
|
|
83
|
+
message: `Installation status: ${code}`,
|
|
84
|
+
...httpStatus === void 0 ? {} : { httpStatus }
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
const read = async () => {
|
|
88
|
+
const response = await fetcher(endpoint, {
|
|
89
|
+
headers: { Authorization: `Bearer ${installToken}` },
|
|
90
|
+
signal: controller.signal,
|
|
91
|
+
cache: "no-store",
|
|
92
|
+
redirect: "error"
|
|
93
|
+
});
|
|
94
|
+
if (!response.ok) {
|
|
95
|
+
response.body?.cancel().catch(() => {});
|
|
96
|
+
return failure(response.status === 401 ? "unauthorized" : response.status === 403 ? "forbidden" : "http_error", response.status);
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
return {
|
|
100
|
+
ok: true,
|
|
101
|
+
status: installationStatusSchema.parse(await response.json())
|
|
102
|
+
};
|
|
103
|
+
} catch {
|
|
104
|
+
return failure("invalid_response", response.status);
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
try {
|
|
108
|
+
return await Promise.race([read(), new Promise((resolve) => {
|
|
109
|
+
timer = setTimeout(() => {
|
|
110
|
+
resolve(failure("timeout"));
|
|
111
|
+
controller.abort();
|
|
112
|
+
}, timeoutMs);
|
|
113
|
+
})]);
|
|
114
|
+
} catch {
|
|
115
|
+
return failure("network");
|
|
116
|
+
} finally {
|
|
117
|
+
clearTimeout(timer);
|
|
118
|
+
}
|
|
119
|
+
} };
|
|
120
|
+
}
|
|
121
|
+
//#endregion
|
|
122
|
+
export { createFluidInstallationClient, installationHealthSchema, installationStatusSchema, reconcileResourceSchema };
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { Logger } from "../redact.mjs";
|
|
2
|
+
import { ReconcileResource } from "../installation.mjs";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
|
|
5
|
+
//#region src/next/reconcile.d.ts
|
|
6
|
+
declare const reconcileEventSchema: z.ZodType<ReconcileEvent>;
|
|
7
|
+
type ReconcileEvent = {
|
|
8
|
+
event: "reconcile.succeeded" | "reconcile.failed" | "reconcile.awaiting_consent";
|
|
9
|
+
id: string;
|
|
10
|
+
occurred_at: string;
|
|
11
|
+
installation: {
|
|
12
|
+
uuid: string;
|
|
13
|
+
company_uuid: string;
|
|
14
|
+
};
|
|
15
|
+
attempt: number;
|
|
16
|
+
terminal: boolean;
|
|
17
|
+
resources?: ReconcileResource[];
|
|
18
|
+
resources_truncated?: true;
|
|
19
|
+
sandbox?: true;
|
|
20
|
+
from_version: string | null;
|
|
21
|
+
to_version: string | null;
|
|
22
|
+
pinned_version: string | null;
|
|
23
|
+
};
|
|
24
|
+
type ReconcileClaim = {
|
|
25
|
+
status: "claimed";
|
|
26
|
+
token: string;
|
|
27
|
+
} | {
|
|
28
|
+
status: "in_flight";
|
|
29
|
+
} | {
|
|
30
|
+
status: "done";
|
|
31
|
+
};
|
|
32
|
+
interface ReconcileAtomicDedupeStore {
|
|
33
|
+
/** Atomically reserves an id for the in-flight lease ttlMs, or reports its state. */
|
|
34
|
+
claim(id: string, ttlMs: number): Promise<ReconcileClaim> | ReconcileClaim;
|
|
35
|
+
/** Retains done for ttlMs only while the token owns an unexpired claim. */
|
|
36
|
+
complete(id: string, token: string, ttlMs: number): Promise<void> | void;
|
|
37
|
+
/** Frees only an unexpired claim still owned by the token. */
|
|
38
|
+
release(id: string, token: string): Promise<void> | void;
|
|
39
|
+
}
|
|
40
|
+
type ReconcileDedupeStore = ReconcileAtomicDedupeStore;
|
|
41
|
+
interface ReconcileOptions {
|
|
42
|
+
secret: string;
|
|
43
|
+
logger?: Logger;
|
|
44
|
+
dedupe: ReconcileDedupeStore;
|
|
45
|
+
dedupeTtlMs?: number;
|
|
46
|
+
inFlightTtlMs?: number;
|
|
47
|
+
maxBodyBytes?: number;
|
|
48
|
+
name?: string;
|
|
49
|
+
}
|
|
50
|
+
type ReconcileHandler = (event: ReconcileEvent, ctx: {
|
|
51
|
+
headers: Headers;
|
|
52
|
+
signal: AbortSignal;
|
|
53
|
+
}) => unknown | Promise<unknown>;
|
|
54
|
+
/** Creates a bounded process-local event dedupe store. */
|
|
55
|
+
declare function createInMemoryReconcileDedupe({
|
|
56
|
+
now,
|
|
57
|
+
maxEntries,
|
|
58
|
+
inFlightTtlMs
|
|
59
|
+
}?: {
|
|
60
|
+
now?: () => number;
|
|
61
|
+
maxEntries?: number;
|
|
62
|
+
inFlightTtlMs?: number;
|
|
63
|
+
}): ReconcileAtomicDedupeStore;
|
|
64
|
+
/** Verifies and handles reconcile events, marking successful deliveries. */
|
|
65
|
+
declare function withFluidReconcileEvents(handler: ReconcileHandler, {
|
|
66
|
+
secret,
|
|
67
|
+
logger,
|
|
68
|
+
dedupe,
|
|
69
|
+
dedupeTtlMs,
|
|
70
|
+
inFlightTtlMs,
|
|
71
|
+
maxBodyBytes,
|
|
72
|
+
name
|
|
73
|
+
}: ReconcileOptions): (request: Request) => Promise<Response>;
|
|
74
|
+
//#endregion
|
|
75
|
+
export { ReconcileAtomicDedupeStore, ReconcileClaim, ReconcileDedupeStore, ReconcileEvent, ReconcileHandler, ReconcileOptions, createInMemoryReconcileDedupe, reconcileEventSchema, withFluidReconcileEvents };
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { verifySignature } from "../signatures.mjs";
|
|
2
|
+
import { consoleLogger } from "../redact.mjs";
|
|
3
|
+
import { reconcileResourceSchema } from "../installation.mjs";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
//#region src/next/reconcile.ts
|
|
7
|
+
const reconcileEventSchema = z.object({
|
|
8
|
+
event: z.enum([
|
|
9
|
+
"reconcile.succeeded",
|
|
10
|
+
"reconcile.failed",
|
|
11
|
+
"reconcile.awaiting_consent"
|
|
12
|
+
]),
|
|
13
|
+
id: z.string().regex(/^evt_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i),
|
|
14
|
+
occurred_at: z.iso.datetime({ offset: true }),
|
|
15
|
+
installation: z.object({
|
|
16
|
+
uuid: z.uuid(),
|
|
17
|
+
company_uuid: z.uuid()
|
|
18
|
+
}),
|
|
19
|
+
attempt: z.number().int().nonnegative(),
|
|
20
|
+
terminal: z.boolean(),
|
|
21
|
+
resources: z.array(reconcileResourceSchema).optional(),
|
|
22
|
+
resources_truncated: z.literal(true).optional(),
|
|
23
|
+
sandbox: z.literal(true).optional(),
|
|
24
|
+
from_version: z.string().nullable(),
|
|
25
|
+
to_version: z.string().nullable(),
|
|
26
|
+
pinned_version: z.string().nullable()
|
|
27
|
+
});
|
|
28
|
+
function createInMemoryReconcileDedupe({ now = Date.now, maxEntries = 1e4, inFlightTtlMs = 6e4 } = {}) {
|
|
29
|
+
if (!Number.isInteger(maxEntries) || maxEntries < 1) throw new TypeError("maxEntries must be positive");
|
|
30
|
+
if (!Number.isFinite(inFlightTtlMs) || inFlightTtlMs <= 0) throw new TypeError("inFlightTtlMs must be positive and finite");
|
|
31
|
+
const entries = new Map();
|
|
32
|
+
const prune = () => {
|
|
33
|
+
const time = now();
|
|
34
|
+
for (const [id, entry] of entries) if (entry.expiry <= time) entries.delete(id);
|
|
35
|
+
};
|
|
36
|
+
const mark = (id, ttlMs) => {
|
|
37
|
+
prune();
|
|
38
|
+
entries.delete(id);
|
|
39
|
+
while (entries.size >= maxEntries) entries.delete(entries.keys().next().value);
|
|
40
|
+
entries.set(id, {
|
|
41
|
+
status: "done",
|
|
42
|
+
expiry: now() + ttlMs
|
|
43
|
+
});
|
|
44
|
+
};
|
|
45
|
+
const owns = (id, token) => {
|
|
46
|
+
prune();
|
|
47
|
+
const entry = entries.get(id);
|
|
48
|
+
return entry?.status === "in_flight" && entry.token === token;
|
|
49
|
+
};
|
|
50
|
+
return {
|
|
51
|
+
claim(id, ttlMs) {
|
|
52
|
+
prune();
|
|
53
|
+
const entry = entries.get(id);
|
|
54
|
+
if (entry) return { status: entry.status };
|
|
55
|
+
while (entries.size >= maxEntries) entries.delete(entries.keys().next().value);
|
|
56
|
+
const token = randomUUID();
|
|
57
|
+
entries.set(id, {
|
|
58
|
+
status: "in_flight",
|
|
59
|
+
token,
|
|
60
|
+
expiry: now() + Math.min(ttlMs, inFlightTtlMs)
|
|
61
|
+
});
|
|
62
|
+
return {
|
|
63
|
+
status: "claimed",
|
|
64
|
+
token
|
|
65
|
+
};
|
|
66
|
+
},
|
|
67
|
+
complete(id, token, ttlMs) {
|
|
68
|
+
prune();
|
|
69
|
+
const entry = entries.get(id);
|
|
70
|
+
if (!entry || entry.status === "in_flight" && entry.token === token) mark(id, ttlMs);
|
|
71
|
+
},
|
|
72
|
+
release(id, token) {
|
|
73
|
+
if (owns(id, token)) entries.delete(id);
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
function withFluidReconcileEvents(handler, { secret, logger = consoleLogger, dedupe, dedupeTtlMs = 864e5, inFlightTtlMs = 6e4, maxBodyBytes = 1048576, name = "reconcile" }) {
|
|
78
|
+
if (!Number.isSafeInteger(maxBodyBytes) || maxBodyBytes < 0) throw new TypeError("maxBodyBytes must be a nonnegative safe integer");
|
|
79
|
+
if (!Number.isFinite(inFlightTtlMs) || inFlightTtlMs <= 0) throw new TypeError("inFlightTtlMs must be positive and finite");
|
|
80
|
+
if (!Number.isFinite(dedupeTtlMs) || dedupeTtlMs <= 0) throw new TypeError("dedupeTtlMs must be positive and finite");
|
|
81
|
+
if (!dedupe || typeof dedupe.claim !== "function" || typeof dedupe.complete !== "function" || typeof dedupe.release !== "function") throw new TypeError("withFluidReconcileEvents requires an atomic dedupe store implementing claim, complete and release; pass createInMemoryReconcileDedupe() for a single instance or a shared store for multiple instances");
|
|
82
|
+
const store = dedupe;
|
|
83
|
+
const release = async (id, token) => {
|
|
84
|
+
try {
|
|
85
|
+
await store.release(id, token);
|
|
86
|
+
} catch {
|
|
87
|
+
logger.warn(`[fluid-webhook:${name}] dedupe release failed`, { id });
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
return async function POST(request) {
|
|
91
|
+
const invalid = () => Response.json({ error: "invalid request" }, { status: 400 });
|
|
92
|
+
let rawBody;
|
|
93
|
+
let rawBytes;
|
|
94
|
+
const tooLarge = () => {
|
|
95
|
+
logger.warn(`[fluid-webhook:${name}] rejected`, { reason: "payload too large" });
|
|
96
|
+
return Response.json({ error: "payload too large" }, { status: 413 });
|
|
97
|
+
};
|
|
98
|
+
const contentLength = request.headers.get("content-length");
|
|
99
|
+
if (contentLength !== null && Number(contentLength) > maxBodyBytes) {
|
|
100
|
+
request.body?.cancel().catch(() => {});
|
|
101
|
+
return tooLarge();
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
const chunks = [];
|
|
105
|
+
let total = 0;
|
|
106
|
+
const reader = request.body?.getReader();
|
|
107
|
+
if (reader) try {
|
|
108
|
+
while (true) {
|
|
109
|
+
const { done, value } = await reader.read();
|
|
110
|
+
if (done) break;
|
|
111
|
+
total += value.byteLength;
|
|
112
|
+
if (total > maxBodyBytes) {
|
|
113
|
+
reader.cancel().catch(() => {});
|
|
114
|
+
return tooLarge();
|
|
115
|
+
}
|
|
116
|
+
chunks.push(value);
|
|
117
|
+
}
|
|
118
|
+
} finally {
|
|
119
|
+
reader.releaseLock();
|
|
120
|
+
}
|
|
121
|
+
const bytes = new Uint8Array(total);
|
|
122
|
+
let offset = 0;
|
|
123
|
+
for (const chunk of chunks) {
|
|
124
|
+
bytes.set(chunk, offset);
|
|
125
|
+
offset += chunk.byteLength;
|
|
126
|
+
}
|
|
127
|
+
rawBytes = bytes;
|
|
128
|
+
rawBody = new TextDecoder().decode(bytes);
|
|
129
|
+
} catch {
|
|
130
|
+
return invalid();
|
|
131
|
+
}
|
|
132
|
+
const verified = verifySignature({
|
|
133
|
+
rawBody: rawBytes,
|
|
134
|
+
secret,
|
|
135
|
+
signature: request.headers.get("x-fluid-signature"),
|
|
136
|
+
timestamp: request.headers.get("x-fluid-timestamp")
|
|
137
|
+
});
|
|
138
|
+
if (!verified.valid) {
|
|
139
|
+
logger.warn(`[fluid-webhook:${name}] rejected`, { reason: verified.reason });
|
|
140
|
+
return Response.json({ error: "unauthorized" }, { status: 401 });
|
|
141
|
+
}
|
|
142
|
+
let body;
|
|
143
|
+
try {
|
|
144
|
+
body = JSON.parse(rawBody);
|
|
145
|
+
} catch {
|
|
146
|
+
return invalid();
|
|
147
|
+
}
|
|
148
|
+
if (typeof body === "object" && body !== null && "event" in body && typeof body.event === "string" && body.event.startsWith("reconcile.") && body.event !== "reconcile.succeeded" && body.event !== "reconcile.failed" && body.event !== "reconcile.awaiting_consent") {
|
|
149
|
+
logger.info(`[fluid-webhook:${name}] ignored`, { event: body.event });
|
|
150
|
+
return Response.json({
|
|
151
|
+
ok: true,
|
|
152
|
+
ignored: true
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
const parsed = reconcileEventSchema.safeParse(body);
|
|
156
|
+
if (!parsed.success) return invalid();
|
|
157
|
+
const event = parsed.data;
|
|
158
|
+
let token;
|
|
159
|
+
try {
|
|
160
|
+
const claim = await store.claim(event.id, Math.min(inFlightTtlMs, dedupeTtlMs));
|
|
161
|
+
if (claim.status === "in_flight") return Response.json({ error: "in flight" }, {
|
|
162
|
+
status: 503,
|
|
163
|
+
headers: { "retry-after": "30" }
|
|
164
|
+
});
|
|
165
|
+
if (claim.status === "done") return Response.json({
|
|
166
|
+
ok: true,
|
|
167
|
+
duplicate: true
|
|
168
|
+
});
|
|
169
|
+
token = claim.token;
|
|
170
|
+
} catch {
|
|
171
|
+
logger.warn(`[fluid-webhook:${name}] dedupe claim failed`, { id: event.id });
|
|
172
|
+
return Response.json({ error: "internal error" }, { status: 500 });
|
|
173
|
+
}
|
|
174
|
+
try {
|
|
175
|
+
if (event.event === "reconcile.failed") logger.error("reconcile.failed", {
|
|
176
|
+
id: event.id,
|
|
177
|
+
installation_uuid: event.installation.uuid,
|
|
178
|
+
attempt: event.attempt,
|
|
179
|
+
terminal: event.terminal,
|
|
180
|
+
error_codes: [...new Set((event.resources ?? []).flatMap((resource) => resource.error_code === null ? [] : [resource.error_code]))]
|
|
181
|
+
});
|
|
182
|
+
const result = await handler(event, {
|
|
183
|
+
headers: request.headers,
|
|
184
|
+
signal: request.signal
|
|
185
|
+
});
|
|
186
|
+
let response;
|
|
187
|
+
if (result instanceof Response) response = result;
|
|
188
|
+
else try {
|
|
189
|
+
response = Response.json(result ?? { ok: true });
|
|
190
|
+
} catch {
|
|
191
|
+
logger.warn(`[fluid-webhook:${name}] handler result not serializable`, { id: event.id });
|
|
192
|
+
response = Response.json({ ok: true });
|
|
193
|
+
}
|
|
194
|
+
if (response.ok) try {
|
|
195
|
+
await store.complete(event.id, token, dedupeTtlMs);
|
|
196
|
+
} catch {
|
|
197
|
+
logger.warn(`[fluid-webhook:${name}] dedupe complete failed`, { id: event.id });
|
|
198
|
+
}
|
|
199
|
+
else await release(event.id, token);
|
|
200
|
+
return response;
|
|
201
|
+
} catch (error) {
|
|
202
|
+
await release(event.id, token);
|
|
203
|
+
logger.error(`[fluid-webhook:${name}] handler threw`, {
|
|
204
|
+
id: event.id,
|
|
205
|
+
name: error instanceof Error ? error.name : "UnknownError"
|
|
206
|
+
});
|
|
207
|
+
return Response.json({ error: "internal error" }, { status: 500 });
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
//#endregion
|
|
212
|
+
export { createInMemoryReconcileDedupe, reconcileEventSchema, withFluidReconcileEvents };
|
package/dist/next.d.mts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
import { CallbackContext, CallbackFailure, CallbackHandler, WithFluidCallbackConfig, withFluidCallback } from "./next/callbacks.mjs";
|
|
2
2
|
import { INSTALL_EVENT, ResolvedWebhookPrincipal, WebhookContext, WebhookHandler, WebhookRoutingHints, WithFluidWebhookConfig, effectivePayload, withFluidWebhook } from "./next/webhooks.mjs";
|
|
3
|
-
|
|
3
|
+
import { ReconcileAtomicDedupeStore, ReconcileClaim, ReconcileDedupeStore, ReconcileEvent, ReconcileHandler, ReconcileOptions, createInMemoryReconcileDedupe, reconcileEventSchema, withFluidReconcileEvents } from "./next/reconcile.mjs";
|
|
4
|
+
export { type CallbackContext, type CallbackFailure, type CallbackHandler, INSTALL_EVENT, ReconcileAtomicDedupeStore, ReconcileClaim, ReconcileDedupeStore, ReconcileEvent, ReconcileHandler, ReconcileOptions, type ResolvedWebhookPrincipal, type WebhookContext, type WebhookHandler, type WebhookRoutingHints, type WithFluidCallbackConfig, type WithFluidWebhookConfig, createInMemoryReconcileDedupe, effectivePayload, reconcileEventSchema, withFluidCallback, withFluidReconcileEvents, withFluidWebhook };
|
package/dist/next.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
import { withFluidCallback } from "./next/callbacks.mjs";
|
|
2
2
|
import { INSTALL_EVENT, effectivePayload, withFluidWebhook } from "./next/webhooks.mjs";
|
|
3
|
-
|
|
3
|
+
import { createInMemoryReconcileDedupe, reconcileEventSchema, withFluidReconcileEvents } from "./next/reconcile.mjs";
|
|
4
|
+
export { INSTALL_EVENT, createInMemoryReconcileDedupe, effectivePayload, reconcileEventSchema, withFluidCallback, withFluidReconcileEvents, withFluidWebhook };
|
package/dist/redact.mjs
CHANGED
|
@@ -21,7 +21,7 @@ function describeError(error) {
|
|
|
21
21
|
};
|
|
22
22
|
}
|
|
23
23
|
function scrubMessage(message) {
|
|
24
|
-
return message.replace(/[{[][\s\S]*[}\]]/g, "[STRUCTURED_CONTENT_REMOVED]").replace(/\b(?:cvt|wvt|dit|dri|drp|cbr)_[A-Za-z0-9._-]+/g, "[REDACTED]").replace(/\b(token|secret|password|credential|api[_-]?key|authorization|signature)\b\s*[:=]\s*\S+/gi, "$1=[REDACTED]");
|
|
24
|
+
return message.replace(/[{[][\s\S]*[}\]]/g, "[STRUCTURED_CONTENT_REMOVED]").replace(/\b(?:cvt|wvt|dit|dri|drp|cbr|dws)_[A-Za-z0-9._-]+/g, "[REDACTED]").replace(/\b(token|secret|password|credential|api[_-]?key|authorization|signature)\b\s*[:=]\s*\S+/gi, "$1=[REDACTED]");
|
|
25
25
|
}
|
|
26
26
|
function truncate(value, max) {
|
|
27
27
|
return value.length <= max ? value : `${value.slice(0, max)}…[truncated]`;
|