@xtrape/capsule-agent-node 0.5.0 → 0.6.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 CHANGED
@@ -8,31 +8,28 @@ current Xtrape concept. New integrations should use `XtrapeAgent` and the
8
8
  `worker` option.
9
9
 
10
10
  [![License: Apache-2.0](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](./LICENSE)
11
- [![Status: Public Review](https://img.shields.io/badge/status-Public%20Review-orange.svg)](https://xtrape-com.github.io/xtrape-capsule-site/)
12
- [![Docs](https://img.shields.io/badge/docs-xtrape--capsule--site-blue.svg)](https://xtrape-com.github.io/xtrape-capsule-site/agents/node-embedded-agent)
11
+ [![Status: Release Train 0.6.0](https://img.shields.io/badge/status-0.6.0-blue.svg)](https://forgejo.xtrape.com/xtrape/xtrape-docs)
12
+ [![Docs](https://img.shields.io/badge/docs-xtrape--docs-blue.svg)](https://forgejo.xtrape.com/xtrape/xtrape-docs)
13
13
 
14
14
  The package supports the Node.js Embedded Agent mode for one Worker. Multi-Worker
15
15
  ownership belongs to a sidecar or external Agent. Gateway, when present, routes
16
16
  Agent/control-plane traffic and does not replace the Agent role.
17
17
 
18
- > **Package status:** Xtrape is currently in **Public Review** before
19
- > the `v0.1.0 Public Preview` release. This package is published under the
20
- > `public-review` dist-tag. APIs, contracts, deployment instructions, and SDK
21
- > interfaces may still change.
18
+ > **Package status:** This package follows the unified Xtrape `0.6.0` release
19
+ > train. The npm package name remains a compatibility identifier, but release
20
+ > versioning now follows the shared Xtrape train version.
22
21
 
23
22
  ## Install
24
23
 
25
- During Public Review, install the prerelease package with:
24
+ This repository tracks the `0.6.0` snapshot train. Until the `0.6.0` npm cut is
25
+ published, install the latest published compatibility package:
26
26
 
27
27
  ```bash
28
- pnpm add @xtrape/capsule-agent-node@public-review
28
+ pnpm add @xtrape/capsule-agent-node@^0.5.0
29
29
  ```
30
30
 
31
- The current Public Review version may change before `v0.1.0`.
32
-
33
- During v0.3 development, this repository may use a local workspace, GitHub
34
- branch dependency, or `0.3.0-rc.x` package for
35
- `@xtrape/capsule-contracts-node` until the final `0.3.0` package is published.
31
+ For source-level snapshot integration, use a local checkout of this repository
32
+ and align the rest of the release train separately.
36
33
 
37
34
  For this repository itself:
38
35
 
@@ -41,6 +38,68 @@ pnpm install
41
38
  pnpm build
42
39
  ```
43
40
 
41
+ ## Xtrape CE Phase 0 Service Agent
42
+
43
+ For the Xtrape CE 0.1 runtime loop (`Telegram → xtrape-server-ce → xtrape-service
44
+ → reply`), use `CeServiceAgent` (or the `serveCeService` bootstrap). An
45
+ `xtrape-service` registers an instance to `xtrape-server-ce`, heartbeats, and
46
+ replies to `ConversationMessage`s. `Service` and `Worker` are roles here, not the
47
+ legacy `Capsule` concept.
48
+
49
+ ### One-call bootstrap
50
+
51
+ `serveCeService` starts an HTTP listener (`POST /messages`, `GET /health`),
52
+ registers the instance (sending the registration token if server-ce requires
53
+ one), and runs heartbeats — so a service does not copy-paste lifecycle code:
54
+
55
+ For deployed or containerized use, set `address` to the callback URL that
56
+ `xtrape-server-ce` can actually reach and allowlist, for example
57
+ `http://demo-service:8080`. The SDK only derives a default address when you
58
+ bind to an explicit local-only host such as `127.0.0.1`.
59
+
60
+ ```ts
61
+ import { serveCeService } from "@xtrape/capsule-agent-node";
62
+
63
+ const svc = await serveCeService({
64
+ serverUrl: process.env.XTRAPE_SERVER_URL ?? "http://localhost:3000",
65
+ registrationToken: process.env.SERVER_CE_REGISTRATION_TOKEN,
66
+ port: 8080,
67
+ // required in deployed/containerized environments
68
+ address: process.env.SERVICE_ADDRESS ?? "http://demo-service:8080",
69
+ manifest: {
70
+ contractVersion: 1,
71
+ service: { id: "demo-echo-service", name: "Demo Echo Service", version: "0.6.0" },
72
+ runtime: { language: "node", agent: "xtrape-agent-nodejs" },
73
+ capabilities: ["status.query"],
74
+ message: { supportedTypes: ["QUERY"] },
75
+ },
76
+ handler: (msg) => `echo: ${msg.content}`,
77
+ });
78
+
79
+ process.on("SIGTERM", () => void svc.close()); // stop heartbeat + deregister + close
80
+ ```
81
+
82
+ ### Lower-level agent
83
+
84
+ When the service already runs its own HTTP server, use `CeServiceAgent` directly:
85
+
86
+ ```ts
87
+ import { CeServiceAgent } from "@xtrape/capsule-agent-node";
88
+
89
+ const agent = new CeServiceAgent({
90
+ serverUrl: "http://localhost:3000",
91
+ registrationToken: process.env.SERVER_CE_REGISTRATION_TOKEN,
92
+ address: "http://127.0.0.1:8080",
93
+ manifest: /* ServiceManifest */ undefined as never,
94
+ });
95
+
96
+ const stop = await agent.start(); // register + heartbeat
97
+ // in your POST /messages route:
98
+ const reply = await agent.handleMessage(requestJson, (m) => `echo: ${m.content}`);
99
+ // on shutdown:
100
+ await stop(); // stop heartbeat + deregister
101
+ ```
102
+
44
103
  ## Minimal Example
45
104
 
46
105
  This example uses the canonical alias `XtrapeAgent`, configures
@@ -402,10 +461,13 @@ and `logger` are configured, `structuredLogger` wins.
402
461
 
403
462
  | Package | Compatible with |
404
463
  | ---------------------------------- | ------------------------------------------------------------- |
464
+ | repository line `0.6.0` | current Xtrape `0.6.0` snapshot train |
405
465
  | `@xtrape/capsule-agent-node@0.1.x` | Xtrape Panel CE `0.1.x` and `@xtrape/capsule-contracts-node@0.1.x` |
406
466
 
407
- Use matching minor versions across CE, Agent SDK, and Contracts during Public
408
- Review and Public Preview. The wire protocol may still change before `v1.0`.
467
+ Use matching release-train versions across CE, Agent SDK, and Contracts. During
468
+ the current `0.6.0` snapshot phase, published compatibility packages may lag
469
+ the repository line until the train npm cut is published. The wire protocol may
470
+ still change before `v1.0`.
409
471
 
410
472
  ## Troubleshooting
411
473
 
@@ -455,10 +517,10 @@ The token is single-use and short-lived.
455
517
 
456
518
  ## Documentation
457
519
 
458
- - Site: https://xtrape-com.github.io/xtrape-capsule-site/
459
- - Node embedded Agent guide: https://xtrape-com.github.io/xtrape-capsule-site/agents/node-embedded-agent
460
- - Action model: https://xtrape-com.github.io/xtrape-capsule-site/agents/action-model
461
- - Xtrape Panel CE: https://xtrape-com.github.io/xtrape-capsule-site/opstage-ce/overview
520
+ - Public docs repo: https://forgejo.xtrape.com/xtrape/xtrape-site
521
+ - Node embedded Agent guide: https://forgejo.xtrape.com/xtrape/xtrape-site/src/branch/main/docs/agents/node-embedded-agent.md
522
+ - Action model: https://forgejo.xtrape.com/xtrape/xtrape-site/src/branch/main/docs/agents/action-model.md
523
+ - Xtrape Panel CE docs: https://forgejo.xtrape.com/xtrape/xtrape-site/src/branch/main/docs/opstage-ce/overview.md
462
524
 
463
525
  ## Contributing
464
526
 
package/dist/index.cjs CHANGED
@@ -31,7 +31,8 @@ __export(index_exports, {
31
31
  FileTokenStore: () => FileTokenStore,
32
32
  NetworkError: () => NetworkError,
33
33
  RegistrationError: () => RegistrationError,
34
- XtrapeAgent: () => CapsuleAgent
34
+ XtrapeAgent: () => CapsuleAgent,
35
+ serveCeService: () => serveCeService
35
36
  });
36
37
  module.exports = __toCommonJS(index_exports);
37
38
 
@@ -431,6 +432,12 @@ var CapsuleAgent = class {
431
432
  // src/ce/index.ts
432
433
  var import_node_crypto = require("crypto");
433
434
  var import_capsule_contracts_node = require("@xtrape/capsule-contracts-node");
435
+ var CeInboundMessageValidationError = class extends Error {
436
+ constructor(message, options) {
437
+ super(message, options);
438
+ this.name = "CeInboundMessageValidationError";
439
+ }
440
+ };
434
441
  var CeServiceAgent = class {
435
442
  serviceId;
436
443
  instanceId;
@@ -438,6 +445,7 @@ var CeServiceAgent = class {
438
445
  manifest;
439
446
  address;
440
447
  tenant;
448
+ registrationToken;
441
449
  fetchImpl;
442
450
  health = "HEALTHY";
443
451
  hbTimer;
@@ -448,6 +456,7 @@ var CeServiceAgent = class {
448
456
  this.tenant = opts.manifest.tenant ?? "default";
449
457
  this.serviceId = opts.manifest.service.id;
450
458
  this.instanceId = opts.instanceId ?? `${this.serviceId}-${(0, import_node_crypto.randomUUID)().slice(0, 8)}`;
459
+ this.registrationToken = opts.registrationToken;
451
460
  this.fetchImpl = opts.fetchImpl ?? fetch;
452
461
  }
453
462
  /** The ServiceInstance this agent reports on register/heartbeat. */
@@ -462,11 +471,26 @@ var CeServiceAgent = class {
462
471
  health: this.health
463
472
  });
464
473
  }
465
- /** POST /v1/instances { manifest, instance } */
474
+ /** POST /v1/instances { manifest, instance } (sends the registration token if set). */
466
475
  async register() {
467
- await this.post("/v1/instances", { manifest: this.manifest, instance: this.instance() });
476
+ const headers = this.registrationToken ? { "x-xtrape-registration-token": this.registrationToken } : void 0;
477
+ await this.post("/v1/instances", { manifest: this.manifest, instance: this.instance() }, headers);
468
478
  return { instanceId: this.instanceId };
469
479
  }
480
+ /**
481
+ * Lifecycle convenience: register, then start periodic heartbeats. Returns a
482
+ * `stop()` that stops heartbeats and best-effort deregisters — so a service
483
+ * does not have to wire register/heartbeat/deregister by hand.
484
+ */
485
+ async start(opts = {}) {
486
+ await this.register();
487
+ if (opts.heartbeatMs !== 0) this.startHeartbeat(opts.heartbeatMs ?? 1e4, opts.metrics);
488
+ return async () => {
489
+ this.stopHeartbeat();
490
+ await this.deregister().catch(() => {
491
+ });
492
+ };
493
+ }
470
494
  setHealth(h) {
471
495
  this.health = h;
472
496
  }
@@ -508,7 +532,15 @@ var CeServiceAgent = class {
508
532
  * (senderType=SERVICE, targeted back at the sender, with replyToMessageId).
509
533
  */
510
534
  async handleMessage(raw, handler) {
511
- const msg = import_capsule_contracts_node.ConversationMessageSchema.parse(raw);
535
+ let msg;
536
+ try {
537
+ msg = import_capsule_contracts_node.ConversationMessageSchema.parse(raw);
538
+ } catch (error) {
539
+ throw new CeInboundMessageValidationError(
540
+ error instanceof Error ? error.message : "invalid inbound ConversationMessage",
541
+ { cause: error }
542
+ );
543
+ }
512
544
  const out = await handler(msg);
513
545
  const reply = typeof out === "string" ? { content: out } : out;
514
546
  return import_capsule_contracts_node.ConversationMessageSchema.parse({
@@ -527,8 +559,8 @@ var CeServiceAgent = class {
527
559
  ...reply.payload ? { payload: reply.payload } : {}
528
560
  });
529
561
  }
530
- post(path, body) {
531
- return this.fetchJson(path, { method: "POST", body: JSON.stringify(body) });
562
+ post(path, body, headers) {
563
+ return this.fetchJson(path, { method: "POST", body: JSON.stringify(body), ...headers ? { headers } : {} });
532
564
  }
533
565
  async fetchJson(path, init) {
534
566
  const res = await this.fetchImpl(this.serverUrl + path, {
@@ -536,13 +568,137 @@ var CeServiceAgent = class {
536
568
  headers: { "content-type": "application/json", ...init.headers ?? {} }
537
569
  });
538
570
  const text = await res.text();
539
- const data = text ? JSON.parse(text) : void 0;
540
571
  if (!res.ok) {
541
572
  throw new Error(`server-ce ${path} -> ${res.status}: ${text}`);
542
573
  }
543
- return data;
574
+ if (!text) return void 0;
575
+ try {
576
+ return JSON.parse(text);
577
+ } catch (error) {
578
+ throw new Error(`server-ce ${path} returned non-JSON success response: ${text}`, { cause: error });
579
+ }
544
580
  }
545
581
  };
582
+
583
+ // src/ce/serve.ts
584
+ var import_node_http = require("http");
585
+ function send(res, status, body) {
586
+ res.writeHead(status, { "content-type": "application/json" });
587
+ res.end(JSON.stringify(body));
588
+ }
589
+ function readBody(req) {
590
+ return new Promise((resolve, reject) => {
591
+ let data = "";
592
+ req.on("data", (c) => data += c);
593
+ req.on("end", () => resolve(data));
594
+ req.on("error", reject);
595
+ });
596
+ }
597
+ function isLocalOnlyHost(host) {
598
+ return host === "127.0.0.1" || host === "localhost" || host === "::1";
599
+ }
600
+ function defaultLocalAddress(host, port) {
601
+ if (host === "::1") return `http://[::1]:${port}`;
602
+ return `http://${host}:${port}`;
603
+ }
604
+ function listen(server, port, host) {
605
+ return new Promise((resolve, reject) => {
606
+ const onError = (error) => {
607
+ server.off("listening", onListening);
608
+ reject(error);
609
+ };
610
+ const onListening = () => {
611
+ server.off("error", onError);
612
+ resolve();
613
+ };
614
+ server.once("error", onError);
615
+ server.once("listening", onListening);
616
+ server.listen(port, host);
617
+ });
618
+ }
619
+ function sendMessageError(res, error) {
620
+ if (error instanceof CeInboundMessageValidationError) {
621
+ send(res, 400, { code: "VALIDATION_FAILED", message: error.message, retryable: false });
622
+ return;
623
+ }
624
+ const message = error instanceof Error ? error.message : "message handler failed";
625
+ send(res, 500, { code: "HANDLER_FAILED", message, retryable: true });
626
+ }
627
+ async function serveCeService(opts) {
628
+ const host = opts.host ?? "0.0.0.0";
629
+ let agent;
630
+ const server = (0, import_node_http.createServer)((req, res) => {
631
+ void (async () => {
632
+ try {
633
+ if (req.method === "GET" && req.url === "/health") {
634
+ send(res, 200, { ok: true, service: opts.manifest.service.id });
635
+ return;
636
+ }
637
+ if (req.method === "POST" && req.url === "/messages") {
638
+ if (!agent) {
639
+ send(res, 503, { code: "NOT_READY", message: "service not registered yet", retryable: true });
640
+ return;
641
+ }
642
+ const raw = await readBody(req);
643
+ let payload;
644
+ try {
645
+ payload = raw ? JSON.parse(raw) : {};
646
+ } catch (error) {
647
+ send(res, 400, {
648
+ code: "VALIDATION_FAILED",
649
+ message: error instanceof Error ? error.message : "invalid JSON request body",
650
+ retryable: false
651
+ });
652
+ return;
653
+ }
654
+ const reply = await agent.handleMessage(payload, opts.handler);
655
+ send(res, 200, reply);
656
+ return;
657
+ }
658
+ send(res, 404, { code: "NOT_FOUND", message: "not found", retryable: false });
659
+ } catch (e) {
660
+ sendMessageError(res, e);
661
+ }
662
+ })();
663
+ });
664
+ await listen(server, opts.port ?? 0, host);
665
+ const info = server.address();
666
+ const port = typeof info === "object" && info ? info.port : opts.port ?? 0;
667
+ const address = opts.address ?? (isLocalOnlyHost(host) ? defaultLocalAddress(host, port) : void 0);
668
+ if (!address) {
669
+ await new Promise((resolve) => server.close(() => resolve()));
670
+ throw new Error(
671
+ `serveCeService requires an explicit address when host=${host}. For deployed/containerized use, pass a reachable callback URL such as http://demo-service:8080.`
672
+ );
673
+ }
674
+ try {
675
+ agent = new CeServiceAgent({
676
+ serverUrl: opts.serverUrl,
677
+ manifest: opts.manifest,
678
+ address,
679
+ instanceId: opts.instanceId,
680
+ registrationToken: opts.registrationToken,
681
+ fetchImpl: opts.fetchImpl
682
+ });
683
+ await agent.register();
684
+ if (opts.heartbeatMs !== 0) agent.startHeartbeat(opts.heartbeatMs ?? 1e4);
685
+ } catch (error) {
686
+ agent?.stopHeartbeat();
687
+ await new Promise((resolve) => server.close(() => resolve()));
688
+ throw error;
689
+ }
690
+ return {
691
+ agent,
692
+ address,
693
+ port,
694
+ async close() {
695
+ agent?.stopHeartbeat();
696
+ await agent?.deregister().catch(() => {
697
+ });
698
+ await new Promise((resolve) => server.close(() => resolve()));
699
+ }
700
+ };
701
+ }
546
702
  // Annotate the CommonJS export names for ESM import in node:
547
703
  0 && (module.exports = {
548
704
  AgentApiClient,
@@ -556,5 +712,6 @@ var CeServiceAgent = class {
556
712
  FileTokenStore,
557
713
  NetworkError,
558
714
  RegistrationError,
559
- XtrapeAgent
715
+ XtrapeAgent,
716
+ serveCeService
560
717
  });
package/dist/index.d.cts CHANGED
@@ -277,6 +277,12 @@ interface CeServiceAgentOptions {
277
277
  address: string;
278
278
  /** optional fixed instance id; defaults to `<serviceId>-<rand>` */
279
279
  instanceId?: string;
280
+ /**
281
+ * Shared registration token. When server-ce has `SERVER_CE_REGISTRATION_TOKEN`
282
+ * set, registration requires it; the agent sends it as the
283
+ * `x-xtrape-registration-token` header on register.
284
+ */
285
+ registrationToken?: string;
280
286
  /** injectable fetch (tests); defaults to global fetch */
281
287
  fetchImpl?: typeof fetch;
282
288
  }
@@ -294,16 +300,26 @@ declare class CeServiceAgent {
294
300
  private readonly manifest;
295
301
  private readonly address;
296
302
  private readonly tenant;
303
+ private readonly registrationToken?;
297
304
  private readonly fetchImpl;
298
305
  private health;
299
306
  private hbTimer?;
300
307
  constructor(opts: CeServiceAgentOptions);
301
308
  /** The ServiceInstance this agent reports on register/heartbeat. */
302
309
  instance(): ServiceInstance;
303
- /** POST /v1/instances { manifest, instance } */
310
+ /** POST /v1/instances { manifest, instance } (sends the registration token if set). */
304
311
  register(): Promise<{
305
312
  instanceId: string;
306
313
  }>;
314
+ /**
315
+ * Lifecycle convenience: register, then start periodic heartbeats. Returns a
316
+ * `stop()` that stops heartbeats and best-effort deregisters — so a service
317
+ * does not have to wire register/heartbeat/deregister by hand.
318
+ */
319
+ start(opts?: {
320
+ heartbeatMs?: number;
321
+ metrics?: () => Record<string, unknown>;
322
+ }): Promise<() => Promise<void>>;
307
323
  setHealth(h: ServiceInstanceHealth): void;
308
324
  /** POST /v1/instances/{id}/heartbeat */
309
325
  sendHeartbeat(metricsSummary?: Record<string, unknown>): Promise<void>;
@@ -321,4 +337,50 @@ declare class CeServiceAgent {
321
337
  private fetchJson;
322
338
  }
323
339
 
324
- export { type ActionHandler, type ActionPrepareHandler, AgentApiClient, AgentApiError, AgentAuthError, type AgentLogLevel, type AgentLogRecord, type AgentMode, BusDepthExceededError, BusDisabledError, BusRateLimitedError, CapsuleAgent, type CapsuleAgentOptions, type CeMessageHandler, type CeMessageReply, CeServiceAgent, type CeServiceAgentOptions, type ConfigProvider, type ConsoleLogger, FileTokenStore, type HealthProvider, type LegacyServiceAgentOptions, NetworkError, type PublishBusEventInput, type PublishBusEventResult, type RegisteredAction, RegistrationError, type ServiceSnapshot, type StructuredLogger, type TokenStore, type WorkerAgentOptions, type WorkerDefinition, type WorkerSnapshot, CapsuleAgent as XtrapeAgent, type XtrapeAgentOptions };
340
+ /**
341
+ * One-call bootstrap for an `xtrape-service`: start an HTTP listener that serves
342
+ * `POST /messages` (and `GET /health`), register the instance to xtrape-server-ce,
343
+ * and run heartbeats. Returns a handle whose `close()` stops heartbeats,
344
+ * deregisters, and closes the listener.
345
+ *
346
+ * Uses only `node:http` so it stays framework-agnostic. A service that already
347
+ * has an HTTP server can use `CeServiceAgent` directly instead.
348
+ */
349
+ interface ServeCeServiceOptions {
350
+ /** xtrape-server-ce base URL, e.g. http://localhost:3000 */
351
+ serverUrl: string;
352
+ manifest: ServiceManifest;
353
+ /** business handler invoked per inbound ConversationMessage */
354
+ handler: CeMessageHandler;
355
+ /** shared registration token, if server-ce requires one */
356
+ registrationToken?: string;
357
+ /** listen port; 0 picks an ephemeral port (default 0) */
358
+ port?: number;
359
+ /** listen host (default 0.0.0.0) */
360
+ host?: string;
361
+ /**
362
+ * Address server-ce should call back.
363
+ *
364
+ * Required for deployed/containerized use. When omitted, the SDK only derives
365
+ * a default address for explicit local-only hosts such as `127.0.0.1` or
366
+ * `localhost`.
367
+ */
368
+ address?: string;
369
+ /** heartbeat interval ms; 0 disables (default 10000) */
370
+ heartbeatMs?: number;
371
+ instanceId?: string;
372
+ /** injectable fetch (tests); defaults to global fetch */
373
+ fetchImpl?: typeof fetch;
374
+ }
375
+ interface ServeCeServiceHandle {
376
+ agent: CeServiceAgent;
377
+ /** advertised address registered with server-ce */
378
+ address: string;
379
+ /** actual listening port */
380
+ port: number;
381
+ /** stop heartbeats, deregister (best-effort), and close the HTTP server */
382
+ close(): Promise<void>;
383
+ }
384
+ declare function serveCeService(opts: ServeCeServiceOptions): Promise<ServeCeServiceHandle>;
385
+
386
+ export { type ActionHandler, type ActionPrepareHandler, AgentApiClient, AgentApiError, AgentAuthError, type AgentLogLevel, type AgentLogRecord, type AgentMode, BusDepthExceededError, BusDisabledError, BusRateLimitedError, CapsuleAgent, type CapsuleAgentOptions, type CeMessageHandler, type CeMessageReply, CeServiceAgent, type CeServiceAgentOptions, type ConfigProvider, type ConsoleLogger, FileTokenStore, type HealthProvider, type LegacyServiceAgentOptions, NetworkError, type PublishBusEventInput, type PublishBusEventResult, type RegisteredAction, RegistrationError, type ServeCeServiceHandle, type ServeCeServiceOptions, type ServiceSnapshot, type StructuredLogger, type TokenStore, type WorkerAgentOptions, type WorkerDefinition, type WorkerSnapshot, CapsuleAgent as XtrapeAgent, type XtrapeAgentOptions, serveCeService };
package/dist/index.d.ts CHANGED
@@ -277,6 +277,12 @@ interface CeServiceAgentOptions {
277
277
  address: string;
278
278
  /** optional fixed instance id; defaults to `<serviceId>-<rand>` */
279
279
  instanceId?: string;
280
+ /**
281
+ * Shared registration token. When server-ce has `SERVER_CE_REGISTRATION_TOKEN`
282
+ * set, registration requires it; the agent sends it as the
283
+ * `x-xtrape-registration-token` header on register.
284
+ */
285
+ registrationToken?: string;
280
286
  /** injectable fetch (tests); defaults to global fetch */
281
287
  fetchImpl?: typeof fetch;
282
288
  }
@@ -294,16 +300,26 @@ declare class CeServiceAgent {
294
300
  private readonly manifest;
295
301
  private readonly address;
296
302
  private readonly tenant;
303
+ private readonly registrationToken?;
297
304
  private readonly fetchImpl;
298
305
  private health;
299
306
  private hbTimer?;
300
307
  constructor(opts: CeServiceAgentOptions);
301
308
  /** The ServiceInstance this agent reports on register/heartbeat. */
302
309
  instance(): ServiceInstance;
303
- /** POST /v1/instances { manifest, instance } */
310
+ /** POST /v1/instances { manifest, instance } (sends the registration token if set). */
304
311
  register(): Promise<{
305
312
  instanceId: string;
306
313
  }>;
314
+ /**
315
+ * Lifecycle convenience: register, then start periodic heartbeats. Returns a
316
+ * `stop()` that stops heartbeats and best-effort deregisters — so a service
317
+ * does not have to wire register/heartbeat/deregister by hand.
318
+ */
319
+ start(opts?: {
320
+ heartbeatMs?: number;
321
+ metrics?: () => Record<string, unknown>;
322
+ }): Promise<() => Promise<void>>;
307
323
  setHealth(h: ServiceInstanceHealth): void;
308
324
  /** POST /v1/instances/{id}/heartbeat */
309
325
  sendHeartbeat(metricsSummary?: Record<string, unknown>): Promise<void>;
@@ -321,4 +337,50 @@ declare class CeServiceAgent {
321
337
  private fetchJson;
322
338
  }
323
339
 
324
- export { type ActionHandler, type ActionPrepareHandler, AgentApiClient, AgentApiError, AgentAuthError, type AgentLogLevel, type AgentLogRecord, type AgentMode, BusDepthExceededError, BusDisabledError, BusRateLimitedError, CapsuleAgent, type CapsuleAgentOptions, type CeMessageHandler, type CeMessageReply, CeServiceAgent, type CeServiceAgentOptions, type ConfigProvider, type ConsoleLogger, FileTokenStore, type HealthProvider, type LegacyServiceAgentOptions, NetworkError, type PublishBusEventInput, type PublishBusEventResult, type RegisteredAction, RegistrationError, type ServiceSnapshot, type StructuredLogger, type TokenStore, type WorkerAgentOptions, type WorkerDefinition, type WorkerSnapshot, CapsuleAgent as XtrapeAgent, type XtrapeAgentOptions };
340
+ /**
341
+ * One-call bootstrap for an `xtrape-service`: start an HTTP listener that serves
342
+ * `POST /messages` (and `GET /health`), register the instance to xtrape-server-ce,
343
+ * and run heartbeats. Returns a handle whose `close()` stops heartbeats,
344
+ * deregisters, and closes the listener.
345
+ *
346
+ * Uses only `node:http` so it stays framework-agnostic. A service that already
347
+ * has an HTTP server can use `CeServiceAgent` directly instead.
348
+ */
349
+ interface ServeCeServiceOptions {
350
+ /** xtrape-server-ce base URL, e.g. http://localhost:3000 */
351
+ serverUrl: string;
352
+ manifest: ServiceManifest;
353
+ /** business handler invoked per inbound ConversationMessage */
354
+ handler: CeMessageHandler;
355
+ /** shared registration token, if server-ce requires one */
356
+ registrationToken?: string;
357
+ /** listen port; 0 picks an ephemeral port (default 0) */
358
+ port?: number;
359
+ /** listen host (default 0.0.0.0) */
360
+ host?: string;
361
+ /**
362
+ * Address server-ce should call back.
363
+ *
364
+ * Required for deployed/containerized use. When omitted, the SDK only derives
365
+ * a default address for explicit local-only hosts such as `127.0.0.1` or
366
+ * `localhost`.
367
+ */
368
+ address?: string;
369
+ /** heartbeat interval ms; 0 disables (default 10000) */
370
+ heartbeatMs?: number;
371
+ instanceId?: string;
372
+ /** injectable fetch (tests); defaults to global fetch */
373
+ fetchImpl?: typeof fetch;
374
+ }
375
+ interface ServeCeServiceHandle {
376
+ agent: CeServiceAgent;
377
+ /** advertised address registered with server-ce */
378
+ address: string;
379
+ /** actual listening port */
380
+ port: number;
381
+ /** stop heartbeats, deregister (best-effort), and close the HTTP server */
382
+ close(): Promise<void>;
383
+ }
384
+ declare function serveCeService(opts: ServeCeServiceOptions): Promise<ServeCeServiceHandle>;
385
+
386
+ export { type ActionHandler, type ActionPrepareHandler, AgentApiClient, AgentApiError, AgentAuthError, type AgentLogLevel, type AgentLogRecord, type AgentMode, BusDepthExceededError, BusDisabledError, BusRateLimitedError, CapsuleAgent, type CapsuleAgentOptions, type CeMessageHandler, type CeMessageReply, CeServiceAgent, type CeServiceAgentOptions, type ConfigProvider, type ConsoleLogger, FileTokenStore, type HealthProvider, type LegacyServiceAgentOptions, NetworkError, type PublishBusEventInput, type PublishBusEventResult, type RegisteredAction, RegistrationError, type ServeCeServiceHandle, type ServeCeServiceOptions, type ServiceSnapshot, type StructuredLogger, type TokenStore, type WorkerAgentOptions, type WorkerDefinition, type WorkerSnapshot, CapsuleAgent as XtrapeAgent, type XtrapeAgentOptions, serveCeService };
package/dist/index.js CHANGED
@@ -398,6 +398,12 @@ import {
398
398
  ServiceInstanceSchema,
399
399
  ConversationMessageSchema
400
400
  } from "@xtrape/capsule-contracts-node";
401
+ var CeInboundMessageValidationError = class extends Error {
402
+ constructor(message, options) {
403
+ super(message, options);
404
+ this.name = "CeInboundMessageValidationError";
405
+ }
406
+ };
401
407
  var CeServiceAgent = class {
402
408
  serviceId;
403
409
  instanceId;
@@ -405,6 +411,7 @@ var CeServiceAgent = class {
405
411
  manifest;
406
412
  address;
407
413
  tenant;
414
+ registrationToken;
408
415
  fetchImpl;
409
416
  health = "HEALTHY";
410
417
  hbTimer;
@@ -415,6 +422,7 @@ var CeServiceAgent = class {
415
422
  this.tenant = opts.manifest.tenant ?? "default";
416
423
  this.serviceId = opts.manifest.service.id;
417
424
  this.instanceId = opts.instanceId ?? `${this.serviceId}-${randomUUID().slice(0, 8)}`;
425
+ this.registrationToken = opts.registrationToken;
418
426
  this.fetchImpl = opts.fetchImpl ?? fetch;
419
427
  }
420
428
  /** The ServiceInstance this agent reports on register/heartbeat. */
@@ -429,11 +437,26 @@ var CeServiceAgent = class {
429
437
  health: this.health
430
438
  });
431
439
  }
432
- /** POST /v1/instances { manifest, instance } */
440
+ /** POST /v1/instances { manifest, instance } (sends the registration token if set). */
433
441
  async register() {
434
- await this.post("/v1/instances", { manifest: this.manifest, instance: this.instance() });
442
+ const headers = this.registrationToken ? { "x-xtrape-registration-token": this.registrationToken } : void 0;
443
+ await this.post("/v1/instances", { manifest: this.manifest, instance: this.instance() }, headers);
435
444
  return { instanceId: this.instanceId };
436
445
  }
446
+ /**
447
+ * Lifecycle convenience: register, then start periodic heartbeats. Returns a
448
+ * `stop()` that stops heartbeats and best-effort deregisters — so a service
449
+ * does not have to wire register/heartbeat/deregister by hand.
450
+ */
451
+ async start(opts = {}) {
452
+ await this.register();
453
+ if (opts.heartbeatMs !== 0) this.startHeartbeat(opts.heartbeatMs ?? 1e4, opts.metrics);
454
+ return async () => {
455
+ this.stopHeartbeat();
456
+ await this.deregister().catch(() => {
457
+ });
458
+ };
459
+ }
437
460
  setHealth(h) {
438
461
  this.health = h;
439
462
  }
@@ -475,7 +498,15 @@ var CeServiceAgent = class {
475
498
  * (senderType=SERVICE, targeted back at the sender, with replyToMessageId).
476
499
  */
477
500
  async handleMessage(raw, handler) {
478
- const msg = ConversationMessageSchema.parse(raw);
501
+ let msg;
502
+ try {
503
+ msg = ConversationMessageSchema.parse(raw);
504
+ } catch (error) {
505
+ throw new CeInboundMessageValidationError(
506
+ error instanceof Error ? error.message : "invalid inbound ConversationMessage",
507
+ { cause: error }
508
+ );
509
+ }
479
510
  const out = await handler(msg);
480
511
  const reply = typeof out === "string" ? { content: out } : out;
481
512
  return ConversationMessageSchema.parse({
@@ -494,8 +525,8 @@ var CeServiceAgent = class {
494
525
  ...reply.payload ? { payload: reply.payload } : {}
495
526
  });
496
527
  }
497
- post(path, body) {
498
- return this.fetchJson(path, { method: "POST", body: JSON.stringify(body) });
528
+ post(path, body, headers) {
529
+ return this.fetchJson(path, { method: "POST", body: JSON.stringify(body), ...headers ? { headers } : {} });
499
530
  }
500
531
  async fetchJson(path, init) {
501
532
  const res = await this.fetchImpl(this.serverUrl + path, {
@@ -503,13 +534,137 @@ var CeServiceAgent = class {
503
534
  headers: { "content-type": "application/json", ...init.headers ?? {} }
504
535
  });
505
536
  const text = await res.text();
506
- const data = text ? JSON.parse(text) : void 0;
507
537
  if (!res.ok) {
508
538
  throw new Error(`server-ce ${path} -> ${res.status}: ${text}`);
509
539
  }
510
- return data;
540
+ if (!text) return void 0;
541
+ try {
542
+ return JSON.parse(text);
543
+ } catch (error) {
544
+ throw new Error(`server-ce ${path} returned non-JSON success response: ${text}`, { cause: error });
545
+ }
511
546
  }
512
547
  };
548
+
549
+ // src/ce/serve.ts
550
+ import { createServer } from "http";
551
+ function send(res, status, body) {
552
+ res.writeHead(status, { "content-type": "application/json" });
553
+ res.end(JSON.stringify(body));
554
+ }
555
+ function readBody(req) {
556
+ return new Promise((resolve, reject) => {
557
+ let data = "";
558
+ req.on("data", (c) => data += c);
559
+ req.on("end", () => resolve(data));
560
+ req.on("error", reject);
561
+ });
562
+ }
563
+ function isLocalOnlyHost(host) {
564
+ return host === "127.0.0.1" || host === "localhost" || host === "::1";
565
+ }
566
+ function defaultLocalAddress(host, port) {
567
+ if (host === "::1") return `http://[::1]:${port}`;
568
+ return `http://${host}:${port}`;
569
+ }
570
+ function listen(server, port, host) {
571
+ return new Promise((resolve, reject) => {
572
+ const onError = (error) => {
573
+ server.off("listening", onListening);
574
+ reject(error);
575
+ };
576
+ const onListening = () => {
577
+ server.off("error", onError);
578
+ resolve();
579
+ };
580
+ server.once("error", onError);
581
+ server.once("listening", onListening);
582
+ server.listen(port, host);
583
+ });
584
+ }
585
+ function sendMessageError(res, error) {
586
+ if (error instanceof CeInboundMessageValidationError) {
587
+ send(res, 400, { code: "VALIDATION_FAILED", message: error.message, retryable: false });
588
+ return;
589
+ }
590
+ const message = error instanceof Error ? error.message : "message handler failed";
591
+ send(res, 500, { code: "HANDLER_FAILED", message, retryable: true });
592
+ }
593
+ async function serveCeService(opts) {
594
+ const host = opts.host ?? "0.0.0.0";
595
+ let agent;
596
+ const server = createServer((req, res) => {
597
+ void (async () => {
598
+ try {
599
+ if (req.method === "GET" && req.url === "/health") {
600
+ send(res, 200, { ok: true, service: opts.manifest.service.id });
601
+ return;
602
+ }
603
+ if (req.method === "POST" && req.url === "/messages") {
604
+ if (!agent) {
605
+ send(res, 503, { code: "NOT_READY", message: "service not registered yet", retryable: true });
606
+ return;
607
+ }
608
+ const raw = await readBody(req);
609
+ let payload;
610
+ try {
611
+ payload = raw ? JSON.parse(raw) : {};
612
+ } catch (error) {
613
+ send(res, 400, {
614
+ code: "VALIDATION_FAILED",
615
+ message: error instanceof Error ? error.message : "invalid JSON request body",
616
+ retryable: false
617
+ });
618
+ return;
619
+ }
620
+ const reply = await agent.handleMessage(payload, opts.handler);
621
+ send(res, 200, reply);
622
+ return;
623
+ }
624
+ send(res, 404, { code: "NOT_FOUND", message: "not found", retryable: false });
625
+ } catch (e) {
626
+ sendMessageError(res, e);
627
+ }
628
+ })();
629
+ });
630
+ await listen(server, opts.port ?? 0, host);
631
+ const info = server.address();
632
+ const port = typeof info === "object" && info ? info.port : opts.port ?? 0;
633
+ const address = opts.address ?? (isLocalOnlyHost(host) ? defaultLocalAddress(host, port) : void 0);
634
+ if (!address) {
635
+ await new Promise((resolve) => server.close(() => resolve()));
636
+ throw new Error(
637
+ `serveCeService requires an explicit address when host=${host}. For deployed/containerized use, pass a reachable callback URL such as http://demo-service:8080.`
638
+ );
639
+ }
640
+ try {
641
+ agent = new CeServiceAgent({
642
+ serverUrl: opts.serverUrl,
643
+ manifest: opts.manifest,
644
+ address,
645
+ instanceId: opts.instanceId,
646
+ registrationToken: opts.registrationToken,
647
+ fetchImpl: opts.fetchImpl
648
+ });
649
+ await agent.register();
650
+ if (opts.heartbeatMs !== 0) agent.startHeartbeat(opts.heartbeatMs ?? 1e4);
651
+ } catch (error) {
652
+ agent?.stopHeartbeat();
653
+ await new Promise((resolve) => server.close(() => resolve()));
654
+ throw error;
655
+ }
656
+ return {
657
+ agent,
658
+ address,
659
+ port,
660
+ async close() {
661
+ agent?.stopHeartbeat();
662
+ await agent?.deregister().catch(() => {
663
+ });
664
+ await new Promise((resolve) => server.close(() => resolve()));
665
+ }
666
+ };
667
+ }
513
668
  export {
514
669
  AgentApiClient,
515
670
  AgentApiError,
@@ -522,5 +677,6 @@ export {
522
677
  FileTokenStore,
523
678
  NetworkError,
524
679
  RegistrationError,
525
- CapsuleAgent as XtrapeAgent
680
+ CapsuleAgent as XtrapeAgent,
681
+ serveCeService
526
682
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xtrape/capsule-agent-node",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Node.js Embedded Agent SDK for reporting Workers to the Xtrape control plane.",
5
5
  "keywords": [
6
6
  "xtrape",
@@ -13,11 +13,11 @@
13
13
  ],
14
14
  "repository": {
15
15
  "type": "git",
16
- "url": "git+https://github.com/xtrape-com/xtrape-capsule-agent-node.git"
16
+ "url": "git+https://forgejo.xtrape.com/xtrape/xtrape-agent-nodejs.git"
17
17
  },
18
- "homepage": "https://github.com/xtrape-com/xtrape-capsule-agent-node#readme",
18
+ "homepage": "https://forgejo.xtrape.com/xtrape/xtrape-agent-nodejs#readme",
19
19
  "bugs": {
20
- "url": "https://github.com/xtrape-com/xtrape-capsule-agent-node/issues"
20
+ "url": "https://forgejo.xtrape.com/xtrape/xtrape-agent-nodejs/issues"
21
21
  },
22
22
  "type": "module",
23
23
  "main": "./dist/index.cjs",
@@ -44,7 +44,7 @@
44
44
  "lint": "tsc --noEmit"
45
45
  },
46
46
  "dependencies": {
47
- "@xtrape/capsule-contracts-node": "^0.5.0"
47
+ "@xtrape/capsule-contracts-node": "^0.6.0"
48
48
  },
49
49
  "devDependencies": {
50
50
  "tsup": "^8.3.5",