x4js 2.2.74 → 2.3.1

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/bin/x4js.mjs ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+
3
+ import "../cli/main.mjs";
@@ -0,0 +1,68 @@
1
+ import path from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ import { sassPlugin } from "esbuild-sass-plugin";
4
+ import { lessPlugin } from "./less-plugin.mjs";
5
+ import { copyPlugin } from "./copy-plugin.mjs";
6
+
7
+ const cliDir = path.dirname(fileURLToPath(import.meta.url));
8
+ const devClient = path.join(cliDir, "dev-client.js");
9
+
10
+ function versionId() {
11
+ const now = new Date();
12
+ const pad = (value) => String(value).padStart(2, "0");
13
+ return `${pad(now.getFullYear() - 2000)}${pad(now.getMonth() + 1)}${pad(now.getDate())}`;
14
+ }
15
+
16
+ export function createBuildOptions(config, mode) {
17
+ const production = mode === "production";
18
+ const dev = mode === "dev";
19
+
20
+ const defaults = {
21
+ absWorkingDir: config.root,
22
+ entryPoints: config.entryPoints,
23
+ outdir: config.outdir,
24
+ bundle: true,
25
+ charset: "utf8",
26
+ keepNames: true,
27
+ platform: "browser",
28
+ format: "iife",
29
+ target: "es2020",
30
+ minify: production,
31
+ sourcemap: production ? false : "inline",
32
+ logLevel: "info",
33
+ external: config.external,
34
+ assetNames: "assets/[name]-[hash]",
35
+ loader: {
36
+ ".svg": "dataurl",
37
+ ".jpg": "file",
38
+ ".jpeg": "file",
39
+ ".png": "file",
40
+ ".ttf": "file",
41
+ ".woff": "file",
42
+ ".woff2": "file",
43
+ },
44
+ define: {
45
+ ...config.define,
46
+ DEBUG_MODE: production ? "false" : "true",
47
+ VERSION_ID: versionId(),
48
+ },
49
+ plugins: [
50
+ sassPlugin({
51
+ type: "css",
52
+ filter: /\.s[ac]ss$/,
53
+ }),
54
+ lessPlugin(config.root),
55
+ copyPlugin(config, { dev }),
56
+ ],
57
+ };
58
+
59
+ if (dev)
60
+ defaults.inject = [devClient];
61
+
62
+ // x4.esbuild is intentionally last: it is the escape hatch for projects
63
+ // that need native esbuild options not modeled by x4js.
64
+ return {
65
+ ...defaults,
66
+ ...config.esbuild,
67
+ };
68
+ }
package/cli/build.mjs ADDED
@@ -0,0 +1,33 @@
1
+ import fs from "node:fs/promises";
2
+ import { parseArgs } from "node:util";
3
+ import esbuild from "esbuild";
4
+ import { loadConfig } from "./config.mjs";
5
+ import { createBuildOptions } from "./build-options.mjs";
6
+ import { info, success } from "./log.mjs";
7
+
8
+ export async function build(argv = [], root = process.cwd()) {
9
+ const { values, positionals } = parseArgs({
10
+ args: argv,
11
+ options: {
12
+ debug: { type: "boolean", default: false },
13
+ },
14
+ allowPositionals: true,
15
+ strict: true,
16
+ });
17
+
18
+ if (positionals.length)
19
+ throw new Error(`Unexpected argument: ${positionals[0]}`);
20
+
21
+ const config = loadConfig(root);
22
+ const mode = values.debug ? "debug" : "production";
23
+
24
+ info("mode", mode);
25
+ info("outdir", config.outdir);
26
+
27
+ await fs.rm(config.outdir, { recursive: true, force: true });
28
+ await fs.mkdir(config.outdir, { recursive: true });
29
+
30
+ const started = performance.now();
31
+ await esbuild.build(createBuildOptions(config, mode));
32
+ success("built", `${Math.round(performance.now() - started)}ms`);
33
+ }
package/cli/config.mjs ADDED
@@ -0,0 +1,166 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ const DEFAULTS = Object.freeze({
5
+ entryPoints: ["src/main.ts"],
6
+ outdir: "./bin",
7
+ copy: [],
8
+ external: [],
9
+ define: {},
10
+ dev: {
11
+ host: "127.0.0.1",
12
+ port: 8000,
13
+ https: false,
14
+ },
15
+ });
16
+
17
+ export function expandEnv(value, env = process.env) {
18
+ if (typeof value !== "string")
19
+ return value;
20
+
21
+ return value.replace(
22
+ /\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))/g,
23
+ (_, braced, plain) => {
24
+ const name = braced ?? plain;
25
+ const result = env[name];
26
+ if (result === undefined)
27
+ throw new Error(`Environment variable '${name}' is not defined`);
28
+ return result;
29
+ },
30
+ );
31
+ }
32
+
33
+ export function resolvePath(root, value, env = process.env) {
34
+ const expanded = expandEnv(value, env);
35
+ return path.isAbsolute(expanded)
36
+ ? path.normalize(expanded)
37
+ : path.resolve(root, expanded);
38
+ }
39
+
40
+ function validateCopy(copy) {
41
+ if (!Array.isArray(copy))
42
+ throw new Error("x4.copy must be an array");
43
+
44
+ for (const item of copy) {
45
+ if (!item || typeof item !== "object" || typeof item.from !== "string" || typeof item.to !== "string")
46
+ throw new Error("Each x4.copy entry must contain string 'from' and 'to' fields");
47
+ if (path.isAbsolute(item.to))
48
+ throw new Error(`x4.copy destination must be relative to outdir: '${item.to}'`);
49
+ const normalized = path.normalize(item.to);
50
+ if (normalized === ".." || normalized.startsWith(`..${path.sep}`))
51
+ throw new Error(`x4.copy destination escapes outdir: '${item.to}'`);
52
+ }
53
+ }
54
+
55
+ export function loadConfig(root = process.cwd(), env = process.env) {
56
+ root = path.resolve(root);
57
+ const packageFile = path.join(root, "package.json");
58
+
59
+ if (!fs.existsSync(packageFile))
60
+ throw new Error(`Cannot find package.json in '${root}'`);
61
+
62
+ let pkg;
63
+ try {
64
+ pkg = JSON.parse(fs.readFileSync(packageFile, "utf8"));
65
+ }
66
+ catch (error) {
67
+ throw new Error(`Cannot read package.json: ${error.message}`);
68
+ }
69
+
70
+ const cfg = pkg.x4 ?? {};
71
+ if (cfg === null || typeof cfg !== "object" || Array.isArray(cfg))
72
+ throw new Error("package.json field 'x4' must be an object");
73
+
74
+ const entryPoints = cfg.entryPoints ?? DEFAULTS.entryPoints;
75
+ if (!Array.isArray(entryPoints) || !entryPoints.length || entryPoints.some((entry) => typeof entry !== "string"))
76
+ throw new Error("x4.entryPoints must be a non-empty array of strings");
77
+
78
+ const copy = cfg.copy ?? DEFAULTS.copy;
79
+ validateCopy(copy);
80
+
81
+ const external = cfg.external ?? DEFAULTS.external;
82
+ if (!Array.isArray(external) || external.some((entry) => typeof entry !== "string"))
83
+ throw new Error("x4.external must be an array of strings");
84
+
85
+ const define = cfg.define ?? DEFAULTS.define;
86
+ if (!define || typeof define !== "object" || Array.isArray(define))
87
+ throw new Error("x4.define must be an object");
88
+ if (Object.values(define).some((value) => typeof value !== "string"))
89
+ throw new Error("x4.define values must be strings containing esbuild define expressions");
90
+
91
+ const devCfg = cfg.dev ?? {};
92
+ if (!devCfg || typeof devCfg !== "object" || Array.isArray(devCfg))
93
+ throw new Error("x4.dev must be an object");
94
+
95
+ const host = devCfg.host ?? DEFAULTS.dev.host;
96
+ if (typeof host !== "string" || !host)
97
+ throw new Error("x4.dev.host must be a non-empty string");
98
+
99
+ const port = devCfg.port ?? DEFAULTS.dev.port;
100
+ if (!Number.isInteger(port) || port < 0 || port > 65535)
101
+ throw new Error("x4.dev.port must be an integer between 0 and 65535");
102
+
103
+ const https = devCfg.https ?? DEFAULTS.dev.https;
104
+ if (typeof https !== "boolean")
105
+ throw new Error("x4.dev.https must be a boolean");
106
+
107
+ if (devCfg.tls !== undefined) {
108
+ if (!devCfg.tls || typeof devCfg.tls !== "object" || Array.isArray(devCfg.tls))
109
+ throw new Error("x4.dev.tls must be an object");
110
+ if (typeof devCfg.tls.cert !== "string" || typeof devCfg.tls.key !== "string")
111
+ throw new Error("x4.dev.tls requires string 'cert' and 'key' fields");
112
+ }
113
+
114
+ const outdirRaw = cfg.outdir ?? DEFAULTS.outdir;
115
+ if (typeof outdirRaw !== "string")
116
+ throw new Error("x4.outdir must be a string");
117
+
118
+ const outdir = resolvePath(root, outdirRaw, env);
119
+ if (outdir === root)
120
+ throw new Error("x4.outdir cannot be the project root");
121
+ if (outdir === path.parse(outdir).root)
122
+ throw new Error("x4.outdir cannot be a filesystem root");
123
+
124
+ return {
125
+ root,
126
+ packageFile,
127
+ package: pkg,
128
+ entryPoints: entryPoints.map((entry) => resolvePath(root, entry, env)),
129
+ outdir,
130
+ copy: copy.map((item) => ({
131
+ from: resolvePath(root, item.from, env),
132
+ to: path.normalize(item.to),
133
+ })),
134
+ external: [...external],
135
+ define: { ...define },
136
+ dev: {
137
+ host,
138
+ port,
139
+ https,
140
+ tls: devCfg.tls ? { ...devCfg.tls } : undefined,
141
+ },
142
+ esbuild: (() => {
143
+ if (cfg.esbuild === undefined)
144
+ return {};
145
+ if (!cfg.esbuild || typeof cfg.esbuild !== "object" || Array.isArray(cfg.esbuild))
146
+ throw new Error("x4.esbuild must be an object");
147
+ return { ...cfg.esbuild };
148
+ })(),
149
+ };
150
+ }
151
+
152
+ export function resolveTls(config, env = process.env) {
153
+ const tls = config.dev.tls;
154
+ if (!tls)
155
+ throw new Error("HTTPS is enabled but x4.dev.tls.cert/key are not configured");
156
+
157
+ const certfile = resolvePath(config.root, tls.cert, env);
158
+ const keyfile = resolvePath(config.root, tls.key, env);
159
+
160
+ if (!fs.existsSync(certfile))
161
+ throw new Error(`TLS certificate not found: ${certfile}`);
162
+ if (!fs.existsSync(keyfile))
163
+ throw new Error(`TLS private key not found: ${keyfile}`);
164
+
165
+ return { certfile, keyfile };
166
+ }
@@ -0,0 +1,115 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ async function walk(source) {
5
+ const files = [];
6
+ const dirs = [];
7
+
8
+ async function visit(current) {
9
+ const stat = await fs.stat(current);
10
+ if (!stat.isDirectory()) {
11
+ files.push({ path: current, stat });
12
+ return;
13
+ }
14
+
15
+ dirs.push(current);
16
+ const entries = await fs.readdir(current, { withFileTypes: true });
17
+ for (const entry of entries)
18
+ await visit(path.join(current, entry.name));
19
+ }
20
+
21
+ try {
22
+ await visit(source);
23
+ }
24
+ catch (error) {
25
+ if (error.code !== "ENOENT")
26
+ throw error;
27
+ }
28
+
29
+ return { files, dirs, isDirectory: dirs.includes(source) };
30
+ }
31
+
32
+ function destinationFor(entry, sourceFile) {
33
+ if (!entry.sourceIsDirectory)
34
+ return entry.destRoot;
35
+ return path.join(entry.destRoot, path.relative(entry.from, sourceFile));
36
+ }
37
+
38
+ export function copyPlugin(config, { dev = false } = {}) {
39
+ const entries = config.copy.map((entry) => ({
40
+ ...entry,
41
+ destRoot: path.resolve(config.outdir, entry.to),
42
+ }));
43
+ let previousDestinations = new Set();
44
+ let snapshot = { files: [], dirs: [], fingerprint: "0" };
45
+
46
+ async function scan() {
47
+ const files = [];
48
+ const dirs = [];
49
+ const fingerprint = [];
50
+
51
+ for (const entry of entries) {
52
+ const result = await walk(entry.from);
53
+ entry.sourceIsDirectory = result.isDirectory;
54
+ dirs.push(...result.dirs);
55
+ for (const item of result.files) {
56
+ files.push({ entry, ...item });
57
+ fingerprint.push(`${item.path}\0${item.stat.size}\0${item.stat.mtimeMs}`);
58
+ }
59
+ }
60
+
61
+ snapshot = {
62
+ files,
63
+ dirs,
64
+ fingerprint: fingerprint.sort().join("\n"),
65
+ };
66
+ }
67
+
68
+ async function copyFiles() {
69
+ const destinations = new Set();
70
+
71
+ for (const item of snapshot.files) {
72
+ const dest = destinationFor(item.entry, item.path);
73
+ destinations.add(dest);
74
+ await fs.mkdir(path.dirname(dest), { recursive: true });
75
+ await fs.copyFile(item.path, dest);
76
+ }
77
+
78
+ for (const stale of previousDestinations) {
79
+ if (!destinations.has(stale)) {
80
+ try { await fs.rm(stale, { force: true }); }
81
+ catch { /* ignored */ }
82
+ }
83
+ }
84
+
85
+ previousDestinations = destinations;
86
+ }
87
+
88
+ return {
89
+ name: "x4-copy",
90
+ setup(build) {
91
+ build.onStart(async () => {
92
+ await scan();
93
+ });
94
+
95
+ if (dev) {
96
+ build.onResolve({ filter: /^x4:copy-state$/ }, () => ({
97
+ path: "copy-state",
98
+ namespace: "x4-internal",
99
+ }));
100
+
101
+ build.onLoad({ filter: /^copy-state$/, namespace: "x4-internal" }, async () => ({
102
+ contents: `export default ${JSON.stringify(snapshot.fingerprint)};`,
103
+ loader: "js",
104
+ watchFiles: snapshot.files.map((item) => item.path),
105
+ watchDirs: snapshot.dirs,
106
+ }));
107
+ }
108
+
109
+ build.onEnd(async (result) => {
110
+ if (result.errors.length === 0)
111
+ await copyFiles();
112
+ });
113
+ },
114
+ };
115
+ }
package/cli/create.mjs ADDED
@@ -0,0 +1,141 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { spawn } from "node:child_process";
4
+ import { parseArgs } from "node:util";
5
+ import { fetchTemplates, fetchTemplateFiles, downloadTemplateFile } from "./templates-source.mjs";
6
+ import { info, success } from "./log.mjs";
7
+
8
+ function x4Minor(version) {
9
+ const match = /^(\d+)\.(\d+)/.exec(version);
10
+ if (!match)
11
+ throw new Error(`Invalid x4js version '${version}'`);
12
+ return `${match[1]}.${match[2]}`;
13
+ }
14
+
15
+ function validatePackageName(name) {
16
+ if (!/^[a-z0-9][a-z0-9._-]*$/.test(name))
17
+ throw new Error(`Invalid project name '${name}'. Use lowercase letters, digits, '.', '_' or '-'.`);
18
+ }
19
+
20
+ async function ensureEmptyTarget(target) {
21
+ try {
22
+ const entries = await fs.readdir(target);
23
+ if (entries.length)
24
+ throw new Error(`Target directory is not empty: ${target}`);
25
+ return false;
26
+ }
27
+ catch (error) {
28
+ if (error.code === "ENOENT") {
29
+ await fs.mkdir(target, { recursive: true });
30
+ return true;
31
+ }
32
+ throw error;
33
+ }
34
+ }
35
+
36
+ async function clearDirectory(target) {
37
+ const entries = await fs.readdir(target, { withFileTypes: true });
38
+ await Promise.all(entries.map((entry) =>
39
+ fs.rm(path.join(target, entry.name), { recursive: true, force: true })
40
+ ));
41
+ }
42
+
43
+ async function patchPackage(target, projectName, version) {
44
+ const filename = path.join(target, "package.json");
45
+ let pkg;
46
+ try {
47
+ pkg = JSON.parse(await fs.readFile(filename, "utf8"));
48
+ }
49
+ catch (error) {
50
+ throw new Error(`Template package.json is missing or invalid: ${error.message}`);
51
+ }
52
+
53
+ pkg.name = projectName;
54
+ const minor = x4Minor(version);
55
+
56
+ if (pkg.dependencies?.x4js !== undefined)
57
+ pkg.dependencies.x4js = minor;
58
+ else if (pkg.devDependencies?.x4js !== undefined)
59
+ pkg.devDependencies.x4js = minor;
60
+ else {
61
+ pkg.dependencies ??= {};
62
+ pkg.dependencies.x4js = minor;
63
+ }
64
+
65
+ await fs.writeFile(filename, `${JSON.stringify(pkg, null, 2)}\n`);
66
+ }
67
+
68
+ async function npmInstall(target) {
69
+ const command = process.platform === "win32" ? "npm.cmd" : "npm";
70
+ await new Promise((resolve, reject) => {
71
+ const child = spawn(command, ["install"], {
72
+ cwd: target,
73
+ stdio: "inherit",
74
+ });
75
+ child.on("error", reject);
76
+ child.on("exit", (code, signal) => {
77
+ if (code === 0)
78
+ resolve();
79
+ else
80
+ reject(new Error(`npm install failed${signal ? ` (${signal})` : ` (exit ${code})`}`));
81
+ });
82
+ });
83
+ }
84
+
85
+ export async function create(argv = [], { version = "unknown", cwd = process.cwd() } = {}) {
86
+ const { values, positionals } = parseArgs({
87
+ args: argv,
88
+ options: {
89
+ template: { type: "string", short: "t", default: "app" },
90
+ "no-install": { type: "boolean", default: false },
91
+ },
92
+ allowPositionals: true,
93
+ strict: true,
94
+ });
95
+
96
+ if (positionals.length !== 1)
97
+ throw new Error("Usage: x4js create <project> [--template <name>] [--no-install]");
98
+
99
+ const target = path.resolve(cwd, positionals[0]);
100
+ const projectName = path.basename(target);
101
+ validatePackageName(projectName);
102
+
103
+ const manifest = await fetchTemplates(version);
104
+ if (!(values.template in manifest))
105
+ throw new Error(`Unknown template '${values.template}'. Run 'x4js templates' to list templates.`);
106
+
107
+ const targetCreated = await ensureEmptyTarget(target);
108
+ info("create", target);
109
+ info("template", values.template);
110
+
111
+ try {
112
+ const files = await fetchTemplateFiles(values.template, version);
113
+
114
+ await Promise.all(files.map(async (file) => {
115
+ if (!file.path || file.path.split("/").includes(".."))
116
+ throw new Error(`Unsafe template path '${file.path}'`);
117
+ const destination = path.join(target, ...file.path.split("/"));
118
+ await fs.mkdir(path.dirname(destination), { recursive: true });
119
+ const data = await downloadTemplateFile(file.url, version);
120
+ await fs.writeFile(destination, data);
121
+ }));
122
+
123
+ await patchPackage(target, projectName, version);
124
+
125
+ if (!values["no-install"]) {
126
+ info("install", "npm");
127
+ await npmInstall(target);
128
+ }
129
+
130
+ success("created", target);
131
+ }
132
+ catch (error) {
133
+ if (targetCreated)
134
+ await fs.rm(target, { recursive: true, force: true });
135
+ else
136
+ await clearDirectory(target);
137
+ throw error;
138
+ }
139
+ }
140
+
141
+ export { x4Minor };
@@ -0,0 +1,41 @@
1
+ import copyState from "x4:copy-state";
2
+
3
+ // Keep the copy-state module in the bundle so changes to copied files also
4
+ // produce a changed JS output and therefore a live-reload event.
5
+ const events = new EventSource("/esbuild");
6
+ Object.defineProperty(events, "__x4CopyState", { value: copyState });
7
+
8
+ events.addEventListener("change", (event) => {
9
+ const change = JSON.parse(event.data);
10
+ const changed = [...change.added, ...change.removed, ...change.updated];
11
+
12
+ if (
13
+ change.added.length === 0 &&
14
+ change.removed.length === 0 &&
15
+ change.updated.length > 0 &&
16
+ change.updated.every((file) => file.endsWith(".css"))
17
+ ) {
18
+ const pending = new Set(change.updated);
19
+ const links = document.querySelectorAll('link[rel="stylesheet"]');
20
+
21
+ for (const link of links) {
22
+ const current = new URL(link.href);
23
+ if (!pending.has(current.pathname))
24
+ continue;
25
+
26
+ const next = link.cloneNode();
27
+ const url = new URL(link.href);
28
+ url.searchParams.set("x4", Date.now().toString());
29
+ next.href = url.href;
30
+ next.onload = () => link.remove();
31
+ link.after(next);
32
+ pending.delete(current.pathname);
33
+ }
34
+
35
+ if (pending.size === 0)
36
+ return;
37
+ }
38
+
39
+ if (changed.length)
40
+ location.reload();
41
+ });
package/cli/dev.mjs ADDED
@@ -0,0 +1,188 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { spawn } from "node:child_process";
4
+ import { parseArgs } from "node:util";
5
+ import esbuild from "esbuild";
6
+ import { loadConfig, resolveTls } from "./config.mjs";
7
+ import { createBuildOptions } from "./build-options.mjs";
8
+ import { info, success, failure } from "./log.mjs";
9
+
10
+ function openBrowser(url) {
11
+ let command;
12
+ let args;
13
+
14
+ if (process.platform === "win32") {
15
+ command = "cmd";
16
+ args = ["/c", "start", "", url];
17
+ }
18
+ else if (process.platform === "darwin") {
19
+ command = "open";
20
+ args = [url];
21
+ }
22
+ else {
23
+ command = "xdg-open";
24
+ args = [url];
25
+ }
26
+
27
+ const child = spawn(command, args, {
28
+ detached: true,
29
+ stdio: "ignore",
30
+ });
31
+ child.unref();
32
+ }
33
+
34
+ function protocolConfig(config, values) {
35
+ if (values.http && values.https)
36
+ throw new Error("--http and --https are mutually exclusive");
37
+
38
+ let https = config.dev.https;
39
+ if (values.http)
40
+ https = false;
41
+ if (values.https)
42
+ https = true;
43
+
44
+ return https;
45
+ }
46
+
47
+ function serverUrl(host, port, https) {
48
+ let displayHost = host;
49
+ if (host === "0.0.0.0")
50
+ displayHost = "127.0.0.1";
51
+ else if (host === "::")
52
+ displayHost = "[::1]";
53
+ else if (host.includes(":") && !host.startsWith("["))
54
+ displayHost = `[${host}]`;
55
+ return `${https ? "https" : "http"}://${displayHost}:${port}/`;
56
+ }
57
+
58
+ export async function dev(argv = [], root = process.cwd()) {
59
+ const { values, positionals } = parseArgs({
60
+ args: argv,
61
+ options: {
62
+ host: { type: "string" },
63
+ port: { type: "string" },
64
+ http: { type: "boolean", default: false },
65
+ https: { type: "boolean", default: false },
66
+ open: { type: "boolean", default: false },
67
+ },
68
+ allowPositionals: true,
69
+ strict: true,
70
+ });
71
+
72
+ if (positionals.length)
73
+ throw new Error(`Unexpected argument: ${positionals[0]}`);
74
+
75
+ if (values.port !== undefined && !/^\d+$/.test(values.port))
76
+ throw new Error("--port must be an integer");
77
+
78
+ let context;
79
+ let packageWatcher;
80
+ let stopping = false;
81
+ let restartTimer;
82
+ let restartChain = Promise.resolve();
83
+ let browserOpened = false;
84
+
85
+ async function start(config) {
86
+ const host = values.host ?? config.dev.host;
87
+ const port = values.port === undefined ? config.dev.port : Number(values.port);
88
+ if (port < 0 || port > 65535)
89
+ throw new Error("--port must be between 0 and 65535");
90
+
91
+ const useHttps = protocolConfig(config, values);
92
+ const serveOptions = {
93
+ host,
94
+ port,
95
+ servedir: config.outdir,
96
+ };
97
+
98
+ if (useHttps)
99
+ Object.assign(serveOptions, resolveTls(config));
100
+
101
+ const next = await esbuild.context(createBuildOptions(config, "dev"));
102
+ try {
103
+ await next.watch();
104
+ const result = await next.serve(serveOptions);
105
+ context = next;
106
+
107
+ const actualHost = result.hosts.includes(host) ? host : (result.hosts[0] ?? host);
108
+ const url = serverUrl(actualHost, result.port, useHttps);
109
+ info("mode", "dev");
110
+ info("outdir", config.outdir);
111
+ success("listening", url);
112
+
113
+ if (values.open && !browserOpened) {
114
+ browserOpened = true;
115
+ openBrowser(url);
116
+ }
117
+ }
118
+ catch (error) {
119
+ await next.dispose();
120
+ throw error;
121
+ }
122
+ }
123
+
124
+ async function reload() {
125
+ let config;
126
+ try {
127
+ config = loadConfig(root);
128
+ protocolConfig(config, values);
129
+ if ((values.https || (!values.http && config.dev.https)))
130
+ resolveTls(config);
131
+ }
132
+ catch (error) {
133
+ failure(`config: ${error.message}`);
134
+ return;
135
+ }
136
+
137
+ const previous = context;
138
+ context = undefined;
139
+ if (previous)
140
+ await previous.dispose();
141
+
142
+ try {
143
+ await start(config);
144
+ success("config", "reloaded");
145
+ }
146
+ catch (error) {
147
+ failure(`restart: ${error.message}`);
148
+ // The old context cannot be restored after dispose. Keep watching
149
+ // package.json so fixing the configuration starts dev again.
150
+ }
151
+ }
152
+
153
+ function scheduleReload() {
154
+ clearTimeout(restartTimer);
155
+ restartTimer = setTimeout(() => {
156
+ restartChain = restartChain.then(reload, reload);
157
+ }, 150);
158
+ }
159
+
160
+ const initialConfig = loadConfig(root);
161
+ await start(initialConfig);
162
+
163
+ packageWatcher = fs.watch(path.resolve(root), { persistent: true }, (_event, filename) => {
164
+ if (filename === null || filename.toString() === "package.json")
165
+ scheduleReload();
166
+ });
167
+
168
+ async function stop() {
169
+ if (stopping)
170
+ return;
171
+ stopping = true;
172
+ clearTimeout(restartTimer);
173
+ packageWatcher?.close();
174
+ await restartChain.catch(() => {});
175
+ await context?.dispose();
176
+ }
177
+
178
+ process.once("SIGINT", async () => {
179
+ await stop();
180
+ process.exit(0);
181
+ });
182
+ process.once("SIGTERM", async () => {
183
+ await stop();
184
+ process.exit(0);
185
+ });
186
+
187
+ return { stop };
188
+ }
@@ -0,0 +1,47 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { createRequire } from "node:module";
4
+ import { pathToFileURL } from "node:url";
5
+
6
+ async function loadLess(projectRoot) {
7
+ const require = createRequire(path.join(projectRoot, "package.json"));
8
+ let resolved;
9
+ try {
10
+ resolved = require.resolve("less");
11
+ }
12
+ catch {
13
+ throw new Error(
14
+ 'LESS support requires the "less" package.\n' +
15
+ 'Install it with: npm install -D less',
16
+ );
17
+ }
18
+
19
+ const module = await import(pathToFileURL(resolved).href);
20
+ return module.default ?? module;
21
+ }
22
+
23
+ export function lessPlugin(projectRoot) {
24
+ let lessPromise;
25
+
26
+ return {
27
+ name: "x4-less",
28
+ setup(build) {
29
+ build.onLoad({ filter: /\.less$/ }, async (args) => {
30
+ const source = await fs.readFile(args.path, "utf8");
31
+ lessPromise ??= loadLess(projectRoot);
32
+ const less = await lessPromise;
33
+
34
+ const result = await less.render(source, {
35
+ filename: args.path,
36
+ });
37
+
38
+ return {
39
+ contents: result.css,
40
+ loader: "css",
41
+ resolveDir: path.dirname(args.path),
42
+ watchFiles: [args.path, ...(result.imports ?? [])],
43
+ };
44
+ });
45
+ },
46
+ };
47
+ }
package/cli/log.mjs ADDED
@@ -0,0 +1,41 @@
1
+ import { styleText } from "node:util";
2
+
3
+ function useColor(stream = process.stdout) {
4
+ if (process.env.NO_COLOR !== undefined)
5
+ return false;
6
+ if (process.env.FORCE_COLOR !== undefined)
7
+ return process.env.FORCE_COLOR !== "0";
8
+ return !!stream.isTTY;
9
+ }
10
+
11
+ function paint(style, value, stream = process.stdout) {
12
+ const text = String(value);
13
+ return useColor(stream) ? styleText(style, text) : text;
14
+ }
15
+
16
+ export const color = {
17
+ cyan: (value) => paint("cyan", value),
18
+ green: (value) => paint("green", value),
19
+ yellow: (value) => paint("yellow", value),
20
+ red: (value) => paint("red", value, process.stderr),
21
+ gray: (value) => paint("gray", value),
22
+ white: (value) => paint("white", value),
23
+ };
24
+
25
+ export function info(label, value = "") {
26
+ const left = color.cyan(String(label).padEnd(11));
27
+ console.log(value === "" ? left.trimEnd() : `${left}${value}`);
28
+ }
29
+
30
+ export function success(label, value = "") {
31
+ const left = color.green(String(label).padEnd(11));
32
+ console.log(value === "" ? left.trimEnd() : `${left}${value}`);
33
+ }
34
+
35
+ export function warning(message) {
36
+ console.warn(color.yellow(message));
37
+ }
38
+
39
+ export function failure(message) {
40
+ console.error(color.red(message));
41
+ }
package/cli/main.mjs ADDED
@@ -0,0 +1,74 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { failure } from "./log.mjs";
5
+
6
+ const cliDir = path.dirname(fileURLToPath(import.meta.url));
7
+ const packageFile = path.join(cliDir, "..", "package.json");
8
+
9
+ function getVersion() {
10
+ try {
11
+ return JSON.parse(fs.readFileSync(packageFile, "utf8")).version ?? "unknown";
12
+ }
13
+ catch {
14
+ return "unknown";
15
+ }
16
+ }
17
+
18
+ function printHelp(version) {
19
+ console.log(`x4js ${version}
20
+
21
+ Usage:
22
+ x4js create <project> [--template <name>] [--no-install]
23
+ x4js templates
24
+ x4js dev [--host <host>] [--port <port>] [--http|--https] [--open]
25
+ x4js build [--debug]
26
+
27
+ Options:
28
+ -h, --help Show help
29
+ -v, --version Show version`);
30
+ }
31
+
32
+ const version = getVersion();
33
+ const [command, ...argv] = process.argv.slice(2);
34
+
35
+ try {
36
+ switch (command) {
37
+ case "create": {
38
+ const { create } = await import("./create.mjs");
39
+ await create(argv, { version });
40
+ break;
41
+ }
42
+ case "templates": {
43
+ const { templates } = await import("./templates.mjs");
44
+ await templates(argv, { version });
45
+ break;
46
+ }
47
+ case "dev": {
48
+ const { dev } = await import("./dev.mjs");
49
+ await dev(argv);
50
+ break;
51
+ }
52
+ case "build": {
53
+ const { build } = await import("./build.mjs");
54
+ await build(argv);
55
+ break;
56
+ }
57
+ case undefined:
58
+ case "help":
59
+ case "--help":
60
+ case "-h":
61
+ printHelp(version);
62
+ break;
63
+ case "--version":
64
+ case "-v":
65
+ console.log(version);
66
+ break;
67
+ default:
68
+ throw new Error(`Unknown command '${command}'. Run 'x4js --help'.`);
69
+ }
70
+ }
71
+ catch (error) {
72
+ failure(error instanceof Error ? error.message : String(error));
73
+ process.exitCode = 1;
74
+ }
@@ -0,0 +1,61 @@
1
+ const OWNER = "rlibre";
2
+ const REPO = "x4-templates";
3
+ const REF = "main";
4
+
5
+ function headers(version = "unknown") {
6
+ return {
7
+ "Accept": "application/vnd.github+json",
8
+ "User-Agent": `x4js/${version}`,
9
+ "X-GitHub-Api-Version": "2022-11-28",
10
+ };
11
+ }
12
+
13
+ async function checkedFetch(url, options) {
14
+ const response = await fetch(url, options);
15
+ if (!response.ok)
16
+ throw new Error(`Template repository request failed (${response.status} ${response.statusText})`);
17
+ return response;
18
+ }
19
+
20
+ export async function fetchTemplates(version = "unknown") {
21
+ const url = `https://raw.githubusercontent.com/${OWNER}/${REPO}/${REF}/templates.json`;
22
+ const response = await checkedFetch(url, { headers: headers(version) });
23
+ const manifest = await response.json();
24
+
25
+ if (!manifest || typeof manifest !== "object" || Array.isArray(manifest))
26
+ throw new Error("Invalid templates.json");
27
+
28
+ for (const [name, description] of Object.entries(manifest)) {
29
+ if (!name || typeof description !== "string")
30
+ throw new Error("Invalid templates.json entry");
31
+ }
32
+
33
+ return manifest;
34
+ }
35
+
36
+ export async function fetchTemplateFiles(name, version = "unknown") {
37
+ const treeUrl = `https://api.github.com/repos/${OWNER}/${REPO}/git/trees/${REF}?recursive=1`;
38
+ const response = await checkedFetch(treeUrl, { headers: headers(version) });
39
+ const tree = await response.json();
40
+
41
+ if (!Array.isArray(tree.tree))
42
+ throw new Error("Invalid template repository tree");
43
+
44
+ const prefix = `${name}/`;
45
+ const files = tree.tree
46
+ .filter((item) => item.type === "blob" && item.path.startsWith(prefix))
47
+ .map((item) => ({
48
+ path: item.path.slice(prefix.length),
49
+ url: `https://raw.githubusercontent.com/${OWNER}/${REPO}/${REF}/${item.path.split("/").map(encodeURIComponent).join("/")}`,
50
+ }));
51
+
52
+ if (!files.length)
53
+ throw new Error(`Template '${name}' contains no files`);
54
+
55
+ return files;
56
+ }
57
+
58
+ export async function downloadTemplateFile(url, version = "unknown") {
59
+ const response = await checkedFetch(url, { headers: headers(version) });
60
+ return Buffer.from(await response.arrayBuffer());
61
+ }
@@ -0,0 +1,13 @@
1
+ import { fetchTemplates } from "./templates-source.mjs";
2
+ import { color } from "./log.mjs";
3
+
4
+ export async function templates(_argv = [], { version = "unknown" } = {}) {
5
+ const manifest = await fetchTemplates(version);
6
+ const names = Object.keys(manifest).sort();
7
+ const width = names.reduce((max, name) => Math.max(max, name.length), 0) + 3;
8
+
9
+ console.log(color.cyan("Available templates"));
10
+ console.log();
11
+ for (const name of names)
12
+ console.log(` ${color.green(name.padEnd(width))}${manifest[name]}`);
13
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "x4js",
3
- "version": "2.2.74",
3
+ "version": "2.3.1",
4
4
  "type": "module",
5
5
  "main": "src/x4.ts",
6
6
  "module": "src/x4.ts",
@@ -9,6 +9,9 @@
9
9
  ".": "./src/x4.ts",
10
10
  "./monaco": "./src/components/monaco/monaco.ts"
11
11
  },
12
+ "bin": {
13
+ "x4js": "./bin/x4js.mjs"
14
+ },
12
15
  "scripts": {
13
16
  "build-publish": "deno run --allow-all ./publish.ts",
14
17
  "build-doc": "typedoc",
@@ -16,6 +19,8 @@
16
19
  },
17
20
  "files": [
18
21
  "src",
22
+ "bin",
23
+ "cli",
19
24
  "README.md",
20
25
  "LICENSE"
21
26
  ],
@@ -32,6 +37,10 @@
32
37
  "desktop",
33
38
  "application"
34
39
  ],
40
+ "dependencies": {
41
+ "esbuild": "0.28",
42
+ "esbuild-sass-plugin": "^3.7"
43
+ },
35
44
  "devDependencies": {
36
45
  "typescript": "^5.8.3",
37
46
  "typedoc": "^0.28.19",
package/readme.npm.md ADDED
@@ -0,0 +1,248 @@
1
+ # x4js
2
+
3
+ **A TypeScript framework for building applications, not markup.**
4
+
5
+ x4js is designed for rich web and desktop applications: business software, industrial interfaces, dashboards, data-heavy tools and desktop-like applications.
6
+
7
+ It uses persistent TypeScript objects associated with the real DOM. No virtual DOM, no reconciliation layer, and no application architecture imposed by JSX.
8
+
9
+ **TypeScript objects. JSX when it helps. Real DOM.**
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ npm install x4js
15
+ ```
16
+
17
+ ## Quick start
18
+
19
+ Create a project:
20
+
21
+ ```bash
22
+ npx x4build create demo --type=html
23
+ cd demo
24
+ npm install
25
+ ```
26
+
27
+ Run it in development mode with HMR:
28
+
29
+ ```bash
30
+ npx x4build --watch --hmr --serve
31
+ ```
32
+
33
+ ## More than 50 UI components
34
+
35
+ x4 includes a complete UI toolkit for building real applications.
36
+
37
+ Among the available components:
38
+
39
+ `Button`, `Input`, `TextEdit`, `TextArea`, `Checkbox`, `Radio`, `Switch`, `Select`, `Combobox`, `Slider`, `ColorPicker`, `Form`, `Dialog`, `Popup`, `Menu`, `Tabs`, `Gridview`, `Treeview`, `PropertyGrid`, `Spreadsheet`, `Calendar`, `Gauge`, `Progress`, `FileDrop`, `MonacoEditor`, layouts and more.
40
+
41
+ The framework also provides:
42
+
43
+ * drag & drop
44
+ * SVG primitives and builder
45
+ * routing
46
+ * PDF generation
47
+ * internationalization
48
+ * data stores and views
49
+ * application-wide events
50
+ * persistence helpers
51
+ * CSS infrastructure and theming
52
+
53
+ ## Easy to extend
54
+
55
+ x4 components are ordinary TypeScript classes.
56
+
57
+ ```ts
58
+ import { Component, ComponentProps } from 'x4js'
59
+
60
+ interface StatusBadgeProps extends ComponentProps {
61
+ text: string
62
+ }
63
+
64
+ class StatusBadge extends Component<StatusBadgeProps> {
65
+ constructor(props: StatusBadgeProps) {
66
+ super(props)
67
+
68
+ this.setContent(props.text)
69
+ }
70
+ }
71
+ ```
72
+
73
+ Your own components use the same DOM, lifecycle and event model as the components provided by x4.
74
+
75
+ Because x4 works with the real browser DOM, third-party UI libraries can also be integrated where they make sense. There is no separate plugin architecture to satisfy.
76
+
77
+ ## Real DOM
78
+
79
+ A `Component` is a persistent TypeScript object associated with an actual DOM element.
80
+
81
+ ```ts
82
+ const button = new Button({
83
+ label: 'Restart'
84
+ })
85
+
86
+ button.on('click', () => restartMachine())
87
+ ```
88
+
89
+ The object remains alive for the lifetime of the component. You can keep references, call methods and listen to events directly.
90
+
91
+ There is no virtual tree to rebuild or reconcile.
92
+
93
+ ## JSX is optional
94
+
95
+ x4 supports JSX, but JSX is syntax — not the application architecture.
96
+
97
+ Object API:
98
+
99
+ ```ts
100
+ this.setContent([
101
+ new Label({
102
+ text: 'Temperature'
103
+ }),
104
+
105
+ new Gauge({
106
+ min: 0,
107
+ max: 100
108
+ })
109
+ ])
110
+ ```
111
+
112
+ JSX:
113
+
114
+ ```tsx
115
+ this.setContent(
116
+ <VBox>
117
+ <Label text="Temperature" />
118
+ <Gauge min={0} max={100} />
119
+ </VBox>
120
+ )
121
+ ```
122
+
123
+ Both create the same x4 Components and the same real DOM.
124
+
125
+ Use whichever makes the code clearer.
126
+
127
+ ## Data-heavy applications
128
+
129
+ x4 treats grids, trees, forms and structured data as first-class application concerns.
130
+
131
+ `DataStore`, `DataView` and `Gridview` are designed to work together for tabular data:
132
+
133
+ ```ts
134
+ const store = new DataStore({
135
+ model,
136
+ data
137
+ })
138
+
139
+ const grid = new Gridview({
140
+ store,
141
+ columns: [
142
+ { id: 'name', title: 'Name', width: 200 },
143
+ { id: 'age', title: 'Age', width: 80 }
144
+ ]
145
+ })
146
+ ```
147
+
148
+ Changes to the data are reflected by the components using it.
149
+
150
+ ## Routing
151
+
152
+ ```ts
153
+ const router = new Router(true)
154
+
155
+ router.get('/home', () => showHome())
156
+ router.get('/device/:id', p => showDevice(p.id))
157
+
158
+ router.init()
159
+ ```
160
+
161
+ ## Internationalization
162
+
163
+ Internationalization support is built in.
164
+
165
+ ```ts
166
+ createLanguage('de', 'en')
167
+ addTranslation('de', translations)
168
+
169
+ selectLanguage('de')
170
+ ```
171
+
172
+ x4 includes French and English translations by default and allows applications to add their own languages.
173
+
174
+ ## SVG and PDF
175
+
176
+ SVG is part of the framework surface through `SvgComponent` and `SvgBuilder`.
177
+
178
+ PDF documents can be generated programmatically with `X4PDFBuilder`:
179
+
180
+ ```ts
181
+ const pdf = new X4PDFBuilder()
182
+
183
+ pdf.page(595, 842, page => {
184
+ // draw page
185
+ })
186
+
187
+ const data = pdf.build()
188
+ ```
189
+
190
+ ## CSS and themes
191
+
192
+ x4 includes the CSS foundation used by its components.
193
+
194
+ Applications can use the default styling, adapt it to their product, or provide their own CSS. Components expose their real DOM and CSS classes, so standard browser styling techniques remain available.
195
+
196
+ No CSS-in-JS runtime is required.
197
+
198
+ ## HMR and development workflow
199
+
200
+ The standard x4 build workflow supports watch mode, development serving and HMR:
201
+
202
+ ```bash
203
+ x4build --watch --hmr --serve
204
+ ```
205
+
206
+ Edit the application, save, see the result.
207
+
208
+ ## Ship only what you use
209
+
210
+ The `x4js` npm package ships its TypeScript sources directly.
211
+
212
+ Your application and x4 therefore participate in the same module graph, allowing the bundler to tree-shake unused code.
213
+
214
+ Import a small part of x4 and the rest of the component library does not need to become part of your application bundle.
215
+
216
+ ## Web and desktop
217
+
218
+ x4 targets both browser and desktop applications.
219
+
220
+ The same component model can be used in the browser or inside a desktop wrapper such as Electron, Tauri or NW.js.
221
+
222
+ ## AI-friendly documentation
223
+
224
+ x4 provides a generated `aicontext.md` containing the actual TypeScript API signatures.
225
+
226
+ AI coding agents can also inspect the framework sources directly because they are distributed with the npm package:
227
+
228
+ ```text
229
+ node_modules/x4js/src/
230
+ ```
231
+
232
+ The goal is not to make x4 an “AI framework”.
233
+
234
+ The goal is simpler: an explicit architecture and an API that are readable by humans and predictable for machines.
235
+
236
+ ## Philosophy
237
+
238
+ x4 deliberately keeps its conceptual model small.
239
+
240
+ Use TypeScript when TypeScript is enough.
241
+ Use the browser when the browser is enough.
242
+ Add an abstraction only when it solves a real problem.
243
+
244
+ **Use the simplest tool that fits.**
245
+
246
+ ---
247
+
248
+ MIT License