@jxsuite/server 7.0.0 → 8.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -2
- package/package.json +37 -17
- package/src/collab.ts +9 -14
- package/src/extension-catalog.ts +5 -12
- package/src/file-response.ts +140 -0
- package/src/fs-glob.ts +45 -0
- package/src/live-preview.ts +26 -12
- package/src/net-guard.ts +2 -5
- package/src/npm-path.ts +145 -0
- package/src/packages-service.ts +213 -0
- package/src/packages.ts +233 -35
- package/src/project-server.ts +17 -17
- package/src/refactor/apply.ts +2 -1
- package/src/refactor/find-refs.ts +0 -0
- package/src/refactor/scan.ts +3 -3
- package/src/resolve.ts +3 -8
- package/src/serve.bun.ts +50 -0
- package/src/serve.node.ts +308 -0
- package/src/serve.ts +67 -0
- package/src/server.ts +10 -142
- package/src/site-preview.ts +36 -21
- package/src/studio-api.ts +41 -51
- package/src/watch-policy.ts +3 -3
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# `@jxsuite/server`
|
|
2
2
|
|
|
3
|
-
>
|
|
3
|
+
> Development server for Jx projects with live reload, proxy resolution, and Studio API, plus the project server, package operations and package service that also run on Node.
|
|
4
4
|
|
|
5
5
|
## Overview
|
|
6
6
|
|
|
@@ -73,7 +73,29 @@ OXC-powered tools for the Studio function body editor:
|
|
|
73
73
|
| ---------- | ----------------------------- |
|
|
74
74
|
| `chokidar` | File watching for live reload |
|
|
75
75
|
|
|
76
|
-
|
|
76
|
+
| `@npmcli/arborist` | Package operations (npm's installer) |
|
|
77
|
+
| `npm-package-arg` | Validating a package spec before anything is installed |
|
|
78
|
+
| `ws` | WebSockets for `serve()` on Node |
|
|
79
|
+
|
|
80
|
+
`createDevServer()` and the build pipeline run on Bun (`Bun.serve`, `Bun.build`, `Bun.Transpiler`).
|
|
81
|
+
|
|
82
|
+
## Running on Node
|
|
83
|
+
|
|
84
|
+
The desktop app runs the project server on the Node inside Electron, so everything it loads is runtime-neutral, with the Bun implementation chosen by the `bun` export condition:
|
|
85
|
+
|
|
86
|
+
- **`@jxsuite/server/serve`**: `serve()`, one HTTP and WebSocket server over `Bun.serve` under Bun and `node:http` with `ws` under Node. It is async, because Node binds asynchronously.
|
|
87
|
+
- **`@jxsuite/server/fs-glob`** and **`@jxsuite/server/file-response`**: globbing and file responses that answer as Bun's own built-ins do.
|
|
88
|
+
- **`@jxsuite/server/packages`**: add, remove, install and update-versions, run in-process on npm's Arborist. Install scripts never run, only the public registry is read, and the operation writes `package-lock.json` (a project's `bun.lock` is replaced on its first install).
|
|
89
|
+
- **`@jxsuite/server/packages-service`**: the same operations over HTTP, lockfile-only, for a host with no filesystem of its own. The Jx cloud platform runs it from the [jxsuite/package-manager](https://github.com/jxsuite/package-manager) container image.
|
|
90
|
+
|
|
91
|
+
## Upgrading to 8.0
|
|
92
|
+
|
|
93
|
+
8.0 is the release that made the server runtime-neutral. Code that imports these from `@jxsuite/server` needs to change:
|
|
94
|
+
|
|
95
|
+
- `createProjectServer()`, `startSitePreview()`, `sitePreviewOrigin()` and `stopSitePreviews()` return promises. Await them.
|
|
96
|
+
- `documentGlob()` returns the glob pattern as a string instead of a `Bun.Glob`. Scan it with `scanFiles()` from `@jxsuite/server/fs-glob`.
|
|
97
|
+
- `bunExecutable()` is removed: package operations no longer run a package manager in a subprocess.
|
|
98
|
+
- `installDependencies()`, `addPackage()`, `removePackage()` and `setPackageVersions()` take an options argument and write `package-lock.json` rather than `bun.lock`.
|
|
77
99
|
|
|
78
100
|
## License
|
|
79
101
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jxsuite/server",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "8.0.1",
|
|
4
4
|
"description": "Jx development server with live reload, server-side proxy, and studio integration",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -12,6 +12,12 @@
|
|
|
12
12
|
"src/"
|
|
13
13
|
],
|
|
14
14
|
"type": "module",
|
|
15
|
+
"imports": {
|
|
16
|
+
"#serve": {
|
|
17
|
+
"bun": "./src/serve.bun.ts",
|
|
18
|
+
"default": "./src/serve.node.ts"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
15
21
|
"exports": {
|
|
16
22
|
".": "./src/server.ts",
|
|
17
23
|
"./dev": "./src/dev.ts",
|
|
@@ -29,7 +35,14 @@
|
|
|
29
35
|
"./resolve": "./src/resolve.ts",
|
|
30
36
|
"./types": "./src/types.ts",
|
|
31
37
|
"./jx-mounts": "./src/jx-mounts.ts",
|
|
32
|
-
"./dev-vars": "./src/dev-vars.ts"
|
|
38
|
+
"./dev-vars": "./src/dev-vars.ts",
|
|
39
|
+
"./fs-glob": "./src/fs-glob.ts",
|
|
40
|
+
"./serve": {
|
|
41
|
+
"bun": "./src/serve.bun.ts",
|
|
42
|
+
"default": "./src/serve.node.ts"
|
|
43
|
+
},
|
|
44
|
+
"./file-response": "./src/file-response.ts",
|
|
45
|
+
"./packages-service": "./src/packages-service.ts"
|
|
33
46
|
},
|
|
34
47
|
"publishConfig": {
|
|
35
48
|
"provenance": true
|
|
@@ -41,25 +54,32 @@
|
|
|
41
54
|
"upgrade": "bun update --latest && bun install"
|
|
42
55
|
},
|
|
43
56
|
"dependencies": {
|
|
44
|
-
"@jxsuite/ai": "^0.41.
|
|
45
|
-
"@jxsuite/catalog": "^0.1.
|
|
46
|
-
"@jxsuite/collab": "^0.11.
|
|
47
|
-
"@jxsuite/compiler": "^
|
|
48
|
-
"@jxsuite/create": "^1.
|
|
49
|
-
"@jxsuite/import": "^0.
|
|
50
|
-
"@jxsuite/protocol": "^
|
|
51
|
-
"@jxsuite/runtime": "^6.0.
|
|
52
|
-
"@jxsuite/schema": "^
|
|
53
|
-
"@jxsuite/site": "^2.2.
|
|
54
|
-
"@jxsuite/starters": "^3.1.
|
|
57
|
+
"@jxsuite/ai": "^0.41.2",
|
|
58
|
+
"@jxsuite/catalog": "^0.1.5",
|
|
59
|
+
"@jxsuite/collab": "^0.11.4",
|
|
60
|
+
"@jxsuite/compiler": "^8.0.1",
|
|
61
|
+
"@jxsuite/create": "^1.5.1",
|
|
62
|
+
"@jxsuite/import": "^0.42.1",
|
|
63
|
+
"@jxsuite/protocol": "^6.1.0",
|
|
64
|
+
"@jxsuite/runtime": "^6.0.1",
|
|
65
|
+
"@jxsuite/schema": "^5.0.0",
|
|
66
|
+
"@jxsuite/site": "^2.2.2",
|
|
67
|
+
"@jxsuite/starters": "^3.1.3",
|
|
68
|
+
"@npmcli/arborist": "^10.0.4",
|
|
55
69
|
"chokidar": "^6.0.0",
|
|
56
70
|
"kysely": "^0.29.7",
|
|
57
|
-
"
|
|
71
|
+
"npm-package-arg": "^14.0.0",
|
|
72
|
+
"oxfmt": "^0.75.0",
|
|
73
|
+
"ws": "^8.22.1",
|
|
58
74
|
"zod": "^4.6.7"
|
|
59
75
|
},
|
|
60
76
|
"devDependencies": {
|
|
61
|
-
"@jxsuite/auth": "^0.8.
|
|
62
|
-
"@jxsuite/connector": "^0.8.
|
|
63
|
-
"@jxsuite/parser": "^4.1.
|
|
77
|
+
"@jxsuite/auth": "^0.8.2",
|
|
78
|
+
"@jxsuite/connector": "^0.8.2",
|
|
79
|
+
"@jxsuite/parser": "^4.1.2",
|
|
80
|
+
"@jxsuite/test-support": "^0.0.0",
|
|
81
|
+
"@types/npm-package-arg": "^6.1.4",
|
|
82
|
+
"@types/npmcli__arborist": "^6.3.3",
|
|
83
|
+
"@types/ws": "^8.18.2"
|
|
64
84
|
}
|
|
65
85
|
}
|
package/src/collab.ts
CHANGED
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
selectSubprotocol,
|
|
25
25
|
} from "@jxsuite/collab/negotiate";
|
|
26
26
|
import { assertAccessible } from "./studio-api";
|
|
27
|
-
import type {
|
|
27
|
+
import type { ServeServer, ServeSocket, ServeWebSocketHandler } from "./serve.ts";
|
|
28
28
|
import type { HostConnection } from "@jxsuite/collab/room";
|
|
29
29
|
import { problem } from "./problem.ts";
|
|
30
30
|
|
|
@@ -66,20 +66,15 @@ export interface CollabSocketData {
|
|
|
66
66
|
export interface CollabRegistry {
|
|
67
67
|
/**
|
|
68
68
|
* Handle a request to /__studio/collab: WebSocket upgrades become collab connections; a plain GET
|
|
69
|
-
* answers the capability probe. Returns undefined when the upgrade was accepted (
|
|
70
|
-
* 101 itself).
|
|
69
|
+
* answers the capability probe. Returns undefined when the upgrade was accepted (the server sends
|
|
70
|
+
* the 101 itself).
|
|
71
71
|
*/
|
|
72
72
|
handleRequest: (
|
|
73
73
|
req: Request,
|
|
74
|
-
server:
|
|
75
|
-
upgrade: (
|
|
76
|
-
req: Request,
|
|
77
|
-
opts: { data: CollabSocketData; headers?: Record<string, string> },
|
|
78
|
-
) => boolean;
|
|
79
|
-
},
|
|
74
|
+
server: Pick<ServeServer<CollabSocketData>, "upgrade">,
|
|
80
75
|
) => Response | undefined;
|
|
81
|
-
/**
|
|
82
|
-
websocket:
|
|
76
|
+
/** The websocket handlers backing the upgraded connections (`serve.ts`). */
|
|
77
|
+
websocket: ServeWebSocketHandler<CollabSocketData>;
|
|
83
78
|
/** Watcher hook: a file changed on disk (absolute path). Echoes of our write-back are ignored. */
|
|
84
79
|
handleExternalChange: (absPath: string) => void;
|
|
85
80
|
/** Flush pending write-backs and tear everything down. */
|
|
@@ -253,17 +248,17 @@ export function createCollabRegistry(opts: {
|
|
|
253
248
|
},
|
|
254
249
|
|
|
255
250
|
websocket: {
|
|
256
|
-
close(ws:
|
|
251
|
+
close(ws: ServeSocket<CollabSocketData>) {
|
|
257
252
|
ws.data.connection?.close();
|
|
258
253
|
ws.data.connection = null;
|
|
259
254
|
},
|
|
260
|
-
message(ws:
|
|
255
|
+
message(ws: ServeSocket<CollabSocketData>, message: string | Buffer) {
|
|
261
256
|
if (isString(message)) {
|
|
262
257
|
return;
|
|
263
258
|
}
|
|
264
259
|
ws.data.connection?.handleMessage(new Uint8Array(message));
|
|
265
260
|
},
|
|
266
|
-
open(ws:
|
|
261
|
+
open(ws: ServeSocket<CollabSocketData>) {
|
|
267
262
|
connectionCounter += 1;
|
|
268
263
|
const login = `local-${connectionCounter}`;
|
|
269
264
|
ws.data.connection = host.connect(
|
package/src/extension-catalog.ts
CHANGED
|
@@ -8,10 +8,11 @@
|
|
|
8
8
|
* `extensions/` tree, so core keeps no dependency on an extension package (§2).
|
|
9
9
|
* 2. The project's own dependencies, scanned for anything exporting a `jx-extension.json`.
|
|
10
10
|
*
|
|
11
|
-
* Both are then annotated with
|
|
12
|
-
* package
|
|
13
|
-
*
|
|
14
|
-
*
|
|
11
|
+
* Both are then annotated with the one fact only a backend holds: whether the PROJECT resolves the
|
|
12
|
+
* package. It is probed rather than declared, and it is the only resolution asked about: an
|
|
13
|
+
* extension resolves from the project's own install and never from this host, first-party ones
|
|
14
|
+
* included (specs/extensions.md §3), so enabling one the project lacks is always
|
|
15
|
+
* install-then-enable.
|
|
15
16
|
*
|
|
16
17
|
* **`installed` is answered here rather than by the client**, because `listPackages` does not mean
|
|
17
18
|
* one thing across backends: the dev server's own packages route drops a declared dependency it
|
|
@@ -184,11 +185,6 @@ export async function buildExtensionCatalog(projectRoot: string): Promise<Extens
|
|
|
184
185
|
if (meta.requires) {
|
|
185
186
|
entry.requires = meta.requires;
|
|
186
187
|
}
|
|
187
|
-
// Bundled means "resolves without a project install": the host answers for it, and the
|
|
188
|
-
// Project does not have to.
|
|
189
|
-
if (found.host && !found.project) {
|
|
190
|
-
entry.bundled = true;
|
|
191
|
-
}
|
|
192
188
|
entries.push(entry);
|
|
193
189
|
}
|
|
194
190
|
|
|
@@ -238,9 +234,6 @@ export async function buildExtensionCatalog(projectRoot: string): Promise<Extens
|
|
|
238
234
|
if (loaded.formats.length > 0) {
|
|
239
235
|
entry.formats = loaded.formats;
|
|
240
236
|
}
|
|
241
|
-
if (found.host) {
|
|
242
|
-
entry.bundled = true;
|
|
243
|
-
}
|
|
244
237
|
entries.push(entry);
|
|
245
238
|
}
|
|
246
239
|
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File responses on `node:fs`, answering what `new Response(Bun.file(path))` answered.
|
|
3
|
+
*
|
|
4
|
+
* The desktop backend runs on Electron's Node (specs/desktop.md §7.1), so the servers it reaches
|
|
5
|
+
* cannot hand `Bun.file` to a `Response`. `openAsBlob` is the shared equivalent: a lazily read,
|
|
6
|
+
* file-backed `Blob` whose `type` becomes the response's `Content-Type`. What it lacks is Bun's
|
|
7
|
+
* inference of that type from the extension, which {@link HOST_MEDIA_TYPES} reproduces for every
|
|
8
|
+
* extension a project or the Studio assets serve; `tests/file-response.test.ts` holds each entry
|
|
9
|
+
* equal to `Bun.file(...).type`. The corrections Jx makes to a host's table (`mediaTypeForPath`)
|
|
10
|
+
* are applied by callers on top of this, exactly as they were on top of Bun's.
|
|
11
|
+
*
|
|
12
|
+
* @docs studio/desktop
|
|
13
|
+
*/
|
|
14
|
+
import { openAsBlob, statSync } from "node:fs";
|
|
15
|
+
import { extname } from "node:path";
|
|
16
|
+
|
|
17
|
+
/** The type `Bun.file` infers for an extension, where it is not `application/octet-stream`. */
|
|
18
|
+
export const HOST_MEDIA_TYPES = new Map<string, string>([
|
|
19
|
+
[".7z", "application/x-7z-compressed"],
|
|
20
|
+
[".aac", "audio/x-aac"],
|
|
21
|
+
[".ai", "application/postscript"],
|
|
22
|
+
[".apng", "image/apng"],
|
|
23
|
+
[".atom", "application/atom+xml"],
|
|
24
|
+
[".avif", "image/avif"],
|
|
25
|
+
[".bmp", "image/x-ms-bmp"],
|
|
26
|
+
[".cjs", "text/javascript;charset=utf-8"],
|
|
27
|
+
[".class", "application/java-vm"],
|
|
28
|
+
[".css", "text/css;charset=utf-8"],
|
|
29
|
+
[".csv", "text/csv"],
|
|
30
|
+
[".dmg", "application/x-apple-diskimage"],
|
|
31
|
+
[".doc", "application/msword"],
|
|
32
|
+
[".docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"],
|
|
33
|
+
[".eot", "application/vnd.ms-fontobject"],
|
|
34
|
+
[".eps", "application/postscript"],
|
|
35
|
+
[".epub", "application/epub+zip"],
|
|
36
|
+
[".exe", "application/x-msdownload"],
|
|
37
|
+
[".flac", "audio/x-flac"],
|
|
38
|
+
[".gif", "image/gif"],
|
|
39
|
+
[".glb", "model/gltf-binary"],
|
|
40
|
+
[".gltf", "model/gltf+json"],
|
|
41
|
+
[".gz", "application/gzip"],
|
|
42
|
+
[".hbs", "text/x-handlebars-template"],
|
|
43
|
+
[".heic", "image/heic"],
|
|
44
|
+
[".htm", "text/html;charset=utf-8"],
|
|
45
|
+
[".html", "text/html;charset=utf-8"],
|
|
46
|
+
[".ico", "image/x-icon"],
|
|
47
|
+
[".ics", "text/calendar"],
|
|
48
|
+
[".iso", "application/x-iso9660-image"],
|
|
49
|
+
[".jpeg", "image/jpeg"],
|
|
50
|
+
[".jpg", "image/jpeg"],
|
|
51
|
+
[".js", "text/javascript;charset=utf-8"],
|
|
52
|
+
[".json", "application/json;charset=utf-8"],
|
|
53
|
+
[".jsonld", "application/ld+json"],
|
|
54
|
+
[".jsx", "text/javascript;charset=utf-8"],
|
|
55
|
+
[".log", "text/plain;charset=utf-8"],
|
|
56
|
+
[".m4a", "audio/x-m4a"],
|
|
57
|
+
[".map", "application/json;charset=utf-8"],
|
|
58
|
+
[".markdown", "text/markdown"],
|
|
59
|
+
[".md", "text/markdown"],
|
|
60
|
+
[".mdx", "text/mdx"],
|
|
61
|
+
[".mjs", "text/javascript;charset=utf-8"],
|
|
62
|
+
[".mov", "video/quicktime"],
|
|
63
|
+
[".mp3", "audio/mpeg"],
|
|
64
|
+
[".mp4", "video/mp4"],
|
|
65
|
+
[".mts", "text/javascript;charset=utf-8"],
|
|
66
|
+
[".oga", "audio/ogg"],
|
|
67
|
+
[".ogg", "audio/ogg"],
|
|
68
|
+
[".ogv", "video/ogg"],
|
|
69
|
+
[".opus", "audio/ogg"],
|
|
70
|
+
[".otf", "font/otf"],
|
|
71
|
+
[".pdf", "application/pdf"],
|
|
72
|
+
[".php", "application/x-httpd-php"],
|
|
73
|
+
[".png", "image/png"],
|
|
74
|
+
[".ppt", "application/vnd.ms-powerpoint"],
|
|
75
|
+
[".pptx", "application/vnd.openxmlformats-officedocument.presentationml.presentation"],
|
|
76
|
+
[".psd", "image/vnd.adobe.photoshop"],
|
|
77
|
+
[".rar", "application/x-rar-compressed"],
|
|
78
|
+
[".rss", "application/rss+xml"],
|
|
79
|
+
[".rtf", "text/rtf"],
|
|
80
|
+
[".sh", "application/x-sh"],
|
|
81
|
+
[".srt", "application/x-subrip"],
|
|
82
|
+
[".svg", "image/svg+xml"],
|
|
83
|
+
[".tar", "application/x-tar"],
|
|
84
|
+
[".tif", "image/tiff"],
|
|
85
|
+
[".tiff", "image/tiff"],
|
|
86
|
+
[".toml", "application/toml"],
|
|
87
|
+
[".ts", "text/javascript;charset=utf-8"],
|
|
88
|
+
[".tsv", "text/tab-separated-values"],
|
|
89
|
+
[".tsx", "text/javascript;charset=utf-8"],
|
|
90
|
+
[".ttf", "font/ttf"],
|
|
91
|
+
[".txt", "text/plain;charset=utf-8"],
|
|
92
|
+
[".vtt", "text/vtt"],
|
|
93
|
+
[".wasm", "application/wasm"],
|
|
94
|
+
[".wav", "audio/x-wav"],
|
|
95
|
+
[".weba", "audio/webm"],
|
|
96
|
+
[".webm", "video/webm"],
|
|
97
|
+
[".webmanifest", "application/manifest+json"],
|
|
98
|
+
[".webp", "image/webp"],
|
|
99
|
+
[".woff", "font/woff"],
|
|
100
|
+
[".woff2", "font/woff2"],
|
|
101
|
+
[".xls", "application/vnd.ms-excel"],
|
|
102
|
+
[".xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"],
|
|
103
|
+
[".xml", "application/xml"],
|
|
104
|
+
[".yaml", "text/yaml"],
|
|
105
|
+
[".yml", "text/yaml"],
|
|
106
|
+
[".zip", "application/zip"],
|
|
107
|
+
]);
|
|
108
|
+
|
|
109
|
+
const FALLBACK_MEDIA_TYPE = "application/octet-stream";
|
|
110
|
+
|
|
111
|
+
/** The media type Bun would infer for `path` from its extension (case-insensitive). */
|
|
112
|
+
export function hostMediaType(path: string): string {
|
|
113
|
+
return HOST_MEDIA_TYPES.get(extname(path).toLowerCase()) ?? FALLBACK_MEDIA_TYPE;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** True when `path` names a regular file (Bun's `exists()` is false for a directory). */
|
|
117
|
+
function isFile(path: string): boolean {
|
|
118
|
+
try {
|
|
119
|
+
return statSync(path).isFile();
|
|
120
|
+
} catch {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The file at `path` as a lazily read `Blob` typed by its extension, or null when there is no
|
|
127
|
+
* regular file there.
|
|
128
|
+
*/
|
|
129
|
+
export async function openFile(path: string): Promise<Blob | null> {
|
|
130
|
+
if (!isFile(path)) {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
return openAsBlob(path, { type: hostMediaType(path) });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** A response streaming the file at `path`, or null when there is no regular file there. */
|
|
137
|
+
export async function fileResponse(path: string, init?: ResponseInit): Promise<Response | null> {
|
|
138
|
+
const blob = await openFile(path);
|
|
139
|
+
return blob ? new Response(blob, init) : null;
|
|
140
|
+
}
|
package/src/fs-glob.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File globbing on `node:fs`, answering what `Bun.Glob#scan({ cwd, dot: false })` answered.
|
|
3
|
+
*
|
|
4
|
+
* The desktop backend runs on Electron's Node (specs/desktop.md §7.1), so nothing it reaches may
|
|
5
|
+
* call `Bun.Glob`. `fs.promises.glob` is the shared API, and two of its defaults differ from Bun's:
|
|
6
|
+
* it yields directories as well as files, and it yields a symbolic link as itself. A third
|
|
7
|
+
* difference is in the matcher, below. Bun's scan yields regular files only and does not follow
|
|
8
|
+
* links, so both are filtered here. `tests/fs-glob.test.ts` holds the two answers equal on the same
|
|
9
|
+
* tree.
|
|
10
|
+
*
|
|
11
|
+
* Paths are relative to `cwd` with the platform's own separator, as Bun's were.
|
|
12
|
+
*/
|
|
13
|
+
import type { Dirent } from "node:fs";
|
|
14
|
+
import { glob, opendir } from "node:fs/promises";
|
|
15
|
+
import { join, relative } from "node:path";
|
|
16
|
+
|
|
17
|
+
/** Bun's scan neither yields nor descends through a symbolic link, file or directory. */
|
|
18
|
+
function isLink(entry: Dirent): boolean {
|
|
19
|
+
return entry.isSymbolicLink();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Bun expands a one-alternative brace (`*.{json}`) and Node's matcher keeps it literal, so a
|
|
24
|
+
* pattern built from a list that happens to hold one extension would match nothing. Unwrap those.
|
|
25
|
+
*/
|
|
26
|
+
function unwrapSingleBraces(pattern: string): string {
|
|
27
|
+
return pattern.replaceAll(/\{([^{},]*)\}/g, "$1");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Every regular file under `cwd` matching `pattern`, skipping dot-prefixed names.
|
|
32
|
+
*
|
|
33
|
+
* @yields {string} Each match, relative to `cwd`
|
|
34
|
+
*/
|
|
35
|
+
export async function* scanFiles(pattern: string, cwd: string): AsyncGenerator<string> {
|
|
36
|
+
// Bun's scan throws for a root it cannot read; Node's glob yields nothing. Opening it first throws.
|
|
37
|
+
const dir = await opendir(cwd);
|
|
38
|
+
await dir.close();
|
|
39
|
+
const options = { cwd, exclude: isLink, withFileTypes: true } as const;
|
|
40
|
+
for await (const entry of glob(unwrapSingleBraces(pattern), options)) {
|
|
41
|
+
if (entry.isFile()) {
|
|
42
|
+
yield relative(cwd, join(entry.parentPath, entry.name));
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
package/src/live-preview.ts
CHANGED
|
@@ -54,6 +54,9 @@ import { handleResolve, handleServerFunction } from "./resolve.ts";
|
|
|
54
54
|
import { handleJxMounts } from "./jx-mounts.ts";
|
|
55
55
|
import { loopbackGate, originHostGate } from "./net-guard.ts";
|
|
56
56
|
import { problem } from "./problem.ts";
|
|
57
|
+
import { scanFiles } from "./fs-glob.ts";
|
|
58
|
+
import { serve } from "#serve";
|
|
59
|
+
import { bundleToString } from "@jxsuite/compiler/bundler";
|
|
57
60
|
|
|
58
61
|
/** A running live preview for one project. */
|
|
59
62
|
export interface LivePreview {
|
|
@@ -116,7 +119,7 @@ function overlayFor(projectRoot: string): Overlay {
|
|
|
116
119
|
}
|
|
117
120
|
|
|
118
121
|
interface Running extends LivePreview {
|
|
119
|
-
server: { stop: (closeActiveConnections?: boolean) => void };
|
|
122
|
+
server: { stop: (closeActiveConnections?: boolean) => Promise<void> };
|
|
120
123
|
hub: SseHub;
|
|
121
124
|
/** This origin's own resolver credential, distinct from any editing server's. */
|
|
122
125
|
token: string;
|
|
@@ -132,6 +135,8 @@ interface Running extends LivePreview {
|
|
|
132
135
|
|
|
133
136
|
/** One origin per project root — a second Open in Browser reuses the first one's port. */
|
|
134
137
|
const running = new Map<string, Running>();
|
|
138
|
+
/** Origins mid-bind, so two starts racing for one project share a server. */
|
|
139
|
+
const starting = new Map<string, Promise<Running>>();
|
|
135
140
|
|
|
136
141
|
/** The runtime bundle is the same bytes for every project, so it is read once per process. */
|
|
137
142
|
let runtimeBundle: string | null = null;
|
|
@@ -162,11 +167,10 @@ async function loadRuntimeBundle(): Promise<string> {
|
|
|
162
167
|
} catch {
|
|
163
168
|
// No published dist on this install; fall through and build one.
|
|
164
169
|
}
|
|
165
|
-
|
|
166
|
-
|
|
170
|
+
runtimeBundle = await bundleToString(require_.resolve("@jxsuite/runtime"), {
|
|
171
|
+
minify: false,
|
|
167
172
|
target: "browser",
|
|
168
173
|
});
|
|
169
|
-
runtimeBundle = await result.outputs[0]!.text();
|
|
170
174
|
return runtimeBundle;
|
|
171
175
|
}
|
|
172
176
|
|
|
@@ -178,7 +182,7 @@ async function treePaths(projectRoot: string, overlay: Map<string, string>): Pro
|
|
|
178
182
|
if (!existsSync(dir)) {
|
|
179
183
|
continue;
|
|
180
184
|
}
|
|
181
|
-
for await (const match of
|
|
185
|
+
for await (const match of scanFiles("**/*", dir)) {
|
|
182
186
|
found.add(`${root}${match.replaceAll("\\", "/")}`);
|
|
183
187
|
}
|
|
184
188
|
}
|
|
@@ -419,7 +423,20 @@ export async function startLivePreview(projectRoot: string): Promise<LivePreview
|
|
|
419
423
|
await ensureCache(existing, projectRoot);
|
|
420
424
|
return report(existing, projectRoot);
|
|
421
425
|
}
|
|
426
|
+
let pending = starting.get(projectRoot);
|
|
427
|
+
if (!pending) {
|
|
428
|
+
pending = listen(projectRoot).finally(() => starting.delete(projectRoot));
|
|
429
|
+
starting.set(projectRoot, pending);
|
|
430
|
+
}
|
|
431
|
+
const entry = await pending;
|
|
432
|
+
/* Counted before answering, because the caller reports it to the author and "0 pages" from a site
|
|
433
|
+
with pages is worse than the moment it costs to walk the tree. */
|
|
434
|
+
await ensureCache(entry, projectRoot);
|
|
435
|
+
return report(entry, projectRoot);
|
|
436
|
+
}
|
|
422
437
|
|
|
438
|
+
/** Bind the live preview origin for `projectRoot` and register it. */
|
|
439
|
+
async function listen(projectRoot: string): Promise<Running> {
|
|
423
440
|
const entry: Running = {
|
|
424
441
|
burstStartedAt: null,
|
|
425
442
|
cache: null,
|
|
@@ -431,11 +448,11 @@ export async function startLivePreview(projectRoot: string): Promise<LivePreview
|
|
|
431
448
|
port: 0,
|
|
432
449
|
reloadTimer: null,
|
|
433
450
|
routes: 0,
|
|
434
|
-
server: { stop: () => {} },
|
|
451
|
+
server: { stop: async () => {} },
|
|
435
452
|
token: crypto.randomUUID(),
|
|
436
453
|
};
|
|
437
454
|
|
|
438
|
-
const server =
|
|
455
|
+
const server = await serve({
|
|
439
456
|
fetch: (req) => handle(entry, projectRoot, req),
|
|
440
457
|
hostname: "127.0.0.1",
|
|
441
458
|
/* The composed page is assembled per request and the stream is long-lived; the dev server's
|
|
@@ -449,10 +466,7 @@ export async function startLivePreview(projectRoot: string): Promise<LivePreview
|
|
|
449
466
|
entry.origin = server.url.origin;
|
|
450
467
|
entry.port = Number(server.url.port);
|
|
451
468
|
running.set(projectRoot, entry);
|
|
452
|
-
|
|
453
|
-
with pages is worse than the moment it costs to walk the tree. */
|
|
454
|
-
await ensureCache(entry, projectRoot);
|
|
455
|
-
return report(entry, projectRoot);
|
|
469
|
+
return entry;
|
|
456
470
|
}
|
|
457
471
|
|
|
458
472
|
/** What a caller hands back to Studio. */
|
|
@@ -616,7 +630,7 @@ export function stopLivePreviews(): void {
|
|
|
616
630
|
if (entry.reloadTimer) {
|
|
617
631
|
clearTimeout(entry.reloadTimer);
|
|
618
632
|
}
|
|
619
|
-
entry.server.stop(true);
|
|
633
|
+
void entry.server.stop(true);
|
|
620
634
|
}
|
|
621
635
|
running.clear();
|
|
622
636
|
}
|
package/src/net-guard.ts
CHANGED
|
@@ -21,6 +21,7 @@ import { realpathSync } from "node:fs";
|
|
|
21
21
|
import { resolveAssetUrl } from "@jxsuite/schema/asset-paths";
|
|
22
22
|
import type { AssetMount } from "@jxsuite/schema/asset-paths";
|
|
23
23
|
import { problem } from "./problem.ts";
|
|
24
|
+
import { fileResponse } from "./file-response.ts";
|
|
24
25
|
|
|
25
26
|
export const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
|
|
26
27
|
|
|
@@ -300,11 +301,7 @@ export async function serveContained(absPath: string, root: string): Promise<Res
|
|
|
300
301
|
if (!safe) {
|
|
301
302
|
return null;
|
|
302
303
|
}
|
|
303
|
-
|
|
304
|
-
if (await file.exists()) {
|
|
305
|
-
return new Response(file);
|
|
306
|
-
}
|
|
307
|
-
return null;
|
|
304
|
+
return fileResponse(safe);
|
|
308
305
|
}
|
|
309
306
|
|
|
310
307
|
/**
|