[major] TypeScript rewrite, support ES Modules. (#706)

This commit is contained in:
Vedant K 2022-07-12 21:31:19 +05:30 committed by GitHub
parent 8949c70d68
commit d2a5187b36
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 6122 additions and 2171 deletions

4
.gitattributes vendored Normal file
View File

@ -0,0 +1,4 @@
# .gitattributes
# Makes sure all line endings are LF.
* text=auto eol=lf

31
.github/ISSUE_TEMPLATE/bug_report.yaml vendored Normal file
View File

@ -0,0 +1,31 @@
name: Report a bug
description: ———
labels: [bug]
body:
- type: markdown
attributes:
value: |
# Thanks for reporting this bug!
Help us replicate and find a fix for the issue by filling in this form.
- type: textarea
attributes:
label: Description
description: |
Describe the issue and how to replicate it. If possible, please include
a minimal example to reproduce the issue.
validations:
required: true
- type: input
attributes:
label: Library version
description: |
Output of the `serve --version` command
validations:
required: true
- type: input
attributes:
label: Node version
description: Output of the `node --version` command
validations:
required: true

View File

@ -0,0 +1,28 @@
name: Suggest an improvement or new feature
description: ———
labels: [enhancement]
body:
- type: markdown
attributes:
value: |
# Thanks for filing this feature request!
Help us understanding this feature and the need for it better by filling in this form.
- type: textarea
attributes:
label: Description
description: Describe the feature in detail
validations:
required: true
- type: textarea
attributes:
label: Why
description: Why should we add this feature? What are potential use cases for it?
validations:
required: true
- type: textarea
attributes:
label: Alternatives
description: Describe the alternatives you have considered, or existing workarounds
validations:
required: true

View File

@ -0,0 +1,56 @@
<!--
Hi there! Thanks for contributing! Please fill in this template to help us
review and merge the PR as quickly and easily as possible!
-->
## Related Issues
<!--
If this is a bug fix, or adds a feature mentioned in another issue, mention
it as follows:
- Closes #10
- Fixes #15
-->
## Description
<!--
Explain what has been added/changed/removed, in
[keepachangelog.com](https://keepachangelog.com) style.
-->
### Added
<!--
- Added a new method on the limiter object to reset the count for a certain IP [#10]
-->
### Changed
<!--
- Deprecated `global` option
- Fixed test for deprecated options [#15]
-->
### Removed
<!--
- Removed deprecated `headers` option
-->
## Caveats/Problems/Issues
<!--
Any weird code/problems you faced while making this PR. Feel free to ask for
help with anything, especially if it's your first time contributing!
-->
## Checklist
- [ ] The issues that this PR fixes/closes have been mentioned above.
- [ ] What this PR adds/changes/removes has been explained.
- [ ] All tests (`pnpm test`) pass.
- [ ] The linter (`pnpm lint`) does not throw an errors.
- [ ] All added/modified code has been commented, and
methods/classes/constants/types have been annotated with TSDoc comments.

View File

@ -1,20 +1,47 @@
# .github/workflows/ci.yaml
# Lints and tests the server every time a commit is pushed to the remote
# repository.
name: CI
on:
- push
- pull_request
on: [push, pull_request]
jobs:
test:
name: Node.js ${{ matrix.node-version }}
lint:
name: Lint
runs-on: ubuntu-latest
strategy:
matrix:
node-version:
- 16
- 14
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v1
- name: Checkout the repository
uses: actions/checkout@v2
- name: Setup PNPM 7
uses: pnpm/action-setup@v2.0.1
with:
node-version: ${{ matrix.node-version }}
- run: npm install
- run: npm test
version: latest
- name: Setup Node 18
uses: actions/setup-node@v2
with:
node-version: 18
registry-url: 'https://registry.npmjs.org/'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install
- name: Check for errors in code/formatting
run: pnpm lint
test:
name: Test
runs-on: ubuntu-latest
steps:
- name: Checkout the repository
uses: actions/checkout@v2
- name: Setup PNPM 7
uses: pnpm/action-setup@v2.0.1
with:
version: latest
- name: Setup Node 18
uses: actions/setup-node@v2
with:
node-version: 18
registry-url: 'https://registry.npmjs.org/'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install
- name: Run all tests
run: pnpm test

15
.gitignore vendored
View File

@ -1,3 +1,14 @@
node_modules
# .gitignore
# A list of files and folders that should not be tracked by Git.
node_modules/
coverage/
build/
.cache/
.idea/
.vscode/
*.log
.nyc_output
*.tgz
*.bak
*.tmp

9
.npmrc
View File

@ -1 +1,8 @@
save-exact = true
# .npmrc
# Configuration for pnpm.
# Uses the exact version instead of any within-patch-range version of an
# installed package.
save-exact=true
# Do not error out on missing peer dependencies.
strict-peer-dependencies=false

21
LICENSE
View File

@ -1,21 +0,0 @@
The MIT License (MIT)
Copyright (c) 2022 Vercel, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -1,87 +0,0 @@
![](https://assets.vercel.com/image/upload/v1527770721/repositories/serve/serve-repo-banner.png)
<a aria-label="Vercel logo" href="https://vercel.com">
<img src="https://img.shields.io/badge/MADE%20BY%20Vercel-000000.svg?style=for-the-badge&logo=ZEIT&labelColor=000000&logoWidth=20">
</a>
[![Build Status](https://circleci.com/gh/vercel/serve.svg?&style=shield)](https://circleci.com/gh/vercel/serve)
[![Install Size](https://packagephobia.now.sh/badge?p=serve)](https://packagephobia.now.sh/result?p=serve)
Assuming you would like to serve a static site, single page application or just a static file (no matter if on your device or on the local network), this package is just the right choice for you.
Once it's time to push your site to production, we recommend using [Vercel](https://vercel.com).
In general, `serve` also provides a neat interface for listing the directory's contents:
![Screenshot](https://user-images.githubusercontent.com/6170607/140353065-414bb2a7-33fb-4319-b359-f5e22edb860b.png)
## Usage
The quickest way to get started is to just run `npx serve` in your project's directory.
If you prefer, you can also install the package globally using [Yarn](https://yarnpkg.com/en/) (you'll need at least [Node.js LTS](https://nodejs.org/en/)):
```bash
yarn global add serve
```
Once that's done, you can run this command inside your project's directory...
```bash
serve
```
...or specify which folder you want to serve:
```bash
serve folder_name
```
Finally, run this command to see a list of all available options:
```bash
serve --help
```
Now you understand how the package works! :tada:
## Configuration
To customize `serve`'s behavior, create a `serve.json` file in the public folder and insert any of [these properties](https://github.com/vercel/serve-handler#options).
## API
The core of `serve` is [serve-handler](https://github.com/vercel/serve-handler), which can be used as middleware in existing HTTP servers:
```js
const handler = require('serve-handler');
const http = require('http');
const server = http.createServer((request, response) => {
// You pass two more arguments for config and middleware
// More details here: https://github.com/vercel/serve-handler#options
return handler(request, response);
});
server.listen(3000, () => {
console.log('Running at http://localhost:3000');
});
```
**NOTE:** You can also replace `http.createServer` with [micro](https://github.com/vercel/micro), if you want.
## Contributing
1. [Fork](https://help.github.com/articles/fork-a-repo/) this repository to your own GitHub account and then [clone](https://help.github.com/articles/cloning-a-repository/) it to your local device
2. Uninstall `serve` if it's already installed: `npm uninstall -g serve`
3. Link it to the global module directory: `npm link`
After that, you can use the `serve` command everywhere. [Here](https://github.com/vercel/serve/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+for+beginners%22)'s a list of issues that are great for beginners.
## Credits
This project used to be called "list" and "micro-list". But thanks to [TJ Holowaychuk](https://github.com/tj) handing us the new name, it's now called "serve" (which is much more definite).
## Author
Leo Lamprecht ([@notquiteleo](https://twitter.com/notquiteleo))

View File

@ -1,462 +0,0 @@
#!/usr/bin/env node
// Native
const http = require('http');
const https = require('https');
const path = require('path');
const fs = require('fs');
const {promisify} = require('util');
const {parse} = require('url');
const os = require('os');
// Packages
const Ajv = require('ajv');
const checkForUpdate = require('update-check');
const chalk = require('chalk');
const arg = require('arg');
const {write: copy} = require('clipboardy');
const handler = require('serve-handler');
const schema = require('@zeit/schemas/deployment/config-static');
const boxen = require('boxen');
const compression = require('compression');
// Utilities
const pkg = require('../package');
const readFile = promisify(fs.readFile);
const compressionHandler = promisify(compression());
const interfaces = os.networkInterfaces();
const warning = (message) => chalk`{yellow WARNING:} ${message}`;
const info = (message) => chalk`{magenta INFO:} ${message}`;
const error = (message) => chalk`{red ERROR:} ${message}`;
const updateCheck = async (isDebugging) => {
let update = null;
try {
update = await checkForUpdate(pkg);
} catch (err) {
const suffix = isDebugging ? ':' : ' (use `--debug` to see full error)';
console.error(warning(`Checking for updates failed${suffix}`));
if (isDebugging) {
console.error(err);
}
}
if (!update) {
return;
}
console.log(`${chalk.bgRed('UPDATE AVAILABLE')} The latest version of \`serve\` is ${update.latest}`);
};
const getHelp = () => chalk`
{bold.cyan serve} - Static file serving and directory listing
{bold USAGE}
{bold $} {cyan serve} --help
{bold $} {cyan serve} --version
{bold $} {cyan serve} folder_name
{bold $} {cyan serve} [-l {underline listen_uri} [-l ...]] [{underline directory}]
By default, {cyan serve} will listen on {bold 0.0.0.0:3000} and serve the
current working directory on that address.
Specifying a single {bold --listen} argument will overwrite the default, not supplement it.
{bold OPTIONS}
--help Shows this help message
-v, --version Displays the current version of serve
-l, --listen {underline listen_uri} Specify a URI endpoint on which to listen (see below) -
more than one may be specified to listen in multiple places
-p Specify custom port
-d, --debug Show debugging information
-s, --single Rewrite all not-found requests to \`index.html\`
-c, --config Specify custom path to \`serve.json\`
-C, --cors Enable CORS, sets \`Access-Control-Allow-Origin\` to \`*\`
-n, --no-clipboard Do not copy the local address to the clipboard
-u, --no-compression Do not compress files
--no-etag Send \`Last-Modified\` header instead of \`ETag\`
-S, --symlinks Resolve symlinks instead of showing 404 errors
--ssl-cert Optional path to an SSL/TLS certificate to serve with HTTPS
--ssl-key Optional path to the SSL/TLS certificate\'s private key
--ssl-pass Optional path to the SSL/TLS certificate\'s passphrase
--no-port-switching Do not open a port other than the one specified when it\'s taken.
{bold ENDPOINTS}
Listen endpoints (specified by the {bold --listen} or {bold -l} options above) instruct {cyan serve}
to listen on one or more interfaces/ports, UNIX domain sockets, or Windows named pipes.
For TCP ports on hostname "localhost":
{bold $} {cyan serve} -l {underline 1234}
For TCP (traditional host/port) endpoints:
{bold $} {cyan serve} -l tcp://{underline hostname}:{underline 1234}
For UNIX domain socket endpoints:
{bold $} {cyan serve} -l unix:{underline /path/to/socket.sock}
For Windows named pipe endpoints:
{bold $} {cyan serve} -l pipe:\\\\.\\pipe\\{underline PipeName}
`;
const parseEndpoint = (str) => {
if (!isNaN(str)) {
return [str];
}
// We cannot use `new URL` here, otherwise it will not
// parse the host properly and it would drop support for IPv6.
const url = parse(str);
switch (url.protocol) {
case 'pipe:': {
// some special handling
const cutStr = str.replace(/^pipe:/, '');
if (cutStr.slice(0, 4) !== '\\\\.\\') {
throw new Error(`Invalid Windows named pipe endpoint: ${str}`);
}
return [cutStr];
}
case 'unix:':
if (!url.pathname) {
throw new Error(`Invalid UNIX domain socket endpoint: ${str}`);
}
return [url.pathname];
case 'tcp:':
url.port = url.port || '3000';
return [parseInt(url.port, 10), url.hostname];
default:
throw new Error(`Unknown --listen endpoint scheme (protocol): ${url.protocol}`);
}
};
const registerShutdown = (fn) => {
let run = false;
const wrapper = () => {
if (!run) {
run = true;
fn();
}
};
process.on('SIGINT', wrapper);
process.on('SIGTERM', wrapper);
process.on('exit', wrapper);
};
const getNetworkAddress = () => {
for (const name of Object.keys(interfaces)) {
for (const interface of interfaces[name]) {
const {address, family, internal} = interface;
if (family === 'IPv4' && !internal) {
return address;
}
}
}
};
const startEndpoint = (endpoint, config, args, previous) => {
const {isTTY} = process.stdout;
const clipboard = args['--no-clipboard'] !== true;
const compress = args['--no-compression'] !== true;
const httpMode = args['--ssl-cert'] && args['--ssl-key'] ? 'https' : 'http';
const serverHandler = async (request, response) => {
if (args['--cors']) {
response.setHeader('Access-Control-Allow-Origin', '*');
}
if (compress) {
await compressionHandler(request, response);
}
return handler(request, response, config);
};
const sslPass = args['--ssl-pass'];
const server = httpMode === 'https'
? https.createServer({
key: fs.readFileSync(args['--ssl-key']),
cert: fs.readFileSync(args['--ssl-cert']),
passphrase: sslPass ? fs.readFileSync(sslPass, "utf8") : ''
}, serverHandler)
: http.createServer(serverHandler);
server.on('error', (err) => {
if (err.code === 'EADDRINUSE' && endpoint.length === 1 && !isNaN(endpoint[0]) && args['--no-port-switching'] !== true) {
startEndpoint([0], config, args, endpoint[0]);
return;
}
console.error(error(`Failed to serve: ${err.stack}`));
process.exit(1);
});
server.listen(...endpoint, async () => {
const details = server.address();
registerShutdown(() => server.close());
let localAddress = null;
let networkAddress = null;
if (typeof details === 'string') {
localAddress = details;
} else if (typeof details === 'object' && details.port) {
const address = details.address === '::' ? 'localhost' : details.address;
const ip = getNetworkAddress();
localAddress = `${httpMode}://${address}:${details.port}`;
networkAddress = ip ? `${httpMode}://${ip}:${details.port}` : null;
}
if (isTTY && process.env.NODE_ENV !== 'production') {
let message = chalk.green('Serving!');
if (localAddress) {
const prefix = networkAddress ? '- ' : '';
const space = networkAddress ? ' ' : ' ';
message += `\n\n${chalk.bold(`${prefix}Local:`)}${space}${localAddress}`;
}
if (networkAddress) {
message += `\n${chalk.bold('- On Your Network:')} ${networkAddress}`;
}
if (previous) {
message += chalk.red(`\n\nThis port was picked because ${chalk.underline(previous)} is in use.`);
}
if (clipboard) {
try {
await copy(localAddress);
message += `\n\n${chalk.grey('Copied local address to clipboard!')}`;
} catch (err) {
console.error(error(`Cannot copy to clipboard: ${err.message}`));
}
}
console.log(boxen(message, {
padding: 1,
borderColor: 'green',
margin: 1
}));
} else {
const suffix = localAddress ? ` at ${localAddress}` : '';
console.log(info(`Accepting connections${suffix}`));
}
});
};
const loadConfig = async (cwd, entry, args) => {
const files = [
'serve.json',
'now.json',
'package.json'
];
if (args['--config']) {
files.unshift(args['--config']);
}
const config = {};
for (const file of files) {
const location = path.resolve(entry, file);
let content = null;
try {
content = await readFile(location, 'utf8');
} catch (err) {
if (err.code === 'ENOENT' && file !== args['--config']) {
continue;
}
console.error(error(`Not able to read ${location}: ${err.message}`));
process.exit(1);
}
try {
content = JSON.parse(content);
} catch (err) {
console.error(error(`Could not parse ${location} as JSON: ${err.message}`));
process.exit(1);
}
if (typeof content !== 'object') {
console.error(warning(`Didn't find a valid object in ${location}. Skipping...`));
continue;
}
try {
switch (file) {
case 'now.json':
content = content.static;
break;
case 'package.json':
content = content.now.static;
break;
}
} catch (err) {
continue;
}
Object.assign(config, content);
console.log(info(`Discovered configuration in \`${file}\``));
if (file === 'now.json' || file === 'package.json') {
console.error(warning('The config files `now.json` and `package.json` are deprecated. Please use `serve.json`.'));
}
break;
}
if (entry) {
const {public} = config;
config.public = path.relative(cwd, (public ? path.resolve(entry, public) : entry));
}
if (Object.keys(config).length !== 0) {
const ajv = new Ajv();
const validateSchema = ajv.compile(schema);
if (!validateSchema(config)) {
const defaultMessage = error('The configuration you provided is wrong:');
const {message, params} = validateSchema.errors[0];
console.error(`${defaultMessage}\n${message}\n${JSON.stringify(params)}`);
process.exit(1);
}
}
// "ETag" headers are enabled by default unless `--no-etag` is provided
config.etag = !args['--no-etag'];
return config;
};
(async () => {
let args = null;
try {
args = arg({
'--help': Boolean,
'--version': Boolean,
'--listen': [parseEndpoint],
'--single': Boolean,
'--debug': Boolean,
'--config': String,
'--no-clipboard': Boolean,
'--no-compression': Boolean,
'--no-etag': Boolean,
'--symlinks': Boolean,
'--cors': Boolean,
'--no-port-switching': Boolean,
'--ssl-cert': String,
'--ssl-key': String,
'--ssl-pass': String,
'-h': '--help',
'-v': '--version',
'-l': '--listen',
'-s': '--single',
'-d': '--debug',
'-c': '--config',
'-n': '--no-clipboard',
'-u': '--no-compression',
'-S': '--symlinks',
'-C': '--cors',
// This is deprecated and only for backwards-compatibility.
'-p': '--listen'
});
} catch (err) {
console.error(error(err.message));
process.exit(1);
}
if (process.env.NO_UPDATE_CHECK !== '1') {
await updateCheck(args['--debug']);
}
if (args['--version']) {
console.log(pkg.version);
return;
}
if (args['--help']) {
console.log(getHelp());
return;
}
if (!args['--listen']) {
// Default endpoint
args['--listen'] = [[process.env.PORT || 3000]];
}
if (args._.length > 1) {
console.error(error('Please provide one path argument at maximum'));
process.exit(1);
}
const cwd = process.cwd();
const entry = args._.length > 0 ? path.resolve(args._[0]) : cwd;
const config = await loadConfig(cwd, entry, args);
if (args['--single']) {
const {rewrites} = config;
const existingRewrites = Array.isArray(rewrites) ? rewrites : [];
// As the first rewrite rule, make `--single` work
config.rewrites = [{
source: '**',
destination: '/index.html'
}, ...existingRewrites];
}
if (args['--symlinks']) {
config.symlinks = true;
}
for (const endpoint of args['--listen']) {
startEndpoint(endpoint, config, args);
}
registerShutdown(() => {
console.log(`\n${info('Gracefully shutting down. Please wait...')}`);
process.on('SIGINT', () => {
console.log(`\n${warning('Force-closing all open sockets...')}`);
process.exit(0);
});
});
})();

8
config/husky/pre-commit Executable file
View File

@ -0,0 +1,8 @@
#!/bin/sh
# config/husky/pre-commit
# Run `lint-staged` before every commit.
. "$(dirname "$0")/_/husky.sh"
FORCE_COLOR=2 pnpm lint-staged

205
contributing.md Normal file
View File

@ -0,0 +1,205 @@
# Contributing Guide
Thanks for your interest in contributing to `serve`! This guide will
show you how to set up your environment and contribute to this library.
## Set Up
First, you need to install and be familiar the following:
- `git`: [Here](https://github.com/git-guides) is a great guide by GitHub on
installing and getting started with Git.
- `node` and `pnpm`:
[This guide](https://nodejs.org/en/download/package-manager/) will help you
install Node and [this one](https://pnpm.io/installation) will help you install PNPM. The
recommended method is using the `n` version manager if you are on MacOS or Linux. Make sure
you are using the [`current` version](https://github.com/nodejs/Release#release-schedule) of
Node.
Once you have installed the above, follow
[these instructions](https://docs.github.com/en/get-started/quickstart/fork-a-repo)
to
[`fork`](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks)
and [`clone`](https://github.com/git-guides/git-clone) the repository
(`vercel/serve`).
Once you have forked and cloned the repository, you can
[pick out an issue](https://github.com/vercel/serve/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-desc)
you want to fix/implement!
## Making Changes
Once you have cloned the repository to your computer (say, in
`~/code/serve`) and picked the issue you want to tackle, create a
branch:
```sh
> git switch --create branch-name
```
While naming your branch, try to follow the below guidelines:
1. Prefix the branch name with the type of change being made:
- `fix`: For a bug fix.
- `feat`: For a new feature.
- `test`: For any change related to tests.
- `perf`: For a performance related change.
- `build`: For changes related to the build process.
- `ci`: For all changes to the CI files.
- `refc`: For any refactoring work.
- `docs`: For any documentation related changes.
2. Make the branch name short but self-explanatory.
Once you have created a branch, you can start coding!
The CLI is written in
[Typescript](https://github.com/microsoft/TypeScript#readme) and uses the
[`current` version](https://github.com/nodejs/Release#release-schedule) of Node.
The code is structured as follows:
```sh
serve
├── config
│ └── husky
│ ├── _
│ └── pre-commit
├── media
│ ├── banner.png
│ └── listing-ui.png
├── source
│ ├── utilities
│ │ ├── cli.ts
│ │ ├── config.ts
│ │ ├── http.ts
│ │ ├── logger.ts
│ │ ├── promise.ts
│ │ └── server.ts
│ ├── main.ts
│ └── types.ts
├── contributing.md
├── license.md
├── package.json
├── pnpm-lock.yaml
├── readme.md
└── tsconfig.json
```
> Most files have a little description of what they do at the top.
#### `./`
- `package.json`: Node package manifest. This file contains the name, version,
description, dependencies, scripts and package configuration of the project.
- `pnpm-lock.yaml`: PNPM lock file, please do not modify it manually. Run
`pnpm install` to update it if you add/remove a dependency to/from
`package.json` manually.
- `tsconfig.json`: The Typescript configuration for this project.
- `contributing.md`: The file you are reading. It helps contributors get
started.
- `license.md`: Tells people how they can use the code.
- `readme.md`: The file everyone should read before running the server. Contains
installation and usage instructions.
#### `config/husky/`
- `pre-commit`: This file is a script that runs before Git commits code.
#### `source/utilities/`
- `utilities/config.ts`: Searches and parses the configuration for the CLI.
- `utilities/http.ts`: Defines and exports helper functions for the server.
- `utilities/server.ts`: Exports a function used to start the server with a
given configuration on a certain port.
- `utilities/promise.ts`: Exports utility functions and wrappers that help
resolve `Promise`s.
- `utilities/cli.ts`: Exports functions that help with CLI-related stuff, e.g.,
parsing arguments and printing help text.
- `utilities/logger.ts`: A barebones logger.
#### `source/`
- `main.ts`: Entrypoint for the CLI.
- `types.ts`: Typescript types used in the project.
When adding a new feature/fixing a bug, please add/update the readme. Also make
sure your code has been linted and that existing tests pass. You can run the linter
using `pnpm lint`, the tests using `pnpm test` and try to automatically fix most lint
issues using `pnpm lint --fix`.
You can run the CLI tool using `pnpm develop`, which will re-run the CLI everytime you
save changes made to the code.
Once you have made changes to the code, you will want to
[`commit`](https://github.com/git-guides/git-commit) (basically, Git's version
of save) the changes. To commit the changes you have made locally:
```sh
> git add this/folder that/file
> git commit --message 'commit-message'
```
While writing the `commit-message`, try to follow the below guidelines:
1. Prefix the message with `type:`, where `type` is one of the following
dependending on what the commit does:
- `fix`: Introduces a bug fix.
- `feat`: Adds a new feature.
- `test`: Any change related to tests.
- `perf`: Any performance related change.
- `build`: For changes related to the build process.
- `ci`: For all changes to the CI files.
- `refc`: Any refactoring work.
- `docs`: Any documentation related changes.
2. Keep the first line brief, and less than 60 characters.
3. Try describing the change in detail in a new paragraph (double newline after
the first line).
## Contributing Changes
Once you have committed your changes, you will want to
[`push`](https://github.com/git-guides/git-push) (basically, publish your
changes to GitHub) your commits. To push your changes to your fork:
```sh
> git push -u origin branch-name
```
If there are changes made to the `main` branch of the
`vercel/serve` repository, you may wish to
[`rebase`](https://docs.github.com/en/get-started/using-git/about-git-rebase)
your branch to include those changes. To rebase, or include the changes from the
`main` branch of the `vercel/serve` repository:
```
> git fetch upstream main
> git rebase upstream/main
```
This will automatically add the changes from `main` branch of the
`vercel/serve` repository to the current branch. If you encounter
any merge conflicts, follow
[this guide](https://docs.github.com/en/get-started/using-git/resolving-merge-conflicts-after-a-git-rebase)
to resolve them.
Once you have pushed your changes to your fork, follow
[these instructions](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request-from-a-fork)
to open a
[`pull request`](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/about-pull-requests):
Once you have submitted a pull request, the maintainers of the repository will
review your pull requests. Whenever a maintainer reviews a pull request they may
request changes. These may be small, such as fixing a typo, or may involve
substantive changes. Such requests are intended to be helpful, but at times may
come across as abrupt or unhelpful, especially if they do not include concrete
suggestions on how to change them. Try not to be discouraged. If you feel that a
review is unfair, say so or seek the input of another project contributor. Often
such comments are the result of a reviewer having taken insufficient time to
review and are not ill-intended. Such difficulties can often be resolved with a
bit of patience. That said, reviewers should be expected to provide helpful
feedback.
In order to land, a pull request needs to be reviewed and approved by at least
one maintainer and pass CI. After that, if there are no objections from other
contributors, the pull request can be merged.
#### Congratulations and thanks for your contribution!

20
license.md Normal file
View File

@ -0,0 +1,20 @@
# The MIT License (MIT)
Copyright (c) 2022 Vercel, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

BIN
media/banner.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

BIN
media/listing-ui.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

1565
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -2,32 +2,83 @@
"name": "serve",
"version": "13.0.4",
"description": "Static file serving and directory listing",
"scripts": {
"test": ""
},
"files": [
"bin"
],
"repository": "vercel/serve",
"bin": {
"serve": "./bin/serve.js"
},
"keywords": [
"vercel",
"serve",
"micro",
"http-server"
],
"repository": "vercel/serve",
"license": "MIT",
"type": "module",
"bin": {
"serve": "./build/main.js"
},
"files": [
"build/"
],
"engines": {
"node": ">= 14"
},
"scripts": {
"develop": "tsx watch ./source/main.ts",
"start": "node ./build/main.js",
"compile": "tsup ./source/main.ts",
"test:tsc": "tsc --project tsconfig.json",
"test": "pnpm test:tsc",
"lint:code": "eslint --max-warnings 0 source/**/*.ts",
"lint:style": "prettier --check --ignore-path .gitignore .",
"lint": "pnpm lint:code && pnpm lint:style",
"format": "prettier --write --ignore-path .gitignore .",
"prepare": "husky install config/husky && pnpm compile"
},
"dependencies": {
"@zeit/schemas": "2.6.0",
"ajv": "6.12.6",
"arg": "2.0.0",
"boxen": "5.1.2",
"chalk": "2.4.1",
"clipboardy": "2.3.0",
"compression": "1.7.3",
"@zeit/schemas": "2.21.0",
"ajv": "8.11.0",
"arg": "5.0.2",
"boxen": "7.0.0",
"chalk": "5.0.1",
"clipboardy": "3.0.0",
"compression": "1.7.4",
"is-port-reachable": "4.0.0",
"serve-handler": "6.1.3",
"update-check": "1.5.2"
"update-check": "1.5.4"
},
"devDependencies": {
"@types/compression": "1.7.2",
"@types/serve-handler": "6.1.1",
"@vercel/style-guide": "3.0.0",
"eslint": "8.19.0",
"husky": "8.0.1",
"lint-staged": "13.0.3",
"prettier": "2.7.1",
"tsup": "6.1.3",
"tsx": "3.7.1",
"typescript": "4.6.4"
},
"tsup": {
"target": "esnext",
"format": [
"esm"
],
"outDir": "./build/"
},
"prettier": "@vercel/style-guide/prettier",
"eslintConfig": {
"extends": [
"./node_modules/@vercel/style-guide/eslint/node.js",
"./node_modules/@vercel/style-guide/eslint/typescript.js"
],
"parserOptions": {
"project": "tsconfig.json"
}
},
"lint-staged": {
"*": [
"prettier --ignore-unknown --write"
],
"source/**/*.ts": [
"eslint --max-warnings 0 --fix"
]
}
}

4715
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

96
readme.md Normal file
View File

@ -0,0 +1,96 @@
![Serve Logo](https://raw.githubusercontent.com/vercel/serve/main/media/banner.png)
<div align="center">
<a aria-label="Vercel logo" href="https://vercel.com">
<img src="https://img.shields.io/badge/made%20by-vercel-%23000000">
</a>
<br>
<a aria-label="Install Size" href="https://packagephobia.com/result?p=serve">
<img src="https://packagephobia.com/badge?p=serve">
</a>
<a aria-label="Stars" href="https://github.com/vercel/serve/stargazers">
<img src="https://img.shields.io/github/stars/vercel/serve">
</a>
<a aria-label="Build Status" href="https://github.com/vercel/serve/actions/workflows/ci.yaml">
<img src="https://github.com/vercel/serve/actions/workflows/ci.yaml/badge.svg">
</a>
</div>
---
`serve` helps you serve a static site, single page application or just a static file (no matter if on your device or on the local network). It also provides a neat interface for listing the directory's contents:
![Listing UI](https://raw.githubusercontent.com/vercel/serve/main/media/listing-ui.png)
> Once it's time to push your site to production, we recommend using [Vercel](https://vercel.com).
## Usage
The quickest way to get started is to just run `npx serve` in your project's directory.
If you prefer, you can also install the package globally (you'll need at least [Node LTS](https://github.com/nodejs/Release#release-schedule)):
```bash
> npm install --global serve
```
Once that's done, you can run this command inside your project's directory...
```bash
> serve
```
...or specify which folder you want to serve:
```bash
> serve folder-name/
```
Finally, run this command to see a list of all available options:
```bash
> serve --help
```
Now you understand how the package works! :tada:
## Configuration
To customize `serve`'s behavior, create a `serve.json` file in the public folder and insert any of [these properties](https://github.com/vercel/serve-handler#options).
## API
The core of `serve` is [serve-handler](https://github.com/vercel/serve-handler), which can be used as middleware in existing HTTP servers:
```js
const handler = require('serve-handler');
const http = require('http');
const server = http.createServer((request, response) => {
// You pass two more arguments for config and middleware
// More details here: https://github.com/vercel/serve-handler#options
return handler(request, response);
});
server.listen(3000, () => {
console.log('Running at http://localhost:3000');
});
```
> **Note**
>
> You can also replace `http.createServer` with [micro](https://github.com/vercel/micro).
## Issues and Contributing
If you want a feature to be added, or wish to report a bug, please open an issue [here](https://github.com/vercel/serve/issues/new).
If you wish to contribute to the project, please read the [contributing guide](contributing.md) first.
## Credits
This project used to be called "list" and "micro-list". But thanks to [TJ Holowaychuk](https://github.com/tj) handing us the new name, it's now called "serve" (which is much more definite).
## Author
Leo Lamprecht ([@notquiteleo](https://twitter.com/notquiteleo))

172
source/main.ts Executable file
View File

@ -0,0 +1,172 @@
#!/usr/bin/env node
// source/main.ts
// The CLI for the `serve-handler` module.
import path from 'node:path';
import chalk from 'chalk';
import boxen from 'boxen';
import clipboard from 'clipboardy';
import checkForUpdate from 'update-check';
import manifest from '../package.json';
import { resolve } from './utilities/promise.js';
import { startServer } from './utilities/server.js';
import { registerCloseListener } from './utilities/http.js';
import { parseArguments, getHelpText } from './utilities/cli.js';
import { loadConfiguration } from './utilities/config.js';
import { logger } from './utilities/logger.js';
import type { Arguments } from './types.js';
/**
* Checks for updates to this package. If an update is available, it brings it
* to the user's notice by printing a message to the console.
*
* @param debugMode - Whether or not we should print additional debug information.
* @returns
*/
const printUpdateNotification = async (debugMode?: boolean) => {
const [error, update] = await resolve(checkForUpdate(manifest));
if (error) {
const suffix = debugMode ? ':' : ' (use `--debug` to see full error)';
logger.warn(`Checking for updates failed${suffix}`);
if (debugMode) logger.error(error.message);
}
if (!update) return;
logger.log(
chalk` {bgRed.white UPDATE } The latest version of \`serve\` is ${update.latest}`,
);
};
// Parse the options passed by the user.
let args: Arguments;
try {
args = parseArguments();
} catch (error: unknown) {
logger.error((error as Error).message);
process.exit(1);
}
// Check for updates to the package unless the user sets the `NO_UPDATE_CHECK`
// variable.
if (process.env.NO_UPDATE_CHECK !== '1')
await printUpdateNotification(args['--debug']);
// If the `version` or `help` arguments are passed, print the version or the
// help text and exit.
if (args['--version']) {
logger.log(manifest.version);
process.exit(0);
}
if (args['--help']) {
logger.log(getHelpText());
process.exit(0);
}
// Default to listening on port 3000.
if (!args['--listen'])
args['--listen'] = [
[process.env.PORT ? parseInt(process.env.PORT, 10) : 3000],
];
// Ensure that the user has passed only one directory to serve.
if (args._.length > 1) {
logger.error('Please provide one path argument at maximum');
process.exit(1);
}
// Warn the user about using deprecated configuration files.
if (args['--config'] === 'now.json' || args['--config'] === 'package.json')
logger.warn(
'The config files `now.json` and `package.json` are deprecated. Please use `serve.json`.',
);
// Parse the configuration.
const cwd = process.cwd();
const entry = args._[0] ? path.resolve(args._[0]) : cwd;
const config = await loadConfiguration(cwd, entry, args);
// If the user wants all the URLs rewritten to `/index.html`, make it happen.
if (args['--single']) {
const { rewrites } = config;
const existingRewrites = Array.isArray(rewrites) ? rewrites : [];
// Ensure this is the first rewrite rule so it gets priority.
config.rewrites = [
{
source: '**',
destination: '/index.html',
},
...existingRewrites,
];
}
// Start the server for each endpoint passed by the user.
for (const endpoint of args['--listen']) {
// Disabling this rule as we want to start each server one by one.
// eslint-disable-next-line no-await-in-loop
const { local, network, previous } = await startServer(
endpoint,
config,
args,
);
const copyAddress = !args['--no-clipboard'];
// If we are not in a TTY or Node is running in production mode, print
// a single line of text with the server address.
if (!process.stdout.isTTY || process.env.NODE_ENV === 'production') {
const suffix = local ? ` at ${local}` : '';
logger.info(`Accepting connections${suffix}`);
continue;
}
// Else print a fancy box with the server address.
let message = chalk.green('Serving!');
if (local) {
const prefix = network ? '- ' : '';
const space = network ? ' ' : ' ';
message += `\n\n${chalk.bold(`${prefix}Local:`)}${space}${local}`;
}
if (network) message += `\n${chalk.bold('- On Your Network:')} ${network}`;
if (previous)
message += chalk.red(
`\n\nThis port was picked because ${chalk.underline(
previous.toString(),
)} is in use.`,
);
// Try to copy the address to the user's clipboard too.
if (copyAddress && local) {
try {
// eslint-disable-next-line no-await-in-loop
await clipboard.write(local);
message += `\n\n${chalk.grey('Copied local address to clipboard!')}`;
} catch (error: unknown) {
logger.error(`Cannot copy to clipboard: ${(error as Error).message}`);
}
}
logger.log(
boxen(message, {
padding: 1,
borderColor: 'green',
margin: 1,
}),
);
}
// Print out a message to let the user know we are shutting down the server
// when they press Ctrl+C or kill the process externally.
registerCloseListener(() => {
logger.log();
logger.info('Gracefully shutting down. Please wait...');
process.on('SIGINT', () => {
logger.log();
logger.warn('Force-closing all open sockets...');
process.exit(0);
});
});

89
source/types.ts Normal file
View File

@ -0,0 +1,89 @@
// source/types.ts
// Type definitions for the CLI.
// An error thrown by any native Node modules.
export declare interface NodeError extends Error {
code: string;
}
// A path to a file/remote resource.
export declare type Path = string;
// The port to bind the server on.
export declare type Port = number;
// The name of the host.
export declare type Host = string;
// The address of the server.
export declare interface ServerAddress {
local?: string;
network?: string;
previous?: number;
}
// The endpoint the server should listen on.
export declare type ListenEndpoint =
| number
| `tcp://${Host}:${Port}`
| `unix:${Path}`
| `pipe:\\\\.\\pipe\\${Host}`;
// The parsed endpoints.
export declare type ParsedEndpoint = [Port] | [Host] | [Port, Host];
// An entry for URL rewrites.
export declare interface Rewrite {
source: string;
destination: string;
}
// An entry for redirecting a URL.
export declare type Redirect = Rewrite & {
type: number;
};
// An entry to send headers for.
export declare interface Header {
source: string;
headers: {
key: string;
value: string;
}[];
}
// The configuration for the CLI.
export declare interface Configuration {
public: Path;
cleanUrls: boolean | Path[];
rewrites: Rewrite[];
redirects: Redirect[];
headers: Header[];
directoryListing: boolean | Path[];
unlisted: Path[];
trailingSlash: boolean;
renderSingle: boolean;
symlinks: boolean;
etag: boolean;
}
// The options you can pass to the CLI.
export declare interface Options {
'--help': boolean;
'--version': boolean;
'--listen': ParsedEndpoint[];
'--single': boolean;
'--debug': boolean;
'--config': Path;
'--no-clipboard': boolean;
'--no-compression': boolean;
'--no-etag': boolean;
'--symlinks': boolean;
'--cors': boolean;
'--no-port-switching': boolean;
'--ssl-cert': Path;
'--ssl-key': Path;
'--ssl-pass': string;
}
// The arguments passed to the CLI (the options + the positional arguments)
export declare type Arguments = Partial<Options> & {
_: string[];
};

127
source/utilities/cli.ts Normal file
View File

@ -0,0 +1,127 @@
// source/utilities/cli.ts
// CLI-related utility functions.
import chalk from 'chalk';
import parseArgv from 'arg';
import { parseEndpoint } from './http.js';
import type { Arguments } from '../types.js';
// The options the CLI accepts, and how to parse them.
const options = {
'--help': Boolean,
'--version': Boolean,
'--listen': [parseEndpoint] as [typeof parseEndpoint],
'--single': Boolean,
'--debug': Boolean,
'--config': String,
'--no-clipboard': Boolean,
'--no-compression': Boolean,
'--no-etag': Boolean,
'--symlinks': Boolean,
'--cors': Boolean,
'--no-port-switching': Boolean,
'--ssl-cert': String,
'--ssl-key': String,
'--ssl-pass': String,
// A list of aliases for the above options.
'-h': '--help',
'-v': '--version',
'-l': '--listen',
'-s': '--single',
'-d': '--debug',
'-c': '--config',
'-n': '--no-clipboard',
'-u': '--no-compression',
'-S': '--symlinks',
'-C': '--cors',
// The `-p` option is deprecated and is kept only for backwards-compatibility.
'-p': '--listen',
};
// The help text for the CLI.
const helpText = chalk`
{bold.cyan serve} - Static file serving and directory listing
{bold USAGE}
{bold $} {cyan serve} --help
{bold $} {cyan serve} --version
{bold $} {cyan serve} folder_name
{bold $} {cyan serve} [-l {underline listen_uri} [-l ...]] [{underline directory}]
By default, {cyan serve} will listen on {bold 0.0.0.0:3000} and serve the
current working directory on that address.
Specifying a single {bold --listen} argument will overwrite the default, not supplement it.
{bold OPTIONS}
--help Shows this help message
-v, --version Displays the current version of serve
-l, --listen {underline listen_uri} Specify a URI endpoint on which to listen (see below) -
more than one may be specified to listen in multiple places
-p Specify custom port
-d, --debug Show debugging information
-s, --single Rewrite all not-found requests to \`index.html\`
-c, --config Specify custom path to \`serve.json\`
-C, --cors Enable CORS, sets \`Access-Control-Allow-Origin\` to \`*\`
-n, --no-clipboard Do not copy the local address to the clipboard
-u, --no-compression Do not compress files
--no-etag Send \`Last-Modified\` header instead of \`ETag\`
-S, --symlinks Resolve symlinks instead of showing 404 errors
--ssl-cert Optional path to an SSL/TLS certificate to serve with HTTPS
--ssl-key Optional path to the SSL/TLS certificate\'s private key
--ssl-pass Optional path to the SSL/TLS certificate\'s passphrase
--no-port-switching Do not open a port other than the one specified when it\'s taken.
{bold ENDPOINTS}
Listen endpoints (specified by the {bold --listen} or {bold -l} options above) instruct {cyan serve}
to listen on one or more interfaces/ports, UNIX domain sockets, or Windows named pipes.
For TCP ports on hostname "localhost":
{bold $} {cyan serve} -l {underline 1234}
For TCP (traditional host/port) endpoints:
{bold $} {cyan serve} -l tcp://{underline hostname}:{underline 1234}
For UNIX domain socket endpoints:
{bold $} {cyan serve} -l unix:{underline /path/to/socket.sock}
For Windows named pipe endpoints:
{bold $} {cyan serve} -l pipe:\\\\.\\pipe\\{underline PipeName}
`;
/**
* Parses the program's `process.argv` and returns the options and arguments.
*
* @returns The parsed options and arguments.
*/
export const parseArguments = (): Arguments => parseArgv(options);
/**
* Returns the help text.
*
* @returns The help text shown when the `--help` option is used.
*/
export const getHelpText = (): string => helpText;

134
source/utilities/config.ts Normal file
View File

@ -0,0 +1,134 @@
// source/utilities/config.ts
// Parse and return the configuration for the CLI.
import {
resolve as resolvePath,
relative as resolveRelativePath,
} from 'node:path';
import { readFile } from 'node:fs/promises';
import Ajv from 'ajv';
// @ts-expect-error No type definitions.
import schema from '@zeit/schemas/deployment/config-static.js';
import { resolve } from './promise.js';
import type { ErrorObject } from 'ajv';
import type { Configuration, Options, NodeError } from '../types.js';
/**
* Parses and returns a configuration object from the designated locations.
*
* @param cwd - The current working directory.
* @param entry - The directory to serve.
* @param args - The arguments passed to the CLI.
*
* @returns The parsed configuration.
*/
export const loadConfiguration = async (
cwd: string,
entry: string,
args: Partial<Options>,
): Promise<Configuration> => {
const files = ['serve.json', 'now.json', 'package.json'];
if (args['--config']) files.unshift(args['--config']);
const config: Partial<Configuration> = {};
for (const file of files) {
// Resolve the path to the configuration file relative to the directory
// with the content in it.
const location = resolvePath(entry, file);
// Disabling the lint rule as we don't want to read all the files at once;
// if we can retrieve the configuration from the first file itself, we
// shouldn't waste time and resources fetching the other files too.
// eslint-disable-next-line no-await-in-loop
const [error, rawContents] = await resolve<string, NodeError>(
readFile(location, 'utf8'),
);
if (error) {
if (error.code === 'ENOENT' && file !== args['--config']) continue;
else
throw new Error(
`Could not read configuration from file ${location}: ${error.message}`,
);
}
// The configuration can come from three files in different forms:
// - now.json: `/now/static`
// - package.json: `/static`
// - serve.json: `/`
type ServeConfigurationFile = Partial<Configuration>;
interface ManifestConfigurationFile {
static?: Partial<Configuration>;
}
interface NowConfigurationFile {
now: { static?: Partial<Configuration> };
}
type ParseableConfiguration =
| ServeConfigurationFile
| ManifestConfigurationFile
| NowConfigurationFile
| undefined;
// Parse the JSON in the file. If the parsed JSON is not an object, or the
// file does not contain valid JSON, throw an error.
let parsedJson: ParseableConfiguration;
try {
parsedJson = JSON.parse(rawContents) as ParseableConfiguration;
if (typeof parsedJson !== 'object')
throw new Error('configuration is not an object');
} catch (parserError: unknown) {
throw new Error(
`Could not parse ${location} as JSON: ${
(parserError as Error).message
}`,
);
}
// Check if any of these files have a serve specific section.
if (file === 'now.json') {
parsedJson = parsedJson as NowConfigurationFile;
parsedJson = parsedJson.now.static;
} else if (file === 'package.json') {
parsedJson = parsedJson as ManifestConfigurationFile;
parsedJson = parsedJson.static;
}
if (!parsedJson) continue;
// Once we have found a valid configuration, assign it and stop looking
// through more configuration files.
Object.assign(config, parsedJson);
break;
}
// Make sure the directory with the content is relative to the entry path
// provided by the user.
if (entry) {
const staticDirectory = config.public;
config.public = resolveRelativePath(
cwd,
staticDirectory ? resolvePath(entry, staticDirectory) : entry,
);
}
// If the configuration isn't empty, validate it against the AJV schema.
if (Object.keys(config).length !== 0) {
const ajv = new Ajv({ allowUnionTypes: true });
const validate = ajv.compile(schema as object);
if (!validate(config) && validate.errors) {
const defaultMessage = 'The configuration you provided is invalid:';
const error = validate.errors[0] as ErrorObject;
throw new Error(
`${defaultMessage}\n${error.message ?? ''}\n${JSON.stringify(
error.params,
)}`,
);
}
}
// Configure defaults based on the options the user has passed.
config.etag = !args['--no-etag'];
config.symlinks = args['--symlinks'] || config.symlinks;
return config as Configuration;
};

89
source/utilities/http.ts Normal file
View File

@ -0,0 +1,89 @@
// source/utilities/http.ts
// Helper functions for the server.
import { parse } from 'node:url';
import { networkInterfaces as getNetworkInterfaces } from 'node:os';
import type { ParsedEndpoint } from '../types.js';
const networkInterfaces = getNetworkInterfaces();
/**
* Parse and return the endpoints from the given string.
*
* @param uriOrPort - The endpoint to listen on.
* @returns A list of parsed endpoints.
*/
export const parseEndpoint = (uriOrPort: string): ParsedEndpoint => {
// If the endpoint is a port number, return it as is.
if (!isNaN(Number(uriOrPort))) return [uriOrPort];
// Cast it as a string, since we know for sure it is not a number.
const endpoint = uriOrPort;
// We cannot use `new URL` here, otherwise it will not
// parse the host properly and it would drop support for IPv6.
const url = parse(endpoint);
switch (url.protocol) {
case 'pipe:': {
const pipe = endpoint.replace(/^pipe:/, '');
if (!pipe.startsWith('\\\\.\\'))
throw new Error(`Invalid Windows named pipe endpoint: ${endpoint}`);
return [pipe];
}
case 'unix:':
if (!url.pathname)
throw new Error(`Invalid UNIX domain socket endpoint: ${endpoint}`);
return [url.pathname];
case 'tcp:':
url.port = url.port ?? '3000';
url.hostname = url.hostname ?? 'localhost';
return [parseInt(url.port, 10), url.hostname];
default:
throw new Error(
`Unknown --listen endpoint scheme (protocol): ${
url.protocol ?? 'undefined'
}`,
);
}
};
/**
* Registers a function that runs on server shutdown.
*
* @param fn - The function to run on server shutdown
*/
export const registerCloseListener = (fn: () => void) => {
let run = false;
const wrapper = () => {
if (!run) {
run = true;
fn();
}
};
process.on('SIGINT', wrapper);
process.on('SIGTERM', wrapper);
process.on('exit', wrapper);
};
/**
* Returns the IP address of the host.
*
* @returns The address of the host.
*/
export const getNetworkAddress = () => {
for (const interfaceDetails of Object.values(networkInterfaces)) {
if (!interfaceDetails) continue;
for (const details of interfaceDetails) {
const { address, family, internal } = details;
if (family === 'IPv4' && !internal) return address;
}
}
};

View File

@ -0,0 +1,16 @@
// source/utilities/logger.ts
// A simple colorized console logger.
/* eslint no-console: 0 */
import chalk from 'chalk';
const info = (...message: string[]) =>
console.error(chalk.magenta('INFO:', ...message));
const warn = (...message: string[]) =>
console.error(chalk.yellow('WARNING:', ...message));
const error = (...message: string[]) =>
console.error(chalk.red('ERROR:', ...message));
const log = console.log;
export const logger = { info, warn, error, log };

View File

@ -0,0 +1,30 @@
// source/utilities/promise.ts
// Exports Promise-related utilities.
/**
* Waits for the passed promise to resolve, then returns the data and error
* in an array, similar to Go.
*
* For example:
*
* ```
* const [error, data] = await resolve(dance())
* if (error) console.error(error)
* else console.log(data)
* ```
*
* @param promise - The promise to resolve.
* @returns An array containing the error as the first element, and the resolved
* data as the second element.
*/
export const resolve = <T = unknown, E = Error>(
promise: Promise<T>,
): Promise<[E, undefined] | [undefined, T]> =>
promise
.then<[undefined, T]>((data) => [undefined, data])
.catch<[E, undefined]>((error) => [error, undefined]);
/**
* Promisifies the passed function.
*/
export { promisify } from 'node:util';

158
source/utilities/server.ts Normal file
View File

@ -0,0 +1,158 @@
// source/utilities/server.ts
// Run the server with the given configuration.
import http from 'node:http';
import https from 'node:https';
import { readFile } from 'node:fs/promises';
import handler from 'serve-handler';
import compression from 'compression';
import isPortReachable from 'is-port-reachable';
import { getNetworkAddress, registerCloseListener } from './http.js';
import { promisify } from './promise.js';
import type { IncomingMessage, ServerResponse } from 'node:http';
import type { AddressInfo } from 'node:net';
import type {
Configuration,
Options,
ParsedEndpoint,
Port,
ServerAddress,
} from '../types.js';
const compress = promisify(compression());
/**
* Starts the server and makes it listen on the given endpoint.
*
* @param endpoint - The endpoint to listen on.
* @param config - The configuration for the `serve-handler` middleware.
* @param args - The arguments passed to the CLI.
* @returns The address of the server.
*/
export const startServer = async (
endpoint: ParsedEndpoint,
config: Partial<Configuration>,
args: Partial<Options>,
previous?: Port,
): Promise<ServerAddress> => {
// Define the request handler for the server.
const serverHandler = (
request: IncomingMessage,
response: ServerResponse,
): void => {
// We can't return a promise in a HTTP request handler, so we run our code
// inside an async function instead.
const run = async () => {
type ExpressRequest = Parameters<typeof compress>[0];
type ExpressResponse = Parameters<typeof compress>[1];
if (args['--cors'])
response.setHeader('Access-Control-Allow-Origin', '*');
if (!args['--no-compression'])
await compress(request as ExpressRequest, response as ExpressResponse);
// Let the `serve-handler` module do the rest.
await handler(request, response, config);
};
// Then we run the async function, and re-throw any errors.
run().catch((error: Error) => {
throw error;
});
};
// Create the server.
const useSsl = args['--ssl-cert'] && args['--ssl-key'];
const httpMode = useSsl ? 'https' : 'http';
const sslPass = args['--ssl-pass'];
const serverConfig =
httpMode === 'https' && args['--ssl-cert'] && args['--ssl-key']
? {
key: await readFile(args['--ssl-key']),
cert: await readFile(args['--ssl-cert']),
passphrase: sslPass ? await readFile(sslPass, 'utf8') : '',
}
: {};
const server =
httpMode === 'https'
? https.createServer(serverConfig, serverHandler)
: http.createServer(serverHandler);
// Once the server starts, return the address it is running on so the CLI
// can tell the user.
const getServerDetails = () => {
// Make sure to close the server once the process ends.
registerCloseListener(() => server.close());
// Once the server has started, get the address the server is running on
// and return it.
const details = server.address() as string | AddressInfo;
let local: string | undefined;
let network: string | undefined;
if (typeof details === 'string') {
local = details;
} else if (typeof details === 'object' && details.port) {
// According to https://www.ietf.org/rfc/rfc2732.txt, IPv6 addresses
// should be surrounded by square brackets (only the address, not the
// port).
let address;
if (details.address === '::') address = 'localhost';
else if (details.family === 'IPv6') address = `[${details.address}]`;
else address = details.address;
const ip = getNetworkAddress();
local = `${httpMode}://${address}:${details.port}`;
network = ip ? `${httpMode}://${ip}:${details.port}` : undefined;
}
return {
local,
network,
previous,
};
};
// Listen for any error that occurs while serving, and throw an error
// if any errors are received.
server.on('error', (error) => {
throw new Error(
`Failed to serve: ${error.stack?.toString() ?? error.message}`,
);
});
// If the endpoint is a non-zero port, make sure it is not occupied.
if (
typeof endpoint[0] === 'number' &&
!isNaN(endpoint[0]) &&
endpoint[0] !== 0
) {
const port = endpoint[0];
const isClosed = await isPortReachable(port, {
host: endpoint[1] ?? 'localhost',
});
// If the port is already taken, then start the server on a random port
// instead.
if (isClosed) return startServer([0], config, args, port);
// Otherwise continue on to starting the server.
}
// Finally, start the server.
return new Promise((resolve, _reject) => {
// If only a port is specified, listen on the given port on localhost.
if (endpoint.length === 1 && typeof endpoint[0] === 'number')
server.listen(endpoint[0], () => resolve(getServerDetails()));
// If the path to a socket or a pipe is given, listen on it.
else if (endpoint.length === 1 && typeof endpoint[0] === 'string')
server.listen(endpoint[0], () => resolve(getServerDetails()));
// If a port number and hostname are given, listen on `host:port`.
else if (
endpoint.length === 2 &&
typeof endpoint[0] === 'number' &&
typeof endpoint[1] === 'string'
)
server.listen(endpoint[0], endpoint[1], () =>
resolve(getServerDetails()),
);
});
};

12
tsconfig.json Normal file
View File

@ -0,0 +1,12 @@
{
"extends": "@vercel/style-guide/typescript",
"compilerOptions": {
"lib": ["es2020"],
"target": "es2020",
"module": "esnext",
"moduleResolution": "node",
"noEmit": true,
"resolveJsonModule": true
},
"include": ["source/"]
}