@north-light/crouter-api 0.3.321 → 0.3.322

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.
@@ -6,6 +6,7 @@ import { mkdirSync, mkdtempSync, rmSync } from 'node:fs';
6
6
  import { tmpdir } from 'node:os';
7
7
  import { join } from 'node:path';
8
8
  import { CrtrClient, safeColdStartDiagnostic } from '../../client.js';
9
+ import { localClient } from '../../node-transport.js';
9
10
  import { ApiError } from '../../errors.js';
10
11
  function startDelayedHealthzServer(socketPath, delayMs) {
11
12
  const server = createServer((_req, res) => {
@@ -38,7 +39,7 @@ test('a hang-up mid-request rides out the daemon handover and replays the idempo
38
39
  const { server, ready } = startHandoverServer(socketPath);
39
40
  try {
40
41
  await ready;
41
- const client = new CrtrClient({ socketPath, autostart: false, coldStartPollWindowMs: 2_000 });
42
+ const client = localClient({ socketPath, autostart: false, coldStartPollWindowMs: 2_000 });
42
43
  assert.deepEqual(await client.request('GET', '/v1/nodes'), { ok: true, method: 'GET' });
43
44
  }
44
45
  finally {
@@ -52,7 +53,7 @@ test('an interrupted mutation is not replayed or labelled a daemon handover', as
52
53
  const { server, ready } = startHandoverServer(socketPath);
53
54
  try {
54
55
  await ready;
55
- const client = new CrtrClient({ socketPath, autostart: false, coldStartPollWindowMs: 2_000 });
56
+ const client = localClient({ socketPath, autostart: false, coldStartPollWindowMs: 2_000 });
56
57
  await assert.rejects(() => client.request('POST', '/v1/nodes', {}),
57
58
  // Replaying is unsafe (the daemon may have applied it), so this one is the
58
59
  // caller's call — but it must not be reported as a daemon that is down.
@@ -68,7 +69,7 @@ test('a non-socket cold-start path surfaces its diagnostic after autostart times
68
69
  const socketPath = join(dir, 'crtrd.sock');
69
70
  mkdirSync(socketPath);
70
71
  const diagnostic = 'crtrd.log (tail):\napi.server.failed: EADDRINUSE';
71
- const client = new CrtrClient({
72
+ const client = localClient({
72
73
  socketPath,
73
74
  autostart: true,
74
75
  onColdSocket: async () => { },
@@ -89,7 +90,7 @@ test('cliClient-style cold start fails loud when the injected poll window is sho
89
90
  const socketPath = join(dir, 'crtrd.sock');
90
91
  const { server, cancel } = startDelayedHealthzServer(socketPath, 300);
91
92
  try {
92
- const client = new CrtrClient({
93
+ const client = localClient({
93
94
  socketPath,
94
95
  autostart: true,
95
96
  onColdSocket: async () => {
@@ -111,7 +112,7 @@ test('a disabled-autostart client waits for an externally managed listener witho
111
112
  const { server, cancel } = startDelayedHealthzServer(socketPath, 100);
112
113
  let spawnAttempts = 0;
113
114
  try {
114
- const client = new CrtrClient({
115
+ const client = localClient({
115
116
  socketPath,
116
117
  autostart: false,
117
118
  onColdSocket: async () => { spawnAttempts += 1; },
@@ -132,7 +133,7 @@ test('availability expiry keeps the final transport failure inside one wall-cloc
132
133
  const server = createServer((_req, _res) => { });
133
134
  let listenTimer;
134
135
  try {
135
- const client = new CrtrClient({
136
+ const client = localClient({
136
137
  socketPath,
137
138
  autostart: true,
138
139
  timeoutMs: 5_000,
@@ -150,6 +151,21 @@ test('availability expiry keeps the final transport failure inside one wall-cloc
150
151
  rmSync(dir, { recursive: true, force: true });
151
152
  }
152
153
  });
154
+ test('a caller abort with a custom reason is request_aborted', async () => {
155
+ const controller = new AbortController();
156
+ const client = new CrtrClient({
157
+ baseUrl: 'http://fixture.test',
158
+ fetch: async (_input, init) => await new Promise((_resolve, reject) => {
159
+ const signal = init?.signal;
160
+ if (signal === null || signal === undefined)
161
+ throw new Error('request signal is required');
162
+ signal.addEventListener('abort', () => reject(signal.reason), { once: true });
163
+ }),
164
+ });
165
+ const request = client.request('GET', '/healthz', undefined, { signal: controller.signal });
166
+ controller.abort(new Error('caller custom abort'));
167
+ await assert.rejects(request, (error) => error instanceof ApiError && error.code === 'request_aborted');
168
+ });
153
169
  test('safeColdStartDiagnostic returns undefined for an absent hook', () => {
154
170
  assert.equal(safeColdStartDiagnostic(undefined), undefined);
155
171
  });
@@ -14,7 +14,7 @@ import type { DeleteProfileRequest, DeleteProfileResultDTO, EnsureProfileRequest
14
14
  import type { FilePeekDTO } from './dto/files.js';
15
15
  import type { MemoryDocRefDTO } from './dto/memory.js';
16
16
  import type { ChatInventoryDTO, ProspectiveChatInventoryDTO, ProspectiveChatInventoryQuery } from './dto/chat-inventory.js';
17
- import type { CredentialRemovalResultDTO, CredentialResultDTO, InstallCredentialRequest, ModelAuthListDTO } from './dto/modelauth.js';
17
+ import type { CredentialRemovalResultDTO, CredentialResultDTO, InstallCredentialRequest, ModelAuthListDTO, ModelAuthReadinessDTO, ModelAuthReadinessQuery } from './dto/modelauth.js';
18
18
  import type { CancelReviewRequest, CreateReviewRequest, ListReviewsQuery, ReviewCancelResultDTO, ReviewDocumentBaseDTO, ReviewDTO, ReviewListDTO, ReviewSubmitResultDTO } from './dto/reviews.js';
19
19
  import type { CreateReviewCommentRequest, EditReviewCommentRequest, ListReviewCommentsQuery, ReadReviewCommentEventsQuery, ReviewCommentActionRequest, ReviewCommentDetailDTO, ReviewCommentEventsDTO, ReviewCommentForkDTO, ReviewCommentListDTO, ReviewCommentMutationDTO, ReviewCommentRangeBatchRequest, ReviewCommentRangeBatchResultDTO } from './dto/review-comments.js';
20
20
  import type { CancelInboxTicketRequest, CanceledTicketResultDTO, InboxListDTO, InboxPageDTO, InboxPageHistoryDTO, InboxPageResponseDTO, InboxTicketIdDTO, PageFeedbackResolutionDTO, PageResponsesDTO, PageTicketResultDTO, RespondInboxPageRequest } from './dto/inbox.js';
@@ -25,16 +25,18 @@ import type { BrokerExtensionStateDTO, BrokerExecutionRequest, BrokerGeneratedNa
25
25
  import type { AcknowledgeMailRequest, AcknowledgeMailResultDTO, ClaimMailRequest, ClaimMailResultDTO } from './dto/mail.js';
26
26
  import type { BrokerFaultInputDTO, BrokerFaultRequest, BrokerFaultResultDTO, BrokerProviderRetryRequest, BrokerProviderRetryResultDTO, BrokerTurnRequest, BrokerTurnResultDTO, NodeFaultClearResultDTO, RecoveryStateDTO } from './dto/recovery.js';
27
27
  export interface CrtrClientOptions {
28
- /** Unix socket path (default local transport). Exactly one of socketPath|baseUrl. */
29
- socketPath?: string;
30
- /** `http(s)://host:port` for TCP/remote transport. */
31
- baseUrl?: string;
28
+ /** `http(s)://host:port` for a TCP/remote transport. */
29
+ baseUrl: string;
30
+ /** Fetch implementation. The global Web fetch is used unless a caller supplies one. */
31
+ fetch?: typeof fetch;
32
32
  /** Extra headers, e.g. `{ authorization: 'Bearer <token>' }` when the target
33
33
  * crtrd's TCP listener has `CRTRD_TOKEN` set (unix-socket transport is
34
34
  * never checked, and a TCP daemon with no token set ignores this too). */
35
35
  headers?: Record<string, string>;
36
- /** Autostart on a cold socket (default true for socketPath, false for baseUrl). */
36
+ /** Retry a cold local connection through this hook once when enabled. */
37
37
  autostart?: boolean;
38
+ /** Maximum retries for transient GET, HEAD, and DELETE requests. Defaults to 2. */
39
+ maxRetries?: number;
38
40
  /** Per-request timeout in ms (default 30_000). */
39
41
  timeoutMs?: number;
40
42
  /** Injected daemon-start hook (spec §7.1). Called once on a cold socket when
@@ -64,22 +66,26 @@ export declare function waitForDaemonAvailability({ windowMs, probe, initialErro
64
66
  now?: () => number;
65
67
  sleep?: (ms: number) => Promise<void> | void;
66
68
  }): Promise<void>;
69
+ export interface CrtrRequestOptions {
70
+ headers?: Record<string, string>;
71
+ signal?: AbortSignal;
72
+ timeout?: number;
73
+ maxRetries?: number;
74
+ }
67
75
  export declare class CrtrClient {
68
- private readonly socketPath?;
69
- private readonly baseUrl?;
76
+ private readonly baseUrl;
77
+ private readonly fetch;
70
78
  private readonly headers;
71
79
  private readonly autostart;
72
80
  private readonly timeoutMs;
81
+ private readonly maxRetries;
82
+ private readonly localSocketTransport;
73
83
  private readonly onColdSocket?;
74
84
  private readonly coldStartDiagnostic?;
75
85
  private readonly coldStartPollWindowMs;
76
86
  /** Guards against invoking the daemon-start hook more than once per client. */
77
87
  private coldStartAttempted;
78
88
  constructor(opts: CrtrClientOptions);
79
- /** Construct a client bound to the default local socket with autostart on. Pass
80
- * `onColdSocket` to enable the daemon-spawn hook (spec §7.1); without it a cold
81
- * socket fails loud with `daemon_unavailable`. */
82
- static forLocalSocket(opts?: Omit<CrtrClientOptions, 'socketPath' | 'baseUrl'>): CrtrClient;
83
89
  healthz(): Promise<HealthDTO>;
84
90
  /** One `/healthz` observation without cold-socket recovery. Availability
85
91
  * waiters own retry policy and pass their remaining wall-clock budget here. */
@@ -219,6 +225,7 @@ export declare class CrtrClient {
219
225
  /** Force-delete or detach one profile by exact id or unique name. */
220
226
  deleteProfile(name: string, req: DeleteProfileRequest): Promise<DeleteProfileResultDTO>;
221
227
  listModelAuth(): Promise<ModelAuthListDTO>;
228
+ getModelAuthReadiness(query?: ModelAuthReadinessQuery): Promise<ModelAuthReadinessDTO>;
222
229
  installCredential(provider: string, req: InstallCredentialRequest): Promise<CredentialResultDTO>;
223
230
  removeCredential(provider: string): Promise<CredentialRemovalResultDTO>;
224
231
  createReview(req: CreateReviewRequest): Promise<ReviewDTO>;
@@ -310,10 +317,12 @@ export declare class CrtrClient {
310
317
  canvasRoster(): Promise<RosterDTO>;
311
318
  prune(req: PruneRequest): Promise<PruneResultDTO>;
312
319
  /** Raw request for routes not yet method-wrapped. Applies the same
313
- * autostart + error-mapping semantics. */
314
- request<T>(method: string, path: string, body?: unknown, timeoutMs?: number): Promise<T>;
315
- /** The unparsed request: cold-socket and interrupted-response recovery, no
316
- * JSON parse. Every wrapper goes through here; only non-JSON routes call it directly. */
320
+ * autostart + retry + error-mapping semantics. */
321
+ request<T>(method: string, path: string, body?: unknown, opts?: CrtrRequestOptions): Promise<T>;
322
+ /** The unparsed request: cold-socket and interrupted-response recovery, plus
323
+ * the §7 retry policy (connection errors and 429/5xx on GET/HEAD/DELETE
324
+ * only — POST/PATCH are never replayed once a request has actually been
325
+ * sent). No JSON parse; every wrapper goes through here. */
317
326
  private send;
318
327
  private nodePath;
319
328
  /** Validate a background job id before route construction. Job ids arrive
@@ -343,7 +352,11 @@ export declare class CrtrClient {
343
352
  * agent argv, so a bad one is a plausible request the server would also
344
353
  * reject, not a caller-bug `TypeError` like `ticketId`. */
345
354
  private commentPath;
355
+ /** The one fetch path (§1). Resolves as soon as headers arrive — the body
356
+ * stays an unread `ReadableStream` on the returned `Response`, so a caller
357
+ * that wants to stream (SSE, later) never waits on a buffered body. */
346
358
  private transport;
359
+ private retainRequestLifetime;
347
360
  private isColdSocketError;
348
361
  /** A connection torn down MID-request (Node's "socket hang up" / a broken
349
362
  * pipe). It establishes only that the response was interrupted, not why. */
@@ -1,40 +1,14 @@
1
- // CrtrClient — the typed HTTP+WS client over crtrd's API (spec §3.4).
1
+ // CrtrClient — the typed fetch client over crtrd's API.
2
2
  //
3
- // PURITY (spec §3.1): the ONLY runtime imports are Node built-ins
4
- // (`node:http`, `node:https`, `node:os`, `node:path`) plus `src/api/*`. Never
5
- // `core/*` / `node:sqlite` / TUI. The daemon-spawn logic lives in `core`/
6
- // `daemon`, which this module may not import — so autostart is delegated to an
7
- // injectable `onColdSocket` hook the CLI wires in (spec §7.1). A cold socket
8
- // always gets one bounded availability observation; the hook only permits spawning.
9
- //
10
- // TRANSPORT (spec O-1): `node:http`/`node:https` `request()` — NO `undici`.
11
- // unix socket via `{ socketPath }`; TCP/remote via a parsed `baseUrl`.
12
- import { envHomeOverride } from '../shared/env.js';
13
- import { request as httpRequest } from 'node:http';
14
- import { request as httpsRequest } from 'node:https';
15
- import { homedir } from 'node:os';
16
- import { join } from 'node:path';
3
+ // The root entry is browser-safe: Node's unix-socket fetch implementation and
4
+ // local path resolution live exclusively in `api/node-transport.ts`.
17
5
  import { ApiError, isErrorBody } from './errors.js';
18
6
  import { routes } from './routes.js';
19
7
  import { isSafeNodeId } from './dto/common.js';
20
8
  import { isSafeBashJobId } from './dto/bash-jobs.js';
21
9
  import { isSafeCronId, } from './dto/crons.js';
22
- /** Filesystem/scope constants mirrored from `core/types.ts` (`CRTR_DIR_NAME`)
23
- * and `core/canvas/paths.ts` (`crtrHome`/`apiSocketPath`). Duplicated — not
24
- * imported — to keep the exported `/api` contract dependency-light; the two
25
- * MUST agree on the resolved socket path. */
26
- const CRTR_DIR_NAME = '.crouter';
27
- const SOCKET_BASENAME = 'crtrd.sock';
28
- /** Resolve crtrd's default unix socket path the same way `apiSocketPath()` does,
29
- * via Node built-ins only (no `core/canvas/paths.ts` import). */
30
- function defaultSocketPath() {
31
- const override = envHomeOverride();
32
- const home = override !== undefined && override !== ''
33
- ? override
34
- : join(homedir(), CRTR_DIR_NAME, 'canvas');
35
- return join(home, SOCKET_BASENAME);
36
- }
37
10
  const DEFAULT_TIMEOUT_MS = 30_000;
11
+ const LOCAL_SOCKET_FETCH = Symbol.for('@north-light/crouter-api/local-socket-fetch');
38
12
  /** Default bounded window to wait for `/healthz` to come up after
39
13
  * `onColdSocket`, when the caller does not pass `coldStartPollWindowMs`. */
40
14
  const HEALTHZ_POLL_WINDOW_MS = 10_000;
@@ -64,41 +38,34 @@ export async function waitForDaemonAvailability({ windowMs, probe, initialError,
64
38
  }
65
39
  }
66
40
  export class CrtrClient {
67
- socketPath;
68
41
  baseUrl;
42
+ fetch;
69
43
  headers;
70
44
  autostart;
71
45
  timeoutMs;
46
+ maxRetries;
47
+ localSocketTransport;
72
48
  onColdSocket;
73
49
  coldStartDiagnostic;
74
50
  coldStartPollWindowMs;
75
51
  /** Guards against invoking the daemon-start hook more than once per client. */
76
52
  coldStartAttempted = false;
77
53
  constructor(opts) {
78
- const hasSocket = opts.socketPath !== undefined && opts.socketPath !== '';
79
- const hasBaseUrl = opts.baseUrl !== undefined && opts.baseUrl !== '';
80
- if (hasSocket === hasBaseUrl) {
81
- throw new TypeError('CrtrClient requires exactly one of socketPath | baseUrl');
82
- }
83
- if (hasSocket)
84
- this.socketPath = opts.socketPath;
85
- if (hasBaseUrl)
86
- this.baseUrl = new URL(opts.baseUrl);
54
+ if (opts.baseUrl === '')
55
+ throw new TypeError('CrtrClient requires baseUrl');
56
+ this.baseUrl = new URL(opts.baseUrl);
57
+ this.fetch = opts.fetch ?? globalThis.fetch;
87
58
  this.headers = { ...opts.headers };
88
- this.autostart = opts.autostart ?? hasSocket;
59
+ this.autostart = opts.autostart ?? false;
89
60
  this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
61
+ this.maxRetries = opts.maxRetries ?? 2;
62
+ this.localSocketTransport = this.fetch[LOCAL_SOCKET_FETCH] === true;
90
63
  if (opts.onColdSocket !== undefined)
91
64
  this.onColdSocket = opts.onColdSocket;
92
65
  if (opts.coldStartDiagnostic !== undefined)
93
66
  this.coldStartDiagnostic = opts.coldStartDiagnostic;
94
67
  this.coldStartPollWindowMs = opts.coldStartPollWindowMs ?? HEALTHZ_POLL_WINDOW_MS;
95
68
  }
96
- /** Construct a client bound to the default local socket with autostart on. Pass
97
- * `onColdSocket` to enable the daemon-spawn hook (spec §7.1); without it a cold
98
- * socket fails loud with `daemon_unavailable`. */
99
- static forLocalSocket(opts) {
100
- return new CrtrClient({ socketPath: defaultSocketPath(), autostart: true, ...opts });
101
- }
102
69
  // Health / status
103
70
  healthz() {
104
71
  return this.request('GET', routes.healthz());
@@ -106,13 +73,13 @@ export class CrtrClient {
106
73
  /** One `/healthz` observation without cold-socket recovery. Availability
107
74
  * waiters own retry policy and pass their remaining wall-clock budget here. */
108
75
  async probeHealthz(timeoutMs) {
109
- const response = await this.transport('GET', routes.healthz(), undefined, undefined, timeoutMs);
76
+ const response = await this.transport('GET', routes.healthz(), undefined, { timeout: timeoutMs });
110
77
  // A restricted daemon is alive and deliberately answers its health DTO with
111
78
  // 503. Preserve that state for daemon startup management instead of treating
112
79
  // the expected non-2xx status as a generic API failure.
113
80
  if (response.status === 503) {
114
81
  try {
115
- const health = JSON.parse(response.text);
82
+ const health = await response.clone().json();
116
83
  if (typeof health.startup_blocked === 'string')
117
84
  return health;
118
85
  }
@@ -156,7 +123,7 @@ export class CrtrClient {
156
123
  if (waitSeconds !== undefined && (!Number.isInteger(waitSeconds) || waitSeconds < 0 || waitSeconds > 25)) {
157
124
  throw new RangeError('waitSeconds must be an integer between 0 and 25');
158
125
  }
159
- return this.request('GET', withQuery(routes.nodeOutcome(this.nodePath(id)), { wait: waitSeconds }), undefined, (waitSeconds ?? 0) * 1_000 + this.timeoutMs);
126
+ return this.request('GET', withQuery(routes.nodeOutcome(this.nodePath(id)), { wait: waitSeconds }), undefined, { timeout: (waitSeconds ?? 0) * 1_000 + this.timeoutMs });
160
127
  }
161
128
  /** Register or replace an armed target for terminal-outcome delivery. */
162
129
  registerOutcomeDelivery(id, req) {
@@ -438,6 +405,9 @@ export class CrtrClient {
438
405
  listModelAuth() {
439
406
  return this.request('GET', routes.modelAuths());
440
407
  }
408
+ getModelAuthReadiness(query) {
409
+ return this.request('GET', withQuery(routes.modelAuthReadiness(), query));
410
+ }
441
411
  installCredential(provider, req) {
442
412
  return this.request('PUT', routes.modelAuth(provider), req);
443
413
  }
@@ -632,29 +602,54 @@ export class CrtrClient {
632
602
  }
633
603
  // Escape hatch
634
604
  /** Raw request for routes not yet method-wrapped. Applies the same
635
- * autostart + error-mapping semantics. */
636
- async request(method, path, body, timeoutMs) {
637
- return parse(await this.send(method, path, body, undefined, timeoutMs));
638
- }
639
- /** The unparsed request: cold-socket and interrupted-response recovery, no
640
- * JSON parse. Every wrapper goes through here; only non-JSON routes call it directly. */
641
- async send(method, path, body, extraHeaders, timeoutMs) {
605
+ * autostart + retry + error-mapping semantics. */
606
+ async request(method, path, body, opts) {
642
607
  try {
643
- return await this.transport(method, path, body, extraHeaders, timeoutMs);
608
+ return await parse(await this.send(method, path, body, opts));
644
609
  }
645
- catch (err) {
646
- if (this.isColdSocketError(err)) {
647
- try {
648
- await this.handleColdSocket(err);
649
- return await this.transport(method, path, body, extraHeaders, timeoutMs);
610
+ catch (error) {
611
+ throw toTransportApiError(error, opts?.signal);
612
+ }
613
+ }
614
+ /** The unparsed request: cold-socket and interrupted-response recovery, plus
615
+ * the §7 retry policy (connection errors and 429/5xx on GET/HEAD/DELETE
616
+ * only — POST/PATCH are never replayed once a request has actually been
617
+ * sent). No JSON parse; every wrapper goes through here. */
618
+ async send(method, path, body, opts = {}) {
619
+ const idempotent = method === 'GET' || method === 'HEAD' || method === 'DELETE';
620
+ const maxRetries = opts.maxRetries ?? this.maxRetries;
621
+ for (let attempt = 0;; attempt++) {
622
+ let response;
623
+ try {
624
+ response = await this.transport(method, path, body, opts);
625
+ }
626
+ catch (err) {
627
+ if (this.isColdSocketError(err)) {
628
+ try {
629
+ await this.handleColdSocket(err);
630
+ response = await this.transport(method, path, body, opts);
631
+ }
632
+ catch (recoveryError) {
633
+ throw toTransportApiError(recoveryError, opts.signal);
634
+ }
635
+ }
636
+ else if (this.isInterruptedSocketError(err) && this.localSocketTransport && (!idempotent || attempt < maxRetries)) {
637
+ return await this.rideOutInterruptedRequest(method, path, body, opts);
650
638
  }
651
- catch (recoveryError) {
652
- throw toTransportApiError(recoveryError);
639
+ else if (idempotent && attempt < maxRetries && !isAbortError(err, opts.signal) && (!this.localSocketTransport || !this.isInterruptedSocketError(err))) {
640
+ await sleepMs(retryDelayMs(attempt + 1));
641
+ continue;
653
642
  }
643
+ else {
644
+ throw toTransportApiError(err, opts.signal);
645
+ }
646
+ }
647
+ if (idempotent && attempt < maxRetries && isRetryableStatus(response.status)) {
648
+ await drainResponseBody(response);
649
+ await sleepMs(retryDelayMs(attempt + 1));
650
+ continue;
654
651
  }
655
- if (this.isInterruptedSocketError(err))
656
- return await this.rideOutInterruptedRequest(method, path, body, extraHeaders, timeoutMs);
657
- throw toTransportApiError(err);
652
+ return response;
658
653
  }
659
654
  }
660
655
  // internals
@@ -721,85 +716,103 @@ export class CrtrClient {
721
716
  }
722
717
  return id;
723
718
  }
724
- transport(method, path, body, extraHeaders, timeoutMs = this.timeoutMs) {
725
- const usingHttps = this.baseUrl?.protocol === 'https:';
726
- const doRequest = usingHttps ? httpsRequest : httpRequest;
719
+ /** The one fetch path (§1). Resolves as soon as headers arrive — the body
720
+ * stays an unread `ReadableStream` on the returned `Response`, so a caller
721
+ * that wants to stream (SSE, later) never waits on a buffered body. */
722
+ transport(method, path, body, opts = {}) {
727
723
  const payload = body === undefined ? undefined : JSON.stringify(body);
728
- const headers = { accept: 'application/json', ...this.headers, ...extraHeaders };
729
- if (payload !== undefined) {
724
+ const headers = { accept: 'application/json', ...this.headers, ...opts.headers };
725
+ if (payload !== undefined)
730
726
  headers['content-type'] = 'application/json';
731
- headers['content-length'] = String(Buffer.byteLength(payload));
727
+ const timeoutMs = opts.timeout ?? this.timeoutMs;
728
+ const controller = new AbortController();
729
+ const timer = timeoutMs > 0
730
+ ? setTimeout(() => controller.abort(new DOMException('request timed out', 'TimeoutError')), timeoutMs)
731
+ : undefined;
732
+ const externalSignal = opts.signal;
733
+ const onExternalAbort = () => controller.abort(externalSignal?.reason);
734
+ if (externalSignal !== undefined) {
735
+ if (externalSignal.aborted)
736
+ controller.abort(externalSignal.reason);
737
+ else
738
+ externalSignal.addEventListener('abort', onExternalAbort, { once: true });
732
739
  }
733
- const options = {
734
- method,
735
- path,
736
- headers,
737
- timeout: timeoutMs,
740
+ const finish = () => {
741
+ if (timer !== undefined)
742
+ clearTimeout(timer);
743
+ externalSignal?.removeEventListener('abort', onExternalAbort);
738
744
  };
739
- if (this.socketPath !== undefined) {
740
- options.socketPath = this.socketPath;
741
- }
742
- else if (this.baseUrl !== undefined) {
743
- options.protocol = this.baseUrl.protocol;
744
- options.hostname = this.baseUrl.hostname;
745
- if (this.baseUrl.port !== '')
746
- options.port = this.baseUrl.port;
745
+ return this.fetch(new URL(path, this.baseUrl), { method, headers, body: payload, signal: controller.signal })
746
+ .then((response) => this.retainRequestLifetime(response, finish, () => controller.signal.aborted ? controller.signal.reason : undefined), (error) => {
747
+ finish();
748
+ throw error;
749
+ });
750
+ }
751
+ retainRequestLifetime(response, finish, abortReason) {
752
+ if (response.body === null) {
753
+ finish();
754
+ return response;
747
755
  }
748
- return new Promise((resolve, reject) => {
749
- const req = doRequest(options, (res) => {
750
- const chunks = [];
751
- res.on('data', (chunk) => chunks.push(chunk));
752
- res.on('end', () => {
753
- const received = {};
754
- for (const [name, value] of Object.entries(res.headers)) {
755
- if (value !== undefined)
756
- received[name] = Array.isArray(value) ? value.join(', ') : value;
756
+ const reader = response.body.getReader();
757
+ let finished = false;
758
+ const close = () => {
759
+ if (finished)
760
+ return;
761
+ finished = true;
762
+ finish();
763
+ };
764
+ const body = new ReadableStream({
765
+ async pull(controller) {
766
+ try {
767
+ const { done, value } = await reader.read();
768
+ if (done) {
769
+ close();
770
+ controller.close();
757
771
  }
758
- resolve({ status: res.statusCode ?? 0, text: Buffer.concat(chunks).toString('utf8'), headers: received });
759
- });
760
- res.on('error', reject);
761
- });
762
- req.on('error', reject);
763
- req.on('timeout', () => {
764
- req.destroy(Object.assign(new Error('request timed out'), { code: 'ETIMEDOUT' }));
765
- });
766
- if (payload !== undefined)
767
- req.write(payload);
768
- req.end();
772
+ else {
773
+ controller.enqueue(value);
774
+ }
775
+ }
776
+ catch (error) {
777
+ close();
778
+ controller.error(abortReason() ?? error);
779
+ }
780
+ },
781
+ async cancel(reason) {
782
+ close();
783
+ await reader.cancel(reason);
784
+ },
769
785
  });
786
+ return new Response(body, { status: response.status, statusText: response.statusText, headers: response.headers });
770
787
  }
771
788
  isColdSocketError(err) {
772
- if (this.socketPath === undefined)
773
- return false;
774
- const code = err?.code;
789
+ const code = transportErrorCode(err);
775
790
  return code === 'ECONNREFUSED' || code === 'ENOENT' || code === 'ENOTSOCK';
776
791
  }
777
792
  /** A connection torn down MID-request (Node's "socket hang up" / a broken
778
793
  * pipe). It establishes only that the response was interrupted, not why. */
779
794
  isInterruptedSocketError(err) {
780
- if (this.socketPath === undefined)
781
- return false;
782
- const code = err?.code;
795
+ const code = transportErrorCode(err);
783
796
  return code === 'ECONNRESET' || code === 'EPIPE';
784
797
  }
785
798
  /** Wait for the local API after an interrupted response. GET/HEAD can retry
786
799
  * because they are idempotent. A mutation may already have been applied, so
787
800
  * it never replays. */
788
- async rideOutInterruptedRequest(method, path, body, extraHeaders, timeoutMs) {
801
+ async rideOutInterruptedRequest(method, path, body, opts = {}) {
789
802
  try {
790
803
  await this.awaitAvailability();
791
804
  }
792
805
  catch (error) {
793
- throw toTransportApiError(error);
806
+ throw toTransportApiError(error, opts.signal);
794
807
  }
795
808
  if (method !== 'GET' && method !== 'HEAD') {
796
809
  throw new ApiError(503, 'daemon_request_interrupted', `crtrd connection ended before this ${method} response; the request may or may not have been applied.`);
797
810
  }
798
811
  try {
799
- return await this.transport(method, path, body, extraHeaders, timeoutMs);
812
+ return await this.transport(method, path, body, opts);
800
813
  }
801
814
  catch (err) {
802
- throw toTransportApiError(err);
815
+ throw toTransportApiError(err, opts.signal);
803
816
  }
804
817
  }
805
818
  async awaitAvailability(initialError) {
@@ -807,18 +820,19 @@ export class CrtrClient {
807
820
  windowMs: this.coldStartPollWindowMs,
808
821
  initialError,
809
822
  probe: async (timeoutMs) => {
810
- const response = await this.transport('GET', routes.healthz(), undefined, undefined, timeoutMs);
823
+ const response = await this.transport('GET', routes.healthz(), undefined, { timeout: timeoutMs });
811
824
  if (response.status >= 200 && response.status < 300)
812
825
  return;
826
+ const text = await response.text();
813
827
  try {
814
- const health = JSON.parse(response.text);
828
+ const health = JSON.parse(text);
815
829
  if (typeof health.startup_blocked === 'string')
816
830
  return;
817
831
  }
818
832
  catch {
819
833
  // The normal unavailable error below carries the response body.
820
834
  }
821
- throw new ApiError(response.status, 'daemon_health_unavailable', `crtrd health check returned HTTP ${response.status}: ${response.text.slice(0, 500)}`);
835
+ throw new ApiError(response.status, 'daemon_health_unavailable', `crtrd health check returned HTTP ${response.status}: ${text.slice(0, 500)}`);
822
836
  },
823
837
  });
824
838
  }
@@ -859,11 +873,12 @@ function withQuery(base, query) {
859
873
  const qs = params.toString();
860
874
  return qs === '' ? base : `${base}?${qs}`;
861
875
  }
862
- /** Parse a raw response into `T`, or throw `ApiError` on non-2xx. A 204/empty
863
- * body yields `undefined` (callers that type `void`/optional handle it). */
864
- function parse(res) {
876
+ /** Parse a response into `T` the first (and here, only) read of its body —
877
+ * or throw `ApiError` on non-2xx. A 204/empty body yields `undefined`
878
+ * (callers that type `void`/optional handle it). */
879
+ async function parse(res) {
865
880
  const ok = res.status >= 200 && res.status < 300;
866
- const trimmed = res.text.trim();
881
+ const trimmed = (await res.text()).trim();
867
882
  let payload;
868
883
  if (trimmed !== '') {
869
884
  try {
@@ -872,23 +887,77 @@ function parse(res) {
872
887
  catch {
873
888
  if (ok)
874
889
  return undefined;
875
- throw new ApiError(res.status, 'invalid_response', res.text.slice(0, 500));
890
+ throw new ApiError(res.status, 'invalid_response', trimmed.slice(0, 500), undefined, res.headers);
876
891
  }
877
892
  }
878
893
  if (ok)
879
894
  return payload;
880
895
  if (isErrorBody(payload)) {
881
- throw new ApiError(res.status, payload.error.code, payload.error.message, payload.error.details);
896
+ throw new ApiError(res.status, payload.error.code, payload.error.message, payload.error.details, res.headers);
882
897
  }
883
- throw new ApiError(res.status, 'internal', `request failed with status ${res.status}`);
898
+ throw new ApiError(res.status, 'internal', `request failed with status ${res.status}`, undefined, res.headers);
899
+ }
900
+ /** `429`/`5xx` are the only statuses the §7 retry policy replays. */
901
+ function isRetryableStatus(status) {
902
+ return status === 429 || status >= 500;
903
+ }
904
+ /** A response abandoned mid-retry must have its stream drained, or the
905
+ * underlying connection (and, over a unix socket, the daemon's handler) is
906
+ * held open by a reader that will never arrive. */
907
+ async function drainResponseBody(res) {
908
+ try {
909
+ await res.body?.cancel();
910
+ }
911
+ catch {
912
+ // Best-effort — the response is being discarded either way.
913
+ }
914
+ }
915
+ /** Exponential backoff for the §7 retry policy: 250ms, 500ms, 1s, capped at 2s. */
916
+ function retryDelayMs(attempt) {
917
+ return Math.min(2_000, 250 * 2 ** (attempt - 1));
918
+ }
919
+ /** The underlying `errno`-style code beneath a fetch failure. Native `fetch`
920
+ * wraps a connection error as `TypeError('fetch failed', { cause })`; the
921
+ * Node-only socket `fetch` (`api/node-transport.ts`) preserves the same
922
+ * shape so both transports classify identically here. */
923
+ function transportErrorCode(err) {
924
+ if (typeof err !== 'object' || err === null)
925
+ return undefined;
926
+ const direct = err.code;
927
+ if (typeof direct === 'string')
928
+ return direct;
929
+ const cause = err.cause;
930
+ if (typeof cause === 'object' && cause !== null) {
931
+ const causeCode = cause.code;
932
+ if (typeof causeCode === 'string')
933
+ return causeCode;
934
+ }
935
+ return undefined;
936
+ }
937
+ /** True when a transport throw is the caller's OWN abort, as opposed to the
938
+ * client's internal per-request timeout — an aborted request must never be
939
+ * retried, whichever reason fired. */
940
+ function isAbortError(err, signal) {
941
+ if (signal?.aborted)
942
+ return true;
943
+ return err instanceof DOMException && (err.name === 'AbortError' || err.name === 'TimeoutError');
884
944
  }
885
945
  /** Map a transport-layer throw (never an HTTP status) to an `ApiError`. A
886
946
  * connection refusal here means the daemon is unreachable and autostart could
887
947
  * not recover it. */
888
- function toTransportApiError(err) {
948
+ function toTransportApiError(err, signal) {
949
+ if (signal?.aborted) {
950
+ return new ApiError(0, 'request_aborted', 'request aborted by caller signal');
951
+ }
889
952
  if (err instanceof ApiError)
890
953
  return err;
891
- const code = err?.code;
954
+ if (err instanceof DOMException && err.name === 'TimeoutError') {
955
+ return new ApiError(504, 'request_timeout', `crtrd request timed out: ${err.message}`);
956
+ }
957
+ if (err instanceof DOMException && err.name === 'AbortError') {
958
+ return new ApiError(0, 'request_aborted', 'request aborted by caller signal');
959
+ }
960
+ const code = transportErrorCode(err);
892
961
  const message = err instanceof Error ? err.message : String(err);
893
962
  if (code === 'ECONNREFUSED' || code === 'ENOENT' || code === 'ENOTSOCK') {
894
963
  return new ApiError(503, 'daemon_unavailable', `crtrd is not reachable: ${message}`);
@@ -66,6 +66,21 @@ export type ModelAuthProviderStatusDTO = {
66
66
  export interface ModelAuthListDTO {
67
67
  providers: ModelAuthProviderStatusDTO[];
68
68
  }
69
+ /** Inputs that select the exact provider a new root node would use. */
70
+ export interface ModelAuthReadinessQuery {
71
+ profile?: string;
72
+ cwd?: string;
73
+ kind?: string;
74
+ model?: string;
75
+ }
76
+ /** `GET /v1/model-auth/readiness` — credential state for the provider selected by a prospective root launch. */
77
+ export interface ModelAuthReadinessDTO {
78
+ provider: string;
79
+ model: string;
80
+ credential: 'ready' | 'missing' | 'unusable';
81
+ reason?: 'invalid_grant';
82
+ rate_limited_until?: string | null;
83
+ }
69
84
  /** `DELETE /v1/model-auth/{provider}` — all user credentials removed for one provider. */
70
85
  export interface CredentialRemovalResultDTO {
71
86
  provider: string;
@@ -9,14 +9,17 @@ export interface ErrorBody {
9
9
  /** Thrown by `CrtrClient` on a non-2xx response (or a synthesized transport
10
10
  * failure such as an unreachable daemon). Carries the HTTP status, the stable
11
11
  * machine `code` from the `ErrorBody`, and optional structured `details`. */
12
- export declare class ApiError extends Error {
12
+ export declare class APIError extends Error {
13
13
  readonly status: number;
14
14
  readonly code: string;
15
15
  readonly details?: unknown;
16
- constructor(status: number, code: string, message: string, details?: unknown);
16
+ readonly headers: Headers;
17
+ constructor(status: number, code: string, message: string, details?: unknown, headers?: HeadersInit);
17
18
  }
19
+ /** @deprecated Use APIError. Kept as the in-repo compatibility alias. */
20
+ export { APIError as ApiError };
18
21
  /** A local API transport failure has stable status/code identity, independent
19
22
  * of message prose. */
20
- export declare function isDaemonTransportApiError(error: unknown): error is ApiError;
23
+ export declare function isDaemonTransportApiError(error: unknown): error is APIError;
21
24
  /** Type guard for an `ErrorBody`-shaped parsed payload. */
22
25
  export declare function isErrorBody(value: unknown): value is ErrorBody;
@@ -5,23 +5,27 @@
5
5
  /** Thrown by `CrtrClient` on a non-2xx response (or a synthesized transport
6
6
  * failure such as an unreachable daemon). Carries the HTTP status, the stable
7
7
  * machine `code` from the `ErrorBody`, and optional structured `details`. */
8
- export class ApiError extends Error {
8
+ export class APIError extends Error {
9
9
  status;
10
10
  code;
11
11
  details;
12
- constructor(status, code, message, details) {
12
+ headers;
13
+ constructor(status, code, message, details, headers) {
13
14
  super(message);
14
- this.name = 'ApiError';
15
+ this.name = 'APIError';
15
16
  this.status = status;
16
17
  this.code = code;
18
+ this.headers = new Headers(headers);
17
19
  if (details !== undefined)
18
20
  this.details = details;
19
21
  }
20
22
  }
23
+ /** @deprecated Use APIError. Kept as the in-repo compatibility alias. */
24
+ export { APIError as ApiError };
21
25
  /** A local API transport failure has stable status/code identity, independent
22
26
  * of message prose. */
23
27
  export function isDaemonTransportApiError(error) {
24
- if (!(error instanceof ApiError))
28
+ if (!(error instanceof APIError))
25
29
  return false;
26
30
  return (error.status === 503 && (error.code === 'daemon_unavailable'
27
31
  || error.code === 'transport_error'
@@ -1,6 +1,6 @@
1
1
  export { CrtrClient, waitForDaemonAvailability } from './client.js';
2
- export type { CrtrClientOptions } from './client.js';
3
- export { ApiError, isErrorBody } from './errors.js';
2
+ export type { CrtrClientOptions, CrtrRequestOptions } from './client.js';
3
+ export { APIError, ApiError, isErrorBody } from './errors.js';
4
4
  export type { ErrorBody } from './errors.js';
5
5
  export { API_VERSION, routes } from './routes.js';
6
6
  export * from '../shared/generated-context.js';
package/dist/api/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // route constants, error contract, and the typed `CrtrClient`. Dependency-light
3
3
  // by design (spec §3.1): Node built-ins + `src/api/*` only.
4
4
  export { CrtrClient, waitForDaemonAvailability } from './client.js';
5
- export { ApiError, isErrorBody } from './errors.js';
5
+ export { APIError, ApiError, isErrorBody } from './errors.js';
6
6
  export { API_VERSION, routes } from './routes.js';
7
7
  export * from '../shared/generated-context.js';
8
8
  export * from './dto/common.js';
@@ -0,0 +1,18 @@
1
+ import { CrtrClient, type CrtrClientOptions } from './client.js';
2
+ /** Resolve crtrd's default unix socket path the same way `apiSocketPath()` does,
3
+ * via Node built-ins only (no `core/canvas/paths.ts` import). */
4
+ export declare function defaultSocketPath(): string;
5
+ /** A standard `fetch` bound to one unix socket, built on `node:http` with
6
+ * `{ socketPath }`. The returned `Response`'s body is a live `ReadableStream`
7
+ * fed as bytes arrive — never buffered before the caller sees headers —
8
+ * which is what lets `CrtrClient` serve a streaming route (SSE) later without
9
+ * a second transport. Connection failures are wrapped as `TypeError('fetch
10
+ * failed', { cause })`, matching the shape native `fetch` throws, so
11
+ * `CrtrClient`'s cold-socket/interrupted-request classification (`err.cause.code`)
12
+ * reads identically off either transport. */
13
+ export declare function socketFetch(socketPath: string): typeof fetch;
14
+ /** Construct a client bound to the default local socket with autostart on.
15
+ * Without `onColdSocket`, a cold socket fails with `daemon_unavailable`. */
16
+ export declare function localClient(opts?: Omit<CrtrClientOptions, 'baseUrl' | 'fetch'> & {
17
+ socketPath?: string;
18
+ }): CrtrClient;
@@ -0,0 +1,101 @@
1
+ // The Node-only half of `@north-light/crouter-api`: crtrd's default unix
2
+ // socket path, a `fetch` implementation that speaks it, and `localClient()` —
3
+ // the one-line way to get a `CrtrClient` bound to the owner's own daemon.
4
+ //
5
+ // This is a SEPARATE package entry (`@north-light/crouter-api/node`) from the
6
+ // root so a browser bundle that imports the root never resolves `node:http`,
7
+ // `node:os`, or `node:path`. Import from here, never re-export it off the root.
8
+ import { request as httpRequest } from 'node:http';
9
+ import { homedir } from 'node:os';
10
+ import { join } from 'node:path';
11
+ import { CrtrClient } from './client.js';
12
+ import { envHomeOverride } from '../shared/env.js';
13
+ /** Filesystem/scope constants mirrored from `core/types.ts` (`CRTR_DIR_NAME`)
14
+ * and `core/canvas/paths.ts` (`crtrHome`/`apiSocketPath`). Duplicated — not
15
+ * imported — to keep the exported `/api` contract dependency-light; the two
16
+ * MUST agree on the resolved socket path. */
17
+ const CRTR_DIR_NAME = '.crouter';
18
+ const SOCKET_BASENAME = 'crtrd.sock';
19
+ const LOCAL_SOCKET_FETCH = Symbol.for('@north-light/crouter-api/local-socket-fetch');
20
+ /** Resolve crtrd's default unix socket path the same way `apiSocketPath()` does,
21
+ * via Node built-ins only (no `core/canvas/paths.ts` import). */
22
+ export function defaultSocketPath() {
23
+ const override = envHomeOverride();
24
+ const home = override !== undefined && override !== ''
25
+ ? override
26
+ : join(homedir(), CRTR_DIR_NAME, 'canvas');
27
+ return join(home, SOCKET_BASENAME);
28
+ }
29
+ /** A standard `fetch` bound to one unix socket, built on `node:http` with
30
+ * `{ socketPath }`. The returned `Response`'s body is a live `ReadableStream`
31
+ * fed as bytes arrive — never buffered before the caller sees headers —
32
+ * which is what lets `CrtrClient` serve a streaming route (SSE) later without
33
+ * a second transport. Connection failures are wrapped as `TypeError('fetch
34
+ * failed', { cause })`, matching the shape native `fetch` throws, so
35
+ * `CrtrClient`'s cold-socket/interrupted-request classification (`err.cause.code`)
36
+ * reads identically off either transport. */
37
+ export function socketFetch(socketPath) {
38
+ const fetchOverSocket = function (input, init = {}) {
39
+ const url = input instanceof URL ? input : new URL(typeof input === 'string' ? input : input.url);
40
+ const method = init.method ?? 'GET';
41
+ const headers = {};
42
+ if (init.headers !== undefined) {
43
+ for (const [name, value] of new Headers(init.headers).entries())
44
+ headers[name] = value;
45
+ }
46
+ const payload = typeof init.body === 'string' ? init.body : undefined;
47
+ if (payload !== undefined)
48
+ headers['content-length'] = String(Buffer.byteLength(payload));
49
+ return new Promise((resolvePromise, reject) => {
50
+ const req = httpRequest({ socketPath, path: url.pathname + url.search, method, headers }, (res) => {
51
+ const body = new ReadableStream({
52
+ start(controller) {
53
+ res.on('data', (chunk) => controller.enqueue(new Uint8Array(chunk)));
54
+ res.on('end', () => controller.close());
55
+ res.on('error', (err) => controller.error(err));
56
+ },
57
+ cancel() {
58
+ res.destroy();
59
+ },
60
+ });
61
+ const responseHeaders = new Headers();
62
+ for (const [name, value] of Object.entries(res.headers)) {
63
+ if (value === undefined)
64
+ continue;
65
+ for (const one of Array.isArray(value) ? value : [value])
66
+ responseHeaders.append(name, one);
67
+ }
68
+ resolvePromise(new Response(body, { status: res.statusCode ?? 0, statusText: res.statusMessage, headers: responseHeaders }));
69
+ });
70
+ req.on('error', (err) => reject(new TypeError('fetch failed', { cause: err })));
71
+ const signal = init.signal;
72
+ const abort = () => {
73
+ req.destroy();
74
+ reject(signal?.reason ?? new DOMException('The operation was aborted.', 'AbortError'));
75
+ };
76
+ if (signal !== null && signal !== undefined) {
77
+ if (signal.aborted) {
78
+ abort();
79
+ return;
80
+ }
81
+ signal.addEventListener('abort', abort, { once: true });
82
+ }
83
+ if (payload !== undefined)
84
+ req.write(payload);
85
+ req.end();
86
+ });
87
+ };
88
+ fetchOverSocket[LOCAL_SOCKET_FETCH] = true;
89
+ return fetchOverSocket;
90
+ }
91
+ /** Construct a client bound to the default local socket with autostart on.
92
+ * Without `onColdSocket`, a cold socket fails with `daemon_unavailable`. */
93
+ export function localClient(opts = {}) {
94
+ const { socketPath, ...rest } = opts;
95
+ return new CrtrClient({
96
+ baseUrl: 'http://localhost',
97
+ fetch: socketFetch(socketPath ?? defaultSocketPath()),
98
+ autostart: true,
99
+ ...rest,
100
+ });
101
+ }
@@ -109,6 +109,7 @@ export declare const routes: {
109
109
  readonly profileResume: (name: string) => string;
110
110
  readonly profileMetadata: (name: string) => string;
111
111
  readonly modelAuths: () => string;
112
+ readonly modelAuthReadiness: () => string;
112
113
  readonly modelAuth: (provider: string) => string;
113
114
  readonly filePeek: () => string;
114
115
  readonly memoryResolve: () => string;
@@ -138,6 +138,7 @@ export const routes = {
138
138
  profileMetadata: (name) => `${V}/profiles/${name}/metadata`,
139
139
  // Model auth
140
140
  modelAuths: () => `${V}/model-auth`,
141
+ modelAuthReadiness: () => `${V}/model-auth/readiness`,
141
142
  modelAuth: (provider) => `${V}/model-auth/${provider}`,
142
143
  // Host file read (browser file-peek panel). The absolute path rides as a
143
144
  // `path` query param, not a path segment — it is not a single safe segment.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/crouter-api",
3
- "version": "0.3.321",
3
+ "version": "0.3.322",
4
4
  "description": "Typed crtrd /v1 API contract — DTOs, route builders, the error contract, the CrtrClient, and the command-plugin manifest format. Zero runtime dependencies.",
5
5
  "type": "module",
6
6
  "main": "./dist/api/index.js",
@@ -12,6 +12,12 @@
12
12
  "require": "./dist/api/index.js",
13
13
  "default": "./dist/api/index.js"
14
14
  },
15
+ "./node": {
16
+ "types": "./dist/api/node-transport.d.ts",
17
+ "import": "./dist/api/node-transport.js",
18
+ "require": "./dist/api/node-transport.js",
19
+ "default": "./dist/api/node-transport.js"
20
+ },
15
21
  "./plugin-manifest": {
16
22
  "types": "./dist/api/plugin-manifest-schema.d.ts",
17
23
  "import": "./dist/api/plugin-manifest-schema.js",