@fluojs/studio 1.0.7 → 2.0.0

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.
@@ -0,0 +1,189 @@
1
+ #!/usr/bin/env node
2
+ import { readFile, realpath } from 'node:fs/promises';
3
+ import { createServer } from 'node:http';
4
+ import { extname, resolve, sep } from 'node:path';
5
+ const viewerHost = '127.0.0.1';
6
+ /** Grace period before close destroys incomplete viewer-owned connections. */
7
+ export const viewerServerCloseGracePeriodMs = 1_000;
8
+ const mimeTypes = {
9
+ '.css': 'text/css; charset=utf-8',
10
+ '.html': 'text/html; charset=utf-8',
11
+ '.js': 'text/javascript; charset=utf-8',
12
+ '.svg': 'image/svg+xml'
13
+ };
14
+
15
+ /** Supported parsed command-line request for the packaged Studio viewer. */
16
+
17
+ /** Running local HTTP server for the packaged Studio viewer. */
18
+
19
+ /**
20
+ * Parses command-line arguments for the packaged Studio viewer.
21
+ *
22
+ * @param arguments_ - Arguments passed after the viewer command name.
23
+ * @returns A help or launch request.
24
+ */
25
+ export function parseStudioViewerArguments(arguments_) {
26
+ let port = 0;
27
+ for (let index = 0; index < arguments_.length; index += 1) {
28
+ const argument = arguments_[index];
29
+ switch (argument) {
30
+ case '--help':
31
+ case '-h':
32
+ return {
33
+ kind: 'help'
34
+ };
35
+ case '--port':
36
+ {
37
+ const portArgument = arguments_[index + 1];
38
+ if (portArgument === undefined) {
39
+ throw new Error('Missing port after --port.');
40
+ }
41
+ port = parsePort(portArgument);
42
+ index += 1;
43
+ break;
44
+ }
45
+ default:
46
+ throw new Error(`Unsupported viewer option: ${argument}`);
47
+ }
48
+ }
49
+ return {
50
+ kind: 'launch',
51
+ port
52
+ };
53
+ }
54
+
55
+ /**
56
+ * Starts an HTTP server that serves the installed Studio viewer artifacts.
57
+ *
58
+ * @param options - Local server options and the packaged artifact directory.
59
+ * @returns The running server and its local URL.
60
+ */
61
+ export async function startStudioViewerServer(options) {
62
+ const viewerDirectory = await realpath(resolve(options.viewerDirectory));
63
+ const server = createServer((request, response) => {
64
+ void serveViewerAsset(request, response, viewerDirectory).catch(() => {
65
+ if (!response.headersSent) {
66
+ response.writeHead(500);
67
+ }
68
+ response.end();
69
+ });
70
+ });
71
+ const connections = new Set();
72
+ server.on('connection', socket => {
73
+ connections.add(socket);
74
+ socket.once('close', () => connections.delete(socket));
75
+ });
76
+ await listen(server, options.port);
77
+ const address = server.address();
78
+ if (address === null || typeof address === 'string') {
79
+ throw new Error('Studio viewer did not bind to a TCP port.');
80
+ }
81
+ let terminalClose;
82
+ return {
83
+ url: new URL(`http://${viewerHost}:${String(address.port)}/`),
84
+ close: () => {
85
+ terminalClose ??= close(server, connections);
86
+ return terminalClose;
87
+ }
88
+ };
89
+ }
90
+ async function serveViewerAsset(request, response, viewerDirectory) {
91
+ if (request.method !== 'GET' && request.method !== 'HEAD') {
92
+ response.writeHead(405, {
93
+ allow: 'GET, HEAD'
94
+ }).end();
95
+ return;
96
+ }
97
+ let pathname;
98
+ try {
99
+ pathname = new URL(request.url ?? '/', `http://${viewerHost}`).pathname;
100
+ } catch {
101
+ response.writeHead(400).end();
102
+ return;
103
+ }
104
+ const relativePath = pathname === '/' ? 'index.html' : `.${pathname}`;
105
+ const assetPath = resolve(viewerDirectory, relativePath);
106
+ if (!assetPath.startsWith(`${viewerDirectory}${sep}`)) {
107
+ response.writeHead(404).end();
108
+ return;
109
+ }
110
+ try {
111
+ const resolvedAssetPath = await realpath(assetPath);
112
+ if (!resolvedAssetPath.startsWith(`${viewerDirectory}${sep}`)) {
113
+ response.writeHead(404).end();
114
+ return;
115
+ }
116
+ const content = await readFile(resolvedAssetPath);
117
+ const mimeType = mimeTypes[extname(resolvedAssetPath)] ?? 'application/octet-stream';
118
+ response.writeHead(200, {
119
+ 'content-type': mimeType
120
+ }).end(request.method === 'HEAD' ? undefined : content);
121
+ } catch {
122
+ response.writeHead(404).end();
123
+ }
124
+ }
125
+ function parsePort(value) {
126
+ const port = Number(value);
127
+ if (!Number.isSafeInteger(port) || port < 0 || port > 65_535) {
128
+ throw new Error(`Invalid port: ${value}`);
129
+ }
130
+ return port;
131
+ }
132
+ async function listen(server, port) {
133
+ await new Promise((resolveListen, rejectListen) => {
134
+ server.once('error', rejectListen);
135
+ server.listen({
136
+ host: viewerHost,
137
+ port
138
+ }, () => {
139
+ server.off('error', rejectListen);
140
+ resolveListen();
141
+ });
142
+ });
143
+ }
144
+ async function close(server, connections) {
145
+ const terminalClose = new Promise((resolveClose, rejectClose) => {
146
+ server.close(error => {
147
+ if (error === undefined) {
148
+ resolveClose();
149
+ return;
150
+ }
151
+ rejectClose(error);
152
+ });
153
+ });
154
+ const forceCloseTimer = setTimeout(() => {
155
+ for (const connection of connections) {
156
+ connection.destroy();
157
+ }
158
+ }, viewerServerCloseGracePeriodMs);
159
+ try {
160
+ await terminalClose;
161
+ } finally {
162
+ clearTimeout(forceCloseTimer);
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Runs the executable Studio viewer command against a packaged artifact directory.
168
+ *
169
+ * @param arguments_ - Arguments passed after the executable name.
170
+ * @param viewerDirectory - Installed directory containing the viewer HTML assets.
171
+ * @returns A Promise that resolves after help output or successful local server launch.
172
+ */
173
+ export async function runStudioViewerCli(arguments_, viewerDirectory) {
174
+ const request = parseStudioViewerArguments(arguments_);
175
+ switch (request.kind) {
176
+ case 'help':
177
+ process.stdout.write('Usage: fluo-studio-viewer [--port <0-65535>]\n');
178
+ return;
179
+ case 'launch':
180
+ {
181
+ const server = await startStudioViewerServer({
182
+ port: request.port,
183
+ viewerDirectory
184
+ });
185
+ process.stdout.write(`Fluo Studio viewer: ${server.url.href}\nPress Ctrl+C to stop.\n`);
186
+ return;
187
+ }
188
+ }
189
+ }
package/package.json CHANGED
@@ -9,7 +9,7 @@
9
9
  "module-graph",
10
10
  "devtools"
11
11
  ],
12
- "version": "1.0.7",
12
+ "version": "2.0.0",
13
13
  "private": false,
14
14
  "license": "MIT",
15
15
  "repository": {
@@ -18,12 +18,15 @@
18
18
  "directory": "packages/studio"
19
19
  },
20
20
  "engines": {
21
- "node": ">=20.0.0"
21
+ "node": ">=24.0.0 <27"
22
22
  },
23
23
  "publishConfig": {
24
24
  "access": "public"
25
25
  },
26
26
  "type": "module",
27
+ "bin": {
28
+ "fluo-studio-viewer": "./dist/viewer-cli.js"
29
+ },
27
30
  "exports": {
28
31
  ".": {
29
32
  "types": "./dist/index.d.ts",
@@ -42,22 +45,23 @@
42
45
  ],
43
46
  "dependencies": {
44
47
  "react": "^19.2.6",
45
- "react-dom": "^19.2.6",
46
- "@fluojs/runtime": "^1.1.8"
48
+ "react-dom": "^19.2.6"
47
49
  },
48
50
  "devDependencies": {
51
+ "@playwright/test": "^1.61.1",
49
52
  "@types/react": "^19.2.14",
50
53
  "@types/react-dom": "^19.2.3",
51
54
  "happy-dom": "^20.9.0",
52
- "vitest": "^3.2.4",
55
+ "vitest": "^4.1.11",
53
56
  "@fluojs-internal/tooling-vite": "^0.0.1"
54
57
  },
55
58
  "scripts": {
56
59
  "prebuild": "node ../../tooling/scripts/clean-dist.mjs",
57
- "build": "pnpm exec vite build && pnpm exec babel src/contracts.ts src/index.ts --extensions .ts --out-dir dist --config-file ../../tooling/babel/babel.config.cjs && pnpm exec tsc -p tsconfig.build.json",
60
+ "build": "pnpm exec vite build && pnpm exec babel src/contracts.ts src/index.ts src/viewer-server.ts src/viewer-cli.ts --extensions .ts --out-dir dist --config-file ../../tooling/babel/babel.config.cjs && pnpm exec tsc -p tsconfig.build.json",
58
61
  "dev": "pnpm exec vite",
59
62
  "preview": "pnpm exec vite preview",
60
- "typecheck": "pnpm exec tsc -p tsconfig.json --noEmit",
63
+ "test:browser": "pnpm build && pnpm exec playwright test -c playwright.config.ts",
64
+ "typecheck": "pnpm exec tsc -p tsconfig.json --noEmit && pnpm exec tsc -p playwright.tsconfig.json --noEmit",
61
65
  "test": "pnpm exec vitest run -c vitest.config.ts",
62
66
  "test:watch": "pnpm exec vitest -c vitest.config.ts"
63
67
  }