import { type LLParse, type source } from 'llparse';
type Node = source.node.Node;
type Match = source.node.Match;
import {
ALPHA,
ERROR,
URL_CHAR,
USERINFO_CHARS,
} from './constants';
type SpanName = 'schema' | 'host' | 'path' | 'query' | 'fragment' | 'url';
export interface IURLResult {
readonly entry: {
readonly normal: Node;
readonly connect: Node;
};
readonly exit: {
readonly toHTTP: Node;
readonly toHTTP09: Node;
};
}
type SpanTable = Map<SpanName, source.Span>;
export class URL {
private readonly spanTable: SpanTable = new Map();
private readonly errorInvalid: Node;
private readonly URL_CHAR: typeof URL_CHAR;
private readonly llparse: LLParse;
constructor(llparse: LLParse, separateSpans = false) {
this.llparse = llparse;
const p = this.llparse;
this.errorInvalid = p.error(ERROR.INVALID_URL, 'Invalid characters in url');
this.URL_CHAR = URL_CHAR;
const table = this.spanTable;
if (separateSpans) {
table.set('schema', p.span(p.code.span('llhttp__on_url_schema')));
table.set('host', p.span(p.code.span('llhttp__on_url_host')));
table.set('path', p.span(p.code.span('llhttp__on_url_path')));
table.set('query', p.span(p.code.span('llhttp__on_url_query')));
table.set('fragment',
p.span(p.code.span('llhttp__on_url_fragment')));
} else {
table.set('url', p.span(p.code.span('llhttp__on_url')));
}
}
public build(): IURLResult {
const p = this.llparse;
const entry = {
connect: this.node('entry_connect'),
normal: this.node('entry_normal'),
};
const start = this.node('start');
const path = this.node('path');
const queryOrFragment = this.node('query_or_fragment');
const schema = this.node('schema');
const schemaDelim = this.node('schema_delim');
const server = this.node('server');
const queryStart = this.node('query_start');
const query = this.node('query');
const fragment = this.node('fragment');
const serverWithAt = this.node('server_with_at');
entry.normal
.otherwise(this.spanStart('url', start));
entry.connect
.otherwise(this.spanStart('url', this.spanStart('host', server)));
start
.peek([ '/', '*' ], this.spanStart('path').skipTo(path))
.peek(ALPHA, this.spanStart('schema', schema))
.otherwise(p.error(ERROR.INVALID_URL, 'Unexpected start char in url'));
schema
.match(ALPHA, schema)
.peek(':', this.spanEnd('schema').skipTo(schemaDelim))
.otherwise(p.error(ERROR.INVALID_URL, 'Unexpected char in url schema'));
schemaDelim
.match('//', this.spanStart('host', server))
.otherwise(p.error(ERROR.INVALID_URL, 'Unexpected char in url schema'));
for (const node of [ server, serverWithAt ]) {
node
.peek('/', this.spanEnd('host', this.spanStart('path').skipTo(path)))
.match('?', this.spanEnd('host', this.spanStart('query', query)))
.match(USERINFO_CHARS, server)
.match([ '[', ']' ], server)
.otherwise(p.error(ERROR.INVALID_URL, 'Unexpected char in url server'));
if (node !== serverWithAt) {
node.match('@', serverWithAt);
}
}
serverWithAt
.match('@', p.error(ERROR.INVALID_URL, 'Double @ in url'));
path
.match(this.URL_CHAR, path)
.otherwise(this.spanEnd('path', queryOrFragment));
// Performance optimization, split `path` so that the fast case remains
// there
queryOrFragment
.match('?', this.spanStart('query', query))
.match('#', this.spanStart('fragment', fragment))
.otherwise(p.error(ERROR.INVALID_URL, 'Invalid char in url path'));
query
.match(this.URL_CHAR, query)
// Allow extra '?' in query string
.match('?', query)
.peek('#', this.spanEnd('query')
.skipTo(this.spanStart('fragment', fragment)))
.otherwise(p.error(ERROR.INVALID_URL, 'Invalid char in url query'));
fragment
.match(this.URL_CHAR, fragment)
.match([ '?', '#' ], fragment)
.otherwise(
p.error(ERROR.INVALID_URL, 'Invalid char in url fragment start'));
for (const node of [ start, schema, schemaDelim ]) {
/* No whitespace allowed here */
node.match([ ' ', '\r', '\n' ], this.errorInvalid);
}
// Adaptors
const toHTTP = this.node('to_http');
const toHTTP09 = this.node('to_http_09');
const skipToHTTP = this.node('skip_to_http')
.skipTo(toHTTP);
const skipToHTTP09 = this.node('skip_to_http09')
.skipTo(toHTTP09);
const skipCRLF = this.node('skip_lf_to_http09')
.match('\r\n', toHTTP09)
.otherwise(p.error(ERROR.INVALID_URL, 'Expected CRLF'));
for (const node of [ server, serverWithAt, queryOrFragment, queryStart, query, fragment ]) {
let spanName: SpanName | undefined;
if (node === server || node === serverWithAt) {
spanName = 'host';
} else if (node === queryStart || node === query) {
spanName = 'query';
} else if (node === fragment) {
spanName = 'fragment';
}
const endTo = (target: Node): Node => {
let res: Node = this.spanEnd('url', target);
if (spanName !== undefined) {
res = this.spanEnd(spanName, res);
}
return res;
};
node.peek(' ', endTo(skipToHTTP));
node.peek('\r', endTo(skipCRLF));
node.peek('\n', endTo(skipToHTTP09));
}
return {
entry,
exit: {
toHTTP,
toHTTP09,
},
};
}
private spanStart(name: SpanName, otherwise?: Node): Node {
let res: Node;
if (this.spanTable.has(name)) {
res = this.spanTable.get(name)!.start();
} else {
res = this.llparse.node('span_start_stub_' + name);
}
if (otherwise !== undefined) {
res.otherwise(otherwise);
}
return res;
}
private spanEnd(name: SpanName, otherwise?: Node): Node {
let res: Node;
if (this.spanTable.has(name)) {
res = this.spanTable.get(name)!.end();
} else {
res = this.llparse.node('span_end_stub_' + name);
}
if (otherwise !== undefined) {
res.otherwise(otherwise);
}
return res;
}
private node(name: string): Match {
const res = this.llparse.node('url_' + name);
res.match([ '\t', '\f' ], this.errorInvalid);
return res;
}
}