anbaric-impl-cloud 1.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 ADDED
@@ -0,0 +1,31 @@
1
+ # anbaric-impl-cloud
2
+
3
+ The client library for a hosted Anbaric platform: HTTP implementations of
4
+ the [`anbaric-tsapi`](https://npmjs.com/package/anbaric-tsapi) contracts.
5
+ App code should not construct these directly — the factories in
6
+ `anbaric-state-machine` and `anbaric-data-store` select them automatically
7
+ when the platform injects the `cloud` environment.
8
+
9
+ All clients default their base URL to `ANBARIC_CLOUD_URL`. On a platform
10
+ this points at the platform's **internal entry point**, which serves only
11
+ the workflow APIs and is unreachable from outside the deployment's network.
12
+
13
+ | Class | Contract | Talks to |
14
+ | --- | --- | --- |
15
+ | `CloudJobPersistence` | `JobPersistence` | `/jobs` |
16
+ | `CloudQueue` | `Queue` | `/queue/enqueue`, `/queue/schedule` |
17
+ | `CloudJsonStore` | `JsonStore` | `/documents/<collection>` (validates against its schema client-side first) |
18
+ | `CloudSecretStore` | `SecretStore` | `/secrets` |
19
+ | `CloudConsumer` | `Consumer` | listens on `ANBARIC_CONSUMER_PORT` for `POST /process` pushes |
20
+
21
+ `CloudConsumer` is the push half of the platform's at-least-once delivery:
22
+ the platform's dispatcher POSTs `{ messages }` to the app's consumer URL,
23
+ the consumer responds `202` immediately, routes each message to its
24
+ workflow's subscriber, and confirms each successfully processed message back
25
+ via `POST /queue/confirm`. Unconfirmed messages are redelivered after the
26
+ platform's lease expires, so job processing must be idempotent.
27
+
28
+ Environment (all injected by the platform into deployed apps):
29
+ `ANBARIC_CLOUD_URL`, `ANBARIC_CONSUMER_PORT`, `ANBARIC_CONSUMER_URL` (how
30
+ the platform reaches the app back), plus the four `ANBARIC_*_TYPE=cloud`
31
+ factory switches.
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "anbaric-impl-cloud",
3
+ "version": "1.5.0",
4
+ "description": "Public client library for the Anbaric Cloud hosting API, providing JobPersistence and Queue implementations backed by the cloud service",
5
+ "license": "MIT",
6
+ "author": "chris@anbaric.ai",
7
+ "type": "module",
8
+ "main": "src/index.ts",
9
+ "types": "src/index.ts",
10
+ "scripts": {
11
+ "test": "vitest run"
12
+ },
13
+ "dependencies": {
14
+ "anbaric-tsapi": "^1.5.0"
15
+ },
16
+ "devDependencies": {
17
+ "@types/node": "^26.2.0",
18
+ "typescript": "^7.0.2"
19
+ },
20
+ "files": [
21
+ "src"
22
+ ]
23
+ }
@@ -0,0 +1,30 @@
1
+ const DEFAULT_BASE_URL = "http://localhost:8787";
2
+
3
+ class CloudApiClient {
4
+
5
+ constructor(private baseUrl : string) {}
6
+
7
+ static defaultBaseUrl() : string {
8
+ return process.env.ANBARIC_CLOUD_URL ?? DEFAULT_BASE_URL;
9
+ }
10
+
11
+ async request(method : string, path : string, body? : unknown) : Promise<any> {
12
+ const response = await fetch(`${this.baseUrl}${path}`, {
13
+ method,
14
+ headers: body === undefined ? undefined : { "content-type": "application/json" },
15
+ body: body === undefined ? undefined : JSON.stringify(body),
16
+ });
17
+
18
+ if (!response.ok) {
19
+ const problem = await response.json().catch(() => ({}));
20
+ throw new Error(problem.error ?? `${method} ${path} failed with status ${response.status}`);
21
+ }
22
+
23
+ if (response.status === 204) return undefined;
24
+
25
+ return response.json();
26
+ }
27
+
28
+ }
29
+
30
+ export { CloudApiClient }
@@ -0,0 +1,26 @@
1
+ import {Actor, AuditChange, Auditor} from "anbaric-tsapi";
2
+ import {CloudApiClient} from "./CloudApiClient";
3
+
4
+ class CloudAuditor implements Auditor {
5
+
6
+ private client : CloudApiClient;
7
+
8
+ constructor(baseUrl : string = CloudApiClient.defaultBaseUrl()) {
9
+ this.client = new CloudApiClient(baseUrl);
10
+ }
11
+
12
+ async audit(jobId : string, actor : Actor, change : AuditChange,
13
+ changeDescription : string, details : any) : Promise<void> {
14
+ await this.client.request("POST", "/audits", {
15
+ jobId,
16
+ actorId: actor.id,
17
+ actorType: actor.type,
18
+ change,
19
+ description: changeDescription,
20
+ details: details ?? null,
21
+ });
22
+ }
23
+
24
+ }
25
+
26
+ export { CloudAuditor }
@@ -0,0 +1,36 @@
1
+ import {Job, JobPersistence, SerializedJob, deserializeJob, serializeJob} from "anbaric-tsapi";
2
+ import {CloudApiClient} from "./CloudApiClient";
3
+
4
+ class CloudJobPersistence implements JobPersistence {
5
+
6
+ private client : CloudApiClient;
7
+
8
+ constructor(baseUrl : string = CloudApiClient.defaultBaseUrl()) {
9
+ this.client = new CloudApiClient(baseUrl);
10
+ }
11
+
12
+ async save(job : Job) : Promise<void> {
13
+ await this.client.request("PUT", `/jobs/${encodeURIComponent(job.id)}`, serializeJob(job));
14
+ }
15
+
16
+ async retrieve(id : string) : Promise<Job> {
17
+ const serialized = await this.client.request("GET", `/jobs/${encodeURIComponent(id)}`) as SerializedJob;
18
+ return deserializeJob(serialized);
19
+ }
20
+
21
+ async delete(id : string) : Promise<void> {
22
+ await this.client.request("DELETE", `/jobs/${encodeURIComponent(id)}`);
23
+ }
24
+
25
+ async list(pageSize : number = 100, page : number = 0) : Promise<Array<Job>> {
26
+ const serialized = await this.client.request("GET", `/jobs?pageSize=${pageSize}&page=${page}`) as Array<SerializedJob>;
27
+ return serialized.map(deserializeJob);
28
+ }
29
+
30
+ async updateProperties(id : string, properties : Map<string, any>) : Promise<void> {
31
+ await this.client.request("PATCH", `/jobs/${encodeURIComponent(id)}/properties`, Object.fromEntries(properties));
32
+ }
33
+
34
+ }
35
+
36
+ export { CloudJobPersistence }
@@ -0,0 +1,41 @@
1
+ import {JsonSchema, JsonStore, validateDocument} from "anbaric-tsapi";
2
+ import {CloudApiClient} from "./CloudApiClient";
3
+
4
+ class CloudJsonStore implements JsonStore {
5
+
6
+ private client : CloudApiClient;
7
+
8
+ constructor(private collection : string, private schema? : JsonSchema,
9
+ baseUrl : string = CloudApiClient.defaultBaseUrl()) {
10
+ this.client = new CloudApiClient(baseUrl);
11
+ }
12
+
13
+ async save(id : string, document : any) : Promise<void> {
14
+ if (this.schema) {
15
+ const violations = validateDocument(document, this.schema);
16
+ if (violations.length > 0) {
17
+ throw new Error(`Document "${id}" failed schema validation: ${violations.join("; ")}`);
18
+ }
19
+ }
20
+ await this.client.request("PUT", this.documentPath(id), document);
21
+ }
22
+
23
+ async retrieve(id : string) : Promise<any> {
24
+ return this.client.request("GET", this.documentPath(id));
25
+ }
26
+
27
+ async delete(id : string) : Promise<void> {
28
+ await this.client.request("DELETE", this.documentPath(id));
29
+ }
30
+
31
+ async list(pageSize : number = 100, page : number = 0) : Promise<Array<any>> {
32
+ return this.client.request("GET", `/documents/${encodeURIComponent(this.collection)}?pageSize=${pageSize}&page=${page}`);
33
+ }
34
+
35
+ private documentPath(id : string) : string {
36
+ return `/documents/${encodeURIComponent(this.collection)}/${encodeURIComponent(id)}`;
37
+ }
38
+
39
+ }
40
+
41
+ export { CloudJsonStore }
@@ -0,0 +1,22 @@
1
+ import {Queue} from "anbaric-tsapi";
2
+ import {CloudApiClient} from "./CloudApiClient";
3
+
4
+ class CloudQueue implements Queue {
5
+
6
+ private client : CloudApiClient;
7
+
8
+ constructor(baseUrl : string = CloudApiClient.defaultBaseUrl()) {
9
+ this.client = new CloudApiClient(baseUrl);
10
+ }
11
+
12
+ async enqueue(jobId : string, workflowId : string) : Promise<void> {
13
+ await this.client.request("POST", "/queue/enqueue", { jobId, workflowId });
14
+ }
15
+
16
+ async schedule(jobId : string, workflowId : string, due : Date) : Promise<void> {
17
+ await this.client.request("POST", "/queue/schedule", { jobId, workflowId, due: due.toISOString() });
18
+ }
19
+
20
+ }
21
+
22
+ export { CloudQueue }
@@ -0,0 +1,35 @@
1
+ import {SecretStore} from "anbaric-tsapi";
2
+ import {CloudApiClient} from "./CloudApiClient";
3
+
4
+ class CloudSecretStore implements SecretStore {
5
+
6
+ private client : CloudApiClient;
7
+
8
+ constructor(baseUrl : string = CloudApiClient.defaultBaseUrl()) {
9
+ this.client = new CloudApiClient(baseUrl);
10
+ }
11
+
12
+ async save(name : string, value : string) : Promise<void> {
13
+ await this.client.request("PUT", this.secretPath(name), { value });
14
+ }
15
+
16
+ async retrieve(name : string) : Promise<string> {
17
+ const secret = await this.client.request("GET", this.secretPath(name)) as { value : string };
18
+ return secret.value;
19
+ }
20
+
21
+ async delete(name : string) : Promise<void> {
22
+ await this.client.request("DELETE", this.secretPath(name));
23
+ }
24
+
25
+ async list() : Promise<Array<string>> {
26
+ return this.client.request("GET", "/secrets");
27
+ }
28
+
29
+ private secretPath(name : string) : string {
30
+ return `/secrets/${encodeURIComponent(name)}`;
31
+ }
32
+
33
+ }
34
+
35
+ export { CloudSecretStore }
@@ -0,0 +1,119 @@
1
+ import {createServer, IncomingMessage, Server, ServerResponse} from "node:http";
2
+ import {AddressInfo} from "node:net";
3
+ import {Consumer, QueueMessage} from "anbaric-tsapi";
4
+ import {CloudApiClient} from "./CloudApiClient";
5
+
6
+ type ProcessJob = (jobId : string) => Promise<void>;
7
+
8
+ const readBody = (request : IncomingMessage) : Promise<any> =>
9
+ new Promise((resolve, reject) => {
10
+ const chunks : Array<Buffer> = [];
11
+ request.on("data", chunk => chunks.push(chunk));
12
+ request.on("error", reject);
13
+ request.on("end", () => {
14
+ const raw = Buffer.concat(chunks).toString();
15
+ try {
16
+ resolve(raw.length === 0 ? undefined : JSON.parse(raw));
17
+ } catch (error) {
18
+ reject(error);
19
+ }
20
+ });
21
+ });
22
+
23
+ class PushConsumer implements Consumer {
24
+
25
+ private subscribers = new Map<string, ProcessJob>();
26
+ private client : CloudApiClient;
27
+ private server? : Server;
28
+ private listening? : Promise<void>;
29
+ private boundPort? : number;
30
+
31
+ constructor(baseUrl : string = CloudApiClient.defaultBaseUrl(),
32
+ private listenPort : number = Number(process.env.ANBARIC_CONSUMER_PORT ?? 8788)) {
33
+ this.client = new CloudApiClient(baseUrl);
34
+ }
35
+
36
+ get port() : number | undefined {
37
+ return this.boundPort;
38
+ }
39
+
40
+ subscribe(workflowId : string, processJob : ProcessJob) : void {
41
+ this.subscribers.set(workflowId, processJob);
42
+ if (!this.server) this.listening = this.listen();
43
+ void this.register(workflowId);
44
+ }
45
+
46
+ private async register(workflowId : string) : Promise<void> {
47
+ try {
48
+ await this.listening;
49
+ const url = process.env.ANBARIC_CONSUMER_URL ?? `http://localhost:${this.boundPort}`;
50
+ await this.client.request("POST", "/consumers", { workflowId, url });
51
+ } catch {
52
+ }
53
+ }
54
+
55
+ async cleanUp() : Promise<void> {
56
+ this.subscribers.clear();
57
+ if (!this.server) return;
58
+ await this.listening;
59
+ await new Promise<void>((resolve, reject) =>
60
+ this.server!.close(error => error ? reject(error) : resolve()));
61
+ this.server = undefined;
62
+ this.boundPort = undefined;
63
+ }
64
+
65
+ private listen() : Promise<void> {
66
+ this.server = createServer((request, response) => {
67
+ this.handle(request, response).catch(error => {
68
+ const message = error instanceof Error ? error.message : "Internal error";
69
+ this.reply(response, 500, { error: message });
70
+ });
71
+ });
72
+ return new Promise(resolve => this.server!.listen(this.listenPort, () => {
73
+ this.boundPort = (this.server!.address() as AddressInfo).port;
74
+ resolve();
75
+ }));
76
+ }
77
+
78
+ private async handle(request : IncomingMessage, response : ServerResponse) : Promise<void> {
79
+ const url = new URL(request.url ?? "/", "http://localhost");
80
+
81
+ if (request.method === "POST" && url.pathname === "/process") {
82
+ const body = await readBody(request);
83
+ if (!body || !Array.isArray(body.messages)) {
84
+ return this.reply(response, 400, { error: "Expected a body of { messages : Array<QueueMessage> }" });
85
+ }
86
+ const messages = body.messages as Array<QueueMessage>;
87
+ this.reply(response, 202, { accepted: messages.map(message => message.jobId) });
88
+ void this.processAll(messages);
89
+ return;
90
+ }
91
+
92
+ this.reply(response, 404, { error: "Not found" });
93
+ }
94
+
95
+ private async processAll(messages : Array<QueueMessage>) : Promise<void> {
96
+ for (const message of messages) {
97
+ const processJob = this.subscribers.get(message.workflowId);
98
+ if (!processJob) continue;
99
+ try {
100
+ await processJob(message.jobId);
101
+ await this.client.request("POST", "/queue/confirm", message);
102
+ } catch {
103
+ }
104
+ }
105
+ }
106
+
107
+ private reply(response : ServerResponse, status : number, body? : unknown) : void {
108
+ if (body === undefined) {
109
+ response.statusCode = status;
110
+ response.end();
111
+ return;
112
+ }
113
+ response.writeHead(status, { "content-type": "application/json" });
114
+ response.end(JSON.stringify(body));
115
+ }
116
+
117
+ }
118
+
119
+ export { PushConsumer }
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ export * from "./PushConsumer";
2
+ export * from "./CloudJobPersistence";
3
+ export * from "./CloudJsonStore";
4
+ export * from "./CloudSecretStore";
5
+ export * from "./CloudQueue";
6
+ export * from "./CloudAuditor";