Skip to content

Commit

Permalink
feat: add "assets" option (#53)
Browse files Browse the repository at this point in the history
* Add option to specify asset paths for SPAs

Allows asset path prefixes to be specified by the CLI when running in
single-page mode.

If a path is requested that doesn't exist, and that path matches one of
the asset path prefixes, the request will 404 instead of retuning a 200
containing the root index.

Fixes #44

* fix: ensure `assets` array & preload prep work

* chore: add `assets` tests

* chore: update options definition

* update `--single` & `--assets` text

Co-authored-by: Luke Edwards <luke.edwards05@gmail.com>
  • Loading branch information
pR0Ps and lukeed authored May 31, 2020
1 parent 9ceecc2 commit 918102e
Show file tree
Hide file tree
Showing 6 changed files with 180 additions and 3 deletions.
3 changes: 2 additions & 1 deletion packages/sirv-cli/bin.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ sade('sirv [dir]')
.option('-C, --cert', 'Path to certificate file for HTTP/2 server')
.option('-K, --key', 'Path to certificate key for HTTP/2 server')
.option('-P, --pass', 'Passphrase to decrypt a certificate key')
.option('-s, --single', 'Serve single-page applications')
.option('-s, --single', 'Serve as single-page application with "index.html" fallback')
.option('-a, --assets', 'URL pattern(s) for the single-page application assets')
.option('-q, --quiet', 'Disable logging to terminal')
.option('-H, --host', 'Hostname to bind', 'localhost')
.option('-p, --port', 'Port to bind', 5000)
Expand Down
2 changes: 2 additions & 0 deletions packages/sirv-cli/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ $ sirv --help
$ sirv public --quiet --etag --maxage 31536000 --immutable
$ sirv public --http2 --key priv.pem --cert cert.pem
$ sirv start public -qeim 31536000
$ sirv start public --assets /static/
$ sirv --port 8080 --etag
$ sirv my-app --dev
```
Expand All @@ -85,6 +86,7 @@ $ sirv start --help
-C, --cert Path to certificate file for HTTP/2 server
-K, --key Path to certificate key for HTTP/2 server
-s, --single Serve single-page applications
-a, --assets Prefix for the asset files of single-page applications
-q, --quiet Disable logging to terminal
-H, --host Hostname to bind (default localhost)
-p, --port Port to bind (default 5000)
Expand Down
17 changes: 16 additions & 1 deletion packages/sirv/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,13 @@ function viaLocal(uri, extns, dir, isEtag) {
}
}

function isOkay(arr, uri) {
for (let i=0; i < arr.length; i++) {
if (arr[i].test(uri)) return false;
}
return true;
}

function is404(req, res) {
return (res.statusCode=404,res.end());
}
Expand Down Expand Up @@ -112,6 +119,14 @@ export default function (dir, opts={}) {
fallback += !!~idx ? opts.single.substring(0, idx) : opts.single;
}

let ignores = [];
if (isSPA && opts.assets !== false) {
ignores.push(/\w\.\w$/); // any extn
[].concat(opts.assets || []).forEach(x => {
ignores.push(new RegExp(x, 'i')); // noop for RegExp
});
}

let cc = opts.maxAge != null && `public,max-age=${opts.maxAge}`;
if (cc && opts.immutable) cc += ',immutable';

Expand All @@ -134,7 +149,7 @@ export default function (dir, opts={}) {

let fn = opts.dev ? viaLocal : viaCache;
let pathname = req.path || parser(req, true).pathname;
let data = fn(pathname, extns, dir, isEtag) || isSPA && fn(fallback, extns, dir, isEtag);
let data = fn(pathname, extns, dir, isEtag) || isSPA && isOkay(ignores, pathname) && fn(fallback, extns, dir, isEtag);
if (!data) return next ? next() : isNotFound(req, res);

if (isEtag && req.headers['if-none-match'] === data.headers['ETag']) {
Expand Down
2 changes: 2 additions & 0 deletions packages/sirv/sirv.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ declare namespace sirv {
import type { Stats } from 'fs';
import type { IncomingMessage, ServerResponse } from 'http';

type Arrayable<T> = T | T[];
export type NextHandler = VoidFunction | Promise<void>;
export type RequestHandler = (req: IncomingMessage, res: ServerResponse, next?: NextHandler) => void;

Expand All @@ -11,6 +12,7 @@ declare namespace sirv {
maxAge?: number;
immutable?: boolean;
single?: string | boolean;
assets?: false | Arrayable<string | RegExp>;
extensions?: string[];
dotfiles?: boolean;
brotli?: boolean;
Expand Down
3 changes: 2 additions & 1 deletion tests/sirv-cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ help('--help', () => {
-C, --cert Path to certificate file for HTTP/2 server
-K, --key Path to certificate key for HTTP/2 server
-P, --pass Passphrase to decrypt a certificate key
-s, --single Serve single-page applications
-s, --single Serve as single-page application with "index.html" fallback
-a, --assets URL pattern(s) for the single-page application assets
-q, --quiet Disable logging to terminal
-H, --host Hostname to bind (default localhost)
-p, --port Port to bind (default 5000)
Expand Down
156 changes: 156 additions & 0 deletions tests/sirv.js
Original file line number Diff line number Diff line change
Expand Up @@ -325,10 +325,166 @@ single('should use custom fallback when `single` is a string', async () => {
}
});

single('should NOT fallback to "index.html" for URLs with extension', async () => {
let server = utils.http({ single: true });

try {
await server.send('GET', '/404.css').catch(err => {
assert.is(err.statusCode, 404);
});

await server.send('GET', '/404.js').catch(err => {
assert.is(err.statusCode, 404);
});

await server.send('GET', '/foo/bar/baz.bat').catch(err => {
assert.is(err.statusCode, 404);
});
} finally {
server.close();
}
});

single.run();

// ---

const assets = suite('assets');

assets('should be able to fallback any URL to "index.html" when desired', async () => {
let server = utils.http({ single:true, assets:false });

try {
let res1 = await server.send('GET', '/404.css');
await utils.matches(res1, 200, 'index.html', 'utf8');

let res2 = await server.send('GET', '/404.js');
await utils.matches(res2, 200, 'index.html', 'utf8');

let res3 = await server.send('GET', '/foo/bar/baz.bat');
await utils.matches(res3, 200, 'index.html', 'utf8');
} finally {
server.close();
}
});

assets('should be able to fallback any URL to "index.html" when desired', async () => {
let server = utils.http({ single:true, assets:false });

try {
let res1 = await server.send('GET', '/404.css');
await utils.matches(res1, 200, 'index.html', 'utf8');

let res2 = await server.send('GET', '/404.js');
await utils.matches(res2, 200, 'index.html', 'utf8');

let res3 = await server.send('GET', '/foo/bar/baz.bat');
await utils.matches(res3, 200, 'index.html', 'utf8');
} finally {
server.close();
}
});

assets('should provide custom RegExp pattern to ignore', async () => {
let server = utils.http({
single: true,
assets: /^[/]foo/
});

try {
await server.send('GET', '/foo/404').catch(err => {
assert.is(err.statusCode, 404);
});

await server.send('GET', '/foobar/baz').catch(err => {
assert.is(err.statusCode, 404);
});

await server.send('GET', '/foo/bar/baz').catch(err => {
assert.is(err.statusCode, 404);
});

let res = await server.send('GET', '/hello/world');
await utils.matches(res, 200, 'index.html', 'utf8');
} finally {
server.close();
}
});

assets('should provide custom String pattern to ignore', async () => {
let server = utils.http({
single: true,
assets: '^/foo'
});

try {
await server.send('GET', '/foo/404').catch(err => {
assert.is(err.statusCode, 404);
});

await server.send('GET', '/foobar/baz').catch(err => {
assert.is(err.statusCode, 404);
});

await server.send('GET', '/foo/bar/baz').catch(err => {
assert.is(err.statusCode, 404);
});

let res = await server.send('GET', '/hello/world');
await utils.matches(res, 200, 'index.html', 'utf8');
} finally {
server.close();
}
});

assets('should provide mulitple RegExp patterns to ignore', async () => {
let server = utils.http({
single: true,
assets: [/^[/]foo/, /bar/]
});

try {
await server.send('GET', '/foo/404').catch(err => {
assert.is(err.statusCode, 404);
});

await server.send('GET', '/hello/bar/baz').catch(err => {
assert.is(err.statusCode, 404);
});

let res = await server.send('GET', '/hello/world');
await utils.matches(res, 200, 'index.html', 'utf8');
} finally {
server.close();
}
});

assets('should provide mulitple String patterns to ignore', async () => {
let server = utils.http({
single: true,
assets: ['^/foo', 'bar']
});

try {
await server.send('GET', '/foo/404').catch(err => {
assert.is(err.statusCode, 404);
});

await server.send('GET', '/hello/bar/baz').catch(err => {
assert.is(err.statusCode, 404);
});

let res = await server.send('GET', '/hello/world');
await utils.matches(res, 200, 'index.html', 'utf8');
} finally {
server.close();
}
});

assets.run();

// ---

const dotfiles = suite('dotfiles');

dotfiles('should reject hidden files (dotfiles) by default', async () => {
Expand Down

0 comments on commit 918102e

Please sign in to comment.