ecopages 0.2.0-beta.31 → 0.2.0-beta.33

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 CHANGED
@@ -79,11 +79,12 @@ Trace lines are prefixed with `[ecopages:startup-trace]` and look like:
79
79
  [ecopages:startup-trace] phase=setupAppRuntimePlugins durationMs=714 wallMs=1999
80
80
  [ecopages:startup-trace] phase=route-registry durationMs=1 wallMs=2000
81
81
  [ecopages:startup-trace] phase=server-listen durationMs=45 wallMs=2046
82
+ [ecopages:startup-trace] phase=dev-client-transform durationMs=42 wallMs=2100
82
83
  [ecopages:startup-trace] phase=first-request-ssr durationMs=5296 wallMs=12495
83
84
  [ecopages:startup-trace] summary path=/docs/getting-started/introduction bundleCount=13 clientBundleBytes=858396 wallMs=12496
84
85
  ```
85
86
 
86
- Phases: config ready → runtime plugins → route registry → server listening → first request SSR. The summary includes bundle count and total client JS bytes for that first request.
87
+ Phases: config ready → runtime plugins → route registry → server listening → first request SSR (with per-module `dev-client-transform` on demand for `/assets/__eco_dev__/` modules). The summary includes bundle count and total client JS bytes for that first request.
87
88
 
88
89
  ```bash
89
90
  # Focused perf trace only
package/bin/cli.js CHANGED
@@ -1,335 +1,387 @@
1
1
  #!/usr/bin/env node
2
- import { downloadTemplate } from "giget";
3
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
4
- import { spawn } from "node:child_process";
5
- import { join } from "node:path";
6
- import { parseArgs } from "node:util";
7
- import { Logger } from "@ecopages/logger";
8
- import { createLaunchPlan } from "./launch-plan.js";
9
- const logger = new Logger("[ecopages:cli]", { debug: process.env.ECOPAGES_LOGGER_DEBUG === "true" });
10
- const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf-8"));
2
+
3
+ import { downloadTemplate } from 'giget';
4
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
5
+ import { spawn } from 'node:child_process';
6
+ import { join } from 'node:path';
7
+ import { parseArgs } from 'node:util';
8
+ import { Logger } from '@ecopages/logger';
9
+ import { createLaunchPlan } from './launch-plan.js';
10
+
11
+ const logger = new Logger('[ecopages:cli]', { debug: process.env.ECOPAGES_LOGGER_DEBUG === 'true' });
12
+
13
+ const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf-8'));
14
+
11
15
  const sharedServerOptionDefinitions = {
12
- port: {
13
- type: "string",
14
- short: "p"
15
- },
16
- hostname: {
17
- type: "string",
18
- short: "n"
19
- },
20
- "base-url": {
21
- type: "string",
22
- short: "b"
23
- },
24
- debug: {
25
- type: "boolean",
26
- short: "d"
27
- },
28
- "react-fast-refresh": {
29
- type: "boolean",
30
- short: "r"
31
- },
32
- runtime: {
33
- type: "string"
34
- },
35
- "entry-file": {
36
- type: "string",
37
- short: "e"
38
- },
39
- help: {
40
- type: "boolean",
41
- short: "h"
42
- }
16
+ port: {
17
+ type: 'string',
18
+ short: 'p',
19
+ },
20
+ hostname: {
21
+ type: 'string',
22
+ short: 'n',
23
+ },
24
+ 'base-url': {
25
+ type: 'string',
26
+ short: 'b',
27
+ },
28
+ debug: {
29
+ type: 'boolean',
30
+ short: 'd',
31
+ },
32
+ 'react-fast-refresh': {
33
+ type: 'boolean',
34
+ short: 'r',
35
+ },
36
+ runtime: {
37
+ type: 'string',
38
+ },
39
+ 'entry-file': {
40
+ type: 'string',
41
+ short: 'e',
42
+ },
43
+ help: {
44
+ type: 'boolean',
45
+ short: 'h',
46
+ },
43
47
  };
48
+
44
49
  const initOptionDefinitions = {
45
- template: {
46
- type: "string"
47
- },
48
- repo: {
49
- type: "string"
50
- },
51
- help: {
52
- type: "boolean",
53
- short: "h"
54
- }
50
+ template: {
51
+ type: 'string',
52
+ },
53
+ repo: {
54
+ type: 'string',
55
+ },
56
+ help: {
57
+ type: 'boolean',
58
+ short: 'h',
59
+ },
55
60
  };
61
+
56
62
  function getMainHelpText() {
57
- return [
58
- `ecopages ${pkg.version}`,
59
- "",
60
- "Usage: ecopages <command> [options]",
61
- "",
62
- "Commands:",
63
- " init <dir> Initialize a new project from a template",
64
- " dev Start the development server",
65
- " dev:watch Start the development server with watch mode",
66
- " dev:hot Start the development server with hot reload",
67
- " build Build the project for production",
68
- " start Start the production server",
69
- " preview Preview the production build",
70
- "",
71
- "Global options:",
72
- " -e, --entry-file <file> Entry file (default: app.ts)",
73
- " -h, --help Show help",
74
- " --version Show version"
75
- ].join("\n");
63
+ return [
64
+ `ecopages ${pkg.version}`,
65
+ '',
66
+ 'Usage: ecopages <command> [options]',
67
+ '',
68
+ 'Commands:',
69
+ ' init <dir> Initialize a new project from a template',
70
+ ' dev Start the development server',
71
+ ' dev:watch Start the development server with watch mode',
72
+ ' dev:hot Start the development server with hot reload',
73
+ ' build Build the project for production',
74
+ ' start Start the production server',
75
+ ' preview Preview the production build',
76
+ '',
77
+ 'Global options:',
78
+ ' -e, --entry-file <file> Entry file (default: app.ts)',
79
+ ' -h, --help Show help',
80
+ ' --version Show version',
81
+ ].join('\n');
76
82
  }
83
+
77
84
  function getServerCommandHelpText(commandName, description) {
78
- return [
79
- `Usage: ecopages ${commandName} [options]`,
80
- "",
81
- description,
82
- "",
83
- "Options:",
84
- " -p, --port <port> Override ECOPAGES_PORT",
85
- " -n, --hostname <hostname> Override ECOPAGES_HOSTNAME",
86
- " -b, --base-url <baseUrl> Override ECOPAGES_BASE_URL",
87
- " -d, --debug Enable debug logging",
88
- " -r, --react-fast-refresh Enable React Fast Refresh for Bun HMR",
89
- " --runtime <runtime> Force bun or node",
90
- " -e, --entry-file <file> Entry file (default: app.ts)",
91
- " -h, --help Show help"
92
- ].join("\n");
85
+ return [
86
+ `Usage: ecopages ${commandName} [options]`,
87
+ '',
88
+ description,
89
+ '',
90
+ 'Options:',
91
+ ' -p, --port <port> Override ECOPAGES_PORT',
92
+ ' -n, --hostname <hostname> Override ECOPAGES_HOSTNAME',
93
+ ' -b, --base-url <baseUrl> Override ECOPAGES_BASE_URL',
94
+ ' -d, --debug Enable debug logging',
95
+ ' -r, --react-fast-refresh Enable React Fast Refresh for Bun HMR',
96
+ ' --runtime <runtime> Force bun or node',
97
+ ' -e, --entry-file <file> Entry file (default: app.ts)',
98
+ ' -h, --help Show help',
99
+ ].join('\n');
93
100
  }
101
+
94
102
  function getBuildCommandHelpText() {
95
- return [
96
- "Usage: ecopages build [options]",
97
- "",
98
- "Build the project for production.",
99
- "",
100
- "Options:",
101
- " -p, --port <port> Override ECOPAGES_PORT",
102
- " -n, --hostname <hostname> Override ECOPAGES_HOSTNAME",
103
- " -b, --base-url <baseUrl> Override ECOPAGES_BASE_URL",
104
- " -d, --debug Enable debug logging",
105
- " -r, --react-fast-refresh Enable React Fast Refresh for Bun HMR",
106
- " --runtime <runtime> Force bun or node",
107
- " -e, --entry-file <file> Entry file (default: app.ts)",
108
- " -h, --help Show help"
109
- ].join("\n");
103
+ return [
104
+ 'Usage: ecopages build [options]',
105
+ '',
106
+ 'Build the project for production.',
107
+ '',
108
+ 'Options:',
109
+ ' -p, --port <port> Override ECOPAGES_PORT',
110
+ ' -n, --hostname <hostname> Override ECOPAGES_HOSTNAME',
111
+ ' -b, --base-url <baseUrl> Override ECOPAGES_BASE_URL',
112
+ ' -d, --debug Enable debug logging',
113
+ ' -r, --react-fast-refresh Enable React Fast Refresh for Bun HMR',
114
+ ' --runtime <runtime> Force bun or node',
115
+ ' -e, --entry-file <file> Entry file (default: app.ts)',
116
+ ' -h, --help Show help',
117
+ ].join('\n');
110
118
  }
119
+
111
120
  function getInitCommandHelpText() {
112
- return [
113
- "Usage: ecopages init <dir> [options]",
114
- "",
115
- "Initialize a new project from a template.",
116
- "",
117
- "Options:",
118
- " --template <template> Template name from ecopages/examples/",
119
- " --repo <repo> GitHub repo in user/repo form",
120
- " -h, --help Show help"
121
- ].join("\n");
121
+ return [
122
+ 'Usage: ecopages init <dir> [options]',
123
+ '',
124
+ 'Initialize a new project from a template.',
125
+ '',
126
+ 'Options:',
127
+ ' --template <template> Template name from ecopages/examples/',
128
+ ' --repo <repo> GitHub repo in user/repo form',
129
+ ' -h, --help Show help',
130
+ ].join('\n');
122
131
  }
132
+
123
133
  function parseCommandArguments(rawArgs, options) {
124
- return parseArgs({
125
- args: rawArgs,
126
- options,
127
- allowPositionals: true,
128
- strict: true
129
- });
134
+ return parseArgs({
135
+ args: rawArgs,
136
+ options,
137
+ allowPositionals: true,
138
+ strict: true,
139
+ });
130
140
  }
131
- function parseServerCommandArgs(rawArgs, commandName, description, mode = "server") {
132
- const { values, positionals } = parseCommandArguments(rawArgs, sharedServerOptionDefinitions);
133
- if (values.help) {
134
- console.log(mode === "build" ? getBuildCommandHelpText() : getServerCommandHelpText(commandName, description));
135
- return { help: true };
136
- }
137
- if (positionals.length > 0) {
138
- throw new Error(
139
- `Positional entry file arguments are not supported for \`${commandName}\`. Use --entry-file <file> instead.`
140
- );
141
- }
142
- const entry = values["entry-file"] ?? "app.ts";
143
- return {
144
- entry,
145
- options: {
146
- port: values.port,
147
- hostname: values.hostname,
148
- baseUrl: values["base-url"],
149
- debug: values.debug,
150
- reactFastRefresh: values["react-fast-refresh"],
151
- runtime: values.runtime
152
- }
153
- };
141
+
142
+ function parseServerCommandArgs(rawArgs, commandName, description, mode = 'server') {
143
+ const { values, positionals } = parseCommandArguments(rawArgs, sharedServerOptionDefinitions);
144
+
145
+ if (values.help) {
146
+ console.log(mode === 'build' ? getBuildCommandHelpText() : getServerCommandHelpText(commandName, description));
147
+ return { help: true };
148
+ }
149
+
150
+ if (positionals.length > 0) {
151
+ throw new Error(
152
+ `Positional entry file arguments are not supported for \`${commandName}\`. Use --entry-file <file> instead.`,
153
+ );
154
+ }
155
+
156
+ const entry = values['entry-file'] ?? 'app.ts';
157
+
158
+ return {
159
+ entry,
160
+ options: {
161
+ port: values.port,
162
+ hostname: values.hostname,
163
+ baseUrl: values['base-url'],
164
+ debug: values.debug,
165
+ reactFastRefresh: values['react-fast-refresh'],
166
+ runtime: values.runtime,
167
+ },
168
+ };
154
169
  }
170
+
155
171
  function parseInitCommandArgs(rawArgs) {
156
- const { values, positionals } = parseCommandArguments(rawArgs, initOptionDefinitions);
157
- if (values.help) {
158
- console.log(getInitCommandHelpText());
159
- return { help: true };
160
- }
161
- if (positionals.length !== 1) {
162
- throw new Error("The `init` command requires exactly one target directory argument.");
163
- }
164
- return {
165
- dir: positionals[0],
166
- template: values.template ?? "starter-jsx",
167
- repo: values.repo ?? "ecopages/ecopages"
168
- };
172
+ const { values, positionals } = parseCommandArguments(rawArgs, initOptionDefinitions);
173
+
174
+ if (values.help) {
175
+ console.log(getInitCommandHelpText());
176
+ return { help: true };
177
+ }
178
+
179
+ if (positionals.length !== 1) {
180
+ throw new Error('The `init` command requires exactly one target directory argument.');
181
+ }
182
+
183
+ return {
184
+ dir: positionals[0],
185
+ template: values.template ?? 'starter-jsx',
186
+ repo: values.repo ?? 'ecopages/ecopages',
187
+ };
169
188
  }
189
+
170
190
  function runLaunchPlan(launchPlan) {
171
- if (Object.keys(launchPlan.envOverrides).length > 0) {
172
- logger.debug(`Environment overrides: ${JSON.stringify(launchPlan.envOverrides)}`);
173
- }
174
- logger.debug(`Runtime: ${launchPlan.runtime}`);
175
- logger.debug(`Running: ${launchPlan.command} ${launchPlan.commandArgs.join(" ")}`);
176
- const child = spawn(launchPlan.command, launchPlan.commandArgs, {
177
- stdio: "inherit",
178
- env: launchPlan.env
179
- });
180
- child.on("error", (error) => {
181
- if (error && error.code === "ENOENT") {
182
- const hint = launchPlan.runtime === "bun" ? "Install Bun from https://bun.sh to continue." : "Reinstall Node.js or run with --runtime bun if this app requires Bun.";
183
- logger.error(`Command not found: ${launchPlan.command}. ${hint}`);
184
- process.exit(1);
185
- }
186
- logger.error(`Failed to run command: ${error.message}`);
187
- process.exit(1);
188
- });
189
- child.on("exit", (code) => {
190
- process.exit(code || 0);
191
- });
191
+ if (Object.keys(launchPlan.envOverrides).length > 0) {
192
+ logger.debug(`Environment overrides: ${JSON.stringify(launchPlan.envOverrides)}`);
193
+ }
194
+
195
+ logger.debug(`Runtime: ${launchPlan.runtime}`);
196
+ logger.debug(`Running: ${launchPlan.command} ${launchPlan.commandArgs.join(' ')}`);
197
+
198
+ const child = spawn(launchPlan.command, launchPlan.commandArgs, {
199
+ stdio: 'inherit',
200
+ env: launchPlan.env,
201
+ });
202
+
203
+ child.on('error', (error) => {
204
+ if (error && error.code === 'ENOENT') {
205
+ const hint =
206
+ launchPlan.runtime === 'bun'
207
+ ? 'Install Bun from https://bun.sh to continue.'
208
+ : 'Reinstall Node.js or run with --runtime bun if this app requires Bun.';
209
+ logger.error(`Command not found: ${launchPlan.command}. ${hint}`);
210
+ process.exit(1);
211
+ }
212
+
213
+ logger.error(`Failed to run command: ${error.message}`);
214
+ process.exit(1);
215
+ });
216
+
217
+ child.on('exit', (code) => {
218
+ process.exit(code || 0);
219
+ });
192
220
  }
193
- async function runEntryCommand(args, options = {}, entryFile = "app.ts", launchMode = "start") {
194
- let launchPlan;
195
- const requiresBuiltBundle = launchMode === "start";
196
- if (!requiresBuiltBundle && !existsSync(entryFile)) {
197
- logger.error(`Error: Entry file "${entryFile}" not found in the current directory.`);
198
- process.exit(1);
199
- }
200
- try {
201
- launchPlan = await createLaunchPlan(args, options, entryFile, launchMode);
202
- } catch (error) {
203
- const message = error instanceof Error ? error.message : String(error);
204
- logger.error(message);
205
- process.exit(1);
206
- }
207
- runLaunchPlan(launchPlan);
221
+
222
+ /**
223
+ * Launch the entry file via the detected or forced runtime.
224
+ * Node uses native Node semantics; Bun uses Bun runtime flags.
225
+ * @param {string[]} args - Arguments to pass to the entry file
226
+ * @param {object} options - CLI options (watch, hot, port, hostname, etc.)
227
+ * @param {string} entryFile - Entry file to run
228
+ */
229
+ async function runEntryCommand(args, options = {}, entryFile = 'app.ts', launchMode = 'start') {
230
+ let launchPlan;
231
+ const requiresBuiltBundle = launchMode === 'start';
232
+
233
+ if (!requiresBuiltBundle && !existsSync(entryFile)) {
234
+ logger.error(`Error: Entry file "${entryFile}" not found in the current directory.`);
235
+ process.exit(1);
236
+ }
237
+
238
+ try {
239
+ launchPlan = await createLaunchPlan(args, options, entryFile, launchMode);
240
+ } catch (error) {
241
+ const message = error instanceof Error ? error.message : String(error);
242
+ logger.error(message);
243
+ process.exit(1);
244
+ }
245
+
246
+ runLaunchPlan(launchPlan);
208
247
  }
248
+
209
249
  async function runInitCommand(rawArgs) {
210
- const parsed = parseInitCommandArgs(rawArgs);
211
- if (parsed.help) {
212
- return;
213
- }
214
- const { dir, template, repo } = parsed;
215
- if (existsSync(dir)) {
216
- logger.error(`Target directory already exists: ${dir}`);
217
- process.exit(1);
218
- }
219
- logger.info(`Creating target directory '${dir}'...`);
220
- try {
221
- await downloadTemplate(`github:${repo}/examples/${template}`, {
222
- dir,
223
- force: true
224
- });
225
- const pkgPath = join(dir, "package.json");
226
- if (existsSync(pkgPath)) {
227
- const projectPkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
228
- projectPkg.name = dir;
229
- writeFileSync(pkgPath, JSON.stringify(projectPkg, null, 2) + "\n");
230
- logger.info(`Renamed project to '${dir}'`);
231
- }
232
- logger.info("Project initialized! Run `bun install && bun dev` to start.");
233
- } catch (error) {
234
- const message = error instanceof Error ? error.message : String(error);
235
- logger.error(`Failed to fetch template: ${message}`);
236
- process.exit(1);
237
- }
250
+ const parsed = parseInitCommandArgs(rawArgs);
251
+
252
+ if (parsed.help) {
253
+ return;
254
+ }
255
+
256
+ const { dir, template, repo } = parsed;
257
+
258
+ if (existsSync(dir)) {
259
+ logger.error(`Target directory already exists: ${dir}`);
260
+ process.exit(1);
261
+ }
262
+
263
+ logger.info(`Creating target directory '${dir}'...`);
264
+
265
+ try {
266
+ await downloadTemplate(`github:${repo}/examples/${template}`, {
267
+ dir,
268
+ force: true,
269
+ });
270
+
271
+ const pkgPath = join(dir, 'package.json');
272
+ if (existsSync(pkgPath)) {
273
+ const projectPkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
274
+ projectPkg.name = dir;
275
+ writeFileSync(pkgPath, JSON.stringify(projectPkg, null, 2) + '\n');
276
+ logger.info(`Renamed project to '${dir}'`);
277
+ }
278
+
279
+ logger.info('Project initialized! Run `bun install && bun dev` to start.');
280
+ } catch (error) {
281
+ const message = error instanceof Error ? error.message : String(error);
282
+ logger.error(`Failed to fetch template: ${message}`);
283
+ process.exit(1);
284
+ }
238
285
  }
286
+
239
287
  async function runServerCommand(rawArgs, definition) {
240
- const parsed = parseServerCommandArgs(rawArgs, definition.name, definition.description, definition.mode);
241
- if (parsed.help) {
242
- return;
243
- }
244
- await runEntryCommand(
245
- definition.entryArgs,
246
- { ...parsed.options, ...definition.optionOverrides, entryFile: parsed.entry },
247
- parsed.entry,
248
- definition.launchMode ?? definition.name
249
- );
288
+ const parsed = parseServerCommandArgs(rawArgs, definition.name, definition.description, definition.mode);
289
+
290
+ if (parsed.help) {
291
+ return;
292
+ }
293
+
294
+ await runEntryCommand(
295
+ definition.entryArgs,
296
+ { ...parsed.options, ...definition.optionOverrides, entryFile: parsed.entry },
297
+ parsed.entry,
298
+ definition.launchMode ?? definition.name,
299
+ );
250
300
  }
251
- async function runCli(rawArgs = process.argv.slice(2)) {
252
- const [commandName, ...commandArgs] = rawArgs;
253
- if (!commandName || commandName === "--help" || commandName === "-h") {
254
- console.log(getMainHelpText());
255
- return;
256
- }
257
- if (commandName === "--version") {
258
- console.log(pkg.version);
259
- return;
260
- }
261
- try {
262
- switch (commandName) {
263
- case "init":
264
- await runInitCommand(commandArgs);
265
- return;
266
- case "dev":
267
- await runServerCommand(commandArgs, {
268
- name: "dev",
269
- description: "Start the development server.",
270
- entryArgs: ["--dev"],
271
- launchMode: "dev",
272
- optionOverrides: { nodeEnv: "development" }
273
- });
274
- return;
275
- case "dev:watch":
276
- await runServerCommand(commandArgs, {
277
- name: "dev:watch",
278
- description: "Start the development server with watch mode.",
279
- entryArgs: ["--dev"],
280
- launchMode: "dev",
281
- optionOverrides: { watch: true, nodeEnv: "development" }
282
- });
283
- return;
284
- case "dev:hot":
285
- await runServerCommand(commandArgs, {
286
- name: "dev:hot",
287
- description: "Start the development server with hot reload.",
288
- entryArgs: ["--dev"],
289
- launchMode: "dev",
290
- optionOverrides: { hot: true, nodeEnv: "development" }
291
- });
292
- return;
293
- case "build":
294
- await runServerCommand(commandArgs, {
295
- name: "build",
296
- description: "Build the project for production.",
297
- entryArgs: ["--build"],
298
- launchMode: "build",
299
- optionOverrides: { nodeEnv: "production" },
300
- mode: "build"
301
- });
302
- return;
303
- case "start":
304
- await runServerCommand(commandArgs, {
305
- name: "start",
306
- description: "Start the production server.",
307
- entryArgs: [],
308
- launchMode: "start",
309
- optionOverrides: { nodeEnv: "production" }
310
- });
311
- return;
312
- case "preview":
313
- await runServerCommand(commandArgs, {
314
- name: "preview",
315
- description: "Preview the production build.",
316
- entryArgs: ["--preview"],
317
- launchMode: "preview",
318
- optionOverrides: { nodeEnv: "production" }
319
- });
320
- return;
321
- default:
322
- throw new Error(`Unknown command \`${commandName}\`.`);
323
- }
324
- } catch (error) {
325
- const message = error instanceof Error ? error.message : String(error);
326
- logger.error(message);
327
- process.exit(1);
328
- }
301
+
302
+ export async function runCli(rawArgs = process.argv.slice(2)) {
303
+ const [commandName, ...commandArgs] = rawArgs;
304
+
305
+ if (!commandName || commandName === '--help' || commandName === '-h') {
306
+ console.log(getMainHelpText());
307
+ return;
308
+ }
309
+
310
+ if (commandName === '--version') {
311
+ console.log(pkg.version);
312
+ return;
313
+ }
314
+
315
+ try {
316
+ switch (commandName) {
317
+ case 'init':
318
+ await runInitCommand(commandArgs);
319
+ return;
320
+ case 'dev':
321
+ await runServerCommand(commandArgs, {
322
+ name: 'dev',
323
+ description: 'Start the development server.',
324
+ entryArgs: ['--dev'],
325
+ launchMode: 'dev',
326
+ optionOverrides: { nodeEnv: 'development' },
327
+ });
328
+ return;
329
+ case 'dev:watch':
330
+ await runServerCommand(commandArgs, {
331
+ name: 'dev:watch',
332
+ description: 'Start the development server with watch mode.',
333
+ entryArgs: ['--dev'],
334
+ launchMode: 'dev',
335
+ optionOverrides: { watch: true, nodeEnv: 'development' },
336
+ });
337
+ return;
338
+ case 'dev:hot':
339
+ await runServerCommand(commandArgs, {
340
+ name: 'dev:hot',
341
+ description: 'Start the development server with hot reload.',
342
+ entryArgs: ['--dev'],
343
+ launchMode: 'dev',
344
+ optionOverrides: { hot: true, nodeEnv: 'development' },
345
+ });
346
+ return;
347
+ case 'build':
348
+ await runServerCommand(commandArgs, {
349
+ name: 'build',
350
+ description: 'Build the project for production.',
351
+ entryArgs: ['--build'],
352
+ launchMode: 'build',
353
+ optionOverrides: { nodeEnv: 'production' },
354
+ mode: 'build',
355
+ });
356
+ return;
357
+ case 'start':
358
+ await runServerCommand(commandArgs, {
359
+ name: 'start',
360
+ description: 'Start the production server.',
361
+ entryArgs: [],
362
+ launchMode: 'start',
363
+ optionOverrides: { nodeEnv: 'production' },
364
+ });
365
+ return;
366
+ case 'preview':
367
+ await runServerCommand(commandArgs, {
368
+ name: 'preview',
369
+ description: 'Preview the production build.',
370
+ entryArgs: ['--preview'],
371
+ launchMode: 'preview',
372
+ optionOverrides: { nodeEnv: 'production' },
373
+ });
374
+ return;
375
+ default:
376
+ throw new Error(`Unknown command \`${commandName}\`.`);
377
+ }
378
+ } catch (error) {
379
+ const message = error instanceof Error ? error.message : String(error);
380
+ logger.error(message);
381
+ process.exit(1);
382
+ }
329
383
  }
384
+
330
385
  if (!process.env.VITEST) {
331
- runCli();
386
+ runCli();
332
387
  }
333
- export {
334
- runCli
335
- };
@@ -1,156 +1,206 @@
1
- import { existsSync, readFileSync } from "node:fs";
2
- import path from "node:path";
3
- import { parseEnv } from "node:util";
4
- import { SERVER_BUNDLE_DIR, SERVER_BUNDLE_FILENAME } from "@ecopages/core/utils/resolve-entry-file";
5
- const SERVER_BUNDLE_MANIFEST_FILENAME = "manifest.json";
6
- const nodeRequirePreload = import.meta.resolve("./node-require-preload.js");
7
- const tsxLoader = import.meta.resolve("tsx/esm");
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { parseEnv } from 'node:util';
4
+ import { SERVER_BUNDLE_DIR, SERVER_BUNDLE_FILENAME } from '@ecopages/core/utils/resolve-entry-file';
5
+
6
+ const SERVER_BUNDLE_MANIFEST_FILENAME = 'manifest.json';
7
+
8
+ const nodeRequirePreload = import.meta.resolve('./node-require-preload.js');
9
+ const tsxLoader = import.meta.resolve('tsx/esm');
10
+
8
11
  function getEnvFilePaths(nodeEnv) {
9
- const envFiles = [".env", ".env.local"];
10
- if (nodeEnv) {
11
- envFiles.push(`.env.${nodeEnv}`, `.env.${nodeEnv}.local`);
12
- }
13
- return envFiles.filter((envFile) => existsSync(envFile));
12
+ const envFiles = ['.env', '.env.local'];
13
+
14
+ if (nodeEnv) {
15
+ envFiles.push(`.env.${nodeEnv}`, `.env.${nodeEnv}.local`);
16
+ }
17
+
18
+ return envFiles.filter((envFile) => existsSync(envFile));
14
19
  }
15
- function buildEnvOverrides(options) {
16
- const env = {};
17
- if (options.port) env.ECOPAGES_PORT = String(options.port);
18
- if (options.hostname) env.ECOPAGES_HOSTNAME = options.hostname;
19
- if (options.baseUrl) env.ECOPAGES_BASE_URL = options.baseUrl;
20
- if (options.debug) env.ECOPAGES_LOGGER_DEBUG = "true";
21
- if (options.nodeEnv) env.NODE_ENV = options.nodeEnv;
22
- if (options.entryFile) env.ECOPAGES_ENTRY_FILE = options.entryFile;
23
- return env;
20
+
21
+ export function buildEnvOverrides(options) {
22
+ const env = {};
23
+ if (options.port) env.ECOPAGES_PORT = String(options.port);
24
+ if (options.hostname) env.ECOPAGES_HOSTNAME = options.hostname;
25
+ if (options.baseUrl) env.ECOPAGES_BASE_URL = options.baseUrl;
26
+ if (options.debug) env.ECOPAGES_LOGGER_DEBUG = 'true';
27
+ if (options.nodeEnv) env.NODE_ENV = options.nodeEnv;
28
+ if (options.entryFile) env.ECOPAGES_ENTRY_FILE = options.entryFile;
29
+ return env;
24
30
  }
25
- function buildLaunchEnv(options) {
26
- const envOverrides = buildEnvOverrides(options);
27
- const envFileValues = getEnvFilePaths(options.nodeEnv).reduce((env, envFile) => {
28
- return { ...env, ...parseEnv(readFileSync(envFile, "utf8")) };
29
- }, {});
30
- return {
31
- envOverrides,
32
- env: { ...envFileValues, ...process.env, ...envOverrides }
33
- };
31
+
32
+ export function buildLaunchEnv(options) {
33
+ const envOverrides = buildEnvOverrides(options);
34
+ const envFileValues = getEnvFilePaths(options.nodeEnv).reduce((env, envFile) => {
35
+ return { ...env, ...parseEnv(readFileSync(envFile, 'utf8')) };
36
+ }, {});
37
+
38
+ return {
39
+ envOverrides,
40
+ env: { ...envFileValues, ...process.env, ...envOverrides },
41
+ };
34
42
  }
35
- function detectRuntime(options = {}) {
36
- if (options.runtime === "bun" || options.runtime === "node") {
37
- return options.runtime;
38
- }
39
- const userAgent = process.env.npm_config_user_agent || "";
40
- if (userAgent.startsWith("bun/")) {
41
- return "bun";
42
- }
43
- if (typeof Bun !== "undefined") {
44
- return "bun";
45
- }
46
- return "node";
43
+
44
+ export function detectRuntime(options = {}) {
45
+ if (options.runtime === 'bun' || options.runtime === 'node') {
46
+ return options.runtime;
47
+ }
48
+
49
+ const userAgent = process.env.npm_config_user_agent || '';
50
+
51
+ if (userAgent.startsWith('bun/')) {
52
+ return 'bun';
53
+ }
54
+
55
+ if (typeof Bun !== 'undefined') {
56
+ return 'bun';
57
+ }
58
+
59
+ return 'node';
47
60
  }
48
- function buildBunArgs(args, options, entryFile, hasConfig) {
49
- const bunArgs = [];
50
- if (options.watch) bunArgs.push("--watch");
51
- if (options.hot) bunArgs.push("--hot");
52
- bunArgs.push("run");
53
- if (hasConfig) {
54
- bunArgs.push("--preload", `./eco.config.${"ts"}`);
55
- }
56
- bunArgs.push(entryFile, ...args);
57
- if (options.reactFastRefresh) {
58
- bunArgs.push("--react-fast-refresh");
59
- }
60
- return bunArgs;
61
+
62
+ export function buildBunArgs(args, options, entryFile, hasConfig) {
63
+ const bunArgs = [];
64
+
65
+ if (options.watch) bunArgs.push('--watch');
66
+ if (options.hot) bunArgs.push('--hot');
67
+
68
+ bunArgs.push('run');
69
+
70
+ if (hasConfig) {
71
+ bunArgs.push('--preload', `./eco.config.${'ts'}`);
72
+ }
73
+
74
+ bunArgs.push(entryFile, ...args);
75
+
76
+ if (options.reactFastRefresh) {
77
+ bunArgs.push('--react-fast-refresh');
78
+ }
79
+
80
+ return bunArgs;
61
81
  }
82
+
62
83
  function usesProductionBundle(launchMode) {
63
- return launchMode === "start";
84
+ return launchMode === 'start';
64
85
  }
86
+
65
87
  function inferLaunchMode(args, launchMode) {
66
- if (launchMode) {
67
- return launchMode;
68
- }
69
- if (args.includes("--build")) {
70
- return "build";
71
- }
72
- if (args.includes("--preview")) {
73
- return "preview";
74
- }
75
- if (args.includes("--dev")) {
76
- return "dev";
77
- }
78
- return "start";
88
+ if (launchMode) {
89
+ return launchMode;
90
+ }
91
+
92
+ if (args.includes('--build')) {
93
+ return 'build';
94
+ }
95
+
96
+ if (args.includes('--preview')) {
97
+ return 'preview';
98
+ }
99
+
100
+ if (args.includes('--dev')) {
101
+ return 'dev';
102
+ }
103
+
104
+ return 'start';
79
105
  }
106
+
107
+ /**
108
+ * Builds the command and environment needed to launch the app.
109
+ *
110
+ * In `start` mode, the bundled server
111
+ * entry at `dist/{SERVER_BUNDLE_DIR}/{SERVER_BUNDLE_FILENAME}` is used
112
+ * directly. If the bundle is missing, an error is thrown directing the
113
+ * caller to run `ecopages build` first.
114
+ *
115
+ * In `build`, `dev`, and `preview` modes, the source entry file is executed via tsx
116
+ * (Node) or Bun's native runtime with the appropriate loader flags.
117
+ *
118
+ * @param {string[]} args - Arguments forwarded to the entry file.
119
+ * @param {object} options - CLI options (nodeEnv, runtime, port, etc.).
120
+ * @param {string} entryFile - Path to the entry file (default: `app.ts`).
121
+ * @param {'build' | 'dev' | 'preview' | 'start'} launchMode - The CLI command mode.
122
+ * @returns {{ runtime: string, command: string, commandArgs: string[], envOverrides: object, env: object }}
123
+ * @throws {Error} When `launchMode` is `start` and the bundle does not exist.
124
+ */
125
+
80
126
  function resolveProductionServerEntry(cwd = process.cwd()) {
81
- const manifestPath = path.join(cwd, "dist", SERVER_BUNDLE_DIR, SERVER_BUNDLE_MANIFEST_FILENAME);
82
- if (existsSync(manifestPath)) {
83
- try {
84
- const parsed = JSON.parse(readFileSync(manifestPath, "utf8"));
85
- if (parsed?.serverEntry) {
86
- const fromManifest = path.isAbsolute(parsed.serverEntry) ? parsed.serverEntry : path.join(path.dirname(manifestPath), parsed.serverEntry);
87
- if (existsSync(fromManifest)) {
88
- return fromManifest;
89
- }
90
- }
91
- if (parsed?.distDir) {
92
- const fromDistDir = path.join(parsed.distDir, SERVER_BUNDLE_DIR, SERVER_BUNDLE_FILENAME);
93
- if (existsSync(fromDistDir)) {
94
- return fromDistDir;
95
- }
96
- }
97
- } catch {
98
- }
99
- }
100
- const legacyPath = path.join(cwd, "dist", SERVER_BUNDLE_DIR, SERVER_BUNDLE_FILENAME);
101
- return existsSync(legacyPath) ? legacyPath : void 0;
127
+ const manifestPath = path.join(cwd, 'dist', SERVER_BUNDLE_DIR, SERVER_BUNDLE_MANIFEST_FILENAME);
128
+ if (existsSync(manifestPath)) {
129
+ try {
130
+ const parsed = JSON.parse(readFileSync(manifestPath, 'utf8'));
131
+ if (parsed?.serverEntry) {
132
+ const fromManifest = path.isAbsolute(parsed.serverEntry)
133
+ ? parsed.serverEntry
134
+ : path.join(path.dirname(manifestPath), parsed.serverEntry);
135
+ if (existsSync(fromManifest)) {
136
+ return fromManifest;
137
+ }
138
+ }
139
+ if (parsed?.distDir) {
140
+ const fromDistDir = path.join(parsed.distDir, SERVER_BUNDLE_DIR, SERVER_BUNDLE_FILENAME);
141
+ if (existsSync(fromDistDir)) {
142
+ return fromDistDir;
143
+ }
144
+ }
145
+ } catch {
146
+ // fall through to legacy path
147
+ }
148
+ }
149
+
150
+ const legacyPath = path.join(cwd, 'dist', SERVER_BUNDLE_DIR, SERVER_BUNDLE_FILENAME);
151
+ return existsSync(legacyPath) ? legacyPath : undefined;
102
152
  }
103
- function createLaunchPlan(args, options, entryFile, launchMode) {
104
- const resolvedOptions = options ?? {};
105
- const resolvedEntryFile = entryFile ?? "app.ts";
106
- const { envOverrides, env } = buildLaunchEnv(resolvedOptions);
107
- const runtime = detectRuntime(resolvedOptions);
108
- const resolvedLaunchMode = inferLaunchMode(args, launchMode);
109
- const distServerApp = resolveProductionServerEntry(process.cwd());
110
- const shouldUseBundle = usesProductionBundle(resolvedLaunchMode);
111
- const useBundle = shouldUseBundle && Boolean(distServerApp);
112
- if (shouldUseBundle && !useBundle) {
113
- throw new Error("No production bundle found. Run `ecopages build` before starting in production mode.");
114
- }
115
- if (useBundle) {
116
- if (runtime === "node") {
117
- return {
118
- runtime,
119
- command: process.execPath,
120
- commandArgs: [distServerApp, ...args],
121
- envOverrides,
122
- env
123
- };
124
- }
125
- return {
126
- runtime,
127
- command: "bun",
128
- commandArgs: ["run", distServerApp, ...args],
129
- envOverrides,
130
- env
131
- };
132
- }
133
- if (runtime === "node") {
134
- return {
135
- runtime,
136
- command: process.execPath,
137
- commandArgs: ["--import", nodeRequirePreload, "--import", tsxLoader, resolvedEntryFile, ...args],
138
- envOverrides,
139
- env
140
- };
141
- }
142
- return {
143
- runtime,
144
- command: "bun",
145
- commandArgs: buildBunArgs(args, resolvedOptions, resolvedEntryFile, existsSync("eco.config.ts")),
146
- envOverrides,
147
- env
148
- };
153
+
154
+ export function createLaunchPlan(args, options, entryFile, launchMode) {
155
+ const resolvedOptions = options ?? {};
156
+ const resolvedEntryFile = entryFile ?? 'app.ts';
157
+ const { envOverrides, env } = buildLaunchEnv(resolvedOptions);
158
+ const runtime = detectRuntime(resolvedOptions);
159
+ const resolvedLaunchMode = inferLaunchMode(args, launchMode);
160
+
161
+ const distServerApp = resolveProductionServerEntry(process.cwd());
162
+ const shouldUseBundle = usesProductionBundle(resolvedLaunchMode);
163
+ const useBundle = shouldUseBundle && Boolean(distServerApp);
164
+
165
+ if (shouldUseBundle && !useBundle) {
166
+ throw new Error('No production bundle found. Run `ecopages build` before starting in production mode.');
167
+ }
168
+
169
+ if (useBundle) {
170
+ if (runtime === 'node') {
171
+ return {
172
+ runtime,
173
+ command: process.execPath,
174
+ commandArgs: [distServerApp, ...args],
175
+ envOverrides,
176
+ env,
177
+ };
178
+ }
179
+
180
+ return {
181
+ runtime,
182
+ command: 'bun',
183
+ commandArgs: ['run', distServerApp, ...args],
184
+ envOverrides,
185
+ env,
186
+ };
187
+ }
188
+
189
+ if (runtime === 'node') {
190
+ return {
191
+ runtime,
192
+ command: process.execPath,
193
+ commandArgs: ['--import', nodeRequirePreload, '--import', tsxLoader, resolvedEntryFile, ...args],
194
+ envOverrides,
195
+ env,
196
+ };
197
+ }
198
+
199
+ return {
200
+ runtime,
201
+ command: 'bun',
202
+ commandArgs: buildBunArgs(args, resolvedOptions, resolvedEntryFile, existsSync('eco.config.ts')),
203
+ envOverrides,
204
+ env,
205
+ };
149
206
  }
150
- export {
151
- buildBunArgs,
152
- buildEnvOverrides,
153
- buildLaunchEnv,
154
- createLaunchPlan,
155
- detectRuntime
156
- };
@@ -1,3 +1,4 @@
1
- import path from "node:path";
2
- import { createRequire } from "node:module";
3
- globalThis.require = createRequire(path.join(process.cwd(), "package.json"));
1
+ import path from 'node:path';
2
+ import { createRequire } from 'node:module';
3
+
4
+ globalThis.require = createRequire(path.join(process.cwd(), 'package.json'));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ecopages",
3
- "version": "0.2.0-beta.31",
3
+ "version": "0.2.0-beta.33",
4
4
  "description": "CLI utilities for Ecopages",
5
5
  "type": "module",
6
6
  "engines": {
@@ -32,7 +32,7 @@
32
32
  "ecopages": "bin/cli.js"
33
33
  },
34
34
  "dependencies": {
35
- "@ecopages/core": "0.2.0-beta.31",
35
+ "@ecopages/core": "0.2.0-beta.33",
36
36
  "@ecopages/logger": "^0.2.3",
37
37
  "giget": "^2.0.0",
38
38
  "tsx": "^4.22.3"