cindel 1.0.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/README.md ADDED
@@ -0,0 +1,546 @@
1
+ ## ![Banner](cindel-banner.png)
2
+
3
+ > Hot module replacement server and client with file watching, static file serving, CORS proxy and WebSocket proxy support
4
+
5
+ [![npm version](https://img.shields.io/npm/v/cindel.svg)](https://www.npmjs.com/package/cindel)
6
+ [![license](https://img.shields.io/npm/l/cindel.svg)](LICENSE)
7
+ [![bundle size](https://img.shields.io/bundlephobia/minzip/cindel)](https://bundlephobia.com/package/cindel)
8
+
9
+ ---
10
+
11
+ ## Features
12
+
13
+ **HMR & File Watching**
14
+
15
+ - Instant push driven HMR over WebSocket on file change
16
+ - Atomic CSS hot swap (no flash of unstyled content), script execution, and ES module reload
17
+ - Glob pattern support for watch, ignore, and cold file configuration
18
+ - Cold file patterns that can trigger a full page reload instead of HMR
19
+ - Override detection to map replacement files onto their originals
20
+
21
+ **Server**
22
+
23
+ - HTTP CORS proxy with configurable header injection
24
+ - WebSocket proxy with header forwarding and message interception
25
+ - Static file server and automatic `index.html` loader injection
26
+ - TLS/HTTPS + WSS support
27
+ - `/files` endpoint exposing the live watched file list as JSON
28
+
29
+ **Client**
30
+
31
+ - Exponential backoff with automatic reconnect
32
+ - No runtime dependencies, so it works in any modern browser
33
+ - Event system with `on`, `once`, and `off` for connect, disconnect, reload, add, remove, etc.
34
+ - IIFE build compatible with userscript managers (Tampermonkey, Greasemonkey) via `@require`
35
+
36
+ ---
37
+
38
+ ## Requirements
39
+
40
+ | Runtime | Version |
41
+ | ------- | -------- |
42
+ | Bun | >= 1.0.0 |
43
+
44
+ The server uses Bun's native `Bun.serve`, `Bun.file` and `Bun.Glob` APIs and is not compatible with Node.js. The browser client has no runtime dependencies and works in any modern browser.
45
+
46
+ > Note that only the changed file itself is re-executed on reload, changes do not propagate up the ES module import chain. TypeScript is not directly supported for the same reason.
47
+
48
+ ---
49
+
50
+ ## Installation
51
+
52
+ ```bash
53
+ bun add cindel
54
+ ```
55
+
56
+ ---
57
+
58
+ ## Quick Start
59
+
60
+ ```js
61
+ // server.js
62
+ import { HMRServer } from "cindel/server";
63
+
64
+ const server = new HMRServer({
65
+ port: 1338,
66
+ watch: ["src"],
67
+ });
68
+
69
+ await server.start();
70
+ ```
71
+
72
+ ```js
73
+ // browser - requires a bundler
74
+ import { HMRClient } from "cindel/client";
75
+
76
+ const client = new HMRClient({ port: 1338 });
77
+ await client.connect();
78
+ ```
79
+
80
+ Or load it directly from a CDN with no bundler:
81
+
82
+ ```html
83
+ <script src="https://cdn.jsdelivr.net/npm/cindel"></script>
84
+ <script>
85
+ const client = new HMR.HMRClient({ port: 1338 });
86
+ client.connect();
87
+ </script>
88
+ ```
89
+
90
+ Another way with dynamic importing:
91
+
92
+ ```js
93
+ (async () => {
94
+ const { HMRClient } =
95
+ await import("https://cdn.jsdelivr.net/npm/cindel/dist/client.js");
96
+ const client = new HMRClient({ port: 1338 });
97
+ await client.connect();
98
+ })();
99
+ ```
100
+
101
+ You can even load it through a user script on any domain:
102
+
103
+ ```js
104
+ // ==UserScript==
105
+ // @name Cindel loader
106
+ // @version 1.0
107
+ // @description Instead of making multiple scripts file you just inject them all locally
108
+ // @match https://example.com/*
109
+ // @require https://cdn.jsdelivr.net/npm/cindel
110
+ // @grant none
111
+ // ==/UserScript==
112
+
113
+ (async () => {
114
+ const client = new HMR.HMRClient({
115
+ port: 1338,
116
+ secure: true,
117
+ });
118
+
119
+ await client.connect();
120
+ })();
121
+ ```
122
+
123
+ ---
124
+
125
+ ## Server
126
+
127
+ ### `new HMRServer(options)`
128
+
129
+ | Option | Type | Default | Description |
130
+ | ---------------- | --------------------------------------------- | -------------------- | ----------------------------------------------------------------------------------------------- |
131
+ | `port` | `number` | `1338` | Port to listen on |
132
+ | `bindHost` | `string` | `'localhost'` | Network interface to bind to. Use `'0.0.0.0'` to expose the server on your local network |
133
+ | `watchFiles` | `boolean` | `true` | Disable chokidar and do a one-time file scan at startup instead |
134
+ | `wsPath` | `string` | `'/hmr'` | WebSocket upgrade path |
135
+ | `watch` | `string[]` | `['src']` | Paths or glob patterns to watch |
136
+ | `ignore` | `string[]` | `[]` | Glob patterns to ignore |
137
+ | `cold` | `string[]` | `[]` | Patterns for files that trigger a full page reload |
138
+ | `extensions` | `string[]` | `.js .cjs .mjs .css` | File extensions to watch |
139
+ | `static` | `string \| false` | `'.'` | Directory to serve static files from. Pass `false` to disable static serving |
140
+ | `indexPath` | `string` | `'index.html'` | Path to `index.html` |
141
+ | `injectLoader` | `string` | | Script path injected into `index.html` before `</head>` |
142
+ | `corsProxy` | `boolean \| string\| CORSProxyConfig` | | Enable the HTTP CORS proxy |
143
+ | `wsProxy` | `WSProxyConfig` | | Proxy WebSocket connections to an upstream server |
144
+ | `filesEndpoint` | `boolean \| string` | `'/files'` | Expose the watched file list as JSON. `true` mounts at `/files` |
145
+ | `configEndpoint` | `boolean \| string` | `'/config'` | Expose the server config as JSON. `false` to disable |
146
+ | `getFiles` | `() => string[]` | | Override the file list sent to connecting clients |
147
+ | `onConnect` | `(client, data) => void` | | Called when an HMR client connects |
148
+ | `onDisconnect` | `(client) => void` | | Called when an HMR client disconnects |
149
+ | `logFiles` | `boolean` | `false` | Log every watched file during startup |
150
+ | `logProxy` | `boolean \| { cors?: boolean, ws?: boolean }` | `false` | Log proxy traffic |
151
+ | `tls` | `TLSConfig` | | Enable HTTPS / WSS |
152
+ | `handleSignals` | `boolean \| string[]` | `true` | Register signal handlers for clean shutdown. false to opt out, or pass an array of signal names |
153
+
154
+ ### Methods
155
+
156
+ ```ts
157
+ server.start(): Promise<void>
158
+ server.stop(): Promise<void>
159
+ server.send(client: WebSocket, payload: Object): boolean
160
+ server.broadcast(action: string, file: string, extra?: Object): void
161
+ server.getConfig(): Object
162
+ ```
163
+
164
+ ---
165
+
166
+ ### CORS Proxy
167
+
168
+ Enabling `corsProxy` mounts an HTTP proxy on the dev server. The browser hits a local URL and the server forwards the request upstream, injecting CORS headers onto the response. This means no browser extensions, no separate proxy process.
169
+
170
+ ```js
171
+ corsProxy: {
172
+ path: '/proxy', // default, can also be a RegExp
173
+
174
+ // Customize outbound headers per request
175
+ getHeaders: (targetUrl, incomingRequest) => ({
176
+ 'Authorization': `Bearer ${getToken()}`,
177
+ 'User-Agent': 'Mozilla/5.0',
178
+ 'X-Forwarded-For': incomingRequest.headers.get('x-real-ip'),
179
+ }),
180
+
181
+ // Intercept and rewrite the upstream response before it reaches the browser
182
+ transformResponse: async (response) => {
183
+ const json = await response.json();
184
+ return new Response(JSON.stringify(patch(json)), response);
185
+ },
186
+ }
187
+ ```
188
+
189
+ ```js
190
+ // Usage from the browser
191
+ const res = await fetch(
192
+ "http://localhost:1338/proxy/https://api.example.com/data",
193
+ );
194
+ ```
195
+
196
+ ---
197
+
198
+ ### WebSocket Proxy
199
+
200
+ `wsProxy` tunnels WebSocket connections from the browser through the dev server to an upstream host. Useful for connecting to game servers, remote APIs, or any WS service that would otherwise be blocked by CORS or mixed-content rules.
201
+
202
+ ```js
203
+ wsProxy: {
204
+ path: '/proxy',
205
+
206
+ // Static headers sent on every upstream connection
207
+ headers: {
208
+ Origin: 'https://www.example.com',
209
+ 'User-Agent': 'Mozilla/5.0',
210
+ },
211
+
212
+ // Forward select client headers upstream (or pass `true` to forward all)
213
+ forwardHeaders: ['cookie', 'authorization'],
214
+
215
+ // Dynamic headers per connection
216
+ getHeaders: (targetUrl, clientHeaders) => ({
217
+ 'X-Session': resolveSession(clientHeaders['cookie']),
218
+ }),
219
+
220
+ // Intercept messages in either direction
221
+ onClientMessage: (message, clientSocket, upstreamSocket) => {
222
+ const data = JSON.parse(message);
223
+ if (data.type === 'PING') return; // drop client pings
224
+ upstreamSocket.send(message);
225
+ },
226
+ onUpstreamMessage: (message, clientSocket, upstreamSocket) => {
227
+ clientSocket.send(transform(message));
228
+ },
229
+
230
+ onConnect: (targetUrl) => console.log('Proxy connected to', targetUrl),
231
+
232
+ // Extra options forwarded to the upstream WebSocket constructor
233
+ options: { perMessageDeflate: true },
234
+ }
235
+ ```
236
+
237
+ ```js
238
+ // Usage from the browser -- the full upstream URL goes after the path prefix
239
+ const ws = new WebSocket(
240
+ "ws://localhost:1338/proxy/wss://game.example.com:9081/",
241
+ );
242
+ ```
243
+
244
+ ---
245
+
246
+ ### Static Server and Loader Injection
247
+
248
+ Setting `static` serves a directory over HTTP. Setting `injectLoader` inserts a `<script>` tag for the given file into `index.html` at request time, so you never have to edit the HTML manually.
249
+
250
+ ```js
251
+ new HMRServer({
252
+ port: 1338,
253
+ watch: ["src"],
254
+ static: ".",
255
+ indexPath: "index.html",
256
+ injectLoader: "src/loader.mjs", // automatically injected before </head>
257
+ });
258
+ ```
259
+
260
+ `.mjs` loader files are injected with `type="module"`. All static responses include `Cache-Control: no-cache` headers so the browser never serves stale files during development.
261
+
262
+ ---
263
+
264
+ ### TLS
265
+
266
+ Pass `tls` to switch the server to HTTPS and WSS. The client's `secure` option or a `wss://` URL flips the client to match.
267
+
268
+ ```js
269
+ new HMRServer({
270
+ port: 1338,
271
+ watch: ["src"],
272
+ tls: {
273
+ key: "localhost-key.pem",
274
+ cert: "localhost.pem",
275
+ ca: "ca.pem", // optional, for mutual TLS
276
+ passphrase: "secret", // optional, for encrypted keys
277
+ },
278
+ });
279
+ ```
280
+
281
+ ```js
282
+ new HMRClient({ port: 1338, secure: true });
283
+ ```
284
+
285
+ ---
286
+
287
+ ### Local Network Sharing
288
+
289
+ Set `bindHost: '0.0.0.0'` to expose the server on all network interfaces. Any device on the same network can then connect using your machine's local IP with no extra configuration needed. The injected loader URL is derived automatically from the `Host` header of each incoming request, so local devices get `localhost` and remote devices get whatever address they used to reach the server.
290
+
291
+ ```js
292
+ new HMRServer({
293
+ port: 1338,
294
+ bindHost: "0.0.0.0",
295
+ watch: ["core"],
296
+ injectLoader: "loader.mjs",
297
+ tls: {
298
+ key: "localhost-key.pem",
299
+ cert: "localhost.pem",
300
+ },
301
+ });
302
+ ```
303
+
304
+ This also works with domains, if you're running on a VPS with a domain pointed at it, devices anywhere can connect to it.
305
+
306
+ Here is how you can find your local IP that other clients would need to connect to your hmr server:
307
+
308
+ **Mac:**
309
+
310
+ ```bash
311
+ ipconfig getifaddr $(route get default | grep interface | awk '{print $2}')
312
+ ```
313
+
314
+ **Linux:**
315
+
316
+ ```bash
317
+ ip route get 1 | awk '{print $7; exit}'
318
+ ```
319
+
320
+ **Windows:**
321
+
322
+ ```
323
+ ipconfig | findstr /i "IPv4"
324
+ ```
325
+
326
+ > **Firewall rules**: only needed if your OS blocks incoming connections on your chosen port. Replace `1338` with your actual port.
327
+ >
328
+ > **Windows** (run as admin):
329
+ >
330
+ > ```
331
+ > netsh advfirewall firewall add rule name="Cindel HMR" dir=in action=allow protocol=TCP localport=1338
332
+ > ```
333
+ >
334
+ > **Linux with ufw:**
335
+ >
336
+ > ```bash
337
+ > sudo ufw allow 1338/tcp
338
+ > ```
339
+ >
340
+ > **Linux with firewalld:**
341
+ >
342
+ > ```bash
343
+ > sudo firewall-cmd --add-port=1338/tcp --permanent && sudo firewall-cmd --reload
344
+ > ```
345
+ >
346
+ > Mac does not require a firewall rule, it works out of the box.
347
+
348
+ ---
349
+
350
+ ### Signal Handling
351
+
352
+ By default cindel registers `SIGINT` and `SIGTERM` handlers so Ctrl+C and process
353
+ managers like Docker, PM2, and systemd all shut down cleanly without leaving the
354
+ chokidar watcher or Bun server hanging.
355
+
356
+ ```js
357
+ // Default: SIGINT + SIGTERM
358
+ new HMRServer({
359
+ port: 1338,
360
+ watch: ["src"],
361
+ });
362
+
363
+ // Add SIGHUP for terminal-close and Nodemon compat
364
+ new HMRServer({
365
+ port: 1338,
366
+ watch: ["src"],
367
+ handleSignals: ["SIGINT", "SIGTERM", "SIGHUP"],
368
+ });
369
+
370
+ // Opt out entirely and manage shutdown yourself
371
+ const server = new HMRServer({
372
+ port: 1338,
373
+ watch: ["src"],
374
+ handleSignals: false,
375
+ });
376
+ process.on("SIGINT", () => server.stop().then(() => process.exit(0)));
377
+ ```
378
+
379
+ ---
380
+
381
+ ## Client
382
+
383
+ ### `new HMRClient(options)`
384
+
385
+ `options` can be shorthand:
386
+
387
+ - **`number`** treated as `{ port: n }`, connects to `ws://localhost:<n>`
388
+ - **`string`** treated as a full WebSocket URL
389
+ - **`object`** full config, see below
390
+
391
+ | Option | Type | Default | Description |
392
+ | ------------------- | ------------------------------------ | ------------------------- | ------------------------------------------------------------------------------------------------ |
393
+ | `port` | `number` | | Port number |
394
+ | `host` | `string` | `'localhost'` | Hostname |
395
+ | `secure` | `boolean` | `false` | Use `wss://` and `https://` |
396
+ | `wsUrl` | `string` | | Explicit WebSocket URL, overrides host/port |
397
+ | `httpUrl` | `string` | | Explicit HTTP base URL for file fetching |
398
+ | `wsPath` | `string` | `'/hmr'` | WebSocket path |
399
+ | `autoReconnect` | `boolean` | `true` | Reconnect on disconnect with exponential backoff |
400
+ | `reconnectDelay` | `number` | `2000` | Base reconnect delay in ms |
401
+ | `maxReconnectDelay` | `number` | `30000` | Maximum reconnect delay cap in ms |
402
+ | `skip` | `string[]` | | Glob patterns for files to never load |
403
+ | `filterSkip` | `(file, allFiles) => boolean` | | Custom skip logic, OR'd with `skip` |
404
+ | `cold` | `string[]` | | Glob patterns that trigger a full page reload. Merged with the server's `cold` config on connect |
405
+ | `filterCold` | `(file) => boolean` | | Custom cold logic, OR'd with `cold` |
406
+ | `getOverrideTarget` | `(file, allFiles) => string \| null` | | Map an override file to the original it replaces |
407
+ | `onFileLoaded` | `(file) => void` | | Called after each file is loaded or reloaded |
408
+ | `sortFiles` | `(files) => string[]` | CSS before JS, cold first | Custom sort for the initial load order |
409
+
410
+ ### Methods
411
+
412
+ ```ts
413
+ client.connect(): Promise<void>
414
+ client.disconnect(): void
415
+ client.on(event, handler): HMRClient // chainable
416
+ client.once(event, handler): HMRClient // chainable
417
+ client.off(event, handler?): HMRClient // chainable
418
+ ```
419
+
420
+ ---
421
+
422
+ ### Events
423
+
424
+ Events fire throughout the connection lifecycle and for every file action. All event methods are chainable.
425
+
426
+ ```js
427
+ client
428
+ .on("connect", () => {
429
+ console.log("HMR connected");
430
+ })
431
+ .on("disconnect", () => {
432
+ showBanner("Dev server offline, reconnecting...");
433
+ })
434
+ .on("init", ({ files, config }) => {
435
+ console.log(`Loaded ${files.length} files`);
436
+ console.log("Server cold patterns:", config.cold);
437
+ })
438
+ .on("reload", ({ file }) => {
439
+ console.log(`Hot-reloaded: ${file}`);
440
+ applyChanges(file);
441
+ })
442
+ .on("add", ({ file }) => {
443
+ console.log(`New file available: ${file}`);
444
+ })
445
+ .on("remove", ({ file }) => {
446
+ console.log(`File removed: ${file}`);
447
+ cleanupForFile(file);
448
+ })
449
+ .on("cold", (file) => {
450
+ console.log(`Cold file changed: ${file} -> forcing hard reload`);
451
+ window.location.reload();
452
+ })
453
+ .on("error", (err) => {
454
+ console.error("HMR error:", err);
455
+ });
456
+ ```
457
+
458
+ | Event | Payload | Description |
459
+ | ------------ | ------------------- | -------------------------------------- |
460
+ | `connect` | | WebSocket connection established |
461
+ | `disconnect` | | WebSocket disconnected |
462
+ | `init` | `{ files, config }` | Server sent the initial file list |
463
+ | `reload` | `{ file }` | A file was changed and hot-reloaded |
464
+ | `add` | `{ file }` | A new file was detected |
465
+ | `remove` | `{ file }` | A file was removed |
466
+ | `cold` | `file: string` | A cold file changed |
467
+ | `error` | `Error` | A connection or message error occurred |
468
+
469
+ ---
470
+
471
+ ### Skip and Cold Filters
472
+
473
+ `skip` prevents files from ever being loaded by the client. `cold` marks files that need a full page reload rather than a hot swap. Both options accept glob patterns, a custom filter function, or both combined via OR logic.
474
+
475
+ > **Note:** Glob patterns are always relative to the project root, not the watched directory.
476
+
477
+ ```js
478
+ new HMRClient({
479
+ port: 1338,
480
+
481
+ // Never load files matching these patterns
482
+ skip: ["**/*.test.js", "_*/**"],
483
+
484
+ // Custom skip logic is context aware, it receives the full file list
485
+ filterSkip: (file, allFiles) => {
486
+ return allFiles.includes(file.replace(".override.js", ".js"));
487
+ },
488
+
489
+ // These files can't be hot-swapped, they need a full reload
490
+ cold: ["**/*.cold.js", "src/bootstrap.js"],
491
+
492
+ // Custom cold logic
493
+ filterCold: (file) => file.includes("/vendor/"),
494
+ });
495
+ ```
496
+
497
+ ---
498
+
499
+ ### Override Detection
500
+
501
+ Override detection lets you maintain a parallel directory of replacement files that shadow originals without modifying them. When an override changes, the client unloads the original before loading the override.
502
+
503
+ ```js
504
+ new HMRClient({
505
+ port: 1338,
506
+
507
+ // x_mypatch/overrides/core/game.js shadows core/game.js
508
+ getOverrideTarget: (file, allFiles) => {
509
+ const match = file.match(/^x_[^/]+\/overrides\/(.+)$/);
510
+ if (!match) return null;
511
+ const original = match[1];
512
+ return allFiles.includes(original) ? original : null;
513
+ },
514
+ });
515
+
516
+ new HMRClient({
517
+ port: 1338,
518
+
519
+ // any file named `override.<original>` shadows the original
520
+ // e.g. override.utils.js -> utils.js
521
+ getOverrideTarget: (file, allFiles) => {
522
+ const name = file.split("/").pop();
523
+ const match = name.match(/^override\.(.+)$/);
524
+ if (!match) return null;
525
+
526
+ const target = file.replace(name, match[1]);
527
+ return allFiles?.includes(target) ? target : null;
528
+ },
529
+ });
530
+ ```
531
+
532
+ ---
533
+
534
+ ## Exports
535
+
536
+ | Import path | Environment | Description |
537
+ | ------------------------------------- | ----------- | -------------------- |
538
+ | `cindel` or `cindel/server` | Node / Bun | `HMRServer` |
539
+ | `cindel/client` | Browser ESM | `HMRClient` |
540
+ | `https://cdn.jsdelivr.net/npm/cindel` | Browser CDN | Exposes `window.HMR` |
541
+
542
+ ---
543
+
544
+ ## License
545
+
546
+ GPL-3.0-or-later (c) [sneazy-ibo](https://github.com/sneazy-ibo)
@@ -0,0 +1,24 @@
1
+ /** Handles loading and hot reloading of JavaScript and CSS files via blob URLs. */
2
+ export class FileLoader {
3
+ constructor(httpUrl: any);
4
+ httpUrl: any;
5
+ /**
6
+ * Debounce state per file. Stores { timeout, resolvers[] } so that
7
+ * when a rapid second change clears the first timeout, the first
8
+ * caller's Promise still resolves with the final load result.
9
+ * @type {Map<string, { timeout: number, resolvers: Function[] }>}
10
+ */
11
+ loadQueue: Map<string, {
12
+ timeout: number;
13
+ resolvers: Function[];
14
+ }>;
15
+ loadFile(path: any): Promise<any>;
16
+ loadCSS(path: any): Promise<any>;
17
+ loadModule(path: any): Promise<any>;
18
+ loadScript(path: any): Promise<any>;
19
+ reloadFile(path: any): Promise<any>;
20
+ _flushReload(path: any): Promise<void>;
21
+ removeFile(path: any): Promise<void>;
22
+ makeUrl(path: any): string;
23
+ }
24
+ //# sourceMappingURL=file-loader.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"file-loader.d.ts","sourceRoot":"","sources":["../../src/client/file-loader.js"],"names":[],"mappings":"AAAA,mFAAmF;AACnF;IACE,0BASC;IARC,aAAsB;IACtB;;;;;OAKG;IACH,WAFU,GAAG,CAAC,MAAM,EAAE;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,UAAU,CAAA;KAAE,CAAC,CAEvC;IAG5B,kCAOC;IAKD,iCAoBC;IAKD,oCAgBC;IAED,oCAeC;IAKD,oCAeC;IAED,uCAUC;IAED,qCAcC;IAGD,2BAGC;CACF"}