@hraness/sponge 0.6.7 → 0.6.9
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/README.md +42 -0
- package/dist/capture/worker.js +154 -152
- package/dist/contracts/index.d.ts +2 -0
- package/dist/src/acquisition/capture.d.ts +4 -0
- package/dist/src/acquisition/capture.js +8 -2
- package/dist/src/acquisition/child-request.d.ts +5 -0
- package/dist/src/acquisition/normalize.js +3 -0
- package/dist/src/app/archive.d.ts +15 -0
- package/dist/src/app/archive.js +51 -0
- package/dist/src/app/article.d.ts +4 -0
- package/dist/src/app/article.js +24 -10
- package/dist/src/app/markdown.d.ts +2 -0
- package/dist/src/app/markdown.js +6 -0
- package/dist/src/browser/bridge.d.ts +48 -0
- package/dist/src/browser/bridge.js +90 -0
- package/dist/src/browser/host.d.ts +10 -0
- package/dist/src/browser/host.js +147 -0
- package/dist/src/browser/setup.d.ts +26 -0
- package/dist/src/browser/setup.js +103 -0
- package/dist/src/capture/network.d.ts +56 -0
- package/dist/src/capture/network.js +453 -0
- package/dist/src/capture/types.d.ts +26 -0
- package/dist/src/capture/types.js +1 -0
- package/dist/src/cli/commands.js +114 -12
- package/dist/src/cli/status.d.ts +7 -0
- package/dist/src/cli/status.js +33 -0
- package/dist/src/errors.d.ts +1 -0
- package/dist/src/errors.js +2 -0
- package/dist/src/pdf/handler.d.ts +2 -0
- package/dist/src/pdf/handler.js +7 -2
- package/dist/src/store/queue.d.ts +5 -1
- package/dist/src/store/queue.js +10 -4
- package/dist/src/store/types.d.ts +5 -1
- package/dist/src/worker/basic.js +15 -0
- package/extension/background.js +180 -0
- package/extension/manifest.json +25 -0
- package/extension/popup.html +85 -0
- package/extension/popup.js +39 -0
- package/native/runtime-manifest.json +5 -5
- package/package.json +2 -1
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { askBridge, bridgeSocket, extensionId, hostName } from './bridge.js';
|
|
7
|
+
/** Where each Chromium browser on macOS looks for user-level native messaging hosts. */
|
|
8
|
+
const browsers = {
|
|
9
|
+
Chrome: 'Google/Chrome',
|
|
10
|
+
'Chrome Beta': 'Google/Chrome Beta',
|
|
11
|
+
'Chrome Canary': 'Google/Chrome Canary',
|
|
12
|
+
Chromium: 'Chromium',
|
|
13
|
+
Brave: 'BraveSoftware/Brave-Browser',
|
|
14
|
+
Edge: 'Microsoft Edge',
|
|
15
|
+
Arc: 'Arc/User Data',
|
|
16
|
+
Vivaldi: 'Vivaldi',
|
|
17
|
+
};
|
|
18
|
+
const manifestName = `${hostName}.json`;
|
|
19
|
+
/** The installed browsers' host directories, plus any the reader named. */
|
|
20
|
+
function hostDirectories(options) {
|
|
21
|
+
const support = join(options.home ?? homedir(), 'Library', 'Application Support');
|
|
22
|
+
return [
|
|
23
|
+
...Object.entries(browsers)
|
|
24
|
+
.filter(([, data]) => existsSync(join(support, data)))
|
|
25
|
+
.map(([browser, data]) => ({ browser, dir: join(support, data, 'NativeMessagingHosts') })),
|
|
26
|
+
...(options.hostsDirs ?? []).map((dir) => ({ browser: 'custom', dir })),
|
|
27
|
+
];
|
|
28
|
+
}
|
|
29
|
+
const wrapperPath = (home) => join(home, '.local', 'state', 'sponge', 'browser-host');
|
|
30
|
+
/** The unpacked extension that ships with this Sponge. */
|
|
31
|
+
function extensionDirectory() {
|
|
32
|
+
return fileURLToPath(new URL(import.meta.url.endsWith('.ts') ? '../../extension/' : '../../../extension/', import.meta.url));
|
|
33
|
+
}
|
|
34
|
+
const quote = (value) => `'${value.replace(/'/g, `'\\''`)}'`;
|
|
35
|
+
/**
|
|
36
|
+
* Registers the native host with each installed browser for one library. The host is a wrapper script, since a
|
|
37
|
+
* manifest names one executable and takes no arguments; it runs this Node and this Sponge's host.
|
|
38
|
+
*/
|
|
39
|
+
export async function setupBrowser(libraryRoot, options = {}) {
|
|
40
|
+
const home = options.home ?? homedir(), wrapper = wrapperPath(home), host = fileURLToPath(new URL('./host.js', import.meta.url));
|
|
41
|
+
await mkdir(dirname(wrapper), { recursive: true, mode: 0o700 });
|
|
42
|
+
await writeFile(wrapper, `#!/bin/sh\nexec ${quote(process.execPath)} ${quote(host)} --library ${quote(libraryRoot)} --socket ${quote(bridgeSocket(libraryRoot, home))} "$@"\n`, { mode: 0o700 });
|
|
43
|
+
await chmod(wrapper, 0o700);
|
|
44
|
+
const manifest = JSON.stringify({
|
|
45
|
+
name: hostName,
|
|
46
|
+
description: 'Sponge: save pages with your own sign-ins',
|
|
47
|
+
path: wrapper,
|
|
48
|
+
type: 'stdio',
|
|
49
|
+
allowed_origins: [`chrome-extension://${extensionId}/`],
|
|
50
|
+
}, null, 2);
|
|
51
|
+
const registered = [];
|
|
52
|
+
for (const { browser, dir } of hostDirectories(options)) {
|
|
53
|
+
await mkdir(dir, { recursive: true });
|
|
54
|
+
await writeFile(join(dir, manifestName), manifest + '\n', { mode: 0o644 });
|
|
55
|
+
registered.push(browser);
|
|
56
|
+
}
|
|
57
|
+
return { registered, extension: extensionDirectory(), extensionId };
|
|
58
|
+
}
|
|
59
|
+
/** Which browsers have the host registered, and whether an extension is connected for this library now. */
|
|
60
|
+
export async function browserStatus(libraryRoot, options = {}) {
|
|
61
|
+
const home = options.home ?? homedir(), wrapper = wrapperPath(home), registered = [];
|
|
62
|
+
for (const { browser, dir } of hostDirectories(options)) {
|
|
63
|
+
try {
|
|
64
|
+
if (JSON.parse(await readFile(join(dir, manifestName), 'utf8')).path === wrapper)
|
|
65
|
+
registered.push(browser);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
// Not registered with this browser.
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
let bound = false;
|
|
72
|
+
try {
|
|
73
|
+
bound = (await readFile(wrapper, 'utf8')).includes(quote(bridgeSocket(libraryRoot, home)));
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
bound = false;
|
|
77
|
+
}
|
|
78
|
+
const answer = await askBridge(bridgeSocket(libraryRoot, home), { op: 'status' }, { timeoutMs: 3000, maxBytes: 4096 });
|
|
79
|
+
return {
|
|
80
|
+
registered,
|
|
81
|
+
bound,
|
|
82
|
+
connected: answer.ok,
|
|
83
|
+
...(answer.ok && 'background' in answer ? { extension: answer.extension, background: answer.background } : {}),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
/** Removes the registrations that point at Sponge's host; another program's manifest is left alone. */
|
|
87
|
+
export async function removeBrowser(options = {}) {
|
|
88
|
+
const home = options.home ?? homedir(), wrapper = wrapperPath(home), removed = [];
|
|
89
|
+
for (const { browser, dir } of hostDirectories(options)) {
|
|
90
|
+
const file = join(dir, manifestName);
|
|
91
|
+
try {
|
|
92
|
+
if (JSON.parse(await readFile(file, 'utf8')).path !== wrapper)
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
await rm(file, { force: true });
|
|
99
|
+
removed.push(browser);
|
|
100
|
+
}
|
|
101
|
+
await rm(wrapper, { force: true });
|
|
102
|
+
return { removed };
|
|
103
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
export type FetchFailureCode = 'invalid-url' | 'private-network' | 'dns' | 'timeout' | 'http' | 'redirect' | 'too-large' | 'network';
|
|
2
|
+
export declare class FetchFailure extends Error {
|
|
3
|
+
readonly code: FetchFailureCode;
|
|
4
|
+
readonly status: number | null;
|
|
5
|
+
constructor(code: FetchFailureCode, message: string, options?: ErrorOptions & {
|
|
6
|
+
status?: number;
|
|
7
|
+
});
|
|
8
|
+
}
|
|
9
|
+
export interface SafeFetchOptions {
|
|
10
|
+
timeoutMs: number;
|
|
11
|
+
maxBytes: number;
|
|
12
|
+
allowPrivateNetwork: boolean;
|
|
13
|
+
userAgent: string;
|
|
14
|
+
referer?: string;
|
|
15
|
+
accept?: string;
|
|
16
|
+
retries?: number;
|
|
17
|
+
maxRedirects?: number;
|
|
18
|
+
}
|
|
19
|
+
export interface SafeFetchResult {
|
|
20
|
+
bytes: Uint8Array;
|
|
21
|
+
finalUrl: URL;
|
|
22
|
+
status: number;
|
|
23
|
+
contentType: string | null;
|
|
24
|
+
}
|
|
25
|
+
interface ResolvedAddress {
|
|
26
|
+
address: string;
|
|
27
|
+
family: 4 | 6;
|
|
28
|
+
}
|
|
29
|
+
export interface PinnedRequest {
|
|
30
|
+
url: URL;
|
|
31
|
+
address: ResolvedAddress;
|
|
32
|
+
headers: Headers;
|
|
33
|
+
signal: AbortSignal;
|
|
34
|
+
}
|
|
35
|
+
export interface PinnedResponse {
|
|
36
|
+
status: number;
|
|
37
|
+
headers: Headers;
|
|
38
|
+
body: AsyncIterable<unknown> | null;
|
|
39
|
+
cancel: () => void;
|
|
40
|
+
}
|
|
41
|
+
export interface SafeFetchDependencies {
|
|
42
|
+
resolveHostname: (hostname: string) => Promise<readonly ResolvedAddress[]>;
|
|
43
|
+
transport: (request: PinnedRequest) => Promise<PinnedResponse>;
|
|
44
|
+
/** Test seam. Production callers use the system interface snapshot. */
|
|
45
|
+
localAddresses: () => readonly string[];
|
|
46
|
+
}
|
|
47
|
+
/** True for loopback, link-local, private, reserved, multicast and unspecified addresses. */
|
|
48
|
+
export declare function isPrivateAddress(address: string): boolean;
|
|
49
|
+
export declare function isPrivateHostname(hostname: string): boolean;
|
|
50
|
+
/** Refuse a private, reserved or host-assigned target unless explicitly allowed. */
|
|
51
|
+
export declare function assertPublicUrl(url: URL, allowPrivateNetwork: boolean, timeoutMs?: number): Promise<void>;
|
|
52
|
+
/** A bounded fetcher: every hop is resolved, validated and pinned; redirects are re-checked; bodies are capped. */
|
|
53
|
+
export declare function createSafeFetch(dependencies?: Partial<SafeFetchDependencies>): (url: URL, options: SafeFetchOptions) => Promise<SafeFetchResult>;
|
|
54
|
+
export declare const safeFetch: (url: URL, options: SafeFetchOptions) => Promise<SafeFetchResult>;
|
|
55
|
+
export declare function decodeBytes(bytes: Uint8Array, contentType: string | null): string;
|
|
56
|
+
export {};
|
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
// Ported from Wordcell's clip/network.ts (MIT, see native/NOTICES-capture.txt). Cookie forwarding, connection
|
|
2
|
+
// pooling and manual redirects are dropped: Sponge captures only public pages over a pinned, bounded fetch.
|
|
3
|
+
import { lookup } from 'node:dns/promises';
|
|
4
|
+
import { request as requestHttp } from 'node:http';
|
|
5
|
+
import { request as requestHttps } from 'node:https';
|
|
6
|
+
import { isIP } from 'node:net';
|
|
7
|
+
import { networkInterfaces } from 'node:os';
|
|
8
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
9
|
+
export class FetchFailure extends Error {
|
|
10
|
+
code;
|
|
11
|
+
status;
|
|
12
|
+
constructor(code, message, options) {
|
|
13
|
+
super(message, options);
|
|
14
|
+
this.name = 'FetchFailure';
|
|
15
|
+
this.code = code;
|
|
16
|
+
this.status = options?.status ?? null;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
const privateSuffixes = ['.localhost', '.local', '.internal', '.home', '.lan'];
|
|
20
|
+
const retryableStatuses = new Set([408, 425, 429, 500, 502, 503, 504]);
|
|
21
|
+
const normalizeHostname = (hostname) => (hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname)
|
|
22
|
+
.toLowerCase()
|
|
23
|
+
.replace(/\.$/, '');
|
|
24
|
+
function parseIpv4(address) {
|
|
25
|
+
const pieces = address.split('.');
|
|
26
|
+
if (pieces.length !== 4)
|
|
27
|
+
return null;
|
|
28
|
+
const numbers = pieces.map(Number);
|
|
29
|
+
return numbers.every((piece) => Number.isInteger(piece) && piece >= 0 && piece <= 255) ? numbers : null;
|
|
30
|
+
}
|
|
31
|
+
function parseIpv6(address) {
|
|
32
|
+
let value = address;
|
|
33
|
+
const separator = value.lastIndexOf(':');
|
|
34
|
+
const tail = separator >= 0 ? value.slice(separator + 1) : value;
|
|
35
|
+
if (tail.includes('.')) {
|
|
36
|
+
const ipv4 = parseIpv4(tail);
|
|
37
|
+
if (ipv4 === null || separator < 0)
|
|
38
|
+
return null;
|
|
39
|
+
value = `${value.slice(0, separator)}:${((ipv4[0] << 8) | ipv4[1]).toString(16)}:${((ipv4[2] << 8) | ipv4[3]).toString(16)}`;
|
|
40
|
+
}
|
|
41
|
+
const compression = value.indexOf('::');
|
|
42
|
+
if (compression !== -1 && compression !== value.lastIndexOf('::'))
|
|
43
|
+
return null;
|
|
44
|
+
const leftText = compression === -1 ? value : value.slice(0, compression);
|
|
45
|
+
const rightText = compression === -1 ? '' : value.slice(compression + 2);
|
|
46
|
+
const left = leftText === '' ? [] : leftText.split(':');
|
|
47
|
+
const right = rightText === '' ? [] : rightText.split(':');
|
|
48
|
+
if ([...left, ...right].some((part) => !/^[0-9a-f]{1,4}$/i.test(part)))
|
|
49
|
+
return null;
|
|
50
|
+
const missing = 8 - left.length - right.length;
|
|
51
|
+
if ((compression === -1 && missing !== 0) || (compression !== -1 && missing < 1))
|
|
52
|
+
return null;
|
|
53
|
+
return [
|
|
54
|
+
...left.map((part) => Number.parseInt(part, 16)),
|
|
55
|
+
...Array.from({ length: missing }, () => 0),
|
|
56
|
+
...right.map((part) => Number.parseInt(part, 16)),
|
|
57
|
+
];
|
|
58
|
+
}
|
|
59
|
+
function withoutScope(address) {
|
|
60
|
+
const normalized = normalizeHostname(address);
|
|
61
|
+
const scope = normalized.indexOf('%');
|
|
62
|
+
return scope === -1 ? normalized : normalized.slice(0, scope);
|
|
63
|
+
}
|
|
64
|
+
/** The IPv4 address an IPv4-mapped or IPv4-compatible IPv6 address reaches, if any. */
|
|
65
|
+
function embeddedIpv4(groups) {
|
|
66
|
+
const compatible = groups.slice(0, 6).every((group) => group === 0);
|
|
67
|
+
const mapped = groups.slice(0, 5).every((group) => group === 0) && groups[5] === 0xffff;
|
|
68
|
+
if (!compatible && !mapped)
|
|
69
|
+
return null;
|
|
70
|
+
const high = groups[6], low = groups[7];
|
|
71
|
+
return `${high >>> 8}.${high & 0xff}.${low >>> 8}.${low & 0xff}`;
|
|
72
|
+
}
|
|
73
|
+
/** Syntax-independent keys for one IP literal; an embedded IPv4 address also gets its IPv4 key. */
|
|
74
|
+
function addressKeys(address) {
|
|
75
|
+
const normalized = withoutScope(address);
|
|
76
|
+
const version = isIP(normalized);
|
|
77
|
+
if (version === 4) {
|
|
78
|
+
const pieces = parseIpv4(normalized);
|
|
79
|
+
return pieces === null ? [] : [`4:${pieces.join('.')}`];
|
|
80
|
+
}
|
|
81
|
+
if (version !== 6)
|
|
82
|
+
return [];
|
|
83
|
+
const groups = parseIpv6(normalized);
|
|
84
|
+
if (groups === null)
|
|
85
|
+
return [];
|
|
86
|
+
const keys = [`6:${groups.map((group) => group.toString(16).padStart(4, '0')).join(':')}`];
|
|
87
|
+
const ipv4 = embeddedIpv4(groups);
|
|
88
|
+
if (ipv4 !== null)
|
|
89
|
+
keys.push(`4:${ipv4}`);
|
|
90
|
+
return keys;
|
|
91
|
+
}
|
|
92
|
+
function systemLocalAddresses() {
|
|
93
|
+
try {
|
|
94
|
+
return Object.values(networkInterfaces()).flatMap((records) => (records ?? []).map((record) => record.address));
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
throw new FetchFailure('network', 'could not inspect local network interfaces', { cause: error });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
function localKeys(provider) {
|
|
101
|
+
let addresses;
|
|
102
|
+
try {
|
|
103
|
+
addresses = provider();
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
if (error instanceof FetchFailure)
|
|
107
|
+
throw error;
|
|
108
|
+
throw new FetchFailure('network', 'could not inspect local network interfaces', { cause: error });
|
|
109
|
+
}
|
|
110
|
+
return new Set(addresses.flatMap(addressKeys));
|
|
111
|
+
}
|
|
112
|
+
const isLocalAddress = (address, keys) => addressKeys(address).some((key) => keys.has(key));
|
|
113
|
+
/** True for loopback, link-local, private, reserved, multicast and unspecified addresses. */
|
|
114
|
+
export function isPrivateAddress(address) {
|
|
115
|
+
const normalized = withoutScope(address);
|
|
116
|
+
const version = isIP(normalized);
|
|
117
|
+
if (version === 4) {
|
|
118
|
+
const pieces = parseIpv4(normalized);
|
|
119
|
+
if (pieces === null)
|
|
120
|
+
return true;
|
|
121
|
+
const [a, b, c] = pieces;
|
|
122
|
+
return (a === 0 ||
|
|
123
|
+
a === 10 ||
|
|
124
|
+
a === 127 ||
|
|
125
|
+
(a === 100 && b >= 64 && b <= 127) ||
|
|
126
|
+
(a === 169 && b === 254) ||
|
|
127
|
+
(a === 172 && b >= 16 && b <= 31) ||
|
|
128
|
+
(a === 192 && (b === 0 || b === 168)) ||
|
|
129
|
+
(a === 198 && (b === 18 || b === 19)) ||
|
|
130
|
+
(a === 198 && b === 51 && c === 100) ||
|
|
131
|
+
(a === 203 && b === 0 && c === 113) ||
|
|
132
|
+
a >= 224);
|
|
133
|
+
}
|
|
134
|
+
if (version === 6) {
|
|
135
|
+
const groups = parseIpv6(normalized);
|
|
136
|
+
if (groups === null)
|
|
137
|
+
return true;
|
|
138
|
+
const ipv4 = embeddedIpv4(groups);
|
|
139
|
+
if (ipv4 !== null)
|
|
140
|
+
return isPrivateAddress(ipv4);
|
|
141
|
+
const [first, second] = groups;
|
|
142
|
+
return ((first & 0xfe00) === 0xfc00 ||
|
|
143
|
+
(first & 0xffc0) === 0xfe80 ||
|
|
144
|
+
(first & 0xffc0) === 0xfec0 ||
|
|
145
|
+
(first & 0xff00) === 0xff00 ||
|
|
146
|
+
first === 0x0064 ||
|
|
147
|
+
first === 0x0100 ||
|
|
148
|
+
(first === 0x2001 && second <= 0x01ff) ||
|
|
149
|
+
(first === 0x2001 && second === 0x0db8) ||
|
|
150
|
+
first === 0x2002 ||
|
|
151
|
+
first === 0x3ffe ||
|
|
152
|
+
(first === 0x3fff && (second & 0xf000) === 0) ||
|
|
153
|
+
first === 0x5f00);
|
|
154
|
+
}
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
export function isPrivateHostname(hostname) {
|
|
158
|
+
const normalized = normalizeHostname(hostname);
|
|
159
|
+
if (normalized === 'localhost' || normalized === 'localhost.localdomain')
|
|
160
|
+
return true;
|
|
161
|
+
if (privateSuffixes.some((suffix) => normalized.endsWith(suffix)))
|
|
162
|
+
return true;
|
|
163
|
+
return isIP(normalized) !== 0 && isPrivateAddress(normalized);
|
|
164
|
+
}
|
|
165
|
+
async function systemResolve(hostname) {
|
|
166
|
+
const answers = await lookup(hostname, { all: true, verbatim: true });
|
|
167
|
+
return answers.flatMap((answer) => answer.family === 4 || answer.family === 6 ? [{ address: answer.address, family: answer.family }] : []);
|
|
168
|
+
}
|
|
169
|
+
async function beforeDeadline(promise, deadline, message) {
|
|
170
|
+
const remaining = deadline - Date.now();
|
|
171
|
+
if (remaining <= 0)
|
|
172
|
+
throw new FetchFailure('timeout', message);
|
|
173
|
+
let timer;
|
|
174
|
+
try {
|
|
175
|
+
return await Promise.race([
|
|
176
|
+
promise,
|
|
177
|
+
new Promise((_, reject) => {
|
|
178
|
+
timer = setTimeout(() => reject(new FetchFailure('timeout', message)), remaining);
|
|
179
|
+
}),
|
|
180
|
+
]);
|
|
181
|
+
}
|
|
182
|
+
finally {
|
|
183
|
+
clearTimeout(timer);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
async function resolveTarget(url, allowPrivateNetwork, dependencies, deadline, timeoutMs) {
|
|
187
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:')
|
|
188
|
+
throw new FetchFailure('invalid-url', `unsupported URL protocol: ${url.protocol}`);
|
|
189
|
+
if (url.username !== '' || url.password !== '')
|
|
190
|
+
throw new FetchFailure('invalid-url', 'credential-bearing URLs are not accepted');
|
|
191
|
+
const hostname = normalizeHostname(url.hostname);
|
|
192
|
+
if (!allowPrivateNetwork && isPrivateHostname(hostname))
|
|
193
|
+
throw new FetchFailure('private-network', `private-network URL: ${url.origin}`);
|
|
194
|
+
const local = allowPrivateNetwork ? new Set() : localKeys(dependencies.localAddresses);
|
|
195
|
+
const literal = isIP(hostname);
|
|
196
|
+
if (literal === 4 || literal === 6) {
|
|
197
|
+
if (isLocalAddress(hostname, local))
|
|
198
|
+
throw new FetchFailure('private-network', `address ${hostname} is assigned to a local interface`);
|
|
199
|
+
return [{ address: hostname, family: literal }];
|
|
200
|
+
}
|
|
201
|
+
let answers;
|
|
202
|
+
try {
|
|
203
|
+
answers = await beforeDeadline(dependencies.resolveHostname(hostname), deadline, `request timed out after ${timeoutMs}ms while resolving ${hostname}`);
|
|
204
|
+
}
|
|
205
|
+
catch (error) {
|
|
206
|
+
if (error instanceof FetchFailure)
|
|
207
|
+
throw error;
|
|
208
|
+
throw new FetchFailure('dns', `could not resolve ${hostname}`, { cause: error });
|
|
209
|
+
}
|
|
210
|
+
if (answers.length === 0)
|
|
211
|
+
throw new FetchFailure('dns', `could not resolve ${hostname}`);
|
|
212
|
+
// DNS can take most of the deadline; an interface that appeared meanwhile must not become a local hop.
|
|
213
|
+
if (!allowPrivateNetwork)
|
|
214
|
+
for (const key of localKeys(dependencies.localAddresses))
|
|
215
|
+
local.add(key);
|
|
216
|
+
const unique = new Map();
|
|
217
|
+
for (const answer of answers) {
|
|
218
|
+
const address = normalizeHostname(answer.address);
|
|
219
|
+
const family = isIP(address);
|
|
220
|
+
if ((family !== 4 && family !== 6) || family !== answer.family)
|
|
221
|
+
throw new FetchFailure('dns', `${hostname} returned an invalid DNS answer`);
|
|
222
|
+
if (!allowPrivateNetwork && (isPrivateAddress(address) || isLocalAddress(address, local)))
|
|
223
|
+
throw new FetchFailure('private-network', `${hostname} resolves to a private or local address`);
|
|
224
|
+
unique.set(`${family}:${address}`, { address, family });
|
|
225
|
+
}
|
|
226
|
+
return [...unique.values()];
|
|
227
|
+
}
|
|
228
|
+
/** Refuse a private, reserved or host-assigned target unless explicitly allowed. */
|
|
229
|
+
export async function assertPublicUrl(url, allowPrivateNetwork, timeoutMs = 30_000) {
|
|
230
|
+
await resolveTarget(url, allowPrivateNetwork, { resolveHostname: systemResolve, localAddresses: systemLocalAddresses }, Date.now() + timeoutMs, timeoutMs);
|
|
231
|
+
}
|
|
232
|
+
/** A lookup callback that can only answer with the address already validated. */
|
|
233
|
+
const pinnedLookup = ({ address, family }) => (_hostname, options, callback) => queueMicrotask(() => options.all === true ? callback(null, [{ address, family }]) : callback(null, address, family));
|
|
234
|
+
function responseHeaders(headers) {
|
|
235
|
+
const result = new Headers();
|
|
236
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
237
|
+
if (typeof value === 'string')
|
|
238
|
+
result.append(name, value);
|
|
239
|
+
else if (Array.isArray(value))
|
|
240
|
+
for (const item of value)
|
|
241
|
+
result.append(name, item);
|
|
242
|
+
}
|
|
243
|
+
return result;
|
|
244
|
+
}
|
|
245
|
+
/** One HTTP(S) request through one validated address. No DNS, redirects, retries or body accumulation. */
|
|
246
|
+
function requestPinned(request) {
|
|
247
|
+
const hostname = normalizeHostname(request.url.hostname);
|
|
248
|
+
if (isIP(request.address.address) !== request.address.family)
|
|
249
|
+
return Promise.reject(new Error('pinned address does not match its family'));
|
|
250
|
+
const literal = isIP(hostname);
|
|
251
|
+
if (literal !== 0 &&
|
|
252
|
+
(literal !== request.address.family || addressKeys(hostname)[0] !== addressKeys(request.address.address)[0]))
|
|
253
|
+
return Promise.reject(new Error('IP-literal hostname does not match the pinned address'));
|
|
254
|
+
const headers = {};
|
|
255
|
+
request.headers.forEach((value, name) => (headers[name] = value));
|
|
256
|
+
const options = {
|
|
257
|
+
protocol: request.url.protocol,
|
|
258
|
+
hostname,
|
|
259
|
+
method: 'GET',
|
|
260
|
+
path: `${request.url.pathname}${request.url.search}`,
|
|
261
|
+
headers,
|
|
262
|
+
lookup: pinnedLookup(request.address),
|
|
263
|
+
family: request.address.family,
|
|
264
|
+
signal: request.signal,
|
|
265
|
+
agent: false,
|
|
266
|
+
...(request.url.port === '' ? {} : { port: request.url.port }),
|
|
267
|
+
};
|
|
268
|
+
return new Promise((resolve, reject) => {
|
|
269
|
+
const onResponse = (response) => {
|
|
270
|
+
if (response.statusCode === undefined) {
|
|
271
|
+
response.destroy();
|
|
272
|
+
reject(new Error('HTTP response omitted a status code'));
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
resolve({
|
|
276
|
+
status: response.statusCode,
|
|
277
|
+
headers: responseHeaders(response.headers),
|
|
278
|
+
body: response,
|
|
279
|
+
cancel: () => response.destroy(),
|
|
280
|
+
});
|
|
281
|
+
};
|
|
282
|
+
const client = request.url.protocol === 'https:'
|
|
283
|
+
? requestHttps({ ...options, ...(literal === 0 ? { servername: hostname } : {}) }, onResponse)
|
|
284
|
+
: requestHttp(options, onResponse);
|
|
285
|
+
// A handshake error can be followed by a socket teardown error; keep the listener for the request lifetime.
|
|
286
|
+
client.on('error', reject);
|
|
287
|
+
client.end();
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
function retryDelay(response, attempt) {
|
|
291
|
+
const header = response?.headers.get('retry-after')?.trim();
|
|
292
|
+
if (header) {
|
|
293
|
+
const seconds = Number(header);
|
|
294
|
+
if (Number.isFinite(seconds) && seconds >= 0)
|
|
295
|
+
return Math.min(seconds * 1000, 5000);
|
|
296
|
+
const date = Date.parse(header);
|
|
297
|
+
if (!Number.isNaN(date))
|
|
298
|
+
return Math.min(Math.max(date - Date.now(), 0), 5000);
|
|
299
|
+
}
|
|
300
|
+
return Math.min(250 * 2 ** attempt, 2000);
|
|
301
|
+
}
|
|
302
|
+
async function readBounded(response, maxBytes) {
|
|
303
|
+
const declared = Number(response.headers.get('content-length') ?? NaN);
|
|
304
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
305
|
+
response.cancel();
|
|
306
|
+
throw new FetchFailure('too-large', `response declares ${declared} bytes; limit is ${maxBytes}`);
|
|
307
|
+
}
|
|
308
|
+
if (response.body === null)
|
|
309
|
+
return new Uint8Array();
|
|
310
|
+
const chunks = [];
|
|
311
|
+
let total = 0;
|
|
312
|
+
for await (const chunk of response.body) {
|
|
313
|
+
if (!(chunk instanceof Uint8Array)) {
|
|
314
|
+
response.cancel();
|
|
315
|
+
throw new FetchFailure('network', 'response body yielded a non-byte chunk');
|
|
316
|
+
}
|
|
317
|
+
total += chunk.byteLength;
|
|
318
|
+
if (total > maxBytes) {
|
|
319
|
+
response.cancel();
|
|
320
|
+
throw new FetchFailure('too-large', `response exceeded ${maxBytes} bytes`);
|
|
321
|
+
}
|
|
322
|
+
chunks.push(chunk);
|
|
323
|
+
}
|
|
324
|
+
return Buffer.concat(chunks, total);
|
|
325
|
+
}
|
|
326
|
+
function requestHeaders(current, options) {
|
|
327
|
+
const headers = new Headers({
|
|
328
|
+
Accept: options.accept ?? 'text/html,application/xhtml+xml;q=0.9,*/*;q=0.5',
|
|
329
|
+
'Accept-Encoding': 'identity',
|
|
330
|
+
'User-Agent': options.userAgent,
|
|
331
|
+
});
|
|
332
|
+
if (options.referer !== undefined) {
|
|
333
|
+
try {
|
|
334
|
+
const referer = new URL(options.referer);
|
|
335
|
+
headers.set('Referer', referer.origin === current.origin ? referer.href : `${referer.origin}/`);
|
|
336
|
+
}
|
|
337
|
+
catch {
|
|
338
|
+
// An invalid optional referrer is dropped rather than forwarded.
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
return headers;
|
|
342
|
+
}
|
|
343
|
+
/** A bounded fetcher: every hop is resolved, validated and pinned; redirects are re-checked; bodies are capped. */
|
|
344
|
+
export function createSafeFetch(dependencies = {}) {
|
|
345
|
+
const resolve = {
|
|
346
|
+
resolveHostname: dependencies.resolveHostname ?? systemResolve,
|
|
347
|
+
localAddresses: dependencies.localAddresses ?? systemLocalAddresses,
|
|
348
|
+
};
|
|
349
|
+
const transport = dependencies.transport ?? requestPinned;
|
|
350
|
+
return async (url, options) => {
|
|
351
|
+
const maxRedirects = options.maxRedirects ?? 8, retries = options.retries ?? 2, deadline = Date.now() + options.timeoutMs;
|
|
352
|
+
let current = new URL(url);
|
|
353
|
+
for (let redirects = 0; redirects <= maxRedirects; redirects += 1) {
|
|
354
|
+
const timedOut = `request timed out after ${options.timeoutMs}ms: ${current.href}`;
|
|
355
|
+
const addresses = await resolveTarget(current, options.allowPrivateNetwork, resolve, deadline, options.timeoutMs);
|
|
356
|
+
let response = null, lastError, controller = null, timer;
|
|
357
|
+
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
|
358
|
+
const remaining = deadline - Date.now();
|
|
359
|
+
if (remaining <= 0)
|
|
360
|
+
throw new FetchFailure('timeout', timedOut);
|
|
361
|
+
const attemptController = new AbortController();
|
|
362
|
+
const attemptTimer = setTimeout(() => attemptController.abort(), remaining);
|
|
363
|
+
try {
|
|
364
|
+
response = await beforeDeadline(transport({
|
|
365
|
+
url: new URL(current),
|
|
366
|
+
address: addresses[attempt % addresses.length],
|
|
367
|
+
headers: requestHeaders(current, options),
|
|
368
|
+
signal: attemptController.signal,
|
|
369
|
+
}), deadline, timedOut);
|
|
370
|
+
if (!retryableStatuses.has(response.status) || attempt === retries) {
|
|
371
|
+
controller = attemptController;
|
|
372
|
+
timer = attemptTimer;
|
|
373
|
+
break;
|
|
374
|
+
}
|
|
375
|
+
attemptController.abort();
|
|
376
|
+
response.cancel();
|
|
377
|
+
clearTimeout(attemptTimer);
|
|
378
|
+
}
|
|
379
|
+
catch (error) {
|
|
380
|
+
lastError = error;
|
|
381
|
+
// The deadline race and the abort timer are separate callbacks; abort so a pending transport frees its socket.
|
|
382
|
+
attemptController.abort();
|
|
383
|
+
clearTimeout(attemptTimer);
|
|
384
|
+
if (error instanceof FetchFailure && error.code === 'timeout')
|
|
385
|
+
throw error;
|
|
386
|
+
if (attempt === retries) {
|
|
387
|
+
if (attemptController.signal.aborted && Date.now() >= deadline)
|
|
388
|
+
throw new FetchFailure('timeout', timedOut, { cause: error });
|
|
389
|
+
if (error instanceof FetchFailure)
|
|
390
|
+
throw error;
|
|
391
|
+
throw new FetchFailure('network', `request failed: ${current.href}`, { cause: error });
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
const delay = Math.min(retryDelay(response, attempt), Math.max(0, deadline - Date.now()));
|
|
395
|
+
if (delay > 0)
|
|
396
|
+
await sleep(delay);
|
|
397
|
+
}
|
|
398
|
+
if (response === null)
|
|
399
|
+
throw new FetchFailure('network', `request failed: ${current.href}`, { cause: lastError });
|
|
400
|
+
if (response.status >= 300 && response.status < 400) {
|
|
401
|
+
try {
|
|
402
|
+
const location = response.headers.get('location');
|
|
403
|
+
if (location === null)
|
|
404
|
+
throw new FetchFailure('redirect', `HTTP ${response.status} omitted Location`);
|
|
405
|
+
if (redirects === maxRedirects)
|
|
406
|
+
throw new FetchFailure('redirect', `more than ${maxRedirects} redirects`);
|
|
407
|
+
try {
|
|
408
|
+
current = new URL(location, current);
|
|
409
|
+
}
|
|
410
|
+
catch (error) {
|
|
411
|
+
throw new FetchFailure('redirect', 'invalid redirect target', { cause: error });
|
|
412
|
+
}
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
finally {
|
|
416
|
+
controller?.abort();
|
|
417
|
+
response.cancel();
|
|
418
|
+
clearTimeout(timer);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
if (response.status < 200 || response.status >= 300) {
|
|
422
|
+
clearTimeout(timer);
|
|
423
|
+
controller?.abort();
|
|
424
|
+
response.cancel();
|
|
425
|
+
throw new FetchFailure('http', `HTTP ${response.status} for ${current.href}`, { status: response.status });
|
|
426
|
+
}
|
|
427
|
+
const bodyTimedOut = `response body timed out after ${options.timeoutMs}ms: ${current.href}`;
|
|
428
|
+
try {
|
|
429
|
+
const bytes = await beforeDeadline(readBounded(response, options.maxBytes), deadline, bodyTimedOut);
|
|
430
|
+
return { bytes, finalUrl: current, status: response.status, contentType: response.headers.get('content-type') };
|
|
431
|
+
}
|
|
432
|
+
catch (error) {
|
|
433
|
+
if (controller?.signal.aborted || (error instanceof FetchFailure && error.code === 'timeout')) {
|
|
434
|
+
response.cancel();
|
|
435
|
+
throw new FetchFailure('timeout', bodyTimedOut, { cause: error });
|
|
436
|
+
}
|
|
437
|
+
if (error instanceof FetchFailure)
|
|
438
|
+
throw error;
|
|
439
|
+
throw new FetchFailure('network', `response body failed: ${current.href}`, { cause: error });
|
|
440
|
+
}
|
|
441
|
+
finally {
|
|
442
|
+
clearTimeout(timer);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
throw new FetchFailure('redirect', `more than ${maxRedirects} redirects`);
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
export const safeFetch = createSafeFetch();
|
|
449
|
+
export function decodeBytes(bytes, contentType) {
|
|
450
|
+
const charset = /charset\s*=\s*["']?([^;"'\s]+)/i.exec(contentType ?? '')?.[1]?.toLowerCase();
|
|
451
|
+
const encoding = charset === 'iso-8859-1' || charset === 'windows-1252' ? 'windows-1252' : 'utf-8';
|
|
452
|
+
return new TextDecoder(encoding, { fatal: false }).decode(bytes);
|
|
453
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export type CaptureScope = 'auto' | 'page' | 'thread' | 'comments';
|
|
2
|
+
export type AcquisitionMethod = 'http' | 'hacker-news-api' | 'bluesky-api' | 'reddit-json' | 'browser-cdp' | 'browser-fetch' | 'browser-render';
|
|
3
|
+
export type AcquiredPage = {
|
|
4
|
+
readonly body: string;
|
|
5
|
+
readonly contentType: string | null;
|
|
6
|
+
readonly finalUrl: URL;
|
|
7
|
+
readonly method: AcquisitionMethod;
|
|
8
|
+
readonly warnings: readonly string[];
|
|
9
|
+
readonly browserTitle?: string;
|
|
10
|
+
readonly contentTruncated?: boolean;
|
|
11
|
+
readonly renderedText?: string;
|
|
12
|
+
readonly renderedTextTruncated?: boolean;
|
|
13
|
+
readonly renderedTextByteLimit?: number;
|
|
14
|
+
};
|
|
15
|
+
/** The bounded knobs one capture runs under; every lane reads the same values. */
|
|
16
|
+
export type CaptureOptions = {
|
|
17
|
+
readonly url: URL;
|
|
18
|
+
readonly scope: CaptureScope;
|
|
19
|
+
readonly timeoutMs: number;
|
|
20
|
+
readonly maxItems: number;
|
|
21
|
+
readonly maxDepth: number;
|
|
22
|
+
readonly maxHtmlBytes: number;
|
|
23
|
+
readonly allowPrivateNetwork: boolean;
|
|
24
|
+
readonly userAgent: string;
|
|
25
|
+
};
|
|
26
|
+
export declare const DEFAULT_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const DEFAULT_USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
|