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.
- package/LICENSE +21 -0
- package/README.md +53 -0
- package/cli.mjs +372 -0
- package/package.json +22 -0
- package/scripts/python-bridge-server.mjs +1023 -0
|
@@ -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
|
+
});
|