diff options
author | Kurt Mackey <mrkurt@gmail.com> | 2019-05-01 22:56:42 -0500 |
---|---|---|
committer | Ryan Dahl <ry@tinyclouds.org> | 2019-05-01 23:56:42 -0400 |
commit | c05cbc8eac91a9e1ab9b87c688ac4392eff01445 (patch) | |
tree | cc6e1e2b789ba6626c33523fa488681c86c1a987 /js | |
parent | 1dd30f658fc031dd1d3cdb5b4c435ff0e48740c9 (diff) |
Add Request global constructor (#2253)
Diffstat (limited to 'js')
-rw-r--r-- | js/body.ts | 348 | ||||
-rw-r--r-- | js/body_test.ts | 60 | ||||
-rw-r--r-- | js/dom_types.ts | 25 | ||||
-rw-r--r-- | js/fetch.ts | 4 | ||||
-rw-r--r-- | js/fetch_test.ts | 14 | ||||
-rw-r--r-- | js/globals.ts | 9 | ||||
-rw-r--r-- | js/request.ts | 160 | ||||
-rw-r--r-- | js/request_test.ts | 49 | ||||
-rw-r--r-- | js/unit_tests.ts | 2 |
9 files changed, 659 insertions, 12 deletions
diff --git a/js/body.ts b/js/body.ts new file mode 100644 index 000000000..fdf7fef06 --- /dev/null +++ b/js/body.ts @@ -0,0 +1,348 @@ +import * as streams from "@stardazed/streams"; +import * as formData from "./form_data"; +import * as blob from "./blob"; +import * as encoding from "./text_encoding"; +import * as headers from "./headers"; + +import * as domTypes from "./dom_types"; + +const { Headers } = headers; + +// only namespace imports work for now, plucking out what we need +const { ReadableStream } = streams; +const { FormData } = formData; +const { TextEncoder, TextDecoder } = encoding; +const Blob = blob.DenoBlob; +const DenoBlob = blob.DenoBlob; + +type ReadableStreamReader = domTypes.ReadableStreamReader; + +interface ReadableStreamController { + enqueue(chunk: string | ArrayBuffer): void; + close(): void; +} + +export type BodySource = + | domTypes.Blob + | domTypes.BufferSource + | domTypes.FormData + | domTypes.URLSearchParams + | domTypes.ReadableStream + | string; + +function validateBodyType(owner: Body, bodySource: BodySource): boolean { + if ( + bodySource instanceof Int8Array || + bodySource instanceof Int16Array || + bodySource instanceof Int32Array || + bodySource instanceof Uint8Array || + bodySource instanceof Uint16Array || + bodySource instanceof Uint32Array || + bodySource instanceof Uint8ClampedArray || + bodySource instanceof Float32Array || + bodySource instanceof Float64Array + ) { + return true; + } else if (bodySource instanceof ArrayBuffer) { + return true; + } else if (typeof bodySource === "string") { + return true; + } else if (bodySource instanceof ReadableStream) { + return true; + } else if (bodySource instanceof FormData) { + return true; + } else if (!bodySource) { + return true; // null body is fine + } + throw new Error( + `Bad ${owner.constructor.name} body type: ${bodySource.constructor.name}` + ); +} + +function concatenate(...arrays: Uint8Array[]): ArrayBuffer { + let totalLength = 0; + for (const arr of arrays) { + totalLength += arr.length; + } + const result = new Uint8Array(totalLength); + let offset = 0; + for (const arr of arrays) { + result.set(arr, offset); + offset += arr.length; + } + return result.buffer as ArrayBuffer; +} + +function bufferFromStream(stream: ReadableStreamReader): Promise<ArrayBuffer> { + return new Promise( + (resolve, reject): void => { + const parts: Uint8Array[] = []; + const encoder = new TextEncoder(); + // recurse + (function pump(): void { + stream + .read() + .then( + ({ done, value }): void => { + if (done) { + return resolve(concatenate(...parts)); + } + + if (typeof value === "string") { + parts.push(encoder.encode(value)); + } else if (value instanceof ArrayBuffer) { + parts.push(new Uint8Array(value)); + } else if (!value) { + // noop for undefined + } else { + reject("unhandled type on stream read"); + } + + return pump(); + } + ) + .catch( + (err): void => { + reject(err); + } + ); + })(); + } + ); +} + +function getHeaderValueParams(value: string): Map<string, string> { + const params = new Map(); + // Forced to do so for some Map constructor param mismatch + value + .split(";") + .slice(1) + .map((s): string[] => s.trim().split("=")) + .filter((arr): boolean => arr.length > 1) + .map(([k, v]): [string, string] => [k, v.replace(/^"([^"]*)"$/, "$1")]) + .forEach(([k, v]): Map<string, string> => params.set(k, v)); + return params; +} + +function hasHeaderValueOf(s: string, value: string): boolean { + return new RegExp(`^${value}[\t\s]*;?`).test(s); +} + +export const BodyUsedError = + "Failed to execute 'clone' on 'Body': body is already used"; + +export class Body implements domTypes.Body { + protected _stream: domTypes.ReadableStream | null; + + constructor(protected _bodySource: BodySource, readonly contentType: string) { + validateBodyType(this, _bodySource); + this._bodySource = _bodySource; + this.contentType = contentType; + this._stream = null; + } + + get body(): domTypes.ReadableStream | null { + if (this._stream) { + return this._stream; + } + if (this._bodySource instanceof ReadableStream) { + // @ts-ignore + this._stream = this._bodySource; + } + if (typeof this._bodySource === "string") { + this._stream = new ReadableStream({ + start(controller: ReadableStreamController): void { + controller.enqueue(this._bodySource); + controller.close(); + } + }); + } + return this._stream; + } + + get bodyUsed(): boolean { + if (this.body && this.body.locked) { + return true; + } + return false; + } + + public async blob(): Promise<domTypes.Blob> { + return new Blob([await this.arrayBuffer()]); + } + + // ref: https://fetch.spec.whatwg.org/#body-mixin + public async formData(): Promise<domTypes.FormData> { + const formData = new FormData(); + const enc = new TextEncoder(); + if (hasHeaderValueOf(this.contentType, "multipart/form-data")) { + const params = getHeaderValueParams(this.contentType); + if (!params.has("boundary")) { + // TypeError is required by spec + throw new TypeError("multipart/form-data must provide a boundary"); + } + // ref: https://tools.ietf.org/html/rfc2046#section-5.1 + const boundary = params.get("boundary")!; + const dashBoundary = `--${boundary}`; + const delimiter = `\r\n${dashBoundary}`; + const closeDelimiter = `${delimiter}--`; + + const body = await this.text(); + let bodyParts: string[]; + const bodyEpilogueSplit = body.split(closeDelimiter); + if (bodyEpilogueSplit.length < 2) { + bodyParts = []; + } else { + // discard epilogue + const bodyEpilogueTrimmed = bodyEpilogueSplit[0]; + // first boundary treated special due to optional prefixed \r\n + const firstBoundaryIndex = bodyEpilogueTrimmed.indexOf(dashBoundary); + if (firstBoundaryIndex < 0) { + throw new TypeError("Invalid boundary"); + } + const bodyPreambleTrimmed = bodyEpilogueTrimmed + .slice(firstBoundaryIndex + dashBoundary.length) + .replace(/^[\s\r\n\t]+/, ""); // remove transport-padding CRLF + // trimStart might not be available + // Be careful! body-part allows trailing \r\n! + // (as long as it is not part of `delimiter`) + bodyParts = bodyPreambleTrimmed + .split(delimiter) + .map((s): string => s.replace(/^[\s\r\n\t]+/, "")); + // TODO: LWSP definition is actually trickier, + // but should be fine in our case since without headers + // we should just discard the part + } + for (const bodyPart of bodyParts) { + const headers = new Headers(); + const headerOctetSeperatorIndex = bodyPart.indexOf("\r\n\r\n"); + if (headerOctetSeperatorIndex < 0) { + continue; // Skip unknown part + } + const headerText = bodyPart.slice(0, headerOctetSeperatorIndex); + const octets = bodyPart.slice(headerOctetSeperatorIndex + 4); + + // TODO: use textproto.readMIMEHeader from deno_std + const rawHeaders = headerText.split("\r\n"); + for (const rawHeader of rawHeaders) { + const sepIndex = rawHeader.indexOf(":"); + if (sepIndex < 0) { + continue; // Skip this header + } + const key = rawHeader.slice(0, sepIndex); + const value = rawHeader.slice(sepIndex + 1); + headers.set(key, value); + } + if (!headers.has("content-disposition")) { + continue; // Skip unknown part + } + // Content-Transfer-Encoding Deprecated + const contentDisposition = headers.get("content-disposition")!; + const partContentType = headers.get("content-type") || "text/plain"; + // TODO: custom charset encoding (needs TextEncoder support) + // const contentTypeCharset = + // getHeaderValueParams(partContentType).get("charset") || ""; + if (!hasHeaderValueOf(contentDisposition, "form-data")) { + continue; // Skip, might not be form-data + } + const dispositionParams = getHeaderValueParams(contentDisposition); + if (!dispositionParams.has("name")) { + continue; // Skip, unknown name + } + const dispositionName = dispositionParams.get("name")!; + if (dispositionParams.has("filename")) { + const filename = dispositionParams.get("filename")!; + const blob = new DenoBlob([enc.encode(octets)], { + type: partContentType + }); + // TODO: based on spec + // https://xhr.spec.whatwg.org/#dom-formdata-append + // https://xhr.spec.whatwg.org/#create-an-entry + // Currently it does not mention how I could pass content-type + // to the internally created file object... + formData.append(dispositionName, blob, filename); + } else { + formData.append(dispositionName, octets); + } + } + return formData; + } else if ( + hasHeaderValueOf(this.contentType, "application/x-www-form-urlencoded") + ) { + // From https://github.com/github/fetch/blob/master/fetch.js + // Copyright (c) 2014-2016 GitHub, Inc. MIT License + const body = await this.text(); + try { + body + .trim() + .split("&") + .forEach( + (bytes): void => { + if (bytes) { + const split = bytes.split("="); + const name = split.shift()!.replace(/\+/g, " "); + const value = split.join("=").replace(/\+/g, " "); + formData.append( + decodeURIComponent(name), + decodeURIComponent(value) + ); + } + } + ); + } catch (e) { + throw new TypeError("Invalid form urlencoded format"); + } + return formData; + } else { + throw new TypeError("Invalid form data"); + } + } + + public async text(): Promise<string> { + if (typeof this._bodySource === "string") { + return this._bodySource; + } + + const ab = await this.arrayBuffer(); + const decoder = new TextDecoder("utf-8"); + return decoder.decode(ab); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public async json(): Promise<any> { + const raw = await this.text(); + return JSON.parse(raw); + } + + public async arrayBuffer(): Promise<ArrayBuffer> { + if ( + this._bodySource instanceof Int8Array || + this._bodySource instanceof Int16Array || + this._bodySource instanceof Int32Array || + this._bodySource instanceof Uint8Array || + this._bodySource instanceof Uint16Array || + this._bodySource instanceof Uint32Array || + this._bodySource instanceof Uint8ClampedArray || + this._bodySource instanceof Float32Array || + this._bodySource instanceof Float64Array + ) { + return this._bodySource.buffer as ArrayBuffer; + } else if (this._bodySource instanceof ArrayBuffer) { + return this._bodySource; + } else if (typeof this._bodySource === "string") { + const enc = new TextEncoder(); + return enc.encode(this._bodySource).buffer as ArrayBuffer; + } else if (this._bodySource instanceof ReadableStream) { + // @ts-ignore + return bufferFromStream(this._bodySource.getReader()); + } else if (this._bodySource instanceof FormData) { + const enc = new TextEncoder(); + return enc.encode(this._bodySource.toString()).buffer as ArrayBuffer; + } else if (!this._bodySource) { + return new ArrayBuffer(0); + } + throw new Error( + `Body type not yet implemented: ${this._bodySource.constructor.name}` + ); + } +} diff --git a/js/body_test.ts b/js/body_test.ts new file mode 100644 index 000000000..ac63ae78a --- /dev/null +++ b/js/body_test.ts @@ -0,0 +1,60 @@ +// Copyright 2018-2019 the Deno authors. All rights reserved. MIT license. +import { test, testPerm, assertEquals, assert } from "./test_util.ts"; + +// just a hack to get a body object +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function buildBody(body: any): domTypes.Body { + const stub = new Request("", { + body: body + }); + return stub as domTypes.Body; +} + +const intArrays = [ + Int8Array, + Int16Array, + Int32Array, + Uint8Array, + Uint16Array, + Uint32Array, + Uint8ClampedArray, + Float32Array, + Float64Array +]; +test(async function arrayBufferFromByteArrays(): Promise<void> { + const buffer = new TextEncoder().encode("ahoyhoy8").buffer; + + for (const type of intArrays) { + const body = buildBody(new type(buffer)); + const text = new TextDecoder("utf-8").decode(await body.arrayBuffer()); + assertEquals(text, "ahoyhoy8"); + } +}); + +//FormData +testPerm({ net: true }, async function bodyMultipartFormData(): Promise<void> { + const response = await fetch( + "http://localhost:4545/tests/subdir/multipart_form_data.txt" + ); + const text = await response.text(); + + const body = buildBody(text); + const formData = await body.formData(); + assert(formData.has("field_1")); + assertEquals(formData.get("field_1").toString(), "value_1 \r\n"); + assert(formData.has("field_2")); +}); + +testPerm({ net: true }, async function bodyURLEncodedFormData(): Promise<void> { + const response = await fetch( + "http://localhost:4545/tests/subdir/form_urlencoded.txt" + ); + const text = await response.text(); + + const body = buildBody(text); + const formData = await body.formData(); + assert(formData.has("field_1")); + assertEquals(formData.get("field_1").toString(), "Hi"); + assert(formData.has("field_2")); + assertEquals(formData.get("field_2").toString(), "<Deno>"); +}); diff --git a/js/dom_types.ts b/js/dom_types.ts index 817f91abf..7e4198506 100644 --- a/js/dom_types.ts +++ b/js/dom_types.ts @@ -254,6 +254,7 @@ export interface ReadableStream { readonly locked: boolean; cancel(): Promise<void>; getReader(): ReadableStreamReader; + tee(): [ReadableStream, ReadableStream]; } export interface EventListenerObject { @@ -434,16 +435,16 @@ export interface Request extends Body { * indicating how the the request will interact with the browser's cache when * fetching. */ - readonly cache: RequestCache; + readonly cache?: RequestCache; /** Returns the credentials mode associated with request, which is a string * indicating whether credentials will be sent with the request always, never, * or only when sent to a same-origin URL. */ - readonly credentials: RequestCredentials; + readonly credentials?: RequestCredentials; /** Returns the kind of resource requested by request, (e.g., `document` or * `script`). */ - readonly destination: RequestDestination; + readonly destination?: RequestDestination; /** Returns a Headers object consisting of the headers associated with * request. * @@ -455,32 +456,32 @@ export interface Request extends Body { * hash of the resource being fetched. Its value consists of multiple hashes * separated by whitespace. [SRI] */ - readonly integrity: string; + readonly integrity?: string; /** Returns a boolean indicating whether or not request is for a history * navigation (a.k.a. back-forward navigation). */ - readonly isHistoryNavigation: boolean; + readonly isHistoryNavigation?: boolean; /** Returns a boolean indicating whether or not request is for a reload * navigation. */ - readonly isReloadNavigation: boolean; + readonly isReloadNavigation?: boolean; /** Returns a boolean indicating whether or not request can outlive the global * in which it was created. */ - readonly keepalive: boolean; + readonly keepalive?: boolean; /** Returns request's HTTP method, which is `GET` by default. */ readonly method: string; /** Returns the mode associated with request, which is a string indicating * whether the request will use CORS, or will be restricted to same-origin * URLs. */ - readonly mode: RequestMode; + readonly mode?: RequestMode; /** Returns the redirect mode associated with request, which is a string * indicating how redirects for the request will be handled during fetching. * * A request will follow redirects by default. */ - readonly redirect: RequestRedirect; + readonly redirect?: RequestRedirect; /** Returns the referrer of request. Its value can be a same-origin URL if * explicitly set in init, the empty string to indicate no referrer, and * `about:client` when defaulting to the global's default. @@ -488,16 +489,16 @@ export interface Request extends Body { * This is used during fetching to determine the value of the `Referer` * header of the request being made. */ - readonly referrer: string; + readonly referrer?: string; /** Returns the referrer policy associated with request. This is used during * fetching to compute the value of the request's referrer. */ - readonly referrerPolicy: ReferrerPolicy; + readonly referrerPolicy?: ReferrerPolicy; /** Returns the signal associated with request, which is an AbortSignal object * indicating whether or not request has been aborted, and its abort event * handler. */ - readonly signal: AbortSignal; + readonly signal?: AbortSignal; /** Returns the URL of request as a string. */ readonly url: string; clone(): Request; diff --git a/js/fetch.ts b/js/fetch.ts index 3489c54a0..a28a81535 100644 --- a/js/fetch.ts +++ b/js/fetch.ts @@ -233,6 +233,10 @@ class Body implements domTypes.Body, domTypes.ReadableStream, io.ReadCloser { getReader(): domTypes.ReadableStreamReader { return notImplemented(); } + + tee(): [domTypes.ReadableStream, domTypes.ReadableStream] { + return notImplemented(); + } } class Response implements domTypes.Response { diff --git a/js/fetch_test.ts b/js/fetch_test.ts index cebef58e2..205f5fe3e 100644 --- a/js/fetch_test.ts +++ b/js/fetch_test.ts @@ -99,6 +99,20 @@ testPerm({ net: true }, async function fetchInitStringBody(): Promise<void> { assert(response.headers.get("content-type").startsWith("text/plain")); }); +testPerm({ net: true }, async function fetchRequestInitStringBody(): Promise< + void +> { + const data = "Hello World"; + const req = new Request("http://localhost:4545/echo_server", { + method: "POST", + body: data + }); + const response = await fetch(req); + const text = await response.text(); + assertEquals(text, data); + assert(response.headers.get("content-type").startsWith("text/plain")); +}); + testPerm({ net: true }, async function fetchInitTypedArrayBody(): Promise< void > { diff --git a/js/globals.ts b/js/globals.ts index 6ebf3ddfd..765215661 100644 --- a/js/globals.ts +++ b/js/globals.ts @@ -26,6 +26,9 @@ import * as urlSearchParams from "./url_search_params"; import * as workers from "./workers"; import * as performanceUtil from "./performance"; +import * as request from "./request"; +//import * as response from "./response"; + // These imports are not exposed and therefore are fine to just import the // symbols required. import { core } from "./core"; @@ -107,6 +110,12 @@ export type TextEncoder = textEncoding.TextEncoder; window.TextDecoder = textEncoding.TextDecoder; export type TextDecoder = textEncoding.TextDecoder; +window.Request = request.Request; +export type Request = request.Request; + +//window.Response = response.Response; +//export type Response = response.Response; + window.performance = new performanceUtil.Performance(); // This variable functioning correctly depends on `declareAsLet` diff --git a/js/request.ts b/js/request.ts new file mode 100644 index 000000000..97bb8944b --- /dev/null +++ b/js/request.ts @@ -0,0 +1,160 @@ +// Copyright 2018-2019 the Deno authors. All rights reserved. MIT license. +import * as headers from "./headers"; +import * as body from "./body"; + +import * as streams from "@stardazed/streams"; + +import * as domTypes from "./dom_types"; + +const { Headers } = headers; +const { ReadableStream } = streams; + +function byteUpperCase(s: string): string { + return String(s).replace(/[a-z]/g, function byteUpperCaseReplace(c): string { + return c.toUpperCase(); + }); +} + +function normalizeMethod(m: string): string { + const u = byteUpperCase(m); + if ( + u === "DELETE" || + u === "GET" || + u === "HEAD" || + u === "OPTIONS" || + u === "POST" || + u === "PUT" + ) { + return u; + } + return m; +} + +/** + * An HTTP request + * @param {Blob|String} [body] + * @param {Object} [init] + */ +export class Request extends body.Body implements domTypes.Request { + public method: string; + public url: string; + public credentials?: "omit" | "same-origin" | "include"; + public headers: domTypes.Headers; + + constructor(input: domTypes.RequestInfo, init?: domTypes.RequestInit) { + if (arguments.length < 1) { + throw TypeError("Not enough arguments"); + } + + if (!init) { + init = {}; + } + + let b: body.BodySource; + + // prefer body from init + if (init.body) { + b = init.body; + } else if (input instanceof Request && input._bodySource) { + if (input.bodyUsed) { + throw TypeError(body.BodyUsedError); + } + b = input._bodySource; + } else if (typeof input === "object" && "body" in input && input.body) { + if (input.bodyUsed) { + throw TypeError(body.BodyUsedError); + } + b = input.body; + } else { + b = ""; + } + + let headers: domTypes.Headers; + + // prefer headers from init + if (init.headers) { + headers = new Headers(init.headers); + } else if (input instanceof Request) { + headers = input.headers; + } else { + headers = new Headers(); + } + + const contentType = headers.get("content-type") || ""; + super(b, contentType); + this.headers = headers; + + // readonly attribute ByteString method; + /** + * The HTTP request method + * @readonly + * @default GET + * @type {string} + */ + this.method = "GET"; + + // readonly attribute USVString url; + /** + * The request URL + * @readonly + * @type {string} + */ + this.url = ""; + + // readonly attribute RequestCredentials credentials; + this.credentials = "omit"; + + if (input instanceof Request) { + if (input.bodyUsed) { + throw TypeError(body.BodyUsedError); + } + this.method = input.method; + this.url = input.url; + this.headers = new Headers(input.headers); + this.credentials = input.credentials; + this._stream = input._stream; + } else if (typeof input === "string") { + this.url = input; + } + + if (init && "method" in init) { + this.method = normalizeMethod(init.method as string); + } + + if ( + init && + "credentials" in init && + init.credentials && + ["omit", "same-origin", "include"].indexOf(init.credentials) !== -1 + ) { + this.credentials = init.credentials; + } + } + + public clone(): domTypes.Request { + if (this.bodyUsed) { + throw TypeError(body.BodyUsedError); + } + + const iterators = this.headers.entries(); + const headersList: Array<[string, string]> = []; + for (const header of iterators) { + headersList.push(header); + } + + let body2 = this._bodySource; + + if (this._bodySource instanceof ReadableStream) { + const tees = (this._bodySource as domTypes.ReadableStream).tee(); + this._stream = this._bodySource = tees[0]; + body2 = tees[1]; + } + const cloned = new Request(this.url, { + body: body2, + method: this.method, + headers: new Headers(headersList), + credentials: this.credentials + }); + return cloned; + } +} diff --git a/js/request_test.ts b/js/request_test.ts new file mode 100644 index 000000000..7421544fe --- /dev/null +++ b/js/request_test.ts @@ -0,0 +1,49 @@ +// Copyright 2018-2019 the Deno authors. All rights reserved. MIT license. +import { test, assertEquals, assert } from "./test_util.ts"; + +test(function fromInit(): void { + const req = new Request("https://example.com", { + body: "ahoyhoy", + method: "POST", + headers: { + "test-header": "value" + } + }); + + // @ts-ignore + assertEquals("ahoyhoy", req._bodySource); + assertEquals(req.url, "https://example.com"); + assertEquals(req.headers.get("test-header"), "value"); +}); + +test(function fromRequest(): void { + const r = new Request("https://example.com"); + // @ts-ignore + r._bodySource = "ahoyhoy"; + r.headers.set("test-header", "value"); + + const req = new Request(r); + + // @ts-ignore + assertEquals(req._bodySource, r._bodySource); + assertEquals(req.url, r.url); + assertEquals(req.headers.get("test-header"), r.headers.get("test-header")); +}); + +test(async function cloneRequestBodyStream(): Promise<void> { + // hack to get a stream + const stream = new Request("", { body: "a test body" }).body; + const r1 = new Request("https://example.com", { + body: stream + }); + + const r2 = r1.clone(); + + const b1 = await r1.text(); + const b2 = await r2.text(); + + assertEquals(b1, b2); + + // @ts-ignore + assert(r1._bodySource !== r2._bodySource); +}); diff --git a/js/unit_tests.ts b/js/unit_tests.ts index 3cef08e77..1fbd1e3cc 100644 --- a/js/unit_tests.ts +++ b/js/unit_tests.ts @@ -4,6 +4,7 @@ // But it can also be run manually: ./target/debug/deno js/unit_tests.ts import "./blob_test.ts"; +import "./body_test.ts"; import "./buffer_test.ts"; import "./build_test.ts"; import "./chmod_test.ts"; @@ -32,6 +33,7 @@ import "./read_dir_test.ts"; import "./read_file_test.ts"; import "./read_link_test.ts"; import "./rename_test.ts"; +import "./request_test.ts"; import "./resources_test.ts"; import "./stat_test.ts"; import "./symlink_test.ts"; |