@hajimism/dek 0.4.0 → 0.5.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.
Files changed (70) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/README.ja.md +1 -1
  3. package/README.md +1 -1
  4. package/package.json +4 -3
  5. package/src/cli/address.ts +11 -4
  6. package/src/cli/commands.ts +47 -14
  7. package/src/cli/contract.ts +49 -3
  8. package/src/cli/files.ts +1 -0
  9. package/src/cli/format.ts +5 -4
  10. package/src/cli/goto.ts +41 -10
  11. package/src/cli/lint.ts +32 -13
  12. package/src/cli/ls.ts +5 -1
  13. package/src/cli/main.ts +46 -11
  14. package/src/cli/new.ts +14 -1
  15. package/src/cli/ref.ts +1 -0
  16. package/src/cli/result.ts +42 -4
  17. package/src/cli/scope.ts +40 -1
  18. package/src/cli/shot.ts +77 -6
  19. package/src/cli/show.ts +15 -2
  20. package/src/cli/text.ts +61 -9
  21. package/src/cli/theme.ts +49 -15
  22. package/src/cli/tty.ts +16 -2
  23. package/src/cli/voice.ts +11 -6
  24. package/src/core/assets.ts +18 -10
  25. package/src/core/attribute-value.ts +188 -0
  26. package/src/core/build.ts +3 -3
  27. package/src/core/css-scan.ts +173 -39
  28. package/src/core/css-transform.ts +57 -11
  29. package/src/core/css.ts +19 -13
  30. package/src/core/foreign-text.ts +138 -0
  31. package/src/core/github.ts +1 -0
  32. package/src/core/html-entities.ts +318 -0
  33. package/src/core/html-scan.ts +49 -27
  34. package/src/core/html.ts +21 -15
  35. package/src/core/lint/asset-refs.ts +44 -6
  36. package/src/core/lint/slide-html.ts +21 -18
  37. package/src/core/mv.ts +28 -18
  38. package/src/core/order.ts +14 -0
  39. package/src/core/path.ts +8 -2
  40. package/src/core/playwright-module.d.ts +12 -1
  41. package/src/core/playwright-motion.ts +1 -0
  42. package/src/core/playwright-sheet.ts +1 -0
  43. package/src/core/playwright-visual.ts +27 -10
  44. package/src/core/playwright.ts +77 -19
  45. package/src/core/pptx.ts +1 -0
  46. package/src/core/ref-name.ts +8 -3
  47. package/src/core/ref.ts +145 -47
  48. package/src/core/resolve.ts +3 -2
  49. package/src/core/rumdl.ts +51 -25
  50. package/src/core/safe-fs.ts +19 -2
  51. package/src/core/sandbox.ts +217 -0
  52. package/src/core/shot-cache.ts +1 -0
  53. package/src/core/slide-eval-worker.ts +1 -0
  54. package/src/core/slide-script-eval.ts +2 -0
  55. package/src/core/spawn.ts +131 -32
  56. package/src/core/sync.ts +3 -12
  57. package/src/core/tar.ts +315 -0
  58. package/src/core/text-contrast.ts +2 -0
  59. package/src/core/toml-keys.ts +116 -51
  60. package/src/core/url-attributes.ts +27 -9
  61. package/src/core/visual.ts +28 -9
  62. package/src/core/voice.ts +2 -1
  63. package/src/server/auth.ts +21 -0
  64. package/src/server/dev.ts +11 -3
  65. package/src/server/lock.ts +4 -4
  66. package/src/server/serial.ts +8 -2
  67. package/src/video/mux.ts +27 -3
  68. package/src/video/recorder.ts +3 -2
  69. package/src/video/worker.ts +1 -0
  70. package/src/voice/engine.ts +81 -18
package/src/core/voice.ts CHANGED
@@ -5,6 +5,7 @@ import { z } from "zod";
5
5
  import type { VoiceDict } from "./cue.ts";
6
6
  import { deckPaths } from "./deck-paths.ts";
7
7
  import { DekError } from "./error.ts";
8
+ import { compareCodeUnits } from "./order.ts";
8
9
  import { cacheDir, deckProjectRoot } from "./path.ts";
9
10
  import { readDeckFile } from "./resolve.ts";
10
11
  import { writeInside } from "./safe-fs.ts";
@@ -184,7 +185,7 @@ export function loadVoiceDict(deckDir: string): VoiceDict {
184
185
  export function writeVoiceDict(deckDir: string, dict: VoiceDict): string {
185
186
  const path = deckPaths(deckDir).voiceDict;
186
187
  const body = Object.entries(dict)
187
- .sort(([a], [b]) => a.localeCompare(b))
188
+ .sort(([a], [b]) => compareCodeUnits(a, b))
188
189
  .map(([key, entry]) => {
189
190
  const accent = entry.accent !== undefined ? `\naccent = ${entry.accent}` : "";
190
191
  return `[${escapeTomlKey(key)}]\nkana = ${JSON.stringify(entry.kana)}${accent}\n`;
@@ -78,6 +78,27 @@ function forbidden(reason: string): Response {
78
78
  return new Response(`Forbidden: ${reason}\n`, { status: 403 });
79
79
  }
80
80
 
81
+ /**
82
+ * What every answer tells the browser: take its type as given, hand it to no other origin's
83
+ * page, and show it in no other origin's frame. Another site can still make the browser ask, but
84
+ * gets nothing to play, apply as a stylesheet, tell apart by whether it loaded, or frame. The
85
+ * frame rule goes on every answer, not only pages: the voice or a listing opened in a frame is
86
+ * a page too.
87
+ */
88
+ const SAME_ORIGIN_ONLY = {
89
+ "x-content-type-options": "nosniff",
90
+ "cross-origin-resource-policy": "same-origin",
91
+ "content-security-policy": "frame-ancestors 'self'",
92
+ } as const;
93
+
94
+ /** `response` with SAME_ORIGIN_ONLY set; the server passes every answer through it. */
95
+ export function keepToOwnOrigin(response: Response): Response {
96
+ for (const [name, value] of Object.entries(SAME_ORIGIN_ONLY)) {
97
+ response.headers.set(name, value);
98
+ }
99
+ return response;
100
+ }
101
+
81
102
  /**
82
103
  * Who may read a response. The audience sees the slides as they are shown; the presenter also
83
104
  * sees what they are made from, and may move the deck.
package/src/server/dev.ts CHANGED
@@ -6,6 +6,7 @@ import { locateDeck, type Project } from "../core/resolve.ts";
6
6
  import { annotateScript, livePlayerScript, liveReloadScript } from "../runtime/player.ts";
7
7
  import {
8
8
  clearance,
9
+ keepToOwnOrigin,
9
10
  mayRead,
10
11
  pairingResponse,
11
12
  ROUTE_EXPOSURE,
@@ -199,10 +200,17 @@ function listen(port: number | undefined, access: Access, routes: ServedRoutes)
199
200
  port: port ?? 0,
200
201
  // Never Bun's development error page: it shows the source and absolute paths to anyone.
201
202
  development: false,
202
- fetch: (req, server) => answer(req, server, access, routes),
203
+ // Every answer dek writes leaves through one of these two, so none goes without
204
+ // keepToOwnOrigin. Bun answers a few requests itself before either runs: one its parser
205
+ // refuses (a bad request line, no Host, a body or headers too large) and an upgrade it
206
+ // cannot take; none of those has a body a page could use.
207
+ fetch: async (req, server) => {
208
+ const response = await answer(req, server, access, routes);
209
+ return response && keepToOwnOrigin(response);
210
+ },
203
211
  error(error) {
204
212
  console.error(error);
205
- return new Response("Internal Server Error\n", { status: 500 });
213
+ return keepToOwnOrigin(new Response("Internal Server Error\n", { status: 500 }));
206
214
  },
207
215
  websocket: roomSockets(routes.rooms),
208
216
  });
@@ -233,7 +241,7 @@ function answer(req: Request, server: Bun.Server<WsData>, access: Access, routes
233
241
  }
234
242
 
235
243
  /** Each socket joins its deck's room; only a presenter's socket may move it or point in it. */
236
- function roomSockets(rooms: RouteContext["rooms"]): Bun.WebSocketHandler<WsData> {
244
+ export function roomSockets(rooms: RouteContext["rooms"]): Bun.WebSocketHandler<WsData> {
237
245
  return {
238
246
  // A position or a point is a few dozen bytes; anything near this is neither.
239
247
  maxPayloadLength: 4 * 1024,
@@ -1,7 +1,7 @@
1
- import { existsSync, unlinkSync } from "node:fs";
1
+ import { existsSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { DekError } from "../core/error.ts";
4
- import { readSourceIfExists, writeInside } from "../core/safe-fs.ts";
4
+ import { readSourceIfExists, removeInside, writeInside } from "../core/safe-fs.ts";
5
5
 
6
6
  export type DevServerLock = {
7
7
  url: string;
@@ -133,8 +133,8 @@ export function removeDevServerLock(root: string): void {
133
133
  return;
134
134
  }
135
135
  try {
136
- unlinkSync(serverLockPath(root));
136
+ removeInside(serverLockPath(root), root);
137
137
  } catch {
138
- /* already gone */
138
+ /* a lock left behind names a process that is gone, which the next server sees */
139
139
  }
140
140
  }
@@ -1,3 +1,9 @@
1
+ /**
2
+ * Runs `run` one at a time: a call while it runs queues one more run after it, however many
3
+ * calls come. Each caller's `run` reports its own failures, as diagnostics or a line on stderr;
4
+ * one that still throws is a bug, logged to the dev server's stderr as Bun.serve's error handler
5
+ * logs one, and the queued run still runs.
6
+ */
1
7
  export function createSerialTask(run: () => Promise<void>): () => void {
2
8
  let running = false;
3
9
  let queued = false;
@@ -13,8 +19,8 @@ export function createSerialTask(run: () => Promise<void>): () => void {
13
19
  queued = false;
14
20
  try {
15
21
  await run();
16
- } catch {
17
- /* keep draining the trailing run */
22
+ } catch (error) {
23
+ console.error(error);
18
24
  }
19
25
  } while (queued);
20
26
  } finally {
package/src/video/mux.ts CHANGED
@@ -1,3 +1,4 @@
1
+ // biome-ignore lint/style/noRestrictedImports: mkdtempSync, rmSync, and writeFileSync, for ffmpeg's frame list in a folder of its own in the system's temp folder.
1
2
  import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
3
  import { tmpdir } from "node:os";
3
4
  import { join } from "node:path";
@@ -5,16 +6,39 @@ import { DekError } from "../core/error.ts";
5
6
  import { runPiped, workerCommand } from "../core/spawn.ts";
6
7
  import type { VideoFrame } from "./recorder.ts";
7
8
 
8
- export function ffmpegResolved(): boolean {
9
+ /** `ffmpeg -version` prints and exits at once; one that takes longer is stuck. */
10
+ const FFMPEG_PROBE_TIMEOUT_MS = 10_000;
11
+
12
+ /**
13
+ * True when ffmpeg is there to run: DEK_FFMPEG names one, or `ffmpeg -version` on PATH exits 0.
14
+ * An ffmpeg that does not answer within `timeoutMs` is killed, and is an error that says so.
15
+ */
16
+ export function ffmpegResolved(timeoutMs = FFMPEG_PROBE_TIMEOUT_MS): boolean {
9
17
  if (process.env.DEK_FFMPEG) {
10
18
  return true;
11
19
  }
20
+ let result: Bun.SyncSubprocess<"ignore", "ignore">;
12
21
  try {
13
- const result = Bun.spawnSync(["ffmpeg", "-version"], { stdout: "pipe", stderr: "pipe" });
14
- return result.exitCode === 0;
22
+ // The environment as it is now, as runPiped passes it: left out, Bun passes the one dek
23
+ // started with. Nothing is piped, so a process it leaves behind cannot hold dek.
24
+ // biome-ignore lint/nursery/noJsRestrictedProperties: ffmpegResolved is synchronous, for tests too; the probe has its own timeout and SIGKILL, and the encode runs through runPiped.
25
+ result = Bun.spawnSync(["ffmpeg", "-version"], {
26
+ stdin: "ignore",
27
+ stdout: "ignore",
28
+ stderr: "ignore",
29
+ env: process.env,
30
+ timeout: timeoutMs,
31
+ killSignal: "SIGKILL",
32
+ });
15
33
  } catch {
16
34
  return false;
17
35
  }
36
+ if (result.exitedDueToTimeout) {
37
+ throw new DekError(`ffmpeg did not answer \`ffmpeg -version\` within ${timeoutMs / 1000}s`, {
38
+ hint: "check the ffmpeg on PATH, or set DEK_FFMPEG to an ffmpeg that runs, then run the command again",
39
+ });
40
+ }
41
+ return result.exitCode === 0;
18
42
  }
19
43
 
20
44
  function ffmpegCommand(args: string[]): string[] {
@@ -1,7 +1,8 @@
1
1
  import { DekError } from "../core/error.ts";
2
2
  import { moduleFilePath } from "../core/path.ts";
3
3
  import { playwrightResolved } from "../core/playwright.ts";
4
- import { runJsonWorker, workerCommand } from "../core/spawn.ts";
4
+ import { runDrawingWorker } from "../core/sandbox.ts";
5
+ import { workerCommand } from "../core/spawn.ts";
5
6
  import type { Position } from "../core/step.ts";
6
7
  import { playbackSchedule, type Timeline } from "../core/timeline.ts";
7
8
 
@@ -85,7 +86,7 @@ function spawnVideoRunner(
85
86
  request: VideoCaptureRequest,
86
87
  options: { timeoutMs?: number },
87
88
  ): Promise<VideoCaptureResponse> {
88
- return runJsonWorker(workerCommand(bin), request, parseVideoResponse, {
89
+ return runDrawingWorker(workerCommand(bin), request, parseVideoResponse, {
89
90
  label: "video capture failed",
90
91
  hint: "install Playwright or set DEK_VIDEO",
91
92
  ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env bun
2
+ // biome-ignore lint/style/noRestrictedImports: mkdirSync, in a worker, for the folder its parent made with outputDir, which Chromium writes into.
2
3
  import { mkdirSync } from "node:fs";
3
4
  import { join } from "node:path";
4
5
  import type { Browser } from "playwright";
@@ -37,41 +37,78 @@ export function engineBaseUrl(engine: string): string {
37
37
  return `http://127.0.0.1:${resolved}`;
38
38
  }
39
39
 
40
- export async function fetchSpeakers(baseUrl: string): Promise<VoiceSpeaker[]> {
41
- const res = await engineFetch(baseUrl, "/speakers");
42
- return (await res.json()) as VoiceSpeaker[];
40
+ export type EngineOptions = {
41
+ /** Covers the whole call, from connecting to the last byte of the answer. */
42
+ timeoutMs?: number;
43
+ };
44
+
45
+ /** /speakers and /version answer from what the engine has loaded; a stall this long is a hang. */
46
+ const PROBE_TIMEOUT_MS = 10 * 1000;
47
+
48
+ /** Reading a sentence's accents takes well under a second; this leaves room for a busy machine. */
49
+ const QUERY_TIMEOUT_MS = 60 * 1000;
50
+
51
+ /** Synthesis on a CPU can take longer than the sentence lasts; a long one fits inside this. */
52
+ const SYNTHESIS_TIMEOUT_MS = 5 * 60 * 1000;
53
+
54
+ export async function fetchSpeakers(
55
+ baseUrl: string,
56
+ options: EngineOptions = {},
57
+ ): Promise<VoiceSpeaker[]> {
58
+ return engineFetch(
59
+ baseUrl,
60
+ "/speakers",
61
+ options.timeoutMs ?? PROBE_TIMEOUT_MS,
62
+ (res) => res.json() as Promise<VoiceSpeaker[]>,
63
+ );
43
64
  }
44
65
 
45
- export async function fetchEngineVersion(baseUrl: string): Promise<string> {
46
- const res = await engineFetch(baseUrl, "/version");
47
- const text = (await res.text()).trim();
48
- return text.replace(/^"|"$/g, "");
66
+ export async function fetchEngineVersion(
67
+ baseUrl: string,
68
+ options: EngineOptions = {},
69
+ ): Promise<string> {
70
+ const text = await engineFetch(
71
+ baseUrl,
72
+ "/version",
73
+ options.timeoutMs ?? PROBE_TIMEOUT_MS,
74
+ (res) => res.text(),
75
+ );
76
+ return text.trim().replace(/^"|"$/g, "");
49
77
  }
50
78
 
51
79
  export async function fetchAudioQuery(
52
80
  baseUrl: string,
53
81
  text: string,
54
82
  speaker: number,
83
+ options: EngineOptions = {},
55
84
  ): Promise<AudioQuery> {
56
- const res = await engineFetch(
85
+ return engineFetch(
57
86
  baseUrl,
58
87
  `/audio_query?text=${encodeURIComponent(text)}&speaker=${speaker}`,
88
+ options.timeoutMs ?? QUERY_TIMEOUT_MS,
89
+ (res) => res.json() as Promise<AudioQuery>,
59
90
  { method: "POST" },
60
91
  );
61
- return (await res.json()) as AudioQuery;
62
92
  }
63
93
 
64
94
  export async function fetchSynthesis(
65
95
  baseUrl: string,
66
96
  query: AudioQuery,
67
97
  speaker: number,
98
+ options: EngineOptions = {},
68
99
  ): Promise<Buffer> {
69
- const res = await engineFetch(baseUrl, `/synthesis?speaker=${speaker}`, {
70
- method: "POST",
71
- headers: { "content-type": "application/json" },
72
- body: JSON.stringify(query),
73
- });
74
- return Buffer.from(await res.arrayBuffer());
100
+ const body = await engineFetch(
101
+ baseUrl,
102
+ `/synthesis?speaker=${speaker}`,
103
+ options.timeoutMs ?? SYNTHESIS_TIMEOUT_MS,
104
+ (res) => res.arrayBuffer(),
105
+ {
106
+ method: "POST",
107
+ headers: { "content-type": "application/json" },
108
+ body: JSON.stringify(query),
109
+ },
110
+ );
111
+ return Buffer.from(body);
75
112
  }
76
113
 
77
114
  export function resolveStyleId(speakers: VoiceSpeaker[], speaker: string): number {
@@ -161,11 +198,30 @@ export async function withEngine<T>(
161
198
  }
162
199
  }
163
200
 
164
- async function engineFetch(baseUrl: string, path: string, init?: RequestInit): Promise<Response> {
201
+ /**
202
+ * What the engine answers at `path`, read by `read` within `timeoutMs`. An engine that cannot be
203
+ * reached is missing; one that is reached but does not answer in time is stuck, and says so.
204
+ */
205
+ async function engineFetch<T>(
206
+ baseUrl: string,
207
+ path: string,
208
+ timeoutMs: number,
209
+ read: (res: Response) => Promise<T>,
210
+ init?: RequestInit,
211
+ ): Promise<T> {
212
+ const signal = AbortSignal.timeout(timeoutMs);
213
+ const timedOut = () =>
214
+ new DekError(`voice engine did not answer ${path.split("?")[0]} within ${timeoutMs / 1000}s`, {
215
+ hint: "check the engine log, and restart the engine if it is stuck, then run the command again",
216
+ });
165
217
  let res: Response;
166
218
  try {
167
- res = await fetch(`${baseUrl}${path}`, init);
219
+ // biome-ignore lint/style/noRestrictedGlobals: signal, an AbortSignal.timeout, bounds the request and read(res).
220
+ res = await fetch(`${baseUrl}${path}`, { ...init, signal });
168
221
  } catch {
222
+ if (signal.aborted) {
223
+ throw timedOut();
224
+ }
169
225
  throw engineMissingError(engineNameForUrl(baseUrl) ?? "voice engine", baseUrl);
170
226
  }
171
227
  if (!res.ok) {
@@ -173,5 +229,12 @@ async function engineFetch(baseUrl: string, path: string, init?: RequestInit): P
173
229
  hint: "check the engine log",
174
230
  });
175
231
  }
176
- return res;
232
+ try {
233
+ return await read(res);
234
+ } catch (error) {
235
+ if (signal.aborted) {
236
+ throw timedOut();
237
+ }
238
+ throw error;
239
+ }
177
240
  }