@loreai/gateway 0.13.3 → 0.14.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/dist/index.js +49694 -3155
- package/package.json +14 -6
- package/src/batch-queue.ts +21 -1
- package/src/cache-analytics.ts +344 -0
- package/src/cli/agents.ts +107 -0
- package/src/cli/bin.ts +11 -0
- package/src/cli/help.ts +55 -0
- package/src/cli/lib/binary.ts +353 -0
- package/src/cli/lib/bspatch.ts +306 -0
- package/src/cli/lib/delta-upgrade.ts +790 -0
- package/src/cli/lib/errors.ts +48 -0
- package/src/cli/lib/ghcr.ts +389 -0
- package/src/cli/lib/patch-cache.ts +342 -0
- package/src/cli/lib/upgrade.ts +454 -0
- package/src/cli/lib/version-check.ts +385 -0
- package/src/cli/main.ts +152 -0
- package/src/cli/run.ts +181 -0
- package/src/cli/start.ts +82 -0
- package/src/cli/upgrade.ts +311 -0
- package/src/cli/version.ts +22 -0
- package/src/idle.ts +0 -6
- package/src/index.ts +27 -27
- package/src/llm-adapter.ts +100 -28
- package/src/pipeline.ts +254 -177
- package/src/recall.ts +223 -91
- package/src/temporal-adapter.ts +3 -0
- package/src/translate/anthropic.ts +50 -6
- package/src/translate/types.ts +54 -9
- package/dist/index.js.map +0 -7
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Binary Management
|
|
3
|
+
*
|
|
4
|
+
* Shared utilities for installing, replacing, and managing the CLI binary.
|
|
5
|
+
* Adapted from Sentry CLI's binary.ts for the Lore upgrade system.
|
|
6
|
+
*
|
|
7
|
+
* Key differences from Sentry CLI:
|
|
8
|
+
* - No musl detection (Lore doesn't target Alpine)
|
|
9
|
+
* - Lore binary naming: `lore-{os}-{arch}[.exe]`
|
|
10
|
+
* - Install dirs: ~/.lore/bin, ~/.local/bin
|
|
11
|
+
* - No Sentry SDK telemetry
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
existsSync,
|
|
16
|
+
readFileSync,
|
|
17
|
+
renameSync,
|
|
18
|
+
unlinkSync,
|
|
19
|
+
writeFileSync,
|
|
20
|
+
} from "node:fs";
|
|
21
|
+
import { chmod, mkdir, unlink } from "node:fs/promises";
|
|
22
|
+
import { delimiter, join, resolve } from "node:path";
|
|
23
|
+
import { VERSION } from "../version";
|
|
24
|
+
import { stringifyUnknown, UpgradeError } from "./errors";
|
|
25
|
+
|
|
26
|
+
/** GitHub owner/repo for Lore releases */
|
|
27
|
+
const GITHUB_OWNER = "BYK";
|
|
28
|
+
const GITHUB_REPO = "loreai";
|
|
29
|
+
|
|
30
|
+
/** GitHub API base URL for releases */
|
|
31
|
+
export const GITHUB_RELEASES_URL =
|
|
32
|
+
`https://api.github.com/repos/${GITHUB_OWNER}/${GITHUB_REPO}/releases`;
|
|
33
|
+
|
|
34
|
+
/** Known directories where the curl installer may place the binary */
|
|
35
|
+
export const KNOWN_CURL_DIRS = [".local/bin", "bin", ".lore/bin"];
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Build the platform-specific binary base name.
|
|
39
|
+
*
|
|
40
|
+
* Matches the naming convention used by GitHub Releases and GHCR:
|
|
41
|
+
* `lore-{os}-{arch}[.exe]`
|
|
42
|
+
*/
|
|
43
|
+
export function getPlatformBinaryName(): string {
|
|
44
|
+
let os: string;
|
|
45
|
+
if (process.platform === "darwin") {
|
|
46
|
+
os = "darwin";
|
|
47
|
+
} else if (process.platform === "win32") {
|
|
48
|
+
os = "windows";
|
|
49
|
+
} else {
|
|
50
|
+
os = "linux";
|
|
51
|
+
}
|
|
52
|
+
const arch = process.arch === "arm64" ? "arm64" : "x64";
|
|
53
|
+
const suffix = process.platform === "win32" ? ".exe" : "";
|
|
54
|
+
return `lore-${os}-${arch}${suffix}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Build the download URL for a platform-specific binary from GitHub releases.
|
|
59
|
+
*/
|
|
60
|
+
export function getBinaryDownloadUrl(version: string): string {
|
|
61
|
+
return `https://github.com/${GITHUB_OWNER}/${GITHUB_REPO}/releases/download/${version}/${getPlatformBinaryName()}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Detect whether a version string identifies a nightly build.
|
|
66
|
+
*
|
|
67
|
+
* Nightlies use the format `X.Y.Z-dev.<unix-seconds>`.
|
|
68
|
+
*/
|
|
69
|
+
export function isNightlyVersion(version: string): boolean {
|
|
70
|
+
return version.includes("-dev.");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Compare two version strings and return their ordering.
|
|
75
|
+
*
|
|
76
|
+
* Uses `Bun.semver.order` which handles both stable (`X.Y.Z`) and
|
|
77
|
+
* nightly (`X.Y.Z-dev.<unix-seconds>`) versions correctly.
|
|
78
|
+
*/
|
|
79
|
+
export function compareVersions(a: string, b: string): -1 | 0 | 1 {
|
|
80
|
+
return Bun.semver.order(a, b);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Check whether moving from `current` to `target` is a downgrade.
|
|
85
|
+
*/
|
|
86
|
+
export function isDowngrade(current: string, target: string): boolean {
|
|
87
|
+
return compareVersions(current, target) === 1;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Get the binary filename for the current platform.
|
|
92
|
+
*/
|
|
93
|
+
export function getBinaryFilename(): string {
|
|
94
|
+
return process.platform === "win32" ? "lore.exe" : "lore";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Build paths object from an install path.
|
|
99
|
+
*/
|
|
100
|
+
export function getBinaryPaths(installPath: string): {
|
|
101
|
+
installPath: string;
|
|
102
|
+
tempPath: string;
|
|
103
|
+
oldPath: string;
|
|
104
|
+
lockPath: string;
|
|
105
|
+
} {
|
|
106
|
+
return {
|
|
107
|
+
installPath,
|
|
108
|
+
tempPath: `${installPath}.download`,
|
|
109
|
+
oldPath: `${installPath}.old`,
|
|
110
|
+
lockPath: `${installPath}.lock`,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Determine the install directory for a curl-installed binary.
|
|
116
|
+
*
|
|
117
|
+
* Priority:
|
|
118
|
+
* 1. $LORE_INSTALL_DIR environment variable
|
|
119
|
+
* 2. ~/.local/bin (if exists AND in $PATH)
|
|
120
|
+
* 3. ~/bin (if exists AND in $PATH)
|
|
121
|
+
* 4. ~/.lore/bin (fallback)
|
|
122
|
+
*/
|
|
123
|
+
export function determineInstallDir(
|
|
124
|
+
homeDir: string,
|
|
125
|
+
env: NodeJS.ProcessEnv,
|
|
126
|
+
): string {
|
|
127
|
+
const pathDirs = (env.PATH ?? "").split(delimiter);
|
|
128
|
+
|
|
129
|
+
if (env.LORE_INSTALL_DIR) {
|
|
130
|
+
return env.LORE_INSTALL_DIR;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const candidates = [join(homeDir, ".local", "bin"), join(homeDir, "bin")];
|
|
134
|
+
|
|
135
|
+
for (const dir of candidates) {
|
|
136
|
+
if (existsSync(dir) && pathDirs.includes(dir)) {
|
|
137
|
+
return dir;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return join(homeDir, ".lore", "bin");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Build headers for GitHub API requests.
|
|
146
|
+
*/
|
|
147
|
+
export function getGitHubHeaders(): Record<string, string> {
|
|
148
|
+
return {
|
|
149
|
+
Accept: "application/vnd.github.v3+json",
|
|
150
|
+
"User-Agent": getUserAgent(),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Generate the User-Agent string for API requests.
|
|
156
|
+
*/
|
|
157
|
+
export function getUserAgent(): string {
|
|
158
|
+
const runtime =
|
|
159
|
+
typeof process.versions.bun !== "undefined"
|
|
160
|
+
? `bun/${process.versions.bun}`
|
|
161
|
+
: `node/${process.versions.node}`;
|
|
162
|
+
return `lore/${VERSION} (${process.platform}-${process.arch}) ${runtime}`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Fetch wrapper that converts network errors to UpgradeError.
|
|
167
|
+
*/
|
|
168
|
+
export async function fetchWithUpgradeError(
|
|
169
|
+
url: string,
|
|
170
|
+
init: RequestInit,
|
|
171
|
+
serviceName: string,
|
|
172
|
+
): Promise<Response> {
|
|
173
|
+
try {
|
|
174
|
+
return await fetch(url, init);
|
|
175
|
+
} catch (error) {
|
|
176
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
177
|
+
throw error;
|
|
178
|
+
}
|
|
179
|
+
const msg = stringifyUnknown(error);
|
|
180
|
+
throw new UpgradeError(
|
|
181
|
+
"network_error",
|
|
182
|
+
`Failed to connect to ${serviceName}: ${msg}`,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Replace the binary at the install path, handling platform differences.
|
|
189
|
+
*
|
|
190
|
+
* Intentionally synchronous: the multi-step rename sequence must be
|
|
191
|
+
* uninterruptible to avoid leaving the install path in a broken state.
|
|
192
|
+
*
|
|
193
|
+
* - Unix: Atomic rename overwrites the target
|
|
194
|
+
* - Windows: Rename old binary to .old first, then rename temp into place
|
|
195
|
+
*/
|
|
196
|
+
export function replaceBinarySync(tempPath: string, installPath: string): void {
|
|
197
|
+
if (process.platform === "win32") {
|
|
198
|
+
const oldPath = `${installPath}.old`;
|
|
199
|
+
try {
|
|
200
|
+
renameSync(installPath, oldPath);
|
|
201
|
+
} catch {
|
|
202
|
+
try {
|
|
203
|
+
unlinkSync(oldPath);
|
|
204
|
+
renameSync(installPath, oldPath);
|
|
205
|
+
} catch {
|
|
206
|
+
// Current binary might not exist — that's fine
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
renameSync(tempPath, installPath);
|
|
210
|
+
} else {
|
|
211
|
+
renameSync(tempPath, installPath);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Clean up leftover .old files from previous upgrades.
|
|
217
|
+
* Called on CLI startup. Fire-and-forget.
|
|
218
|
+
*/
|
|
219
|
+
export function cleanupOldBinary(oldPath: string): void {
|
|
220
|
+
unlink(oldPath).catch(() => {
|
|
221
|
+
// Intentionally ignore — file may not exist
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Lock Management
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Check if a process with the given PID is still running.
|
|
229
|
+
*/
|
|
230
|
+
export function isProcessRunning(pid: number): boolean {
|
|
231
|
+
try {
|
|
232
|
+
process.kill(pid, 0);
|
|
233
|
+
return true;
|
|
234
|
+
} catch (error) {
|
|
235
|
+
if ((error as NodeJS.ErrnoException).code === "EPERM") {
|
|
236
|
+
return true;
|
|
237
|
+
}
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Acquire an exclusive lock for binary installation/upgrade.
|
|
244
|
+
* Uses atomic file creation with 'wx' flag to prevent race conditions.
|
|
245
|
+
*/
|
|
246
|
+
export function acquireLock(lockPath: string): void {
|
|
247
|
+
try {
|
|
248
|
+
writeFileSync(lockPath, String(process.pid), { flag: "wx" });
|
|
249
|
+
} catch (error) {
|
|
250
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
|
|
251
|
+
throw error;
|
|
252
|
+
}
|
|
253
|
+
handleExistingLock(lockPath);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function handleExistingLock(lockPath: string): void {
|
|
258
|
+
let content: string;
|
|
259
|
+
try {
|
|
260
|
+
content = readFileSync(lockPath, "utf-8").trim();
|
|
261
|
+
} catch (error) {
|
|
262
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
263
|
+
acquireLock(lockPath);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
throw error;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const existingPid = Number.parseInt(content, 10);
|
|
270
|
+
|
|
271
|
+
if (!Number.isNaN(existingPid) && isProcessRunning(existingPid)) {
|
|
272
|
+
if (existingPid === process.ppid) {
|
|
273
|
+
writeFileSync(lockPath, String(process.pid));
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
throw new UpgradeError(
|
|
277
|
+
"execution_failed",
|
|
278
|
+
"Another upgrade is already in progress",
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Stale lock from dead process — remove and retry
|
|
283
|
+
try {
|
|
284
|
+
unlinkSync(lockPath);
|
|
285
|
+
} catch (error) {
|
|
286
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
287
|
+
throw error;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
acquireLock(lockPath);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Release the binary lock.
|
|
296
|
+
*/
|
|
297
|
+
export function releaseLock(lockPath: string): void {
|
|
298
|
+
try {
|
|
299
|
+
unlinkSync(lockPath);
|
|
300
|
+
} catch {
|
|
301
|
+
// Ignore — file might already be gone
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Install a binary to the target directory.
|
|
307
|
+
*/
|
|
308
|
+
export async function installBinary(
|
|
309
|
+
sourcePath: string,
|
|
310
|
+
installDir: string,
|
|
311
|
+
): Promise<string> {
|
|
312
|
+
await mkdir(installDir, { recursive: true, mode: 0o755 });
|
|
313
|
+
|
|
314
|
+
const installPath = join(installDir, getBinaryFilename());
|
|
315
|
+
const { tempPath, lockPath } = getBinaryPaths(installPath);
|
|
316
|
+
|
|
317
|
+
acquireLock(lockPath);
|
|
318
|
+
|
|
319
|
+
try {
|
|
320
|
+
if (resolve(sourcePath) !== resolve(tempPath)) {
|
|
321
|
+
try {
|
|
322
|
+
await unlink(tempPath);
|
|
323
|
+
} catch {
|
|
324
|
+
// Ignore if doesn't exist
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
await Bun.write(tempPath, Bun.file(sourcePath));
|
|
328
|
+
|
|
329
|
+
if (process.platform !== "win32") {
|
|
330
|
+
await chmod(tempPath, 0o755);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
replaceBinarySync(tempPath, installPath);
|
|
335
|
+
} finally {
|
|
336
|
+
releaseLock(lockPath);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return installPath;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Get the Lore config directory.
|
|
344
|
+
* Uses $LORE_CONFIG_DIR if set, otherwise ~/.lore
|
|
345
|
+
*/
|
|
346
|
+
export function getConfigDir(): string {
|
|
347
|
+
if (process.env.LORE_CONFIG_DIR) {
|
|
348
|
+
return process.env.LORE_CONFIG_DIR;
|
|
349
|
+
}
|
|
350
|
+
const home =
|
|
351
|
+
process.env.HOME ?? process.env.USERPROFILE ?? require("node:os").homedir();
|
|
352
|
+
return join(home, ".lore");
|
|
353
|
+
}
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Streaming TRDIFF10 Binary Patch Application
|
|
3
|
+
*
|
|
4
|
+
* Implements the bspatch algorithm for applying binary delta patches in the
|
|
5
|
+
* TRDIFF10 format (produced by zig-bsdiff with `--use-zstd`). Designed for
|
|
6
|
+
* minimal memory usage during CLI self-upgrades:
|
|
7
|
+
*
|
|
8
|
+
* - Old binary: copy-then-mmap for 0 JS heap (CoW on btrfs/xfs/APFS),
|
|
9
|
+
* falling back to `arrayBuffer()` if copy/mmap fails
|
|
10
|
+
* - Diff/extra blocks: streamed via `DecompressionStream('zstd')`
|
|
11
|
+
* - Output: written incrementally to disk via `Bun.file().writer()`
|
|
12
|
+
* - Integrity: SHA-256 computed inline via `Bun.CryptoHasher`
|
|
13
|
+
*
|
|
14
|
+
* TRDIFF10 format (from zig-bsdiff):
|
|
15
|
+
* ```
|
|
16
|
+
* [0..8] magic: "TRDIFF10"
|
|
17
|
+
* [8..16] controlLen: i64 LE (compressed size of control block)
|
|
18
|
+
* [16..24] diffLen: i64 LE (compressed size of diff block)
|
|
19
|
+
* [24..32] newSize: i64 LE (expected output size)
|
|
20
|
+
* [32..] zstd(control) | zstd(diff) | zstd(extra)
|
|
21
|
+
* ```
|
|
22
|
+
*
|
|
23
|
+
* Adapted from Sentry CLI's bspatch.ts — pure Bun, no external dependencies.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { constants, copyFileSync } from "node:fs";
|
|
27
|
+
import { unlink } from "node:fs/promises";
|
|
28
|
+
import { tmpdir } from "node:os";
|
|
29
|
+
import { join } from "node:path";
|
|
30
|
+
|
|
31
|
+
/** TRDIFF10 header magic bytes */
|
|
32
|
+
const TRDIFF10_MAGIC = "TRDIFF10";
|
|
33
|
+
|
|
34
|
+
/** Header size in bytes (magic + 3 x i64) */
|
|
35
|
+
const HEADER_SIZE = 32;
|
|
36
|
+
|
|
37
|
+
/** Parsed TRDIFF10 header fields */
|
|
38
|
+
export type PatchHeader = {
|
|
39
|
+
controlLen: number;
|
|
40
|
+
diffLen: number;
|
|
41
|
+
newSize: number;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Read a signed 64-bit little-endian integer using the zig-bsdiff encoding.
|
|
46
|
+
*
|
|
47
|
+
* The sign is stored in bit 7 of byte 7 (the MSB of the last byte).
|
|
48
|
+
* The magnitude is in the lower 63 bits, read as unsigned LE.
|
|
49
|
+
* This differs from standard two's complement — it uses sign-magnitude.
|
|
50
|
+
*/
|
|
51
|
+
export function offtin(buf: Uint8Array, offset: number): number {
|
|
52
|
+
const view = new DataView(buf.buffer, buf.byteOffset + offset, 8);
|
|
53
|
+
const lo = view.getUint32(0, true);
|
|
54
|
+
const hi = view.getUint32(4, true);
|
|
55
|
+
|
|
56
|
+
const magnitude = (hi % 0x80_00_00_00) * 0x1_00_00_00_00 + lo;
|
|
57
|
+
|
|
58
|
+
if (magnitude !== 0 && hi >= 0x80_00_00_00) {
|
|
59
|
+
return -magnitude;
|
|
60
|
+
}
|
|
61
|
+
return magnitude;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Parse and validate a TRDIFF10 patch header.
|
|
66
|
+
*/
|
|
67
|
+
export function parsePatchHeader(patch: Uint8Array): PatchHeader {
|
|
68
|
+
if (patch.byteLength < HEADER_SIZE) {
|
|
69
|
+
throw new Error(
|
|
70
|
+
`Patch too small: ${patch.byteLength} bytes (need at least ${HEADER_SIZE})`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const magic = new TextDecoder().decode(patch.subarray(0, 8));
|
|
75
|
+
if (magic !== TRDIFF10_MAGIC) {
|
|
76
|
+
throw new Error(`Invalid patch format: expected TRDIFF10, got "${magic}"`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const controlLen = offtin(patch, 8);
|
|
80
|
+
const diffLen = offtin(patch, 16);
|
|
81
|
+
const newSize = offtin(patch, 24);
|
|
82
|
+
|
|
83
|
+
if (controlLen < 0 || diffLen < 0 || newSize < 0) {
|
|
84
|
+
throw new Error("Corrupt patch: negative length in header");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const totalCompressed = HEADER_SIZE + controlLen + diffLen;
|
|
88
|
+
if (totalCompressed > patch.byteLength) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
`Corrupt patch: header lengths (${totalCompressed}) exceed file size (${patch.byteLength})`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return { controlLen, diffLen, newSize };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Buffered reader over a `ReadableStream` that serves exact byte counts.
|
|
99
|
+
*/
|
|
100
|
+
class BufferedStreamReader {
|
|
101
|
+
private readonly chunks: Uint8Array[] = [];
|
|
102
|
+
private buffered = 0;
|
|
103
|
+
private done = false;
|
|
104
|
+
private readonly reader: ReadableStreamDefaultReader<Uint8Array>;
|
|
105
|
+
|
|
106
|
+
constructor(reader: ReadableStreamDefaultReader<Uint8Array>) {
|
|
107
|
+
this.reader = reader;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async read(n: number): Promise<Uint8Array> {
|
|
111
|
+
while (this.buffered < n && !this.done) {
|
|
112
|
+
const result = await this.reader.read();
|
|
113
|
+
if (result.done) {
|
|
114
|
+
this.done = true;
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
this.chunks.push(result.value);
|
|
118
|
+
this.buffered += result.value.byteLength;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (this.buffered < n) {
|
|
122
|
+
throw new Error(
|
|
123
|
+
`Unexpected end of stream: needed ${n} bytes, have ${this.buffered}`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const output = new Uint8Array(n);
|
|
128
|
+
let written = 0;
|
|
129
|
+
|
|
130
|
+
while (written < n) {
|
|
131
|
+
const front = this.chunks[0];
|
|
132
|
+
if (!front) break;
|
|
133
|
+
const needed = n - written;
|
|
134
|
+
|
|
135
|
+
if (front.byteLength <= needed) {
|
|
136
|
+
output.set(front, written);
|
|
137
|
+
written += front.byteLength;
|
|
138
|
+
this.buffered -= front.byteLength;
|
|
139
|
+
this.chunks.shift();
|
|
140
|
+
} else {
|
|
141
|
+
output.set(front.subarray(0, needed), written);
|
|
142
|
+
this.chunks[0] = front.subarray(needed);
|
|
143
|
+
this.buffered -= needed;
|
|
144
|
+
written = n;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return output;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Create a streaming zstd decompressor from a compressed buffer.
|
|
154
|
+
*/
|
|
155
|
+
function createZstdStreamReader(compressed: Uint8Array): BufferedStreamReader {
|
|
156
|
+
const input = new ReadableStream<Uint8Array>({
|
|
157
|
+
start(controller) {
|
|
158
|
+
controller.enqueue(compressed);
|
|
159
|
+
controller.close();
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// Bun supports 'zstd' but the standard CompressionFormat type doesn't include it.
|
|
164
|
+
// The double cast works around TypeScript's strict WritableStream<BufferSource>
|
|
165
|
+
// vs WritableStream<Uint8Array> mismatch in DecompressionStream.
|
|
166
|
+
const decompressed = input.pipeThrough(
|
|
167
|
+
new DecompressionStream("zstd" as "deflate") as unknown as TransformStream<Uint8Array, Uint8Array>,
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
return new BufferedStreamReader(
|
|
171
|
+
decompressed.getReader() as ReadableStreamDefaultReader<Uint8Array>,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
type OldFileHandle = {
|
|
176
|
+
data: Uint8Array;
|
|
177
|
+
cleanup: () => void | Promise<void>;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Load the old binary for read access during patching.
|
|
182
|
+
*
|
|
183
|
+
* Strategy: copy to temp file, then try mmap on the copy. Falls back to
|
|
184
|
+
* arrayBuffer() if copy or mmap fails.
|
|
185
|
+
*/
|
|
186
|
+
let loadCounter = 0;
|
|
187
|
+
|
|
188
|
+
async function loadOldBinary(oldPath: string): Promise<OldFileHandle> {
|
|
189
|
+
loadCounter += 1;
|
|
190
|
+
const tempCopy = join(
|
|
191
|
+
tmpdir(),
|
|
192
|
+
`lore-patch-old-${process.pid}-${loadCounter}`,
|
|
193
|
+
);
|
|
194
|
+
try {
|
|
195
|
+
copyFileSync(oldPath, tempCopy, constants.COPYFILE_FICLONE);
|
|
196
|
+
const data = Bun.mmap(tempCopy, { shared: false });
|
|
197
|
+
return {
|
|
198
|
+
data,
|
|
199
|
+
cleanup: () =>
|
|
200
|
+
unlink(tempCopy).catch(() => {
|
|
201
|
+
/* Best-effort */
|
|
202
|
+
}),
|
|
203
|
+
};
|
|
204
|
+
} catch {
|
|
205
|
+
await unlink(tempCopy).catch(() => {
|
|
206
|
+
/* May not exist */
|
|
207
|
+
});
|
|
208
|
+
return {
|
|
209
|
+
data: new Uint8Array(await Bun.file(oldPath).arrayBuffer()),
|
|
210
|
+
cleanup: () => {},
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Apply a TRDIFF10 binary patch with streaming I/O for minimal memory usage.
|
|
217
|
+
*
|
|
218
|
+
* @param oldPath - Path to the existing (old) binary file
|
|
219
|
+
* @param patchData - Complete TRDIFF10 patch file contents
|
|
220
|
+
* @param destPath - Path to write the patched (new) binary
|
|
221
|
+
* @returns SHA-256 hex digest of the written output
|
|
222
|
+
*/
|
|
223
|
+
export async function applyPatch(
|
|
224
|
+
oldPath: string,
|
|
225
|
+
patchData: Uint8Array,
|
|
226
|
+
destPath: string,
|
|
227
|
+
): Promise<string> {
|
|
228
|
+
const { controlLen, diffLen, newSize } = parsePatchHeader(patchData);
|
|
229
|
+
|
|
230
|
+
const controlStart = HEADER_SIZE;
|
|
231
|
+
const diffStart = controlStart + controlLen;
|
|
232
|
+
const extraStart = diffStart + diffLen;
|
|
233
|
+
|
|
234
|
+
// Control block is tiny — decompress fully for random access
|
|
235
|
+
const controlBlock = Bun.zstdDecompressSync(
|
|
236
|
+
patchData.subarray(controlStart, diffStart),
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
// Diff and extra blocks are streamed
|
|
240
|
+
const diffReader = createZstdStreamReader(
|
|
241
|
+
patchData.subarray(diffStart, extraStart),
|
|
242
|
+
);
|
|
243
|
+
const extraReader = createZstdStreamReader(patchData.subarray(extraStart));
|
|
244
|
+
|
|
245
|
+
const { data: oldFile, cleanup: cleanupOldFile } =
|
|
246
|
+
await loadOldBinary(oldPath);
|
|
247
|
+
|
|
248
|
+
const writer = Bun.file(destPath).writer();
|
|
249
|
+
const hasher = new Bun.CryptoHasher("sha256");
|
|
250
|
+
|
|
251
|
+
let oldpos = 0;
|
|
252
|
+
let newpos = 0;
|
|
253
|
+
|
|
254
|
+
try {
|
|
255
|
+
for (
|
|
256
|
+
let controlPos = 0;
|
|
257
|
+
controlPos < controlBlock.byteLength;
|
|
258
|
+
controlPos += 24
|
|
259
|
+
) {
|
|
260
|
+
const readDiffBy = offtin(controlBlock, controlPos);
|
|
261
|
+
const readExtraBy = offtin(controlBlock, controlPos + 8);
|
|
262
|
+
const seekBy = offtin(controlBlock, controlPos + 16);
|
|
263
|
+
|
|
264
|
+
// Step 1: Read diff bytes and add to old file bytes (wrapping u8 add)
|
|
265
|
+
if (readDiffBy > 0) {
|
|
266
|
+
const diffChunk = await diffReader.read(readDiffBy);
|
|
267
|
+
const outputChunk = new Uint8Array(readDiffBy);
|
|
268
|
+
|
|
269
|
+
for (let i = 0; i < readDiffBy; i++) {
|
|
270
|
+
outputChunk[i] =
|
|
271
|
+
((oldFile[oldpos + i] ?? 0) + (diffChunk[i] ?? 0)) % 256;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
writer.write(outputChunk);
|
|
275
|
+
hasher.update(outputChunk);
|
|
276
|
+
oldpos += readDiffBy;
|
|
277
|
+
newpos += readDiffBy;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Step 2: Copy extra bytes directly to output
|
|
281
|
+
if (readExtraBy > 0) {
|
|
282
|
+
const extraChunk = await extraReader.read(readExtraBy);
|
|
283
|
+
writer.write(extraChunk);
|
|
284
|
+
hasher.update(extraChunk);
|
|
285
|
+
newpos += readExtraBy;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Step 3: Seek old file position
|
|
289
|
+
oldpos += seekBy;
|
|
290
|
+
}
|
|
291
|
+
} finally {
|
|
292
|
+
try {
|
|
293
|
+
await writer.end();
|
|
294
|
+
} finally {
|
|
295
|
+
await cleanupOldFile();
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (newpos !== newSize) {
|
|
300
|
+
throw new Error(
|
|
301
|
+
`Output size mismatch: wrote ${newpos} bytes, expected ${newSize}`,
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return hasher.digest("hex") as string;
|
|
306
|
+
}
|