@hajimism/dek 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.
Files changed (187) hide show
  1. package/LICENSE +21 -0
  2. package/README.ja.md +75 -0
  3. package/README.md +75 -0
  4. package/package.json +65 -0
  5. package/src/cli/address.ts +53 -0
  6. package/src/cli/build.ts +85 -0
  7. package/src/cli/check.ts +97 -0
  8. package/src/cli/commands.ts +851 -0
  9. package/src/cli/contract.ts +444 -0
  10. package/src/cli/cues.ts +18 -0
  11. package/src/cli/files.ts +309 -0
  12. package/src/cli/flags.ts +295 -0
  13. package/src/cli/format.ts +156 -0
  14. package/src/cli/goto.ts +96 -0
  15. package/src/cli/init.ts +136 -0
  16. package/src/cli/keep-alive.ts +72 -0
  17. package/src/cli/lint.ts +84 -0
  18. package/src/cli/ls.ts +119 -0
  19. package/src/cli/main.ts +231 -0
  20. package/src/cli/marks.ts +16 -0
  21. package/src/cli/mv.ts +37 -0
  22. package/src/cli/new.ts +64 -0
  23. package/src/cli/pdf.ts +16 -0
  24. package/src/cli/qr.ts +466 -0
  25. package/src/cli/ref.ts +165 -0
  26. package/src/cli/rehearse.ts +42 -0
  27. package/src/cli/result.ts +161 -0
  28. package/src/cli/scope.ts +308 -0
  29. package/src/cli/serve.ts +125 -0
  30. package/src/cli/shot.ts +134 -0
  31. package/src/cli/show.ts +103 -0
  32. package/src/cli/sync.ts +37 -0
  33. package/src/cli/text.ts +479 -0
  34. package/src/cli/theme.ts +56 -0
  35. package/src/cli/tty.ts +81 -0
  36. package/src/cli/usage.ts +198 -0
  37. package/src/cli/video.ts +17 -0
  38. package/src/cli/voice.ts +113 -0
  39. package/src/cli.ts +4 -0
  40. package/src/core/assets.ts +239 -0
  41. package/src/core/build.ts +77 -0
  42. package/src/core/bundled-theme.ts +15 -0
  43. package/src/core/capture-go.ts +72 -0
  44. package/src/core/chrome.ts +128 -0
  45. package/src/core/config-keys.ts +82 -0
  46. package/src/core/config.ts +95 -0
  47. package/src/core/css-scan.ts +158 -0
  48. package/src/core/css-transform.ts +140 -0
  49. package/src/core/css-value.ts +127 -0
  50. package/src/core/css.ts +545 -0
  51. package/src/core/cue.ts +187 -0
  52. package/src/core/deck-paths.ts +34 -0
  53. package/src/core/diagnostic.ts +114 -0
  54. package/src/core/document.ts +218 -0
  55. package/src/core/error.ts +31 -0
  56. package/src/core/escape.ts +16 -0
  57. package/src/core/fill.ts +100 -0
  58. package/src/core/finish-beat.ts +30 -0
  59. package/src/core/github.ts +234 -0
  60. package/src/core/html-scan.ts +253 -0
  61. package/src/core/html.ts +234 -0
  62. package/src/core/in-page-go.ts +128 -0
  63. package/src/core/lines.ts +43 -0
  64. package/src/core/lint/asset-refs.ts +109 -0
  65. package/src/core/lint/context.ts +116 -0
  66. package/src/core/lint/files.ts +117 -0
  67. package/src/core/lint/keys.ts +48 -0
  68. package/src/core/lint/morph.ts +91 -0
  69. package/src/core/lint/script.ts +102 -0
  70. package/src/core/lint/shared-styles.ts +165 -0
  71. package/src/core/lint/slide-html.ts +466 -0
  72. package/src/core/lint/slide-script.ts +179 -0
  73. package/src/core/lint/theme-css.ts +295 -0
  74. package/src/core/lint/timing.ts +66 -0
  75. package/src/core/lint/tokens.ts +520 -0
  76. package/src/core/lint/voice.ts +76 -0
  77. package/src/core/lint.ts +137 -0
  78. package/src/core/live-protocol.ts +130 -0
  79. package/src/core/marks.ts +198 -0
  80. package/src/core/mv.ts +293 -0
  81. package/src/core/ogp.ts +99 -0
  82. package/src/core/optional.ts +24 -0
  83. package/src/core/overflow.ts +220 -0
  84. package/src/core/page-pool.ts +53 -0
  85. package/src/core/page.ts +66 -0
  86. package/src/core/parse.ts +307 -0
  87. package/src/core/path.ts +58 -0
  88. package/src/core/pdf.ts +71 -0
  89. package/src/core/playwright-module.d.ts +43 -0
  90. package/src/core/playwright-motion.ts +42 -0
  91. package/src/core/playwright-sheet.ts +32 -0
  92. package/src/core/playwright-visual.ts +391 -0
  93. package/src/core/playwright-worker.ts +5 -0
  94. package/src/core/playwright.ts +431 -0
  95. package/src/core/position.ts +146 -0
  96. package/src/core/presenter-state.ts +43 -0
  97. package/src/core/presenter.ts +131 -0
  98. package/src/core/prose.ts +16 -0
  99. package/src/core/pseudo-text.ts +67 -0
  100. package/src/core/rail-width.ts +4 -0
  101. package/src/core/ref-name.ts +110 -0
  102. package/src/core/ref.ts +238 -0
  103. package/src/core/resolve.ts +228 -0
  104. package/src/core/rumdl.ts +99 -0
  105. package/src/core/safe-fs.ts +196 -0
  106. package/src/core/sarif.ts +135 -0
  107. package/src/core/schema.ts +89 -0
  108. package/src/core/script-lines.ts +53 -0
  109. package/src/core/sheet.ts +223 -0
  110. package/src/core/shot/morph.ts +80 -0
  111. package/src/core/shot/motion.ts +187 -0
  112. package/src/core/shot/still.ts +216 -0
  113. package/src/core/shot-cache.ts +140 -0
  114. package/src/core/size.ts +11 -0
  115. package/src/core/skeleton-record.ts +51 -0
  116. package/src/core/skeleton.ts +89 -0
  117. package/src/core/slide-draw.ts +165 -0
  118. package/src/core/slide-eval-worker.ts +58 -0
  119. package/src/core/slide-measure.ts +272 -0
  120. package/src/core/slide-script-eval.ts +258 -0
  121. package/src/core/slide-script.ts +252 -0
  122. package/src/core/spawn.ts +128 -0
  123. package/src/core/step.ts +94 -0
  124. package/src/core/still-page.ts +70 -0
  125. package/src/core/suggest.ts +33 -0
  126. package/src/core/sync.ts +316 -0
  127. package/src/core/text-contrast.ts +466 -0
  128. package/src/core/theme-excerpt.ts +187 -0
  129. package/src/core/theme-facts.ts +51 -0
  130. package/src/core/timeline.ts +216 -0
  131. package/src/core/timing.ts +88 -0
  132. package/src/core/toml-keys.ts +349 -0
  133. package/src/core/url-attributes.ts +103 -0
  134. package/src/core/video-document.ts +15 -0
  135. package/src/core/visual-cache.ts +100 -0
  136. package/src/core/visual.ts +558 -0
  137. package/src/core/voice.ts +321 -0
  138. package/src/core/zod.ts +79 -0
  139. package/src/runtime/browser.ts +203 -0
  140. package/src/runtime/clone.ts +52 -0
  141. package/src/runtime/deck-control.ts +43 -0
  142. package/src/runtime/fit.ts +32 -0
  143. package/src/runtime/go.ts +56 -0
  144. package/src/runtime/input.ts +128 -0
  145. package/src/runtime/laser-view.ts +110 -0
  146. package/src/runtime/laser.ts +145 -0
  147. package/src/runtime/live-host.ts +33 -0
  148. package/src/runtime/live-reload.ts +29 -0
  149. package/src/runtime/live.ts +88 -0
  150. package/src/runtime/marks-view.ts +74 -0
  151. package/src/runtime/motion.ts +113 -0
  152. package/src/runtime/navigator.ts +189 -0
  153. package/src/runtime/player.ts +28 -0
  154. package/src/runtime/presenter-view.ts +289 -0
  155. package/src/runtime/presenter.ts +52 -0
  156. package/src/runtime/rail-view.ts +229 -0
  157. package/src/runtime/rail.ts +52 -0
  158. package/src/runtime/rehearse.ts +264 -0
  159. package/src/runtime/routes.ts +142 -0
  160. package/src/runtime/settle.ts +45 -0
  161. package/src/runtime/slide.d.ts +28 -0
  162. package/src/runtime/socket.ts +143 -0
  163. package/src/runtime/stage.ts +259 -0
  164. package/src/runtime/step.ts +139 -0
  165. package/src/runtime/window.ts +38 -0
  166. package/src/server/auth.ts +210 -0
  167. package/src/server/deck-snapshot.ts +146 -0
  168. package/src/server/dev.ts +251 -0
  169. package/src/server/hub.ts +104 -0
  170. package/src/server/lan.ts +51 -0
  171. package/src/server/lock.ts +97 -0
  172. package/src/server/pages.ts +71 -0
  173. package/src/server/pairing.ts +40 -0
  174. package/src/server/project-watch.ts +142 -0
  175. package/src/server/rooms.ts +133 -0
  176. package/src/server/routes.ts +286 -0
  177. package/src/server/serial.ts +25 -0
  178. package/src/server/watch.ts +308 -0
  179. package/src/theme/default.css +175 -0
  180. package/src/video/bake.ts +152 -0
  181. package/src/video/mux.ts +103 -0
  182. package/src/video/recorder.ts +115 -0
  183. package/src/video/sidecar.ts +60 -0
  184. package/src/video/worker.ts +51 -0
  185. package/src/voice/engine.ts +177 -0
  186. package/src/voice/synth.ts +127 -0
  187. package/src/voice/wav.ts +115 -0
@@ -0,0 +1,210 @@
1
+ import { timingSafeEqual } from "node:crypto";
2
+ import { hostname } from "node:os";
3
+ import type { LiveEventType } from "../core/live-protocol.ts";
4
+
5
+ /**
6
+ * Requests the dev server refuses before routing. A local server answers only to a loopback Host,
7
+ * so a page on another site cannot reach it by pointing its own name at 127.0.0.1 (DNS
8
+ * rebinding); a --remote one also to an address or this machine's own name, which is what the
9
+ * audience types, and to no other DNS name, which could be an attacker's. Anything that moves the deck, a WebSocket or a POST, must come from the deck's own
10
+ * origin or from no browser at all: `dekc goto` sends no Origin. A path that does not decode names
11
+ * nothing, so no route has to guard its own decodeURIComponent.
12
+ */
13
+ export function requestGuard(req: Request, options: { remote: boolean }): Response | undefined {
14
+ let url: URL;
15
+ try {
16
+ url = new URL(req.url);
17
+ } catch {
18
+ // An HTTP/1.0 request can come with no Host at all.
19
+ return new Response("Bad Request: no Host\n", { status: 400 });
20
+ }
21
+ if (!servesHost(url.hostname, options.remote)) {
22
+ return forbidden(
23
+ options.remote
24
+ ? "unexpected Host; open the dev server at its address or this machine's name"
25
+ : "unexpected Host; open the dev server at 127.0.0.1 or localhost",
26
+ );
27
+ }
28
+ if (!decodes(url.pathname)) {
29
+ return new Response("Bad Request: malformed percent-encoding\n", { status: 400 });
30
+ }
31
+ const origin = req.headers.get("origin");
32
+ const moves = req.method !== "GET" || req.headers.get("upgrade")?.toLowerCase() === "websocket";
33
+ if (moves && origin !== null && !sameOrigin(origin, url)) {
34
+ return forbidden("cross-origin request");
35
+ }
36
+ return undefined;
37
+ }
38
+
39
+ const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "[::1]"]);
40
+
41
+ function servesHost(name: string, remote: boolean): boolean {
42
+ if (LOOPBACK_HOSTS.has(name)) {
43
+ return true;
44
+ }
45
+ if (!remote) {
46
+ return false;
47
+ }
48
+ const machine = hostname()
49
+ .toLowerCase()
50
+ .replace(/\.local$/, "");
51
+ return (
52
+ /^\d{1,3}(\.\d{1,3}){3}$/.test(name) ||
53
+ /^\[[0-9a-f:.]+\]$/i.test(name) ||
54
+ name === machine ||
55
+ name === `${machine}.local`
56
+ );
57
+ }
58
+
59
+ function sameOrigin(origin: string, url: URL): boolean {
60
+ try {
61
+ return new URL(origin).origin === url.origin;
62
+ } catch {
63
+ // "null" from a file:// page or a sandboxed frame is no origin of ours.
64
+ return false;
65
+ }
66
+ }
67
+
68
+ function decodes(pathname: string): boolean {
69
+ try {
70
+ decodeURIComponent(pathname);
71
+ return true;
72
+ } catch {
73
+ return false;
74
+ }
75
+ }
76
+
77
+ function forbidden(reason: string): Response {
78
+ return new Response(`Forbidden: ${reason}\n`, { status: 403 });
79
+ }
80
+
81
+ /**
82
+ * Who may read a response. The audience sees the slides as they are shown; the presenter also
83
+ * sees what they are made from, and may move the deck.
84
+ */
85
+ export type Exposure = "audience" | "presenter";
86
+
87
+ /**
88
+ * What each dev server route reveals. The script, its notes, the voice read from it, moving the
89
+ * deck, and the marks left on its beats belong to the presenter. The server routes only by these names, so a new route does
90
+ * not compile until it is placed here.
91
+ */
92
+ export const ROUTE_EXPOSURE = {
93
+ index: "audience",
94
+ player: "audience",
95
+ presenter: "presenter",
96
+ slide: "audience",
97
+ theme: "audience",
98
+ asset: "audience",
99
+ events: "audience",
100
+ socket: "audience",
101
+ voice: "presenter",
102
+ control: "presenter",
103
+ marks: "presenter",
104
+ missing: "audience",
105
+ } as const satisfies Record<string, Exposure>;
106
+
107
+ export type Route = keyof typeof ROUTE_EXPOSURE;
108
+
109
+ /**
110
+ * What each `/events` message reveals. Diagnostics quote the script and name files on disk; the
111
+ * timeline follows the voice. A Record over every event type, so a new one must be placed here.
112
+ */
113
+ export const EVENT_EXPOSURE: Record<LiveEventType, Exposure> = {
114
+ sync: "audience",
115
+ "reload-slide": "audience",
116
+ "reload-theme": "audience",
117
+ "reload-script": "audience",
118
+ diagnostics: "presenter",
119
+ timeline: "presenter",
120
+ };
121
+
122
+ /** True when a request cleared for `seen` may read what `exposure` names. */
123
+ export function mayRead(seen: Exposure, exposure: Exposure): boolean {
124
+ return exposure === "audience" || seen === "presenter";
125
+ }
126
+
127
+ /** The cookie a paired phone holds instead of the password: its name, and the value it carries. */
128
+ export type PresenterSession = { name: string; secret: string };
129
+
130
+ /**
131
+ * The most a request may read: everything without a password, and with one, only what matching
132
+ * HTTP Basic credentials, a `?token=` (a WebSocket cannot send headers), or the cookie a pairing
133
+ * code left unlock.
134
+ */
135
+ export function clearance(
136
+ req: Request,
137
+ password: string | undefined,
138
+ session?: PresenterSession,
139
+ ): Exposure {
140
+ if (!password) {
141
+ return "presenter";
142
+ }
143
+ if (session) {
144
+ const prefix = `${session.name}=`;
145
+ const value = req.headers
146
+ .get("cookie")
147
+ ?.split(/;\s*/)
148
+ .find((pair) => pair.startsWith(prefix))
149
+ ?.slice(prefix.length);
150
+ if (value !== undefined && samePassword(value, session.secret)) {
151
+ return "presenter";
152
+ }
153
+ }
154
+ const token = new URL(req.url).searchParams.get("token");
155
+ if (token !== null && samePassword(token, password)) {
156
+ return "presenter";
157
+ }
158
+ const header = req.headers.get("authorization");
159
+ if (header?.startsWith("Basic ")) {
160
+ try {
161
+ const decoded = atob(header.slice("Basic ".length));
162
+ const colon = decoded.indexOf(":");
163
+ if (samePassword(colon >= 0 ? decoded.slice(colon + 1) : decoded, password)) {
164
+ return "presenter";
165
+ }
166
+ } catch {
167
+ /* malformed credentials */
168
+ }
169
+ }
170
+ return "audience";
171
+ }
172
+
173
+ /**
174
+ * The answer to `?pair=<code>`: a good code becomes the presenter cookie and a redirect to the
175
+ * same page without it, so the code leaves the address bar and the history. Lax, not Strict:
176
+ * a phone opens the link from its camera, and a Strict cookie would miss the redirect after.
177
+ */
178
+ export function pairingResponse(url: URL, redeemed: boolean, session: PresenterSession): Response {
179
+ if (!redeemed) {
180
+ return new Response(
181
+ "This QR code was used already or has expired; press Enter in the terminal where dek runs for a new one.\n",
182
+ { status: 403, headers: { "content-type": "text/plain; charset=utf-8" } },
183
+ );
184
+ }
185
+ const next = new URL(url);
186
+ next.searchParams.delete("pair");
187
+ return new Response(null, {
188
+ status: 303,
189
+ headers: {
190
+ location: `${next.pathname}${next.search}`,
191
+ "set-cookie": `${session.name}=${session.secret}; Path=/; HttpOnly; SameSite=Lax; Max-Age=43200`,
192
+ "cache-control": "no-store",
193
+ },
194
+ });
195
+ }
196
+
197
+ /** The answer to a request for a presenter route without the password: ask the browser for it. */
198
+ export function unauthorized(): Response {
199
+ return new Response("Unauthorized\n", {
200
+ status: 401,
201
+ headers: { "WWW-Authenticate": 'Basic realm="dek presenter"' },
202
+ });
203
+ }
204
+
205
+ /** Compare in constant time, so response timing does not leak how much of a guess was right. */
206
+ function samePassword(given: string, expected: string): boolean {
207
+ const a = new TextEncoder().encode(given);
208
+ const b = new TextEncoder().encode(expected);
209
+ return a.length === b.length && timingSafeEqual(a, b);
210
+ }
@@ -0,0 +1,146 @@
1
+ import { existsSync, readdirSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { deckPaths } from "../core/deck-paths.ts";
4
+ import type { LiveEvent } from "../core/live-protocol.ts";
5
+ import { listSlideFiles, type SlideSidecar } from "../core/resolve.ts";
6
+
7
+ /** Modification times by name; a file that is gone is left out. */
8
+ export type Mtimes = Record<string, number>;
9
+
10
+ /** What a deck watcher compares between scans: the mtime of every file a page is made from. */
11
+ export type DeckSnapshot = {
12
+ script: number;
13
+ theme: number;
14
+ /** `slides/<slug>.html`, and each sidecar kind, keyed by slug. */
15
+ slides: Mtimes;
16
+ styles: Mtimes;
17
+ scripts: Mtimes;
18
+ /** `.js` scripts, which dek does not load but lint names so the author can rename them. */
19
+ javascript: Mtimes;
20
+ /** `voice/voice.toml`, `voice/dict.toml`, and `voice/pin/*`. */
21
+ voice: Mtimes;
22
+ };
23
+
24
+ /** Which slides to lint: one slug, or the whole deck when it is left out. */
25
+ export type DiagnoseScope = { slug?: string };
26
+
27
+ /** What one scan found to do. */
28
+ export type DeckChange = {
29
+ /** The script changed: sync its slides first. */
30
+ sync: boolean;
31
+ events: LiveEvent[];
32
+ diagnose?: DiagnoseScope;
33
+ synth: boolean;
34
+ };
35
+
36
+ export function takeSnapshot(deckDir: string): DeckSnapshot {
37
+ const paths = deckPaths(deckDir);
38
+ return {
39
+ script: mtime(paths.script),
40
+ theme: mtime(paths.theme),
41
+ slides: slideMtimes(deckDir),
42
+ styles: slideMtimes(deckDir, ".css"),
43
+ scripts: slideMtimes(deckDir, ".ts"),
44
+ javascript: slideMtimes(deckDir, ".js"),
45
+ voice: voiceMtimes(paths.voice),
46
+ };
47
+ }
48
+
49
+ /**
50
+ * What changed from `prev` to `next`, as the events a page hears and the work that follows.
51
+ * Any change counts, an older mtime too: a file restored from a copy is still an edit.
52
+ */
53
+ export function diffSnapshot(prev: DeckSnapshot, next: DeckSnapshot): DeckChange {
54
+ const events: LiveEvent[] = [];
55
+ let diagnose: DiagnoseScope | undefined;
56
+ const lint = (scope: DiagnoseScope): void => {
57
+ diagnose = mergeScope(diagnose, scope);
58
+ };
59
+
60
+ const sync = next.script !== prev.script;
61
+ if (sync) {
62
+ lint({});
63
+ }
64
+ if (next.theme !== prev.theme || changedKeys(prev.styles, next.styles).length > 0) {
65
+ events.push({ type: "reload-theme" });
66
+ lint({});
67
+ }
68
+ // Slide scripts register once at page load, so a change reloads the page.
69
+ const scripts = changedKeys(prev.scripts, next.scripts);
70
+ if (scripts.length > 0) {
71
+ events.push({ type: "reload-script", slugs: scripts });
72
+ lint({});
73
+ }
74
+ if (changedKeys(prev.javascript, next.javascript).length > 0) {
75
+ lint({});
76
+ }
77
+ // A sync reloads the whole page, and the slides it writes are no edit of the author's.
78
+ if (!sync) {
79
+ const removed = Object.keys(prev.slides).filter((slug) => !(slug in next.slides));
80
+ if (removed.length > 0) {
81
+ events.push({ type: "sync", created: [], removed });
82
+ lint({});
83
+ }
84
+ for (const [slug, time] of Object.entries(next.slides)) {
85
+ if (time !== prev.slides[slug]) {
86
+ events.push({ type: "reload-slide", slug });
87
+ lint({ slug });
88
+ }
89
+ }
90
+ }
91
+ const synth = sync || changedKeys(prev.voice, next.voice).length > 0;
92
+ return { sync, events, ...(diagnose ? { diagnose } : {}), synth };
93
+ }
94
+
95
+ /** One slide stays one slide; two different ones, or the whole deck, lint the whole deck. */
96
+ export function mergeScope(a: DiagnoseScope | undefined, b: DiagnoseScope): DiagnoseScope {
97
+ if (!a) {
98
+ return b;
99
+ }
100
+ return a.slug !== undefined && a.slug === b.slug ? a : {};
101
+ }
102
+
103
+ /** Mtimes of `slides/<slug><ext>`, keyed by slug; the same files lint and render read. */
104
+ export function slideMtimes(
105
+ deckDir: string,
106
+ ext: ".html" | ".js" | SlideSidecar = ".html",
107
+ ): Mtimes {
108
+ return Object.fromEntries(
109
+ listSlideFiles(deckDir, ext).map((file) => [file.slug, mtime(file.path)]),
110
+ );
111
+ }
112
+
113
+ function voiceMtimes(dir: string): Mtimes {
114
+ const times: Mtimes = {};
115
+ if (!existsSync(dir)) {
116
+ return times;
117
+ }
118
+ for (const name of ["voice.toml", "dict.toml"]) {
119
+ const path = join(dir, name);
120
+ if (existsSync(path)) {
121
+ times[name] = mtime(path);
122
+ }
123
+ }
124
+ const pinDir = join(dir, "pin");
125
+ if (!existsSync(pinDir)) {
126
+ return times;
127
+ }
128
+ for (const name of readdirSync(pinDir)) {
129
+ times[`pin/${name}`] = mtime(join(pinDir, name));
130
+ }
131
+ return times;
132
+ }
133
+
134
+ /** Keys whose mtime changed, including files added or removed. */
135
+ function changedKeys(previous: Mtimes, next: Mtimes): string[] {
136
+ const keys = new Set([...Object.keys(previous), ...Object.keys(next)]);
137
+ return [...keys].filter((key) => (previous[key] ?? 0) !== (next[key] ?? 0)).sort();
138
+ }
139
+
140
+ function mtime(path: string): number {
141
+ try {
142
+ return statSync(path).mtimeMs;
143
+ } catch {
144
+ return 0;
145
+ }
146
+ }
@@ -0,0 +1,251 @@
1
+ import { join } from "node:path";
2
+ import { DekError } from "../core/error.ts";
3
+ import type { PlaywrightRunner } from "../core/playwright.ts";
4
+ import { locateDeck, type Project, resolveProject } from "../core/resolve.ts";
5
+ import { liveReloadScript, playerScript } from "../runtime/player.ts";
6
+ import {
7
+ clearance,
8
+ mayRead,
9
+ pairingResponse,
10
+ ROUTE_EXPOSURE,
11
+ requestGuard,
12
+ unauthorized,
13
+ } from "./auth.ts";
14
+ import type { EventFeed } from "./hub.ts";
15
+ import { generateRemotePassword, lanUrls } from "./lan.ts";
16
+ import { assertNoDevServer, removeDevServerLock, writeDevServerLock } from "./lock.ts";
17
+ import { createDevPages } from "./pages.ts";
18
+ import { createPairings, type Pairings } from "./pairing.ts";
19
+ import { createProjectWatch } from "./project-watch.ts";
20
+ import { createRooms, type RoomClient } from "./rooms.ts";
21
+ import { type RouteContext, routeRequest, type WsData } from "./routes.ts";
22
+ import { POLL_INTERVAL_MS } from "./watch.ts";
23
+
24
+ export type DevServer = {
25
+ url: string;
26
+ listenHostname: string;
27
+ remoteUrls: string[];
28
+ deckDir?: string;
29
+ decks: string[];
30
+ close: () => Promise<void>;
31
+ /** What the decks' watchers report, each event with its deck. */
32
+ events: EventFeed;
33
+ /** With --remote: a one-use code for a QR code that lets a phone in as the presenter. */
34
+ pair?: () => string;
35
+ };
36
+
37
+ export type DevServerOptions = {
38
+ cwd: string;
39
+ deck?: string;
40
+ port?: number;
41
+ visual?: boolean;
42
+ visualRunner?: PlaywrightRunner;
43
+ remote?: boolean;
44
+ /** The --remote password, which dek makes; never taken from the user, so never weak. */
45
+ password?: string;
46
+ /** How long a pairing code stays good; see PAIRING_TTL_MS. */
47
+ pairingTtlMs?: number;
48
+ /** How often the project and each deck are re-scanned for edits fs.watch missed. */
49
+ pollIntervalMs?: number;
50
+ };
51
+
52
+ /** Who may reach the server, and how a presenter proves who they are. */
53
+ type Access = {
54
+ remote: boolean;
55
+ listenHostname: string;
56
+ password: string | undefined;
57
+ pairings: Pairings | undefined;
58
+ /** What a paired phone's cookie carries: new on every start, so a restart ends every pairing. */
59
+ sessionSecret: string;
60
+ };
61
+
62
+ /** What the routes read, all but the socket upgrade, which needs the listening server. */
63
+ type ServedRoutes = Omit<RouteContext, "upgrade">;
64
+
65
+ export async function startDevServer(options: DevServerOptions): Promise<DevServer> {
66
+ const project = resolveProject(options.cwd);
67
+ // Before the port and the watchers; writing the lock checks again, for a server that raced us.
68
+ assertNoDevServer(project.root);
69
+ const { scopedDeckName, deckDir } = serverScope(project, options);
70
+ const access = accessFor(options);
71
+ const embed = {
72
+ playerScript: await playerScript(),
73
+ liveReloadScript: liveReloadScript(),
74
+ };
75
+
76
+ const pages = createDevPages({
77
+ embed,
78
+ remote: access.remote,
79
+ password: access.password,
80
+ project: () => watch.project(),
81
+ });
82
+ const rooms = createRooms<RoomClient>(() => watch.project().decks);
83
+ const watch = createProjectWatch({
84
+ root: options.cwd,
85
+ project,
86
+ ...(scopedDeckName ? { scopedDeckName } : {}),
87
+ visual: options.visual === true,
88
+ ...(options.visualRunner ? { visualRunner: options.visualRunner } : {}),
89
+ pollIntervalMs: options.pollIntervalMs ?? POLL_INTERVAL_MS,
90
+ onChange: () => {
91
+ pages.clear();
92
+ // A script edit may have removed the slide or beat a room stood at.
93
+ rooms.reclamp();
94
+ },
95
+ });
96
+ const routes: ServedRoutes = {
97
+ ...(scopedDeckName ? { scopedDeckName } : {}),
98
+ ...(deckDir ? { deckDir } : {}),
99
+ project: watch.project,
100
+ hub: watch.hub,
101
+ rooms,
102
+ deckPage: pages.deckPage,
103
+ indexPage: pages.indexPage,
104
+ };
105
+
106
+ let server: Bun.Server<WsData>;
107
+ try {
108
+ server = listen(options.port, access, routes);
109
+ } catch (error) {
110
+ watch.close();
111
+ throw listenError(error, options.port);
112
+ }
113
+
114
+ const port = server.port ?? 0;
115
+ const url = `http://127.0.0.1:${port}/`;
116
+ const remoteUrls = [...new Set([url, ...(access.remote ? lanUrls(port) : [])])];
117
+ try {
118
+ writeDevServerLock(project.root, url, access.password, scopedDeckName);
119
+ } catch (error) {
120
+ watch.close();
121
+ await server.stop(true);
122
+ throw error;
123
+ }
124
+
125
+ const { pairings } = access;
126
+ return {
127
+ url,
128
+ listenHostname: access.listenHostname,
129
+ remoteUrls,
130
+ ...(deckDir ? { deckDir } : {}),
131
+ decks: project.decks.map((deck) => deck.name),
132
+ events: { listen: watch.hub.listen },
133
+ ...(pairings ? { pair: () => pairings.issue() } : {}),
134
+ async close() {
135
+ removeDevServerLock(project.root);
136
+ // Connections first: Bun never finishes stopping once two or more SSE streams were closed.
137
+ await server.stop(true);
138
+ watch.close();
139
+ },
140
+ };
141
+ }
142
+
143
+ /** The deck the server is scoped to, by --deck or by the directory it started in, if any. */
144
+ function serverScope(
145
+ project: Project,
146
+ options: DevServerOptions,
147
+ ): { scopedDeckName: string | undefined; deckDir: string | undefined } {
148
+ const fromCwd = locateDeck(project.root, options.cwd);
149
+ const scopedDeckName = options.deck ?? fromCwd?.name;
150
+ if (options.deck) {
151
+ assertKnownDeck(project, options.deck);
152
+ }
153
+ const deckDir = scopedDeckName ? join(project.root, "decks", scopedDeckName) : fromCwd?.dir;
154
+ return { scopedDeckName, deckDir };
155
+ }
156
+
157
+ /** A deck that failed to parse is still one to serve: its page shows why. */
158
+ function assertKnownDeck(project: Project, name: string): void {
159
+ const known =
160
+ project.decks.some((entry) => entry.name === name) ||
161
+ project.failed.some((entry) => entry.name === name);
162
+ if (!known) {
163
+ throw new DekError(`deck "${name}" not found`, {
164
+ path: join(project.root, "decks", name),
165
+ hint: "run `dekc ls`",
166
+ });
167
+ }
168
+ }
169
+
170
+ function accessFor(options: DevServerOptions): Access {
171
+ const remote = options.remote === true;
172
+ return {
173
+ remote,
174
+ listenHostname: remote ? "0.0.0.0" : "127.0.0.1",
175
+ // An empty one would open the presenter to the LAN.
176
+ password: remote ? options.password || generateRemotePassword() : options.password,
177
+ pairings: remote
178
+ ? createPairings(options.pairingTtlMs !== undefined ? { ttlMs: options.pairingTtlMs } : {})
179
+ : undefined,
180
+ sessionSecret: generateRemotePassword(32),
181
+ };
182
+ }
183
+
184
+ function listen(port: number | undefined, access: Access, routes: ServedRoutes) {
185
+ return Bun.serve<WsData>({
186
+ hostname: access.listenHostname,
187
+ port: port ?? 0,
188
+ // Never Bun's development error page: it shows the source and absolute paths to anyone.
189
+ development: false,
190
+ fetch: (req, server) => answer(req, server, access, routes),
191
+ error(error) {
192
+ console.error(error);
193
+ return new Response("Internal Server Error\n", { status: 500 });
194
+ },
195
+ websocket: roomSockets(routes.rooms),
196
+ });
197
+ }
198
+
199
+ /** Refuse what the guard refuses, redeem a pairing code, then route what the request may read. */
200
+ function answer(req: Request, server: Bun.Server<WsData>, access: Access, routes: ServedRoutes) {
201
+ const refused = requestGuard(req, { remote: access.remote });
202
+ if (refused) {
203
+ return refused;
204
+ }
205
+ // Per port: a phone may pair with two dek servers on one machine.
206
+ const session = { name: `dek-presenter-${server.port}`, secret: access.sessionSecret };
207
+ const { pairings } = access;
208
+ const code = pairings && new URL(req.url).searchParams.get("pair");
209
+ if (pairings && typeof code === "string") {
210
+ return pairingResponse(new URL(req.url), pairings.redeem(code), session);
211
+ }
212
+ const routed = routeRequest(req, {
213
+ ...routes,
214
+ upgrade: (request, data) => server.upgrade(request, { data }),
215
+ });
216
+ const seen = clearance(req, access.password, access.remote ? session : undefined);
217
+ if (!mayRead(seen, ROUTE_EXPOSURE[routed.route])) {
218
+ return unauthorized();
219
+ }
220
+ return routed.respond(seen);
221
+ }
222
+
223
+ /** Each socket joins its deck's room; only a presenter's socket may move it or point in it. */
224
+ function roomSockets(rooms: RouteContext["rooms"]): Bun.WebSocketHandler<WsData> {
225
+ return {
226
+ // A position or a point is a few dozen bytes; anything near this is neither.
227
+ maxPayloadLength: 4 * 1024,
228
+ open(ws) {
229
+ rooms.join(ws.data.room, ws);
230
+ },
231
+ message(ws, message) {
232
+ if (!ws.data.control) {
233
+ return;
234
+ }
235
+ const payload = typeof message === "string" ? message : new TextDecoder().decode(message);
236
+ rooms.relay(ws.data.room, ws, payload);
237
+ },
238
+ close(ws) {
239
+ rooms.leave(ws.data.room, ws);
240
+ },
241
+ };
242
+ }
243
+
244
+ function listenError(error: unknown, port: number | undefined): unknown {
245
+ if (port && (error as { code?: string } | null)?.code === "EADDRINUSE") {
246
+ return new DekError(`port ${port} is already in use`, {
247
+ hint: "pass another --port, or omit it to let the OS choose",
248
+ });
249
+ }
250
+ return error;
251
+ }