@transclude/core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +121 -0
- package/bin/build.js +469 -0
- package/bin/check.js +78 -0
- package/bin/dev.js +348 -0
- package/bin/release.js +176 -0
- package/bin/serve.bun.js +15 -0
- package/bin/serve.deno.js +15 -0
- package/bin/serve.js +12 -0
- package/editor/server.js +172 -0
- package/editor/vscode/extension.js +49 -0
- package/editor/vscode/package.json +32 -0
- package/editor/vscode/syntaxes/transclude.injection.json +41 -0
- package/package.json +82 -0
- package/src/address.js +183 -0
- package/src/app.js +492 -0
- package/src/cache.js +137 -0
- package/src/compiler/bind.js +496 -0
- package/src/compiler/codegen.js +1061 -0
- package/src/compiler/expr.js +221 -0
- package/src/compiler/index.js +964 -0
- package/src/compiler/interp.js +82 -0
- package/src/compiler/script.js +620 -0
- package/src/compiler/shim.js +756 -0
- package/src/compiler/sourcemap.js +140 -0
- package/src/compiler/types.js +163 -0
- package/src/compress.js +104 -0
- package/src/cookies.js +157 -0
- package/src/csp.js +192 -0
- package/src/document.js +604 -0
- package/src/extract.js +339 -0
- package/src/feed.js +194 -0
- package/src/include.js +89 -0
- package/src/lookup.js +49 -0
- package/src/negotiate.js +95 -0
- package/src/plugin.js +423 -0
- package/src/pool.js +29 -0
- package/src/precache.js +68 -0
- package/src/production.js +159 -0
- package/src/project.js +110 -0
- package/src/proxy.js +319 -0
- package/src/public-files.js +77 -0
- package/src/rewrite.js +281 -0
- package/src/routes.js +199 -0
- package/src/runtime/index.js +1345 -0
- package/src/server.js +183 -0
- package/src/sitemap.js +124 -0
- package/src/static-cache.js +170 -0
- package/src/typecheck.js +492 -0
- package/src/worker.js +87 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// A source map, so a stack from a rendered page names the `.html` file.
|
|
2
|
+
//
|
|
3
|
+
// Line level, not column. The generated line for an interpolation is one
|
|
4
|
+
// statement produced from one expression, so the line is the whole answer and a
|
|
5
|
+
// column would claim a precision the codegen does not have.
|
|
6
|
+
//
|
|
7
|
+
// No dependency. The encoding is small and writing it here keeps the compiler's
|
|
8
|
+
// import list what it is.
|
|
9
|
+
|
|
10
|
+
const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* One number, base64 VLQ.
|
|
14
|
+
*
|
|
15
|
+
* The low bit is the sign and each group of five bits carries a continuation
|
|
16
|
+
* flag, which is why this is not just base64 of the number.
|
|
17
|
+
*
|
|
18
|
+
* @param {number} value
|
|
19
|
+
* @returns {string}
|
|
20
|
+
*/
|
|
21
|
+
function vlq(value) {
|
|
22
|
+
let bits = value < 0 ? (-value << 1) | 1 : value << 1;
|
|
23
|
+
let out = '';
|
|
24
|
+
|
|
25
|
+
do {
|
|
26
|
+
let digit = bits & 31;
|
|
27
|
+
bits >>>= 5;
|
|
28
|
+
if (bits > 0) digit |= 32;
|
|
29
|
+
out += ALPHABET[digit];
|
|
30
|
+
} while (bits > 0);
|
|
31
|
+
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A source map v3 for a file whose generated lines are known to come from
|
|
37
|
+
* particular source lines.
|
|
38
|
+
*
|
|
39
|
+
* `lines` is one entry per generated line, counting from zero: the 1-based
|
|
40
|
+
* source line it came from, or null for a line the compiler wrote itself, which
|
|
41
|
+
* is most of the module. A null contributes no mapping at all rather than a
|
|
42
|
+
* wrong one, so a stack in generated scaffolding stays honest about being
|
|
43
|
+
* there.
|
|
44
|
+
*
|
|
45
|
+
* @param {(number|null)[]} lines
|
|
46
|
+
* @param {string} source the `.html` file's path, as it should appear to a tool
|
|
47
|
+
* @param {string} content the file's text, embedded so nothing has to find it
|
|
48
|
+
* @returns {string} JSON
|
|
49
|
+
*/
|
|
50
|
+
export function sourceMap(lines, source, content) {
|
|
51
|
+
const segments = [];
|
|
52
|
+
let previous = 0;
|
|
53
|
+
|
|
54
|
+
for (const line of lines) {
|
|
55
|
+
if (line === null || line === undefined) {
|
|
56
|
+
segments.push('');
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
// Fields: generated column, source index, source line, source column. Every
|
|
60
|
+
// one but the first is relative to the last mapping emitted, which is what
|
|
61
|
+
// makes the format compact and what makes order matter here.
|
|
62
|
+
const target = line - 1;
|
|
63
|
+
segments.push(`${vlq(0)}${vlq(0)}${vlq(target - previous)}${vlq(0)}`);
|
|
64
|
+
previous = target;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return JSON.stringify({
|
|
68
|
+
version: 3,
|
|
69
|
+
sources: [source],
|
|
70
|
+
sourcesContent: [content],
|
|
71
|
+
names: [],
|
|
72
|
+
mappings: segments.join(';'),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The map as a comment a runtime will read, for appending to the module.
|
|
78
|
+
*
|
|
79
|
+
* @param {string} json
|
|
80
|
+
* @returns {string}
|
|
81
|
+
*/
|
|
82
|
+
export function inlineMap(json) {
|
|
83
|
+
const base64 =
|
|
84
|
+
typeof Buffer === 'undefined'
|
|
85
|
+
? btoa(unescape(encodeURIComponent(json)))
|
|
86
|
+
: Buffer.from(json, 'utf8').toString('base64');
|
|
87
|
+
|
|
88
|
+
return `\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,${base64}\n`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Where a block landed in the assembled module.
|
|
93
|
+
*
|
|
94
|
+
* The assemblers build one template literal, so rather than restructuring them
|
|
95
|
+
* the block is written with a marker above it. This finds the marker, counts the
|
|
96
|
+
* lines before it, and hands back the module without it.
|
|
97
|
+
*
|
|
98
|
+
* @param {string} code the assembled module, markers and all
|
|
99
|
+
* @param {Array<{ marker: string, at: (number|null)[] }>} blocks
|
|
100
|
+
* @returns {{ code: string, lines: (number|null)[] }}
|
|
101
|
+
*/
|
|
102
|
+
export function lineMap(code, blocks) {
|
|
103
|
+
// In the order they appear, and measured against the text being built rather
|
|
104
|
+
// than the original. Taking them in the order the caller listed them recorded
|
|
105
|
+
// a position and then moved it: removing a marker earlier in the file shifts
|
|
106
|
+
// every line already noted below it, silently, by one per marker.
|
|
107
|
+
const found = blocks
|
|
108
|
+
.map((block) => ({ ...block, index: code.indexOf(block.marker) }))
|
|
109
|
+
.filter((block) => block.index !== -1)
|
|
110
|
+
.sort((a, b) => a.index - b.index);
|
|
111
|
+
|
|
112
|
+
const placed = [];
|
|
113
|
+
let text = '';
|
|
114
|
+
let cursor = 0;
|
|
115
|
+
|
|
116
|
+
for (const block of found) {
|
|
117
|
+
text += code.slice(cursor, block.index);
|
|
118
|
+
placed.push({ line: countLines(text), at: block.at });
|
|
119
|
+
// The marker and the newline ending its line, so the block moves up into it.
|
|
120
|
+
cursor = block.index + block.marker.length + 1;
|
|
121
|
+
}
|
|
122
|
+
text += code.slice(cursor);
|
|
123
|
+
|
|
124
|
+
const total = countLines(text) + 1;
|
|
125
|
+
const lines = new Array(total).fill(null);
|
|
126
|
+
|
|
127
|
+
for (const { line, at } of placed) {
|
|
128
|
+
for (let i = 0; i < at.length; i++) {
|
|
129
|
+
if (line + i < total) lines[line + i] = at[i];
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return { code: text, lines };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function countLines(text) {
|
|
137
|
+
let n = 0;
|
|
138
|
+
for (let i = 0; i < text.length; i++) if (text[i] === '\n') n += 1;
|
|
139
|
+
return n;
|
|
140
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// Emits transclude-env.d.ts from what TypeScript made of each file.
|
|
2
|
+
//
|
|
3
|
+
// Everything here is a type *string* produced by tsc's own printer, so this file
|
|
4
|
+
// formats and names things and nothing else. Nothing infers.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param {{ components?: object[], partials?: object[], layouts?: object[],
|
|
8
|
+
* pages?: object[] }} [what] each element carries its `tag`, its props `type`,
|
|
9
|
+
* its `members`, its `state` and whether anything `upgrades` it. `partials` is
|
|
10
|
+
* the light elements: the key is the old name and is load-bearing until the
|
|
11
|
+
* callers change with it.
|
|
12
|
+
* @returns {string} the contents of transclude-env.d.ts
|
|
13
|
+
*/
|
|
14
|
+
export function emitTypes({ components = [], partials = [], layouts = [], pages = [] } = {}) {
|
|
15
|
+
const out = [
|
|
16
|
+
'// Generated by transclude. Do not edit. `npm run check` rewrites it.',
|
|
17
|
+
'//',
|
|
18
|
+
'// Every type here was produced by TypeScript from the file it describes.',
|
|
19
|
+
'',
|
|
20
|
+
'export {};',
|
|
21
|
+
'',
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
for (const { tag, type, members, state } of [...components, ...partials]) {
|
|
25
|
+
out.push(`/** Properties of \`<${tag}>\`, from its <script properties> block. */`);
|
|
26
|
+
out.push(`export type ${interfaceName(tag)}Props = ${pretty(type)};`);
|
|
27
|
+
out.push('');
|
|
28
|
+
if (state) {
|
|
29
|
+
out.push(`/** Internal state of \`<${tag}>\`, from its <script state> block. */`);
|
|
30
|
+
out.push(`export type ${interfaceName(tag)}State = ${pretty(state)};`);
|
|
31
|
+
out.push('');
|
|
32
|
+
}
|
|
33
|
+
if (members) {
|
|
34
|
+
out.push(`/** Members of \`<${tag}>\`, from its \`export const prototype\`. */`);
|
|
35
|
+
out.push(`export type ${interfaceName(tag)}Members = ${pretty(members)};`);
|
|
36
|
+
out.push('');
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
for (const { id, type, context } of layouts) {
|
|
41
|
+
const name = `${interfaceName(id)}Layout`;
|
|
42
|
+
out.push(`/** The \`ctx\` argument of \`${id}/_layout.html\`'s loader. */`);
|
|
43
|
+
out.push(`export type ${name}Context = ${pretty(context ?? 'unknown')};`);
|
|
44
|
+
out.push('');
|
|
45
|
+
out.push(`/** Data returned by \`${id}/_layout.html\`. */`);
|
|
46
|
+
out.push(`export type ${name}Data = ${pretty(type)};`);
|
|
47
|
+
out.push('');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
for (const { id, type, context, params = [], pattern } of pages) {
|
|
51
|
+
const name = interfaceName(id);
|
|
52
|
+
|
|
53
|
+
if (params.length) {
|
|
54
|
+
out.push(`/** Route params for \`${pattern}\`. */`);
|
|
55
|
+
out.push(`export type ${name}Params = { ${params.map((p) => `${p}: string`).join('; ')} };`);
|
|
56
|
+
out.push('');
|
|
57
|
+
}
|
|
58
|
+
out.push(`/** The \`ctx\` argument of \`${id}\`'s <script server> loader. */`);
|
|
59
|
+
out.push(`export type ${name}Context = ${pretty(context ?? 'unknown')};`);
|
|
60
|
+
out.push('');
|
|
61
|
+
out.push(`/** Data returned by \`${id}\`'s <script server> block. */`);
|
|
62
|
+
out.push(`export type ${name}Data = ${pretty(type)};`);
|
|
63
|
+
out.push('');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (components.length || partials.length) {
|
|
67
|
+
out.push('declare global {');
|
|
68
|
+
out.push(' interface HTMLElementTagNameMap {');
|
|
69
|
+
// A light element registers as a custom element too, so it is an HTMLElement.
|
|
70
|
+
// querySelector should know about it even though nothing registers it.
|
|
71
|
+
for (const { tag, upgrades, members, state } of [...components, ...partials]) {
|
|
72
|
+
const name = interfaceName(tag);
|
|
73
|
+
// Accessors exist only where something registers the element. Where
|
|
74
|
+
// nothing does, the tag is still an HTMLElement, just a plain one.
|
|
75
|
+
const parts = ['HTMLElement'];
|
|
76
|
+
if (upgrades) parts.push(`${name}Props`);
|
|
77
|
+
if (state) parts.push(`${name}State`);
|
|
78
|
+
if (members) parts.push(`${name}Members`);
|
|
79
|
+
out.push(` ${JSON.stringify(tag)}: ${parts.join(' & ')};`);
|
|
80
|
+
}
|
|
81
|
+
out.push(' }');
|
|
82
|
+
out.push('}');
|
|
83
|
+
out.push('');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return out.join('\n');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* tsc prints a type on one line. Breaking it at top-level separators keeps a
|
|
91
|
+
* generated file worth opening; anything it cannot parse is left alone.
|
|
92
|
+
*/
|
|
93
|
+
function pretty(type, indent = '') {
|
|
94
|
+
if (!type || !type.includes('{')) return type;
|
|
95
|
+
// tsc prints an empty object as `{}`; expanding it leaves a blank line.
|
|
96
|
+
if (type.trim() === '{}') return '{}';
|
|
97
|
+
|
|
98
|
+
let out = '';
|
|
99
|
+
let depth = 0;
|
|
100
|
+
let quote = null;
|
|
101
|
+
|
|
102
|
+
for (let i = 0; i < type.length; i++) {
|
|
103
|
+
const char = type[i];
|
|
104
|
+
|
|
105
|
+
if (quote) {
|
|
106
|
+
out += char;
|
|
107
|
+
if (char === quote && type[i - 1] !== '\\') quote = null;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (char === '"' || char === "'") {
|
|
111
|
+
quote = char;
|
|
112
|
+
out += char;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (char === '{') {
|
|
117
|
+
// `{}` stays on one line; expanding it leaves a blank body.
|
|
118
|
+
const rest = type.slice(i + 1).replace(/^\s*/, '');
|
|
119
|
+
if (rest.startsWith('}')) {
|
|
120
|
+
out += '{}';
|
|
121
|
+
i = type.indexOf('}', i + 1);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
depth++;
|
|
125
|
+
out += `{\n${indent}${' '.repeat(depth)}`;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (char === '}') {
|
|
129
|
+
depth--;
|
|
130
|
+
out = out.replace(/[ \t]+$/, '');
|
|
131
|
+
out += `\n${indent}${' '.repeat(depth)}}`;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (char === ';' && depth > 0) {
|
|
135
|
+
const rest = type.slice(i + 1).replace(/^\s*/, '');
|
|
136
|
+
out += rest.startsWith('}') ? ';' : `;\n${indent}${' '.repeat(depth)}`;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (char === ' ' && out.endsWith('\n' + indent + ' '.repeat(depth))) continue;
|
|
140
|
+
out += char;
|
|
141
|
+
}
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* `user-card` -> `UserCard`, `people-_name` -> `PeopleName`, `404` -> `_404`.
|
|
147
|
+
*
|
|
148
|
+
* The result has to be a valid TypeScript identifier, and a route named for a
|
|
149
|
+
* status code starts with a digit, which is a syntax error, not a style one.
|
|
150
|
+
*
|
|
151
|
+
* @param {string} name a tag or a route id
|
|
152
|
+
* @returns {string} PascalCase, safe as a type name
|
|
153
|
+
*/
|
|
154
|
+
export function interfaceName(name) {
|
|
155
|
+
const camel = String(name)
|
|
156
|
+
.split(/[^A-Za-z0-9]+/)
|
|
157
|
+
.filter(Boolean)
|
|
158
|
+
.map((part) => part[0].toUpperCase() + part.slice(1))
|
|
159
|
+
.join('');
|
|
160
|
+
|
|
161
|
+
if (!camel) return 'Anonymous';
|
|
162
|
+
return /^[0-9]/.test(camel) ? `_${camel}` : camel;
|
|
163
|
+
}
|
package/src/compress.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Build-time compression.
|
|
2
|
+
//
|
|
3
|
+
// Compressing once, at rest, is why this is worth doing at all: brotli can run
|
|
4
|
+
// at quality 11 because nobody is waiting for it, where a proxy compressing on
|
|
5
|
+
// the fly picks 4 or 5 to keep latency down. The output is strictly smaller than
|
|
6
|
+
// anything the request path could produce.
|
|
7
|
+
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import zlib from 'node:zlib';
|
|
11
|
+
import { promisify } from 'node:util';
|
|
12
|
+
import { pool } from './pool.js';
|
|
13
|
+
|
|
14
|
+
const brotli = promisify(zlib.brotliCompress);
|
|
15
|
+
const gzip = promisify(zlib.gzip);
|
|
16
|
+
|
|
17
|
+
const COMPRESSIBLE = new Set(['.html', '.js', '.mjs', '.css', '.json', '.svg', '.txt', '.xml', '.map']);
|
|
18
|
+
|
|
19
|
+
// Below this, the framing costs more than it saves. A 91 byte file gzips to 120.
|
|
20
|
+
export const COMPRESSIBLE_FLOOR = 512;
|
|
21
|
+
|
|
22
|
+
// At build time nothing is waiting, so brotli runs at its maximum. Per request
|
|
23
|
+
// it is not: measured on a rendered page, quality 11 costs 1.372 ms against
|
|
24
|
+
// 0.056 ms at quality 5, and buys 105 bytes. The levels below are the ones worth
|
|
25
|
+
// paying for while a client is on the line.
|
|
26
|
+
const DYNAMIC = {
|
|
27
|
+
br: (body) => ({
|
|
28
|
+
params: {
|
|
29
|
+
[zlib.constants.BROTLI_PARAM_QUALITY]: 5,
|
|
30
|
+
[zlib.constants.BROTLI_PARAM_SIZE_HINT]: body.length,
|
|
31
|
+
},
|
|
32
|
+
}),
|
|
33
|
+
gzip: () => ({ level: 6 }),
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Compresses a response as it goes out. Async so the work lands on libuv's
|
|
38
|
+
* thread pool rather than the event loop.
|
|
39
|
+
*
|
|
40
|
+
* @param {Buffer|Uint8Array} body
|
|
41
|
+
* @param {'br'|'gzip'|string} encoding anything else is returned unchanged
|
|
42
|
+
* @returns {Promise<Buffer|Uint8Array>}
|
|
43
|
+
*/
|
|
44
|
+
export async function compressResponse(body, encoding) {
|
|
45
|
+
if (encoding === 'br') return brotli(body, DYNAMIC.br(body));
|
|
46
|
+
if (encoding === 'gzip') return gzip(body, DYNAMIC.gzip());
|
|
47
|
+
return body;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Writes a `.br` and a `.gz` beside every compressible file in `dirs`.
|
|
52
|
+
*
|
|
53
|
+
* @param {string[]} dirs
|
|
54
|
+
* @param {{ floor?: number, concurrency?: number }} [options] `floor` is the
|
|
55
|
+
* size below which framing costs more than it saves
|
|
56
|
+
* @returns {Promise<{ files: number, raw: number, gzip: number, brotli: number }>}
|
|
57
|
+
*/
|
|
58
|
+
export async function precompress(dirs, { floor = COMPRESSIBLE_FLOOR, concurrency = 8 } = {}) {
|
|
59
|
+
const files = dirs.flatMap((dir) => walk(dir)).filter((file) => {
|
|
60
|
+
if (!COMPRESSIBLE.has(path.extname(file))) return false;
|
|
61
|
+
return fs.statSync(file).size >= floor;
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const results = await pool(files, concurrency, async (file) => {
|
|
65
|
+
const raw = fs.readFileSync(file);
|
|
66
|
+
|
|
67
|
+
const [br, gz] = await Promise.all([
|
|
68
|
+
brotli(raw, {
|
|
69
|
+
params: {
|
|
70
|
+
[zlib.constants.BROTLI_PARAM_QUALITY]: 11,
|
|
71
|
+
[zlib.constants.BROTLI_PARAM_SIZE_HINT]: raw.length,
|
|
72
|
+
},
|
|
73
|
+
}),
|
|
74
|
+
gzip(raw, { level: 9 }),
|
|
75
|
+
]);
|
|
76
|
+
|
|
77
|
+
// Only keep a variant that actually helps; compression is not guaranteed to.
|
|
78
|
+
let saved = 0;
|
|
79
|
+
if (br.length < raw.length) {
|
|
80
|
+
fs.writeFileSync(`${file}.br`, br);
|
|
81
|
+
saved = raw.length - br.length;
|
|
82
|
+
}
|
|
83
|
+
if (gz.length < raw.length) fs.writeFileSync(`${file}.gz`, gz);
|
|
84
|
+
|
|
85
|
+
return { raw: raw.length, br: br.length, gz: gz.length, saved };
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
files: results.length,
|
|
90
|
+
raw: results.reduce((total, r) => total + r.raw, 0),
|
|
91
|
+
brotli: results.reduce((total, r) => total + r.br, 0),
|
|
92
|
+
gzip: results.reduce((total, r) => total + r.gz, 0),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function walk(dir, out = []) {
|
|
97
|
+
if (!fs.existsSync(dir)) return out;
|
|
98
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
99
|
+
const full = path.join(dir, entry.name);
|
|
100
|
+
if (entry.isDirectory()) walk(full, out);
|
|
101
|
+
else if (!full.endsWith('.br') && !full.endsWith('.gz')) out.push(full);
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}
|
package/src/cookies.js
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// Cookies, on the two things a loader already has: `ctx.request` to read and
|
|
2
|
+
// `ctx.response.headers` to write.
|
|
3
|
+
//
|
|
4
|
+
// The server has no built-in cookie API. `document.cookie` is the browser's, and
|
|
5
|
+
// on this side there is only a header to parse and a header to format. So this is
|
|
6
|
+
// the one place the framework has to supply something rather than point at the
|
|
7
|
+
// platform. The parsing and formatting are Hono's, which are already a dependency
|
|
8
|
+
// and already correct about encoding, `Max-Age`, and HMAC signing; what this adds
|
|
9
|
+
// is that an author never sees a router `Context` to use them.
|
|
10
|
+
|
|
11
|
+
import { parse, parseSigned, serialize, serializeSigned } from 'hono/utils/cookie';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* `set` appends rather than replaces: two cookies are two `Set-Cookie` headers,
|
|
15
|
+
* and `Headers.set` would throw the first one away.
|
|
16
|
+
*
|
|
17
|
+
* `secret` comes from the config, which is the app's file, so where it comes from,
|
|
18
|
+
* whether an env var or a secret manager, is the app's decision and not the
|
|
19
|
+
* framework's. Without it, signing is an error rather than a silent downgrade to
|
|
20
|
+
* unsigned, because a signature nobody checks is worse than none.
|
|
21
|
+
*
|
|
22
|
+
* @param {Request} request
|
|
23
|
+
* @param {{ headers: Headers }} response the shared envelope
|
|
24
|
+
* @param {string|null} [secret] without one, `signed` throws rather than writing unsigned
|
|
25
|
+
* @returns {object} `get`, `set`, `delete`, `all`, `signed`, and the `personal` flag the cache reads
|
|
26
|
+
*/
|
|
27
|
+
export function cookiesOf(request, response, secret = null) {
|
|
28
|
+
// Reading one is what makes a page personal, and a personal page must not be
|
|
29
|
+
// held in a shared cache. Writing a header is not the whole test: a page that
|
|
30
|
+
// only *reads* `mine` and renders a count sets nothing, and caching it would
|
|
31
|
+
// hand one visitor's count to the next. So the read is what is recorded.
|
|
32
|
+
let read = false;
|
|
33
|
+
const header = () => {
|
|
34
|
+
read = true;
|
|
35
|
+
return request?.headers?.get('cookie') ?? '';
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const write = (value) => response.headers.append('Set-Cookie', value);
|
|
39
|
+
|
|
40
|
+
const requireSecret = (what) => {
|
|
41
|
+
if (secret) return secret;
|
|
42
|
+
throw new Error(
|
|
43
|
+
`[transclude] ${what} needs a secret. Set \`cookieSecret\` in ` +
|
|
44
|
+
`transclude.config.js (read it from the environment there, not from a literal)`,
|
|
45
|
+
);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
/** Whether anything asked what the request carried. Read by the cache. */
|
|
50
|
+
get personal() {
|
|
51
|
+
return read;
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
/** One cookie, or undefined. */
|
|
55
|
+
get(name) {
|
|
56
|
+
return parse(header(), name)[name];
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Every cookie the request carried.
|
|
61
|
+
*
|
|
62
|
+
* A null-prototype object, which is Hono's doing and worth keeping: a cookie
|
|
63
|
+
* called `constructor` or `__proto__` is attacker-supplied input, and on a
|
|
64
|
+
* plain object it would collide with something that already exists.
|
|
65
|
+
*/
|
|
66
|
+
all() {
|
|
67
|
+
return parse(header());
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
set(name, value, options = {}) {
|
|
71
|
+
write(serialize(name, value, withDefaults(options, request)));
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Expiry in the past is how a cookie is removed. There is no delete verb.
|
|
76
|
+
* The path has to match the one it was set with or the browser keeps it.
|
|
77
|
+
*/
|
|
78
|
+
delete(name, options = {}) {
|
|
79
|
+
write(serialize(name, '', { ...withDefaults(options, request), maxAge: 0, expires: new Date(0) }));
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Signed with HMAC, so the value is readable by the client but not
|
|
84
|
+
* forgeable. That is what makes a cookie usable as a session: put an id in
|
|
85
|
+
* it, keep the rest on the server.
|
|
86
|
+
*
|
|
87
|
+
* Async because verifying is a crypto operation. A tampered or unsigned
|
|
88
|
+
* value reads as undefined rather than throwing. It is untrusted input, and
|
|
89
|
+
* "no valid cookie" is the honest answer.
|
|
90
|
+
*
|
|
91
|
+
* Hono reports absent and present-but-forged differently: a missing name is a
|
|
92
|
+
* missing key, a bad signature is `false`. Both mean the same thing here,
|
|
93
|
+
* that there is nothing the caller can trust, and turning them into one value
|
|
94
|
+
* is what keeps `?? fallback` doing what it looks like it does.
|
|
95
|
+
*/
|
|
96
|
+
signed: {
|
|
97
|
+
async get(name) {
|
|
98
|
+
const parsed = await parseSigned(header(), requireSecret('reading a signed cookie'), name);
|
|
99
|
+
return typeof parsed[name] === 'string' ? parsed[name] : undefined;
|
|
100
|
+
},
|
|
101
|
+
async all() {
|
|
102
|
+
const parsed = await parseSigned(header(), requireSecret('reading a signed cookie'));
|
|
103
|
+
return Object.fromEntries(
|
|
104
|
+
Object.entries(parsed).filter(([, value]) => typeof value === 'string'),
|
|
105
|
+
);
|
|
106
|
+
},
|
|
107
|
+
async set(name, value, options = {}) {
|
|
108
|
+
write(
|
|
109
|
+
await serializeSigned(
|
|
110
|
+
name,
|
|
111
|
+
value,
|
|
112
|
+
requireSecret('signing a cookie'),
|
|
113
|
+
withDefaults(options, request),
|
|
114
|
+
),
|
|
115
|
+
);
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Whether this request arrived over TLS, so a cookie can say `Secure`.
|
|
123
|
+
*
|
|
124
|
+
* A proxy that terminates TLS forwards plain HTTP, so the request's own URL says
|
|
125
|
+
* `http:` for a visitor who used `https:`. `X-Forwarded-Proto` is a header a
|
|
126
|
+
* client can set, and trusting it here is safe in the only direction it can be
|
|
127
|
+
* wrong: a lie turns `Secure` *on*, and a cookie that is then not sent over
|
|
128
|
+
* plain HTTP fails closed. Nothing reads it to turn `Secure` off.
|
|
129
|
+
*
|
|
130
|
+
* @param {Request|null|undefined} request
|
|
131
|
+
* @returns {boolean}
|
|
132
|
+
*/
|
|
133
|
+
function overTls(request) {
|
|
134
|
+
const forwarded = request?.headers?.get('x-forwarded-proto') ?? '';
|
|
135
|
+
if (forwarded.split(',')[0].trim().toLowerCase() === 'https') return true;
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
return new URL(request.url).protocol === 'https:';
|
|
139
|
+
} catch {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Defaults worth having rather than defaults the spec gives you. A cookie with
|
|
146
|
+
* no `Path` is scoped to the current directory, which is almost never what was
|
|
147
|
+
* meant; without `HttpOnly` a script can read a session id; and `SameSite=Lax`
|
|
148
|
+
* is what stops it riding along on a cross-site request. That is the same hole
|
|
149
|
+
* CSRF protection closes from the other side.
|
|
150
|
+
*
|
|
151
|
+
* `Secure` follows the connection rather than being always on, because always
|
|
152
|
+
* on breaks `http://localhost` and an author who cannot keep a session in dev
|
|
153
|
+
* turns the whole thing off. Set it yourself to override either way.
|
|
154
|
+
*/
|
|
155
|
+
function withDefaults(options, request) {
|
|
156
|
+
return { path: '/', httpOnly: true, sameSite: 'Lax', secure: overTls(request), ...options };
|
|
157
|
+
}
|