pigo-excel-python-bridge 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,1023 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Local Python / LibreOffice bridge for Pi for Excel.
5
+ *
6
+ * Modes:
7
+ * - stub (default): deterministic simulated responses for local development.
8
+ * - real: executes local python + libreoffice commands with guardrails.
9
+ *
10
+ * Endpoints:
11
+ * - GET /health
12
+ * - POST /v1/python-run
13
+ * - POST /v1/libreoffice-convert
14
+ */
15
+
16
+ import http from "node:http";
17
+ import https from "node:https";
18
+ import fs from "node:fs";
19
+ import path from "node:path";
20
+ import os from "node:os";
21
+ import { spawn, spawnSync } from "node:child_process";
22
+ import { timingSafeEqual } from "node:crypto";
23
+
24
+ const args = new Set(process.argv.slice(2));
25
+ const useHttps = args.has("--https") || process.env.HTTPS === "1" || process.env.HTTPS === "true";
26
+ const useHttp = args.has("--http");
27
+
28
+ if (useHttps && useHttp) {
29
+ console.error("[pi-for-excel] Invalid args: can't use both --https and --http");
30
+ process.exit(1);
31
+ }
32
+
33
+ const HOST = process.env.HOST || (useHttps ? "localhost" : "127.0.0.1");
34
+ const PORT = Number.parseInt(process.env.PORT || "3340", 10);
35
+
36
+ const MODE_RAW = (process.env.PYTHON_BRIDGE_MODE || "stub").trim().toLowerCase();
37
+ const MODE = MODE_RAW === "real" ? "real" : MODE_RAW === "stub" ? "stub" : null;
38
+ if (!MODE) {
39
+ console.error(`[pi-for-excel] Invalid PYTHON_BRIDGE_MODE: ${MODE_RAW}. Use "stub" or "real".`);
40
+ process.exit(1);
41
+ }
42
+
43
+ const PYTHON_BIN = (process.env.PYTHON_BRIDGE_PYTHON_BIN || "python3").trim();
44
+ const LIBREOFFICE_BIN_RAW = (process.env.PYTHON_BRIDGE_LIBREOFFICE_BIN || "").trim();
45
+ const LIBREOFFICE_CANDIDATES = LIBREOFFICE_BIN_RAW.length > 0
46
+ ? [LIBREOFFICE_BIN_RAW]
47
+ : ["soffice", "libreoffice"];
48
+
49
+ function resolveOptionalEnvPath(name) {
50
+ const raw = process.env[name];
51
+ if (typeof raw !== "string") return null;
52
+
53
+ const trimmed = raw.trim();
54
+ if (trimmed.length === 0) return null;
55
+
56
+ return path.resolve(trimmed);
57
+ }
58
+
59
+ const certDir = resolveOptionalEnvPath("PI_FOR_EXCEL_CERT_DIR") ?? path.resolve(process.cwd());
60
+ const keyPath = resolveOptionalEnvPath("PI_FOR_EXCEL_KEY_PATH") ?? path.join(certDir, "key.pem");
61
+ const certPath = resolveOptionalEnvPath("PI_FOR_EXCEL_CERT_PATH") ?? path.join(certDir, "cert.pem");
62
+
63
+ const DEFAULT_ALLOWED_ORIGINS = new Set([
64
+ "https://localhost:3000",
65
+ "https://pi-for-excel.vercel.app",
66
+ "https://pigo.toolooz.com",
67
+ ]);
68
+
69
+ const MAX_JSON_BODY_BYTES = 512 * 1024;
70
+ const MAX_CODE_LENGTH = 40_000;
71
+ const MAX_INPUT_JSON_LENGTH = 200_000;
72
+ const MAX_OUTPUT_BYTES = 256 * 1024;
73
+
74
+ const PYTHON_DEFAULT_TIMEOUT_MS = 10_000;
75
+ const PYTHON_MIN_TIMEOUT_MS = 100;
76
+ const PYTHON_MAX_TIMEOUT_MS = 120_000;
77
+
78
+ const LIBREOFFICE_DEFAULT_TIMEOUT_MS = 60_000;
79
+ const LIBREOFFICE_MIN_TIMEOUT_MS = 1_000;
80
+ const LIBREOFFICE_MAX_TIMEOUT_MS = 300_000;
81
+
82
+ const LIBREOFFICE_TARGET_FORMATS = new Set(["csv", "pdf", "xlsx"]);
83
+ const RESULT_JSON_MARKER = "__PI_FOR_EXCEL_RESULT_JSON_V1__";
84
+
85
+ const allowedOrigins = (() => {
86
+ const raw = process.env.ALLOWED_ORIGINS;
87
+ if (!raw) return DEFAULT_ALLOWED_ORIGINS;
88
+
89
+ const custom = new Set(
90
+ raw
91
+ .split(",")
92
+ .map((value) => value.trim())
93
+ .filter(Boolean),
94
+ );
95
+
96
+ return custom.size > 0 ? custom : DEFAULT_ALLOWED_ORIGINS;
97
+ })();
98
+
99
+ const authToken = (() => {
100
+ const raw = process.env.PYTHON_BRIDGE_TOKEN;
101
+ if (typeof raw !== "string") return "";
102
+ return raw.trim();
103
+ })();
104
+
105
+ const PYTHON_WRAPPER_CODE = [
106
+ "import json",
107
+ "import os",
108
+ "import sys",
109
+ "import traceback",
110
+ "",
111
+ "raw_input = os.environ.get('PI_INPUT_JSON', '')",
112
+ "input_data = None",
113
+ "if raw_input:",
114
+ " input_data = json.loads(raw_input)",
115
+ "",
116
+ "user_code = os.environ.get('PI_USER_CODE', '')",
117
+ "scope = {'__name__': '__main__', 'input_data': input_data}",
118
+ "",
119
+ "try:",
120
+ " exec(user_code, scope, scope)",
121
+ "except Exception:",
122
+ " traceback.print_exc()",
123
+ " raise",
124
+ "",
125
+ "if 'result' in scope:",
126
+ " try:",
127
+ ` print('${RESULT_JSON_MARKER}')`,
128
+ " print(json.dumps(scope['result'], ensure_ascii=False))",
129
+ " except Exception as exc:",
130
+ " print(f'[pi-python-bridge] result serialization error: {exc}', file=sys.stderr)",
131
+ ].join("\n");
132
+
133
+ class HttpError extends Error {
134
+ /**
135
+ * @param {number} status
136
+ * @param {string} message
137
+ */
138
+ constructor(status, message) {
139
+ super(message);
140
+ this.name = "HttpError";
141
+ this.status = status;
142
+ }
143
+ }
144
+
145
+ /**
146
+ * @param {unknown} value
147
+ * @returns {value is Record<string, unknown>}
148
+ */
149
+ function isRecord(value) {
150
+ return typeof value === "object" && value !== null && !Array.isArray(value);
151
+ }
152
+
153
+ /**
154
+ * @param {string | undefined} origin
155
+ */
156
+ function isAllowedOrigin(origin) {
157
+ return typeof origin === "string" && allowedOrigins.has(origin);
158
+ }
159
+
160
+ /**
161
+ * @param {string | undefined} addr
162
+ */
163
+ function isLoopbackAddress(addr) {
164
+ if (!addr) return false;
165
+ if (addr === "::1" || addr === "0:0:0:0:0:0:0:1") return true;
166
+ if (addr.startsWith("127.")) return true;
167
+ if (addr.startsWith("::ffff:127.")) return true;
168
+ return false;
169
+ }
170
+
171
+ /**
172
+ * @param {http.IncomingMessage} req
173
+ * @param {http.ServerResponse} res
174
+ */
175
+ function setCorsHeaders(req, res) {
176
+ const origin = req.headers.origin;
177
+ if (isAllowedOrigin(origin)) {
178
+ res.setHeader("Access-Control-Allow-Origin", origin);
179
+ res.setHeader("Vary", "Origin");
180
+ }
181
+
182
+ res.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS");
183
+ res.setHeader(
184
+ "Access-Control-Allow-Headers",
185
+ req.headers["access-control-request-headers"] || "content-type,authorization",
186
+ );
187
+ res.setHeader("Access-Control-Max-Age", "86400");
188
+ }
189
+
190
+ /**
191
+ * @param {http.ServerResponse} res
192
+ * @param {number} status
193
+ * @param {unknown} payload
194
+ */
195
+ function respondJson(res, status, payload) {
196
+ res.statusCode = status;
197
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
198
+ res.end(JSON.stringify(payload));
199
+ }
200
+
201
+ /**
202
+ * @param {http.ServerResponse} res
203
+ * @param {number} status
204
+ * @param {string} text
205
+ */
206
+ function respondText(res, status, text) {
207
+ res.statusCode = status;
208
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
209
+ res.end(text);
210
+ }
211
+
212
+ /**
213
+ * @param {string | undefined} headerValue
214
+ */
215
+ function extractBearerToken(headerValue) {
216
+ if (typeof headerValue !== "string") return null;
217
+ const prefix = "Bearer ";
218
+ if (!headerValue.startsWith(prefix)) return null;
219
+ const token = headerValue.slice(prefix.length).trim();
220
+ return token.length > 0 ? token : null;
221
+ }
222
+
223
+ /**
224
+ * @param {string} left
225
+ * @param {string} right
226
+ */
227
+ function secureEquals(left, right) {
228
+ const leftBuffer = Buffer.from(left);
229
+ const rightBuffer = Buffer.from(right);
230
+ if (leftBuffer.length !== rightBuffer.length) return false;
231
+ return timingSafeEqual(leftBuffer, rightBuffer);
232
+ }
233
+
234
+ /**
235
+ * @param {http.IncomingMessage} req
236
+ */
237
+ function isAuthorized(req) {
238
+ if (!authToken) return true;
239
+
240
+ const candidate = extractBearerToken(req.headers.authorization);
241
+ if (!candidate) return false;
242
+
243
+ return secureEquals(candidate, authToken);
244
+ }
245
+
246
+ /**
247
+ * @param {http.IncomingMessage} req
248
+ */
249
+ async function readJsonBody(req) {
250
+ const chunks = [];
251
+ let size = 0;
252
+
253
+ for await (const chunk of req) {
254
+ const part = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
255
+ size += part.length;
256
+
257
+ if (size > MAX_JSON_BODY_BYTES) {
258
+ throw new HttpError(413, `Request body too large (max ${MAX_JSON_BODY_BYTES} bytes).`);
259
+ }
260
+
261
+ chunks.push(part);
262
+ }
263
+
264
+ const text = Buffer.concat(chunks).toString("utf8").trim();
265
+ if (text.length === 0) {
266
+ throw new HttpError(400, "Missing JSON request body.");
267
+ }
268
+
269
+ try {
270
+ return JSON.parse(text);
271
+ } catch {
272
+ throw new HttpError(400, "Invalid JSON body.");
273
+ }
274
+ }
275
+
276
+ /**
277
+ * @param {unknown} value
278
+ */
279
+ function normalizeOptionalString(value) {
280
+ if (typeof value !== "string") return undefined;
281
+
282
+ const trimmed = value.trim();
283
+ return trimmed.length > 0 ? trimmed : undefined;
284
+ }
285
+
286
+ /**
287
+ * @param {unknown} value
288
+ * @param {{ name: string; min: number; max: number; defaultValue: number }} options
289
+ */
290
+ function parseBoundedInteger(value, options) {
291
+ if (value === undefined) return options.defaultValue;
292
+ if (typeof value !== "number" || !Number.isInteger(value)) {
293
+ throw new HttpError(400, `${options.name} must be an integer.`);
294
+ }
295
+ if (value < options.min || value > options.max) {
296
+ throw new HttpError(400, `${options.name} must be between ${options.min} and ${options.max}.`);
297
+ }
298
+ return value;
299
+ }
300
+
301
+ /**
302
+ * @param {string} value
303
+ */
304
+ function isAbsolutePath(value) {
305
+ if (value.startsWith("/")) return true;
306
+ if (/^[A-Za-z]:[\\/]/.test(value)) return true;
307
+ if (value.startsWith("\\\\")) return true;
308
+ return false;
309
+ }
310
+
311
+ /**
312
+ * @param {unknown} value
313
+ * @param {string} field
314
+ */
315
+ function normalizeAbsolutePath(value, field) {
316
+ const pathValue = normalizeOptionalString(value);
317
+ if (!pathValue) {
318
+ throw new HttpError(400, `${field} is required.`);
319
+ }
320
+
321
+ if (!isAbsolutePath(pathValue)) {
322
+ throw new HttpError(400, `${field} must be an absolute path.`);
323
+ }
324
+
325
+ return pathValue;
326
+ }
327
+
328
+ /**
329
+ * @param {string | undefined} output
330
+ */
331
+ function normalizeOutput(output) {
332
+ if (typeof output !== "string") return undefined;
333
+ const normalized = output.replace(/\r/g, "").trim();
334
+ return normalized.length > 0 ? normalized : undefined;
335
+ }
336
+
337
+ /**
338
+ * @param {unknown} payload
339
+ */
340
+ function parsePythonRunRequest(payload) {
341
+ if (!isRecord(payload)) {
342
+ throw new HttpError(400, "Request body must be a JSON object.");
343
+ }
344
+
345
+ const code = typeof payload.code === "string" ? payload.code : "";
346
+ if (code.trim().length === 0) {
347
+ throw new HttpError(400, "code is required.");
348
+ }
349
+
350
+ if (code.length > MAX_CODE_LENGTH) {
351
+ throw new HttpError(400, `code is too long (max ${MAX_CODE_LENGTH} characters).`);
352
+ }
353
+
354
+ const inputJson = normalizeOptionalString(payload.input_json);
355
+ if (inputJson && inputJson.length > MAX_INPUT_JSON_LENGTH) {
356
+ throw new HttpError(400, `input_json is too long (max ${MAX_INPUT_JSON_LENGTH} characters).`);
357
+ }
358
+
359
+ if (inputJson) {
360
+ try {
361
+ void JSON.parse(inputJson);
362
+ } catch {
363
+ throw new HttpError(400, "input_json must be valid JSON.");
364
+ }
365
+ }
366
+
367
+ const timeoutMs = parseBoundedInteger(payload.timeout_ms, {
368
+ name: "timeout_ms",
369
+ min: PYTHON_MIN_TIMEOUT_MS,
370
+ max: PYTHON_MAX_TIMEOUT_MS,
371
+ defaultValue: PYTHON_DEFAULT_TIMEOUT_MS,
372
+ });
373
+
374
+ return {
375
+ code,
376
+ input_json: inputJson,
377
+ timeout_ms: timeoutMs,
378
+ };
379
+ }
380
+
381
+ /**
382
+ * @param {unknown} payload
383
+ */
384
+ function parseLibreOfficeRequest(payload) {
385
+ if (!isRecord(payload)) {
386
+ throw new HttpError(400, "Request body must be a JSON object.");
387
+ }
388
+
389
+ const inputPath = normalizeAbsolutePath(payload.input_path, "input_path");
390
+
391
+ const targetFormat = normalizeOptionalString(payload.target_format)?.toLowerCase();
392
+ if (!targetFormat || !LIBREOFFICE_TARGET_FORMATS.has(targetFormat)) {
393
+ throw new HttpError(400, "target_format must be one of: csv, pdf, xlsx.");
394
+ }
395
+
396
+ let outputPath;
397
+ if (payload.output_path !== undefined) {
398
+ outputPath = normalizeAbsolutePath(payload.output_path, "output_path");
399
+ }
400
+
401
+ const overwrite = typeof payload.overwrite === "boolean" ? payload.overwrite : false;
402
+
403
+ const timeoutMs = parseBoundedInteger(payload.timeout_ms, {
404
+ name: "timeout_ms",
405
+ min: LIBREOFFICE_MIN_TIMEOUT_MS,
406
+ max: LIBREOFFICE_MAX_TIMEOUT_MS,
407
+ defaultValue: LIBREOFFICE_DEFAULT_TIMEOUT_MS,
408
+ });
409
+
410
+ return {
411
+ input_path: inputPath,
412
+ target_format: targetFormat,
413
+ output_path: outputPath,
414
+ overwrite,
415
+ timeout_ms: timeoutMs,
416
+ };
417
+ }
418
+
419
+ /**
420
+ * @param {string} command
421
+ * @param {string[]} args
422
+ */
423
+ function probeBinary(command, args) {
424
+ const probe = spawnSync(command, args, {
425
+ encoding: "utf8",
426
+ });
427
+
428
+ if (probe.error) {
429
+ const code = typeof probe.error.code === "string"
430
+ ? probe.error.code
431
+ : "unknown_error";
432
+ const message = probe.error instanceof Error
433
+ ? probe.error.message
434
+ : String(probe.error);
435
+
436
+ console.warn(`[pi-for-excel] Binary "${command}" not available (${code}): ${message}`);
437
+
438
+ return {
439
+ available: false,
440
+ error: "probe_failed",
441
+ command,
442
+ };
443
+ }
444
+
445
+ if (probe.status !== 0) {
446
+ const stderr = typeof probe.stderr === "string" ? probe.stderr.trim() : "";
447
+ const reason = stderr.length > 0
448
+ ? stderr
449
+ : `probe_exit_${String(probe.status)}`;
450
+
451
+ return {
452
+ available: false,
453
+ error: reason,
454
+ command,
455
+ };
456
+ }
457
+
458
+ const stdout = typeof probe.stdout === "string" ? probe.stdout.trim() : "";
459
+ return {
460
+ available: true,
461
+ version: stdout || command,
462
+ command,
463
+ };
464
+ }
465
+
466
+ function probeLibreOfficeBinary() {
467
+ for (const candidate of LIBREOFFICE_CANDIDATES) {
468
+ const probe = probeBinary(candidate, ["--version"]);
469
+ if (probe.available) {
470
+ return {
471
+ available: true,
472
+ command: candidate,
473
+ version: probe.version,
474
+ };
475
+ }
476
+ }
477
+
478
+ return {
479
+ available: false,
480
+ command: LIBREOFFICE_CANDIDATES[0] || "soffice",
481
+ error: `No LibreOffice binary found (tried: ${LIBREOFFICE_CANDIDATES.join(", ")})`,
482
+ };
483
+ }
484
+
485
+ /**
486
+ * @param {{ command: string; args: string[]; timeoutMs: number; env?: NodeJS.ProcessEnv }} options
487
+ */
488
+ async function runCommandCapture(options) {
489
+ let timedOut = false;
490
+ let overflow = false;
491
+
492
+ const result = await new Promise((resolve, reject) => {
493
+ const child = spawn(options.command, options.args, {
494
+ stdio: ["ignore", "pipe", "pipe"],
495
+ env: options.env,
496
+ });
497
+
498
+ let stdout = "";
499
+ let stderr = "";
500
+
501
+ child.stdout.setEncoding("utf8");
502
+ child.stderr.setEncoding("utf8");
503
+
504
+ child.stdout.on("data", (chunk) => {
505
+ if (overflow) return;
506
+ stdout += chunk;
507
+ if (Buffer.byteLength(stdout, "utf8") > MAX_OUTPUT_BYTES) {
508
+ overflow = true;
509
+ child.kill("SIGKILL");
510
+ }
511
+ });
512
+
513
+ child.stderr.on("data", (chunk) => {
514
+ if (overflow) return;
515
+ stderr += chunk;
516
+ if (Buffer.byteLength(stderr, "utf8") > MAX_OUTPUT_BYTES) {
517
+ overflow = true;
518
+ child.kill("SIGKILL");
519
+ }
520
+ });
521
+
522
+ const timeoutId = setTimeout(() => {
523
+ timedOut = true;
524
+ child.kill("SIGKILL");
525
+ }, options.timeoutMs);
526
+
527
+ child.once("error", (error) => {
528
+ clearTimeout(timeoutId);
529
+ reject(error);
530
+ });
531
+
532
+ child.once("close", (code, signal) => {
533
+ clearTimeout(timeoutId);
534
+ resolve({
535
+ code: code ?? -1,
536
+ signal: signal ?? null,
537
+ stdout,
538
+ stderr,
539
+ });
540
+ });
541
+ });
542
+
543
+ if (overflow) {
544
+ throw new HttpError(413, `Process output exceeded ${MAX_OUTPUT_BYTES} bytes.`);
545
+ }
546
+
547
+ if (timedOut) {
548
+ throw new HttpError(504, `Process timed out after ${options.timeoutMs}ms.`);
549
+ }
550
+
551
+ if (result.signal) {
552
+ throw new HttpError(500, `Process exited with signal ${result.signal}.`);
553
+ }
554
+
555
+ return result;
556
+ }
557
+
558
+ /**
559
+ * @param {string} stdout
560
+ */
561
+ function splitPythonResult(stdout) {
562
+ const normalized = stdout.replace(/\r/g, "");
563
+ const marker = `\n${RESULT_JSON_MARKER}\n`;
564
+
565
+ let markerIndex = normalized.lastIndexOf(marker);
566
+ let markerLength = marker.length;
567
+
568
+ if (markerIndex === -1 && normalized.startsWith(`${RESULT_JSON_MARKER}\n`)) {
569
+ markerIndex = 0;
570
+ markerLength = RESULT_JSON_MARKER.length + 1;
571
+ }
572
+
573
+ if (markerIndex === -1) {
574
+ return {
575
+ stdout: normalizeOutput(normalized),
576
+ resultJson: undefined,
577
+ };
578
+ }
579
+
580
+ const before = normalized.slice(0, markerIndex);
581
+ const after = normalized.slice(markerIndex + markerLength).trim();
582
+
583
+ return {
584
+ stdout: normalizeOutput(before),
585
+ resultJson: after.length > 0 ? after : undefined,
586
+ };
587
+ }
588
+
589
+ /**
590
+ * @param {{ code: string; input_json?: string; timeout_ms: number }} request
591
+ * @param {{ command: string }} pythonInfo
592
+ */
593
+ async function runPython(request, pythonInfo) {
594
+ const env = {
595
+ ...process.env,
596
+ PI_USER_CODE: request.code,
597
+ PI_INPUT_JSON: request.input_json || "",
598
+ };
599
+
600
+ const result = await runCommandCapture({
601
+ command: pythonInfo.command,
602
+ args: ["-I", "-c", PYTHON_WRAPPER_CODE],
603
+ timeoutMs: request.timeout_ms,
604
+ env,
605
+ });
606
+
607
+ if (result.code !== 0) {
608
+ const message = [result.stderr, result.stdout]
609
+ .map((value) => value.trim())
610
+ .find((value) => value.length > 0) || `python exited with code ${result.code}`;
611
+
612
+ throw new HttpError(400, message);
613
+ }
614
+
615
+ const parsed = splitPythonResult(result.stdout);
616
+
617
+ if (parsed.resultJson) {
618
+ try {
619
+ void JSON.parse(parsed.resultJson);
620
+ } catch {
621
+ throw new HttpError(500, "Bridge produced invalid result_json payload.");
622
+ }
623
+ }
624
+
625
+ return {
626
+ ok: true,
627
+ action: "run_python",
628
+ exit_code: result.code,
629
+ stdout: parsed.stdout,
630
+ stderr: normalizeOutput(result.stderr),
631
+ result_json: parsed.resultJson,
632
+ truncated: false,
633
+ };
634
+ }
635
+
636
+ /**
637
+ * @param {{ input_path: string; target_format: string; output_path?: string; overwrite: boolean; timeout_ms: number }} request
638
+ */
639
+ function resolveOutputPath(request) {
640
+ if (request.output_path) {
641
+ return request.output_path;
642
+ }
643
+
644
+ const parsed = path.parse(request.input_path);
645
+ return path.join(parsed.dir, `${parsed.name}.${request.target_format}`);
646
+ }
647
+
648
+ /**
649
+ * @param {string} filePath
650
+ */
651
+ function ensureFileExists(filePath) {
652
+ let stats;
653
+ try {
654
+ stats = fs.statSync(filePath);
655
+ } catch {
656
+ throw new HttpError(400, `input_path does not exist: ${filePath}`);
657
+ }
658
+
659
+ if (!stats.isFile()) {
660
+ throw new HttpError(400, `input_path is not a file: ${filePath}`);
661
+ }
662
+ }
663
+
664
+ /**
665
+ * @param {string} outputPath
666
+ * @param {boolean} overwrite
667
+ */
668
+ function ensureOutputWritable(outputPath, overwrite) {
669
+ if (fs.existsSync(outputPath) && !overwrite) {
670
+ throw new HttpError(409, `output_path already exists: ${outputPath}`);
671
+ }
672
+
673
+ const outputDir = path.dirname(outputPath);
674
+ if (!fs.existsSync(outputDir)) {
675
+ throw new HttpError(400, `output_path directory does not exist: ${outputDir}`);
676
+ }
677
+ }
678
+
679
+ /**
680
+ * @param {string} tempDir
681
+ * @param {string} inputPath
682
+ * @param {string} targetFormat
683
+ */
684
+ function findConvertedFile(tempDir, inputPath, targetFormat) {
685
+ const baseName = path.parse(inputPath).name;
686
+ const expected = path.join(tempDir, `${baseName}.${targetFormat}`);
687
+ if (fs.existsSync(expected)) {
688
+ return expected;
689
+ }
690
+
691
+ const entries = fs.readdirSync(tempDir, { withFileTypes: true });
692
+ for (const entry of entries) {
693
+ if (!entry.isFile()) continue;
694
+ if (!entry.name.toLowerCase().endsWith(`.${targetFormat}`)) continue;
695
+ return path.join(tempDir, entry.name);
696
+ }
697
+
698
+ return null;
699
+ }
700
+
701
+ /**
702
+ * @param {{ input_path: string; target_format: string; output_path?: string; overwrite: boolean; timeout_ms: number }} request
703
+ * @param {{ command: string }} libreOfficeInfo
704
+ */
705
+ async function runLibreOfficeConvert(request, libreOfficeInfo) {
706
+ ensureFileExists(request.input_path);
707
+
708
+ const outputPath = resolveOutputPath(request);
709
+ ensureOutputWritable(outputPath, request.overwrite);
710
+
711
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-libreoffice-"));
712
+
713
+ try {
714
+ const result = await runCommandCapture({
715
+ command: libreOfficeInfo.command,
716
+ args: [
717
+ "--headless",
718
+ "--convert-to",
719
+ request.target_format,
720
+ "--outdir",
721
+ tempDir,
722
+ request.input_path,
723
+ ],
724
+ timeoutMs: request.timeout_ms,
725
+ env: process.env,
726
+ });
727
+
728
+ if (result.code !== 0) {
729
+ const message = [result.stderr, result.stdout]
730
+ .map((value) => value.trim())
731
+ .find((value) => value.length > 0) || `LibreOffice exited with code ${result.code}`;
732
+ throw new HttpError(400, message);
733
+ }
734
+
735
+ const convertedPath = findConvertedFile(tempDir, request.input_path, request.target_format);
736
+ if (!convertedPath) {
737
+ throw new HttpError(500, "LibreOffice did not produce an output file.");
738
+ }
739
+
740
+ fs.copyFileSync(convertedPath, outputPath);
741
+
742
+ const stats = fs.statSync(outputPath);
743
+
744
+ return {
745
+ ok: true,
746
+ action: "convert",
747
+ input_path: request.input_path,
748
+ target_format: request.target_format,
749
+ output_path: outputPath,
750
+ bytes: stats.size,
751
+ converter: libreOfficeInfo.command,
752
+ };
753
+ } finally {
754
+ fs.rmSync(tempDir, { recursive: true, force: true });
755
+ }
756
+ }
757
+
758
+ function createStubBackend() {
759
+ return {
760
+ mode: "stub",
761
+ async health() {
762
+ return {
763
+ backend: "stub",
764
+ python: {
765
+ available: true,
766
+ mode: "stub",
767
+ },
768
+ libreoffice: {
769
+ available: true,
770
+ mode: "stub",
771
+ },
772
+ };
773
+ },
774
+
775
+ /**
776
+ * @param {{ code: string; input_json?: string; timeout_ms: number }} request
777
+ */
778
+ async handlePython(request) {
779
+ let resultJson;
780
+ if (request.input_json) {
781
+ resultJson = request.input_json;
782
+ }
783
+
784
+ return {
785
+ ok: true,
786
+ action: "run_python",
787
+ exit_code: 0,
788
+ stdout: "[stub] Python execution simulated.",
789
+ result_json: resultJson,
790
+ truncated: false,
791
+ };
792
+ },
793
+
794
+ /**
795
+ * @param {{ input_path: string; target_format: string; output_path?: string; overwrite: boolean; timeout_ms: number }} request
796
+ */
797
+ async handleLibreOffice(request) {
798
+ const outputPath = resolveOutputPath(request);
799
+
800
+ return {
801
+ ok: true,
802
+ action: "convert",
803
+ input_path: request.input_path,
804
+ target_format: request.target_format,
805
+ output_path: outputPath,
806
+ bytes: 0,
807
+ converter: "stub",
808
+ };
809
+ },
810
+ };
811
+ }
812
+
813
+ function createRealBackend() {
814
+ const pythonInfo = probeBinary(PYTHON_BIN, ["--version"]);
815
+ const libreOfficeInfo = probeLibreOfficeBinary();
816
+
817
+ if (!pythonInfo.available) {
818
+ console.warn(
819
+ `[pi-for-excel] Python binary "${PYTHON_BIN}" is unavailable. ` +
820
+ "python_run and python_transform_range will fail until PYTHON_BRIDGE_PYTHON_BIN is set to a valid executable.",
821
+ );
822
+ }
823
+
824
+ if (!libreOfficeInfo.available) {
825
+ console.warn(
826
+ "[pi-for-excel] LibreOffice binary is unavailable. " +
827
+ "python_run can still work, but libreoffice_convert requires installing LibreOffice (soffice/libreoffice) " +
828
+ "or setting PYTHON_BRIDGE_LIBREOFFICE_BIN.",
829
+ );
830
+ }
831
+
832
+ return {
833
+ mode: "real",
834
+ async health() {
835
+ return {
836
+ backend: "real",
837
+ python: pythonInfo.available
838
+ ? {
839
+ available: true,
840
+ command: pythonInfo.command,
841
+ version: pythonInfo.version,
842
+ }
843
+ : {
844
+ available: false,
845
+ command: pythonInfo.command,
846
+ error: pythonInfo.error,
847
+ },
848
+ libreoffice: libreOfficeInfo.available
849
+ ? {
850
+ available: true,
851
+ command: libreOfficeInfo.command,
852
+ version: libreOfficeInfo.version,
853
+ }
854
+ : {
855
+ available: false,
856
+ command: libreOfficeInfo.command,
857
+ error: libreOfficeInfo.error,
858
+ },
859
+ };
860
+ },
861
+
862
+ /**
863
+ * @param {{ code: string; input_json?: string; timeout_ms: number }} request
864
+ */
865
+ async handlePython(request) {
866
+ if (!pythonInfo.available) {
867
+ throw new HttpError(501, "python binary not available");
868
+ }
869
+
870
+ return runPython(request, { command: pythonInfo.command });
871
+ },
872
+
873
+ /**
874
+ * @param {{ input_path: string; target_format: string; output_path?: string; overwrite: boolean; timeout_ms: number }} request
875
+ */
876
+ async handleLibreOffice(request) {
877
+ if (!libreOfficeInfo.available) {
878
+ throw new HttpError(501, "libreoffice binary not available");
879
+ }
880
+
881
+ return runLibreOfficeConvert(request, { command: libreOfficeInfo.command });
882
+ },
883
+ };
884
+ }
885
+
886
+ const backend = MODE === "real" ? createRealBackend() : createStubBackend();
887
+
888
+ const handler = async (req, res) => {
889
+ try {
890
+ const remote = req.socket?.remoteAddress;
891
+ if (!isLoopbackAddress(remote)) {
892
+ respondText(res, 403, "forbidden");
893
+ return;
894
+ }
895
+
896
+ const origin = req.headers.origin;
897
+ if (!isAllowedOrigin(origin)) {
898
+ respondText(res, 403, "forbidden");
899
+ return;
900
+ }
901
+
902
+ setCorsHeaders(req, res);
903
+
904
+ if (req.method === "OPTIONS") {
905
+ res.statusCode = 204;
906
+ res.end();
907
+ return;
908
+ }
909
+
910
+ const rawUrl = req.url || "/";
911
+ const url = new URL(rawUrl, `http://${HOST}:${PORT}`);
912
+
913
+ if (url.pathname === "/health") {
914
+ if (req.method !== "GET") {
915
+ throw new HttpError(405, "Method not allowed.");
916
+ }
917
+
918
+ respondJson(res, 200, {
919
+ ok: true,
920
+ mode: backend.mode,
921
+ ...await backend.health(),
922
+ });
923
+ return;
924
+ }
925
+
926
+ if (url.pathname === "/v1/python-run") {
927
+ if (req.method !== "POST") {
928
+ throw new HttpError(405, "Method not allowed.");
929
+ }
930
+
931
+ if (!isAuthorized(req)) {
932
+ throw new HttpError(401, "Unauthorized.");
933
+ }
934
+
935
+ const payload = await readJsonBody(req);
936
+ const request = parsePythonRunRequest(payload);
937
+ const result = await backend.handlePython(request);
938
+
939
+ respondJson(res, 200, {
940
+ ok: true,
941
+ ...result,
942
+ });
943
+ return;
944
+ }
945
+
946
+ if (url.pathname === "/v1/libreoffice-convert") {
947
+ if (req.method !== "POST") {
948
+ throw new HttpError(405, "Method not allowed.");
949
+ }
950
+
951
+ if (!isAuthorized(req)) {
952
+ throw new HttpError(401, "Unauthorized.");
953
+ }
954
+
955
+ const payload = await readJsonBody(req);
956
+ const request = parseLibreOfficeRequest(payload);
957
+ const result = await backend.handleLibreOffice(request);
958
+
959
+ respondJson(res, 200, {
960
+ ok: true,
961
+ ...result,
962
+ });
963
+ return;
964
+ }
965
+
966
+ throw new HttpError(404, "Not found.");
967
+ } catch (error) {
968
+ const isHttpError = error instanceof HttpError;
969
+ const status = isHttpError ? error.status : 500;
970
+
971
+ if (!isHttpError) {
972
+ console.error("[pi-for-excel] Unhandled python bridge error:", error);
973
+ }
974
+
975
+ const message = isHttpError
976
+ ? error.message
977
+ : "Internal server error.";
978
+
979
+ respondJson(res, status, {
980
+ ok: false,
981
+ error: message,
982
+ });
983
+ }
984
+ };
985
+
986
+ const server = (() => {
987
+ if (!useHttps) {
988
+ return http.createServer(handler);
989
+ }
990
+
991
+ if (!fs.existsSync(keyPath) || !fs.existsSync(certPath)) {
992
+ console.error("[pi-for-excel] HTTPS requested but key.pem/cert.pem not found in repo root.");
993
+ console.error("Generate them with mkcert (see README). Example: mkcert localhost");
994
+ process.exit(1);
995
+ }
996
+
997
+ return https.createServer(
998
+ {
999
+ key: fs.readFileSync(keyPath),
1000
+ cert: fs.readFileSync(certPath),
1001
+ },
1002
+ handler,
1003
+ );
1004
+ })();
1005
+
1006
+ server.listen(PORT, HOST, () => {
1007
+ const scheme = useHttps ? "https" : "http";
1008
+ console.log(`[pi-for-excel] python bridge listening on ${scheme}://${HOST}:${PORT}`);
1009
+ console.log(`[pi-for-excel] mode: ${backend.mode}`);
1010
+ console.log(`[pi-for-excel] health: ${scheme}://${HOST}:${PORT}/health`);
1011
+ console.log(`[pi-for-excel] endpoint: ${scheme}://${HOST}:${PORT}/v1/python-run`);
1012
+ console.log(`[pi-for-excel] endpoint: ${scheme}://${HOST}:${PORT}/v1/libreoffice-convert`);
1013
+ console.log(`[pi-for-excel] allowed origins: ${Array.from(allowedOrigins).join(", ")}`);
1014
+
1015
+ if (authToken) {
1016
+ console.log("[pi-for-excel] auth: bearer token required for POST endpoints");
1017
+ }
1018
+
1019
+ if (backend.mode === "stub") {
1020
+ console.log("[pi-for-excel] stub mode: python/libreoffice calls are simulated.");
1021
+ console.log("[pi-for-excel] use PYTHON_BRIDGE_MODE=real for local command execution.");
1022
+ }
1023
+ });