pi-web-voice 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,312 @@
1
+ "use strict";
2
+
3
+ const fs = require("node:fs");
4
+ const os = require("node:os");
5
+ const path = require("node:path");
6
+
7
+ /**
8
+ * Recognition vocabulary derived from what you have actually been talking
9
+ * about, instead of a list you have to maintain by hand.
10
+ *
11
+ * pi stores every session as JSONL under
12
+ * <agent dir>/sessions/<encoded cwd>/<timestamp>_<session id>.jsonl
13
+ * so the session id from the browser is enough to find the current
14
+ * conversation, and its sibling files are that project's history.
15
+ *
16
+ * Only user and assistant prose is read. Thinking blocks, tool arguments and
17
+ * tool results are skipped: they are noisy, and they are where secrets live.
18
+ */
19
+
20
+ const CACHE_TTL_MS = 20_000;
21
+ const cache = new Map();
22
+
23
+ function agentDir() {
24
+ return process.env.PI_CODING_AGENT_DIR || path.join(os.homedir(), ".pi", "agent");
25
+ }
26
+
27
+ /** Locates the JSONL file for a session id and the project it belongs to. */
28
+ function resolveSession(sessionId) {
29
+ if (!/^[\w-]{6,}$/.test(sessionId || "")) return null;
30
+ const root = path.join(agentDir(), "sessions");
31
+ let projects;
32
+ try {
33
+ projects = fs.readdirSync(root, { withFileTypes: true });
34
+ } catch {
35
+ return null;
36
+ }
37
+ for (const project of projects) {
38
+ if (!project.isDirectory()) continue;
39
+ const dir = path.join(root, project.name);
40
+ let files;
41
+ try {
42
+ files = fs.readdirSync(dir);
43
+ } catch {
44
+ continue;
45
+ }
46
+ const match = files.find((name) => name.endsWith(`${sessionId}.jsonl`));
47
+ if (match) return { file: path.join(dir, match), dir };
48
+ }
49
+ return null;
50
+ }
51
+
52
+ /**
53
+ * Locates a project's session directory from its working directory.
54
+ *
55
+ * The directory name is an encoding of the path, but rather than depend on
56
+ * that encoding this reads the `cwd` recorded in the first line of each
57
+ * session file, which is part of the on-disk session format.
58
+ */
59
+ let projectIndex = { expires: 0, byCwd: new Map() };
60
+
61
+ function resolveProject(cwd) {
62
+ if (!cwd) return null;
63
+ const now = Date.now();
64
+ if (projectIndex.expires <= now) {
65
+ const byCwd = new Map();
66
+ const root = path.join(agentDir(), "sessions");
67
+ let projects = [];
68
+ try {
69
+ projects = fs.readdirSync(root, { withFileTypes: true });
70
+ } catch {
71
+ /* no sessions yet */
72
+ }
73
+ for (const project of projects) {
74
+ if (!project.isDirectory()) continue;
75
+ const dir = path.join(root, project.name);
76
+ try {
77
+ const newest = fs
78
+ .readdirSync(dir)
79
+ .filter((name) => name.endsWith(".jsonl"))
80
+ .sort()
81
+ .pop();
82
+ if (!newest) continue;
83
+ const header = JSON.parse(readTail(path.join(dir, newest), 4096).split("\n")[0] || "{}");
84
+ // The tail may start mid-file, so fall back to reading the head.
85
+ const recorded = header.cwd ?? readHeaderCwd(path.join(dir, newest));
86
+ if (recorded) byCwd.set(recorded, dir);
87
+ } catch {
88
+ /* skip unreadable projects */
89
+ }
90
+ }
91
+ projectIndex = { expires: now + 60_000, byCwd };
92
+ }
93
+ const dir = projectIndex.byCwd.get(cwd);
94
+ return dir ? { dir, file: null } : null;
95
+ }
96
+
97
+ function readHeaderCwd(file) {
98
+ try {
99
+ const buffer = Buffer.alloc(512);
100
+ const handle = fs.openSync(file, "r");
101
+ try {
102
+ fs.readSync(handle, buffer, 0, 512, 0);
103
+ } finally {
104
+ fs.closeSync(handle);
105
+ }
106
+ const line = buffer.toString("utf8").split("\n")[0];
107
+ return JSON.parse(line).cwd ?? null;
108
+ } catch {
109
+ return null;
110
+ }
111
+ }
112
+
113
+ /** Reads the last `maxBytes` of a file, dropping the partial first line. */
114
+ function readTail(file, maxBytes) {
115
+ let handle;
116
+ try {
117
+ const { size } = fs.statSync(file);
118
+ const start = Math.max(0, size - maxBytes);
119
+ const length = size - start;
120
+ if (length <= 0) return "";
121
+ const buffer = Buffer.alloc(length);
122
+ handle = fs.openSync(file, "r");
123
+ fs.readSync(handle, buffer, 0, length, start);
124
+ const text = buffer.toString("utf8");
125
+ return start === 0 ? text : text.slice(text.indexOf("\n") + 1);
126
+ } catch {
127
+ return "";
128
+ } finally {
129
+ if (handle !== undefined) fs.closeSync(handle);
130
+ }
131
+ }
132
+
133
+ // ── candidate extraction ───────────────────────────────────────────────────
134
+
135
+ // Shapes a speech model tends to get wrong and a plain dictionary will not fix.
136
+ const PATTERNS = [
137
+ /`([^`\n]{2,40})`/g, // `inline code`
138
+ /\b([A-Za-z][a-z0-9]*(?:[A-Z][a-z0-9]+)+)\b/g, // camelCase, PascalCase
139
+ /\b([A-Za-z][A-Za-z0-9]*(?:[-_][A-Za-z0-9]+)+)\b/g, // kebab-case, snake_case
140
+ /\b([A-Za-z][\w-]*\.(?:[a-z]{2,5}))\b/g, // package.json, hook.cjs
141
+ /\b([\w.-]+\/[\w./-]+)\b/g, // spec/lock.yml, docs/probing.md
142
+ /\b([A-Z]{3,8})\b/g, // MCP, USWDS, SSE
143
+ ];
144
+
145
+ // Ordinary words that happen to be written in caps or wrapped in backticks.
146
+ // Boosting them wastes a slot; a speech model already gets them right.
147
+ const STOPWORDS = new Set([
148
+ "AND", "THE", "FOR", "NOT", "YOU", "ALL", "ANY", "CAN", "GET", "SET", "NEW",
149
+ "USE", "ADD", "RUN", "ONE", "TWO", "YES", "WILL", "MUST", "MAY", "SHOULD",
150
+ "NOTE", "TODO", "WARNING", "REQUIRED", "OPTIONAL", "DEFINED", "RECOMMENDED",
151
+ "HTTP", "HTTPS", "JSON", "HTML", "TEXT", "NULL", "TRUE", "FALSE",
152
+ ]);
153
+
154
+ /**
155
+ * Only terms with a distinctive written form are worth boosting: mixed case,
156
+ * a separator, a digit, or an acronym. A plain lowercase word like "host" adds
157
+ * nothing and crowds out the vocabulary budget.
158
+ */
159
+ function hasShape(term) {
160
+ return (
161
+ /[a-z][A-Z]/.test(term) ||
162
+ /[-_./]/.test(term) ||
163
+ /^[A-Z]{3,8}$/.test(term) ||
164
+ /\d/.test(term)
165
+ );
166
+ }
167
+
168
+ /**
169
+ * Conservative secret filter. Anything that looks like a credential is dropped
170
+ * before it can reach a speech provider.
171
+ */
172
+ function looksLikeSecret(term) {
173
+ if (term.includes("@") || term.includes("://")) return true;
174
+ if (/^(sk|ghp|gho|ghs|github_pat|xox[abprs]|AKIA|ASIA|AIza|glpat|dop_v1)[-_]/i.test(term)) return true;
175
+ if (/^[0-9a-f]{16,}$/i.test(term)) return true; // hex digest
176
+ if (/^[A-Za-z0-9+/]{24,}={0,2}$/.test(term) && /\d/.test(term)) return true; // base64-ish
177
+ // Long, separator-free, mixed letters and digits: almost always a token.
178
+ if (term.length >= 24 && /[A-Za-z]/.test(term) && /\d/.test(term) && !/[-_./]/.test(term)) return true;
179
+ return false;
180
+ }
181
+
182
+ function acceptable(term) {
183
+ if (term.length < 3 || term.length > 40) return false;
184
+ if (STOPWORDS.has(term.toUpperCase())) return false;
185
+ if (/^\d+$/.test(term)) return false;
186
+ if (!/[A-Za-z]/.test(term)) return false;
187
+ // Single words only. A phrase list counts words, not entries, so a
188
+ // two-word entry silently costs two slots out of a very small budget —
189
+ // and multi-word candidates are mostly backtick spans of shell anyway.
190
+ if (/\s/.test(term)) return false;
191
+ if (/[{}"'|$<>`]/.test(term)) return false;
192
+ if (!hasShape(term)) return false;
193
+ if (looksLikeSecret(term)) return false;
194
+ return true;
195
+ }
196
+
197
+ function harvest(text, weight, scores) {
198
+ for (const pattern of PATTERNS) {
199
+ pattern.lastIndex = 0;
200
+ let match;
201
+ while ((match = pattern.exec(text)) !== null) {
202
+ const term = match[1].trim().replace(/[.,;:)]+$/, "");
203
+ if (!acceptable(term)) continue;
204
+ scores.set(term, (scores.get(term) ?? 0) + weight);
205
+
206
+ // "spec/lock.yml" is rarely spoken whole; the basename usually is.
207
+ if (term.includes("/")) {
208
+ const base = term.slice(term.lastIndexOf("/") + 1);
209
+ if (acceptable(base)) scores.set(base, (scores.get(base) ?? 0) + weight * 0.5);
210
+ }
211
+ }
212
+ }
213
+ }
214
+
215
+ /** Pulls prose out of one session file and scores the terms inside it. */
216
+ function scoreSession(file, weight, scores, maxBytes) {
217
+ const messages = [];
218
+ for (const line of readTail(file, maxBytes).split("\n")) {
219
+ if (!line.startsWith("{")) continue;
220
+ let entry;
221
+ try {
222
+ entry = JSON.parse(line);
223
+ } catch {
224
+ continue;
225
+ }
226
+ if (entry.type !== "message") continue;
227
+
228
+ const role = entry.message?.role;
229
+ if (role !== "user" && role !== "assistant") continue;
230
+
231
+ const content = entry.message?.content;
232
+ const texts =
233
+ typeof content === "string"
234
+ ? [content]
235
+ : Array.isArray(content)
236
+ ? content.filter((block) => block?.type === "text" && typeof block.text === "string").map((block) => block.text)
237
+ : [];
238
+ if (texts.length > 0) messages.push({ role, texts });
239
+ }
240
+
241
+ // Frequency across the whole conversation, not recency. Recency was tried
242
+ // and measured worse: a conversation that drifts onto some side topic for a
243
+ // few turns would evict the project's durable vocabulary from the very
244
+ // small budget a phrase list has.
245
+ for (const message of messages) {
246
+ // What you said yourself is the strongest signal for what you will say next.
247
+ const roleWeight = weight * (message.role === "user" ? 1.5 : 1);
248
+ for (const text of message.texts) harvest(text, roleWeight, scores);
249
+ }
250
+ }
251
+
252
+ function recentProjectSessions(dir, currentFile, limit) {
253
+ try {
254
+ return fs
255
+ .readdirSync(dir)
256
+ .filter((name) => name.endsWith(".jsonl"))
257
+ .map((name) => path.join(dir, name))
258
+ .filter((file) => file !== currentFile)
259
+ .map((file) => ({ file, mtime: fs.statSync(file).mtimeMs }))
260
+ .sort((a, b) => b.mtime - a.mtime)
261
+ .slice(0, limit)
262
+ .map((entry) => entry.file);
263
+ } catch {
264
+ return [];
265
+ }
266
+ }
267
+
268
+ /**
269
+ * Returns the ranked vocabulary for a request, mined from the conversation and
270
+ * capped at `limit`.
271
+ *
272
+ * `sessionId` pins it to the exact conversation the tab is showing. `cwd` is
273
+ * the fallback for a session that does not exist yet, where the project's
274
+ * earlier conversations are still the right vocabulary.
275
+ */
276
+ function collectTerms(sessionId, cwd, config, limit) {
277
+ const key = `${sessionId || ""}|${cwd || ""}`;
278
+ const cached = cache.get(key);
279
+ const now = Date.now();
280
+
281
+ if (cached && cached.expires > now) return cached.terms.slice(0, limit);
282
+
283
+ const found = resolveSession(sessionId) ?? resolveProject(cwd);
284
+ if (!found) return [];
285
+
286
+ const scores = new Map();
287
+ // The conversation you are in outweighs the project's older ones, and when
288
+ // there is no conversation yet the project's history is all there is.
289
+ if (found.file) scoreSession(found.file, 3, scores, config.context.bytes);
290
+ for (const file of recentProjectSessions(found.dir, found.file, config.context.sessions)) {
291
+ scoreSession(file, 1, scores, Math.min(config.context.bytes, 128 * 1024));
292
+ }
293
+
294
+ const mined = [...scores.entries()]
295
+ .sort((a, b) => b[1] - a[1])
296
+ .map(([term]) => term)
297
+ .slice(0, config.context.maxTerms);
298
+
299
+ cache.set(key, { expires: now + CACHE_TTL_MS, terms: mined });
300
+ return mined.slice(0, limit);
301
+ }
302
+
303
+ module.exports = {
304
+ collectTerms,
305
+ resolveSession,
306
+ resolveProject,
307
+ looksLikeSecret,
308
+ __cache: cache,
309
+ __resetIndex: () => {
310
+ projectIndex = { expires: 0, byCwd: new Map() };
311
+ },
312
+ };
package/lib/doctor.cjs ADDED
@@ -0,0 +1,126 @@
1
+ "use strict";
2
+
3
+ const fs = require("node:fs");
4
+ const { loadConfig, ENV_FILE } = require("./config.cjs");
5
+ const { transcribe, termBudget } = require("./providers.cjs");
6
+ const { collectTerms } = require("./context.cjs");
7
+
8
+ /**
9
+ * Preflight for the configured speech backend.
10
+ *
11
+ * pi-web-voice doctor [recording.wav]
12
+ *
13
+ * With no file it sends a one-second tone. The transcript will be empty, which
14
+ * is expected: the point is to prove that the key, region, endpoint and model
15
+ * name are right before you go looking for a microphone bug that isn't there.
16
+ */
17
+
18
+ function toneWav(seconds = 1, sampleRate = 16000) {
19
+ const samples = seconds * sampleRate;
20
+ const buffer = Buffer.alloc(44 + samples * 2);
21
+ buffer.write("RIFF", 0);
22
+ buffer.writeUInt32LE(36 + samples * 2, 4);
23
+ buffer.write("WAVEfmt ", 8);
24
+ buffer.writeUInt32LE(16, 16);
25
+ buffer.writeUInt16LE(1, 20);
26
+ buffer.writeUInt16LE(1, 22);
27
+ buffer.writeUInt32LE(sampleRate, 24);
28
+ buffer.writeUInt32LE(sampleRate * 2, 28);
29
+ buffer.writeUInt16LE(2, 32);
30
+ buffer.writeUInt16LE(16, 34);
31
+ buffer.write("data", 36);
32
+ buffer.writeUInt32LE(samples * 2, 40);
33
+ for (let i = 0; i < samples; i += 1) {
34
+ buffer.writeInt16LE(Math.round(3000 * Math.sin((2 * Math.PI * 440 * i) / sampleRate)), 44 + i * 2);
35
+ }
36
+ return buffer;
37
+ }
38
+
39
+ function mask(value) {
40
+ if (!value) return "(missing)";
41
+ return value.length <= 8 ? "***" : `${value.slice(0, 4)}…${value.slice(-2)}`;
42
+ }
43
+
44
+ function describe(config) {
45
+ switch (config.provider) {
46
+ case "azure-speech":
47
+ return [
48
+ ["endpoint", config.azureSpeech.endpoint || "(missing)"],
49
+ ["key", mask(config.azureSpeech.key)],
50
+ ["model", config.azureSpeech.model],
51
+ ];
52
+ case "azure-openai":
53
+ return [
54
+ ["endpoint", config.azureOpenAI.endpoint || "(missing)"],
55
+ ["key", mask(config.azureOpenAI.key)],
56
+ ["deployment", config.azureOpenAI.deployment],
57
+ ];
58
+ case "openai":
59
+ return [
60
+ ["base url", config.openai.baseUrl],
61
+ ["key", mask(config.openai.key)],
62
+ ["model", config.openai.model],
63
+ ];
64
+ default:
65
+ return [];
66
+ }
67
+ }
68
+
69
+ /** Turns provider failures into the thing that is actually wrong. */
70
+ function diagnose(message) {
71
+ if (/\b401\b|Unauthorized|Access denied/i.test(message)) {
72
+ return "the key is wrong, or it belongs to a different resource";
73
+ }
74
+ if (/\b403\b/.test(message)) return "the key is valid but not allowed to call this operation";
75
+ if (/\b404\b/.test(message)) {
76
+ return "endpoint or deployment not found — check the resource name, and that the region offers this model";
77
+ }
78
+ if (/\b400\b/.test(message)) {
79
+ return "the request was rejected — usually a model name the region does not serve, or an unsupported audio format";
80
+ }
81
+ if (/\b429\b/.test(message)) return "rate limited — the credentials work, try again shortly";
82
+ if (/ENOTFOUND|EAI_AGAIN|fetch failed/i.test(message)) return "cannot reach the endpoint — check the URL and your network";
83
+ if (/aborted/i.test(message)) return "timed out";
84
+ return "";
85
+ }
86
+
87
+ async function doctor(argv) {
88
+ const config = loadConfig();
89
+ const file = argv[0];
90
+
91
+ console.log(`provider ${config.provider}`);
92
+ console.log(`env file ${config.envFile || `(none at ${ENV_FILE})`}`);
93
+ for (const [label, value] of describe(config)) {
94
+ console.log(`${label.padEnd(11)}${value}`);
95
+ }
96
+
97
+ const cwd = process.cwd();
98
+ const limit = Math.min(config.context.maxTerms, termBudget(config.provider));
99
+ const terms = collectTerms("", cwd, config, limit);
100
+ console.log(`context ${terms.length} terms`);
101
+ if (terms.length > 0) console.log(` ${terms.slice(0, 12).join(", ")}${terms.length > 12 ? " …" : ""}`);
102
+
103
+ const audio = file ? fs.readFileSync(file) : toneWav();
104
+ console.log(`audio ${file ?? "generated 1s tone"} (${audio.length} bytes)`);
105
+
106
+ const started = Date.now();
107
+ try {
108
+ const text = await transcribe(audio, config, terms);
109
+ const ms = Date.now() - started;
110
+ console.log(`\nok ${ms} ms`);
111
+ console.log(`transcript ${text ? JSON.stringify(text) : "(empty)"}`);
112
+ if (!text && !file) {
113
+ console.log("\nAn empty transcript from a tone is expected. Credentials, region and\nmodel are working. Pass a real recording to check accuracy.");
114
+ }
115
+ return 0;
116
+ } catch (error) {
117
+ const message = error.message ?? String(error);
118
+ console.error(`\nfailed ${Date.now() - started} ms`);
119
+ console.error(` ${message.slice(0, 400)}`);
120
+ const hint = diagnose(message);
121
+ if (hint) console.error(`\nlikely ${hint}`);
122
+ return 1;
123
+ }
124
+ }
125
+
126
+ module.exports = { doctor, toneWav };
package/lib/patch.cjs ADDED
@@ -0,0 +1,204 @@
1
+ "use strict";
2
+
3
+ const http = require("node:http");
4
+ const { StringDecoder } = require("node:string_decoder");
5
+
6
+ /**
7
+ * Attaches to every HTTP server in this process without touching pi-web.
8
+ *
9
+ * `http.Server.prototype.emit` is patched rather than `http.createServer`,
10
+ * because that catches both `createServer(handler)` and `server.on("request")`
11
+ * styles regardless of how the framework wires things up.
12
+ */
13
+
14
+ // Give up buffering an HTML response after this much text and flush it
15
+ // unmodified, so a pathological response can never be held in memory.
16
+ const MAX_BUFFERED_HTML = 1024 * 1024;
17
+
18
+ function install({ prefix, tag, handleRoute, onError = () => {}, onListen = () => {} }) {
19
+ const proto = http.Server.prototype;
20
+ if (proto.__piWebVoicePatched) return;
21
+ proto.__piWebVoicePatched = true;
22
+
23
+ // pi-web's launcher spawns `next start` as a child, so the hook is loaded in
24
+ // both processes. Only the one that actually listens is interesting.
25
+ const originalListen = proto.listen;
26
+ let announced = false;
27
+ proto.listen = function listen(...args) {
28
+ if (!announced) {
29
+ announced = true;
30
+ try {
31
+ onListen();
32
+ } catch (error) {
33
+ onError(error);
34
+ }
35
+ }
36
+ return originalListen.apply(this, args);
37
+ };
38
+
39
+ const originalEmit = proto.emit;
40
+
41
+ proto.emit = function emit(event, ...args) {
42
+ if (event === "request") {
43
+ const [req, res] = args;
44
+ try {
45
+ if (typeof req?.url === "string" && req.url.startsWith(`${prefix}/`)) {
46
+ Promise.resolve(handleRoute(req, res)).catch((error) => {
47
+ onError(error);
48
+ if (!res.headersSent) res.writeHead(500, { "content-type": "text/plain" });
49
+ res.end("pi-web-voice route failed");
50
+ });
51
+ return true; // handled here; upstream listeners never see it
52
+ }
53
+
54
+ // Identity encoding keeps HTML greppable. Loopback traffic, so the
55
+ // extra bytes cost nothing, and the browser still gets compression
56
+ // from any real proxy sitting in front.
57
+ delete req.headers["accept-encoding"];
58
+
59
+ injectIntoHtml(res, tag, onError);
60
+ } catch (error) {
61
+ onError(error);
62
+ }
63
+ }
64
+ return originalEmit.apply(this, [event, ...args]);
65
+ };
66
+ }
67
+
68
+ /**
69
+ * Wraps a response so that the first `</head>` (or `<body>`) in an HTML body
70
+ * gains one script tag. Non-HTML responses — SSE, JSON, static assets — are
71
+ * passed straight through untouched and unbuffered.
72
+ */
73
+ function injectIntoHtml(res, tag, onError) {
74
+ const originalWriteHead = res.writeHead;
75
+ const originalWrite = res.write;
76
+ const originalEnd = res.end;
77
+
78
+ // unknown → not yet classified, html → buffering, plain → pass through,
79
+ // done → already injected or gave up
80
+ let mode = "unknown";
81
+ let buffered = "";
82
+ let decoder = null;
83
+
84
+ function headerValue(headers, name) {
85
+ if (!headers || Array.isArray(headers)) return undefined;
86
+ for (const key of Object.keys(headers)) {
87
+ if (key.toLowerCase() === name) return headers[key];
88
+ }
89
+ return undefined;
90
+ }
91
+
92
+ function dropHeader(headers, name) {
93
+ if (!headers || Array.isArray(headers)) return;
94
+ for (const key of Object.keys(headers)) {
95
+ if (key.toLowerCase() === name) delete headers[key];
96
+ }
97
+ }
98
+
99
+ function classify(headersFromWriteHead) {
100
+ if (mode !== "unknown") return mode;
101
+ const contentType =
102
+ headerValue(headersFromWriteHead, "content-type") ?? res.getHeader("content-type");
103
+ mode = typeof contentType === "string" && /text\/html/i.test(contentType) ? "html" : "plain";
104
+ if (mode === "html") {
105
+ // The body grows by the tag, and streamed HTML has no length anyway.
106
+ res.removeHeader("content-length");
107
+ dropHeader(headersFromWriteHead, "content-length");
108
+ }
109
+ return mode;
110
+ }
111
+
112
+ function asText(chunk) {
113
+ if (chunk === null || chunk === undefined) return "";
114
+ if (typeof chunk === "string") return chunk;
115
+ if (!Buffer.isBuffer(chunk)) return String(chunk);
116
+ // A decoder keeps multi-byte characters intact across chunk boundaries.
117
+ decoder ??= new StringDecoder("utf8");
118
+ return decoder.write(chunk);
119
+ }
120
+
121
+ function withTag(html) {
122
+ const head = html.toLowerCase().indexOf("</head>");
123
+ if (head >= 0) return html.slice(0, head) + tag + html.slice(head);
124
+ const body = /<body[^>]*>/i.exec(html);
125
+ if (body) {
126
+ const at = body.index + body[0].length;
127
+ return html.slice(0, at) + tag + html.slice(at);
128
+ }
129
+ return null;
130
+ }
131
+
132
+ res.writeHead = function writeHead(status, reasonOrHeaders, maybeHeaders) {
133
+ try {
134
+ const headers =
135
+ reasonOrHeaders && typeof reasonOrHeaders === "object" ? reasonOrHeaders : maybeHeaders;
136
+ classify(headers);
137
+ } catch (error) {
138
+ onError(error);
139
+ mode = "plain";
140
+ }
141
+ return originalWriteHead.apply(res, arguments);
142
+ };
143
+
144
+ res.write = function write(chunk, encoding, callback) {
145
+ if (typeof encoding === "function") {
146
+ callback = encoding;
147
+ encoding = undefined;
148
+ }
149
+ try {
150
+ if (classify() !== "html") return originalWrite.call(res, chunk, encoding, callback);
151
+
152
+ buffered += asText(chunk);
153
+
154
+ const injected = withTag(buffered);
155
+ if (injected !== null) {
156
+ mode = "done";
157
+ buffered = "";
158
+ return originalWrite.call(res, injected, "utf8", callback);
159
+ }
160
+
161
+ if (buffered.length > MAX_BUFFERED_HTML) {
162
+ mode = "done";
163
+ const flush = buffered;
164
+ buffered = "";
165
+ return originalWrite.call(res, flush, "utf8", callback);
166
+ }
167
+
168
+ if (callback) process.nextTick(callback);
169
+ return true;
170
+ } catch (error) {
171
+ onError(error);
172
+ mode = "done";
173
+ return originalWrite.call(res, chunk, encoding, callback);
174
+ }
175
+ };
176
+
177
+ res.end = function end(chunk, encoding, callback) {
178
+ if (typeof chunk === "function") {
179
+ callback = chunk;
180
+ chunk = undefined;
181
+ encoding = undefined;
182
+ } else if (typeof encoding === "function") {
183
+ callback = encoding;
184
+ encoding = undefined;
185
+ }
186
+ try {
187
+ if (classify() !== "html" || (mode === "done" && !buffered)) {
188
+ return originalEnd.call(res, chunk, encoding, callback);
189
+ }
190
+
191
+ const tail = buffered + asText(chunk) + (decoder ? decoder.end() : "");
192
+ buffered = "";
193
+ mode = "done";
194
+ // Last resort: a script appended after the markup still executes.
195
+ const out = withTag(tail) ?? tail + tag;
196
+ return originalEnd.call(res, out, "utf8", callback);
197
+ } catch (error) {
198
+ onError(error);
199
+ return originalEnd.call(res, chunk, encoding, callback);
200
+ }
201
+ };
202
+ }
203
+
204
+ module.exports = { install };