@fluojs/cli 1.0.4 → 1.0.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.ko.md CHANGED
@@ -184,6 +184,29 @@ fluo start --dry-run
184
184
 
185
185
  `fluo dev --dry-run`은 watch boundary도 함께 표시합니다. 생성된 Node 프로젝트는 기본적으로 `Watch mode: fluo-restart`를 보여 주며, Node의 `--raw-watch` 또는 `FLUO_DEV_RAW_WATCH=1`은 `Watch mode: native-watch`를 보여 줍니다. Bun/Deno/Workers 프로젝트는 기본적으로 `Watch mode: runtime-native-watch`를 보여 주며, `--runner fluo` 또는 `FLUO_DEV_RUNNER=fluo`를 사용하면 `Watch mode: fluo-restart`로 fluo 소유 restart runner가 복원됩니다.
186
186
 
187
+ ### Runtime-connected Studio devtool
188
+
189
+ 실행 중인 앱에 local React Studio devtool을 붙이고 싶을 때는 static HTML/JSON을 먼저 내보내지 말고 `fluo dev --studio`를 사용합니다.
190
+
191
+ ```bash
192
+ fluo dev --studio
193
+ fluo dev --studio --studio-port 51234
194
+ fluo dev --studio --dry-run
195
+ ```
196
+
197
+ CLI는 local Studio sidecar를 시작하고, tokenized URL을 출력하며, restart lifecycle event를 sidecar로 계속 전달하고, 앱이 `@fluojs/runtime`을 import하기 전에 명시적인 Studio config를 Node 앱 child에 주입합니다. Optional package인 `@fluojs/studio`가 설치되어 있으면 sidecar는 패키징된 `@fluojs/studio/viewer` React app을 제공합니다. Runtime package source는 `process.env`를 직접 읽지 않으며, CLI가 주입한 Studio config가 있을 때만 live graph/routes/request/timing/diagnostic event를 전송합니다.
198
+
199
+ 보안 기본값은 local-only입니다. Sidecar는 `127.0.0.1`에 bind되고, runtime ingestion 및 browser state/SSE API는 generated token을 요구하며, CORS는 기본적으로 활성화하지 않고, request body는 기본적으로 수집하지 않습니다.
200
+
201
+ MVP runtime support는 명시적으로 제한됩니다.
202
+
203
+ | Runtime target | `fluo dev --studio` status |
204
+ | --- | --- |
205
+ | Node dev runner | Full support target입니다. |
206
+ | Bun | 이번 MVP에서는 활성화하지 않습니다. Dedicated bridge를 구현하고 검증하기 전까지 `fluo dev --studio`는 Bun 프로젝트를 거부합니다. |
207
+ | Deno | 이번 MVP에서는 활성화하지 않습니다. Dedicated bridge를 구현하고 검증하기 전까지 `fluo dev --studio`는 Deno 프로젝트를 거부합니다. |
208
+ | Cloudflare Workers | worker bridge를 추가하고 테스트하지 않는 한 이번 MVP에서는 unsupported입니다. |
209
+
187
210
  CLI process boundary를 조정해야 할 때는 런타임 앱 로깅이 아니라 reporter flag를 사용하세요:
188
211
 
189
212
  ```bash
package/README.md CHANGED
@@ -184,6 +184,29 @@ fluo start --dry-run
184
184
 
185
185
  `fluo dev --dry-run` also reports the watch boundary. Generated Node projects show `Watch mode: fluo-restart` by default, Node `--raw-watch` and `FLUO_DEV_RAW_WATCH=1` show `Watch mode: native-watch`, and Bun/Deno/Workers projects show `Watch mode: runtime-native-watch` by default. Use `--runner fluo` or `FLUO_DEV_RUNNER=fluo` in Bun/Deno/Workers projects to show `Watch mode: fluo-restart` and restore the fluo-owned restart runner.
186
186
 
187
+ ### Runtime-connected Studio devtool
188
+
189
+ Use `fluo dev --studio` when you want the local React Studio devtool attached to the running app instead of exporting static HTML/JSON first:
190
+
191
+ ```bash
192
+ fluo dev --studio
193
+ fluo dev --studio --studio-port 51234
194
+ fluo dev --studio --dry-run
195
+ ```
196
+
197
+ The CLI starts a local Studio sidecar, prints a tokenized URL, keeps restart lifecycle events flowing through the sidecar, and injects an explicit Studio config into the Node app child before the app imports `@fluojs/runtime`. The sidecar serves the packaged `@fluojs/studio/viewer` React app when that optional package is installed. Runtime package source never reads `process.env` directly; it publishes live graph/routes/request/timing/diagnostic events only when CLI-injected Studio config is present.
198
+
199
+ Security defaults are local-only: the sidecar binds `127.0.0.1`, runtime ingestion and browser state/SSE APIs require generated tokens, CORS is not enabled by default, and request bodies are not captured by default.
200
+
201
+ Runtime support for the MVP is explicit:
202
+
203
+ | Runtime target | `fluo dev --studio` status |
204
+ | --- | --- |
205
+ | Node dev runner | Full support target. |
206
+ | Bun | Not enabled for this MVP; `fluo dev --studio` rejects Bun projects until a dedicated bridge is implemented and verified. |
207
+ | Deno | Not enabled for this MVP; `fluo dev --studio` rejects Deno projects until a dedicated bridge is implemented and verified. |
208
+ | Cloudflare Workers | Unsupported for this MVP unless a worker bridge is added and tested. |
209
+
187
210
  Use reporter flags when you need to tune the CLI process boundary rather than runtime app logging:
188
211
 
189
212
  ```bash
package/dist/cli.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { type InspectCommandRuntimeOptions } from './commands/inspect.js';
2
2
  import { type NewCommandRuntimeOptions } from './commands/new.js';
3
+ import type { startStudioSidecar } from './studio/sidecar.js';
3
4
  import { type CliUpdateCheckRuntimeOptions } from './update-check.js';
4
5
  type CliStream = {
5
6
  isTTY?: boolean;
@@ -23,6 +24,7 @@ export interface CliRuntimeOptions {
23
24
  stdio: 'inherit' | 'pipe';
24
25
  stdout?: CliStream;
25
26
  }) => Promise<number>;
27
+ startStudioSidecar?: typeof startStudioSidecar;
26
28
  stderr?: CliStream;
27
29
  stdin?: CliReadableStream;
28
30
  stdout?: CliStream;
package/dist/cli.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"AAMA,OAAO,EAAE,KAAK,4BAA4B,EAAmC,MAAM,uBAAuB,CAAC;AAE3G,OAAO,EAAE,KAAK,wBAAwB,EAA2B,MAAM,mBAAmB,CAAC;AAO3F,OAAO,EAAE,KAAK,4BAA4B,EAA6C,MAAM,mBAAmB,CAAC;AAEjH,KAAK,SAAS,GAAG;IACf,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,KAAK,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC;CACjC,CAAC;AAEF,KAAK,iBAAiB,GAAG;IACvB,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB,CAAC;AAEF;;GAEG;AACH,MAAM,WAAW,iBAAiB;IAChC,EAAE,CAAC,EAAE,OAAO,CAAC;IACb,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACxB,aAAa,CAAC,EAAE,CAAC,WAAW,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,SAAS,CAAC,CAAC;IACrF,YAAY,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,OAAO,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAC,UAAU,CAAC;QAAC,MAAM,CAAC,EAAE,SAAS,CAAC;QAAC,KAAK,EAAE,SAAS,GAAG,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,SAAS,CAAA;KAAE,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;IACzL,MAAM,CAAC,EAAE,SAAS,CAAC;IACnB,KAAK,CAAC,EAAE,iBAAiB,CAAC;IAC1B,MAAM,CAAC,EAAE,SAAS,CAAC;IACnB,WAAW,CAAC,EAAE,KAAK,GAAG,4BAA4B,CAAC;CACpD;AA4ZD;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAsB,MAAM,CAC1B,IAAI,WAAwB,EAC5B,OAAO,GAAE,iBAAiB,GAAG,wBAAwB,GAAG,4BAAiC,GACxF,OAAO,CAAC,MAAM,CAAC,CAuNjB"}
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"AAMA,OAAO,EAAE,KAAK,4BAA4B,EAAmC,MAAM,uBAAuB,CAAC;AAE3G,OAAO,EAAE,KAAK,wBAAwB,EAA2B,MAAM,mBAAmB,CAAC;AAI3F,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;AAI9D,OAAO,EAAE,KAAK,4BAA4B,EAA6C,MAAM,mBAAmB,CAAC;AAEjH,KAAK,SAAS,GAAG;IACf,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,KAAK,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC;CACjC,CAAC;AAEF,KAAK,iBAAiB,GAAG;IACvB,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB,CAAC;AAEF;;GAEG;AACH,MAAM,WAAW,iBAAiB;IAChC,EAAE,CAAC,EAAE,OAAO,CAAC;IACb,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACxB,aAAa,CAAC,EAAE,CAAC,WAAW,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,SAAS,CAAC,CAAC;IACrF,YAAY,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,OAAO,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAC,UAAU,CAAC;QAAC,MAAM,CAAC,EAAE,SAAS,CAAC;QAAC,KAAK,EAAE,SAAS,GAAG,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,SAAS,CAAA;KAAE,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;IACzL,kBAAkB,CAAC,EAAE,OAAO,kBAAkB,CAAC;IAC/C,MAAM,CAAC,EAAE,SAAS,CAAC;IACnB,KAAK,CAAC,EAAE,iBAAiB,CAAC;IAC1B,MAAM,CAAC,EAAE,SAAS,CAAC;IACnB,WAAW,CAAC,EAAE,KAAK,GAAG,4BAA4B,CAAC;CACpD;AA4ZD;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAsB,MAAM,CAC1B,IAAI,WAAwB,EAC5B,OAAO,GAAE,iBAAiB,GAAG,wBAAwB,GAAG,4BAAiC,GACxF,OAAO,CAAC,MAAM,CAAC,CAuNjB"}
@@ -1 +1 @@
1
- {"version":3,"file":"inspect.d.ts","sourceRoot":"","sources":["../../src/commands/inspect.ts"],"names":[],"mappings":"AAMA,OAAO,EAOL,KAAK,qBAAqB,EAC3B,MAAM,iBAAiB,CAAC;AAMzB,KAAK,SAAS,GAAG;IACf,KAAK,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC;CACjC,CAAC;AAEF,KAAK,eAAe,GAAG;IACrB,KAAK,CAAC,IAAI,IAAI,CAAC;IACf,OAAO,CAAC,OAAO,EAAE,MAAM,EAAE,YAAY,EAAE,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CACnE,CAAC;AAEF,KAAK,cAAc,GAAG;IACpB,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB,CAAC;AAEF,KAAK,qBAAqB,GAAG,CAAC,QAAQ,EAAE,qBAAqB,KAAK,MAAM,CAAC;AAEzE,KAAK,2BAA2B,GAAG,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,qBAAqB,GAAG,SAAS,CAAC,CAAC;AAE/F;;GAEG;AACH,MAAM,WAAW,4BAA4B;IAC3C,yEAAyE;IACzE,EAAE,CAAC,EAAE,OAAO,CAAC;IACb,uDAAuD;IACvD,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,yEAAyE;IACzE,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,yEAAyE;IACzE,yBAAyB,CAAC,EAAE,2BAA2B,CAAC;IACxD,wFAAwF;IACxF,MAAM,CAAC,EAAE,eAAe,CAAC;IACzB,sCAAsC;IACtC,MAAM,CAAC,EAAE,SAAS,CAAC;IACnB,4CAA4C;IAC5C,KAAK,CAAC,EAAE,cAAc,CAAC;IACvB,yCAAyC;IACzC,MAAM,CAAC,EAAE,SAAS,CAAC;CACpB;AAmFD;;;;GAIG;AACH,wBAAgB,YAAY,IAAI,MAAM,CAarC;AA+PD;;;;;;GAMG;AACH,wBAAsB,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,OAAO,GAAE,4BAAiC,GAAG,OAAO,CAAC,MAAM,CAAC,CAwDnH"}
1
+ {"version":3,"file":"inspect.d.ts","sourceRoot":"","sources":["../../src/commands/inspect.ts"],"names":[],"mappings":"AAMA,OAAO,EAOL,KAAK,qBAAqB,EAC3B,MAAM,iBAAiB,CAAC;AAMzB,KAAK,SAAS,GAAG;IACf,KAAK,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC;CACjC,CAAC;AAEF,KAAK,eAAe,GAAG;IACrB,KAAK,CAAC,IAAI,IAAI,CAAC;IACf,OAAO,CAAC,OAAO,EAAE,MAAM,EAAE,YAAY,EAAE,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CACnE,CAAC;AAEF,KAAK,cAAc,GAAG;IACpB,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB,CAAC;AAEF,KAAK,qBAAqB,GAAG,CAAC,QAAQ,EAAE,qBAAqB,KAAK,MAAM,CAAC;AAEzE,KAAK,2BAA2B,GAAG,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,qBAAqB,GAAG,SAAS,CAAC,CAAC;AAE/F;;GAEG;AACH,MAAM,WAAW,4BAA4B;IAC3C,yEAAyE;IACzE,EAAE,CAAC,EAAE,OAAO,CAAC;IACb,uDAAuD;IACvD,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,yEAAyE;IACzE,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,yEAAyE;IACzE,yBAAyB,CAAC,EAAE,2BAA2B,CAAC;IACxD,wFAAwF;IACxF,MAAM,CAAC,EAAE,eAAe,CAAC;IACzB,sCAAsC;IACtC,MAAM,CAAC,EAAE,SAAS,CAAC;IACnB,4CAA4C;IAC5C,KAAK,CAAC,EAAE,cAAc,CAAC;IACvB,yCAAyC;IACzC,MAAM,CAAC,EAAE,SAAS,CAAC;CACpB;AAmFD;;;;GAIG;AACH,wBAAgB,YAAY,IAAI,MAAM,CAarC;AA+PD;;;;;;GAMG;AACH,wBAAsB,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,OAAO,GAAE,4BAAiC,GAAG,OAAO,CAAC,MAAM,CAAC,CAkDnH"}
@@ -289,13 +289,7 @@ export async function runInspectCommand(argv, runtime = {}) {
289
289
  const context = await FluoFactory.createApplicationContext(rootModule, {
290
290
  diagnostics: parsed.timing || parsed.report ? {
291
291
  timing: true
292
- } : undefined,
293
- logger: {
294
- debug() {},
295
- error() {},
296
- log() {},
297
- warn() {}
298
- }
292
+ } : undefined
299
293
  });
300
294
  try {
301
295
  const platformShell = await context.get(PLATFORM_SHELL);
@@ -1,3 +1,4 @@
1
+ import { startStudioSidecar } from '../studio/sidecar.js';
1
2
  type CliStream = {
2
3
  isTTY?: boolean;
3
4
  write(message: string): unknown;
@@ -14,6 +15,7 @@ type ScriptRuntimeOptions = {
14
15
  cwd?: string;
15
16
  env?: NodeJS.ProcessEnv;
16
17
  spawnCommand?: (command: string, args: string[], options: SpawnCommandOptions) => Promise<number>;
18
+ startStudioSidecar?: typeof startStudioSidecar;
17
19
  stderr?: CliStream;
18
20
  stdout?: CliStream;
19
21
  };
@@ -1 +1 @@
1
- {"version":3,"file":"scripts.d.ts","sourceRoot":"","sources":["../../src/commands/scripts.ts"],"names":[],"mappings":"AAMA,KAAK,SAAS,GAAG;IACf,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,KAAK,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC;CACjC,CAAC;AAMF,KAAK,mBAAmB,GAAG;IACzB,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC,UAAU,CAAC;IACvB,MAAM,CAAC,EAAE,SAAS,CAAC;IACnB,KAAK,EAAE,SAAS,GAAG,MAAM,CAAC;IAC1B,MAAM,CAAC,EAAE,SAAS,CAAC;CACpB,CAAC;AAEF,KAAK,oBAAoB,GAAG;IAC1B,EAAE,CAAC,EAAE,OAAO,CAAC;IACb,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACxB,YAAY,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,OAAO,EAAE,mBAAmB,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;IAClG,MAAM,CAAC,EAAE,SAAS,CAAC;IACnB,MAAM,CAAC,EAAE,SAAS,CAAC;CACpB,CAAC;AAGF,KAAK,aAAa,GAAG,OAAO,GAAG,KAAK,GAAG,OAAO,CAAC;AA4c/C;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,OAAO,EAAE,aAAa,GAAG,MAAM,CAmB1D;AAED;;;;;;;GAOG;AACH,wBAAsB,gBAAgB,CAAC,OAAO,EAAE,aAAa,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,OAAO,GAAE,oBAAyB,GAAG,OAAO,CAAC,MAAM,CAAC,CAyElI"}
1
+ {"version":3,"file":"scripts.d.ts","sourceRoot":"","sources":["../../src/commands/scripts.ts"],"names":[],"mappings":"AAKA,OAAO,EAAE,kBAAkB,EAAiD,MAAM,sBAAsB,CAAC;AAGzG,KAAK,SAAS,GAAG;IACf,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,KAAK,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC;CACjC,CAAC;AAMF,KAAK,mBAAmB,GAAG;IACzB,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC,UAAU,CAAC;IACvB,MAAM,CAAC,EAAE,SAAS,CAAC;IACnB,KAAK,EAAE,SAAS,GAAG,MAAM,CAAC;IAC1B,MAAM,CAAC,EAAE,SAAS,CAAC;CACpB,CAAC;AAEF,KAAK,oBAAoB,GAAG;IAC1B,EAAE,CAAC,EAAE,OAAO,CAAC;IACb,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACxB,YAAY,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,OAAO,EAAE,mBAAmB,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;IAClG,kBAAkB,CAAC,EAAE,OAAO,kBAAkB,CAAC;IAC/C,MAAM,CAAC,EAAE,SAAS,CAAC;IACnB,MAAM,CAAC,EAAE,SAAS,CAAC;CACpB,CAAC;AAGF,KAAK,aAAa,GAAG,OAAO,GAAG,KAAK,GAAG,OAAO,CAAC;AAulB/C;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,OAAO,EAAE,aAAa,GAAG,MAAM,CAqB1D;AAED;;;;;;;GAOG;AACH,wBAAsB,gBAAgB,CAAC,OAAO,EAAE,aAAa,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,OAAO,GAAE,oBAAyB,GAAG,OAAO,CAAC,MAAM,CAAC,CAsFlI"}
@@ -2,6 +2,8 @@ import { spawn } from 'node:child_process';
2
2
  import { existsSync, readFileSync } from 'node:fs';
3
3
  import { delimiter, dirname, join, resolve } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
+ import { createStudioDevtoolsNodeImport } from '../studio/runtime-config.js';
6
+ import { startStudioSidecar } from '../studio/sidecar.js';
5
7
  import { SUPPORTED_PACKAGE_MANAGERS } from './package-manager.js';
6
8
  const EMPTY_ENV = {};
7
9
  const FAILURE_STDOUT_BUFFER_LIMIT = 16_384;
@@ -241,19 +243,18 @@ async function runProjectRunnerSteps(steps, runtime, options) {
241
243
  }
242
244
  return 0;
243
245
  }
244
- function withPipedAppColorTtyBootstrap(steps, env) {
245
- if (env[PRETTY_TTY_COLOR_ENV] !== '1') {
246
- return steps;
247
- }
246
+ function withPipedAppBootstrapImports(steps, env) {
248
247
  return steps.map(step => {
249
248
  const preserveColorTtyImport = getPreserveColorTtyImport();
250
- if (step.command === 'node' && step.mode !== 'fluo-restart') {
249
+ const colorImport = env[PRETTY_TTY_COLOR_ENV] === '1' ? ['--import', preserveColorTtyImport] : [];
250
+ const studioDevtoolsImport = step.mode === 'fluo-restart' ? [] : createStudioDevtoolsNodeImport(env);
251
+ if (step.command === 'node') {
251
252
  return {
252
253
  ...step,
253
- args: ['--import', preserveColorTtyImport, ...step.args]
254
+ args: [...colorImport, ...studioDevtoolsImport, ...step.args]
254
255
  };
255
256
  }
256
- if (step.command === 'bun' && (step.mode === 'runtime-native-watch' || step.args[0] === 'dist/main.js')) {
257
+ if (env[PRETTY_TTY_COLOR_ENV] === '1' && step.command === 'bun' && (step.mode === 'runtime-native-watch' || step.args[0] === 'dist/main.js')) {
257
258
  return {
258
259
  ...step,
259
260
  args: ['--preload', preserveColorTtyImport, ...step.args]
@@ -268,6 +269,8 @@ function parseScriptArgs(argv) {
268
269
  let packageManager;
269
270
  let rawWatch = false;
270
271
  let reporter = 'auto';
272
+ let studio = false;
273
+ let studioPort;
271
274
  let verbose = false;
272
275
  const passThrough = [];
273
276
  for (let index = 0; index < argv.length; index += 1) {
@@ -308,6 +311,24 @@ function parseScriptArgs(argv) {
308
311
  index += 1;
309
312
  continue;
310
313
  }
314
+ if (arg === '--studio') {
315
+ studio = true;
316
+ continue;
317
+ }
318
+ if (arg === '--studio-port') {
319
+ const value = argv[index + 1];
320
+ if (!value || value.startsWith('-')) {
321
+ throw new Error('Expected --studio-port to have a value.');
322
+ }
323
+ const parsedPort = Number(value);
324
+ if (!Number.isInteger(parsedPort) || parsedPort < 0 || parsedPort > 65_535) {
325
+ throw new Error('Invalid --studio-port value. Use a TCP port between 0 and 65535.');
326
+ }
327
+ studio = true;
328
+ studioPort = parsedPort;
329
+ index += 1;
330
+ continue;
331
+ }
311
332
  if (arg === '--package-manager') {
312
333
  const value = argv[index + 1];
313
334
  if (!value || value.startsWith('-')) {
@@ -333,6 +354,8 @@ function parseScriptArgs(argv) {
333
354
  passThrough,
334
355
  rawWatch,
335
356
  reporter,
357
+ studio,
358
+ studioPort,
336
359
  verbose
337
360
  };
338
361
  }
@@ -358,6 +381,37 @@ function resolveDevRunnerPreference(parsed, env, runtime) {
358
381
  }
359
382
  throw new Error(`Invalid FLUO_DEV_RUNNER value "${configured}". Use one of: fluo, native.`);
360
383
  }
384
+ function projectRuntimeToStudioRuntime(runtime) {
385
+ if (runtime === 'bun' || runtime === 'deno' || runtime === 'node') {
386
+ return runtime;
387
+ }
388
+ return 'unknown';
389
+ }
390
+ function projectDisplayName(project) {
391
+ return typeof project.manifest.name === 'string' && project.manifest.name.length > 0 ? project.manifest.name : project.directory.split(/[\\/]/).filter(Boolean).at(-1) ?? 'fluo-app';
392
+ }
393
+ function assertStudioSupport(command, studio, projectRuntime, _devRunner) {
394
+ if (!studio) {
395
+ return;
396
+ }
397
+ if (command !== 'dev') {
398
+ throw new Error('--studio is only supported for fluo dev.');
399
+ }
400
+ if (projectRuntime !== 'node') {
401
+ throw new Error(`fluo dev --studio currently supports Node dev runner projects only. ${projectRuntime} Studio support remains experimental until a dedicated bridge is implemented and verified.`);
402
+ }
403
+ }
404
+ function withStudioDryRunEnv(env, project, projectRuntime) {
405
+ return {
406
+ ...env,
407
+ FLUO_STUDIO: '1',
408
+ FLUO_STUDIO_APP_ID: projectDisplayName(project),
409
+ FLUO_STUDIO_EPOCH: '<generated-at-runtime>',
410
+ FLUO_STUDIO_RUNTIME: projectRuntimeToStudioRuntime(projectRuntime),
411
+ FLUO_STUDIO_TOKEN: '<generated-at-runtime>',
412
+ FLUO_STUDIO_URL: 'http://127.0.0.1:<auto>'
413
+ };
414
+ }
361
415
  function renderStep(step) {
362
416
  return `${step.command} ${step.args.join(' ')}`.trim();
363
417
  }
@@ -474,6 +528,57 @@ function createReporterStreams(mode, verbose, stdout, stderr) {
474
528
  stdio: 'inherit'
475
529
  };
476
530
  }
531
+ function colorizeRunnerSteps(steps, env) {
532
+ return withPipedAppBootstrapImports(steps, env);
533
+ }
534
+ async function executeRunnerStepsWithReporter(options) {
535
+ if (options.command === 'dev' && (options.reporterMode === 'pretty' || options.verbose)) {
536
+ options.childEnv[SHOW_NODE_RESTART_NOTICE_ENV] = '1';
537
+ }
538
+ if (options.reporterMode === 'pretty') {
539
+ options.stdout.write(`[fluo] ${options.command} ${options.projectRuntime} lifecycle starting\n`);
540
+ options.stdout.write(`[fluo] ${options.runnerSteps.map(renderStep).join(' && ')}\n`);
541
+ }
542
+ const reporterStreams = createReporterStreams(options.reporterMode, options.verbose, options.stdout, options.stderr);
543
+ const exitCode = await runProjectRunnerSteps(options.runnerSteps, {
544
+ spawnCommand: options.runtime.spawnCommand ?? defaultSpawnCommand
545
+ }, {
546
+ cwd: options.projectDirectory,
547
+ env: options.childEnv,
548
+ ...reporterStreams
549
+ });
550
+ if (options.reporterMode === 'pretty') {
551
+ reporterStreams.finalizeChildOutputBeforeStatus();
552
+ if (exitCode === 0) {
553
+ options.stdout.write(`[fluo] ${options.command} lifecycle completed\n`);
554
+ } else {
555
+ reporterStreams.flushBufferedStdoutOnFailure();
556
+ options.stderr.write(`[fluo] ${options.command} lifecycle failed with exit code ${exitCode}\n`);
557
+ }
558
+ } else if (options.reporterMode === 'silent' && exitCode !== 0) {
559
+ reporterStreams.flushBufferedStdoutOnFailure();
560
+ options.stderr.write(`[fluo] ${options.command} lifecycle failed with exit code ${exitCode}\n`);
561
+ }
562
+ return exitCode;
563
+ }
564
+ async function runScriptWithStudioSidecar(command, projectDirectory, projectRuntime, runnerSteps, childEnv, runtime, reporterMode, verbose, stdout, stderr, studioSidecar) {
565
+ try {
566
+ return await executeRunnerStepsWithReporter({
567
+ childEnv,
568
+ command,
569
+ projectDirectory,
570
+ projectRuntime,
571
+ reporterMode,
572
+ runnerSteps,
573
+ runtime,
574
+ stderr,
575
+ stdout,
576
+ verbose
577
+ });
578
+ } finally {
579
+ await studioSidecar.close();
580
+ }
581
+ }
477
582
 
478
583
  /**
479
584
  * Renders lifecycle command help text.
@@ -483,7 +588,7 @@ function createReporterStreams(mode, verbose, stdout, stderr) {
483
588
  */
484
589
  export function scriptUsage(command) {
485
590
  const nodeEnv = command === 'dev' ? 'development' : 'production';
486
- return [`Usage: fluo ${command} [options] [-- <args>]`, '', `Run the generated fluo project ${command} lifecycle with NODE_ENV defaulting to ${nodeEnv} when unset.`, '', 'Default output forwards child stdout/stderr without fluo lifecycle UI.', 'Use --reporter pretty for fluo lifecycle status + app │ prefixes.', 'Use --verbose (or FLUO_VERBOSE=1) to expose raw runtime/tooling output.', '', 'Options', ' --dry-run Print the command without running it.', command === 'dev' ? ' --raw-watch Use the runtime-native Node watcher instead of the fluo restart runner.' : undefined, command === 'dev' ? ' --runner <fluo|native> Select fluo restart supervision or runtime-native watch (default: fluo for Node, native for non-Node runtimes).' : undefined, ' --reporter <auto|pretty|stream|silent> Choose lifecycle reporter output mode (default: auto).', ' --verbose Expose raw child process output; also honored by FLUO_VERBOSE=1.', ` --help Show help for the ${command} command.`].filter(line => typeof line === 'string').join('\n');
591
+ return [`Usage: fluo ${command} [options] [-- <args>]`, '', `Run the generated fluo project ${command} lifecycle with NODE_ENV defaulting to ${nodeEnv} when unset.`, '', 'Default output forwards child stdout/stderr without fluo lifecycle UI.', 'Use --reporter pretty for fluo lifecycle status + app │ prefixes.', 'Use --verbose (or FLUO_VERBOSE=1) to expose raw runtime/tooling output.', '', 'Options', ' --dry-run Print the command without running it.', command === 'dev' ? ' --raw-watch Use the runtime-native Node watcher instead of the fluo restart runner.' : undefined, command === 'dev' ? ' --runner <fluo|native> Select fluo restart supervision or runtime-native watch (default: fluo for Node, native for non-Node runtimes).' : undefined, command === 'dev' ? ' --studio Start the local Fluo Studio sidecar and inject runtime devtool env.' : undefined, command === 'dev' ? ' --studio-port <port> Bind the Studio sidecar to a specific local port (default: 0).' : undefined, ' --reporter <auto|pretty|stream|silent> Choose lifecycle reporter output mode (default: auto).', ' --verbose Expose raw child process output; also honored by FLUO_VERBOSE=1.', ` --help Show help for the ${command} command.`].filter(line => typeof line === 'string').join('\n');
487
592
  }
488
593
 
489
594
  /**
@@ -514,6 +619,7 @@ export async function runScriptCommand(command, argv, runtime = {}) {
514
619
  throw new Error('--runner is only supported for fluo dev. Use -- to forward --runner to the child command.');
515
620
  }
516
621
  const devRunner = command === 'dev' ? resolveDevRunnerPreference(parsed, env, projectRuntime) : 'fluo';
622
+ assertStudioSupport(command, parsed.studio, projectRuntime, devRunner);
517
623
  const runnerSteps = buildProjectRunner(command, projectRuntime, parsed.passThrough, {
518
624
  devRunner,
519
625
  rawWatch
@@ -524,8 +630,26 @@ export async function runScriptCommand(command, argv, runtime = {}) {
524
630
  stdout
525
631
  });
526
632
  const verbose = parsed.verbose || isEnabledEnvironmentFlag(env.FLUO_VERBOSE);
527
- const childEnv = withPipedReporterColorEnv(withProjectLocalBin(withDefaultNodeEnv(env, defaultNodeEnv), project.directory), reporterMode, stdout, stderr);
528
- const colorAwareRunnerSteps = withPipedAppColorTtyBootstrap(runnerSteps, childEnv);
633
+ let childEnv = withPipedReporterColorEnv(withProjectLocalBin(withDefaultNodeEnv(env, defaultNodeEnv), project.directory), reporterMode, stdout, stderr);
634
+ if (parsed.studio && parsed.dryRun) {
635
+ childEnv = withStudioDryRunEnv(childEnv, project, projectRuntime);
636
+ }
637
+ if (command === 'dev' && parsed.studio && !parsed.dryRun) {
638
+ const studioSidecarFactory = runtime.startStudioSidecar ?? startStudioSidecar;
639
+ const studioSidecar = await studioSidecarFactory({
640
+ appId: projectDisplayName(project),
641
+ port: parsed.studioPort,
642
+ runtime: projectRuntimeToStudioRuntime(projectRuntime)
643
+ });
644
+ childEnv = {
645
+ ...childEnv,
646
+ ...studioSidecar.env
647
+ };
648
+ const studioUrl = `${studioSidecar.url}/?token=${encodeURIComponent(studioSidecar.token)}`;
649
+ stdout.write(`[fluo] Studio listening at ${studioUrl}\n`);
650
+ return await runScriptWithStudioSidecar(command, project.directory, projectRuntime, colorizeRunnerSteps(runnerSteps, childEnv), childEnv, runtime, reporterMode, verbose, stdout, stderr, studioSidecar);
651
+ }
652
+ const colorAwareRunnerSteps = colorizeRunnerSteps(runnerSteps, childEnv);
529
653
  if (command === 'dev' && (reporterMode === 'pretty' || verbose)) {
530
654
  childEnv[SHOW_NODE_RESTART_NOTICE_ENV] = '1';
531
655
  }
@@ -539,32 +663,24 @@ export async function runScriptCommand(command, argv, runtime = {}) {
539
663
  stdout.write(`Reporter: ${reporterMode}\n`);
540
664
  if (command === 'dev') {
541
665
  stdout.write(`Watch mode: ${colorAwareRunnerSteps.map(step => step.mode ?? 'single-run').join(', ')}\n`);
666
+ if (parsed.studio) {
667
+ stdout.write('Studio: enabled (sidecar binds 127.0.0.1 at runtime)\n');
668
+ stdout.write(`FLUO_STUDIO: ${childEnv.FLUO_STUDIO ?? ''}\n`);
669
+ stdout.write(`FLUO_STUDIO_URL: ${childEnv.FLUO_STUDIO_URL ?? ''}\n`);
670
+ }
542
671
  }
543
672
  return 0;
544
673
  }
545
- if (reporterMode === 'pretty') {
546
- stdout.write(`[fluo] ${command} ${projectRuntime} lifecycle starting\n`);
547
- stdout.write(`[fluo] ${colorAwareRunnerSteps.map(renderStep).join(' && ')}\n`);
548
- }
549
- const reporterStreams = createReporterStreams(reporterMode, verbose, stdout, stderr);
550
- const exitCode = await runProjectRunnerSteps(colorAwareRunnerSteps, {
551
- spawnCommand: runtime.spawnCommand ?? defaultSpawnCommand
552
- }, {
553
- cwd: project.directory,
554
- env: childEnv,
555
- ...reporterStreams
674
+ return await executeRunnerStepsWithReporter({
675
+ childEnv,
676
+ command,
677
+ projectDirectory: project.directory,
678
+ projectRuntime,
679
+ reporterMode,
680
+ runnerSteps: colorAwareRunnerSteps,
681
+ runtime,
682
+ stderr,
683
+ stdout,
684
+ verbose
556
685
  });
557
- if (reporterMode === 'pretty') {
558
- reporterStreams.finalizeChildOutputBeforeStatus();
559
- if (exitCode === 0) {
560
- stdout.write(`[fluo] ${command} lifecycle completed\n`);
561
- } else {
562
- reporterStreams.flushBufferedStdoutOnFailure();
563
- stderr.write(`[fluo] ${command} lifecycle failed with exit code ${exitCode}\n`);
564
- }
565
- } else if (reporterMode === 'silent' && exitCode !== 0) {
566
- reporterStreams.flushBufferedStdoutOnFailure();
567
- stderr.write(`[fluo] ${command} lifecycle failed with exit code ${exitCode}\n`);
568
- }
569
- return exitCode;
570
686
  }
@@ -1 +1 @@
1
- {"version":3,"file":"node-restart-runner.d.ts","sourceRoot":"","sources":["../../src/dev-runner/node-restart-runner.ts"],"names":[],"mappings":"AAAA,OAAO,EAAS,KAAK,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAE9D,OAAO,EAA0D,KAAK,SAAS,EAAE,MAAM,SAAS,CAAC;AAIjG,KAAK,mBAAmB,GAAG;IACzB,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,KAAK,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC;CACjC,CAAC;AAEF,KAAK,mBAAmB,GAAG,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,OAAO,EAAE;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC,UAAU,CAAC;IAAC,KAAK,EAAE,SAAS,CAAA;CAAE,KAAK,YAAY,CAAC;AACjJ,2EAA2E;AAC3E,MAAM,MAAM,gBAAgB,GAAG,KAAK,GAAG,oBAAoB,GAAG,MAAM,GAAG,MAAM,CAAC;AAE9E,KAAK,aAAa,GAAG,QAAQ,GAAG,SAAS,CAAC;AAE1C,KAAK,mBAAmB,GAAG;IACzB,GAAG,CAAC,MAAM,EAAE,aAAa,EAAE,QAAQ,EAAE,MAAM,IAAI,GAAG,OAAO,CAAC;IAC1D,IAAI,CAAC,MAAM,EAAE,aAAa,EAAE,QAAQ,EAAE,MAAM,IAAI,GAAG,OAAO,CAAC;CAC5D,CAAC;AAEF,KAAK,qBAAqB,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,iBAAiB,EAAE;IAAE,SAAS,EAAE,OAAO,CAAA;CAAE,GAAG,CAAC,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,KAAK,IAAI,CAAC,EAAE,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,KAAK,IAAI,KAAK,SAAS,CAAC;AAE1O,KAAK,iBAAiB,GAAG;IACvB,cAAc,CAAC,KAAK,EAAE,QAAQ,CAAC,MAAM,CAAC,GAAG,IAAI,CAAC;IAC9C,mBAAmB,CAAC,KAAK,EAAE,QAAQ,CAAC,MAAM,CAAC,GAAG,OAAO,CAAC;CACvD,CAAC;AAEF,uFAAuF;AACvF,MAAM,MAAM,wBAAwB,GAAG;IACrC,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;IACnB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,GAAG,EAAE,MAAM,CAAC,UAAU,CAAC;IACvB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,OAAO,CAAC,EAAE,gBAAgB,CAAC;IAC3B,YAAY,CAAC,EAAE,mBAAmB,CAAC;IACnC,UAAU,CAAC,EAAE,mBAAmB,CAAC;IACjC,MAAM,CAAC,EAAE,mBAAmB,CAAC;IAC7B,MAAM,CAAC,EAAE,mBAAmB,CAAC;IAC7B,WAAW,CAAC,EAAE,qBAAqB,CAAC;CACrC,CAAC;AA4HF;;;;;;GAMG;AACH,wBAAgB,uBAAuB,CAAC,gBAAgB,EAAE,MAAM,EAAE,cAAc,GAAE,MAAM,EAAoB,GAAG,iBAAiB,CAuB/H;AAiFD;;;;;GAKG;AACH,wBAAsB,oBAAoB,CAAC,OAAO,EAAE,wBAAwB,GAAG,OAAO,CAAC,MAAM,CAAC,CA2N7F"}
1
+ {"version":3,"file":"node-restart-runner.d.ts","sourceRoot":"","sources":["../../src/dev-runner/node-restart-runner.ts"],"names":[],"mappings":"AAAA,OAAO,EAAS,KAAK,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAE9D,OAAO,EAA0D,KAAK,SAAS,EAAE,MAAM,SAAS,CAAC;AAKjG,KAAK,mBAAmB,GAAG;IACzB,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,KAAK,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC;CACjC,CAAC;AAEF,KAAK,mBAAmB,GAAG,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,OAAO,EAAE;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC,UAAU,CAAC;IAAC,KAAK,EAAE,SAAS,CAAA;CAAE,KAAK,YAAY,CAAC;AACjJ,2EAA2E;AAC3E,MAAM,MAAM,gBAAgB,GAAG,KAAK,GAAG,oBAAoB,GAAG,MAAM,GAAG,MAAM,CAAC;AAE9E,KAAK,aAAa,GAAG,QAAQ,GAAG,SAAS,CAAC;AAE1C,KAAK,mBAAmB,GAAG;IACzB,GAAG,CAAC,MAAM,EAAE,aAAa,EAAE,QAAQ,EAAE,MAAM,IAAI,GAAG,OAAO,CAAC;IAC1D,IAAI,CAAC,MAAM,EAAE,aAAa,EAAE,QAAQ,EAAE,MAAM,IAAI,GAAG,OAAO,CAAC;CAC5D,CAAC;AAEF,KAAK,qBAAqB,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,iBAAiB,EAAE;IAAE,SAAS,EAAE,OAAO,CAAA;CAAE,GAAG,CAAC,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,KAAK,IAAI,CAAC,EAAE,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,KAAK,IAAI,KAAK,SAAS,CAAC;AAE1O,KAAK,iBAAiB,GAAG;IACvB,cAAc,CAAC,KAAK,EAAE,QAAQ,CAAC,MAAM,CAAC,GAAG,IAAI,CAAC;IAC9C,mBAAmB,CAAC,KAAK,EAAE,QAAQ,CAAC,MAAM,CAAC,GAAG,OAAO,CAAC;CACvD,CAAC;AAEF,uFAAuF;AACvF,MAAM,MAAM,wBAAwB,GAAG;IACrC,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;IACnB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,GAAG,EAAE,MAAM,CAAC,UAAU,CAAC;IACvB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,OAAO,CAAC,EAAE,gBAAgB,CAAC;IAC3B,YAAY,CAAC,EAAE,mBAAmB,CAAC;IACnC,UAAU,CAAC,EAAE,mBAAmB,CAAC;IACjC,MAAM,CAAC,EAAE,mBAAmB,CAAC;IAC7B,MAAM,CAAC,EAAE,mBAAmB,CAAC;IAC7B,WAAW,CAAC,EAAE,qBAAqB,CAAC;CACrC,CAAC;AA2LF;;;;;;GAMG;AACH,wBAAgB,uBAAuB,CAAC,gBAAgB,EAAE,MAAM,EAAE,cAAc,GAAE,MAAM,EAAoB,GAAG,iBAAiB,CAuB/H;AAkFD;;;;;GAKG;AACH,wBAAsB,oBAAoB,CAAC,OAAO,EAAE,wBAAwB,GAAG,OAAO,CAAC,MAAM,CAAC,CAiP7F"}
@@ -3,6 +3,7 @@ import { createHash } from 'node:crypto';
3
3
  import { existsSync, readdirSync, readFileSync, statSync, watch } from 'node:fs';
4
4
  import { basename, dirname, join, relative, sep } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
+ import { createStudioDevtoolsNodeImport } from '../studio/runtime-config.js';
6
7
 
7
8
  /** Runtime target handled by the fluo-owned development restart runner. */
8
9
 
@@ -15,6 +16,53 @@ const DEFAULT_IGNORES = ['.cache', '.fluo', '.git', '.turbo', 'coverage', 'dist'
15
16
  const WATCH_FILES = ['.env', 'package.json', 'tsconfig.json', 'tsconfig.build.json'];
16
17
  const SHOW_NODE_RESTART_NOTICE_ENV = 'FLUO_DEV_SHOW_RESTART_NOTICE';
17
18
  const CLEAR_SCREEN = '\u001B[2J\u001B[3J\u001B[H';
19
+ function isEnabledEnvironmentFlag(value) {
20
+ return value === '1' || value === 'true' || value === 'yes';
21
+ }
22
+ function studioRuntimeName(runtime) {
23
+ if (runtime === 'cloudflare-workers') {
24
+ return 'worker';
25
+ }
26
+ return runtime;
27
+ }
28
+ function resolveStudioIngestEndpoint(env) {
29
+ if (!isEnabledEnvironmentFlag(env.FLUO_STUDIO) || !env.FLUO_STUDIO_TOKEN) {
30
+ return undefined;
31
+ }
32
+ if (env.FLUO_STUDIO_ENDPOINT) {
33
+ return env.FLUO_STUDIO_ENDPOINT;
34
+ }
35
+ if (!env.FLUO_STUDIO_URL) {
36
+ return undefined;
37
+ }
38
+ try {
39
+ return new URL('/api/runtime/events', env.FLUO_STUDIO_URL).toString();
40
+ } catch {
41
+ return undefined;
42
+ }
43
+ }
44
+ function publishStudioLifecycleEvent(env, runtime, type, payload) {
45
+ const endpoint = resolveStudioIngestEndpoint(env);
46
+ if (!endpoint || typeof globalThis.fetch !== 'function') {
47
+ return;
48
+ }
49
+ void globalThis.fetch(endpoint, {
50
+ body: JSON.stringify({
51
+ payload,
52
+ source: {
53
+ appId: env.FLUO_STUDIO_APP_ID ?? basename(process.cwd()),
54
+ runtime: studioRuntimeName(runtime)
55
+ },
56
+ type,
57
+ version: 1
58
+ }),
59
+ headers: {
60
+ authorization: `Bearer ${env.FLUO_STUDIO_TOKEN}`,
61
+ 'content-type': 'application/json'
62
+ },
63
+ method: 'POST'
64
+ }).catch(() => undefined);
65
+ }
18
66
  function normalizeIgnorePatterns(patterns) {
19
67
  return patterns.map(pattern => pattern.trim()).filter(pattern => pattern.length > 0);
20
68
  }
@@ -151,7 +199,8 @@ function getPreserveColorTtyImport() {
151
199
  }
152
200
  function buildNodeAppArgs(env, appArgs) {
153
201
  const colorTtyImport = env[PRETTY_TTY_COLOR_ENV] === '1' ? ['--import', getPreserveColorTtyImport()] : [];
154
- return ['--env-file=.env', ...colorTtyImport, '--import', 'tsx', 'src/main.ts', ...appArgs];
202
+ const studioDevtoolsImport = createStudioDevtoolsNodeImport(env);
203
+ return ['--env-file=.env', ...colorTtyImport, ...studioDevtoolsImport, '--import', 'tsx', 'src/main.ts', ...appArgs];
155
204
  }
156
205
  function buildBunAppArgs(env, appArgs) {
157
206
  const colorTtyPreload = env[PRETTY_TTY_COLOR_ENV] === '1' ? ['--preload', getPreserveColorTtyImport()] : [];
@@ -230,11 +279,19 @@ export async function runNodeRestartRunner(options) {
230
279
  let stopping = false;
231
280
  const startChild = (resolveExitCode, cleanup) => {
232
281
  const appCommand = buildAppCommand(runnerRuntime, env, appArgs);
282
+ publishStudioLifecycleEvent(env, runnerRuntime, 'restart', {
283
+ phase: 'starting',
284
+ reason: 'fluo dev runner starting app child'
285
+ });
233
286
  child = spawnChild(appCommand.command, appCommand.args, {
234
287
  cwd: projectDirectory,
235
288
  env,
236
289
  stdio: 'inherit'
237
290
  });
291
+ publishStudioLifecycleEvent(env, runnerRuntime, 'restart', {
292
+ phase: 'started',
293
+ reason: 'fluo dev runner spawned app child'
294
+ });
238
295
  let childSettled = false;
239
296
  child.once('error', error => {
240
297
  if (childSettled) {
@@ -255,10 +312,16 @@ export async function runNodeRestartRunner(options) {
255
312
  return;
256
313
  }
257
314
  if (stopping) {
315
+ publishStudioLifecycleEvent(env, runnerRuntime, 'disconnect', {
316
+ reason: 'fluo dev runner stopped'
317
+ });
258
318
  cleanup();
259
319
  resolveExitCode(code ?? 0);
260
320
  return;
261
321
  }
322
+ publishStudioLifecycleEvent(env, runnerRuntime, 'disconnect', {
323
+ reason: `app child exited with code ${String(code ?? 1)}`
324
+ });
262
325
  cleanup();
263
326
  resolveExitCode(code ?? 1);
264
327
  });
@@ -278,6 +341,10 @@ export async function runNodeRestartRunner(options) {
278
341
  if (env[SHOW_NODE_RESTART_NOTICE_ENV] === '1') {
279
342
  stdout.write(`[fluo] restarting after content change: ${relative(projectDirectory, restartPaths[restartPaths.length - 1] ?? projectDirectory)}\n`);
280
343
  }
344
+ publishStudioLifecycleEvent(env, runnerRuntime, 'restart', {
345
+ phase: 'scheduled',
346
+ reason: `content changed: ${relative(projectDirectory, restartPaths[restartPaths.length - 1] ?? projectDirectory)}`
347
+ });
281
348
  const previousChild = child;
282
349
  const startReplacementChild = () => {
283
350
  redrawDevScriptHeader(stdout, projectDirectory, env);
@@ -292,6 +359,10 @@ export async function runNodeRestartRunner(options) {
292
359
  return;
293
360
  }
294
361
  restarting = true;
362
+ publishStudioLifecycleEvent(env, runnerRuntime, 'restart', {
363
+ phase: 'stopping',
364
+ reason: 'stopping previous app child before restart'
365
+ });
295
366
  previousChild.once('close', () => {
296
367
  const committedRestartPaths = [...restartAfterClosePaths];
297
368
  restartAfterClosePaths.clear();
@@ -1 +1 @@
1
- {"version":3,"file":"scaffold.d.ts","sourceRoot":"","sources":["../../src/new/scaffold.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,gBAAgB,EAAkB,MAAM,YAAY,CAAC;AAqxEnE;;;;;;GAMG;AACH,wBAAsB,oBAAoB,CACxC,OAAO,EAAE,gBAAgB,EACzB,aAAa,SAAkB,GAC9B,OAAO,CAAC,IAAI,CAAC,CA6Bf;AAED;;GAEG;AACH,eAAO,MAAM,eAAe,6BAAuB,CAAC"}
1
+ {"version":3,"file":"scaffold.d.ts","sourceRoot":"","sources":["../../src/new/scaffold.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,gBAAgB,EAAkB,MAAM,YAAY,CAAC;AA8xEnE;;;;;;GAMG;AACH,wBAAsB,oBAAoB,CACxC,OAAO,EAAE,gBAAgB,EACzB,aAAa,SAAkB,GAC9B,OAAO,CAAC,IAAI,CAAC,CA6Bf;AAED;;GAEG;AACH,eAAO,MAAM,eAAe,6BAAuB,CAAC"}
@@ -52,6 +52,7 @@ function describeApplicationStarter(options) {
52
52
  entrypoint: 'src/main.ts',
53
53
  packageName: '@fluojs/platform-bun',
54
54
  platformLabel: 'Bun native HTTP',
55
+ runHelper: 'runBunApplication',
55
56
  runtimeLabel: 'Bun runtime'
56
57
  };
57
58
  }
@@ -79,6 +80,7 @@ function describeApplicationStarter(options) {
79
80
  entrypoint: 'src/main.ts',
80
81
  packageName: '@fluojs/platform-express',
81
82
  platformLabel: 'Express HTTP',
83
+ runHelper: 'runExpressApplication',
82
84
  runtimeLabel: 'Node.js runtime'
83
85
  };
84
86
  case 'nodejs':
@@ -88,6 +90,7 @@ function describeApplicationStarter(options) {
88
90
  entrypoint: 'src/main.ts',
89
91
  packageName: '@fluojs/platform-nodejs',
90
92
  platformLabel: 'raw Node.js HTTP',
93
+ runHelper: 'runNodejsApplication',
91
94
  runtimeLabel: 'Node.js runtime'
92
95
  };
93
96
  default:
@@ -97,6 +100,7 @@ function describeApplicationStarter(options) {
97
100
  entrypoint: 'src/main.ts',
98
101
  packageName: '@fluojs/platform-fastify',
99
102
  platformLabel: 'Fastify HTTP',
103
+ runHelper: 'runFastifyApplication',
100
104
  runtimeLabel: 'Node.js runtime'
101
105
  };
102
106
  }
@@ -380,8 +384,8 @@ function createHttpCommandsSection(options) {
380
384
  function createHttpProjectReadme(options) {
381
385
  const starter = describeApplicationStarter(options);
382
386
  const entrypointLabel = starter.entrypoint;
383
- const starterContract = options.runtime === 'deno' ? `\`${entrypointLabel}\` boots the selected first-class application starter: ${starter.runtimeLabel} + ${starter.platformLabel} via \`runDenoApplication(...)\`` : options.runtime === 'cloudflare-workers' ? `\`${entrypointLabel}\` exports the selected first-class application starter: ${starter.runtimeLabel} + ${starter.platformLabel} via \`createCloudflareWorkerEntrypoint(...)\`` : `\`${entrypointLabel}\` wires the selected first-class application starter: ${starter.runtimeLabel} + ${starter.platformLabel} via \`${starter.adapterFactory}(... )\``.replace('(... )', '(...)');
384
- const corsLine = options.runtime === 'cloudflare-workers' ? '- CORS: defaults to allowOrigin `*`; pass a `cors` option to `createCloudflareWorkerEntrypoint(..., { cors })` when you need edge-specific restrictions' : options.runtime === 'deno' ? '- CORS: defaults to allowOrigin `*`; configure it through the Deno HTTP bootstrap path before exposing the adapter in production' : `- CORS: defaults to allowOrigin '*'; pass a \`cors\` option to \`FluoFactory.create(..., { cors, adapter: ${starter.adapterFactory}(...) })\` to restrict origins`;
387
+ const starterContract = starter.runHelper ? `\`${entrypointLabel}\` boots the selected first-class application starter: ${starter.runtimeLabel} + ${starter.platformLabel} via \`${starter.runHelper}(...)\`` : options.runtime === 'deno' ? `\`${entrypointLabel}\` boots the selected first-class application starter: ${starter.runtimeLabel} + ${starter.platformLabel} via \`runDenoApplication(...)\`` : options.runtime === 'cloudflare-workers' ? `\`${entrypointLabel}\` exports the selected first-class application starter: ${starter.runtimeLabel} + ${starter.platformLabel} via \`createCloudflareWorkerEntrypoint(...)\`` : `\`${entrypointLabel}\` wires the selected first-class application starter: ${starter.runtimeLabel} + ${starter.platformLabel} via \`${starter.adapterFactory}(... )\``.replace('(... )', '(...)');
388
+ const corsLine = options.runtime === 'cloudflare-workers' ? '- CORS: defaults to allowOrigin `*`; pass a `cors` option to `createCloudflareWorkerEntrypoint(..., { cors })` when you need edge-specific restrictions' : options.runtime === 'deno' ? '- CORS: defaults to allowOrigin `*`; configure it through the Deno HTTP bootstrap path before exposing the adapter in production' : starter.runHelper ? `- CORS: defaults to allowOrigin '*'; pass a \`cors\` option to \`${starter.runHelper}(..., { cors })\` to restrict origins` : `- CORS: defaults to allowOrigin '*'; pass a \`cors\` option to \`FluoFactory.create(..., { cors, adapter: ${starter.adapterFactory}(...) })\` to restrict origins`;
385
389
  const testingSection = options.runtime === 'deno' ? `## Official generated testing templates\n\n- \`src/app.test.ts\` — Deno-native integration-style dispatch verification for the generated runtime + starter routes.\n\nUse this test when you need confidence that the generated Deno entrypoint and module graph still agree on the same HTTP contract.` : `## Official generated testing templates\n\n- \`src/greeting/greeting.repo.test.ts\`, \`src/greeting/greeting.service.test.ts\`, and \`src/greeting/greeting.controller.test.ts\` — unit templates for the starter-owned greeting slice.\n- \`src/greeting/greeting.slice.test.ts\` — module/slice template via \`createTestingModule\` for real DI graph confidence.\n- \`src/app.test.ts\` — integration-style dispatch template for runtime + starter routes.\n- \`test/app.e2e.test.ts\` — default HTTP/e2e-style template powered by \`createTestApp\` and \`app.request(...).send()\` from \`@fluojs/testing\`; older \`src/app.e2e.test.ts\` tests can be moved here without changing the request helper.\n- \`${createExecCommand(options.packageManager, 'fluo g repo User')}\` also adds:\n - \`src/users/user.repo.test.ts\` (unit template)\n - \`src/users/user.repo.slice.test.ts\` (slice/integration template via \`createTestingModule\`)\n\nUse unit templates for fast logic checks, \`${createRunCommand(options.packageManager, 'test:e2e')}\` for the dedicated request-level e2e suite, and \`${createRunCommand(options.packageManager, 'test:cov')}\` when your Vitest runtime supports coverage.`;
386
390
  return `# ${options.projectName}
387
391
 
@@ -774,12 +778,17 @@ export default {
774
778
  `;
775
779
  }
776
780
  const portExpression = options.runtime === 'bun' ? "Bun.env.PORT ?? '3000'" : "process.env.PORT ?? '3000'";
777
- const loggerGuidance = options.runtime === 'node' ? `
778
- // Application logging defaults to the pretty console logger when logger is omitted.
779
- // JSON logs are opt-in:
780
- // import { createJsonApplicationLogger } from '@fluojs/runtime/node';
781
- // Then pass \`logger: createJsonApplicationLogger()\` to FluoFactory.create(...).
782
- ` : '';
781
+ if (starter.runHelper) {
782
+ return `import { ${starter.runHelper} } from '${starter.packageName}';
783
+
784
+ import { AppModule } from './app';
785
+
786
+ const parsedPort = Number.parseInt(${portExpression}, 10);
787
+ const port = Number.isFinite(parsedPort) ? parsedPort : 3000;
788
+
789
+ await ${starter.runHelper}(AppModule, { port });
790
+ `;
791
+ }
783
792
  return `import { ${starter.adapterFactory} } from '${starter.packageName}';
784
793
  import { FluoFactory } from '@fluojs/runtime';
785
794
 
@@ -787,7 +796,6 @@ import { AppModule } from './app';
787
796
 
788
797
  // The generated starter wires the selected first-class fluo new application path:
789
798
  // ${starter.runtimeLabel} + ${starter.platformLabel} via ${starter.adapterFactory}(...).
790
- ${loggerGuidance}
791
799
 
792
800
  const parsedPort = Number.parseInt(${portExpression}, 10);
793
801
  const port = Number.isFinite(parsedPort) ? parsedPort : 3000;
@@ -1610,17 +1618,14 @@ export class AppModule {}
1610
1618
  `;
1611
1619
  }
1612
1620
  function createMixedMainFile() {
1613
- return `import { createFastifyAdapter } from '@fluojs/platform-fastify';
1614
- import { FluoFactory } from '@fluojs/runtime';
1621
+ return `import { bootstrapFastifyApplication } from '@fluojs/platform-fastify';
1615
1622
 
1616
1623
  import { AppModule } from './app';
1617
1624
 
1618
1625
  const parsedPort = Number.parseInt(process.env.PORT ?? '3000', 10);
1619
1626
  const port = Number.isFinite(parsedPort) ? parsedPort : 3000;
1620
1627
 
1621
- const app = await FluoFactory.create(AppModule, {
1622
- adapter: createFastifyAdapter({ port }),
1623
- });
1628
+ const app = await bootstrapFastifyApplication(AppModule, { port });
1624
1629
  await app.connectMicroservice();
1625
1630
  await app.startAllMicroservices();
1626
1631
  await app.listen();
@@ -0,0 +1,20 @@
1
+ export declare const STUDIO_DEVTOOLS_GLOBAL_CONFIG_KEY = "__FLUO_STUDIO_DEVTOOLS_CONFIG__";
2
+ export interface StudioDevtoolsInjectedConfig {
3
+ FLUO_STUDIO: '1';
4
+ FLUO_STUDIO_APP_ID?: string;
5
+ FLUO_STUDIO_ENDPOINT?: string;
6
+ FLUO_STUDIO_EPOCH?: string;
7
+ FLUO_STUDIO_RUNTIME?: string;
8
+ FLUO_STUDIO_TOKEN: string;
9
+ FLUO_STUDIO_URL?: string;
10
+ }
11
+ /**
12
+ * Builds the explicit Studio config that CLI-owned dev boundaries inject into app children.
13
+ *
14
+ * Package runtime code must not read ambient environment state directly. The CLI sidecar still stores the
15
+ * generated app id/token/URL in child environment for process supervision, then this helper converts
16
+ * those values into a typed process-local config object before the app imports `@fluojs/runtime`.
17
+ */
18
+ export declare function resolveStudioDevtoolsInjectedConfig(env: NodeJS.ProcessEnv): StudioDevtoolsInjectedConfig | undefined;
19
+ export declare function createStudioDevtoolsNodeImport(env: NodeJS.ProcessEnv): string[];
20
+ //# sourceMappingURL=runtime-config.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"runtime-config.d.ts","sourceRoot":"","sources":["../../src/studio/runtime-config.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,iCAAiC,oCAAoC,CAAC;AAEnF,MAAM,WAAW,4BAA4B;IAC3C,WAAW,EAAE,GAAG,CAAC;IACjB,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,iBAAiB,EAAE,MAAM,CAAC;IAC1B,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AAsBD;;;;;;GAMG;AACH,wBAAgB,mCAAmC,CAAC,GAAG,EAAE,MAAM,CAAC,UAAU,GAAG,4BAA4B,GAAG,SAAS,CAmBpH;AAED,wBAAgB,8BAA8B,CAAC,GAAG,EAAE,MAAM,CAAC,UAAU,GAAG,MAAM,EAAE,CAS/E"}
@@ -0,0 +1,51 @@
1
+ export const STUDIO_DEVTOOLS_GLOBAL_CONFIG_KEY = '__FLUO_STUDIO_DEVTOOLS_CONFIG__';
2
+ function isEnabledEnvironmentFlag(value) {
3
+ return value === '1' || value === 'true' || value === 'yes' || value === 'on';
4
+ }
5
+ function resolveStudioIngestEndpoint(env) {
6
+ if (env.FLUO_STUDIO_ENDPOINT) {
7
+ return env.FLUO_STUDIO_ENDPOINT;
8
+ }
9
+ if (!env.FLUO_STUDIO_URL) {
10
+ return undefined;
11
+ }
12
+ try {
13
+ return new URL('/api/runtime/events', env.FLUO_STUDIO_URL).toString();
14
+ } catch {
15
+ return undefined;
16
+ }
17
+ }
18
+
19
+ /**
20
+ * Builds the explicit Studio config that CLI-owned dev boundaries inject into app children.
21
+ *
22
+ * Package runtime code must not read ambient environment state directly. The CLI sidecar still stores the
23
+ * generated app id/token/URL in child environment for process supervision, then this helper converts
24
+ * those values into a typed process-local config object before the app imports `@fluojs/runtime`.
25
+ */
26
+ export function resolveStudioDevtoolsInjectedConfig(env) {
27
+ if (!isEnabledEnvironmentFlag(env.FLUO_STUDIO) || !env.FLUO_STUDIO_TOKEN) {
28
+ return undefined;
29
+ }
30
+ const endpoint = resolveStudioIngestEndpoint(env);
31
+ if (!endpoint) {
32
+ return undefined;
33
+ }
34
+ return {
35
+ FLUO_STUDIO: '1',
36
+ FLUO_STUDIO_APP_ID: env.FLUO_STUDIO_APP_ID,
37
+ FLUO_STUDIO_ENDPOINT: endpoint,
38
+ FLUO_STUDIO_EPOCH: env.FLUO_STUDIO_EPOCH,
39
+ FLUO_STUDIO_RUNTIME: env.FLUO_STUDIO_RUNTIME,
40
+ FLUO_STUDIO_TOKEN: env.FLUO_STUDIO_TOKEN,
41
+ FLUO_STUDIO_URL: env.FLUO_STUDIO_URL
42
+ };
43
+ }
44
+ export function createStudioDevtoolsNodeImport(env) {
45
+ const config = resolveStudioDevtoolsInjectedConfig(env);
46
+ if (!config) {
47
+ return [];
48
+ }
49
+ const source = `Object.defineProperty(globalThis, ${JSON.stringify(STUDIO_DEVTOOLS_GLOBAL_CONFIG_KEY)}, { configurable: true, enumerable: false, writable: true, value: ${JSON.stringify(config)} });`;
50
+ return ['--import', `data:text/javascript,${encodeURIComponent(source)}`];
51
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Defines Studio Sidecar Runtime values used by the Studio devtool.
3
+ */
4
+ export type StudioSidecarRuntime = 'bun' | 'deno' | 'node' | 'unknown';
5
+ /**
6
+ * Describes Studio Sidecar Options data used by the Studio devtool.
7
+ */
8
+ export interface StudioSidecarOptions {
9
+ appId?: string;
10
+ heartbeatMs?: number;
11
+ host?: string;
12
+ port?: number;
13
+ runtime?: StudioSidecarRuntime;
14
+ }
15
+ /**
16
+ * Describes Studio Sidecar data used by the Studio devtool.
17
+ */
18
+ export interface StudioSidecar {
19
+ readonly appId: string;
20
+ readonly epoch: string;
21
+ readonly env: NodeJS.ProcessEnv;
22
+ readonly host: string;
23
+ readonly port: number;
24
+ readonly token: string;
25
+ readonly url: string;
26
+ close(): Promise<void>;
27
+ }
28
+ /**
29
+ * Provides start Studio Sidecar behavior for the Studio devtool.
30
+ *
31
+ * @param options options value used by start Studio Sidecar.
32
+ * @returns The start Studio Sidecar result.
33
+ */
34
+ export declare function startStudioSidecar(options?: StudioSidecarOptions): Promise<StudioSidecar>;
35
+ //# sourceMappingURL=sidecar.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sidecar.d.ts","sourceRoot":"","sources":["../../src/studio/sidecar.ts"],"names":[],"mappings":"AAOA;;GAEG;AACH,MAAM,MAAM,oBAAoB,GAAG,KAAK,GAAG,MAAM,GAAG,MAAM,GAAG,SAAS,CAAC;AAEvE;;GAEG;AACH,MAAM,WAAW,oBAAoB;IACnC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,oBAAoB,CAAC;CAChC;AAED;;GAEG;AACH,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC,UAAU,CAAC;IAChC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AAkPD;;;;;GAKG;AACH,wBAAsB,kBAAkB,CAAC,OAAO,GAAE,oBAAyB,GAAG,OAAO,CAAC,aAAa,CAAC,CAiLnG"}
@@ -0,0 +1,401 @@
1
+ import { randomBytes, randomUUID } from 'node:crypto';
2
+ import { createReadStream, existsSync, readFileSync, statSync } from 'node:fs';
3
+ import { createServer } from 'node:http';
4
+ import { createRequire } from 'node:module';
5
+ import { dirname, extname, join, normalize, relative, sep } from 'node:path';
6
+ import { URL } from 'node:url';
7
+
8
+ /**
9
+ * Defines Studio Sidecar Runtime values used by the Studio devtool.
10
+ */
11
+
12
+ /**
13
+ * Describes Studio Sidecar Options data used by the Studio devtool.
14
+ */
15
+
16
+ /**
17
+ * Describes Studio Sidecar data used by the Studio devtool.
18
+ */
19
+
20
+ const DEFAULT_HOST = '127.0.0.1';
21
+ const DEFAULT_HEARTBEAT_MS = 15_000;
22
+ const MAX_EVENT_REPLAY = 1_000;
23
+ const MAX_REQUEST_BYTES = 1_048_576;
24
+ const require = createRequire(import.meta.url);
25
+ function isRecord(value) {
26
+ return typeof value === 'object' && value !== null;
27
+ }
28
+ function isRestartEpochBoundary(incoming) {
29
+ if (incoming.type !== 'restart' || !isRecord(incoming.payload)) {
30
+ return false;
31
+ }
32
+ return incoming.payload.phase === 'scheduled' || incoming.payload.phase === 'starting';
33
+ }
34
+ function createToken() {
35
+ return randomBytes(24).toString('base64url');
36
+ }
37
+ function createEpoch() {
38
+ return randomUUID();
39
+ }
40
+ function createDefaultAppId() {
41
+ return `fluo-app-${process.pid}`;
42
+ }
43
+ function readBody(request) {
44
+ return new Promise((resolve, reject) => {
45
+ let body = '';
46
+ request.setEncoding('utf8');
47
+ request.on('data', chunk => {
48
+ body += chunk;
49
+ if (body.length > MAX_REQUEST_BYTES) {
50
+ reject(new Error('Studio event payload is too large.'));
51
+ request.destroy();
52
+ }
53
+ });
54
+ request.on('end', () => resolve(body));
55
+ request.on('error', reject);
56
+ });
57
+ }
58
+ function writeJson(response, statusCode, payload) {
59
+ response.writeHead(statusCode, {
60
+ 'cache-control': 'no-store',
61
+ 'content-type': 'application/json; charset=utf-8'
62
+ });
63
+ response.end(JSON.stringify(payload));
64
+ }
65
+ function writeText(response, statusCode, body, contentType = 'text/plain; charset=utf-8') {
66
+ response.writeHead(statusCode, {
67
+ 'cache-control': 'no-store',
68
+ 'content-type': contentType
69
+ });
70
+ response.end(body);
71
+ }
72
+ function contentTypeForPath(pathname) {
73
+ switch (extname(pathname)) {
74
+ case '.css':
75
+ return 'text/css; charset=utf-8';
76
+ case '.html':
77
+ return 'text/html; charset=utf-8';
78
+ case '.js':
79
+ return 'text/javascript; charset=utf-8';
80
+ case '.json':
81
+ return 'application/json; charset=utf-8';
82
+ case '.map':
83
+ return 'application/json; charset=utf-8';
84
+ case '.svg':
85
+ return 'image/svg+xml';
86
+ case '.woff2':
87
+ return 'font/woff2';
88
+ default:
89
+ return 'application/octet-stream';
90
+ }
91
+ }
92
+ function resolveStudioViewerPath() {
93
+ try {
94
+ const viewerPath = require.resolve('@fluojs/studio/viewer');
95
+ return existsSync(viewerPath) ? viewerPath : undefined;
96
+ } catch {
97
+ return undefined;
98
+ }
99
+ }
100
+ function injectStudioConfig(html, options) {
101
+ const configScript = `<script>window.__FLUO_STUDIO__ = ${JSON.stringify(options).replaceAll('<', '\\u003c')};</script>`;
102
+ if (html.includes('</head>')) {
103
+ return html.replace('</head>', ` ${configScript}\n</head>`);
104
+ }
105
+ return `${configScript}\n${html}`;
106
+ }
107
+ function safeAssetPath(rootDirectory, pathname) {
108
+ let decodedPath;
109
+ try {
110
+ decodedPath = decodeURIComponent(pathname);
111
+ } catch {
112
+ return undefined;
113
+ }
114
+ const normalized = normalize(decodedPath).replace(/^[/\\]+/, '');
115
+ const candidate = join(rootDirectory, normalized);
116
+ const relativePath = relative(rootDirectory, candidate);
117
+ if (relativePath.startsWith('..') || relativePath.split(sep).includes('..')) {
118
+ return undefined;
119
+ }
120
+ return candidate;
121
+ }
122
+ function serveStudioAsset(response, rootDirectory, pathname) {
123
+ const assetPath = safeAssetPath(rootDirectory, pathname);
124
+ if (!assetPath || !existsSync(assetPath)) {
125
+ return false;
126
+ }
127
+ const stats = statSync(assetPath);
128
+ if (!stats.isFile()) {
129
+ return false;
130
+ }
131
+ response.writeHead(200, {
132
+ 'cache-control': 'no-store',
133
+ 'content-length': String(stats.size),
134
+ 'content-type': contentTypeForPath(assetPath)
135
+ });
136
+ createReadStream(assetPath).pipe(response);
137
+ return true;
138
+ }
139
+ function extractBearerToken(request) {
140
+ const authorization = request.headers.authorization;
141
+ if (typeof authorization !== 'string') {
142
+ return undefined;
143
+ }
144
+ const [scheme, token] = authorization.split(' ');
145
+ return scheme.toLowerCase() === 'bearer' && token ? token : undefined;
146
+ }
147
+ function requestToken(request, url) {
148
+ return extractBearerToken(request) ?? url.searchParams.get('token') ?? undefined;
149
+ }
150
+ function isAuthorized(request, url, token) {
151
+ return requestToken(request, url) === token;
152
+ }
153
+ function parseAfterSequence(url, request, epoch) {
154
+ const after = url.searchParams.get('after') ?? request.headers['last-event-id'];
155
+ const value = Array.isArray(after) ? after[0] : after;
156
+ if (!value) {
157
+ return 0;
158
+ }
159
+ if (/^\d+$/.test(value)) {
160
+ return Number(value);
161
+ }
162
+ const [eventEpoch, sequence] = value.split(':');
163
+ if (eventEpoch === epoch && /^\d+$/.test(sequence)) {
164
+ return Number(sequence);
165
+ }
166
+ return 0;
167
+ }
168
+ function writeSseEvent(response, event) {
169
+ response.write(`id: ${event.eventId}\n`);
170
+ response.write(`event: ${event.type}\n`);
171
+ response.write(`data: ${JSON.stringify(event)}\n\n`);
172
+ }
173
+ function renderStudioShell(options) {
174
+ const viewerPath = resolveStudioViewerPath();
175
+ if (viewerPath) {
176
+ return injectStudioConfig(readFileSync(viewerPath, 'utf8'), options);
177
+ }
178
+ const config = JSON.stringify(options).replaceAll('<', '\\u003c');
179
+ return `<!doctype html>
180
+ <html lang="en">
181
+ <head>
182
+ <meta charset="utf-8" />
183
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
184
+ <title>Fluo Studio</title>
185
+ <style>
186
+ :root { color-scheme: dark; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
187
+ body { margin: 0; background: #0a0f1d; color: #dbeafe; }
188
+ main { min-height: 100vh; display: grid; place-items: center; padding: 32px; }
189
+ section { max-width: 760px; border: 1px solid rgba(148, 163, 184, 0.22); border-radius: 24px; padding: 32px; background: linear-gradient(135deg, rgba(15, 23, 42, 0.96), rgba(30, 41, 59, 0.82)); box-shadow: 0 24px 80px rgba(0, 0, 0, 0.28); }
190
+ h1 { margin: 0 0 12px; font-size: 34px; letter-spacing: -0.04em; }
191
+ p { color: #94a3b8; line-height: 1.7; }
192
+ code { color: #93c5fd; }
193
+ </style>
194
+ </head>
195
+ <body>
196
+ <main>
197
+ <section>
198
+ <h1>Fluo Studio sidecar is live</h1>
199
+ <p>This token-protected local sidecar is receiving runtime events. The React Studio UI will attach to <code>/api/events</code> in the next package layer.</p>
200
+ <p>Config: <code id="config"></code></p>
201
+ </section>
202
+ </main>
203
+ <script>window.__FLUO_STUDIO__ = ${config}; document.getElementById('config').textContent = JSON.stringify(window.__FLUO_STUDIO__);</script>
204
+ </body>
205
+ </html>`;
206
+ }
207
+
208
+ /**
209
+ * Provides start Studio Sidecar behavior for the Studio devtool.
210
+ *
211
+ * @param options options value used by start Studio Sidecar.
212
+ * @returns The start Studio Sidecar result.
213
+ */
214
+ export async function startStudioSidecar(options = {}) {
215
+ const host = options.host ?? DEFAULT_HOST;
216
+ const appId = options.appId ?? createDefaultAppId();
217
+ const runtime = options.runtime ?? 'node';
218
+ let epoch = createEpoch();
219
+ const token = createToken();
220
+ const events = [];
221
+ const clients = new Set();
222
+ let sequence = 0;
223
+ const startedAt = performance.now();
224
+ const publish = incoming => {
225
+ if (isRestartEpochBoundary(incoming)) {
226
+ epoch = createEpoch();
227
+ }
228
+ sequence += 1;
229
+ const source = isRecord(incoming.source) ? incoming.source : undefined;
230
+ const sourceAppId = typeof source?.appId === 'string' && source.appId.length > 0 ? source.appId : appId;
231
+ const sourceRuntime = source?.runtime === 'bun' || source?.runtime === 'deno' || source?.runtime === 'node' ? source.runtime : runtime;
232
+ const event = {
233
+ emittedAt: new Date().toISOString(),
234
+ epoch,
235
+ eventId: `${epoch}:${String(sequence)}`,
236
+ payload: incoming.payload ?? {},
237
+ sequence,
238
+ source: {
239
+ appId: sourceAppId,
240
+ runtime: sourceRuntime
241
+ },
242
+ type: typeof incoming.type === 'string' && incoming.type.length > 0 ? incoming.type : 'diagnostic',
243
+ version: 1
244
+ };
245
+ events.push(event);
246
+ if (events.length > MAX_EVENT_REPLAY) {
247
+ events.splice(0, events.length - MAX_EVENT_REPLAY);
248
+ }
249
+ for (const client of clients) {
250
+ writeSseEvent(client.response, event);
251
+ }
252
+ return event;
253
+ };
254
+ const server = createServer(async (request, response) => {
255
+ const requestUrl = new URL(request.url ?? '/', `http://${host}`);
256
+ const viewerPath = resolveStudioViewerPath();
257
+ if (request.method === 'GET' && requestUrl.pathname.startsWith('/assets/') && viewerPath) {
258
+ if (serveStudioAsset(response, dirname(viewerPath), requestUrl.pathname)) {
259
+ return;
260
+ }
261
+ }
262
+ if (!isAuthorized(request, requestUrl, token)) {
263
+ writeJson(response, 401, {
264
+ error: 'Unauthorized Studio sidecar request.'
265
+ });
266
+ return;
267
+ }
268
+ if (request.method === 'GET' && requestUrl.pathname === '/') {
269
+ const tokenQuery = encodeURIComponent(token);
270
+ writeText(response, 200, renderStudioShell({
271
+ eventsUrl: `/api/events?token=${tokenQuery}`,
272
+ stateUrl: `/api/state?token=${tokenQuery}`
273
+ }), 'text/html; charset=utf-8');
274
+ return;
275
+ }
276
+ if (request.method === 'GET' && requestUrl.pathname === '/api/state') {
277
+ writeJson(response, 200, {
278
+ appId,
279
+ clientCount: clients.size,
280
+ epoch,
281
+ events,
282
+ sequence
283
+ });
284
+ return;
285
+ }
286
+ if (request.method === 'GET' && requestUrl.pathname === '/api/events') {
287
+ response.writeHead(200, {
288
+ 'cache-control': 'no-cache, no-transform',
289
+ connection: 'keep-alive',
290
+ 'content-type': 'text/event-stream; charset=utf-8',
291
+ 'x-accel-buffering': 'no'
292
+ });
293
+ response.write(': fluo studio stream ready\n\n');
294
+ const afterSequence = requestUrl.searchParams.get('replay') === '0' ? sequence : parseAfterSequence(requestUrl, request, epoch);
295
+ for (const event of events) {
296
+ if (event.sequence > afterSequence) {
297
+ writeSseEvent(response, event);
298
+ }
299
+ }
300
+ const client = {
301
+ response
302
+ };
303
+ clients.add(client);
304
+ request.on('close', () => {
305
+ clients.delete(client);
306
+ });
307
+ return;
308
+ }
309
+ if (request.method === 'POST' && requestUrl.pathname === '/api/runtime/events') {
310
+ try {
311
+ const body = await readBody(request);
312
+ const parsed = body ? JSON.parse(body) : {};
313
+ if (!isRecord(parsed)) {
314
+ writeJson(response, 400, {
315
+ error: 'Studio runtime event must be a JSON object.'
316
+ });
317
+ return;
318
+ }
319
+ const event = publish(parsed);
320
+ writeJson(response, 202, {
321
+ accepted: true,
322
+ epoch: event.epoch,
323
+ sequence: event.sequence
324
+ });
325
+ } catch (error) {
326
+ writeJson(response, 400, {
327
+ error: error instanceof Error ? error.message : String(error)
328
+ });
329
+ }
330
+ return;
331
+ }
332
+ writeJson(response, 404, {
333
+ error: 'Unknown Studio sidecar route.'
334
+ });
335
+ });
336
+ const heartbeat = options.heartbeatMs === 0 ? undefined : setInterval(() => {
337
+ publish({
338
+ payload: {
339
+ uptimeMs: Number((performance.now() - startedAt).toFixed(3))
340
+ },
341
+ source: {
342
+ appId,
343
+ runtime
344
+ },
345
+ type: 'heartbeat'
346
+ });
347
+ }, options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS);
348
+ heartbeat?.unref();
349
+ await new Promise((resolve, reject) => {
350
+ server.once('error', reject);
351
+ server.listen(options.port ?? 0, host, () => {
352
+ server.off('error', reject);
353
+ resolve();
354
+ });
355
+ });
356
+ const address = server.address();
357
+ if (!address || typeof address === 'string') {
358
+ await closeServer(server, clients, heartbeat);
359
+ throw new Error('Failed to resolve Studio sidecar address.');
360
+ }
361
+ const url = `http://${host}:${String(address.port)}`;
362
+ return {
363
+ appId,
364
+ get epoch() {
365
+ return epoch;
366
+ },
367
+ env: {
368
+ FLUO_STUDIO: '1',
369
+ FLUO_STUDIO_APP_ID: appId,
370
+ FLUO_STUDIO_EPOCH: epoch,
371
+ FLUO_STUDIO_RUNTIME: runtime,
372
+ FLUO_STUDIO_TOKEN: token,
373
+ FLUO_STUDIO_URL: url
374
+ },
375
+ host,
376
+ port: address.port,
377
+ token,
378
+ url,
379
+ async close() {
380
+ await closeServer(server, clients, heartbeat);
381
+ }
382
+ };
383
+ }
384
+ async function closeServer(server, clients, heartbeat) {
385
+ if (heartbeat) {
386
+ clearInterval(heartbeat);
387
+ }
388
+ for (const client of clients) {
389
+ client.response.end();
390
+ }
391
+ clients.clear();
392
+ await new Promise((resolve, reject) => {
393
+ server.close(error => {
394
+ if (error) {
395
+ reject(error);
396
+ return;
397
+ }
398
+ resolve();
399
+ });
400
+ });
401
+ }
package/package.json CHANGED
@@ -9,7 +9,7 @@
9
9
  "migration",
10
10
  "diagnostics"
11
11
  ],
12
- "version": "1.0.4",
12
+ "version": "1.0.6",
13
13
  "private": false,
14
14
  "license": "MIT",
15
15
  "repository": {
@@ -44,10 +44,10 @@
44
44
  "ejs": "^3.1.10",
45
45
  "tsx": "^4.20.4",
46
46
  "typescript": "^6.0.2",
47
- "@fluojs/runtime": "^1.1.2"
47
+ "@fluojs/runtime": "^1.1.5"
48
48
  },
49
49
  "peerDependencies": {
50
- "@fluojs/studio": "^1.0.4"
50
+ "@fluojs/studio": "^1.0.5"
51
51
  },
52
52
  "peerDependenciesMeta": {
53
53
  "@fluojs/studio": {