@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.
- package/dist/api/__tests__/integration/client.test.js +22 -6
- package/dist/api/client.d.ts +29 -16
- package/dist/api/client.js +198 -129
- package/dist/api/dto/modelauth.d.ts +15 -0
- package/dist/api/errors.d.ts +6 -3
- package/dist/api/errors.js +8 -4
- package/dist/api/index.d.ts +2 -2
- package/dist/api/index.js +1 -1
- package/dist/api/node-transport.d.ts +18 -0
- package/dist/api/node-transport.js +101 -0
- package/dist/api/routes.d.ts +1 -0
- package/dist/api/routes.js +1 -0
- package/package.json +7 -1
|
@@ -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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
});
|
package/dist/api/client.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
|
|
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
|
-
/**
|
|
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
|
|
69
|
-
private readonly
|
|
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,
|
|
315
|
-
/** The unparsed request: cold-socket and interrupted-response recovery,
|
|
316
|
-
*
|
|
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. */
|
package/dist/api/client.js
CHANGED
|
@@ -1,40 +1,14 @@
|
|
|
1
|
-
// CrtrClient — the typed
|
|
1
|
+
// CrtrClient — the typed fetch client over crtrd's API.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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 ??
|
|
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,
|
|
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 =
|
|
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,
|
|
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.
|
|
608
|
+
return await parse(await this.send(method, path, body, opts));
|
|
644
609
|
}
|
|
645
|
-
catch (
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
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
|
-
|
|
652
|
-
|
|
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
|
-
|
|
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
|
-
|
|
725
|
-
|
|
726
|
-
|
|
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, ...
|
|
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
|
-
|
|
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
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
timeout: timeoutMs,
|
|
740
|
+
const finish = () => {
|
|
741
|
+
if (timer !== undefined)
|
|
742
|
+
clearTimeout(timer);
|
|
743
|
+
externalSignal?.removeEventListener('abort', onExternalAbort);
|
|
738
744
|
};
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
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
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
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
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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(
|
|
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}: ${
|
|
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
|
|
863
|
-
*
|
|
864
|
-
|
|
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',
|
|
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
|
-
|
|
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;
|
package/dist/api/errors.d.ts
CHANGED
|
@@ -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
|
|
12
|
+
export declare class APIError extends Error {
|
|
13
13
|
readonly status: number;
|
|
14
14
|
readonly code: string;
|
|
15
15
|
readonly details?: unknown;
|
|
16
|
-
|
|
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
|
|
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;
|
package/dist/api/errors.js
CHANGED
|
@@ -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
|
|
8
|
+
export class APIError extends Error {
|
|
9
9
|
status;
|
|
10
10
|
code;
|
|
11
11
|
details;
|
|
12
|
-
|
|
12
|
+
headers;
|
|
13
|
+
constructor(status, code, message, details, headers) {
|
|
13
14
|
super(message);
|
|
14
|
-
this.name = '
|
|
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
|
|
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'
|
package/dist/api/index.d.ts
CHANGED
|
@@ -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
|
+
}
|
package/dist/api/routes.d.ts
CHANGED
|
@@ -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;
|
package/dist/api/routes.js
CHANGED
|
@@ -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.
|
|
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",
|