@edryslabs/edrys-dev 0.0.0-stage → 0.1.1

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.
@@ -0,0 +1,130 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import express from 'express';
4
+ /**
5
+ * Reports a module's uncaught errors, rejected promises and console.error to
6
+ * edrys-dev. Plain text without reading the answer, so the browser sends it to
7
+ * another origin without asking first; `name` comes from the server, since a
8
+ * module that edrys-Lite inlines runs on a data: URL.
9
+ */
10
+ export function errorReporter(name, reportUrl) {
11
+ // a string literal that cannot end the script tag
12
+ const literal = (value) => JSON.stringify(value).replace(/</g, '\\u003c');
13
+ return `<script>(function () {
14
+ var send = function (kind, message) {
15
+ try {
16
+ // no-cors: the answer is of no use, and reading it would log a CORS error
17
+ fetch(${literal(reportUrl)}, {
18
+ method: 'POST',
19
+ mode: 'no-cors',
20
+ body: JSON.stringify({ module: ${literal(name)}, kind: kind, message: String(message).slice(0, 2000) }),
21
+ keepalive: true
22
+ }).catch(function () {})
23
+ } catch (e) {}
24
+ }
25
+ addEventListener('error', function (e) {
26
+ // a failed <img> or <script> load fires this too, without a message
27
+ if (e.error || e.message) send('error', (e.error && e.error.stack) || e.message)
28
+ })
29
+ addEventListener('unhandledrejection', function (e) {
30
+ send('error', (e.reason && e.reason.stack) || e.reason)
31
+ })
32
+ var original = console.error
33
+ console.error = function () {
34
+ send('console', [].map.call(arguments, function (a) {
35
+ try { return typeof a === 'string' ? a : (a && a.stack) || JSON.stringify(a) } catch (_) { return String(a) }
36
+ }).join(' '))
37
+ return original.apply(console, arguments)
38
+ }
39
+ })()</script>`;
40
+ }
41
+ /**
42
+ * Runs code the class tab sends (run_in_module): only from the parent frame,
43
+ * and only when that is edrys-Lite served by edrys-dev (`pageOrigins`). The
44
+ * code is an expression, or statements whose last value is the result; with
45
+ * await, statements need return.
46
+ */
47
+ export function runner(pageOrigins) {
48
+ const literal = JSON.stringify(pageOrigins).replace(/</g, '\\u003c');
49
+ return `<script>(function () {
50
+ var origins = ${literal}
51
+ var AsyncFunction = (async function () {}).constructor
52
+ var show = function (value) {
53
+ if (value === undefined) return 'undefined'
54
+ try {
55
+ var text = JSON.stringify(value)
56
+ if (text !== undefined) return text.length > 20000 ? text.slice(0, 20000) + '…' : text
57
+ } catch (e) {}
58
+ return String(value)
59
+ }
60
+ addEventListener('message', function (e) {
61
+ var data = e.data
62
+ if (e.source !== parent || origins.indexOf(e.origin) < 0 || !data || data.event !== 'dev-run') return
63
+ var answer = function (result) {
64
+ result.event = 'dev-ran'
65
+ result.id = data.id
66
+ parent.postMessage(result, e.origin)
67
+ }
68
+ var code = String(data.code).replace(/[\\s;]+$/, '')
69
+ var run
70
+ try {
71
+ run = new AsyncFunction('return (' + code + '\\n)')
72
+ } catch (e) {
73
+ try {
74
+ // statements: their last value, as in the console; with await they need return
75
+ run = /\\b(await|return)\\b/.test(code)
76
+ ? new AsyncFunction(code)
77
+ : function () { return Promise.resolve((0, eval)(code)) }
78
+ } catch (error) {
79
+ return answer({ error: String(error) })
80
+ }
81
+ }
82
+ // without the stack: for an inlined module it holds its whole data: URL
83
+ Promise.resolve().then(run).then(
84
+ function (value) { answer({ value: show(value) }) },
85
+ function (error) { answer({ error: String(error) }) }
86
+ )
87
+ })
88
+ })()</script>`;
89
+ }
90
+ /** `script` as early in `html` as possible, before the module's own scripts. */
91
+ export function inject(html, script) {
92
+ // a function, so a $ in the script is no replacement pattern
93
+ for (const tag of [/<head(\s[^>]*)?>/i, /<html(\s[^>]*)?>/i]) {
94
+ if (tag.test(html))
95
+ return html.replace(tag, (match) => match + script);
96
+ }
97
+ return script + html;
98
+ }
99
+ /**
100
+ * Serves local modules (`dir/<name>/...`), on a port of their own: a module on
101
+ * edrys-Lite's origin could read its storage, the owner's key included. Real
102
+ * modules always come from another origin, so these do too. HTML pages get
103
+ * the error reporter, which posts to `reportUrl`, and the runner, which takes
104
+ * code from edrys-Lite at `pageOrigins`.
105
+ */
106
+ export function moduleApp(dir, reportUrl, pageOrigins) {
107
+ const app = express();
108
+ app.use((req, res, next) => {
109
+ // edrys-Lite fetches a module from its own origin to read its meta tags
110
+ res.setHeader('Access-Control-Allow-Origin', '*');
111
+ // a reload must get the files as they are now
112
+ res.setHeader('Cache-Control', 'no-store');
113
+ next();
114
+ });
115
+ app.get(/(\/|\.html?)$/i, async (req, res, next) => {
116
+ const file = path.resolve(dir, '.' + decodeURIComponent(req.path) + (req.path.endsWith('/') ? 'index.html' : ''));
117
+ const name = path.relative(dir, file).split(path.sep)[0];
118
+ if (!file.startsWith(dir + path.sep) || !name)
119
+ return next();
120
+ try {
121
+ const html = await fs.readFile(file, 'utf8');
122
+ res.type('html').send(inject(html, errorReporter(name, reportUrl) + runner(pageOrigins)));
123
+ }
124
+ catch {
125
+ next();
126
+ }
127
+ });
128
+ app.use(express.static(dir, { etag: false, lastModified: false }));
129
+ return app;
130
+ }
package/dist/origin.js ADDED
@@ -0,0 +1,5 @@
1
+ /** Requests from a page of another origin, e.g. a module's own site, are refused. */
2
+ export function sameOrigin(req) {
3
+ const origin = req.headers.origin;
4
+ return origin === undefined || origin === `http://${req.headers.host}`;
5
+ }
package/dist/server.js ADDED
@@ -0,0 +1,260 @@
1
+ import crypto from 'node:crypto';
2
+ import http from 'node:http';
3
+ import fs from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { EventEmitter } from 'node:events';
6
+ import { createRequire } from 'node:module';
7
+ import express from 'express';
8
+ import { unpack } from 'msgpackr';
9
+ import { watchFile, watchModules } from './watch.js';
10
+ import { moduleApp } from './modules.js';
11
+ import { sameOrigin } from './origin.js';
12
+ import { Chat, PANEL_SCRIPT } from './chat.js';
13
+ // entries the log keeps, oldest first out
14
+ const LOG_MAX = 300;
15
+ // a body in the log is cut to this many characters of JSON
16
+ const BODY_MAX = 1000;
17
+ /**
18
+ * A module message body as the agent can read it: edrys.js sends bodies as
19
+ * base64 MessagePack, the class tab passes them on as they are.
20
+ */
21
+ export function readableBody(body) {
22
+ let value = body;
23
+ if (typeof body === 'string' && body.length % 4 === 0 && /^[A-Za-z0-9+/]+={0,2}$/.test(body)) {
24
+ try {
25
+ value = unpack(Buffer.from(body, 'base64'));
26
+ }
27
+ catch { }
28
+ }
29
+ let text;
30
+ try {
31
+ text = JSON.stringify(value) ?? String(value);
32
+ }
33
+ catch {
34
+ text = String(value);
35
+ }
36
+ return text.length > BODY_MAX ? text.slice(0, BODY_MAX) + '…' : text;
37
+ }
38
+ // A reload closes the owner tab's stream and opens a new one within a moment;
39
+ // only a change that lasts this long counts.
40
+ const SETTLE_MS = 2000;
41
+ /** The built edrys-Lite this package serves. */
42
+ export function edrysDist() {
43
+ const require = createRequire(import.meta.url);
44
+ return path.join(path.dirname(require.resolve('@edryslabs/edrys-lite/package.json')), 'dist');
45
+ }
46
+ /**
47
+ * Serves edrys-Lite with the endpoints its DevBridge talks to (see
48
+ * edrys-Lite src/ts/DevBridge.ts), plus a sync server with `local`, all on
49
+ * one port. When the port is taken, the next free one is used.
50
+ */
51
+ export async function startDevServer(options) {
52
+ const app = express();
53
+ const bus = new EventEmitter();
54
+ const clients = new Set();
55
+ let share = null;
56
+ let classState = null;
57
+ const log = [];
58
+ let dropped = 0;
59
+ const addLog = (entry) => {
60
+ log.push(entry);
61
+ if (log.length > LOG_MAX)
62
+ log.splice(0, log.length - LOG_MAX);
63
+ };
64
+ let reported = false;
65
+ let settling;
66
+ const settle = () => {
67
+ clearTimeout(settling);
68
+ settling = setTimeout(() => {
69
+ const connected = clients.size > 0;
70
+ if (connected !== reported)
71
+ bus.emit('connection', (reported = connected));
72
+ }, SETTLE_MS);
73
+ };
74
+ app.get('/edrys-dev/lab', async (req, res) => {
75
+ res.json({
76
+ path: options.lab,
77
+ text: await fs.readFile(options.lab, 'utf8'),
78
+ // Without it the class keeps its own (or edrys-Lite's default) transport.
79
+ communication: options.local
80
+ ? { communicationMethod: 'Websocket', websocketUrl: `ws://${req.headers.host}` }
81
+ : undefined,
82
+ });
83
+ });
84
+ app.get('/edrys-dev/events', (req, res) => {
85
+ res.writeHead(200, {
86
+ 'Content-Type': 'text/event-stream',
87
+ 'Cache-Control': 'no-cache',
88
+ Connection: 'keep-alive',
89
+ });
90
+ res.write(': edrys-dev\n\n');
91
+ clients.add(res);
92
+ settle();
93
+ req.on('close', () => {
94
+ clients.delete(res);
95
+ settle();
96
+ });
97
+ });
98
+ // Student and station tabs have no DevBridge: they only learn that a local
99
+ // module changed, and load the page again. Not counted as the owner tab.
100
+ const others = new Set();
101
+ app.get('/edrys-dev/reload', (req, res) => {
102
+ res.writeHead(200, {
103
+ 'Content-Type': 'text/event-stream',
104
+ 'Cache-Control': 'no-cache',
105
+ Connection: 'keep-alive',
106
+ });
107
+ res.write(': edrys-dev\n\n');
108
+ others.add(res);
109
+ req.on('close', () => others.delete(res));
110
+ });
111
+ // questions to the owner tab, by id, until it replies
112
+ const pending = new Map();
113
+ // a reply carries a room's whole state
114
+ app.post('/edrys-dev/bridge', express.json({ limit: '2mb' }), (req, res) => {
115
+ if (!sameOrigin(req))
116
+ return res.sendStatus(403);
117
+ const report = req.body;
118
+ if (report.type === 'reply') {
119
+ pending.get(report.id)?.(report);
120
+ return res.sendStatus(204);
121
+ }
122
+ if (report.type === 'open' && typeof report.url === 'string')
123
+ share = report.url;
124
+ if (report.type === 'class') {
125
+ const { type, ...state } = report;
126
+ classState = state;
127
+ }
128
+ if (report.type === 'messages' && Array.isArray(report.messages)) {
129
+ for (const m of report.messages)
130
+ addLog({ ...m, body: readableBody(m.body), kind: 'message' });
131
+ dropped += Number(report.dropped) || 0;
132
+ }
133
+ bus.emit('report', report);
134
+ res.sendStatus(204);
135
+ });
136
+ // The error reporter of local modules (see modules.ts): from the module
137
+ // server's origin, or "null" for a module edrys-Lite inlined.
138
+ let moduleOrigins = [];
139
+ app.post('/edrys-dev/module-error', express.text({ type: () => true }), (req, res) => {
140
+ if (!moduleOrigins.includes(String(req.headers.origin)))
141
+ return res.sendStatus(403);
142
+ let error;
143
+ try {
144
+ error = JSON.parse(req.body);
145
+ }
146
+ catch {
147
+ return res.sendStatus(400);
148
+ }
149
+ const entry = {
150
+ t: Date.now(),
151
+ kind: 'error',
152
+ module: String(error.module),
153
+ source: error.kind === 'console' ? 'console' : 'error',
154
+ message: String(error.message).slice(0, 2000),
155
+ };
156
+ addLog(entry);
157
+ bus.emit('report', { type: 'module-error', ...entry });
158
+ res.sendStatus(204);
159
+ });
160
+ const chat = new Chat(path.join(path.dirname(options.lab), '.edrys-dev', 'chat.json'));
161
+ chat.routes(app);
162
+ // edrys-Lite's page, with the chat panel (it shows only in the class tab)
163
+ app.get(['/', '/index.html'], async (req, res) => {
164
+ const page = await fs.readFile(path.join(edrysDist(), 'index.html'), 'utf8');
165
+ res.type('html').send(page + PANEL_SCRIPT);
166
+ });
167
+ app.use(express.static(edrysDist()));
168
+ const server = http.createServer(app);
169
+ // Loaded only when used: @edryslabs/websocket-server is an optional dependency.
170
+ const stopSync = options.local ? (await import('./sync.js')).attachSync(server) : () => { };
171
+ const send = (event, data) => {
172
+ for (const client of clients)
173
+ client.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
174
+ };
175
+ const request = (event, data, ms = 5000) => new Promise((resolve, reject) => {
176
+ if (!clients.size)
177
+ return reject(new Error('the class tab is not open'));
178
+ const id = crypto.randomUUID();
179
+ const timer = setTimeout(() => {
180
+ pending.delete(id);
181
+ reject(new Error(`the class tab did not answer within ${ms / 1000} s`));
182
+ }, ms);
183
+ pending.set(id, (reply) => {
184
+ clearTimeout(timer);
185
+ pending.delete(id);
186
+ if (reply.error !== undefined)
187
+ reject(new Error(String(reply.error)));
188
+ else
189
+ resolve(reply.value);
190
+ });
191
+ send(event, { id, ...data });
192
+ });
193
+ const stopWatching = watchFile(options.lab, () => send('reload-lab', {}));
194
+ const port = await listen(server, options.port, options.host);
195
+ let modules = null;
196
+ let stopModules = async () => { };
197
+ if (options.modules) {
198
+ const dir = options.modules;
199
+ // the class tab may use either local address
200
+ const pageOrigins = [...new Set(['localhost', '127.0.0.1', options.host].map((host) => `http://${host}:${port}`))];
201
+ const moduleServer = http.createServer(moduleApp(dir, `http://localhost:${port}/edrys-dev/module-error`, pageOrigins));
202
+ const modulePort = await listen(moduleServer, port + 1, options.host);
203
+ modules = { dir, url: `http://localhost:${modulePort}/` };
204
+ moduleOrigins = [`http://localhost:${modulePort}`, `http://127.0.0.1:${modulePort}`, 'null'];
205
+ const stopWatchingModules = watchModules(dir, (name) => {
206
+ // the lab may name either local address
207
+ for (const host of ['localhost', '127.0.0.1']) {
208
+ send('reload-module', { url: `http://${host}:${modulePort}/${name}/` });
209
+ }
210
+ for (const other of others)
211
+ other.write('event: reload-module\ndata: {}\n\n');
212
+ bus.emit('module-changed', name);
213
+ });
214
+ stopModules = async () => {
215
+ await stopWatchingModules();
216
+ moduleServer.closeAllConnections();
217
+ await new Promise((resolve) => moduleServer.close(resolve));
218
+ };
219
+ }
220
+ return {
221
+ port,
222
+ app,
223
+ bus,
224
+ chat,
225
+ state: () => ({ connected: clients.size > 0, share, class: classState }),
226
+ send,
227
+ request,
228
+ modules,
229
+ log: () => ({ entries: log, dropped }),
230
+ async close() {
231
+ clearTimeout(settling);
232
+ await stopWatching();
233
+ await stopModules();
234
+ stopSync();
235
+ for (const client of [...clients, ...others])
236
+ client.end();
237
+ server.closeAllConnections();
238
+ await new Promise((resolve) => server.close(resolve));
239
+ },
240
+ };
241
+ }
242
+ async function listen(server, port, host, tries = 20) {
243
+ for (let next = port; next < port + tries; next++) {
244
+ try {
245
+ await new Promise((resolve, reject) => {
246
+ server.once('error', reject);
247
+ server.listen(next, host, () => {
248
+ server.off('error', reject);
249
+ resolve();
250
+ });
251
+ });
252
+ return next;
253
+ }
254
+ catch (error) {
255
+ if (error.code !== 'EADDRINUSE')
256
+ throw error;
257
+ }
258
+ }
259
+ throw new Error(`no free port in ${port}-${port + tries - 1}`);
260
+ }
package/dist/sync.js ADDED
@@ -0,0 +1,15 @@
1
+ import { WebSocketServer } from 'ws';
2
+ import { setupWSConnection } from '@edryslabs/websocket-server/utils';
3
+ /**
4
+ * Yjs sync on the dev server's own port (`--local`), as in the edrys
5
+ * websocket-server: every path is a doc named after it. Returns a function
6
+ * that shuts it down.
7
+ */
8
+ export function attachSync(server) {
9
+ const docs = new WebSocketServer({ noServer: true });
10
+ docs.on('connection', setupWSConnection);
11
+ server.on('upgrade', (request, socket, head) => {
12
+ docs.handleUpgrade(request, socket, head, (ws) => docs.emit('connection', ws, request));
13
+ });
14
+ return () => docs.close();
15
+ }
package/dist/watch.js ADDED
@@ -0,0 +1,44 @@
1
+ import path from 'node:path';
2
+ import { watch } from 'chokidar';
3
+ /**
4
+ * Calls `onChange` once per save of `file`: editors often write a file in
5
+ * several steps (truncate + write, or write a copy and rename it over).
6
+ * Returns a function that stops watching.
7
+ */
8
+ export function watchFile(file, onChange, delay = 100) {
9
+ let timer;
10
+ const watcher = watch(file, { ignoreInitial: true });
11
+ watcher.on('all', () => {
12
+ clearTimeout(timer);
13
+ timer = setTimeout(onChange, delay);
14
+ });
15
+ return () => {
16
+ clearTimeout(timer);
17
+ return watcher.close();
18
+ };
19
+ }
20
+ /**
21
+ * Calls `onChange(name)` once per save in a folder `dir/<name>/`, i.e. per
22
+ * module; files directly in `dir` are no module. Returns a function that
23
+ * stops watching.
24
+ */
25
+ export function watchModules(dir, onChange, delay = 100) {
26
+ const timers = new Map();
27
+ // hidden files and node_modules below dir; dir itself may sit in a hidden folder
28
+ const ignored = (file) => path
29
+ .relative(dir, file)
30
+ .split(path.sep)
31
+ .some((part) => part.startsWith('.') || part === 'node_modules');
32
+ const watcher = watch(dir, { ignoreInitial: true, ignored });
33
+ watcher.on('all', (_event, file) => {
34
+ const [name, ...rest] = path.relative(dir, file).split(path.sep);
35
+ if (!name || !rest.length)
36
+ return;
37
+ clearTimeout(timers.get(name));
38
+ timers.set(name, setTimeout(() => onChange(name), delay));
39
+ });
40
+ return () => {
41
+ timers.forEach(clearTimeout);
42
+ return watcher.close();
43
+ };
44
+ }
package/package.json CHANGED
@@ -1,6 +1,60 @@
1
1
  {
2
2
  "name": "@edryslabs/edrys-dev",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "Develop edrys-Lite labs locally: serves edrys-Lite, syncs on its own port and republishes the class whenever the lab file changes",
5
+ "type": "module",
6
+ "bin": {
7
+ "edrys-dev": "dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "views"
12
+ ],
13
+ "publishConfig": {
14
+ "access": "public"
15
+ },
16
+ "scripts": {
17
+ "build": "tsc",
18
+ "watch": "tsc --watch",
19
+ "test": "tsc && node --test",
20
+ "prepublishOnly": "rm -rf dist && tsc"
21
+ },
22
+ "keywords": [
23
+ "edrys",
24
+ "edrys-lite",
25
+ "remote lab",
26
+ "devServer"
27
+ ],
28
+ "license": "MIT",
29
+ "author": "edrys-labs (https://github.com/edrys-labs)",
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+https://github.com/edrys-labs/edrys-devserver.git"
33
+ },
34
+ "bugs": {
35
+ "url": "https://github.com/edrys-labs/edrys-devserver/issues"
36
+ },
37
+ "homepage": "https://github.com/edrys-labs/edrys-devserver#readme",
38
+ "dependencies": {
39
+ "@edryslabs/edrys-lite": "3.2.0-dev.0",
40
+ "chokidar": "^4.0.3",
41
+ "dompurify": "^3.4.17",
42
+ "express": "^5.1.0",
43
+ "marked": "^18.1.1",
44
+ "msgpackr": "^2.1.2",
45
+ "open": "^10.1.0"
46
+ },
47
+ "optionalDependencies": {
48
+ "@edryslabs/websocket-server": "1.1.0",
49
+ "ws": "^8.18.0"
50
+ },
51
+ "devDependencies": {
52
+ "@types/express": "^5.0.0",
53
+ "@types/node": "^22.10.0",
54
+ "@types/ws": "^8.5.13",
55
+ "typescript": "^5.7.0"
56
+ },
57
+ "engines": {
58
+ "node": ">=20"
59
+ }
60
+ }