mdeck 2.3.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 (268) hide show
  1. package/CHANGELOG.md +259 -0
  2. package/LICENSE +21 -0
  3. package/README.md +156 -0
  4. package/assets/base.css +196 -0
  5. package/assets/extensions/layouts/chapter/extension.toml +26 -0
  6. package/assets/extensions/layouts/chapter/layout.jsx +21 -0
  7. package/assets/extensions/layouts/chapter/starter.md +8 -0
  8. package/assets/extensions/layouts/focus/extension.toml +8 -0
  9. package/assets/extensions/layouts/focus/layout.jsx +10 -0
  10. package/assets/extensions/layouts/focus/starter.md +4 -0
  11. package/assets/extensions/layouts/full-bleed-image/extension.toml +31 -0
  12. package/assets/extensions/layouts/full-bleed-image/layout.jsx +19 -0
  13. package/assets/extensions/layouts/full-bleed-image/starter.md +6 -0
  14. package/assets/extensions/layouts/generic/extension.toml +8 -0
  15. package/assets/extensions/layouts/generic/layout.jsx +8 -0
  16. package/assets/extensions/layouts/generic/starter.md +4 -0
  17. package/assets/extensions/layouts/image-text/extension.toml +25 -0
  18. package/assets/extensions/layouts/image-text/layout.jsx +17 -0
  19. package/assets/extensions/layouts/image-text/starter.md +6 -0
  20. package/assets/extensions/layouts/split/extension.toml +25 -0
  21. package/assets/extensions/layouts/split/layout.jsx +19 -0
  22. package/assets/extensions/layouts/split/starter.md +14 -0
  23. package/assets/extensions/layouts/title/extension.toml +26 -0
  24. package/assets/extensions/layouts/title/layout.jsx +14 -0
  25. package/assets/extensions/layouts/title/starter.md +5 -0
  26. package/assets/extensions/palettes/brand/extension.toml +29 -0
  27. package/assets/extensions/palettes/cobalt/extension.toml +27 -0
  28. package/assets/extensions/palettes/ember/extension.toml +27 -0
  29. package/assets/extensions/palettes/forest/extension.toml +27 -0
  30. package/assets/extensions/palettes/graphite/extension.toml +27 -0
  31. package/assets/extensions/palettes/lagoon/extension.toml +27 -0
  32. package/assets/extensions/palettes/neon/extension.toml +27 -0
  33. package/assets/extensions/palettes/nordic/extension.toml +27 -0
  34. package/assets/extensions/palettes/pastel/extension.toml +30 -0
  35. package/assets/extensions/palettes/phosphor/extension.toml +27 -0
  36. package/assets/extensions/palettes/swiss/extension.toml +27 -0
  37. package/assets/extensions/palettes/terra/extension.toml +27 -0
  38. package/assets/extensions/themes/academic/extension.toml +53 -0
  39. package/assets/extensions/themes/academic/styles.css +631 -0
  40. package/assets/extensions/themes/aurora/extension.toml +37 -0
  41. package/assets/extensions/themes/aurora/styles.css +644 -0
  42. package/assets/extensions/themes/duet/extension.toml +46 -0
  43. package/assets/extensions/themes/duet/styles.css +573 -0
  44. package/assets/extensions/themes/editorial/extension.toml +46 -0
  45. package/assets/extensions/themes/editorial/styles.css +574 -0
  46. package/assets/extensions/themes/fhnw/extension.toml +61 -0
  47. package/assets/extensions/themes/fhnw/styles.css +604 -0
  48. package/assets/extensions/themes/neue/extension.toml +46 -0
  49. package/assets/extensions/themes/neue/styles.css +489 -0
  50. package/assets/extensions/themes/sketch/extension.toml +48 -0
  51. package/assets/extensions/themes/sketch/styles.css +633 -0
  52. package/assets/extensions/themes/terminal/extension.toml +38 -0
  53. package/assets/extensions/themes/terminal/styles.css +550 -0
  54. package/assets/logo/avatar.png +0 -0
  55. package/assets/logo/logo-dark.svg +10 -0
  56. package/assets/logo/logo-light.svg +10 -0
  57. package/assets/logo/mark.svg +15 -0
  58. package/bin/mdeck.js +2 -0
  59. package/docs/README.md +8 -0
  60. package/docs/reference/authoring.md +795 -0
  61. package/docs/reference/claude-skill.md +96 -0
  62. package/docs/reference/extensions.md +250 -0
  63. package/docs/reference/layouts.md +153 -0
  64. package/docs/reference/palettes.md +106 -0
  65. package/docs/reference/structured-slides.md +120 -0
  66. package/docs/reference/themes.md +133 -0
  67. package/docs/site/assets/favicon.svg +15 -0
  68. package/docs/site/assets/github-logo.svg +1 -0
  69. package/docs/site/assets/site.css +162 -0
  70. package/docs/site/assets/site.js +123 -0
  71. package/docs/site/build.js +148 -0
  72. package/docs/site/content/appearance.md +203 -0
  73. package/docs/site/content/audience.md +178 -0
  74. package/docs/site/content/commands.md +153 -0
  75. package/docs/site/content/components.md +134 -0
  76. package/docs/site/content/drawing.md +72 -0
  77. package/docs/site/content/editing.md +59 -0
  78. package/docs/site/content/getting-started.md +103 -0
  79. package/docs/site/content/layouts.md +126 -0
  80. package/docs/site/content/media.md +107 -0
  81. package/docs/site/content/more-content.md +130 -0
  82. package/docs/site/content/presenting.md +93 -0
  83. package/docs/site/content/reusable-layouts.md +74 -0
  84. package/docs/site/content/sharing.md +88 -0
  85. package/docs/site/content/theme-authoring.md +144 -0
  86. package/docs/site/content/theme-repository.md +76 -0
  87. package/docs/site/content/troubleshooting.md +92 -0
  88. package/docs/site/content/views.md +56 -0
  89. package/docs/site/content/welcome.md +71 -0
  90. package/docs/site/content/writing.md +121 -0
  91. package/docs/site/examples/first-talk.md +88 -0
  92. package/docs/site/examples/notebook.svg +58 -0
  93. package/docs/site/images/themes/academic-dark.webp +0 -0
  94. package/docs/site/images/themes/academic-light.webp +0 -0
  95. package/docs/site/images/themes/academic.webp +0 -0
  96. package/docs/site/images/themes/aurora-dark.webp +0 -0
  97. package/docs/site/images/themes/aurora-light.webp +0 -0
  98. package/docs/site/images/themes/aurora.webp +0 -0
  99. package/docs/site/images/themes/duet-dark.webp +0 -0
  100. package/docs/site/images/themes/duet-light.webp +0 -0
  101. package/docs/site/images/themes/duet.webp +0 -0
  102. package/docs/site/images/themes/editorial-dark.webp +0 -0
  103. package/docs/site/images/themes/editorial-light.webp +0 -0
  104. package/docs/site/images/themes/editorial.webp +0 -0
  105. package/docs/site/images/themes/fhnw.webp +0 -0
  106. package/docs/site/images/themes/neue-dark.webp +0 -0
  107. package/docs/site/images/themes/neue-light.webp +0 -0
  108. package/docs/site/images/themes/neue.webp +0 -0
  109. package/docs/site/images/themes/sketch-dark.webp +0 -0
  110. package/docs/site/images/themes/sketch-light.webp +0 -0
  111. package/docs/site/images/themes/sketch.webp +0 -0
  112. package/docs/site/images/themes/terminal-dark.webp +0 -0
  113. package/docs/site/images/themes/terminal-light.webp +0 -0
  114. package/docs/site/images/themes/terminal.webp +0 -0
  115. package/docs/site/package.json +9 -0
  116. package/docs/site/pages.js +27 -0
  117. package/docs/site/server.js +58 -0
  118. package/examples/custom-layouts/extensions/comparison/extension.toml +35 -0
  119. package/examples/custom-layouts/extensions/comparison/layout.jsx +15 -0
  120. package/examples/custom-layouts/extensions/comparison/starter.md +17 -0
  121. package/examples/custom-layouts/extensions/comparison/styles.css +4 -0
  122. package/examples/custom-layouts/slides.md +60 -0
  123. package/examples/poll/slides.md +127 -0
  124. package/examples/python/.mdeck-backups/slides.drawings-2026-10-06T08-36-47Z.json +19 -0
  125. package/examples/python/.mdeck-backups/slides.drawings-2026-10-06T11-27-30Z.json +6 -0
  126. package/examples/python/slides.drawings.json +6 -0
  127. package/examples/python/slides.md +493 -0
  128. package/examples/showcase/.mdeck-backups/slides.drawings-2026-10-06T08-08-30Z.json +10 -0
  129. package/examples/showcase/.mdeck-backups/slides.drawings-2026-10-06T11-41-35Z.json +13 -0
  130. package/examples/showcase/img/image.jpg +0 -0
  131. package/examples/showcase/img/mdeck.svg +66 -0
  132. package/examples/showcase/slides.drawings.json +11 -0
  133. package/examples/showcase/slides.md +427 -0
  134. package/package.json +75 -0
  135. package/skills/write-slides/SKILL.md +144 -0
  136. package/src/build/check.js +49 -0
  137. package/src/build/chrome.js +130 -0
  138. package/src/build/components.js +44 -0
  139. package/src/build/config.js +61 -0
  140. package/src/build/drawings.js +14 -0
  141. package/src/build/editorPlugin.js +272 -0
  142. package/src/build/editorServer.js +36 -0
  143. package/src/build/fonts.js +84 -0
  144. package/src/build/homePlugin.js +208 -0
  145. package/src/build/inkPlugin.js +192 -0
  146. package/src/build/ownWrites.js +18 -0
  147. package/src/build/pairing.js +81 -0
  148. package/src/build/pdf.js +34 -0
  149. package/src/build/relayAccess.js +85 -0
  150. package/src/build/slidesPlugin.js +371 -0
  151. package/src/build/themeCheck.js +151 -0
  152. package/src/build/tunnelClient.js +184 -0
  153. package/src/cli/args.js +76 -0
  154. package/src/cli/index.js +758 -0
  155. package/src/cli/migrate.js +123 -0
  156. package/src/cli/skill.js +40 -0
  157. package/src/cli/themes.js +140 -0
  158. package/src/components/CodeBlock.jsx +168 -0
  159. package/src/components/ConnectedViews.jsx +25 -0
  160. package/src/components/Icon.jsx +8 -0
  161. package/src/components/Poll.jsx +67 -0
  162. package/src/components/QrCode.jsx +29 -0
  163. package/src/components/QrCodeTag.jsx +27 -0
  164. package/src/components/Question.jsx +28 -0
  165. package/src/components/Scale.jsx +47 -0
  166. package/src/components/VideoPlayer.jsx +108 -0
  167. package/src/components/WordCloud.jsx +106 -0
  168. package/src/components/activity.jsx +35 -0
  169. package/src/components/callout.css +78 -0
  170. package/src/components/code-block.css +123 -0
  171. package/src/components/columns.css +11 -0
  172. package/src/components/poll.css +81 -0
  173. package/src/components/video-player.css +14 -0
  174. package/src/core/devPath.js +4 -0
  175. package/src/core/editDeck.js +230 -0
  176. package/src/core/icons.js +69 -0
  177. package/src/core/ink.js +221 -0
  178. package/src/core/labels.js +144 -0
  179. package/src/core/outline.js +33 -0
  180. package/src/core/parseSlides.js +152 -0
  181. package/src/core/source.js +137 -0
  182. package/src/core/urls.js +34 -0
  183. package/src/core/validateDeck.js +139 -0
  184. package/src/editor/api.js +67 -0
  185. package/src/editor/components/App.jsx +75 -0
  186. package/src/editor/components/ConflictBanner.jsx +10 -0
  187. package/src/editor/components/DeckSettings.jsx +108 -0
  188. package/src/editor/components/DesignApp.jsx +252 -0
  189. package/src/editor/components/Diagnostics.jsx +10 -0
  190. package/src/editor/components/ExtensionEditor.jsx +66 -0
  191. package/src/editor/components/ExtensionForms.jsx +134 -0
  192. package/src/editor/components/ExtensionsPanel.jsx +114 -0
  193. package/src/editor/components/Field.jsx +24 -0
  194. package/src/editor/components/Inspector.jsx +36 -0
  195. package/src/editor/components/LayoutPicker.jsx +23 -0
  196. package/src/editor/components/LayoutSelect.jsx +25 -0
  197. package/src/editor/components/LookThumbnail.jsx +8 -0
  198. package/src/editor/components/MetaFields.jsx +24 -0
  199. package/src/editor/components/Outline.jsx +28 -0
  200. package/src/editor/components/PalettePicker.jsx +32 -0
  201. package/src/editor/components/Preview.jsx +63 -0
  202. package/src/editor/components/PropsForm.jsx +46 -0
  203. package/src/editor/components/RawSlideEditor.jsx +9 -0
  204. package/src/editor/components/RegionEditor.jsx +15 -0
  205. package/src/editor/components/Resizer.jsx +18 -0
  206. package/src/editor/designLink.js +19 -0
  207. package/src/editor/editor.css +139 -0
  208. package/src/editor/extensions.js +86 -0
  209. package/src/editor/keyboard.js +21 -0
  210. package/src/editor/model.js +38 -0
  211. package/src/editor/sampleDeck.js +165 -0
  212. package/src/editor/saveQueue.js +47 -0
  213. package/src/editor/schema.js +36 -0
  214. package/src/editor/state.js +92 -0
  215. package/src/editor/useDeckDocument.js +67 -0
  216. package/src/extensions/appearance.js +79 -0
  217. package/src/extensions/contrast.js +57 -0
  218. package/src/extensions/discover.js +126 -0
  219. package/src/extensions/manifest.js +209 -0
  220. package/src/extensions/packs.js +268 -0
  221. package/src/extensions/tokens.js +25 -0
  222. package/src/home/Home.jsx +362 -0
  223. package/src/home/home.css +148 -0
  224. package/src/layouts/layoutApi.jsx +205 -0
  225. package/src/layouts/layoutProps.js +30 -0
  226. package/src/layouts/renderSlide.jsx +17 -0
  227. package/src/live/answer/answer.css +34 -0
  228. package/src/live/answer/answer.html +14 -0
  229. package/src/live/answer/answer.js +130 -0
  230. package/src/live/client.js +290 -0
  231. package/src/live/code.js +19 -0
  232. package/src/live/follow.js +17 -0
  233. package/src/live/pairing.js +34 -0
  234. package/src/live/presence.js +33 -0
  235. package/src/live/roomTag.js +38 -0
  236. package/src/live/rooms.js +249 -0
  237. package/src/live/server.js +82 -0
  238. package/src/live/tunnel.js +180 -0
  239. package/src/paths.js +9 -0
  240. package/src/runtime/ReaderView.jsx +193 -0
  241. package/src/runtime/SlideErrorBoundary.jsx +28 -0
  242. package/src/runtime/chrome.jsx +55 -0
  243. package/src/runtime/deck-stage.js +1533 -0
  244. package/src/runtime/design.html +12 -0
  245. package/src/runtime/design.jsx +5 -0
  246. package/src/runtime/editor.html +12 -0
  247. package/src/runtime/editor.jsx +5 -0
  248. package/src/runtime/editorBridge.js +58 -0
  249. package/src/runtime/fit.js +149 -0
  250. package/src/runtime/home.html +13 -0
  251. package/src/runtime/home.jsx +5 -0
  252. package/src/runtime/index.html +15 -0
  253. package/src/runtime/ink/InkLayer.jsx +17 -0
  254. package/src/runtime/ink/Toolbar.jsx +145 -0
  255. package/src/runtime/ink/attach.js +69 -0
  256. package/src/runtime/ink/bus.js +126 -0
  257. package/src/runtime/ink/controller.js +132 -0
  258. package/src/runtime/ink/room.js +101 -0
  259. package/src/runtime/ink/select.js +124 -0
  260. package/src/runtime/ink/server.js +75 -0
  261. package/src/runtime/ink/store.js +55 -0
  262. package/src/runtime/ink/toolbar.css +79 -0
  263. package/src/runtime/main.jsx +863 -0
  264. package/src/runtime/markedSetup.js +147 -0
  265. package/src/runtime/reader.css +59 -0
  266. package/src/runtime/registry.jsx +26 -0
  267. package/src/runtime/syncOrder.js +32 -0
  268. package/src/runtime/themeLoader.js +68 -0
@@ -0,0 +1,249 @@
1
+ // Rooms for audience interaction. A room is a list of small messages that
2
+ // phones post and slides follow live; the server never interprets them, so a
3
+ // new kind of activity (poll, word cloud, questions) needs no server change.
4
+ // A room also has one state value that only the presenter sets: the deck's
5
+ // session room holds the activity on screen, which the answer page shows.
6
+ // Everything lives in memory: nothing is written to disk, and idle rooms are
7
+ // forgotten. The same handler runs inside `mdeck run` and as `mdeck server`.
8
+ import { timingSafeEqual } from 'node:crypto'
9
+ import { readFileSync } from 'node:fs'
10
+ import { CODE_RE } from './code.js'
11
+
12
+ // The phones' answer page, served by the server itself at /<code>.
13
+ const ANSWER_FILES = Object.fromEntries(['answer.html', 'answer.js', 'answer.css'].map(name => [name, readFileSync(new URL(`./answer/${name}`, import.meta.url))]))
14
+ const ANSWER_TYPES = { html: 'text/html; charset=utf-8', js: 'text/javascript; charset=utf-8', css: 'text/css; charset=utf-8' }
15
+
16
+ export const ROOM_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/
17
+ // What a connected window may say it is, for the presenter's overview of
18
+ // who is connected (src/components/ConnectedViews.jsx); phones only count.
19
+ export const VIEWS = ['presenter', 'audience', 'deck', 'phone']
20
+ export const DEVICES = ['computer', 'ipad', 'tablet', 'phone']
21
+ const HOUR = 60 * 60 * 1000
22
+
23
+ export function createRooms({ maxMessages = 5000, maxInk = 5000, maxRooms = 500, idleMs = 12 * HOUR, now = Date.now } = {}) {
24
+ const rooms = new Map()
25
+ const prune = () => { for (const [id, room] of rooms) if (!room.listeners.size && now() - room.touched > idleMs) rooms.delete(id) }
26
+ const get = id => {
27
+ let room = rooms.get(id)
28
+ if (!room) {
29
+ if (rooms.size >= maxRooms) prune()
30
+ if (rooms.size >= maxRooms) throw Object.assign(new Error('Too many rooms are open on this server'), { status: 503 })
31
+ room = { messages: [], state: null, stamp: 0, screens: new Map(), ink: [], listeners: new Set(), present: new Map(), next: 1, touched: now() }
32
+ rooms.set(id, room)
33
+ }
34
+ room.touched = now()
35
+ return room
36
+ }
37
+ const broadcast = (room, event) => { for (const listener of room.listeners) listener(event) }
38
+ // A session is a room and the rooms named after it (`<code>.stage`, …).
39
+ const sessionRooms = id => { const session = id.split('.')[0]; return [...rooms].filter(([name]) => name === session || name.startsWith(`${session}.`)).map(([, room]) => room) }
40
+ const presence = id => {
41
+ const present = sessionRooms(id).flatMap(room => [...room.present.values()])
42
+ const views = present.filter(entry => entry.view !== 'phone').sort((a, b) => VIEWS.indexOf(a.view) - VIEWS.indexOf(b.view))
43
+ return { views, phones: present.length - views.length }
44
+ }
45
+ const announcePresence = id => { const event = { type: 'presence', presence: presence(id) }; for (const room of sessionRooms(id)) broadcast(room, event) }
46
+ return {
47
+ post(id, { from = null, data }) {
48
+ const room = get(id)
49
+ if (room.messages.length >= maxMessages) throw Object.assign(new Error('This room is full'), { status: 429 })
50
+ const message = { n: room.next++, at: new Date(now()).toISOString(), from, data }
51
+ room.messages.push(message)
52
+ broadcast(room, { type: 'message', message })
53
+ return message
54
+ },
55
+ reset(id) {
56
+ const room = get(id)
57
+ room.messages = []
58
+ broadcast(room, { type: 'reset' })
59
+ },
60
+ // A state carries `at`, when its screen last changed it. The latest change
61
+ // wins: a state older than the current one is ignored (returns false), so
62
+ // a forgotten screen repeating an old slide cannot take the phones back.
63
+ // States that name their `screen` are ordered by when this server first
64
+ // heard of the change, not by the clocks of the devices (an iPad and a
65
+ // laptop rarely agree to the second); a screen that just opened says
66
+ // `at: 0` and is the oldest.
67
+ setState(id, state) {
68
+ const room = get(id)
69
+ const next = state ?? null
70
+ const at = Number.isFinite(next?.at) ? next.at : 0
71
+ let stamp = at
72
+ if (typeof next?.screen === 'string' && at) {
73
+ const seen = room.screens.get(next.screen)
74
+ stamp = seen?.at === at ? seen.stamp : Math.max(now(), room.stamp + 1)
75
+ room.screens.delete(next.screen)
76
+ room.screens.set(next.screen, { at, stamp })
77
+ if (room.screens.size > 50) room.screens.delete(room.screens.keys().next().value)
78
+ }
79
+ // A state without `at`, as from an outdated page, counts as the oldest.
80
+ if (next && room.state && stamp < room.stamp) return false
81
+ room.stamp = next ? stamp : 0
82
+ // The presenter repeats its state; listeners only hear changes.
83
+ if (JSON.stringify(next) === JSON.stringify(room.state)) return true
84
+ room.state = next
85
+ broadcast(room, { type: 'state', state: room.state })
86
+ return true
87
+ },
88
+ // Live ink (src/runtime/ink/bus.js): passed on at once and not kept,
89
+ // except the changes of the saved ink, which a screen that opens later
90
+ // gets with the snapshot. Returns how many listen.
91
+ relayInk(id, messages) {
92
+ const room = get(id)
93
+ const changes = messages.filter(message => message?.type === 'op')
94
+ if (changes.length) room.ink = [...room.ink, ...changes].slice(-maxInk)
95
+ broadcast(room, { type: 'ink', messages })
96
+ return room.listeners.size
97
+ },
98
+ ink: id => get(id).ink,
99
+ snapshot: id => get(id).messages,
100
+ state: id => get(id).state,
101
+ presence: id => { get(id); return presence(id) },
102
+ // A listener that says what it is ({ view, device }) counts in the
103
+ // session's presence; every change reaches all of the session's rooms.
104
+ subscribe(id, listener, present = null) {
105
+ const room = get(id)
106
+ room.listeners.add(listener)
107
+ if (present) { room.present.set(listener, present); announcePresence(id) }
108
+ return () => {
109
+ room.listeners.delete(listener)
110
+ room.touched = now()
111
+ if (room.present.delete(listener)) announcePresence(id)
112
+ }
113
+ },
114
+ get size() { return rooms.size },
115
+ }
116
+ }
117
+
118
+ // Per-address token bucket: `burst` messages at once, refilled over `perMs`.
119
+ export function createLimiter({ burst = 30, perMs = 10000, now = Date.now } = {}) {
120
+ const buckets = new Map()
121
+ return key => {
122
+ const t = now()
123
+ const bucket = buckets.get(key) ?? { tokens: burst, at: t }
124
+ bucket.tokens = Math.min(burst, bucket.tokens + ((t - bucket.at) / perMs) * burst)
125
+ bucket.at = t
126
+ buckets.set(key, bucket)
127
+ if (buckets.size > 10000) for (const [k, b] of buckets) if (b.tokens >= burst) buckets.delete(k)
128
+ if (bucket.tokens < 1) return false
129
+ bucket.tokens -= 1
130
+ return true
131
+ }
132
+ }
133
+
134
+ const LOOPBACK = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1'])
135
+ export const isLoopbackAddress = address => LOOPBACK.has(address)
136
+
137
+ // Behind a proxy on the same machine (nginx), the client is the forwarded one.
138
+ export function clientAddress(request) {
139
+ const direct = request.socket?.remoteAddress ?? ''
140
+ const forwarded = request.headers['x-forwarded-for']
141
+ return isLoopbackAddress(direct) && forwarded ? forwarded.split(',')[0].trim() : direct
142
+ }
143
+
144
+ export function keyMatches(request, key) {
145
+ if (!key) return false
146
+ const given = Buffer.from((request.headers.authorization ?? '').replace(/^Bearer\s+/i, ''))
147
+ const wanted = Buffer.from(key)
148
+ return given.length === wanted.length && timingSafeEqual(given, wanted)
149
+ }
150
+
151
+ const MAX_BODY = 4096
152
+ const MAX_STATE = 32768
153
+ const MAX_INK = 65536
154
+
155
+ function readBody(request, limit = MAX_BODY) {
156
+ return new Promise((done, fail) => {
157
+ if (!/^application\/json\b/i.test(request.headers['content-type'] ?? '')) return fail(Object.assign(new Error('Send JSON'), { status: 415 }))
158
+ let size = 0
159
+ const chunks = []
160
+ request.on('data', chunk => {
161
+ size += chunk.length
162
+ if (size > limit) { request.pause(); fail(Object.assign(new Error('Message too large'), { status: 413, close: true })); return }
163
+ chunks.push(chunk)
164
+ })
165
+ request.on('end', () => { try { done(JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}')) } catch { fail(Object.assign(new Error('Not valid JSON'), { status: 400 })) } })
166
+ request.on('error', fail)
167
+ })
168
+ }
169
+
170
+ function reply(response, status, body) {
171
+ const text = JSON.stringify(body)
172
+ response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' })
173
+ response.end(text)
174
+ }
175
+
176
+ // Routes, relative to where the handler is mounted:
177
+ // GET /<code> the phones' answer page (and answer.js, answer.css)
178
+ // GET /info { canReset, ...extra }
179
+ // GET /rooms/<id>/events Server-Sent Events: snapshot, message, reset, state;
180
+ // ?view=…&device=… counts this window as connected,
181
+ // ?presence=1 adds who is connected to the session
182
+ // GET /rooms/<id>/presence who is connected to the session, once
183
+ // POST /rooms/<id> { from, data } → { n }
184
+ // POST /rooms/<id>/reset presenter only
185
+ // POST /rooms/<id>/state { state } — presenter only
186
+ // POST /rooms/<id>/ink { messages } — presenter only, live ink
187
+ // Answers carry no cookies and no personal data, so any page may use them.
188
+ export function liveHandler({ rooms = createRooms(), canReset = () => false, info = () => ({}), limit = createLimiter(), inkLimit = createLimiter({ burst: 60, perMs: 3000 }), heartbeatMs = 20000 } = {}) {
189
+ return async (request, response, next = () => reply(response, 404, { error: 'Not found' })) => {
190
+ const { pathname, searchParams } = new URL(request.url, 'http://localhost')
191
+ response.setHeader('Access-Control-Allow-Origin', '*')
192
+ response.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
193
+ response.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
194
+ if (request.method === 'OPTIONS') { response.writeHead(204); response.end(); return }
195
+ try {
196
+ if (pathname === '/info' && request.method === 'GET') return reply(response, 200, { canReset: !!canReset(request, searchParams.get('session')), ...info(request) })
197
+ const page = pathname.slice(1)
198
+ if (request.method === 'GET' && (CODE_RE.test(page) || Object.hasOwn(ANSWER_FILES, page))) {
199
+ const name = CODE_RE.test(page) ? 'answer.html' : page
200
+ response.writeHead(200, { 'Content-Type': ANSWER_TYPES[name.split('.').pop()], 'Cache-Control': 'no-cache', 'X-Content-Type-Options': 'nosniff' })
201
+ return response.end(ANSWER_FILES[name])
202
+ }
203
+ const match = pathname.match(/^\/rooms\/([^/]+)(\/events|\/presence|\/reset|\/state|\/ink)?$/)
204
+ if (!match) return next()
205
+ const id = decodeURIComponent(match[1])
206
+ if (!ROOM_RE.test(id)) return reply(response, 400, { error: 'Room names use letters, digits, dots, hyphens and underscores' })
207
+ if (match[2] === '/events' && request.method === 'GET') {
208
+ response.writeHead(200, { 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-store', 'Connection': 'keep-alive', 'X-Accel-Buffering': 'no' })
209
+ const write = (event, data) => response.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
210
+ const ink = rooms.ink(id)
211
+ const view = searchParams.get('view'), device = searchParams.get('device')
212
+ const present = VIEWS.includes(view) ? { view, device: DEVICES.includes(device) ? device : 'computer' } : null
213
+ const wantsPresence = searchParams.get('presence') === '1'
214
+ write('snapshot', { messages: rooms.snapshot(id), state: rooms.state(id), ...(ink.length ? { ink } : {}), ...(wantsPresence ? { presence: rooms.presence(id) } : {}) })
215
+ const unsubscribe = rooms.subscribe(id, event => {
216
+ if (event.type === 'presence') { if (wantsPresence) write('presence', event.presence); return }
217
+ write(event.type, event.type === 'message' ? event.message : event.type === 'state' ? { state: event.state } : event.type === 'ink' ? { messages: event.messages } : {})
218
+ }, present)
219
+ const beat = setInterval(() => response.write(': ping\n\n'), heartbeatMs)
220
+ request.on('close', () => { clearInterval(beat); unsubscribe() })
221
+ return
222
+ }
223
+ if (match[2] === '/presence' && request.method === 'GET') return reply(response, 200, rooms.presence(id))
224
+ if (request.method !== 'POST') return reply(response, 405, { error: 'Method not allowed' })
225
+ if (match[2] === '/ink') {
226
+ if (!canReset(request, id)) return reply(response, 403, { error: 'Only the presenter can draw' })
227
+ if (!inkLimit(clientAddress(request))) return reply(response, 429, { error: 'Too much ink at once' })
228
+ const { messages } = await readBody(request, MAX_INK)
229
+ if (!Array.isArray(messages) || messages.length > 100) return reply(response, 400, { error: 'Send { messages: [...] }' })
230
+ return reply(response, 200, { ok: true, listeners: rooms.relayInk(id, messages) })
231
+ }
232
+ if (match[2] === '/reset' || match[2] === '/state') {
233
+ if (!canReset(request, id)) return reply(response, 403, { error: 'Only the presenter can change a room' })
234
+ if (match[2] === '/reset') rooms.reset(id)
235
+ else if (!rooms.setState(id, (await readBody(request, MAX_STATE)).state)) return reply(response, 200, { ok: true, ignored: true })
236
+ return reply(response, 200, { ok: true })
237
+ }
238
+ if (!limit(clientAddress(request))) return reply(response, 429, { error: 'Too many answers at once; wait a moment' })
239
+ const body = await readBody(request)
240
+ if (!('data' in body)) return reply(response, 400, { error: 'Send { data }' })
241
+ const from = typeof body.from === 'string' ? body.from.slice(0, 64) : null
242
+ return reply(response, 201, { n: rooms.post(id, { from, data: body.data }).n })
243
+ } catch (error) {
244
+ // An oversized body is not read to the end; answer, then drop the connection.
245
+ if (error.close) { response.setHeader('Connection', 'close'); response.on('finish', () => request.destroy()) }
246
+ if (!response.headersSent) reply(response, error.status ?? 500, { error: error.message })
247
+ }
248
+ }
249
+ }
@@ -0,0 +1,82 @@
1
+ // The server for audience interaction, two ways: inside `mdeck run`
2
+ // at /__mdeck/live, or on its own with `mdeck server` for hosted decks.
3
+ import { createServer } from 'node:http'
4
+ import { liveHandler, createRooms, keyMatches } from './rooms.js'
5
+ import { isAllowedRequest } from '../build/editorPlugin.js'
6
+ import { createTunnels } from './tunnel.js'
7
+ import http from 'node:http'
8
+ import https from 'node:https'
9
+
10
+ // In the dev server only the presenter's own browser, on this computer and on
11
+ // a page this server served, or a paired device (src/build/pairing.js) may
12
+ // reset rooms; phones in the network may answer.
13
+ export function livePlugin({ pairing = null, upstream = null, key = null, session = () => null } = {}) {
14
+ return {
15
+ name: 'vite-plugin-mdeck-live',
16
+ configureServer(server) {
17
+ const handler = liveHandler({
18
+ rooms: createRooms(),
19
+ canReset: (request, room) => isAllowedRequest(request) || (!!pairing?.allows(request) && (room == null || room === session() || room.startsWith(`${session()}.`))),
20
+ info: () => ({ network: server.resolvedUrls?.network?.[0] ?? null }),
21
+ })
22
+ server.middlewares.use('/__mdeck/live', (request, response, next) => {
23
+ const address = typeof upstream === 'function' ? upstream() : upstream
24
+ // The stage room (position and live ink between the presenter's
25
+ // devices and the audience window) stays here, on the local network,
26
+ // even when polls go to a standalone server: the projector follows
27
+ // the iPad without a detour over the internet, or without internet.
28
+ if (address && key && !isStageRequest(request.url, session())) return controlProxy({ upstream: address, key, session, authorize: request => isAllowedRequest(request) || !!pairing?.allows(request) })(request, response)
29
+ return handler(request, response, next)
30
+ })
31
+ },
32
+ }
33
+ }
34
+
35
+ // A request for the deck's stage room: /rooms/<code>.stage[/…], or its
36
+ // /info?stage=1 (src/live/client.js).
37
+ function isStageRequest(url, code) {
38
+ try {
39
+ const { pathname, searchParams } = new URL(url, 'http://localhost')
40
+ return decodeURIComponent(pathname).startsWith(`/rooms/${code}.stage`) || (pathname === '/info' && searchParams.has('stage'))
41
+ } catch { return false }
42
+ }
43
+
44
+ // Standalone: put it behind a web server (proxy / to it) and give the deck
45
+ // `server: https://…`. Resetting a room needs the key. With a key it
46
+ // is also the relay for `mdeck run --server` (src/live/tunnel.js), which needs
47
+ // the web server to pass WebSocket upgrades on.
48
+ export function startLiveServer({ port = 8787, host = '127.0.0.1', key = null } = {}) {
49
+ const tunnels = createTunnels({ key })
50
+ const handler = liveHandler({ rooms: createRooms(), canReset: (request, room) => keyMatches(request, key) || tunnels.allows(request, room) })
51
+ const server = createServer((request, response) => { if (!tunnels.handleRequest(request, response)) handler(request, response) })
52
+ server.on('upgrade', (request, socket, head) => { if (!tunnels.handleUpgrade(request, socket, head)) socket.destroy() })
53
+ return new Promise((done, fail) => {
54
+ server.once('error', fail)
55
+ server.listen(port, host, () => done({ server, tunnels, url: `http://${host.includes(':') ? `[${host}]` : host}:${server.address().port}`, close: () => new Promise(resolve => { tunnels.close(); server.closeAllConnections?.(); server.close(resolve) }) }))
56
+ })
57
+ }
58
+
59
+ // Network pairing with a standalone room server keeps its master key on the
60
+ // presenter's computer. Devices send their revocable token to this proxy.
61
+ export function controlProxy({ upstream, key, session, authorize }) {
62
+ return (request, response) => {
63
+ const url = new URL(request.url, 'http://localhost')
64
+ let room
65
+ try { room = decodeURIComponent(url.pathname.match(/^\/rooms\/([^/]+)(?:\/(?:events|presence|reset|state|ink))?$/)?.[1] ?? '') } catch {}
66
+ const code = session()
67
+ const scoped = room === code || room?.startsWith(`${code}.`)
68
+ const allowed = authorize(request)
69
+ if ((url.pathname !== '/info' && !scoped) || (url.pathname === '/info' && url.searchParams.has('session') && url.searchParams.get('session') !== code) || (request.method === 'POST' && !allowed) || !['GET', 'POST', 'OPTIONS'].includes(request.method)) {
70
+ response.writeHead(403, { 'Content-Type': 'application/json' }); response.end(JSON.stringify({ error: 'This device cannot control that room' })); return
71
+ }
72
+ const target = new URL(`${upstream.replace(/\/+$/, '')}${url.pathname}${url.search}`)
73
+ const headers = { 'Content-Type': request.headers['content-type'] ?? 'application/json', ...(allowed ? { Authorization: `Bearer ${key}` } : {}) }
74
+ const forward = (target.protocol === 'https:' ? https : http).request(target, { method: request.method, headers }, result => {
75
+ response.writeHead(result.statusCode, { ...result.headers, 'referrer-policy': 'no-referrer' })
76
+ result.pipe(response)
77
+ })
78
+ forward.on('error', () => { if (!response.headersSent) response.writeHead(502); response.end() })
79
+ response.on('close', () => forward.destroy())
80
+ request.pipe(forward)
81
+ }
82
+ }
@@ -0,0 +1,180 @@
1
+ // The server as a relay for `mdeck run --server`. The presenter's computer
2
+ // opens one outbound WebSocket to /tunnel/<id>, authenticated with the room
3
+ // server's key. A browser, such as an iPad, then opens /t/<id>/… on this
4
+ // server; each request is passed down that connection to the computer, which
5
+ // answers it from its own dev server, and the answer is passed back. The
6
+ // slides are never stored here, and the computer needs no open port.
7
+ //
8
+ // Messages are JSON text frames, bodies are base64 in chunks:
9
+ // server → computer req {id, method, path, headers, body?} abort {id}
10
+ // wsopen {id, path, protocols} wsmsg {id, b, bin} wsclose {id, code}
11
+ // computer → server head {id, status, headers} data {id, b} end {id} err {id, status, message}
12
+ // tokens {tokens} wsmsg {id, b, bin} wsclose {id, code}
13
+ // `tokens` are the computer's paired devices: while the tunnel is open they
14
+ // may steer rooms here like the key does.
15
+ import { timingSafeEqual } from 'node:crypto'
16
+ import { WebSocketServer } from 'ws'
17
+ import { keyMatches } from './rooms.js'
18
+
19
+ export const TUNNEL_ID_RE = /^[A-Za-z0-9_-]{16,64}$/
20
+ const ROUTE_RE = /^\/t\/([A-Za-z0-9_-]{16,64})(\/.*)?$/
21
+ const MB = 1024 * 1024
22
+ // Hop-by-hop headers are not passed on, and neither are the ones the two ends
23
+ // decide for themselves.
24
+ const HOP = new Set(['connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'proxy-connection', 'te', 'trailer', 'transfer-encoding', 'upgrade'])
25
+ const NOT_REQUEST = new Set([...HOP, 'host', 'accept-encoding', 'content-length', 'x-forwarded-for', 'x-forwarded-host', 'x-forwarded-proto', 'x-real-ip', 'x-mdeck-tunnel', 'cookie'])
26
+ const NOT_RESPONSE = new Set([...HOP, 'content-length', 'content-encoding'])
27
+ const same = (a, b) => { const x = Buffer.from(a), y = Buffer.from(b); return x.length === y.length && timingSafeEqual(x, y) }
28
+
29
+ function forward(headers, skip) {
30
+ const out = {}
31
+ for (const [name, value] of Object.entries(headers)) if (!skip.has(name.toLowerCase())) out[name.toLowerCase()] = value
32
+ return out
33
+ }
34
+
35
+ function text(response, status, message) {
36
+ if (response.headersSent) return response.end()
37
+ response.writeHead(status, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer' })
38
+ response.end(message)
39
+ }
40
+
41
+ export function createTunnels({ key = null, bodyLimit = 2 * MB, maxTunnels = 50, maxPending = 128, requestTimeoutMs = 60000, pingMs = 25000 } = {}) {
42
+ const tunnels = new Map()
43
+ const sockets = new WebSocketServer({ noServer: true, maxPayload: 16 * MB, handleProtocols: protocols => [...protocols][0] ?? false })
44
+
45
+ function open(id, socket) {
46
+ tunnels.get(id)?.socket.close(4000, 'Replaced by a newer connection')
47
+ const tunnel = { socket, pending: new Map(), peers: new Map(), tokens: new Set(), session: null, next: 1, alive: true }
48
+ tunnels.set(id, tunnel)
49
+ const send = message => { if (socket.readyState === socket.OPEN) socket.send(JSON.stringify(message)) }
50
+ tunnel.send = send
51
+ socket.on('pong', () => { tunnel.alive = true })
52
+ const beat = setInterval(() => {
53
+ if (!tunnel.alive) return socket.terminate()
54
+ tunnel.alive = false
55
+ socket.ping()
56
+ }, pingMs)
57
+ socket.on('message', raw => {
58
+ let message
59
+ try { message = JSON.parse(raw.toString()) } catch { return }
60
+ if (!message || typeof message !== 'object') return
61
+ const pending = tunnel.pending.get(message.id)
62
+ switch (message.t) {
63
+ case 'head':
64
+ if (pending && !pending.headersSent) pending.writeHead(Number(message.status) || 502, { ...forward(message.headers ?? {}, NOT_RESPONSE), 'referrer-policy': 'no-referrer' })
65
+ break
66
+ case 'data':
67
+ if (pending && typeof message.b === 'string') pending.write(Buffer.from(message.b, 'base64'))
68
+ break
69
+ case 'end':
70
+ if (pending) { pending.end(); tunnel.pending.delete(message.id) }
71
+ break
72
+ case 'err':
73
+ if (pending) { text(pending, Number(message.status) || 502, String(message.message ?? 'The presenter\'s computer could not answer')); tunnel.pending.delete(message.id) }
74
+ break
75
+ case 'tokens':
76
+ tunnel.session = /^\d{4,8}$/.test(message.session ?? '') ? String(message.session) : null
77
+ tunnel.tokens = new Set((Array.isArray(message.tokens) ? message.tokens : []).filter(token => typeof token === 'string' && token.length >= 16 && token.length <= 200).slice(0, 50))
78
+ break
79
+ case 'wsmsg': {
80
+ const peer = tunnel.peers.get(message.id)
81
+ if (peer && typeof message.b === 'string') peer.send(message.bin ? Buffer.from(message.b, 'base64') : Buffer.from(message.b, 'base64').toString('utf8'))
82
+ break
83
+ }
84
+ case 'wsclose': {
85
+ const peer = tunnel.peers.get(message.id)
86
+ if (peer) { tunnel.peers.delete(message.id); peer.close(Number.isInteger(message.code) && message.code >= 1000 && message.code < 5000 && ![1004, 1005, 1006, 1015].includes(message.code) ? message.code : 1011) }
87
+ break
88
+ }
89
+ }
90
+ })
91
+ socket.on('close', () => {
92
+ clearInterval(beat)
93
+ for (const response of tunnel.pending.values()) text(response, 502, 'The presenter\'s computer went away')
94
+ for (const peer of tunnel.peers.values()) peer.close(1001)
95
+ if (tunnels.get(id) === tunnel) tunnels.delete(id)
96
+ })
97
+ }
98
+
99
+ return {
100
+ /** Whether the request carries the token of a device paired through an open tunnel. */
101
+ allows(request, room = null) {
102
+ const given = (request.headers.authorization ?? '').replace(/^Bearer\s+/i, '')
103
+ if (!given) return false
104
+ for (const tunnel of tunnels.values()) {
105
+ if (!tunnel.session || (room !== null && room !== tunnel.session && !room.startsWith(`${tunnel.session}.`))) continue
106
+ for (const token of tunnel.tokens) if (same(token, given)) return true
107
+ }
108
+ return false
109
+ },
110
+
111
+ get size() { return tunnels.size },
112
+
113
+ /** A browser's request for /t/<id>/…; false when the path is not a tunnel's. */
114
+ handleRequest(request, response) {
115
+ const match = new URL(request.url, 'http://localhost').pathname.match(ROUTE_RE)
116
+ if (!match) return false
117
+ if (!key) { text(response, 404, 'Not found'); return true }
118
+ const tunnel = tunnels.get(match[1])
119
+ if (!tunnel) { text(response, 404, 'This presentation is not shared right now. Start it again with mdeck run --server.'); return true }
120
+ if (tunnel.pending.size >= maxPending) { text(response, 503, 'Too many requests at once'); return true }
121
+ const id = tunnel.next++
122
+ const chunks = []
123
+ let size = 0
124
+ let over = false
125
+ request.on('data', chunk => {
126
+ size += chunk.length
127
+ if (size > bodyLimit) { over = true; return }
128
+ chunks.push(chunk)
129
+ })
130
+ request.on('end', () => {
131
+ if (over) return text(response, 413, 'Request body too large')
132
+ if (tunnel.socket.readyState !== tunnel.socket.OPEN) return text(response, 502, 'The presenter\'s computer went away')
133
+ tunnel.pending.set(id, response)
134
+ const timer = setTimeout(() => { if (!response.headersSent) { text(response, 504, 'The presenter\'s computer did not answer in time'); tunnel.pending.delete(id); tunnel.send({ t: 'abort', id }) } }, requestTimeoutMs)
135
+ response.on('close', () => {
136
+ clearTimeout(timer)
137
+ if (tunnel.pending.delete(id)) tunnel.send({ t: 'abort', id })
138
+ })
139
+ response.on('finish', () => clearTimeout(timer))
140
+ tunnel.send({ t: 'req', id, method: request.method, path: request.url, headers: forward(request.headers, NOT_REQUEST), ...(chunks.length ? { body: Buffer.concat(chunks).toString('base64') } : {}) })
141
+ })
142
+ request.on('error', () => {})
143
+ return true
144
+ },
145
+
146
+ /** An upgrade: the computer's /tunnel/<id>, or a browser's WebSocket to /t/<id>/…. False when not ours. */
147
+ handleUpgrade(request, socket, head) {
148
+ const { pathname } = new URL(request.url, 'http://localhost')
149
+ const refuse = (status, reason) => { socket.write(`HTTP/1.1 ${status} ${reason}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`); socket.destroy() }
150
+ const own = pathname.match(/^\/tunnel\/([A-Za-z0-9_-]{16,64})$/)
151
+ if (own) {
152
+ if (!key) { refuse(404, 'Not Found'); return true }
153
+ if (!keyMatches(request, key)) { refuse(401, 'Unauthorized'); return true }
154
+ if (!tunnels.has(own[1]) && tunnels.size >= maxTunnels) { refuse(503, 'Service Unavailable'); return true }
155
+ sockets.handleUpgrade(request, socket, head, ws => open(own[1], ws))
156
+ return true
157
+ }
158
+ const match = pathname.match(ROUTE_RE)
159
+ if (!match) return false
160
+ const tunnel = key ? tunnels.get(match[1]) : null
161
+ if (!tunnel) { refuse(404, 'Not Found'); return true }
162
+ const protocols = String(request.headers['sec-websocket-protocol'] ?? '').split(',').map(item => item.trim()).filter(Boolean)
163
+ sockets.handleUpgrade(request, socket, head, ws => {
164
+ const id = tunnel.next++
165
+ tunnel.peers.set(id, ws)
166
+ tunnel.send({ t: 'wsopen', id, path: request.url, protocols })
167
+ ws.on('message', (data, bin) => tunnel.send({ t: 'wsmsg', id, b: Buffer.from(data).toString('base64'), bin }))
168
+ ws.on('close', code => { if (tunnel.peers.delete(id)) tunnel.send({ t: 'wsclose', id, code }) })
169
+ ws.on('error', () => {})
170
+ })
171
+ return true
172
+ },
173
+
174
+ close() {
175
+ for (const tunnel of tunnels.values()) tunnel.socket.close(1001)
176
+ for (const client of sockets.clients) client.terminate()
177
+ sockets.close()
178
+ },
179
+ }
180
+ }
package/src/paths.js ADDED
@@ -0,0 +1,9 @@
1
+ import { fileURLToPath } from 'node:url'
2
+ import { resolve } from 'node:path'
3
+
4
+ // Files installed with mdeck are located relative to this module, never cwd.
5
+ export const frameworkRoot = fileURLToPath(new URL('../', import.meta.url))
6
+ export const runtimeRoot = resolve(frameworkRoot, 'src/runtime')
7
+ export const assetsRoot = resolve(frameworkRoot, 'assets')
8
+ export const docsRoot = resolve(frameworkRoot, 'docs/site')
9
+ export const builtinExtensionsRoot = resolve(assetsRoot, 'extensions')