@fluojs/cli 1.0.3 → 1.0.5

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
@@ -259,7 +282,7 @@ fluo inspect ./src/app.module.ts --report --output artifacts/inspect-report.json
259
282
  fluo inspect ./src/app.module.ts --export AdminModule --json
260
283
  ```
261
284
 
262
- 런타임이 inspection snapshot을 생산합니다. `fluo inspect`는 `./src/app.ts` 또는 `./src/app.module.ts` 같은 생성된 TypeScript source module을 명시적 TypeScript loader boundary로 받아들이며, 기존 `.js`와 `.mjs` module path는 계속 Node.js native ESM으로 로드합니다. `fluo inspect`는 output mode flag가 없을 때 기본적으로 그 snapshot을 JSON으로 직렬화하고, `fluo inspect --mermaid`는 snapshot-to-Mermaid 렌더링을 선택적 `@fluojs/studio` 계약에 위임합니다. `--export <name>`은 bootstrap할 module export를 선택하며 기본값은 `AppModule`입니다. `--timing`은 명시적인 `--json` flag 없이 제공된 경우를 포함해 JSON snapshot 출력 옆에 bootstrap timing diagnostics를 기록하고, `--report`는 CI/support triage를 위해 런타임이 생산한 snapshot을 안정적인 요약과 함께 감쌉니다. `--timing`은 Mermaid 출력과 함께 사용할 수 없습니다. `--output <path>`는 선택한 inspect payload를 stdout 대신 명시적 artifact 경로에 씁니다. 이 동작은 검사 대상 애플리케이션을 writable하게 만들지 않으며, 일반 bootstrap/close cycle 외에 module graph state를 바꾸지 않습니다. Mermaid 출력이 필요하면 명령을 실행하는 프로젝트에 Studio를 설치하세요:
285
+ 런타임이 inspection snapshot을 생산합니다. `fluo inspect`는 `./src/app.ts` 또는 `./src/app.module.ts` 같은 생성된 TypeScript source module을 명시적 TypeScript loader boundary로 받아들이며, 기존 `.js`와 `.mjs` module path는 계속 Node.js native ESM으로 로드합니다. CLI는 inspect orchestration, JSON serialization, report wrapping, `--output <path>` artifact write를 소유하고, Studio는 snapshot parsing, filtering, connection inspection, viewer rendering, Mermaid graph semantics를 소유합니다. `fluo inspect`는 output mode flag가 없을 때 기본적으로 그 snapshot을 JSON으로 직렬화하고, `fluo inspect --mermaid`는 snapshot-to-Mermaid 렌더링을 선택적 `@fluojs/studio` 계약에 위임합니다. `--export <name>`은 bootstrap할 module export를 선택하며 기본값은 `AppModule`입니다. `--timing`은 명시적인 `--json` flag 없이 제공된 경우를 포함해 JSON snapshot 출력 옆에 bootstrap timing diagnostics를 기록하고, `--report`는 CI/support triage를 위해 런타임이 생산한 snapshot을 안정적인 요약과 함께 감쌉니다. `--timing`은 Mermaid 출력과 함께 사용할 수 없습니다. `--output <path>`는 선택한 inspect payload를 stdout 대신 명시적 artifact 경로에 씁니다. 이 동작은 검사 대상 애플리케이션을 writable하게 만들지 않으며, 일반 bootstrap/close cycle 외에 module graph state를 바꾸지 않습니다. Mermaid 출력이 필요하면 명령을 실행하는 프로젝트에 Studio를 설치하세요:
263
286
 
264
287
  ```bash
265
288
  pnpm add -D @fluojs/studio
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
@@ -259,7 +282,7 @@ fluo inspect ./src/app.module.ts --report --output artifacts/inspect-report.json
259
282
  fluo inspect ./src/app.module.ts --export AdminModule --json
260
283
  ```
261
284
 
262
- The runtime produces the inspection snapshot. `fluo inspect` accepts generated TypeScript source modules such as `./src/app.ts` or `./src/app.module.ts` through an explicit TypeScript loader boundary, while existing `.js` and `.mjs` module paths continue to load through native Node.js ESM. `fluo inspect` serializes the snapshot as JSON by default when no output mode flag is provided, and `fluo inspect --mermaid` delegates snapshot-to-Mermaid rendering to the optional `@fluojs/studio` contract. `--export <name>` selects the module export to bootstrap and defaults to `AppModule`; `--timing` records bootstrap timing diagnostics next to the JSON snapshot output, including when `--timing` is provided without an explicit `--json` flag, and `--report` wraps the runtime-produced snapshot with a stable summary for CI/support triage. `--timing` cannot be combined with Mermaid output. `--output <path>` writes the selected inspect payload to an explicit artifact path instead of stdout; it does not make the inspected application writable or change module graph state beyond the normal bootstrap/close cycle. Install Studio in the project that runs the command when you need Mermaid output:
285
+ The runtime produces the inspection snapshot. `fluo inspect` accepts generated TypeScript source modules such as `./src/app.ts` or `./src/app.module.ts` through an explicit TypeScript loader boundary, while existing `.js` and `.mjs` module paths continue to load through native Node.js ESM. The CLI owns inspect orchestration, JSON serialization, report wrapping, and `--output <path>` artifact writes; Studio owns snapshot parsing, filtering, connection inspection, viewer rendering, and Mermaid graph semantics. `fluo inspect` serializes the snapshot as JSON by default when no output mode flag is provided, and `fluo inspect --mermaid` delegates snapshot-to-Mermaid rendering to the optional `@fluojs/studio` contract. `--export <name>` selects the module export to bootstrap and defaults to `AppModule`; `--timing` records bootstrap timing diagnostics next to the JSON snapshot output, including when `--timing` is provided without an explicit `--json` flag, and `--report` wraps the runtime-produced snapshot with a stable summary for CI/support triage. `--timing` cannot be combined with Mermaid output. `--output <path>` writes the selected inspect payload to an explicit artifact path instead of stdout; it does not make the inspected application writable or change module graph state beyond the normal bootstrap/close cycle. Install Studio in the project that runs the command when you need Mermaid output:
263
286
 
264
287
  ```bash
265
288
  pnpm add -D @fluojs/studio
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,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,CA4M7F"}
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,20 +279,49 @@ 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
+ });
295
+ let childSettled = false;
296
+ child.once('error', error => {
297
+ if (childSettled) {
298
+ return;
299
+ }
300
+ childSettled = true;
301
+ restarting = false;
302
+ stderr.write(`[fluo] failed to start app child: ${error.message}\n`);
303
+ cleanup();
304
+ resolveExitCode(1);
305
+ });
238
306
  child.once('close', code => {
307
+ if (childSettled) {
308
+ return;
309
+ }
310
+ childSettled = true;
239
311
  if (restarting) {
240
312
  return;
241
313
  }
242
314
  if (stopping) {
315
+ publishStudioLifecycleEvent(env, runnerRuntime, 'disconnect', {
316
+ reason: 'fluo dev runner stopped'
317
+ });
243
318
  cleanup();
244
319
  resolveExitCode(code ?? 0);
245
320
  return;
246
321
  }
322
+ publishStudioLifecycleEvent(env, runnerRuntime, 'disconnect', {
323
+ reason: `app child exited with code ${String(code ?? 1)}`
324
+ });
247
325
  cleanup();
248
326
  resolveExitCode(code ?? 1);
249
327
  });
@@ -263,6 +341,10 @@ export async function runNodeRestartRunner(options) {
263
341
  if (env[SHOW_NODE_RESTART_NOTICE_ENV] === '1') {
264
342
  stdout.write(`[fluo] restarting after content change: ${relative(projectDirectory, restartPaths[restartPaths.length - 1] ?? projectDirectory)}\n`);
265
343
  }
344
+ publishStudioLifecycleEvent(env, runnerRuntime, 'restart', {
345
+ phase: 'scheduled',
346
+ reason: `content changed: ${relative(projectDirectory, restartPaths[restartPaths.length - 1] ?? projectDirectory)}`
347
+ });
266
348
  const previousChild = child;
267
349
  const startReplacementChild = () => {
268
350
  redrawDevScriptHeader(stdout, projectDirectory, env);
@@ -277,6 +359,10 @@ export async function runNodeRestartRunner(options) {
277
359
  return;
278
360
  }
279
361
  restarting = true;
362
+ publishStudioLifecycleEvent(env, runnerRuntime, 'restart', {
363
+ phase: 'stopping',
364
+ reason: 'stopping previous app child before restart'
365
+ });
280
366
  previousChild.once('close', () => {
281
367
  const committedRestartPaths = [...restartAfterClosePaths];
282
368
  restartAfterClosePaths.clear();
@@ -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.3",
12
+ "version": "1.0.5",
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.0"
47
+ "@fluojs/runtime": "^1.1.3"
48
48
  },
49
49
  "peerDependencies": {
50
- "@fluojs/studio": "^1.0.2"
50
+ "@fluojs/studio": "^1.0.5"
51
51
  },
52
52
  "peerDependenciesMeta": {
53
53
  "@fluojs/studio": {