ecopages 0.2.0-beta.4 → 0.2.0-beta.40

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
@@ -34,14 +34,14 @@ bun dev
34
34
 
35
35
  Server and build commands accept the following options. They automatically map to the equivalent environment variables for the underlying process:
36
36
 
37
- | Option | Env Var | Description |
38
- | :------------------------- | :---------------------- | :---------------------------------- |
39
- | `-p, --port <port>` | `ECOPAGES_PORT` | Server port (default 3000) |
40
- | `-n, --hostname <host>` | `ECOPAGES_HOSTNAME` | Server hostname |
41
- | `-b, --base-url <url>` | `ECOPAGES_BASE_URL` | Base URL string |
42
- | `-d, --debug` | `ECOPAGES_LOGGER_DEBUG` | Enables debug-level logging |
43
- | `-r, --react-fast-refresh` | | Enables React Fast Refresh |
44
- | `--runtime <runtime>` | | Force execution via `bun` or `node` |
37
+ | Option | Env Var | Description |
38
+ | :------------------------- | :---------------------- | :-------------------------------------------------------- |
39
+ | `-p, --port <port>` | `ECOPAGES_PORT` | Server port (default 3000) |
40
+ | `-n, --hostname <host>` | `ECOPAGES_HOSTNAME` | Server hostname |
41
+ | `-b, --base-url <url>` | `ECOPAGES_BASE_URL` | Base URL string |
42
+ | `-d, --debug` | `ECOPAGES_LOGGER_DEBUG` | Enables debug logging and startup phase trace (see below) |
43
+ | `-r, --react-fast-refresh` | | Enables React Fast Refresh |
44
+ | `--runtime <runtime>` | | Force execution via `bun` or `node` |
45
45
 
46
46
  ### Runtime Detection
47
47
 
@@ -63,6 +63,37 @@ ecopages dev --port 8080 --debug
63
63
  ecopages dev -r
64
64
  ```
65
65
 
66
+ ### Debug logging and startup trace
67
+
68
+ Set these in `.env` or on the command line when diagnosing slow dev startup or first page load.
69
+
70
+ | Env var | CLI | What you get |
71
+ | :---------------------------- | :--------------------- | :--------------------------------------------------------------------------------------------- |
72
+ | `ECOPAGES_LOGGER_DEBUG=true` | `ecopages dev --debug` | Verbose `[@ecopages/core]` logs across the stack, plus **startup phase trace** lines on stderr |
73
+ | `ECOPAGES_STARTUP_TRACE=true` | — | **Only** the phase trace (no extra debug noise). Useful when measuring first-open latency |
74
+
75
+ Trace lines are prefixed with `[ecopages:startup-trace]` and look like:
76
+
77
+ ```text
78
+ [ecopages:startup-trace] phase=config-ready wallMs=1271
79
+ [ecopages:startup-trace] phase=setupAppRuntimePlugins durationMs=714 wallMs=1999
80
+ [ecopages:startup-trace] phase=route-registry durationMs=1 wallMs=2000
81
+ [ecopages:startup-trace] phase=server-listen durationMs=45 wallMs=2046
82
+ [ecopages:startup-trace] phase=dev-client-transform durationMs=42 wallMs=2100
83
+ [ecopages:startup-trace] phase=first-request-ssr durationMs=5296 wallMs=12495
84
+ [ecopages:startup-trace] summary path=/docs/getting-started/introduction bundleCount=13 clientBundleBytes=858396 wallMs=12496
85
+ ```
86
+
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.
88
+
89
+ ```bash
90
+ # Focused perf trace only
91
+ ECOPAGES_STARTUP_TRACE=true pnpm dev
92
+
93
+ # Full debug + trace
94
+ ecopages dev --debug
95
+ ```
96
+
66
97
  ## Ecosystem & Plugins
67
98
 
68
99
  Ecopages relies on a modular architecture. Core logic and framework integrations are published as `@ecopages/*` packages on [npm](https://www.npmjs.com/org/ecopages).
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,132 +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 nodeRequirePreload = import.meta.resolve("./node-require-preload.js");
6
- 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
+
7
11
  function getEnvFilePaths(nodeEnv) {
8
- const envFiles = [".env", ".env.local"];
9
- if (nodeEnv) {
10
- envFiles.push(`.env.${nodeEnv}`, `.env.${nodeEnv}.local`);
11
- }
12
- 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));
13
19
  }
14
- function buildEnvOverrides(options) {
15
- const env = {};
16
- if (options.port) env.ECOPAGES_PORT = String(options.port);
17
- if (options.hostname) env.ECOPAGES_HOSTNAME = options.hostname;
18
- if (options.baseUrl) env.ECOPAGES_BASE_URL = options.baseUrl;
19
- if (options.debug) env.ECOPAGES_LOGGER_DEBUG = "true";
20
- if (options.nodeEnv) env.NODE_ENV = options.nodeEnv;
21
- if (options.entryFile) env.ECOPAGES_ENTRY_FILE = options.entryFile;
22
- 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;
23
30
  }
24
- function buildLaunchEnv(options) {
25
- const envOverrides = buildEnvOverrides(options);
26
- const envFileValues = getEnvFilePaths(options.nodeEnv).reduce((env, envFile) => {
27
- return { ...env, ...parseEnv(readFileSync(envFile, "utf8")) };
28
- }, {});
29
- return {
30
- envOverrides,
31
- env: { ...envFileValues, ...process.env, ...envOverrides }
32
- };
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
+ };
33
42
  }
34
- function detectRuntime(options = {}) {
35
- if (options.runtime === "bun" || options.runtime === "node") {
36
- return options.runtime;
37
- }
38
- const userAgent = process.env.npm_config_user_agent || "";
39
- if (userAgent.startsWith("bun/")) {
40
- return "bun";
41
- }
42
- if (typeof Bun !== "undefined") {
43
- return "bun";
44
- }
45
- 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';
46
60
  }
47
- function buildBunArgs(args, options, entryFile, hasConfig) {
48
- const bunArgs = [];
49
- if (options.watch) bunArgs.push("--watch");
50
- if (options.hot) bunArgs.push("--hot");
51
- bunArgs.push("run");
52
- if (hasConfig) {
53
- bunArgs.push("--preload", `./eco.config.${"ts"}`);
54
- }
55
- bunArgs.push(entryFile, ...args);
56
- if (options.reactFastRefresh) {
57
- bunArgs.push("--react-fast-refresh");
58
- }
59
- 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;
60
81
  }
82
+
61
83
  function usesProductionBundle(launchMode) {
62
- return launchMode === "start";
84
+ return launchMode === 'start';
63
85
  }
86
+
64
87
  function inferLaunchMode(args, launchMode) {
65
- if (launchMode) {
66
- return launchMode;
67
- }
68
- if (args.includes("--build")) {
69
- return "build";
70
- }
71
- if (args.includes("--preview")) {
72
- return "preview";
73
- }
74
- if (args.includes("--dev")) {
75
- return "dev";
76
- }
77
- 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';
78
105
  }
79
- function createLaunchPlan(args, options, entryFile, launchMode) {
80
- const resolvedOptions = options ?? {};
81
- const resolvedEntryFile = entryFile ?? "app.ts";
82
- const { envOverrides, env } = buildLaunchEnv(resolvedOptions);
83
- const runtime = detectRuntime(resolvedOptions);
84
- const resolvedLaunchMode = inferLaunchMode(args, launchMode);
85
- const distServerApp = path.join(process.cwd(), "dist", SERVER_BUNDLE_DIR, SERVER_BUNDLE_FILENAME);
86
- const shouldUseBundle = usesProductionBundle(resolvedLaunchMode);
87
- const useBundle = shouldUseBundle && existsSync(distServerApp);
88
- if (shouldUseBundle && !useBundle) {
89
- throw new Error("No production bundle found. Run `ecopages build` before starting in production mode.");
90
- }
91
- if (useBundle) {
92
- if (runtime === "node") {
93
- return {
94
- runtime,
95
- command: process.execPath,
96
- commandArgs: [distServerApp, ...args],
97
- envOverrides,
98
- env
99
- };
100
- }
101
- return {
102
- runtime,
103
- command: "bun",
104
- commandArgs: ["run", distServerApp, ...args],
105
- envOverrides,
106
- env
107
- };
108
- }
109
- if (runtime === "node") {
110
- return {
111
- runtime,
112
- command: process.execPath,
113
- commandArgs: ["--import", nodeRequirePreload, "--import", tsxLoader, resolvedEntryFile, ...args],
114
- envOverrides,
115
- env
116
- };
117
- }
118
- return {
119
- runtime,
120
- command: "bun",
121
- commandArgs: buildBunArgs(args, resolvedOptions, resolvedEntryFile, existsSync("eco.config.ts")),
122
- envOverrides,
123
- env
124
- };
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
+
126
+ function resolveProductionServerEntry(cwd = process.cwd()) {
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;
152
+ }
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
+ };
125
206
  }
126
- export {
127
- buildBunArgs,
128
- buildEnvOverrides,
129
- buildLaunchEnv,
130
- createLaunchPlan,
131
- detectRuntime
132
- };
@@ -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'));
@@ -5,14 +5,11 @@
5
5
  * @import 'ecopages/css/view-transitions.css';
6
6
  *
7
7
  * Works with both @ecopages/browser-router and @ecopages/react-router.
8
+ *
9
+ * Root opt-out (`html { view-transition-name: none }`) is injected by the router when
10
+ * view transitions are enabled — do not duplicate here.
8
11
  */
9
12
 
10
- /* Disable root animation to prevent full page flash during transitions */
11
- ::view-transition-old(root),
12
- ::view-transition-new(root) {
13
- animation: none;
14
- }
15
-
16
13
  /* Shared element transitions animate position/size smoothly */
17
14
  ::view-transition-group(*) {
18
15
  animation-duration: 180ms;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ecopages",
3
- "version": "0.2.0-beta.4",
3
+ "version": "0.2.0-beta.40",
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.4",
35
+ "@ecopages/core": "0.2.0-beta.40",
36
36
  "@ecopages/logger": "^0.2.3",
37
37
  "giget": "^2.0.0",
38
38
  "tsx": "^4.22.3"