flet-box 1.0.5 β†’ 1.0.7

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 (94) hide show
  1. package/README.md +28 -1
  2. package/bin/cli.js +85 -18
  3. package/bin/commands/auditFix.js +682 -0
  4. package/bin/commands/checkApi.js +7 -5
  5. package/bin/commands/create.js +40 -10
  6. package/bin/commands/createBundle.js +54 -1
  7. package/bin/commands/images.js +349 -0
  8. package/bin/commands/pruneAssets.js +170 -0
  9. package/bin/commands/release.js +34 -1
  10. package/bin/commands/runCloudflare.js +47 -9
  11. package/bin/commands/runServer.js +81 -24
  12. package/bin/commands/toPng.js +346 -0
  13. package/bin/commands/unpack.js +1173 -0
  14. package/bin/ota/loader.js +90 -0
  15. package/bin/utils/appSources.js +101 -0
  16. package/bin/utils/bundleConfig.js +166 -0
  17. package/bin/utils/dtsProps.js +147 -0
  18. package/bin/utils/imageTools.js +271 -0
  19. package/bin/utils/images/bmp.js +99 -0
  20. package/bin/utils/images/gif.js +244 -0
  21. package/bin/utils/images/index.js +163 -0
  22. package/bin/utils/images/jpeg.js +643 -0
  23. package/bin/utils/images/png.js +486 -0
  24. package/bin/utils/images/unsupported.js +29 -0
  25. package/bin/utils/neutralise.js +109 -6
  26. package/bin/utils/templates.js +307 -64
  27. package/docs/cli/README.md +82 -0
  28. package/docs/guides/online-updates.md +57 -9
  29. package/docs/widget/Accordion.md +1 -0
  30. package/docs/widget/AdMob.md +1 -0
  31. package/docs/widget/AdSense.md +1 -0
  32. package/docs/widget/AnimatedBox.md +1 -0
  33. package/docs/widget/AnimatedText.md +1 -0
  34. package/docs/widget/Audio.md +2 -1
  35. package/docs/widget/Avatar.md +12 -3
  36. package/docs/widget/Badge.md +1 -0
  37. package/docs/widget/Button.md +1 -0
  38. package/docs/widget/Card.md +1 -0
  39. package/docs/widget/Carousel.md +1 -0
  40. package/docs/widget/Chart.md +1 -0
  41. package/docs/widget/Checkbox.md +1 -0
  42. package/docs/widget/Chip.md +1 -0
  43. package/docs/widget/CircularBar.md +1 -0
  44. package/docs/widget/CircularChart.md +1 -0
  45. package/docs/widget/CodeViewer.md +1 -0
  46. package/docs/widget/Column.md +1 -0
  47. package/docs/widget/Container.md +1 -0
  48. package/docs/widget/DataTable.md +1 -0
  49. package/docs/widget/Divider.md +1 -0
  50. package/docs/widget/DraggBox.md +3 -2
  51. package/docs/widget/Dropdown.md +3 -0
  52. package/docs/widget/DroppBox.md +2 -1
  53. package/docs/widget/FloatingActionButton.md +1 -0
  54. package/docs/widget/GridView.md +1 -0
  55. package/docs/widget/Icon.md +1 -0
  56. package/docs/widget/Image.md +11 -3
  57. package/docs/widget/Input.md +1 -0
  58. package/docs/widget/InstallButton.md +1 -0
  59. package/docs/widget/ListTile.md +1 -0
  60. package/docs/widget/ListView.md +1 -0
  61. package/docs/widget/Maps.md +188 -0
  62. package/docs/widget/Markdown.md +1 -0
  63. package/docs/widget/MatrixRain.md +1 -0
  64. package/docs/widget/Pagination.md +1 -0
  65. package/docs/widget/ParallaxBox.md +1 -0
  66. package/docs/widget/ProgressBar.md +1 -0
  67. package/docs/widget/QRCode.md +1 -0
  68. package/docs/widget/README.md +12 -10
  69. package/docs/widget/Radio.md +1 -0
  70. package/docs/widget/Rating.md +1 -0
  71. package/docs/widget/Row.md +1 -0
  72. package/docs/widget/Skeleton.md +1 -0
  73. package/docs/widget/Slider.md +1 -0
  74. package/docs/widget/Stack.md +1 -0
  75. package/docs/widget/Stepper.md +1 -0
  76. package/docs/widget/Switch.md +1 -0
  77. package/docs/widget/Text.md +1 -0
  78. package/docs/widget/Tooltip.md +11 -13
  79. package/docs/widget/TreeView.md +1 -0
  80. package/docs/widget/Video.md +3 -2
  81. package/package.json +2 -2
  82. package/src/core/hmr-client.js +129 -34
  83. package/src/index.d.ts +132 -0
  84. package/src/index.js +1 -0
  85. package/src/navigations/AppBar.js +15 -4
  86. package/src/navigations/Drawer.js +9 -1
  87. package/src/widget-factory/translateProps.js +32 -1
  88. package/src/widgets/Avatar.js +228 -131
  89. package/src/widgets/Container.js +13 -0
  90. package/src/widgets/Dropdown.js +33 -1
  91. package/src/widgets/Image.js +102 -3
  92. package/src/widgets/Maps.js +1495 -0
  93. package/src/widgets/Tooltip.js +12 -2
  94. package/src/widgets/index.js +1 -0
@@ -0,0 +1,170 @@
1
+ /**
2
+ * @file bin/commands/pruneAssets.js
3
+ * @description `flet-box prune-assets` β€” deletes the images a generated app never
4
+ * names.
5
+ *
6
+ * This exists because of what `flet-box create` ships: a demo asset library
7
+ * (dishes, drinks, gallery photos) that no screen of a fresh app imports, which
8
+ * weighs the repository down and β€” worse β€” gets copied into any release whose
9
+ * asset glob is broad enough to catch it. The failure is silent: the site works,
10
+ * the bundle is 1.5 GB, and the visitor on a bad connection never sees the
11
+ * difference because those files were never requested.
12
+ *
13
+ * A file is kept when its path or its exact filename appears in the app's own
14
+ * sources. `www/` is deliberately not a source: it is generated output, so a name
15
+ * that only appears there is a stale bundle, not a consumer.
16
+ *
17
+ * Deletion is the one thing this command will not do unasked: it reports, and
18
+ * `--apply` deletes. Everything it removes is a tracked file in a generated
19
+ * project, so `git checkout -- assets/` puts it back.
20
+ */
21
+
22
+ import fs from "node:fs";
23
+ import path from "node:path";
24
+ import { c, section, dot } from "../utils/colors.js";
25
+ import { readAppSources, references, protectedMaster } from "../utils/appSources.js";
26
+
27
+ const USAGE = `Usage: flet-box prune-assets [dir] [options]
28
+
29
+ dir Asset folder to clean (default: ./assets)
30
+
31
+ Options:
32
+ --apply Actually delete. Without it this is a report only.
33
+ --root <dir> Project root the sources are read from (default: .)
34
+ --keep <name> Extra filename to protect, repeatable
35
+ --quiet Only the summary line
36
+ --help This page`;
37
+
38
+ const IMAGE_FILE = /\.(png|jpe?g|webp|gif|svg|ico|avif)$/i;
39
+ const SKIPPED_DIRS = new Set(["node_modules", ".git", "dist", "build", ".cache", "www", "vendor", "ios", "android"]);
40
+
41
+ const size = (bytes) =>
42
+ bytes >= 1024 * 1024
43
+ ? `${(bytes / 1024 / 1024).toFixed(1)} MB`
44
+ : bytes >= 1024
45
+ ? `${Math.round(bytes / 1024)} KB`
46
+ : `${bytes} B`;
47
+
48
+ /**
49
+ * @param {string} dir
50
+ * @returns {string[]} Image files under it, absolute, sorted.
51
+ */
52
+ const collectImages = (dir) => {
53
+ const found = [];
54
+ const walk = (current) => {
55
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
56
+ const full = path.join(current, entry.name);
57
+ if (entry.isDirectory()) {
58
+ if (!SKIPPED_DIRS.has(entry.name)) walk(full);
59
+ continue;
60
+ }
61
+ if (entry.isFile() && IMAGE_FILE.test(entry.name)) found.push(full);
62
+ }
63
+ };
64
+ if (fs.existsSync(dir)) walk(dir);
65
+ return found.sort();
66
+ };
67
+
68
+ /**
69
+ * Runs the report, and the deletion if asked.
70
+ *
71
+ * @param {string[]} args - CLI arguments after the command name.
72
+ * @returns {Promise<{exitCode: number}>}
73
+ */
74
+ export const pruneAssets = async (args = []) => {
75
+ const has = (flag) => args.includes(flag);
76
+ const valueOf = (flag) => {
77
+ const i = args.indexOf(flag);
78
+ return i >= 0 ? args[i + 1] : undefined;
79
+ };
80
+
81
+ if (has("--help") || has("-h")) {
82
+ console.log(USAGE);
83
+ return { exitCode: 0 };
84
+ }
85
+
86
+ const apply = has("--apply");
87
+ const quiet = has("--quiet");
88
+ const root = path.resolve(valueOf("--root") || ".");
89
+ const handKeep = new Set(args.filter((_, i) => args[i - 1] === "--keep"));
90
+
91
+ // Resolved against --root, not against the shell's cwd: a caller that names a
92
+ // project root means that project's assets, and the two arguments disagreeing
93
+ // is how a cleanup run ends up pointing at someone else's folder.
94
+ const positional = args.filter((a, i) => !a.startsWith("--") && !["--root", "--keep"].includes(args[i - 1]));
95
+ const dir = path.resolve(root, positional[0] || "assets");
96
+ if (!dir.startsWith(root)) {
97
+ console.log(c("red", `Refusing: ${dir} is outside the project root ${root}.`));
98
+ return { exitCode: 1 };
99
+ }
100
+ if (!fs.existsSync(dir)) {
101
+ console.log(c("red", `No such folder: ${path.relative(root, dir) || dir}`));
102
+ return { exitCode: 1 };
103
+ }
104
+
105
+ const text = readAppSources(root);
106
+ const images = collectImages(dir);
107
+ const dead = [];
108
+ const alive = [];
109
+ const protectedByHand = [];
110
+ for (const file of images) {
111
+ const rel = path.relative(root, file).split(path.sep).join("/");
112
+ const master = protectedMaster(rel);
113
+ if (master) {
114
+ alive.push(file);
115
+ protectedByHand.push({ rel, why: master.why });
116
+ continue;
117
+ }
118
+ if (handKeep.has(rel) || handKeep.has(path.basename(file))) {
119
+ alive.push(file);
120
+ protectedByHand.push({ rel, why: "kept by hand" });
121
+ continue;
122
+ }
123
+ (references(text, rel) ? alive : dead).push(file);
124
+ }
125
+
126
+ // Measured before anything is deleted: the report that runs after `--apply`
127
+ // would otherwise stat files that no longer exist and crash on its own summary.
128
+ const sizes = new Map(images.map((f) => [f, fs.statSync(f).size]));
129
+ const bytesOf = (list) => list.reduce((s, f) => s + sizes.get(f), 0);
130
+
131
+ if (!quiet) {
132
+ console.log(section("🧹", `prune-assets β€” ${path.relative(root, dir) || "."}`));
133
+ if (!dead.length) console.log(` ${dot("#34d399")} every one of the ${images.length} images here is referenced.`);
134
+ for (const file of dead.slice(0, 40)) {
135
+ console.log(` ${dot("#ef4444")} ${path.relative(root, file)} β€” ${c("dim", size(sizes.get(file)))}`);
136
+ }
137
+ if (dead.length > 40) console.log(c("gray", ` … ${dead.length - 40} more`));
138
+ for (const { rel, why } of protectedByHand) {
139
+ console.log(` ${dot("#34d399")} ${rel} β€” kept: ${why}`);
140
+ }
141
+ }
142
+
143
+ const summary = `${images.length} images Β· ${alive.length} referenced (${size(bytesOf(alive))}) Β· ${dead.length} unreferenced (${size(bytesOf(dead))})`;
144
+ if (!dead.length) {
145
+ console.log(`\n ${summary}\n`);
146
+ return { exitCode: 0 };
147
+ }
148
+ if (!apply) {
149
+ console.log(`\n ${summary}`);
150
+ console.log(c("yellow", " dry run β€” nothing deleted. Add --apply to remove them.\n"));
151
+ return { exitCode: 0 };
152
+ }
153
+
154
+ const emptied = new Set();
155
+ for (const file of dead) {
156
+ fs.rmSync(file, { force: true });
157
+ emptied.add(path.dirname(file));
158
+ }
159
+ // A directory the purge emptied is dead weight in the repo and in every future
160
+ // glob, so it goes with its files.
161
+ for (const d of [...emptied].sort().reverse()) {
162
+ if (d !== dir && fs.existsSync(d) && fs.readdirSync(d).length === 0) fs.rmSync(d, { recursive: true, force: true });
163
+ }
164
+ console.log(`\n ${summary}`);
165
+ console.log(c("green", ` deleted ${dead.length} files (${size(bytesOf(dead))}).`));
166
+ console.log(c("gray", " Undo while the delete is uncommitted: git checkout -- assets/\n"));
167
+ return { exitCode: 0 };
168
+ };
169
+
170
+ export default pruneAssets;
@@ -27,6 +27,7 @@ import fs from "fs";
27
27
  import path from "path";
28
28
  import { c, banner, gradient } from "../utils/colors.js";
29
29
  import { createBundle } from "./createBundle.js";
30
+ import { resolveUpdateUrl } from "../utils/bundleConfig.js";
30
31
  import { INITIAL_VERSION, incrementVersion, isValidVersion } from "../utils/version.js";
31
32
 
32
33
  /**
@@ -183,7 +184,39 @@ export const release = async (argv = []) => {
183
184
  c("gray", ` ⬆️ Publish it: ${gradient(`git push && git push origin ${tag}`, "#22d3ee", "#38bdf8")}`),
184
185
  );
185
186
  }
187
+
188
+ // The live URL is the first thing people look for after a release, and the
189
+ // Actions run page hides it (it appears under the environment chip, never in
190
+ // the log) β€” so print the exact address this bundle was built for, plus the
191
+ // two steps that decide whether it is serving yet.
192
+ const siteUrl = resolveUpdateUrl(projectRoot);
193
+ console.log(
194
+ c("gray", " 🌐 Web: ") +
195
+ (siteUrl
196
+ ? gradient(siteUrl, "#22d3ee", "#38bdf8")
197
+ : c("yellow", "unknown β€” no origin remote set yet")),
198
+ );
186
199
  console.log(
187
- c("dim", " ℹ️ CI deploys www/ to GitHub Pages on the tag; open apps self-update from config.json."),
200
+ c(
201
+ "dim",
202
+ ` Deploys: every push to main publishes www/ to GitHub Pages, so the\n` +
203
+ ` site is already live. Only the OTA feed is gated: the build\n` +
204
+ ` keeps www/config.json when its commit carries a v* tag, and\n` +
205
+ ` drops it otherwise, so an ordinary push never offers itself\n` +
206
+ ` to installed apps.\n` +
207
+ ` Release: a tag push cannot deploy β€” the github-pages environment\n` +
208
+ ` refuses runs that do not start from the default branch. So\n` +
209
+ ` after ${tag} exists, run the workflow once more by hand\n` +
210
+ ` (Actions β†’ Deploy β†’ Run workflow) to publish the feed.\n` +
211
+ ` Where: Actions β†’ the run β†’ Deploy to Pages β†’ the link under the\n` +
212
+ ` "github-pages" environment box. Not in the log.\n` +
213
+ ` Setup: Settings β†’ Pages β†’ Source = "GitHub Actions", or even a\n` +
214
+ ` green build leaves the old site standing.`,
215
+ ),
188
216
  );
217
+ if (push) {
218
+ console.log(
219
+ c("gray", ` ⏳ CI is publishing ${tag} now β€” open the run to follow it.`),
220
+ );
221
+ }
189
222
  };
@@ -1,21 +1,34 @@
1
1
  // bin/commands/runCloudflare.js
2
2
  import { spawn, spawnSync } from "child_process";
3
+ import fs from "fs";
4
+ import path from "path";
3
5
  import { c, banner, divider } from "../utils/colors.js";
4
6
  import { spinner } from "../utils/spinner.js";
7
+ import { createBundle } from "./createBundle.js";
5
8
  import { runDevServer } from "./runServer.js";
6
9
 
7
10
  const TUNNEL_URL_RE = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
8
11
 
9
12
  /**
10
- * Starts the local dev server and exposes it through a free Cloudflare quick
13
+ * Builds the production bundle and exposes it through a free Cloudflare quick
11
14
  * tunnel (no account required), printing the public HTTPS link to the console.
12
15
  *
13
- * @param {{ port?: number, logRequests?: boolean }} [options]
16
+ * The bundle is the thing on the tunnel, never the source tree: `index.html`
17
+ * maps the bare `flet-box` specifier to `./node_modules/...`, so serving `src/`
18
+ * publicly only works once `npm install` has run on that machine, and a fresh
19
+ * clone, a phone on 4G or a deep link showed a module-resolution error instead
20
+ * of the app. `createBundle` inlines the framework, so what the visitor gets is
21
+ * exactly what would be deployed.
22
+ *
23
+ * @param {{ target?: string, port?: number, logRequests?: boolean }} [options]
14
24
  * @returns {Promise<void>}
15
25
  */
16
- export const runCloudflareDemo = async (options = {}) => {
17
- const { port = 8000, logRequests = false } = options;
18
-
26
+ export const runCloudflareDemo = async ({
27
+ target = "www",
28
+ port = 8000,
29
+ logRequests = false,
30
+ } = {}) => {
31
+ // Check the tunnel binary before spending a build on a command that cannot run.
19
32
  const probe = spawnSync("cloudflared", ["--version"], { encoding: "utf-8" });
20
33
  if (probe.error) {
21
34
  console.error(c("red", "❌ cloudflared is not installed"));
@@ -27,11 +40,36 @@ export const runCloudflareDemo = async (options = {}) => {
27
40
  process.exit(1);
28
41
  }
29
42
 
30
- console.log(banner("☁️ FletBox Cloudflare Demo", "public HTTPS tunnel"));
43
+ console.log(banner("☁️ FletBox Cloudflare Demo", `bundle ${target}/ β†’ public HTTPS tunnel`));
44
+
45
+ await createBundle(target);
46
+
47
+ // The bundle's `update.url` points at the production OTA source (the GitHub
48
+ // Pages site). A tunnel is not that origin, so every visitor's first action was
49
+ // a cross-origin fetch that the browser logged as a CORS error. Same-origin is
50
+ // both quieter and honest: the demo checks itself and reports "up to date".
51
+ try {
52
+ const configPath = path.join(process.cwd(), target, "config.json");
53
+ const cfg = JSON.parse(fs.readFileSync(configPath, "utf-8"));
54
+ if (cfg?.update) {
55
+ cfg.update.url = "/";
56
+ fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2) + "\n");
57
+ }
58
+ } catch (e) {
59
+ console.warn(c("yellow", `⚠️ Could not retarget the OTA url: ${e.message}`));
60
+ }
61
+
62
+ console.log(
63
+ `\n ${c("gray", `Bundle built Β· serving ${target}/ (SPA, hot reload OFF)`)}\n`,
64
+ );
65
+
66
+ // Serve from inside the bundle so every relative path the visitor requests
67
+ // resolves against the build output.
68
+ process.chdir(target);
31
69
 
32
70
  await runDevServer({
33
- spaMode: false,
34
- hotReload: true,
71
+ spaMode: true,
72
+ hotReload: false,
35
73
  port,
36
74
  askPort: false,
37
75
  logRequests,
@@ -53,7 +91,7 @@ export const runCloudflareDemo = async (options = {}) => {
53
91
  console.log(c("brightCyan", " 🌐 Public link (open this):"));
54
92
  console.log(` ${c("bold", c("brightGreen", url))}`);
55
93
  console.log("");
56
- console.log(c("gray", ` Local: http://localhost:${port}`));
94
+ console.log(c("gray", ` Local: http://localhost:${port} Β· ${target}/`));
57
95
  console.log(
58
96
  c("gray", " The tunnel stays alive while this process runs. Ctrl+C to stop."),
59
97
  );
@@ -3,7 +3,7 @@ import http from "http";
3
3
  import fs from "fs";
4
4
  import path from "path";
5
5
  import readline from "readline";
6
- import { WebSocketServer } from "ws";
6
+ import { WebSocketServer, WebSocket } from "ws";
7
7
  import chokidar from "chokidar";
8
8
  import { c, banner, rainbow, dot, typewriter } from "../utils/colors.js";
9
9
  import { spinner } from "../utils/spinner.js";
@@ -59,7 +59,12 @@ export const runDevServer = async (options = {}) => {
59
59
  let watcher = null;
60
60
  let PORT = Number(port) || 8000;
61
61
 
62
- console.log(banner("πŸ“‘ FletBox Server", spaMode ? "SPA + Hot Reload" : "Static"));
62
+ console.log(
63
+ banner(
64
+ "πŸ“‘ FletBox Server",
65
+ `${spaMode ? "SPA" : "Static"}${hotReload ? " + Hot Reload" : ""}`,
66
+ ),
67
+ );
63
68
 
64
69
  // Interactive port: if no --port was passed and there is a TTY, ask
65
70
  if (options.askPort && process.stdin.isTTY) {
@@ -80,6 +85,8 @@ export const runDevServer = async (options = {}) => {
80
85
  ".json": "application/json",
81
86
  ".png": "image/png",
82
87
  ".jpg": "image/jpeg",
88
+ ".jpeg": "image/jpeg",
89
+ ".webp": "image/webp",
83
90
  ".gif": "image/gif",
84
91
  ".svg": "image/svg+xml",
85
92
  ".ico": "image/x-icon",
@@ -89,8 +96,11 @@ export const runDevServer = async (options = {}) => {
89
96
 
90
97
  const broadcast = (message) => {
91
98
  const msg = typeof message === "string" ? message : JSON.stringify(message);
99
+ // `readyState` is a `WebSocket` constant, not a `WebSocketServer` one:
100
+ // comparing against `WebSocketServer.OPEN` (undefined) matched no client and
101
+ // silently disabled hot reload for everybody.
92
102
  clients.forEach((client) => {
93
- if (client.readyState === WebSocketServer.OPEN) {
103
+ if (client.readyState === WebSocket.OPEN) {
94
104
  client.send(msg);
95
105
  }
96
106
  });
@@ -153,9 +163,11 @@ export const runDevServer = async (options = {}) => {
153
163
 
154
164
  fs.access(filePath, fs.constants.F_OK, (err) => {
155
165
  // SPA fallback: if the file doesn't exist and has no extension, serve index.html
166
+ let isFallback = false;
156
167
  if (err && spaMode && (!ext || ext === "")) {
157
168
  filePath = path.join(projectRoot, "index.html");
158
169
  ext = ".html";
170
+ isFallback = true;
159
171
  }
160
172
 
161
173
  fs.readFile(filePath, (err2, content) => {
@@ -170,6 +182,18 @@ export const runDevServer = async (options = {}) => {
170
182
 
171
183
  const contentType = mimeTypes[ext] || "text/plain";
172
184
 
185
+ // A rewritten deep link is served from `index.html`, but the browser
186
+ // still resolves `./loader.js` against `/demo/forms/`, so the page came
187
+ // up blank on every shared link. Anchoring the page at the served root
188
+ // keeps the app's relative URLs working whatever the route is.
189
+ if (isFallback && ext === ".html") {
190
+ let html = content.toString("utf-8");
191
+ if (!/<base [^>]*href=/i.test(html)) {
192
+ html = html.replace(/<head(\s[^>]*)?>/i, (m) => `${m}\n<base href="/">`);
193
+ }
194
+ content = html;
195
+ }
196
+
173
197
  if (logRequests) {
174
198
  const sizeKB = (content.length / 1024).toFixed(1);
175
199
  console.log(
@@ -195,29 +219,40 @@ export const runDevServer = async (options = {}) => {
195
219
  c("blue", "πŸ”§ Framework mode: using direct HMR import"),
196
220
  );
197
221
  } else {
198
- // βœ… Normal project: import from the npm module
222
+ // A static import, not `import('flet-box').then(...)`: the client
223
+ // restores the state snapshot and that must run before the app's
224
+ // own `useState` call. A `.then()` is a microtask later, so the
225
+ // app would already have booted with defaults.
199
226
  hmrScript = `
200
227
  <script type="module">
201
- import('flet-box').then(({ initHMR }) => {
202
- if (typeof initHMR === 'function') {
203
- initHMR();
204
- }
205
- }).catch(e => console.warn('HMR not available:', e));
228
+ import { initHMR } from 'flet-box';
229
+ initHMR();
206
230
  </script>
207
231
  `;
208
232
  }
209
233
 
210
234
  if (!html.includes("initHMR")) {
211
- if (html.includes("</body>")) {
235
+ // Inserted before the app's module tag, so module execution order
236
+ // puts the restore first; `</head>` and `</body>` are the fallbacks
237
+ // for a page that has no module script yet.
238
+ const anchor = /<script[^>]*type=["']module["'][^>]*>/i.exec(html);
239
+ if (anchor)
240
+ html = html.slice(0, anchor.index) + hmrScript + html.slice(anchor.index);
241
+ else if (html.includes("</head>"))
242
+ html = html.replace("</head>", `${hmrScript}</head>`);
243
+ else if (html.includes("</body>"))
212
244
  html = html.replace("</body>", `${hmrScript}</body>`);
213
- } else {
214
- html += hmrScript;
215
- }
245
+ else html += hmrScript;
216
246
  content = html;
217
247
  }
218
248
  }
219
249
 
220
- res.writeHead(200, { "Content-Type": contentType });
250
+ // A dev server that lets the browser cache a module is a hot reload
251
+ // that "does nothing": the page reloads and re-gets the old code.
252
+ res.writeHead(200, {
253
+ "Content-Type": contentType,
254
+ "Cache-Control": "no-store, no-cache, must-revalidate",
255
+ });
221
256
  res.end(content);
222
257
  });
223
258
  });
@@ -296,11 +331,21 @@ export const runDevServer = async (options = {}) => {
296
331
  // WebSocket para HMR
297
332
  if (hotReload) {
298
333
  const wss = new WebSocketServer({ server });
299
- wss.on("error", onServerError);
334
+
335
+ // A browser tab that vanishes mid-flight (a tunnel closing an idle socket is
336
+ // enough) makes `ws` emit "error". With no listener that is an uncaught
337
+ // exception and the dev server dies with the tab β€” the usual reason hot
338
+ // reload "crashes" during a live demo.
339
+ wss.on("error", (err) => {
340
+ console.warn(c("yellow", `⚠️ HMR socket error: ${err && err.message}`));
341
+ });
300
342
 
301
343
  wss.on("connection", (ws) => {
302
344
  clients.add(ws);
303
- console.log(c("green", "πŸ”Œ HMR client connected"));
345
+ if (logRequests) console.log(c("green", "πŸ”Œ HMR client connected"));
346
+ ws.on("error", () => {
347
+ // The peer is already gone; `close` follows and drops it from the set.
348
+ });
304
349
  ws.send(JSON.stringify({ type: "request-modules" }));
305
350
  ws.on("message", (data) => {
306
351
  try {
@@ -317,16 +362,16 @@ export const runDevServer = async (options = {}) => {
317
362
  });
318
363
  ws.on("close", () => {
319
364
  clients.delete(ws);
320
- console.log(c("gray", "πŸ”Œ HMR client disconnected"));
365
+ if (logRequests) console.log(c("gray", "πŸ”Œ HMR client disconnected"));
321
366
  });
322
367
  });
323
368
 
324
- const watchPaths = [
325
- "src/**/*.js",
326
- "src/**/*.mjs",
327
- "src/**/*.css",
328
- "index.html",
329
- ];
369
+ // chokidar 4 removed glob support: `src/**/*.js` is watched as a literal
370
+ // path that never exists, so the watcher silently reported "Watching…" and
371
+ // never fired once. Watch the real roots and filter by extension instead.
372
+ const watchPaths = ["src", "index.html"];
373
+ const isWatched = (filePath) =>
374
+ /\.(js|mjs|css)$/.test(filePath) || filePath === "index.html";
330
375
  const moduleVersions = new Map();
331
376
 
332
377
  watcher = chokidar.watch(watchPaths, {
@@ -334,22 +379,32 @@ export const runDevServer = async (options = {}) => {
334
379
  persistent: true,
335
380
  cwd: projectRoot,
336
381
  ignoreInitial: true,
382
+ // An editor saves in two or three writes (write, rename, chmod). Firing on
383
+ // each one reloads the page several times per Ctrl+S.
384
+ awaitWriteFinish: { stabilityThreshold: 150, pollInterval: 25 },
337
385
  });
338
386
 
387
+ const lastSent = new Map();
388
+
339
389
  watcher.on("change", async (filePath) => {
390
+ if (!isWatched(filePath)) return;
340
391
  const fullPath = path.join(projectRoot, filePath);
341
392
  try {
342
393
  const newContent = fs.readFileSync(fullPath, "utf-8");
343
394
  const oldContent = moduleVersions.get(filePath);
344
395
  const changeType = getChangeType(filePath, oldContent, newContent);
345
396
  moduleVersions.set(filePath, newContent);
397
+ const key = `${filePath}:${changeType}`;
398
+ const now = Date.now();
399
+ if (now - (lastSent.get(key) || 0) < 700) return;
400
+ lastSent.set(key, now);
346
401
  console.log(c("yellow", `πŸ“ Changed: ${filePath} (${changeType})`));
347
402
  broadcast(
348
403
  JSON.stringify({
349
404
  type: "hmr",
350
405
  file: filePath,
351
406
  changeType,
352
- timestamp: Date.now(),
407
+ timestamp: now,
353
408
  }),
354
409
  );
355
410
  } catch (err) {
@@ -358,11 +413,13 @@ export const runDevServer = async (options = {}) => {
358
413
  });
359
414
 
360
415
  watcher.on("add", (filePath) => {
416
+ if (!isWatched(filePath)) return;
361
417
  console.log(c("green", `βž• Added: ${filePath}`));
362
418
  broadcast(JSON.stringify({ type: "reload", reason: "file-added" }));
363
419
  });
364
420
 
365
421
  watcher.on("unlink", (filePath) => {
422
+ if (!isWatched(filePath)) return;
366
423
  console.log(c("red", `βž– Removed: ${filePath}`));
367
424
  broadcast(JSON.stringify({ type: "reload", reason: "file-removed" }));
368
425
  });