@hwp-editor/server 1.0.0-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs ADDED
@@ -0,0 +1,964 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/index.ts
31
+ var index_exports = {};
32
+ __export(index_exports, {
33
+ DEFAULT_TTL_MS: () => DEFAULT_TTL_MS,
34
+ HWP_TIMEOUT_MS: () => HWP_TIMEOUT_MS,
35
+ HwpCliError: () => HwpCliError,
36
+ SessionNotFoundError: () => SessionNotFoundError,
37
+ createCliEngine: () => createCliEngine,
38
+ createHwpEditorHandler: () => createHwpEditorHandler,
39
+ createSessionStore: () => createSessionStore
40
+ });
41
+ module.exports = __toCommonJS(index_exports);
42
+
43
+ // src/cli-engine.ts
44
+ var import_node_child_process = require("child_process");
45
+ var import_node_crypto = require("crypto");
46
+ var import_promises = require("fs/promises");
47
+ var import_node_os = require("os");
48
+ var import_node_path = __toESM(require("path"), 1);
49
+ var import_core = require("@hwp-editor/core");
50
+ var HWP_TIMEOUT_MS = 6e4;
51
+ var HWP_MAX_BUFFER = 32 * 1024 * 1024;
52
+ var KILL_GRACE_MS = 3e3;
53
+ var MIN_VERSION = [0, 16, 0];
54
+ var MAX_VERSION_EXCLUSIVE = [1, 0, 0];
55
+ var FLAG_TOKEN = /(?:^|\s)(--[a-z][a-z0-9-]*)(?=[\s,=<]|$)/gm;
56
+ var HANDSHAKE_FLAGS = [
57
+ ...Object.values(import_core.OP_FLAGS),
58
+ "--verify",
59
+ "--allow-partial"
60
+ ];
61
+ var CFBF_SIGNATURE = [208, 207, 17, 224, 161, 177, 26, 225];
62
+ var PNG_SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10];
63
+ var HwpCliError = class extends Error {
64
+ constructor(reason, message, stderr, detail) {
65
+ super(message);
66
+ this.reason = reason;
67
+ this.stderr = stderr;
68
+ this.detail = detail;
69
+ this.name = "HwpCliError";
70
+ }
71
+ reason;
72
+ stderr;
73
+ detail;
74
+ };
75
+ function detailFor(bin, output) {
76
+ const trimmed = output?.trim() ?? "";
77
+ return trimmed === "" ? bin : `${bin}: ${trimmed}`;
78
+ }
79
+ var HWP_ENV_ALLOWLIST = ["HWP_FONT_DIR"];
80
+ function scrubbedEnv(locale) {
81
+ const env = {};
82
+ for (const key of ["PATH", "HOME"]) {
83
+ const value = process.env[key];
84
+ if (value !== void 0) env[key] = value;
85
+ }
86
+ for (const key of HWP_ENV_ALLOWLIST) {
87
+ const value = process.env[key];
88
+ if (value !== void 0) env[key] = value;
89
+ }
90
+ env.LANG = "C.UTF-8";
91
+ env.LC_ALL = "C.UTF-8";
92
+ env.LC_MESSAGES = "C.UTF-8";
93
+ env.HWP_LANG = locale?.trim() || "en";
94
+ return env;
95
+ }
96
+ function runCli(bin, args, timeoutMs = HWP_TIMEOUT_MS, locale, requestSignal) {
97
+ return new Promise((resolve, reject) => {
98
+ if (requestSignal?.aborted === true) {
99
+ reject(new HwpCliError("cancelled", `hwp ${args[0] ?? ""} was cancelled by the caller`));
100
+ return;
101
+ }
102
+ let cause = null;
103
+ let escalation;
104
+ const signalChild = () => {
105
+ if (escalation !== void 0) return;
106
+ child.kill("SIGTERM");
107
+ escalation = setTimeout(() => child.kill("SIGKILL"), KILL_GRACE_MS);
108
+ escalation.unref();
109
+ };
110
+ const timer = setTimeout(() => {
111
+ cause = "timeout";
112
+ signalChild();
113
+ }, timeoutMs);
114
+ const onCancel = () => {
115
+ cause ??= "cancelled";
116
+ signalChild();
117
+ };
118
+ requestSignal?.addEventListener("abort", onCancel, { once: true });
119
+ const child = (0, import_node_child_process.execFile)(
120
+ bin,
121
+ args,
122
+ {
123
+ maxBuffer: HWP_MAX_BUFFER,
124
+ env: scrubbedEnv(locale),
125
+ encoding: "utf8"
126
+ },
127
+ (error2, stdout, stderr) => {
128
+ clearTimeout(timer);
129
+ if (escalation !== void 0) clearTimeout(escalation);
130
+ requestSignal?.removeEventListener("abort", onCancel);
131
+ if (cause === "timeout") {
132
+ reject(new HwpCliError("timeout", `hwp ${args[0] ?? ""} timed out after ${timeoutMs}ms`));
133
+ return;
134
+ }
135
+ if (cause === "cancelled") {
136
+ reject(new HwpCliError("cancelled", `hwp ${args[0] ?? ""} was cancelled by the caller`));
137
+ return;
138
+ }
139
+ if (error2 === null) {
140
+ resolve({ stdout, stderr, code: 0 });
141
+ return;
142
+ }
143
+ const raw = error2.code;
144
+ if (raw === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") {
145
+ reject(new HwpCliError(
146
+ "output_too_large",
147
+ `hwp ${args[0] ?? ""} produced more than ${HWP_MAX_BUFFER} bytes on stdout`
148
+ ));
149
+ return;
150
+ }
151
+ if (raw === "ENOENT") {
152
+ reject(new HwpCliError(
153
+ "unavailable",
154
+ "hwp binary not found (install hwp-cli >= 0.16.0, or set HWP_EDITOR_BIN / the bin option)",
155
+ void 0,
156
+ detailFor(bin)
157
+ ));
158
+ return;
159
+ }
160
+ resolve({ stdout, stderr, code: typeof raw === "number" ? raw : 1 });
161
+ }
162
+ );
163
+ });
164
+ }
165
+ async function runCliOk(bin, args, timeoutMs, locale, requestSignal) {
166
+ const result = await runCli(bin, args, timeoutMs, locale, requestSignal);
167
+ if (result.code !== 0) {
168
+ throw new HwpCliError(
169
+ "failed",
170
+ `hwp ${args[0] ?? ""} failed (exit ${result.code})`,
171
+ result.stderr,
172
+ detailFor(bin, result.stderr.trim() || result.stdout.trim())
173
+ );
174
+ }
175
+ return result;
176
+ }
177
+ function parseVersion(stdout) {
178
+ const match = stdout.match(/(\d+)\.(\d+)\.(\d+)/);
179
+ if (match === null) return null;
180
+ return [Number(match[1]), Number(match[2]), Number(match[3])];
181
+ }
182
+ function versionAtLeast(v, min) {
183
+ for (let i = 0; i < 3; i++) {
184
+ if (v[i] > min[i]) return true;
185
+ if (v[i] < min[i]) return false;
186
+ }
187
+ return true;
188
+ }
189
+ function sha256(data) {
190
+ return (0, import_node_crypto.createHash)("sha256").update(data).digest("hex");
191
+ }
192
+ var DEFAULT_CALL_SCOPE = "default";
193
+ function cacheKey(scope, data) {
194
+ return (0, import_node_crypto.createHash)("sha256").update(`${scope ?? DEFAULT_CALL_SCOPE}\0${sha256(data)}`).digest("hex");
195
+ }
196
+ function sniffExtension(document) {
197
+ const ext = import_node_path.default.extname(document.name).toLowerCase();
198
+ if (ext === ".hwp" || ext === ".hwpx") return ext;
199
+ const isCfbf = document.data.length >= CFBF_SIGNATURE.length && CFBF_SIGNATURE.every((byte, i) => document.data[i] === byte);
200
+ return isCfbf ? ".hwp" : ".hwpx";
201
+ }
202
+ function safeOutputName(name, fallbackExt) {
203
+ const base = import_node_path.default.basename(name).replace(/[^\w.가-힣-]/g, "_") || `document${fallbackExt}`;
204
+ const ext = import_node_path.default.extname(base).toLowerCase();
205
+ if (ext === ".hwp" || ext === ".hwpx") return base;
206
+ return `${base}${fallbackExt}`;
207
+ }
208
+ async function tryJson(bin, args, timeoutMs, locale, requestSignal) {
209
+ try {
210
+ const result = await runCliOk(bin, args, timeoutMs, locale, requestSignal);
211
+ return JSON.parse(result.stdout);
212
+ } catch (error2) {
213
+ if (error2 instanceof HwpCliError && error2.reason === "cancelled") throw error2;
214
+ return null;
215
+ }
216
+ }
217
+ var PROTECTED_ATTRIBUTES = {
218
+ "DRM \uBCF4\uC548": "DRM-protected document (DRM \uBCF4\uC548)",
219
+ "\uACF5\uC778 \uC778\uC99D\uC11C \uC554\uD638\uD654": "certificate-encrypted document (\uACF5\uC778 \uC778\uC99D\uC11C \uC554\uD638\uD654)",
220
+ "\uACF5\uC778 \uC778\uC99D\uC11C DRM \uBCF4\uC548": "certificate DRM-protected document (\uACF5\uC778 \uC778\uC99D\uC11C DRM \uBCF4\uC548)",
221
+ "\uC804\uC790 \uC11C\uBA85 \uC815\uBCF4": "signed document (\uC804\uC790 \uC11C\uBA85 \uC815\uBCF4)"
222
+ };
223
+ function documentEditability(info) {
224
+ if (typeof info !== "object" || info === null) return { editable: true };
225
+ const record = info;
226
+ if (record["encrypted"] === true) {
227
+ return { editable: false, reason: "encrypted document; hwp-cli refuses edit/fill" };
228
+ }
229
+ if (record["distribution"] === true) {
230
+ return {
231
+ editable: false,
232
+ reason: "distribution (\uBC30\uD3EC\uC6A9) document; hwp-cli refuses edit/fill"
233
+ };
234
+ }
235
+ const attributes = record["attributes"];
236
+ if (Array.isArray(attributes)) {
237
+ for (const attribute of attributes) {
238
+ const label = typeof attribute === "string" ? PROTECTED_ATTRIBUTES[attribute] : void 0;
239
+ if (label !== void 0) {
240
+ return { editable: false, reason: `${label}; hwp-cli refuses edit/fill` };
241
+ }
242
+ }
243
+ }
244
+ return { editable: true };
245
+ }
246
+ function protectedReasonFromStderr(stderr) {
247
+ return (0, import_core.protectedReasonFromDiagnostics)(stderr);
248
+ }
249
+ function rethrowProtected(error2) {
250
+ if (error2 instanceof HwpCliError && error2.reason === "failed") {
251
+ const message = protectedReasonFromStderr(error2.stderr ?? "");
252
+ if (message !== null) throw new HwpCliError("protected", message, error2.stderr);
253
+ }
254
+ throw error2;
255
+ }
256
+ function positiveSize(width, height) {
257
+ if (!Number.isFinite(width) || !Number.isFinite(height)) return null;
258
+ if (width <= 0 || height <= 0) return null;
259
+ return { width, height };
260
+ }
261
+ function pngSize(data) {
262
+ if (data.length < 24) return null;
263
+ if (PNG_SIGNATURE.some((byte, i) => data[i] !== byte)) return null;
264
+ if (data[12] !== 73 || data[13] !== 72 || data[14] !== 68 || data[15] !== 82) return null;
265
+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
266
+ return positiveSize(view.getUint32(16), view.getUint32(20));
267
+ }
268
+ var SVG_UNITS = "pt|px|mm|in|cm|em|%";
269
+ function svgSize(source) {
270
+ const tag = source.match(/<svg\b[^>]*>/i);
271
+ if (tag === null) return null;
272
+ const width = tag[0].match(new RegExp(`\\bwidth="([\\d.]+)(${SVG_UNITS})?"`, "i"));
273
+ const height = tag[0].match(new RegExp(`\\bheight="([\\d.]+)(${SVG_UNITS})?"`, "i"));
274
+ if (width !== null && height !== null) {
275
+ if ((width[2] ?? "") !== (height[2] ?? "")) return null;
276
+ return positiveSize(Number(width[1]), Number(height[1]));
277
+ }
278
+ if (/\b(?:width|height)="/i.test(tag[0])) return null;
279
+ const viewBox = tag[0].match(/\bviewBox="[\d.-]+\s+[\d.-]+\s+([\d.]+)\s+([\d.]+)"/i);
280
+ if (viewBox === null) return null;
281
+ return positiveSize(Number(viewBox[1]), Number(viewBox[2]));
282
+ }
283
+ function parseSinglePage(pages) {
284
+ if (pages !== void 0 && /^\d+$/.test(pages)) return Number(pages);
285
+ return null;
286
+ }
287
+ function createCliEngine(opts = {}) {
288
+ const timeoutMs = opts.timeoutMs ?? HWP_TIMEOUT_MS;
289
+ function resolveBin() {
290
+ const fromOpts = opts.bin?.trim();
291
+ if (fromOpts) return fromOpts;
292
+ const fromEditorEnv = process.env.HWP_EDITOR_BIN?.trim();
293
+ if (fromEditorEnv) return fromEditorEnv;
294
+ const fromCliEnv = process.env.HWP_CLI?.trim();
295
+ if (fromCliEnv) return fromCliEnv;
296
+ return "hwp";
297
+ }
298
+ let verifiedVersion = null;
299
+ function ensureVersion() {
300
+ verifiedVersion ??= (async () => {
301
+ const bin = resolveBin();
302
+ let result;
303
+ try {
304
+ result = await runCli(bin, ["--version"], timeoutMs, opts.locale, void 0);
305
+ } catch (error2) {
306
+ if (error2 instanceof HwpCliError) throw error2;
307
+ throw new HwpCliError(
308
+ "unavailable",
309
+ "hwp binary is not executable (set HWP_EDITOR_BIN or the bin option)",
310
+ void 0,
311
+ detailFor(bin, error2 instanceof Error ? error2.message : String(error2))
312
+ );
313
+ }
314
+ if (result.code !== 0) {
315
+ throw new HwpCliError(
316
+ "unavailable",
317
+ `hwp --version failed (exit ${result.code})`,
318
+ result.stderr,
319
+ detailFor(bin, result.stderr)
320
+ );
321
+ }
322
+ const version = parseVersion(result.stdout);
323
+ if (version === null) {
324
+ throw new HwpCliError(
325
+ "version",
326
+ "cannot parse a semver from the hwp --version output",
327
+ void 0,
328
+ detailFor(bin, result.stdout)
329
+ );
330
+ }
331
+ if (!versionAtLeast(version, MIN_VERSION)) {
332
+ throw new HwpCliError(
333
+ "version",
334
+ `hwp ${version.join(".")} is too old; >= ${MIN_VERSION.join(".")} required`,
335
+ void 0,
336
+ detailFor(bin)
337
+ );
338
+ }
339
+ if (versionAtLeast(version, MAX_VERSION_EXCLUSIVE)) {
340
+ throw new HwpCliError(
341
+ "version",
342
+ `hwp ${version.join(".")} is newer than this engine supports; < ${MAX_VERSION_EXCLUSIVE.join(".")} required`,
343
+ void 0,
344
+ detailFor(bin)
345
+ );
346
+ }
347
+ const help = await runCli(bin, ["edit", "--help"], timeoutMs, opts.locale, void 0);
348
+ if (help.code !== 0) {
349
+ throw new HwpCliError(
350
+ "version",
351
+ `hwp edit --help failed (exit ${help.code}); the edit flag surface cannot be verified`,
352
+ help.stderr,
353
+ detailFor(bin, help.stderr)
354
+ );
355
+ }
356
+ const present = new Set([...help.stdout.matchAll(FLAG_TOKEN)].map((match) => match[1]));
357
+ const missing = HANDSHAKE_FLAGS.filter((flag) => !present.has(flag));
358
+ if (missing.length > 0) {
359
+ throw new HwpCliError(
360
+ "version",
361
+ `hwp ${version.join(".")} does not accept ${missing.join(", ")} on edit; the binary does not match this engine's edit grammar`,
362
+ void 0,
363
+ detailFor(bin)
364
+ );
365
+ }
366
+ return version.join(".");
367
+ })();
368
+ return verifiedVersion;
369
+ }
370
+ async function withWorkDir(fn) {
371
+ const dir = await (0, import_promises.mkdtemp)(import_node_path.default.join((0, import_node_os.tmpdir)(), "hwp-editor-"));
372
+ try {
373
+ return await fn(dir);
374
+ } finally {
375
+ await (0, import_promises.rm)(dir, { recursive: true, force: true });
376
+ }
377
+ }
378
+ async function stage(dir, document) {
379
+ const file = import_node_path.default.join(dir, `in${sniffExtension(document)}`);
380
+ await (0, import_promises.writeFile)(file, document.data, { mode: 384 });
381
+ return file;
382
+ }
383
+ const inspections = /* @__PURE__ */ new Map();
384
+ const snapshots = /* @__PURE__ */ new Map();
385
+ async function describe(document, call) {
386
+ await ensureVersion();
387
+ const bin = resolveBin();
388
+ const key = cacheKey(call?.scope, document.data);
389
+ const cached = inspections.get(key);
390
+ if (cached !== void 0) return cached;
391
+ const signal = call?.signal;
392
+ const inspection = await withWorkDir(async (dir) => {
393
+ const file = await stage(dir, document);
394
+ const cat = await runCliOk(bin, ["cat", file, "--format", "markdown", "--with-segments"], timeoutMs, opts.locale, signal);
395
+ const envelope = (0, import_core.parseCatEnvelope)(cat.stdout);
396
+ const [fields, bookmarks, slots, info] = await Promise.all([
397
+ tryJson(bin, ["fields", file, "--json"], timeoutMs, opts.locale, signal),
398
+ tryJson(bin, ["bookmarks", file, "--json"], timeoutMs, opts.locale, signal),
399
+ tryJson(bin, ["slots", file, "--json"], timeoutMs, opts.locale, signal),
400
+ tryJson(bin, ["info", file, "--json"], timeoutMs, opts.locale, signal)
401
+ ]);
402
+ return {
403
+ envelope,
404
+ fields,
405
+ bookmarks,
406
+ slots,
407
+ info,
408
+ capabilities: documentEditability(info)
409
+ };
410
+ });
411
+ if (inspections.size >= 64) {
412
+ const oldest = inspections.keys().next().value;
413
+ if (oldest !== void 0) inspections.delete(oldest);
414
+ }
415
+ inspections.set(key, inspection);
416
+ return inspection;
417
+ }
418
+ const engine = {
419
+ async read(document, call) {
420
+ return (await describe(document, call)).envelope;
421
+ },
422
+ describe,
423
+ async render(document, options = {}, call) {
424
+ await ensureVersion();
425
+ const bin = resolveBin();
426
+ const requested = options.format ?? "svg";
427
+ if (requested === "jpeg" || requested === "webp") {
428
+ throw new HwpCliError(
429
+ "unsupported_format",
430
+ `hwp-cli render supports png and svg only; got "${requested}"`
431
+ );
432
+ }
433
+ const dpi = options.dpi ?? 96;
434
+ if (!Number.isFinite(dpi) || dpi < 36 || dpi > 600) {
435
+ throw new HwpCliError("bad_request", `dpi must be within 36..=600; got ${options.dpi}`);
436
+ }
437
+ const pages = options.pages ?? "all";
438
+ if (pages !== "all" && !/^\d+(-\d+)?$/.test(pages)) {
439
+ throw new HwpCliError("bad_request", `invalid page range: ${pages}`);
440
+ }
441
+ return withWorkDir(async (dir) => {
442
+ const input = await stage(dir, document);
443
+ const attempt = async (format) => {
444
+ const outBase = import_node_path.default.join(dir, `page.${format}`);
445
+ const reportPath = import_node_path.default.join(dir, "render-report.json");
446
+ await runCliOk(bin, [
447
+ "render",
448
+ input,
449
+ "-o",
450
+ outBase,
451
+ "--format",
452
+ format,
453
+ "--pages",
454
+ pages,
455
+ "--dpi",
456
+ String(dpi),
457
+ "--report",
458
+ reportPath
459
+ ], timeoutMs, opts.locale, call?.signal);
460
+ const filePattern = new RegExp(`^page-(\\d+)\\.${format}$`);
461
+ const files = (await (0, import_promises.readdir)(dir)).filter((f) => f === `page.${format}` || filePattern.test(f)).sort((a, b) => {
462
+ const na = Number(filePattern.exec(a)?.[1] ?? 0);
463
+ const nb = Number(filePattern.exec(b)?.[1] ?? 0);
464
+ return na - nb;
465
+ });
466
+ let selected = null;
467
+ try {
468
+ const report = JSON.parse(await (0, import_promises.readFile)(reportPath, "utf8"));
469
+ if (Array.isArray(report.selected_pages)) {
470
+ selected = report.selected_pages.filter((n) => typeof n === "number");
471
+ }
472
+ } catch {
473
+ selected = null;
474
+ }
475
+ const images = [];
476
+ for (let i = 0; i < files.length; i++) {
477
+ const file = files[i];
478
+ const data = new Uint8Array(await (0, import_promises.readFile)(import_node_path.default.join(dir, file)));
479
+ const suffix = file.match(/^page-(\d+)\./);
480
+ const page = suffix !== null ? Number(suffix[1]) : selected?.[i] ?? parseSinglePage(options.pages) ?? i + 1;
481
+ const size = format === "png" ? pngSize(data) : svgSize(Buffer.from(data).toString("utf8"));
482
+ if (size === null) {
483
+ throw new HwpCliError(
484
+ "failed",
485
+ `unreadable ${format} page dimensions on page ${page}`,
486
+ void 0,
487
+ detailFor(bin, file)
488
+ );
489
+ }
490
+ images.push({
491
+ page,
492
+ width: size.width,
493
+ height: size.height,
494
+ dpi,
495
+ format,
496
+ data
497
+ });
498
+ }
499
+ return images;
500
+ };
501
+ try {
502
+ return await attempt(requested);
503
+ } catch (error2) {
504
+ if (requested === "svg" && error2 instanceof HwpCliError && error2.reason === "failed") {
505
+ return attempt("png");
506
+ }
507
+ throw error2;
508
+ }
509
+ });
510
+ },
511
+ async edit(document, ops, options = {}, call) {
512
+ await ensureVersion();
513
+ const bin = resolveBin();
514
+ if (!Array.isArray(ops)) {
515
+ throw new HwpCliError("bad_request", "ops must be an array of edit operations");
516
+ }
517
+ const ext = sniffExtension(document);
518
+ const edited = await withWorkDir(async (dir) => {
519
+ const input = await stage(dir, document);
520
+ const cached = inspections.get(cacheKey(call?.scope, document.data))?.capabilities;
521
+ const capabilities = cached ?? documentEditability(await tryJson(bin, ["info", input, "--json"], timeoutMs, opts.locale, call?.signal));
522
+ if (!capabilities.editable) {
523
+ throw new HwpCliError(
524
+ "protected",
525
+ capabilities.reason ?? "protected document; hwp-cli refuses edit/compose"
526
+ );
527
+ }
528
+ const output = import_node_path.default.join(dir, `out${ext}`);
529
+ const args = ["edit", input, "-o", output, ...(0, import_core.opsToArgv)(ops)];
530
+ if (options.verify !== false) args.push("--verify");
531
+ if (options.allowPartial === true) args.push("--allow-partial");
532
+ await runCliOk(bin, args, timeoutMs, opts.locale, call?.signal).catch(rethrowProtected);
533
+ return new Uint8Array(await (0, import_promises.readFile)(output));
534
+ });
535
+ snapshots.set(cacheKey(call?.scope, edited), { name: document.name, data: document.data });
536
+ if (snapshots.size > 256) {
537
+ const oldest = snapshots.keys().next().value;
538
+ if (oldest !== void 0) snapshots.delete(oldest);
539
+ }
540
+ return { name: document.name, data: edited };
541
+ },
542
+ undo(document, call) {
543
+ const key = cacheKey(call?.scope, document.data);
544
+ const snapshot = snapshots.get(key) ?? null;
545
+ if (snapshot !== null) snapshots.delete(key);
546
+ return snapshot;
547
+ },
548
+ async compose(spec, name, call) {
549
+ await ensureVersion();
550
+ const bin = resolveBin();
551
+ const outName = safeOutputName(name, ".hwpx");
552
+ return withWorkDir(async (dir) => {
553
+ const specPath = import_node_path.default.join(dir, "spec.json");
554
+ await (0, import_promises.writeFile)(specPath, JSON.stringify(spec), { mode: 384 });
555
+ const outPath = import_node_path.default.join(dir, outName);
556
+ const result = await runCliOk(
557
+ bin,
558
+ ["compose", specPath, "-o", outPath, "--report"],
559
+ timeoutMs,
560
+ opts.locale,
561
+ call?.signal
562
+ ).catch(rethrowProtected);
563
+ let report;
564
+ try {
565
+ report = JSON.parse(result.stdout);
566
+ } catch {
567
+ report = void 0;
568
+ }
569
+ const data = new Uint8Array(await (0, import_promises.readFile)(outPath));
570
+ const composeResult = { document: { name: outName, data } };
571
+ if (report !== void 0) composeResult.report = report;
572
+ return composeResult;
573
+ });
574
+ },
575
+ async validate(document, call) {
576
+ await ensureVersion();
577
+ const bin = resolveBin();
578
+ return withWorkDir(async (dir) => {
579
+ const file = await stage(dir, document);
580
+ const result = await runCli(bin, ["validate", file, "--json"], timeoutMs, opts.locale, call?.signal);
581
+ let parsed;
582
+ try {
583
+ parsed = JSON.parse(result.stdout);
584
+ } catch {
585
+ throw new HwpCliError(
586
+ "failed",
587
+ `hwp validate failed (exit ${result.code}): no JSON report`,
588
+ result.stderr,
589
+ detailFor(bin, result.stderr.trim() || result.stdout.trim())
590
+ );
591
+ }
592
+ const rawErrors = Array.isArray(parsed.errors) ? parsed.errors : [];
593
+ const errors = rawErrors.map(
594
+ (entry) => typeof entry === "string" ? { code: "invalid", message: entry } : { code: "invalid", message: JSON.stringify(entry) }
595
+ );
596
+ const report = { valid: parsed.valid === true, errors };
597
+ return report;
598
+ });
599
+ },
600
+ async capabilities() {
601
+ const version = await ensureVersion();
602
+ return { version, editable: true, formats: ["hwp", "hwpx"] };
603
+ },
604
+ async binaryInfo() {
605
+ return { bin: resolveBin(), version: await ensureVersion() };
606
+ }
607
+ };
608
+ return engine;
609
+ }
610
+
611
+ // src/session.ts
612
+ var import_node_crypto2 = require("crypto");
613
+ var DEFAULT_TTL_MS = 30 * 60 * 1e3;
614
+ var SESSION_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
615
+ var SessionNotFoundError = class extends Error {
616
+ constructor(id) {
617
+ super(`unknown or expired session: ${id}`);
618
+ this.name = "SessionNotFoundError";
619
+ }
620
+ };
621
+ function createSessionStore(opts = {}) {
622
+ const ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
623
+ const sessions = /* @__PURE__ */ new Map();
624
+ function lookup(id) {
625
+ if (!SESSION_ID_PATTERN.test(id)) throw new SessionNotFoundError(id);
626
+ const session = sessions.get(id);
627
+ if (session === void 0) throw new SessionNotFoundError(id);
628
+ session.touchedAt = Date.now();
629
+ return session;
630
+ }
631
+ function sweepExpired(now = Date.now()) {
632
+ let removed = 0;
633
+ for (const [id, session] of [...sessions]) {
634
+ if (session.touchedAt + ttlMs < now) {
635
+ sessions.delete(id);
636
+ removed++;
637
+ }
638
+ }
639
+ return removed;
640
+ }
641
+ return {
642
+ create(name) {
643
+ sweepExpired();
644
+ const id = (0, import_node_crypto2.randomUUID)();
645
+ const now = Date.now();
646
+ const session = {
647
+ id,
648
+ // Basename only: the name rides out in nothing but this record, but a
649
+ // client-supplied string with a path in it should not be kept as one.
650
+ name: name.split(/[/\\]/).pop() || "document.hwpx",
651
+ createdAt: now,
652
+ touchedAt: now
653
+ };
654
+ sessions.set(id, session);
655
+ return session;
656
+ },
657
+ get(id) {
658
+ return lookup(id);
659
+ },
660
+ has(id) {
661
+ return sessions.has(id);
662
+ },
663
+ attachInspection(id, inspection) {
664
+ lookup(id).inspection = inspection;
665
+ },
666
+ sweep(now = Date.now()) {
667
+ return sweepExpired(now);
668
+ },
669
+ dispose() {
670
+ sessions.clear();
671
+ },
672
+ size() {
673
+ return sessions.size;
674
+ },
675
+ ids() {
676
+ return [...sessions.keys()];
677
+ }
678
+ };
679
+ }
680
+
681
+ // src/routes.ts
682
+ var import_node_crypto3 = require("crypto");
683
+ var ACTION_LIST = ["read", "render", "edit", "compose", "validate", "capabilities"];
684
+ var DEFAULT_SCOPE = "default";
685
+ var DEFAULT_MAX_REQUEST_BYTES = 50 * 1024 * 1024;
686
+ var ACTIONS = new Set(ACTION_LIST);
687
+ function json(body, status = 200) {
688
+ return new Response(JSON.stringify(body), {
689
+ status,
690
+ headers: { "content-type": "application/json; charset=utf-8" }
691
+ });
692
+ }
693
+ function error(status, code, message) {
694
+ const body = { error: { code, message } };
695
+ return json(body, status);
696
+ }
697
+ function statusFor(err) {
698
+ switch (err.reason) {
699
+ case "bad_request":
700
+ case "unsupported_format":
701
+ return 400;
702
+ case "unavailable":
703
+ return 503;
704
+ case "timeout":
705
+ return 504;
706
+ case "version":
707
+ return 500;
708
+ // The client went away; nothing was produced and nobody is listening.
709
+ case "cancelled":
710
+ return 499;
711
+ // The document's CLI output exceeded the 32 MiB stdout ceiling.
712
+ case "output_too_large":
713
+ return 413;
714
+ case "failed":
715
+ // 403 is deliberately left unclaimed for Phase 4's `authorize`
716
+ // rejections, so a host can tell an auth refusal from a document
717
+ // refusal by status alone.
718
+ case "protected":
719
+ return 422;
720
+ }
721
+ }
722
+ function toBase64(data) {
723
+ return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString("base64");
724
+ }
725
+ function sha2562(data) {
726
+ return (0, import_node_crypto3.createHash)("sha256").update(data).digest("hex");
727
+ }
728
+ function sha256Text(text) {
729
+ return (0, import_node_crypto3.createHash)("sha256").update(text).digest("hex");
730
+ }
731
+ var CFBF_SIGNATURE2 = [208, 207, 17, 224, 161, 177, 26, 225];
732
+ var HWPX_MIMETYPE = "application/hwp+zip";
733
+ function sniffFormat(d) {
734
+ if (d.length >= CFBF_SIGNATURE2.length && CFBF_SIGNATURE2.every((b, i) => d[i] === b)) {
735
+ return ".hwp";
736
+ }
737
+ if (d.length < 38) return null;
738
+ if (!(d[0] === 80 && d[1] === 75 && d[2] === 3 && d[3] === 4)) return null;
739
+ const view = new DataView(d.buffer, d.byteOffset, d.byteLength);
740
+ if (view.getUint16(8, true) !== 0) return null;
741
+ const nameLen = view.getUint16(26, true);
742
+ const extraLen = view.getUint16(28, true);
743
+ if (nameLen !== 8) return null;
744
+ if (new TextDecoder().decode(d.subarray(30, 38)) !== "mimetype") return null;
745
+ const start = 30 + nameLen + extraLen;
746
+ const end = start + HWPX_MIMETYPE.length;
747
+ if (d.length < end) return null;
748
+ return new TextDecoder().decode(d.subarray(start, end)) === HWPX_MIMETYPE ? ".hwpx" : null;
749
+ }
750
+ async function formDocument(req) {
751
+ let form;
752
+ try {
753
+ form = await req.formData();
754
+ } catch {
755
+ throw new HwpCliError("bad_request", "expected multipart/form-data with a file field");
756
+ }
757
+ const file = form.get("file");
758
+ if (file === null || typeof file === "string") {
759
+ throw new HwpCliError("bad_request", 'multipart field "file" is required');
760
+ }
761
+ const blob = file;
762
+ const name = "name" in blob && typeof blob.name === "string" && blob.name !== "" ? blob.name : "document.hwpx";
763
+ const data = new Uint8Array(await blob.arrayBuffer());
764
+ if (data.length === 0) {
765
+ throw new HwpCliError("bad_request", 'multipart field "file" is empty');
766
+ }
767
+ if (sniffFormat(data) === null) {
768
+ throw new HwpCliError("bad_request", "file is not an HWP or HWPX document");
769
+ }
770
+ return { form, document: { name, data } };
771
+ }
772
+ function formString(form, key) {
773
+ const value = form.get(key);
774
+ return typeof value === "string" && value !== "" ? value : void 0;
775
+ }
776
+ function formFlag(form, key) {
777
+ const value = form.get(key);
778
+ if (value === null) return void 0;
779
+ return value === "true" || value === "1";
780
+ }
781
+ function createHwpEditorHandler(opts = {}) {
782
+ const engine = opts.engine ?? createCliEngine({
783
+ ...opts.bin === void 0 ? {} : { bin: opts.bin },
784
+ ...opts.timeoutMs === void 0 ? {} : { timeoutMs: opts.timeoutMs },
785
+ ...opts.locale === void 0 ? {} : { locale: opts.locale }
786
+ });
787
+ const cli = "describe" in engine ? engine : null;
788
+ const maxRequestBytes = opts.maxRequestBytes ?? DEFAULT_MAX_REQUEST_BYTES;
789
+ const sessions = opts.sessions === false ? null : opts.sessions ?? createSessionStore();
790
+ function sessionFor(document, scope) {
791
+ if (sessions === null) return null;
792
+ const key = sha256Text(`${scope}\0${sha2562(document.data)}`);
793
+ const existing = hashToSession.get(key);
794
+ if (existing !== void 0 && sessions.has(existing)) return existing;
795
+ const session = sessions.create(document.name);
796
+ hashToSession.set(key, session.id);
797
+ return session.id;
798
+ }
799
+ const hashToSession = /* @__PURE__ */ new Map();
800
+ async function handleRead(req, scope) {
801
+ const { document } = await formDocument(req);
802
+ if (sessions !== null && cli !== null) {
803
+ const inspection = await cli.describe(document, { signal: req.signal, scope });
804
+ const id = sessionFor(document, scope);
805
+ if (id !== null) sessions.attachInspection(id, inspection);
806
+ return json(inspection.envelope);
807
+ }
808
+ return json(
809
+ cli !== null ? await cli.read(document, { signal: req.signal, scope }) : await engine.read(document)
810
+ );
811
+ }
812
+ async function handleRender(req, scope) {
813
+ const { form, document } = await formDocument(req);
814
+ const dpiField = formString(form, "dpi");
815
+ const dpi = dpiField === void 0 ? void 0 : Number(dpiField);
816
+ if (dpi !== void 0 && !Number.isFinite(dpi)) {
817
+ throw new HwpCliError("bad_request", `dpi must be a number; got "${dpiField}"`);
818
+ }
819
+ const format = formString(form, "format");
820
+ const options = {};
821
+ const pagesField = formString(form, "pages");
822
+ if (pagesField !== void 0) options.pages = pagesField;
823
+ if (dpi !== void 0) options.dpi = dpi;
824
+ if (format !== void 0) options.format = format;
825
+ const pages = cli !== null ? await cli.render(document, options, { signal: req.signal, scope }) : await engine.render(document, options);
826
+ const body = {
827
+ pages: pages.map(
828
+ (p) => ({
829
+ page: p.page,
830
+ width: p.width,
831
+ height: p.height,
832
+ dpi: p.dpi,
833
+ format: p.format,
834
+ dataBase64: toBase64(p.data)
835
+ })
836
+ )
837
+ };
838
+ return json(body);
839
+ }
840
+ async function handleEdit(req, scope) {
841
+ const { form, document } = await formDocument(req);
842
+ const opsField = form.get("ops");
843
+ if (typeof opsField !== "string" || opsField === "") {
844
+ throw new HwpCliError("bad_request", 'multipart field "ops" (EditOp[] JSON) is required');
845
+ }
846
+ let ops;
847
+ try {
848
+ const parsed = JSON.parse(opsField);
849
+ if (!Array.isArray(parsed)) throw new Error("not an array");
850
+ ops = parsed;
851
+ } catch {
852
+ throw new HwpCliError("bad_request", 'multipart field "ops" is not a JSON array');
853
+ }
854
+ if (ops.some((op) => op?.kind === "insert-image" || op?.kind === "seal")) {
855
+ return error(
856
+ 400,
857
+ "path_traversal",
858
+ 'ops "insert-image" and "seal" name a server-local path and are not accepted over HTTP; upload the asset with the request instead'
859
+ );
860
+ }
861
+ const options = {};
862
+ const verify = formFlag(form, "verify");
863
+ if (verify !== void 0) options.verify = verify;
864
+ const allowPartial = formFlag(form, "allowPartial");
865
+ if (allowPartial !== void 0) options.allowPartial = allowPartial;
866
+ const edited = cli !== null ? await cli.edit(document, ops, options, { signal: req.signal, scope }) : await engine.edit(document, ops, options);
867
+ const body = { name: edited.name, dataBase64: toBase64(edited.data) };
868
+ return json(body);
869
+ }
870
+ async function handleCompose(req, scope) {
871
+ let body;
872
+ try {
873
+ body = await req.json();
874
+ } catch {
875
+ throw new HwpCliError("bad_request", "expected a JSON ComposeRequest body");
876
+ }
877
+ if (typeof body !== "object" || body === null || typeof body.spec !== "object" || body.spec === null) {
878
+ throw new HwpCliError("bad_request", 'ComposeRequest requires a "spec" object');
879
+ }
880
+ if (typeof body.name !== "string" || body.name === "") {
881
+ throw new HwpCliError("bad_request", 'ComposeRequest requires a non-empty "name"');
882
+ }
883
+ const result = cli !== null ? await cli.compose(body.spec, body.name, { signal: req.signal, scope }) : await engine.compose(body.spec, body.name);
884
+ const responseBody = {
885
+ name: result.document.name,
886
+ dataBase64: toBase64(result.document.data)
887
+ };
888
+ if (result.report !== void 0) responseBody.report = result.report;
889
+ return json(responseBody);
890
+ }
891
+ async function handleValidate(req, scope) {
892
+ const { document } = await formDocument(req);
893
+ return json(
894
+ cli !== null ? await cli.validate(document, { signal: req.signal, scope }) : await engine.validate(document)
895
+ );
896
+ }
897
+ async function handleCapabilities() {
898
+ return json(await engine.capabilities());
899
+ }
900
+ return async function handler(req) {
901
+ const url = new URL(req.url);
902
+ const segments = url.pathname.split("/").filter(Boolean);
903
+ const action = segments[segments.length - 1] ?? "";
904
+ try {
905
+ if (!ACTIONS.has(action)) {
906
+ return error(404, "not_found", `unknown action: ${action || "(empty)"}`);
907
+ }
908
+ if (action === "capabilities") {
909
+ if (req.method !== "GET") return error(405, "method_not_allowed", "capabilities requires GET");
910
+ } else if (req.method !== "POST") {
911
+ return error(405, "method_not_allowed", `${action} requires POST`);
912
+ }
913
+ const scope = opts.authorize === void 0 ? DEFAULT_SCOPE : await opts.authorize(req, action);
914
+ if (scope === null) return error(403, "forbidden", "forbidden");
915
+ if (action === "capabilities") {
916
+ return await handleCapabilities();
917
+ }
918
+ const declared = req.headers.get("content-length");
919
+ if (declared === null) {
920
+ return error(400, "bad_request", "content-length is required");
921
+ }
922
+ const bytes = /^\d+$/.test(declared) ? Number(declared) : NaN;
923
+ if (!Number.isSafeInteger(bytes)) {
924
+ return error(400, "bad_request", "invalid content-length");
925
+ }
926
+ if (bytes > maxRequestBytes) {
927
+ return error(413, "bad_request", `request exceeds the ${maxRequestBytes} byte limit`);
928
+ }
929
+ switch (action) {
930
+ case "read":
931
+ return await handleRead(req, scope);
932
+ case "render":
933
+ return await handleRender(req, scope);
934
+ case "edit":
935
+ return await handleEdit(req, scope);
936
+ case "compose":
937
+ return await handleCompose(req, scope);
938
+ case "validate":
939
+ return await handleValidate(req, scope);
940
+ default:
941
+ return error(404, "not_found", `unknown action: ${action}`);
942
+ }
943
+ } catch (err) {
944
+ if (err instanceof HwpCliError) {
945
+ return error(statusFor(err), err.reason, err.message);
946
+ }
947
+ if (err instanceof SessionNotFoundError) {
948
+ return error(404, "session_not_found", err.message);
949
+ }
950
+ return error(500, "internal", "internal error");
951
+ }
952
+ };
953
+ }
954
+ // Annotate the CommonJS export names for ESM import in node:
955
+ 0 && (module.exports = {
956
+ DEFAULT_TTL_MS,
957
+ HWP_TIMEOUT_MS,
958
+ HwpCliError,
959
+ SessionNotFoundError,
960
+ createCliEngine,
961
+ createHwpEditorHandler,
962
+ createSessionStore
963
+ });
964
+ //# sourceMappingURL=index.cjs.map