├── .eslintignore ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── bin ├── create-tsconfig.js └── vercel-node-dev ├── docs └── testing-vercel-json-routes.jsonc ├── jest.config.js ├── package.json ├── src ├── child-processes │ ├── start-api-dev-process.ts │ └── start-ui-dev-process.ts ├── frameworks │ ├── frameworks.ts │ └── get-framework.ts ├── get-context.ts ├── index.ts ├── lib.ts ├── ports.ts ├── routes │ ├── api-routes.ts │ └── routing.ts └── servers │ ├── start-api-server.ts │ └── start-proxy-server.ts ├── test ├── fixtures │ └── create-react-app │ │ ├── .env │ │ ├── .gitignore │ │ ├── README.md │ │ ├── api │ │ ├── articles │ │ │ ├── [id].js │ │ │ └── index.js │ │ ├── blog │ │ │ └── [slug] │ │ │ │ ├── admin │ │ │ │ └── [action] │ │ │ │ │ ├── [type].js │ │ │ │ │ └── index.js │ │ │ │ ├── edit.js │ │ │ │ └── index.js │ │ ├── body.js │ │ ├── hello-world.js │ │ ├── index.js │ │ ├── method.js │ │ ├── query-strings.js │ │ └── typescript-world.ts │ │ ├── package.json │ │ ├── public │ │ ├── favicon.ico │ │ ├── foo.html │ │ ├── index.html │ │ ├── logo192.png │ │ ├── logo512.png │ │ ├── manifest.json │ │ └── robots.txt │ │ ├── src │ │ ├── App.css │ │ ├── App.js │ │ ├── App.test.js │ │ ├── index.css │ │ ├── index.js │ │ ├── logo.svg │ │ ├── react-app-env.d.ts │ │ ├── serviceWorker.js │ │ └── setupTests.js │ │ ├── vercel.json │ │ └── yarn.lock └── integration.test.ts ├── tsconfig.json └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | docs -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .vscode 3 | *.log 4 | dist 5 | node_modules 6 | targets 7 | temp 8 | /tsconfig.*.json 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | cache: 4 | yarn: true 5 | directories: 6 | - node_modules 7 | - test/fixtures/create-react-app/node_modules 8 | node_js: 9 | - "12" 10 | - "10" 11 | before_script: 12 | - cd test/fixtures/create-react-app 13 | - yarn install 14 | - cd ../../../ 15 | script: 16 | - yarn run test 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Sean Matheson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | 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 FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Deprecated 2 | 3 | The Vercel team has done a great job on the latest version of the `vercel dev` server. 💃 4 | 5 | --- 6 | 7 |

8 | 🧸 9 |
10 | vercel-node-dev 11 |

12 | 13 |

14 | An unofficial development CLI for Vercel applications targeting the Node.js runtime. 15 |

16 | 17 |

18 | Fast reloads, improved reliability, offline and debugger support. 19 |

20 | 21 |

22 | npm 23 | MIT License 24 | Travis 25 |

26 | 27 |

 

28 | 29 | ## Table of Contents 30 | 31 | - [Features](#features) 32 | - [Motivation](#motivation) 33 | - [Vercel vs Us?](#vercel-vs-us) 34 | - [Installation](#installation) 35 | - [Usage](#usage) 36 | - [Supported Features](#supported-features) 37 | - [Supported Frameworks](#supported-frameworks) 38 | - [Limitations](#limitations) 39 | - [CLI Options](#cli-options) 40 | - [Specifying a custom develop command](#specifying-a-custom-develop-command) 41 | - [Debugging your Lambdas](#debugging-your-lambdas) 42 | - [Bonus: Unit Testing your Lambdas](#bonus-unit-testing-your-lambdas) 43 | 44 |

 

45 | 46 | ## Features 47 | 48 | - Optimised for the [Node.js runtime](https://vercel.com/docs/runtimes#official-runtimes/node-js) 49 | - Built against the [Vercel specifications](https://vercel.com/docs/runtimes#official-runtimes/node-js) 50 | - Supports Vercel ["Zero Config"](https://vercel.com/blog/zero-config) approach 51 | - Supports both JavaScript or TypeScript lambdas 52 | - Supports the attaching of a debugger session against lambda execution 53 | - Supports Vercel custom [routes](https://vercel.com/docs/configuration#project/routes) configuration 54 | - Fast reloading for any changes to your lambdas 55 | - Extensively tested and verified via an integration test suite 56 | - Can be run offline 57 | - No need for a Vercel project to be configured prior to execution 58 | - Does not need to be installed as a local dependency 59 | 60 |

 

61 | 62 | ## Motivation 63 | 64 | [Vercel](https://vercel.com/) provides its own development environment via the `vercel dev` command. Whilst this tool is powerful, I was experiencing the following issues: 65 | 66 | - **Performance:** The feedback loop when making changes to my lambdas was very slow. After making a change, the subsequent request to the lamda would result in a significant wait for the lambda to be rebuilt and then served. 67 | - **Reliability:** When making changes to my lambdas the build performance degraded over time, eventually resulting in them becoming unresponsive. The only way I was able to resolve this was to restart the `vercel dev` command, significantly impacting my development flow. 68 | - **Debugging:** I was unable to attach a debugger instance against my lambdas. Whilst I try not to overuse the debugger, preferring to leverage integration tests, there are times when a debugger session can work wonders at issue discovery. 69 | - **Usability:** There was a double hit for me here. Firstly, you need to have first set up / linked your project to a project configuration against Vercel. Secondly, and related to the first point, you need to be connected to the internet in order to use the tool. 70 | 71 | The implementation of `vercel-node-dev` addresses these issues; focussing on the [Node.js runtime](https://vercel.com/docs/runtimes#official-runtimes/node-js), which allowed me to create a much more bespoke and optimised development tool. 72 | 73 |

 

74 | 75 | ## Vercel vs Us? 76 | 77 | This tool is **_not_** meant to be **_competitive_**! 78 | 79 | It is released with good intentions and respect for the Vercel team and community, offering a temporary alternative for those who may be experiencing similar issues with their development flow. 80 | 81 | The `vercel dev` command is still in **beta**. My belief is that the Vercel team will eventually address the issues above, at which point this CLI will be deprecated. Given that we are following the official Vercel specifications, this would be a zero impact move for those using this CLI. You would only need to switch to executing the official `vercel dev` command instead of this one. 82 | 83 |

 

84 | 85 | ## Installation 86 | 87 | We recommend installing this library globally. This will allow you to quickly and easily use it against any of your Vercel applications, without polluting them with additional dependencies. 88 | 89 | ```bash 90 | npm install -g vercel-node-dev 91 | ``` 92 | 93 | After the installation two CLI commands will be available to you, namely; 94 | 95 | - `vercel-node-dev` 96 | - `vnd` - a shorter aliased version 97 | 98 |

 

99 | 100 | ## Usage 101 | 102 | Simply navigate to the root of the [Vercel](https://vercel.com/) application and execute the CLI. 103 | 104 | ```bash 105 | vercel-node-dev 106 | ``` 107 | 108 | Similar to the `vercel dev` command we will attempt to bind the development server against port `3000`. 109 | 110 | Open your web browser and browse to `http://localhost:3000`. 111 | 112 |

 

113 | 114 | ## Supported Features 115 | 116 | Below is a list of the official Vercel features that we support. 117 | 118 | I don't add any additional API features in comparison to the official APIs. 119 | 120 | The design goal of this tool is to support as much of the official API as possible, whilst providing an improved development experience. 121 | 122 | - The ["Zero Config"](https://vercel.com/blog/zero-config) approach promoted by Vercel. 123 | - TypeScript (`.ts`) or JavaScript (`.js`) lambdas, within the `/api` directory, with filesystem based routing. 124 | - The ability to [ignore files/directories](https://vercel.com/docs/v2/serverless-functions/introduction#prevent-endpoint-listing) within your `/api` directory from being bound as lambdas. 125 | - [Path segments](https://vercel.com/docs/v2/serverless-functions/introduction#path-segments) for your lambdas. 126 | - A [custom build step](https://vercel.com/docs/runtimes#advanced-usage/advanced-node-js-usage/custom-build-step-for-node-js), which will be executed prior to the execution of your lambdas. 127 | - Automatic exposure of any environment variables defined within the `.env` file defined in the root of the project. This is inline with the [approach recommended by Vercel](https://vercel.com/docs/v2/build-step#environment-variables) for managing environment variables. 128 | - Automated [extension of the HTTP Request and Response objects](https://vercel.com/docs/runtimes#official-runtimes/node-js/node-js-request-and-response-objects/node-js-helpers) that are passed into your lambdas. 129 | - Custom [routes](https://vercel.com/docs/configuration#project/routes) configuration. 130 | 131 | We follows the design, API, and features of the official [Node.js runtime](https://vercel.com/docs/runtimes#official-runtimes/node-js) as closely as possible. 132 | 133 | The [official docs](https://vercel.com/docs/runtimes#official-runtimes/node-js) and [advanced usage docs](https://vercel.com/docs/runtimes#advanced-usage/advanced-node-js-usage) should be used as your reference for understanding the above features in detail. 134 | 135 | **Todo** 136 | 137 | The following configuration options will be supported in the near future: 138 | 139 | - [cleanUrls](https://vercel.com/docs/configuration#project/cleanurls) 140 | - [trailingSlash](https://vercel.com/docs/configuration#project/trailingslash) 141 | 142 |

 

143 | 144 | ## Supported Frameworks 145 | 146 | I have tested the CLI against the following frameworks, utilising the ["Zero Config"](https://vercel.com/blog/zero-config) approach. 147 | 148 | - [x] Create React App 149 | - [x] Create React App + TypeScript 150 | - [x] Gatsby 151 | - [x] Vue.js 152 | - [ ] Svelte 153 | - [ ] Angular 154 | - [ ] Ember.js 155 | - [ ] Hugo 156 | - [ ] Preact 157 | - [ ] Docusaurus 158 | - [ ] Gridsome 159 | - [ ] Nuxt.js 160 | - [ ] Eleventy 161 | - [ ] Hexo 162 | 163 |

 

164 | 165 | ## Limitations 166 | 167 | Below is a list of the Vercel features that this CLI does not support. These limitations are intentional, and the likelihood is that I will not extend the CLI to support them. 168 | 169 | - Support for any of the other runtimes - i.e. Go, Ruby, Python, or bespoke. 170 | 171 | > I feel like Node.js is where Vercel really shines, and IMO feel like they should have gone all in on Node.js rather than supporting an array of runtimes. 172 | 173 | - Support for any of the following [`vercel.json` configuration](https://vercel.com/docs/configuration#introduction) options: 174 | 175 | - [headers](https://vercel.com/docs/configuration#project/headers) 176 | - [redirects](https://vercel.com/docs/configuration#project/redirects) 177 | - [rewrites](https://vercel.com/docs/configuration#project/rewrites) 178 | 179 | > Despair not! All of these configuration options can be represented via the [routes](https://vercel.com/docs/configuration#project/routes) configuration, which is supported. 180 | 181 | - Support for the raw AWS lambda API, which is currently [supported by the official Node.js runtime](https://vercel.com/docs/runtimes#advanced-usage/advanced-node-js-usage/aws-lambda-api) 182 | 183 | > As stated in their docs, this has been exposed to help clients with existing lambdas migrate over to their platform. I'm not convinced this alternative CLI should support this. 184 | 185 | - The bespoke [Vercel](https://vercel.com/) 4XX / 5XX error pages. 186 | 187 | > Meh. I return the expected error codes. I'd recommend you implement your own custom error pages in your UI anyways. 188 | 189 | - Support for [disabling the Node.js helpers](https://vercel.com/docs/runtimes#advanced-usage/advanced-node-js-usage/disabling-helpers-for-node-js) 190 | 191 | > This has more of an impact on production environments, where you would want to hyper optimise lambda performance in some cases. 192 | 193 | - Support for [Next.js](https://nextjs.org/) projects. 194 | 195 | > Next has it's own development server with bespoke features around the APIs and pages. I would recommend using the `next dev` command instead. 196 | 197 |

 

198 | 199 | ## CLI Options 200 | 201 | The CLI current supports the following options. 202 | 203 | - `--debug-apis` | `-d` 204 | 205 | Setting this flag will enable the debugger against your lambdas. 206 | 207 | - `--debug-apis-port [number]` | `-o [number]` 208 | 209 | Allows you to specify the port at which the debugger for your lambdas will run. By default it runs on port `9229`. 210 | 211 | - `--port [number]` | `-p [number]` 212 | 213 | Allows you to specify the port at which the `vercel-node-dev` server will run. By default it will attempt to bind to port `3000`. 214 | 215 | - `--root [string]` | `-r [string]` 216 | 217 | Allows you to specify the relative path at which your application code (APIs + UI) live. By default it will be `.`. 218 | 219 |

 

220 | 221 | ## Specifying a custom develop command 222 | 223 | If you aren't using one of the [supported frameworks](#supported-frameworks), or if you would like to customise the develop command, then you can add a `dev` script to your `package.json`: 224 | 225 | ```json 226 | { 227 | "scripts": { 228 | "dev": "my-dev-tool -p $PORT" 229 | } 230 | } 231 | ``` 232 | 233 | **Note:** It is important that you pass through the `$PORT` environment variable as shown in the example above. This is so that your development server can be correctly managed by `vercel-node-dev`. 234 | 235 |

 

236 | 237 | ## Debugging your Lambdas 238 | 239 | You can enabled the debugger for your lambdas by providing the respective option to the CLI: 240 | 241 | ```bash 242 | vercel-node-dev --debug-apis 243 | ``` 244 | 245 | This will start the development server, with the debugger instance running. By default the debugger runs on port `9229`. You can change the port number via the `--debug-apis-port` option. 246 | 247 | Once the debugger is running you can attach to it utilising your tool of choice. 248 | 249 | ### Debugging via VSCode 250 | 251 | Below is a set of [`code` debug configurations](https://code.visualstudio.com/docs/editor/debugging), allowing you to either start the `vercel-dev-node` instance with the debugger attached, or to attach to an already executing debugger instance. 252 | 253 | ```jsonc 254 | { 255 | "version": "0.2.0", 256 | "configurations": [ 257 | // This will start vercel-node-dev with the debugger attached 258 | { 259 | "name": "Launch vercel-node-dev", 260 | "type": "node", 261 | "request": "launch", 262 | "program": "vercel-node-dev", 263 | "args": ["--debug-apis"], 264 | "skipFiles": ["/**"], 265 | "autoAttachChildProcesses": true, 266 | "protocol": "inspector" 267 | }, 268 | // First run `vercel-node-dev --debug-apis` and then execute this debug 269 | // configuration in order to attach to the running debugger. 270 | { 271 | "name": "Attach to process", 272 | "type": "node", 273 | "request": "attach" 274 | } 275 | ] 276 | } 277 | ``` 278 | 279 |

 

280 | 281 | ## Bonus: Unit Testing your Lambdas 282 | 283 | This tool leverages another library I built, [`vercel-node-server`](https://github.com/ctrlplusb/vercel-node-server), which allows you to create `http` instances of your lambdas. This opens up opportunities for you to write integration or unit tests for you lambdas. 284 | 285 | Firstly, install the library as development dependency: 286 | 287 | ```bash 288 | npm install -D vercel-node-server 289 | ``` 290 | 291 | You can then write tests for your lambdas similar to the one below: 292 | 293 | ```javascript 294 | import { createServer } from 'vercel-node-server'; 295 | import listen from 'test-listen'; 296 | import axios from 'axios'; 297 | import helloLambda from './api/hello'; 298 | 299 | let server; 300 | let url; 301 | 302 | beforeAll(async () => { 303 | server = createServer(routeUnderTest); 304 | url = await listen(server); 305 | }); 306 | 307 | afterAll(() => { 308 | server.close(); 309 | }); 310 | 311 | it('should return the expected response', async () => { 312 | const response = await axios.get(url, { params: { name: 'Pearl' } }); 313 | expect(response.data).toBe('Hello Pearl'); 314 | }); 315 | ``` 316 | -------------------------------------------------------------------------------- /bin/create-tsconfig.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | 3 | const writeJsonFile = require('write-json-file'); 4 | const fs = require('fs-extra'); 5 | const path = require('path'); 6 | const loadJsonFile = require('load-json-file'); 7 | 8 | async function createTSConfig({ 9 | targetTsConfigPath, 10 | targetOriginalPath, 11 | targetSymlinkPath, 12 | }) { 13 | const vndRootPath = path.resolve(__dirname, '../'); 14 | 15 | const requiredCompilerOptions = { 16 | allowJs: false, 17 | allowSyntheticDefaultImports: true, 18 | alwaysStrict: false, 19 | baseUrl: '.', 20 | emitDecoratorMetadata: true, 21 | esModuleInterop: true, 22 | experimentalDecorators: true, 23 | forceConsistentCasingInFileNames: true, 24 | isolatedModules: true, 25 | jsx: 'react', 26 | lib: ['ESNext'], 27 | module: 'CommonJS', 28 | moduleResolution: 'node', 29 | noEmit: true, 30 | noImplicitAny: false, 31 | noImplicitThis: false, 32 | // Don't actually think we need these anymore, but leaving for now: 33 | paths: { 34 | '@vercel/build-utils': ['node_modules/@vercel/build-utils/dist/index.js'], 35 | '@vercel/node': ['node_modules/@vercel/node/dist/index.js'], 36 | '@vercel/routing-utils': [ 37 | 'node_modules/@vercel/routing-utils/dist/index.js', 38 | ], 39 | 'vercel-node-server': ['node_modules/vercel-node-server/dist/index.js'], 40 | micro: ['node_modules/micro/lib/index.js'], 41 | }, 42 | preserveSymlinks: true, 43 | resolveJsonModule: true, 44 | skipLibCheck: true, 45 | strict: false, 46 | strictNullChecks: false, 47 | strictPropertyInitialization: false, 48 | target: 'ES2018', 49 | }; 50 | 51 | let tsConfig; 52 | 53 | const targetOriginalTsConfig = path.join(targetOriginalPath, 'tsconfig.json'); 54 | 55 | // If the target has a tsconfig, we'll use it to build a customised tsconfig 56 | if (fs.existsSync(targetOriginalTsConfig)) { 57 | const applicationTsConfig = await loadJsonFile(targetOriginalTsConfig); 58 | 59 | tsConfig = { 60 | ...applicationTsConfig, 61 | compilerOptions: { 62 | ...(applicationTsConfig.compilerOptions || {}), 63 | ...requiredCompilerOptions, 64 | lib: applicationTsConfig.compilerOptions.lib || [], 65 | }, 66 | }; 67 | 68 | // The vnd source needs at least ESNext 69 | if ( 70 | !tsConfig.compilerOptions.lib.find((x) => x.match(/^esnext$/i) != null) 71 | ) { 72 | tsConfig.compilerOptions.lib.push('ESNext'); 73 | } 74 | 75 | // Remap the excludes to the nested symlink path 76 | if (Array.isArray(tsConfig.exclude)) { 77 | tsConfig.exclude = tsConfig.exclude.map((x) => 78 | path.relative(vndRootPath, path.join(targetSymlinkPath, x)), 79 | ); 80 | } 81 | 82 | // Remap the includes to the nested symlink path 83 | if (Array.isArray(tsConfig.include)) { 84 | tsConfig.include = tsConfig.include.map((x) => 85 | path.relative(vndRootPath, path.join(targetSymlinkPath, x)), 86 | ); 87 | } else { 88 | tsConfig.include = [targetSymlinkPath]; 89 | } 90 | 91 | tsConfig.include.push('src'); 92 | } else { 93 | tsConfig = { 94 | compilerOptions: requiredCompilerOptions, 95 | }; 96 | } 97 | 98 | await writeJsonFile(targetTsConfigPath, tsConfig); 99 | } 100 | 101 | module.exports = createTSConfig; 102 | -------------------------------------------------------------------------------- /bin/vercel-node-dev: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 'use strict'; 3 | 4 | /* eslint-disable @typescript-eslint/no-var-requires */ 5 | 6 | const meow = require('meow'); 7 | const execa = require('execa'); 8 | const path = require('path'); 9 | const fs = require('fs-extra'); 10 | const createTSConfig = require('./create-tsconfig'); 11 | 12 | const cli = meow( 13 | ` 14 | Usage 15 | $ vercel-node-dev 16 | 17 | Options 18 | --debug-apis -d | Attach a Node.js inspect debugger instance to the APIs 19 | --debug-apis-port -o [number] | Specify the debug port number for API debugger. Default=9229 20 | --dev-command -c [string] | Specify a custom develop command to execute for UI. 21 | --port -p [number] | Specify the port for vercel-node-dev server. Default=3000 22 | --root -r [string] | Specify the root directory for your app src. Default=. 23 | 24 | Examples 25 | $ vercel-node-dev --debug-apis 26 | $ vercel-node-dev -d 27 | $ vercel-node-dev --debug-apis --debug-apis-port 8989 28 | $ vercel-node-dev -d -o 8989 29 | $ vercel-node-dev -p 1337 30 | $ vercel-node-dev -c "react-app-rewired start" 31 | $ vercel-node-dev --root code 32 | `, 33 | { 34 | flags: { 35 | debugApis: { 36 | alias: 'd', 37 | default: false, 38 | isRequired: false, 39 | type: 'boolean', 40 | }, 41 | debugApisPort: { 42 | alias: 'o', 43 | default: 9229, 44 | isRequired: false, 45 | type: 'number', 46 | }, 47 | devCommand: { 48 | alias: 'c', 49 | isRequired: false, 50 | type: 'string', 51 | }, 52 | port: { 53 | alias: 'p', 54 | default: 3000, 55 | isRequired: false, 56 | type: 'number', 57 | }, 58 | root: { 59 | alias: 'r', 60 | default: './', 61 | isRequired: false, 62 | type: 'string', 63 | }, 64 | }, 65 | }, 66 | ); 67 | 68 | (async () => { 69 | const targetOriginalPath = process.cwd(); 70 | const targetName = path.basename(process.cwd()); 71 | const targetTsConfigPath = path.resolve( 72 | __dirname, 73 | `../tsconfig.${targetName}.json`, 74 | ); 75 | const targetSymlinkPath = path.resolve(__dirname, `../targets/${targetName}`); 76 | 77 | await createTSConfig({ 78 | targetTsConfigPath, 79 | targetOriginalPath, 80 | targetSymlinkPath, 81 | }); 82 | 83 | await fs.ensureSymlink(targetOriginalPath, targetSymlinkPath); 84 | 85 | const childProcess = execa( 86 | 'node', 87 | [ 88 | '-r', 89 | 'ts-node/register', 90 | '--preserve-symlinks', 91 | path.resolve(__dirname, '../src'), 92 | ], 93 | { 94 | cwd: path.resolve(__dirname, '../'), 95 | env: { 96 | FORCE_COLOR: '1', 97 | VND_DEBUG_APIS: cli.flags.debugApis ? '1' : undefined, 98 | VND_DEBUG_APIS_PORT: cli.flags.debugApisPort, 99 | VND_DEV_COMMAND: cli.flags.devCommand, 100 | VND_PORT: cli.flags.port, 101 | VND_TARGET_ORIGINAL_PATH: targetOriginalPath, 102 | VND_TARGET_NAME: targetName, 103 | VND_TARGET_TS_CONFIG_PATH: targetTsConfigPath, 104 | VND_TARGET_SYM_LINK_PATH: targetSymlinkPath, 105 | VND_TARGET_ROOT_PATH: cli.flags.root, 106 | 107 | // env vars for ts-node 108 | TS_NODE_PROJECT: targetTsConfigPath, 109 | TS_NODE_TRANSPILE_ONLY: 'true', 110 | }, 111 | stdio: 'inherit', 112 | }, 113 | ); 114 | 115 | childProcess.catch((err) => { 116 | console.error(err); 117 | }); 118 | 119 | const dispose = (signal) => { 120 | if (childProcess) { 121 | childProcess.kill(signal); 122 | } 123 | }; 124 | process.on('SIGINT', dispose); 125 | process.on('SIGTERM', dispose); 126 | })(); 127 | -------------------------------------------------------------------------------- /docs/testing-vercel-json-routes.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "version": 2, 3 | "routes": [ 4 | // 🔥 DOES NOT WORK! 5 | // 🐛 THE DOCS CLAIM THIS SHOULD RESTRICT THE FUNCTION TO ONLY RECEIVE 6 | // GET OR POST REQUESTS 7 | { 8 | "src": "/api/articles/restricted.js", 9 | "methods": ["POST", "GET"], 10 | "dest": "/api/articles/restricted.js" 11 | }, 12 | // 🔥 DOES NOT WORK! 13 | { "src": "/article-api-no-ext", "dest": "/api/articles/12345" }, 14 | // 🔥 DOES NOT WORK! 15 | { "src": "/article-api-ext", "dest": "/api/articles/12345.js" }, 16 | // ✅ WORKS. "id" is undefined 17 | { "src": "/article-api", "dest": "/api/articles/[id].js" }, 18 | // ✅ WORKS 19 | { "src": "/root-api-no-leading", "dest": "api/index.js" }, 20 | // 🔥 DOES NOT WORK 21 | { "src": "no-leading-root-api", "dest": "/api/index.js" }, 22 | // 🔥 DOES NOT WORK 23 | { "src": "/bob-no-ext", "dest": "/foo" }, 24 | // ✅ WORKS 25 | { "src": "/bob-no-leading", "dest": "foo.html" }, 26 | // 🔥 DOES NOT WORK! 27 | { "src": "no-leading-bob", "dest": "/foo.html" } 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | testMatch: ['**/test/*.test.(t|j)s'], 5 | }; 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vercel-node-dev", 3 | "version": "0.7.1", 4 | "description": "An unofficial development CLI for Vercel applications targeting the Node.js runtime.", 5 | "license": "MIT", 6 | "author": "Sean Matheson", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/ctrlplusb/vercel-node-dev.git" 10 | }, 11 | "bin": { 12 | "vercel-node-dev": "./bin/vercel-node-dev", 13 | "vnd": "./bin/vercel-node-dev" 14 | }, 15 | "files": [ 16 | "bin", 17 | "src", 18 | "tsconfig.json" 19 | ], 20 | "engines": { 21 | "node": ">=10" 22 | }, 23 | "scripts": { 24 | "test": "jest", 25 | "experiment": "FORCE_COLOR=1 VND_TARGET_PATH=targets/finished node -r ts-node/register --preserve-symlinks src" 26 | }, 27 | "dependencies": { 28 | "@vercel/build-utils": "^2.4.1", 29 | "@vercel/frameworks": "^0.0.16", 30 | "@vercel/node": "^1.7.2", 31 | "@vercel/routing-utils": "^1.8.3", 32 | "chalk": "^4.1.0", 33 | "dedent": "^0.7.0", 34 | "dotenv": "^8.2.0", 35 | "execa": "^4.0.3", 36 | "fs-extra": "^9.0.1", 37 | "get-port": "^5.1.1", 38 | "globby": "^11.0.1", 39 | "http-proxy": "^1.18.1", 40 | "load-json-file": "^6.2.0", 41 | "meow": "^7.0.1", 42 | "npm-run": "^5.0.1", 43 | "p-timeout": "^3.2.0", 44 | "pretty-format": "^26.1.0", 45 | "strip-color": "^0.1.0", 46 | "tempy": "^0.5.0", 47 | "ts-node-dev": "^1.0.0-pre.51", 48 | "typescript": "^3.9.6", 49 | "vercel-node-server": "^2.2.1", 50 | "wait-port": "^0.2.9", 51 | "write-json-file": "^4.3.0" 52 | }, 53 | "devDependencies": { 54 | "@types/dedent": "^0.7.0", 55 | "@types/fs-extra": "^9.0.1", 56 | "@types/http-proxy": "^1.17.4", 57 | "@types/jest": "^26.0.4", 58 | "@types/node": "^14.0.23", 59 | "@types/strip-color": "^0.1.0", 60 | "@typescript-eslint/eslint-plugin": "^3.6.1", 61 | "@typescript-eslint/parser": "^3.6.1", 62 | "axios": "^0.19.2", 63 | "babel-eslint": "^10.1.0", 64 | "eslint": "^7.4.0", 65 | "eslint-config-prettier": "^6.11.0", 66 | "eslint-config-react-app": "^5.2.1", 67 | "eslint-plugin-flowtype": "^5.2.0", 68 | "eslint-plugin-import": "^2.22.0", 69 | "eslint-plugin-jsx-a11y": "^6.3.1", 70 | "eslint-plugin-prettier": "^3.1.4", 71 | "eslint-plugin-react": "^7.20.3", 72 | "eslint-plugin-react-hooks": "^4.0.8", 73 | "husky": "^4.2.5", 74 | "jest": "^26.1.0", 75 | "prettier": "^2.0.5", 76 | "ts-jest": "^26.1.2" 77 | }, 78 | "prettier": { 79 | "semi": true, 80 | "singleQuote": true, 81 | "trailingComma": "all" 82 | }, 83 | "eslintConfig": { 84 | "root": true, 85 | "parser": "@typescript-eslint/parser", 86 | "extends": [ 87 | "react-app", 88 | "plugin:@typescript-eslint/recommended", 89 | "prettier", 90 | "prettier/@typescript-eslint" 91 | ], 92 | "plugins": [ 93 | "@typescript-eslint" 94 | ], 95 | "env": { 96 | "commonjs": true, 97 | "es6": true, 98 | "node": true, 99 | "jest": true 100 | }, 101 | "rules": { 102 | "@typescript-eslint/explicit-function-return-type": 0 103 | } 104 | }, 105 | "eslintIgnore": [ 106 | "**/node_modules/*" 107 | ] 108 | } 109 | -------------------------------------------------------------------------------- /src/child-processes/start-api-dev-process.ts: -------------------------------------------------------------------------------- 1 | import execa from 'execa'; 2 | import path from 'path'; 3 | import { Context } from '../get-context'; 4 | import { Ports } from '../ports'; 5 | import * as lib from '../lib'; 6 | 7 | export default async function startAPIDevProcess( 8 | context: Context, 9 | ports: Ports, 10 | ): Promise<{ childProcess: execa.ExecaChildProcess }> { 11 | // Start the api-server via ts-node-dev in order to support auto reloading 12 | // on any code changes to the APIs 13 | const childProcess = execa( 14 | 'ts-node-dev', 15 | [ 16 | '--respawn', 17 | '--transpile-only', 18 | '--watch', 19 | [ 20 | path.join(context.targetSymlinkCodePath, 'api'), 21 | path.join(context.targetSymlinkPath, 'vercel.json'), 22 | path.join(context.targetSymlinkCodePath, 'package.json'), 23 | path.join(context.targetSymlinkCodePath, 'tsconfig.json'), 24 | path.join(context.targetSymlinkPath, 'package.json'), 25 | path.join(context.targetSymlinkPath, 'tsconfig.json'), 26 | ] 27 | .map((x) => `"${x}"`) 28 | .join(','), 29 | '--project', 30 | context.targetTsConfigPath, 31 | context.debugApis ? `--inspect=${context.debugApisPort}` : '', 32 | '--preserve-symlinks', 33 | '--', 34 | 'src/servers/start-api-server', 35 | ].filter(Boolean), 36 | { 37 | cwd: process.cwd(), 38 | env: { 39 | ...context.env, 40 | PORT: ports.apiServer.toString(), 41 | }, 42 | stdio: 'inherit', 43 | preferLocal: true, 44 | }, 45 | ); 46 | 47 | childProcess.catch((err) => { 48 | lib.log('API Server failed', err); 49 | }); 50 | 51 | return { childProcess }; 52 | } 53 | -------------------------------------------------------------------------------- /src/child-processes/start-ui-dev-process.ts: -------------------------------------------------------------------------------- 1 | import execa from 'execa'; 2 | import { Context } from '../get-context'; 3 | import * as lib from '../lib'; 4 | import { Ports } from '../ports'; 5 | 6 | export default function startUIDevProcess( 7 | context: Context, 8 | ports: Ports, 9 | ): { childProcess: execa.ExecaChildProcess } { 10 | // Resolve the development command 11 | const devCommand = 12 | context.devCommand || 13 | context.packageJson?.scripts?.dev || 14 | context.framework?.devCommand; 15 | if (devCommand == null) { 16 | throw new Error( 17 | '[vercel-node-dev] No develop command could be resolved for your project. Please specify one via the "dev" script.', 18 | ); 19 | } 20 | lib.log('Using dev command:', devCommand); 21 | 22 | // Run the UI script 23 | const childProcess = execa.command(devCommand, { 24 | cwd: context.targetSymlinkCodePath, 25 | env: { 26 | ...context.env, 27 | PORT: ports.uiServer.toString(), 28 | SKIP_PREFLIGHT_CHECK: 'true', 29 | BROWSER: 'none', 30 | FORCE_COLOR: 'true', 31 | }, 32 | shell: true, 33 | preferLocal: true, 34 | }); 35 | 36 | childProcess.catch((err) => { 37 | lib.log('Failed to run develop command for UI', err); 38 | }); 39 | 40 | if (process.env.VND_SILENT_UI == null) { 41 | childProcess.stdout?.on('data', (data: Buffer) => { 42 | let msg = String(data) 43 | // Remove any "clear screen" codes 44 | .replace(/\\033\[2J/g, '') 45 | // Remove unnecessary empty lines 46 | .replace(/\n$/, ''); 47 | 48 | if (!context.debug) { 49 | // Replace ui server port with our proxy server port 50 | msg = msg.replace( 51 | new RegExp(ports.uiServer.toString(), 'g'), 52 | `${ports.proxyServer}`, 53 | ); 54 | } 55 | 56 | console.log(msg); 57 | }); 58 | 59 | childProcess.stderr?.pipe(process.stderr); 60 | } 61 | 62 | return { childProcess }; 63 | } 64 | -------------------------------------------------------------------------------- /src/frameworks/frameworks.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file has been extract from the official vercel repo. 3 | * 4 | * packages/now-static-build/src/frameworks.ts 5 | */ 6 | 7 | import { readdir, stat, readFile, unlink } from 'fs'; 8 | import { promisify } from 'util'; 9 | import { join } from 'path'; 10 | import { readConfigFile } from '@vercel/build-utils'; 11 | import { Route } from '@vercel/routing-utils'; 12 | import NowFrameworks, { 13 | Framework as NowFramework, 14 | SettingValue, 15 | } from '@vercel/frameworks'; 16 | 17 | const readirPromise = promisify(readdir); 18 | const readFilePromise = promisify(readFile); 19 | const statPromise = promisify(stat); 20 | const unlinkPromise = promisify(unlink); 21 | const isDir = async (file: string): Promise => 22 | (await statPromise(file)).isDirectory(); 23 | 24 | export interface Framework { 25 | name: string; 26 | slug: string; 27 | dependency?: string; 28 | getOutputDirName: (dirPrefix: string) => Promise; 29 | defaultRoutes?: Route[] | ((dirPrefix: string) => Promise); 30 | cachePattern?: string; 31 | buildCommand?: string; 32 | devCommand?: string; 33 | } 34 | 35 | // Please note that is extremely important 36 | // that the `dependency` property needs 37 | // to reference a CLI. This is needed because 38 | // you might want (for example) a Gatsby 39 | // site that is powered by Preact, so you 40 | // can't look for the `preact` dependency. 41 | // Instead, you need to look for `preact-cli` 42 | // when optimizing Preact CLI projects. 43 | 44 | const frameworkList: Framework[] = [ 45 | { 46 | name: 'Gatsby.js', 47 | slug: 'gatsby', 48 | dependency: 'gatsby', 49 | buildCommand: 'gatsby build', 50 | getOutputDirName: async () => 'public', 51 | defaultRoutes: async (dirPrefix: string) => { 52 | try { 53 | const nowRoutesPath = join( 54 | dirPrefix, 55 | 'public', 56 | '__now_routes_g4t5bY.json', 57 | ); 58 | const content = await readFilePromise(nowRoutesPath, 'utf8'); 59 | const nowRoutes = JSON.parse(content); 60 | try { 61 | await unlinkPromise(nowRoutesPath); 62 | } catch (err) { 63 | // do nothing if deleting the file fails 64 | } 65 | return nowRoutes; 66 | } catch (err) { 67 | // if the file doesn't exist, we don't create routes 68 | return []; 69 | } 70 | }, 71 | cachePattern: '{.cache,public}/**', 72 | }, 73 | { 74 | name: 'Hexo', 75 | slug: 'hexo', 76 | dependency: 'hexo', 77 | buildCommand: 'hexo generate', 78 | getOutputDirName: async () => 'public', 79 | }, 80 | { 81 | name: 'Eleventy', 82 | slug: 'eleventy', 83 | dependency: '@11ty/eleventy', 84 | buildCommand: 'npx @11ty/eleventy', 85 | getOutputDirName: async () => '_site', 86 | }, 87 | { 88 | name: 'Docusaurus 2', 89 | slug: 'docusaurus-2', 90 | dependency: '@docusaurus/core', 91 | buildCommand: 'docusaurus build', 92 | getOutputDirName: async (dirPrefix: string) => { 93 | const base = 'build'; 94 | const location = join(dirPrefix, base); 95 | const content = await readirPromise(location); 96 | 97 | // If there is only one file in it that is a dir we'll use it as dist dir 98 | if (content.length === 1 && (await isDir(join(location, content[0])))) { 99 | return join(base, content[0]); 100 | } 101 | 102 | return base; 103 | }, 104 | defaultRoutes: [ 105 | { 106 | src: '^/[^./]+\\.[0-9a-f]{8}\\.(css|js)', 107 | headers: { 'cache-control': 'max-age=31536000, immutable' }, 108 | continue: true, 109 | }, 110 | { 111 | handle: 'filesystem', 112 | }, 113 | { 114 | src: '.*', 115 | status: 404, 116 | dest: '404.html', 117 | }, 118 | ], 119 | }, 120 | { 121 | name: 'Preact', 122 | slug: 'preact', 123 | dependency: 'preact-cli', 124 | buildCommand: 'preact build', 125 | getOutputDirName: async () => 'build', 126 | defaultRoutes: [ 127 | { 128 | handle: 'filesystem', 129 | }, 130 | { 131 | src: '/(.*)', 132 | dest: '/index.html', 133 | }, 134 | ], 135 | }, 136 | { 137 | name: 'Dojo', 138 | slug: 'dojo', 139 | dependency: '@dojo/cli', 140 | buildCommand: 'dojo build', 141 | getOutputDirName: async () => join('output', 'dist'), 142 | defaultRoutes: [ 143 | { 144 | handle: 'filesystem', 145 | }, 146 | { 147 | src: '/service-worker.js', 148 | headers: { 'cache-control': 's-maxage=0' }, 149 | continue: true, 150 | }, 151 | { 152 | src: '/(.*)', 153 | dest: '/index.html', 154 | }, 155 | ], 156 | }, 157 | { 158 | name: 'Ember', 159 | slug: 'ember', 160 | dependency: 'ember-cli', 161 | buildCommand: 'ember build', 162 | getOutputDirName: async () => 'dist', 163 | defaultRoutes: [ 164 | { 165 | handle: 'filesystem', 166 | }, 167 | { 168 | src: '/(.*)', 169 | dest: '/index.html', 170 | }, 171 | ], 172 | }, 173 | { 174 | name: 'Vue.js', 175 | slug: 'vue', 176 | dependency: '@vue/cli-service', 177 | buildCommand: 'vue-cli-service build', 178 | getOutputDirName: async () => 'dist', 179 | defaultRoutes: [ 180 | { 181 | src: '^/[^/]*\\.(js|txt|ico|json)', 182 | headers: { 'cache-control': 'max-age=300' }, 183 | continue: true, 184 | }, 185 | { 186 | src: '^/(img|js|css|fonts|media)/.*', 187 | headers: { 'cache-control': 'max-age=31536000, immutable' }, 188 | continue: true, 189 | }, 190 | { 191 | handle: 'filesystem', 192 | }, 193 | { 194 | src: '^.*', 195 | dest: '/index.html', 196 | }, 197 | ], 198 | }, 199 | { 200 | name: 'Scully', 201 | slug: 'scully', 202 | dependency: '@scullyio/init', 203 | buildCommand: 'ng build && scully', 204 | getOutputDirName: async () => 'dist/static', 205 | }, 206 | { 207 | name: 'Ionic Angular', 208 | slug: 'ionic-angular', 209 | dependency: '@ionic/angular', 210 | buildCommand: 'ng build', 211 | getOutputDirName: async () => 'www', 212 | defaultRoutes: [ 213 | { 214 | handle: 'filesystem', 215 | }, 216 | { 217 | src: '/(.*)', 218 | dest: '/index.html', 219 | }, 220 | ], 221 | }, 222 | { 223 | name: 'Angular', 224 | slug: 'angular', 225 | dependency: '@angular/cli', 226 | buildCommand: 'ng build', 227 | getOutputDirName: async (dirPrefix: string) => { 228 | const base = 'dist'; 229 | const location = join(dirPrefix, base); 230 | const content = await readirPromise(location); 231 | 232 | // If there is only one file in it that is a dir we'll use it as dist dir 233 | if (content.length === 1 && (await isDir(join(location, content[0])))) { 234 | return join(base, content[0]); 235 | } 236 | 237 | return base; 238 | }, 239 | defaultRoutes: [ 240 | { 241 | handle: 'filesystem', 242 | }, 243 | { 244 | src: '/(.*)', 245 | dest: '/index.html', 246 | }, 247 | ], 248 | }, 249 | { 250 | name: 'Polymer', 251 | slug: 'polymer', 252 | dependency: 'polymer-cli', 253 | buildCommand: 'polymer build', 254 | getOutputDirName: async (dirPrefix: string) => { 255 | const base = 'build'; 256 | const location = join(dirPrefix, base); 257 | const content = await readirPromise(location); 258 | const paths = content.filter((item) => !item.includes('.')); 259 | 260 | return join(base, paths[0]); 261 | }, 262 | defaultRoutes: [ 263 | { 264 | handle: 'filesystem', 265 | }, 266 | { 267 | src: '/(.*)', 268 | dest: '/index.html', 269 | }, 270 | ], 271 | }, 272 | { 273 | name: 'Svelte', 274 | slug: 'svelte', 275 | dependency: 'sirv-cli', 276 | buildCommand: 'rollup -c', 277 | getOutputDirName: async () => 'public', 278 | defaultRoutes: [ 279 | { 280 | handle: 'filesystem', 281 | }, 282 | { 283 | src: '/(.*)', 284 | dest: '/index.html', 285 | }, 286 | ], 287 | }, 288 | { 289 | name: 'Ionic React', 290 | slug: 'ionic-react', 291 | dependency: '@ionic/react', 292 | buildCommand: 'react-scripts build', 293 | getOutputDirName: async () => 'build', 294 | defaultRoutes: [ 295 | { 296 | src: '/static/(.*)', 297 | headers: { 'cache-control': 's-maxage=31536000, immutable' }, 298 | continue: true, 299 | }, 300 | { 301 | src: '/service-worker.js', 302 | headers: { 'cache-control': 's-maxage=0' }, 303 | continue: true, 304 | }, 305 | { 306 | src: '/sockjs-node/(.*)', 307 | dest: '/sockjs-node/$1', 308 | }, 309 | { 310 | handle: 'filesystem', 311 | }, 312 | { 313 | src: '/(.*)', 314 | headers: { 'cache-control': 's-maxage=0' }, 315 | dest: '/index.html', 316 | }, 317 | ], 318 | }, 319 | { 320 | name: 'Create React App', 321 | slug: 'create-react-app', 322 | dependency: 'react-scripts', 323 | buildCommand: 'react-scripts build', 324 | getOutputDirName: async () => 'build', 325 | defaultRoutes: [ 326 | { 327 | src: '/static/(.*)', 328 | headers: { 'cache-control': 's-maxage=31536000, immutable' }, 329 | continue: true, 330 | }, 331 | { 332 | src: '/service-worker.js', 333 | headers: { 'cache-control': 's-maxage=0' }, 334 | continue: true, 335 | }, 336 | { 337 | src: '/sockjs-node/(.*)', 338 | dest: '/sockjs-node/$1', 339 | }, 340 | { 341 | handle: 'filesystem', 342 | }, 343 | { 344 | src: '/(.*)', 345 | headers: { 'cache-control': 's-maxage=0' }, 346 | dest: '/index.html', 347 | }, 348 | ], 349 | }, 350 | { 351 | name: 'Create React App (ejected)', 352 | slug: 'create-react-app', 353 | dependency: 'react-dev-utils', 354 | buildCommand: 'react-scripts build', 355 | getOutputDirName: async () => 'build', 356 | defaultRoutes: [ 357 | { 358 | src: '/static/(.*)', 359 | headers: { 'cache-control': 's-maxage=31536000, immutable' }, 360 | continue: true, 361 | }, 362 | { 363 | src: '/service-worker.js', 364 | headers: { 'cache-control': 's-maxage=0' }, 365 | continue: true, 366 | }, 367 | { 368 | src: '/sockjs-node/(.*)', 369 | dest: '/sockjs-node/$1', 370 | }, 371 | { 372 | handle: 'filesystem', 373 | }, 374 | { 375 | src: '/(.*)', 376 | headers: { 'cache-control': 's-maxage=0' }, 377 | dest: '/index.html', 378 | }, 379 | ], 380 | }, 381 | { 382 | name: 'Gridsome', 383 | slug: 'gridsome', 384 | dependency: 'gridsome', 385 | buildCommand: 'gridsome build', 386 | getOutputDirName: async () => 'dist', 387 | }, 388 | { 389 | name: 'UmiJS', 390 | slug: 'umijs', 391 | dependency: 'umi', 392 | buildCommand: 'umi build', 393 | getOutputDirName: async () => 'dist', 394 | defaultRoutes: [ 395 | { 396 | handle: 'filesystem', 397 | }, 398 | { 399 | src: '/(.*)', 400 | dest: '/index.html', 401 | }, 402 | ], 403 | }, 404 | { 405 | name: 'Docusaurus 1.0', 406 | slug: 'docusaurus', 407 | dependency: 'docusaurus', 408 | buildCommand: 'docusaurus-build', 409 | getOutputDirName: async (dirPrefix: string) => { 410 | const base = 'build'; 411 | const location = join(dirPrefix, base); 412 | const content = await readirPromise(location); 413 | 414 | // If there is only one file in it that is a dir we'll use it as dist dir 415 | if (content.length === 1 && (await isDir(join(location, content[0])))) { 416 | return join(base, content[0]); 417 | } 418 | 419 | return base; 420 | }, 421 | }, 422 | { 423 | name: 'Sapper', 424 | slug: 'sapper', 425 | dependency: 'sapper', 426 | buildCommand: 'sapper export', 427 | getOutputDirName: async () => '__sapper__/export', 428 | }, 429 | { 430 | name: 'Saber', 431 | slug: 'saber', 432 | dependency: 'saber', 433 | buildCommand: 'saber build', 434 | getOutputDirName: async () => 'public', 435 | defaultRoutes: [ 436 | { 437 | src: '/_saber/.*', 438 | headers: { 'cache-control': 'max-age=31536000, immutable' }, 439 | }, 440 | { 441 | handle: 'filesystem', 442 | }, 443 | { 444 | src: '.*', 445 | status: 404, 446 | dest: '404.html', 447 | }, 448 | ], 449 | }, 450 | { 451 | name: 'Stencil', 452 | slug: 'stencil', 453 | dependency: '@stencil/core', 454 | buildCommand: 'stencil build', 455 | getOutputDirName: async () => 'www', 456 | defaultRoutes: [ 457 | { 458 | src: '/assets/(.*)', 459 | headers: { 'cache-control': 'max-age=2592000' }, 460 | continue: true, 461 | }, 462 | { 463 | src: '/build/p-.*', 464 | headers: { 'cache-control': 'max-age=31536000, immutable' }, 465 | continue: true, 466 | }, 467 | { 468 | src: '/sw.js', 469 | headers: { 'cache-control': 'no-cache' }, 470 | continue: true, 471 | }, 472 | { 473 | handle: 'filesystem', 474 | }, 475 | { 476 | src: '/(.*)', 477 | dest: '/index.html', 478 | }, 479 | ], 480 | }, 481 | { 482 | name: 'Nuxt.js', 483 | slug: 'nuxtjs', 484 | dependency: 'nuxt', 485 | buildCommand: 'nuxt generate', 486 | getOutputDirName: async () => 'dist', 487 | }, 488 | { 489 | name: 'Hugo', 490 | slug: 'hugo', 491 | buildCommand: 'hugo -D --gc', 492 | getOutputDirName: async (dirPrefix: string): Promise => { 493 | const config = await readConfigFile( 494 | ['config.json', 'config.yaml', 'config.toml'].map((fileName) => { 495 | return join(dirPrefix, fileName); 496 | }), 497 | ); 498 | 499 | return (config && config.publishDir) || 'public'; 500 | }, 501 | }, 502 | { 503 | name: 'Jekyll', 504 | slug: 'jekyll', 505 | buildCommand: 'jekyll build', 506 | getOutputDirName: async (dirPrefix: string): Promise => { 507 | const config = await readConfigFile(join(dirPrefix, '_config.yml')); 508 | return (config && config.destination) || '_site'; 509 | }, 510 | }, 511 | { 512 | name: 'Brunch', 513 | slug: 'brunch', 514 | buildCommand: 'brunch build --production', 515 | getOutputDirName: async () => 'public', 516 | }, 517 | { 518 | name: 'Middleman', 519 | slug: 'middleman', 520 | buildCommand: 'bundle exec middleman build', 521 | getOutputDirName: async () => 'build', 522 | }, 523 | { 524 | name: 'Zola', 525 | slug: 'zola', 526 | buildCommand: 'zola build', 527 | getOutputDirName: async () => 'public', 528 | }, 529 | ]; 530 | 531 | function getValue( 532 | framework: NowFramework | undefined, 533 | name: keyof NowFramework['settings'], 534 | ) { 535 | const setting = framework && framework.settings && framework.settings[name]; 536 | return setting && (setting as SettingValue).value; 537 | } 538 | 539 | export const frameworks: Framework[] = frameworkList.map((partialFramework) => { 540 | const frameworkItem = (NowFrameworks as NowFramework[]).find( 541 | (f) => f.slug === partialFramework.slug, 542 | ); 543 | 544 | const devCommand = getValue(frameworkItem, 'devCommand'); 545 | const buildCommand = getValue(frameworkItem, 'buildCommand'); 546 | const outputDirectory = getValue(frameworkItem, 'outputDirectory'); 547 | 548 | const getOutputDirName = partialFramework.getOutputDirName 549 | ? partialFramework.getOutputDirName 550 | : async () => outputDirectory || 'public'; 551 | 552 | return { 553 | devCommand, 554 | buildCommand, 555 | ...partialFramework, 556 | getOutputDirName, 557 | }; 558 | }); 559 | -------------------------------------------------------------------------------- /src/frameworks/get-framework.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file has been extract from the official vercel repo. 3 | * 4 | * packages/now-static-build/src/index.ts 5 | */ 6 | 7 | import { PackageJson } from '@vercel/build-utils'; 8 | import { frameworks, Framework } from './frameworks'; 9 | 10 | export function getFramework(pkg: PackageJson): Framework | undefined { 11 | const dependencies = Object.assign({}, pkg.dependencies, pkg.devDependencies); 12 | const framework = frameworks.find( 13 | ({ dependency }) => dependencies[dependency || ''], 14 | ); 15 | return framework; 16 | } 17 | -------------------------------------------------------------------------------- /src/get-context.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import dotenv from 'dotenv'; 3 | import fs from 'fs-extra'; 4 | import { normalizeRoutes, Route } from '@vercel/routing-utils'; 5 | import { PackageJson } from '@vercel/build-utils/dist'; 6 | import loadJsonFile from 'load-json-file'; 7 | import { getFramework } from './frameworks/get-framework'; 8 | import { Framework } from './frameworks/frameworks'; 9 | import * as lib from './lib'; 10 | 11 | export interface Context { 12 | debug: boolean; 13 | debugApis: boolean; 14 | debugApisPort: number; 15 | devCommand?: string; 16 | env: { [key: string]: string }; 17 | framework?: Framework; 18 | packageJson?: PackageJson; 19 | routes?: Route[]; 20 | targetOriginalPath: string; 21 | targetName: string; 22 | targetRootPath: string; 23 | targetSymlinkPath: string; 24 | targetSymlinkCodePath: string; 25 | targetTsConfigPath: string; 26 | } 27 | 28 | export interface Ports { 29 | apiServer: number; 30 | proxyServer: number; 31 | uiServer: number; 32 | } 33 | 34 | export interface VercelConfig { 35 | routes: Route[]; 36 | } 37 | 38 | function getEnv(target: string): { [key: string]: string } { 39 | const envFilePath = path.join(target, '.env'); 40 | const dotEnvLoadResult = fs.existsSync(envFilePath) 41 | ? dotenv.parse(fs.readFileSync(envFilePath, { encoding: 'utf-8' })) 42 | : undefined; 43 | return { 44 | ...(dotEnvLoadResult != null ? dotEnvLoadResult : {}), 45 | NODE_ENV: 'development', 46 | }; 47 | } 48 | 49 | async function tryGetVercelConfig( 50 | target: string, 51 | ): Promise { 52 | const vercelJsonPath = path.join(target, 'vercel.json'); 53 | if (fs.existsSync(vercelJsonPath)) { 54 | const result = await loadJsonFile(vercelJsonPath); 55 | return (result as unknown) as VercelConfig; 56 | } 57 | return undefined; 58 | } 59 | 60 | const ensureEnv = (s?: string): string => { 61 | if (s == null) { 62 | throw new Error('Expected env var to exist'); 63 | } 64 | return s; 65 | }; 66 | 67 | export default async function getContext(): Promise { 68 | const targetOriginalPath = ensureEnv(process.env.VND_TARGET_ORIGINAL_PATH); 69 | const targetName = ensureEnv(process.env.VND_TARGET_NAME); 70 | const targetTsConfigPath = ensureEnv(process.env.VND_TARGET_TS_CONFIG_PATH); 71 | const targetSymlinkPath = ensureEnv(process.env.VND_TARGET_SYM_LINK_PATH); 72 | const targetRootPath = ensureEnv(process.env.VND_TARGET_ROOT_PATH); 73 | 74 | const packageJsonPath = path.join(targetOriginalPath, 'package.json'); 75 | 76 | const vercelConfig = await tryGetVercelConfig(targetOriginalPath); 77 | 78 | const context: Context = { 79 | debug: process.env.VND_DEBUG != null, 80 | debugApis: process.env.VND_DEBUG_APIS != null, 81 | debugApisPort: process.env.VND_DEBUG_APIS_PORT 82 | ? parseInt(process.env.VND_DEBUG_APIS_PORT) 83 | : 9292, 84 | devCommand: process.env.VND_DEV_COMMAND, 85 | env: getEnv(targetOriginalPath), 86 | targetOriginalPath, 87 | targetName, 88 | targetTsConfigPath, 89 | targetRootPath, 90 | targetSymlinkPath, 91 | targetSymlinkCodePath: path.resolve(targetSymlinkPath, targetRootPath), 92 | packageJson: fs.existsSync(packageJsonPath) 93 | ? ((await loadJsonFile(packageJsonPath)) as PackageJson) 94 | : undefined, 95 | }; 96 | 97 | if (context.packageJson) { 98 | context.framework = getFramework(context.packageJson); 99 | if (context.framework) { 100 | lib.debug(`Identified framework as ${context.framework.name}`); 101 | } 102 | } 103 | 104 | const { error, routes } = normalizeRoutes(vercelConfig?.routes || []); 105 | 106 | if (error) { 107 | throw error; 108 | } 109 | 110 | context.routes = routes || []; 111 | 112 | lib.debug('Context', context); 113 | 114 | return context; 115 | } 116 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra'; 2 | import path from 'path'; 3 | import execa from 'execa'; 4 | import { Server } from 'http'; 5 | import util from 'util'; 6 | import * as lib from './lib'; 7 | import startAPIDevProcess from './child-processes/start-api-dev-process'; 8 | import startUIDevProcess from './child-processes/start-ui-dev-process'; 9 | import startProxyServer from './servers/start-proxy-server'; 10 | import getContext from './get-context'; 11 | import { getPorts } from './ports'; 12 | 13 | let apiChildProcess: execa.ExecaChildProcess; 14 | let uiChildProcess: execa.ExecaChildProcess; 15 | let proxyServer: Server; 16 | 17 | export async function startVercelNodeDev(): Promise { 18 | const context = await getContext(); 19 | const ports = await getPorts(); 20 | 21 | // Fire up the API development process 22 | if (fs.existsSync(path.join(context.targetSymlinkCodePath, 'api'))) { 23 | const apiResult = await startAPIDevProcess(context, ports); 24 | apiChildProcess = apiResult.childProcess; 25 | await lib.waitForPortToRespond(ports.apiServer, 60 * 1000); 26 | } else { 27 | lib.debug('No api directory exists, skipping API server'); 28 | } 29 | 30 | // Fire up the UI development process 31 | const uiResult = startUIDevProcess(context, ports); 32 | uiChildProcess = uiResult.childProcess; 33 | await lib.waitForPortToRespond(ports.uiServer, 60 * 1000); 34 | 35 | // Fire up the proxy server, which will proxy requests to the API/UI servers 36 | startProxyServer(context, ports); 37 | } 38 | 39 | lib.bindProcessTermination(async (signal: 'SIGINT' | 'SIGTERM') => { 40 | if (uiChildProcess) { 41 | uiChildProcess.kill(signal); 42 | } 43 | if (apiChildProcess) { 44 | apiChildProcess.kill(signal); 45 | } 46 | if (proxyServer) { 47 | const asyncClose = util.promisify(proxyServer.close); 48 | await asyncClose(); 49 | } 50 | }); 51 | 52 | startVercelNodeDev(); 53 | -------------------------------------------------------------------------------- /src/lib.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import prettyFormat from 'pretty-format'; 3 | import pTimeout, { TimeoutError } from 'p-timeout'; 4 | import waitForPort from 'wait-port'; 5 | 6 | export function bindProcessTermination( 7 | onTerminating: (signal: 'SIGTERM' | 'SIGINT') => void | Promise, 8 | ): void { 9 | let disposing = false; 10 | const disposer = (sig: 'SIGTERM' | 'SIGINT') => async () => { 11 | if (disposing) { 12 | return; 13 | } 14 | debug('Disposing server'); 15 | disposing = true; 16 | try { 17 | await Promise.resolve(onTerminating(sig)); 18 | debug('Exiting server'); 19 | } finally { 20 | process.exit(0); 21 | } 22 | }; 23 | 24 | process.on('SIGTERM', disposer('SIGTERM')); 25 | process.on('SIGINT', disposer('SIGINT')); 26 | } 27 | 28 | // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types 29 | export function debug(msg: string, data?: any): void { 30 | if (process.env.VND_DEBUG) { 31 | console.log(`${chalk.bgBlue.white('[vercel-node-dev]')} - ${msg}`); 32 | if (data !== undefined) { 33 | console.log(prettyFormat(data)); 34 | } 35 | } 36 | } 37 | 38 | export function escapeRegex(str: string): string { 39 | // https://stackoverflow.com/a/3561711/350933 40 | return str.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&'); 41 | } 42 | 43 | // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types 44 | export function log(msg: string, data?: any): void { 45 | console.log(`${chalk.magentaBright('[vercel-node-dev]')} - ${msg}`); 46 | if (data !== undefined) { 47 | console.log(prettyFormat(data)); 48 | } 49 | } 50 | 51 | export function replaceAll( 52 | original: string, 53 | match: string, 54 | replaceWith: string, 55 | ): string { 56 | return original.replace(new RegExp(escapeRegex(match), 'g'), replaceWith); 57 | } 58 | 59 | export async function waitForPortToRespond( 60 | port: number, 61 | waitMS: number, 62 | ): Promise { 63 | try { 64 | debug(`Waiting for port ${port} to respond`); 65 | await pTimeout( 66 | waitForPort({ 67 | host: 'localhost', 68 | output: 'silent', 69 | port, 70 | }), 71 | waitMS, 72 | ); 73 | debug(`Port ${port} is responding`); 74 | } catch (err) { 75 | if (err instanceof TimeoutError) { 76 | console.log(`Timed out waiting for port ${port} to respond`); 77 | } else { 78 | throw err; 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/ports.ts: -------------------------------------------------------------------------------- 1 | import getPort from 'get-port'; 2 | 3 | export interface Ports { 4 | apiServer: number; 5 | proxyServer: number; 6 | uiServer: number; 7 | } 8 | 9 | export async function getPorts(): Promise { 10 | return { 11 | proxyServer: 12 | process.env.VND_PORT != null ? parseInt(process.env.VND_PORT) : 3000, 13 | apiServer: await getPort(), 14 | uiServer: await getPort(), 15 | }; 16 | } 17 | -------------------------------------------------------------------------------- /src/routes/api-routes.ts: -------------------------------------------------------------------------------- 1 | import { NowApiHandler } from '@vercel/node'; 2 | import path from 'path'; 3 | import globby from 'globby'; 4 | import url from 'url'; 5 | import { IncomingMessage } from 'http'; 6 | import prettyFormat from 'pretty-format'; 7 | import dedent from 'dedent'; 8 | import stripColor from 'strip-color'; 9 | import * as lib from '../lib'; 10 | import { Context } from '../get-context'; 11 | 12 | export type Route = { 13 | filePath: string; 14 | handler: NowApiHandler; 15 | matcher: RegExp; 16 | }; 17 | 18 | export interface ResolveError { 19 | type: 'error'; 20 | } 21 | 22 | export interface ResolveNotFound { 23 | type: 'not_found'; 24 | } 25 | 26 | export interface ResolveInvalid { 27 | type: 'invalid'; 28 | } 29 | 30 | export interface ResolveFound { 31 | type: 'found'; 32 | handler: NowApiHandler; 33 | query: { [key: string]: string }; 34 | } 35 | 36 | export type ResolveResult = 37 | | ResolveError 38 | | ResolveNotFound 39 | | ResolveInvalid 40 | | ResolveFound; 41 | 42 | export type Query = { [key: string]: string }; 43 | 44 | const indexFilenameRegex = /^index\.(t|j)s$/i; 45 | const segmentFilenameRegex = /^\[\w+\]\.(t|j)s$/i; 46 | 47 | const pathToRegex = (p: string): RegExp => { 48 | const filename = path.basename(p); 49 | const filenameMatch = filename.match(/^(?.*)(?\.(t|j)s)$/i); 50 | if (filenameMatch == null || filenameMatch.groups == null) { 51 | throw new Error('Invalid path'); 52 | } 53 | const { name, ext } = filenameMatch.groups; 54 | 55 | // Create the regex for the filename 56 | const filenameRegexWithSlugs = indexFilenameRegex.test(filename) 57 | ? `(\\/(index(\\.(t|j)s)?)?)?` 58 | : segmentFilenameRegex.test(filename) 59 | ? filename.replace(/^\[(\w+)\](\.(t|j)s)$/g, '\\/(?<$1>[^/\\.]+)($2)?') 60 | : `\\/${lib.escapeRegex(name)}(${lib.escapeRegex(ext)})?`; 61 | 62 | // Create the regex for the full directory based path 63 | const dirRegexWithSlugs = lib 64 | .escapeRegex(path.dirname(p)) 65 | .replace(/\\\[(\w+)\\\]/gi, '(?<$1>[^/\\.]+)'); 66 | 67 | return new RegExp(`^\\/${dirRegexWithSlugs}${filenameRegexWithSlugs}$`); 68 | }; 69 | 70 | const resolveRoutes = async ( 71 | context: Context, 72 | includeHandlers: boolean, 73 | ): Promise => { 74 | const routes: Route[] = []; 75 | 76 | const globs = [ 77 | 'api/**/*.(ts|js)', 78 | // "_" as a directory name prefix indicates that the dir should be ignored 79 | '!api/**/_*/*', 80 | // "." as a directory name prefix indicates that the dir should be ignored 81 | '!api/**/.*/*', 82 | // "." as a filename prefix indicates that the file should be ignored 83 | '!api/**/.*', 84 | // "_" as a filename prefix indicates that the file should be ignored 85 | '!api/**/_*', 86 | ]; 87 | 88 | const paths = ( 89 | await globby(globs, { 90 | cwd: context.targetSymlinkCodePath, 91 | }) 92 | ) 93 | // We will sort so that the paths by length, descending, whilst ensuring all 94 | // "index" files are at the end and are sorted in ascending order. This will 95 | // ensure our matching will work as expected because the index filenames are 96 | // optional in terms of requests 97 | .sort((a, b) => { 98 | if (a.length < b.length || a.match(/index\.(t|j)s$/i) != null) { 99 | return 1; 100 | } else if (a.length > b.length) { 101 | return -1; 102 | } 103 | return 0; 104 | }); 105 | 106 | if (paths.length > 0) { 107 | lib.debug('Identified the following API functions:', paths); 108 | } else { 109 | lib.debug('No API functions found.'); 110 | } 111 | 112 | for (const p of paths) { 113 | let handler: NowApiHandler; 114 | 115 | if (includeHandlers) { 116 | try { 117 | const mod = await import(`${context.targetSymlinkCodePath}/${p}`); 118 | 119 | if (typeof mod !== 'object' || typeof mod.default !== 'function') { 120 | console.error( 121 | `[vercel-node-dev] Serverless function is not being exported from: ${p}`, 122 | ); 123 | } 124 | 125 | handler = mod.default; 126 | } catch (err) { 127 | lib.log(`Failed to load function "${p}"`, err); 128 | 129 | handler = (_req, res) => { 130 | res.status(500).send(dedent` 131 | There is an error in the following function: 132 | - ${stripColor(p)} 133 | 134 | ${stripColor(err.stack)} 135 | `); 136 | }; 137 | } 138 | } else { 139 | handler = (_req, res) => res.send(500).send('Route handler not resolved'); 140 | } 141 | 142 | routes.push({ 143 | filePath: p, 144 | handler, 145 | matcher: pathToRegex(p), 146 | }); 147 | } 148 | 149 | lib.debug('Resolved routes', routes); 150 | 151 | return routes; 152 | }; 153 | 154 | class APIRoutes { 155 | private routes: Route[]; 156 | 157 | constructor(routes: Route[]) { 158 | this.routes = routes; 159 | } 160 | 161 | private forPathname = ( 162 | pathname: string, 163 | caseInsensitive = false, 164 | ): { route: Route; match: RegExpMatchArray } | void => { 165 | let route: Route | null = null; 166 | let matcherResult: RegExpMatchArray | null = null; 167 | 168 | for (const r of this.routes) { 169 | matcherResult = pathname.match( 170 | caseInsensitive ? new RegExp(r.matcher.source, 'i') : r.matcher, 171 | ); 172 | if (matcherResult != null) { 173 | route = r; 174 | break; 175 | } 176 | } 177 | 178 | if (route != null && matcherResult != null) { 179 | return { 180 | route, 181 | match: matcherResult, 182 | }; 183 | } 184 | 185 | return undefined; 186 | }; 187 | 188 | resolve = (req: IncomingMessage): ResolveResult => { 189 | const query: Query = {}; 190 | 191 | const parts = url.parse(req.url || '/'); 192 | 193 | if (parts.pathname == null || parts.pathname.match(/^\/api/i) == null) { 194 | lib.debug(`Invalid request received`, parts); 195 | return { 196 | type: 'error', 197 | }; 198 | } else { 199 | const pathname = parts.pathname 200 | // We will remove the trailing slash as it's not needed, and will allow us 201 | // to match paths such as /api/hello-world for a function at 202 | // api/hello-world.js 203 | .replace(/\/$/, '') 204 | // Remove redundant /'s 205 | .replace(/\/+/g, '/'); 206 | 207 | const routeFindResult = this.forPathname(pathname); 208 | 209 | if (routeFindResult != null) { 210 | const { route, match } = routeFindResult; 211 | 212 | // Check to see if we need to attach any matched segments to querystring 213 | if (match != null && match.groups != null) { 214 | const groups = match.groups; 215 | Object.keys(groups).forEach((key) => { 216 | query[key] = groups[key]; 217 | }); 218 | } 219 | 220 | return { 221 | type: 'found', 222 | handler: route.handler, 223 | query, 224 | }; 225 | } else { 226 | // Vercel seems to have some "complex" logic regarding returning either 227 | // a 404 vs 405 depending on whether or not an api route could be matched. 228 | // If the path does not exist at all then it should return a 405. 229 | // If the path does not match a routes extension or casing then we 230 | // return a 404. 231 | // This distinction seems a bit strange to me, but our aim is to be 232 | // 100% the same as the Vercel cloud experience so we copy this logic. 233 | 234 | if (pathname.match(/\.(t|j)s$/)) { 235 | const pathNameWithoutExtension = pathname.replace(/\.(t|j)s$/, ''); 236 | if ( 237 | this.forPathname(pathNameWithoutExtension, true) || 238 | this.forPathname(pathname, true) 239 | ) { 240 | lib.debug(`Invalid route extension: ${pathname}`); 241 | return { 242 | type: 'not_found', 243 | }; 244 | } 245 | } 246 | 247 | lib.debug(`Invalid route: ${pathname}`); 248 | return { type: 'invalid' }; 249 | } 250 | } 251 | }; 252 | } 253 | 254 | export async function resolveAPIRoutes( 255 | context: Context, 256 | includeHandlers: boolean, 257 | ): Promise { 258 | const routes = await resolveRoutes(context, includeHandlers); 259 | return new APIRoutes(routes); 260 | } 261 | -------------------------------------------------------------------------------- /src/routes/routing.ts: -------------------------------------------------------------------------------- 1 | import { IncomingMessage, ServerResponse } from 'http'; 2 | import url, { URL, URLSearchParams } from 'url'; 3 | import * as lib from '../lib'; 4 | import { Context, Ports } from '../get-context'; 5 | 6 | type RedirectCode = 301 | 302 | 303 | 307 | 308; 7 | 8 | const redirectStatusCodes: RedirectCode[] = [ 9 | 301, // Moved Permanently 10 | 302, // Found 11 | 303, // See Other 12 | 307, // Temporary Redirect 13 | 308, // Permanent Redirect 14 | ]; 15 | 16 | export interface RoutingRedirect { 17 | type: 'redirect'; 18 | status: RedirectCode; 19 | } 20 | 21 | export interface RoutingApplied { 22 | type: 'applied'; 23 | pathname: string; 24 | } 25 | 26 | export type RoutingResult = RoutingRedirect | RoutingApplied; 27 | 28 | export function applyRouting( 29 | req: IncomingMessage, 30 | res: ServerResponse, 31 | context: Context, 32 | ports: Ports, 33 | ): RoutingResult { 34 | let parts = url.parse(req.url || '/'); 35 | let pathname = parts.pathname || '/'; 36 | lib.debug(`Proxy server handling request: ${req.url}`); 37 | 38 | const routes = context.routes || []; 39 | 40 | for (let i = 0; i < routes.length; i += 1) { 41 | const _parts = url.parse(req.url || '/'); 42 | const _pathname = _parts.pathname || '/'; 43 | 44 | const route = routes[i]; 45 | 46 | const { src } = route; 47 | if (src == null) { 48 | lib.log(`Skipping route as it does not have a "src" defined`); 49 | continue; 50 | } 51 | 52 | const srcRegex = new RegExp(src); 53 | 54 | const match = _pathname.match(srcRegex); 55 | if (match == null) { 56 | continue; 57 | } 58 | lib.debug('Processing route match', route); 59 | 60 | if ('methods' in route && Array.isArray(route.methods)) { 61 | if (!route.methods.includes(req.method || 'GET')) { 62 | lib.debug('Route does not satisfy the configured HTTP methods'); 63 | if ('continue' in route && route.continue) { 64 | continue; 65 | } else { 66 | break; 67 | } 68 | } 69 | } 70 | 71 | parts = _parts; 72 | pathname = _pathname; 73 | 74 | const replaceRouteMatchBackReferences = ( 75 | s: string, 76 | includeNamedGroups: boolean, 77 | ) => { 78 | if (match == null || s == null || s === '') { 79 | return s; 80 | } 81 | let result = s; 82 | if (includeNamedGroups && match.groups) { 83 | const groups = match.groups; 84 | result = Object.keys(groups).reduce((acc, cur) => { 85 | return lib.replaceAll(acc, `:${cur}`, groups[cur]); 86 | }, s); 87 | } 88 | if (match.length > 1) { 89 | // We reverse this loop to make it more specific. 90 | // e.g. match $14 before $1 91 | for (let i = match.length; i > 0; i -= 1) { 92 | result = lib.replaceAll(result, `$${i}`, match[i]); 93 | } 94 | } 95 | return result; 96 | }; 97 | 98 | if ('headers' in route && route.headers != null) { 99 | const headers = route.headers; 100 | // Attach resolved headers to res 101 | Object.keys(headers).forEach((key) => { 102 | res.setHeader( 103 | key, 104 | replaceRouteMatchBackReferences(headers[key], false), 105 | ); 106 | }); 107 | } 108 | 109 | if ('status' in route && typeof route.status === 'number') { 110 | lib.debug('Setting status', route.status); 111 | // If it's a redirect status we'll immediately resolve 112 | if (redirectStatusCodes.includes(route.status as RedirectCode)) { 113 | lib.debug('Stopping route mapping as found redirect status code'); 114 | return { 115 | type: 'redirect', 116 | status: route.status as RedirectCode, 117 | }; 118 | } 119 | } 120 | 121 | if ('dest' in route && typeof route.dest === 'string') { 122 | const destURL = new URL( 123 | `http://localhost:${ports.proxyServer}${replaceRouteMatchBackReferences( 124 | route.dest, 125 | true, 126 | )}`, 127 | ); 128 | new URLSearchParams( 129 | parts.search ? parts.search.slice(1) : undefined, 130 | ).forEach((value, name) => { 131 | destURL.searchParams.append(name, value); 132 | }); 133 | req.url = url.format({ 134 | pathname: destURL.pathname, 135 | search: destURL.searchParams.toString(), 136 | }); 137 | } 138 | 139 | if ('continue' in route && route.continue) { 140 | continue; 141 | } else { 142 | break; 143 | } 144 | } 145 | 146 | // The request URL could have been modified by the route resolution, so 147 | // we should extract the pathname again before determining whether 148 | // to route this to the apis vs ui 149 | parts = url.parse(req.url || '/'); 150 | pathname = parts.pathname || '/'; 151 | 152 | lib.debug(`Routing applied. Url resolved to: ${req.url}`); 153 | 154 | return { 155 | type: 'applied', 156 | pathname, 157 | }; 158 | } 159 | -------------------------------------------------------------------------------- /src/servers/start-api-server.ts: -------------------------------------------------------------------------------- 1 | import { createServer } from 'vercel-node-server'; 2 | import util from 'util'; 3 | import execa from 'execa'; 4 | import * as lib from '../lib'; 5 | import { resolveAPIRoutes } from '../routes/api-routes'; 6 | import getContext from '../get-context'; 7 | 8 | export default async function startAPIServer(): Promise { 9 | const context = await getContext(); 10 | 11 | if (context.packageJson?.scripts?.['now-build'] != null) { 12 | try { 13 | lib.log('Executing now-build script'); 14 | await execa('npm', ['run', 'now-build'], { 15 | cwd: context.targetSymlinkPath, 16 | }); 17 | } catch (err) { 18 | lib.log('Failed to execute now-build script', err); 19 | } 20 | } 21 | 22 | const server = createServer(async (req, res) => { 23 | lib.debug(`API server handling request: ${req.url}`); 24 | 25 | const apiRoutes = await resolveAPIRoutes(context, true); 26 | 27 | const resolveResult = apiRoutes.resolve(req); 28 | 29 | switch (resolveResult.type) { 30 | case 'error': 31 | case 'not_found': 32 | case 'invalid': { 33 | throw new Error( 34 | `Invalid state, these should have been handled by the proxy: ${req.url}`, 35 | ); 36 | } 37 | case 'found': { 38 | const { query, handler } = resolveResult; 39 | Object.keys(query).forEach((key) => { 40 | req.query[key] = query[key]; 41 | }); 42 | Promise.resolve(handler(req, res)).catch((err) => { 43 | res.status(500).send(err.stack); 44 | }); 45 | } 46 | } 47 | }); 48 | 49 | const port = process.env.PORT; 50 | if (port == null) { 51 | throw new Error('PORT environment variable not provided to api-server'); 52 | } 53 | 54 | lib.debug(`Starting API server on ${port}`); 55 | 56 | server.listen(parseInt(port, 10), () => { 57 | lib.debug(`API server listening on ${port}`); 58 | }); 59 | 60 | const asyncClose = util.promisify(server.close); 61 | 62 | lib.bindProcessTermination(() => asyncClose()); 63 | } 64 | 65 | startAPIServer(); 66 | -------------------------------------------------------------------------------- /src/servers/start-proxy-server.ts: -------------------------------------------------------------------------------- 1 | import http, { IncomingMessage } from 'http'; 2 | import httpProxy from 'http-proxy'; 3 | import * as lib from '../lib'; 4 | import { Context } from '../get-context'; 5 | import { applyRouting } from '../routes/routing'; 6 | import { resolveAPIRoutes } from '../routes/api-routes'; 7 | import { 8 | writeHeaders, 9 | writeStatusCode, 10 | // eslint-disable-next-line 11 | // @ts-ignore 12 | } from 'http-proxy/lib/http-proxy/passes/web-outgoing'; 13 | import { Ports } from '../ports'; 14 | 15 | export default async function startProxyServer( 16 | context: Context, 17 | ports: Ports, 18 | ): Promise { 19 | lib.debug(`Starting proxy server on port ${ports.proxyServer}`); 20 | 21 | const apiProxy = httpProxy.createProxyServer({ 22 | changeOrigin: true, 23 | target: { 24 | host: 'localhost', 25 | port: ports.apiServer, 26 | protocol: 'http', 27 | }, 28 | xfwd: true, 29 | secure: false, 30 | ws: true, 31 | preserveHeaderKeyCase: true, 32 | }); 33 | 34 | apiProxy.on('econnreset', (err, _req, res) => { 35 | lib.log('API connection reset', err); 36 | res.statusCode = 500; 37 | res.end(err.message); 38 | }); 39 | 40 | apiProxy.on('error', (err, _req, res) => { 41 | lib.log('API connection error', err); 42 | res.statusCode = 500; 43 | res.end(err.message); 44 | }); 45 | 46 | apiProxy.on('proxyReq', (proxyReq, _req, _res) => { 47 | // Browsers may send Origin headers even with same-origin requests. To 48 | // prevent CORS issues, we have to change the Origin to match the target URL. 49 | if (proxyReq.getHeader('origin')) { 50 | proxyReq.setHeader('origin', `http://localhost:${ports.apiServer}`); 51 | _res.setHeader('origin', `http://localhost:${ports.apiServer}`); 52 | } 53 | }); 54 | 55 | const uiProxy = httpProxy.createProxyServer({ 56 | changeOrigin: true, 57 | target: `http://localhost:${ports.uiServer}`, 58 | ws: true, 59 | }); 60 | 61 | // We create another UI proxy server. This version will be responsible for 62 | // passing on the response from the target "manually". It affords us the 63 | // opportunity with doing last minute adjustments to the response prior 64 | // to the user receiving it. 65 | // One example case being if we would like to 404 for an invalid API path but 66 | // then have the UI handle the body rendering, allowing for a custom 404 page 67 | // to be rendered. 68 | 69 | const selfHandleUIProxy = httpProxy.createProxyServer({ 70 | changeOrigin: true, 71 | selfHandleResponse: true, 72 | target: `http://localhost:${ports.uiServer}`, 73 | }); 74 | 75 | const selfHandleUIStatusOverrides = new Map(); 76 | 77 | selfHandleUIProxy.on('proxyRes', (proxyRes, req, res) => { 78 | // Ensure headers are mapped to response 79 | writeHeaders(req, res, proxyRes, {}); 80 | 81 | // Override the status 82 | const overideStatus = selfHandleUIStatusOverrides.get(req); 83 | if (overideStatus != null) { 84 | lib.debug('Overriding status for response', overideStatus); 85 | res.statusCode = overideStatus; 86 | selfHandleUIStatusOverrides.delete(req); 87 | } else { 88 | lib.debug('No status override found, passing through original status'); 89 | writeStatusCode(req, res, proxyRes); 90 | } 91 | 92 | // Ensure body is mapped to response 93 | proxyRes.pipe(res); 94 | }); 95 | 96 | const handler = async ( 97 | req: http.IncomingMessage, 98 | res: http.ServerResponse, 99 | ) => { 100 | lib.debug(`Proxy server handling request: ${req.url}`); 101 | 102 | const apiRoutes = await resolveAPIRoutes(context, false); 103 | 104 | const routingResult = applyRouting(req, res, context, ports); 105 | 106 | switch (routingResult.type) { 107 | case 'redirect': { 108 | lib.debug('Responding with a redirect'); 109 | res.statusCode = routingResult.status; 110 | res.end(); 111 | break; 112 | } 113 | case 'applied': { 114 | const { pathname } = routingResult; 115 | if (pathname.match(/^\/api(\/.*)?$/) != null) { 116 | const apiResolveResult = apiRoutes.resolve(req); 117 | if ( 118 | apiResolveResult.type === 'not_found' || 119 | apiResolveResult.type === 'invalid' 120 | ) { 121 | lib.debug('API route is not valid, passing request to UI.'); 122 | selfHandleUIStatusOverrides.set(req, 404); 123 | selfHandleUIProxy.web(req, res); 124 | } else { 125 | lib.debug(`Proxying request to API server: ${pathname}`); 126 | apiProxy.web(req, res); 127 | } 128 | } else { 129 | lib.debug(`Proxying request to UI server: ${pathname}`); 130 | uiProxy.web(req, res); 131 | } 132 | } 133 | } 134 | }; 135 | 136 | const server = http.createServer(handler); 137 | 138 | server.on('upgrade', function (req, socket, head) { 139 | uiProxy.ws(req, socket, head); 140 | }); 141 | 142 | server.listen(ports.proxyServer, () => { 143 | lib.log(`Server started on http://localhost:${ports.proxyServer}`); 144 | }); 145 | 146 | return server; 147 | } 148 | -------------------------------------------------------------------------------- /test/fixtures/create-react-app/.env: -------------------------------------------------------------------------------- 1 | SKIP_PREFLIGHT_CHECK=true 2 | BROWSER=none -------------------------------------------------------------------------------- /test/fixtures/create-react-app/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | .vercel -------------------------------------------------------------------------------- /test/fixtures/create-react-app/README.md: -------------------------------------------------------------------------------- 1 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 2 | 3 | ## Available Scripts 4 | 5 | In the project directory, you can run: 6 | 7 | ### `yarn start` 8 | 9 | Runs the app in the development mode.
10 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 11 | 12 | The page will reload if you make edits.
13 | You will also see any lint errors in the console. 14 | 15 | ### `yarn test` 16 | 17 | Launches the test runner in the interactive watch mode.
18 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 19 | 20 | ### `yarn build` 21 | 22 | Builds the app for production to the `build` folder.
23 | It correctly bundles React in production mode and optimizes the build for the best performance. 24 | 25 | The build is minified and the filenames include the hashes.
26 | Your app is ready to be deployed! 27 | 28 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 29 | 30 | ### `yarn eject` 31 | 32 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 33 | 34 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 35 | 36 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 37 | 38 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 39 | 40 | ## Learn More 41 | 42 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 43 | 44 | To learn React, check out the [React documentation](https://reactjs.org/). 45 | 46 | ### Code Splitting 47 | 48 | This section has moved here: https://facebook.github.io/create-react-app/docs/code-splitting 49 | 50 | ### Analyzing the Bundle Size 51 | 52 | This section has moved here: https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size 53 | 54 | ### Making a Progressive Web App 55 | 56 | This section has moved here: https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app 57 | 58 | ### Advanced Configuration 59 | 60 | This section has moved here: https://facebook.github.io/create-react-app/docs/advanced-configuration 61 | 62 | ### Deployment 63 | 64 | This section has moved here: https://facebook.github.io/create-react-app/docs/deployment 65 | 66 | ### `yarn build` fails to minify 67 | 68 | This section has moved here: https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify 69 | -------------------------------------------------------------------------------- /test/fixtures/create-react-app/api/articles/[id].js: -------------------------------------------------------------------------------- 1 | module.exports = async (req, res) => { 2 | res.send(`articleId: ${req.query.id}`); 3 | }; 4 | -------------------------------------------------------------------------------- /test/fixtures/create-react-app/api/articles/index.js: -------------------------------------------------------------------------------- 1 | module.exports = (req, res) => { 2 | res.send(JSON.stringify(req.body, null, 2)); 3 | }; 4 | -------------------------------------------------------------------------------- /test/fixtures/create-react-app/api/blog/[slug]/admin/[action]/[type].js: -------------------------------------------------------------------------------- 1 | module.exports = (req, res) => { 2 | res.send( 3 | `blogSlug: ${req.query.slug}, blogAction: ${req.query.action}, blogType: ${req.query.type}`, 4 | ); 5 | }; 6 | -------------------------------------------------------------------------------- /test/fixtures/create-react-app/api/blog/[slug]/admin/[action]/index.js: -------------------------------------------------------------------------------- 1 | module.exports = (req, res) => { 2 | res.send(`blogSlug: ${req.query.slug}, blogAction: ${req.query.action}`); 3 | }; 4 | -------------------------------------------------------------------------------- /test/fixtures/create-react-app/api/blog/[slug]/edit.js: -------------------------------------------------------------------------------- 1 | module.exports = (req, res) => { 2 | res.send(`edit blogSlug: ${req.query.slug}`); 3 | }; 4 | -------------------------------------------------------------------------------- /test/fixtures/create-react-app/api/blog/[slug]/index.js: -------------------------------------------------------------------------------- 1 | module.exports = (req, res) => { 2 | res.send(`blogSlug: ${req.query.slug}`); 3 | }; 4 | -------------------------------------------------------------------------------- /test/fixtures/create-react-app/api/body.js: -------------------------------------------------------------------------------- 1 | module.exports = (req, res) => { 2 | res.json(req.body); 3 | }; 4 | -------------------------------------------------------------------------------- /test/fixtures/create-react-app/api/hello-world.js: -------------------------------------------------------------------------------- 1 | module.exports = (req, res) => { 2 | res.send('Hello world'); 3 | }; 4 | -------------------------------------------------------------------------------- /test/fixtures/create-react-app/api/index.js: -------------------------------------------------------------------------------- 1 | module.exports = (req, res) => { 2 | res.send('root'); 3 | }; 4 | -------------------------------------------------------------------------------- /test/fixtures/create-react-app/api/method.js: -------------------------------------------------------------------------------- 1 | module.exports = (req, res) => { 2 | res.send(req.method); 3 | }; 4 | -------------------------------------------------------------------------------- /test/fixtures/create-react-app/api/query-strings.js: -------------------------------------------------------------------------------- 1 | module.exports = (req, res) => { 2 | res.send(req.query); 3 | }; 4 | -------------------------------------------------------------------------------- /test/fixtures/create-react-app/api/typescript-world.ts: -------------------------------------------------------------------------------- 1 | import { NowRequest, NowResponse } from '@vercel/node'; 2 | 3 | export default (req: NowRequest, res: NowResponse) => { 4 | res.send('Hello world'); 5 | }; 6 | -------------------------------------------------------------------------------- /test/fixtures/create-react-app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "create-react-app", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^4.2.4", 7 | "@testing-library/react": "^9.3.2", 8 | "@testing-library/user-event": "^7.1.2", 9 | "react": "^16.13.1", 10 | "react-dom": "^16.13.1", 11 | "react-scripts": "3.4.1" 12 | }, 13 | "scripts": { 14 | "start": "react-scripts start", 15 | "build": "react-scripts build", 16 | "test": "react-scripts test", 17 | "eject": "react-scripts eject" 18 | }, 19 | "eslintConfig": { 20 | "extends": "react-app" 21 | }, 22 | "browserslist": { 23 | "production": [ 24 | ">0.2%", 25 | "not dead", 26 | "not op_mini all" 27 | ], 28 | "development": [ 29 | "last 1 chrome version", 30 | "last 1 firefox version", 31 | "last 1 safari version" 32 | ] 33 | }, 34 | "devDependencies": { 35 | "typescript": "3.8.3" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /test/fixtures/create-react-app/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ctrlplusb/vercel-node-dev/ba9459515ed45a64e55cea2e1179edea19455d36/test/fixtures/create-react-app/public/favicon.ico -------------------------------------------------------------------------------- /test/fixtures/create-react-app/public/foo.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Foo 7 | 8 | 9 | This is the foo.html page 10 | 11 | 12 | -------------------------------------------------------------------------------- /test/fixtures/create-react-app/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React App 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /test/fixtures/create-react-app/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ctrlplusb/vercel-node-dev/ba9459515ed45a64e55cea2e1179edea19455d36/test/fixtures/create-react-app/public/logo192.png -------------------------------------------------------------------------------- /test/fixtures/create-react-app/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ctrlplusb/vercel-node-dev/ba9459515ed45a64e55cea2e1179edea19455d36/test/fixtures/create-react-app/public/logo512.png -------------------------------------------------------------------------------- /test/fixtures/create-react-app/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /test/fixtures/create-react-app/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /test/fixtures/create-react-app/src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .App-logo { 6 | height: 40vmin; 7 | pointer-events: none; 8 | } 9 | 10 | @media (prefers-reduced-motion: no-preference) { 11 | .App-logo { 12 | animation: App-logo-spin infinite 20s linear; 13 | } 14 | } 15 | 16 | .App-header { 17 | background-color: #282c34; 18 | min-height: 100vh; 19 | display: flex; 20 | flex-direction: column; 21 | align-items: center; 22 | justify-content: center; 23 | font-size: calc(10px + 2vmin); 24 | color: white; 25 | } 26 | 27 | .App-link { 28 | color: #61dafb; 29 | } 30 | 31 | @keyframes App-logo-spin { 32 | from { 33 | transform: rotate(0deg); 34 | } 35 | to { 36 | transform: rotate(360deg); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /test/fixtures/create-react-app/src/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import logo from './logo.svg'; 3 | import './App.css'; 4 | 5 | function App() { 6 | return ( 7 |
8 |
9 | logo 10 |

11 | Edit src/App.js and save to reload. 12 |

13 | 19 | Learn React 20 | 21 |
22 |
23 | ); 24 | } 25 | 26 | export default App; 27 | -------------------------------------------------------------------------------- /test/fixtures/create-react-app/src/App.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import App from './App'; 4 | 5 | test('renders learn react link', () => { 6 | const { getByText } = render(); 7 | const linkElement = getByText(/learn react/i); 8 | expect(linkElement).toBeInTheDocument(); 9 | }); 10 | -------------------------------------------------------------------------------- /test/fixtures/create-react-app/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /test/fixtures/create-react-app/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './index.css'; 4 | import App from './App'; 5 | import * as serviceWorker from './serviceWorker'; 6 | 7 | ReactDOM.render( 8 | 9 | 10 | , 11 | document.getElementById('root') 12 | ); 13 | 14 | // If you want your app to work offline and load faster, you can change 15 | // unregister() to register() below. Note this comes with some pitfalls. 16 | // Learn more about service workers: https://bit.ly/CRA-PWA 17 | serviceWorker.unregister(); 18 | -------------------------------------------------------------------------------- /test/fixtures/create-react-app/src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /test/fixtures/create-react-app/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /test/fixtures/create-react-app/src/serviceWorker.js: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read https://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === 'localhost' || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === '[::1]' || 17 | // 127.0.0.0/8 are considered localhost for IPv4. 18 | window.location.hostname.match( 19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 20 | ) 21 | ); 22 | 23 | export function register(config) { 24 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 25 | // The URL constructor is available in all browsers that support SW. 26 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); 27 | if (publicUrl.origin !== window.location.origin) { 28 | // Our service worker won't work if PUBLIC_URL is on a different origin 29 | // from what our page is served on. This might happen if a CDN is used to 30 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 31 | return; 32 | } 33 | 34 | window.addEventListener('load', () => { 35 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 36 | 37 | if (isLocalhost) { 38 | // This is running on localhost. Let's check if a service worker still exists or not. 39 | checkValidServiceWorker(swUrl, config); 40 | 41 | // Add some additional logging to localhost, pointing developers to the 42 | // service worker/PWA documentation. 43 | navigator.serviceWorker.ready.then(() => { 44 | console.log( 45 | 'This web app is being served cache-first by a service ' + 46 | 'worker. To learn more, visit https://bit.ly/CRA-PWA' 47 | ); 48 | }); 49 | } else { 50 | // Is not localhost. Just register service worker 51 | registerValidSW(swUrl, config); 52 | } 53 | }); 54 | } 55 | } 56 | 57 | function registerValidSW(swUrl, config) { 58 | navigator.serviceWorker 59 | .register(swUrl) 60 | .then(registration => { 61 | registration.onupdatefound = () => { 62 | const installingWorker = registration.installing; 63 | if (installingWorker == null) { 64 | return; 65 | } 66 | installingWorker.onstatechange = () => { 67 | if (installingWorker.state === 'installed') { 68 | if (navigator.serviceWorker.controller) { 69 | // At this point, the updated precached content has been fetched, 70 | // but the previous service worker will still serve the older 71 | // content until all client tabs are closed. 72 | console.log( 73 | 'New content is available and will be used when all ' + 74 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.' 75 | ); 76 | 77 | // Execute callback 78 | if (config && config.onUpdate) { 79 | config.onUpdate(registration); 80 | } 81 | } else { 82 | // At this point, everything has been precached. 83 | // It's the perfect time to display a 84 | // "Content is cached for offline use." message. 85 | console.log('Content is cached for offline use.'); 86 | 87 | // Execute callback 88 | if (config && config.onSuccess) { 89 | config.onSuccess(registration); 90 | } 91 | } 92 | } 93 | }; 94 | }; 95 | }) 96 | .catch(error => { 97 | console.error('Error during service worker registration:', error); 98 | }); 99 | } 100 | 101 | function checkValidServiceWorker(swUrl, config) { 102 | // Check if the service worker can be found. If it can't reload the page. 103 | fetch(swUrl, { 104 | headers: { 'Service-Worker': 'script' }, 105 | }) 106 | .then(response => { 107 | // Ensure service worker exists, and that we really are getting a JS file. 108 | const contentType = response.headers.get('content-type'); 109 | if ( 110 | response.status === 404 || 111 | (contentType != null && contentType.indexOf('javascript') === -1) 112 | ) { 113 | // No service worker found. Probably a different app. Reload the page. 114 | navigator.serviceWorker.ready.then(registration => { 115 | registration.unregister().then(() => { 116 | window.location.reload(); 117 | }); 118 | }); 119 | } else { 120 | // Service worker found. Proceed as normal. 121 | registerValidSW(swUrl, config); 122 | } 123 | }) 124 | .catch(() => { 125 | console.log( 126 | 'No internet connection found. App is running in offline mode.' 127 | ); 128 | }); 129 | } 130 | 131 | export function unregister() { 132 | if ('serviceWorker' in navigator) { 133 | navigator.serviceWorker.ready 134 | .then(registration => { 135 | registration.unregister(); 136 | }) 137 | .catch(error => { 138 | console.error(error.message); 139 | }); 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /test/fixtures/create-react-app/src/setupTests.js: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom/extend-expect'; 6 | -------------------------------------------------------------------------------- /test/fixtures/create-react-app/vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 2, 3 | "routes": [ 4 | { 5 | "src": "/simple-route-to-dest", 6 | "dest": "/api/hello-world" 7 | }, 8 | { 9 | "src": "/query-strings-with-continue-stack", 10 | "dest": "/query-strings-stack?internalContinued=true", 11 | "continue": true 12 | }, 13 | { 14 | "src": "/query-strings-stack", 15 | "dest": "/api/query-strings?internal=bar" 16 | }, 17 | { 18 | "src": "/redirect-301", 19 | "status": 301, 20 | "headers": { "Location": "https://google.com" } 21 | }, 22 | { 23 | "src": "/redirect-302", 24 | "status": 302, 25 | "headers": { "Location": "https://google.com" } 26 | }, 27 | { 28 | "src": "/redirect-303", 29 | "status": 303, 30 | "headers": { "Location": "https://google.com" } 31 | }, 32 | { 33 | "src": "/redirect-307", 34 | "status": 307, 35 | "headers": { "Location": "https://google.com" } 36 | }, 37 | { 38 | "src": "/redirect-308", 39 | "status": 308, 40 | "headers": { "Location": "https://google.com" } 41 | }, 42 | { 43 | "src": "/redirect-with-group-in-location-header/(.*)", 44 | "status": 301, 45 | "headers": { "Location": "/api/articles/$1" } 46 | }, 47 | { 48 | "src": "/redirect-with-dest_the-dest-is-ignored", 49 | "status": 301, 50 | "headers": { "Location": "https://google.com" }, 51 | "dest": "/api/hello-world" 52 | }, 53 | { 54 | "src": "/headers-with-numbered-groups/([^/]*)/([^/]*)", 55 | "headers": { 56 | "X-MY-CUSTOM-HEADER-01": "foo-$1-$2", 57 | "X-MY-CUSTOM-HEADER-02": "foo-$1", 58 | "X-MY-CUSTOM-HEADER-03": "foo-$2" 59 | }, 60 | "dest": "/api/hello-world" 61 | }, 62 | { 63 | "src": "/headers-with-named-groups-do-not-work/(?[^/]*)/(?[^/]*)", 64 | "headers": { 65 | "X-MY-CUSTOM-HEADER-01": "foo-:first-:second", 66 | "X-MY-CUSTOM-HEADER-02": "foo-:first", 67 | "X-MY-CUSTOM-HEADER-03": "foo-:second" 68 | }, 69 | "dest": "/api/hello-world" 70 | }, 71 | { 72 | "src": "/numbered-groups/([^/]+)/([^/]+)", 73 | "dest": "/api/query-strings?first=$1&second=$2" 74 | }, 75 | { 76 | "src": "/named-groups/(?[^/]+)/(?[^/]+)", 77 | "dest": "/api/query-strings?first=:first&second=:second" 78 | }, 79 | { 80 | "src": "/restricted-to-post", 81 | "methods": ["POST"], 82 | "dest": "/api/method" 83 | }, 84 | { 85 | "src": "/api/articles/restricted\\.js", 86 | "methods": ["POST", "GET"], 87 | "dest": "/api/articles/restricted.js" 88 | }, 89 | { 90 | "src": "/foo.html" 91 | }, 92 | { 93 | "src": "/articles/(.*)", 94 | "headers": { "Z_MY_CUSTOM_HEADER": "article" }, 95 | "continue": true 96 | }, 97 | { "src": "/articles", "methods": ["POST"], "dest": "/api/articles" }, 98 | { 99 | "src": "/api/articles/restricted.js", 100 | "methods": ["POST", "GET"], 101 | "dest": "/api/articles/restricted.js" 102 | }, 103 | { "src": "/legacy", "status": 404 }, 104 | 105 | { "src": "/article-api-no-ext", "dest": "/api/articles/12345" }, 106 | { "src": "/article-api-ext", "dest": "/api/articles/12345.js" }, 107 | { "src": "/article-api", "dest": "/api/articles/[id].js" }, 108 | 109 | { "src": "/root-api-no-ext", "dest": "/api/index" }, 110 | { "src": "/root-api-simple", "dest": "/api" }, 111 | { "src": "/root-api", "dest": "/api/index.js" }, 112 | { "src": "/root-api-no-leading", "dest": "api/index.js" }, 113 | { "src": "no-leading-root-api", "dest": "/api/index.js" }, 114 | { "src": "/bob-no-ext", "dest": "/foo" }, 115 | { "src": "/bob", "dest": "/foo.html" }, 116 | { "src": "/bob-no-leading", "dest": "foo.html" }, 117 | { "src": "no-leading-bob", "dest": "/foo.html" } 118 | ] 119 | } 120 | -------------------------------------------------------------------------------- /test/integration.test.ts: -------------------------------------------------------------------------------- 1 | import execa from 'execa'; 2 | import path from 'path'; 3 | import axios from 'axios'; 4 | import waitForPort from 'wait-port'; 5 | import getPort from 'get-port'; 6 | 7 | const testTimeout = 10 * 1000; 8 | 9 | let vercelNodeDevProcess: execa.ExecaChildProcess; 10 | let vercelNodeDevPort: number; 11 | 12 | let vercelDevProcess: execa.ExecaChildProcess; 13 | let vercelDevPort: number; 14 | 15 | const vercelPreviewURL = 'https://test-create-react-app.ctrlplusb.now.sh'; 16 | 17 | type Environment = 'vercel' | 'vercel dev' | 'vercel-node-dev'; 18 | 19 | const environments: Environment[] = process.env.CI 20 | ? ['vercel-node-dev'] 21 | : [ 22 | //'vercel', // 23 | // 'vercel dev', // 24 | 'vercel-node-dev', // 25 | ]; 26 | 27 | const envURL = (environment: Environment, url: string) => { 28 | switch (environment) { 29 | case 'vercel': 30 | return `${vercelPreviewURL}${url}`; 31 | case 'vercel dev': 32 | return `http://localhost:${vercelDevPort}${url}`; 33 | case 'vercel-node-dev': 34 | return `http://localhost:${vercelNodeDevPort}${url}`; 35 | } 36 | }; 37 | 38 | const spinUpVercelNodeDevOnTestProject = async () => { 39 | vercelNodeDevPort = await getPort(); 40 | vercelNodeDevProcess = execa( 41 | path.join(process.cwd(), 'bin/vercel-node-dev'), 42 | ['-p', vercelNodeDevPort.toString()], 43 | { 44 | cwd: path.join(__dirname, 'fixtures/create-react-app'), 45 | env: { 46 | FORCE_COLOR: '1', 47 | VND_DEBUG: '1', 48 | // VND_SILENT_UI: '1', 49 | }, 50 | stdio: 'inherit', 51 | }, 52 | ); 53 | await waitForPort({ 54 | host: 'localhost', 55 | port: vercelNodeDevPort, 56 | output: 'silent', 57 | }); 58 | }; 59 | 60 | const spinUpVercelDevOnTestProject = async () => { 61 | vercelDevPort = await getPort(); 62 | vercelDevProcess = execa( 63 | 'vercel', 64 | ['dev', '--listen', `0.0.0.0:${vercelDevPort}`], 65 | { 66 | cwd: path.join(__dirname, 'fixtures/create-react-app'), 67 | env: { 68 | FORCE_COLOR: '1', 69 | PORT: vercelDevPort.toString(), 70 | }, 71 | stdio: 'inherit', 72 | }, 73 | ); 74 | await waitForPort({ 75 | host: 'localhost', 76 | port: vercelDevPort, 77 | output: 'silent', 78 | }); 79 | }; 80 | 81 | const deployTestProjectToVercel = async () => { 82 | await execa('vercel', [], { 83 | cwd: path.join(__dirname, 'fixtures/create-react-app'), 84 | env: process.env, 85 | stdio: 'inherit', 86 | }); 87 | }; 88 | 89 | beforeAll(async () => { 90 | await Promise.all([ 91 | environments.includes('vercel') 92 | ? await deployTestProjectToVercel() 93 | : Promise.resolve(), 94 | environments.includes('vercel dev') 95 | ? await spinUpVercelDevOnTestProject() 96 | : Promise.resolve(), 97 | environments.includes('vercel-node-dev') 98 | ? await spinUpVercelNodeDevOnTestProject() 99 | : Promise.resolve(), 100 | ]); 101 | }, 120 * 1000); 102 | 103 | afterAll(() => { 104 | if (vercelNodeDevProcess) { 105 | vercelNodeDevProcess.kill('SIGTERM'); 106 | } 107 | if (vercelDevProcess) { 108 | vercelDevProcess.kill('SIGTERM'); 109 | } 110 | }); 111 | 112 | environments.forEach((environment) => { 113 | describe('APIs', () => { 114 | test( 115 | `Specifying only the src falls through [${environment}]`, 116 | async () => { 117 | // ACT 118 | const result = await axios.get(envURL(environment, `/foo.html`)); 119 | 120 | // ASSERT 121 | expect(result.status).toBe(200); 122 | expect(result.data).toMatch(/This is the foo\.html page/); 123 | }, 124 | testTimeout, 125 | ); 126 | 127 | test( 128 | `Path segment as filename [${environment}]`, 129 | async () => { 130 | // ACT 131 | const result = await axios.get( 132 | envURL(environment, `/api/articles/123456`), 133 | ); 134 | 135 | // ASSERT 136 | expect(result.status).toBe(200); 137 | expect(result.data).toMatch(/articleId: 123456/); 138 | }, 139 | testTimeout, 140 | ); 141 | 142 | test( 143 | `Path segment as filename - with file extension [${environment}]`, 144 | async () => { 145 | // ACT 146 | const result = await axios.get( 147 | envURL(environment, `/api/articles/123456.js`), 148 | ); 149 | 150 | // ASSERT 151 | expect(result.status).toBe(200); 152 | expect(result.data).toMatch(/articleId: 123456/); 153 | }, 154 | testTimeout, 155 | ); 156 | 157 | test( 158 | `Path segment as directory name [${environment}]`, 159 | async () => { 160 | // ACT 161 | const result = await axios.get( 162 | envURL(environment, `/api/blog/this-is-the-blog-slug`), 163 | ); 164 | 165 | // ASSERT 166 | expect(result.status).toBe(200); 167 | expect(result.data).toMatch(/blogSlug: this-is-the-blog-slug/); 168 | }, 169 | testTimeout, 170 | ); 171 | 172 | test( 173 | `Path segment as directory name with sub path [${environment}]`, 174 | async () => { 175 | // ACT 176 | const result = await axios.get( 177 | envURL(environment, `/api/blog/this-is-the-blog-slug/edit`), 178 | ); 179 | 180 | // ASSERT 181 | expect(result.status).toBe(200); 182 | expect(result.data).toMatch(/edit blogSlug: this-is-the-blog-slug/); 183 | }, 184 | testTimeout, 185 | ); 186 | 187 | test( 188 | `Multiple directory path segments [${environment}]`, 189 | async () => { 190 | // ACT 191 | const result = await axios.get( 192 | envURL( 193 | environment, 194 | `/api/blog/this-is-the-blog-slug/admin/this-is-the-action`, 195 | ), 196 | ); 197 | 198 | // ASSERT 199 | expect(result.status).toBe(200); 200 | expect(result.data).toMatch( 201 | /blogSlug: this-is-the-blog-slug, blogAction: this-is-the-action/, 202 | ); 203 | }, 204 | testTimeout, 205 | ); 206 | 207 | test( 208 | `Multiple directory path segments + filename path segment [${environment}]`, 209 | async () => { 210 | // ACT 211 | const result = await axios.get( 212 | envURL( 213 | environment, 214 | `/api/blog/this-is-the-blog-slug/admin/this-is-the-action/this-is-the-type`, 215 | ), 216 | ); 217 | 218 | // ASSERT 219 | expect(result.status).toBe(200); 220 | expect(result.data).toMatch( 221 | /blogSlug: this-is-the-blog-slug, blogAction: this-is-the-action, blogType: this-is-the-type/, 222 | ); 223 | }, 224 | testTimeout, 225 | ); 226 | 227 | test( 228 | `With file extension resolves [${environment}]`, 229 | async () => { 230 | // ACT 231 | const result = await axios.get( 232 | envURL(environment, `/api/hello-world.js`), 233 | ); 234 | 235 | // ASSERT 236 | expect(result.status).toBe(200); 237 | expect(result.data).toMatch(/Hello world/); 238 | }, 239 | testTimeout, 240 | ); 241 | 242 | test( 243 | `Adjacent "/" characters in request paths are treated as a single "/" [${environment}]`, 244 | async () => { 245 | // ACT 246 | const result = await axios.get( 247 | envURL(environment, `/api//blog//foo-bar///edit.js`), 248 | ); 249 | 250 | // ASSERT 251 | expect(result.status).toBe(200); 252 | expect(result.data).toMatch(/edit blogSlug: foo-bar/); 253 | }, 254 | testTimeout, 255 | ); 256 | 257 | test( 258 | `GET /api [${environment}]`, 259 | async () => { 260 | // ACT 261 | const result = await axios.get(envURL(environment, `/api`)); 262 | 263 | // ASSERT 264 | expect(result.data).toEqual('root'); 265 | }, 266 | testTimeout, 267 | ); 268 | 269 | test( 270 | `POST /api/method [${environment}]`, 271 | async () => { 272 | // ACT 273 | const result = await axios.post(envURL(environment, `/api/method`)); 274 | 275 | // ASSERT 276 | expect(result.data).toEqual('POST'); 277 | }, 278 | testTimeout, 279 | ); 280 | 281 | test( 282 | `POST /api/body [${environment}]`, 283 | async () => { 284 | // ACT 285 | const result = await axios({ 286 | method: 'post', 287 | url: envURL(environment, `/api/body`), 288 | data: { 289 | foo: 'bar', 290 | baz: 'bob', 291 | }, 292 | responseType: 'json', 293 | }); 294 | 295 | // ASSERT 296 | expect(result.data).toEqual({ 297 | foo: 'bar', 298 | baz: 'bob', 299 | }); 300 | }, 301 | testTimeout, 302 | ); 303 | 304 | test( 305 | `GET /api/ [${environment}]`, 306 | async () => { 307 | // ACT 308 | const result = await axios.get(envURL(environment, `/api/`)); 309 | 310 | // ASSERT 311 | expect(result.data).toEqual('root'); 312 | }, 313 | testTimeout, 314 | ); 315 | 316 | test( 317 | `GET /api/hello-world/ [${environment}]`, 318 | async () => { 319 | // ACT 320 | const result = await axios.get( 321 | envURL(environment, `/api/hello-world/`), 322 | ); 323 | 324 | // ASSERT 325 | expect(result.data).toEqual('Hello world'); 326 | }, 327 | testTimeout, 328 | ); 329 | 330 | test( 331 | `Invalid file extension results in 404 [${environment}]`, 332 | async () => { 333 | // ARRANGE 334 | expect.assertions(1); 335 | 336 | try { 337 | // ACT 338 | await axios.get(envURL(environment, `/api/hello-world.ts`)); 339 | } catch (err) { 340 | // ASSERT 341 | expect(err.response.status).toBe(404); 342 | } 343 | }, 344 | testTimeout, 345 | ); 346 | 347 | test( 348 | `POST /api/invalid-path [${environment}]`, 349 | async () => { 350 | expect.assertions(1); 351 | 352 | try { 353 | // ACT 354 | await axios.post(envURL(environment, `/api/invalid-path`)); 355 | } catch (err) { 356 | // ASSERT 357 | expect(err.response.status).toBe(404); 358 | } 359 | }, 360 | testTimeout, 361 | ); 362 | 363 | test( 364 | `API root path is case sensitive [${environment}]`, 365 | async () => { 366 | try { 367 | // ACT 368 | await axios.get(envURL(environment, `/Api/hello-world.js`)); 369 | } catch (err) { 370 | // ASSERT 371 | expect(err.response.status).toBe(404); 372 | } 373 | }, 374 | testTimeout, 375 | ); 376 | 377 | test( 378 | `API routes are case sensitive [${environment}]`, 379 | async () => { 380 | try { 381 | // ACT 382 | await axios.get(envURL(environment, `/api/HellO-WoRlD.js`)); 383 | } catch (err) { 384 | // ASSERT 385 | expect(err.response.status).toBe(404); 386 | } 387 | }, 388 | testTimeout, 389 | ); 390 | 391 | test(`404 on existing path mismatch should fall back to UI for body result [${environment}]`, async () => { 392 | expect.assertions(2); 393 | 394 | try { 395 | // ACT 396 | await axios.get(envURL(environment, `/api/HELLO-WORLD`)); 397 | } catch (err) { 398 | // ASSERT 399 | expect(err.response.status).toBe(404); 400 | expect(err.response.data).toMatch( 401 | environment === 'vercel-node-dev' 402 | ? /Cannot GET \/api\/HELLO-WORLD/ 403 | : /Web site created using create-react-app/, 404 | ); 405 | } 406 | }); 407 | 408 | test(`404 on non-existing API path should fall back to UI for body result [${environment}]`, async () => { 409 | expect.assertions(2); 410 | 411 | try { 412 | // ACT 413 | await axios.get(envURL(environment, `/api/invalid-path`)); 414 | } catch (err) { 415 | // ASSERT 416 | expect(err.response.status).toBe(404); 417 | expect(err.response.data).toMatch( 418 | environment === 'vercel-node-dev' 419 | ? /Cannot GET \/api\/invalid-path/ 420 | : /Web site created using create-react-app/, 421 | ); 422 | } 423 | }); 424 | 425 | test('TypeScript function', async () => { 426 | // ACT 427 | const result = await axios.get( 428 | envURL(environment, `/api/typescript-world`), 429 | ); 430 | 431 | // ASSERT 432 | expect(result.data).toEqual('Hello world'); 433 | }); 434 | }); 435 | 436 | describe('Routes configuration', () => { 437 | test( 438 | `GET /simple-route-to-dest [${environment}]`, 439 | async () => { 440 | // ACT 441 | const response = await axios.get( 442 | envURL(environment, `/simple-route-to-dest`), 443 | ); 444 | 445 | // ASSERT 446 | expect(response.status).toEqual(200); 447 | expect(response.data).toEqual('Hello world'); 448 | }, 449 | testTimeout, 450 | ); 451 | 452 | test( 453 | `GET /query-strings-stack [${environment}]`, 454 | async () => { 455 | // ACT 456 | const response = await axios.get( 457 | envURL(environment, `/query-strings-stack?external=foo`), 458 | ); 459 | 460 | // ASSERT 461 | expect(response.status).toEqual(200); 462 | expect(response.data).toEqual({ 463 | external: 'foo', 464 | internal: 'bar', 465 | }); 466 | }, 467 | testTimeout, 468 | ); 469 | 470 | test( 471 | `GET /query-strings-with-continue-stack [${environment}]`, 472 | async () => { 473 | // ACT 474 | const response = await axios.get( 475 | envURL( 476 | environment, 477 | `/query-strings-with-continue-stack?external=foo`, 478 | ), 479 | ); 480 | 481 | // ASSERT 482 | expect(response.status).toEqual(200); 483 | expect(response.data).toEqual({ 484 | external: 'foo', 485 | internalContinued: 'true', 486 | internal: 'bar', 487 | }); 488 | }, 489 | testTimeout, 490 | ); 491 | 492 | test( 493 | `GET /headers-with-numbered-groups/$1/$2 [${environment}]`, 494 | async () => { 495 | // ACT 496 | const response = await axios.get( 497 | envURL(environment, `/headers-with-numbered-groups/one/two`), 498 | ); 499 | 500 | // ASSERT 501 | expect(response.status).toEqual(200); 502 | expect(response.headers).toMatchObject({ 503 | 'x-my-custom-header-01': 'foo-one-two', 504 | 'x-my-custom-header-02': 'foo-one', 505 | 'x-my-custom-header-03': 'foo-two', 506 | }); 507 | expect(response.data).toEqual('Hello world'); 508 | }, 509 | testTimeout, 510 | ); 511 | 512 | // 😝 This is a strange one. Vercel cloud doesn't support named groups, only 513 | // numbered groups. We will of course have to match them! 514 | test( 515 | `GET /headers-with-named-groups-do-not-work/:first/:second [${environment}]`, 516 | async () => { 517 | // ACT 518 | const response = await axios.get( 519 | envURL(environment, `/headers-with-named-groups-do-not-work/one/two`), 520 | ); 521 | 522 | // ASSERT 523 | expect(response.status).toEqual(200); 524 | expect(response.headers).toMatchObject({ 525 | 'x-my-custom-header-01': 'foo-:first-:second', 526 | 'x-my-custom-header-02': 'foo-:first', 527 | 'x-my-custom-header-03': 'foo-:second', 528 | }); 529 | expect(response.data).toEqual('Hello world'); 530 | }, 531 | testTimeout, 532 | ); 533 | 534 | [301, 302, 303, 307, 308].forEach((redirectStatusCode) => { 535 | test( 536 | `GET /redirect-${redirectStatusCode} [${environment}]`, 537 | async () => { 538 | // ARRANGE 539 | expect.assertions(2); 540 | 541 | // ACT 542 | try { 543 | await axios.get( 544 | envURL(environment, `/redirect-${redirectStatusCode}`), 545 | { 546 | maxRedirects: 0, 547 | }, 548 | ); 549 | } catch (err) { 550 | // ASSERT 551 | expect(err.response.status).toEqual(redirectStatusCode); 552 | expect(err.response.headers['location']).toEqual( 553 | 'https://google.com', 554 | ); 555 | } 556 | }, 557 | testTimeout, 558 | ); 559 | }); 560 | 561 | test( 562 | `GET /redirect-with-group-in-location-header [${environment}]`, 563 | async () => { 564 | // ARRANGE 565 | expect.assertions(2); 566 | const groupMatch = 'foo-bar-baz'; 567 | 568 | // ACT 569 | try { 570 | await axios.get( 571 | envURL( 572 | environment, 573 | `/redirect-with-group-in-location-header/${groupMatch}`, 574 | ), 575 | { 576 | maxRedirects: 0, 577 | }, 578 | ); 579 | } catch (err) { 580 | // ASSERT 581 | expect(err.response.status).toEqual(301); 582 | expect(err.response.headers['location']).toEqual( 583 | `/api/articles/${groupMatch}`, 584 | ); 585 | } 586 | }, 587 | testTimeout, 588 | ); 589 | 590 | test( 591 | `GET /redirect-with-dest_the-dest-is-ignored [${environment}]`, 592 | async () => { 593 | // ARRANGE 594 | expect.assertions(3); 595 | 596 | // ACT 597 | try { 598 | await axios.get( 599 | envURL(environment, `/redirect-with-dest_the-dest-is-ignored`), 600 | { 601 | maxRedirects: 0, 602 | }, 603 | ); 604 | } catch (err) { 605 | // ASSERT 606 | expect(err.response.status).toEqual(301); 607 | expect(err.response.headers['location']).toEqual( 608 | `https://google.com`, 609 | ); 610 | expect(err.response.body).toEqual(undefined); 611 | } 612 | }, 613 | testTimeout, 614 | ); 615 | 616 | test( 617 | `GET /numbered-groups/$1/$2 [${environment}]`, 618 | async () => { 619 | // ACT 620 | const response = await axios.get( 621 | envURL(environment, `/numbered-groups/one/two`), 622 | ); 623 | 624 | // ASSERT 625 | expect(response.status).toEqual(200); 626 | expect(response.data).toEqual({ 627 | first: 'one', 628 | second: 'two', 629 | }); 630 | }, 631 | testTimeout, 632 | ); 633 | 634 | test( 635 | `GET /named-groups/:first/:second [${environment}]`, 636 | async () => { 637 | // ACT 638 | const response = await axios.get( 639 | envURL(environment, `/numbered-groups/one/two`), 640 | ); 641 | 642 | // ASSERT 643 | expect(response.status).toEqual(200); 644 | expect(response.data).toEqual({ 645 | first: 'one', 646 | second: 'two', 647 | }); 648 | }, 649 | testTimeout, 650 | ); 651 | 652 | test( 653 | `POST /restricted-to-post [${environment}]`, 654 | async () => { 655 | // ACT 656 | const response = await axios.post( 657 | envURL(environment, `/restricted-to-post`), 658 | ); 659 | 660 | // ASSERT 661 | expect(response.status).toEqual(200); 662 | expect(response.data).toEqual('POST'); 663 | }, 664 | testTimeout, 665 | ); 666 | 667 | test( 668 | `GET /restricted-to-post [${environment}]`, 669 | async () => { 670 | // ACT 671 | const result = await axios.get( 672 | envURL(environment, `/restricted-to-post`), 673 | { 674 | headers: { 675 | // 😱 Gosh this was a pain to figure out, but apparently for the 676 | // create-react-app development server to respond with the 677 | // index.html file if no public/static paths are matched you need 678 | // to attach this header. 679 | Accept: 'text/html', 680 | }, 681 | }, 682 | ); 683 | 684 | // ASSERT 685 | expect(result.status).toBe(200); 686 | expect(result.data).toMatch(/Web site created using create-react-app/); 687 | }, 688 | testTimeout, 689 | ); 690 | }); 691 | }); 692 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src", "test"], 3 | "exclude": ["test/fixtures"], 4 | "compilerOptions": { 5 | "allowJs": true, 6 | "allowSyntheticDefaultImports": true, 7 | "esModuleInterop": true, 8 | "lib": ["ESNext"], 9 | "module": "CommonJS", 10 | "moduleResolution": "node", 11 | "noEmit": true, 12 | "noFallthroughCasesInSwitch": true, 13 | "noImplicitReturns": true, 14 | "noUnusedLocals": true, 15 | "noUnusedParameters": true, 16 | "skipLibCheck": true, 17 | "strict": true 18 | } 19 | } 20 | --------------------------------------------------------------------------------