@edryslabs/edrys-dev 0.1.1 → 0.2.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, PANEL_SCRIPT } from './chat.js';
11
+ import { hostGuard, hostNames, sameOrigin } from './origin.js';
12
+ import { Chat, 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}` }
@@ -134,11 +137,15 @@ export async function startDevServer(options) {
134
137
  res.sendStatus(204);
135
138
  });
136
139
  // The error reporter of local modules (see modules.ts): from the module
137
- // server's origin, or "null" for a module edrys-Lite inlined.
140
+ // server's origin, or "null" for a module edrys-Lite inlined. Any page can
141
+ // be "null" (a sandboxed frame), so the reporter also carries the token,
142
+ // which only local module pages hold.
138
143
  let moduleOrigins = [];
139
- app.post('/edrys-dev/module-error', express.text({ type: () => true }), (req, res) => {
140
- if (!moduleOrigins.includes(String(req.headers.origin)))
144
+ const reportToken = crypto.randomBytes(16).toString('hex');
145
+ app.post('/edrys-dev/module-error/:token', express.text({ type: () => true }), (req, res) => {
146
+ if (req.params.token !== reportToken || !moduleOrigins.includes(String(req.headers.origin))) {
141
147
  return res.sendStatus(403);
148
+ }
142
149
  let error;
143
150
  try {
144
151
  error = JSON.parse(req.body);
@@ -157,17 +164,20 @@ export async function startDevServer(options) {
157
164
  bus.emit('report', { type: 'module-error', ...entry });
158
165
  res.sendStatus(204);
159
166
  });
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)
167
+ const chat = new Chat(path.join(path.dirname(options.lab), '.edrys-dev'), SETTLE_MS);
168
+ // without an agent, nobody would answer
169
+ if (options.agent)
170
+ chat.routes(app);
171
+ panelFiles(app);
172
+ // edrys-Lite's page, with panel.js (the chat shows only in the class tab)
163
173
  app.get(['/', '/index.html'], async (req, res) => {
164
174
  const page = await fs.readFile(path.join(edrysDist(), 'index.html'), 'utf8');
165
- res.type('html').send(page + PANEL_SCRIPT);
175
+ res.type('html').send(page + panelScript(!!options.agent));
166
176
  });
167
177
  app.use(express.static(edrysDist()));
168
178
  const server = http.createServer(app);
169
179
  // Loaded only when used: @edryslabs/websocket-server is an optional dependency.
170
- const stopSync = options.local ? (await import('./sync.js')).attachSync(server) : () => { };
180
+ const stopSync = options.local ? (await import('./sync.js')).attachSync(server, names) : () => { };
171
181
  const send = (event, data) => {
172
182
  for (const client of clients)
173
183
  client.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
@@ -198,15 +208,16 @@ export async function startDevServer(options) {
198
208
  const dir = options.modules;
199
209
  // the class tab may use either local address
200
210
  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));
211
+ const moduleServer = http.createServer(moduleApp(dir, `http://localhost:${port}/edrys-dev/module-error/${reportToken}`, pageOrigins, names));
202
212
  const modulePort = await listen(moduleServer, port + 1, options.host);
203
213
  modules = { dir, url: `http://localhost:${modulePort}/` };
204
214
  moduleOrigins = [`http://localhost:${modulePort}`, `http://127.0.0.1:${modulePort}`, 'null'];
205
215
  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
- }
216
+ // the lab may name either local address; one event, so the class tab
217
+ // scrapes the module once
218
+ send('reload-module', {
219
+ urls: ['localhost', '127.0.0.1'].map((host) => `http://${host}:${modulePort}/${name}/`),
220
+ });
210
221
  for (const other of others)
211
222
  other.write('event: reload-module\ndata: {}\n\n');
212
223
  bus.emit('module-changed', name);
@@ -236,6 +247,7 @@ export async function startDevServer(options) {
236
247
  client.end();
237
248
  server.closeAllConnections();
238
249
  await new Promise((resolve) => server.close(resolve));
250
+ await chat.flush();
239
251
  },
240
252
  };
241
253
  }
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.1",
3
+ "version": "0.2.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
@@ -10,6 +10,8 @@
10
10
  }
11
11
  return
12
12
  }
13
+ // the chat only with an agent to answer it (edrys-dev --mcp)
14
+ if (!document.currentScript || !document.currentScript.hasAttribute('data-chat')) return
13
15
 
14
16
  const OPEN_KEY = 'edrys-dev:chat-open'
15
17
 
@@ -47,10 +49,14 @@
47
49
  }
48
50
  .panel.open { display: flex; }
49
51
  header {
50
- padding: 10px 14px; border-bottom: 1px solid var(--line);
51
- 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);
52
59
  }
53
- header small { font-weight: 400; color: var(--muted); }
54
60
  .log { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 8px; }
55
61
  .msg { max-width: 88%; padding: 8px 11px; border-radius: 10px; font-size: 14px; line-height: 1.4;
56
62
  overflow-wrap: anywhere; }
@@ -70,6 +76,10 @@
70
76
  }
71
77
  .msg form[aria-disabled="true"] { opacity: .5; pointer-events: none; }
72
78
  .status { padding: 0 12px 6px; font-size: 12px; color: var(--muted); min-height: 1em; }
79
+ .hint { padding: 0 12px 6px; font-size: 12px; color: var(--muted); font-style: italic; }
80
+ .hint:empty { display: none; }
81
+ .reason { margin-top: 4px; font-size: 12px; color: var(--muted); }
82
+ .skipped { align-self: center; font-size: 12px; color: var(--muted); }
73
83
  .compose { display: flex; gap: 6px; padding: 8px; border-top: 1px solid var(--line); }
74
84
  textarea {
75
85
  flex: 1; resize: none; height: 40px; padding: 8px; font-size: 14px; border-radius: 8px;
@@ -90,9 +100,15 @@
90
100
  🤖<span class="dot"></span>
91
101
  </button>
92
102
  <section class="panel" id="panel" aria-label="Agent chat">
93
- <header>Agent <small>edrys-dev, only you see this</small></header>
103
+ <header>
104
+ Agent
105
+ <label for="sessions" hidden>Chats</label>
106
+ <select id="sessions"></select>
107
+ <small title="edrys-dev: not the class's chat">only you see this</small>
108
+ </header>
94
109
  <div class="log" role="log" aria-live="polite"></div>
95
110
  <div class="status" aria-live="polite"></div>
111
+ <div class="hint" aria-live="polite"></div>
96
112
  <form class="compose">
97
113
  <label for="text" hidden>Message to the agent</label>
98
114
  <textarea id="text" placeholder="Message to the agent (Enter sends)"></textarea>
@@ -106,10 +122,39 @@
106
122
  const panel = root.querySelector('.panel')
107
123
  const log = root.querySelector('.log')
108
124
  const statusLine = root.querySelector('.status')
125
+ const hintLine = root.querySelector('.hint')
109
126
  const compose = root.querySelector('.compose')
110
127
  const textarea = root.querySelector('textarea')
128
+ const menu = root.querySelector('#sessions')
111
129
 
112
130
  let open = null // the ask id of the form the user can still answer
131
+ let current = null // the chat shown, {id, title}
132
+ let sessions = [] // the saved chats, the one changed last first
133
+
134
+ const NEW = ''
135
+ // + New chat, then the chats; the current one is new until it has a file
136
+ function showMenu() {
137
+ const option = (value, text) => {
138
+ const o = document.createElement('option')
139
+ o.value = value
140
+ o.textContent = text
141
+ return o
142
+ }
143
+ const options = [option(NEW, '+ New chat')]
144
+ if (current && !sessions.some((s) => s.id === current.id)) options.push(option(current.id, 'New chat'))
145
+ for (const s of sessions) options.push(option(s.id, s.title))
146
+ menu.replaceChildren(...options)
147
+ if (current) menu.value = current.id
148
+ }
149
+
150
+ menu.addEventListener('change', async () => {
151
+ const response = await post({ session: menu.value === NEW ? null : menu.value })
152
+ // the server sends the chat it shows now; otherwise back to the one before
153
+ if (!response?.ok) {
154
+ showMenu()
155
+ notSent()
156
+ } else if (menu.value === NEW) showMenu()
157
+ })
113
158
 
114
159
  function setOpen(isOpen) {
115
160
  panel.classList.toggle('open', isOpen)
@@ -122,20 +167,30 @@
122
167
  toggle.addEventListener('click', () => setOpen(!panel.classList.contains('open')))
123
168
  setOpen(sessionStorage.getItem(OPEN_KEY) === '1')
124
169
 
170
+ // the response, or null if edrys-dev could not be reached
125
171
  function post(body) {
126
172
  return fetch('/edrys-dev/chat', {
127
173
  method: 'POST',
128
174
  headers: { 'Content-Type': 'application/json' },
129
175
  body: JSON.stringify(body),
130
- })
176
+ }).catch(() => null)
177
+ }
178
+
179
+ // shown in the status line until the agent sets the next one
180
+ function notSent() {
181
+ statusLine.textContent = 'Not sent: is edrys-dev still running? Try again.'
131
182
  }
132
183
 
133
- compose.addEventListener('submit', (event) => {
184
+ compose.addEventListener('submit', async (event) => {
134
185
  event.preventDefault()
135
186
  const text = textarea.value.trim()
136
187
  if (!text) return
137
188
  textarea.value = ''
138
- post({ text })
189
+ const response = await post({ text })
190
+ if (response?.ok) return
191
+ // back into the box, unless the user typed something new meanwhile
192
+ if (!textarea.value) textarea.value = text
193
+ notSent()
139
194
  })
140
195
  textarea.addEventListener('keydown', (event) => {
141
196
  if (event.key === 'Enter' && !event.shiftKey) {
@@ -210,15 +265,26 @@
210
265
  values[input.id] = input.type === 'radio' ? checked[0] ?? '' : checked
211
266
  }
212
267
  const response = await post({ ask: entry.ask, values })
213
- if (!response.ok) disable(entry.ask)
268
+ // closed in the meantime; otherwise the form stays, to send again
269
+ if (response?.status === 409) disable(entry.ask)
270
+ else if (!response?.ok) notSent()
214
271
  })
215
272
 
216
273
  if (entry.ask !== open) form.setAttribute('aria-disabled', 'true')
217
274
  return form
218
275
  }
219
276
 
220
- function disable(ask) {
221
- root.querySelectorAll(`form[data-ask="${ask}"]`).forEach((form) => form.setAttribute('aria-disabled', 'true'))
277
+ function disable(ask, reason) {
278
+ root.querySelectorAll(`form[data-ask="${ask}"]`).forEach((form) => {
279
+ form.setAttribute('aria-disabled', 'true')
280
+ // why the agent closed it, shown only live
281
+ if (reason && !form.nextElementSibling?.classList.contains('reason')) {
282
+ const note = document.createElement('div')
283
+ note.className = 'reason'
284
+ note.textContent = reason
285
+ form.after(note)
286
+ }
287
+ })
222
288
  }
223
289
 
224
290
  function show(entry) {
@@ -246,8 +312,18 @@
246
312
  case 'history':
247
313
  log.replaceChildren()
248
314
  open = message.open
315
+ current = message.session
316
+ sessions = message.sessions
317
+ showMenu()
318
+ if (message.skipped) {
319
+ const note = document.createElement('div')
320
+ note.className = 'skipped'
321
+ note.textContent = `${message.skipped} earlier messages not shown`
322
+ log.appendChild(note)
323
+ }
249
324
  message.entries.forEach(show)
250
325
  statusLine.textContent = message.status
326
+ hintLine.textContent = message.hint || ''
251
327
  toggle.classList.remove('unread')
252
328
  break
253
329
  case 'entry':
@@ -258,9 +334,16 @@
258
334
  case 'status':
259
335
  statusLine.textContent = message.text
260
336
  break
337
+ case 'sessions':
338
+ sessions = message.sessions
339
+ showMenu()
340
+ break
341
+ case 'hint':
342
+ hintLine.textContent = message.text
343
+ break
261
344
  case 'close':
262
345
  if (open === message.ask) open = null
263
- disable(message.ask)
346
+ disable(message.ask, message.reason)
264
347
  break
265
348
  }
266
349
  }