├── .editorconfig ├── .eslintrc.js ├── .gitignore ├── .npmignore ├── .travis.yml ├── .vscode ├── extensions.json └── settings.json ├── LICENSE.md ├── README.md ├── browser.js ├── doc └── shadow-fetch.png ├── examples └── next.js │ ├── package.json │ ├── pages │ └── index.js │ └── server.js ├── index.js ├── lib ├── headers.js ├── incomingmessage.js ├── response.js ├── serverresponse.js └── statustexts.js ├── package-lock.json ├── package.json ├── shadow-fetch-express ├── .npmignore ├── LICENSE.md ├── README.md ├── index.js └── package.json └── test ├── helper.js ├── test-benchmark.js ├── test-express.js ├── test-headers.js ├── test-incomingmessage.js ├── test-server.js ├── test-serverresponse.js └── test-shadow-fetch.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | charset = utf-8 6 | trim_trailing_whitespace = true 7 | insert_final_newline = true 8 | 9 | [*.{json,js,jsx,html,css}] 10 | indent_size = 4 -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "env": { 3 | "browser": true, 4 | "node": true, 5 | "es6": true 6 | }, 7 | "parserOptions": { 8 | "ecmaVersion": 2017 9 | }, 10 | "extends": "eslint:recommended", 11 | "rules": { 12 | "indent": ["error", 4], 13 | "quotes": ["error", "double"], 14 | "semi": ["error", "always"], 15 | "no-console": 0 16 | } 17 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | .nyc_output 4 | examples/next.js/.next/ 5 | examples/next.js/package-lock.json 6 | package 7 | *.tgz 8 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | doc/*.png 2 | test 3 | examples 4 | node_modules 5 | shadow-fetch-express 6 | .nyc_output 7 | .vscode 8 | coverage 9 | *.tgz 10 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "node" 4 | - "lts/*" 5 | - "7" 6 | - "8" 7 | - "9" 8 | after_success: npm run coverage 9 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint", 4 | "michelemelluso.gitignore", 5 | "EditorConfig.EditorConfig", 6 | "flowtype.flow-for-vscode" 7 | ] 8 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "workbench.editor.enablePreview": false, 3 | "editor.formatOnSave": false, 4 | "eslint.autoFixOnSave": true, 5 | "javascript.validate.enable": false, 6 | "flow.useNPMPackagedFlow": true, 7 | "typescript.disableAutomaticTypeAcquisition": true, 8 | "git.ignoreLimitWarning": true 9 | } 10 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | ## MIT License 2 | 3 | Copyright © 2018 Yoshiki Shibukawa 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a 6 | copy of this software and associated documentation files (the "Software"), 7 | to deal in the Software without restriction, including without limitation 8 | the rights to use, copy, modify, merge, publish, distribute, sublicense, 9 | and/or sell copies of the Software, and to permit persons to whom the 10 | Software is furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 20 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 21 | DEALINGS IN THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # shadow-fetch 2 | 3 | [![Build Status](https://travis-ci.org/shibukawa/shadow-fetch.svg?branch=master)](https://travis-ci.org/shibukawa/shadow-fetch) 4 | [![npm version](https://badge.fury.io/js/shadow-fetch.svg)](https://badge.fury.io/js/shadow-fetch) 5 | [![codecov](https://codecov.io/gh/shibukawa/shadow-fetch/branch/master/graph/badge.svg)](https://codecov.io/gh/shibukawa/shadow-fetch) 6 | [![Known Vulnerabilities](https://snyk.io/test/npm/shadow-fetch/badge.svg)](https://snyk.io/test/npm/shadow-fetch) 7 | [![NPM](https://nodei.co/npm/shadow-fetch.png)](https://nodei.co/npm/shadow-fetch/) 8 | 9 | Accelorator of Server Side Rendering (and unit tests). 10 | 11 | Next.js and Nuxt.js and some framework improves productivity of development. 12 | You just write code once, the code run on the server and the client. 13 | Almost all part or code is compatible including server access code. 14 | 15 | Next.js's documents uses [isomorphic-unfetch](https://github.com/developit/unfetch/tree/master/packages/isomorphic-unfetch) and Nuxt.js's document uses [Axios](https://github.com/axios/axios). 16 | They are both excellent isomorphic libraries, but they make actual packet even if the client and the server work on the same process for server side rendering. 17 | 18 | This library provides ``fetch()`` compatible function and Node.js' ``http.createServer()`` compatible function. 19 | They are connected directly and bypass system calls. 20 | 21 | * You can make shorten SSR response a little 22 | * The request object has special attribute to identify actual access or shortcut access. You can skip authentication of BFF's API during SSR safely. 23 | * shadow-fetch provides special method to handle JSON. That method skip converting JSON into string. 24 | 25 | ![ScreenShot](https://raw.github.com/shibukawa/shadow-fetch/master/doc/shadow-fetch.png) 26 | 27 | ## Simple Benchmark 28 | 29 | | | time | 30 | |:-----------:|:-----------| 31 | | ``node-fetch`` and ``http.Server`` | 14.3 mS | 32 | | ``shadow-fetch`` with standard API | 1.8 mS | 33 | | ``shadow-fetch`` with direct JSON API | 0.13mS | 34 | 35 | * 8th Gen Core i5 with Node 9.4.0. 36 | * You can test via ``node run benchmark``. 37 | 38 | ## Installation 39 | 40 | ```sh 41 | $ npm install shadow-fetch 42 | ``` 43 | 44 | ## Usage 45 | 46 | ### Node.js's http server 47 | 48 | shadow-fetch provides the function that is compatible with ``http.createServer()``. shadow-fetch's server is available inside the same process. So useally you should launch two servers. 49 | 50 | ```js 51 | const { createServer } = require("shadow-fetch"); 52 | const { http } = require("http"); 53 | 54 | handler = (req, res) => { 55 | res.writeHead(200, { "Content-Type": "application/json" }); 56 | res.end(JSON.stringify({ message: "hello" })); 57 | }; 58 | 59 | const server = createServer(handler); 60 | server.listen(); 61 | 62 | const server = http.createServer(handler); 63 | server.listen(80); 64 | ``` 65 | 66 | You can use ``fetch`` function to access this server: 67 | 68 | ```js 69 | import { fetch } from "shadow-fetch"; 70 | 71 | const res = await fetch("/test"); 72 | 73 | if (res.ok) { 74 | const json = await res.json(); 75 | console.log(json.message); 76 | } 77 | ``` 78 | 79 | ### Express.js 80 | 81 | Express.js modifies request object (replace prototype). shadow-fetch's middleware for Express.js enable shadow-fetch's feature even if you uses Express.js 82 | 83 | You should pass ``express()`` result to ``createServer()`` instead of ``app.listen()``. That uses Node.js's ``createServer()`` internally, 84 | 85 | ```js 86 | const { createServer } = require("shadow-fetch"); 87 | const { shadowFetchMiddleware } = require("shadow-fetch-express"); 88 | 89 | const app = express(); 90 | app.use(shadowFetchMiddleware); 91 | app.use(bodyParser.json()); 92 | app.post("/test", (req, res) => { 93 | t.is(req.shadow, true); 94 | t.is(req.body.message, "hello"); 95 | res.send({ message: "world" }); 96 | }); 97 | const { fetch, createServer } = initFetch(); 98 | 99 | createServer(app); 100 | ``` 101 | 102 | ### Next.js 103 | 104 | It is alomot as same as Express.js. This package provides factory function that makes ``fetch`` and ``createServer()`` pairs. But they are not working on Next.js environment. You should pre careted ``createServer()`` and ``fetch()`` functions they are available via just ``require`` (``import``). 105 | 106 | ```js 107 | const next = require("next"); 108 | const http = require("http"); 109 | const express = require("express"); 110 | const bodyParser = require("body-parser"); 111 | const createServer = require("shadow-fetch"); 112 | const { shadowFetchMiddleware } = require("shadow-fetch-express"); 113 | 114 | const dev = process.env.NODE_ENV !== "production"; 115 | const app = next({ dev }); 116 | const handle = app.getRequestHandler(); 117 | 118 | app.prepare().then(() => { 119 | const server = express(); 120 | server.use(shadowFetchMiddleware); 121 | server.use(bodyParser.json()); 122 | server.get("/api/message", (req, res) => { 123 | res.json({message: "hello via shadow-fetch"}); 124 | }); 125 | server.get("*", (req, res) => { 126 | return handle(req, res); 127 | }); 128 | // enable shadow fetch entrypoint 129 | createServer(server).listen(); 130 | // enable standard HTTP entrypoint 131 | http.createServer(server).listen(3000, err => { 132 | if (err) throw err; 133 | console.log("> Ready on http://localhost:3000"); 134 | }); 135 | }); 136 | ``` 137 | 138 | ## Server Side Rendering and Authentication 139 | 140 | Sometimes, you want to add authentication feature. The standard ``fetch`` were called from browseres and shadow-fetch was called during server side rendering. 141 | 142 | You can detect the client environment inside event handler. If the API checks authentication, you should check ``shadow`` property. 143 | 144 | ```js 145 | const isAuthenticatedAPI = (req, res, next) => { 146 | if (req.shadow || req.isAuthenticated()) { 147 | return next(); 148 | } else { 149 | res.sendStatus(403); 150 | } 151 | }; 152 | 153 | server.get("/api/item/:id", isAuthenticatedAPI, (req, res) => { 154 | // API implementation 155 | }); 156 | ``` 157 | 158 | [This is why I started to make this package](https://github.com/zeit/next.js/issues/3797). 159 | 160 | ## Client API 161 | 162 | ```js 163 | const { fetch, Headers } = require("shadow-fetch"); 164 | ``` 165 | 166 | It provides three functions that are almost compatible with standard [``fetch()``](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API): 167 | 168 | * ``shadowFetch()``: Provides direct access between ``createServer``. 169 | * ``fetch()``: Alias of ``shadowFetch()`` or standard ``fetch()``. 170 | 171 | Usually ``fetch()`` is the only function you use. 172 | 173 | | Name | on Node.js | on Browser | 174 | |:-----------:|:-----------|:------------| 175 | | ``fetch`` | ``shadowFetch`` | standard ``fetch`` | 176 | | ``shadowFetch`` | ``shadowFetch`` | ``shadowFetch`` | 177 | 178 | If you want to select actual HTTP access or not explicitly, use regular ``fetch()``. 179 | 180 | This library provides ``Headers`` compatible class too. But there is no ``Request`` class now. 181 | 182 | ## Server API 183 | 184 | * ``createServer()``: It is a compatible function of Node.js's ``http.createServer()``. This package provides ``createShadowServer()`` for your convenience. 185 | * ``IncomingMessage``: Request object of server code. It is also compatible with Node.js's ``IncomingMessage`` except the following members: 186 | 187 | * ``shadow`` property: It always ``true``. You can identify the request is made by ``shadowFetch`` or regular HTTP access. 188 | 189 | * ``ServerResponse``: Response object of server code. It is also compatible with Node.js's ``ServerResponse`` except the following members: 190 | 191 | * ``writeJSON()``: It stores JSON without calling ``JSON.stringify()`` function. You can get JSON directly via ``Response#json()`` method of ``shadowFetch()``. 192 | 193 | ```js 194 | const { fetch, createServer } = require("shadow-fetch"); 195 | ``` 196 | 197 | ## Utility Function 198 | 199 | * ``initFetch()``: It generates ``fetch()`` (shadow version) and ``createServer()`` they are connected internally. It is good for writing unit tests. 200 | 201 | ## Trouble Shooting 202 | 203 | ### ``"shadow-fetch is not initialized properly. See https://github.com/shibukawa/shadow-fetch#trouble-shooting."`` 204 | 205 | This error is thrown when ``fetch`` is used without initialization. You should call shadow-fetch's ``createServer`` like [this](https://github.com/shibukawa/shadow-fetch#nodejss-http-server). 206 | 207 | ### Error occures inside web server handlers 208 | 209 | Express-middleware is not installed. Read [this section](https://github.com/shibukawa/shadow-fetch#expressjs). 210 | 211 | Express.js overwrite prototype of ``ServerResponse``/``IncomingMessage`` with http's ones inside its framework. 212 | In that case, required properties are not initilized because original constructor is not called. 213 | So some method calls are failed like the following error: 214 | 215 | ``` 216 | TypeError: Cannot read property 'push' of undefined 217 | at ServerResponse._writeRaw (_http_outgoing.js:281:24) 218 | at ServerResponse._send (_http_outgoing.js:240:15) 219 | at ServerResponse.end (_http_outgoing.js:770:16) 220 | at ServerResponse.end (/Users/shibukawa/develop/frx/dam-front/node_modules/compression/index.js:107:21) 221 | at ServerResponse.send (/Users/shibukawa/develop/frx/dam-front/node_modules/express/lib/response.js:221:10) 222 | ``` 223 | 224 | ### ``Error: bundles/pages/index.js from UglifyJs Name expected`` during ``next build`` 225 | 226 | Your next.js version is low. Try 5.1.0. 227 | 228 | ## License 229 | 230 | MIT 231 | -------------------------------------------------------------------------------- /browser.js: -------------------------------------------------------------------------------- 1 | const fetch = window.fetch || (window.fetch = require("unfetch").default || require("unfetch")); 2 | 3 | const { 4 | initFetch, 5 | shadowFetch, 6 | createServer, 7 | Headers, 8 | IncomingMessage, 9 | ServerResponse 10 | } = require("./index"); 11 | 12 | module.exports = { 13 | initFetch, 14 | fetch, 15 | shadowFetch, 16 | createServer, 17 | Headers, 18 | IncomingMessage, 19 | ServerResponse 20 | }; 21 | -------------------------------------------------------------------------------- /doc/shadow-fetch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shibukawa/shadow-fetch/8fe71327d9530d3dc19bf4d2be4adf8a087826f9/doc/shadow-fetch.png -------------------------------------------------------------------------------- /examples/next.js/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "next-js-example", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "build": "next build", 8 | "dev": "node server.js", 9 | "start": "cross-env NODE_ENV=production node server.js" 10 | }, 11 | "keywords": [], 12 | "author": "", 13 | "license": "MIT", 14 | "dependencies": { 15 | "cross-env": "^5.1.4", 16 | "express": "^4.16.2", 17 | "next": "^5.0.0", 18 | "prop-types": "^15.6.1", 19 | "react": "^16.2.0", 20 | "react-dom": "^16.2.0", 21 | "shadow-fetch": "file:shadow-fetch-0.1.2.tgz", 22 | "shadow-fetch-express": "file:shadow-fetch-express-0.1.2.tgz" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /examples/next.js/pages/index.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from "react"; 2 | import PropTypes from "prop-types"; 3 | import { fetch } from "../../../"; 4 | //import { fetch } from "shadow-fetch"; 5 | 6 | export default class Index extends Component { 7 | static async getInitialProps({req}) { 8 | const res = await fetch("/api/message"); 9 | const message = await res.json(); 10 | return message; 11 | } 12 | 13 | render() { 14 | return
15 | Message from shadow-fetch: 16 | {this.props.message} 17 |
; 18 | } 19 | } 20 | 21 | Index.propTypes = { 22 | message: PropTypes.string 23 | }; 24 | -------------------------------------------------------------------------------- /examples/next.js/server.js: -------------------------------------------------------------------------------- 1 | const next = require("next"); 2 | const { createServer } = require("http"); 3 | const express = require("express"); 4 | const bodyParser = require("body-parser"); 5 | const { createShadowServer } = require("shadow-fetch"); 6 | const { shadowFetchMiddleware } = require("shadow-fetch-express"); 7 | 8 | 9 | const dev = process.env.NODE_ENV !== "production"; 10 | const app = next({ dev }); 11 | const handle = app.getRequestHandler(); 12 | 13 | 14 | app.prepare().then(() => { 15 | const server = express(); 16 | server.use(shadowFetchMiddleware); 17 | server.use(bodyParser.json()); 18 | server.get("/api/message", (req, res) => { 19 | res.json({message: "hello via shadow-fetch"}); 20 | }); 21 | server.get("*", (req, res) => { 22 | return handle(req, res); 23 | }); 24 | // enable shadow fetch entrypoint 25 | createShadowServer(server).listen(); 26 | // enable standard HTTP entrypoint 27 | createServer(server).listen(3000, err => { 28 | if (err) throw err; 29 | console.log("> Ready on http://localhost:3000"); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const { Headers } = require("./lib/headers"); 2 | const { ServerResponse } = require("./lib/serverresponse"); 3 | const { IncomingMessage, shadowKey } = require("./lib/incomingmessage"); 4 | const { Response } = require("./lib/response"); 5 | 6 | 7 | class Connection { 8 | constructor() { 9 | this._handler = null; 10 | this.fetch = this.fetch.bind(this); 11 | } 12 | 13 | async fetch(url, opts) { 14 | return new Promise((resolve, reject) => { 15 | if (!this._handler) { 16 | return reject(new Error("shadow-fetch is not initialized properly. See https://github.com/shibukawa/shadow-fetch#trouble-shooting.")) 17 | } 18 | let redirected = false; 19 | const receiveResponse = (res) => { 20 | if ([301, 302, 303, 307, 308].includes(res.statusCode)) { 21 | redirected = true; 22 | this._handler( 23 | new IncomingMessage(res._headers.get("location"), opts), 24 | new ServerResponse(receiveResponse)); 25 | } else { 26 | const clientResponse = new Response(res); 27 | clientResponse._redirected = redirected; 28 | resolve(clientResponse); 29 | } 30 | }; 31 | this._handler(new IncomingMessage(url, opts), new ServerResponse(receiveResponse)); 32 | }); 33 | } 34 | } 35 | 36 | 37 | class Server { 38 | constructor(connection, handler) { 39 | connection._handler = handler; 40 | this._address = { 41 | port: Math.floor(Math.random() * (65536 - 1024)) + 1024, 42 | address: "127.0.0.1", 43 | family: "IPv4", 44 | shadow: true 45 | }; 46 | this._listening = false; 47 | } 48 | 49 | get listening() { 50 | return this._listening; 51 | } 52 | 53 | listen(...args) { 54 | if (typeof args[0] === "number") { 55 | this._address.port = args[0]; 56 | args.splice(0, 1); 57 | } 58 | if (typeof args[0] === "function") { 59 | process.nextTick(args[0]); 60 | } 61 | this._listening = true; 62 | } 63 | 64 | address() { 65 | return this._address; 66 | } 67 | 68 | close(callback) { 69 | this._listening = false; 70 | if (callback) { 71 | process.nextTick(callback); 72 | } 73 | } 74 | } 75 | 76 | const initFetch = () => { 77 | const connection = new Connection(); 78 | return { 79 | fetch(url, opts) { 80 | return connection.fetch(url, opts); 81 | }, 82 | createServer(handler) { 83 | return new Server(connection, handler); 84 | } 85 | }; 86 | }; 87 | 88 | if (typeof window === "undefined") { 89 | if (!global.shadowFetch) { 90 | const { fetch, createServer } = initFetch(); 91 | global.shadowFetch = fetch; 92 | global.createShadowServer = createServer; 93 | } 94 | module.exports = global.shadowFetch; 95 | module.exports.shadowFetch = module.exports.fetch = global.shadowFetch; 96 | module.exports.createShadowServer = module.exports.createServer = global.createShadowServer; 97 | } 98 | 99 | module.exports.initFetch = initFetch; 100 | module.exports.Headers = Headers; 101 | module.exports.IncomingMessage = IncomingMessage; 102 | module.exports.ServerResponse = ServerResponse; 103 | module.exports.shadowKey = shadowKey; 104 | -------------------------------------------------------------------------------- /lib/headers.js: -------------------------------------------------------------------------------- 1 | // this code is based on https://github.com/github/fetch/blob/master/fetch.js 2 | 3 | function normalizeName(name) { 4 | if (typeof name !== "string") { 5 | name = String(name); 6 | } 7 | if (/[^a-z0-9\-#$%&'*+.^_`|~]/i.test(name)) { 8 | throw new TypeError("Invalid character in header field name"); 9 | } 10 | return name.toLowerCase(); 11 | } 12 | 13 | function normalizeValue(value) { 14 | if (typeof value !== "string") { 15 | value = String(value); 16 | } 17 | return value; 18 | } 19 | 20 | 21 | class Headers { 22 | constructor(headers) { 23 | this._map = new Map(); 24 | this._clientMode = true; 25 | if (headers instanceof Headers) { 26 | for (const [name, value] of headers.entries()) { 27 | this._map.set(name, value); 28 | } 29 | } else if (Array.isArray(headers)) { 30 | for (const [name, value] of headers) { 31 | this._map.set(name, value); 32 | } 33 | } else if (headers) { 34 | for (const [name, value] of Object.entries(headers)) { 35 | this._map.set(normalizeName(name), normalizeValue(value)); 36 | } 37 | } 38 | } 39 | 40 | append(name, value) { 41 | name = normalizeName(name); 42 | value = normalizeValue(value); 43 | var oldValue = this._map.get(name); 44 | this._map.set(name, oldValue ? oldValue + ", " + value : value); 45 | } 46 | 47 | delete(name) { 48 | return this._map.delete(normalizeName(name)); 49 | } 50 | 51 | get(name) { 52 | name = normalizeName(name); 53 | if (this._map.has(name)) { 54 | return this._map.get(name); 55 | } 56 | return null; 57 | } 58 | 59 | has(name) { 60 | return this._map.has(normalizeName(name)); 61 | } 62 | 63 | set(name, value) { 64 | if (this._clientMode) { 65 | this._map.set(normalizeName(name), normalizeValue(value)); 66 | } else { 67 | this._map.set(normalizeName(name), value); 68 | } 69 | return this; 70 | } 71 | 72 | forEach(callback, thisArg) { 73 | this._map.forEach(callback, thisArg); 74 | } 75 | 76 | keys() { 77 | return this._map.keys(); 78 | } 79 | 80 | values() { 81 | return this._map.values(); 82 | } 83 | 84 | entries() { 85 | return this._map.entries(); 86 | } 87 | } 88 | 89 | const convertToClientMode = (headers) => { 90 | for (const [key, value] of headers.entries()) { 91 | if (Array.isArray(value)) { 92 | headers._map.set(key, value.join(", ")); 93 | } 94 | } 95 | }; 96 | 97 | const convertToServerHeaders = (headers) => { 98 | const serverHeaders = {}; 99 | const serverRawHeaders = []; 100 | for (const [name, value] of headers.entries()) { 101 | serverHeaders[name] = value; 102 | serverRawHeaders.push(name, value); 103 | } 104 | 105 | return { 106 | headers: serverHeaders, 107 | rawHeaders: serverRawHeaders 108 | }; 109 | }; 110 | 111 | module.exports = { 112 | Headers, 113 | convertToClientMode, 114 | convertToServerHeaders 115 | }; 116 | -------------------------------------------------------------------------------- /lib/incomingmessage.js: -------------------------------------------------------------------------------- 1 | const { Headers, convertToServerHeaders } = require("./headers"); 2 | 3 | // HTTP methods whose capitalization should be normalized 4 | var methods = ["DELETE", "GET", "HEAD", "OPTIONS", "POST", "PUT"]; 5 | 6 | function normalizeMethod(method) { 7 | var upcased = method.toUpperCase(); 8 | return (methods.indexOf(upcased) > -1) ? upcased : method; 9 | } 10 | 11 | const bodyKey = Symbol("body"); 12 | const textKey = Symbol("text"); 13 | const jsonKey = Symbol("json"); 14 | const formKey = Symbol("form"); 15 | const shadowKey = Symbol("shadow"); 16 | 17 | class IncomingMessage { 18 | constructor(url, { method, headers, body } = {}) { 19 | this[shadowKey] = true; 20 | if (!url) { 21 | return; 22 | } 23 | this.url = url; 24 | this.method = method ? normalizeMethod(method) : "GET"; 25 | this.httpVersion = "1.1"; 26 | this.ip = "::1" 27 | const hasHeaders = !!headers; 28 | if (headers && !(headers instanceof Headers)) { 29 | headers = new Headers(headers); 30 | } 31 | 32 | let contentType; 33 | let contentLength = 0; 34 | if (body) { 35 | if (!Buffer.isBuffer(body)) { 36 | if (typeof body === "string") { 37 | this[textKey] = body; 38 | body = new Buffer(body, "utf8"); 39 | } else { 40 | body = new Buffer(body.toString(), "utf8"); 41 | } 42 | } 43 | this[bodyKey] = body; 44 | contentType = "text/plain"; 45 | contentLength = String(body.length); 46 | } 47 | 48 | if (hasHeaders) { 49 | if (contentType) { 50 | if (!headers.has("content-type")) { 51 | headers.set("content-type", contentType); 52 | } 53 | headers.set("content-length", contentLength); 54 | } 55 | const serverHeader = convertToServerHeaders(headers); 56 | this.headers = serverHeader.headers; 57 | this.rawHeaders = serverHeader.rawHeaders; 58 | } else if (contentType) { 59 | this.headers = { 60 | "content-type": contentType, 61 | "content-length": contentLength 62 | }; 63 | this.rawHeaders = [ 64 | "content-type", contentType, 65 | "content-length", contentLength 66 | ]; 67 | } else { 68 | this.headers = {}; 69 | this.rawHeaders = []; 70 | } 71 | this.trailers = {}; 72 | this.rawTrailers = []; 73 | 74 | this._sendBody = false; 75 | this._listeners = new Map(); 76 | } 77 | 78 | get shadow() { 79 | return !!this[shadowKey]; 80 | } 81 | 82 | on(eventType, handler) { 83 | const listneers = this._listeners.get(eventType); 84 | if (listneers) { 85 | listneers.push(handler); 86 | } else { 87 | this._listeners.set(eventType, [handler]); 88 | } 89 | if (eventType === "data") { 90 | handler(this[bodyKey]); 91 | this._sendBody = true; 92 | const listeners = this._listeners.get("end"); 93 | if (listeners) { 94 | for (const listener of listeners) { 95 | listener(); 96 | } 97 | } 98 | } else if (eventType === "end") { 99 | if (this._sendBody) { 100 | handler(); 101 | } else { 102 | this._endListener = handler; 103 | } 104 | } 105 | } 106 | 107 | listeners(eventName) { 108 | return this._listeners.get(eventName) || []; 109 | } 110 | 111 | resume() { 112 | } 113 | 114 | pipe(dest) { 115 | dest.emit("pipe", this); 116 | this.on("end", () => { 117 | dest.end(); 118 | }); 119 | this.on("data", (data) => { 120 | dest.write(data); 121 | }); 122 | return dest; 123 | } 124 | 125 | removeListener(event, handler) { 126 | const listeners = this._listeners.get(event); 127 | listeners.splice(listeners.indexOf(handler), 1); 128 | } 129 | } 130 | 131 | module.exports = { 132 | IncomingMessage, 133 | bodyKey, 134 | jsonKey, 135 | formKey, 136 | textKey, 137 | shadowKey 138 | }; 139 | -------------------------------------------------------------------------------- /lib/response.js: -------------------------------------------------------------------------------- 1 | const { statusTexts } = require("./statustexts"); 2 | const { jsonKey } = require("./incomingmessage"); 3 | 4 | class Response { 5 | constructor(serverResponse) { 6 | this._response = serverResponse; 7 | this._redirected = false; 8 | } 9 | 10 | get status() { 11 | return this._response.statusCode; 12 | } 13 | 14 | get ok() { 15 | return this._response.statusCode < 299; 16 | } 17 | 18 | get statusText() { 19 | if (this._response.statusMessage) { 20 | return this._response.statusMessage; 21 | } else { 22 | return statusTexts.get(this._response.statusCode); 23 | } 24 | } 25 | 26 | get headers() { 27 | return this._response._headers; 28 | } 29 | 30 | get redirected() { 31 | return this._redirected; 32 | } 33 | 34 | get type() { 35 | return "shadow"; 36 | } 37 | 38 | get url() { 39 | return "/"; 40 | } 41 | 42 | async arraybuffer() { 43 | throw new Error("not implemented"); 44 | } 45 | 46 | async blob() { 47 | throw new Error("not implemented"); 48 | } 49 | 50 | async json() { 51 | const json = this._response[jsonKey]; 52 | if (json) { 53 | return json; 54 | } 55 | const text = await this.text(); 56 | return JSON.parse(text); 57 | } 58 | 59 | async text() { 60 | const outputs = this._response.output; 61 | if (outputs.length === 0) { 62 | return ""; 63 | } else if (outputs.length === 1) { 64 | if (typeof outputs[0] === "string") { 65 | return outputs[0]; 66 | } else if (Buffer.isBuffer(outputs[0])) { 67 | return outputs[0].toString("utf8"); 68 | } else { 69 | throw new Error("unsupported type: ", outputs[0]); 70 | } 71 | } 72 | let result = ""; 73 | for (const output of outputs) { 74 | if (typeof output === "string") { 75 | result = result + output; 76 | } else if (Buffer.isBuffer(output)) { 77 | result = result + output.toString("utf8"); 78 | } else { 79 | throw new Error("unsupported type: ", output); 80 | } 81 | } 82 | return result; 83 | } 84 | } 85 | 86 | module.exports = { 87 | Response 88 | }; 89 | -------------------------------------------------------------------------------- /lib/serverresponse.js: -------------------------------------------------------------------------------- 1 | const { Headers } = require("./headers"); 2 | const { jsonKey } = require("./incomingmessage"); 3 | 4 | 5 | class ServerResponse { 6 | constructor(onfinish) { 7 | this.statusCode = 200; 8 | this.statusMessage = ""; 9 | this._headers = new Headers(); 10 | this._trailers = null; 11 | this.output = []; 12 | this._onfinish = onfinish; 13 | this.sendDate = false; 14 | this.headersSent = false; 15 | this._readableStream = null; 16 | this._drainListener = null; 17 | this._listeners = { 18 | close: null, 19 | drain: null, 20 | error: null, 21 | finish: null, 22 | pipe: null, 23 | unpipe: null 24 | }; 25 | this._readableStream; 26 | } 27 | 28 | get shadow() { 29 | return true; 30 | } 31 | 32 | on(eventName, listener) { 33 | if (!this._listeners.hasOwnProperty(eventName)) { 34 | throw new Error(`Unsupported event: '${eventName}'`); 35 | } 36 | this._listeners[eventName] = { listener, once: false }; 37 | } 38 | 39 | once(eventName, listener) { 40 | if (!this._listeners.hasOwnProperty(eventName)) { 41 | throw new Error(`Unsupported event: '${eventName}'`); 42 | } 43 | this._listeners[eventName] = { listener, once: true }; 44 | } 45 | 46 | emit(eventName, arg) { 47 | if (!this._listeners.hasOwnProperty(eventName)) { 48 | throw new Error(`Unsupported event: '${eventName}'`); 49 | } 50 | this._emit(eventName, arg); 51 | if (eventName === "pipe") { 52 | if (!this._listeners.unpipe) { 53 | // Stream v1 54 | arg.on("data", (arg) => { 55 | this.write(arg); 56 | }); 57 | } 58 | this._readableStream = arg; 59 | this._emit("drain", arg); 60 | } 61 | } 62 | 63 | _emit(eventName, arg) { 64 | if (this._listeners[eventName]) { 65 | const { listener, once } = this._listeners[eventName]; 66 | listener(arg); 67 | if (once) { 68 | delete this._listeners[eventName]; 69 | } 70 | } 71 | } 72 | 73 | removeListener(eventName, listener) { 74 | if (!this._listeners.hasOwnProperty(eventName)) { 75 | throw new Error(`Unsupported event: '${eventName}'`); 76 | } 77 | if (this._listeners[eventName] && this._listeners[eventName].listener === listener) { 78 | delete this._listeners[eventName]; 79 | } 80 | } 81 | 82 | pipe() { 83 | throw new Error("Can't call ServerResponse's pipe because it is not readable"); 84 | } 85 | 86 | addTrailers(headers) { 87 | this._trailers = headers; 88 | } 89 | 90 | getHeader(name) { 91 | return this._headers.get(name); 92 | } 93 | 94 | getHeaderNames() { 95 | return Array.from(this._headers.keys()); 96 | } 97 | 98 | getHeaders() { 99 | return this._headers.entries(); 100 | } 101 | 102 | hasHeader(name) { 103 | return this._headers.has(name); 104 | } 105 | 106 | removeHeader(name) { 107 | if (this.headersSent) { 108 | throw new Error("[ERR_HTTP_HEADERS_SENT]: Cannot remove headers after they are sent to the client"); 109 | } else { 110 | this._headers.delete(name); 111 | } 112 | } 113 | 114 | setHeader(name, value) { 115 | if (this.headersSent) { 116 | throw new Error("[ERR_HTTP_HEADERS_SENT]: Cannot set headers after they are sent to the client"); 117 | } else { 118 | this._headers.set(name, value); 119 | } 120 | } 121 | 122 | listeners() { 123 | return []; 124 | } 125 | 126 | writeHead(statusCode, headers) { 127 | this.statusCode = statusCode; 128 | if (headers) { 129 | for (const [name, value] of Object.entries(headers)) { 130 | this._headers.set(name, value); 131 | } 132 | } 133 | } 134 | 135 | write(chunk, encoding, callback) { 136 | if (typeof chunk === "string" && typeof encoding === "string") { 137 | this.output.push(Buffer.from(chunk, encoding)); 138 | } else if (chunk) { 139 | this.output.push(chunk); 140 | } 141 | if (callback) { 142 | throw new Error("not implemented: callback of write()"); 143 | } 144 | return true; 145 | } 146 | 147 | end(chunk, encoding, callback) { 148 | if (typeof chunk === "string" && typeof encoding === "string") { 149 | this.output.push(Buffer.from(chunk, encoding)); 150 | } else if (chunk) { 151 | this.output.push(chunk); 152 | } 153 | if (callback) { 154 | throw new Error("not implemented: callback of write()"); 155 | } 156 | this._onfinish(this); 157 | } 158 | 159 | writeJSON(json) { 160 | this[jsonKey] = json; 161 | } 162 | } 163 | 164 | module.exports = { 165 | ServerResponse 166 | }; 167 | -------------------------------------------------------------------------------- /lib/statustexts.js: -------------------------------------------------------------------------------- 1 | const statusTexts = new Map([ 2 | [100, "Continue"], 3 | [101, "Switching Protocols"], 4 | [200, "OK"], 5 | [201, "Created"], 6 | [202, "Accepted"], 7 | [203, "Non - Authoritative Information"], 8 | [204, "No Content"], 9 | [205, "Reset Content"], 10 | [206, "Partial Content"], 11 | [300, "Multiple Choices"], 12 | [301, "Moved Permanently"], 13 | [302, "Found"], 14 | [303, "See Other"], 15 | [304, "Not Modified"], 16 | [307, "Temporary Redirect"], 17 | [308, "Permanent Redirect"], 18 | [400, "Bad Request"], 19 | [401, "Unauthorized"], 20 | [403, "Forbidden"], 21 | [404, "Not Found"], 22 | [405, "Method Not Allowed"], 23 | [406, "Not Acceptable"], 24 | [407, "Proxy Authentication Required"], 25 | [408, "Request Timeout"], 26 | [409, "Conflict"], 27 | [410, "Gone"], 28 | [411, "Length Required"], 29 | [412, "Precondition Failed"], 30 | [413, "Payload Too Large"], 31 | [414, "URI Too Long"], 32 | [415, "Unsupported Media Type"], 33 | [416, "Range Not Satisfiable"], 34 | [417, "Expectation Failed"], 35 | [418, "I'm a teapot"], 36 | [426, "Upgrade Required"], 37 | [428, "Precondition Required"], 38 | [429, "Too Many Requests"], 39 | [431, "Request Header Fields Too Large"], 40 | [451, "Unavailable For Legal Reasons"], 41 | [500, "Internal Server Error"], 42 | [501, "Not Implemented"], 43 | [502, "Bad Gateway"], 44 | [503, "Service Unavailable"], 45 | [504, "Gateway Timeout"], 46 | [505, "HTTP Version Not Supported"], 47 | [511, "Network Authentication Required"]]); 48 | module.exports = { 49 | statusTexts 50 | }; 51 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "shadow-fetch", 3 | "version": "0.2.1", 4 | "description": "fake fetch/httpServer for SSR", 5 | "main": "index.js", 6 | "browser": "browser.js", 7 | "scripts": { 8 | "test": "nyc ava -v", 9 | "benchmark": "ava -v --serial", 10 | "coverage": "nyc report --reporter=text-lcov > coverage.lcov && codecov", 11 | "security-test": "snyk test" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "https://github.com/shibukawa/shadow-fetch" 16 | }, 17 | "keywords": [ 18 | "isomorphic", 19 | "fetch", 20 | "next.js", 21 | "express.js" 22 | ], 23 | "author": "Yoshiki Shibukawa", 24 | "license": "MIT", 25 | "devDependencies": { 26 | "ava": "^0.25.0", 27 | "browserify": "^16.2.0", 28 | "codecov": "^3.0.0", 29 | "eslint": "^4.19.1", 30 | "express": "^4.16.3", 31 | "flow-bin": "^0.70.0", 32 | "node-fetch": "^2.1.2", 33 | "nyc": "^11.6.0", 34 | "override-require": "^1.1.1", 35 | "snyk": "^1.78.1", 36 | "streamtest": "^1.2.3", 37 | "unfetch": "^3.0.0" 38 | }, 39 | "ava": { 40 | "files": [ 41 | "test/test-*.js" 42 | ], 43 | "source": [ 44 | "lib/**/*.{js,jsx}", 45 | "index.js" 46 | ] 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /shadow-fetch-express/.npmignore: -------------------------------------------------------------------------------- 1 | doc/*.png 2 | test 3 | examples 4 | node_modules 5 | shadow-fetch-express 6 | .nyc_output 7 | .vscode 8 | coverage 9 | *.tgz 10 | -------------------------------------------------------------------------------- /shadow-fetch-express/LICENSE.md: -------------------------------------------------------------------------------- 1 | ## MIT License 2 | 3 | Copyright © 2018 Yoshiki Shibukawa 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a 6 | copy of this software and associated documentation files (the "Software"), 7 | to deal in the Software without restriction, including without limitation 8 | the rights to use, copy, modify, merge, publish, distribute, sublicense, 9 | and/or sell copies of the Software, and to permit persons to whom the 10 | Software is furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 20 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 21 | DEALINGS IN THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /shadow-fetch-express/README.md: -------------------------------------------------------------------------------- 1 | # shadow-dom-express middleware 2 | 3 | [![npm version](https://badge.fury.io/js/shadow-fetch-express.svg)](https://badge.fury.io/js/shadow-fetch-express) 4 | [![NPM](https://nodei.co/npm/shadow-fetch-express.png)](https://nodei.co/npm/shadow-fetch-express/) 5 | 6 | see: https://github.com/shibukawa/shadow-fetch 7 | -------------------------------------------------------------------------------- /shadow-fetch-express/index.js: -------------------------------------------------------------------------------- 1 | const { IncomingMessage, shadowKey, ServerResponse } = require("shadow-fetch"); 2 | 3 | const shadowFetchMiddleware = (req, res, next) => { 4 | if (req[shadowKey]) { 5 | for (const key of Object.getOwnPropertyNames(IncomingMessage.prototype)) { 6 | if (key !== "constructor" || key !== "shadow") { 7 | req[key] = IncomingMessage.prototype[key]; 8 | } 9 | } 10 | Object.defineProperty(req, "shadow", Object.getOwnPropertyDescriptor(IncomingMessage.prototype, "shadow")); 11 | for (const key of Object.getOwnPropertyNames(ServerResponse.prototype)) { 12 | if (key !== "constructor") { 13 | res[key] = ServerResponse.prototype[key]; 14 | } 15 | } 16 | 17 | res.json = function (obj) { 18 | let val = obj; 19 | // allow status / body 20 | if (arguments.length === 2) { 21 | // res.json(body, status) backwards compat 22 | if (typeof arguments[1] === "number") { 23 | this.statusCode = arguments[1]; 24 | } else { 25 | this.statusCode = arguments[0]; 26 | val = arguments[1]; 27 | } 28 | } 29 | // content-type 30 | if (!this.get("Content-Type")) { 31 | this.set("Content-Type", "application/json"); 32 | } 33 | this.writeJSON(val); 34 | return this.end(); 35 | }; 36 | } 37 | next(); 38 | }; 39 | 40 | module.exports = { 41 | shadowFetchMiddleware 42 | }; 43 | -------------------------------------------------------------------------------- /shadow-fetch-express/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "shadow-fetch-express", 3 | "version": "0.2.0", 4 | "description": "express.js middleware of shadow-fetch", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/shibukawa/shadow-fetch" 12 | }, 13 | "keywords": [ 14 | "isomorphic", 15 | "fetch", 16 | "next.js", 17 | "express.js" 18 | ], 19 | "dependencies": { 20 | "shadow-fetch": "^0.2.0" 21 | }, 22 | "author": "Yoshiki Shibukawa", 23 | "license": "MIT" 24 | } 25 | -------------------------------------------------------------------------------- /test/helper.js: -------------------------------------------------------------------------------- 1 | const fetch = require("node-fetch"); 2 | const { createServer } = require("http"); 3 | 4 | module.exports = { 5 | initStandardFetch() { 6 | return { 7 | fetch, 8 | createServer 9 | }; 10 | }, 11 | printBenchmark(message, [sec, nanosec]) { 12 | let microsec = Math.round(nanosec / 1000).toString(10); 13 | for (let i = microsec.length; i < 6; i++) { 14 | microsec = "0" + microsec; 15 | } 16 | return `${message}: ${sec}.${microsec} seconds`; 17 | } 18 | }; 19 | -------------------------------------------------------------------------------- /test/test-benchmark.js: -------------------------------------------------------------------------------- 1 | const test = require("ava"); 2 | const { initStandardFetch, printBenchmark } = require("./helper"); 3 | 4 | test("the actual connection and receive json", async (t) => { 5 | const { fetch, createServer } = initStandardFetch(); 6 | 7 | const server = createServer((req, res) => { 8 | res.writeHead(200, { "Content-Type": "application/json" }); 9 | res.end(JSON.stringify({ message: "hello" })); 10 | }); 11 | 12 | return new Promise((resolve) => { 13 | server.listen(async () => { 14 | const { port } = server.address(); 15 | 16 | const start = process.hrtime(); 17 | const res = await fetch(`http://localhost:${port}/test`); 18 | const json = await res.json(); 19 | const end = process.hrtime(start); 20 | 21 | t.is(res.status, 200); 22 | t.is(res.statusText, "OK"); 23 | t.is(res.ok, true); 24 | 25 | t.is(json.message, "hello"); 26 | t.log(printBenchmark("Benchmark regular fetch and server", end)); 27 | server.close(resolve); 28 | }); 29 | }); 30 | }); 31 | /* 32 | test("write chunks and receive text", async (t) => { 33 | const { fetch, createServer } = initFetch(); 34 | 35 | const server = createServer((req, res) => { 36 | res.writeHead(200, { "Content-Type": "application/json" }); 37 | res.write("hello\n"); 38 | res.write("world\n"); 39 | res.end(); 40 | }); 41 | 42 | server.listen(80); 43 | 44 | const res = await fetch("/test"); 45 | t.is(res.status, 200); 46 | t.is(res.statusText, "OK"); 47 | t.is(res.ok, true); 48 | 49 | const message = await res.text(); 50 | t.is(message, "hello\nworld\n"); 51 | }); 52 | 53 | test("POST method and receive header", async (t) => { 54 | const { fetch, createServer } = initFetch(); 55 | 56 | const server = createServer((req, res) => { 57 | t.is(req.method, "POST"); 58 | res.writeHead(201, { "Location": "/test/2" }); 59 | res.end(); 60 | }); 61 | 62 | server.listen(80); 63 | 64 | const res = await fetch("/test", { method: "POST" }); 65 | t.is(res.status, 201); 66 | t.is(res.statusText, "Created"); 67 | t.is(res.ok, true); 68 | t.is(res.headers.get("location", "/test/2")); 69 | });*/ 70 | -------------------------------------------------------------------------------- /test/test-express.js: -------------------------------------------------------------------------------- 1 | const test = require("ava"); 2 | const express = require("express"); 3 | const bodyParser = require("body-parser"); 4 | const restoreOriginalRequire = require("override-require"); 5 | 6 | 7 | // override require to resolve shadow-fetch form subproject 8 | const isOverride = (request) => { 9 | return request === "shadow-fetch"; 10 | }; 11 | const resolveRequest = () => { 12 | return require("../index"); 13 | }; 14 | restoreOriginalRequire(isOverride, resolveRequest); 15 | 16 | 17 | const { initFetch } = require("../index"); 18 | const { shadowFetchMiddleware } = require("../shadow-fetch-express/index"); 19 | 20 | test("use express middleware", async (t) => { 21 | const app = express(); 22 | app.use(shadowFetchMiddleware); 23 | app.use(bodyParser.json()); 24 | app.post("/test", (req, res) => { 25 | t.is(req.ip, "::1"); 26 | t.is(req.shadow, true); 27 | t.is(req.body.message, "hello"); 28 | res.send({ message: "world" }); 29 | }); 30 | const { fetch, createServer } = initFetch(); 31 | 32 | createServer(app); 33 | 34 | const res = await fetch("/test", { 35 | method: "post", 36 | headers: { 37 | "content-type": "application/json" 38 | }, 39 | body: JSON.stringify({ message: "hello" }) 40 | }); 41 | t.is(res.status, 200); 42 | t.is(res.statusText, "OK"); 43 | t.is(res.ok, true); 44 | const json = await res.json(); 45 | t.is(json.message, "world"); 46 | }); 47 | 48 | test("use middleware and shadow fetch's json() method", async (t) => { 49 | const app = express(); 50 | app.use(shadowFetchMiddleware); 51 | app.get("/test", (req, res) => { 52 | t.is(req.shadow, true); 53 | res.json({ message: "world" }); 54 | }); 55 | const { fetch, createServer } = initFetch(); 56 | 57 | createServer(app); 58 | const res = await fetch("/test"); 59 | t.is(res.status, 200); 60 | t.is(res.statusText, "OK"); 61 | t.is(res.ok, true); 62 | const json = await res.json(); 63 | t.is(json.message, "world"); 64 | }); 65 | -------------------------------------------------------------------------------- /test/test-headers.js: -------------------------------------------------------------------------------- 1 | const test = require("ava"); 2 | const { 3 | Headers, 4 | convertToClientMode, 5 | convertToServerHeaders 6 | } = require("../lib/headers"); 7 | 8 | test("empty headers", (t) => { 9 | const headers = new Headers(); 10 | 11 | t.deepEqual(Array.from(headers.keys()), []); 12 | t.deepEqual(Array.from(headers.values()), []); 13 | t.deepEqual(Array.from(headers.entries()), []); 14 | 15 | t.is(headers.get("no-exist-key"), null); 16 | t.is(headers.has("no-exist-key"), false); 17 | }); 18 | 19 | test("set/get", (t) => { 20 | const headers = new Headers(); 21 | 22 | headers.set("accept-encoding", "gzip"); 23 | 24 | t.is(headers.get("accept-encoding"), "gzip"); 25 | t.is(headers.has("accept-encoding"), true); 26 | 27 | t.is(headers.get("Accept-Encoding"), "gzip"); 28 | t.is(headers.has("Accept-Encoding"), true); 29 | 30 | headers.append("Content-Type", "application/json"); 31 | 32 | t.is(headers.get("content-type"), "application/json"); 33 | t.is(headers.has("content-type"), true); 34 | 35 | t.is(headers.get("Content-Type"), "application/json"); 36 | t.is(headers.has("Content-Type"), true); 37 | 38 | t.deepEqual(Array.from(headers.keys()), 39 | ["accept-encoding", "content-type"]); 40 | t.deepEqual(Array.from(headers.values()), 41 | ["gzip", "application/json"]); 42 | t.deepEqual(Array.from(headers.entries()), 43 | [ 44 | ["accept-encoding", "gzip"], 45 | ["content-type", "application/json"] 46 | ]); 47 | }); 48 | 49 | test("set with not-string key", (t) => { 50 | const headers = new Headers(); 51 | 52 | headers.set(1, "convert-to-number"); 53 | t.is(headers.get("1"), "convert-to-number"); 54 | t.is(headers.has("1"), true); 55 | }); 56 | 57 | test("set with invalid key", (t) => { 58 | const headers = new Headers(); 59 | 60 | let message = null; 61 | try { 62 | headers.set("不正なキー", "invalid key"); 63 | } catch (e) { 64 | message = e.message; 65 | } 66 | t.is(message, "Invalid character in header field name"); 67 | }); 68 | 69 | test("delete", (t) => { 70 | const headers = new Headers(); 71 | 72 | headers.set("accept-encoding", "gzip"); 73 | headers.delete("Accept-Encoding"); 74 | t.is(headers.get("accept-encoding"), null); 75 | t.is(headers.has("accept-encoding"), false); 76 | }); 77 | 78 | test("init with object", (t) => { 79 | const headers = new Headers({ 80 | "accept-encoding": "gzip", 81 | "content-type": "application/json" 82 | }); 83 | t.is(headers.get("accept-encoding"), "gzip"); 84 | t.is(headers.get("content-type"), "application/json"); 85 | }); 86 | 87 | test("init with array", (t) => { 88 | const headers = new Headers([ 89 | ["accept-encoding", "gzip"], 90 | ["content-type", "application/json"] 91 | ]); 92 | t.is(headers.get("accept-encoding"), "gzip"); 93 | t.is(headers.get("content-type"), "application/json"); 94 | }); 95 | 96 | test("init with Headers", (t) => { 97 | const originalHeaders = new Headers({ 98 | "accept-encoding": "gzip", 99 | "content-type": "application/json" 100 | }); 101 | const headers = new Headers(originalHeaders); 102 | t.is(headers.get("accept-encoding"), "gzip"); 103 | t.is(headers.get("content-type"), "application/json"); 104 | }); 105 | 106 | test("forEach", (t) => { 107 | const headers = new Headers([ 108 | ["accept-encoding", "gzip"], 109 | ["content-type", "application/json"] 110 | ]); 111 | 112 | const entries = []; 113 | headers.forEach((key, value) => { 114 | entries.push([key, value]); 115 | }); 116 | 117 | t.deepEqual(entries, 118 | [ 119 | ["gzip", "accept-encoding"], 120 | ["application/json", "content-type"] 121 | ]); 122 | }); 123 | 124 | test("client mode", (t) => { 125 | const headers = new Headers(); 126 | 127 | headers.set("accept-encoding", ["gzip", "br"]); 128 | headers.append("accept", ["application/json"]); 129 | headers.append("accept", ["text/html"]); 130 | 131 | t.is(headers.get("accept-encoding"), "gzip,br"); 132 | // Chrome's Headers.append() inserts space after comma 133 | t.is(headers.get("accept"), "application/json, text/html"); 134 | }); 135 | 136 | test("compatible mode with nodejs's ServerResponse.setHeader()", (t) => { 137 | const headers = new Headers(); 138 | headers._clientMode = false; 139 | 140 | // res.setHeader() keeps array as is 141 | headers.set("accept-encoding", ["gzip", "br"]); 142 | 143 | t.deepEqual(headers.get("accept-encoding"), ["gzip", "br"]); 144 | }); 145 | 146 | test("convert to server headers", (t) => { 147 | const clientHeaders = new Headers(); 148 | 149 | clientHeaders.set("accept-encoding", "gzip"); 150 | clientHeaders.set("Content-Type", "application/json"); 151 | 152 | const { headers, rawHeaders } = convertToServerHeaders(clientHeaders); 153 | 154 | t.deepEqual(headers, 155 | { 156 | "accept-encoding": "gzip", 157 | "content-type": "application/json" 158 | }); 159 | t.deepEqual(rawHeaders, 160 | [ 161 | "accept-encoding", "gzip", 162 | "content-type", "application/json" 163 | ]); 164 | }); 165 | 166 | test("convert to client mode", (t) => { 167 | const headers = new Headers(); 168 | headers._clientMode = false; 169 | 170 | headers.set("accept-encoding", ["gzip", "br"]); 171 | headers.set("Content-Type", "application/json"); 172 | 173 | convertToClientMode(headers); 174 | 175 | t.is(headers.get("accept-encoding"), "gzip, br"); 176 | t.is(headers.get("content-type"), "application/json"); 177 | }); 178 | -------------------------------------------------------------------------------- /test/test-incomingmessage.js: -------------------------------------------------------------------------------- 1 | const test = require("ava"); 2 | var StreamTest = require("streamtest"); 3 | const { IncomingMessage, bodyKey, textKey } = require("../lib/incomingmessage"); 4 | 5 | process.on("uncaughtException", console.dir); 6 | 7 | test("init without url", (t) => { 8 | const req = new IncomingMessage(); 9 | t.is(req.url, undefined); 10 | }); 11 | 12 | test("init with url", (t) => { 13 | const req = new IncomingMessage("/test"); 14 | t.is(req.url, "/test"); 15 | t.is(req.method, "GET"); 16 | t.is(req.httpVersion, "1.1"); 17 | t.deepEqual(req.headers, {}); 18 | t.deepEqual(req.rawHeaders, []); 19 | t.deepEqual(req.trailers, {}); 20 | t.deepEqual(req.rawTrailers, []); 21 | }); 22 | 23 | test("init with method/headers", (t) => { 24 | const req = new IncomingMessage("/test", { 25 | method: "put", 26 | headers: { 27 | "Accept": "application/json" 28 | } 29 | }); 30 | t.is(req.url, "/test"); 31 | t.is(req.method, "PUT"); 32 | t.is(req.httpVersion, "1.1"); 33 | t.deepEqual(req.headers, { 34 | "accept": "application/json" 35 | }); 36 | t.deepEqual(req.rawHeaders, [ 37 | "accept", "application/json" 38 | ]); 39 | t.deepEqual(req.trailers, {}); 40 | t.deepEqual(req.rawTrailers, []); 41 | }); 42 | 43 | test("init with body", (t) => { 44 | const req = new IncomingMessage("/test", { 45 | method: "post", 46 | headers: { 47 | "content-type": "text/plain" 48 | }, 49 | body: "hello world" 50 | }); 51 | t.is(req[bodyKey].toString("utf8"), "hello world"); 52 | t.is(req[textKey].toString("utf8"), "hello world"); 53 | t.is(req.headers["content-type"], "text/plain"); 54 | t.is(req.headers["content-length"], "11"); 55 | }); 56 | 57 | test("init with body without headers (1)", (t) => { 58 | const req = new IncomingMessage("/test", { 59 | method: "post", 60 | body: "hello world" 61 | }); 62 | t.is(req[bodyKey].toString("utf8"), "hello world"); 63 | t.is(req[textKey].toString("utf8"), "hello world"); 64 | t.is(req.headers["content-type"], "text/plain"); 65 | t.is(req.headers["content-length"], "11"); 66 | }); 67 | 68 | 69 | test("init with body without headers (2)", (t) => { 70 | const req = new IncomingMessage("/test", { 71 | method: "post", 72 | body: 10 73 | }); 74 | t.is(req[bodyKey].toString("utf8"), "10"); 75 | t.is(req.headers["content-type"], "text/plain"); 76 | t.is(req.headers["content-length"], "2"); 77 | }); 78 | 79 | test("init with body without headers (3)", (t) => { 80 | const req = new IncomingMessage("/test", { 81 | method: "post", 82 | headers: { 83 | "cookie": "session=12345" 84 | }, 85 | body: 10 86 | }); 87 | t.is(req[bodyKey].toString("utf8"), "10"); 88 | t.is(req.headers["content-type"], "text/plain"); 89 | t.is(req.headers["content-length"], "2"); 90 | }); 91 | 92 | test("support reader stream interface", (t) => { 93 | const req = new IncomingMessage("/test", { 94 | method: "post", 95 | headers: { 96 | "content-type": "text/plain" 97 | }, 98 | body: "hello world" 99 | }); 100 | 101 | const called = []; 102 | 103 | t.is(req.listeners("end").length, 0); 104 | t.is(req.listeners("data").length, 0); 105 | 106 | req.on("end", () => { 107 | called.push("end"); 108 | }); 109 | 110 | req.on("end", () => { 111 | called.push("end2"); 112 | }); 113 | 114 | req.on("data", (data) => { 115 | called.push("data"); 116 | t.is(data.toString("utf8"), "hello world"); 117 | }); 118 | 119 | t.deepEqual(called, ["data", "end", "end2"]); 120 | t.is(req.listeners("end").length, 2); 121 | t.is(req.listeners("data").length, 1); 122 | }); 123 | 124 | test("stream compatibility test", async (t) => { 125 | for (const version of StreamTest.versions) { 126 | const text = await new Promise((resolve, reject) => { 127 | try { 128 | const writer = StreamTest[version].toText((err, text) => { 129 | if (err) { 130 | reject(err); 131 | return; 132 | } 133 | resolve(text); 134 | }); 135 | const req = new IncomingMessage("/test", { 136 | method: "post", 137 | headers: { 138 | "content-type": "text/plain" 139 | }, 140 | body: "hello world" 141 | }); 142 | req.pipe(writer); 143 | } catch (e) { 144 | reject(e); 145 | } 146 | }); 147 | t.is(text, "hello world"); 148 | } 149 | }); 150 | -------------------------------------------------------------------------------- /test/test-server.js: -------------------------------------------------------------------------------- 1 | const test = require("ava"); 2 | const { initFetch } = require("../index"); 3 | 4 | function delay(t) { 5 | return new Promise(function(resolve) { 6 | setTimeout(resolve.bind(null), t); 7 | }); 8 | } 9 | 10 | const redirectApp = (req, res) => { 11 | res.end("ok"); 12 | }; 13 | 14 | test("createServer behaves like net/http createServer", async (t) => { 15 | const { createServer } = initFetch(); 16 | 17 | const server = createServer(redirectApp); 18 | 19 | t.is(server.listening, false); 20 | let calledListnerCallback = false; 21 | server.listen(80, () => { 22 | calledListnerCallback = true; 23 | }); 24 | t.is(server.listening, true); 25 | t.is(server.address().port, 80); 26 | 27 | await delay(50); 28 | t.is(calledListnerCallback, true); 29 | 30 | let calledCloseCallback = false; 31 | server.close(() => { 32 | calledCloseCallback = true; 33 | }); 34 | 35 | await delay(50); 36 | t.is(calledCloseCallback, true); 37 | }); 38 | -------------------------------------------------------------------------------- /test/test-serverresponse.js: -------------------------------------------------------------------------------- 1 | const test = require("ava"); 2 | const StreamTest = require("streamtest"); 3 | const { ServerResponse } = require("../lib/serverresponse"); 4 | const { Response } = require("../lib/response"); 5 | 6 | test("stream compatibility test v1", async (t) => { 7 | const serverResponse = await new Promise(resolve => { 8 | const response = new ServerResponse(resolve); 9 | StreamTest.v1.fromChunks(["a ", "chunk ", "and ", "another"]) 10 | .pipe(response); 11 | }); 12 | const response = new Response(serverResponse); 13 | const text = await response.text(); 14 | t.is(text, "a chunk and another"); 15 | }); 16 | 17 | test("stream compatibility test v2", async (t) => { 18 | const serverResponse = await new Promise(resolve => { 19 | const response = new ServerResponse(resolve); 20 | StreamTest.v2.fromChunks(["a ", "chunk ", "and ", "another"]) 21 | .pipe(response); 22 | }); 23 | const response = new Response(serverResponse); 24 | const text = await response.text(); 25 | t.is(text, "a chunk and another"); 26 | }); 27 | -------------------------------------------------------------------------------- /test/test-shadow-fetch.js: -------------------------------------------------------------------------------- 1 | const test = require("ava"); 2 | const { initFetch } = require("../index"); 3 | const fetch = require("../index"); 4 | const { printBenchmark } = require("./helper"); 5 | 6 | test("the simplest connection and receive regular output", async (t) => { 7 | const { fetch, createServer } = initFetch(); 8 | 9 | const server = createServer((req, res) => { 10 | res.writeHead(200, { "Content-Type": "application/json" }); 11 | res.end(JSON.stringify({ message: "hello" })); 12 | }); 13 | 14 | server.listen(80); 15 | 16 | const start = process.hrtime(); 17 | const res = await fetch("/test"); 18 | const json = await res.json(); 19 | const end = process.hrtime(start); 20 | 21 | t.is(res.status, 200); 22 | t.is(res.statusText, "OK"); 23 | t.is(res.ok, true); 24 | t.is(json.message, "hello"); 25 | 26 | t.log(printBenchmark("Benchmark shadow fetch and server with standard output", end)); 27 | }); 28 | 29 | test("the simplest connection and receive JSON output", async (t) => { 30 | const { fetch, createServer } = initFetch(); 31 | 32 | const server = createServer((req, res) => { 33 | res.writeHead(200, { "Content-Type": "application/json" }); 34 | res.writeJSON({ message: "hello" }); 35 | res.end(); 36 | }); 37 | 38 | server.listen(80); 39 | 40 | const start = process.hrtime(); 41 | const res = await fetch("/test"); 42 | const json = await res.json(); 43 | const end = process.hrtime(start); 44 | 45 | t.is(res.status, 200); 46 | t.is(res.statusText, "OK"); 47 | t.is(res.ok, true); 48 | t.is(json.message, "hello"); 49 | 50 | t.log(printBenchmark("Benchmark shadow fetch and server with JSON output", end)); 51 | }); 52 | 53 | test("write chunks and receive text", async (t) => { 54 | const { fetch, createServer } = initFetch(); 55 | 56 | const server = createServer((req, res) => { 57 | res.writeHead(200, { "Content-Type": "application/json" }); 58 | res.write("hello\n"); 59 | res.write("world\n"); 60 | res.end(); 61 | }); 62 | 63 | server.listen(80); 64 | 65 | const res = await fetch("/test"); 66 | t.is(res.status, 200); 67 | t.is(res.statusText, "OK"); 68 | t.is(res.ok, true); 69 | 70 | const message = await res.text(); 71 | t.is(message, "hello\nworld\n"); 72 | }); 73 | 74 | test("POST method and receive header", async (t) => { 75 | const { fetch, createServer } = initFetch(); 76 | 77 | const server = createServer((req, res) => { 78 | t.is(req.method, "POST"); 79 | res.writeHead(201, { "Location": "/test/2" }); 80 | res.end(); 81 | }); 82 | 83 | server.listen(80); 84 | 85 | const res = await fetch("/test", { method: "POST" }); 86 | t.is(res.status, 201); 87 | t.is(res.statusText, "Created"); 88 | t.is(res.ok, true); 89 | t.is(res.headers.get("location"), "/test/2"); 90 | }); 91 | 92 | const redirectApp = (req, res) => { 93 | if (req.url === "/redirect") { 94 | res.writeHead(302, { "Location": "/landing-page" }); 95 | res.end(); 96 | } else if (req.url === "/landing-page") { 97 | res.end("landed"); 98 | } 99 | }; 100 | 101 | test("shadowFetch can follow redirection by default", async (t) => { 102 | const { fetch, createServer } = initFetch(); 103 | 104 | const server = createServer(redirectApp); 105 | 106 | server.listen(80); 107 | 108 | const res = await fetch("/redirect"); 109 | t.is(res.status, 200); 110 | t.is(res.redirected, true); 111 | const message = await res.text(); 112 | t.is(message, "landed"); 113 | }); 114 | 115 | test("shadowFetch can detect uninitilzed status (1)", async (t) => { 116 | let message = null; 117 | try { 118 | await fetch("/test", { method: "POST" }); 119 | } catch (e) { 120 | message = e.message; 121 | } 122 | t.is(typeof message, "string"); 123 | }); 124 | 125 | test("shadowFetch can detect uninitilzed status (2)", async (t) => { 126 | const { fetch } = initFetch(); 127 | 128 | let message = null; 129 | try { 130 | await fetch("/test", { method: "POST" }); 131 | } catch (e) { 132 | message = e.message; 133 | } 134 | t.is(typeof message, "string"); 135 | }); 136 | --------------------------------------------------------------------------------