mez-easyjs 0.1.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.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +40 -0
  3. package/bin/create-easy-app.js +60 -0
  4. package/bin/easy.js +7 -0
  5. package/compiler.d.ts +37 -0
  6. package/package.json +74 -0
  7. package/runtime.d.ts +103 -0
  8. package/src/commands/build.js +284 -0
  9. package/src/commands/create.js +476 -0
  10. package/src/commands/dev.js +434 -0
  11. package/src/commands/generate.js +104 -0
  12. package/src/commands/ws.js +157 -0
  13. package/src/compiler/codegen.js +1334 -0
  14. package/src/compiler/css.js +117 -0
  15. package/src/compiler/errors.js +65 -0
  16. package/src/compiler/expr.js +142 -0
  17. package/src/compiler/index.js +105 -0
  18. package/src/compiler/parse.js +115 -0
  19. package/src/compiler/strip-ts.js +39 -0
  20. package/src/compiler/template.js +419 -0
  21. package/src/index.js +128 -0
  22. package/src/runtime/app.js +115 -0
  23. package/src/runtime/component.js +222 -0
  24. package/src/runtime/css.js +35 -0
  25. package/src/runtime/errors.js +121 -0
  26. package/src/runtime/escape.js +62 -0
  27. package/src/runtime/events.js +151 -0
  28. package/src/runtime/hmr.js +157 -0
  29. package/src/runtime/index.js +40 -0
  30. package/src/runtime/overlay.js +95 -0
  31. package/src/runtime/reactive.js +172 -0
  32. package/src/runtime/router.js +423 -0
  33. package/src/runtime/scheduler.js +39 -0
  34. package/src/runtime/store.js +42 -0
  35. package/src/transform.js +106 -0
  36. package/templates/minimal/README.md +21 -0
  37. package/templates/minimal/easy.config.js +7 -0
  38. package/templates/minimal/index.html +13 -0
  39. package/templates/minimal/package.json +14 -0
  40. package/templates/minimal/src/App.easy +24 -0
  41. package/templates/minimal/src/main.js +4 -0
  42. package/templates/minimal/src/styles.css +14 -0
  43. package/templates/starter/README.md +18 -0
  44. package/templates/starter/easy.config.js +7 -0
  45. package/templates/starter/index.html +13 -0
  46. package/templates/starter/package.json +14 -0
  47. package/templates/starter/src/App.easy +69 -0
  48. package/templates/starter/src/components/Counter.easy +76 -0
  49. package/templates/starter/src/components/Greeting.easy +24 -0
  50. package/templates/starter/src/main.js +11 -0
  51. package/templates/starter/src/pages/About.easy +20 -0
  52. package/templates/starter/src/pages/Home.easy +69 -0
  53. package/templates/starter/src/styles.css +14 -0
  54. package/templates/tailwind/README.md +23 -0
  55. package/templates/tailwind/easy.config.js +7 -0
  56. package/templates/tailwind/index.html +19 -0
  57. package/templates/tailwind/package.json +14 -0
  58. package/templates/tailwind/src/App.easy +53 -0
  59. package/templates/tailwind/src/main.js +4 -0
  60. package/templates/tailwind/src/styles.css +4 -0
@@ -0,0 +1,434 @@
1
+ import http from 'node:http';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { exec } from 'node:child_process';
6
+ import { createHash } from 'node:crypto';
7
+ import { WebSocketServer } from './ws.js';
8
+ import { loadConfig, transformFile, walkFiles } from '../transform.js';
9
+ import { compile, CompileError, parseSFC } from '../compiler/index.js';
10
+
11
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
+ const RUNTIME_DIR = path.resolve(__dirname, '../runtime');
13
+
14
+ /** path -> { stylesHash, codeHash, scopeId } */
15
+ const compileCache = new Map();
16
+
17
+ export async function runDev(flags = {}) {
18
+ const root = path.resolve(flags.cwd || process.cwd());
19
+ const config = await loadConfig(root);
20
+ const port = Number(flags.port || config.port || 5173);
21
+ const runtimeUrl = '/easyjs/runtime/index.js';
22
+
23
+ const clients = new Set();
24
+
25
+ const server = http.createServer(async (req, res) => {
26
+ try {
27
+ await handleRequest(req, res, { root, config, runtimeUrl });
28
+ } catch (err) {
29
+ console.error(err);
30
+ const body = err instanceof CompileError || err?.name === 'CompileError'
31
+ ? String(err.message)
32
+ : String(err.message || err);
33
+ res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
34
+ res.end(body);
35
+ }
36
+ });
37
+
38
+ const wss = new WebSocketServer(server);
39
+
40
+ wss.onConnection((ws) => {
41
+ clients.add(ws);
42
+ ws.send(JSON.stringify({ type: 'connected', dev: true }));
43
+ ws.onClose(() => clients.delete(ws));
44
+ // Bidirectional: client may ack or request reload
45
+ // (text frames handled lightly in ws.js — extend if needed)
46
+ });
47
+
48
+ server.listen(port, () => {
49
+ console.log(`
50
+ Easy.js dev server (HMR + overlay)
51
+
52
+ ➜ Local: http://localhost:${port}/
53
+ ➜ Root: ${root}
54
+ `);
55
+ if (flags.open) openBrowser(`http://localhost:${port}/`);
56
+ });
57
+
58
+ const srcDir = path.join(root, config.srcDir || 'src');
59
+ watchTree(srcDir, async (file) => {
60
+ const rel = '/' + path.relative(root, file).split(path.sep).join('/');
61
+ const ext = path.extname(file);
62
+ const t0 = Date.now();
63
+
64
+ try {
65
+ if (ext === '.easy' || ext === '.ejs') {
66
+ const source = fs.readFileSync(file, 'utf8');
67
+ const sfc = parseSFC(source, path.basename(file));
68
+ const logicHash = hash(
69
+ (sfc.template?.content || '') + '\0' + (sfc.script?.content || '')
70
+ );
71
+ const stylesSrc = (sfc.styles || []).map((s) => s.content).join('\n');
72
+ const stylesHash = hash(stylesSrc);
73
+
74
+ const result = compile(source, { filename: path.basename(file) });
75
+ const prev = compileCache.get(rel);
76
+ compileCache.set(rel, {
77
+ logicHash,
78
+ stylesHash,
79
+ scopeId: result.scopeId
80
+ });
81
+
82
+ const styleOnly =
83
+ prev && prev.logicHash === logicHash && prev.stylesHash !== stylesHash;
84
+
85
+ const ms = Date.now() - t0;
86
+ console.log(`[hmr] ${rel} recompiled in ${ms}ms${styleOnly ? ' (styles only)' : ''}`);
87
+
88
+ broadcast(clients, {
89
+ type: styleOnly ? 'style-update' : 'js-update',
90
+ path: rel,
91
+ scopeId: result.scopeId,
92
+ styles: result.styles,
93
+ timestamp: Date.now()
94
+ });
95
+ return;
96
+ }
97
+
98
+ if (ext === '.css') {
99
+ console.log(`[hmr] ${rel} css`);
100
+ broadcast(clients, {
101
+ type: 'css-update',
102
+ path: rel,
103
+ timestamp: Date.now()
104
+ });
105
+ return;
106
+ }
107
+
108
+ if (ext === '.js' || ext === '.mjs') {
109
+ console.log(`[hmr] ${rel}`);
110
+ broadcast(clients, {
111
+ type: 'js-update',
112
+ path: rel,
113
+ timestamp: Date.now()
114
+ });
115
+ return;
116
+ }
117
+
118
+ broadcast(clients, { type: 'full-reload', path: rel });
119
+ } catch (err) {
120
+ const message = err?.message || String(err);
121
+ console.error(message);
122
+ broadcast(clients, {
123
+ type: 'compile-error',
124
+ path: rel,
125
+ message,
126
+ filename: err.filename || path.basename(file),
127
+ line: err.line,
128
+ column: err.column,
129
+ frame: err.frame,
130
+ hint: err.hint,
131
+ timestamp: Date.now()
132
+ });
133
+ }
134
+ });
135
+
136
+ const indexHtml = path.join(root, 'index.html');
137
+ if (fs.existsSync(indexHtml)) {
138
+ fs.watch(indexHtml, () => {
139
+ broadcast(clients, { type: 'full-reload' });
140
+ });
141
+ }
142
+
143
+ return server;
144
+ }
145
+
146
+ function broadcast(clients, payload) {
147
+ const data = JSON.stringify(payload);
148
+ for (const c of clients) c.send(data);
149
+ }
150
+
151
+ function hash(s) {
152
+ return createHash('sha1').update(s).digest('hex').slice(0, 12);
153
+ }
154
+
155
+ async function handleRequest(req, res, ctx) {
156
+ const url = new URL(req.url || '/', `http://${req.headers.host}`);
157
+ let pathname = decodeURIComponent(url.pathname);
158
+
159
+ if (pathname === '/@easy/hmr.js') {
160
+ res.writeHead(200, { 'Content-Type': 'application/javascript', 'Cache-Control': 'no-store' });
161
+ res.end(hmrClientSource());
162
+ return;
163
+ }
164
+
165
+ if (pathname.startsWith('/easyjs/runtime/')) {
166
+ const rel = pathname.replace('/easyjs/runtime/', '');
167
+ const file = path.join(RUNTIME_DIR, rel);
168
+ if (!fs.existsSync(file)) {
169
+ res.writeHead(404);
170
+ res.end('Not found');
171
+ return;
172
+ }
173
+ let code = fs.readFileSync(file, 'utf8');
174
+ code = code.replace(
175
+ /from\s+['"]\.\/([^'"]+)['"]/g,
176
+ (_, p) => `from '/easyjs/runtime/${p}'`
177
+ );
178
+ res.writeHead(200, {
179
+ 'Content-Type': 'application/javascript',
180
+ 'Cache-Control': 'no-store'
181
+ });
182
+ res.end(code);
183
+ return;
184
+ }
185
+
186
+ if (pathname === '/') pathname = '/index.html';
187
+
188
+ const filePath = path.join(ctx.root, pathname.replace(/^\//, ''));
189
+ const publicPath = path.join(
190
+ ctx.root,
191
+ ctx.config.publicDir || 'public',
192
+ pathname.replace(/^\//, '')
193
+ );
194
+
195
+ let resolved = null;
196
+ if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) resolved = filePath;
197
+ else if (fs.existsSync(publicPath) && fs.statSync(publicPath).isFile()) resolved = publicPath;
198
+
199
+ if (!resolved) {
200
+ const index = path.join(ctx.root, 'index.html');
201
+ if (fs.existsSync(index) && !path.extname(pathname)) {
202
+ let html = fs.readFileSync(index, 'utf8');
203
+ html = injectDev(html);
204
+ res.writeHead(200, { 'Content-Type': 'text/html' });
205
+ res.end(html);
206
+ return;
207
+ }
208
+ res.writeHead(404);
209
+ res.end('Not found');
210
+ return;
211
+ }
212
+
213
+ const ext = path.extname(resolved);
214
+
215
+ if (ext === '.html') {
216
+ let html = fs.readFileSync(resolved, 'utf8');
217
+ html = injectDev(html);
218
+ res.writeHead(200, { 'Content-Type': 'text/html', 'Cache-Control': 'no-store' });
219
+ res.end(html);
220
+ return;
221
+ }
222
+
223
+ if (ext === '.easy' || ext === '.ejs' || ext === '.js' || ext === '.mjs') {
224
+ const source = fs.readFileSync(resolved, 'utf8');
225
+ try {
226
+ const { code } = transformFile(resolved, source, {
227
+ runtimeUrl: ctx.runtimeUrl
228
+ });
229
+ // Cache hashes on first request
230
+ if (ext === '.easy' || ext === '.ejs') {
231
+ const rel = '/' + path.relative(ctx.root, resolved).split(path.sep).join('/');
232
+ const compiled = compile(source, { filename: path.basename(resolved) });
233
+ const sfc = parseSFC(source, path.basename(resolved));
234
+ compileCache.set(rel, {
235
+ logicHash: hash(
236
+ (sfc.template?.content || '') + '\0' + (sfc.script?.content || '')
237
+ ),
238
+ stylesHash: hash((sfc.styles || []).map((s) => s.content).join('\n')),
239
+ scopeId: compiled.scopeId
240
+ });
241
+ }
242
+ res.writeHead(200, {
243
+ 'Content-Type': 'application/javascript',
244
+ 'Cache-Control': 'no-store'
245
+ });
246
+ res.end(code);
247
+ return;
248
+ } catch (err) {
249
+ console.error(err.message || err);
250
+ res.writeHead(500, { 'Content-Type': 'application/javascript' });
251
+ res.end(
252
+ `throw new Error(${JSON.stringify(err.message || String(err))});`
253
+ );
254
+ return;
255
+ }
256
+ }
257
+
258
+ if (ext === '.css') {
259
+ const css = fs.readFileSync(resolved, 'utf8');
260
+ const js = `
261
+ const id = ${JSON.stringify(pathname)};
262
+ let s = document.querySelector('style[data-easy-css="'+id+'"]');
263
+ if (!s) {
264
+ s = document.createElement('style');
265
+ s.setAttribute('data-easy-css', id);
266
+ document.head.appendChild(s);
267
+ }
268
+ s.textContent = ${JSON.stringify(css)};
269
+ export default ${JSON.stringify(css)};
270
+ `;
271
+ res.writeHead(200, { 'Content-Type': 'application/javascript' });
272
+ res.end(js);
273
+ return;
274
+ }
275
+
276
+ const data = fs.readFileSync(resolved);
277
+ res.writeHead(200, { 'Content-Type': mime(ext), 'Cache-Control': 'no-store' });
278
+ res.end(data);
279
+ }
280
+
281
+ function injectDev(html) {
282
+ const boot = `<script>window.__EASY_DEV__=true;</script>`;
283
+ const hmr = `<script type="module" src="/@easy/hmr.js"></script>`;
284
+ let out = html;
285
+ if (!out.includes('__EASY_DEV__')) {
286
+ out = out.includes('<head>')
287
+ ? out.replace('<head>', `<head>\n ${boot}`)
288
+ : boot + out;
289
+ }
290
+ if (!out.includes('/@easy/hmr.js')) {
291
+ out = out.includes('</body>') ? out.replace('</body>', `${hmr}\n</body>`) : out + hmr;
292
+ }
293
+ return out;
294
+ }
295
+
296
+ function hmrClientSource() {
297
+ return `
298
+ window.__EASY_DEV__ = true;
299
+
300
+ import {
301
+ enableHmr,
302
+ applyHmrUpdate,
303
+ replaceStyles,
304
+ installOverlay,
305
+ showOverlay
306
+ } from '/easyjs/runtime/index.js';
307
+
308
+ enableHmr();
309
+ installOverlay();
310
+
311
+ const wsProto = location.protocol === 'https:' ? 'wss' : 'ws';
312
+ let ws;
313
+
314
+ function connect() {
315
+ ws = new WebSocket(wsProto + '://' + location.host);
316
+ ws.addEventListener('open', () => {
317
+ console.log('[easy] HMR connected');
318
+ ws.send(JSON.stringify({ type: 'hello', href: location.href }));
319
+ });
320
+ ws.addEventListener('message', async (e) => {
321
+ let msg;
322
+ try { msg = JSON.parse(e.data); } catch { return; }
323
+
324
+ if (msg.type === 'full-reload') {
325
+ location.reload();
326
+ return;
327
+ }
328
+
329
+ if (msg.type === 'compile-error') {
330
+ showOverlay({
331
+ message: msg.message,
332
+ filename: msg.filename || msg.path,
333
+ hint: msg.hint,
334
+ frame: msg.frame,
335
+ component: 'compiler'
336
+ });
337
+ return;
338
+ }
339
+
340
+ if (msg.type === 'style-update') {
341
+ if (msg.scopeId && msg.styles != null) {
342
+ replaceStyles(msg.scopeId, msg.styles);
343
+ console.log('[easy hmr] swapped styles', msg.path);
344
+ ws.send(JSON.stringify({ type: 'ack', path: msg.path, kind: 'style' }));
345
+ }
346
+ return;
347
+ }
348
+
349
+ if (msg.type === 'css-update') {
350
+ try {
351
+ await import(msg.path + '?t=' + msg.timestamp);
352
+ console.log('[easy hmr] global css', msg.path);
353
+ ws.send(JSON.stringify({ type: 'ack', path: msg.path, kind: 'css' }));
354
+ } catch (err) {
355
+ console.error(err);
356
+ location.reload();
357
+ }
358
+ return;
359
+ }
360
+
361
+ if (msg.type === 'js-update' || msg.type === 'update') {
362
+ const url = msg.path + '?t=' + (msg.timestamp || Date.now());
363
+ try {
364
+ const mod = await import(url);
365
+ const result = await applyHmrUpdate(msg.path, mod, {
366
+ styleOnly: false
367
+ });
368
+ console.log('[easy hmr] module injected', msg.path, result);
369
+ ws.send(JSON.stringify({ type: 'ack', path: msg.path, kind: 'js', result }));
370
+ } catch (err) {
371
+ console.warn('[easy hmr] inject failed, full reload', err);
372
+ location.reload();
373
+ }
374
+ }
375
+ });
376
+ ws.addEventListener('close', () => setTimeout(connect, 1000));
377
+ }
378
+
379
+ connect();
380
+ `;
381
+ }
382
+
383
+ function watchTree(dir, onChange) {
384
+ if (!fs.existsSync(dir)) return;
385
+ const debounce = new Map();
386
+ try {
387
+ fs.watch(dir, { recursive: true }, (event, filename) => {
388
+ if (!filename) return;
389
+ const full = path.join(dir, filename);
390
+ clearTimeout(debounce.get(full));
391
+ debounce.set(
392
+ full,
393
+ setTimeout(() => {
394
+ try {
395
+ if (fs.existsSync(full) && fs.statSync(full).isFile()) onChange(full);
396
+ } catch {
397
+ /* ignore */
398
+ }
399
+ }, 60)
400
+ );
401
+ });
402
+ } catch {
403
+ for (const file of walkFiles(dir, ['.js', '.easy', '.css', '.html'])) {
404
+ fs.watch(file, () => onChange(file));
405
+ }
406
+ }
407
+ }
408
+
409
+ function mime(ext) {
410
+ return (
411
+ {
412
+ '.png': 'image/png',
413
+ '.jpg': 'image/jpeg',
414
+ '.jpeg': 'image/jpeg',
415
+ '.gif': 'image/gif',
416
+ '.svg': 'image/svg+xml',
417
+ '.ico': 'image/x-icon',
418
+ '.json': 'application/json',
419
+ '.txt': 'text/plain',
420
+ '.woff': 'font/woff',
421
+ '.woff2': 'font/woff2'
422
+ }[ext] || 'application/octet-stream'
423
+ );
424
+ }
425
+
426
+ function openBrowser(url) {
427
+ const cmd =
428
+ process.platform === 'win32'
429
+ ? `start "" "${url}"`
430
+ : process.platform === 'darwin'
431
+ ? `open "${url}"`
432
+ : `xdg-open "${url}"`;
433
+ exec(cmd);
434
+ }
@@ -0,0 +1,104 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { loadConfig } from '../transform.js';
4
+
5
+ export async function generate(type, name, flags = {}) {
6
+ if (!type || !name) {
7
+ throw new Error('Usage: easy generate <component|page> <Name>');
8
+ }
9
+
10
+ const safeName = String(name).trim();
11
+ if (!/^[A-Za-z][A-Za-z0-9_-]*$/.test(safeName)) {
12
+ throw new Error(
13
+ 'Name must start with a letter and contain only letters, digits, _ or - (no paths).'
14
+ );
15
+ }
16
+
17
+ const root = path.resolve(flags.cwd || process.cwd());
18
+ const config = await loadConfig(root);
19
+ const srcDir = path.join(root, config.srcDir || 'src');
20
+
21
+ const pascal = toPascal(safeName);
22
+ const kebab = toKebab(pascal);
23
+
24
+ if (type === 'component' || type === 'c') {
25
+ const dir = path.join(srcDir, 'components');
26
+ fs.mkdirSync(dir, { recursive: true });
27
+ const file = path.resolve(dir, `${pascal}.easy`);
28
+ assertInside(srcDir, file);
29
+ if (fs.existsSync(file)) throw new Error(`Already exists: ${file}`);
30
+ fs.writeFileSync(file, componentTemplate(pascal));
31
+ console.log(`✔ Created component ${path.relative(root, file)}`);
32
+ return;
33
+ }
34
+
35
+ if (type === 'page' || type === 'p') {
36
+ const dir = path.join(srcDir, 'pages');
37
+ fs.mkdirSync(dir, { recursive: true });
38
+ const file = path.resolve(dir, `${pascal}.easy`);
39
+ assertInside(srcDir, file);
40
+ if (fs.existsSync(file)) throw new Error(`Already exists: ${file}`);
41
+ fs.writeFileSync(file, pageTemplate(pascal, kebab));
42
+ console.log(`✔ Created page ${path.relative(root, file)}`);
43
+ console.log(` Tip: add a route for /${kebab} in your router config.`);
44
+ return;
45
+ }
46
+
47
+ throw new Error(`Unknown generate type: ${type} (use component|page)`);
48
+ }
49
+
50
+ function assertInside(root, file) {
51
+ const rel = path.relative(path.resolve(root), path.resolve(file));
52
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) {
53
+ throw new Error('Refusing to write outside the project src directory.');
54
+ }
55
+ }
56
+
57
+ function toPascal(s) {
58
+ return String(s)
59
+ .replace(/[-_\s]+(.)?/g, (_, c) => (c ? c.toUpperCase() : ''))
60
+ .replace(/^(.)/, (c) => c.toUpperCase());
61
+ }
62
+
63
+ function toKebab(s) {
64
+ return s
65
+ .replace(/([a-z])([A-Z])/g, '$1-$2')
66
+ .replace(/[\s_]+/g, '-')
67
+ .toLowerCase();
68
+ }
69
+
70
+ function componentTemplate(name) {
71
+ return `<script>
72
+ let label = '${name}';
73
+ </script>
74
+
75
+ <div class="${toKebab(name)}">
76
+ <p>{label}</p>
77
+ </div>
78
+
79
+ <style>
80
+ .${toKebab(name)} {
81
+ padding: 0.5rem;
82
+ }
83
+ </style>
84
+ `;
85
+ }
86
+
87
+ function pageTemplate(name, kebab) {
88
+ return `<script>
89
+ let title = '${name}';
90
+ let subtitle = 'Generated by easy generate page';
91
+ </script>
92
+
93
+ <section class="page page-${kebab}">
94
+ <h1>{title}</h1>
95
+ <p>{subtitle}</p>
96
+ </section>
97
+
98
+ <style>
99
+ .page-${kebab} {
100
+ padding: 1.5rem;
101
+ }
102
+ </style>
103
+ `;
104
+ }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Minimal WebSocket server on top of an HTTP server (no deps).
3
+ * Bidirectional text frames for HMR.
4
+ */
5
+ import crypto from 'node:crypto';
6
+
7
+ export class WebSocketServer {
8
+ constructor(server) {
9
+ this.clients = new Set();
10
+ this._onConnection = null;
11
+
12
+ server.on('upgrade', (req, socket, head) => {
13
+ const key = req.headers['sec-websocket-key'];
14
+ if (!key) {
15
+ socket.destroy();
16
+ return;
17
+ }
18
+ const accept = crypto
19
+ .createHash('sha1')
20
+ .update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11')
21
+ .digest('base64');
22
+
23
+ socket.write(
24
+ 'HTTP/1.1 101 Switching Protocols\r\n' +
25
+ 'Upgrade: websocket\r\n' +
26
+ 'Connection: Upgrade\r\n' +
27
+ `Sec-WebSocket-Accept: ${accept}\r\n` +
28
+ '\r\n'
29
+ );
30
+
31
+ const client = createClient(socket);
32
+ this.clients.add(client);
33
+ if (this._onConnection) this._onConnection(client);
34
+ });
35
+ }
36
+
37
+ onConnection(fn) {
38
+ this._onConnection = fn;
39
+ }
40
+ }
41
+
42
+ function createClient(socket) {
43
+ const closeHandlers = new Set();
44
+ const messageHandlers = new Set();
45
+ let closed = false;
46
+ let buffer = Buffer.alloc(0);
47
+
48
+ socket.on('data', (chunk) => {
49
+ buffer = Buffer.concat([buffer, chunk]);
50
+ while (true) {
51
+ const frame = tryParseFrame(buffer);
52
+ if (!frame) break;
53
+ buffer = buffer.slice(frame.bytesConsumed);
54
+ if (frame.opcode === 0x8) {
55
+ cleanup();
56
+ return;
57
+ }
58
+ if (frame.opcode === 0x9) {
59
+ // ping → pong
60
+ socket.write(encodeFrame(frame.payload, 0x0a));
61
+ continue;
62
+ }
63
+ if (frame.opcode === 0x1) {
64
+ const text = frame.payload.toString('utf8');
65
+ for (const fn of messageHandlers) {
66
+ try {
67
+ fn(text);
68
+ } catch (_) {
69
+ /* ignore */
70
+ }
71
+ }
72
+ try {
73
+ const msg = JSON.parse(text);
74
+ if (msg.type === 'ack') {
75
+ // quiet success
76
+ } else if (msg.type === 'hello') {
77
+ console.log('[hmr] client hello', msg.href || '');
78
+ }
79
+ } catch {
80
+ /* ignore non-json */
81
+ }
82
+ }
83
+ }
84
+ });
85
+
86
+ socket.on('close', cleanup);
87
+ socket.on('error', cleanup);
88
+
89
+ function cleanup() {
90
+ if (closed) return;
91
+ closed = true;
92
+ for (const fn of closeHandlers) fn();
93
+ }
94
+
95
+ return {
96
+ send(text) {
97
+ if (closed) return;
98
+ socket.write(encodeFrame(Buffer.from(String(text)), 0x81));
99
+ },
100
+ onClose(fn) {
101
+ closeHandlers.add(fn);
102
+ },
103
+ onMessage(fn) {
104
+ messageHandlers.add(fn);
105
+ }
106
+ };
107
+ }
108
+
109
+ function tryParseFrame(buf) {
110
+ if (buf.length < 2) return null;
111
+ const second = buf[1];
112
+ const masked = (second & 0x80) !== 0;
113
+ let len = second & 0x7f;
114
+ let offset = 2;
115
+ if (len === 126) {
116
+ if (buf.length < 4) return null;
117
+ len = buf.readUInt16BE(2);
118
+ offset = 4;
119
+ } else if (len === 127) {
120
+ if (buf.length < 10) return null;
121
+ len = Number(buf.readBigUInt64BE(2));
122
+ offset = 10;
123
+ }
124
+ const maskLen = masked ? 4 : 0;
125
+ if (buf.length < offset + maskLen + len) return null;
126
+ let payload = buf.slice(offset + maskLen, offset + maskLen + len);
127
+ if (masked) {
128
+ const mask = buf.slice(offset, offset + 4);
129
+ payload = Buffer.from(payload.map((b, i) => b ^ mask[i % 4]));
130
+ }
131
+ return {
132
+ opcode: buf[0] & 0x0f,
133
+ payload,
134
+ bytesConsumed: offset + maskLen + len
135
+ };
136
+ }
137
+
138
+ function encodeFrame(payload, firstByte) {
139
+ const len = payload.length;
140
+ let header;
141
+ if (len < 126) {
142
+ header = Buffer.alloc(2);
143
+ header[0] = firstByte;
144
+ header[1] = len;
145
+ } else if (len < 65536) {
146
+ header = Buffer.alloc(4);
147
+ header[0] = firstByte;
148
+ header[1] = 126;
149
+ header.writeUInt16BE(len, 2);
150
+ } else {
151
+ header = Buffer.alloc(10);
152
+ header[0] = firstByte;
153
+ header[1] = 127;
154
+ header.writeBigUInt64BE(BigInt(len), 2);
155
+ }
156
+ return Buffer.concat([header, payload]);
157
+ }