x4js 2.2.74 → 2.3.2

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,165 @@
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
+ https: false,
13
+ },
14
+ });
15
+
16
+ export function expandEnv(value, env = process.env) {
17
+ if (typeof value !== "string")
18
+ return value;
19
+
20
+ return value.replace(
21
+ /\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))/g,
22
+ (_, braced, plain) => {
23
+ const name = braced ?? plain;
24
+ const result = env[name];
25
+ if (result === undefined)
26
+ throw new Error(`Environment variable '${name}' is not defined`);
27
+ return result;
28
+ },
29
+ );
30
+ }
31
+
32
+ export function resolvePath(root, value, env = process.env) {
33
+ const expanded = expandEnv(value, env);
34
+ return path.isAbsolute(expanded)
35
+ ? path.normalize(expanded)
36
+ : path.resolve(root, expanded);
37
+ }
38
+
39
+ function validateCopy(copy) {
40
+ if (!Array.isArray(copy))
41
+ throw new Error("x4.copy must be an array");
42
+
43
+ for (const item of copy) {
44
+ if (!item || typeof item !== "object" || typeof item.from !== "string" || typeof item.to !== "string")
45
+ throw new Error("Each x4.copy entry must contain string 'from' and 'to' fields");
46
+ if (path.isAbsolute(item.to))
47
+ throw new Error(`x4.copy destination must be relative to outdir: '${item.to}'`);
48
+ const normalized = path.normalize(item.to);
49
+ if (normalized === ".." || normalized.startsWith(`..${path.sep}`))
50
+ throw new Error(`x4.copy destination escapes outdir: '${item.to}'`);
51
+ }
52
+ }
53
+
54
+ export function loadConfig(root = process.cwd(), env = process.env) {
55
+ root = path.resolve(root);
56
+ const packageFile = path.join(root, "package.json");
57
+
58
+ if (!fs.existsSync(packageFile))
59
+ throw new Error(`Cannot find package.json in '${root}'`);
60
+
61
+ let pkg;
62
+ try {
63
+ pkg = JSON.parse(fs.readFileSync(packageFile, "utf8"));
64
+ }
65
+ catch (error) {
66
+ throw new Error(`Cannot read package.json: ${error.message}`);
67
+ }
68
+
69
+ const cfg = pkg.x4 ?? {};
70
+ if (cfg === null || typeof cfg !== "object" || Array.isArray(cfg))
71
+ throw new Error("package.json field 'x4' must be an object");
72
+
73
+ const entryPoints = cfg.entryPoints ?? DEFAULTS.entryPoints;
74
+ if (!Array.isArray(entryPoints) || !entryPoints.length || entryPoints.some((entry) => typeof entry !== "string"))
75
+ throw new Error("x4.entryPoints must be a non-empty array of strings");
76
+
77
+ const copy = cfg.copy ?? DEFAULTS.copy;
78
+ validateCopy(copy);
79
+
80
+ const external = cfg.external ?? DEFAULTS.external;
81
+ if (!Array.isArray(external) || external.some((entry) => typeof entry !== "string"))
82
+ throw new Error("x4.external must be an array of strings");
83
+
84
+ const define = cfg.define ?? DEFAULTS.define;
85
+ if (!define || typeof define !== "object" || Array.isArray(define))
86
+ throw new Error("x4.define must be an object");
87
+ if (Object.values(define).some((value) => typeof value !== "string"))
88
+ throw new Error("x4.define values must be strings containing esbuild define expressions");
89
+
90
+ const devCfg = cfg.dev ?? {};
91
+ if (!devCfg || typeof devCfg !== "object" || Array.isArray(devCfg))
92
+ throw new Error("x4.dev must be an object");
93
+
94
+ const host = devCfg.host ?? DEFAULTS.dev.host;
95
+ if (typeof host !== "string" || !host)
96
+ throw new Error("x4.dev.host must be a non-empty string");
97
+
98
+ const port = devCfg.port;
99
+ if (port !== undefined && (!Number.isInteger(port) || port < 0 || port > 65535))
100
+ throw new Error("x4.dev.port must be an integer between 0 and 65535");
101
+
102
+ const https = devCfg.https ?? DEFAULTS.dev.https;
103
+ if (typeof https !== "boolean")
104
+ throw new Error("x4.dev.https must be a boolean");
105
+
106
+ if (devCfg.tls !== undefined) {
107
+ if (!devCfg.tls || typeof devCfg.tls !== "object" || Array.isArray(devCfg.tls))
108
+ throw new Error("x4.dev.tls must be an object");
109
+ if (typeof devCfg.tls.cert !== "string" || typeof devCfg.tls.key !== "string")
110
+ throw new Error("x4.dev.tls requires string 'cert' and 'key' fields");
111
+ }
112
+
113
+ const outdirRaw = cfg.outdir ?? DEFAULTS.outdir;
114
+ if (typeof outdirRaw !== "string")
115
+ throw new Error("x4.outdir must be a string");
116
+
117
+ const outdir = resolvePath(root, outdirRaw, env);
118
+ if (outdir === root)
119
+ throw new Error("x4.outdir cannot be the project root");
120
+ if (outdir === path.parse(outdir).root)
121
+ throw new Error("x4.outdir cannot be a filesystem root");
122
+
123
+ return {
124
+ root,
125
+ packageFile,
126
+ package: pkg,
127
+ entryPoints: entryPoints.map((entry) => resolvePath(root, entry, env)),
128
+ outdir,
129
+ copy: copy.map((item) => ({
130
+ from: resolvePath(root, item.from, env),
131
+ to: path.normalize(item.to),
132
+ })),
133
+ external: [...external],
134
+ define: { ...define },
135
+ dev: {
136
+ host,
137
+ port,
138
+ https,
139
+ tls: devCfg.tls ? { ...devCfg.tls } : undefined,
140
+ },
141
+ esbuild: (() => {
142
+ if (cfg.esbuild === undefined)
143
+ return {};
144
+ if (!cfg.esbuild || typeof cfg.esbuild !== "object" || Array.isArray(cfg.esbuild))
145
+ throw new Error("x4.esbuild must be an object");
146
+ return { ...cfg.esbuild };
147
+ })(),
148
+ };
149
+ }
150
+
151
+ export function resolveTls(config, env = process.env) {
152
+ const tls = config.dev.tls;
153
+ if (!tls)
154
+ throw new Error("HTTPS is enabled but x4.dev.tls.cert/key are not configured");
155
+
156
+ const certfile = resolvePath(config.root, tls.cert, env);
157
+ const keyfile = resolvePath(config.root, tls.key, env);
158
+
159
+ if (!fs.existsSync(certfile))
160
+ throw new Error(`TLS certificate not found: ${certfile}`);
161
+ if (!fs.existsSync(keyfile))
162
+ throw new Error(`TLS private key not found: ${keyfile}`);
163
+
164
+ return { certfile, keyfile };
165
+ }
@@ -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,159 @@
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
+
37
+ async function patchPackage(target, projectName, version) {
38
+ const filename = path.join(target, "package.json");
39
+ let pkg;
40
+ try {
41
+ pkg = JSON.parse(await fs.readFile(filename, "utf8"));
42
+ }
43
+ catch (error) {
44
+ throw new Error(`Template package.json is missing or invalid: ${error.message}`);
45
+ }
46
+
47
+ pkg.name = projectName;
48
+ const minor = x4Minor(version);
49
+
50
+ if (pkg.dependencies?.x4js !== undefined)
51
+ pkg.dependencies.x4js = minor;
52
+ else if (pkg.devDependencies?.x4js !== undefined)
53
+ pkg.devDependencies.x4js = minor;
54
+ else {
55
+ pkg.dependencies ??= {};
56
+ pkg.dependencies.x4js = minor;
57
+ }
58
+
59
+ await fs.writeFile(filename, `${JSON.stringify(pkg, null, 2)}\n`);
60
+ }
61
+
62
+ async function npmInstall(target) {
63
+ let command;
64
+ let args;
65
+
66
+ if (process.platform === "win32") {
67
+ command = process.env.ComSpec || "cmd.exe";
68
+ args = ["/d", "/s", "/c", "npm install"];
69
+ }
70
+ else {
71
+ command = "npm";
72
+ args = ["install"];
73
+ }
74
+
75
+ await new Promise((resolve, reject) => {
76
+ const child = spawn(command, args, {
77
+ cwd: target,
78
+ stdio: "inherit",
79
+ });
80
+
81
+ child.on("error", reject);
82
+
83
+ child.on("exit", (code, signal) => {
84
+ if (code === 0) {
85
+ resolve();
86
+ return;
87
+ }
88
+
89
+ reject(
90
+ new Error(
91
+ `npm install failed${
92
+ signal ? ` (${signal})` : ` (exit ${code})`
93
+ }`
94
+ )
95
+ );
96
+ });
97
+ });
98
+ }
99
+
100
+ export async function create(argv = [], { version = "unknown", cwd = process.cwd() } = {}) {
101
+ const { values, positionals } = parseArgs({
102
+ args: argv,
103
+ options: {
104
+ template: { type: "string", short: "t", default: "app" },
105
+ "no-install": { type: "boolean", default: false },
106
+ },
107
+ allowPositionals: true,
108
+ strict: true,
109
+ });
110
+
111
+ if (positionals.length !== 1)
112
+ throw new Error("Usage: x4js create <project> [--template <name>] [--no-install]");
113
+
114
+ const target = path.resolve(cwd, positionals[0]);
115
+ const projectName = path.basename(target);
116
+ validatePackageName(projectName);
117
+
118
+ const manifest = await fetchTemplates(version);
119
+ if (!(values.template in manifest))
120
+ throw new Error(`Unknown template '${values.template}'. Run 'x4js templates' to list templates.`);
121
+
122
+ const targetCreated = await ensureEmptyTarget(target);
123
+ let projectReady = false;
124
+ info("create", target);
125
+ info("template", values.template);
126
+
127
+ try {
128
+ const files = await fetchTemplateFiles(values.template, version);
129
+
130
+ await Promise.all(files.map(async (file) => {
131
+ if (!file.path || file.path.split("/").includes(".."))
132
+ throw new Error(`Unsafe template path '${file.path}'`);
133
+ const destination = path.join(target, ...file.path.split("/"));
134
+ await fs.mkdir(path.dirname(destination), { recursive: true });
135
+ const data = await downloadTemplateFile(file.url, version);
136
+ await fs.writeFile(destination, data);
137
+ }));
138
+
139
+ await patchPackage(target, projectName, version);
140
+ projectReady = true;
141
+
142
+ if (!values["no-install"]) {
143
+ info("install", "npm");
144
+ await npmInstall(target);
145
+ }
146
+
147
+ success("created", target);
148
+ }
149
+ catch (error) {
150
+ // Never remove a directory that existed before `x4js create`.
151
+ // Once package.json has been patched, the project is considered
152
+ // created and is also kept if `npm install` fails.
153
+ if (targetCreated && !projectReady)
154
+ await fs.rm(target, { recursive: true, force: true });
155
+ throw error;
156
+ }
157
+ }
158
+
159
+ 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
+ });