@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
package/editor/server.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// Language server for .html files in an transclude project.
|
|
2
|
+
//
|
|
3
|
+
// Hand-written JSON-RPC rather than a dependency. The part of the protocol
|
|
4
|
+
// needed here is small, and keeping it dependency-free means any editor that
|
|
5
|
+
// speaks LSP can use it without the project growing a toolchain.
|
|
6
|
+
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
10
|
+
import { createChecker } from '../src/typecheck.js';
|
|
11
|
+
import { loadProject } from '../src/project.js';
|
|
12
|
+
|
|
13
|
+
let checker = null;
|
|
14
|
+
let root = process.cwd();
|
|
15
|
+
|
|
16
|
+
// ---- transport -------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
let buffer = Buffer.alloc(0);
|
|
19
|
+
|
|
20
|
+
process.stdin.on('data', (chunk) => {
|
|
21
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
22
|
+
|
|
23
|
+
for (;;) {
|
|
24
|
+
const header = buffer.indexOf('\r\n\r\n');
|
|
25
|
+
if (header === -1) return;
|
|
26
|
+
|
|
27
|
+
const length = Number(/Content-Length: (\d+)/i.exec(buffer.slice(0, header).toString())?.[1]);
|
|
28
|
+
if (!Number.isFinite(length)) return;
|
|
29
|
+
|
|
30
|
+
const start = header + 4;
|
|
31
|
+
if (buffer.length < start + length) return;
|
|
32
|
+
|
|
33
|
+
const message = JSON.parse(buffer.slice(start, start + length).toString());
|
|
34
|
+
buffer = buffer.slice(start + length);
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
handle(message);
|
|
38
|
+
} catch (err) {
|
|
39
|
+
log(`error handling ${message.method}: ${err.stack ?? err.message}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
function send(message) {
|
|
45
|
+
const body = Buffer.from(JSON.stringify({ jsonrpc: '2.0', ...message }));
|
|
46
|
+
process.stdout.write(`Content-Length: ${body.length}\r\n\r\n`);
|
|
47
|
+
process.stdout.write(body);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** stderr, so it never corrupts the protocol stream on stdout. */
|
|
51
|
+
function log(text) {
|
|
52
|
+
process.stderr.write(`[transclude] ${text}\n`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ---- protocol --------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
function handle(message) {
|
|
58
|
+
switch (message.method) {
|
|
59
|
+
case 'initialize': {
|
|
60
|
+
root = message.params?.rootPath ?? fileURLToPath(message.params?.rootUri ?? pathToFileURL(root).href);
|
|
61
|
+
// The editor says which folder it opened, so the config is loaded from
|
|
62
|
+
// there. Everything after this needs it, so nothing is answered until it
|
|
63
|
+
// has arrived.
|
|
64
|
+
loadProject(root)
|
|
65
|
+
.then((project) => {
|
|
66
|
+
checker = createChecker({ root: project.root, ...project.config });
|
|
67
|
+
log(`watching ${project.root}`);
|
|
68
|
+
})
|
|
69
|
+
.catch((err) => log(`no project here: ${err.message}`));
|
|
70
|
+
send({
|
|
71
|
+
id: message.id,
|
|
72
|
+
result: {
|
|
73
|
+
capabilities: {
|
|
74
|
+
textDocumentSync: { openClose: true, change: 1, save: true },
|
|
75
|
+
hoverProvider: true,
|
|
76
|
+
},
|
|
77
|
+
serverInfo: { name: 'transclude', version: '0.1.0' },
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
case 'initialized':
|
|
84
|
+
return;
|
|
85
|
+
|
|
86
|
+
case 'shutdown':
|
|
87
|
+
send({ id: message.id, result: null });
|
|
88
|
+
return;
|
|
89
|
+
|
|
90
|
+
case 'exit':
|
|
91
|
+
process.exit(0);
|
|
92
|
+
return;
|
|
93
|
+
|
|
94
|
+
case 'textDocument/didOpen':
|
|
95
|
+
publish(message.params.textDocument.uri, message.params.textDocument.text);
|
|
96
|
+
return;
|
|
97
|
+
|
|
98
|
+
case 'textDocument/didChange':
|
|
99
|
+
// Full sync, so the last change carries the whole document.
|
|
100
|
+
publish(message.params.textDocument.uri, message.params.contentChanges.at(-1).text);
|
|
101
|
+
return;
|
|
102
|
+
|
|
103
|
+
case 'textDocument/didSave':
|
|
104
|
+
if (message.params.text !== undefined) publish(message.params.textDocument.uri, message.params.text);
|
|
105
|
+
return;
|
|
106
|
+
|
|
107
|
+
case 'textDocument/hover':
|
|
108
|
+
send({ id: message.id, result: hover(message.params) });
|
|
109
|
+
return;
|
|
110
|
+
|
|
111
|
+
default:
|
|
112
|
+
// Requests must be answered even when unsupported, or the client waits.
|
|
113
|
+
if (message.id !== undefined) send({ id: message.id, result: null });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function publish(uri, text) {
|
|
118
|
+
if (!checker) return;
|
|
119
|
+
const file = fileURLToPath(uri);
|
|
120
|
+
if (!file.endsWith('.html')) return;
|
|
121
|
+
|
|
122
|
+
checker.update(file, text);
|
|
123
|
+
|
|
124
|
+
const diagnostics = checker.check(file).map((diagnostic) => ({
|
|
125
|
+
range: rangeOf(text, diagnostic.offset, diagnostic.length),
|
|
126
|
+
severity: diagnostic.severity === 'error' ? 1 : 2,
|
|
127
|
+
code: `TS${diagnostic.code}`,
|
|
128
|
+
source: 'transclude',
|
|
129
|
+
message: diagnostic.message,
|
|
130
|
+
}));
|
|
131
|
+
|
|
132
|
+
send({ method: 'textDocument/publishDiagnostics', params: { uri, diagnostics } });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function hover({ textDocument, position }) {
|
|
136
|
+
if (!checker) return null;
|
|
137
|
+
const file = fileURLToPath(textDocument.uri);
|
|
138
|
+
if (!fs.existsSync(file)) return null;
|
|
139
|
+
|
|
140
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
141
|
+
const info = checker.quickInfo(file, offsetOf(text, position));
|
|
142
|
+
if (!info?.text) return null;
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
contents: {
|
|
146
|
+
kind: 'markdown',
|
|
147
|
+
value: `\`\`\`ts\n${info.text}\n\`\`\`${info.documentation ? `\n\n${info.documentation}` : ''}`,
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ---- positions -------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
/** LSP counts lines and characters from zero. */
|
|
155
|
+
function positionOf(text, offset) {
|
|
156
|
+
const before = text.slice(0, offset);
|
|
157
|
+
const line = before.split('\n').length - 1;
|
|
158
|
+
return { line, character: offset - (before.lastIndexOf('\n') + 1) };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function rangeOf(text, offset, length) {
|
|
162
|
+
return { start: positionOf(text, offset), end: positionOf(text, offset + Math.max(1, length)) };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function offsetOf(text, position) {
|
|
166
|
+
const lines = text.split('\n');
|
|
167
|
+
let offset = 0;
|
|
168
|
+
for (let i = 0; i < position.line && i < lines.length; i++) offset += lines[i].length + 1;
|
|
169
|
+
return offset + position.character;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
log(`ready (node ${process.version}, cwd ${path.basename(process.cwd())})`);
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Starts the language server for workspaces that look like an transclude
|
|
2
|
+
// project. Anything else is left alone. The grammar is harmless everywhere, and
|
|
3
|
+
// the checker only makes sense where transclude.config.js exists.
|
|
4
|
+
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const fs = require('node:fs');
|
|
7
|
+
const vscode = require('vscode');
|
|
8
|
+
const { LanguageClient, TransportKind } = require('vscode-languageclient/node');
|
|
9
|
+
|
|
10
|
+
let client;
|
|
11
|
+
|
|
12
|
+
function activate(context) {
|
|
13
|
+
if (!vscode.workspace.getConfiguration('transclude').get('enable')) return;
|
|
14
|
+
|
|
15
|
+
const folder = vscode.workspace.workspaceFolders?.[0];
|
|
16
|
+
if (!folder) return;
|
|
17
|
+
|
|
18
|
+
const root = folder.uri.fsPath;
|
|
19
|
+
if (!fs.existsSync(path.join(root, 'transclude.config.js'))) return;
|
|
20
|
+
|
|
21
|
+
// Installed, the server is in the package. In the framework's own repo it is
|
|
22
|
+
// beside this file. Try both rather than assume a layout.
|
|
23
|
+
const server = [
|
|
24
|
+
path.join(root, 'node_modules/transclude/editor/server.js'),
|
|
25
|
+
path.join(root, 'editor/server.js'),
|
|
26
|
+
].find((file) => fs.existsSync(file));
|
|
27
|
+
if (!server) return;
|
|
28
|
+
|
|
29
|
+
client = new LanguageClient(
|
|
30
|
+
'transclude',
|
|
31
|
+
'transclude',
|
|
32
|
+
{
|
|
33
|
+
run: { module: server, transport: TransportKind.stdio },
|
|
34
|
+
debug: { module: server, transport: TransportKind.stdio },
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
documentSelector: [{ scheme: 'file', language: 'html' }],
|
|
38
|
+
synchronize: { fileEvents: vscode.workspace.createFileSystemWatcher('**/*.html') },
|
|
39
|
+
},
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
context.subscriptions.push(client.start());
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function deactivate() {
|
|
46
|
+
return client?.stop();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
module.exports = { activate, deactivate };
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "transclude",
|
|
3
|
+
"displayName": "transclude",
|
|
4
|
+
"description": "Diagnostics, hovers and highlighting for transclude .html files",
|
|
5
|
+
"version": "0.1.0",
|
|
6
|
+
"private": true,
|
|
7
|
+
"engines": { "vscode": "^1.85.0" },
|
|
8
|
+
"categories": ["Programming Languages"],
|
|
9
|
+
"activationEvents": ["onLanguage:html"],
|
|
10
|
+
"main": "./extension.js",
|
|
11
|
+
"contributes": {
|
|
12
|
+
"grammars": [
|
|
13
|
+
{
|
|
14
|
+
"scopeName": "transclude.injection",
|
|
15
|
+
"path": "./syntaxes/transclude.injection.json",
|
|
16
|
+
"injectTo": ["text.html.basic", "text.html.derivative"],
|
|
17
|
+
"embeddedLanguages": { "meta.embedded.expression.transclude": "javascript" }
|
|
18
|
+
}
|
|
19
|
+
],
|
|
20
|
+
"configuration": {
|
|
21
|
+
"title": "transclude",
|
|
22
|
+
"properties": {
|
|
23
|
+
"transclude.enable": {
|
|
24
|
+
"type": "boolean",
|
|
25
|
+
"default": true,
|
|
26
|
+
"description": "Type check .html files in an transclude project."
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"dependencies": { "vscode-languageclient": "^9.0.1" }
|
|
32
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.json",
|
|
3
|
+
"scopeName": "transclude.injection",
|
|
4
|
+
"injectionSelector": "L:text.html -comment",
|
|
5
|
+
"patterns": [
|
|
6
|
+
{ "include": "#interpolation" },
|
|
7
|
+
{ "include": "#directive" },
|
|
8
|
+
{ "include": "#server-block" }
|
|
9
|
+
],
|
|
10
|
+
"repository": {
|
|
11
|
+
"interpolation": {
|
|
12
|
+
"name": "meta.embedded.expression.transclude",
|
|
13
|
+
"begin": "(?<!\\\\)\\$\\{",
|
|
14
|
+
"end": "\\}",
|
|
15
|
+
"beginCaptures": { "0": { "name": "punctuation.section.embedded.begin.transclude" } },
|
|
16
|
+
"endCaptures": { "0": { "name": "punctuation.section.embedded.end.transclude" } },
|
|
17
|
+
"patterns": [{ "include": "source.js" }]
|
|
18
|
+
},
|
|
19
|
+
"directive": {
|
|
20
|
+
"match": "(?<=\\s)(each|if|else-if|else)(?=[\\s=>/])",
|
|
21
|
+
"name": "keyword.control.directive.transclude"
|
|
22
|
+
},
|
|
23
|
+
"server-block": {
|
|
24
|
+
"begin": "(<)(script)\\s+(server|props)\\s*(>)",
|
|
25
|
+
"end": "(</)(script)\\s*(>)",
|
|
26
|
+
"beginCaptures": {
|
|
27
|
+
"1": { "name": "punctuation.definition.tag.begin.html" },
|
|
28
|
+
"2": { "name": "entity.name.tag.script.html" },
|
|
29
|
+
"3": { "name": "entity.other.attribute-name.html" },
|
|
30
|
+
"4": { "name": "punctuation.definition.tag.end.html" }
|
|
31
|
+
},
|
|
32
|
+
"endCaptures": {
|
|
33
|
+
"1": { "name": "punctuation.definition.tag.begin.html" },
|
|
34
|
+
"2": { "name": "entity.name.tag.script.html" },
|
|
35
|
+
"3": { "name": "punctuation.definition.tag.end.html" }
|
|
36
|
+
},
|
|
37
|
+
"contentName": "source.js",
|
|
38
|
+
"patterns": [{ "include": "source.js" }]
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@transclude/core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "An HTML-first server framework. A page is an .html file, the directory tree is the route table, and any fragment of a page is a URL of its own. Runs on Node, Bun, Deno and workerd, and ships no client JavaScript by default.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"html",
|
|
7
|
+
"hypermedia",
|
|
8
|
+
"fragments",
|
|
9
|
+
"ssr",
|
|
10
|
+
"server-side-rendering",
|
|
11
|
+
"framework",
|
|
12
|
+
"custom-elements",
|
|
13
|
+
"declarative-shadow-dom",
|
|
14
|
+
"htmx",
|
|
15
|
+
"hono",
|
|
16
|
+
"vite"
|
|
17
|
+
],
|
|
18
|
+
"homepage": "https://transclude.dev",
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/transclude-dev/transclude.git"
|
|
22
|
+
},
|
|
23
|
+
"bugs": {
|
|
24
|
+
"url": "https://github.com/transclude-dev/transclude/issues"
|
|
25
|
+
},
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"author": "Joe Dakroub",
|
|
28
|
+
"type": "module",
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=22"
|
|
31
|
+
},
|
|
32
|
+
"bin": {
|
|
33
|
+
"transclude-build": "./bin/build.js",
|
|
34
|
+
"transclude-check": "./bin/check.js",
|
|
35
|
+
"transclude-dev": "./bin/dev.js",
|
|
36
|
+
"transclude-serve": "./bin/serve.js"
|
|
37
|
+
},
|
|
38
|
+
"exports": {
|
|
39
|
+
".": "./src/plugin.js",
|
|
40
|
+
"./app": "./src/app.js",
|
|
41
|
+
"./cookies": "./src/cookies.js",
|
|
42
|
+
"./document": "./src/document.js",
|
|
43
|
+
"./production": "./src/production.js",
|
|
44
|
+
"./routes": "./src/routes.js",
|
|
45
|
+
"./runtime": "./src/runtime/index.js",
|
|
46
|
+
"./serve.bun": "./bin/serve.bun.js",
|
|
47
|
+
"./serve.deno": "./bin/serve.deno.js",
|
|
48
|
+
"./typecheck": "./src/typecheck.js",
|
|
49
|
+
"./worker": "./src/worker.js"
|
|
50
|
+
},
|
|
51
|
+
"files": [
|
|
52
|
+
"LICENSE",
|
|
53
|
+
"bin",
|
|
54
|
+
"editor",
|
|
55
|
+
"src"
|
|
56
|
+
],
|
|
57
|
+
"scripts": {
|
|
58
|
+
"test": "node --test \"test/**/*.test.js\"",
|
|
59
|
+
"test:examples": "npm test --prefix examples/showcase",
|
|
60
|
+
"test:docs": "npm test --prefix docs",
|
|
61
|
+
"showcase": "npm run dev --prefix examples/showcase",
|
|
62
|
+
"docs": "npm run dev --prefix docs",
|
|
63
|
+
"check:src": "tsc -p tsconfig.src.json",
|
|
64
|
+
"release": "node bin/release.js"
|
|
65
|
+
},
|
|
66
|
+
"dependencies": {
|
|
67
|
+
"@hono/node-server": "^2.0.12",
|
|
68
|
+
"acorn": "^8.18.0",
|
|
69
|
+
"hono": "^4.12.32",
|
|
70
|
+
"jsep": "^1.4.0",
|
|
71
|
+
"parse5": "^8.0.1"
|
|
72
|
+
},
|
|
73
|
+
"peerDependencies": {
|
|
74
|
+
"typescript": "^5.9",
|
|
75
|
+
"vite": "^8"
|
|
76
|
+
},
|
|
77
|
+
"devDependencies": {
|
|
78
|
+
"@types/node": "^22.20.1",
|
|
79
|
+
"typescript": "^5.9.3",
|
|
80
|
+
"vite": "^8.1.5"
|
|
81
|
+
}
|
|
82
|
+
}
|
package/src/address.js
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// Which addresses a server may be asked to fetch from.
|
|
2
|
+
//
|
|
3
|
+
// A server that fetches a URL somebody sent it can be pointed at things only it
|
|
4
|
+
// can reach: another service on the same host, a database on the private
|
|
5
|
+
// network, or the cloud metadata endpoint that hands out credentials. Refusing
|
|
6
|
+
// those is the whole job here.
|
|
7
|
+
//
|
|
8
|
+
// No `node:` imports and no DNS. This decides what an address means; resolving
|
|
9
|
+
// a name to one is the runtime's job, and one of the four runtimes cannot do it
|
|
10
|
+
// at all.
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Ranges that are not the public internet, as [test, why].
|
|
14
|
+
*
|
|
15
|
+
* @type {Array<[(a: number[]) => boolean, string]>}
|
|
16
|
+
*/
|
|
17
|
+
const V4_BLOCKED = [
|
|
18
|
+
[(a) => a[0] === 0, 'this network'],
|
|
19
|
+
[(a) => a[0] === 10, 'private'],
|
|
20
|
+
[(a) => a[0] === 127, 'loopback'],
|
|
21
|
+
[(a) => a[0] === 100 && a[1] >= 64 && a[1] <= 127, 'carrier-grade NAT'],
|
|
22
|
+
[(a) => a[0] === 169 && a[1] === 254, 'link-local, and the metadata endpoint'],
|
|
23
|
+
[(a) => a[0] === 172 && a[1] >= 16 && a[1] <= 31, 'private'],
|
|
24
|
+
[(a) => a[0] === 192 && a[1] === 0 && a[2] === 0, 'protocol assignments'],
|
|
25
|
+
[(a) => a[0] === 192 && a[1] === 168, 'private'],
|
|
26
|
+
[(a) => a[0] === 198 && (a[1] === 18 || a[1] === 19), 'benchmarking'],
|
|
27
|
+
[(a) => a[0] >= 224, 'multicast or reserved'],
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* An IPv4 address as four numbers, or null if the text is not one.
|
|
32
|
+
*
|
|
33
|
+
* @param {string} text
|
|
34
|
+
* @returns {number[]|null} four octets, or null when it is not one
|
|
35
|
+
*/
|
|
36
|
+
export function parseV4(text) {
|
|
37
|
+
const parts = text.split('.');
|
|
38
|
+
if (parts.length !== 4) return null;
|
|
39
|
+
|
|
40
|
+
const octets = parts.map((part) => {
|
|
41
|
+
if (!/^\d{1,3}$/.test(part)) return NaN;
|
|
42
|
+
return Number(part);
|
|
43
|
+
});
|
|
44
|
+
if (octets.some((n) => Number.isNaN(n) || n > 255)) return null;
|
|
45
|
+
return octets;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* An IPv6 address as eight groups, or null.
|
|
50
|
+
*
|
|
51
|
+
* Only enough of the format to classify one. An address with a trailing IPv4
|
|
52
|
+
* part is handled, because `::ffff:169.254.169.254` is the obvious way around a
|
|
53
|
+
* checker that only reads the hex form.
|
|
54
|
+
*
|
|
55
|
+
* @param {string} text
|
|
56
|
+
* @returns {number[]|null} sixteen bytes, or null
|
|
57
|
+
*/
|
|
58
|
+
export function parseV6(text) {
|
|
59
|
+
let body = text;
|
|
60
|
+
let tail = null;
|
|
61
|
+
|
|
62
|
+
const dotted = body.lastIndexOf(':');
|
|
63
|
+
if (body.slice(dotted + 1).includes('.')) {
|
|
64
|
+
tail = parseV4(body.slice(dotted + 1));
|
|
65
|
+
if (!tail) return null;
|
|
66
|
+
body = body.slice(0, dotted + 1) + '0:0';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const halves = body.split('::');
|
|
70
|
+
if (halves.length > 2) return null;
|
|
71
|
+
|
|
72
|
+
const read = (part) =>
|
|
73
|
+
part === '' ? [] : part.split(':').map((g) => (/^[0-9a-f]{1,4}$/i.test(g) ? parseInt(g, 16) : NaN));
|
|
74
|
+
|
|
75
|
+
const head = read(halves[0]);
|
|
76
|
+
const rest = halves.length === 2 ? read(halves[1]) : [];
|
|
77
|
+
if ([...head, ...rest].some(Number.isNaN)) return null;
|
|
78
|
+
|
|
79
|
+
let groups;
|
|
80
|
+
if (halves.length === 2) {
|
|
81
|
+
const gap = 8 - head.length - rest.length;
|
|
82
|
+
if (gap < 0) return null;
|
|
83
|
+
groups = [...head, ...Array(gap).fill(0), ...rest];
|
|
84
|
+
} else {
|
|
85
|
+
groups = head;
|
|
86
|
+
}
|
|
87
|
+
if (groups.length !== 8) return null;
|
|
88
|
+
|
|
89
|
+
if (tail) {
|
|
90
|
+
groups[6] = (tail[0] << 8) | tail[1];
|
|
91
|
+
groups[7] = (tail[2] << 8) | tail[3];
|
|
92
|
+
}
|
|
93
|
+
return groups;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Why this address may not be fetched from, or null if it may.
|
|
98
|
+
*
|
|
99
|
+
* Takes the text of a host, with no brackets. A name that is not an address
|
|
100
|
+
* returns null: whether a *name* is allowed is the allowlist's question, and
|
|
101
|
+
* where it resolves to is the runtime's.
|
|
102
|
+
*
|
|
103
|
+
* @param {string} host a literal address, not a name
|
|
104
|
+
* @returns {string|null} why it is refused, or null when it is allowed
|
|
105
|
+
*/
|
|
106
|
+
export function blockedAddress(host) {
|
|
107
|
+
const v4 = parseV4(host);
|
|
108
|
+
if (v4) {
|
|
109
|
+
for (const [test, why] of V4_BLOCKED) if (test(v4)) return why;
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const v6 = parseV6(host);
|
|
114
|
+
if (!v6) return null;
|
|
115
|
+
|
|
116
|
+
const [a, b] = v6;
|
|
117
|
+
if (v6.every((g) => g === 0)) return 'unspecified';
|
|
118
|
+
if (v6.slice(0, 7).every((g) => g === 0) && v6[7] === 1) return 'loopback';
|
|
119
|
+
// An IPv4 address wearing an IPv6 hat. `::ffff:10.0.0.1` reaches the same
|
|
120
|
+
// host `10.0.0.1` does.
|
|
121
|
+
if (v6.slice(0, 5).every((g) => g === 0) && v6[5] === 0xffff) {
|
|
122
|
+
const mapped = [v6[6] >> 8, v6[6] & 255, v6[7] >> 8, v6[7] & 255];
|
|
123
|
+
for (const [test, why] of V4_BLOCKED) if (test(mapped)) return `${why}, through an IPv4-mapped address`;
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
if ((a & 0xfe00) === 0xfc00) return 'unique local';
|
|
127
|
+
if ((a & 0xffc0) === 0xfe80) return 'link-local';
|
|
128
|
+
if (a === 0x2001 && b === 0x0db8) return 'documentation';
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Whether a hostname is one the config named.
|
|
134
|
+
*
|
|
135
|
+
* Default deny. An entry is an exact hostname, or `*.example.com`, which covers
|
|
136
|
+
* any subdomain but not the bare domain: naming a wildcard should not quietly
|
|
137
|
+
* hand over the apex too.
|
|
138
|
+
*
|
|
139
|
+
* @param {string} host
|
|
140
|
+
* @param {string[]} [allow] names, and `*.` wildcards
|
|
141
|
+
* @returns {boolean} false unless something on the list names it
|
|
142
|
+
*/
|
|
143
|
+
export function allowedHost(host, allow = []) {
|
|
144
|
+
const name = String(host).toLowerCase().replace(/\.$/, '');
|
|
145
|
+
|
|
146
|
+
return allow.some((entry) => {
|
|
147
|
+
const rule = String(entry).toLowerCase().replace(/\.$/, '');
|
|
148
|
+
if (rule.startsWith('*.')) return name.endsWith(rule.slice(1)) && name !== rule.slice(2);
|
|
149
|
+
return name === rule;
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The one place a URL is judged before anything connects.
|
|
155
|
+
*
|
|
156
|
+
* Order matters. The allowlist is checked before the address, so a host nobody
|
|
157
|
+
* permitted is refused without this having formed an opinion about where it
|
|
158
|
+
* points.
|
|
159
|
+
*
|
|
160
|
+
* @param {string} url
|
|
161
|
+
* @param {{ allow?: string[] }} [options]
|
|
162
|
+
* @returns {string|null} why it is refused, or null when it may be fetched
|
|
163
|
+
*/
|
|
164
|
+
export function checkUrl(url, { allow = [] } = {}) {
|
|
165
|
+
let parsed;
|
|
166
|
+
try {
|
|
167
|
+
parsed = new URL(url);
|
|
168
|
+
} catch {
|
|
169
|
+
return 'not a URL';
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
|
173
|
+
return `${parsed.protocol} is not a scheme this fetches`;
|
|
174
|
+
}
|
|
175
|
+
if (parsed.username || parsed.password) return 'credentials in a URL are not passed on';
|
|
176
|
+
if (!allowedHost(parsed.hostname, allow)) return `${parsed.hostname} is not an allowed host`;
|
|
177
|
+
|
|
178
|
+
// Brackets are the URL syntax for a v6 host and are not part of the address.
|
|
179
|
+
const blocked = blockedAddress(parsed.hostname.replace(/^\[|\]$/g, ''));
|
|
180
|
+
if (blocked) return `${parsed.hostname} is ${blocked}`;
|
|
181
|
+
|
|
182
|
+
return null;
|
|
183
|
+
}
|