Skip to content

Commit 08d899c

Browse files
committed
Add --redirect-map to CLI
1 parent 82669bf commit 08d899c

8 files changed

Lines changed: 392 additions & 2 deletions

File tree

src/bin/buildRouter.mts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { IncomingMessage, ServerResponse } from 'node:http';
2+
import { readFile } from 'node:fs/promises';
23
import {
34
addTeardown,
45
CONTINUE,
@@ -98,6 +99,33 @@ export async function buildRouter(mount: ConfigMount[], log: (info: LogInfo) =>
9899
}),
99100
);
100101
break;
102+
case 'redirect-map':
103+
const norm = item.options.caseSensitive ? (v: string) => v : (v: string) => v.toLowerCase();
104+
const mapping = new Map<string, string>();
105+
if (typeof item.mapping === 'string') {
106+
for (const statement of nginxTokenise(await readFile(item.mapping, 'utf-8'))) {
107+
if (statement.length !== 2 || statement[0]![0] !== '/') {
108+
throw new Error(
109+
`unexpected statement in mapping file: ${statement.map((p) => JSON.stringify(p)).join(' ')}`,
110+
);
111+
}
112+
mapping.set(norm(statement[0]!), statement[1]!);
113+
}
114+
} else {
115+
for (const [k, v] of Object.entries(item.mapping)) {
116+
mapping.set(norm(k), v);
117+
}
118+
}
119+
router.use((req, res) => {
120+
const redirect = mapping.get(norm(req.url ?? '/'));
121+
if (redirect !== undefined && redirect !== (req.url ?? '/')) {
122+
res.setHeader('location', redirect);
123+
res.statusCode = item.status;
124+
return res.end();
125+
}
126+
return CONTINUE;
127+
});
128+
break;
101129
case 'dependencies':
102130
router.mount(
103131
item.path,
@@ -115,3 +143,32 @@ const getParam = (req: IncomingMessage) => (key: string) =>
115143
: key[0] === '?'
116144
? { _value: getQuery(req, key.substring(1)), _encoding: 'raw' }
117145
: { _value: getPathParameter(req, key), _encoding: 'raw' };
146+
147+
function* nginxTokenise(source: string) {
148+
let statement: string[] = [];
149+
const token =
150+
/(\s+|#[^\n]*)|(;)|(?:"((?:[^"\\]+|\\.)*)")|(?:'((?:[^'\\]+|\\.)*)')|((?:[^#;\s\\"']+|\\.)+)/y;
151+
while (token.lastIndex < source.length) {
152+
const m = token.exec(source);
153+
if (!m) {
154+
throw new Error('invalid nginx syntax');
155+
}
156+
const [, separator, semicolon, dquot, squot, nquot] = m;
157+
if (separator) {
158+
continue;
159+
}
160+
if (semicolon) {
161+
yield statement;
162+
statement = [];
163+
continue;
164+
}
165+
const part = dquot ?? squot ?? nquot;
166+
if (part === undefined) {
167+
throw new Error('nginx tokenisation error');
168+
}
169+
statement.push(part.replaceAll(/\\(.)/g, '$1'));
170+
}
171+
if (statement.length) {
172+
yield statement;
173+
}
174+
}

src/bin/buildRouter.test.mts

Lines changed: 143 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { requestHandler } from '../index.mts';
2-
import { makeTestTempDir } from '../test-helpers/makeFileStructure.mts';
2+
import { makeTestTempDir, makeTestTempFile } from '../test-helpers/makeFileStructure.mts';
33
import { responds } from '../test-helpers/responds.mts';
44
import { withServer } from '../test-helpers/withServer.mts';
5+
import type { ConfigMount } from './config/types.mts';
56
import { buildRouter, type LogInfo } from './buildRouter.mts';
67
import 'lean-test';
78

@@ -237,6 +238,138 @@ describe('buildRouter', () => {
237238
});
238239
});
239240

241+
describe('redirect-map', () => {
242+
it('adds multiple redirects', { timeout: 3000 }, async () => {
243+
const router = await buildRouter([
244+
{
245+
type: 'redirect-map',
246+
mapping: { '/one': '/new-one', '/two': '/new-two' },
247+
status: 307,
248+
options: { caseSensitive: false },
249+
},
250+
FALLBACK_200,
251+
]);
252+
return withServer(router, async (url) => {
253+
await expect(
254+
fetch(url + '/one', { redirect: 'manual' }),
255+
responds({ status: 307, headers: { location: '/new-one' }, body: '' }),
256+
);
257+
await expect(
258+
fetch(url + '/ONE', { redirect: 'manual' }),
259+
responds({ status: 307, headers: { location: '/new-one' }, body: '' }),
260+
);
261+
await expect(
262+
fetch(url + '/two', { redirect: 'manual' }),
263+
responds({ status: 307, headers: { location: '/new-two' }, body: '' }),
264+
);
265+
await expect(fetch(url + '/other', { redirect: 'manual' }), responds({ status: 200 }));
266+
});
267+
});
268+
269+
it('can be case sensitive', { timeout: 3000 }, async () => {
270+
const router = await buildRouter([
271+
{
272+
type: 'redirect-map',
273+
mapping: { '/one': '/new-one' },
274+
status: 307,
275+
options: { caseSensitive: true },
276+
},
277+
FALLBACK_200,
278+
]);
279+
return withServer(router, async (url) => {
280+
await expect(
281+
fetch(url + '/one', { redirect: 'manual' }),
282+
responds({ status: 307, headers: { location: '/new-one' }, body: '' }),
283+
);
284+
await expect(fetch(url + '/ONE', { redirect: 'manual' }), responds({ status: 200 }));
285+
});
286+
});
287+
288+
it('allows routing paths to themselves to normalise case', { timeout: 3000 }, async () => {
289+
const router = await buildRouter([
290+
{
291+
type: 'redirect-map',
292+
mapping: { '/one': '/one' },
293+
status: 307,
294+
options: { caseSensitive: false },
295+
},
296+
FALLBACK_200,
297+
]);
298+
return withServer(router, async (url) => {
299+
await expect(
300+
fetch(url + '/ONE', { redirect: 'manual' }),
301+
responds({ status: 307, headers: { location: '/one' }, body: '' }),
302+
);
303+
await expect(
304+
fetch(url + '/One', { redirect: 'manual' }),
305+
responds({ status: 307, headers: { location: '/one' }, body: '' }),
306+
);
307+
await expect(fetch(url + '/one', { redirect: 'manual' }), responds({ status: 200 }));
308+
});
309+
});
310+
311+
const TEST_MAPPING_FILE = makeTestTempFile(
312+
'map-',
313+
'redirects.map',
314+
`
315+
/foo /new-foo;
316+
# comment
317+
/bar\t/new-bar #comment
318+
;
319+
/baz
320+
/new-baz ;
321+
/indented \t /new-indented
322+
;
323+
# /nope /new-nope; /nope /new-nope
324+
/s1 /escaped\\ space;
325+
/s2 "/quoted space";
326+
`,
327+
);
328+
329+
it(
330+
'loads mappings from an nginx-formatted mapping file',
331+
{ timeout: 3000 },
332+
async ({ getTyped }) => {
333+
const router = await buildRouter([
334+
{
335+
type: 'redirect-map',
336+
mapping: getTyped(TEST_MAPPING_FILE),
337+
status: 307,
338+
options: { caseSensitive: false },
339+
},
340+
FALLBACK_200,
341+
]);
342+
return withServer(router, async (url) => {
343+
await expect(
344+
fetch(url + '/foo', { redirect: 'manual' }),
345+
responds({ status: 307, headers: { location: '/new-foo' }, body: '' }),
346+
);
347+
await expect(
348+
fetch(url + '/bar', { redirect: 'manual' }),
349+
responds({ status: 307, headers: { location: '/new-bar' }, body: '' }),
350+
);
351+
await expect(
352+
fetch(url + '/baz', { redirect: 'manual' }),
353+
responds({ status: 307, headers: { location: '/new-baz' }, body: '' }),
354+
);
355+
await expect(
356+
fetch(url + '/indented', { redirect: 'manual' }),
357+
responds({ status: 307, headers: { location: '/new-indented' }, body: '' }),
358+
);
359+
await expect(
360+
fetch(url + '/s1', { redirect: 'manual' }),
361+
responds({ status: 307, headers: { location: '/escaped space' }, body: '' }),
362+
);
363+
await expect(
364+
fetch(url + '/s2', { redirect: 'manual' }),
365+
responds({ status: 307, headers: { location: '/quoted space' }, body: '' }),
366+
);
367+
await expect(fetch(url + '/nope', { redirect: 'manual' }), responds({ status: 200 }));
368+
});
369+
},
370+
);
371+
});
372+
240373
it('logs requests', { timeout: 3000 }, async () => {
241374
const events: Omit<LogInfo, 'duration'>[] = [];
242375
const router = await buildRouter(
@@ -270,3 +403,12 @@ describe('buildRouter', () => {
270403
});
271404
});
272405
});
406+
407+
const FALLBACK_200: ConfigMount = {
408+
type: 'fixture',
409+
method: 'GET',
410+
path: '/*any',
411+
body: '',
412+
status: 200,
413+
headers: {},
414+
};

src/bin/config/loader.mts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ const params = new Map<string, { type: 'string' | 'number' | 'boolean'; multi?:
4040
['dependencies', { type: 'string' }],
4141
['mime', { type: 'string', multi: true }],
4242
['mime-types', { type: 'string', multi: true }],
43+
['redirect-map', { type: 'string', multi: true }],
4344
['write-compressed', { type: 'boolean' }],
4445
['min-compress', { type: 'number' }],
4546
['no-serve', { type: 'boolean' }],
@@ -151,6 +152,7 @@ export async function loadConfig(
151152
const minCompress = numberParam('min-compress');
152153
const mime = stringListParam('mime');
153154
const mimeTypes = stringListParam('mime-types');
155+
const redirectMap = stringListParam('redirect-map');
154156
const log = stringParam('log');
155157

156158
if (Number(Boolean(file)) + Number(Boolean(json)) + Number(Boolean(proxy)) > 1) {
@@ -207,6 +209,18 @@ export async function loadConfig(
207209
server.host = host;
208210
}
209211
}
212+
if (redirectMap.length > 0) {
213+
for (const server of config.servers) {
214+
for (const filePath of redirectMap) {
215+
server.mount.push({
216+
type: 'redirect-map',
217+
mapping: filePath,
218+
status: 307,
219+
options: { caseSensitive: false },
220+
});
221+
}
222+
}
223+
}
210224
if (dependencies !== undefined) {
211225
for (const server of config.servers) {
212226
server.mount.push({

src/bin/config/loader.test.mts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,48 @@ describe('loadConfig', () => {
257257
],
258258
},
259259
},
260+
{
261+
name: 'dependencies',
262+
args: ['--dependencies', './package.json'],
263+
expected: {
264+
...DEFAULT_CONFIG,
265+
servers: [
266+
{
267+
...DEFAULT_SERVER,
268+
mount: [
269+
DEFAULT_FILES,
270+
{
271+
type: 'dependencies',
272+
path: '/node_modules',
273+
package: './package.json',
274+
options: {},
275+
},
276+
],
277+
},
278+
],
279+
},
280+
},
281+
{
282+
name: 'redirect-map',
283+
args: ['--redirect-map', './redirects.map'],
284+
expected: {
285+
...DEFAULT_CONFIG,
286+
servers: [
287+
{
288+
...DEFAULT_SERVER,
289+
mount: [
290+
DEFAULT_FILES,
291+
{
292+
type: 'redirect-map',
293+
mapping: './redirects.map',
294+
status: 307,
295+
options: { caseSensitive: false },
296+
},
297+
],
298+
},
299+
],
300+
},
301+
},
260302
{
261303
name: 'no-serve',
262304
args: ['--no-serve'],

0 commit comments

Comments
 (0)