ecopages 0.2.0-alpha.2 → 0.2.0-alpha.6
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 -68
- package/bin/cli.js +20 -119
- 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 +45 -43
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,91 +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) |
|
|
70
|
+
## Ecosystem & Plugins
|
|
77
71
|
|
|
78
|
-
|
|
79
|
-
| `@ecopages/file-system` | Runtime-agnostic file system utilities (Bun/Node.js). | [JSR](https://jsr.io/@ecopages/file-system) |
|
|
80
|
-
| `@ecopages/image-processor` | Image processing library for optimized responsive images. | [JSR](https://jsr.io/@ecopages/image-processor) |
|
|
81
|
-
| `@ecopages/kitajs` | KitaJS plugin for Ecopages integration. | [JSR](https://jsr.io/@ecopages/kitajs) |
|
|
82
|
-
| `@ecopages/lit` | Lit plugin for Ecopages integration. | [JSR](https://jsr.io/@ecopages/lit) |
|
|
83
|
-
| `@ecopages/mdx` | MDX plugin for Ecopages integration. | [JSR](https://jsr.io/@ecopages/mdx) |
|
|
84
|
-
| `@ecopages/postcss-processor` | Utility functions for processing CSS with PostCSS. | [JSR](https://jsr.io/@ecopages/postcss-processor) |
|
|
85
|
-
| `@ecopages/react` | React plugin for Ecopages integration. | [JSR](https://jsr.io/@ecopages/react) |
|
|
86
|
-
| `@ecopages/react-router` | Client-side SPA router for Ecopages React apps. | [JSR](https://jsr.io/@ecopages/react-router) |
|
|
72
|
+
Ecopages relies on a modular architecture. Core logic and framework integrations are published separately to [JSR](https://jsr.io/@ecopages).
|
|
87
73
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
## Installation
|
|
91
|
-
|
|
92
|
-
```bash
|
|
93
|
-
bun add ecopages
|
|
94
|
-
```
|
|
95
|
-
|
|
96
|
-
To use Ecopages packages in your project, create a `.npmrc` file in the root of your project to configure JSR registry resolution:
|
|
74
|
+
Configure your project to use JSR by adding a `.npmrc` file:
|
|
97
75
|
|
|
98
76
|
```ini
|
|
99
77
|
@jsr:registry=https://npm.jsr.io
|
|
100
78
|
```
|
|
101
79
|
|
|
102
|
-
|
|
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. |
|
|
103
95
|
|
|
104
|
-
|
|
105
|
-
bun jsr add @ecopages/core @ecopages/kitajs
|
|
106
|
-
```
|
|
96
|
+
Explore all packages at [jsr.io/@ecopages](https://jsr.io/@ecopages).
|
|
107
97
|
|
|
108
98
|
## License
|
|
109
99
|
|
package/bin/cli.js
CHANGED
|
@@ -6,6 +6,7 @@ import { spawn } from 'node:child_process';
|
|
|
6
6
|
import { join } from 'node:path';
|
|
7
7
|
import tiged from 'tiged';
|
|
8
8
|
import { Logger } from '@ecopages/logger';
|
|
9
|
+
import { createLaunchPlan, launchPlanRequiresExistingEntryFile } from './launch-plan.js';
|
|
9
10
|
|
|
10
11
|
const logger = new Logger('[ecopages:cli]');
|
|
11
12
|
|
|
@@ -56,107 +57,6 @@ program
|
|
|
56
57
|
.option('--repo <repo>', 'GitHub repo (user/repo)', 'ecopages/ecopages')
|
|
57
58
|
.action(handleInit);
|
|
58
59
|
|
|
59
|
-
/**
|
|
60
|
-
* Build environment variables from CLI options
|
|
61
|
-
*/
|
|
62
|
-
function buildEnvOverrides(options) {
|
|
63
|
-
const env = {};
|
|
64
|
-
if (options.port) env.ECOPAGES_PORT = String(options.port);
|
|
65
|
-
if (options.hostname) env.ECOPAGES_HOSTNAME = options.hostname;
|
|
66
|
-
if (options.baseUrl) env.ECOPAGES_BASE_URL = options.baseUrl;
|
|
67
|
-
if (options.debug) env.ECOPAGES_LOGGER_DEBUG = 'true';
|
|
68
|
-
if (options.nodeEnv) env.NODE_ENV = options.nodeEnv;
|
|
69
|
-
return env;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function detectRuntime(options = {}) {
|
|
73
|
-
if (options.runtime === 'bun' || options.runtime === 'node') {
|
|
74
|
-
return options.runtime;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
const userAgent = process.env.npm_config_user_agent || '';
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* If explicitly launched via bun (e.g., `bun run ...`)
|
|
81
|
-
*/
|
|
82
|
-
if (userAgent.startsWith('bun/')) {
|
|
83
|
-
return 'bun';
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Catch when CLI is run directly by Bun without a package manager command
|
|
88
|
-
*/
|
|
89
|
-
if (typeof Bun !== 'undefined') {
|
|
90
|
-
return 'bun';
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* Default to node for npm, pnpm, yarn, or direct execution
|
|
95
|
-
*/
|
|
96
|
-
return 'node';
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
function buildBunArgs(args, options, entryFile, hasConfig) {
|
|
100
|
-
const bunArgs = [];
|
|
101
|
-
|
|
102
|
-
if (options.watch) bunArgs.push('--watch');
|
|
103
|
-
if (options.hot) bunArgs.push('--hot');
|
|
104
|
-
|
|
105
|
-
bunArgs.push('run');
|
|
106
|
-
|
|
107
|
-
if (hasConfig) {
|
|
108
|
-
bunArgs.push('--preload', 'eco.config.ts');
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
bunArgs.push(entryFile, ...args);
|
|
112
|
-
|
|
113
|
-
if (options.reactFastRefresh) {
|
|
114
|
-
bunArgs.push('--react-fast-refresh');
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
return bunArgs;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
function buildNodeArgs(args, options, entryFile) {
|
|
121
|
-
const tsxArgs = [];
|
|
122
|
-
|
|
123
|
-
if (options.watch) tsxArgs.push('watch');
|
|
124
|
-
|
|
125
|
-
tsxArgs.push(entryFile, ...args);
|
|
126
|
-
|
|
127
|
-
if (options.reactFastRefresh) {
|
|
128
|
-
tsxArgs.push('--react-fast-refresh');
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
return tsxArgs;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
function createLaunchPlan(args, options = {}, entryFile = 'app.ts') {
|
|
135
|
-
const hasConfig = existsSync('eco.config.ts');
|
|
136
|
-
const envOverrides = buildEnvOverrides(options);
|
|
137
|
-
const runtime = detectRuntime(options);
|
|
138
|
-
|
|
139
|
-
if (runtime === 'node') {
|
|
140
|
-
return {
|
|
141
|
-
runtime,
|
|
142
|
-
executionStrategy: 'tsx',
|
|
143
|
-
command: 'tsx',
|
|
144
|
-
commandArgs: buildNodeArgs(args, options, entryFile),
|
|
145
|
-
envOverrides,
|
|
146
|
-
env: { ...process.env, ...envOverrides },
|
|
147
|
-
};
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
return {
|
|
151
|
-
runtime,
|
|
152
|
-
executionStrategy: 'direct-runtime',
|
|
153
|
-
command: 'bun',
|
|
154
|
-
commandArgs: buildBunArgs(args, options, entryFile, hasConfig),
|
|
155
|
-
envOverrides,
|
|
156
|
-
env: { ...process.env, ...envOverrides },
|
|
157
|
-
};
|
|
158
|
-
}
|
|
159
|
-
|
|
160
60
|
function runLaunchPlan(launchPlan) {
|
|
161
61
|
if (Object.keys(launchPlan.envOverrides).length > 0) {
|
|
162
62
|
logger.info(`Environment overrides: ${JSON.stringify(launchPlan.envOverrides)}`);
|
|
@@ -175,7 +75,7 @@ function runLaunchPlan(launchPlan) {
|
|
|
175
75
|
const hint =
|
|
176
76
|
launchPlan.command === 'bun'
|
|
177
77
|
? 'Install Bun from https://bun.sh to continue.'
|
|
178
|
-
: 'Install
|
|
78
|
+
: 'Install Node.js and ensure the `node` command is available to continue.';
|
|
179
79
|
logger.error(`Command not found: ${launchPlan.command}. ${hint}`);
|
|
180
80
|
process.exit(1);
|
|
181
81
|
}
|
|
@@ -196,13 +96,14 @@ function runLaunchPlan(launchPlan) {
|
|
|
196
96
|
* @param {object} options - CLI options (watch, hot, port, hostname, etc.)
|
|
197
97
|
* @param {string} entryFile - Entry file to run
|
|
198
98
|
*/
|
|
199
|
-
function runBunCommand(args, options = {}, entryFile = 'app.ts') {
|
|
200
|
-
|
|
99
|
+
async function runBunCommand(args, options = {}, entryFile = 'app.ts') {
|
|
100
|
+
const launchPlan = await createLaunchPlan(args, options, entryFile);
|
|
101
|
+
|
|
102
|
+
if (launchPlanRequiresExistingEntryFile(launchPlan) && !existsSync(entryFile)) {
|
|
201
103
|
logger.error(`Error: Entry file "${entryFile}" not found in the current directory.`);
|
|
202
104
|
process.exit(1);
|
|
203
105
|
}
|
|
204
106
|
|
|
205
|
-
const launchPlan = createLaunchPlan(args, options, entryFile);
|
|
206
107
|
runLaunchPlan(launchPlan);
|
|
207
108
|
}
|
|
208
109
|
|
|
@@ -218,12 +119,12 @@ const serverOptions = (cmd) =>
|
|
|
218
119
|
.option('-b, --base-url <url>', 'Override ECOPAGES_BASE_URL')
|
|
219
120
|
.option('-d, --debug', 'Enable debug logging (ECOPAGES_LOGGER_DEBUG=true)')
|
|
220
121
|
.option('-r, --react-fast-refresh', 'Enable React Fast Refresh for HMR')
|
|
221
|
-
.option('--runtime <runtime>', 'Force a specific runtime (bun or node)');
|
|
122
|
+
.option('--runtime <runtime>', 'Force a specific runtime (bun, node, or node-experimental)');
|
|
222
123
|
|
|
223
124
|
serverOptions(
|
|
224
125
|
program.command('dev').description('Start the development server').argument('[entry]', 'Entry file', 'app.ts'),
|
|
225
|
-
).action((entry, opts) => {
|
|
226
|
-
runBunCommand(['--dev'], { ...opts, nodeEnv: 'development' }, entry);
|
|
126
|
+
).action(async (entry, opts) => {
|
|
127
|
+
await runBunCommand(['--dev'], { ...opts, nodeEnv: 'development' }, entry);
|
|
227
128
|
});
|
|
228
129
|
|
|
229
130
|
serverOptions(
|
|
@@ -231,8 +132,8 @@ serverOptions(
|
|
|
231
132
|
.command('dev:watch')
|
|
232
133
|
.description('Start the development server with watch mode (restarts on file changes)')
|
|
233
134
|
.argument('[entry]', 'Entry file', 'app.ts'),
|
|
234
|
-
).action((entry, opts) => {
|
|
235
|
-
runBunCommand(['--dev'], { ...opts, watch: true, nodeEnv: 'development' }, entry);
|
|
135
|
+
).action(async (entry, opts) => {
|
|
136
|
+
await runBunCommand(['--dev'], { ...opts, watch: true, nodeEnv: 'development' }, entry);
|
|
236
137
|
});
|
|
237
138
|
|
|
238
139
|
serverOptions(
|
|
@@ -240,29 +141,29 @@ serverOptions(
|
|
|
240
141
|
.command('dev:hot')
|
|
241
142
|
.description('Start the development server with hot reload (HMR without restart)')
|
|
242
143
|
.argument('[entry]', 'Entry file', 'app.ts'),
|
|
243
|
-
).action((entry, opts) => {
|
|
244
|
-
runBunCommand(['--dev'], { ...opts, hot: true, nodeEnv: 'development' }, entry);
|
|
144
|
+
).action(async (entry, opts) => {
|
|
145
|
+
await runBunCommand(['--dev'], { ...opts, hot: true, nodeEnv: 'development' }, entry);
|
|
245
146
|
});
|
|
246
147
|
|
|
247
148
|
program
|
|
248
149
|
.command('build')
|
|
249
150
|
.description('Build the project for production')
|
|
250
151
|
.argument('[entry]', 'Entry file', 'app.ts')
|
|
251
|
-
.option('--runtime <runtime>', 'Force a specific runtime (bun or node)')
|
|
252
|
-
.action((entry, opts) => {
|
|
253
|
-
runBunCommand(['--build'], { nodeEnv: 'production', ...opts }, entry);
|
|
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);
|
|
254
155
|
});
|
|
255
156
|
|
|
256
157
|
serverOptions(
|
|
257
158
|
program.command('start').description('Start the production server').argument('[entry]', 'Entry file', 'app.ts'),
|
|
258
|
-
).action((entry, opts) => {
|
|
259
|
-
runBunCommand([], { ...opts, nodeEnv: 'production' }, entry);
|
|
159
|
+
).action(async (entry, opts) => {
|
|
160
|
+
await runBunCommand([], { ...opts, nodeEnv: 'production' }, entry);
|
|
260
161
|
});
|
|
261
162
|
|
|
262
163
|
serverOptions(
|
|
263
164
|
program.command('preview').description('Preview the production build').argument('[entry]', 'Entry file', 'app.ts'),
|
|
264
|
-
).action((entry, opts) => {
|
|
265
|
-
runBunCommand(['--preview'], { ...opts, nodeEnv: 'production' }, entry);
|
|
165
|
+
).action(async (entry, opts) => {
|
|
166
|
+
await runBunCommand(['--preview'], { ...opts, nodeEnv: 'production' }, entry);
|
|
266
167
|
});
|
|
267
168
|
|
|
268
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,45 +1,47 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
2
|
+
"name": "ecopages",
|
|
3
|
+
"version": "0.2.0-alpha.6",
|
|
4
|
+
"description": "CLI utilities for Ecopages",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Ecopages Team",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/ecopages/ecopages.git",
|
|
11
|
+
"directory": "packages/ecopages"
|
|
12
|
+
},
|
|
13
|
+
"homepage": "https://github.com/ecopages/ecopages#readme",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/ecopages/ecopages/issues"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"ecopages",
|
|
19
|
+
"bun",
|
|
20
|
+
"web-framework",
|
|
21
|
+
"ssr",
|
|
22
|
+
"static-site-generator",
|
|
23
|
+
"react",
|
|
24
|
+
"kitajs",
|
|
25
|
+
"lit",
|
|
26
|
+
"mdx"
|
|
27
|
+
],
|
|
28
|
+
"bin": {
|
|
29
|
+
"ecopages": "bin/cli.js"
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"bin/",
|
|
33
|
+
"css/",
|
|
34
|
+
"README.md"
|
|
35
|
+
],
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@ecopages/core": "workspace:*",
|
|
38
|
+
"esbuild": "^0.27.3",
|
|
39
|
+
"commander": "^12.1.0",
|
|
40
|
+
"tiged": "^2.12.7",
|
|
41
|
+
"@ecopages/logger": "^0.2.2"
|
|
42
|
+
},
|
|
43
|
+
"peerDependencies": {
|
|
44
|
+
"bun-types": "*",
|
|
45
|
+
"typescript": "^5"
|
|
46
|
+
}
|
|
45
47
|
}
|