├── .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 |
23 |
24 |
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 | You need to enable JavaScript to run this app.
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 |
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 |
--------------------------------------------------------------------------------