@hunterzhu/pulse-adapters 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.
@@ -0,0 +1,73 @@
1
+ import { spawn } from 'node:child_process';
2
+ function shellError(code, retryable = false, cause) {
3
+ return Object.assign(new Error(code), { code, retryable, ...(cause === undefined ? {} : { cause }) });
4
+ }
5
+ export function runShell(command, args = [], options = {}) {
6
+ const max = options.maxOutputBytes ?? 256 * 1024;
7
+ if (!Number.isFinite(max) || max < 0)
8
+ return Promise.reject(shellError('INVALID_SHELL_OUTPUT_LIMIT'));
9
+ if (options.timeoutMs !== undefined && (!Number.isFinite(options.timeoutMs) || options.timeoutMs < 0))
10
+ return Promise.reject(shellError('INVALID_SHELL_TIMEOUT'));
11
+ return new Promise((resolve, reject) => {
12
+ const child = spawn(command, args, { cwd: options.cwd, env: options.env, shell: false, detached: process.platform !== 'win32' });
13
+ let stdout = '';
14
+ let stderr = '';
15
+ let truncated = false;
16
+ const append = (target, chunk) => { const value = chunk.toString(); const current = target === 'stdout' ? stdout : stderr; const next = current + value; if (Buffer.byteLength(next) > max) {
17
+ truncated = true;
18
+ const limited = next.slice(0, max);
19
+ if (target === 'stdout')
20
+ stdout = limited;
21
+ else
22
+ stderr = limited;
23
+ }
24
+ else if (target === 'stdout')
25
+ stdout = next;
26
+ else
27
+ stderr = next; };
28
+ let closed = false;
29
+ const signalProcessGroup = (signal) => {
30
+ if (process.platform !== 'win32' && child.pid) {
31
+ try {
32
+ process.kill(-child.pid, signal);
33
+ return;
34
+ }
35
+ catch { /* process group may already be gone */ }
36
+ }
37
+ child.kill(signal);
38
+ };
39
+ let termination;
40
+ let killTimer;
41
+ const terminate = (reason) => {
42
+ if (closed)
43
+ return;
44
+ termination ??= reason;
45
+ signalProcessGroup('SIGTERM');
46
+ if (!killTimer)
47
+ killTimer = setTimeout(() => { if (!closed)
48
+ signalProcessGroup('SIGKILL'); }, 250);
49
+ };
50
+ const timer = options.timeoutMs && options.timeoutMs > 0 ? setTimeout(() => terminate('timeout'), options.timeoutMs) : undefined;
51
+ const abort = () => terminate('aborted');
52
+ if (options.signal) {
53
+ if (options.signal.aborted)
54
+ abort();
55
+ else
56
+ options.signal.addEventListener('abort', abort, { once: true });
57
+ }
58
+ child.stdout.on('data', (chunk) => append('stdout', chunk));
59
+ child.stderr.on('data', (chunk) => append('stderr', chunk));
60
+ child.on('error', (cause) => {
61
+ closed = true;
62
+ if (timer)
63
+ clearTimeout(timer);
64
+ if (killTimer)
65
+ clearTimeout(killTimer);
66
+ const code = cause && typeof cause === 'object' && typeof cause.code === 'string' ? String(cause.code) : 'SHELL_EXECUTION_ERROR';
67
+ reject(shellError(code === 'ENOENT' || code === 'EACCES' ? code : 'SHELL_EXECUTION_ERROR', false, cause));
68
+ });
69
+ child.on('close', (code) => { closed = true; if (timer)
70
+ clearTimeout(timer); if (killTimer)
71
+ clearTimeout(killTimer); resolve({ code, stdout, stderr, truncated, timedOut: termination === 'timeout', aborted: termination === 'aborted' }); });
72
+ });
73
+ }
@@ -0,0 +1,60 @@
1
+ import { type ServerOptions as HttpsServerOptions } from 'node:https';
2
+ import { type EffectExecutor, type JsonValue, type RuntimeError, type WorkerCoordinatorContract, type WorkerHandler, type WorkerLease, type WorkerSubmitOptions, type WorkerTaskRecord } from '@hunterzhu/pulse-runtime';
3
+ type AuthTokenSource = string | readonly string[];
4
+ export interface WorkerHttpServer {
5
+ readonly url: string;
6
+ close(): Promise<void>;
7
+ }
8
+ export interface WorkerHttpServerOptions {
9
+ host?: string;
10
+ port?: number;
11
+ authToken?: string;
12
+ authTokens?: readonly string[];
13
+ /** Resolve accepted credentials for every request so key rotation can overlap old and new tokens. */
14
+ authTokenProvider?: () => AuthTokenSource;
15
+ /** Enable HTTPS for the coordinator transport. The caller owns certificate rotation and reload. */
16
+ tls?: Pick<HttpsServerOptions, 'key' | 'cert' | 'ca' | 'passphrase' | 'requestCert' | 'rejectUnauthorized'>;
17
+ recoveryIntervalMs?: number;
18
+ }
19
+ export declare function startWorkerCoordinatorServer(coordinator: WorkerCoordinatorContract, options?: WorkerHttpServerOptions): Promise<WorkerHttpServer>;
20
+ export interface HttpWorkerClientOptions {
21
+ baseUrl: string;
22
+ workerId: string;
23
+ pollMs?: number;
24
+ authToken?: string;
25
+ requestTimeoutMs?: number;
26
+ fetch?: typeof globalThis.fetch;
27
+ }
28
+ export declare class HttpWorkerClient {
29
+ private readonly baseUrl;
30
+ private readonly workerId;
31
+ private readonly pollMs;
32
+ private readonly requestTimeoutMs;
33
+ private readonly authToken;
34
+ private readonly fetcher;
35
+ private sequence;
36
+ constructor(options: HttpWorkerClientOptions);
37
+ private request;
38
+ register(): Promise<void>;
39
+ unregister(): Promise<void>;
40
+ claim(): Promise<WorkerLease | undefined>;
41
+ renew(leaseId: string, leaseMs?: number): Promise<number>;
42
+ complete(leaseId: string, value: JsonValue): Promise<void>;
43
+ fail(leaseId: string, error: RuntimeError): Promise<void>;
44
+ cancel(taskId: string): Promise<boolean>;
45
+ get(taskId: string): Promise<WorkerTaskRecord | undefined>;
46
+ submit(payload: JsonValue, options?: Omit<WorkerSubmitOptions, 'signal'> & {
47
+ signal?: AbortSignal;
48
+ }): Promise<JsonValue>;
49
+ }
50
+ export interface HttpWorkerHandle {
51
+ stop(): Promise<void>;
52
+ }
53
+ export declare function startHttpWorker(client: HttpWorkerClient, handler: WorkerHandler, options?: {
54
+ signal?: AbortSignal;
55
+ renewMs?: number;
56
+ }): Promise<HttpWorkerHandle>;
57
+ export declare function createHttpWorkerEffectExecutor(client: HttpWorkerClient, options?: {
58
+ leaseMs?: number;
59
+ }): EffectExecutor;
60
+ export {};
@@ -0,0 +1,402 @@
1
+ import { createServer } from 'node:http';
2
+ import { createServer as createHttpsServer } from 'node:https';
3
+ import { createHash, timingSafeEqual } from 'node:crypto';
4
+ import { isSideEffectful } from '@hunterzhu/pulse-runtime';
5
+ function object(value) {
6
+ return value !== undefined && typeof value === 'object' && value !== null && !Array.isArray(value) ? value : {};
7
+ }
8
+ async function readBody(request) {
9
+ const chunks = [];
10
+ let size = 0;
11
+ for await (const chunk of request) {
12
+ const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
13
+ size += value.byteLength;
14
+ if (size > 4 * 1024 * 1024)
15
+ throw new Error('WORKER_HTTP_BODY_TOO_LARGE');
16
+ chunks.push(value);
17
+ }
18
+ if (chunks.length === 0)
19
+ return {};
20
+ try {
21
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'));
22
+ }
23
+ catch {
24
+ throw new Error('WORKER_HTTP_INVALID_JSON');
25
+ }
26
+ }
27
+ function responseBody(value) { return JSON.stringify(value); }
28
+ function requiredString(value, code) {
29
+ if (typeof value !== 'string' || value.length === 0)
30
+ throw new Error(code);
31
+ return value;
32
+ }
33
+ function validWorkerTask(value) {
34
+ if (!value || typeof value !== 'object' || Array.isArray(value))
35
+ return false;
36
+ const task = value;
37
+ if (!requiredField(task.id) || !['queued', 'leased', 'succeeded', 'failed', 'cancelled'].includes(String(task.state)) || !Number.isInteger(task.attempt) || task.attempt < 0 || task.payload === undefined)
38
+ return false;
39
+ if (task.leaseId !== undefined && !requiredField(task.leaseId))
40
+ return false;
41
+ if (task.workerId !== undefined && !requiredField(task.workerId))
42
+ return false;
43
+ if (task.leaseExpiresAt !== undefined && (typeof task.leaseExpiresAt !== 'number' || !Number.isFinite(task.leaseExpiresAt)))
44
+ return false;
45
+ if (task.leaseMs !== undefined && (typeof task.leaseMs !== 'number' || !Number.isFinite(task.leaseMs) || task.leaseMs <= 0))
46
+ return false;
47
+ if (task.idempotencyKey !== undefined && !requiredField(task.idempotencyKey))
48
+ return false;
49
+ if (task.error !== undefined && (!task.error || typeof task.error !== 'object' || Array.isArray(task.error) || !requiredField(task.error.code) || typeof task.error.message !== 'string'))
50
+ return false;
51
+ return true;
52
+ }
53
+ function requiredField(value) { return typeof value === 'string' && value.length > 0; }
54
+ function validWorkerLease(value) {
55
+ if (!value || typeof value !== 'object' || Array.isArray(value))
56
+ return false;
57
+ const lease = value;
58
+ return requiredField(lease.workerId) && requiredField(lease.leaseId) && validWorkerTask(lease.task);
59
+ }
60
+ function send(response, status, value) {
61
+ response.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
62
+ response.end(responseBody(value));
63
+ }
64
+ function errorMessage(cause) { return cause instanceof Error ? cause.message : String(cause); }
65
+ function workerHttpError(code, status) {
66
+ const retryable = status === undefined ? true : status === 408 || status === 425 || status === 429 || status >= 500;
67
+ return Object.assign(new Error(code), { code, retryable });
68
+ }
69
+ function workerControlError(code, retryable = false, details) {
70
+ return Object.assign(new Error(code), { code, retryable, ...(details === undefined ? {} : { details }) });
71
+ }
72
+ function workerTaskError(error) {
73
+ const code = error?.code ?? 'WORKER_FAILED';
74
+ return Object.assign(new Error(error?.message ?? code), { code, ...(error?.retryable === undefined ? {} : { retryable: error.retryable }), ...(error?.details === undefined ? {} : { details: error.details }) });
75
+ }
76
+ function workerRuntimeError(cause) {
77
+ if (cause && typeof cause === 'object') {
78
+ const candidate = cause;
79
+ return {
80
+ code: typeof candidate.code === 'string' ? candidate.code : 'WORKER_FAILED',
81
+ message: typeof candidate.message === 'string' ? candidate.message : errorMessage(cause),
82
+ ...(typeof candidate.retryable === 'boolean' ? { retryable: candidate.retryable } : {}),
83
+ ...(candidate.details === undefined ? {} : { details: candidate.details }),
84
+ };
85
+ }
86
+ return { code: 'WORKER_FAILED', message: errorMessage(cause) };
87
+ }
88
+ function secretEquals(left, right) {
89
+ const leftHash = createHash('sha256').update(left).digest();
90
+ const rightHash = createHash('sha256').update(right).digest();
91
+ return timingSafeEqual(leftHash, rightHash);
92
+ }
93
+ function authorized(request, source) {
94
+ if (source === undefined)
95
+ return true;
96
+ const tokens = typeof source === 'string' ? [source] : source;
97
+ const presented = request.headers.authorization?.startsWith('Bearer ') ? request.headers.authorization.slice('Bearer '.length) : undefined;
98
+ return presented !== undefined && tokens.some((token) => secretEquals(presented, token));
99
+ }
100
+ export async function startWorkerCoordinatorServer(coordinator, options = {}) {
101
+ const registrations = new Map();
102
+ const handler = async (request, response) => {
103
+ try {
104
+ const method = request.method ?? 'GET';
105
+ const path = request.url?.split('?')[0] ?? '/';
106
+ const configuredTokens = options.authTokenProvider?.() ?? options.authTokens ?? options.authToken;
107
+ if (!authorized(request, configuredTokens)) {
108
+ send(response, 401, { error: 'WORKER_HTTP_UNAUTHORIZED' });
109
+ return;
110
+ }
111
+ if (method === 'GET' && path === '/health') {
112
+ send(response, 200, { ok: true });
113
+ return;
114
+ }
115
+ if (method !== 'POST') {
116
+ send(response, 405, { error: 'WORKER_HTTP_METHOD_NOT_ALLOWED' });
117
+ return;
118
+ }
119
+ const body = object(await readBody(request));
120
+ if (path === '/workers/register') {
121
+ const workerId = requiredString(body.workerId, 'INVALID_WORKER_ID');
122
+ const unregister = coordinator.registerRemote(workerId);
123
+ registrations.set(workerId, unregister);
124
+ send(response, 200, { workerId, registered: true });
125
+ return;
126
+ }
127
+ if (path === '/workers/unregister') {
128
+ const workerId = requiredString(body.workerId, 'INVALID_WORKER_ID');
129
+ registrations.get(workerId)?.();
130
+ registrations.delete(workerId);
131
+ send(response, 200, { workerId, registered: false });
132
+ return;
133
+ }
134
+ if (path === '/tasks/submit') {
135
+ const taskId = requiredString(body.taskId, 'WORKER_HTTP_TASK_ID_REQUIRED');
136
+ if (!('payload' in body))
137
+ throw new Error('WORKER_HTTP_PAYLOAD_REQUIRED');
138
+ if (body.idempotencyKey !== undefined && !requiredField(body.idempotencyKey))
139
+ throw new Error('INVALID_WORKER_IDEMPOTENCY_KEY');
140
+ if (body.leaseMs !== undefined && (typeof body.leaseMs !== 'number' || !Number.isFinite(body.leaseMs) || body.leaseMs <= 0))
141
+ throw new Error('INVALID_WORKER_LEASE');
142
+ const options = { taskId, ...(body.idempotencyKey === undefined ? {} : { idempotencyKey: body.idempotencyKey }), ...(body.leaseMs === undefined ? {} : { leaseMs: body.leaseMs }) };
143
+ const pending = coordinator.submit(body.payload, options);
144
+ void pending.catch(() => undefined);
145
+ send(response, 200, { taskId });
146
+ return;
147
+ }
148
+ if (path === '/tasks/get') {
149
+ const taskId = requiredString(body.taskId, 'WORKER_HTTP_TASK_ID_REQUIRED');
150
+ send(response, 200, (coordinator.get(taskId) ?? null));
151
+ return;
152
+ }
153
+ if (path === '/tasks/claim') {
154
+ const workerId = requiredString(body.workerId, 'INVALID_WORKER_ID');
155
+ send(response, 200, (coordinator.claim(workerId) ?? null));
156
+ return;
157
+ }
158
+ if (path === '/tasks/renew') {
159
+ const workerId = body.workerId;
160
+ const leaseId = body.leaseId;
161
+ if (!requiredField(workerId) || !requiredField(leaseId))
162
+ throw new Error('INVALID_WORKER_LEASE');
163
+ if (body.leaseMs !== undefined && (typeof body.leaseMs !== 'number' || !Number.isFinite(body.leaseMs) || body.leaseMs <= 0))
164
+ throw new Error('INVALID_WORKER_LEASE');
165
+ const expiresAt = coordinator.renewLease(workerId, leaseId, Date.now(), body.leaseMs === undefined ? undefined : body.leaseMs);
166
+ if (expiresAt === undefined) {
167
+ send(response, 409, { error: 'WORKER_LEASE_NOT_FOUND' });
168
+ return;
169
+ }
170
+ send(response, 200, { leaseExpiresAt: expiresAt });
171
+ return;
172
+ }
173
+ if (path === '/tasks/complete') {
174
+ const workerId = body.workerId;
175
+ const leaseId = body.leaseId;
176
+ if (!requiredField(workerId) || !requiredField(leaseId) || !('value' in body))
177
+ throw new Error('INVALID_WORKER_COMPLETION');
178
+ if (!coordinator.completeRemote(workerId, leaseId, body.value)) {
179
+ send(response, 409, { error: 'WORKER_LEASE_NOT_FOUND' });
180
+ return;
181
+ }
182
+ send(response, 200, { completed: true });
183
+ return;
184
+ }
185
+ if (path === '/tasks/fail') {
186
+ const workerId = body.workerId;
187
+ const leaseId = body.leaseId;
188
+ if (!requiredField(workerId) || !requiredField(leaseId))
189
+ throw new Error('INVALID_WORKER_COMPLETION');
190
+ const rawError = object(body.error);
191
+ const error = { code: typeof rawError.code === 'string' ? rawError.code : 'WORKER_FAILED', message: typeof rawError.message === 'string' ? rawError.message : 'Worker failed.', ...(typeof rawError.retryable === 'boolean' ? { retryable: rawError.retryable } : {}), ...(rawError.details === undefined ? {} : { details: rawError.details }) };
192
+ if (!coordinator.failRemote(workerId, leaseId, error)) {
193
+ send(response, 409, { error: 'WORKER_LEASE_NOT_FOUND' });
194
+ return;
195
+ }
196
+ send(response, 200, { failed: true });
197
+ return;
198
+ }
199
+ if (path === '/tasks/cancel') {
200
+ const taskId = requiredString(body.taskId, 'WORKER_HTTP_TASK_ID_REQUIRED');
201
+ send(response, 200, { cancelled: coordinator.cancel(taskId) });
202
+ return;
203
+ }
204
+ send(response, 404, { error: 'WORKER_HTTP_NOT_FOUND' });
205
+ }
206
+ catch (cause) {
207
+ send(response, 400, { error: errorMessage(cause) });
208
+ }
209
+ };
210
+ const server = options.tls === undefined ? createServer(handler) : createHttpsServer(options.tls, handler);
211
+ await new Promise((resolve, reject) => { server.once('error', reject); server.listen(options.port ?? 0, options.host ?? '127.0.0.1', resolve); });
212
+ const address = server.address();
213
+ if (address === null || typeof address === 'string')
214
+ throw new Error('WORKER_HTTP_ADDRESS_UNAVAILABLE');
215
+ const host = options.host === '0.0.0.0' || options.host === '::' || options.host === undefined ? '127.0.0.1' : options.host;
216
+ const url = `${options.tls === undefined ? 'http' : 'https'}://${host}:${address.port}`;
217
+ const recoveryIntervalMs = options.recoveryIntervalMs ?? 1_000;
218
+ const recoveryTimer = recoveryIntervalMs > 0 ? setInterval(() => { coordinator.recoverExpired(Date.now()); }, recoveryIntervalMs) : undefined;
219
+ recoveryTimer?.unref();
220
+ return { url, close: async () => { if (recoveryTimer)
221
+ clearInterval(recoveryTimer); await new Promise((resolve, reject) => { server.close((cause) => cause ? reject(cause) : resolve()); }); } };
222
+ }
223
+ export class HttpWorkerClient {
224
+ baseUrl;
225
+ workerId;
226
+ pollMs;
227
+ requestTimeoutMs;
228
+ authToken;
229
+ fetcher;
230
+ sequence = 1;
231
+ constructor(options) {
232
+ if (!options.baseUrl || !options.workerId)
233
+ throw new Error('INVALID_WORKER_HTTP_CLIENT');
234
+ this.baseUrl = options.baseUrl.replace(/\/$/, '');
235
+ this.workerId = options.workerId;
236
+ this.pollMs = options.pollMs ?? 10;
237
+ this.requestTimeoutMs = options.requestTimeoutMs ?? 30_000;
238
+ if (!Number.isFinite(this.requestTimeoutMs) || this.requestTimeoutMs <= 0)
239
+ throw new Error('INVALID_WORKER_HTTP_TIMEOUT');
240
+ this.authToken = options.authToken;
241
+ this.fetcher = options.fetch ?? globalThis.fetch;
242
+ }
243
+ async request(path, body) {
244
+ const controller = new AbortController();
245
+ const timeout = setTimeout(() => controller.abort(), this.requestTimeoutMs);
246
+ try {
247
+ const response = await this.fetcher(`${this.baseUrl}${path}`, { method: 'POST', headers: { 'content-type': 'application/json', ...(this.authToken === undefined ? {} : { authorization: `Bearer ${this.authToken}` }) }, body: JSON.stringify(body), signal: controller.signal });
248
+ const text = await response.text();
249
+ let value = null;
250
+ try {
251
+ value = text.length === 0 ? null : JSON.parse(text);
252
+ }
253
+ catch {
254
+ throw workerHttpError('WORKER_HTTP_INVALID_RESPONSE', 502);
255
+ }
256
+ if (!response.ok) {
257
+ const code = object(value).error && typeof object(value).error === 'string' ? object(value).error : `WORKER_HTTP_${response.status}`;
258
+ throw workerHttpError(code, response.status);
259
+ }
260
+ return value;
261
+ }
262
+ catch (cause) {
263
+ if (controller.signal.aborted)
264
+ throw workerHttpError('WORKER_HTTP_TIMEOUT');
265
+ if (cause instanceof Error && 'code' in cause && typeof cause.code === 'string' && 'retryable' in cause && typeof cause.retryable === 'boolean')
266
+ throw cause;
267
+ throw Object.assign(workerHttpError('WORKER_HTTP_NETWORK_ERROR'), { cause });
268
+ }
269
+ finally {
270
+ clearTimeout(timeout);
271
+ }
272
+ }
273
+ async register() { await this.request('/workers/register', { workerId: this.workerId }); }
274
+ async unregister() { await this.request('/workers/unregister', { workerId: this.workerId }); }
275
+ async claim() {
276
+ const result = await this.request('/tasks/claim', { workerId: this.workerId });
277
+ if (result === null)
278
+ return undefined;
279
+ if (!validWorkerLease(result))
280
+ throw new Error('WORKER_HTTP_INVALID_LEASE');
281
+ return result;
282
+ }
283
+ async renew(leaseId, leaseMs) {
284
+ requiredString(leaseId, 'INVALID_WORKER_LEASE');
285
+ if (leaseMs !== undefined && (!Number.isFinite(leaseMs) || leaseMs <= 0))
286
+ throw new Error('INVALID_WORKER_LEASE');
287
+ const result = await this.request('/tasks/renew', { workerId: this.workerId, leaseId, ...(leaseMs === undefined ? {} : { leaseMs }) });
288
+ const expiresAt = object(result).leaseExpiresAt;
289
+ if (typeof expiresAt !== 'number' || !Number.isFinite(expiresAt))
290
+ throw new Error('WORKER_HTTP_INVALID_LEASE');
291
+ return expiresAt;
292
+ }
293
+ async complete(leaseId, value) { await this.request('/tasks/complete', { workerId: this.workerId, leaseId, value }); }
294
+ async fail(leaseId, error) { await this.request('/tasks/fail', { workerId: this.workerId, leaseId, error: error }); }
295
+ async cancel(taskId) { return Boolean(object(await this.request('/tasks/cancel', { taskId })).cancelled); }
296
+ async get(taskId) {
297
+ requiredString(taskId, 'WORKER_HTTP_TASK_ID_REQUIRED');
298
+ const result = await this.request('/tasks/get', { taskId });
299
+ if (result === null)
300
+ return undefined;
301
+ if (!validWorkerTask(result))
302
+ throw new Error('WORKER_HTTP_INVALID_TASK');
303
+ return result;
304
+ }
305
+ async submit(payload, options = {}) {
306
+ const taskId = options.taskId ?? `http-worker-task-${this.sequence++}`;
307
+ await this.request('/tasks/submit', { taskId, payload, ...(options.idempotencyKey === undefined ? {} : { idempotencyKey: options.idempotencyKey }), ...(options.leaseMs === undefined ? {} : { leaseMs: options.leaseMs }) });
308
+ if (options.signal?.aborted) {
309
+ await this.cancel(taskId);
310
+ throw workerControlError('WORKER_CANCELLED');
311
+ }
312
+ let abort;
313
+ const cancellation = options.signal === undefined ? undefined : new Promise((_, reject) => {
314
+ abort = () => { void this.cancel(taskId).catch(() => undefined); reject(workerControlError('WORKER_CANCELLED')); };
315
+ options.signal.addEventListener('abort', abort, { once: true });
316
+ });
317
+ const poll = async () => {
318
+ while (true) {
319
+ const task = await this.get(taskId);
320
+ if (task?.state === 'succeeded')
321
+ return task.result ?? null;
322
+ if (task?.state === 'failed')
323
+ throw workerTaskError(task.error);
324
+ if (task?.state === 'cancelled')
325
+ throw workerControlError('WORKER_CANCELLED');
326
+ await new Promise((resolve) => setTimeout(resolve, this.pollMs));
327
+ }
328
+ };
329
+ try {
330
+ return await (cancellation === undefined ? poll() : Promise.race([poll(), cancellation]));
331
+ }
332
+ finally {
333
+ if (options.signal !== undefined && abort !== undefined)
334
+ options.signal.removeEventListener('abort', abort);
335
+ }
336
+ }
337
+ }
338
+ export async function startHttpWorker(client, handler, options = {}) {
339
+ await client.register();
340
+ const stopping = new AbortController();
341
+ const onAbort = () => stopping.abort();
342
+ options.signal?.addEventListener('abort', onAbort, { once: true });
343
+ const loop = (async () => {
344
+ while (!stopping.signal.aborted) {
345
+ try {
346
+ const lease = await client.claim();
347
+ if (lease === undefined) {
348
+ await new Promise((resolve) => setTimeout(resolve, 10));
349
+ continue;
350
+ }
351
+ const taskController = new AbortController();
352
+ const stopTask = () => taskController.abort();
353
+ stopping.signal.addEventListener('abort', stopTask, { once: true });
354
+ const renewMs = options.renewMs ?? Math.max(1, Math.floor((lease.task.leaseMs ?? 30_000) / 3));
355
+ const timer = setInterval(() => { void client.renew(lease.leaseId, lease.task.leaseMs).catch(() => undefined); }, renewMs);
356
+ try {
357
+ const value = await handler(lease.task.payload, taskController.signal);
358
+ if (!stopping.signal.aborted)
359
+ await client.complete(lease.leaseId, value);
360
+ }
361
+ catch (cause) {
362
+ if (!stopping.signal.aborted)
363
+ await client.fail(lease.leaseId, workerRuntimeError(cause));
364
+ }
365
+ finally {
366
+ clearInterval(timer);
367
+ stopping.signal.removeEventListener('abort', stopTask);
368
+ }
369
+ }
370
+ catch {
371
+ if (!stopping.signal.aborted)
372
+ await new Promise((resolve) => setTimeout(resolve, 10));
373
+ }
374
+ }
375
+ })();
376
+ return { stop: async () => { stopping.abort(); await loop; options.signal?.removeEventListener('abort', onAbort); await client.unregister(); } };
377
+ }
378
+ export function createHttpWorkerEffectExecutor(client, options = {}) {
379
+ return async (effect, signal) => {
380
+ const taskId = `${effect.id}:${effect.attemptId}`;
381
+ const idempotencyKey = effect.idempotencyKey ?? taskId;
382
+ const executionRef = { transport: 'http-worker', taskId, idempotencyKey };
383
+ const sideEffectState = isSideEffectful(effect.sideEffectPolicy) ? 'applied' : 'none';
384
+ try {
385
+ const value = await client.submit({ effectId: effect.id, attemptId: effect.attemptId, kind: effect.kind, input: effect.input }, { taskId, idempotencyKey, ...(options.leaseMs === undefined ? {} : { leaseMs: options.leaseMs }), signal });
386
+ return { value, executionState: 'succeeded', sideEffectState, executionRef };
387
+ }
388
+ catch (cause) {
389
+ const message = cause instanceof Error ? cause.message : String(cause);
390
+ if (message === 'WORKER_CANCELLED' || message === 'WORKER_FAILED' || /^WORKER_HTTP_4\d\d$/.test(message) || (cause && typeof cause === 'object' && 'retryable' in cause && cause.retryable === false))
391
+ throw cause;
392
+ const task = await client.get(taskId).catch(() => undefined);
393
+ if (task?.state === 'succeeded')
394
+ return { value: task.result ?? null, executionState: 'succeeded', sideEffectState, executionRef };
395
+ if (task?.state === 'failed')
396
+ return { value: null, status: 'failed', executionState: 'failed', sideEffectState: 'none', executionRef, error: task.error ?? { code: 'WORKER_FAILED', message: 'Remote Worker task failed.' } };
397
+ if (task?.state === 'cancelled')
398
+ return { value: null, status: 'cancelled', executionState: 'failed', sideEffectState: 'none', executionRef, error: { code: 'WORKER_CANCELLED', message: 'Remote Worker task was cancelled.' } };
399
+ return { value: null, executionState: 'remote_unknown', sideEffectState: isSideEffectful(effect.sideEffectPolicy) ? 'unknown' : 'none', executionRef, error: { code: 'WORKER_EXECUTION_UNKNOWN', message: `Remote Worker task outcome is unknown: ${message}` } };
400
+ }
401
+ };
402
+ }
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "@hunterzhu/pulse-adapters",
3
+ "version": "0.1.0",
4
+ "repository": {
5
+ "type": "git",
6
+ "url": "https://github.com/zhuhengtan/Pulse"
7
+ },
8
+ "type": "module",
9
+ "main": "dist/index.js",
10
+ "types": "dist/index.d.ts",
11
+ "scripts": {
12
+ "build": "tsc -p tsconfig.json"
13
+ },
14
+ "publishConfig": {
15
+ "access": "public",
16
+ "registry": "https://registry.npmjs.org"
17
+ },
18
+ "dependencies": {
19
+ "@hunterzhu/pulse-runtime": "0.1.0",
20
+ "@hunterzhu/pulse-tool-sdk": "0.1.0"
21
+ }
22
+ }