orbit-bus 0.1.1
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/CHANGELOG.md +36 -0
- package/LICENSE +21 -0
- package/README.md +501 -0
- package/dist/src/agent_ipc.d.ts +4 -0
- package/dist/src/agent_ipc.js +77 -0
- package/dist/src/api_contract.d.ts +19 -0
- package/dist/src/api_contract.js +81 -0
- package/dist/src/api_http.d.ts +23 -0
- package/dist/src/api_http.js +62 -0
- package/dist/src/call_protection.d.ts +5 -0
- package/dist/src/call_protection.js +83 -0
- package/dist/src/cell/gateway.d.ts +13 -0
- package/dist/src/cell/gateway.js +171 -0
- package/dist/src/cell/routing.d.ts +18 -0
- package/dist/src/cell/routing.js +48 -0
- package/dist/src/cell/template.d.ts +7 -0
- package/dist/src/cell/template.js +24 -0
- package/dist/src/cli.d.ts +1 -0
- package/dist/src/cli.js +305 -0
- package/dist/src/commands/agent.d.ts +3 -0
- package/dist/src/commands/agent.js +187 -0
- package/dist/src/commands/api.d.ts +6 -0
- package/dist/src/commands/api.js +226 -0
- package/dist/src/commands/bench.d.ts +13 -0
- package/dist/src/commands/bench.js +125 -0
- package/dist/src/commands/bench_overhead.d.ts +8 -0
- package/dist/src/commands/bench_overhead.js +71 -0
- package/dist/src/commands/call.d.ts +10 -0
- package/dist/src/commands/call.js +45 -0
- package/dist/src/commands/cell.d.ts +3 -0
- package/dist/src/commands/cell.js +186 -0
- package/dist/src/commands/context.d.ts +9 -0
- package/dist/src/commands/context.js +71 -0
- package/dist/src/commands/dlq_inspect.d.ts +11 -0
- package/dist/src/commands/dlq_inspect.js +86 -0
- package/dist/src/commands/dlq_purge.d.ts +12 -0
- package/dist/src/commands/dlq_purge.js +84 -0
- package/dist/src/commands/dlq_replay.d.ts +15 -0
- package/dist/src/commands/dlq_replay.js +127 -0
- package/dist/src/commands/inspect.d.ts +6 -0
- package/dist/src/commands/inspect.js +57 -0
- package/dist/src/commands/monitor.d.ts +32 -0
- package/dist/src/commands/monitor.js +201 -0
- package/dist/src/commands/publish.d.ts +10 -0
- package/dist/src/commands/publish.js +64 -0
- package/dist/src/commands/serve.d.ts +8 -0
- package/dist/src/commands/serve.js +258 -0
- package/dist/src/commands/subscribe.d.ts +11 -0
- package/dist/src/commands/subscribe.js +78 -0
- package/dist/src/commands/trace.d.ts +5 -0
- package/dist/src/commands/trace.js +26 -0
- package/dist/src/commands/up.d.ts +3 -0
- package/dist/src/commands/up.js +91 -0
- package/dist/src/config.d.ts +6 -0
- package/dist/src/config.js +281 -0
- package/dist/src/dlq.d.ts +20 -0
- package/dist/src/dlq.js +71 -0
- package/dist/src/echo/benchmark.d.ts +10 -0
- package/dist/src/echo/benchmark.js +105 -0
- package/dist/src/echo/bus.d.ts +22 -0
- package/dist/src/echo/bus.js +89 -0
- package/dist/src/echo/cli.d.ts +1 -0
- package/dist/src/echo/cli.js +135 -0
- package/dist/src/echo/client.d.ts +12 -0
- package/dist/src/echo/client.js +46 -0
- package/dist/src/echo/daemon.d.ts +8 -0
- package/dist/src/echo/daemon.js +181 -0
- package/dist/src/echo/index.d.ts +6 -0
- package/dist/src/echo/index.js +5 -0
- package/dist/src/echo/ring_buffer.d.ts +27 -0
- package/dist/src/echo/ring_buffer.js +73 -0
- package/dist/src/echo/types.d.ts +27 -0
- package/dist/src/echo/types.js +1 -0
- package/dist/src/echocore.d.ts +2 -0
- package/dist/src/echocore.js +6 -0
- package/dist/src/envelope.d.ts +14 -0
- package/dist/src/envelope.js +92 -0
- package/dist/src/errors.d.ts +5 -0
- package/dist/src/errors.js +9 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.js +12 -0
- package/dist/src/jetstream_durable.d.ts +4 -0
- package/dist/src/jetstream_durable.js +51 -0
- package/dist/src/json_schema.d.ts +6 -0
- package/dist/src/json_schema.js +154 -0
- package/dist/src/logger.d.ts +16 -0
- package/dist/src/logger.js +31 -0
- package/dist/src/metrics.d.ts +6 -0
- package/dist/src/metrics.js +95 -0
- package/dist/src/nats.d.ts +16 -0
- package/dist/src/nats.js +129 -0
- package/dist/src/orbit_actions.d.ts +4 -0
- package/dist/src/orbit_actions.js +129 -0
- package/dist/src/otel.d.ts +2 -0
- package/dist/src/otel.js +96 -0
- package/dist/src/registry.d.ts +10 -0
- package/dist/src/registry.js +57 -0
- package/dist/src/retry.d.ts +9 -0
- package/dist/src/retry.js +32 -0
- package/dist/src/rpc_call.d.ts +22 -0
- package/dist/src/rpc_call.js +119 -0
- package/dist/src/service_adapter.d.ts +7 -0
- package/dist/src/service_adapter.js +163 -0
- package/dist/src/spec.d.ts +2 -0
- package/dist/src/spec.js +30 -0
- package/dist/src/subjects.d.ts +2 -0
- package/dist/src/subjects.js +4 -0
- package/dist/src/trace.d.ts +4 -0
- package/dist/src/trace.js +86 -0
- package/dist/src/types.d.ts +133 -0
- package/dist/src/types.js +1 -0
- package/dist/src/util.d.ts +10 -0
- package/dist/src/util.js +63 -0
- package/dist/src/worker_pool.d.ts +9 -0
- package/dist/src/worker_pool.js +163 -0
- package/docs/orbit-api-contract.yaml +376 -0
- package/package.json +40 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { OrbitError } from "./errors.js";
|
|
2
|
+
import { assertJsonSchema } from "./json_schema.js";
|
|
3
|
+
const ACTIONS = new Set(["call", "publish", "inspect", "ping"]);
|
|
4
|
+
export function isOrbitApiAction(value) {
|
|
5
|
+
return ACTIONS.has(value);
|
|
6
|
+
}
|
|
7
|
+
export function actionFromApiPath(pathname) {
|
|
8
|
+
const parts = pathname.split("/").filter(Boolean);
|
|
9
|
+
if (parts.length !== 2)
|
|
10
|
+
return null;
|
|
11
|
+
if (parts[0] !== "v1")
|
|
12
|
+
return null;
|
|
13
|
+
const action = parts[1];
|
|
14
|
+
if (!isOrbitApiAction(action))
|
|
15
|
+
return null;
|
|
16
|
+
return action;
|
|
17
|
+
}
|
|
18
|
+
export function parseObjectPayload(input) {
|
|
19
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
20
|
+
throw new OrbitError("BAD_ARGS", "payload must be a JSON object");
|
|
21
|
+
}
|
|
22
|
+
return input;
|
|
23
|
+
}
|
|
24
|
+
const ACTION_PAYLOAD_SCHEMAS = {
|
|
25
|
+
ping: {
|
|
26
|
+
type: "object",
|
|
27
|
+
additionalProperties: false
|
|
28
|
+
},
|
|
29
|
+
call: {
|
|
30
|
+
type: "object",
|
|
31
|
+
required: ["target", "body"],
|
|
32
|
+
additionalProperties: false,
|
|
33
|
+
properties: {
|
|
34
|
+
target: { type: "string", minLength: 3, pattern: "^[a-zA-Z0-9_-]+\\.[a-zA-Z0-9_-]+$" },
|
|
35
|
+
body: {},
|
|
36
|
+
timeoutMs: { type: "integer", minimum: 1 },
|
|
37
|
+
retries: { type: "integer", minimum: 0 },
|
|
38
|
+
runId: { type: "string", minLength: 1 },
|
|
39
|
+
packFile: { type: "string", minLength: 1 },
|
|
40
|
+
taskId: { type: "string", minLength: 1 },
|
|
41
|
+
threadId: { type: "string", minLength: 1 },
|
|
42
|
+
parentMessageId: { type: "string", minLength: 1 },
|
|
43
|
+
capabilities: { type: "array", items: { type: "string", minLength: 1 } },
|
|
44
|
+
traceparent: { type: "string", minLength: 1 },
|
|
45
|
+
dedupeKey: { type: "string", minLength: 1 }
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
publish: {
|
|
49
|
+
type: "object",
|
|
50
|
+
required: ["topic", "body"],
|
|
51
|
+
additionalProperties: false,
|
|
52
|
+
properties: {
|
|
53
|
+
topic: { type: "string", minLength: 1 },
|
|
54
|
+
body: {},
|
|
55
|
+
runId: { type: "string", minLength: 1 },
|
|
56
|
+
packFile: { type: "string", minLength: 1 },
|
|
57
|
+
durable: { type: "boolean" },
|
|
58
|
+
dedupeKey: { type: "string", minLength: 1 },
|
|
59
|
+
taskId: { type: "string", minLength: 1 },
|
|
60
|
+
threadId: { type: "string", minLength: 1 },
|
|
61
|
+
parentMessageId: { type: "string", minLength: 1 },
|
|
62
|
+
capabilities: { type: "array", items: { type: "string", minLength: 1 } },
|
|
63
|
+
traceparent: { type: "string", minLength: 1 }
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
inspect: {
|
|
67
|
+
type: "object",
|
|
68
|
+
required: ["service"],
|
|
69
|
+
additionalProperties: false,
|
|
70
|
+
properties: {
|
|
71
|
+
service: { type: "string", minLength: 1 },
|
|
72
|
+
timeoutMs: { type: "integer", minimum: 1 }
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
export function validateActionPayload(action, payload) {
|
|
77
|
+
const schema = ACTION_PAYLOAD_SCHEMAS[action];
|
|
78
|
+
if (!schema)
|
|
79
|
+
throw new OrbitError("BAD_ARGS", `unsupported action ${action}`);
|
|
80
|
+
assertJsonSchema(payload, schema, `action payload (${action})`);
|
|
81
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export interface ApiErrorBody {
|
|
2
|
+
type: "orbit_error";
|
|
3
|
+
code: string;
|
|
4
|
+
message: string;
|
|
5
|
+
details?: unknown;
|
|
6
|
+
}
|
|
7
|
+
export interface ApiErrorResponse {
|
|
8
|
+
id: string;
|
|
9
|
+
ok: false;
|
|
10
|
+
error: ApiErrorBody;
|
|
11
|
+
}
|
|
12
|
+
export interface ApiSuccessResponse {
|
|
13
|
+
id: string;
|
|
14
|
+
ok: true;
|
|
15
|
+
payload: unknown;
|
|
16
|
+
}
|
|
17
|
+
export declare class ApiHttpError extends Error {
|
|
18
|
+
readonly status: number;
|
|
19
|
+
readonly code: string;
|
|
20
|
+
readonly details?: unknown;
|
|
21
|
+
constructor(status: number, code: string, message: string, details?: unknown);
|
|
22
|
+
}
|
|
23
|
+
export declare function normalizeApiError(err: unknown): ApiHttpError;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { OrbitError } from "./errors.js";
|
|
2
|
+
export class ApiHttpError extends Error {
|
|
3
|
+
status;
|
|
4
|
+
code;
|
|
5
|
+
details;
|
|
6
|
+
constructor(status, code, message, details) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.status = status;
|
|
9
|
+
this.code = code;
|
|
10
|
+
this.details = details;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
function isOrbitError(err) {
|
|
14
|
+
return err instanceof OrbitError;
|
|
15
|
+
}
|
|
16
|
+
function statusForCode(code) {
|
|
17
|
+
switch (code) {
|
|
18
|
+
case "UNAUTHORIZED":
|
|
19
|
+
return 401;
|
|
20
|
+
case "FORBIDDEN":
|
|
21
|
+
case "FORBIDDEN_BIND_HOST":
|
|
22
|
+
return 403;
|
|
23
|
+
case "NOT_FOUND":
|
|
24
|
+
return 404;
|
|
25
|
+
case "REQUEST_TIMEOUT":
|
|
26
|
+
case "TIMEOUT":
|
|
27
|
+
case "METHOD_TIMEOUT":
|
|
28
|
+
return 408;
|
|
29
|
+
case "PAYLOAD_TOO_LARGE":
|
|
30
|
+
case "REQUEST_TOO_LARGE":
|
|
31
|
+
case "AGENT_PAYLOAD_TOO_LARGE":
|
|
32
|
+
return 413;
|
|
33
|
+
case "RATE_LIMITED":
|
|
34
|
+
case "CIRCUIT_OPEN":
|
|
35
|
+
case "OVERLOADED":
|
|
36
|
+
case "API_OVERLOADED":
|
|
37
|
+
case "AGENT_OVERLOADED":
|
|
38
|
+
return 429;
|
|
39
|
+
case "METHOD_NOT_ALLOWED":
|
|
40
|
+
return 405;
|
|
41
|
+
case "BAD_ARGS":
|
|
42
|
+
case "BAD_TARGET":
|
|
43
|
+
case "BAD_JSON":
|
|
44
|
+
case "BAD_JSON_INPUT":
|
|
45
|
+
case "INVALID_SPEC":
|
|
46
|
+
case "SCHEMA_VALIDATION_FAILED":
|
|
47
|
+
return 400;
|
|
48
|
+
default:
|
|
49
|
+
return 500;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export function normalizeApiError(err) {
|
|
53
|
+
if (err instanceof ApiHttpError)
|
|
54
|
+
return err;
|
|
55
|
+
if (isOrbitError(err)) {
|
|
56
|
+
return new ApiHttpError(statusForCode(err.code), err.code, err.message, err.details);
|
|
57
|
+
}
|
|
58
|
+
const maybe = err;
|
|
59
|
+
const code = typeof maybe.code === "string" ? maybe.code : "API_ERROR";
|
|
60
|
+
const message = typeof maybe.message === "string" && maybe.message.length > 0 ? maybe.message : "internal API error";
|
|
61
|
+
return new ApiHttpError(statusForCode(code), code, message, maybe.details);
|
|
62
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { OrbitConfig } from "./types.js";
|
|
2
|
+
export declare function beforeCall(config: OrbitConfig, target: string): void;
|
|
3
|
+
export declare function onCallSuccess(target: string): void;
|
|
4
|
+
export declare function onCallFailure(config: OrbitConfig, target: string): void;
|
|
5
|
+
export declare function afterCallAttempt(target: string): void;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { OrbitError } from "./errors.js";
|
|
2
|
+
const rateBuckets = new Map();
|
|
3
|
+
const circuits = new Map();
|
|
4
|
+
function nowMs() {
|
|
5
|
+
return Date.now();
|
|
6
|
+
}
|
|
7
|
+
export function beforeCall(config, target) {
|
|
8
|
+
enforceRateLimit(config, target);
|
|
9
|
+
enforceCircuit(config, target);
|
|
10
|
+
}
|
|
11
|
+
export function onCallSuccess(target) {
|
|
12
|
+
const state = circuits.get(target);
|
|
13
|
+
if (!state)
|
|
14
|
+
return;
|
|
15
|
+
state.failures = 0;
|
|
16
|
+
state.mode = "closed";
|
|
17
|
+
state.halfOpenInFlight = 0;
|
|
18
|
+
}
|
|
19
|
+
export function onCallFailure(config, target) {
|
|
20
|
+
const state = circuits.get(target) ?? { mode: "closed", failures: 0, openedAtMs: 0, halfOpenInFlight: 0 };
|
|
21
|
+
state.failures += 1;
|
|
22
|
+
if (state.mode === "half_open") {
|
|
23
|
+
state.mode = "open";
|
|
24
|
+
state.openedAtMs = nowMs();
|
|
25
|
+
state.halfOpenInFlight = 0;
|
|
26
|
+
circuits.set(target, state);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (state.failures >= config.runtime.circuitBreakerFailureThreshold) {
|
|
30
|
+
state.mode = "open";
|
|
31
|
+
state.openedAtMs = nowMs();
|
|
32
|
+
state.halfOpenInFlight = 0;
|
|
33
|
+
}
|
|
34
|
+
circuits.set(target, state);
|
|
35
|
+
}
|
|
36
|
+
export function afterCallAttempt(target) {
|
|
37
|
+
const state = circuits.get(target);
|
|
38
|
+
if (!state)
|
|
39
|
+
return;
|
|
40
|
+
if (state.mode === "half_open" && state.halfOpenInFlight > 0) {
|
|
41
|
+
state.halfOpenInFlight -= 1;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function enforceRateLimit(config, target) {
|
|
45
|
+
const rate = config.runtime.callRateLimitPerSec;
|
|
46
|
+
if (rate <= 0)
|
|
47
|
+
return;
|
|
48
|
+
const now = nowMs();
|
|
49
|
+
const bucket = rateBuckets.get(target) ?? { tokens: rate, lastRefillMs: now };
|
|
50
|
+
const elapsedSec = Math.max(0, (now - bucket.lastRefillMs) / 1000);
|
|
51
|
+
bucket.tokens = Math.min(rate, bucket.tokens + elapsedSec * rate);
|
|
52
|
+
bucket.lastRefillMs = now;
|
|
53
|
+
if (bucket.tokens < 1) {
|
|
54
|
+
rateBuckets.set(target, bucket);
|
|
55
|
+
throw new OrbitError("RATE_LIMITED", `rate limited target ${target}`);
|
|
56
|
+
}
|
|
57
|
+
bucket.tokens -= 1;
|
|
58
|
+
rateBuckets.set(target, bucket);
|
|
59
|
+
}
|
|
60
|
+
function enforceCircuit(config, target) {
|
|
61
|
+
const cooldownMs = config.runtime.circuitBreakerCooldownMs;
|
|
62
|
+
const halfOpenMax = config.runtime.circuitBreakerHalfOpenMax;
|
|
63
|
+
const now = nowMs();
|
|
64
|
+
const state = circuits.get(target) ?? { mode: "closed", failures: 0, openedAtMs: 0, halfOpenInFlight: 0 };
|
|
65
|
+
if (state.mode === "open") {
|
|
66
|
+
if (now - state.openedAtMs >= cooldownMs) {
|
|
67
|
+
state.mode = "half_open";
|
|
68
|
+
state.halfOpenInFlight = 0;
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
circuits.set(target, state);
|
|
72
|
+
throw new OrbitError("CIRCUIT_OPEN", `circuit open for target ${target}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (state.mode === "half_open") {
|
|
76
|
+
if (state.halfOpenInFlight >= halfOpenMax) {
|
|
77
|
+
circuits.set(target, state);
|
|
78
|
+
throw new OrbitError("CIRCUIT_OPEN", `circuit half-open limit reached for ${target}`);
|
|
79
|
+
}
|
|
80
|
+
state.halfOpenInFlight += 1;
|
|
81
|
+
}
|
|
82
|
+
circuits.set(target, state);
|
|
83
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Logger } from "../logger.js";
|
|
2
|
+
import { OrbitConfig } from "../types.js";
|
|
3
|
+
import { CellRoute } from "./routing.js";
|
|
4
|
+
interface GatewayOptions {
|
|
5
|
+
socketPath?: string;
|
|
6
|
+
host?: string;
|
|
7
|
+
port?: number;
|
|
8
|
+
cellId: string;
|
|
9
|
+
routes: CellRoute[];
|
|
10
|
+
fromLatest?: boolean;
|
|
11
|
+
}
|
|
12
|
+
export declare function runCellGateway(config: OrbitConfig, logger: Logger, opts: GatewayOptions): Promise<void>;
|
|
13
|
+
export {};
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { connectEchoClient } from "../echo/client.js";
|
|
3
|
+
import { closeBus, connectBus, decodeJson, encodeJson } from "../nats.js";
|
|
4
|
+
class RecentHashCache {
|
|
5
|
+
entries = [];
|
|
6
|
+
has(hash) {
|
|
7
|
+
this.prune();
|
|
8
|
+
return this.entries.some((entry) => entry.hash === hash);
|
|
9
|
+
}
|
|
10
|
+
add(hash, ttlMs) {
|
|
11
|
+
this.prune();
|
|
12
|
+
this.entries.push({ hash, expiresAt: Date.now() + ttlMs });
|
|
13
|
+
if (this.entries.length > 4096)
|
|
14
|
+
this.entries.splice(0, this.entries.length - 4096);
|
|
15
|
+
}
|
|
16
|
+
prune() {
|
|
17
|
+
const now = Date.now();
|
|
18
|
+
while (this.entries.length > 0 && this.entries[0].expiresAt <= now) {
|
|
19
|
+
this.entries.shift();
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
class EchoPublisher {
|
|
24
|
+
client;
|
|
25
|
+
pending = [];
|
|
26
|
+
closed = false;
|
|
27
|
+
constructor(client) {
|
|
28
|
+
this.client = client;
|
|
29
|
+
this.client.onLine((line) => {
|
|
30
|
+
const wait = this.pending.shift();
|
|
31
|
+
if (!wait)
|
|
32
|
+
return;
|
|
33
|
+
try {
|
|
34
|
+
const msg = JSON.parse(line);
|
|
35
|
+
if (msg.ok)
|
|
36
|
+
wait.resolve();
|
|
37
|
+
else
|
|
38
|
+
wait.reject(new Error(msg.error ?? "echo publish failed"));
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
wait.reject(err);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
publish(channel, payloadBase64) {
|
|
46
|
+
if (this.closed)
|
|
47
|
+
return Promise.reject(new Error("echo publisher closed"));
|
|
48
|
+
return new Promise((resolve, reject) => {
|
|
49
|
+
this.pending.push({ resolve, reject });
|
|
50
|
+
this.client.send({ type: "publish", channel, payloadBase64 });
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
close() {
|
|
54
|
+
this.closed = true;
|
|
55
|
+
this.client.close();
|
|
56
|
+
while (this.pending.length > 0) {
|
|
57
|
+
const wait = this.pending.shift();
|
|
58
|
+
wait?.reject(new Error("echo publisher closed"));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function sha256Base64(payloadBase64) {
|
|
63
|
+
return crypto.createHash("sha256").update(payloadBase64).digest("hex");
|
|
64
|
+
}
|
|
65
|
+
export async function runCellGateway(config, logger, opts) {
|
|
66
|
+
const endpoint = {
|
|
67
|
+
socketPath: opts.socketPath,
|
|
68
|
+
host: opts.host,
|
|
69
|
+
port: opts.port
|
|
70
|
+
};
|
|
71
|
+
const nc = await connectBus(config.natsUrl);
|
|
72
|
+
const ingressPublisher = new EchoPublisher(await connectEchoClient(endpoint));
|
|
73
|
+
const localSubs = [];
|
|
74
|
+
const recentIngress = new RecentHashCache();
|
|
75
|
+
const stopLocalLoops = [];
|
|
76
|
+
const stopNetworkLoops = [];
|
|
77
|
+
for (const route of opts.routes) {
|
|
78
|
+
if (route.localToNetwork) {
|
|
79
|
+
const client = await connectEchoClient(endpoint);
|
|
80
|
+
client.onLine((line) => {
|
|
81
|
+
const msg = JSON.parse(line);
|
|
82
|
+
if (!msg.ok || msg.type !== "event" || !msg.payloadBase64)
|
|
83
|
+
return;
|
|
84
|
+
const hash = sha256Base64(msg.payloadBase64);
|
|
85
|
+
if (recentIngress.has(hash))
|
|
86
|
+
return;
|
|
87
|
+
const payloadText = Buffer.from(msg.payloadBase64, "base64").toString("utf-8");
|
|
88
|
+
let payload;
|
|
89
|
+
try {
|
|
90
|
+
payload = JSON.parse(payloadText);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
payload = { raw_base64: msg.payloadBase64 };
|
|
94
|
+
}
|
|
95
|
+
const envelope = {
|
|
96
|
+
channel: route.channel,
|
|
97
|
+
payload,
|
|
98
|
+
meta: { cell_id: opts.cellId, ts: new Date().toISOString() }
|
|
99
|
+
};
|
|
100
|
+
nc.publish(route.subject, encodeJson(envelope));
|
|
101
|
+
});
|
|
102
|
+
client.send({ type: "subscribe", channel: route.channel, fromLatest: opts.fromLatest ?? true });
|
|
103
|
+
localSubs.push(client);
|
|
104
|
+
stopLocalLoops.push(() => client.close());
|
|
105
|
+
}
|
|
106
|
+
if (route.networkToLocal) {
|
|
107
|
+
const sub = nc.subscribe(route.subject);
|
|
108
|
+
const stop = { active: true };
|
|
109
|
+
stopNetworkLoops.push(() => {
|
|
110
|
+
stop.active = false;
|
|
111
|
+
sub.unsubscribe();
|
|
112
|
+
});
|
|
113
|
+
void (async () => {
|
|
114
|
+
for await (const msg of sub) {
|
|
115
|
+
if (!stop.active)
|
|
116
|
+
break;
|
|
117
|
+
let envelope;
|
|
118
|
+
try {
|
|
119
|
+
envelope = decodeJson(msg.data);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (envelope.meta?.cell_id === opts.cellId)
|
|
125
|
+
continue;
|
|
126
|
+
const payload = "payload" in envelope ? envelope.payload : envelope;
|
|
127
|
+
const payloadBase64 = Buffer.from(JSON.stringify(payload)).toString("base64");
|
|
128
|
+
recentIngress.add(sha256Base64(payloadBase64), 5000);
|
|
129
|
+
try {
|
|
130
|
+
await ingressPublisher.publish(route.channel, payloadBase64);
|
|
131
|
+
}
|
|
132
|
+
catch (err) {
|
|
133
|
+
logger.warn("cell ingress publish failed", {
|
|
134
|
+
channel: route.channel,
|
|
135
|
+
subject: route.subject,
|
|
136
|
+
err: err.message
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
})().catch((err) => {
|
|
141
|
+
logger.error("network->local loop crashed", {
|
|
142
|
+
channel: route.channel,
|
|
143
|
+
subject: route.subject,
|
|
144
|
+
err: err.message
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
logger.info("cell gateway online", {
|
|
150
|
+
cell_id: opts.cellId,
|
|
151
|
+
routes: opts.routes.map((route) => ({
|
|
152
|
+
channel: route.channel,
|
|
153
|
+
mode: route.mode,
|
|
154
|
+
subject: route.subject
|
|
155
|
+
}))
|
|
156
|
+
});
|
|
157
|
+
const shutdown = async () => {
|
|
158
|
+
stopLocalLoops.forEach((fn) => fn());
|
|
159
|
+
stopNetworkLoops.forEach((fn) => fn());
|
|
160
|
+
localSubs.forEach((client) => client.close());
|
|
161
|
+
ingressPublisher.close();
|
|
162
|
+
await closeBus(config.natsUrl);
|
|
163
|
+
};
|
|
164
|
+
process.once("SIGINT", () => {
|
|
165
|
+
void shutdown().finally(() => process.exit(0));
|
|
166
|
+
});
|
|
167
|
+
process.once("SIGTERM", () => {
|
|
168
|
+
void shutdown().finally(() => process.exit(0));
|
|
169
|
+
});
|
|
170
|
+
await nc.closed();
|
|
171
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { OrbitConfig } from "../types.js";
|
|
2
|
+
export type CellRouteMode = "local_only" | "replicate" | "global_only";
|
|
3
|
+
export interface CellRoute {
|
|
4
|
+
channel: string;
|
|
5
|
+
mode: CellRouteMode;
|
|
6
|
+
subject: string;
|
|
7
|
+
localToNetwork: boolean;
|
|
8
|
+
networkToLocal: boolean;
|
|
9
|
+
}
|
|
10
|
+
export interface CellRoutingPlan {
|
|
11
|
+
routes: CellRoute[];
|
|
12
|
+
source?: string;
|
|
13
|
+
}
|
|
14
|
+
export declare function resolveCellRoutingPlan(config: OrbitConfig, opts: {
|
|
15
|
+
routesFile?: string;
|
|
16
|
+
channels?: string[];
|
|
17
|
+
defaultMode?: CellRouteMode;
|
|
18
|
+
}): CellRoutingPlan;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
function defaultSubject(config, channel) {
|
|
3
|
+
return `${config.routing.subjectPrefix}.cell.channels.${channel}`;
|
|
4
|
+
}
|
|
5
|
+
function isValidChannel(channel) {
|
|
6
|
+
return /^[A-Za-z0-9._-]+$/.test(channel);
|
|
7
|
+
}
|
|
8
|
+
function parseMode(value) {
|
|
9
|
+
if (value === "local_only" || value === "replicate" || value === "global_only")
|
|
10
|
+
return value;
|
|
11
|
+
return undefined;
|
|
12
|
+
}
|
|
13
|
+
function toRoute(config, channel, mode, subject) {
|
|
14
|
+
if (!isValidChannel(channel)) {
|
|
15
|
+
throw new Error(`invalid channel '${channel}': use [A-Za-z0-9._-]`);
|
|
16
|
+
}
|
|
17
|
+
const outSubject = (subject ?? defaultSubject(config, channel)).trim();
|
|
18
|
+
if (!outSubject || outSubject.includes(" ")) {
|
|
19
|
+
throw new Error(`invalid subject for channel '${channel}'`);
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
channel,
|
|
23
|
+
mode,
|
|
24
|
+
subject: outSubject,
|
|
25
|
+
localToNetwork: mode === "replicate" || mode === "global_only",
|
|
26
|
+
networkToLocal: mode === "replicate"
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
export function resolveCellRoutingPlan(config, opts) {
|
|
30
|
+
const defaultMode = opts.defaultMode ?? "replicate";
|
|
31
|
+
if (opts.routesFile) {
|
|
32
|
+
const raw = JSON.parse(fs.readFileSync(opts.routesFile, "utf-8"));
|
|
33
|
+
const routes = Object.entries(raw).map(([channel, value]) => {
|
|
34
|
+
if (typeof value === "string") {
|
|
35
|
+
const mode = parseMode(value);
|
|
36
|
+
if (!mode)
|
|
37
|
+
throw new Error(`invalid mode '${value}' for channel '${channel}'`);
|
|
38
|
+
return toRoute(config, channel, mode);
|
|
39
|
+
}
|
|
40
|
+
const mode = parseMode(value?.mode) ?? defaultMode;
|
|
41
|
+
return toRoute(config, channel, mode, value?.subject);
|
|
42
|
+
});
|
|
43
|
+
return { routes, source: opts.routesFile };
|
|
44
|
+
}
|
|
45
|
+
const channels = opts.channels && opts.channels.length > 0 ? opts.channels : ["agent.loop"];
|
|
46
|
+
const routes = channels.map((channel) => toRoute(config, channel, defaultMode));
|
|
47
|
+
return { routes };
|
|
48
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export interface CellRouteTemplateEntry {
|
|
2
|
+
mode: "local_only" | "replicate" | "global_only";
|
|
3
|
+
subject: string;
|
|
4
|
+
}
|
|
5
|
+
export type CellRoutesTemplate = Record<string, CellRouteTemplateEntry>;
|
|
6
|
+
export type CellProfile = "production" | "high_throughput";
|
|
7
|
+
export declare function buildCellRoutesTemplate(profile: CellProfile, subjectPrefix: string): CellRoutesTemplate;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
function subject(subjectPrefix, tail) {
|
|
2
|
+
return `${subjectPrefix}.${tail}`;
|
|
3
|
+
}
|
|
4
|
+
export function buildCellRoutesTemplate(profile, subjectPrefix) {
|
|
5
|
+
const cleanPrefix = (subjectPrefix || "orbit").trim();
|
|
6
|
+
if (!cleanPrefix || cleanPrefix.includes(" ")) {
|
|
7
|
+
throw new Error("invalid subject prefix");
|
|
8
|
+
}
|
|
9
|
+
if (profile === "high_throughput") {
|
|
10
|
+
return {
|
|
11
|
+
"agent.loop": { mode: "replicate", subject: subject(cleanPrefix, "cell.channels.agent.loop") },
|
|
12
|
+
"agent.audit": { mode: "global_only", subject: subject(cleanPrefix, "cell.audit.events") },
|
|
13
|
+
"agent.metrics": { mode: "global_only", subject: subject(cleanPrefix, "cell.metrics.events") },
|
|
14
|
+
"agent.trace": { mode: "global_only", subject: subject(cleanPrefix, "cell.trace.events") },
|
|
15
|
+
"agent.debug": { mode: "local_only", subject: subject(cleanPrefix, "cell.debug.events") }
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
return {
|
|
19
|
+
"agent.loop": { mode: "replicate", subject: subject(cleanPrefix, "cell.channels.agent.loop") },
|
|
20
|
+
"agent.audit": { mode: "global_only", subject: subject(cleanPrefix, "cell.audit.events") },
|
|
21
|
+
"agent.metrics": { mode: "global_only", subject: subject(cleanPrefix, "cell.metrics.events") },
|
|
22
|
+
"agent.debug": { mode: "local_only", subject: subject(cleanPrefix, "cell.debug.events") }
|
|
23
|
+
};
|
|
24
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function run(argv: string[], cwd: string): Promise<void>;
|