Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refc: rewrite the project in typescript #706

Merged
merged 27 commits into from
Jul 12, 2022
Merged
Changes from 10 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
ba72886
refc: rewrite the project in typescript
gamemaker1 Jul 7, 2022
12fdda2
docs: update badges in readme
gamemaker1 Jul 7, 2022
e5c5100
docs: improve readme; move images to `media/`
gamemaker1 Jul 7, 2022
101df4d
docs: add contributing guide
gamemaker1 Jul 7, 2022
7a9c543
docs: point to contributing guide in the readme
gamemaker1 Jul 7, 2022
a923b94
docs: add issue and pr templates
gamemaker1 Jul 7, 2022
0c25a7c
docs: fix copy-paste errors
gamemaker1 Jul 8, 2022
23b5fe0
chore: upgrade dependencies
gamemaker1 Jul 8, 2022
d9bdc25
chore: adjust code for new versions of packages
gamemaker1 Jul 9, 2022
77c3165
chore: enable color output in git hooks
gamemaker1 Jul 9, 2022
842fc35
Update readme.md
leerob Jul 11, 2022
47d392f
style(types): re-order members of `Options` interface
gamemaker1 Jul 11, 2022
1b6af45
build(manifest): mention only `build/` in the `files` array
gamemaker1 Jul 11, 2022
8dfdc9d
docs(readme): use absolute links to media files
gamemaker1 Jul 11, 2022
dbc0282
docs(readme): show global installation with `npm`
gamemaker1 Jul 11, 2022
b5d3c49
fix(lint): fix some lint errors; and cause more
gamemaker1 Jul 11, 2022
734b442
fix(types): add types for config files
gamemaker1 Jul 11, 2022
06f9665
fix(types): remove some `@ts-expect-error` comments
gamemaker1 Jul 11, 2022
29ef1a3
fix(types): remove more `@ts-expect-error` comments
gamemaker1 Jul 11, 2022
50be7cd
fix(types): make signature for `parseEndpoint` match `Handler`
gamemaker1 Jul 11, 2022
b67e2e3
build(node): target node 14
gamemaker1 Jul 11, 2022
6746348
build(ts): downgrade to ts 4.6.4 for eslint
gamemaker1 Jul 11, 2022
10ac621
chore(scripts): run `compile` post install
gamemaker1 Jul 11, 2022
f6d29ca
docs(readme): fix badge url
gamemaker1 Jul 11, 2022
2237b53
docs(readme): add alt text for logo image
gamemaker1 Jul 11, 2022
083a43c
chore(tsc): remove duplicate `esModuleInterop` from tsconfig.json
gamemaker1 Jul 12, 2022
69876e5
fix(utilities/config): make error message less harsh
gamemaker1 Jul 12, 2022
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# .gitattributes
# Makes sure all line endings are LF.

* text=auto eol=lf
31 changes: 31 additions & 0 deletions .github/ISSUE_TEMPLATE/bug_report.yaml
Original file line number Diff line number Diff line change
@@ -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
28 changes: 28 additions & 0 deletions .github/ISSUE_TEMPLATE/feature_request.yaml
Original file line number Diff line number Diff line change
@@ -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
56 changes: 56 additions & 0 deletions .github/PULL_REQUEST_TEMPLATE/default.md
Original file line number Diff line number Diff line change
@@ -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.
55 changes: 41 additions & 14 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
@@ -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:
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- name: Checkout the repository
uses: actions/checkout@v2
- name: Setup PNPM 7
uses: pnpm/[email protected]
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: Check for errors in code/formatting
run: pnpm lint
test:
name: Node.js ${{ matrix.node-version }}
name: Test
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/[email protected]
with:
version: latest
- name: Setup Node 18
uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node-version }}
- run: npm install
- run: npm test
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 changes: 13 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -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 changes: 8 additions & 1 deletion .npmrc
Original file line number Diff line number Diff line change
@@ -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 changes: 0 additions & 21 deletions LICENSE

This file was deleted.

87 changes: 0 additions & 87 deletions README.md

This file was deleted.

462 changes: 0 additions & 462 deletions bin/serve.js

This file was deleted.

8 changes: 8 additions & 0 deletions config/husky/pre-commit
Original file line number Diff line number Diff line change
@@ -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 changes: 205 additions & 0 deletions contributing.md
Original file line number Diff line number Diff line change
@@ -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 changes: 20 additions & 0 deletions license.md
Original file line number Diff line number Diff line change
@@ -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.
Binary file added media/banner.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added media/listing-ui.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1,565 changes: 0 additions & 1,565 deletions package-lock.json

This file was deleted.

93 changes: 75 additions & 18 deletions package.json
Original file line number Diff line number Diff line change
@@ -2,32 +2,89 @@
"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/",
"package.json",
"readme.md",
"license.md",
".npmrc"
],
"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:rest": "prettier --check --ignore-path .gitignore .",
"lint": "pnpm lint:code && pnpm lint:rest",
"format": "prettier --write --ignore-path .gitignore .",
"prepare": "husky install config/husky"
},
"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.7.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",
"warnOnUnsupportedTypeScriptVersion": false
},
"rules": {
"@typescript-eslint/restrict-template-expressions": 0,
"@typescript-eslint/no-explicit-any": 0
}
},
"lint-staged": {
"*": [
"prettier --ignore-unknown --write"
],
"source/**/*.ts": [
"eslint --max-warnings 0 --fix"
]
}
}
4,715 changes: 4,715 additions & 0 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

97 changes: 97 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
![](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.now.sh/result?p=serve">
<img src="https://packagephobia.now.sh/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/gamemaker1/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](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 using [Yarn](https://yarnpkg.com/en/) or [PNPM](https://pnpm.io/) (you'll need at least [Node LTS](https://github.com/nodejs/Release#release-schedule)):

```bash
> yarn global add serve
> pnpm add --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))
174 changes: 174 additions & 0 deletions source/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
#!/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: any) {
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.
// Disabling as this is not an unnecessary check.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
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: any) {
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 changes: 89 additions & 0 deletions source/types.ts
Original file line number Diff line number Diff line change
@@ -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[];
'--debug': boolean;
'--single': boolean;
'--config': Path;
'--cors': boolean;
'--no-clipboard': boolean;
'--no-compression': boolean;
'--no-etag': boolean;
'--symlinks': boolean;
'--ssl-cert': Path;
'--ssl-key': Path;
'--ssl-pass': Path;
'--no-port-switching': boolean;
}

// The arguments passed to the CLI (the options + the positional arguments)
export declare type Arguments = Options & {
_: string[];
};
130 changes: 130 additions & 0 deletions source/utilities/cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
// 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],
'--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 =>
// @ts-expect-error The handler array for the `--listen` option does have only
// one element, I'm not sure why Typescript is throwing an error here.
parseArgv(options) as unknown as Arguments;

/**
* Returns the help text.
*
* @returns The help text shown when the `--help` option is used.
*/
export const getHelpText = (): string => helpText;
126 changes: 126 additions & 0 deletions source/utilities/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
// 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: 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}`,
);
}

// 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: unknown;
try {
parsedJson = JSON.parse(rawContents);
if (typeof parsedJson !== 'object')
throw new Error('configuration is not an object');
} catch (parserError: any) {
throw new Error(
`Could not parse ${location} as JSON: ${
(parserError as Error).message
}`,
);
}

// The lint rules have been disabled here because we don't know for sure
// what the contents of these files are. In case anything is undefined and
// an error is thrown, we simply move on - so there's no need to be careful
// of unsafe property access.
try {
switch (file) {
case 'now.json':
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
parsedJson = (parsedJson as any).static;
break;
case 'package.json':
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
parsedJson = (parsedJson as any).now.static;
break;
}
} catch {
// If these files don't have a serve-specific section, find it in another
// config file.
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 wrong:';
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;
};
88 changes: 88 additions & 0 deletions source/utilities/http.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// source/utilities/http.ts
// Helper functions for the server.

import { parse } from 'node:url';
import { networkInterfaces as getNetworkInterfaces } from 'node:os';
import type { ListenEndpoint, 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: ListenEndpoint): ParsedEndpoint => {
// If the endpoint is a port number, return it as is.
// @ts-expect-error `isNaN` accepts strings too.
if (!isNaN(uriOrPort)) return [uriOrPort];

// Cast it as a string, since we know for sure it is not a number.
const endpoint = uriOrPort as string;

// 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}`,
);
}
};

/**
* 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;
}
}
};
16 changes: 16 additions & 0 deletions source/utilities/logger.ts
Original file line number Diff line number Diff line change
@@ -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 };
30 changes: 30 additions & 0 deletions source/utilities/promise.ts
Original file line number Diff line number Diff line change
@@ -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';
126 changes: 126 additions & 0 deletions source/utilities/server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
// 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: Options,
previous?: Port,
): Promise<ServerAddress> => {
// Define the request handler for the server.
const serverHandler = async (
request: IncomingMessage,
response: ServerResponse,
): Promise<void> => {
if (args['--cors']) response.setHeader('Access-Control-Allow-Origin', '*');
// @ts-expect-error The `compression` library uses the Express Request type
// instead of the native HTTP IncomingMessage type.
if (!args['--no-compression']) await compress(request, response);

// Let the `serve-handler` module do the rest.
await handler(request, response, config);
};

// Create the server.
const httpMode = args['--ssl-cert'] && args['--ssl-key'] ? 'https' : 'http';
const sslPass = args['--ssl-pass'];
const server =
httpMode === 'https'
? https.createServer(
{
key: await readFile(args['--ssl-key']),
cert: await readFile(args['--ssl-cert']),
passphrase: sslPass ? await readFile(sslPass, 'utf8') : '',
},
serverHandler, // eslint-disable-line @typescript-eslint/no-misused-promises
)
: http.createServer(serverHandler); // eslint-disable-line @typescript-eslint/no-misused-promises

// 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 address 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}`);
});

// If the endpoint is a non-zero port, make sure it is not occupied.
// @ts-expect-error `isNaN` accepts strings too.
if (!isNaN(endpoint[0]) && endpoint[0] !== 0) {
const port = endpoint[0] as number;
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) => {
// @ts-expect-error I'm not sure why Typescript thinks this is invalid, the spread
// operator should pass it a `number` or a `string` or a `number`, `string`.
server.listen(...endpoint, () => resolve(getServerDetails()));
});
};
12 changes: 12 additions & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"extends": "@vercel/style-guide/typescript",
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"moduleResolution": "node",
"noEmit": true,
"resolveJsonModule": true,
"esModuleInterop": true
},
"include": ["source/"]
}