@corvohq/worker 0.2.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/rpc.js ADDED
@@ -0,0 +1,193 @@
1
+ import { createClient } from "@connectrpc/connect";
2
+ import { createConnectTransport } from "@connectrpc/connect-node";
3
+ import { create } from "@bufbuild/protobuf";
4
+ import { WorkerService, HeartbeatJobUpdateSchema, } from "./gen/corvo/v1/worker_pb.js";
5
+ // ---------------------------------------------------------------------------
6
+ // Auth interceptor
7
+ // ---------------------------------------------------------------------------
8
+ function authInterceptor(auth) {
9
+ return (next) => async (req) => {
10
+ if (auth.headers) {
11
+ for (const [k, v] of Object.entries(auth.headers)) {
12
+ req.header.set(k, v);
13
+ }
14
+ }
15
+ if (auth.apiKey) {
16
+ req.header.set(auth.apiKeyHeader || "X-API-Key", auth.apiKey);
17
+ }
18
+ let token = auth.bearerToken || "";
19
+ if (auth.tokenProvider) {
20
+ token = await auth.tokenProvider();
21
+ }
22
+ if (token) {
23
+ req.header.set("authorization", `Bearer ${token}`);
24
+ }
25
+ return next(req);
26
+ };
27
+ }
28
+ function makeClient(baseUrl, auth) {
29
+ const transport = createConnectTransport({
30
+ baseUrl,
31
+ httpVersion: "1.1",
32
+ useBinaryFormat: false, // use JSON for compatibility
33
+ interceptors: [authInterceptor(auth)],
34
+ });
35
+ return createClient(WorkerService, transport);
36
+ }
37
+ // ---------------------------------------------------------------------------
38
+ // RpcClient
39
+ // ---------------------------------------------------------------------------
40
+ export class RpcClient {
41
+ constructor(baseUrl, auth = {}) {
42
+ this.client = makeClient(baseUrl.replace(/\/$/, ""), auth);
43
+ }
44
+ async enqueue(queue, payloadJson) {
45
+ const resp = await this.client.enqueue({ queue, payloadJson });
46
+ return {
47
+ jobId: resp.jobId,
48
+ status: resp.status,
49
+ uniqueExisting: resp.uniqueExisting,
50
+ };
51
+ }
52
+ async fail(jobId, error, backtrace = "") {
53
+ const resp = await this.client.fail({ jobId, error, backtrace });
54
+ return { status: resp.status };
55
+ }
56
+ async heartbeat(jobs) {
57
+ const protoJobs = {};
58
+ for (const [id, update] of Object.entries(jobs)) {
59
+ protoJobs[id] = create(HeartbeatJobUpdateSchema, {
60
+ progressJson: update.progressJson ?? "",
61
+ checkpointJson: update.checkpointJson ?? "",
62
+ streamDelta: update.streamDelta ?? "",
63
+ });
64
+ }
65
+ const resp = await this.client.heartbeat({ jobs: protoJobs });
66
+ const out = {};
67
+ for (const [id, jr] of Object.entries(resp.jobs)) {
68
+ out[id] = { status: jr.status };
69
+ }
70
+ return out;
71
+ }
72
+ /** Unary call to StreamLifecycle endpoint (Connect JSON protocol). */
73
+ async lifecycleExchange(body) {
74
+ // StreamLifecycle is bidi streaming in proto, but we call it as unary
75
+ // using the raw Connect JSON-over-HTTP protocol for compatibility.
76
+ // The server supports single-request/single-response over HTTP/1.1.
77
+ throw new Error("lifecycleExchange is not supported via createClient; use LifecycleStream instead");
78
+ }
79
+ }
80
+ // ---------------------------------------------------------------------------
81
+ // ErrNotLeader
82
+ // ---------------------------------------------------------------------------
83
+ export class ErrNotLeader extends Error {
84
+ constructor(leaderAddr) {
85
+ super(leaderAddr
86
+ ? `NOT_LEADER: leader is at ${leaderAddr}`
87
+ : "NOT_LEADER: leader unknown");
88
+ this.name = "ErrNotLeader";
89
+ this.leaderAddr = leaderAddr;
90
+ }
91
+ }
92
+ // ---------------------------------------------------------------------------
93
+ // LifecycleStream — bidi streaming over Connect RPC
94
+ // ---------------------------------------------------------------------------
95
+ export class LifecycleStream {
96
+ constructor(baseUrl, auth = {}) {
97
+ this.closed = false;
98
+ this.baseUrl = baseUrl.replace(/\/$/, "");
99
+ this.auth = auth;
100
+ }
101
+ async exchange(req) {
102
+ if (this.closed) {
103
+ throw new Error("stream closed");
104
+ }
105
+ // StreamLifecycle is bidi streaming in the proto, but the server
106
+ // supports Connect protocol unary-style JSON calls. We use raw HTTP
107
+ // because createClient's bidi streaming requires HTTP/2 and a long-lived
108
+ // stream, which doesn't match our request-per-exchange pattern.
109
+ const h = { "content-type": "application/json" };
110
+ if (this.auth.headers) {
111
+ for (const [k, v] of Object.entries(this.auth.headers)) {
112
+ h[k] = v;
113
+ }
114
+ }
115
+ if (this.auth.apiKey) {
116
+ h[this.auth.apiKeyHeader || "X-API-Key"] = this.auth.apiKey;
117
+ }
118
+ let token = this.auth.bearerToken || "";
119
+ if (this.auth.tokenProvider) {
120
+ token = await this.auth.tokenProvider();
121
+ }
122
+ if (token) {
123
+ h["authorization"] = `Bearer ${token}`;
124
+ }
125
+ const url = `${this.baseUrl}/corvo.v1.WorkerService/StreamLifecycle`;
126
+ const body = JSON.stringify({
127
+ requestId: req.requestId,
128
+ queues: req.queues,
129
+ workerId: req.workerId,
130
+ hostname: req.hostname,
131
+ leaseDuration: req.leaseDuration,
132
+ fetchCount: req.fetchCount,
133
+ acks: req.acks.map((a) => ({
134
+ jobId: a.jobId,
135
+ resultJson: a.resultJson,
136
+ })),
137
+ enqueues: req.enqueues.map((e) => ({
138
+ queue: e.queue,
139
+ payloadJson: e.payloadJson,
140
+ })),
141
+ });
142
+ const resp = await globalThis.fetch(url, {
143
+ method: "POST",
144
+ headers: h,
145
+ body,
146
+ });
147
+ if (!resp.ok) {
148
+ throw new Error(`RPC StreamLifecycle failed: HTTP ${resp.status}`);
149
+ }
150
+ const msg = (await resp.json());
151
+ if (msg.error === "NOT_LEADER") {
152
+ throw new ErrNotLeader(msg.leaderAddr ?? "");
153
+ }
154
+ return {
155
+ requestId: msg.requestId ?? 0,
156
+ jobs: msg.jobs ?? [],
157
+ acked: msg.acked ?? 0,
158
+ enqueuedJobIds: msg.enqueuedJobIds ?? [],
159
+ error: msg.error ?? "",
160
+ leaderAddr: msg.leaderAddr ?? "",
161
+ };
162
+ }
163
+ close() {
164
+ this.closed = true;
165
+ }
166
+ }
167
+ // ---------------------------------------------------------------------------
168
+ // ResilientLifecycleStream — reconnects on NOT_LEADER
169
+ // ---------------------------------------------------------------------------
170
+ export class ResilientLifecycleStream {
171
+ constructor(baseUrl, auth = {}) {
172
+ this.baseUrl = baseUrl.replace(/\/$/, "");
173
+ this.auth = auth;
174
+ this.stream = new LifecycleStream(this.baseUrl, auth);
175
+ }
176
+ async exchange(req) {
177
+ try {
178
+ return await this.stream.exchange(req);
179
+ }
180
+ catch (err) {
181
+ if (err instanceof ErrNotLeader && err.leaderAddr) {
182
+ this.stream.close();
183
+ this.baseUrl = err.leaderAddr;
184
+ this.stream = new LifecycleStream(this.baseUrl, this.auth);
185
+ return this.stream.exchange(req);
186
+ }
187
+ throw err;
188
+ }
189
+ }
190
+ close() {
191
+ this.stream.close();
192
+ }
193
+ }
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "@corvohq/worker",
3
+ "version": "0.2.0",
4
+ "description": "TypeScript worker runtime for Corvo",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "dist/index.js",
8
+ "types": "dist/index.d.ts",
9
+ "files": ["dist"],
10
+ "scripts": {
11
+ "build": "tsc -p tsconfig.json"
12
+ },
13
+ "dependencies": {
14
+ "@corvohq/client": "0.2.0",
15
+ "@connectrpc/connect": "^2.1.1",
16
+ "@connectrpc/connect-node": "^2.1.1",
17
+ "@bufbuild/protobuf": "^2.11.0"
18
+ },
19
+ "devDependencies": {
20
+ "typescript": "^5.6.3",
21
+ "@bufbuild/buf": "^1.65.0",
22
+ "@bufbuild/protoc-gen-es": "^2.11.0"
23
+ }
24
+ }