viteboard 0.1.8 → 0.2.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.
@@ -1,9 +1,11 @@
1
1
  import { promises as fs } from "node:fs";
2
2
  import * as path from "node:path";
3
+ import os from "node:os";
3
4
  import { createHash } from "node:crypto";
4
5
  import { execFile } from "node:child_process";
5
6
  import { promisify } from "node:util";
6
- import { fileURLToPath } from "node:url";
7
+ import { fileURLToPath, pathToFileURL } from "node:url";
8
+ import { renderPdfFromHtml as renderPdfFromHtmlPortable, sanitizePdfFileName as sanitizePdfFileNamePortable } from "./pdf-export.mjs";
7
9
 
8
10
  const WORKSPACE_DIR = ".workspace";
9
11
  const WORKSPACE_FILE = "workspace.json";
@@ -53,6 +55,13 @@ export function createContentApiPlugin() {
53
55
  res.end(body);
54
56
  }
55
57
 
58
+ function sendBinary(res, status, body, type, fileName) {
59
+ res.statusCode = status;
60
+ res.setHeader("Content-Type", type);
61
+ if (fileName) res.setHeader("Content-Disposition", `attachment; filename="${String(fileName).replace(/[\r\n\"]/g, "-")}"`);
62
+ res.end(body);
63
+ }
64
+
56
65
  function sendSse(res, event, body) {
57
66
  res.write(`event: ${event}\n`);
58
67
  res.write(`data: ${JSON.stringify(body)}\n\n`);
@@ -105,6 +114,133 @@ export function createContentApiPlugin() {
105
114
  setInterval(pruneEphemeral, 10000).unref?.();
106
115
 
107
116
  function revisionForText(content) { return createHash("sha1").update(content).digest("hex"); }
117
+ let chromiumBinaryPromise = null;
118
+ async function resolveChromiumBinary() {
119
+ if (chromiumBinaryPromise) return chromiumBinaryPromise;
120
+ chromiumBinaryPromise = (async () => {
121
+ for (const candidate of ["chromium-browser", "chromium", "google-chrome", "google-chrome-stable"]) {
122
+ try {
123
+ await execFileAsync("which", [candidate]);
124
+ return candidate;
125
+ } catch {
126
+ /* continue */
127
+ }
128
+ }
129
+ throw new Error("No Chromium-compatible browser is available for PDF export.");
130
+ })();
131
+ return chromiumBinaryPromise;
132
+ }
133
+ function sanitizePdfFileName(input) {
134
+ const cleaned = String(input ?? "").trim().replace(/[\\/:*?"<>|]+/g, "-").replace(/\s+/g, " ").slice(0, 120) || "export";
135
+ return cleaned.toLowerCase().endsWith(".pdf") ? cleaned : `${cleaned}.pdf`;
136
+ }
137
+ function mmToInches(mm) {
138
+ return Number(mm ?? 0) / 25.4;
139
+ }
140
+ async function openCdpPage(port) {
141
+ const res = await fetch(`http://127.0.0.1:${port}/json/new?about:blank`, { method: "PUT" });
142
+ const target = await res.json();
143
+ if (!target.webSocketDebuggerUrl) throw new Error("Could not open Chromium page target for PDF export.");
144
+ return target.webSocketDebuggerUrl;
145
+ }
146
+ async function printPdfViaCdp(wsUrl, url, pdf) {
147
+ const ws = new WebSocket(wsUrl);
148
+ let nextId = 0;
149
+ const pending = new Map();
150
+ let loadResolve = null;
151
+ const loaded = new Promise((resolve) => { loadResolve = resolve; });
152
+ ws.onmessage = (event) => {
153
+ const message = JSON.parse(String(event.data));
154
+ if (message.method === "Page.loadEventFired") {
155
+ if (loadResolve) loadResolve();
156
+ loadResolve = null;
157
+ return;
158
+ }
159
+ if (!message.id) return;
160
+ const entry = pending.get(message.id);
161
+ if (!entry) return;
162
+ pending.delete(message.id);
163
+ if (message.error) entry.reject(new Error(message.error.message || "CDP error"));
164
+ else entry.resolve(message.result);
165
+ };
166
+ const opened = new Promise((resolve, reject) => {
167
+ ws.onopen = resolve;
168
+ ws.onerror = () => reject(new Error("Could not connect to Chromium PDF export session."));
169
+ });
170
+ const send = (method, params = {}) => new Promise((resolve, reject) => {
171
+ const id = ++nextId;
172
+ pending.set(id, { resolve, reject });
173
+ ws.send(JSON.stringify({ id, method, params }));
174
+ });
175
+ await opened;
176
+ try {
177
+ await send("Page.enable");
178
+ await send("Runtime.enable");
179
+ await send("Page.navigate", { url });
180
+ await loaded;
181
+ await send("Runtime.evaluate", {
182
+ expression: "document.fonts ? document.fonts.ready.then(() => true) : true",
183
+ awaitPromise: true,
184
+ returnByValue: true,
185
+ });
186
+ const result = await send("Page.printToPDF", {
187
+ printBackground: true,
188
+ preferCSSPageSize: true,
189
+ displayHeaderFooter: Boolean(pdf.displayHeaderFooter),
190
+ headerTemplate: pdf.headerTemplate || "<div></div>",
191
+ footerTemplate: pdf.footerTemplate || "<div></div>",
192
+ marginTop: mmToInches(pdf.marginTopMm ?? 12),
193
+ marginBottom: mmToInches(pdf.marginBottomMm ?? 12),
194
+ marginLeft: mmToInches(pdf.marginLeftMm ?? 12),
195
+ marginRight: mmToInches(pdf.marginRightMm ?? 12),
196
+ });
197
+ return Buffer.from(result.data, "base64");
198
+ } finally {
199
+ try { ws.close(); } catch { /* ignore */ }
200
+ }
201
+ }
202
+ async function renderPdfFromHtml(html, fileName, pdf = {}) {
203
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "viteboard-pdf-"));
204
+ const htmlPath = path.join(tmpDir, "publication.html");
205
+ const profileDir = path.join(tmpDir, "profile");
206
+ const browser = await resolveChromiumBinary();
207
+ let browserProcess = null;
208
+ try {
209
+ await fs.writeFile(htmlPath, html, "utf-8");
210
+ await fs.mkdir(profileDir, { recursive: true });
211
+ browserProcess = execFile(browser, [
212
+ "--headless",
213
+ "--disable-gpu",
214
+ "--no-sandbox",
215
+ `--user-data-dir=${profileDir}`,
216
+ "--remote-debugging-port=0",
217
+ "about:blank",
218
+ ], { windowsHide: true });
219
+ const activePortPath = path.join(profileDir, "DevToolsActivePort");
220
+ for (let attempt = 0; attempt < 100; attempt += 1) {
221
+ try {
222
+ const raw = await fs.readFile(activePortPath, "utf-8");
223
+ const port = raw.split(/\r?\n/)[0]?.trim();
224
+ if (!port) throw new Error("Chromium debugging port missing.");
225
+ const wsUrl = await openCdpPage(port);
226
+ return await printPdfViaCdp(wsUrl, pathToFileURL(htmlPath).href, pdf);
227
+ } catch {
228
+ await new Promise((resolve) => setTimeout(resolve, 100));
229
+ }
230
+ }
231
+ throw new Error("Timed out starting Chromium PDF export session.");
232
+ } finally {
233
+ if (browserProcess) {
234
+ const exited = new Promise((resolve) => {
235
+ browserProcess?.once("exit", resolve);
236
+ browserProcess?.once("close", resolve);
237
+ });
238
+ try { browserProcess.kill(); } catch { /* ignore */ }
239
+ await Promise.race([exited, new Promise((resolve) => setTimeout(resolve, 500))]);
240
+ }
241
+ await fs.rm(tmpDir, { recursive: true, force: true });
242
+ }
243
+ }
108
244
  function hashPath(value) {
109
245
  let hash = 2166136261;
110
246
  for (let i = 0; i < value.length; i++) {
@@ -673,6 +809,12 @@ export function createContentApiPlugin() {
673
809
  if (removed > 0) broadcast("asset-changed", { rootId: resolved.root.id, action: "cleanup", removed, actorId: actor?.actorId ?? null });
674
810
  return send(res, 200, { ok: true, removed });
675
811
  }
812
+ if (route === "/__api/export/pdf" && method === "POST") {
813
+ const { html, fileName, pdf } = JSON.parse(await readBody(req));
814
+ if (typeof html !== "string" || !html.trim()) return send(res, 400, { error: "Missing HTML payload" });
815
+ const pdfBuffer = await renderPdfFromHtmlPortable(html, String(fileName ?? "export.pdf"), typeof pdf === "object" && pdf ? pdf : {});
816
+ return sendBinary(res, 200, pdfBuffer, "application/pdf", sanitizePdfFileNamePortable(String(fileName ?? "export.pdf")));
817
+ }
676
818
  if (route === "/__api/workspace-file" && method === "GET") {
677
819
  await ensureWorkspaceDir();
678
820
  const manifest = await loadManifest();
@@ -0,0 +1,337 @@
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+ import os from "node:os";
4
+ import http from "node:http";
5
+ import { createHash, randomBytes } from "node:crypto";
6
+ import { execFile } from "node:child_process";
7
+ import { promisify } from "node:util";
8
+ import { pathToFileURL } from "node:url";
9
+
10
+ const execFileAsync = promisify(execFile);
11
+ const WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
12
+
13
+ let browserBinaryPromise = null;
14
+
15
+ export async function renderPdfFromHtml(html, fileName, pdf = {}) {
16
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "viteboard-pdf-"));
17
+ const htmlPath = path.join(tmpDir, "publication.html");
18
+ const profileDir = path.join(tmpDir, "profile");
19
+ const browser = await resolveBrowserBinary();
20
+ let browserProcess = null;
21
+ try {
22
+ await fs.writeFile(htmlPath, html, "utf-8");
23
+ await fs.mkdir(profileDir, { recursive: true });
24
+ browserProcess = execFile(browser, [
25
+ "--headless",
26
+ "--disable-gpu",
27
+ "--no-sandbox",
28
+ `--user-data-dir=${profileDir}`,
29
+ "--remote-debugging-port=0",
30
+ "about:blank",
31
+ ], { windowsHide: true });
32
+
33
+ const activePortPath = path.join(profileDir, "DevToolsActivePort");
34
+ const port = await waitForDevToolsPort(activePortPath);
35
+ const wsUrl = await openCdpPage(port);
36
+ return await printPdfViaCdp(wsUrl, pathToFileURL(htmlPath).href, {
37
+ displayHeaderFooter: Boolean(pdf.displayHeaderFooter),
38
+ headerTemplate: pdf.headerTemplate,
39
+ footerTemplate: pdf.footerTemplate,
40
+ marginTopMm: pdf.marginTopMm,
41
+ marginBottomMm: pdf.marginBottomMm,
42
+ marginLeftMm: pdf.marginLeftMm,
43
+ marginRightMm: pdf.marginRightMm,
44
+ });
45
+ } finally {
46
+ if (browserProcess) {
47
+ const exited = new Promise((resolve) => {
48
+ browserProcess.once("exit", resolve);
49
+ browserProcess.once("close", resolve);
50
+ });
51
+ try { browserProcess.kill(); } catch { /* ignore */ }
52
+ await Promise.race([exited, delay(500)]);
53
+ }
54
+ await fs.rm(tmpDir, { recursive: true, force: true });
55
+ }
56
+ }
57
+
58
+ export function sanitizePdfFileName(input) {
59
+ const cleaned = String(input ?? "").trim().replace(/[\\/:*?"<>|]+/g, "-").replace(/\s+/g, " ").slice(0, 120) || "export";
60
+ return cleaned.toLowerCase().endsWith(".pdf") ? cleaned : `${cleaned}.pdf`;
61
+ }
62
+
63
+ async function resolveBrowserBinary() {
64
+ if (browserBinaryPromise) return browserBinaryPromise;
65
+ browserBinaryPromise = (async () => {
66
+ const override = process.env.VITEBOARD_PDF_BROWSER || process.env.VITEBOARD_BROWSER || process.env.BROWSER || "";
67
+ if (override && await fileExists(override)) return override;
68
+
69
+ const commandCandidates = process.platform === "win32"
70
+ ? ["msedge.exe", "chrome.exe", "chromium.exe", "brave.exe"]
71
+ : ["microsoft-edge", "microsoft-edge-stable", "google-chrome", "google-chrome-stable", "chromium-browser", "chromium", "brave-browser"];
72
+ const lookupCommand = process.platform === "win32" ? "where" : "which";
73
+ for (const candidate of commandCandidates) {
74
+ try {
75
+ const { stdout } = await execFileAsync(lookupCommand, [candidate]);
76
+ const first = stdout.split(/\r?\n/).map((line) => line.trim()).find(Boolean);
77
+ if (first) return first;
78
+ } catch {
79
+ /* try next */
80
+ }
81
+ }
82
+
83
+ for (const candidate of absoluteBrowserCandidates()) {
84
+ if (await fileExists(candidate)) return candidate;
85
+ }
86
+
87
+ throw new Error("No supported Chromium-based browser was found for PDF export. Install Chrome, Edge, Chromium, or Brave, or set VITEBOARD_PDF_BROWSER.");
88
+ })();
89
+ return browserBinaryPromise;
90
+ }
91
+
92
+ function absoluteBrowserCandidates() {
93
+ if (process.platform === "darwin") {
94
+ return [
95
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
96
+ "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
97
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
98
+ "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
99
+ path.join(os.homedir(), "Applications/Google Chrome.app/Contents/MacOS/Google Chrome"),
100
+ path.join(os.homedir(), "Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"),
101
+ ];
102
+ }
103
+ if (process.platform === "win32") {
104
+ const roots = [process.env.PROGRAMFILES, process.env["PROGRAMFILES(X86)"], process.env.LOCALAPPDATA].filter(Boolean);
105
+ const suffixes = [
106
+ path.join("Google", "Chrome", "Application", "chrome.exe"),
107
+ path.join("Microsoft", "Edge", "Application", "msedge.exe"),
108
+ path.join("Chromium", "Application", "chrome.exe"),
109
+ path.join("BraveSoftware", "Brave-Browser", "Application", "brave.exe"),
110
+ ];
111
+ return roots.flatMap((root) => suffixes.map((suffix) => path.join(root, suffix)));
112
+ }
113
+ return [
114
+ "/usr/bin/chromium-browser",
115
+ "/usr/bin/chromium",
116
+ "/usr/bin/google-chrome",
117
+ "/usr/bin/google-chrome-stable",
118
+ "/usr/bin/microsoft-edge",
119
+ "/snap/bin/chromium",
120
+ ];
121
+ }
122
+
123
+ async function fileExists(filePath) {
124
+ try {
125
+ await fs.access(filePath);
126
+ return true;
127
+ } catch {
128
+ return false;
129
+ }
130
+ }
131
+
132
+ async function waitForDevToolsPort(activePortPath) {
133
+ for (let attempt = 0; attempt < 100; attempt += 1) {
134
+ try {
135
+ const raw = await fs.readFile(activePortPath, "utf-8");
136
+ const port = raw.split(/\r?\n/)[0]?.trim();
137
+ if (port) return port;
138
+ } catch {
139
+ /* wait */
140
+ }
141
+ await delay(100);
142
+ }
143
+ throw new Error("Timed out starting Chromium PDF export session.");
144
+ }
145
+
146
+ async function openCdpPage(port) {
147
+ const response = await fetch(`http://127.0.0.1:${port}/json/new?about:blank`, { method: "PUT" });
148
+ const target = await response.json();
149
+ if (!target.webSocketDebuggerUrl) throw new Error("Could not open Chromium page target for PDF export.");
150
+ return target.webSocketDebuggerUrl;
151
+ }
152
+
153
+ async function printPdfViaCdp(wsUrl, url, pdf) {
154
+ const ws = await connectWebSocket(wsUrl);
155
+ let nextId = 0;
156
+ const pending = new Map();
157
+ let loadResolve = null;
158
+ const loaded = new Promise((resolve) => { loadResolve = resolve; });
159
+
160
+ ws.onMessage = (text) => {
161
+ const message = JSON.parse(text);
162
+ if (message.method === "Page.loadEventFired") {
163
+ if (loadResolve) loadResolve();
164
+ loadResolve = null;
165
+ return;
166
+ }
167
+ if (!message.id) return;
168
+ const entry = pending.get(message.id);
169
+ if (!entry) return;
170
+ pending.delete(message.id);
171
+ if (message.error) entry.reject(new Error(message.error.message || "CDP error"));
172
+ else entry.resolve(message.result);
173
+ };
174
+
175
+ const send = (method, params = {}) => new Promise((resolve, reject) => {
176
+ const id = ++nextId;
177
+ pending.set(id, { resolve, reject });
178
+ ws.send(JSON.stringify({ id, method, params }));
179
+ });
180
+
181
+ try {
182
+ await send("Page.enable");
183
+ await send("Runtime.enable");
184
+ await send("Page.navigate", { url });
185
+ await loaded;
186
+ await send("Runtime.evaluate", {
187
+ expression: "document.fonts ? document.fonts.ready.then(() => true) : true",
188
+ awaitPromise: true,
189
+ returnByValue: true,
190
+ });
191
+ const result = await send("Page.printToPDF", {
192
+ printBackground: true,
193
+ preferCSSPageSize: true,
194
+ displayHeaderFooter: Boolean(pdf.displayHeaderFooter),
195
+ headerTemplate: pdf.headerTemplate || "<div></div>",
196
+ footerTemplate: pdf.footerTemplate || "<div></div>",
197
+ marginTop: mmToInches(pdf.marginTopMm ?? 12),
198
+ marginBottom: mmToInches(pdf.marginBottomMm ?? 12),
199
+ marginLeft: mmToInches(pdf.marginLeftMm ?? 12),
200
+ marginRight: mmToInches(pdf.marginRightMm ?? 12),
201
+ });
202
+ return Buffer.from(result.data, "base64");
203
+ } finally {
204
+ ws.close();
205
+ }
206
+ }
207
+
208
+ function mmToInches(mm) {
209
+ return Number(mm ?? 0) / 25.4;
210
+ }
211
+
212
+ async function connectWebSocket(wsUrl) {
213
+ const parsed = new URL(wsUrl);
214
+ if (parsed.protocol !== "ws:") throw new Error(`Unsupported DevTools protocol: ${parsed.protocol}`);
215
+
216
+ return new Promise((resolve, reject) => {
217
+ const key = randomBytes(16).toString("base64");
218
+ const req = http.request({
219
+ hostname: parsed.hostname,
220
+ port: parsed.port || 80,
221
+ path: `${parsed.pathname}${parsed.search}`,
222
+ headers: {
223
+ Connection: "Upgrade",
224
+ Upgrade: "websocket",
225
+ "Sec-WebSocket-Version": 13,
226
+ "Sec-WebSocket-Key": key,
227
+ },
228
+ });
229
+ req.once("upgrade", (res, socket) => {
230
+ const expectedAccept = createHash("sha1").update(key + WS_GUID).digest("base64");
231
+ if (res.headers["sec-websocket-accept"] !== expectedAccept) {
232
+ socket.destroy();
233
+ reject(new Error("Invalid DevTools WebSocket handshake."));
234
+ return;
235
+ }
236
+ resolve(new SimpleWebSocket(socket));
237
+ });
238
+ req.once("error", reject);
239
+ req.end();
240
+ });
241
+ }
242
+
243
+ class SimpleWebSocket {
244
+ constructor(socket) {
245
+ this.socket = socket;
246
+ this.buffer = Buffer.alloc(0);
247
+ this.onMessage = null;
248
+ socket.on("data", (chunk) => this.handleData(chunk));
249
+ socket.on("error", () => {});
250
+ }
251
+
252
+ send(text) {
253
+ const payload = Buffer.from(String(text));
254
+ const frame = encodeClientFrame(0x1, payload);
255
+ this.socket.write(frame);
256
+ }
257
+
258
+ close() {
259
+ try { this.socket.end(); } catch { /* ignore */ }
260
+ }
261
+
262
+ handleData(chunk) {
263
+ this.buffer = Buffer.concat([this.buffer, chunk]);
264
+ while (true) {
265
+ const frame = decodeFrame(this.buffer);
266
+ if (!frame) return;
267
+ this.buffer = this.buffer.slice(frame.byteLength);
268
+ if (frame.opcode === 0x1 && this.onMessage) this.onMessage(frame.payload.toString("utf-8"));
269
+ if (frame.opcode === 0x9) this.socket.write(encodeClientFrame(0xA, frame.payload));
270
+ if (frame.opcode === 0x8) {
271
+ this.close();
272
+ return;
273
+ }
274
+ }
275
+ }
276
+ }
277
+
278
+ function encodeClientFrame(opcode, payload) {
279
+ const payloadLength = payload.length;
280
+ const mask = randomBytes(4);
281
+ let header;
282
+ if (payloadLength < 126) {
283
+ header = Buffer.alloc(2);
284
+ header[1] = 0x80 | payloadLength;
285
+ } else if (payloadLength < 65536) {
286
+ header = Buffer.alloc(4);
287
+ header[1] = 0x80 | 126;
288
+ header.writeUInt16BE(payloadLength, 2);
289
+ } else {
290
+ header = Buffer.alloc(10);
291
+ header[1] = 0x80 | 127;
292
+ header.writeBigUInt64BE(BigInt(payloadLength), 2);
293
+ }
294
+ header[0] = 0x80 | (opcode & 0x0f);
295
+ const masked = Buffer.alloc(payloadLength);
296
+ for (let i = 0; i < payloadLength; i += 1) masked[i] = payload[i] ^ mask[i % 4];
297
+ return Buffer.concat([header, mask, masked]);
298
+ }
299
+
300
+ function decodeFrame(buffer) {
301
+ if (buffer.length < 2) return null;
302
+ const first = buffer[0];
303
+ const second = buffer[1];
304
+ let offset = 2;
305
+ let payloadLength = second & 0x7f;
306
+ if (payloadLength === 126) {
307
+ if (buffer.length < 4) return null;
308
+ payloadLength = buffer.readUInt16BE(2);
309
+ offset = 4;
310
+ } else if (payloadLength === 127) {
311
+ if (buffer.length < 10) return null;
312
+ payloadLength = Number(buffer.readBigUInt64BE(2));
313
+ offset = 10;
314
+ }
315
+ const masked = (second & 0x80) !== 0;
316
+ let mask = null;
317
+ if (masked) {
318
+ if (buffer.length < offset + 4) return null;
319
+ mask = buffer.slice(offset, offset + 4);
320
+ offset += 4;
321
+ }
322
+ if (buffer.length < offset + payloadLength) return null;
323
+ const payload = Buffer.from(buffer.slice(offset, offset + payloadLength));
324
+ if (masked && mask) {
325
+ for (let i = 0; i < payload.length; i += 1) payload[i] ^= mask[i % 4];
326
+ }
327
+ return {
328
+ fin: (first & 0x80) !== 0,
329
+ opcode: first & 0x0f,
330
+ payload,
331
+ byteLength: offset + payloadLength,
332
+ };
333
+ }
334
+
335
+ function delay(ms) {
336
+ return new Promise((resolve) => setTimeout(resolve, ms));
337
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ViteBoard",
3
- "appVersion": "0.1.8",
3
+ "appVersion": "0.2.0",
4
4
  "machineVersion": 1,
5
5
  "summary": "Local-first documentation and infinite-canvas boards stored as plain files.",
6
6
  "interactionModel": "human-directed-via-agent",