@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.
- package/LICENSE +27 -0
- package/README.md +84 -2
- package/dist/chat.js +136 -0
- package/dist/index.js +86 -0
- package/dist/mcp.js +587 -0
- package/dist/modules.js +130 -0
- package/dist/origin.js +5 -0
- package/dist/server.js +260 -0
- package/dist/sync.js +15 -0
- package/dist/watch.js +44 -0
- package/package.json +58 -4
- package/views/panel.js +277 -0
package/dist/modules.js
ADDED
|
@@ -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
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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|