x4js 2.3.12 → 2.3.13

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/aicli.md CHANGED
@@ -5,8 +5,8 @@
5
5
  ```
6
6
  x4js create <project> [--template <name>] [--no-install]
7
7
  x4js templates
8
- x4js dev [--config <file>] [--host <host>] [--port <port>] [--http|--https] [--open]
9
- x4js build [--config <file>] [--debug]
8
+ x4js dev [--config <file>] [--env <file>] [--host <host>] [--port <port>] [--http|--https] [--open]
9
+ x4js build [--config <file>] [--env <file>] [--debug]
10
10
  x4js --help | --version
11
11
  ```
12
12
 
@@ -76,6 +76,7 @@ Builds the application, serves the output folder and rebuilds on every change.
76
76
  | Option | Effect |
77
77
  |---|---|
78
78
  | `--config <file>` | Config file to use. Default: `x4.config.json`. |
79
+ | `--env <file>` | File of variables for `$NAME` in the config (see below). |
79
80
  | `--host <host>` | Address to listen on. Default: `127.0.0.1`. |
80
81
  | `--port <port>` | Port to listen on. Default: the first free port from 8000. |
81
82
  | `--http`, `--https` | Force the protocol, whatever the config says. |
@@ -83,7 +84,7 @@ Builds the application, serves the output folder and rebuilds on every change.
83
84
 
84
85
  - The page reloads when a source or a copied file changes. A change that only touches CSS is applied without reloading.
85
86
  - Build errors and warnings are printed in the terminal.
86
- - Editing the config file restarts the build.
87
+ - Editing the config file restarts the build. The file given to `--env` is read again at that moment; editing it alone restarts nothing.
87
88
  - HTTPS needs a certificate and a key, declared in `dev.tls` (see below).
88
89
 
89
90
  ### `x4js build`
@@ -93,6 +94,7 @@ Writes the application to the output folder.
93
94
  | Option | Effect |
94
95
  |---|---|
95
96
  | `--config <file>` | Config file to use. Default: `x4.config.json`. |
97
+ | `--env <file>` | File of variables for `$NAME` in the config (see below). |
96
98
  | `--debug` | Debug build: not minified, with source maps. |
97
99
 
98
100
  The output folder is **emptied before each build**. Do not keep hand-written files in it: put them in the sources and list them in `copy`.
@@ -129,7 +131,60 @@ The file sits at the root of the project. Every key is optional; without the fil
129
131
  | `dev` | | Dev server settings: `host`, `port`, `https`, `tls`. |
130
132
  | `esbuild` | `{}` | Native esbuild options, applied last. They override everything above. |
131
133
 
132
- Relative paths are resolved from the project root. Paths accept environment variables, written `$NAME` or `${NAME}`; an undefined variable is an error.
134
+ Relative paths are resolved from the project root. Paths and `define` values accept environment variables, written `$NAME` or `${NAME}`; an undefined variable is an error.
135
+
136
+ ### Environment variables in `define`
137
+
138
+ A `define` value is a JavaScript expression, so a text is written between escaped double quotes. The variable is read when the command runs: its value ends up in the bundle.
139
+
140
+ ```json
141
+ {
142
+ "define": {
143
+ "API_URL": "\"$API_URL\"",
144
+ "API_PORT": "$API_PORT"
145
+ }
146
+ }
147
+ ```
148
+
149
+ ```ts
150
+ declare const API_URL: string;
151
+ declare const API_PORT: number;
152
+ ```
153
+
154
+ With `API_URL=https://example.com` and `API_PORT=8080`, the code sees the text `"https://example.com"` and the number `8080`. A quote or a backslash in the value of a variable is escaped: use double quotes around `$NAME`, not single ones.
155
+
156
+ Everything in the bundle is public: do not put a secret in `define`.
157
+
158
+ ### Variables from a file
159
+
160
+ `--env <file>` reads variables from a file of `NAME=value` lines, for `dev` and `build`:
161
+
162
+ ```
163
+ # .env
164
+ API_URL=https://example.com
165
+ API_PORT=8080
166
+ ```
167
+
168
+ ```
169
+ x4js dev --env .env
170
+ ```
171
+
172
+ - The file only serves `$NAME` in the config. Its variables are not given to the application: pass the ones it needs through `define`.
173
+ - A variable already defined in the environment keeps its value: the file only adds the missing ones.
174
+ - A file that does not exist is an error.
175
+
176
+ This lets the same config work on a machine and on a host that defines the variables itself:
177
+
178
+ ```json
179
+ {
180
+ "scripts": {
181
+ "dev": "x4js dev --env .env",
182
+ "build": "x4js build"
183
+ }
184
+ }
185
+ ```
186
+
187
+ Keep the file out of Git when it holds values that are not public.
133
188
 
134
189
  ### The HTML page
135
190
 
@@ -189,6 +244,7 @@ declare const VERSION_ID: number;
189
244
  | A file placed in the output folder disappeared | `x4js build` empties the folder. Move the file to the sources and add it to `copy`. |
190
245
  | `Cannot find package 'esbuild'` | The dependencies of the `x4js` copy being run are not installed. Run `npm install` in the project. |
191
246
  | `Config file not found` | The file given to `--config` does not exist. Without `--config`, a missing `x4.config.json` is not an error. |
192
- | `Environment variable '…' is not defined` | A path in the config uses `$NAME` and the variable is not set. |
247
+ | `Environment variable '…' is not defined` | A path or a `define` value in the config uses `$NAME` and the variable is not set, in the environment or in the file given to `--env`. |
248
+ | `Env file not found` | The file given to `--env` does not exist. |
193
249
  | `LESS support requires the "less" package` | A `.less` file is imported and `less` is not installed in the project. |
194
250
  | No colors in the terminal, or unwanted ones | Set `FORCE_COLOR=1` to force them, `NO_COLOR=1` to remove them. |
@@ -27,12 +27,12 @@ export function createBuildOptions(config, mode) {
27
27
  charset: "utf8",
28
28
  keepNames: true,
29
29
  platform: "browser",
30
- logLevel: "silent",
30
+ // Errors and warnings are printed by diagnostic-plugin.mjs.
31
+ logLevel: "silent",
31
32
  format: "iife",
32
33
  target: "es2020",
33
34
  minify: production,
34
35
  sourcemap: production ? false : "linked",
35
- logLevel: "info",
36
36
  external: config.external,
37
37
  assetNames: "assets/[name]-[hash]",
38
38
  loader: {
package/cli/build.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs/promises";
2
2
  import { parseArgs } from "node:util";
3
3
  import esbuild from "esbuild";
4
- import { DEFAULT_CONFIG_FILE, expandEnv, loadConfig } from "./config.mjs";
4
+ import { DEFAULT_CONFIG_FILE, expandEnv, loadConfig, loadEnvFile } from "./config.mjs";
5
5
  import { createBuildOptions } from "./build-options.mjs";
6
6
  import { info, success } from "./log.mjs";
7
7
  import { printBuildError, printWarnings } from "./diagnostic-plugin.mjs";
@@ -11,6 +11,7 @@ export async function build(argv = [], root = process.cwd()) {
11
11
  args: argv,
12
12
  options: {
13
13
  config: { type: "string" },
14
+ env: { type: "string" },
14
15
  debug: { type: "boolean", default: false },
15
16
  },
16
17
  allowPositionals: true,
@@ -20,12 +21,17 @@ export async function build(argv = [], root = process.cwd()) {
20
21
  if (positionals.length)
21
22
  throw new Error(`Unexpected argument: ${positionals[0]}`);
22
23
 
24
+ const envFile = values.env ? loadEnvFile(root, values.env) : undefined;
25
+ const env = envFile?.env ?? process.env;
26
+
23
27
  const configArg = values.config ? expandEnv(values.config) : DEFAULT_CONFIG_FILE;
24
- const config = loadConfig(root, configArg, process.env, { explicit: values.config !== undefined });
28
+ const config = loadConfig(root, configArg, env, { explicit: values.config !== undefined });
25
29
  const mode = values.debug ? "debug" : "production";
26
30
 
27
31
  info("mode", mode);
28
32
  info("config", config.configFile);
33
+ if (envFile)
34
+ info("env file", envFile.filename);
29
35
  info("outdir", config.outdir);
30
36
 
31
37
  await fs.rm(config.outdir, { recursive: true, force: true });
package/cli/config.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import { parseEnv } from "node:util";
3
4
 
4
5
  export const DEFAULT_CONFIG_FILE = "x4.config.json";
5
6
 
@@ -15,7 +16,7 @@ const DEFAULTS = Object.freeze({
15
16
  },
16
17
  });
17
18
 
18
- export function expandEnv(value, env = process.env) {
19
+ export function expandEnv(value, env = process.env, escape = (text) => text) {
19
20
  if (typeof value !== "string")
20
21
  return value;
21
22
 
@@ -26,11 +27,18 @@ export function expandEnv(value, env = process.env) {
26
27
  const result = env[name];
27
28
  if (result === undefined)
28
29
  throw new Error(`Environment variable '${name}' is not defined`);
29
- return result;
30
+ return escape(result);
30
31
  },
31
32
  );
32
33
  }
33
34
 
35
+ // A define value is a JavaScript expression, usually a string literal
36
+ // ("\"$NAME\""). The value of the variable is escaped so that a quote or a
37
+ // backslash in it cannot end or corrupt that literal.
38
+ function escapeForDefine(text) {
39
+ return JSON.stringify(text).slice(1, -1);
40
+ }
41
+
34
42
  export function resolvePath(root, value, env = process.env) {
35
43
  const expanded = expandEnv(value, env);
36
44
  return path.isAbsolute(expanded)
@@ -42,6 +50,39 @@ export function resolveConfigFile(root, configFile = DEFAULT_CONFIG_FILE, env =
42
50
  return resolvePath(root, configFile, env);
43
51
  }
44
52
 
53
+ /**
54
+ * Reads the file given to --env.
55
+ * Returns the environment to expand the config with: the variables of the
56
+ * file, under those already defined, which win as with `node --env-file`.
57
+ *
58
+ * The option is not named --env-file on purpose: node handles that name
59
+ * itself, even after the script name, before this code runs.
60
+ */
61
+ export function loadEnvFile(root, file, env = process.env) {
62
+ const filename = resolvePath(root, file, env);
63
+
64
+ let variables;
65
+ try {
66
+ variables = parseEnv(fs.readFileSync(filename, "utf8"));
67
+ }
68
+ catch (error) {
69
+ if (error.code === "ENOENT")
70
+ throw new Error(`Env file not found: ${filename}`);
71
+ throw new Error(`Cannot read env file '${filename}': ${error.message}`);
72
+ }
73
+
74
+ // Inherit from the environment instead of copying it: process.env has
75
+ // its own lookup rules (names are case-insensitive on Windows).
76
+ // defineProperty, because a plain assignment would go through to it.
77
+ const merged = Object.create(env);
78
+ for (const [name, value] of Object.entries(variables)) {
79
+ if (env[name] === undefined)
80
+ Object.defineProperty(merged, name, { value, enumerable: true });
81
+ }
82
+
83
+ return { filename, env: merged };
84
+ }
85
+
45
86
  function readJson(filename) {
46
87
  try {
47
88
  return JSON.parse(fs.readFileSync(filename, "utf8"));
@@ -146,7 +187,9 @@ export function loadConfig(root = process.cwd(), configFile = DEFAULT_CONFIG_FIL
146
187
  to: path.normalize(item.to),
147
188
  })),
148
189
  external: [...external],
149
- define: { ...define },
190
+ define: Object.fromEntries(
191
+ Object.entries(define).map(([name, value]) => [name, expandEnv(value, env, escapeForDefine)]),
192
+ ),
150
193
  dev: {
151
194
  host,
152
195
  port,
package/cli/dev.mjs CHANGED
@@ -3,7 +3,7 @@ import path from "node:path";
3
3
  import { spawn } from "node:child_process";
4
4
  import { parseArgs } from "node:util";
5
5
  import esbuild from "esbuild";
6
- import { DEFAULT_CONFIG_FILE, expandEnv, loadConfig, resolveConfigFile, resolveTls } from "./config.mjs";
6
+ import { DEFAULT_CONFIG_FILE, expandEnv, loadConfig, loadEnvFile, resolveConfigFile, resolveTls } from "./config.mjs";
7
7
  import { createBuildOptions } from "./build-options.mjs";
8
8
  import { info, success, failure } from "./log.mjs";
9
9
 
@@ -60,6 +60,7 @@ export async function dev(argv = [], root = process.cwd()) {
60
60
  args: argv,
61
61
  options: {
62
62
  config: { type: "string" },
63
+ env: { type: "string" },
63
64
  host: { type: "string" },
64
65
  port: { type: "string" },
65
66
  http: { type: "boolean", default: false },
@@ -86,7 +87,16 @@ export async function dev(argv = [], root = process.cwd()) {
86
87
  let restartChain = Promise.resolve();
87
88
  let browserOpened = false;
88
89
 
89
- async function start(config) {
90
+ // Read again on each (re)start, so an edited env file is taken into
91
+ // account when the config is reloaded.
92
+ function load() {
93
+ const envFile = values.env ? loadEnvFile(root, values.env) : undefined;
94
+ const env = envFile?.env ?? process.env;
95
+ const config = loadConfig(root, configArg, env, { explicit: explicitConfig });
96
+ return { config, env, envFile };
97
+ }
98
+
99
+ async function start({ config, env, envFile }) {
90
100
  const host = values.host ?? config.dev.host;
91
101
  const port = values.port === undefined ? config.dev.port : Number(values.port);
92
102
  if (port !== undefined && (port < 0 || port > 65535))
@@ -101,7 +111,7 @@ export async function dev(argv = [], root = process.cwd()) {
101
111
  if (port !== undefined)
102
112
  serveOptions.port = port;
103
113
  if (useHttps)
104
- Object.assign(serveOptions, resolveTls(config));
114
+ Object.assign(serveOptions, resolveTls(config, env));
105
115
 
106
116
  const next = await esbuild.context(createBuildOptions(config, "dev"));
107
117
  try {
@@ -113,6 +123,8 @@ export async function dev(argv = [], root = process.cwd()) {
113
123
  const url = serverUrl(actualHost, result.port, useHttps);
114
124
  info("mode", "dev");
115
125
  info("config", config.configFile);
126
+ if (envFile)
127
+ info("env file", envFile.filename);
116
128
  info("outdir", config.outdir);
117
129
  success("server", url);
118
130
 
@@ -128,12 +140,12 @@ export async function dev(argv = [], root = process.cwd()) {
128
140
  }
129
141
 
130
142
  async function reload() {
131
- let config;
143
+ let loaded;
132
144
  try {
133
- config = loadConfig(root, configArg, process.env, { explicit: explicitConfig });
134
- protocolConfig(config, values);
135
- if (values.https || (!values.http && config.dev.https))
136
- resolveTls(config);
145
+ loaded = load();
146
+ protocolConfig(loaded.config, values);
147
+ if (values.https || (!values.http && loaded.config.dev.https))
148
+ resolveTls(loaded.config, loaded.env);
137
149
  }
138
150
  catch (error) {
139
151
  failure(`config: ${error.message}`);
@@ -146,7 +158,7 @@ export async function dev(argv = [], root = process.cwd()) {
146
158
  await previous.dispose();
147
159
 
148
160
  try {
149
- await start(config);
161
+ await start(loaded);
150
162
  success("config", "reloaded");
151
163
  }
152
164
  catch (error) {
@@ -161,8 +173,7 @@ export async function dev(argv = [], root = process.cwd()) {
161
173
  }, 150);
162
174
  }
163
175
 
164
- const initialConfig = loadConfig(root, configArg, process.env, { explicit: explicitConfig });
165
- await start(initialConfig);
176
+ await start(load());
166
177
 
167
178
  const watchDir = path.dirname(watchedConfigFile);
168
179
  const watchName = path.basename(watchedConfigFile);
package/cli/main.mjs CHANGED
@@ -21,11 +21,12 @@ function printHelp(version) {
21
21
  Usage:
22
22
  x4js create <project> [--template <name>] [--no-install]
23
23
  x4js templates
24
- x4js dev [--config <file>] [--host <host>] [--port <port>] [--http|--https] [--open]
25
- x4js build [--config <file>] [--debug]
24
+ x4js dev [--config <file>] [--env <file>] [--host <host>] [--port <port>] [--http|--https] [--open]
25
+ x4js build [--config <file>] [--env <file>] [--debug]
26
26
 
27
27
  Options:
28
28
  --config <file> Config file (default: x4.config.json)
29
+ --env <file> File of NAME=value lines, for $NAME in the config
29
30
  -h, --help Show help
30
31
  -v, --version Show version`);
31
32
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "x4js",
3
- "version": "2.3.12",
3
+ "version": "2.3.13",
4
4
  "type": "module",
5
5
  "main": "src/x4.ts",
6
6
  "module": "src/x4.ts",