ecopages 0.1.105 → 0.2.0-alpha.10
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 +58 -70
- package/bin/cli.js +83 -75
- package/bin/launch-plan.js +142 -0
- package/bin/launch-plan.test.ts +316 -0
- package/bin/node-thin-host.js +138 -0
- package/bin/node-thin-host.test.ts +167 -0
- package/package.json +5 -3
package/README.md
CHANGED
|
@@ -1,16 +1,12 @@
|
|
|
1
1
|
# ecopages
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
> This package is currently in a draft state and is subject to significant changes.
|
|
3
|
+
The official CLI for the Ecopages framework.
|
|
5
4
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
- **Project scaffolding**: Quickly initialize new Ecopages projects from templates using `bunx ecopages init`
|
|
9
|
-
- **Command utilities**: Namespaced commands that wrap common Bun operations, automatically detecting and applying your `eco.config.ts`
|
|
5
|
+
It provides scaffolding and development commands to streamline your workflow. It natively wraps your execution environment (Bun or Node) and automatically detects your `eco.config.ts`.
|
|
10
6
|
|
|
11
7
|
## Quick Start
|
|
12
8
|
|
|
13
|
-
Initialize a new project:
|
|
9
|
+
Initialize a new project from the default template:
|
|
14
10
|
|
|
15
11
|
```bash
|
|
16
12
|
bunx ecopages init my-app
|
|
@@ -19,93 +15,85 @@ bun install
|
|
|
19
15
|
bun dev
|
|
20
16
|
```
|
|
21
17
|
|
|
22
|
-
##
|
|
18
|
+
## Commands
|
|
23
19
|
|
|
24
|
-
|
|
20
|
+
| Command | Description | Equivalent (Bun) | Equivalent (Node) |
|
|
21
|
+
| :--------------------------- | :----------------------------------------- | :------------------------------ | :-------------------------------------------- |
|
|
22
|
+
| `ecopages init <dir>` | Scaffolds a new project | N/A | N/A |
|
|
23
|
+
| `ecopages dev [entry]` | Starts the dev server | `bun run [entry] --dev` | `node [ecopages thin host] [entry] --dev` |
|
|
24
|
+
| `ecopages dev:watch [entry]` | Dev server + hard restarts on file changes | `bun --watch run [entry] --dev` | `node --watch [ecopages thin host] ...` |
|
|
25
|
+
| `ecopages dev:hot [entry]` | Dev server + HMR (no hard restarts) | `bun --hot run [entry] --dev` | N/A |
|
|
26
|
+
| `ecopages build [entry]` | Creates a production build | `bun run [entry] --build` | `node [ecopages thin host] [entry] --build` |
|
|
27
|
+
| `ecopages start [entry]` | Starts the production server | `bun run [entry]` | `node [ecopages thin host] [entry]` |
|
|
28
|
+
| `ecopages preview [entry]` | Previews the production build locally | `bun run [entry] --preview` | `node [ecopages thin host] [entry] --preview` |
|
|
25
29
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
| `ecopages init <dir>` | Initialize a new Ecopages project | scaffolding tool |
|
|
29
|
-
| `ecopages dev [entry]` | Start the development server | `bun run [entry] --dev` |
|
|
30
|
-
| `ecopages dev:watch [entry]` | Start with watch mode (restarts on file changes) | `bun --watch run [entry] --dev` |
|
|
31
|
-
| `ecopages dev:hot [entry]` | Start with hot reload (HMR without restart) | `bun --hot run [entry] --dev` |
|
|
32
|
-
| `ecopages build [entry]` | Build for production | `bun run [entry] --build` |
|
|
33
|
-
| `ecopages start [entry]` | Start production server | `bun run [entry]` |
|
|
34
|
-
| `ecopages preview [entry]` | Preview production build | `bun run [entry] --preview` |
|
|
30
|
+
> [!NOTE]
|
|
31
|
+
> `[entry]` defaults to `app.ts` if not provided.
|
|
35
32
|
|
|
36
|
-
|
|
33
|
+
## Environment & Runtime Options
|
|
37
34
|
|
|
38
|
-
|
|
35
|
+
Server and build commands accept the following options. They automatically map to the equivalent environment variables for the underlying process:
|
|
39
36
|
|
|
40
|
-
|
|
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`, `node`, or `node-experimental` |
|
|
41
45
|
|
|
42
|
-
|
|
43
|
-
| :------------------------- | :---------------------- | :---------------------------- |
|
|
44
|
-
| `-p, --port <port>` | `ECOPAGES_PORT` | Server port (default 3000) |
|
|
45
|
-
| `-n, --hostname <host>` | `ECOPAGES_HOSTNAME` | Server hostname |
|
|
46
|
-
| `-b, --base-url <url>` | `ECOPAGES_BASE_URL` | Base URL for the app |
|
|
47
|
-
| `-d, --debug` | `ECOPAGES_LOGGER_DEBUG` | Enable debug logging |
|
|
48
|
-
| `-r, --react-fast-refresh` | - | Enable React Fast Refresh HMR |
|
|
46
|
+
### Runtime Detection
|
|
49
47
|
|
|
50
|
-
|
|
48
|
+
The CLI automatically detects your runtime environments by inspecting the package manager (`npm_config_user_agent`) or whether the `Bun` global exists. If neither is forcing Bun, it executes your app through the Ecopages-owned Node thin host.
|
|
51
49
|
|
|
52
|
-
|
|
53
|
-
# Start dev server on port 8080 with debug logging
|
|
54
|
-
ecopages dev --port 8080 --debug
|
|
50
|
+
Both `node` and `node-experimental` now launch through the thin-host boundary and hand off the same serialized Node runtime manifest. `node-experimental` remains available as an explicit verification alias while the unified Node host path continues to settle.
|
|
55
51
|
|
|
56
|
-
|
|
57
|
-
ecopages dev -r
|
|
52
|
+
You can explicitly force the engine using the `--runtime` flag:
|
|
58
53
|
|
|
59
|
-
|
|
60
|
-
ecopages
|
|
54
|
+
```bash
|
|
55
|
+
ecopages dev --runtime node
|
|
56
|
+
ecopages build --runtime bun
|
|
57
|
+
ecopages dev --runtime node-experimental
|
|
61
58
|
```
|
|
62
59
|
|
|
63
|
-
|
|
60
|
+
### Example Usage
|
|
64
61
|
|
|
65
|
-
|
|
62
|
+
```bash
|
|
63
|
+
# Debug dev server on custom port
|
|
64
|
+
ecopages dev --port 8080 --debug
|
|
66
65
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
import { kitajsPlugin } from '@ecopages/kitajs';
|
|
66
|
+
# Dev server with React Fast Refresh enabled
|
|
67
|
+
ecopages dev -r
|
|
70
68
|
```
|
|
71
69
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
| Package | Description | JSR Link |
|
|
75
|
-
| :-------------------------------- | :-------------------------------------------------------- | :---------------------------------------------------- |
|
|
76
|
-
| `@ecopages/browser-router` | Client-side navigation and view transitions for Ecopages. | [JSR](https://jsr.io/@ecopages/browser-router) |
|
|
77
|
-
| `@ecopages/bun-inline-css-plugin` | Bun plugin to process CSS files using CSS Processors. | [JSR](https://jsr.io/@ecopages/bun-inline-css-plugin) |
|
|
78
|
-
| `@ecopages/bun-mdx-kitajs-loader` | Bun loader to load MDX files with KitaJS. | [JSR](https://jsr.io/@ecopages/bun-mdx-kitajs-loader) |
|
|
79
|
-
| `@ecopages/bun-postcss-loader` | Bun loader to load PostCSS files. | [JSR](https://jsr.io/@ecopages/bun-postcss-loader) |
|
|
80
|
-
| `@ecopages/core` | Foundational layer of the Ecopages ecosystem. | [JSR](https://jsr.io/@ecopages/core) |
|
|
81
|
-
| `@ecopages/file-system` | Runtime-agnostic file system utilities (Bun/Node.js). | [JSR](https://jsr.io/@ecopages/file-system) |
|
|
82
|
-
| `@ecopages/image-processor` | Image processing library for optimized responsive images. | [JSR](https://jsr.io/@ecopages/image-processor) |
|
|
83
|
-
| `@ecopages/kitajs` | KitaJS plugin for Ecopages integration. | [JSR](https://jsr.io/@ecopages/kitajs) |
|
|
84
|
-
| `@ecopages/lit` | Lit plugin for Ecopages integration. | [JSR](https://jsr.io/@ecopages/lit) |
|
|
85
|
-
| `@ecopages/mdx` | MDX plugin for Ecopages integration. | [JSR](https://jsr.io/@ecopages/mdx) |
|
|
86
|
-
| `@ecopages/postcss-processor` | Utility functions for processing CSS with PostCSS. | [JSR](https://jsr.io/@ecopages/postcss-processor) |
|
|
87
|
-
| `@ecopages/react` | React plugin for Ecopages integration. | [JSR](https://jsr.io/@ecopages/react) |
|
|
88
|
-
| `@ecopages/react-router` | Client-side SPA router for Ecopages React apps. | [JSR](https://jsr.io/@ecopages/react-router) |
|
|
70
|
+
## Ecosystem & Plugins
|
|
89
71
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
## Installation
|
|
93
|
-
|
|
94
|
-
```bash
|
|
95
|
-
bun add ecopages
|
|
96
|
-
```
|
|
72
|
+
Ecopages relies on a modular architecture. Core logic and framework integrations are published separately to [JSR](https://jsr.io/@ecopages).
|
|
97
73
|
|
|
98
|
-
|
|
74
|
+
Configure your project to use JSR by adding a `.npmrc` file:
|
|
99
75
|
|
|
100
76
|
```ini
|
|
101
77
|
@jsr:registry=https://npm.jsr.io
|
|
102
78
|
```
|
|
103
79
|
|
|
104
|
-
|
|
80
|
+
### Official Packages
|
|
81
|
+
|
|
82
|
+
| Package | Description |
|
|
83
|
+
| :---------------------------- | :----------------------------------------- |
|
|
84
|
+
| `@ecopages/browser-router` | Client-side navigation & view transitions. |
|
|
85
|
+
| `@ecopages/codemod` | AST migrations for codebase upgrades. |
|
|
86
|
+
| `@ecopages/core` | The foundational SSG engine. |
|
|
87
|
+
| `@ecopages/file-system` | Runtime-agnostic file system utilities. |
|
|
88
|
+
| `@ecopages/image-processor` | Asset pipeline for responsive images. |
|
|
89
|
+
| `@ecopages/kitajs` | Integration for KitaJS. |
|
|
90
|
+
| `@ecopages/lit` | Integration for Lit SSR/Islands. |
|
|
91
|
+
| `@ecopages/mdx` | Integration for standalone MDX routes. |
|
|
92
|
+
| `@ecopages/postcss-processor` | CSS processing pipeline using PostCSS. |
|
|
93
|
+
| `@ecopages/react` | Integration for React 19 SSR/Islands. |
|
|
94
|
+
| `@ecopages/react-router` | SPA routing for React. |
|
|
105
95
|
|
|
106
|
-
|
|
107
|
-
bun jsr add @ecopages/core @ecopages/kitajs
|
|
108
|
-
```
|
|
96
|
+
Explore all packages at [jsr.io/@ecopages](https://jsr.io/@ecopages).
|
|
109
97
|
|
|
110
98
|
## License
|
|
111
99
|
|
package/bin/cli.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
#!/usr/bin/env
|
|
1
|
+
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import { Command } from 'commander';
|
|
4
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
4
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
5
5
|
import { spawn } from 'node:child_process';
|
|
6
|
+
import { join } from 'node:path';
|
|
6
7
|
import tiged from 'tiged';
|
|
7
8
|
import { Logger } from '@ecopages/logger';
|
|
9
|
+
import { createLaunchPlan, launchPlanRequiresExistingEntryFile } from './launch-plan.js';
|
|
8
10
|
|
|
9
11
|
const logger = new Logger('[ecopages:cli]');
|
|
10
12
|
|
|
@@ -13,48 +15,78 @@ const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url),
|
|
|
13
15
|
|
|
14
16
|
program.name('ecopages').description('Ecopages CLI utilities').version(pkg.version);
|
|
15
17
|
|
|
18
|
+
async function handleInit(dir, opts) {
|
|
19
|
+
const { template, repo } = opts;
|
|
20
|
+
const targetDir = dir;
|
|
21
|
+
|
|
22
|
+
if (existsSync(targetDir)) {
|
|
23
|
+
logger.error(`Target directory already exists: ${targetDir}`);
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
logger.info(`Creating target directory '${targetDir}'...`);
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const emitter = tiged(`${repo}/examples/${template}`, {
|
|
31
|
+
disableCache: true,
|
|
32
|
+
force: true,
|
|
33
|
+
verbose: false,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
await emitter.clone(targetDir);
|
|
37
|
+
|
|
38
|
+
const pkgPath = join(targetDir, 'package.json');
|
|
39
|
+
if (existsSync(pkgPath)) {
|
|
40
|
+
const projectPkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
41
|
+
projectPkg.name = dir;
|
|
42
|
+
writeFileSync(pkgPath, JSON.stringify(projectPkg, null, 2) + '\n');
|
|
43
|
+
logger.info(`Renamed project to '${dir}'`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
logger.info('Project initialized! Run `bun install && bun dev` to start.');
|
|
47
|
+
} catch (err) {
|
|
48
|
+
logger.error(`Failed to fetch template: ${err.message}`);
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
16
53
|
program
|
|
17
54
|
.command('init <dir>')
|
|
18
55
|
.description('Initialize a new project from a template')
|
|
19
56
|
.option('--template <name>', 'Template name from ecopages/examples/', 'starter-jsx')
|
|
20
57
|
.option('--repo <repo>', 'GitHub repo (user/repo)', 'ecopages/ecopages')
|
|
21
|
-
.action(
|
|
22
|
-
const { template, repo } = opts;
|
|
23
|
-
const targetDir = dir;
|
|
58
|
+
.action(handleInit);
|
|
24
59
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
60
|
+
function runLaunchPlan(launchPlan) {
|
|
61
|
+
if (Object.keys(launchPlan.envOverrides).length > 0) {
|
|
62
|
+
logger.info(`Environment overrides: ${JSON.stringify(launchPlan.envOverrides)}`);
|
|
63
|
+
}
|
|
29
64
|
|
|
30
|
-
|
|
65
|
+
logger.info(`Runtime: ${launchPlan.runtime}`);
|
|
66
|
+
logger.info(`Running: ${launchPlan.command} ${launchPlan.commandArgs.join(' ')}`);
|
|
31
67
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
verbose: false,
|
|
37
|
-
});
|
|
68
|
+
const child = spawn(launchPlan.command, launchPlan.commandArgs, {
|
|
69
|
+
stdio: 'inherit',
|
|
70
|
+
env: launchPlan.env,
|
|
71
|
+
});
|
|
38
72
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
73
|
+
child.on('error', (error) => {
|
|
74
|
+
if (error && error.code === 'ENOENT') {
|
|
75
|
+
const hint =
|
|
76
|
+
launchPlan.command === 'bun'
|
|
77
|
+
? 'Install Bun from https://bun.sh to continue.'
|
|
78
|
+
: 'Install Node.js and ensure the `node` command is available to continue.';
|
|
79
|
+
logger.error(`Command not found: ${launchPlan.command}. ${hint}`);
|
|
43
80
|
process.exit(1);
|
|
44
81
|
}
|
|
82
|
+
|
|
83
|
+
logger.error(`Failed to run command: ${error.message}`);
|
|
84
|
+
process.exit(1);
|
|
45
85
|
});
|
|
46
86
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
function buildEnvOverrides(options) {
|
|
51
|
-
const env = {};
|
|
52
|
-
if (options.port) env.ECOPAGES_PORT = String(options.port);
|
|
53
|
-
if (options.hostname) env.ECOPAGES_HOSTNAME = options.hostname;
|
|
54
|
-
if (options.baseUrl) env.ECOPAGES_BASE_URL = options.baseUrl;
|
|
55
|
-
if (options.debug) env.ECOPAGES_LOGGER_DEBUG = 'true';
|
|
56
|
-
if (options.nodeEnv) env.NODE_ENV = options.nodeEnv;
|
|
57
|
-
return env;
|
|
87
|
+
child.on('exit', (code) => {
|
|
88
|
+
process.exit(code || 0);
|
|
89
|
+
});
|
|
58
90
|
}
|
|
59
91
|
|
|
60
92
|
/**
|
|
@@ -64,41 +96,15 @@ function buildEnvOverrides(options) {
|
|
|
64
96
|
* @param {object} options - CLI options (watch, hot, port, hostname, etc.)
|
|
65
97
|
* @param {string} entryFile - Entry file to run
|
|
66
98
|
*/
|
|
67
|
-
function runBunCommand(args, options = {}, entryFile = 'app.ts') {
|
|
68
|
-
const
|
|
69
|
-
|
|
99
|
+
async function runBunCommand(args, options = {}, entryFile = 'app.ts') {
|
|
100
|
+
const launchPlan = await createLaunchPlan(args, options, entryFile);
|
|
101
|
+
|
|
102
|
+
if (launchPlanRequiresExistingEntryFile(launchPlan) && !existsSync(entryFile)) {
|
|
70
103
|
logger.error(`Error: Entry file "${entryFile}" not found in the current directory.`);
|
|
71
104
|
process.exit(1);
|
|
72
105
|
}
|
|
73
106
|
|
|
74
|
-
|
|
75
|
-
if (options.watch) bunArgs.push('--watch');
|
|
76
|
-
if (options.hot) bunArgs.push('--hot');
|
|
77
|
-
|
|
78
|
-
bunArgs.push('run');
|
|
79
|
-
|
|
80
|
-
if (hasConfig) {
|
|
81
|
-
bunArgs.push('--preload', 'eco.config.ts');
|
|
82
|
-
}
|
|
83
|
-
bunArgs.push(entryFile, ...args);
|
|
84
|
-
|
|
85
|
-
if (options.reactFastRefresh) {
|
|
86
|
-
bunArgs.push('--react-fast-refresh');
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/** Merge CLI overrides with current environment */
|
|
90
|
-
const envOverrides = buildEnvOverrides(options);
|
|
91
|
-
const env = { ...process.env, ...envOverrides };
|
|
92
|
-
|
|
93
|
-
if (Object.keys(envOverrides).length > 0) {
|
|
94
|
-
logger.info(`Environment overrides: ${JSON.stringify(envOverrides)}`);
|
|
95
|
-
}
|
|
96
|
-
logger.info(`Running: bun ${bunArgs.join(' ')}`);
|
|
97
|
-
|
|
98
|
-
const child = spawn('bun', bunArgs, { stdio: 'inherit', env });
|
|
99
|
-
child.on('exit', (code) => {
|
|
100
|
-
process.exit(code || 0);
|
|
101
|
-
});
|
|
107
|
+
runLaunchPlan(launchPlan);
|
|
102
108
|
}
|
|
103
109
|
|
|
104
110
|
/**
|
|
@@ -112,12 +118,13 @@ const serverOptions = (cmd) =>
|
|
|
112
118
|
.option('-n, --hostname <hostname>', 'Override ECOPAGES_HOSTNAME')
|
|
113
119
|
.option('-b, --base-url <url>', 'Override ECOPAGES_BASE_URL')
|
|
114
120
|
.option('-d, --debug', 'Enable debug logging (ECOPAGES_LOGGER_DEBUG=true)')
|
|
115
|
-
.option('-r, --react-fast-refresh', 'Enable React Fast Refresh for HMR')
|
|
121
|
+
.option('-r, --react-fast-refresh', 'Enable React Fast Refresh for HMR')
|
|
122
|
+
.option('--runtime <runtime>', 'Force a specific runtime (bun, node, or node-experimental)');
|
|
116
123
|
|
|
117
124
|
serverOptions(
|
|
118
125
|
program.command('dev').description('Start the development server').argument('[entry]', 'Entry file', 'app.ts'),
|
|
119
|
-
).action((entry, opts) => {
|
|
120
|
-
runBunCommand(['--dev'], { ...opts, nodeEnv: 'development' }, entry);
|
|
126
|
+
).action(async (entry, opts) => {
|
|
127
|
+
await runBunCommand(['--dev'], { ...opts, nodeEnv: 'development' }, entry);
|
|
121
128
|
});
|
|
122
129
|
|
|
123
130
|
serverOptions(
|
|
@@ -125,8 +132,8 @@ serverOptions(
|
|
|
125
132
|
.command('dev:watch')
|
|
126
133
|
.description('Start the development server with watch mode (restarts on file changes)')
|
|
127
134
|
.argument('[entry]', 'Entry file', 'app.ts'),
|
|
128
|
-
).action((entry, opts) => {
|
|
129
|
-
runBunCommand(['--dev'], { ...opts, watch: true, nodeEnv: 'development' }, entry);
|
|
135
|
+
).action(async (entry, opts) => {
|
|
136
|
+
await runBunCommand(['--dev'], { ...opts, watch: true, nodeEnv: 'development' }, entry);
|
|
130
137
|
});
|
|
131
138
|
|
|
132
139
|
serverOptions(
|
|
@@ -134,28 +141,29 @@ serverOptions(
|
|
|
134
141
|
.command('dev:hot')
|
|
135
142
|
.description('Start the development server with hot reload (HMR without restart)')
|
|
136
143
|
.argument('[entry]', 'Entry file', 'app.ts'),
|
|
137
|
-
).action((entry, opts) => {
|
|
138
|
-
runBunCommand(['--dev'], { ...opts, hot: true, nodeEnv: 'development' }, entry);
|
|
144
|
+
).action(async (entry, opts) => {
|
|
145
|
+
await runBunCommand(['--dev'], { ...opts, hot: true, nodeEnv: 'development' }, entry);
|
|
139
146
|
});
|
|
140
147
|
|
|
141
148
|
program
|
|
142
149
|
.command('build')
|
|
143
150
|
.description('Build the project for production')
|
|
144
151
|
.argument('[entry]', 'Entry file', 'app.ts')
|
|
145
|
-
.
|
|
146
|
-
|
|
152
|
+
.option('--runtime <runtime>', 'Force a specific runtime (bun, node, or node-experimental)')
|
|
153
|
+
.action(async (entry, opts) => {
|
|
154
|
+
await runBunCommand(['--build'], { nodeEnv: 'production', ...opts }, entry);
|
|
147
155
|
});
|
|
148
156
|
|
|
149
157
|
serverOptions(
|
|
150
158
|
program.command('start').description('Start the production server').argument('[entry]', 'Entry file', 'app.ts'),
|
|
151
|
-
).action((entry, opts) => {
|
|
152
|
-
runBunCommand([], { ...opts, nodeEnv: 'production' }, entry);
|
|
159
|
+
).action(async (entry, opts) => {
|
|
160
|
+
await runBunCommand([], { ...opts, nodeEnv: 'production' }, entry);
|
|
153
161
|
});
|
|
154
162
|
|
|
155
163
|
serverOptions(
|
|
156
164
|
program.command('preview').description('Preview the production build').argument('[entry]', 'Entry file', 'app.ts'),
|
|
157
|
-
).action((entry, opts) => {
|
|
158
|
-
runBunCommand(['--preview'], { ...opts, nodeEnv: 'production' }, entry);
|
|
165
|
+
).action(async (entry, opts) => {
|
|
166
|
+
await runBunCommand(['--preview'], { ...opts, nodeEnv: 'production' }, entry);
|
|
159
167
|
});
|
|
160
168
|
|
|
161
169
|
program.parse();
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
const NODE_THIN_HOST_PATH = fileURLToPath(new URL('./node-thin-host.js', import.meta.url));
|
|
6
|
+
const DEFAULT_INTERNAL_WORK_DIR = '.eco';
|
|
7
|
+
|
|
8
|
+
export function buildEnvOverrides(options) {
|
|
9
|
+
const env = {};
|
|
10
|
+
if (options.port) env.ECOPAGES_PORT = String(options.port);
|
|
11
|
+
if (options.hostname) env.ECOPAGES_HOSTNAME = options.hostname;
|
|
12
|
+
if (options.baseUrl) env.ECOPAGES_BASE_URL = options.baseUrl;
|
|
13
|
+
if (options.debug) env.ECOPAGES_LOGGER_DEBUG = 'true';
|
|
14
|
+
if (options.nodeEnv) env.NODE_ENV = options.nodeEnv;
|
|
15
|
+
return env;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function detectRuntime(options = {}) {
|
|
19
|
+
if (options.runtime === 'bun' || options.runtime === 'node' || options.runtime === 'node-experimental') {
|
|
20
|
+
return options.runtime;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const userAgent = process.env.npm_config_user_agent || '';
|
|
24
|
+
|
|
25
|
+
if (userAgent.startsWith('bun/')) {
|
|
26
|
+
return 'bun';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (typeof Bun !== 'undefined') {
|
|
30
|
+
return 'bun';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return 'node';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function buildBunArgs(args, options, entryFile, hasConfig) {
|
|
37
|
+
const bunArgs = [];
|
|
38
|
+
|
|
39
|
+
if (options.watch) bunArgs.push('--watch');
|
|
40
|
+
if (options.hot) bunArgs.push('--hot');
|
|
41
|
+
|
|
42
|
+
bunArgs.push('run');
|
|
43
|
+
|
|
44
|
+
if (hasConfig) {
|
|
45
|
+
bunArgs.push('--preload', 'eco.config.ts');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
bunArgs.push(entryFile, ...args);
|
|
49
|
+
|
|
50
|
+
if (options.reactFastRefresh) {
|
|
51
|
+
bunArgs.push('--react-fast-refresh');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return bunArgs;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function buildNodeArgs(args, options, entryFile) {
|
|
58
|
+
const nodeArgs = [];
|
|
59
|
+
|
|
60
|
+
if (options.watch) nodeArgs.push('--watch');
|
|
61
|
+
|
|
62
|
+
nodeArgs.push(NODE_THIN_HOST_PATH, entryFile, ...args);
|
|
63
|
+
|
|
64
|
+
if (options.reactFastRefresh) {
|
|
65
|
+
nodeArgs.push('--react-fast-refresh');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return nodeArgs;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function resolveNodeRuntimeManifestPath(projectDir = process.cwd()) {
|
|
72
|
+
return path.join(path.resolve(projectDir), DEFAULT_INTERNAL_WORK_DIR, 'runtime', 'node-runtime-manifest.json');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function createNodeRuntimeManifestFile(
|
|
76
|
+
entryFile,
|
|
77
|
+
options = {
|
|
78
|
+
cwd: process.cwd(),
|
|
79
|
+
env: process.env,
|
|
80
|
+
},
|
|
81
|
+
) {
|
|
82
|
+
const projectDir = path.resolve(options.cwd ?? process.cwd());
|
|
83
|
+
const configPath = path.join(projectDir, 'eco.config.ts');
|
|
84
|
+
const manifestFilePath = options.manifestFilePath ?? resolveNodeRuntimeManifestPath(projectDir);
|
|
85
|
+
|
|
86
|
+
if (!existsSync(configPath)) {
|
|
87
|
+
throw new Error('The Node thin-host runtime requires eco.config.ts in the current project root.');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const manifest = {
|
|
91
|
+
runtime: 'node',
|
|
92
|
+
appRootDir: projectDir,
|
|
93
|
+
sourceRootDir: path.join(projectDir, 'src'),
|
|
94
|
+
distDir: path.join(projectDir, 'dist'),
|
|
95
|
+
workDir: path.join(projectDir, DEFAULT_INTERNAL_WORK_DIR),
|
|
96
|
+
modulePaths: {
|
|
97
|
+
config: configPath,
|
|
98
|
+
entry: path.resolve(projectDir, entryFile),
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
mkdirSync(path.dirname(manifestFilePath), { recursive: true });
|
|
103
|
+
writeFileSync(manifestFilePath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
|
|
104
|
+
|
|
105
|
+
return manifestFilePath;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function createLaunchPlan(args, options = {}, entryFile = 'app.ts') {
|
|
109
|
+
const hasConfig = existsSync('eco.config.ts');
|
|
110
|
+
const envOverrides = buildEnvOverrides(options);
|
|
111
|
+
const runtime = detectRuntime(options);
|
|
112
|
+
const env = { ...process.env, ...envOverrides };
|
|
113
|
+
|
|
114
|
+
if (runtime === 'node' || runtime === 'node-experimental') {
|
|
115
|
+
const manifestFilePath = await createNodeRuntimeManifestFile(entryFile, { env });
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
runtime,
|
|
119
|
+
executionStrategy: 'node-thin-host',
|
|
120
|
+
command: 'node',
|
|
121
|
+
commandArgs: buildNodeArgs(args, options, entryFile),
|
|
122
|
+
envOverrides,
|
|
123
|
+
env: {
|
|
124
|
+
...env,
|
|
125
|
+
ECOPAGES_NODE_RUNTIME_MANIFEST_PATH: manifestFilePath,
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
runtime,
|
|
132
|
+
executionStrategy: 'direct-runtime',
|
|
133
|
+
command: 'bun',
|
|
134
|
+
commandArgs: buildBunArgs(args, options, entryFile, hasConfig),
|
|
135
|
+
envOverrides,
|
|
136
|
+
env,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function launchPlanRequiresExistingEntryFile(launchPlan) {
|
|
141
|
+
return launchPlan.executionStrategy !== 'config-only-bootstrap';
|
|
142
|
+
}
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
5
|
+
import {
|
|
6
|
+
buildEnvOverrides,
|
|
7
|
+
buildNodeArgs,
|
|
8
|
+
buildBunArgs,
|
|
9
|
+
createNodeRuntimeManifestFile,
|
|
10
|
+
createLaunchPlan,
|
|
11
|
+
detectRuntime,
|
|
12
|
+
launchPlanRequiresExistingEntryFile,
|
|
13
|
+
resolveNodeRuntimeManifestPath,
|
|
14
|
+
} from './launch-plan.js';
|
|
15
|
+
|
|
16
|
+
const originalUserAgent = process.env.npm_config_user_agent;
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
if (originalUserAgent === undefined) {
|
|
20
|
+
delete process.env.npm_config_user_agent;
|
|
21
|
+
} else {
|
|
22
|
+
process.env.npm_config_user_agent = originalUserAgent;
|
|
23
|
+
}
|
|
24
|
+
process.chdir('/Users/andeeplus/github/ecopages');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('launch-plan', () => {
|
|
28
|
+
function writeExperimentalRuntimeConfig(tempDir) {
|
|
29
|
+
fs.writeFileSync(
|
|
30
|
+
path.join(tempDir, 'eco.config.ts'),
|
|
31
|
+
[
|
|
32
|
+
'const rootDir = process.cwd();',
|
|
33
|
+
'export default {',
|
|
34
|
+
'\trootDir,',
|
|
35
|
+
'\tloaders: new Map(),',
|
|
36
|
+
'\tabsolutePaths: {',
|
|
37
|
+
'\t\tconfig: `${rootDir}/eco.config.ts`,',
|
|
38
|
+
'\t\tsrcDir: `${rootDir}/src`,',
|
|
39
|
+
'\t\tdistDir: `${rootDir}/dist`,',
|
|
40
|
+
'\t\tworkDir: `${rootDir}/.eco`,',
|
|
41
|
+
'\t},',
|
|
42
|
+
'\truntime: {},',
|
|
43
|
+
'};',
|
|
44
|
+
].join('\n'),
|
|
45
|
+
'utf8',
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function writeImportMetaRuntimeConfig(tempDir) {
|
|
50
|
+
fs.writeFileSync(
|
|
51
|
+
path.join(tempDir, 'eco.config.ts'),
|
|
52
|
+
[
|
|
53
|
+
"import path from 'node:path';",
|
|
54
|
+
'export default {',
|
|
55
|
+
'\trootDir: import.meta.dirname,',
|
|
56
|
+
'\tloaders: new Map(),',
|
|
57
|
+
'\tabsolutePaths: {',
|
|
58
|
+
"\t\tconfig: path.join(import.meta.dirname, 'eco.config.ts'),",
|
|
59
|
+
"\t\tsrcDir: path.join(import.meta.dirname, 'src'),",
|
|
60
|
+
"\t\tdistDir: path.join(import.meta.dirname, 'dist'),",
|
|
61
|
+
"\t\tworkDir: path.join(import.meta.dirname, '.eco'),",
|
|
62
|
+
'\t},',
|
|
63
|
+
'\truntime: {},',
|
|
64
|
+
'};',
|
|
65
|
+
].join('\n'),
|
|
66
|
+
'utf8',
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
it('buildEnvOverrides maps CLI options onto environment variables', () => {
|
|
71
|
+
expect(
|
|
72
|
+
buildEnvOverrides({
|
|
73
|
+
port: 4173,
|
|
74
|
+
hostname: '127.0.0.1',
|
|
75
|
+
baseUrl: 'https://example.test',
|
|
76
|
+
debug: true,
|
|
77
|
+
nodeEnv: 'production',
|
|
78
|
+
}),
|
|
79
|
+
).toEqual({
|
|
80
|
+
ECOPAGES_PORT: '4173',
|
|
81
|
+
ECOPAGES_HOSTNAME: '127.0.0.1',
|
|
82
|
+
ECOPAGES_BASE_URL: 'https://example.test',
|
|
83
|
+
ECOPAGES_LOGGER_DEBUG: 'true',
|
|
84
|
+
NODE_ENV: 'production',
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('detectRuntime defaults to node unless bun is explicit', () => {
|
|
89
|
+
process.env.npm_config_user_agent = 'pnpm/10.0.0 npm/? node/v24.0.0 darwin arm64';
|
|
90
|
+
expect(detectRuntime()).toBe('node');
|
|
91
|
+
expect(detectRuntime({ runtime: 'bun' })).toBe('bun');
|
|
92
|
+
expect(detectRuntime({ runtime: 'node' })).toBe('node');
|
|
93
|
+
expect(detectRuntime({ runtime: 'node-experimental' })).toBe('node-experimental');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('buildNodeArgs preserves watch mode and fast refresh flags', () => {
|
|
97
|
+
expect(buildNodeArgs(['--dev'], { watch: true, reactFastRefresh: true }, 'app.ts')).toEqual([
|
|
98
|
+
'--watch',
|
|
99
|
+
expect.stringMatching(/node-thin-host\.js$/),
|
|
100
|
+
'app.ts',
|
|
101
|
+
'--dev',
|
|
102
|
+
'--react-fast-refresh',
|
|
103
|
+
]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('buildNodeArgs routes stable Node through the thin launcher', () => {
|
|
107
|
+
expect(buildNodeArgs(['--dev'], { watch: true }, 'app.ts')).toEqual([
|
|
108
|
+
'--watch',
|
|
109
|
+
expect.stringMatching(/node-thin-host\.js$/),
|
|
110
|
+
'app.ts',
|
|
111
|
+
'--dev',
|
|
112
|
+
]);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('buildBunArgs preloads eco.config.ts when present', () => {
|
|
116
|
+
expect(buildBunArgs(['--dev'], { hot: true }, 'app.ts', true)).toEqual([
|
|
117
|
+
'--hot',
|
|
118
|
+
'run',
|
|
119
|
+
'--preload',
|
|
120
|
+
'eco.config.ts',
|
|
121
|
+
'app.ts',
|
|
122
|
+
'--dev',
|
|
123
|
+
]);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('createLaunchPlan routes node app launches through the thin host', async () => {
|
|
127
|
+
const tempDir = fs.mkdtempSync(path.join(tmpdir(), 'eco-cli-launch-plan-'));
|
|
128
|
+
try {
|
|
129
|
+
process.env.npm_config_user_agent = 'pnpm/10.0.0 npm/? node/v24.0.0 darwin arm64';
|
|
130
|
+
process.chdir(tempDir);
|
|
131
|
+
writeExperimentalRuntimeConfig(tempDir);
|
|
132
|
+
fs.writeFileSync(
|
|
133
|
+
path.join(tempDir, 'app.ts'),
|
|
134
|
+
[
|
|
135
|
+
"import { EcopagesApp } from '@ecopages/core';",
|
|
136
|
+
"import appConfig from './eco.config';",
|
|
137
|
+
'const app = new EcopagesApp({ appConfig });',
|
|
138
|
+
'await app.start();',
|
|
139
|
+
].join('\n'),
|
|
140
|
+
'utf8',
|
|
141
|
+
);
|
|
142
|
+
const plan = await createLaunchPlan(['--dev'], { watch: true, nodeEnv: 'development' }, 'app.ts');
|
|
143
|
+
|
|
144
|
+
expect(plan).toMatchObject({
|
|
145
|
+
runtime: 'node',
|
|
146
|
+
executionStrategy: 'node-thin-host',
|
|
147
|
+
command: 'node',
|
|
148
|
+
envOverrides: { NODE_ENV: 'development' },
|
|
149
|
+
});
|
|
150
|
+
expect(plan.commandArgs).toEqual([
|
|
151
|
+
'--watch',
|
|
152
|
+
expect.stringMatching(/node-thin-host\.js$/),
|
|
153
|
+
'app.ts',
|
|
154
|
+
'--dev',
|
|
155
|
+
]);
|
|
156
|
+
} finally {
|
|
157
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('createLaunchPlan preserves direct node entrypoints on the thin-host path', async () => {
|
|
162
|
+
const tempDir = fs.mkdtempSync(path.join(tmpdir(), 'eco-cli-launch-plan-'));
|
|
163
|
+
try {
|
|
164
|
+
process.env.npm_config_user_agent = 'pnpm/10.0.0 npm/? node/v24.0.0 darwin arm64';
|
|
165
|
+
process.chdir(tempDir);
|
|
166
|
+
fs.writeFileSync(path.join(tempDir, 'server.ts'), 'await Promise.resolve();', 'utf8');
|
|
167
|
+
writeExperimentalRuntimeConfig(tempDir);
|
|
168
|
+
|
|
169
|
+
const plan = await createLaunchPlan(['--dev'], { watch: true }, 'server.ts');
|
|
170
|
+
|
|
171
|
+
expect(plan).toMatchObject({
|
|
172
|
+
runtime: 'node',
|
|
173
|
+
executionStrategy: 'node-thin-host',
|
|
174
|
+
command: 'node',
|
|
175
|
+
});
|
|
176
|
+
expect(plan.commandArgs).toEqual([
|
|
177
|
+
'--watch',
|
|
178
|
+
expect.stringMatching(/node-thin-host\.js$/),
|
|
179
|
+
'server.ts',
|
|
180
|
+
'--dev',
|
|
181
|
+
]);
|
|
182
|
+
} finally {
|
|
183
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('createLaunchPlan uses bun direct runtime and preloads eco.config.ts', async () => {
|
|
188
|
+
const tempDir = fs.mkdtempSync(path.join(tmpdir(), 'eco-cli-launch-plan-'));
|
|
189
|
+
try {
|
|
190
|
+
process.chdir(tempDir);
|
|
191
|
+
fs.writeFileSync(path.join(tempDir, 'app.ts'), 'await Promise.resolve();', 'utf8');
|
|
192
|
+
writeExperimentalRuntimeConfig(tempDir);
|
|
193
|
+
|
|
194
|
+
const plan = await createLaunchPlan(['--preview'], { runtime: 'bun' }, 'app.ts');
|
|
195
|
+
|
|
196
|
+
expect(plan).toMatchObject({
|
|
197
|
+
runtime: 'bun',
|
|
198
|
+
executionStrategy: 'direct-runtime',
|
|
199
|
+
command: 'bun',
|
|
200
|
+
});
|
|
201
|
+
expect(plan.commandArgs).toEqual(['run', '--preload', 'eco.config.ts', 'app.ts', '--preview']);
|
|
202
|
+
} finally {
|
|
203
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('createNodeRuntimeManifestFile writes the core-owned manifest under .eco/runtime', async () => {
|
|
208
|
+
const tempDir = fs.mkdtempSync(path.join(tmpdir(), 'eco-cli-launch-plan-'));
|
|
209
|
+
try {
|
|
210
|
+
process.chdir(tempDir);
|
|
211
|
+
writeExperimentalRuntimeConfig(tempDir);
|
|
212
|
+
const manifestFilePath = await createNodeRuntimeManifestFile('app.ts');
|
|
213
|
+
const manifest = JSON.parse(fs.readFileSync(manifestFilePath, 'utf8'));
|
|
214
|
+
const resolvedTempDir = fs.realpathSync(tempDir);
|
|
215
|
+
|
|
216
|
+
expect(manifestFilePath).toBe(resolveNodeRuntimeManifestPath(resolvedTempDir));
|
|
217
|
+
expect(manifest).toMatchObject({
|
|
218
|
+
runtime: 'node',
|
|
219
|
+
appRootDir: resolvedTempDir,
|
|
220
|
+
sourceRootDir: path.join(resolvedTempDir, 'src'),
|
|
221
|
+
distDir: path.join(resolvedTempDir, 'dist'),
|
|
222
|
+
workDir: path.join(resolvedTempDir, '.eco'),
|
|
223
|
+
modulePaths: {
|
|
224
|
+
config: path.join(resolvedTempDir, 'eco.config.ts'),
|
|
225
|
+
entry: path.join(resolvedTempDir, 'app.ts'),
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
} finally {
|
|
229
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('createNodeRuntimeManifestFile does not evaluate eco.config.ts while writing the manifest', async () => {
|
|
234
|
+
const tempDir = fs.mkdtempSync(path.join(tmpdir(), 'eco-cli-launch-plan-'));
|
|
235
|
+
try {
|
|
236
|
+
process.chdir(tempDir);
|
|
237
|
+
writeImportMetaRuntimeConfig(tempDir);
|
|
238
|
+
const manifestFilePath = await createNodeRuntimeManifestFile('app.ts');
|
|
239
|
+
const manifest = JSON.parse(fs.readFileSync(manifestFilePath, 'utf8'));
|
|
240
|
+
const resolvedTempDir = fs.realpathSync(tempDir);
|
|
241
|
+
|
|
242
|
+
expect(manifestFilePath).toBe(resolveNodeRuntimeManifestPath(resolvedTempDir));
|
|
243
|
+
expect(manifest).toMatchObject({
|
|
244
|
+
appRootDir: resolvedTempDir,
|
|
245
|
+
sourceRootDir: path.join(resolvedTempDir, 'src'),
|
|
246
|
+
distDir: path.join(resolvedTempDir, 'dist'),
|
|
247
|
+
workDir: path.join(resolvedTempDir, '.eco'),
|
|
248
|
+
modulePaths: {
|
|
249
|
+
config: path.join(resolvedTempDir, 'eco.config.ts'),
|
|
250
|
+
entry: path.join(resolvedTempDir, 'app.ts'),
|
|
251
|
+
},
|
|
252
|
+
});
|
|
253
|
+
} finally {
|
|
254
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('createLaunchPlan routes node-experimental through the thin host launcher', async () => {
|
|
259
|
+
const tempDir = fs.mkdtempSync(path.join(tmpdir(), 'eco-cli-launch-plan-'));
|
|
260
|
+
try {
|
|
261
|
+
process.chdir(tempDir);
|
|
262
|
+
writeExperimentalRuntimeConfig(tempDir);
|
|
263
|
+
const resolvedTempDir = fs.realpathSync(tempDir);
|
|
264
|
+
const plan = await createLaunchPlan(
|
|
265
|
+
['--dev'],
|
|
266
|
+
{ runtime: 'node-experimental', nodeEnv: 'development' },
|
|
267
|
+
'app.ts',
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
expect(plan).toMatchObject({
|
|
271
|
+
runtime: 'node-experimental',
|
|
272
|
+
executionStrategy: 'node-thin-host',
|
|
273
|
+
command: 'node',
|
|
274
|
+
envOverrides: { NODE_ENV: 'development' },
|
|
275
|
+
});
|
|
276
|
+
expect(plan.commandArgs).toEqual([expect.stringMatching(/node-thin-host\.js$/), 'app.ts', '--dev']);
|
|
277
|
+
expect(plan.env.ECOPAGES_NODE_RUNTIME_MANIFEST_PATH).toBe(resolveNodeRuntimeManifestPath(resolvedTempDir));
|
|
278
|
+
expect(JSON.parse(fs.readFileSync(plan.env.ECOPAGES_NODE_RUNTIME_MANIFEST_PATH, 'utf8'))).toMatchObject({
|
|
279
|
+
runtime: 'node',
|
|
280
|
+
modulePaths: {
|
|
281
|
+
config: path.join(resolvedTempDir, 'eco.config.ts'),
|
|
282
|
+
entry: path.join(resolvedTempDir, 'app.ts'),
|
|
283
|
+
},
|
|
284
|
+
});
|
|
285
|
+
} finally {
|
|
286
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('launchPlanRequiresExistingEntryFile requires a concrete entry on every runtime path', async () => {
|
|
291
|
+
const tempDir = fs.mkdtempSync(path.join(tmpdir(), 'eco-cli-launch-plan-'));
|
|
292
|
+
try {
|
|
293
|
+
process.env.npm_config_user_agent = 'pnpm/10.0.0 npm/? node/v24.0.0 darwin arm64';
|
|
294
|
+
process.chdir(tempDir);
|
|
295
|
+
fs.writeFileSync(path.join(tempDir, 'app.ts'), 'await Promise.resolve();', 'utf8');
|
|
296
|
+
writeExperimentalRuntimeConfig(tempDir);
|
|
297
|
+
|
|
298
|
+
const nodePlan = await createLaunchPlan(['--dev'], { nodeEnv: 'development' }, 'app.ts');
|
|
299
|
+
expect(nodePlan.executionStrategy).toBe('node-thin-host');
|
|
300
|
+
expect(launchPlanRequiresExistingEntryFile(nodePlan)).toBe(true);
|
|
301
|
+
|
|
302
|
+
fs.writeFileSync(path.join(tempDir, 'server.ts'), 'await Promise.resolve();', 'utf8');
|
|
303
|
+
const directNodePlan = await createLaunchPlan(['--dev'], { nodeEnv: 'development' }, 'server.ts');
|
|
304
|
+
expect(directNodePlan.executionStrategy).toBe('node-thin-host');
|
|
305
|
+
expect(launchPlanRequiresExistingEntryFile(directNodePlan)).toBe(true);
|
|
306
|
+
|
|
307
|
+
const bunPlan = await createLaunchPlan(['--dev'], { runtime: 'bun' }, 'app.ts');
|
|
308
|
+
expect(launchPlanRequiresExistingEntryFile(bunPlan)).toBe(true);
|
|
309
|
+
|
|
310
|
+
const experimentalNodePlan = await createLaunchPlan(['--dev'], { runtime: 'node-experimental' }, 'app.ts');
|
|
311
|
+
expect(launchPlanRequiresExistingEntryFile(experimentalNodePlan)).toBe(true);
|
|
312
|
+
} finally {
|
|
313
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
});
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { Logger } from '@ecopages/logger';
|
|
5
|
+
import { assertNodeRuntimeManifest, createNodeRuntimeAdapter } from '@ecopages/core/node/runtime-adapter';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
|
|
8
|
+
const logger = new Logger('[ecopages:node-thin-host]');
|
|
9
|
+
|
|
10
|
+
function formatErrorForLog(error) {
|
|
11
|
+
if (error instanceof Error) {
|
|
12
|
+
return error.stack ?? error.message;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return String(error);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function attachShutdownHandlers(session) {
|
|
19
|
+
let shutdownPromise;
|
|
20
|
+
|
|
21
|
+
const shutdown = async (signal) => {
|
|
22
|
+
if (!shutdownPromise) {
|
|
23
|
+
shutdownPromise = (async () => {
|
|
24
|
+
try {
|
|
25
|
+
logger.info(`Received ${signal}. Shutting down Node thin-host runtime.`);
|
|
26
|
+
await session.dispose();
|
|
27
|
+
} catch (error) {
|
|
28
|
+
logger.error(formatErrorForLog(error));
|
|
29
|
+
} finally {
|
|
30
|
+
process.exit(0);
|
|
31
|
+
}
|
|
32
|
+
})();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
await shutdownPromise;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
process.once('SIGINT', () => {
|
|
39
|
+
void shutdown('SIGINT');
|
|
40
|
+
});
|
|
41
|
+
process.once('SIGTERM', () => {
|
|
42
|
+
void shutdown('SIGTERM');
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Creates the host-to-adapter handoff payload for the Node thin-host runtime.
|
|
48
|
+
*/
|
|
49
|
+
export function createRuntimeStartOptions(options = {}) {
|
|
50
|
+
return {
|
|
51
|
+
manifest: options.manifest ?? readRuntimeManifest(),
|
|
52
|
+
workingDirectory: options.workingDirectory ?? process.cwd(),
|
|
53
|
+
cliArgs: options.cliArgs ?? process.argv.slice(2),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function readRuntimeManifest() {
|
|
58
|
+
const manifestFilePath = process.env.ECOPAGES_NODE_RUNTIME_MANIFEST_PATH;
|
|
59
|
+
|
|
60
|
+
if (!manifestFilePath) {
|
|
61
|
+
throw new Error('Missing ECOPAGES_NODE_RUNTIME_MANIFEST_PATH for Node thin-host launch.');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let serializedManifest;
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
serializedManifest = readFileSync(manifestFilePath, 'utf8');
|
|
68
|
+
} catch (error) {
|
|
69
|
+
throw new Error(
|
|
70
|
+
`Failed to read ECOPAGES_NODE_RUNTIME_MANIFEST_PATH at ${manifestFilePath}: ${error instanceof Error ? error.message : String(error)}`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let parsedManifest;
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
parsedManifest = JSON.parse(serializedManifest);
|
|
78
|
+
} catch (error) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
`Invalid Node runtime manifest JSON: ${error instanceof Error ? error.message : String(error)}`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return assertNodeRuntimeManifest(parsedManifest);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Starts the Node thin-host runtime by delegating validated input to the
|
|
89
|
+
* adapter boundary.
|
|
90
|
+
*
|
|
91
|
+
* @remarks
|
|
92
|
+
* This function intentionally keeps the thin host transport-oriented. It does
|
|
93
|
+
* not own framework bootstrap policy beyond reading the manifest, creating the
|
|
94
|
+
* adapter, and delegating startup plus shutdown lifecycle.
|
|
95
|
+
*/
|
|
96
|
+
export async function startThinHostRuntime(options = {}) {
|
|
97
|
+
const adapter = options.adapter ?? createNodeRuntimeAdapter();
|
|
98
|
+
const startOptions = createRuntimeStartOptions(options);
|
|
99
|
+
const session = await adapter.start(startOptions);
|
|
100
|
+
let loadedAppRuntime;
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
loadedAppRuntime = await session.loadApp();
|
|
104
|
+
} catch (error) {
|
|
105
|
+
try {
|
|
106
|
+
await session.dispose();
|
|
107
|
+
} catch (disposeError) {
|
|
108
|
+
throw new Error(
|
|
109
|
+
`Failed to dispose Node thin-host runtime session after bootstrap error: ${disposeError instanceof Error ? disposeError.message : String(disposeError)}`,
|
|
110
|
+
{ cause: error instanceof Error ? error : undefined },
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (options.attachShutdownHandlers !== false) {
|
|
118
|
+
attachShutdownHandlers(session);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
session,
|
|
123
|
+
loadedAppRuntime,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function main() {
|
|
128
|
+
try {
|
|
129
|
+
await startThinHostRuntime();
|
|
130
|
+
} catch (error) {
|
|
131
|
+
logger.error(formatErrorForLog(error));
|
|
132
|
+
process.exit(1);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
137
|
+
await main();
|
|
138
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { createRuntimeStartOptions, readRuntimeManifest, startThinHostRuntime } from './node-thin-host.js';
|
|
6
|
+
|
|
7
|
+
const originalManifestPath = process.env.ECOPAGES_NODE_RUNTIME_MANIFEST_PATH;
|
|
8
|
+
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
if (originalManifestPath === undefined) {
|
|
11
|
+
delete process.env.ECOPAGES_NODE_RUNTIME_MANIFEST_PATH;
|
|
12
|
+
} else {
|
|
13
|
+
process.env.ECOPAGES_NODE_RUNTIME_MANIFEST_PATH = originalManifestPath;
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe('node-thin-host', () => {
|
|
18
|
+
it('reads, validates, and returns the runtime manifest from the file handoff', () => {
|
|
19
|
+
const tempDir = fs.mkdtempSync(path.join(tmpdir(), 'eco-node-thin-host-'));
|
|
20
|
+
const manifestFilePath = path.join(tempDir, 'node-runtime-manifest.json');
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
fs.writeFileSync(
|
|
24
|
+
manifestFilePath,
|
|
25
|
+
JSON.stringify({
|
|
26
|
+
runtime: 'node',
|
|
27
|
+
appRootDir: '/repo',
|
|
28
|
+
sourceRootDir: '/repo/src',
|
|
29
|
+
distDir: '/repo/dist',
|
|
30
|
+
workDir: '/repo/.eco',
|
|
31
|
+
modulePaths: {
|
|
32
|
+
config: '/repo/eco.config.ts',
|
|
33
|
+
entry: '/repo/app.ts',
|
|
34
|
+
},
|
|
35
|
+
}),
|
|
36
|
+
'utf8',
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
process.env.ECOPAGES_NODE_RUNTIME_MANIFEST_PATH = manifestFilePath;
|
|
40
|
+
|
|
41
|
+
expect(readRuntimeManifest()).toMatchObject({
|
|
42
|
+
modulePaths: {
|
|
43
|
+
entry: '/repo/app.ts',
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
} finally {
|
|
47
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('creates the adapter handoff from validated manifest plus process launch context', () => {
|
|
52
|
+
const manifest = {
|
|
53
|
+
runtime: 'node',
|
|
54
|
+
appRootDir: '/repo',
|
|
55
|
+
sourceRootDir: '/repo/src',
|
|
56
|
+
distDir: '/repo/dist',
|
|
57
|
+
workDir: '/repo/.eco',
|
|
58
|
+
modulePaths: {
|
|
59
|
+
config: '/repo/eco.config.ts',
|
|
60
|
+
entry: '/repo/app.ts',
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
expect(
|
|
65
|
+
createRuntimeStartOptions({
|
|
66
|
+
manifest,
|
|
67
|
+
workingDirectory: '/repo',
|
|
68
|
+
cliArgs: ['app.ts', '--dev'],
|
|
69
|
+
}),
|
|
70
|
+
).toEqual({
|
|
71
|
+
manifest,
|
|
72
|
+
workingDirectory: '/repo',
|
|
73
|
+
cliArgs: ['app.ts', '--dev'],
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('delegates startup to the adapter boundary and returns the loaded runtime session', async () => {
|
|
78
|
+
const session = {
|
|
79
|
+
loadApp: vi.fn(async () => ({
|
|
80
|
+
manifest: { runtime: 'node' },
|
|
81
|
+
workingDirectory: '/repo',
|
|
82
|
+
appConfig: { rootDir: '/repo' },
|
|
83
|
+
entryModulePath: '/repo/app.ts',
|
|
84
|
+
entryModule: { default: true },
|
|
85
|
+
})),
|
|
86
|
+
dispose: vi.fn(async () => undefined),
|
|
87
|
+
};
|
|
88
|
+
const adapter = {
|
|
89
|
+
start: vi.fn(async () => session),
|
|
90
|
+
};
|
|
91
|
+
const manifest = {
|
|
92
|
+
runtime: 'node',
|
|
93
|
+
appRootDir: '/repo',
|
|
94
|
+
sourceRootDir: '/repo/src',
|
|
95
|
+
distDir: '/repo/dist',
|
|
96
|
+
workDir: '/repo/.eco',
|
|
97
|
+
modulePaths: {
|
|
98
|
+
config: '/repo/eco.config.ts',
|
|
99
|
+
entry: '/repo/app.ts',
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const runtime = await startThinHostRuntime({
|
|
104
|
+
adapter,
|
|
105
|
+
manifest,
|
|
106
|
+
workingDirectory: '/repo',
|
|
107
|
+
cliArgs: ['app.ts', '--dev'],
|
|
108
|
+
attachShutdownHandlers: false,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
expect(adapter.start).toHaveBeenCalledWith({
|
|
112
|
+
manifest,
|
|
113
|
+
workingDirectory: '/repo',
|
|
114
|
+
cliArgs: ['app.ts', '--dev'],
|
|
115
|
+
});
|
|
116
|
+
expect(session.loadApp).toHaveBeenCalledTimes(1);
|
|
117
|
+
expect(runtime).toMatchObject({
|
|
118
|
+
loadedAppRuntime: {
|
|
119
|
+
entryModulePath: '/repo/app.ts',
|
|
120
|
+
appConfig: { rootDir: '/repo' },
|
|
121
|
+
},
|
|
122
|
+
session,
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('disposes the runtime session if app bootstrap fails after adapter startup', async () => {
|
|
127
|
+
const bootstrapError = new Error('bootstrap failed');
|
|
128
|
+
const session = {
|
|
129
|
+
loadApp: vi.fn(async () => {
|
|
130
|
+
throw bootstrapError;
|
|
131
|
+
}),
|
|
132
|
+
dispose: vi.fn(async () => undefined),
|
|
133
|
+
};
|
|
134
|
+
const adapter = {
|
|
135
|
+
start: vi.fn(async () => session),
|
|
136
|
+
};
|
|
137
|
+
const manifest = {
|
|
138
|
+
runtime: 'node',
|
|
139
|
+
appRootDir: '/repo',
|
|
140
|
+
sourceRootDir: '/repo/src',
|
|
141
|
+
distDir: '/repo/dist',
|
|
142
|
+
workDir: '/repo/.eco',
|
|
143
|
+
modulePaths: {
|
|
144
|
+
config: '/repo/eco.config.ts',
|
|
145
|
+
entry: '/repo/app.ts',
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
await expect(
|
|
150
|
+
startThinHostRuntime({
|
|
151
|
+
adapter,
|
|
152
|
+
manifest,
|
|
153
|
+
workingDirectory: '/repo',
|
|
154
|
+
cliArgs: ['app.ts', '--dev'],
|
|
155
|
+
attachShutdownHandlers: false,
|
|
156
|
+
}),
|
|
157
|
+
).rejects.toThrow('bootstrap failed');
|
|
158
|
+
|
|
159
|
+
expect(adapter.start).toHaveBeenCalledWith({
|
|
160
|
+
manifest,
|
|
161
|
+
workingDirectory: '/repo',
|
|
162
|
+
cliArgs: ['app.ts', '--dev'],
|
|
163
|
+
});
|
|
164
|
+
expect(session.loadApp).toHaveBeenCalledTimes(1);
|
|
165
|
+
expect(session.dispose).toHaveBeenCalledTimes(1);
|
|
166
|
+
});
|
|
167
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ecopages",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0-alpha.10",
|
|
4
4
|
"description": "CLI utilities for Ecopages",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
"repository": {
|
|
9
9
|
"type": "git",
|
|
10
10
|
"url": "git+https://github.com/ecopages/ecopages.git",
|
|
11
|
-
"directory": "
|
|
11
|
+
"directory": "packages/ecopages"
|
|
12
12
|
},
|
|
13
13
|
"homepage": "https://github.com/ecopages/ecopages#readme",
|
|
14
14
|
"bugs": {
|
|
@@ -34,12 +34,14 @@
|
|
|
34
34
|
"README.md"
|
|
35
35
|
],
|
|
36
36
|
"dependencies": {
|
|
37
|
+
"@ecopages/core": "workspace:*",
|
|
38
|
+
"esbuild": "^0.27.3",
|
|
37
39
|
"commander": "^12.1.0",
|
|
38
40
|
"tiged": "^2.12.7",
|
|
39
41
|
"@ecopages/logger": "^0.2.2"
|
|
40
42
|
},
|
|
41
43
|
"peerDependencies": {
|
|
42
|
-
"bun-types": "
|
|
44
|
+
"bun-types": "*",
|
|
43
45
|
"typescript": "^5"
|
|
44
46
|
}
|
|
45
47
|
}
|