@torrent-tv/proxy 2.83.7 → 2.83.8
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/CHANGELOG.md +6 -0
- package/bin/cli.js +1 -0
- package/package.json +1 -1
- package/routes/api/client-logs/post.js +86 -0
- package/server.js +13 -1
- package/test/client-log-file.test.js +110 -0
- package/utils/client-log-file.js +151 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
## 2.83.8
|
|
2
|
+
|
|
3
|
+
- **New**: THE BROWSER'S OWN LOG IS KEPT BESIDE THE PROXY'S, ON A DISK THAT SURVIVES. Explaining any failure here needs both halves — what the proxy did and what the page saw — and the second half went to the registry server's standard output on the droplet, which every release of it destroys. On 2026-09-13 a viewer reported a desync and a frozen picture and the analysis ran on half the evidence for exactly that reason. `POST /api/client-logs` on the proxy writes them into the same directory as its own log (`/data` on the addon host), one file per viewing session: `client-<start>-<session>-<film>-<infohash>.log`. The start time and the session id are what join the two halves without guessing, and the film says which one it is without opening the file. Seven checks over the writer, one confirmed to go red.
|
|
4
|
+
- **New**: A session that has not chosen a film yet still gets its file (`-no-torrent-yet`), because the page opening, a proxy being chosen and a connection failing all happen before any torrent exists and are worth keeping. The file is keyed by the SESSION, so the film arriving mid-session does not start a second one.
|
|
5
|
+
- **Chore**: A torrent name is arbitrary bytes chosen by a stranger and here it becomes a path, so it is reduced to letters, digits, dot, dash and underscore and truncated; a check holds that `../../etc/passwd` cannot escape the directory. Closing the sink is awaitable: a write stream flushes asynchronously, and a process that ends without waiting loses the last lines of every session — the ones a crash is explained by.
|
|
6
|
+
|
|
1
7
|
## 2.83.7
|
|
2
8
|
- **Fix**: A SUBTITLE SUBSCRIPTION BELONGS TO THE VIEWER, NOT TO THE CHANNEL. It was a `Set<DataChannel>` inside the transport, so a seamless reconnect lost subtitles for the rest of the session (recorded 2026-08-26 and never fixed), and a transport that rotates its association on purpose — which is what the delivery wedge now requires — would lose them at every rotation. It is held against the viewer now (`Viewers.wantsCues` / `wantingCues`), registered by the request that turns subtitles on, which is the only place that knows both the person and the file. Whatever channel that person is reachable on next, they are still subscribed. Eight checks over the registry, three of which go red if the file index stops being part of the key.
|
|
3
9
|
- **Chore**: The transport no longer sniffs `/api/subtitles` out of the request path, no longer derives a torrent source key, and no longer takes `sourceRegistry` at all — 41 lines of application routing and torrent identity out of the layer whose job is carrying bytes. It is given one plain function returning opaque ids (`viewersWantingCues`) and keeps only "this name is reachable on this channel", which is its own fact. Requires server 0.27.11 or newer in the page: an older page sends no `consumerId`, and then nothing subscribes.
|
package/bin/cli.js
CHANGED
|
@@ -323,6 +323,7 @@ try {
|
|
|
323
323
|
memoryBytes,
|
|
324
324
|
segmentFormat: options.segmentFormat,
|
|
325
325
|
stateDir: options.stateDir,
|
|
326
|
+
logFile: options.logFile ?? "",
|
|
326
327
|
deliverySink: options.deliverySink === true,
|
|
327
328
|
// Late-bound the same way `webRtcManager` is below: the torrent pool is
|
|
328
329
|
// built inside `startProxyServer`, before `dataChannelHandler` — the
|
package/package.json
CHANGED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Take the browser's own log and keep it beside the proxy's.
|
|
3
|
+
*
|
|
4
|
+
* The page has always forwarded its console somewhere; until now that was the
|
|
5
|
+
* SERVER's standard output on the droplet, which every release destroys. Both
|
|
6
|
+
* halves of a failure are needed to explain one — what the proxy did and what
|
|
7
|
+
* the page saw — and on 2026-09-13 an investigation ran on half of them for
|
|
8
|
+
* exactly that reason.
|
|
9
|
+
*
|
|
10
|
+
* Here the two land in one directory, on the host's durable disk, in files
|
|
11
|
+
* named so they join without guessing.
|
|
12
|
+
*
|
|
13
|
+
* The server's route stays and is not replaced: a page that has no proxy yet,
|
|
14
|
+
* or whose connection to one just failed, still has something to say, and those
|
|
15
|
+
* are the moments that matter most.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const MAX_LINES = 50;
|
|
19
|
+
const MAX_MSG_LEN = 2000;
|
|
20
|
+
const MAX_TAG_LEN = 40;
|
|
21
|
+
const MAX_SID_LEN = 16;
|
|
22
|
+
const MAX_NAME_LEN = 200;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {unknown} value
|
|
26
|
+
* @param {number} maxLen
|
|
27
|
+
* @returns {string}
|
|
28
|
+
*/
|
|
29
|
+
function safeString(value, maxLen) {
|
|
30
|
+
return (typeof value === "string" ? value : "").slice(0, maxLen);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Replace every control character with a space.
|
|
35
|
+
*
|
|
36
|
+
* These lines are written by a stranger's browser into a file a person will
|
|
37
|
+
* later read in a terminal: a newline would forge a log entry and an escape
|
|
38
|
+
* sequence would drive the terminal.
|
|
39
|
+
*
|
|
40
|
+
* @param {string} value
|
|
41
|
+
* @returns {string}
|
|
42
|
+
*/
|
|
43
|
+
function sanitizeLine(value) {
|
|
44
|
+
let out = "";
|
|
45
|
+
for (const character of value) {
|
|
46
|
+
const code = character.codePointAt(0) ?? 0;
|
|
47
|
+
out += code < 0x20 || code === 0x7f ? " " : character;
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* `POST /api/client-logs`
|
|
54
|
+
*
|
|
55
|
+
* Body: `{ sessionId, startedAt, torrentName, infoHash, tag, signalSessionId,
|
|
56
|
+
* lines: [{ level, ts, msg }] }`.
|
|
57
|
+
*
|
|
58
|
+
* @param {import("fastify").FastifyRequest} req
|
|
59
|
+
* @param {import("fastify").FastifyReply} reply
|
|
60
|
+
* @param {{ clientLogs: { write: (session: object, lines: string[]) => void } | null }} deps
|
|
61
|
+
* @returns {Promise<unknown>}
|
|
62
|
+
*/
|
|
63
|
+
export async function handleApiClientLogsPost(req, reply, { clientLogs }) {
|
|
64
|
+
const body = req.body && typeof req.body === "object" && !Array.isArray(req.body) ? req.body : {};
|
|
65
|
+
const session = {
|
|
66
|
+
sessionId: safeString(body.sessionId, MAX_SID_LEN) || "unknown",
|
|
67
|
+
startedAt: safeString(body.startedAt, 30),
|
|
68
|
+
torrentName: sanitizeLine(safeString(body.torrentName, MAX_NAME_LEN)),
|
|
69
|
+
infoHash: safeString(body.infoHash, 40)
|
|
70
|
+
};
|
|
71
|
+
const tag = sanitizeLine(safeString(body.tag, MAX_TAG_LEN)) || "Unknown/Unknown";
|
|
72
|
+
const rows = Array.isArray(body.lines) ? body.lines.slice(0, MAX_LINES) : [];
|
|
73
|
+
|
|
74
|
+
const lines = rows.map((row) => {
|
|
75
|
+
const entry = row && typeof row === "object" ? row : {};
|
|
76
|
+
const level = safeString(entry.level, 8) || "log";
|
|
77
|
+
const at = sanitizeLine(safeString(entry.ts, 16));
|
|
78
|
+
const message = sanitizeLine(safeString(entry.msg, MAX_MSG_LEN));
|
|
79
|
+
return `[${tag}] ${at} ${level}: ${message}`;
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
if (lines.length > 0) {
|
|
83
|
+
clientLogs?.write(session, lines);
|
|
84
|
+
}
|
|
85
|
+
return reply.code(204).send();
|
|
86
|
+
}
|
package/server.js
CHANGED
|
@@ -22,6 +22,8 @@ import { handleApiSourceStatsGet } from "./routes/api/sources/stats/get.js";
|
|
|
22
22
|
import { handleApiSourceFilesGet } from "./routes/api/sources/files/get.js";
|
|
23
23
|
import { handleApiSourceWarmPost } from "./routes/api/sources/warm/post.js";
|
|
24
24
|
import { handleApiPlaybackPlanPost } from "./routes/api/playback-plan/post.js";
|
|
25
|
+
import { handleApiClientLogsPost } from "./routes/api/client-logs/post.js";
|
|
26
|
+
import { createClientLogFiles } from "./utils/client-log-file.js";
|
|
25
27
|
import { handleApiSubtitlesGet } from "./routes/api/subtitles/get.js";
|
|
26
28
|
import { handleApiTranscodeSessionsPost } from "./routes/api/transcode-sessions/post.js";
|
|
27
29
|
import { handleApiTranscodeSessionsProgressGet } from "./routes/api/transcode-sessions/progress/get.js";
|
|
@@ -84,7 +86,11 @@ function buildPortCandidates(startPort, maxAttempts = 51) {
|
|
|
84
86
|
*/
|
|
85
87
|
export async function startProxyServer({
|
|
86
88
|
host, port, transcodeAudio, ffmpegBin, maxDiskBytes, memoryBytes, segmentFormat, stateDir, onSubtitleCues,
|
|
87
|
-
deliverySink = false
|
|
89
|
+
deliverySink = false,
|
|
90
|
+
// Where the proxy writes its own log. Its DIRECTORY is what matters here:
|
|
91
|
+
// the browser's half of every session is written beside it, so the two are
|
|
92
|
+
// on one durable disk and join by name.
|
|
93
|
+
logFile = ""
|
|
88
94
|
}) {
|
|
89
95
|
const app = Fastify({
|
|
90
96
|
// No practical body-size limit — the proxy server is localhost-only and
|
|
@@ -346,6 +352,12 @@ export async function startProxyServer({
|
|
|
346
352
|
app.post("/api/sources/:sourceKey/warm", async (req, reply) =>
|
|
347
353
|
handleApiSourceWarmPost(req, reply, { sourceRegistry, torrentPool })
|
|
348
354
|
);
|
|
355
|
+
// The browser's own log, kept beside the proxy's. See the route's own file
|
|
356
|
+
// for why it is here and not only on the registry server.
|
|
357
|
+
const clientLogs = logFile ? createClientLogFiles(logFile) : null;
|
|
358
|
+
app.post("/api/client-logs", async (req, reply) =>
|
|
359
|
+
handleApiClientLogsPost(req, reply, { clientLogs })
|
|
360
|
+
);
|
|
349
361
|
app.post("/api/playback-plan", async (req, reply) =>
|
|
350
362
|
handleApiPlaybackPlanPost(req, reply, { playbackPlanner })
|
|
351
363
|
);
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file The browser's log, written beside the proxy's.
|
|
3
|
+
*
|
|
4
|
+
* What these hold is the naming, because the name is the whole point: the two
|
|
5
|
+
* halves of a session have to join without guessing, and a torrent name is
|
|
6
|
+
* arbitrary bytes chosen by a stranger that is about to become a path.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import test from "node:test";
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import { mkdtempSync, readdirSync, readFileSync } from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
|
|
15
|
+
import { createClientLogFiles } from "../utils/client-log-file.js";
|
|
16
|
+
|
|
17
|
+
/** A directory with a proxy log in it, which is what the sink is given. */
|
|
18
|
+
function aDirectory() {
|
|
19
|
+
const dir = mkdtempSync(join(tmpdir(), "ttv-client-log-"));
|
|
20
|
+
return { dir, proxyLog: join(dir, "proxy.log") };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const aSession = (over = {}) => ({
|
|
24
|
+
sessionId: "abcd1234",
|
|
25
|
+
startedAt: "2026-09-13T16:05:24.123Z",
|
|
26
|
+
torrentName: "Reacher.S04E07.1080p.rus.LostFilm.TV.mkv",
|
|
27
|
+
infoHash: "94dda59fdf252447d6ca5a8775dab2d11a832da5",
|
|
28
|
+
...over
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("the file lands beside the proxy log, named by when it started and what is watched", async () => {
|
|
32
|
+
const { dir, proxyLog } = aDirectory();
|
|
33
|
+
const logs = createClientLogFiles(proxyLog);
|
|
34
|
+
logs.write(aSession(), ["first line"]);
|
|
35
|
+
await logs.close();
|
|
36
|
+
|
|
37
|
+
const [name] = readdirSync(dir).filter((f) => f.startsWith("client-"));
|
|
38
|
+
assert.ok(name, "a client log file was written");
|
|
39
|
+
// The stamp is UTC and sorts; the session id joins it to the proxy's lines;
|
|
40
|
+
// the film says which one it is without opening the file.
|
|
41
|
+
assert.match(name, /^client-20260913-160524-abcd1234-Reacher\.S04E07\.1080p\.rus\.LostFilm\.TV\.mkv-94dda59f\.log$/);
|
|
42
|
+
assert.equal(readFileSync(join(dir, name), "utf8"), "first line\n");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("lines from one session go on appending to the one file", async () => {
|
|
46
|
+
const { dir, proxyLog } = aDirectory();
|
|
47
|
+
const logs = createClientLogFiles(proxyLog);
|
|
48
|
+
logs.write(aSession(), ["one", "two"]);
|
|
49
|
+
logs.write(aSession(), ["three"]);
|
|
50
|
+
await logs.close();
|
|
51
|
+
|
|
52
|
+
const names = readdirSync(dir).filter((f) => f.startsWith("client-"));
|
|
53
|
+
assert.equal(names.length, 1);
|
|
54
|
+
assert.equal(readFileSync(join(dir, names[0]), "utf8"), "one\ntwo\nthree\n");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("a session that has not chosen a film yet still gets its own file", async () => {
|
|
58
|
+
// The page opening, a proxy being chosen, a connection failing — all before
|
|
59
|
+
// any torrent exists, and all worth keeping.
|
|
60
|
+
const { dir, proxyLog } = aDirectory();
|
|
61
|
+
const logs = createClientLogFiles(proxyLog);
|
|
62
|
+
logs.write(aSession({ torrentName: "", infoHash: "" }), ["before any film"]);
|
|
63
|
+
await logs.close();
|
|
64
|
+
|
|
65
|
+
const [name] = readdirSync(dir).filter((f) => f.startsWith("client-"));
|
|
66
|
+
assert.match(name, /-no-torrent-yet\.log$/);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("the file keeps its name when the film arrives mid-session", async () => {
|
|
70
|
+
// Keyed by the session, not by the name: the earlier lines belong to the same
|
|
71
|
+
// viewing and must not start a second file.
|
|
72
|
+
const { dir, proxyLog } = aDirectory();
|
|
73
|
+
const logs = createClientLogFiles(proxyLog);
|
|
74
|
+
logs.write(aSession({ torrentName: "", infoHash: "" }), ["before"]);
|
|
75
|
+
logs.write(aSession(), ["after"]);
|
|
76
|
+
await logs.close();
|
|
77
|
+
|
|
78
|
+
const names = readdirSync(dir).filter((f) => f.startsWith("client-"));
|
|
79
|
+
assert.equal(names.length, 1);
|
|
80
|
+
assert.match(names[0], /-no-torrent-yet\.log$/);
|
|
81
|
+
assert.equal(readFileSync(join(dir, names[0]), "utf8"), "before\nafter\n");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("a hostile torrent name cannot escape the directory", async () => {
|
|
85
|
+
const { dir, proxyLog } = aDirectory();
|
|
86
|
+
const logs = createClientLogFiles(proxyLog);
|
|
87
|
+
logs.write(aSession({ torrentName: "../../etc/passwd" }), ["x"]);
|
|
88
|
+
await logs.close();
|
|
89
|
+
|
|
90
|
+
const names = readdirSync(dir).filter((f) => f.startsWith("client-"));
|
|
91
|
+
assert.equal(names.length, 1);
|
|
92
|
+
assert.ok(!names[0].includes(".."), `no traversal in ${names[0]}`);
|
|
93
|
+
assert.ok(!names[0].includes("/"), `no separator in ${names[0]}`);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("two sessions of one film are two files", async () => {
|
|
97
|
+
const { dir, proxyLog } = aDirectory();
|
|
98
|
+
const logs = createClientLogFiles(proxyLog);
|
|
99
|
+
logs.write(aSession({ sessionId: "aaaa1111" }), ["one viewer"]);
|
|
100
|
+
logs.write(aSession({ sessionId: "bbbb2222", startedAt: "2026-09-13T16:07:11.000Z" }), ["another"]);
|
|
101
|
+
await logs.close();
|
|
102
|
+
|
|
103
|
+
assert.equal(readdirSync(dir).filter((f) => f.startsWith("client-")).length, 2);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("no proxy log path means nothing is written and nothing throws", async () => {
|
|
107
|
+
const logs = createClientLogFiles("");
|
|
108
|
+
logs.write(aSession(), ["dropped"]);
|
|
109
|
+
await logs.close();
|
|
110
|
+
});
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Where a browser's own log lands: a file beside the proxy's, per session.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS. Both halves of every failure this project investigates live
|
|
5
|
+
* in two places at once — what the proxy did, and what the page saw — and until
|
|
6
|
+
* now the second half went to the SERVER's standard output on the droplet.
|
|
7
|
+
* Every release recreates that container and destroys it. On 2026-09-13 a
|
|
8
|
+
* viewer reported a desync and a frozen picture, and the analysis ran on half
|
|
9
|
+
* the evidence for exactly this reason.
|
|
10
|
+
*
|
|
11
|
+
* Written here instead, next to the proxy's own log, on the same durable
|
|
12
|
+
* directory (`/data` on the addon host). One file per viewing session, named so
|
|
13
|
+
* the two halves join without guessing: the session's start, then the torrent.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { createWriteStream, mkdirSync, renameSync, statSync } from "node:fs";
|
|
17
|
+
import { dirname, join } from "node:path";
|
|
18
|
+
|
|
19
|
+
/** One file may reach this before it is rotated. */
|
|
20
|
+
const MAX_BYTES = 16 * 1024 * 1024;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Make a name safe for a file and short enough to read in a directory listing.
|
|
24
|
+
*
|
|
25
|
+
* Deliberately strict: a torrent name is arbitrary bytes chosen by a stranger,
|
|
26
|
+
* and it is about to become a path.
|
|
27
|
+
*
|
|
28
|
+
* @param {string} value
|
|
29
|
+
* @param {number} max
|
|
30
|
+
* @returns {string}
|
|
31
|
+
*/
|
|
32
|
+
function safeName(value, max) {
|
|
33
|
+
const cleaned = String(value ?? "")
|
|
34
|
+
.replace(/[^\p{L}\p{N}._-]+/gu, "-")
|
|
35
|
+
.replace(/-+/g, "-")
|
|
36
|
+
.replace(/^[-.]+|[-.]+$/g, "");
|
|
37
|
+
return cleaned.slice(0, max);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* `2026-09-13T16:05:24.123Z` → `20260913-160524`, which sorts and reads.
|
|
42
|
+
*
|
|
43
|
+
* @param {string} iso
|
|
44
|
+
* @returns {string}
|
|
45
|
+
*/
|
|
46
|
+
function stamp(iso) {
|
|
47
|
+
const when = new Date(iso);
|
|
48
|
+
const at = Number.isNaN(when.getTime()) ? new Date() : when;
|
|
49
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
50
|
+
return `${at.getUTCFullYear()}${pad(at.getUTCMonth() + 1)}${pad(at.getUTCDate())}` +
|
|
51
|
+
`-${pad(at.getUTCHours())}${pad(at.getUTCMinutes())}${pad(at.getUTCSeconds())}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A sink that writes one session's browser lines to its own file.
|
|
56
|
+
*
|
|
57
|
+
* @param {string} proxyLogPath - The proxy's own log file; its directory is
|
|
58
|
+
* used, so the two halves are always side by side.
|
|
59
|
+
* @returns {{ write: (session: object, lines: string[]) => void, close: () => void }}
|
|
60
|
+
*/
|
|
61
|
+
export function createClientLogFiles(proxyLogPath) {
|
|
62
|
+
const directory = typeof proxyLogPath === "string" && proxyLogPath.length > 0
|
|
63
|
+
? dirname(proxyLogPath)
|
|
64
|
+
: "";
|
|
65
|
+
/** @type {Map<string, { stream: import("node:fs").WriteStream, path: string, bytes: number }>} */
|
|
66
|
+
const open = new Map();
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The file for this session, opened on first use.
|
|
70
|
+
*
|
|
71
|
+
* Keyed by the session's own id rather than by the name, because the name
|
|
72
|
+
* gains the torrent the moment one is chosen and the earlier lines — the
|
|
73
|
+
* page opening, the proxy being chosen, a connection failing — belong to the
|
|
74
|
+
* same session and must not start a second file.
|
|
75
|
+
*
|
|
76
|
+
* @param {{ sessionId: string, startedAt: string, torrentName: string, infoHash: string }} session
|
|
77
|
+
* @returns {{ stream: import("node:fs").WriteStream, path: string, bytes: number } | null}
|
|
78
|
+
*/
|
|
79
|
+
function fileFor(session) {
|
|
80
|
+
if (!directory) return null;
|
|
81
|
+
const key = session.sessionId;
|
|
82
|
+
const existing = open.get(key);
|
|
83
|
+
if (existing) return existing;
|
|
84
|
+
const film = session.torrentName
|
|
85
|
+
? `${safeName(session.torrentName, 60)}-${safeName(session.infoHash, 8)}`
|
|
86
|
+
: "no-torrent-yet";
|
|
87
|
+
const path = join(directory, `client-${stamp(session.startedAt)}-${safeName(session.sessionId, 8)}-${film}.log`);
|
|
88
|
+
try {
|
|
89
|
+
mkdirSync(directory, { recursive: true });
|
|
90
|
+
const record = {
|
|
91
|
+
stream: createWriteStream(path, { flags: "a" }),
|
|
92
|
+
path,
|
|
93
|
+
bytes: statSync(path, { throwIfNoEntry: false })?.size ?? 0
|
|
94
|
+
};
|
|
95
|
+
// A file that stops being writable must not take the proxy down with it,
|
|
96
|
+
// and must not be retried on every line.
|
|
97
|
+
record.stream.on("error", () => open.delete(key));
|
|
98
|
+
open.set(key, record);
|
|
99
|
+
return record;
|
|
100
|
+
} catch {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
write(session, lines) {
|
|
107
|
+
const record = fileFor(session);
|
|
108
|
+
if (!record) return;
|
|
109
|
+
for (const line of lines) {
|
|
110
|
+
const text = `${line}\n`;
|
|
111
|
+
record.bytes += Buffer.byteLength(text);
|
|
112
|
+
record.stream.write(text);
|
|
113
|
+
}
|
|
114
|
+
if (record.bytes >= MAX_BYTES) {
|
|
115
|
+
try {
|
|
116
|
+
record.stream.end(() => {
|
|
117
|
+
try {
|
|
118
|
+
renameSync(record.path, `${record.path}.1`);
|
|
119
|
+
} catch {
|
|
120
|
+
// Renaming a file that has gone leaves nothing to do.
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
} catch {
|
|
124
|
+
// A rotation that cannot happen leaves the file growing, which is
|
|
125
|
+
// better than losing the session's log to an exception.
|
|
126
|
+
}
|
|
127
|
+
open.delete(session.sessionId);
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
/**
|
|
131
|
+
* Finish every open file.
|
|
132
|
+
*
|
|
133
|
+
* Awaitable, and that is not a convenience: a write stream opens and
|
|
134
|
+
* flushes asynchronously, so a process that ends without waiting loses the
|
|
135
|
+
* last lines of every session — which are the ones a crash is explained by.
|
|
136
|
+
*
|
|
137
|
+
* @returns {Promise<void>}
|
|
138
|
+
*/
|
|
139
|
+
async close() {
|
|
140
|
+
const ending = [...open.values()].map((record) => new Promise((resolve) => {
|
|
141
|
+
try {
|
|
142
|
+
record.stream.end(resolve);
|
|
143
|
+
} catch {
|
|
144
|
+
resolve(undefined);
|
|
145
|
+
}
|
|
146
|
+
}));
|
|
147
|
+
open.clear();
|
|
148
|
+
await Promise.all(ending);
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
}
|