flet-box 1.0.6 β†’ 1.0.8

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 (162) hide show
  1. package/README.md +28 -1
  2. package/bin/cli.js +90 -11
  3. package/bin/commands/auditFix.js +682 -0
  4. package/bin/commands/checkApi.js +20 -6
  5. package/bin/commands/checkUi.js +1369 -0
  6. package/bin/commands/create.js +86 -12
  7. package/bin/commands/createBundle.js +75 -1
  8. package/bin/commands/images.js +349 -0
  9. package/bin/commands/pruneAssets.js +170 -0
  10. package/bin/commands/release.js +34 -1
  11. package/bin/commands/runCloudflare.js +47 -9
  12. package/bin/commands/runServer.js +91 -25
  13. package/bin/commands/toPng.js +346 -0
  14. package/bin/commands/unpack.js +1173 -0
  15. package/bin/ota/loader.js +90 -0
  16. package/bin/utils/appSources.js +101 -0
  17. package/bin/utils/bundleConfig.js +166 -0
  18. package/bin/utils/dtsProps.js +147 -0
  19. package/bin/utils/imageTools.js +271 -0
  20. package/bin/utils/images/bmp.js +99 -0
  21. package/bin/utils/images/gif.js +244 -0
  22. package/bin/utils/images/index.js +163 -0
  23. package/bin/utils/images/jpeg.js +643 -0
  24. package/bin/utils/images/png.js +486 -0
  25. package/bin/utils/images/unsupported.js +29 -0
  26. package/bin/utils/neutralise.js +109 -6
  27. package/bin/utils/pwaShell.js +137 -0
  28. package/bin/utils/templates.js +1653 -471
  29. package/docs/README.md +2 -0
  30. package/docs/cli/README.md +144 -1
  31. package/docs/guides/README.md +2 -0
  32. package/docs/guides/clean-ui.md +211 -0
  33. package/docs/guides/minification-and-protection.md +1 -1
  34. package/docs/guides/online-updates.md +105 -9
  35. package/docs/tools/README.md +1 -1
  36. package/docs/tools/animation.md +1 -1
  37. package/docs/tools/markdown.md +3 -3
  38. package/docs/tools/theme.md +41 -1
  39. package/docs/widget/Accordion.md +4 -0
  40. package/docs/widget/AdMob.md +5 -1
  41. package/docs/widget/AdSense.md +4 -0
  42. package/docs/widget/AnimatedBox.md +7 -3
  43. package/docs/widget/AnimatedText.md +4 -0
  44. package/docs/widget/Audio.md +5 -1
  45. package/docs/widget/Avatar.md +15 -3
  46. package/docs/widget/Badge.md +4 -0
  47. package/docs/widget/Button.md +4 -0
  48. package/docs/widget/COMMON_PROPS.md +52 -0
  49. package/docs/widget/Card.md +4 -0
  50. package/docs/widget/Carousel.md +4 -0
  51. package/docs/widget/Chart.md +4 -0
  52. package/docs/widget/Checkbox.md +4 -0
  53. package/docs/widget/Chip.md +4 -0
  54. package/docs/widget/CircularBar.md +4 -0
  55. package/docs/widget/CircularChart.md +4 -0
  56. package/docs/widget/CodeViewer.md +4 -0
  57. package/docs/widget/Column.md +4 -0
  58. package/docs/widget/Container.md +4 -0
  59. package/docs/widget/DataTable.md +4 -0
  60. package/docs/widget/Divider.md +4 -0
  61. package/docs/widget/DraggBox.md +6 -2
  62. package/docs/widget/Dropdown.md +6 -0
  63. package/docs/widget/DroppBox.md +5 -1
  64. package/docs/widget/FloatingActionButton.md +4 -0
  65. package/docs/widget/GridView.md +4 -0
  66. package/docs/widget/Icon.md +4 -0
  67. package/docs/widget/Image.md +14 -3
  68. package/docs/widget/Input.md +4 -0
  69. package/docs/widget/Inspector.md +2 -2
  70. package/docs/widget/InstallButton.md +4 -0
  71. package/docs/widget/ListTile.md +4 -0
  72. package/docs/widget/ListView.md +4 -0
  73. package/docs/widget/Maps.md +191 -0
  74. package/docs/widget/Markdown.md +4 -0
  75. package/docs/widget/MatrixRain.md +4 -0
  76. package/docs/widget/Pagination.md +4 -0
  77. package/docs/widget/ParallaxBox.md +4 -0
  78. package/docs/widget/ProgressBar.md +4 -0
  79. package/docs/widget/QRCode.md +4 -0
  80. package/docs/widget/README.md +12 -10
  81. package/docs/widget/Radio.md +5 -1
  82. package/docs/widget/Rating.md +4 -0
  83. package/docs/widget/Row.md +4 -0
  84. package/docs/widget/Skeleton.md +4 -0
  85. package/docs/widget/Slider.md +4 -0
  86. package/docs/widget/Stack.md +4 -0
  87. package/docs/widget/Stepper.md +4 -0
  88. package/docs/widget/Switch.md +4 -0
  89. package/docs/widget/Tabs.md +5 -5
  90. package/docs/widget/Text.md +4 -0
  91. package/docs/widget/Tooltip.md +14 -13
  92. package/docs/widget/TreeView.md +4 -0
  93. package/docs/widget/Video.md +6 -2
  94. package/package.json +2 -2
  95. package/src/animations/AnimatedBox.js +4 -1
  96. package/src/animations/MatrixRain.js +3 -3
  97. package/src/core/hmr-client.js +129 -34
  98. package/src/index.d.ts +195 -7
  99. package/src/index.js +2 -0
  100. package/src/navigations/AppBar.js +22 -16
  101. package/src/navigations/BottomNavigation.js +8 -13
  102. package/src/navigations/CollapsibleSideBar.js +30 -8
  103. package/src/navigations/Drawer.js +25 -15
  104. package/src/navigations/DrawerItem.js +3 -1
  105. package/src/navigations/Scaffold.js +26 -12
  106. package/src/navigations/Tabs.js +104 -59
  107. package/src/tools/color.js +54 -0
  108. package/src/tools/index.js +9 -1
  109. package/src/tools/scale.js +41 -0
  110. package/src/tools/shadow.js +20 -0
  111. package/src/utils/index.js +1 -0
  112. package/src/utils/readableInk.js +19 -0
  113. package/src/utils/syntaxHighlight.js +78 -10
  114. package/src/utils/themes.js +217 -54
  115. package/src/utils/units.js +33 -11
  116. package/src/widget-factory/effects.js +56 -5
  117. package/src/widget-factory/processProps.js +10 -1
  118. package/src/widget-factory/remTool.js +15 -29
  119. package/src/widget-factory/stackPosition.js +18 -3
  120. package/src/widget-factory/translateProps.js +60 -1
  121. package/src/widgets/Accordion.js +20 -23
  122. package/src/widgets/AdMob.js +7 -2
  123. package/src/widgets/Avatar.js +236 -133
  124. package/src/widgets/Badge.js +3 -2
  125. package/src/widgets/BottomSheet.js +14 -13
  126. package/src/widgets/Button.js +69 -30
  127. package/src/widgets/Card.js +6 -2
  128. package/src/widgets/Carousel.js +12 -10
  129. package/src/widgets/Chart.js +28 -5
  130. package/src/widgets/Checkbox.js +76 -18
  131. package/src/widgets/Chip.js +31 -10
  132. package/src/widgets/CircularBar.js +7 -7
  133. package/src/widgets/CircularChart.js +26 -8
  134. package/src/widgets/CodeViewer.js +93 -20
  135. package/src/widgets/Container.js +13 -0
  136. package/src/widgets/DataTable.js +17 -11
  137. package/src/widgets/DraggBox.js +2 -1
  138. package/src/widgets/Dropdown.js +59 -14
  139. package/src/widgets/DroppBox.js +4 -2
  140. package/src/widgets/FloatingActionButton.js +13 -7
  141. package/src/widgets/Icon.js +3 -1
  142. package/src/widgets/Image.js +102 -3
  143. package/src/widgets/Input.js +30 -23
  144. package/src/widgets/Inspector.js +54 -1
  145. package/src/widgets/InstallButton.js +6 -4
  146. package/src/widgets/ListTile.js +4 -3
  147. package/src/widgets/ListView.js +30 -12
  148. package/src/widgets/Maps.js +1495 -0
  149. package/src/widgets/Markdown.js +37 -26
  150. package/src/widgets/Modal.js +14 -11
  151. package/src/widgets/Pagination.js +16 -11
  152. package/src/widgets/ProgressBar.js +6 -4
  153. package/src/widgets/Radio.js +73 -15
  154. package/src/widgets/Rating.js +24 -6
  155. package/src/widgets/Skeleton.js +31 -31
  156. package/src/widgets/Slider.js +29 -18
  157. package/src/widgets/SnackBar.js +35 -39
  158. package/src/widgets/Stepper.js +13 -9
  159. package/src/widgets/Switch.js +59 -18
  160. package/src/widgets/Tooltip.js +24 -12
  161. package/src/widgets/TreeView.js +27 -15
  162. 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
  });
@@ -144,7 +154,11 @@ export const runDevServer = async (options = {}) => {
144
154
  url = req.url;
145
155
  }
146
156
 
147
- const safePath = path.normalize(url).replace(/^(\.\.[\/\\])+/, "");
157
+ // The query is part of the request, not of the file name: `?theme=dark`
158
+ // and the CSS hot-swap's `?t=` cache-buster were being resolved on disk
159
+ // and answered with a 404.
160
+ const pathname = url.split("?")[0].split("#")[0];
161
+ const safePath = path.normalize(pathname).replace(/^(\.\.[\/\\])+/, "");
148
162
  let filePath = path.join(
149
163
  projectRoot,
150
164
  safePath === "/" ? "index.html" : safePath,
@@ -153,9 +167,11 @@ export const runDevServer = async (options = {}) => {
153
167
 
154
168
  fs.access(filePath, fs.constants.F_OK, (err) => {
155
169
  // SPA fallback: if the file doesn't exist and has no extension, serve index.html
170
+ let isFallback = false;
156
171
  if (err && spaMode && (!ext || ext === "")) {
157
172
  filePath = path.join(projectRoot, "index.html");
158
173
  ext = ".html";
174
+ isFallback = true;
159
175
  }
160
176
 
161
177
  fs.readFile(filePath, (err2, content) => {
@@ -170,6 +186,23 @@ export const runDevServer = async (options = {}) => {
170
186
 
171
187
  const contentType = mimeTypes[ext] || "text/plain";
172
188
 
189
+ // A rewritten deep link is served from `index.html`, but the browser
190
+ // still resolves `./loader.js` against `/demo/forms/`, so the page came
191
+ // up blank on every shared link. Anchoring the page at the served root
192
+ // keeps the app's relative URLs working whatever the route is.
193
+ //
194
+ // The root document needs the same anchor: a client router pushes
195
+ // `/widget/Button` without a reload, and from then on the browser
196
+ // re-resolves a relative `<link rel="icon">` against that path. The
197
+ // favicon then 404s on every in-app navigation.
198
+ if (spaMode && ext === ".html" && (isFallback || safePath === "/")) {
199
+ let html = content.toString("utf-8");
200
+ if (!/<base [^>]*href=/i.test(html)) {
201
+ html = html.replace(/<head(\s[^>]*)?>/i, (m) => `${m}\n<base href="/">`);
202
+ }
203
+ content = html;
204
+ }
205
+
173
206
  if (logRequests) {
174
207
  const sizeKB = (content.length / 1024).toFixed(1);
175
208
  console.log(
@@ -195,29 +228,40 @@ export const runDevServer = async (options = {}) => {
195
228
  c("blue", "πŸ”§ Framework mode: using direct HMR import"),
196
229
  );
197
230
  } else {
198
- // βœ… Normal project: import from the npm module
231
+ // A static import, not `import('flet-box').then(...)`: the client
232
+ // restores the state snapshot and that must run before the app's
233
+ // own `useState` call. A `.then()` is a microtask later, so the
234
+ // app would already have booted with defaults.
199
235
  hmrScript = `
200
236
  <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));
237
+ import { initHMR } from 'flet-box';
238
+ initHMR();
206
239
  </script>
207
240
  `;
208
241
  }
209
242
 
210
243
  if (!html.includes("initHMR")) {
211
- if (html.includes("</body>")) {
244
+ // Inserted before the app's module tag, so module execution order
245
+ // puts the restore first; `</head>` and `</body>` are the fallbacks
246
+ // for a page that has no module script yet.
247
+ const anchor = /<script[^>]*type=["']module["'][^>]*>/i.exec(html);
248
+ if (anchor)
249
+ html = html.slice(0, anchor.index) + hmrScript + html.slice(anchor.index);
250
+ else if (html.includes("</head>"))
251
+ html = html.replace("</head>", `${hmrScript}</head>`);
252
+ else if (html.includes("</body>"))
212
253
  html = html.replace("</body>", `${hmrScript}</body>`);
213
- } else {
214
- html += hmrScript;
215
- }
254
+ else html += hmrScript;
216
255
  content = html;
217
256
  }
218
257
  }
219
258
 
220
- res.writeHead(200, { "Content-Type": contentType });
259
+ // A dev server that lets the browser cache a module is a hot reload
260
+ // that "does nothing": the page reloads and re-gets the old code.
261
+ res.writeHead(200, {
262
+ "Content-Type": contentType,
263
+ "Cache-Control": "no-store, no-cache, must-revalidate",
264
+ });
221
265
  res.end(content);
222
266
  });
223
267
  });
@@ -296,11 +340,21 @@ export const runDevServer = async (options = {}) => {
296
340
  // WebSocket para HMR
297
341
  if (hotReload) {
298
342
  const wss = new WebSocketServer({ server });
299
- wss.on("error", onServerError);
343
+
344
+ // A browser tab that vanishes mid-flight (a tunnel closing an idle socket is
345
+ // enough) makes `ws` emit "error". With no listener that is an uncaught
346
+ // exception and the dev server dies with the tab β€” the usual reason hot
347
+ // reload "crashes" during a live demo.
348
+ wss.on("error", (err) => {
349
+ console.warn(c("yellow", `⚠️ HMR socket error: ${err && err.message}`));
350
+ });
300
351
 
301
352
  wss.on("connection", (ws) => {
302
353
  clients.add(ws);
303
- console.log(c("green", "πŸ”Œ HMR client connected"));
354
+ if (logRequests) console.log(c("green", "πŸ”Œ HMR client connected"));
355
+ ws.on("error", () => {
356
+ // The peer is already gone; `close` follows and drops it from the set.
357
+ });
304
358
  ws.send(JSON.stringify({ type: "request-modules" }));
305
359
  ws.on("message", (data) => {
306
360
  try {
@@ -317,16 +371,16 @@ export const runDevServer = async (options = {}) => {
317
371
  });
318
372
  ws.on("close", () => {
319
373
  clients.delete(ws);
320
- console.log(c("gray", "πŸ”Œ HMR client disconnected"));
374
+ if (logRequests) console.log(c("gray", "πŸ”Œ HMR client disconnected"));
321
375
  });
322
376
  });
323
377
 
324
- const watchPaths = [
325
- "src/**/*.js",
326
- "src/**/*.mjs",
327
- "src/**/*.css",
328
- "index.html",
329
- ];
378
+ // chokidar 4 removed glob support: `src/**/*.js` is watched as a literal
379
+ // path that never exists, so the watcher silently reported "Watching…" and
380
+ // never fired once. Watch the real roots and filter by extension instead.
381
+ const watchPaths = ["src", "index.html"];
382
+ const isWatched = (filePath) =>
383
+ /\.(js|mjs|css)$/.test(filePath) || filePath === "index.html";
330
384
  const moduleVersions = new Map();
331
385
 
332
386
  watcher = chokidar.watch(watchPaths, {
@@ -334,22 +388,32 @@ export const runDevServer = async (options = {}) => {
334
388
  persistent: true,
335
389
  cwd: projectRoot,
336
390
  ignoreInitial: true,
391
+ // An editor saves in two or three writes (write, rename, chmod). Firing on
392
+ // each one reloads the page several times per Ctrl+S.
393
+ awaitWriteFinish: { stabilityThreshold: 150, pollInterval: 25 },
337
394
  });
338
395
 
396
+ const lastSent = new Map();
397
+
339
398
  watcher.on("change", async (filePath) => {
399
+ if (!isWatched(filePath)) return;
340
400
  const fullPath = path.join(projectRoot, filePath);
341
401
  try {
342
402
  const newContent = fs.readFileSync(fullPath, "utf-8");
343
403
  const oldContent = moduleVersions.get(filePath);
344
404
  const changeType = getChangeType(filePath, oldContent, newContent);
345
405
  moduleVersions.set(filePath, newContent);
406
+ const key = `${filePath}:${changeType}`;
407
+ const now = Date.now();
408
+ if (now - (lastSent.get(key) || 0) < 700) return;
409
+ lastSent.set(key, now);
346
410
  console.log(c("yellow", `πŸ“ Changed: ${filePath} (${changeType})`));
347
411
  broadcast(
348
412
  JSON.stringify({
349
413
  type: "hmr",
350
414
  file: filePath,
351
415
  changeType,
352
- timestamp: Date.now(),
416
+ timestamp: now,
353
417
  }),
354
418
  );
355
419
  } catch (err) {
@@ -358,11 +422,13 @@ export const runDevServer = async (options = {}) => {
358
422
  });
359
423
 
360
424
  watcher.on("add", (filePath) => {
425
+ if (!isWatched(filePath)) return;
361
426
  console.log(c("green", `βž• Added: ${filePath}`));
362
427
  broadcast(JSON.stringify({ type: "reload", reason: "file-added" }));
363
428
  });
364
429
 
365
430
  watcher.on("unlink", (filePath) => {
431
+ if (!isWatched(filePath)) return;
366
432
  console.log(c("red", `βž– Removed: ${filePath}`));
367
433
  broadcast(JSON.stringify({ type: "reload", reason: "file-removed" }));
368
434
  });