flet-box 1.0.6 β 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.
- package/README.md +28 -1
- package/bin/cli.js +77 -11
- package/bin/commands/auditFix.js +682 -0
- package/bin/commands/checkApi.js +7 -5
- package/bin/commands/create.js +40 -10
- package/bin/commands/createBundle.js +54 -1
- package/bin/commands/images.js +349 -0
- package/bin/commands/pruneAssets.js +170 -0
- package/bin/commands/release.js +34 -1
- package/bin/commands/runCloudflare.js +47 -9
- package/bin/commands/runServer.js +81 -24
- package/bin/commands/toPng.js +346 -0
- package/bin/commands/unpack.js +1173 -0
- package/bin/ota/loader.js +90 -0
- package/bin/utils/appSources.js +101 -0
- package/bin/utils/bundleConfig.js +166 -0
- package/bin/utils/dtsProps.js +147 -0
- package/bin/utils/imageTools.js +271 -0
- package/bin/utils/images/bmp.js +99 -0
- package/bin/utils/images/gif.js +244 -0
- package/bin/utils/images/index.js +163 -0
- package/bin/utils/images/jpeg.js +643 -0
- package/bin/utils/images/png.js +486 -0
- package/bin/utils/images/unsupported.js +29 -0
- package/bin/utils/neutralise.js +109 -6
- package/bin/utils/templates.js +307 -64
- package/docs/cli/README.md +82 -0
- package/docs/guides/online-updates.md +57 -9
- package/docs/widget/Accordion.md +1 -0
- package/docs/widget/AdMob.md +1 -0
- package/docs/widget/AdSense.md +1 -0
- package/docs/widget/AnimatedBox.md +1 -0
- package/docs/widget/AnimatedText.md +1 -0
- package/docs/widget/Audio.md +2 -1
- package/docs/widget/Avatar.md +12 -3
- package/docs/widget/Badge.md +1 -0
- package/docs/widget/Button.md +1 -0
- package/docs/widget/Card.md +1 -0
- package/docs/widget/Carousel.md +1 -0
- package/docs/widget/Chart.md +1 -0
- package/docs/widget/Checkbox.md +1 -0
- package/docs/widget/Chip.md +1 -0
- package/docs/widget/CircularBar.md +1 -0
- package/docs/widget/CircularChart.md +1 -0
- package/docs/widget/CodeViewer.md +1 -0
- package/docs/widget/Column.md +1 -0
- package/docs/widget/Container.md +1 -0
- package/docs/widget/DataTable.md +1 -0
- package/docs/widget/Divider.md +1 -0
- package/docs/widget/DraggBox.md +3 -2
- package/docs/widget/Dropdown.md +3 -0
- package/docs/widget/DroppBox.md +2 -1
- package/docs/widget/FloatingActionButton.md +1 -0
- package/docs/widget/GridView.md +1 -0
- package/docs/widget/Icon.md +1 -0
- package/docs/widget/Image.md +11 -3
- package/docs/widget/Input.md +1 -0
- package/docs/widget/InstallButton.md +1 -0
- package/docs/widget/ListTile.md +1 -0
- package/docs/widget/ListView.md +1 -0
- package/docs/widget/Maps.md +188 -0
- package/docs/widget/Markdown.md +1 -0
- package/docs/widget/MatrixRain.md +1 -0
- package/docs/widget/Pagination.md +1 -0
- package/docs/widget/ParallaxBox.md +1 -0
- package/docs/widget/ProgressBar.md +1 -0
- package/docs/widget/QRCode.md +1 -0
- package/docs/widget/README.md +12 -10
- package/docs/widget/Radio.md +1 -0
- package/docs/widget/Rating.md +1 -0
- package/docs/widget/Row.md +1 -0
- package/docs/widget/Skeleton.md +1 -0
- package/docs/widget/Slider.md +1 -0
- package/docs/widget/Stack.md +1 -0
- package/docs/widget/Stepper.md +1 -0
- package/docs/widget/Switch.md +1 -0
- package/docs/widget/Text.md +1 -0
- package/docs/widget/Tooltip.md +11 -13
- package/docs/widget/TreeView.md +1 -0
- package/docs/widget/Video.md +3 -2
- package/package.json +2 -2
- package/src/core/hmr-client.js +129 -34
- package/src/index.d.ts +132 -0
- package/src/index.js +1 -0
- package/src/navigations/AppBar.js +15 -4
- package/src/navigations/Drawer.js +9 -1
- package/src/widget-factory/translateProps.js +32 -1
- package/src/widgets/Avatar.js +228 -131
- package/src/widgets/Container.js +13 -0
- package/src/widgets/Dropdown.js +33 -1
- package/src/widgets/Image.js +102 -3
- package/src/widgets/Maps.js +1495 -0
- package/src/widgets/Tooltip.js +12 -2
- 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;
|
package/bin/commands/release.js
CHANGED
|
@@ -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(
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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 (
|
|
17
|
-
|
|
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",
|
|
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:
|
|
34
|
-
hotReload:
|
|
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(
|
|
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 ===
|
|
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
|
-
//
|
|
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
|
|
202
|
-
|
|
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
|
-
|
|
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
|
-
|
|
214
|
-
html += hmrScript;
|
|
215
|
-
}
|
|
245
|
+
else html += hmrScript;
|
|
216
246
|
content = html;
|
|
217
247
|
}
|
|
218
248
|
}
|
|
219
249
|
|
|
220
|
-
|
|
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
|
-
|
|
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
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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:
|
|
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
|
});
|