summaryrefslogtreecommitdiff
path: root/js
diff options
context:
space:
mode:
Diffstat (limited to 'js')
-rw-r--r--js/body.ts348
-rw-r--r--js/body_test.ts60
-rw-r--r--js/dom_types.ts25
-rw-r--r--js/fetch.ts4
-rw-r--r--js/fetch_test.ts14
-rw-r--r--js/globals.ts9
-rw-r--r--js/request.ts160
-rw-r--r--js/request_test.ts49
-rw-r--r--js/unit_tests.ts2
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";