@edryslabs/edrys-dev 0.1.2 → 0.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/dist/origin.js CHANGED
@@ -1,5 +1,57 @@
1
- /** Requests from a page of another origin, e.g. a module's own site, are refused. */
2
- export function sameOrigin(req) {
1
+ import os from 'node:os';
2
+ const WILDCARDS = ['0.0.0.0', '::'];
3
+ /**
4
+ * The host names a server listening on `host` answers to: this machine's own,
5
+ * plus every address of its network interfaces when it listens on all of them.
6
+ * A page that rebinds its own domain to 127.0.0.1 sends that domain instead.
7
+ */
8
+ export function hostNames(host) {
9
+ const names = ['localhost', '127.0.0.1', '[::1]'];
10
+ const add = (address) => names.push(address.includes(':') ? `[${address}]` : address);
11
+ if (WILDCARDS.includes(host)) {
12
+ for (const addresses of Object.values(os.networkInterfaces())) {
13
+ for (const { address } of addresses ?? [])
14
+ add(address);
15
+ }
16
+ }
17
+ else {
18
+ add(host);
19
+ }
20
+ return [...new Set(names)];
21
+ }
22
+ /** Whether `host` listens on more than this machine's loopback addresses. */
23
+ export function reachable(host) {
24
+ return !['localhost', '127.0.0.1', '::1', '[::1]'].includes(host);
25
+ }
26
+ // `value` (a Host, or an Origin without its scheme) is one of `names` on this server's port
27
+ function ours(req, names, value) {
28
+ const port = req.socket.localPort;
29
+ // browsers leave out the default port
30
+ return names.some((name) => value === `${name}:${port}` || (port === 80 && value === name));
31
+ }
32
+ /** Refuses requests whose Host header is not one of `names` on this server's port. */
33
+ export function hostGuard(names) {
34
+ return (req, res, next) => (ours(req, names, req.headers.host) ? next() : res.sendStatus(403));
35
+ }
36
+ /**
37
+ * Whether a WebSocket upgrade comes from one of edrys-dev's own pages. Browsers
38
+ * send an Origin with every WebSocket, and let any site open one; a student
39
+ * tab (127.0.0.1) connects to the owner's address (localhost), so any of
40
+ * `names` will do, not only the Host.
41
+ */
42
+ export function ownPage(req, names) {
3
43
  const origin = req.headers.origin;
4
- return origin === undefined || origin === `http://${req.headers.host}`;
44
+ return (ours(req, names, req.headers.host) &&
45
+ typeof origin === 'string' &&
46
+ origin.startsWith('http://') &&
47
+ ours(req, names, origin.slice('http://'.length)));
48
+ }
49
+ /**
50
+ * Posts from edrys-Lite's page on this server only: browsers always send an
51
+ * Origin with a POST, so one without it is no page of ours (another local user,
52
+ * another program). Pages of another origin, e.g. a module's own site, are
53
+ * refused too. Run after hostGuard, which vouches for the Host.
54
+ */
55
+ export function sameOrigin(req) {
56
+ return req.headers.origin === `http://${req.headers.host}`;
5
57
  }
package/dist/server.js CHANGED
@@ -6,10 +6,10 @@ import { EventEmitter } from 'node:events';
6
6
  import { createRequire } from 'node:module';
7
7
  import express from 'express';
8
8
  import { unpack } from 'msgpackr';
9
- import { watchFile, watchModules } from './watch.js';
9
+ import { readSaved, watchFile, watchModules } from './watch.js';
10
10
  import { moduleApp } from './modules.js';
11
- import { sameOrigin } from './origin.js';
12
- import { Chat, panelFiles, panelScript } from './chat.js';
11
+ import { hostGuard, hostNames, sameOrigin } from './origin.js';
12
+ import { Chat, explain, panelFiles, panelScript } from './chat.js';
13
13
  // entries the log keeps, oldest first out
14
14
  const LOG_MAX = 300;
15
15
  // a body in the log is cut to this many characters of JSON
@@ -50,6 +50,9 @@ export function edrysDist() {
50
50
  */
51
51
  export async function startDevServer(options) {
52
52
  const app = express();
53
+ // a page that rebinds its own domain to this machine would pass sameOrigin
54
+ const names = hostNames(options.host);
55
+ app.use(hostGuard(names));
53
56
  const bus = new EventEmitter();
54
57
  const clients = new Set();
55
58
  let share = null;
@@ -74,7 +77,7 @@ export async function startDevServer(options) {
74
77
  app.get('/edrys-dev/lab', async (req, res) => {
75
78
  res.json({
76
79
  path: options.lab,
77
- text: await fs.readFile(options.lab, 'utf8'),
80
+ text: await readSaved(options.lab),
78
81
  // Without it the class keeps its own (or edrys-Lite's default) transport.
79
82
  communication: options.local
80
83
  ? { communicationMethod: 'Websocket', websocketUrl: `ws://${req.headers.host}` }
@@ -125,6 +128,11 @@ export async function startDevServer(options) {
125
128
  const { type, ...state } = report;
126
129
  classState = state;
127
130
  }
131
+ // the user may not see the terminal or the agent: the chat panel says it too
132
+ if (report.type === 'error')
133
+ chat.labProblem(explain(String(report.message)));
134
+ if (report.type === 'applied')
135
+ chat.labProblem(null);
128
136
  if (report.type === 'messages' && Array.isArray(report.messages)) {
129
137
  for (const m of report.messages)
130
138
  addLog({ ...m, body: readableBody(m.body), kind: 'message' });
@@ -134,11 +142,15 @@ export async function startDevServer(options) {
134
142
  res.sendStatus(204);
135
143
  });
136
144
  // The error reporter of local modules (see modules.ts): from the module
137
- // server's origin, or "null" for a module edrys-Lite inlined.
145
+ // server's origin, or "null" for a module edrys-Lite inlined. Any page can
146
+ // be "null" (a sandboxed frame), so the reporter also carries the token,
147
+ // which only local module pages hold.
138
148
  let moduleOrigins = [];
139
- app.post('/edrys-dev/module-error', express.text({ type: () => true }), (req, res) => {
140
- if (!moduleOrigins.includes(String(req.headers.origin)))
149
+ const reportToken = crypto.randomBytes(16).toString('hex');
150
+ app.post('/edrys-dev/module-error/:token', express.text({ type: () => true }), (req, res) => {
151
+ if (req.params.token !== reportToken || !moduleOrigins.includes(String(req.headers.origin))) {
141
152
  return res.sendStatus(403);
153
+ }
142
154
  let error;
143
155
  try {
144
156
  error = JSON.parse(req.body);
@@ -157,7 +169,7 @@ export async function startDevServer(options) {
157
169
  bus.emit('report', { type: 'module-error', ...entry });
158
170
  res.sendStatus(204);
159
171
  });
160
- const chat = new Chat(path.join(path.dirname(options.lab), '.edrys-dev', 'chat.json'));
172
+ const chat = new Chat(path.join(path.dirname(options.lab), '.edrys-dev'), SETTLE_MS);
161
173
  // without an agent, nobody would answer
162
174
  if (options.agent)
163
175
  chat.routes(app);
@@ -170,7 +182,7 @@ export async function startDevServer(options) {
170
182
  app.use(express.static(edrysDist()));
171
183
  const server = http.createServer(app);
172
184
  // Loaded only when used: @edryslabs/websocket-server is an optional dependency.
173
- const stopSync = options.local ? (await import('./sync.js')).attachSync(server) : () => { };
185
+ const stopSync = options.local ? (await import('./sync.js')).attachSync(server, names) : () => { };
174
186
  const send = (event, data) => {
175
187
  for (const client of clients)
176
188
  client.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
@@ -201,15 +213,16 @@ export async function startDevServer(options) {
201
213
  const dir = options.modules;
202
214
  // the class tab may use either local address
203
215
  const pageOrigins = [...new Set(['localhost', '127.0.0.1', options.host].map((host) => `http://${host}:${port}`))];
204
- const moduleServer = http.createServer(moduleApp(dir, `http://localhost:${port}/edrys-dev/module-error`, pageOrigins));
216
+ const moduleServer = http.createServer(moduleApp(dir, `http://localhost:${port}/edrys-dev/module-error/${reportToken}`, pageOrigins, names));
205
217
  const modulePort = await listen(moduleServer, port + 1, options.host);
206
218
  modules = { dir, url: `http://localhost:${modulePort}/` };
207
219
  moduleOrigins = [`http://localhost:${modulePort}`, `http://127.0.0.1:${modulePort}`, 'null'];
208
220
  const stopWatchingModules = watchModules(dir, (name) => {
209
- // the lab may name either local address
210
- for (const host of ['localhost', '127.0.0.1']) {
211
- send('reload-module', { url: `http://${host}:${modulePort}/${name}/` });
212
- }
221
+ // the lab may name either local address; one event, so the class tab
222
+ // scrapes the module once
223
+ send('reload-module', {
224
+ urls: ['localhost', '127.0.0.1'].map((host) => `http://${host}:${modulePort}/${name}/`),
225
+ });
213
226
  for (const other of others)
214
227
  other.write('event: reload-module\ndata: {}\n\n');
215
228
  bus.emit('module-changed', name);
@@ -239,6 +252,7 @@ export async function startDevServer(options) {
239
252
  client.end();
240
253
  server.closeAllConnections();
241
254
  await new Promise((resolve) => server.close(resolve));
255
+ await chat.flush();
242
256
  },
243
257
  };
244
258
  }
package/dist/sync.js CHANGED
@@ -1,14 +1,20 @@
1
1
  import { WebSocketServer } from 'ws';
2
2
  import { setupWSConnection } from '@edryslabs/websocket-server/utils';
3
+ import { ownPage } from './origin.js';
3
4
  /**
4
5
  * 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.
6
+ * websocket-server: every path is a doc named after it. Only edrys-dev's own
7
+ * pages may connect (`names`, see hostGuard), not any site the user visits.
8
+ * Returns a function that shuts it down.
7
9
  */
8
- export function attachSync(server) {
10
+ export function attachSync(server, names) {
9
11
  const docs = new WebSocketServer({ noServer: true });
10
12
  docs.on('connection', setupWSConnection);
11
13
  server.on('upgrade', (request, socket, head) => {
14
+ if (!ownPage(request, names)) {
15
+ socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
16
+ return;
17
+ }
12
18
  docs.handleUpgrade(request, socket, head, (ws) => docs.emit('connection', ws, request));
13
19
  });
14
20
  return () => docs.close();
package/dist/watch.js CHANGED
@@ -1,5 +1,23 @@
1
+ import fs from 'node:fs/promises';
1
2
  import path from 'node:path';
2
3
  import { watch } from 'chokidar';
4
+ /**
5
+ * Reads `file`; while it is missing, tries again for up to `ms`: a save that
6
+ * renames a copy over it, or deletes and writes it, leaves it gone a moment.
7
+ */
8
+ export async function readSaved(file, ms = 500) {
9
+ const end = Date.now() + ms;
10
+ for (;;) {
11
+ try {
12
+ return await fs.readFile(file, 'utf8');
13
+ }
14
+ catch (error) {
15
+ if (error.code !== 'ENOENT' || Date.now() >= end)
16
+ throw error;
17
+ await new Promise((resolve) => setTimeout(resolve, 25));
18
+ }
19
+ }
20
+ }
3
21
  /**
4
22
  * Calls `onChange` once per save of `file`: editors often write a file in
5
23
  * several steps (truncate + write, or write a copy and rename it over).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edryslabs/edrys-dev",
3
- "version": "0.1.2",
3
+ "version": "0.3.0",
4
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
5
  "type": "module",
6
6
  "bin": {
@@ -36,7 +36,7 @@
36
36
  },
37
37
  "homepage": "https://github.com/edrys-labs/edrys-devserver#readme",
38
38
  "dependencies": {
39
- "@edryslabs/edrys-lite": "3.2.0-dev.0",
39
+ "@edryslabs/edrys-lite": "3.2.0-dev.1",
40
40
  "chokidar": "^4.0.3",
41
41
  "dompurify": "^3.4.17",
42
42
  "express": "^5.1.0",
package/views/panel.js CHANGED
@@ -49,10 +49,14 @@
49
49
  }
50
50
  .panel.open { display: flex; }
51
51
  header {
52
- padding: 10px 14px; border-bottom: 1px solid var(--line);
53
- font-size: 14px; font-weight: 600; display: flex; justify-content: space-between;
52
+ padding: 8px 10px 8px 14px; border-bottom: 1px solid var(--line);
53
+ font-size: 14px; font-weight: 600; display: flex; align-items: center; gap: 8px;
54
+ }
55
+ header small { font-weight: 400; color: var(--muted); white-space: nowrap; }
56
+ header select {
57
+ flex: 1; min-width: 0; font-size: 13px; padding: 3px 4px; border-radius: 6px;
58
+ border: 1px solid var(--line); background: var(--bg); color: var(--fg);
54
59
  }
55
- header small { font-weight: 400; color: var(--muted); }
56
60
  .log { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 8px; }
57
61
  .msg { max-width: 88%; padding: 8px 11px; border-radius: 10px; font-size: 14px; line-height: 1.4;
58
62
  overflow-wrap: anywhere; }
@@ -71,7 +75,16 @@
71
75
  border: 1px solid var(--accent); background: transparent; color: var(--accent);
72
76
  }
73
77
  .msg form[aria-disabled="true"] { opacity: .5; pointer-events: none; }
78
+ .problem { margin: 0 12px 8px; padding: 8px 10px; border-radius: 8px; font-size: 13px; line-height: 1.4;
79
+ border: 1px solid #e0452b; background: rgba(224,69,43,.1); }
80
+ .problem[hidden] { display: none; }
81
+ .problem summary { cursor: pointer; color: var(--muted); font-size: 12px; margin-top: 4px; }
82
+ .problem pre { margin: 4px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; font: 12px ui-monospace, monospace; }
74
83
  .status { padding: 0 12px 6px; font-size: 12px; color: var(--muted); min-height: 1em; }
84
+ .hint { padding: 0 12px 6px; font-size: 12px; color: var(--muted); font-style: italic; }
85
+ .hint:empty { display: none; }
86
+ .reason { margin-top: 4px; font-size: 12px; color: var(--muted); }
87
+ .skipped { align-self: center; font-size: 12px; color: var(--muted); }
75
88
  .compose { display: flex; gap: 6px; padding: 8px; border-top: 1px solid var(--line); }
76
89
  textarea {
77
90
  flex: 1; resize: none; height: 40px; padding: 8px; font-size: 14px; border-radius: 8px;
@@ -92,9 +105,16 @@
92
105
  🤖<span class="dot"></span>
93
106
  </button>
94
107
  <section class="panel" id="panel" aria-label="Agent chat">
95
- <header>Agent <small>edrys-dev, only you see this</small></header>
108
+ <header>
109
+ Agent
110
+ <label for="sessions" hidden>Chats</label>
111
+ <select id="sessions"></select>
112
+ <small title="edrys-dev: not the class's chat">only you see this</small>
113
+ </header>
96
114
  <div class="log" role="log" aria-live="polite"></div>
115
+ <div class="problem" role="alert" hidden></div>
97
116
  <div class="status" aria-live="polite"></div>
117
+ <div class="hint" aria-live="polite"></div>
98
118
  <form class="compose">
99
119
  <label for="text" hidden>Message to the agent</label>
100
120
  <textarea id="text" placeholder="Message to the agent (Enter sends)"></textarea>
@@ -108,10 +128,40 @@
108
128
  const panel = root.querySelector('.panel')
109
129
  const log = root.querySelector('.log')
110
130
  const statusLine = root.querySelector('.status')
131
+ const hintLine = root.querySelector('.hint')
132
+ const problemBox = root.querySelector('.problem')
111
133
  const compose = root.querySelector('.compose')
112
134
  const textarea = root.querySelector('textarea')
135
+ const menu = root.querySelector('#sessions')
113
136
 
114
137
  let open = null // the ask id of the form the user can still answer
138
+ let current = null // the chat shown, {id, title}
139
+ let sessions = [] // the saved chats, the one changed last first
140
+
141
+ const NEW = ''
142
+ // + New chat, then the chats; the current one is new until it has a file
143
+ function showMenu() {
144
+ const option = (value, text) => {
145
+ const o = document.createElement('option')
146
+ o.value = value
147
+ o.textContent = text
148
+ return o
149
+ }
150
+ const options = [option(NEW, '+ New chat')]
151
+ if (current && !sessions.some((s) => s.id === current.id)) options.push(option(current.id, 'New chat'))
152
+ for (const s of sessions) options.push(option(s.id, s.title))
153
+ menu.replaceChildren(...options)
154
+ if (current) menu.value = current.id
155
+ }
156
+
157
+ menu.addEventListener('change', async () => {
158
+ const response = await post({ session: menu.value === NEW ? null : menu.value })
159
+ // the server sends the chat it shows now; otherwise back to the one before
160
+ if (!response?.ok) {
161
+ showMenu()
162
+ notSent()
163
+ } else if (menu.value === NEW) showMenu()
164
+ })
115
165
 
116
166
  function setOpen(isOpen) {
117
167
  panel.classList.toggle('open', isOpen)
@@ -124,20 +174,30 @@
124
174
  toggle.addEventListener('click', () => setOpen(!panel.classList.contains('open')))
125
175
  setOpen(sessionStorage.getItem(OPEN_KEY) === '1')
126
176
 
177
+ // the response, or null if edrys-dev could not be reached
127
178
  function post(body) {
128
179
  return fetch('/edrys-dev/chat', {
129
180
  method: 'POST',
130
181
  headers: { 'Content-Type': 'application/json' },
131
182
  body: JSON.stringify(body),
132
- })
183
+ }).catch(() => null)
133
184
  }
134
185
 
135
- compose.addEventListener('submit', (event) => {
186
+ // shown in the status line until the agent sets the next one
187
+ function notSent() {
188
+ statusLine.textContent = 'Not sent: is edrys-dev still running? Try again.'
189
+ }
190
+
191
+ compose.addEventListener('submit', async (event) => {
136
192
  event.preventDefault()
137
193
  const text = textarea.value.trim()
138
194
  if (!text) return
139
195
  textarea.value = ''
140
- post({ text })
196
+ const response = await post({ text })
197
+ if (response?.ok) return
198
+ // back into the box, unless the user typed something new meanwhile
199
+ if (!textarea.value) textarea.value = text
200
+ notSent()
141
201
  })
142
202
  textarea.addEventListener('keydown', (event) => {
143
203
  if (event.key === 'Enter' && !event.shiftKey) {
@@ -212,15 +272,42 @@
212
272
  values[input.id] = input.type === 'radio' ? checked[0] ?? '' : checked
213
273
  }
214
274
  const response = await post({ ask: entry.ask, values })
215
- if (!response.ok) disable(entry.ask)
275
+ // closed in the meantime; otherwise the form stays, to send again
276
+ if (response?.status === 409) disable(entry.ask)
277
+ else if (!response?.ok) notSent()
216
278
  })
217
279
 
218
280
  if (entry.ask !== open) form.setAttribute('aria-disabled', 'true')
219
281
  return form
220
282
  }
221
283
 
222
- function disable(ask) {
223
- root.querySelectorAll(`form[data-ask="${ask}"]`).forEach((form) => form.setAttribute('aria-disabled', 'true'))
284
+ function disable(ask, reason) {
285
+ root.querySelectorAll(`form[data-ask="${ask}"]`).forEach((form) => {
286
+ form.setAttribute('aria-disabled', 'true')
287
+ // why the agent closed it, shown only live
288
+ if (reason && !form.nextElementSibling?.classList.contains('reason')) {
289
+ const note = document.createElement('div')
290
+ note.className = 'reason'
291
+ note.textContent = reason
292
+ form.after(note)
293
+ }
294
+ })
295
+ }
296
+
297
+ // the lab file does not load: until it does, in plain words, edrys-Lite's message folded
298
+ function showProblem(problem) {
299
+ problemBox.hidden = !problem
300
+ if (!problem) return problemBox.replaceChildren()
301
+ const text = document.createElement('div')
302
+ text.textContent = problem.text
303
+ const details = document.createElement('details')
304
+ const summary = document.createElement('summary')
305
+ summary.textContent = 'Details'
306
+ const pre = document.createElement('pre')
307
+ pre.textContent = problem.detail
308
+ details.append(summary, pre)
309
+ problemBox.replaceChildren(text, details)
310
+ if (!panel.classList.contains('open')) toggle.classList.add('unread')
224
311
  }
225
312
 
226
313
  function show(entry) {
@@ -248,9 +335,23 @@
248
335
  case 'history':
249
336
  log.replaceChildren()
250
337
  open = message.open
338
+ current = message.session
339
+ sessions = message.sessions
340
+ showMenu()
341
+ if (message.skipped) {
342
+ const note = document.createElement('div')
343
+ note.className = 'skipped'
344
+ note.textContent = `${message.skipped} earlier messages not shown`
345
+ log.appendChild(note)
346
+ }
251
347
  message.entries.forEach(show)
252
348
  statusLine.textContent = message.status
349
+ hintLine.textContent = message.hint || ''
253
350
  toggle.classList.remove('unread')
351
+ showProblem(message.lab)
352
+ break
353
+ case 'lab':
354
+ showProblem(message.problem)
254
355
  break
255
356
  case 'entry':
256
357
  if (message.entry.type === 'ai' && message.entry.ask) open = message.entry.ask
@@ -260,9 +361,16 @@
260
361
  case 'status':
261
362
  statusLine.textContent = message.text
262
363
  break
364
+ case 'sessions':
365
+ sessions = message.sessions
366
+ showMenu()
367
+ break
368
+ case 'hint':
369
+ hintLine.textContent = message.text
370
+ break
263
371
  case 'close':
264
372
  if (open === message.ask) open = null
265
- disable(message.ask)
373
+ disable(message.ask, message.reason)
266
374
  break
267
375
  }
268
376
  }