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.
- package/CHANGELOG.md +259 -0
- package/LICENSE +21 -0
- package/README.md +156 -0
- package/assets/base.css +196 -0
- package/assets/extensions/layouts/chapter/extension.toml +26 -0
- package/assets/extensions/layouts/chapter/layout.jsx +21 -0
- package/assets/extensions/layouts/chapter/starter.md +8 -0
- package/assets/extensions/layouts/focus/extension.toml +8 -0
- package/assets/extensions/layouts/focus/layout.jsx +10 -0
- package/assets/extensions/layouts/focus/starter.md +4 -0
- package/assets/extensions/layouts/full-bleed-image/extension.toml +31 -0
- package/assets/extensions/layouts/full-bleed-image/layout.jsx +19 -0
- package/assets/extensions/layouts/full-bleed-image/starter.md +6 -0
- package/assets/extensions/layouts/generic/extension.toml +8 -0
- package/assets/extensions/layouts/generic/layout.jsx +8 -0
- package/assets/extensions/layouts/generic/starter.md +4 -0
- package/assets/extensions/layouts/image-text/extension.toml +25 -0
- package/assets/extensions/layouts/image-text/layout.jsx +17 -0
- package/assets/extensions/layouts/image-text/starter.md +6 -0
- package/assets/extensions/layouts/split/extension.toml +25 -0
- package/assets/extensions/layouts/split/layout.jsx +19 -0
- package/assets/extensions/layouts/split/starter.md +14 -0
- package/assets/extensions/layouts/title/extension.toml +26 -0
- package/assets/extensions/layouts/title/layout.jsx +14 -0
- package/assets/extensions/layouts/title/starter.md +5 -0
- package/assets/extensions/palettes/brand/extension.toml +29 -0
- package/assets/extensions/palettes/cobalt/extension.toml +27 -0
- package/assets/extensions/palettes/ember/extension.toml +27 -0
- package/assets/extensions/palettes/forest/extension.toml +27 -0
- package/assets/extensions/palettes/graphite/extension.toml +27 -0
- package/assets/extensions/palettes/lagoon/extension.toml +27 -0
- package/assets/extensions/palettes/neon/extension.toml +27 -0
- package/assets/extensions/palettes/nordic/extension.toml +27 -0
- package/assets/extensions/palettes/pastel/extension.toml +30 -0
- package/assets/extensions/palettes/phosphor/extension.toml +27 -0
- package/assets/extensions/palettes/swiss/extension.toml +27 -0
- package/assets/extensions/palettes/terra/extension.toml +27 -0
- package/assets/extensions/themes/academic/extension.toml +53 -0
- package/assets/extensions/themes/academic/styles.css +631 -0
- package/assets/extensions/themes/aurora/extension.toml +37 -0
- package/assets/extensions/themes/aurora/styles.css +644 -0
- package/assets/extensions/themes/duet/extension.toml +46 -0
- package/assets/extensions/themes/duet/styles.css +573 -0
- package/assets/extensions/themes/editorial/extension.toml +46 -0
- package/assets/extensions/themes/editorial/styles.css +574 -0
- package/assets/extensions/themes/fhnw/extension.toml +61 -0
- package/assets/extensions/themes/fhnw/styles.css +604 -0
- package/assets/extensions/themes/neue/extension.toml +46 -0
- package/assets/extensions/themes/neue/styles.css +489 -0
- package/assets/extensions/themes/sketch/extension.toml +48 -0
- package/assets/extensions/themes/sketch/styles.css +633 -0
- package/assets/extensions/themes/terminal/extension.toml +38 -0
- package/assets/extensions/themes/terminal/styles.css +550 -0
- package/assets/logo/avatar.png +0 -0
- package/assets/logo/logo-dark.svg +10 -0
- package/assets/logo/logo-light.svg +10 -0
- package/assets/logo/mark.svg +15 -0
- package/bin/mdeck.js +2 -0
- package/docs/README.md +8 -0
- package/docs/reference/authoring.md +795 -0
- package/docs/reference/claude-skill.md +96 -0
- package/docs/reference/extensions.md +250 -0
- package/docs/reference/layouts.md +153 -0
- package/docs/reference/palettes.md +106 -0
- package/docs/reference/structured-slides.md +120 -0
- package/docs/reference/themes.md +133 -0
- package/docs/site/assets/favicon.svg +15 -0
- package/docs/site/assets/github-logo.svg +1 -0
- package/docs/site/assets/site.css +162 -0
- package/docs/site/assets/site.js +123 -0
- package/docs/site/build.js +148 -0
- package/docs/site/content/appearance.md +203 -0
- package/docs/site/content/audience.md +178 -0
- package/docs/site/content/commands.md +153 -0
- package/docs/site/content/components.md +134 -0
- package/docs/site/content/drawing.md +72 -0
- package/docs/site/content/editing.md +59 -0
- package/docs/site/content/getting-started.md +103 -0
- package/docs/site/content/layouts.md +126 -0
- package/docs/site/content/media.md +107 -0
- package/docs/site/content/more-content.md +130 -0
- package/docs/site/content/presenting.md +93 -0
- package/docs/site/content/reusable-layouts.md +74 -0
- package/docs/site/content/sharing.md +88 -0
- package/docs/site/content/theme-authoring.md +144 -0
- package/docs/site/content/theme-repository.md +76 -0
- package/docs/site/content/troubleshooting.md +92 -0
- package/docs/site/content/views.md +56 -0
- package/docs/site/content/welcome.md +71 -0
- package/docs/site/content/writing.md +121 -0
- package/docs/site/examples/first-talk.md +88 -0
- package/docs/site/examples/notebook.svg +58 -0
- package/docs/site/images/themes/academic-dark.webp +0 -0
- package/docs/site/images/themes/academic-light.webp +0 -0
- package/docs/site/images/themes/academic.webp +0 -0
- package/docs/site/images/themes/aurora-dark.webp +0 -0
- package/docs/site/images/themes/aurora-light.webp +0 -0
- package/docs/site/images/themes/aurora.webp +0 -0
- package/docs/site/images/themes/duet-dark.webp +0 -0
- package/docs/site/images/themes/duet-light.webp +0 -0
- package/docs/site/images/themes/duet.webp +0 -0
- package/docs/site/images/themes/editorial-dark.webp +0 -0
- package/docs/site/images/themes/editorial-light.webp +0 -0
- package/docs/site/images/themes/editorial.webp +0 -0
- package/docs/site/images/themes/fhnw.webp +0 -0
- package/docs/site/images/themes/neue-dark.webp +0 -0
- package/docs/site/images/themes/neue-light.webp +0 -0
- package/docs/site/images/themes/neue.webp +0 -0
- package/docs/site/images/themes/sketch-dark.webp +0 -0
- package/docs/site/images/themes/sketch-light.webp +0 -0
- package/docs/site/images/themes/sketch.webp +0 -0
- package/docs/site/images/themes/terminal-dark.webp +0 -0
- package/docs/site/images/themes/terminal-light.webp +0 -0
- package/docs/site/images/themes/terminal.webp +0 -0
- package/docs/site/package.json +9 -0
- package/docs/site/pages.js +27 -0
- package/docs/site/server.js +58 -0
- package/examples/custom-layouts/extensions/comparison/extension.toml +35 -0
- package/examples/custom-layouts/extensions/comparison/layout.jsx +15 -0
- package/examples/custom-layouts/extensions/comparison/starter.md +17 -0
- package/examples/custom-layouts/extensions/comparison/styles.css +4 -0
- package/examples/custom-layouts/slides.md +60 -0
- package/examples/poll/slides.md +127 -0
- package/examples/python/.mdeck-backups/slides.drawings-2026-10-06T08-36-47Z.json +19 -0
- package/examples/python/.mdeck-backups/slides.drawings-2026-10-06T11-27-30Z.json +6 -0
- package/examples/python/slides.drawings.json +6 -0
- package/examples/python/slides.md +493 -0
- package/examples/showcase/.mdeck-backups/slides.drawings-2026-10-06T08-08-30Z.json +10 -0
- package/examples/showcase/.mdeck-backups/slides.drawings-2026-10-06T11-41-35Z.json +13 -0
- package/examples/showcase/img/image.jpg +0 -0
- package/examples/showcase/img/mdeck.svg +66 -0
- package/examples/showcase/slides.drawings.json +11 -0
- package/examples/showcase/slides.md +427 -0
- package/package.json +75 -0
- package/skills/write-slides/SKILL.md +144 -0
- package/src/build/check.js +49 -0
- package/src/build/chrome.js +130 -0
- package/src/build/components.js +44 -0
- package/src/build/config.js +61 -0
- package/src/build/drawings.js +14 -0
- package/src/build/editorPlugin.js +272 -0
- package/src/build/editorServer.js +36 -0
- package/src/build/fonts.js +84 -0
- package/src/build/homePlugin.js +208 -0
- package/src/build/inkPlugin.js +192 -0
- package/src/build/ownWrites.js +18 -0
- package/src/build/pairing.js +81 -0
- package/src/build/pdf.js +34 -0
- package/src/build/relayAccess.js +85 -0
- package/src/build/slidesPlugin.js +371 -0
- package/src/build/themeCheck.js +151 -0
- package/src/build/tunnelClient.js +184 -0
- package/src/cli/args.js +76 -0
- package/src/cli/index.js +758 -0
- package/src/cli/migrate.js +123 -0
- package/src/cli/skill.js +40 -0
- package/src/cli/themes.js +140 -0
- package/src/components/CodeBlock.jsx +168 -0
- package/src/components/ConnectedViews.jsx +25 -0
- package/src/components/Icon.jsx +8 -0
- package/src/components/Poll.jsx +67 -0
- package/src/components/QrCode.jsx +29 -0
- package/src/components/QrCodeTag.jsx +27 -0
- package/src/components/Question.jsx +28 -0
- package/src/components/Scale.jsx +47 -0
- package/src/components/VideoPlayer.jsx +108 -0
- package/src/components/WordCloud.jsx +106 -0
- package/src/components/activity.jsx +35 -0
- package/src/components/callout.css +78 -0
- package/src/components/code-block.css +123 -0
- package/src/components/columns.css +11 -0
- package/src/components/poll.css +81 -0
- package/src/components/video-player.css +14 -0
- package/src/core/devPath.js +4 -0
- package/src/core/editDeck.js +230 -0
- package/src/core/icons.js +69 -0
- package/src/core/ink.js +221 -0
- package/src/core/labels.js +144 -0
- package/src/core/outline.js +33 -0
- package/src/core/parseSlides.js +152 -0
- package/src/core/source.js +137 -0
- package/src/core/urls.js +34 -0
- package/src/core/validateDeck.js +139 -0
- package/src/editor/api.js +67 -0
- package/src/editor/components/App.jsx +75 -0
- package/src/editor/components/ConflictBanner.jsx +10 -0
- package/src/editor/components/DeckSettings.jsx +108 -0
- package/src/editor/components/DesignApp.jsx +252 -0
- package/src/editor/components/Diagnostics.jsx +10 -0
- package/src/editor/components/ExtensionEditor.jsx +66 -0
- package/src/editor/components/ExtensionForms.jsx +134 -0
- package/src/editor/components/ExtensionsPanel.jsx +114 -0
- package/src/editor/components/Field.jsx +24 -0
- package/src/editor/components/Inspector.jsx +36 -0
- package/src/editor/components/LayoutPicker.jsx +23 -0
- package/src/editor/components/LayoutSelect.jsx +25 -0
- package/src/editor/components/LookThumbnail.jsx +8 -0
- package/src/editor/components/MetaFields.jsx +24 -0
- package/src/editor/components/Outline.jsx +28 -0
- package/src/editor/components/PalettePicker.jsx +32 -0
- package/src/editor/components/Preview.jsx +63 -0
- package/src/editor/components/PropsForm.jsx +46 -0
- package/src/editor/components/RawSlideEditor.jsx +9 -0
- package/src/editor/components/RegionEditor.jsx +15 -0
- package/src/editor/components/Resizer.jsx +18 -0
- package/src/editor/designLink.js +19 -0
- package/src/editor/editor.css +139 -0
- package/src/editor/extensions.js +86 -0
- package/src/editor/keyboard.js +21 -0
- package/src/editor/model.js +38 -0
- package/src/editor/sampleDeck.js +165 -0
- package/src/editor/saveQueue.js +47 -0
- package/src/editor/schema.js +36 -0
- package/src/editor/state.js +92 -0
- package/src/editor/useDeckDocument.js +67 -0
- package/src/extensions/appearance.js +79 -0
- package/src/extensions/contrast.js +57 -0
- package/src/extensions/discover.js +126 -0
- package/src/extensions/manifest.js +209 -0
- package/src/extensions/packs.js +268 -0
- package/src/extensions/tokens.js +25 -0
- package/src/home/Home.jsx +362 -0
- package/src/home/home.css +148 -0
- package/src/layouts/layoutApi.jsx +205 -0
- package/src/layouts/layoutProps.js +30 -0
- package/src/layouts/renderSlide.jsx +17 -0
- package/src/live/answer/answer.css +34 -0
- package/src/live/answer/answer.html +14 -0
- package/src/live/answer/answer.js +130 -0
- package/src/live/client.js +290 -0
- package/src/live/code.js +19 -0
- package/src/live/follow.js +17 -0
- package/src/live/pairing.js +34 -0
- package/src/live/presence.js +33 -0
- package/src/live/roomTag.js +38 -0
- package/src/live/rooms.js +249 -0
- package/src/live/server.js +82 -0
- package/src/live/tunnel.js +180 -0
- package/src/paths.js +9 -0
- package/src/runtime/ReaderView.jsx +193 -0
- package/src/runtime/SlideErrorBoundary.jsx +28 -0
- package/src/runtime/chrome.jsx +55 -0
- package/src/runtime/deck-stage.js +1533 -0
- package/src/runtime/design.html +12 -0
- package/src/runtime/design.jsx +5 -0
- package/src/runtime/editor.html +12 -0
- package/src/runtime/editor.jsx +5 -0
- package/src/runtime/editorBridge.js +58 -0
- package/src/runtime/fit.js +149 -0
- package/src/runtime/home.html +13 -0
- package/src/runtime/home.jsx +5 -0
- package/src/runtime/index.html +15 -0
- package/src/runtime/ink/InkLayer.jsx +17 -0
- package/src/runtime/ink/Toolbar.jsx +145 -0
- package/src/runtime/ink/attach.js +69 -0
- package/src/runtime/ink/bus.js +126 -0
- package/src/runtime/ink/controller.js +132 -0
- package/src/runtime/ink/room.js +101 -0
- package/src/runtime/ink/select.js +124 -0
- package/src/runtime/ink/server.js +75 -0
- package/src/runtime/ink/store.js +55 -0
- package/src/runtime/ink/toolbar.css +79 -0
- package/src/runtime/main.jsx +863 -0
- package/src/runtime/markedSetup.js +147 -0
- package/src/runtime/reader.css +59 -0
- package/src/runtime/registry.jsx +26 -0
- package/src/runtime/syncOrder.js +32 -0
- 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')
|