the-moby-effect
Version:
Moby/Docker API client built using effect-ts
144 lines • 6.38 kB
JavaScript
import * as Effect from "effect/Effect";
import * as Function from "effect/Function";
import * as Layer from "effect/Layer";
import * as Predicate from "effect/Predicate";
import * as Stream from "effect/Stream";
import * as String from "effect/String";
import * as HttpClient from "effect/unstable/http/HttpClient";
import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest";
import * as MobyNumber from "../schemas/number.js";
import * as internalConnection from "./connection.js";
/** @internal */
export const makeVersionPath = connectionOptions => Predicate.isNotUndefined(connectionOptions.version) ? `/v${connectionOptions.version}` : "";
/** @internal */
export const makeHttpRequestUrl = /*#__PURE__*/internalConnection.MobyConnectionOptions.$match({
ssh: options => `http://0.0.0.0${makeVersionPath(options)}`,
socket: options => `http://0.0.0.0${makeVersionPath(options)}`,
http: options => `http://${options.host}:${options.port}${options.path ?? ""}${makeVersionPath(options)}`,
https: options => `https://${options.host}:${options.port}${options.path ?? ""}${makeVersionPath(options)}`
});
/** @internal */
export const makeWebsocketRequestUrl = /*#__PURE__*/internalConnection.MobyConnectionOptions.$match({
ssh: options => `ws://0.0.0.0${makeVersionPath(options)}`,
socket: options => `ws+unix://${options.socketPath}:${makeVersionPath(options)}`,
http: options => `ws://${options.host}:${options.port}${options.path ?? ""}${makeVersionPath(options)}`,
https: options => `wss://${options.host}:${options.port}${options.path ?? ""}${makeVersionPath(options)}`
});
/**
* Matches one complete JSON number token (integer or float, optional sign and
* exponent) anchored at the current scan position.
*
* @internal
*/
const numberToken = /-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/y;
/**
* Wraps every JSON number that is not inside a quoted string in quotes, so
* that 64-bit fields can be decoded losslessly as bigints from strings. A
* plain regex cannot do this: digits inside quoted strings (dates like
* "2023-08-30T22:26:00.000000000Z", ip addresses like "127.0.0.1", or any
* label value containing ": 123,") must not be touched, value-position
* matching misses array elements and negative numbers, and tokens like
* -1.5e9 must be quoted whole. So this walks the text tracking whether the
* position is inside a string (honoring backslash escapes) and quotes only
* complete number tokens found outside of strings.
*
* @internal
*/
export const replacer = text => {
let output = "";
let index = 0;
let insideString = false;
while (index < text.length) {
const char = text[index];
if (insideString) {
output += char;
if (char === "\\") {
output += text[index + 1] ?? "";
index += 2;
continue;
}
if (char === '"') insideString = false;
index += 1;
continue;
}
if (char === '"') {
insideString = true;
output += char;
index += 1;
continue;
}
numberToken.lastIndex = index;
const match = numberToken.exec(text);
if (Predicate.isNotNull(match)) {
output += `"${match[0]}"`;
index += match[0].length;
continue;
}
output += char;
index += 1;
}
return output;
};
/**
* Matches a quoted number token carrying the wire-number sentinel, i.e. the
* encoded form produced by the schemas in ../schemas/number.ts.
*
* @internal
*/
const sentinelNumberToken = /*#__PURE__*/new RegExp(`"${MobyNumber.wireNumberSentinel}(${numberToken.source})"`, "g");
/**
* The inverse of {@link replacer} for request bodies. Numeric schema fields
* encode to strings (so 64-bit fields survive the JavaScript number type),
* but the daemon expects bare JSON numbers. A blind inverse cannot work -
* `{"Memory":"1024"}` (was a bigint) is indistinguishable from a genuine
* string field containing digits - so the schemas prefix the strings they
* encode with a sentinel character and this strips the quotes and the
* sentinel from exactly those tokens.
*
* @internal
*/
export const unreplacer = text => text.replace(sentinelNumberToken, "$1");
/** @internal */
export const makeAgnosticHttpClientLayer = connectionOptions => Layer.effect(HttpClient.HttpClient, Effect.map(HttpClient.HttpClient, Function.flow(HttpClient.mapRequest(request => {
const httpUrl = makeHttpRequestUrl(connectionOptions);
const urlPrepended = HttpClientRequest.prependUrl(request, httpUrl);
// Json bodies may carry sentinel-marked numeric strings that
// the daemon must receive as bare JSON numbers - binary and
// streaming bodies never do and pass through untouched.
const body = urlPrepended.body;
if (body._tag === "Uint8Array" && body.contentType.includes("application/json")) {
const text = new TextDecoder().decode(body.body);
return HttpClientRequest.bodyText(urlPrepended, unreplacer(text), body.contentType);
}
return urlPrepended;
}), HttpClient.transformResponse(Effect.map(response => {
const handler = {
get: (target, prop, receiver) => {
// Binary bodies (tarballs, multiplexed log streams) must
// pass through untouched - only json bodies carry numbers
// that need re-quoting for lossless bigint decoding.
const contentType = target.headers["content-type"] ?? "";
if (!contentType.includes("application/json")) {
return Reflect.get(target, prop, receiver);
}
if (prop === "text") {
const ogText = Reflect.get(target, prop, receiver);
return Function.pipe(ogText, Effect.map(replacer));
}
if (prop === "arrayBuffer") {
const ogArrayBuffer = Reflect.get(target, prop, receiver);
return Function.pipe(ogArrayBuffer, Effect.map(ab => new TextDecoder().decode(ab)), Effect.map(replacer), Effect.map(str => new TextEncoder().encode(str).buffer));
}
if (prop === "stream") {
const ogStream = Reflect.get(target, prop, receiver);
return Function.pipe(ogStream, Stream.decodeText(), Stream.splitLines, Stream.map(replacer), Stream.map(String.concat("\n")), Stream.encodeText);
}
return Reflect.get(target, prop, receiver);
},
set: (target, prop, value, receiver) => {
return Reflect.set(target, prop, value, receiver);
}
};
return new Proxy(response, handler);
})))));
//# sourceMappingURL=agnostic.js.map