@johannc-dev/zyrk 0.0.0-stage → 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/client.js ADDED
@@ -0,0 +1,416 @@
1
+ import { Cause, Config, Context, Effect, Exit, Layer, Option, Redacted, Schema, } from "effect";
2
+ import { decode, Job, Json, ProgramSummary, Started, toJobResult, } from "./contracts.js";
3
+ import { ApiError, ConfigError, DecodeError, ExecutionEnded, ExecutionFailed, Failure, messageOf, NetworkError, WaitTimeout, } from "./errors.js";
4
+ export * from "./errors.js";
5
+ const ServerError = Schema.Struct({
6
+ error: Schema.Union([Schema.String, Failure]),
7
+ message: Schema.optionalKey(Schema.String),
8
+ });
9
+ const apiError = (status, body) => {
10
+ const parsed = Schema.decodeUnknownOption(ServerError)(body);
11
+ if (Option.isNone(parsed))
12
+ return new ApiError({
13
+ status,
14
+ code: `http_${status}`,
15
+ message: `HTTP ${status}`,
16
+ body,
17
+ });
18
+ const { error, message } = parsed.value;
19
+ return typeof error === "string"
20
+ ? new ApiError({ status, code: error, message: message ?? error, body })
21
+ : new ApiError({
22
+ status,
23
+ code: error.code,
24
+ message: error.message,
25
+ failure: error,
26
+ body,
27
+ });
28
+ };
29
+ const parseJson = (text) => {
30
+ if (text === "")
31
+ return Option.some(null);
32
+ try {
33
+ return Option.some(JSON.parse(text));
34
+ }
35
+ catch {
36
+ return Option.none();
37
+ }
38
+ };
39
+ const segment = encodeURIComponent;
40
+ const isFinal = (r) => r.status !== "queued" && r.status !== "running";
41
+ export const make = (config) => {
42
+ const base = `${config.url.replace(/\/$/, "")}/v1/functions/`;
43
+ const request = Effect.fnUntraced(function* (method, path, body) {
44
+ const url = base + path;
45
+ const response = yield* Effect.tryPromise({
46
+ try: async (signal) => {
47
+ const response = await fetch(url, {
48
+ method,
49
+ headers: {
50
+ authorization: `Bearer ${config.apiKey}`,
51
+ "content-type": "application/json",
52
+ },
53
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
54
+ redirect: "error",
55
+ signal,
56
+ });
57
+ return {
58
+ status: response.status,
59
+ ok: response.ok,
60
+ text: await response.text(),
61
+ };
62
+ },
63
+ catch: (cause) => new NetworkError({
64
+ url,
65
+ message: `${method} ${url}: ${messageOf(cause)}`,
66
+ cause,
67
+ }),
68
+ });
69
+ const parsed = parseJson(response.text);
70
+ if (!response.ok)
71
+ return yield* apiError(response.status, Option.getOrElse(parsed, () => response.text));
72
+ if (Option.isNone(parsed))
73
+ return yield* new DecodeError({
74
+ what: `${method} ${path}`,
75
+ message: "Response body is not JSON",
76
+ });
77
+ return yield* decode(Json, `${method} ${path}`)(parsed.value);
78
+ });
79
+ const execution = (id) => request("GET", `executions/${segment(id)}`).pipe(Effect.flatMap(decode(Job, "execution")));
80
+ const result = (id) => {
81
+ const follow = (current, hops) => execution(current).pipe(Effect.flatMap((job) => job.status === "continued" && hops < 10_000
82
+ ? follow(job.nextExecutionId, hops + 1)
83
+ : Effect.succeed({ ...toJobResult(job), id })));
84
+ return follow(id, 0);
85
+ };
86
+ const poll = (check, options, onTimeout) => {
87
+ const deadline = options.timeoutMs === undefined
88
+ ? Infinity
89
+ : Date.now() + options.timeoutMs;
90
+ const step = check.pipe(Effect.flatMap(Option.match({
91
+ onSome: Effect.succeed,
92
+ onNone: () => {
93
+ const left = deadline - Date.now();
94
+ if (left <= 0)
95
+ return onTimeout();
96
+ return Effect.sleep(Math.min(options.pollMs ?? 1000, left)).pipe(Effect.flatMap(() => step));
97
+ },
98
+ })));
99
+ return step;
100
+ };
101
+ const wait = (id, options = {}) => poll(result(id).pipe(Effect.flatMap((r) => {
102
+ switch (r.status) {
103
+ case "succeeded":
104
+ return Effect.succeed(Option.some(r.result));
105
+ case "failed":
106
+ return Effect.fail(new ExecutionFailed({ executionId: id, failure: r.error }));
107
+ case "cancelled":
108
+ case "expired":
109
+ case "acknowledged":
110
+ return Effect.fail(new ExecutionEnded({ executionId: id, status: r.status }));
111
+ case "queued":
112
+ case "running":
113
+ return Effect.succeed(Option.none());
114
+ }
115
+ })), options, () => Effect.fail(new WaitTimeout({
116
+ executionId: id,
117
+ timeoutMs: options.timeoutMs ?? 0,
118
+ })));
119
+ const waitAll = (ids, options = {}) => {
120
+ const latest = new Map();
121
+ const snapshot = () => ids.map((id) => latest.get(id));
122
+ const check = Effect.forEach(ids.filter((id) => !latest.has(id) || !isFinal(latest.get(id))), (id) => result(id).pipe(Effect.tap((r) => Effect.sync(() => latest.set(id, r)))), { concurrency: 8 }).pipe(Effect.map(() => ids.every((id) => isFinal(latest.get(id)))
123
+ ? Option.some(snapshot())
124
+ : Option.none()));
125
+ return poll(check, options, () => Effect.sync(snapshot));
126
+ };
127
+ const start = (program, input) => request("POST", `programs/${segment(program)}/invoke`, {
128
+ ...input,
129
+ requestId: input.requestId ?? crypto.randomUUID(),
130
+ }).pipe(Effect.flatMap(decode(Started, "start response")), Effect.map((started) => started.id));
131
+ return {
132
+ request,
133
+ deploy: (program, code, options = {}) => request("PUT", `programs/${segment(program)}`, {
134
+ code,
135
+ force: options.force ?? false,
136
+ }),
137
+ programs: () => request("GET", "programs").pipe(Effect.flatMap(decode(Schema.Array(ProgramSummary), "programs"))),
138
+ inspect: (program) => request("GET", `programs/${segment(program)}`),
139
+ pause: (program, paused = true) => request("POST", `programs/${segment(program)}/pause`, { paused }),
140
+ executions: (program) => request("GET", `programs/${segment(program)}/executions`),
141
+ start,
142
+ execution,
143
+ result,
144
+ wait,
145
+ waitAll,
146
+ run: (program, input, options) => start(program, input).pipe(Effect.flatMap((id) => wait(id, options))),
147
+ acknowledge: (id) => request("POST", `executions/${segment(id)}/ack`, {}),
148
+ cancel: (id) => request("POST", `executions/${segment(id)}/cancel`, {}),
149
+ events: (afterSeq) => request("GET", afterSeq === undefined ? "events" : `events?after_seq=${afterSeq}`),
150
+ measurements: () => request("GET", "measurements"),
151
+ webhook: () => request("GET", "webhook"),
152
+ setWebhook: (config) => request("PUT", "webhook", config),
153
+ deleteWebhook: () => request("DELETE", "webhook"),
154
+ rotateWebhookSecret: (options = {}) => request("POST", "webhook/rotate", options),
155
+ testWebhook: () => request("POST", "webhook/test", {}),
156
+ webhookDeliveries: (limit) => request("GET", limit === undefined
157
+ ? "webhook/deliveries"
158
+ : `webhook/deliveries?limit=${limit}`),
159
+ };
160
+ };
161
+ export const clientConfig = Config.all({
162
+ url: Config.String("TH_API_URL"),
163
+ apiKey: Config.Redacted("TH_API_KEY"),
164
+ }).pipe(Effect.map(({ url, apiKey }) => ({ url, apiKey: Redacted.value(apiKey) })), Effect.mapError((error) => new ConfigError({
165
+ message: `Set TH_API_URL and TH_API_KEY: ${error.message.split("\n")[0]}`,
166
+ })));
167
+ export class ZyrkClient extends Context.Service()("@johannc-dev/zyrk/ZyrkClient") {
168
+ static layer = (config) => Layer.succeed(ZyrkClient, make(config));
169
+ static layerConfig = Layer.effect(ZyrkClient, Effect.map(clientConfig, make));
170
+ }
171
+ const runPromise = async (effect, signal) => {
172
+ signal?.throwIfAborted();
173
+ const exit = await Effect.runPromiseExit(effect, { signal });
174
+ if (Exit.isSuccess(exit))
175
+ return exit.value;
176
+ if (signal?.aborted && Cause.hasInterrupts(exit.cause))
177
+ throw signal.reason;
178
+ throw Cause.squash(exit.cause);
179
+ };
180
+ export class FunctionsClient {
181
+ client;
182
+ config;
183
+ constructor(config) {
184
+ this.client = make(config);
185
+ this.config = config;
186
+ }
187
+ storage(area) {
188
+ return httpStorageArea(this.config, area);
189
+ }
190
+ request(method, path, body, signal) {
191
+ return runPromise(this.client.request(method, path, body), signal);
192
+ }
193
+ deploy(program, code, options = {}) {
194
+ return runPromise(this.client.deploy(program, code, options), options.signal);
195
+ }
196
+ programs(signal) {
197
+ return runPromise(this.client.programs(), signal);
198
+ }
199
+ inspect(program, signal) {
200
+ return runPromise(this.client.inspect(program), signal);
201
+ }
202
+ pause(program, paused = true, signal) {
203
+ return runPromise(this.client.pause(program, paused), signal);
204
+ }
205
+ executions(program, signal) {
206
+ return runPromise(this.client.executions(program), signal);
207
+ }
208
+ start(program, input, signal) {
209
+ return runPromise(this.client.start(program, input), signal);
210
+ }
211
+ execution(id, signal) {
212
+ return runPromise(this.client.execution(id), signal);
213
+ }
214
+ result(id, signal) {
215
+ return runPromise(this.client.result(id), signal);
216
+ }
217
+ wait(id, options = {}) {
218
+ return runPromise(this.client.wait(id, options), options.signal);
219
+ }
220
+ waitAll(ids, options = {}) {
221
+ return runPromise(this.client.waitAll(ids, options), options.signal);
222
+ }
223
+ run(program, input, options = {}) {
224
+ return runPromise(this.client.run(program, input, options), options.signal);
225
+ }
226
+ acknowledge(id, signal) {
227
+ return runPromise(this.client.acknowledge(id), signal);
228
+ }
229
+ cancel(id, signal) {
230
+ return runPromise(this.client.cancel(id), signal);
231
+ }
232
+ events(afterSeq, signal) {
233
+ return runPromise(this.client.events(afterSeq), signal);
234
+ }
235
+ measurements(signal) {
236
+ return runPromise(this.client.measurements(), signal);
237
+ }
238
+ webhook(signal) {
239
+ return runPromise(this.client.webhook(), signal);
240
+ }
241
+ setWebhook(config, signal) {
242
+ return runPromise(this.client.setWebhook(config), signal);
243
+ }
244
+ deleteWebhook(signal) {
245
+ return runPromise(this.client.deleteWebhook(), signal);
246
+ }
247
+ rotateWebhookSecret(options = {}, signal) {
248
+ return runPromise(this.client.rotateWebhookSecret(options), signal);
249
+ }
250
+ testWebhook(signal) {
251
+ return runPromise(this.client.testWebhook(), signal);
252
+ }
253
+ webhookDeliveries(limit, signal) {
254
+ return runPromise(this.client.webhookDeliveries(limit), signal);
255
+ }
256
+ replay(program, request, signal) {
257
+ return this.request("POST", `programs/${encodeURIComponent(program)}/replay`, request, signal);
258
+ }
259
+ replayStatus(id, signal) {
260
+ return this.request("GET", `replays/${encodeURIComponent(id)}`, undefined, signal);
261
+ }
262
+ cancelReplay(id, signal) {
263
+ return this.request("POST", `replays/${encodeURIComponent(id)}/cancel`, {}, signal);
264
+ }
265
+ replayExecutions(id, after = 0, signal) {
266
+ return this.request("GET", `replays/${encodeURIComponent(id)}/executions?after=${after}`, undefined, signal);
267
+ }
268
+ replayEmits(id, afterSeq = 0, signal) {
269
+ return this.request("GET", `replays/${encodeURIComponent(id)}/emits?after_seq=${afterSeq}`, undefined, signal);
270
+ }
271
+ }
272
+ export const storageKeyPath = (key) => {
273
+ const parts = key.split("/");
274
+ if (key === "" || parts.some((p) => p === "" || p === "." || p === ".."))
275
+ throw new ApiError({
276
+ status: 400,
277
+ code: "invalid_request",
278
+ message: "Over HTTP, a key cannot have empty, '.' or '..' parts",
279
+ body: null,
280
+ });
281
+ return parts.map(encodeURIComponent).join("/");
282
+ };
283
+ const httpStorageArea = (config, area) => {
284
+ const base = `${config.url.replace(/\/$/, "")}/v1/functions/storage/`;
285
+ const call = async (method, path, init = {}) => {
286
+ const url = base + path;
287
+ try {
288
+ return await fetch(url, {
289
+ method,
290
+ headers: { authorization: `Bearer ${config.apiKey}`, ...init.headers },
291
+ ...(init.body === undefined ? {} : { body: init.body }),
292
+ redirect: "error",
293
+ ...(init.signal ? { signal: init.signal } : {}),
294
+ });
295
+ }
296
+ catch (cause) {
297
+ throw new NetworkError({
298
+ url,
299
+ message: `${method} ${url}: ${messageOf(cause)}`,
300
+ cause,
301
+ });
302
+ }
303
+ };
304
+ const fail = async (response) => {
305
+ const text = await response.text();
306
+ throw apiError(response.status, Option.getOrElse(parseJson(text), () => text));
307
+ };
308
+ const json = async (response) => {
309
+ if (!response.ok)
310
+ return fail(response);
311
+ return (await response.json());
312
+ };
313
+ const headOf = (key, response) => {
314
+ const h = response.headers;
315
+ const metadata = {};
316
+ h.forEach((value, name) => {
317
+ if (name.startsWith("x-meta-"))
318
+ metadata[name.slice(7)] = value;
319
+ });
320
+ const range = h.get("content-range");
321
+ const total = range
322
+ ? Number(range.split("/")[1])
323
+ : Number(h.get("content-length") ?? 0);
324
+ return {
325
+ key,
326
+ size: total,
327
+ contentType: h.get("content-type") ?? "application/octet-stream",
328
+ metadata,
329
+ lastModified: h.get("last-modified") ?? "",
330
+ expiresAt: h.get("x-expires-at"),
331
+ etag: (h.get("etag") ?? "").replace(/^"|"$/g, ""),
332
+ };
333
+ };
334
+ const objectPath = (key) => `${area}/${storageKeyPath(key)}`;
335
+ return {
336
+ get: async (key, options = {}) => {
337
+ if (options.length === 0) {
338
+ const response = await call("HEAD", objectPath(key), {
339
+ signal: options.signal,
340
+ });
341
+ if (response.status === 404)
342
+ return null;
343
+ if (!response.ok)
344
+ return fail(response);
345
+ return { ...headOf(key, response), body: new Uint8Array(0) };
346
+ }
347
+ const headers = {};
348
+ if (options.offset !== undefined || options.length !== undefined) {
349
+ const start = options.offset ?? 0;
350
+ headers.range =
351
+ options.length === undefined
352
+ ? `bytes=${start}-`
353
+ : `bytes=${start}-${start + options.length - 1}`;
354
+ }
355
+ const response = await call("GET", objectPath(key), {
356
+ headers,
357
+ signal: options.signal,
358
+ });
359
+ if (response.status === 404)
360
+ return null;
361
+ if (!response.ok)
362
+ return fail(response);
363
+ return {
364
+ ...headOf(key, response),
365
+ body: new Uint8Array(await response.arrayBuffer()),
366
+ };
367
+ },
368
+ head: async (key, signal) => {
369
+ const response = await call("HEAD", objectPath(key), { signal });
370
+ if (response.status === 404)
371
+ return null;
372
+ if (!response.ok)
373
+ return fail(response);
374
+ return headOf(key, response);
375
+ },
376
+ put: async (key, body, options = {}) => {
377
+ const headers = {
378
+ "content-type": options.contentType ??
379
+ (typeof body === "string"
380
+ ? "text/plain; charset=utf-8"
381
+ : "application/octet-stream"),
382
+ };
383
+ for (const [name, value] of Object.entries(options.metadata ?? {}))
384
+ headers[`x-meta-${name}`] = value;
385
+ if (options.ifAbsent)
386
+ headers["if-none-match"] = "*";
387
+ if (options.ifMatch !== undefined)
388
+ headers["if-match"] = `"${options.ifMatch}"`;
389
+ return json(await call("PUT", objectPath(key), {
390
+ headers,
391
+ body: body,
392
+ signal: options.signal,
393
+ }));
394
+ },
395
+ delete: async (key, options = {}) => {
396
+ const headers = {};
397
+ if (options.ifMatch !== undefined)
398
+ headers["if-match"] = `"${options.ifMatch}"`;
399
+ const response = await call("DELETE", objectPath(key), {
400
+ headers,
401
+ signal: options.signal,
402
+ });
403
+ if (!response.ok)
404
+ await fail(response);
405
+ },
406
+ list: async (prefix = "", options = {}) => {
407
+ const query = new URLSearchParams({ prefix });
408
+ if (options.cursor)
409
+ query.set("cursor", options.cursor);
410
+ if (options.limit !== undefined)
411
+ query.set("limit", String(options.limit));
412
+ return (await json(await call("GET", `${area}?${query}`, { signal: options.signal })));
413
+ },
414
+ usage: async (signal) => json(await call("GET", "usage", { signal })),
415
+ };
416
+ };