EdgeAIG's picture
download
raw
26.3 kB
import * as Cause from "../../Cause.js";
import { Clock } from "../../Clock.js";
import * as Context from "../../Context.js";
import * as Duration from "../../Duration.js";
import * as Effect from "../../Effect.js";
import * as Fiber from "../../Fiber.js";
import { constant, constFalse, constTrue, dual, flow, identity } from "../../Function.js";
import * as Inspectable from "../../Inspectable.js";
import * as Layer from "../../Layer.js";
import * as Option from "../../Option.js";
import { pipeArguments } from "../../Pipeable.js";
import * as Predicate from "../../Predicate.js";
import * as Ref from "../../Ref.js";
import * as Result from "../../Result.js";
import * as Schedule from "../../Schedule.js";
import * as Stream from "../../Stream.js";
import * as Tracer from "../../Tracer.js";
import * as Cookies from "./Cookies.js";
import * as Headers from "./Headers.js";
import * as Error from "./HttpClientError.js";
import * as HttpClientRequest from "./HttpClientRequest.js";
import * as HttpClientResponse from "./HttpClientResponse.js";
import * as HttpIncomingMessage from "./HttpIncomingMessage.js";
import * as HttpMethod from "./HttpMethod.js";
import * as TraceContext from "./HttpTraceContext.js";
import * as UrlParams from "./UrlParams.js";
const TypeId = "~effect/http/HttpClient";
/**
* Returns `true` if the provided value is an `HttpClient`.
*
* @category guards
* @since 4.0.0
*/
export const isHttpClient = u => Predicate.hasProperty(u, TypeId);
/**
* Service tag for the default outgoing HTTP client service.
*
* **When to use**
*
* Use to provide the default outgoing HTTP client service used by request
* accessors such as `execute`, `get`, and `post`.
*
* @category services
* @since 4.0.0
*/
export const HttpClient = /*#__PURE__*/Context.Service("effect/HttpClient");
const accessor = method => (...args) => Effect.flatMap(HttpClient, client => client[method](...args));
/**
* Executes a prebuilt `HttpClientRequest` using the `HttpClient` service from the environment.
*
* @category accessors
* @since 4.0.0
*/
export const execute = /*#__PURE__*/accessor("execute");
/**
* Executes a `GET` request using the `HttpClient` service from the environment.
*
* @category accessors
* @since 4.0.0
*/
export const get = /*#__PURE__*/accessor("get");
/**
* Executes a `HEAD` request using the `HttpClient` service from the environment.
*
* @category accessors
* @since 4.0.0
*/
export const head = /*#__PURE__*/accessor("head");
/**
* Executes a `POST` request using the `HttpClient` service from the environment.
*
* @category accessors
* @since 4.0.0
*/
export const post = /*#__PURE__*/accessor("post");
/**
* Executes a `PATCH` request using the `HttpClient` service from the environment.
*
* @category accessors
* @since 4.0.0
*/
export const patch = /*#__PURE__*/accessor("patch");
/**
* Executes a `PUT` request using the `HttpClient` service from the environment.
*
* @category accessors
* @since 4.0.0
*/
export const put = /*#__PURE__*/accessor("put");
/**
* Executes a `DELETE` request using the `HttpClient` service from the environment.
*
* @category accessors
* @since 4.0.0
*/
export const del = /*#__PURE__*/accessor("del");
/**
* Executes an `OPTIONS` request using the `HttpClient` service from the environment.
*
* @category accessors
* @since 4.0.0
*/
export const options = /*#__PURE__*/accessor("options");
/**
* Transforms a client by wrapping the response effect for each request.
*
* **Details**
*
* The transformation receives both the response effect and the original request, allowing it to change success, error, and environment behavior.
*
* @category mapping & sequencing
* @since 4.0.0
*/
export const transform = /*#__PURE__*/dual(2, (self, f) => makeWith(Effect.flatMap(request => f(self.postprocess(Effect.succeed(request)), request)), self.preprocess));
/**
* Transforms a client by applying an effectful transformation to each response effect.
*
* @category mapping & sequencing
* @since 4.0.0
*/
export const transformResponse = /*#__PURE__*/dual(2, (self, f) => makeWith(request => f(self.postprocess(request)), self.preprocess));
const catch_ = /*#__PURE__*/dual(2, (self, f) => transformResponse(self, Effect.catch(f)));
export {
/**
* Handles all client failures with an effectful recovery function and returns a transformed client.
*
* @category error handling
* @since 4.0.0
*/
catch_ as catch };
/**
* Handles client failures with one or more matching `_tag` values and returns a transformed client.
*
* @category error handling
* @since 4.0.0
*/
export const catchTag = /*#__PURE__*/dual(3, (self, tag, f) => transformResponse(self, effect => Effect.catchTag(effect, tag, f)));
/**
* Handles client failures by matching their `_tag` values against a case map.
*
* @category error handling
* @since 4.0.0
*/
export const catchTags = /*#__PURE__*/dual(2, (self, cases) => transformResponse(self, Effect.catchTags(cases)));
/**
* Filters the result of a response, or runs an alternative effect if the predicate fails.
*
* @category filters
* @since 4.0.0
*/
export const filterOrElse = /*#__PURE__*/dual(3, (self, f, orElse) => transformResponse(self, Effect.filterOrElse(f, orElse)));
/**
* Filters successful responses, or fails with the error produced by `orFailWith` when the predicate does not match.
*
* @category filters
* @since 4.0.0
*/
export const filterOrFail = /*#__PURE__*/dual(3, (self, f, orFailWith) => transformResponse(self, Effect.filterOrFail(f, orFailWith)));
/**
* Filters responses by HTTP status code.
*
* @category filters
* @since 4.0.0
*/
export const filterStatus = /*#__PURE__*/dual(2, (self, f) => transformResponse(self, Effect.flatMap(HttpClientResponse.filterStatus(f))));
/**
* Filters responses that return a 2xx status code.
*
* @category filters
* @since 4.0.0
*/
export const filterStatusOk = /*#__PURE__*/transformResponse(/*#__PURE__*/Effect.flatMap(HttpClientResponse.filterStatusOk));
/**
* Constructs an `HttpClient.With` from a preprocessing function and a postprocessing function.
*
* **Details**
*
* `execute` applies preprocessing to the request and then passes the resulting request effect to postprocessing.
*
* @category constructors
* @since 4.0.0
*/
export const makeWith = (postprocess, preprocess) => {
const self = Object.create(Proto);
self.preprocess = preprocess;
self.postprocess = postprocess;
self.execute = function (request) {
return postprocess(preprocess(request));
};
return self;
};
const Proto = {
[TypeId]: TypeId,
pipe() {
return pipeArguments(this, arguments);
},
...Inspectable.BaseProto,
toJSON() {
return {
_id: "effect/HttpClient"
};
},
... /*#__PURE__*/Object.fromEntries(/*#__PURE__*/HttpMethod.allShort.map(([fullMethod, method]) => [method, function (url, options) {
return this.execute(HttpClientRequest.make(fullMethod)(url, options));
}]))
};
/**
* Constructs an `HttpClient` from a low-level request runner.
*
* **Details**
*
* The runner receives the request, resolved URL, abort signal, and current fiber. The client wrapper handles URL construction failures, tracing and propagation, header redaction, and aborting non-scoped requests on interruption.
*
* @category constructors
* @since 4.0.0
*/
export const make = f => makeWith(effect => Effect.flatMap(effect, request => Effect.withFiber(fiber => {
const scopedController = scopedRequests.get(request);
const controller = scopedController ?? new AbortController();
const urlResult = UrlParams.makeUrl(request.url, request.urlParams, Option.getOrUndefined(request.hash));
if (Result.isFailure(urlResult)) {
return Effect.fail(new Error.HttpClientError({
reason: new Error.InvalidUrlError({
request,
cause: urlResult.failure
})
}));
}
const url = urlResult.success;
const tracerDisabled = fiber.getRef(Tracer.DisablePropagation) || fiber.getRef(TracerDisabledWhen)(request);
if (tracerDisabled) {
const effect = f(request, url, controller.signal, fiber);
if (scopedController) return effect;
return Effect.uninterruptibleMask(restore => Effect.matchCauseEffect(restore(effect), {
onSuccess(response) {
responseRegistry.register(response, controller);
return Effect.succeed(new InterruptibleResponse(response, controller));
},
onFailure(cause) {
if (Cause.hasInterrupts(cause)) {
controller.abort();
}
return Effect.failCause(cause);
}
}));
}
return Effect.useSpan(fiber.getRef(SpanNameGenerator)(request), {
kind: "client"
}, span => {
span.attribute("http.request.method", request.method);
span.attribute("server.address", url.origin);
if (url.port !== "") {
span.attribute("server.port", +url.port);
}
span.attribute("url.full", url.toString());
span.attribute("url.path", url.pathname);
span.attribute("url.scheme", url.protocol.slice(0, -1));
const query = url.search.slice(1);
if (query !== "") {
span.attribute("url.query", query);
}
const redactedHeaderNames = fiber.getRef(Headers.CurrentRedactedNames);
const redactedHeaders = Headers.redact(request.headers, redactedHeaderNames);
for (const name in redactedHeaders) {
span.attribute(`http.request.header.${name}`, String(redactedHeaders[name]));
}
request = fiber.getRef(TracerPropagationEnabled) ? HttpClientRequest.setHeaders(request, TraceContext.toHeaders(span)) : request;
return Effect.uninterruptibleMask(restore => restore(f(request, url, controller.signal, fiber)).pipe(Effect.withParentSpan(span, {
captureStackTrace: false
}), Effect.matchCauseEffect({
onSuccess: response => {
span.attribute("http.response.status_code", response.status);
const redactedHeaders = Headers.redact(response.headers, redactedHeaderNames);
for (const name in redactedHeaders) {
span.attribute(`http.response.header.${name}`, String(redactedHeaders[name]));
}
if (scopedController) return Effect.succeed(response);
responseRegistry.register(response, controller);
return Effect.succeed(new InterruptibleResponse(response, controller));
},
onFailure(cause) {
if (!scopedController && Cause.hasInterrupts(cause)) {
controller.abort();
}
return Effect.failCause(cause);
}
})));
});
})), Effect.succeed);
/**
* Appends a transformation of the request object before sending it.
*
* @category mapping & sequencing
* @since 4.0.0
*/
export const mapRequest = /*#__PURE__*/dual(2, (self, f) => makeWith(self.postprocess, request => Effect.map(self.preprocess(request), f)));
/**
* Appends an effectful transformation of the request object before sending it.
*
* @category mapping & sequencing
* @since 4.0.0
*/
export const mapRequestEffect = /*#__PURE__*/dual(2, (self, f) => makeWith(self.postprocess, request => Effect.flatMap(self.preprocess(request), f)));
/**
* Prepends a transformation of the request object before sending it.
*
* @category mapping & sequencing
* @since 4.0.0
*/
export const mapRequestInput = /*#__PURE__*/dual(2, (self, f) => makeWith(self.postprocess, request => self.preprocess(f(request))));
/**
* Prepends an effectful transformation of the request object before sending it.
*
* @category mapping & sequencing
* @since 4.0.0
*/
export const mapRequestInputEffect = /*#__PURE__*/dual(2, (self, f) => makeWith(self.postprocess, request => Effect.flatMap(f(request), self.preprocess)));
/**
* Retries the request based on a provided schedule or policy.
*
* @category error handling
* @since 4.0.0
*/
export const retry = /*#__PURE__*/dual(2, (self, policy) => transformResponse(self, Effect.retry(policy)));
/**
* Retries common transient errors, such as rate limiting, timeouts or network issues.
*
* **When to use**
*
* Use to focus on retrying errors, transient responses, or both.
*
* **Details**
*
* Specifying a `while` predicate allows you to consider other errors as
* transient, and is ignored in "response-only" mode.
*
* @category error handling
* @since 4.0.0
*/
export const retryTransient = /*#__PURE__*/dual(2, (self, options) => {
const isOnlySchedule = Schedule.isSchedule(options);
const retryOn = isOnlySchedule ? "errors-and-responses" : options.retryOn ?? "errors-and-responses";
const schedule = isOnlySchedule ? options : options.schedule;
const passthroughSchedule = schedule && Schedule.passthrough(schedule);
const times = isOnlySchedule ? undefined : options.times;
return transformResponse(self, flow(retryOn === "errors-only" ? identity : Effect.repeat({
schedule: passthroughSchedule,
times,
while: isTransientResponse
}), retryOn === "response-only" ? identity : Effect.retry({
while: isOnlySchedule || options.while === undefined ? isTransientError : Predicate.or(isTransientError, options.while),
schedule,
times
})));
});
/**
* Applies request rate limiting using the `RateLimiter` service.
*
* **Details**
*
* It can update limits by inspecting common rate limit response headers and
* automatically retries HTTP `429` responses (or `HttpClientError` values
* wrapping a `429` response) by forcing the retry back through the limiter.
*
* @category rate limiting
* @since 4.0.0
*/
export const withRateLimiter = /*#__PURE__*/dual(2, (self, options) => {
const initialState = {
initial: true,
limit: options.limit,
window: Duration.max(Duration.fromInputUnsafe(options.window), Duration.millis(1))
};
const states = new Map();
const keyOption = options.key;
const resolveKey = typeof keyOption === "function" ? keyOption : constant(keyOption);
const tokensOption = options.tokens;
const resolveTokens = typeof tokensOption === "function" ? tokensOption : constant(tokensOption ?? 1);
const getState = key => {
const current = states.get(key);
if (current !== undefined) {
return current;
}
states.set(key, initialState);
return initialState;
};
const onResponse = options.disableResponseInspection ? undefined : (clock, key, headers, tokens) => {
const current = getState(key);
const next = parseRateLimiterState(current, clock, headers, tokens);
if (next.limit !== current.limit || !Duration.equals(next.window, current.window)) {
states.set(key, next);
}
};
return transform(self, function loop(effect, request) {
const fiber = Fiber.getCurrent();
const clock = fiber.getRef(Clock);
const key = resolveKey(request);
const tokens = Math.max(resolveTokens(request), 1);
const current = getState(key);
function retry(response) {
if (options.disableResponseInspection) return loop(effect, request);
const retryAfter = parseRetryAfter(clock, getHeader(response.headers, "retry-after"));
return retryAfter ? Effect.flatMap(Effect.sleep(retryAfter), () => loop(effect, request)) : loop(effect, request);
}
return Effect.flatMap(options.limiter.consume({
algorithm: options.algorithm,
onExceeded: "delay",
key,
limit: current.limit,
window: current.window,
tokens
}), ({
delay
}) => {
const run = Effect.matchEffect(effect, {
onSuccess(response) {
onResponse?.(clock, key, response.headers, tokens);
if (response.status !== 429) return Effect.succeed(response);
return retry(response);
},
onFailure(error) {
if (isTooManyRequestsHttpClientError(error)) {
onResponse?.(clock, key, error.reason.response.headers, tokens);
return retry(error.reason.response);
}
return Effect.fail(error);
}
});
return Duration.isZero(delay) ? run : Effect.delay(run, delay);
});
});
});
const parseRateLimiterState = (state, clock, headers, tokens) => {
const limit = parseRateLimitLimit(state, headers, tokens) ?? state.limit;
const window = parseRateLimitWindow(clock, headers) ?? state.window;
if (limit === state.limit && Duration.equals(window, state.window)) {
return state;
}
return {
limit,
window,
initial: false
};
};
const parseRateLimitLimit = (state, headers, tokens) => {
const raw = getHeader(headers, "ratelimit-limit", "x-ratelimit-limit");
const value = parseNumberHeader(raw);
if (value !== undefined && value > 0) {
return value;
}
const remaining = parseRateLimitRemaining(headers);
if (remaining === undefined) {
return undefined;
}
return state.initial ? remaining + tokens : Math.max(remaining + tokens, state.limit);
};
const parseRateLimitRemaining = headers => {
const raw = getHeader(headers, "ratelimit-remaining", "x-ratelimit-remaining");
const value = parseNumberHeader(raw);
return value !== undefined && value >= 0 ? value : undefined;
};
const parseRateLimitWindow = (clock, headers) => {
const retryAfter = parseRetryAfter(clock, getHeader(headers, "retry-after"));
if (retryAfter !== undefined) {
return retryAfter;
}
const resetAfter = parseResetAfter(getHeader(headers, "ratelimit-reset-after", "x-ratelimit-reset-after"));
if (resetAfter !== undefined) {
return resetAfter;
}
return parseResetHeader(clock, getHeader(headers, "ratelimit-reset", "x-ratelimit-reset"));
};
const parseRetryAfter = (clock, value) => {
if (value === undefined) {
return undefined;
}
const numeric = parseNumberHeader(value);
if (numeric !== undefined) {
return Duration.max(Duration.seconds(numeric), Duration.millis(1));
}
const parsedDate = Date.parse(value);
if (Number.isNaN(parsedDate)) {
return undefined;
}
const millis = parsedDate - clock.currentTimeMillisUnsafe();
if (millis <= 0) {
return Duration.millis(1);
}
return Duration.millis(millis);
};
const parseResetAfter = value => {
const numeric = parseNumberHeader(value);
if (numeric === undefined || numeric <= 0) {
return undefined;
}
return Duration.max(Duration.seconds(numeric), Duration.millis(1));
};
const parseResetHeader = (clock, value) => {
const numeric = parseNumberHeader(value);
if (numeric === undefined || numeric <= 0) {
return undefined;
}
const nowMillis = clock.currentTimeMillisUnsafe();
if (numeric > 1_000_000_000_000) {
return Duration.millis(Math.max(numeric - nowMillis, 1));
}
if (numeric > 1_000_000_000) {
return Duration.millis(Math.max(numeric * 1_000 - nowMillis, 1));
}
return Duration.max(Duration.seconds(numeric), Duration.millis(1));
};
const parseNumberHeader = value => {
if (value === undefined) {
return undefined;
}
const match = /-?\d+(?:\.\d+)?/.exec(value);
if (match === null) {
return undefined;
}
const parsed = Number(match[0]);
return Number.isFinite(parsed) ? parsed : undefined;
};
const getHeader = (headers, ...keys) => {
for (let i = 0; i < keys.length; i++) {
const value = headers[keys[i]];
if (value !== undefined) {
return value;
}
}
return undefined;
};
/**
* Performs an additional effect after a successful request.
*
* @category mapping & sequencing
* @since 4.0.0
*/
export const tap = /*#__PURE__*/dual(2, (self, f) => transformResponse(self, Effect.tap(f)));
/**
* Performs an additional effect after an unsuccessful request.
*
* @category mapping & sequencing
* @since 4.0.0
*/
export const tapError = /*#__PURE__*/dual(2, (self, f) => transformResponse(self, Effect.tapError(f)));
/**
* Performs an additional effect on the request before sending it.
*
* @category mapping & sequencing
* @since 4.0.0
*/
export const tapRequest = /*#__PURE__*/dual(2, (self, f) => makeWith(self.postprocess, request => Effect.tap(self.preprocess(request), f)));
/**
* Adds a `Ref` of cookies to the client for handling cookies across requests.
*
* **When to use**
*
* Use to add shared cookie storage to a client so response cookies are retained
* and sent by later requests.
*
* @category cookies
* @since 4.0.0
*/
export const withCookiesRef = /*#__PURE__*/dual(2, (self, ref) => makeWith(request => Effect.tap(self.postprocess(request), response => Ref.update(ref, cookies => Cookies.merge(cookies, response.cookies))), request => Effect.flatMap(self.preprocess(request), request => Effect.map(Ref.get(ref), cookies => Cookies.isEmpty(cookies) ? request : HttpClientRequest.setHeader(request, "cookie", Cookies.toCookieHeader(cookies))))));
/**
* Attaches the lifetime of the `HttpClientRequest` to a `Scope`.
*
* @category resource management
* @since 4.0.0
*/
export const withScope = self => transform(self, (effect, request) => {
const controller = new AbortController();
scopedRequests.set(request, controller);
return Effect.andThen(Effect.addFinalizer(() => Effect.sync(() => controller.abort())), effect);
});
/**
* Enables following HTTP redirects up to a specified number of times.
*
* @category redirects
* @since 4.0.0
*/
export const followRedirects = /*#__PURE__*/dual(args => isHttpClient(args[0]), (self, maxRedirects) => makeWith(request => {
const loop = (request, redirects) => Effect.flatMap(self.postprocess(Effect.succeed(request)), response => response.status >= 300 && response.status < 400 && response.headers.location && redirects < (maxRedirects ?? 10) ? loop(HttpClientRequest.setUrl(request, new URL(response.headers.location, response.request.url)), redirects + 1) : Effect.succeed(response));
return Effect.flatMap(request, request => loop(request, 0));
}, self.preprocess));
/**
* Context reference for a predicate that disables client-side tracing for matching outgoing requests.
*
* @category references
* @since 4.0.0
*/
export const TracerDisabledWhen = /*#__PURE__*/Context.Reference("effect/http/HttpClient/TracerDisabledWhen", {
defaultValue: () => constFalse
});
/**
* Context reference that controls whether outgoing client spans are propagated to request headers.
*
* @category references
* @since 4.0.0
*/
export const TracerPropagationEnabled = /*#__PURE__*/Context.Reference("effect/HttpClient/TracerPropagationEnabled", {
defaultValue: constTrue
});
/**
* Context reference for generating the span name used for outgoing client request spans.
*
* @category references
* @since 4.0.0
*/
export const SpanNameGenerator = /*#__PURE__*/Context.Reference("effect/http/HttpClient/SpanNameGenerator", {
defaultValue: () => request => `http.client ${request.method}`
});
/**
* Creates an `HttpClient` layer and merges the layer construction context into client response effects.
*
* @category layers
* @since 4.0.0
*/
export const layerMergedContext = effect => Layer.effect(HttpClient)(Effect.contextWith(context => Effect.map(effect, client => transformResponse(client, Effect.updateContext(input => Context.merge(context, input))))));
// -----------------------------------------------------------------------------
// internal
// -----------------------------------------------------------------------------
const responseRegistry = /*#__PURE__*/(() => {
if ("FinalizationRegistry" in globalThis && globalThis.FinalizationRegistry) {
const registry = /*#__PURE__*/new FinalizationRegistry(controller => {
controller.abort();
});
return {
register(response, controller) {
registry.register(response, controller, response);
},
unregister(response) {
registry.unregister(response);
}
};
}
const timers = /*#__PURE__*/new Map();
return {
register(response, controller) {
timers.set(response, setTimeout(() => controller.abort(), 5000));
},
unregister(response) {
const timer = timers.get(response);
if (timer === undefined) return;
clearTimeout(timer);
timers.delete(response);
}
};
})();
const scopedRequests = /*#__PURE__*/new WeakMap();
class InterruptibleResponse {
original;
controller;
constructor(original, controller) {
this.original = original;
this.controller = controller;
}
[HttpClientResponse.TypeId] = HttpClientResponse.TypeId;
[HttpIncomingMessage.TypeId] = HttpIncomingMessage.TypeId;
applyInterrupt(effect) {
return Effect.suspend(() => {
responseRegistry.unregister(this.original);
return Effect.onInterrupt(effect, () => Effect.sync(() => {
this.controller.abort();
}));
});
}
get request() {
return this.original.request;
}
get status() {
return this.original.status;
}
get headers() {
return this.original.headers;
}
get cookies() {
return this.original.cookies;
}
get remoteAddress() {
return this.original.remoteAddress;
}
get formData() {
return this.applyInterrupt(this.original.formData);
}
get text() {
return this.applyInterrupt(this.original.text);
}
get json() {
return this.applyInterrupt(this.original.json);
}
get urlParamsBody() {
return this.applyInterrupt(this.original.urlParamsBody);
}
get arrayBuffer() {
return this.applyInterrupt(this.original.arrayBuffer);
}
get stream() {
return Stream.suspend(() => {
responseRegistry.unregister(this.original);
return Stream.ensuring(this.original.stream, Effect.sync(() => {
this.controller.abort();
}));
});
}
toJSON() {
return this.original.toJSON();
}
[Inspectable.NodeInspectSymbol]() {
return this.original[Inspectable.NodeInspectSymbol]();
}
pipe() {
return pipeArguments(this, arguments);
}
}
const isTransientError = error => Cause.isTimeoutError(error) || isTransientHttpError(error);
const isTransientHttpError = error => Error.isHttpClientError(error) && (error.reason._tag === "TransportError" || error.reason._tag === "StatusCodeError" && isTransientResponse(error.reason.response));
const isTooManyRequestsHttpClientError = error => Error.isHttpClientError(error) && error.reason._tag === "StatusCodeError" && error.reason.response.status === 429;
const isTransientResponse = response => response.status === 408 || response.status === 429 || response.status === 500 || response.status === 502 || response.status === 503 || response.status === 504;
//# sourceMappingURL=HttpClient.js.map

Xet Storage Details

Size:
26.3 kB
·
Xet hash:
2893522b368b8214a3ebcdc2fcfd279a4604bf48e0c08bf3761e99dfd0dc10ff

Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.