@buyong/pi-devin 0.1.5 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +3 -5
- package/README.md +3 -5
- package/index.ts +6 -15
- package/package.json +3 -1
- package/src/core/auth/access.ts +7 -3
- package/src/core/auth/callback-server.ts +57 -0
- package/src/core/auth/index.ts +2 -1
- package/src/core/auth/login.ts +104 -151
- package/src/core/auth/pkce.ts +32 -0
- package/src/core/auth/rest-exchange.ts +32 -30
- package/src/core/cancellation.ts +99 -0
- package/src/core/client.ts +127 -122
- package/src/core/errors.ts +115 -0
- package/src/core/index.ts +18 -4
- package/src/core/inference/accumulator.ts +174 -0
- package/src/core/inference/images.ts +22 -15
- package/src/core/inference/request.ts +59 -0
- package/src/core/inference/retry.ts +31 -31
- package/src/core/inference/state.ts +7 -3
- package/src/core/inference/stream-chat.ts +48 -0
- package/src/core/models/access.ts +4 -3
- package/src/core/models/catalog.ts +29 -20
- package/src/core/models/select.ts +14 -12
- package/src/core/models/variants.ts +85 -72
- package/src/core/protocol/connect-client.ts +288 -0
- package/src/core/protocol/device.ts +33 -34
- package/src/core/protocol/endpoint.ts +22 -0
- package/src/core/protocol/fingerprint.ts +38 -26
- package/src/core/protocol/frames.ts +101 -0
- package/src/core/protocol/http/fetch-client.ts +42 -0
- package/src/core/protocol/http/node-client.ts +130 -0
- package/src/core/protocol/http/response.ts +99 -0
- package/src/core/protocol/http.ts +41 -101
- package/src/core/protocol/protobuf.ts +93 -74
- package/src/core/redaction.ts +33 -0
- package/src/core/services/device-identity.ts +15 -0
- package/src/core/services/http-transport.ts +28 -0
- package/src/core/services/platform.ts +54 -0
- package/src/core/types.ts +2 -23
- package/src/core/validation.ts +25 -0
- package/src/pi/adapters/pi/devin-command.ts +82 -0
- package/src/pi/adapters/pi/fusion-command.ts +92 -0
- package/src/pi/adapters/pi/oauth.ts +14 -0
- package/src/pi/adapters/pi/provider.ts +22 -0
- package/src/pi/adapters/pi/registration.ts +89 -0
- package/src/pi/adapters/pi/response-stream.ts +164 -0
- package/src/pi/adapters/pi/sidekick-tool.ts +45 -0
- package/src/pi/adapters/pi/transcript.ts +177 -0
- package/src/pi/adapters/pi/types.ts +14 -0
- package/src/pi/{devin-panel.ts → adapters/pi/ui/devin-panel.ts} +11 -9
- package/src/pi/{fusion-picker.ts → adapters/pi/ui/fusion-picker.ts} +1 -1
- package/src/pi/adapters/pi/usage-status.ts +65 -0
- package/src/pi/adapters/storage/files.ts +66 -0
- package/src/pi/adapters/storage/json-file.ts +31 -0
- package/src/pi/app/config.ts +25 -0
- package/src/pi/app/environment.ts +30 -0
- package/src/pi/app/layer.ts +43 -0
- package/src/pi/app/runtime.ts +20 -0
- package/src/pi/app/session.ts +26 -0
- package/src/pi/features/accounts/service.ts +69 -0
- package/src/pi/features/accounts/types.ts +25 -0
- package/src/pi/features/auth/credential.ts +40 -0
- package/src/pi/features/auth/guard.ts +25 -0
- package/src/pi/features/auth/login.ts +116 -0
- package/src/pi/features/auth/state.ts +48 -0
- package/src/pi/features/auth/types.ts +36 -0
- package/src/pi/features/bounded.ts +4 -0
- package/src/pi/features/catalog/constants.ts +10 -0
- package/src/pi/features/catalog/fusion.ts +45 -0
- package/src/pi/features/catalog/ids.ts +79 -0
- package/src/pi/features/catalog/projection.ts +93 -0
- package/src/pi/features/catalog/remote.ts +15 -0
- package/src/pi/features/catalog/restore.ts +53 -0
- package/src/pi/features/catalog/service.ts +158 -0
- package/src/pi/features/catalog/types.ts +56 -0
- package/src/pi/features/clients/service.ts +47 -0
- package/src/pi/features/clients/types.ts +22 -0
- package/src/pi/features/default-scheduler.ts +12 -0
- package/src/pi/features/inference/request.ts +190 -0
- package/src/pi/features/inference/service.ts +33 -0
- package/src/pi/features/inference/types.ts +11 -0
- package/src/pi/features/lifetime/service.ts +19 -0
- package/src/pi/features/lifetime/types.ts +13 -0
- package/src/pi/features/runs/admission.ts +36 -0
- package/src/pi/features/runs/lifecycle.ts +207 -0
- package/src/pi/features/runs/service.ts +10 -0
- package/src/pi/features/runs/state.ts +37 -0
- package/src/pi/features/runs/types.ts +43 -0
- package/src/pi/features/settings/service.ts +46 -0
- package/src/pi/features/settings/types.ts +36 -0
- package/src/pi/features/sidekick/child.ts +228 -0
- package/src/pi/features/sidekick/command.ts +80 -0
- package/src/pi/features/sidekick/launcher.ts +12 -0
- package/src/pi/features/sidekick/prompts.ts +17 -0
- package/src/pi/features/sidekick/report.ts +51 -0
- package/src/pi/features/sidekick/service.ts +176 -0
- package/src/pi/features/sidekick/types.ts +99 -0
- package/src/pi/features/usage/changes.ts +20 -0
- package/src/pi/{usage.ts → features/usage/report.ts} +3 -34
- package/src/pi/features/usage/types.ts +33 -0
- package/src/core/inference/chat.ts +0 -234
- package/src/core/protocol/transport.ts +0 -513
- package/src/pi/auth.ts +0 -166
- package/src/pi/devin-command.ts +0 -86
- package/src/pi/devin-settings.ts +0 -50
- package/src/pi/extension.ts +0 -89
- package/src/pi/fusion-command.ts +0 -83
- package/src/pi/fusion-settings.ts +0 -44
- package/src/pi/lifecycle.ts +0 -217
- package/src/pi/messages.ts +0 -131
- package/src/pi/models.ts +0 -415
- package/src/pi/provider.ts +0 -278
- package/src/pi/sidekick-report.ts +0 -47
- package/src/pi/sidekick-runtime.ts +0 -300
- package/src/pi/sidekick.ts +0 -211
- package/src/pi/stream.ts +0 -124
- package/src/pi/usage-store.ts +0 -75
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { createCipheriv, createHash, randomBytes } from "node:crypto";
|
|
2
2
|
import { performance } from "node:perf_hooks";
|
|
3
|
-
import {
|
|
3
|
+
import { Effect } from "effect";
|
|
4
|
+
import { toDevinError, type DevinError } from "../errors.ts";
|
|
5
|
+
import { failWith } from "../validation.ts";
|
|
6
|
+
import type { DeviceIdentity } from "./device.ts";
|
|
4
7
|
|
|
5
8
|
// Matches the CLI's fingerprint construction; verified with fake-token requests only.
|
|
6
9
|
// This is not encryption for credential storage. f contains the API token, so it is never logged.
|
|
@@ -8,8 +11,10 @@ const KEY_MATERIAL = "df97465349f38646af1d66c263e25e08";
|
|
|
8
11
|
const KEY = createHash("sha256").update(KEY_MATERIAL, "utf8").digest();
|
|
9
12
|
|
|
10
13
|
export interface FingerprintProvider {
|
|
11
|
-
|
|
12
|
-
|
|
14
|
+
/** A new ciphertext per request: the same device, a fresh timestamp and nonce. */
|
|
15
|
+
readonly create: Effect.Effect<string, DevinError>;
|
|
16
|
+
/** The device hash, collected once on first use. */
|
|
17
|
+
readonly device: Effect.Effect<string, DevinError>;
|
|
13
18
|
}
|
|
14
19
|
|
|
15
20
|
export function hashDeviceIdentity(identity: DeviceIdentity): string {
|
|
@@ -17,20 +22,20 @@ export function hashDeviceIdentity(identity: DeviceIdentity): string {
|
|
|
17
22
|
return createHash("sha512").update(macs + identity.serial + identity.username, "utf8").digest("hex");
|
|
18
23
|
}
|
|
19
24
|
|
|
20
|
-
export
|
|
21
|
-
|
|
25
|
+
export interface FingerprintOptions {
|
|
26
|
+
/** Device values for this provider. Collected on first use and kept; concurrent first requests share one collection. */
|
|
27
|
+
collectIdentity: Effect.Effect<DeviceIdentity, DevinError>;
|
|
22
28
|
nowNanoseconds?: () => bigint;
|
|
23
29
|
nonce?: () => Uint8Array;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
return {
|
|
28
|
-
device
|
|
29
|
-
create: () => encodeFingerprint({
|
|
30
|
-
deviceFingerprint:
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function makeFingerprintProvider(sessionToken: string, options: FingerprintOptions): Effect.Effect<FingerprintProvider> {
|
|
33
|
+
return Effect.map(Effect.cached(Effect.map(options.collectIdentity, hashDeviceIdentity)), (device) => ({
|
|
34
|
+
device,
|
|
35
|
+
create: Effect.flatMap(device, (deviceFingerprint) => encodeFingerprint({
|
|
36
|
+
deviceFingerprint, sessionToken, timestampNanoseconds: options.nowNanoseconds?.(), nonce: options.nonce?.(),
|
|
37
|
+
})),
|
|
38
|
+
}));
|
|
34
39
|
}
|
|
35
40
|
|
|
36
41
|
export function unixNanoseconds(): bigint {
|
|
@@ -45,15 +50,22 @@ export function encodeFingerprint(options: {
|
|
|
45
50
|
sessionToken: string;
|
|
46
51
|
timestampNanoseconds?: bigint;
|
|
47
52
|
nonce?: Uint8Array;
|
|
48
|
-
}): string {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
53
|
+
}): Effect.Effect<string, DevinError> {
|
|
54
|
+
return Effect.suspend(() => {
|
|
55
|
+
if (!/^[0-9a-f]{128}$/.test(options.deviceFingerprint)) return failWith("The device identifier must be 128 lowercase hex characters.");
|
|
56
|
+
if (!options.sessionToken || /[|\s\u0000-\u001f\u007f]/.test(options.sessionToken)) return failWith("Invalid session token format.");
|
|
57
|
+
const timestamp = options.timestampNanoseconds ?? unixNanoseconds();
|
|
58
|
+
if (timestamp < 0n || timestamp >= (1n << 128n)) return failWith("Invalid fingerprint timestamp.");
|
|
59
|
+
const nonce = options.nonce ?? randomBytes(12);
|
|
60
|
+
if (nonce.length !== 12) return failWith("The fingerprint nonce must be 12 bytes.");
|
|
61
|
+
const plaintext = `${options.deviceFingerprint}|${options.sessionToken}|${timestamp}`;
|
|
62
|
+
return Effect.try({
|
|
63
|
+
try: () => {
|
|
64
|
+
const cipher = createCipheriv("aes-256-gcm", KEY, nonce);
|
|
65
|
+
const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
66
|
+
return Buffer.concat([nonce, ciphertext, cipher.getAuthTag()]).toString("hex");
|
|
67
|
+
},
|
|
68
|
+
catch: toDevinError,
|
|
69
|
+
});
|
|
70
|
+
});
|
|
59
71
|
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { gunzipSync } from "node:zlib";
|
|
2
|
+
import { Effect, Stream } from "effect";
|
|
3
|
+
import { DevinError } from "../errors.ts";
|
|
4
|
+
import type { JsonObject } from "../types.ts";
|
|
5
|
+
import { failWith, object } from "../validation.ts";
|
|
6
|
+
import { chatResponseCodec } from "./protobuf.ts";
|
|
7
|
+
|
|
8
|
+
export const MAX_FRAME_BYTES = 8 * 1024 * 1024;
|
|
9
|
+
export const MAX_RESPONSE_BYTES = 64 * 1024 * 1024;
|
|
10
|
+
|
|
11
|
+
export interface Frame {
|
|
12
|
+
isEnd: boolean;
|
|
13
|
+
data: JsonObject;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function encodeFrame(bytes: Uint8Array, flags = 0): Effect.Effect<Buffer, DevinError> {
|
|
17
|
+
return Effect.suspend(() => {
|
|
18
|
+
const payload = Buffer.from(bytes);
|
|
19
|
+
if (payload.length > MAX_FRAME_BYTES) return failWith("The request frame exceeds 8 MiB.");
|
|
20
|
+
const header = Buffer.alloc(5);
|
|
21
|
+
header[0] = flags;
|
|
22
|
+
header.writeUInt32BE(payload.length, 1);
|
|
23
|
+
return Effect.succeed(Buffer.concat([header, payload]));
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The connection ended before the stream was complete, so a new request may succeed. */
|
|
28
|
+
const prematureClose = (message: string) => new DevinError({ message, code: "ERR_STREAM_PREMATURE_CLOSE" });
|
|
29
|
+
|
|
30
|
+
/** Parses one response's Connect envelopes. HTTP chunk boundaries and Connect frame boundaries are independent. */
|
|
31
|
+
class FrameDecoder {
|
|
32
|
+
private buffered: Buffer = Buffer.alloc(0);
|
|
33
|
+
private hasEnded = false;
|
|
34
|
+
private wireBytes = 0;
|
|
35
|
+
private decodedBytes = 0;
|
|
36
|
+
private readonly decoder = new TextDecoder("utf-8", { fatal: true });
|
|
37
|
+
private readonly compression: "identity" | "gzip" | undefined;
|
|
38
|
+
|
|
39
|
+
constructor(compression: "identity" | "gzip" | undefined) {
|
|
40
|
+
this.compression = compression;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
push(chunk: Uint8Array): Effect.Effect<Frame[], DevinError> {
|
|
44
|
+
const self = this;
|
|
45
|
+
return Effect.gen(function* () {
|
|
46
|
+
self.wireBytes += chunk.byteLength;
|
|
47
|
+
if (self.wireBytes > MAX_RESPONSE_BYTES) return yield* failWith("The response exceeds 64 MiB.");
|
|
48
|
+
if (self.hasEnded && chunk.byteLength > 0) return yield* failWith("Data follows EndStream.");
|
|
49
|
+
self.buffered = Buffer.concat([self.buffered, chunk]);
|
|
50
|
+
const frames: Frame[] = [];
|
|
51
|
+
while (self.buffered.length >= 5) {
|
|
52
|
+
if (self.hasEnded) return yield* failWith("A frame follows EndStream.");
|
|
53
|
+
const flags = self.buffered[0];
|
|
54
|
+
if ((flags & ~3) !== 0) return yield* failWith(`Unknown Connect flags: ${flags}`);
|
|
55
|
+
const payloadBytes = self.buffered.readUInt32BE(1);
|
|
56
|
+
if (payloadBytes > MAX_FRAME_BYTES) return yield* failWith("The response frame exceeds 8 MiB.");
|
|
57
|
+
if (self.buffered.length < 5 + payloadBytes) break;
|
|
58
|
+
let payload: Buffer = self.buffered.subarray(5, 5 + payloadBytes);
|
|
59
|
+
self.buffered = self.buffered.subarray(5 + payloadBytes);
|
|
60
|
+
if (flags & 1) {
|
|
61
|
+
if (self.compression !== "gzip") return yield* failWith("A compressed frame has no valid gzip encoding header.");
|
|
62
|
+
const compressed = payload;
|
|
63
|
+
payload = yield* Effect.try({
|
|
64
|
+
try: () => gunzipSync(compressed, { maxOutputLength: MAX_FRAME_BYTES }),
|
|
65
|
+
catch: () => new DevinError({ message: "The gzip frame is corrupt or exceeds the decompression limit." }),
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
self.decodedBytes += payload.byteLength;
|
|
69
|
+
if (self.decodedBytes > MAX_RESPONSE_BYTES) return yield* failWith("The decompressed response exceeds 64 MiB.");
|
|
70
|
+
self.hasEnded = Boolean(flags & 2);
|
|
71
|
+
const bytes = payload;
|
|
72
|
+
const data = self.hasEnded
|
|
73
|
+
? yield* Effect.try({ try: () => JSON.parse(self.decoder.decode(bytes)) as unknown, catch: () => undefined }).pipe(
|
|
74
|
+
Effect.flatMap((value) => object(value, "EndStream")),
|
|
75
|
+
Effect.mapError(() => new DevinError({ message: "EndStream is not a valid UTF-8 JSON object." })),
|
|
76
|
+
)
|
|
77
|
+
: yield* chatResponseCodec.decode(bytes);
|
|
78
|
+
frames.push({ isEnd: self.hasEnded, data });
|
|
79
|
+
}
|
|
80
|
+
return frames;
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
get end(): Effect.Effect<void, DevinError> {
|
|
85
|
+
if (this.buffered.length !== 0) return Effect.fail(prematureClose("The connection ended with an incomplete Connect frame."));
|
|
86
|
+
if (!this.hasEnded) return Effect.fail(prematureClose("The connection ended without EndStream."));
|
|
87
|
+
return Effect.void;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Decodes Connect frames from a response body. A truncated stream fails as a premature close. */
|
|
92
|
+
export function decodeFrames<E, R>(chunks: Stream.Stream<Uint8Array, E, R>, options: { compression?: "identity" | "gzip" } = {}): Stream.Stream<Frame, E | DevinError, R> {
|
|
93
|
+
return Stream.suspend(() => {
|
|
94
|
+
const decoder = new FrameDecoder(options.compression);
|
|
95
|
+
return chunks.pipe(
|
|
96
|
+
Stream.mapEffect((chunk) => decoder.push(chunk)),
|
|
97
|
+
Stream.flattenIterable,
|
|
98
|
+
Stream.concat(Stream.fromEffectDrain(Effect.suspend(() => decoder.end))),
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { HttpClient, type HttpClientError, type HttpClientRequest, type HttpClientResponse } from "effect/http";
|
|
3
|
+
import { requestBody, transportError } from "./node-client.ts";
|
|
4
|
+
import { DevinHttpResponse } from "./response.ts";
|
|
5
|
+
|
|
6
|
+
const ignore = () => {};
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* `HttpClient` over an injected `fetch`, such as the one Pi passes in stream options. Sends the transport's exact headers
|
|
10
|
+
* and body, never follows redirects, and discards a response that arrives after its request ended.
|
|
11
|
+
*/
|
|
12
|
+
export function makeFetchHttpClient(fetchImpl: typeof fetch): HttpClient.HttpClient {
|
|
13
|
+
const send = (request: HttpClientRequest.HttpClientRequest) => Effect.callback<HttpClientResponse.HttpClientResponse, HttpClientError.HttpClientError>((resume) => {
|
|
14
|
+
const controller = new AbortController();
|
|
15
|
+
let isSettled = false;
|
|
16
|
+
let pending: Promise<Response>;
|
|
17
|
+
try {
|
|
18
|
+
pending = Promise.resolve(fetchImpl(request.url, {
|
|
19
|
+
method: request.method, redirect: "error", headers: Object.fromEntries(Object.entries(request.headers)),
|
|
20
|
+
body: requestBody(request) as BodyInit | undefined, signal: controller.signal,
|
|
21
|
+
}));
|
|
22
|
+
} catch (error) {
|
|
23
|
+
resume(Effect.fail(transportError(request, error)));
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
pending.then((response) => {
|
|
27
|
+
if (isSettled) { void response.body?.cancel().catch(ignore); return; }
|
|
28
|
+
isSettled = true;
|
|
29
|
+
resume(Effect.succeed(new DevinHttpResponse(request, response, () => controller.abort())));
|
|
30
|
+
}, (error: unknown) => {
|
|
31
|
+
if (isSettled) return;
|
|
32
|
+
isSettled = true;
|
|
33
|
+
resume(Effect.fail(transportError(request, error)));
|
|
34
|
+
});
|
|
35
|
+
return Effect.sync(() => {
|
|
36
|
+
if (isSettled) return;
|
|
37
|
+
isSettled = true;
|
|
38
|
+
controller.abort();
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
return HttpClient.makeWith<HttpClientError.HttpClientError, never, HttpClientError.HttpClientError, never>((request) => Effect.flatMap(request, send), Effect.succeed);
|
|
42
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { Agent as HTTPAgent, request as httpRequest, type IncomingMessage } from "node:http";
|
|
2
|
+
import { Agent as HTTPSAgent, request as httpsRequest } from "node:https";
|
|
3
|
+
import { pipeline, Readable } from "node:stream";
|
|
4
|
+
import type { ConnectionOptions } from "node:tls";
|
|
5
|
+
import { createGunzip } from "node:zlib";
|
|
6
|
+
import { Effect, type Scope } from "effect";
|
|
7
|
+
import { HttpClient, HttpClientError, type HttpClientRequest, type HttpClientResponse } from "effect/http";
|
|
8
|
+
import { DevinHttpResponse } from "./response.ts";
|
|
9
|
+
|
|
10
|
+
export interface HTTPTransportOptions {
|
|
11
|
+
/** Trusts a user-supplied CA without disabling certificate or host verification. */
|
|
12
|
+
ca?: ConnectionOptions["ca"];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function transportError(request: HttpClientRequest.HttpClientRequest, cause: unknown): HttpClientError.HttpClientError {
|
|
16
|
+
return new HttpClientError.HttpClientError({ reason: new HttpClientError.TransportError({ request, cause }) });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The request body as the transports send it: bytes or text, never a stream. */
|
|
20
|
+
export function requestBody(request: HttpClientRequest.HttpClientRequest): Uint8Array | string | undefined {
|
|
21
|
+
const body = request.body;
|
|
22
|
+
if (body._tag === "Uint8Array") return body.body;
|
|
23
|
+
if (body._tag === "Raw" && (typeof body.body === "string" || body.body instanceof Uint8Array)) return body.body;
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function nodeResponse(incoming: IncomingMessage): Response | Error {
|
|
28
|
+
const responseHeaders = new Headers();
|
|
29
|
+
for (const [name, value] of Object.entries(incoming.headers)) {
|
|
30
|
+
for (const entry of Array.isArray(value) ? value : value === undefined ? [] : [value]) responseHeaders.append(name, entry);
|
|
31
|
+
}
|
|
32
|
+
const status = incoming.statusCode ?? 502;
|
|
33
|
+
const hasNoBody = [204, 205, 304].includes(status);
|
|
34
|
+
const encoding = responseHeaders.get("content-encoding")?.trim().toLowerCase();
|
|
35
|
+
let stream: Readable = incoming;
|
|
36
|
+
if (hasNoBody) incoming.resume();
|
|
37
|
+
else if (encoding && encoding !== "identity") {
|
|
38
|
+
if (encoding !== "gzip") {
|
|
39
|
+
// An encoding problem in an error body must not hide an already received 401/403/429 status.
|
|
40
|
+
if (status >= 200 && status < 300) return new Error("Unsupported HTTP response compression.");
|
|
41
|
+
} else {
|
|
42
|
+
const decoded = createGunzip();
|
|
43
|
+
// Propagates consumer cancellation and compression errors to the original socket and keeps backpressure.
|
|
44
|
+
pipeline(incoming, decoded, () => {});
|
|
45
|
+
stream = decoded;
|
|
46
|
+
responseHeaders.delete("content-encoding");
|
|
47
|
+
responseHeaders.delete("content-length");
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
return new Response(hasNoBody ? null : Readable.toWeb(stream) as ReadableStream<Uint8Array>, { status, headers: responseHeaders });
|
|
52
|
+
} catch (error) {
|
|
53
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* HTTP/1.1-only `HttpClient` over `node:http`. Owns a pool for the scope's lifetime and verifies TLS certificates and hosts.
|
|
59
|
+
* The 5000ms socket timeout spells out the previous Node global agent setting as a PoC policy;
|
|
60
|
+
* it is not the overall RPC deadline. It does not claim to match the CLI's TLS or pool policy.
|
|
61
|
+
*/
|
|
62
|
+
export function makeNodeHttpClient(options: HTTPTransportOptions = {}): Effect.Effect<HttpClient.HttpClient, never, Scope.Scope> {
|
|
63
|
+
return Effect.gen(function* () {
|
|
64
|
+
const pool = yield* Effect.acquireRelease(Effect.sync(() => ({
|
|
65
|
+
isClosed: false,
|
|
66
|
+
http: new HTTPAgent({ keepAlive: true, timeout: 5000 }),
|
|
67
|
+
https: new HTTPSAgent({
|
|
68
|
+
keepAlive: true, timeout: 5000, rejectUnauthorized: true, minVersion: "TLSv1.2",
|
|
69
|
+
ALPNProtocols: ["http/1.1"], ...(options.ca === undefined ? {} : { ca: options.ca }),
|
|
70
|
+
}),
|
|
71
|
+
})), (pool) => Effect.sync(() => {
|
|
72
|
+
pool.isClosed = true;
|
|
73
|
+
pool.http.destroy();
|
|
74
|
+
pool.https.destroy();
|
|
75
|
+
}));
|
|
76
|
+
|
|
77
|
+
const send = (request: HttpClientRequest.HttpClientRequest): Effect.Effect<HttpClientResponse.HttpClientResponse, HttpClientError.HttpClientError> => Effect.suspend(() => {
|
|
78
|
+
if (pool.isClosed) return Effect.fail(transportError(request, new Error("The HTTP transport is closed.")));
|
|
79
|
+
if (request.method !== "POST" && request.method !== "GET") return Effect.fail(transportError(request, new Error("This transport supports only URL GET/POST without redirects.")));
|
|
80
|
+
let url: URL;
|
|
81
|
+
try { url = new URL(request.url); } catch (error) { return Effect.fail(transportError(request, error)); }
|
|
82
|
+
if (!["https:", "http:"].includes(url.protocol)) return Effect.fail(transportError(request, new Error("Unsupported HTTP protocol.")));
|
|
83
|
+
const raw = requestBody(request);
|
|
84
|
+
const body = raw === undefined ? undefined : Buffer.from(raw);
|
|
85
|
+
if (request.method === "POST" && !body) return Effect.fail(transportError(request, new Error("A POST body must be a string or a byte array.")));
|
|
86
|
+
if (request.method === "GET" && body) return Effect.fail(transportError(request, new Error("A GET body is not supported.")));
|
|
87
|
+
let headers: Record<string, string>;
|
|
88
|
+
try {
|
|
89
|
+
// Validates names and values the same way fetch does before anything is sent.
|
|
90
|
+
const validated = new Headers(Object.entries(request.headers));
|
|
91
|
+
headers = {};
|
|
92
|
+
for (const [name, value] of validated) if (name !== "host" && name !== "content-length") headers[name] = value;
|
|
93
|
+
} catch (error) { return Effect.fail(transportError(request, error)); }
|
|
94
|
+
headers.host = url.host;
|
|
95
|
+
if (body) headers["content-length"] = String(body.byteLength);
|
|
96
|
+
const isHTTPS = url.protocol === "https:";
|
|
97
|
+
return Effect.callback<HttpClientResponse.HttpClientResponse, HttpClientError.HttpClientError>((resume) => {
|
|
98
|
+
let isSettled = false;
|
|
99
|
+
const fail = (cause: unknown) => {
|
|
100
|
+
if (isSettled) return;
|
|
101
|
+
isSettled = true;
|
|
102
|
+
resume(Effect.fail(transportError(request, cause)));
|
|
103
|
+
};
|
|
104
|
+
const outgoing = (isHTTPS ? httpsRequest : httpRequest)(url, {
|
|
105
|
+
method: request.method, headers, agent: isHTTPS ? pool.https : pool.http,
|
|
106
|
+
}, (incoming) => {
|
|
107
|
+
if (isSettled) { incoming.destroy(); return; }
|
|
108
|
+
const response = nodeResponse(incoming);
|
|
109
|
+
if (response instanceof Error) { incoming.destroy(); fail(response); return; }
|
|
110
|
+
isSettled = true;
|
|
111
|
+
resume(Effect.succeed(new DevinHttpResponse(request, response, () => { incoming.destroy(); outgoing.destroy(); })));
|
|
112
|
+
});
|
|
113
|
+
outgoing.on("error", fail);
|
|
114
|
+
outgoing.once("upgrade", (_response, socket) => {
|
|
115
|
+
socket.destroy();
|
|
116
|
+
fail(new Error("HTTP upgrade is not supported."));
|
|
117
|
+
});
|
|
118
|
+
outgoing.removeHeader("connection");
|
|
119
|
+
outgoing.end(body);
|
|
120
|
+
return Effect.sync(() => {
|
|
121
|
+
if (isSettled) return;
|
|
122
|
+
isSettled = true;
|
|
123
|
+
outgoing.destroy();
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
return HttpClient.makeWith<HttpClientError.HttpClientError, never, HttpClientError.HttpClientError, never>((request) => Effect.flatMap(request, send), Effect.succeed);
|
|
129
|
+
});
|
|
130
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { Effect, Inspectable, Option, Stream } from "effect";
|
|
2
|
+
import { pipeArguments } from "effect/Pipeable";
|
|
3
|
+
import { HttpClientError, HttpClientResponse, HttpIncomingMessage, type HttpClientRequest } from "effect/http";
|
|
4
|
+
|
|
5
|
+
const ignore = () => {};
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A web `Response` seen through Effect's `HttpClientResponse`. Unlike `HttpClientResponse.fromWeb`, ending its body early
|
|
9
|
+
* never waits for an uncooperative `cancel()`, and the transport is stopped only while the body is still in flight,
|
|
10
|
+
* so a fully read keep-alive connection stays reusable.
|
|
11
|
+
*/
|
|
12
|
+
export class DevinHttpResponse extends Inspectable.Class implements HttpClientResponse.HttpClientResponse {
|
|
13
|
+
readonly [HttpClientResponse.TypeId] = HttpClientResponse.TypeId;
|
|
14
|
+
readonly [HttpIncomingMessage.TypeId] = HttpIncomingMessage.TypeId;
|
|
15
|
+
readonly request: HttpClientRequest.HttpClientRequest;
|
|
16
|
+
readonly source: Response;
|
|
17
|
+
private readonly web: HttpClientResponse.HttpClientResponse;
|
|
18
|
+
private readonly abortTransport: () => void;
|
|
19
|
+
private isBodySettled = false;
|
|
20
|
+
|
|
21
|
+
constructor(request: HttpClientRequest.HttpClientRequest, source: Response, abortTransport: () => void) {
|
|
22
|
+
super();
|
|
23
|
+
this.request = request;
|
|
24
|
+
this.source = source;
|
|
25
|
+
this.abortTransport = abortTransport;
|
|
26
|
+
this.web = HttpClientResponse.fromWeb(request, source);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
get url() { return this.web.url; }
|
|
30
|
+
get status() { return this.source.status; }
|
|
31
|
+
get headers() { return this.web.headers; }
|
|
32
|
+
get cookies() { return this.web.cookies; }
|
|
33
|
+
get remoteAddress() { return Option.none<string>(); }
|
|
34
|
+
get formData() { return this.web.formData; }
|
|
35
|
+
get json() { return this.web.json; }
|
|
36
|
+
get text() { return this.web.text; }
|
|
37
|
+
get urlParamsBody() { return this.web.urlParamsBody; }
|
|
38
|
+
get arrayBuffer() { return this.web.arrayBuffer; }
|
|
39
|
+
|
|
40
|
+
/** Stops an unread or partly read body. Responses that arrive after their request ended are discarded the same way. */
|
|
41
|
+
get discard(): Effect.Effect<void> {
|
|
42
|
+
return Effect.sync(() => this.abandon(undefined));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
private abandon(reader: ReadableStreamDefaultReader<Uint8Array> | undefined): void {
|
|
46
|
+
if (this.isBodySettled) return;
|
|
47
|
+
this.isBodySettled = true;
|
|
48
|
+
try {
|
|
49
|
+
if (reader) { void reader.cancel().catch(ignore); reader.releaseLock(); }
|
|
50
|
+
else if (this.source.body && !this.source.body.locked) void this.source.body.cancel().catch(ignore);
|
|
51
|
+
} finally { this.abortTransport(); }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
get stream(): Stream.Stream<Uint8Array, HttpClientError.HttpClientError> {
|
|
55
|
+
return Stream.suspend(() => {
|
|
56
|
+
const body = this.source.body;
|
|
57
|
+
if (!body || this.isBodySettled) {
|
|
58
|
+
return Stream.fail(new HttpClientError.HttpClientError({
|
|
59
|
+
reason: new HttpClientError.EmptyBodyError({ request: this.request, response: this, description: "The response has no body." }),
|
|
60
|
+
}));
|
|
61
|
+
}
|
|
62
|
+
const reader = body.getReader();
|
|
63
|
+
const iterator: AsyncIterator<Uint8Array> = {
|
|
64
|
+
next: async () => {
|
|
65
|
+
try {
|
|
66
|
+
const item = await reader.read();
|
|
67
|
+
if (item.done && !this.isBodySettled) { this.isBodySettled = true; reader.releaseLock(); }
|
|
68
|
+
return item.done ? { done: true, value: undefined } : item;
|
|
69
|
+
} catch (error) {
|
|
70
|
+
this.abandon(reader);
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
// Called when the consumer stops early. Cancellation is started but never awaited.
|
|
75
|
+
return: async () => {
|
|
76
|
+
this.abandon(reader);
|
|
77
|
+
return { done: true, value: undefined };
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
return Stream.fromAsyncIterable({ [Symbol.asyncIterator]: () => iterator }, (cause) => new HttpClientError.HttpClientError({
|
|
81
|
+
reason: new HttpClientError.DecodeError({ request: this.request, response: this, cause }),
|
|
82
|
+
}));
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
toJSON(): unknown {
|
|
87
|
+
return HttpIncomingMessage.inspect(this, { _id: "HttpClientResponse", request: this.request.toJSON(), status: this.status });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
pipe() {
|
|
91
|
+
// biome-ignore lint/complexity/noArguments: Pipeable forwards the call's arguments.
|
|
92
|
+
return pipeArguments(this, arguments);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Discards the body of a response this package's transports produced. */
|
|
97
|
+
export function discardResponse(response: HttpClientResponse.HttpClientResponse): Effect.Effect<void> {
|
|
98
|
+
return response instanceof DevinHttpResponse ? response.discard : Effect.void;
|
|
99
|
+
}
|
|
@@ -1,109 +1,49 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
5
|
-
import { createGunzip } from "node:zlib";
|
|
1
|
+
import { Effect, Stream } from "effect";
|
|
2
|
+
import type { HttpClientError, HttpClientResponse } from "effect/http";
|
|
3
|
+
import { DevinError, toDevinFailure, type DevinFailure } from "../errors.ts";
|
|
4
|
+
import { failWith } from "../validation.ts";
|
|
6
5
|
|
|
7
|
-
export
|
|
8
|
-
|
|
9
|
-
|
|
6
|
+
export { discardResponse } from "./http/response.ts";
|
|
7
|
+
export { makeFetchHttpClient } from "./http/fetch-client.ts";
|
|
8
|
+
export { makeNodeHttpClient, type HTTPTransportOptions } from "./http/node-client.ts";
|
|
9
|
+
|
|
10
|
+
/** Keeps the transport cause (message and Node error code) so callers can tell network failures apart. */
|
|
11
|
+
export function fromHttpClientError(error: HttpClientError.HttpClientError): DevinFailure {
|
|
12
|
+
const reason = error.reason;
|
|
13
|
+
switch (reason._tag) {
|
|
14
|
+
case "TransportError":
|
|
15
|
+
case "DecodeError":
|
|
16
|
+
case "EncodeError":
|
|
17
|
+
case "InvalidUrlError": return toDevinFailure(reason.cause ?? reason.description ?? error.message);
|
|
18
|
+
case "EmptyBodyError": return new DevinError({ message: reason.description ?? "The response has no body." });
|
|
19
|
+
case "StatusCodeError": return new DevinError({ message: reason.description ?? error.message });
|
|
20
|
+
}
|
|
10
21
|
}
|
|
11
22
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
23
|
+
/** The response body as chunks, failing as Devin failures. */
|
|
24
|
+
export function responseChunks(response: HttpClientResponse.HttpClientResponse): Stream.Stream<Uint8Array, DevinFailure> {
|
|
25
|
+
return Stream.mapError(response.stream, fromHttpClientError);
|
|
15
26
|
}
|
|
16
27
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
28
|
+
export function readBoundedBytes(response: HttpClientResponse.HttpClientResponse, maxBytes: number): Effect.Effect<Buffer, DevinFailure> {
|
|
29
|
+
return Effect.suspend(() => {
|
|
30
|
+
let bytes = 0;
|
|
31
|
+
const chunks: Uint8Array[] = [];
|
|
32
|
+
return responseChunks(response).pipe(
|
|
33
|
+
Stream.runForEach((chunk) => {
|
|
34
|
+
bytes += chunk.byteLength;
|
|
35
|
+
if (bytes > maxBytes) return failWith("The response size limit was exceeded.");
|
|
36
|
+
chunks.push(chunk);
|
|
37
|
+
return Effect.void;
|
|
38
|
+
}),
|
|
39
|
+
Effect.map(() => Buffer.concat(chunks)),
|
|
40
|
+
);
|
|
27
41
|
});
|
|
28
|
-
let isClosed = false;
|
|
29
|
-
const fetchHTTP: typeof fetch = async (input, init) => {
|
|
30
|
-
if (isClosed) throw new Error("The HTTP transport is closed.");
|
|
31
|
-
if (!(typeof input === "string" || input instanceof URL) || !["POST", "GET"].includes(init?.method ?? "") || init?.redirect !== "error") {
|
|
32
|
-
throw new Error("This transport supports only URL GET/POST without redirects.");
|
|
33
|
-
}
|
|
34
|
-
const url = new URL(input);
|
|
35
|
-
if (!["https:", "http:"].includes(url.protocol)) throw new Error("Unsupported HTTP protocol.");
|
|
36
|
-
const body = typeof init.body === "string" ? Buffer.from(init.body)
|
|
37
|
-
: init.body instanceof Uint8Array ? Buffer.from(init.body) : undefined;
|
|
38
|
-
if (init.method === "POST" && !body) throw new Error("A POST body must be a string or a byte array.");
|
|
39
|
-
if (init.method === "GET" && init.body != null) throw new Error("A GET body is not supported.");
|
|
40
|
-
const validated = new Headers(init.headers);
|
|
41
|
-
const entries = init.headers instanceof Headers ? [...init.headers.entries()]
|
|
42
|
-
: Array.isArray(init.headers) ? init.headers : Object.entries(init.headers ?? {});
|
|
43
|
-
const headers: Record<string, string> = {};
|
|
44
|
-
for (const [name] of entries) {
|
|
45
|
-
const key = name.toLowerCase();
|
|
46
|
-
if (key !== "host" && key !== "content-length") headers[key] = validated.get(name)!;
|
|
47
|
-
}
|
|
48
|
-
headers.host = url.host;
|
|
49
|
-
if (body) headers["content-length"] = String(body.byteLength);
|
|
50
|
-
return new Promise<Response>((resolve, reject) => {
|
|
51
|
-
const isHTTPS = url.protocol === "https:";
|
|
52
|
-
const send = isHTTPS ? httpsRequest : httpRequest;
|
|
53
|
-
const request = send(url, {
|
|
54
|
-
method: init.method, headers, signal: init.signal ?? undefined,
|
|
55
|
-
agent: isHTTPS ? httpsAgent : httpAgent,
|
|
56
|
-
}, (incoming) => {
|
|
57
|
-
try {
|
|
58
|
-
const responseHeaders = new Headers();
|
|
59
|
-
for (const [name, value] of Object.entries(incoming.headers)) {
|
|
60
|
-
for (const entry of Array.isArray(value) ? value : value === undefined ? [] : [value]) responseHeaders.append(name, entry);
|
|
61
|
-
}
|
|
62
|
-
const status = incoming.statusCode ?? 502;
|
|
63
|
-
const hasNoBody = [204, 205, 304].includes(status);
|
|
64
|
-
const encoding = responseHeaders.get("content-encoding")?.trim().toLowerCase();
|
|
65
|
-
let stream: Readable = incoming;
|
|
66
|
-
if (hasNoBody) incoming.resume();
|
|
67
|
-
else if (encoding && encoding !== "identity") {
|
|
68
|
-
if (encoding !== "gzip") {
|
|
69
|
-
// An encoding problem in an error body must not hide an already received 401/403/429 status.
|
|
70
|
-
if (status >= 200 && status < 300) throw new Error("Unsupported HTTP response compression.");
|
|
71
|
-
} else {
|
|
72
|
-
const decoded = createGunzip();
|
|
73
|
-
// Propagates consumer cancellation and compression errors to the original socket and keeps backpressure.
|
|
74
|
-
pipeline(incoming, decoded, () => {});
|
|
75
|
-
stream = decoded;
|
|
76
|
-
responseHeaders.delete("content-encoding");
|
|
77
|
-
responseHeaders.delete("content-length");
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
resolve(new Response(hasNoBody ? null : Readable.toWeb(stream) as ReadableStream<Uint8Array>, {
|
|
81
|
-
status, headers: responseHeaders,
|
|
82
|
-
}));
|
|
83
|
-
} catch (error) {
|
|
84
|
-
incoming.destroy();
|
|
85
|
-
reject(error);
|
|
86
|
-
}
|
|
87
|
-
});
|
|
88
|
-
request.once("error", reject);
|
|
89
|
-
request.once("upgrade", (_response, socket) => {
|
|
90
|
-
socket.destroy();
|
|
91
|
-
reject(new Error("HTTP upgrade is not supported."));
|
|
92
|
-
});
|
|
93
|
-
request.removeHeader("connection");
|
|
94
|
-
request.end(body);
|
|
95
|
-
});
|
|
96
|
-
};
|
|
97
|
-
return {
|
|
98
|
-
fetch: fetchHTTP,
|
|
99
|
-
close() {
|
|
100
|
-
if (isClosed) return;
|
|
101
|
-
isClosed = true;
|
|
102
|
-
httpAgent.destroy();
|
|
103
|
-
httpsAgent.destroy();
|
|
104
|
-
},
|
|
105
|
-
};
|
|
106
42
|
}
|
|
107
43
|
|
|
108
|
-
|
|
109
|
-
|
|
44
|
+
export function readBoundedJSON(response: HttpClientResponse.HttpClientResponse, maxBytes: number): Effect.Effect<unknown, DevinFailure> {
|
|
45
|
+
return Effect.flatMap(readBoundedBytes(response, maxBytes), (bytes) => Effect.try({
|
|
46
|
+
try: () => JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)) as unknown,
|
|
47
|
+
catch: () => new DevinError({ message: "The server did not return valid JSON." }),
|
|
48
|
+
}));
|
|
49
|
+
}
|