@hraness/sponge 0.1.2 → 0.2.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/README.md +163 -17
- package/bin/sponge.js +9 -2
- package/dist/contracts/index.d.ts +164 -0
- package/dist/contracts/index.js +6 -0
- package/dist/contracts/validate.d.ts +14 -0
- package/dist/contracts/validate.js +187 -0
- package/dist/migrations/001.d.ts +3 -0
- package/dist/migrations/001.js +33 -0
- package/dist/migrations/002.d.ts +3 -0
- package/dist/migrations/002.js +6 -0
- package/dist/migrations/003.d.ts +3 -0
- package/dist/migrations/003.js +10 -0
- package/dist/migrations/004.d.ts +3 -0
- package/dist/migrations/004.js +9 -0
- package/dist/src/acquisition/capture.d.ts +29 -0
- package/dist/src/acquisition/capture.js +160 -0
- package/dist/src/acquisition/cleanup.d.ts +6 -0
- package/dist/src/acquisition/cleanup.js +20 -0
- package/dist/src/acquisition/errors.d.ts +3 -0
- package/dist/src/acquisition/errors.js +20 -0
- package/dist/src/acquisition/files.d.ts +4 -0
- package/dist/src/acquisition/files.js +49 -0
- package/dist/src/acquisition/index.d.ts +6 -0
- package/dist/src/acquisition/index.js +4 -0
- package/dist/src/acquisition/lock.d.ts +1 -0
- package/dist/src/acquisition/lock.js +2 -0
- package/dist/src/acquisition/normalize.d.ts +11 -0
- package/dist/src/acquisition/normalize.js +62 -0
- package/dist/src/acquisition/pins.d.ts +3 -0
- package/dist/src/acquisition/pins.js +9 -0
- package/dist/src/acquisition/process.d.ts +16 -0
- package/dist/src/acquisition/process.js +63 -0
- package/dist/src/acquisition/runtime.d.ts +53 -0
- package/dist/src/acquisition/runtime.js +216 -0
- package/dist/src/acquisition/verify-helper.d.ts +1 -0
- package/dist/src/acquisition/verify-helper.js +19 -0
- package/dist/src/app/article.d.ts +23 -0
- package/dist/src/app/article.js +82 -0
- package/dist/src/app/bundle-files.d.ts +8 -0
- package/dist/src/app/bundle-files.js +66 -0
- package/dist/src/app/markdown.d.ts +6 -0
- package/dist/src/app/markdown.js +90 -0
- package/dist/src/app/source.d.ts +15 -0
- package/dist/src/app/source.js +40 -0
- package/dist/src/cli/arguments.d.ts +9 -0
- package/dist/src/cli/arguments.js +106 -0
- package/dist/src/cli/config.d.ts +8 -0
- package/dist/src/cli/config.js +45 -0
- package/dist/src/cli/import.d.ts +4 -0
- package/dist/src/cli/import.js +3 -0
- package/dist/src/cli/indexing.d.ts +7 -0
- package/dist/src/cli/indexing.js +21 -0
- package/dist/src/cli/main.d.ts +1 -0
- package/dist/src/cli/main.js +333 -0
- package/dist/src/cli/portability.d.ts +9 -0
- package/dist/src/cli/portability.js +39 -0
- package/dist/src/cli/queue.d.ts +2 -0
- package/dist/src/cli/queue.js +23 -0
- package/dist/src/cli/terminal.d.ts +2 -0
- package/dist/src/cli/terminal.js +4 -0
- package/dist/src/cli/worker.d.ts +16 -0
- package/dist/src/cli/worker.js +62 -0
- package/dist/src/importers/apply.d.ts +29 -0
- package/dist/src/importers/apply.js +85 -0
- package/dist/src/importers/custody.d.ts +6 -0
- package/dist/src/importers/custody.js +35 -0
- package/dist/src/importers/inspect.d.ts +21 -0
- package/dist/src/importers/inspect.js +97 -0
- package/dist/src/importers/manifest.d.ts +22 -0
- package/dist/src/importers/manifest.js +43 -0
- package/dist/src/importers/verify-helper.d.ts +1 -0
- package/dist/src/importers/verify-helper.js +27 -0
- package/dist/src/indexing/image.d.ts +10 -0
- package/dist/src/indexing/image.js +34 -0
- package/dist/src/indexing/index.d.ts +53 -0
- package/dist/src/indexing/index.js +123 -0
- package/dist/src/indexing/jobs.d.ts +14 -0
- package/dist/src/indexing/jobs.js +49 -0
- package/dist/src/indexing/runner.d.ts +23 -0
- package/dist/src/indexing/runner.js +114 -0
- package/dist/src/inference/asr/bridge.d.ts +1 -0
- package/dist/src/inference/asr/bridge.js +16 -0
- package/dist/src/inference/asr/protocol.d.ts +23 -0
- package/dist/src/inference/asr/protocol.js +60 -0
- package/dist/src/inference/asr/runtime.d.ts +29 -0
- package/dist/src/inference/asr/runtime.js +103 -0
- package/dist/src/inference/asr/windows.d.ts +9 -0
- package/dist/src/inference/asr/windows.js +41 -0
- package/dist/src/inference/embedding/index.d.ts +47 -0
- package/dist/src/inference/embedding/index.js +122 -0
- package/dist/src/media/capture-helper.d.ts +1 -0
- package/dist/src/media/capture-helper.js +16 -0
- package/dist/src/media/capture.d.ts +32 -0
- package/dist/src/media/capture.js +193 -0
- package/dist/src/media/handler.d.ts +22 -0
- package/dist/src/media/handler.js +82 -0
- package/dist/src/media/transcribe.d.ts +29 -0
- package/dist/src/media/transcribe.js +93 -0
- package/dist/src/metadata/article.d.ts +5 -0
- package/dist/src/metadata/article.js +51 -0
- package/dist/src/models/artifacts.d.ts +36 -0
- package/dist/src/models/artifacts.js +274 -0
- package/dist/src/models/catalog.d.ts +35 -0
- package/dist/src/models/catalog.js +71 -0
- package/dist/src/models/download.d.ts +9 -0
- package/dist/src/models/download.js +147 -0
- package/dist/src/models/runtime.d.ts +24 -0
- package/dist/src/models/runtime.js +37 -0
- package/dist/src/pdf/capture-helper.d.ts +1 -0
- package/dist/src/pdf/capture-helper.js +12 -0
- package/dist/src/pdf/capture.d.ts +29 -0
- package/dist/src/pdf/capture.js +156 -0
- package/dist/src/pdf/errors.d.ts +2 -0
- package/dist/src/pdf/errors.js +13 -0
- package/dist/src/pdf/handler.d.ts +24 -0
- package/dist/src/pdf/handler.js +84 -0
- package/dist/src/pdf/index.d.ts +4 -0
- package/dist/src/pdf/index.js +4 -0
- package/dist/src/pdf/input.d.ts +2 -0
- package/dist/src/pdf/input.js +46 -0
- package/dist/src/pdf/normalize.d.ts +10 -0
- package/dist/src/pdf/normalize.js +64 -0
- package/dist/src/pdf/source-identity.d.ts +4 -0
- package/dist/src/pdf/source-identity.js +12 -0
- package/dist/src/pdf/source.d.ts +6 -0
- package/dist/src/pdf/source.js +22 -0
- package/dist/src/pdf/verify-helper.d.ts +1 -0
- package/dist/src/pdf/verify-helper.js +17 -0
- package/dist/src/portability/backup.d.ts +23 -0
- package/dist/src/portability/backup.js +138 -0
- package/dist/src/portability/database.d.ts +14 -0
- package/dist/src/portability/database.js +78 -0
- package/dist/src/portability/export.d.ts +8 -0
- package/dist/src/portability/export.js +36 -0
- package/dist/src/portability/files.d.ts +33 -0
- package/dist/src/portability/files.js +78 -0
- package/dist/src/portability/gc.d.ts +21 -0
- package/dist/src/portability/gc.js +66 -0
- package/dist/src/search/extension.d.ts +3 -0
- package/dist/src/search/extension.js +17 -0
- package/dist/src/search/hybrid.d.ts +39 -0
- package/dist/src/search/hybrid.js +118 -0
- package/dist/src/search/query.d.ts +12 -0
- package/dist/src/search/query.js +47 -0
- package/dist/src/search/vector.d.ts +21 -0
- package/dist/src/search/vector.js +21 -0
- package/dist/src/service/launch-agent.d.ts +47 -0
- package/dist/src/service/launch-agent.js +179 -0
- package/dist/src/service/lifetime.d.ts +7 -0
- package/dist/src/service/lifetime.js +73 -0
- package/dist/src/service/loop.d.ts +21 -0
- package/dist/src/service/loop.js +94 -0
- package/dist/src/store/files.d.ts +12 -0
- package/dist/src/store/files.js +101 -0
- package/dist/src/store/index.d.ts +118 -0
- package/dist/src/store/index.js +727 -0
- package/dist/src/store/types.d.ts +90 -0
- package/dist/src/store/types.js +8 -0
- package/dist/src/worker/basic.d.ts +10 -0
- package/dist/src/worker/basic.js +27 -0
- package/dist/src/worker/custody.d.ts +13 -0
- package/dist/src/worker/custody.js +185 -0
- package/dist/src/worker/engine.d.ts +28 -0
- package/dist/src/worker/engine.js +78 -0
- package/dist/src/worker/identity.d.ts +7 -0
- package/dist/src/worker/identity.js +27 -0
- package/dist/src/worker/journal.d.ts +5 -0
- package/dist/src/worker/journal.js +30 -0
- package/dist/src/worker/supervisor-child.d.ts +1 -0
- package/dist/src/worker/supervisor-child.js +33 -0
- package/dist/src/worker/supervisor.d.ts +3 -0
- package/dist/src/worker/supervisor.js +47 -0
- package/native/runtime-manifest.json +40 -0
- package/package.json +31 -5
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { mkdir, mkdtemp, writeFile, rename, rm, chmod, statfs, lstat, readdir, readlink, realpath } from 'node:fs/promises';
|
|
2
|
+
import { join, relative } from 'node:path';
|
|
3
|
+
import { runOwned, isolatedEnvironment } from './process.js';
|
|
4
|
+
import { hash, privateRoot, readConfined, physicalBytes } from './files.js';
|
|
5
|
+
export class CaptureSetupError extends Error {
|
|
6
|
+
code;
|
|
7
|
+
recoveryRoot;
|
|
8
|
+
constructor(code, recoveryRoot) {
|
|
9
|
+
super(code);
|
|
10
|
+
this.code = code;
|
|
11
|
+
this.recoveryRoot = recoveryRoot;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
// Retained inventory excludes the isolated Bun cache and temporary Bun zip.
|
|
15
|
+
const budget = 1024 ** 3;
|
|
16
|
+
const stagingBudget = 2 * budget;
|
|
17
|
+
function checkPins(pins) {
|
|
18
|
+
if (!/^[a-z0-9][a-z0-9.-]{0,99}$/.test(pins.id) || hash(pins.lockfile) !== pins.lockSha256)
|
|
19
|
+
throw new CaptureSetupError('invalid-input');
|
|
20
|
+
for (const pin of [pins.bun, pins.ghostget, pins.wordcell]) {
|
|
21
|
+
let url;
|
|
22
|
+
try {
|
|
23
|
+
url = new URL(pin.url);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
throw new CaptureSetupError('invalid-input');
|
|
27
|
+
}
|
|
28
|
+
if (!/^[0-9]+\.[0-9]+\.[0-9]+(?:-[a-zA-Z0-9.-]+)?$/.test(pin.version) || !/^[a-f0-9]{64}$/.test(pin.sha256) || !Number.isSafeInteger(pin.bytes) || pin.bytes < 1 || pin.bytes > budget || url.protocol !== 'https:' || url.username || url.password)
|
|
29
|
+
throw new CaptureSetupError('invalid-input');
|
|
30
|
+
}
|
|
31
|
+
if (!/^[a-z0-9][a-z0-9.-]*\/bun$/.test(pins.bun.archiveExecutable) || !/^[a-f0-9]{64}$/.test(pins.bun.executableSha256))
|
|
32
|
+
throw new CaptureSetupError('invalid-input');
|
|
33
|
+
}
|
|
34
|
+
export function captureSetupPlan(pins) {
|
|
35
|
+
checkPins(pins);
|
|
36
|
+
const artifacts = [pins.bun, pins.ghostget, pins.wordcell], downloadBytes = artifacts.reduce((n, a) => n + a.bytes, 0);
|
|
37
|
+
return { artifacts, downloadBytes, installationBudgetBytes: budget, stagingBudgetBytes: stagingBudget, installationBudgetKind: 'monitored', dependencyDownloads: 'frozen-lock', requiredFreeBytes: 2 * 1024 ** 3 + budget + stagingBudget + downloadBytes, capabilities: ['article-http', 'selected-ghostget-account', 'selected-attached-browser', 'verified-offline-images'] };
|
|
38
|
+
}
|
|
39
|
+
async function download(pin, path, signal) {
|
|
40
|
+
const boundedSignal = signal ? AbortSignal.any([signal, AbortSignal.timeout(120000)]) : AbortSignal.timeout(120000);
|
|
41
|
+
const response = await fetch(pin.url, { signal: boundedSignal });
|
|
42
|
+
if (!response.ok || !response.body || new URL(response.url).protocol !== 'https:')
|
|
43
|
+
throw new CaptureSetupError('unavailable');
|
|
44
|
+
const declared = response.headers.get('content-length');
|
|
45
|
+
if (declared !== null && Number(declared) !== pin.bytes) {
|
|
46
|
+
await response.body.cancel();
|
|
47
|
+
throw new CaptureSetupError('integrity');
|
|
48
|
+
}
|
|
49
|
+
// Primary release artifacts are small, fixed byte records. Never write an unchecked chunk.
|
|
50
|
+
const chunks = [];
|
|
51
|
+
let bytes = 0;
|
|
52
|
+
for await (const chunk of response.body) {
|
|
53
|
+
bytes += chunk.length;
|
|
54
|
+
if (bytes > pin.bytes)
|
|
55
|
+
throw new CaptureSetupError('integrity');
|
|
56
|
+
chunks.push(chunk);
|
|
57
|
+
}
|
|
58
|
+
const data = Buffer.concat(chunks);
|
|
59
|
+
if (bytes !== pin.bytes || hash(data) !== pin.sha256)
|
|
60
|
+
throw new CaptureSetupError('integrity');
|
|
61
|
+
await writeFile(path, data, { mode: 0o600, flag: 'wx' });
|
|
62
|
+
}
|
|
63
|
+
async function inventory(root) {
|
|
64
|
+
const entries = [];
|
|
65
|
+
let total = 0, count = 0;
|
|
66
|
+
async function visit(dir) {
|
|
67
|
+
for (const name of (await readdir(dir)).sort()) {
|
|
68
|
+
const path = join(dir, name), rel = relative(root, path);
|
|
69
|
+
if (rel === 'runtime-receipt.json')
|
|
70
|
+
continue;
|
|
71
|
+
if (++count > 60000)
|
|
72
|
+
throw new CaptureSetupError('limit-exceeded');
|
|
73
|
+
const stat = await lstat(path);
|
|
74
|
+
total += stat.size;
|
|
75
|
+
if (total > budget)
|
|
76
|
+
throw new CaptureSetupError('limit-exceeded');
|
|
77
|
+
if (stat.isDirectory())
|
|
78
|
+
await visit(path);
|
|
79
|
+
else if (stat.isSymbolicLink()) {
|
|
80
|
+
const target = await realpath(path);
|
|
81
|
+
if (!target.startsWith(root + '/'))
|
|
82
|
+
throw new CaptureSetupError('integrity');
|
|
83
|
+
entries.push(`${rel}\0link\0${await readlink(path)}`);
|
|
84
|
+
}
|
|
85
|
+
else if (stat.isFile())
|
|
86
|
+
entries.push(`${rel}\0${hash(await readConfined(root, rel, budget - total + stat.size))}`);
|
|
87
|
+
else
|
|
88
|
+
throw new CaptureSetupError('integrity');
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
await visit(root);
|
|
92
|
+
return hash(entries.join('\n'));
|
|
93
|
+
}
|
|
94
|
+
async function inspectRuntime(root, pins) {
|
|
95
|
+
checkPins(pins);
|
|
96
|
+
await privateRoot(root);
|
|
97
|
+
const receipt = JSON.parse((await readConfined(root, 'runtime-receipt.json', 4096)).toString());
|
|
98
|
+
if (receipt.schemaVersion !== 1 || receipt.pinId !== pins.id || receipt.lockSha256 !== pins.lockSha256 || receipt.inventorySha256 !== await inventory(root))
|
|
99
|
+
throw new CaptureSetupError('integrity');
|
|
100
|
+
const bun = join(root, pins.bun.archiveExecutable), ghostget = join(root, 'node_modules/@hraness/ghostget/src/cli.ts');
|
|
101
|
+
if (hash(await readConfined(root, pins.bun.archiveExecutable, 128 * 1024 ** 2)) !== pins.bun.executableSha256)
|
|
102
|
+
throw new CaptureSetupError('integrity');
|
|
103
|
+
return { root, bun, ghostget, pins };
|
|
104
|
+
}
|
|
105
|
+
async function provisionRuntime(options) {
|
|
106
|
+
const { pins, signal } = options, plan = captureSetupPlan(pins);
|
|
107
|
+
options.onPlan?.(plan);
|
|
108
|
+
if (process.platform !== 'darwin' || process.arch !== 'arm64')
|
|
109
|
+
throw new CaptureSetupError('unsupported');
|
|
110
|
+
await mkdir(options.runtimeRoot, { recursive: true, mode: 0o700 });
|
|
111
|
+
const base = await privateRoot(await realpath(options.runtimeRoot));
|
|
112
|
+
const destination = join(base, pins.id);
|
|
113
|
+
try {
|
|
114
|
+
await lstat(destination);
|
|
115
|
+
return await inspectCaptureRuntime(destination, pins);
|
|
116
|
+
}
|
|
117
|
+
catch (e) {
|
|
118
|
+
if (e.code !== 'ENOENT')
|
|
119
|
+
throw e;
|
|
120
|
+
}
|
|
121
|
+
const disk = await statfs(base);
|
|
122
|
+
if (disk.bavail * disk.bsize < plan.requiredFreeBytes)
|
|
123
|
+
throw new CaptureSetupError('limit-exceeded');
|
|
124
|
+
const stage = await mkdtemp(join(base, '.install-'));
|
|
125
|
+
await chmod(stage, 0o700);
|
|
126
|
+
const home = join(stage, 'home');
|
|
127
|
+
await mkdir(home, { mode: 0o700 });
|
|
128
|
+
const controller = new AbortController();
|
|
129
|
+
const abort = () => controller.abort();
|
|
130
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
131
|
+
if (signal?.aborted)
|
|
132
|
+
abort();
|
|
133
|
+
let cleanup = 'settled', checking = false, limitExceeded = false;
|
|
134
|
+
const monitor = setInterval(async () => { if (checking)
|
|
135
|
+
return; checking = true; try {
|
|
136
|
+
if (await physicalBytes(stage) > stagingBudget) {
|
|
137
|
+
limitExceeded = true;
|
|
138
|
+
controller.abort();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
controller.abort();
|
|
143
|
+
}
|
|
144
|
+
finally {
|
|
145
|
+
checking = false;
|
|
146
|
+
} }, 100);
|
|
147
|
+
try {
|
|
148
|
+
await download(pins.bun, join(stage, 'bun.zip'), controller.signal);
|
|
149
|
+
const unpack = await runOwned('/usr/bin/unzip', ['-q', join(stage, 'bun.zip'), '-d', stage], { cwd: stage, env: isolatedEnvironment(home, home, home), signal: controller.signal, deadlineMs: 30000, maxOutputBytes: 65536 });
|
|
150
|
+
cleanup = unpack.cleanup;
|
|
151
|
+
if (unpack.code !== 0 || unpack.stopped || cleanup !== 'settled')
|
|
152
|
+
throw new CaptureSetupError(limitExceeded ? 'limit-exceeded' : unpack.stopped ?? 'unavailable');
|
|
153
|
+
const bun = join(stage, pins.bun.archiveExecutable);
|
|
154
|
+
if (hash(await readConfined(stage, pins.bun.archiveExecutable, 128 * 1024 ** 2)) !== pins.bun.executableSha256)
|
|
155
|
+
throw new CaptureSetupError('integrity');
|
|
156
|
+
await chmod(bun, 0o700);
|
|
157
|
+
await download(pins.ghostget, join(stage, `hraness-ghostget-${pins.ghostget.version}.tgz`), controller.signal);
|
|
158
|
+
await download(pins.wordcell, join(stage, `hraness-wordcell-${pins.wordcell.version}.tgz`), controller.signal);
|
|
159
|
+
await writeFile(join(stage, 'package.json'), JSON.stringify({ private: true, dependencies: { '@hraness/ghostget': `./hraness-ghostget-${pins.ghostget.version}.tgz` } }), { mode: 0o600 });
|
|
160
|
+
await writeFile(join(stage, 'bun.lock'), pins.lockfile, { mode: 0o600 });
|
|
161
|
+
const install = await runOwned(bun, ['install', '--frozen-lockfile', '--ignore-scripts'], { cwd: stage, env: isolatedEnvironment(home, home, home), signal: controller.signal, deadlineMs: 300000, maxOutputBytes: 65536 });
|
|
162
|
+
cleanup = install.cleanup;
|
|
163
|
+
if (install.code !== 0 || install.stopped || cleanup !== 'settled')
|
|
164
|
+
throw new CaptureSetupError(limitExceeded ? 'limit-exceeded' : install.stopped ?? 'unavailable');
|
|
165
|
+
await rm(home, { recursive: true, force: true });
|
|
166
|
+
await rm(join(stage, 'bun.zip'));
|
|
167
|
+
for (const [name, pin] of [['ghostget', pins.ghostget], ['wordcell', pins.wordcell]]) {
|
|
168
|
+
const pkg = JSON.parse((await readConfined(stage, `node_modules/@hraness/${name}/package.json`, 65536)).toString());
|
|
169
|
+
if (pkg.version !== pin.version)
|
|
170
|
+
throw new CaptureSetupError('integrity');
|
|
171
|
+
}
|
|
172
|
+
await writeFile(join(stage, 'runtime-receipt.json'), JSON.stringify({ schemaVersion: 1, pinId: pins.id, lockSha256: pins.lockSha256, inventorySha256: await inventory(stage) }), { mode: 0o600 });
|
|
173
|
+
if (controller.signal.aborted)
|
|
174
|
+
throw new CaptureSetupError('cancelled');
|
|
175
|
+
await rename(stage, destination);
|
|
176
|
+
return await inspectCaptureRuntime(destination, pins);
|
|
177
|
+
}
|
|
178
|
+
finally {
|
|
179
|
+
clearInterval(monitor);
|
|
180
|
+
while (checking)
|
|
181
|
+
await new Promise(r => setTimeout(r, 5));
|
|
182
|
+
signal?.removeEventListener('abort', abort);
|
|
183
|
+
if (cleanup === 'settled') {
|
|
184
|
+
try {
|
|
185
|
+
await rm(stage, { recursive: true, force: true });
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
throw new CaptureSetupError('cleanup-required', stage);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
else
|
|
192
|
+
throw new CaptureSetupError('cleanup-required', stage);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
export async function inspectCaptureRuntime(root, pins) {
|
|
196
|
+
try {
|
|
197
|
+
return await inspectRuntime(root, pins);
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
if (error instanceof CaptureSetupError)
|
|
201
|
+
throw error;
|
|
202
|
+
throw new CaptureSetupError('runtime-missing');
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
export async function provisionCapture(options) {
|
|
206
|
+
try {
|
|
207
|
+
return await provisionRuntime(options);
|
|
208
|
+
}
|
|
209
|
+
catch (error) {
|
|
210
|
+
if (error instanceof CaptureSetupError)
|
|
211
|
+
throw error;
|
|
212
|
+
if (options.signal?.aborted)
|
|
213
|
+
throw new CaptureSetupError('cancelled');
|
|
214
|
+
throw new CaptureSetupError('unavailable');
|
|
215
|
+
}
|
|
216
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Executed only by the pinned Bun runtime. The import is Wordcell's public API.
|
|
2
|
+
const runtime = process.argv[2], bundle = process.argv[3];
|
|
3
|
+
try {
|
|
4
|
+
const { verifyCaptureBundle } = await import(import.meta.resolve('@hraness/wordcell/clip/bundle-reader', `${runtime}/package.json`));
|
|
5
|
+
const result = await verifyCaptureBundle(bundle, { verifyAssets: true, maxDocumentBytes: 16 * 1024 ** 2, maxVerifiedAssets: 200, maxAssetBytes: 20 * 1024 ** 2, maxTotalAssetBytes: 500 * 1024 ** 2, maxAssetVerificationMs: 30000 });
|
|
6
|
+
// The document travels via the verified file, never a giant protocol frame.
|
|
7
|
+
const { markdown: _, ...document } = result.inspection.document;
|
|
8
|
+
// Metadata is validated upstream and read from the confined manifest by the parent.
|
|
9
|
+
const { metadata: _metadata, ...inspection } = result.inspection;
|
|
10
|
+
const output = JSON.stringify({ ...result, inspection: { ...inspection, document } });
|
|
11
|
+
if (Buffer.byteLength(output) > 65536)
|
|
12
|
+
throw Error('frame-limit');
|
|
13
|
+
process.stdout.write(output);
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
process.stdout.write(JSON.stringify({ ok: false }));
|
|
17
|
+
process.exitCode = 1;
|
|
18
|
+
}
|
|
19
|
+
export {};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ErrorEnvelope, JobCheckpoint } from '../../contracts/index.js';
|
|
2
|
+
import { type CaptureRequest, type CaptureOutcome, type CaptureRuntime, type CaptureConnection } from '../acquisition/index.js';
|
|
3
|
+
import type { LibraryStore, AttemptToken } from '../store/index.js';
|
|
4
|
+
export interface ArticleResult {
|
|
5
|
+
ok: true;
|
|
6
|
+
itemId: string;
|
|
7
|
+
revisionId: string;
|
|
8
|
+
status: 'complete' | 'partial';
|
|
9
|
+
}
|
|
10
|
+
export type ArticleOutcome = (ArticleResult | ErrorEnvelope) & {
|
|
11
|
+
cleanup: 'settled' | 'pending';
|
|
12
|
+
};
|
|
13
|
+
export interface ArticleOptions {
|
|
14
|
+
checkpoint?: (stage: JobCheckpoint['stage']) => Promise<void>;
|
|
15
|
+
runProcess?: CaptureRequest['runProcess'];
|
|
16
|
+
runtime: CaptureRuntime;
|
|
17
|
+
connection?: CaptureConnection;
|
|
18
|
+
signal?: AbortSignal;
|
|
19
|
+
allowPrivateNetwork?: boolean;
|
|
20
|
+
capture?: (request: CaptureRequest) => Promise<CaptureOutcome>;
|
|
21
|
+
}
|
|
22
|
+
/** One attempt. Durable queue policy and process recovery live above this function. */
|
|
23
|
+
export declare function ingestArticle(store: LibraryStore, token: AttemptToken, options: ArticleOptions): Promise<ArticleOutcome>;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { validateCapture } from '../../contracts/validate.js';
|
|
4
|
+
import { captureArticle } from '../acquisition/index.js';
|
|
5
|
+
import { failure } from '../acquisition/errors.js';
|
|
6
|
+
import { bundleBytes, bundleText } from './bundle-files.js';
|
|
7
|
+
import { selectedMetadata } from '../metadata/article.js';
|
|
8
|
+
import { localizeMarkdown } from './markdown.js';
|
|
9
|
+
const known = new Set(['integrity', 'limit-exceeded', 'cancelled', 'invalid-input', 'owner-busy', 'unavailable']);
|
|
10
|
+
/** One attempt. Durable queue policy and process recovery live above this function. */
|
|
11
|
+
export async function ingestArticle(store, token, options) {
|
|
12
|
+
let captured, committed = false;
|
|
13
|
+
let outcome = failure(token.attemptId, 'unclassified', 'storage');
|
|
14
|
+
let cleanup = 'settled';
|
|
15
|
+
try {
|
|
16
|
+
const source = store.sourceForJob(token.jobId);
|
|
17
|
+
if (!source || source.kind !== 'article')
|
|
18
|
+
throw Object.assign(Error('unsupported'), { code: 'invalid-input' });
|
|
19
|
+
const result = await (options.capture ?? captureArticle)({ runProcess: options.runProcess, runtime: options.runtime, stagingRoot: join(store.root, 'staging'), sourceUrl: source.locator, requestId: token.attemptId, connection: options.connection, signal: options.signal, allowPrivateNetwork: options.allowPrivateNetwork });
|
|
20
|
+
if (!result.ok) {
|
|
21
|
+
outcome = result;
|
|
22
|
+
cleanup = result.code === 'cleanup-required' ? 'pending' : 'settled';
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
captured = result;
|
|
26
|
+
await options.checkpoint?.('extracting');
|
|
27
|
+
const capture = result.capture;
|
|
28
|
+
validateCapture(capture);
|
|
29
|
+
if (capture.kind !== 'article' || capture.requestId !== token.attemptId || capture.sourceUrl !== source.locator)
|
|
30
|
+
throw Object.assign(Error('integrity'), { code: 'integrity' });
|
|
31
|
+
const original = await bundleText(result.bundleRoot, capture.markdown), localized = localizeMarkdown(original, capture.assets);
|
|
32
|
+
const assets = [];
|
|
33
|
+
for (const asset of capture.assets) {
|
|
34
|
+
if (options.signal?.aborted)
|
|
35
|
+
throw Object.assign(Error('cancelled'), { code: 'cancelled' });
|
|
36
|
+
const blob = await store.putBlob(token, asset.sha256, asset.bytes, bundleBytes(result.bundleRoot, asset));
|
|
37
|
+
assets.push({ ...blob, mediaType: asset.mediaType });
|
|
38
|
+
}
|
|
39
|
+
if (options.signal?.aborted) {
|
|
40
|
+
store.requestCancellation(token.jobId);
|
|
41
|
+
throw Object.assign(Error('cancelled'), { code: 'cancelled' });
|
|
42
|
+
}
|
|
43
|
+
const selectedTitle = selectedMetadata(capture.metadata, 'title')?.value;
|
|
44
|
+
const title = typeof selectedTitle === 'string' ? selectedTitle : undefined;
|
|
45
|
+
const status = localized.missingImages ? 'partial' : capture.status;
|
|
46
|
+
// Representation algorithm participates in identity: later extraction changes cannot reuse an old payload.
|
|
47
|
+
const bundleHash = createHash('sha256').update(JSON.stringify(['article-local-markdown-v1', capture.manifestSha256])).digest('hex');
|
|
48
|
+
await options.checkpoint?.('promoting');
|
|
49
|
+
const revision = store.commitRevision({ ...token, itemId: source.itemId, bundleHash, markdown: localized.markdown, title, extractor: capture.extractor, status, metadata: capture.metadata, metadataWarnings: capture.metadataWarnings, assets, warnings: [...new Set([...capture.warnings, ...(localized.missingImages ? ['partial'] : [])])] });
|
|
50
|
+
committed = true;
|
|
51
|
+
outcome = { ok: true, itemId: source.itemId, revisionId: revision.revisionId, status };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
const code = error?.code;
|
|
56
|
+
outcome = failure(token.attemptId, code && known.has(code) ? code : 'unclassified', 'storage');
|
|
57
|
+
}
|
|
58
|
+
if (captured)
|
|
59
|
+
try {
|
|
60
|
+
await captured.dispose();
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
cleanup = 'pending';
|
|
64
|
+
}
|
|
65
|
+
if (!committed)
|
|
66
|
+
try {
|
|
67
|
+
store.failAttempt(token, outcome.ok ? 'unclassified' : outcome.code, outcome.ok ? {} : { retryable: outcome.retryable, retryAfterMs: outcome.retryAfterMs });
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
cleanup = 'pending';
|
|
71
|
+
}
|
|
72
|
+
if (cleanup === 'settled')
|
|
73
|
+
try {
|
|
74
|
+
store.settleAttempt(token);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
cleanup = 'pending';
|
|
78
|
+
}
|
|
79
|
+
if (cleanup === 'pending')
|
|
80
|
+
return { ...failure(token.attemptId, 'cleanup-required', 'cleanup'), cleanup };
|
|
81
|
+
return { ...outcome, cleanup };
|
|
82
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type Asset } from '../../contracts/index.js';
|
|
2
|
+
export declare class BundleIntegrityError extends Error {
|
|
3
|
+
readonly code = "integrity";
|
|
4
|
+
constructor();
|
|
5
|
+
}
|
|
6
|
+
/** Stream only the verified regular inode; parent path checks never follow symlinks. */
|
|
7
|
+
export declare function bundleBytes(root: string, asset: Asset): AsyncGenerator<Uint8Array>;
|
|
8
|
+
export declare function bundleText(root: string, asset: Asset): Promise<string>;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { constants } from 'node:fs';
|
|
2
|
+
import { open, lstat, realpath } from 'node:fs/promises';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { dirname, join, isAbsolute, relative, sep } from 'node:path';
|
|
5
|
+
import { limits } from '../../contracts/index.js';
|
|
6
|
+
import { validateAsset } from '../../contracts/validate.js';
|
|
7
|
+
export class BundleIntegrityError extends Error {
|
|
8
|
+
code = 'integrity';
|
|
9
|
+
constructor() { super('integrity'); }
|
|
10
|
+
}
|
|
11
|
+
/** Stream only the verified regular inode; parent path checks never follow symlinks. */
|
|
12
|
+
export async function* bundleBytes(root, asset) {
|
|
13
|
+
validateAsset(asset);
|
|
14
|
+
if (asset.bytes > limits.pdfBytes)
|
|
15
|
+
throw new BundleIntegrityError();
|
|
16
|
+
if (!isAbsolute(root) || await realpath(root) !== root)
|
|
17
|
+
throw new BundleIntegrityError();
|
|
18
|
+
const path = join(root, asset.relativePath), rel = relative(root, path);
|
|
19
|
+
if (!rel || rel.startsWith('..' + sep) || isAbsolute(rel))
|
|
20
|
+
throw new BundleIntegrityError();
|
|
21
|
+
let parent = root;
|
|
22
|
+
for (const part of relative(root, dirname(path)).split(sep).filter(Boolean)) {
|
|
23
|
+
parent = join(parent, part);
|
|
24
|
+
const stat = await lstat(parent);
|
|
25
|
+
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
26
|
+
throw new BundleIntegrityError();
|
|
27
|
+
}
|
|
28
|
+
const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
29
|
+
try {
|
|
30
|
+
const stat = await file.stat();
|
|
31
|
+
if (!stat.isFile() || stat.nlink !== 1 || stat.size !== asset.bytes)
|
|
32
|
+
throw new BundleIntegrityError();
|
|
33
|
+
const digest = createHash('sha256');
|
|
34
|
+
let count = 0;
|
|
35
|
+
for (;;) {
|
|
36
|
+
const buffer = Buffer.allocUnsafe(Math.min(64 * 1024, asset.bytes - count + 1));
|
|
37
|
+
const { bytesRead } = await file.read(buffer, 0, buffer.length, null);
|
|
38
|
+
if (!bytesRead)
|
|
39
|
+
break;
|
|
40
|
+
count += bytesRead;
|
|
41
|
+
if (count > asset.bytes)
|
|
42
|
+
throw new BundleIntegrityError();
|
|
43
|
+
const bytes = buffer.subarray(0, bytesRead);
|
|
44
|
+
digest.update(bytes);
|
|
45
|
+
yield bytes;
|
|
46
|
+
}
|
|
47
|
+
if (count !== asset.bytes || digest.digest('hex') !== asset.sha256)
|
|
48
|
+
throw new BundleIntegrityError();
|
|
49
|
+
}
|
|
50
|
+
finally {
|
|
51
|
+
await file.close();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
export async function bundleText(root, asset) {
|
|
55
|
+
if (asset.mediaType !== 'text/markdown' || asset.bytes > limits.markdownBytes)
|
|
56
|
+
throw new BundleIntegrityError();
|
|
57
|
+
const chunks = [];
|
|
58
|
+
for await (const chunk of bundleBytes(root, asset))
|
|
59
|
+
chunks.push(chunk);
|
|
60
|
+
try {
|
|
61
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks));
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
throw new BundleIntegrityError();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { Asset } from '../../contracts/index.js';
|
|
2
|
+
/** Preserve original formatting/frontmatter; edit parsed destination spans, never regex Markdown. */
|
|
3
|
+
export declare function localizeMarkdown(markdown: string, assets: Asset[]): {
|
|
4
|
+
markdown: string;
|
|
5
|
+
missingImages: boolean;
|
|
6
|
+
};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { fromMarkdown } from 'mdast-util-from-markdown';
|
|
2
|
+
import { toMarkdown } from 'mdast-util-to-markdown';
|
|
3
|
+
const assetPrefix = 'sponge-asset:';
|
|
4
|
+
function localKey(url) {
|
|
5
|
+
try {
|
|
6
|
+
const key = decodeURIComponent(url).replace(/^\.\//u, '');
|
|
7
|
+
return key.split('/').some(p => p === '..' || p === '') || key.includes('\\') || key.includes(':') ? null : key;
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
function text(value) { return toMarkdown({ type: 'text', value }).trimEnd(); }
|
|
14
|
+
/** Preserve original formatting/frontmatter; edit parsed destination spans, never regex Markdown. */
|
|
15
|
+
export function localizeMarkdown(markdown, assets) {
|
|
16
|
+
const frontmatter = /^---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/u.exec(markdown)?.[0];
|
|
17
|
+
if (frontmatter) {
|
|
18
|
+
const body = localizeMarkdown(markdown.slice(frontmatter.length), assets);
|
|
19
|
+
return { ...body, markdown: frontmatter + body.markdown };
|
|
20
|
+
}
|
|
21
|
+
const tree = fromMarkdown(markdown), byPath = new Map(assets.map(a => [a.relativePath, a.sha256]));
|
|
22
|
+
const definitions = new Map();
|
|
23
|
+
const patches = [];
|
|
24
|
+
let missingImages = false;
|
|
25
|
+
function collect(node) {
|
|
26
|
+
if (node.type === 'definition' && !definitions.has(node.identifier))
|
|
27
|
+
definitions.set(node.identifier, node);
|
|
28
|
+
if ('children' in node)
|
|
29
|
+
for (const child of node.children)
|
|
30
|
+
collect(child);
|
|
31
|
+
}
|
|
32
|
+
collect(tree);
|
|
33
|
+
function replace(node, value) {
|
|
34
|
+
const start = node.position?.start.offset, end = node.position?.end.offset;
|
|
35
|
+
if (start === undefined || end === undefined)
|
|
36
|
+
throw Error('integrity');
|
|
37
|
+
patches.push({ start, end, value });
|
|
38
|
+
}
|
|
39
|
+
function walk(node) {
|
|
40
|
+
if (node.type === 'image' || node.type === 'imageReference') {
|
|
41
|
+
const url = node.type === 'image' ? node.url : definitions.get(node.identifier)?.url;
|
|
42
|
+
const key = url ? localKey(url) : null, sha = key ? byPath.get(key) : undefined;
|
|
43
|
+
if (sha) {
|
|
44
|
+
const image = { type: 'image', url: assetPrefix + sha, alt: node.alt ?? '', title: node.type === 'image' ? node.title : definitions.get(node.identifier)?.title };
|
|
45
|
+
replace(node, toMarkdown(image).trimEnd());
|
|
46
|
+
Object.assign(node, image);
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
missingImages = true;
|
|
50
|
+
const value = node.alt ? `[Image unavailable: ${node.alt}]` : '[Image unavailable]';
|
|
51
|
+
replace(node, text(value));
|
|
52
|
+
Object.assign(node, { type: 'text', value });
|
|
53
|
+
}
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (node.type === 'html') {
|
|
57
|
+
replace(node, node.value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>'));
|
|
58
|
+
Object.assign(node, { type: 'text' });
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (node.type === 'link' || node.type === 'definition') {
|
|
62
|
+
const key = localKey(node.url), sha = key ? byPath.get(key) : undefined;
|
|
63
|
+
if (sha) {
|
|
64
|
+
const start = patches.length;
|
|
65
|
+
if ('children' in node)
|
|
66
|
+
for (const child of node.children)
|
|
67
|
+
walk(child);
|
|
68
|
+
patches.splice(start);
|
|
69
|
+
replace(node, toMarkdown({ ...node, url: assetPrefix + sha }).trimEnd());
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (!/^(?:https?:|mailto:|#)/iu.test(node.url)) {
|
|
73
|
+
if (node.type === 'definition')
|
|
74
|
+
replace(node, '');
|
|
75
|
+
else {
|
|
76
|
+
const plain = (n) => 'value' in n ? String(n.value) : 'children' in n ? n.children.map(c => plain(c)).join('') : '';
|
|
77
|
+
replace(node, text(plain(node)));
|
|
78
|
+
}
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if ('children' in node)
|
|
83
|
+
for (const child of node.children)
|
|
84
|
+
walk(child);
|
|
85
|
+
}
|
|
86
|
+
walk(tree);
|
|
87
|
+
for (const patch of patches.sort((a, b) => b.start - a.start))
|
|
88
|
+
markdown = markdown.slice(0, patch.start) + patch.value + markdown.slice(patch.end);
|
|
89
|
+
return { markdown, missingImages };
|
|
90
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export declare class InputError extends Error {
|
|
2
|
+
readonly code = "invalid-input";
|
|
3
|
+
constructor();
|
|
4
|
+
}
|
|
5
|
+
export declare function articleSource(locator: string, realm?: string): {
|
|
6
|
+
sourceKey: string;
|
|
7
|
+
locator: string;
|
|
8
|
+
realm: string;
|
|
9
|
+
};
|
|
10
|
+
/** A page and its media can be saved independently without changing existing article identities. */
|
|
11
|
+
export declare function mediaSource(locator: string, realm?: string): {
|
|
12
|
+
sourceKey: string;
|
|
13
|
+
locator: string;
|
|
14
|
+
realm: string;
|
|
15
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
export class InputError extends Error {
|
|
3
|
+
code = 'invalid-input';
|
|
4
|
+
constructor() { super('invalid-input'); }
|
|
5
|
+
}
|
|
6
|
+
/** Identity normalization removes only known campaign fields; private realms remain distinct. */
|
|
7
|
+
function webSource(locator, realm, kind) {
|
|
8
|
+
if (Buffer.byteLength(locator) > 8192 || !realm || Buffer.byteLength(realm) > 256 || /[\u0000-\u001f\u007f]/u.test(locator + realm))
|
|
9
|
+
throw new InputError();
|
|
10
|
+
let url;
|
|
11
|
+
try {
|
|
12
|
+
url = new URL(locator);
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
throw new InputError();
|
|
16
|
+
}
|
|
17
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || !url.hostname)
|
|
18
|
+
throw new InputError();
|
|
19
|
+
// Filter raw fields: URLSearchParams mutation rewrites unknown signed query bytes.
|
|
20
|
+
if (url.search) {
|
|
21
|
+
const fields = url.search.slice(1).split('&').filter(field => {
|
|
22
|
+
let key;
|
|
23
|
+
try {
|
|
24
|
+
key = decodeURIComponent(field.split('=', 1)[0].replaceAll('+', ' '));
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
return !['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'utm_id'].includes(key.toLowerCase());
|
|
30
|
+
});
|
|
31
|
+
url.search = fields.length ? '?' + fields.join('&') : '';
|
|
32
|
+
}
|
|
33
|
+
// URL normalizes host casing/default ports. Preserve query order, unknown fields and fragments.
|
|
34
|
+
const normalized = url.href;
|
|
35
|
+
const sourceKey = createHash('sha256').update(JSON.stringify([kind, realm, normalized])).digest('hex');
|
|
36
|
+
return { sourceKey, locator, realm };
|
|
37
|
+
}
|
|
38
|
+
export function articleSource(locator, realm = 'public') { return webSource(locator, realm, 'article'); }
|
|
39
|
+
/** A page and its media can be saved independently without changing existing article identities. */
|
|
40
|
+
export function mediaSource(locator, realm = 'public') { return webSource(locator, realm, 'media'); }
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface Arguments {
|
|
2
|
+
command: string;
|
|
3
|
+
positional: string[];
|
|
4
|
+
flags: Record<string, string | true>;
|
|
5
|
+
library?: string;
|
|
6
|
+
json: boolean;
|
|
7
|
+
}
|
|
8
|
+
/** No shell grammar, implicit option coercion, duplicate flags, or unbounded argv. */
|
|
9
|
+
export declare function parseArguments(argv: string[]): Arguments;
|