Skip to content

Commit 4dca810

Browse files
committed
Allow serving content from .zip files via CLI
1 parent 31068be commit 4dca810

17 files changed

Lines changed: 158 additions & 27 deletions

src/bin/ServerManager.mts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { createServer, type Server, type ServerOptions } from 'node:http';
33
import type { Readable, Writable } from 'node:stream';
44
import { findCause, HTTPError, WebListener, type ListenOptions } from '../index.mts';
55
import type { ConfigServer, ConfigServerOptions, ConfigBackgroundTask } from './config/types.mts';
6-
import { buildRouter } from './buildRouter.mts';
6+
import { buildRouter } from './routes/buildRouter.mts';
77
import type { Logger, AddColour } from './log.mts';
88
import { TransientError } from './TransientError.mts';
99

src/bin/ServerManager.test.mts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -248,7 +248,7 @@ describe('ServerManager', () => {
248248
],
249249
() => fail(),
250250
);
251-
expect(logs).contains(matches(/directory to serve not found/));
251+
expect(logs).contains(matches(/content to serve not found/));
252252
expect(logs).not(contains(`http://localhost:${port} ready`));
253253
expect(logs).not(contains('all servers ready'));
254254

@@ -299,8 +299,8 @@ describe('ServerManager', () => {
299299
);
300300
const capturedError = await awaitError;
301301
expect(capturedError).isInstanceOf(Error);
302-
expect((capturedError as Error).message).contains('directory to serve not found');
303-
expect(logs).contains(matches(/directory to serve not found/));
302+
expect((capturedError as Error).message).contains('content to serve not found');
303+
expect(logs).contains(matches(/content to serve not found/));
304304
expect(logs).not(contains(`http://localhost:${port} ready`));
305305
expect(logs).not(contains('all servers ready'));
306306
} finally {

src/bin/config/types.mts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type {
44
CombinedServerOptions,
55
FileNegotiation,
66
} from '../../index.mts';
7-
import type { DependencyHandlerOptions } from '../modules/dependencies.mts';
7+
import type { DependencyHandlerOptions } from '../routes/modules/dependencies.mts';
88
import type { LogLevel } from '../log.mts';
99

1010
export type ConfigHeaders = Record<string, string | number | string[]>;

src/bin/man1/web-listener.1

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,10 @@ providing a newline to stdin.
119119
.
120120
.It Fl d Ar directory , Fl -dir Ar directory
121121
The directory to serve files from, relative to the current working directory.
122+
This can also be the path to a .zip file, or a path within a .zip file
123+
.Po "e.g. "
124+
.Qq "/path/to/file.zip/path/within/zip"
125+
.Pc .
122126
Can be specified multiple times to provide fall-backs.
123127
Defaults to
124128
.Qq "." .

src/bin/routes/anyFileFinder.mts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { stat } from 'node:fs/promises';
2+
import { join, sep } from 'node:path';
3+
import {
4+
dynamicFileFinder,
5+
negotiateEncoding,
6+
Negotiator,
7+
readZip,
8+
staticFileFinder,
9+
zipFileFinder,
10+
type FileFinder,
11+
type FileServerOptions,
12+
} from '../../index.mts';
13+
import { TransientError } from '../TransientError.mts';
14+
15+
export async function anyFileFinder(path: string, options: FileServerOptions): Promise<FileFinder> {
16+
const direct = await stat(path).catch(() => null);
17+
if (direct?.isDirectory()) {
18+
if (options.mode === 'static-paths') {
19+
return staticFileFinder(path, options);
20+
} else {
21+
return dynamicFileFinder(path, options);
22+
}
23+
}
24+
25+
const parts = path.split(sep);
26+
if (parts[parts.length - 1] === '') {
27+
parts.pop();
28+
}
29+
if (!parts[0]) {
30+
parts.shift();
31+
if (parts.length > 0) {
32+
parts[0] = sep + parts[0];
33+
}
34+
}
35+
for (let i = parts.length; i > 0; --i) {
36+
const filePath = join(...parts.slice(0, i));
37+
const stats = await stat(filePath).catch(() => null);
38+
if (!stats) {
39+
continue;
40+
}
41+
if (!stats.isFile()) {
42+
break;
43+
}
44+
const zipRoot = await readZip(filePath);
45+
const zipDir = zipRoot.find(parts.slice(i));
46+
if (!zipDir?.isDirectory) {
47+
throw new Error(`${parts.slice(i).join('/')} in ${filePath} is not a directory`);
48+
}
49+
const adjustedOptions = options;
50+
if (!adjustedOptions.negotiator) {
51+
adjustedOptions.negotiator = new Negotiator([negotiateEncoding(['deflate'])]);
52+
}
53+
return zipFileFinder(zipDir, adjustedOptions);
54+
}
55+
throw new TransientError(`content to serve not found at ${path}`);
56+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { dirname, join, sep } from 'node:path';
2+
import { makeTestTempDir } from '../../test-helpers/makeFileStructure.mts';
3+
import { anyFileFinder } from './anyFileFinder.mts';
4+
import 'lean-test';
5+
6+
describe('anyFileFinder', () => {
7+
describe('given a directory', () => {
8+
const TEST_DIR = makeTestTempDir('aff-', { 'file.txt': 'Content' });
9+
10+
it('loads directories dynamically', async ({ getTyped }) => {
11+
const fileFinder = await anyFileFinder(getTyped(TEST_DIR), {});
12+
expect(fileFinder.isStaticListing).isFalse();
13+
const file = await fileFinder.find(['file.txt']);
14+
await file?.handle.close();
15+
expect(file?.stats.size).equals(7);
16+
});
17+
18+
it('loads directories statically', async ({ getTyped }) => {
19+
const fileFinder = await anyFileFinder(getTyped(TEST_DIR), { mode: 'static-paths' });
20+
expect(fileFinder.isStaticListing).isTrue();
21+
const file = await fileFinder.find(['file.txt']);
22+
await file?.handle.close();
23+
expect(file?.stats.size).equals(7);
24+
});
25+
});
26+
27+
it('loads zip contents', async () => {
28+
const testZip = join(dirname(new URL(import.meta.url).pathname), 'test-assets.zip');
29+
30+
const fileFinder = await anyFileFinder(testZip, {});
31+
expect(fileFinder.isStaticListing).isTrue();
32+
const file1 = await fileFinder.find(['file.txt']);
33+
await file1?.handle.close();
34+
expect(file1?.stats.size).equals(7);
35+
36+
const file2 = await fileFinder.find(['sub', 'subfile.txt']);
37+
await file2?.handle.close();
38+
expect(file2?.stats.size).equals(14);
39+
});
40+
41+
it('loads zip contents with trailing slash', async () => {
42+
const testZip = join(dirname(new URL(import.meta.url).pathname), 'test-assets.zip') + sep;
43+
44+
const fileFinder = await anyFileFinder(testZip, {});
45+
expect(fileFinder.isStaticListing).isTrue();
46+
const file1 = await fileFinder.find(['file.txt']);
47+
await file1?.handle.close();
48+
expect(file1?.stats.size).equals(7);
49+
50+
const file2 = await fileFinder.find(['sub', 'subfile.txt']);
51+
await file2?.handle.close();
52+
expect(file2?.stats.size).equals(14);
53+
});
54+
55+
it('loads nested zip contents', async () => {
56+
const testZip = join(dirname(new URL(import.meta.url).pathname), 'test-assets.zip', 'sub');
57+
58+
const fileFinder = await anyFileFinder(testZip, {});
59+
expect(fileFinder.isStaticListing).isTrue();
60+
const file = await fileFinder.find(['subfile.txt']);
61+
await file?.handle.close();
62+
expect(file?.stats.size).equals(14);
63+
64+
expect(await fileFinder.find(['file.txt'])).isNull();
65+
});
66+
67+
it('throws if given a path which does not exist', async () => {
68+
await expect(() => anyFileFinder('/does/not/exist', {})).throws(
69+
'content to serve not found at /does/not/exist',
70+
);
71+
});
72+
});
Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ import type { IncomingMessage, ServerResponse } from 'node:http';
22
import { readFile } from 'node:fs/promises';
33
import {
44
addTeardown,
5+
assetServer,
56
CONTINUE,
6-
fileServer,
77
getPathParameter,
88
getQuery,
99
getSearch,
@@ -12,12 +12,13 @@ import {
1212
requestHandler,
1313
Router,
1414
getAbsolutePath,
15-
} from '../index.mts';
16-
import type { ConfigMount } from './config/types.mts';
15+
} from '../../index.mts';
16+
import type { ConfigMount } from '../config/types.mts';
17+
import { TransientError } from '../TransientError.mts';
1718
import { dependencies } from './modules/dependencies.mts';
19+
import { anyFileFinder } from './anyFileFinder.mts';
1820
import { Mapper, nginxTokenise } from './nginx.mts';
1921
import { render } from './template.mts';
20-
import { TransientError } from './TransientError.mts';
2122

2223
export interface LogInfo {
2324
method: string;
@@ -58,15 +59,13 @@ export async function buildRouter(mount: ConfigMount[], log: (info: LogInfo) =>
5859
item.options.negotiation && item.options.negotiation.length > 0
5960
? new Negotiator(item.options.negotiation)
6061
: undefined;
61-
try {
62-
router.mount(item.path, await fileServer(item.dir, { ...item.options, negotiator }));
63-
} catch (error: unknown) {
64-
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
65-
throw new TransientError(`directory to serve not found at ${item.dir}`);
66-
} else {
67-
throw error;
68-
}
69-
}
62+
router.mount(
63+
item.path,
64+
assetServer(
65+
await anyFileFinder(item.dir, { ...item.options, negotiator }),
66+
item.options,
67+
),
68+
);
7069
}
7170
break;
7271
case 'proxy':
Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { join } from 'node:path';
2-
import { requestHandler } from '../index.mts';
3-
import { makeTestTempDir } from '../test-helpers/makeFileStructure.mts';
4-
import { responds } from '../test-helpers/responds.mts';
5-
import { withServer } from '../test-helpers/withServer.mts';
6-
import type { ConfigMount } from './config/types.mts';
2+
import { requestHandler } from '../../index.mts';
3+
import { makeTestTempDir } from '../../test-helpers/makeFileStructure.mts';
4+
import { responds } from '../../test-helpers/responds.mts';
5+
import { withServer } from '../../test-helpers/withServer.mts';
6+
import type { ConfigMount } from '../config/types.mts';
77
import { buildRouter, type LogInfo } from './buildRouter.mts';
88
import 'lean-test';
99

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { access, constants, readdir, readFile, realpath, stat } from 'node:fs/promises';
22
import { dirname, join, sep } from 'node:path';
33
import { fileURLToPath, pathToFileURL } from 'node:url';
4-
import { Queue } from '../../index.mts';
4+
import { Queue } from '../../../index.mts';
55

66
export interface PackageInfo {
77
isRoot: boolean;

src/bin/modules/PackageInfo.test.mts renamed to src/bin/routes/modules/PackageInfo.test.mts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { join } from 'node:path';
2-
import { makeTestTempDir } from '../../test-helpers/makeFileStructure.mts';
2+
import { makeTestTempDir } from '../../../test-helpers/makeFileStructure.mts';
33
import { getResolvedExportMap, readPackageGraph, type PackageJson } from './PackageInfo.mts';
44
import 'lean-test';
55

0 commit comments

Comments
 (0)