@useairfoil/connector-kit 0.3.0 → 0.5.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/README.md +279 -90
- package/dist/chunk-Bp6m_JJh.js +13 -0
- package/dist/connector-app/index.d.ts +9 -0
- package/dist/connector-app/index.js +8 -0
- package/dist/connector-app-CvqbfN6R.js +32 -0
- package/dist/connector-app-CvqbfN6R.js.map +1 -0
- package/dist/errors/index.d.ts +2 -0
- package/dist/errors/index.js +3 -0
- package/dist/errors-DHWylqRZ.js +8 -0
- package/dist/errors-DHWylqRZ.js.map +1 -0
- package/dist/index-8KnVpoZp.d.ts +32 -0
- package/dist/index-BlhJCpEk.d.ts +124 -0
- package/dist/index-CQJsFJs3.d.ts +23 -0
- package/dist/index-DK9YcR6W.d.ts +25 -0
- package/dist/index-DaNW63Mv.d.ts +21 -0
- package/dist/index-DyuT2jzP.d.ts +14 -0
- package/dist/index.d.ts +9 -166
- package/dist/index.js +44 -229
- package/dist/index.js.map +1 -1
- package/dist/ingestion/index.d.ts +6 -0
- package/dist/ingestion/index.js +7 -0
- package/dist/ingestion-CMCroK7Y.js +171 -0
- package/dist/ingestion-CMCroK7Y.js.map +1 -0
- package/dist/publisher/index.d.ts +5 -0
- package/dist/publisher/index.js +5 -0
- package/dist/publisher-DusgYvaV.js +131 -0
- package/dist/publisher-DusgYvaV.js.map +1 -0
- package/dist/server-DAeFWT4o.js +113 -0
- package/dist/server-DAeFWT4o.js.map +1 -0
- package/dist/service-B7g_lHMI.d.ts +32 -0
- package/dist/state-store/index.d.ts +4 -0
- package/dist/state-store/index.js +3 -0
- package/dist/state-store-DG_4QpWG.js +31 -0
- package/dist/state-store-DG_4QpWG.js.map +1 -0
- package/dist/telemetry-p0dE5Mdu.js +171 -0
- package/dist/telemetry-p0dE5Mdu.js.map +1 -0
- package/dist/types-ZNrA-Dn0.d.ts +160 -0
- package/dist/webhook/index.d.ts +5 -0
- package/dist/webhook/index.js +6 -0
- package/dist/webhook-BSA-tXtU.js +13 -0
- package/dist/webhook-BSA-tXtU.js.map +1 -0
- package/package.json +41 -14
- package/src/connector-app/index.ts +1 -0
- package/src/connector-app/start.ts +38 -0
- package/src/core/builder.ts +73 -15
- package/src/core/index.ts +22 -0
- package/src/core/types.ts +186 -78
- package/src/errors/index.ts +1 -0
- package/src/index.ts +8 -28
- package/src/ingestion/engine.ts +302 -206
- package/src/ingestion/index.ts +1 -0
- package/src/publisher/console.ts +18 -0
- package/src/publisher/index.ts +7 -0
- package/src/publisher/service.ts +33 -14
- package/src/publisher/wings.ts +162 -82
- package/src/runtime/context.ts +15 -0
- package/src/state-store/index.ts +2 -0
- package/src/state-store/memory.ts +25 -0
- package/src/state-store/service.ts +18 -0
- package/src/telemetry.ts +246 -0
- package/src/webhook/index.ts +8 -0
- package/src/webhook/server.ts +177 -54
- package/src/webhook/types.ts +6 -16
- package/src/ingestion/state-store.ts +0 -36
- package/src/streams/pull-stream.ts +0 -55
- package/src/streams/webhook-queue.ts +0 -15
- /package/src/{core/errors.ts → errors/connector-error.ts} +0 -0
package/src/webhook/server.ts
CHANGED
|
@@ -1,57 +1,184 @@
|
|
|
1
|
-
import { Cause,
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
1
|
+
import { Cause, Context, Effect, Queue, Schema } from "effect";
|
|
2
|
+
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
|
|
3
|
+
|
|
4
|
+
import type {
|
|
5
|
+
ResourceBatch,
|
|
6
|
+
ResourceDefinition,
|
|
7
|
+
ResourceMutation,
|
|
8
|
+
WebhookHandler,
|
|
9
|
+
WebhookRoute,
|
|
10
|
+
} from "../core/types";
|
|
11
|
+
|
|
12
|
+
import { ConnectorError } from "../errors";
|
|
13
|
+
import { Publisher } from "../publisher/service";
|
|
14
|
+
import { Attr, SpanName, annotateError } from "../telemetry";
|
|
15
|
+
|
|
16
|
+
export type QueuedWebhookBatch = {
|
|
17
|
+
readonly resource: string;
|
|
18
|
+
readonly batch: ResourceBatch;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export class WebhookQueue extends Context.Service<
|
|
22
|
+
WebhookQueue,
|
|
23
|
+
{
|
|
24
|
+
readonly queue: Queue.Queue<QueuedWebhookBatch>;
|
|
25
|
+
}
|
|
26
|
+
>()("@useairfoil/connector-kit/WebhookQueue") {}
|
|
27
|
+
|
|
28
|
+
const resourceBatch = <Row extends object>(
|
|
29
|
+
mutations: ReadonlyArray<ResourceMutation<Row>>,
|
|
30
|
+
): ResourceBatch<Row> => ({
|
|
31
|
+
mutations,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const decodeAndHandleWebhook = <Row extends object, Payload>(
|
|
35
|
+
resourceName: string,
|
|
36
|
+
webhook: WebhookHandler<Row, Payload>,
|
|
37
|
+
payload: unknown,
|
|
38
|
+
) =>
|
|
39
|
+
Schema.decodeUnknownEffect(webhook.schema)(payload).pipe(
|
|
40
|
+
Effect.mapError(
|
|
41
|
+
(cause) =>
|
|
42
|
+
new ConnectorError({
|
|
43
|
+
message: `Invalid webhook payload for resource ${resourceName}`,
|
|
44
|
+
cause,
|
|
45
|
+
}),
|
|
46
|
+
),
|
|
47
|
+
Effect.flatMap((decoded) => webhook.handler({ payload: decoded })),
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
const makeRouteContext = (
|
|
51
|
+
request: HttpServerRequest.HttpServerRequest,
|
|
52
|
+
rawBody: Uint8Array,
|
|
53
|
+
payload: unknown,
|
|
54
|
+
batches: Array<QueuedWebhookBatch>,
|
|
55
|
+
): import("../core/types").WebhookRouteContext<ReadonlyArray<ResourceDefinition>, unknown> => ({
|
|
56
|
+
request,
|
|
57
|
+
rawBody,
|
|
58
|
+
payload,
|
|
59
|
+
to: (resource, payload) =>
|
|
60
|
+
Effect.gen(function* () {
|
|
61
|
+
if (!resource.webhook) {
|
|
62
|
+
return yield* Effect.fail(
|
|
63
|
+
new ConnectorError({
|
|
64
|
+
message: `Resource ${resource.name} does not define a webhook handler`,
|
|
65
|
+
}),
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const mutations = yield* decodeAndHandleWebhook(resource.name, resource.webhook, payload);
|
|
70
|
+
batches.push({
|
|
71
|
+
resource: resource.name,
|
|
72
|
+
batch: resourceBatch(mutations),
|
|
73
|
+
});
|
|
74
|
+
}),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const compactBatches = (batches: ReadonlyArray<QueuedWebhookBatch>) => {
|
|
78
|
+
const grouped = new Map<string, ResourceMutation[]>();
|
|
79
|
+
for (const item of batches) {
|
|
80
|
+
const current = grouped.get(item.resource) ?? [];
|
|
81
|
+
grouped.set(item.resource, [...current, ...item.batch.mutations]);
|
|
82
|
+
}
|
|
83
|
+
return Array.from(grouped.entries()).map(([resource, mutations]) => ({
|
|
84
|
+
resource,
|
|
85
|
+
batch: resourceBatch(mutations),
|
|
86
|
+
}));
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const publishBatches = (batches: ReadonlyArray<QueuedWebhookBatch>) =>
|
|
90
|
+
Effect.gen(function* () {
|
|
91
|
+
const publisher = yield* Publisher;
|
|
92
|
+
yield* Effect.forEach(
|
|
93
|
+
batches,
|
|
94
|
+
(batch) =>
|
|
95
|
+
Effect.gen(function* () {
|
|
96
|
+
const ack = yield* publisher.publish({
|
|
97
|
+
resource: batch.resource,
|
|
98
|
+
source: "webhook",
|
|
99
|
+
batch: batch.batch,
|
|
100
|
+
});
|
|
101
|
+
if (ack.status === "rejected") {
|
|
102
|
+
return yield* Effect.fail(new ConnectorError({ message: ack.reason }));
|
|
103
|
+
}
|
|
104
|
+
}),
|
|
105
|
+
{ concurrency: "unbounded" },
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const badRequest = (message: string) =>
|
|
110
|
+
HttpServerResponse.jsonUnsafe({ ok: false, error: message }, { status: 400 });
|
|
111
|
+
|
|
112
|
+
const readRawBody = (request: HttpServerRequest.HttpServerRequest) =>
|
|
113
|
+
request.arrayBuffer.pipe(Effect.map((buffer) => new Uint8Array(buffer)));
|
|
114
|
+
|
|
115
|
+
const parseJson = (rawBody: Uint8Array) =>
|
|
116
|
+
Effect.try({
|
|
117
|
+
try: () => JSON.parse(new TextDecoder().decode(rawBody)) as unknown,
|
|
118
|
+
catch: (cause) => new ConnectorError({ message: "Invalid webhook JSON", cause }),
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const makeHandler = (route: WebhookRoute) =>
|
|
16
122
|
Effect.gen(function* () {
|
|
17
123
|
const request = yield* HttpServerRequest.HttpServerRequest;
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
),
|
|
124
|
+
const collected: Array<QueuedWebhookBatch> = [];
|
|
125
|
+
|
|
126
|
+
const rawBodyResult = yield* readRawBody(request).pipe(
|
|
127
|
+
Effect.match({
|
|
128
|
+
onFailure: () => ({ _tag: "Error" as const, message: "Failed to read webhook body" }),
|
|
129
|
+
onSuccess: (value) => ({ _tag: "Success" as const, value }),
|
|
130
|
+
}),
|
|
25
131
|
);
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
)
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
132
|
+
if (rawBodyResult._tag === "Error") {
|
|
133
|
+
return badRequest(rawBodyResult.message);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const jsonResult = yield* parseJson(rawBodyResult.value).pipe(
|
|
137
|
+
Effect.match({
|
|
138
|
+
onFailure: (error) => ({ _tag: "Error" as const, message: error.message }),
|
|
139
|
+
onSuccess: (value) => ({ _tag: "Success" as const, value }),
|
|
140
|
+
}),
|
|
141
|
+
);
|
|
142
|
+
if (jsonResult._tag === "Error") {
|
|
143
|
+
return badRequest(jsonResult.message);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const payloadResult = yield* Schema.decodeUnknownEffect(route.schema)(jsonResult.value).pipe(
|
|
147
|
+
Effect.match({
|
|
148
|
+
onFailure: () => ({ _tag: "Error" as const, message: "Invalid webhook payload" }),
|
|
149
|
+
onSuccess: (value) => ({ _tag: "Success" as const, value }),
|
|
150
|
+
}),
|
|
151
|
+
);
|
|
152
|
+
if (payloadResult._tag === "Error") {
|
|
153
|
+
return badRequest(payloadResult.message);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const response = yield* Effect.withSpan(
|
|
157
|
+
route
|
|
158
|
+
.handler(makeRouteContext(request, rawBodyResult.value, payloadResult.value, collected))
|
|
159
|
+
.pipe(Effect.tapError((error) => annotateError("webhook_handle", error))),
|
|
160
|
+
SpanName.webhookHandle,
|
|
161
|
+
{ attributes: { [Attr.webhookPath]: route.path } },
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
const batches = compactBatches(collected);
|
|
165
|
+
|
|
166
|
+
if (route.ackMode === "after-enqueue") {
|
|
167
|
+
const queue = yield* WebhookQueue;
|
|
168
|
+
yield* Queue.offerAll(queue.queue, batches);
|
|
169
|
+
return response;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
yield* Effect.withSpan(
|
|
173
|
+
publishBatches(batches).pipe(
|
|
174
|
+
Effect.tapError((error) => annotateError("webhook_publish", error)),
|
|
41
175
|
),
|
|
176
|
+
SpanName.webhookHandle,
|
|
177
|
+
{ attributes: { [Attr.webhookPath]: route.path } },
|
|
42
178
|
);
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
return HttpServerResponse.jsonUnsafe({ ok: true });
|
|
179
|
+
|
|
180
|
+
return response;
|
|
46
181
|
}).pipe(
|
|
47
|
-
Effect.catchTag("InvalidWebhookPayloadError", () =>
|
|
48
|
-
Effect.succeed(
|
|
49
|
-
HttpServerResponse.jsonUnsafe(
|
|
50
|
-
{ ok: false, error: "Invalid webhook payload" },
|
|
51
|
-
{ status: 400 },
|
|
52
|
-
),
|
|
53
|
-
),
|
|
54
|
-
),
|
|
55
182
|
Effect.catchCause((cause) =>
|
|
56
183
|
Effect.logWarning(`Webhook handler error: ${Cause.pretty(cause)}`).pipe(
|
|
57
184
|
Effect.andThen(
|
|
@@ -66,11 +193,7 @@ const makeHandler = <TPayload>(route: WebhookRoute<TPayload>) =>
|
|
|
66
193
|
),
|
|
67
194
|
);
|
|
68
195
|
|
|
69
|
-
export const
|
|
70
|
-
routes: ReadonlyArray<WebhookRoute<TPayload>>,
|
|
71
|
-
) =>
|
|
196
|
+
export const router = (routes: ReadonlyArray<WebhookRoute>) =>
|
|
72
197
|
HttpRouter.addAll(
|
|
73
|
-
routes.map((route) =>
|
|
74
|
-
HttpRouter.route("POST", route.path, makeHandler(route)),
|
|
75
|
-
),
|
|
198
|
+
routes.map((route) => HttpRouter.route("POST", route.path, makeHandler(route))),
|
|
76
199
|
);
|
package/src/webhook/types.ts
CHANGED
|
@@ -1,16 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
export type WebhookRoute<TPayload> = {
|
|
9
|
-
readonly path: HttpRouter.PathInput;
|
|
10
|
-
readonly schema: Schema.Schema<TPayload>;
|
|
11
|
-
readonly handle: (
|
|
12
|
-
payload: TPayload,
|
|
13
|
-
request: HttpServerRequest.HttpServerRequest,
|
|
14
|
-
rawBody?: Uint8Array,
|
|
15
|
-
) => Effect.Effect<void, ConnectorError>;
|
|
16
|
-
};
|
|
1
|
+
export type {
|
|
2
|
+
WebhookAckMode,
|
|
3
|
+
WebhookRoute,
|
|
4
|
+
WebhookRouteContext,
|
|
5
|
+
WebhookRouteInput,
|
|
6
|
+
} from "../core/types";
|
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
import { Effect, Layer, Ref } from "effect";
|
|
2
|
-
import * as ServiceMap from "effect/ServiceMap";
|
|
3
|
-
import type { ConnectorError } from "../core/errors";
|
|
4
|
-
import type { Cursor, IngestionState } from "../core/types";
|
|
5
|
-
|
|
6
|
-
export class StateStore extends ServiceMap.Service<
|
|
7
|
-
StateStore,
|
|
8
|
-
{
|
|
9
|
-
readonly getState: (
|
|
10
|
-
key: string,
|
|
11
|
-
) => Effect.Effect<IngestionState<Cursor> | undefined, ConnectorError>;
|
|
12
|
-
readonly setState: (
|
|
13
|
-
key: string,
|
|
14
|
-
state: IngestionState<Cursor>,
|
|
15
|
-
) => Effect.Effect<void, ConnectorError>;
|
|
16
|
-
}
|
|
17
|
-
>()("StateStore") {}
|
|
18
|
-
|
|
19
|
-
export const StateStoreInMemory = Layer.effect(StateStore)(
|
|
20
|
-
Effect.gen(function* () {
|
|
21
|
-
const ref = yield* Ref.make(new Map<string, IngestionState<Cursor>>());
|
|
22
|
-
|
|
23
|
-
return {
|
|
24
|
-
getState: (key) => Effect.map(Ref.get(ref), (map) => map.get(key)),
|
|
25
|
-
setState: (key, state) =>
|
|
26
|
-
Effect.map(
|
|
27
|
-
Ref.update(ref, (map) => {
|
|
28
|
-
const next = new Map(map);
|
|
29
|
-
next.set(key, state);
|
|
30
|
-
return next;
|
|
31
|
-
}),
|
|
32
|
-
() => undefined,
|
|
33
|
-
),
|
|
34
|
-
};
|
|
35
|
-
}),
|
|
36
|
-
);
|
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
import { Effect, Stream } from "effect";
|
|
2
|
-
import type { ConnectorError } from "../core/errors";
|
|
3
|
-
import type { Batch, Cursor } from "../core/types";
|
|
4
|
-
|
|
5
|
-
export type PullPage<T> = {
|
|
6
|
-
readonly cursor: Cursor;
|
|
7
|
-
readonly rows: ReadonlyArray<T>;
|
|
8
|
-
readonly hasMore: boolean;
|
|
9
|
-
};
|
|
10
|
-
|
|
11
|
-
export type PullFetcher<T, R = never> = (
|
|
12
|
-
cursor: Cursor | undefined,
|
|
13
|
-
) => Effect.Effect<PullPage<T>, ConnectorError, R>;
|
|
14
|
-
|
|
15
|
-
export type PullStreamOptions<T, R = never> = {
|
|
16
|
-
readonly initialCursor?: Cursor;
|
|
17
|
-
readonly fetchPage: PullFetcher<T, R>;
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
export const makePullStream = <T, R = never>(
|
|
21
|
-
options: PullStreamOptions<T, R>,
|
|
22
|
-
): Stream.Stream<Batch<T>, ConnectorError, R> =>
|
|
23
|
-
Stream.unfold({ cursor: options.initialCursor, done: false }, (state) =>
|
|
24
|
-
Effect.gen(function* () {
|
|
25
|
-
if (state.done) {
|
|
26
|
-
return undefined;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
let nextCursor: Cursor | undefined = state.cursor;
|
|
30
|
-
|
|
31
|
-
while (true) {
|
|
32
|
-
const page = yield* options.fetchPage(nextCursor);
|
|
33
|
-
|
|
34
|
-
if (page.rows.length > 0) {
|
|
35
|
-
const batch: Batch<T> = {
|
|
36
|
-
cursor: page.cursor,
|
|
37
|
-
rows: page.rows,
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
return [
|
|
41
|
-
batch,
|
|
42
|
-
page.hasMore
|
|
43
|
-
? { cursor: page.cursor, done: false }
|
|
44
|
-
: { cursor: undefined, done: true },
|
|
45
|
-
] as const;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
if (!page.hasMore) {
|
|
49
|
-
return undefined;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
nextCursor = page.cursor;
|
|
53
|
-
}
|
|
54
|
-
}),
|
|
55
|
-
);
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
import { Effect, Queue, Stream } from "effect";
|
|
2
|
-
import type { ConnectorError } from "../core/errors";
|
|
3
|
-
import type { Batch, WebhookStream } from "../core/types";
|
|
4
|
-
|
|
5
|
-
export const makeWebhookQueue = <T>(options?: {
|
|
6
|
-
readonly capacity?: number;
|
|
7
|
-
}): Effect.Effect<WebhookStream<T>, never> =>
|
|
8
|
-
Effect.gen(function* () {
|
|
9
|
-
const capacity = options?.capacity ?? 1024;
|
|
10
|
-
const queue = yield* Queue.bounded<Batch<T>>(capacity);
|
|
11
|
-
const stream: Stream.Stream<Batch<T>, ConnectorError> = Stream.fromQueue(
|
|
12
|
-
queue,
|
|
13
|
-
);
|
|
14
|
-
return { queue, stream };
|
|
15
|
-
});
|
|
File without changes
|