@jxsuite/server 6.1.0 → 8.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.
- package/README.md +24 -2
- package/package.json +36 -16
- package/src/collab.ts +9 -14
- package/src/dev.ts +95 -1
- 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 +212 -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 +52 -22
- package/src/serve.bun.ts +50 -0
- package/src/serve.node.ts +311 -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 +43 -151
- package/src/watch-policy.ts +3 -3
package/src/npm-path.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolving an npm bare specifier from a URL path, shared by the dev server (`server.ts`) and the
|
|
3
|
+
* desktop's project server (`project-server.ts`). Its own module so the project server does not
|
|
4
|
+
* import the dev server, which stays Bun-only (server.md §1).
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { isJsonObject, isNonNullObject, isString } from "@jxsuite/schema/guards";
|
|
9
|
+
import type { JsonValue } from "@jxsuite/schema/types";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Read `key` off a parsed `package.json` value, or `undefined` when the value is not an object.
|
|
13
|
+
*
|
|
14
|
+
* Every lookup below reads through this one, so a manifest of the wrong shape (an `exports` that is
|
|
15
|
+
* a string, a `main` that is an array) yields "absent" rather than a member of something else.
|
|
16
|
+
*/
|
|
17
|
+
function manifestField(value: JsonValue | undefined, key: string): JsonValue | undefined {
|
|
18
|
+
return isJsonObject(value) ? value[key] : undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Read and parse a package's `package.json`. Throws when the file is not JSON. */
|
|
22
|
+
function readManifest(pkgJsonPath: string): JsonValue {
|
|
23
|
+
// SAFETY: JSON.parse without a reviver only ever yields a JSON value; its `any` says no more.
|
|
24
|
+
return JSON.parse(readFileSync(pkgJsonPath, "utf8")) as JsonValue;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Resolve an npm-style bare specifier from a URL path via node_modules. Handles scoped packages
|
|
29
|
+
* (@scope/pkg/subpath) and respects package.json exports. Strips leading directory segments (e.g.
|
|
30
|
+
* /pages/@scope/pkg/file → @scope/pkg/file).
|
|
31
|
+
*
|
|
32
|
+
* @param {string} root - Absolute project root
|
|
33
|
+
* @param {string} urlPath - URL pathname (e.g. "/pages/@jxsuite/parser/Foo.class.json")
|
|
34
|
+
* @returns {string | null} Absolute file path or null
|
|
35
|
+
*/
|
|
36
|
+
export function resolveNpmPath(rootDir: string, urlPath: string): string | null {
|
|
37
|
+
let root = rootDir;
|
|
38
|
+
let segments = urlPath.split("/").filter(Boolean);
|
|
39
|
+
|
|
40
|
+
// If "node_modules" appears in the path, use everything before it as a subdirectory
|
|
41
|
+
// Prefix and everything after as the package specifier.
|
|
42
|
+
// E.g. /examples/demo/node_modules/@scope/pkg → root=root/examples/demo, pkg=@scope/pkg
|
|
43
|
+
const nmIdx = segments.indexOf("node_modules");
|
|
44
|
+
if (nmIdx !== -1) {
|
|
45
|
+
if (nmIdx > 0) {
|
|
46
|
+
root = join(root, ...segments.slice(0, nmIdx));
|
|
47
|
+
}
|
|
48
|
+
segments = segments.slice(nmIdx + 1);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Find the package start — either @scope/pkg or unscoped pkg
|
|
52
|
+
let start = -1;
|
|
53
|
+
let isScoped = false;
|
|
54
|
+
for (let i = 0; i < segments.length; i++) {
|
|
55
|
+
if (segments[i]!.startsWith("@")) {
|
|
56
|
+
start = i;
|
|
57
|
+
isScoped = true;
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let pkgDir = "";
|
|
63
|
+
let subpath = "";
|
|
64
|
+
|
|
65
|
+
if (isScoped) {
|
|
66
|
+
if (start < 0 || start + 1 >= segments.length) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
const scope = segments[start]!;
|
|
70
|
+
const pkg = segments[start + 1]!;
|
|
71
|
+
subpath = segments.slice(start + 2).join("/");
|
|
72
|
+
pkgDir = join(root, "node_modules", scope, pkg);
|
|
73
|
+
} else {
|
|
74
|
+
// Unscoped: try each segment as a package name in node_modules
|
|
75
|
+
for (let i = 0; i < segments.length; i++) {
|
|
76
|
+
const candidate = join(root, "node_modules", segments[i]!);
|
|
77
|
+
if (existsSync(join(candidate, "package.json"))) {
|
|
78
|
+
start = i;
|
|
79
|
+
pkgDir = candidate;
|
|
80
|
+
subpath = segments.slice(i + 1).join("/");
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (start < 0) {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const pkgJsonPath = join(pkgDir, "package.json");
|
|
90
|
+
if (!existsSync(pkgJsonPath)) {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// If there's a subpath, check package.json exports first
|
|
95
|
+
if (subpath) {
|
|
96
|
+
try {
|
|
97
|
+
const pkgJson = readManifest(pkgJsonPath);
|
|
98
|
+
const exportVal = manifestField(manifestField(pkgJson, "exports"), `./${subpath}`);
|
|
99
|
+
if (isString(exportVal)) {
|
|
100
|
+
const mapped = join(pkgDir, exportVal);
|
|
101
|
+
if (existsSync(mapped)) {
|
|
102
|
+
return mapped;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
} catch {}
|
|
106
|
+
// Fall back to direct path
|
|
107
|
+
const direct = join(pkgDir, subpath);
|
|
108
|
+
if (existsSync(direct)) {
|
|
109
|
+
return direct;
|
|
110
|
+
}
|
|
111
|
+
// CEM-relative: subpath may be relative to the custom elements manifest directory
|
|
112
|
+
try {
|
|
113
|
+
const customElements = manifestField(readManifest(pkgJsonPath), "customElements");
|
|
114
|
+
if (isString(customElements) && customElements) {
|
|
115
|
+
const cemDir = customElements.replace(/\/[^/]+$/, "");
|
|
116
|
+
const cemRelative = join(pkgDir, cemDir, subpath);
|
|
117
|
+
if (existsSync(cemRelative)) {
|
|
118
|
+
return cemRelative;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
} catch {}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Bare package (no subpath): resolve entry point
|
|
125
|
+
try {
|
|
126
|
+
const pkgJson = readManifest(pkgJsonPath);
|
|
127
|
+
const exp = manifestField(manifestField(pkgJson, "exports"), ".");
|
|
128
|
+
// An explicit `null` entry declines the package's entry point outright: no `module`/`main`.
|
|
129
|
+
if (exp !== null) {
|
|
130
|
+
const conditional = isNonNullObject(exp)
|
|
131
|
+
? (manifestField(exp, "import") ?? manifestField(exp, "default"))
|
|
132
|
+
: exp;
|
|
133
|
+
const entry =
|
|
134
|
+
conditional ?? manifestField(pkgJson, "module") ?? manifestField(pkgJson, "main");
|
|
135
|
+
if (isString(entry) && entry) {
|
|
136
|
+
const resolved = join(pkgDir, entry);
|
|
137
|
+
if (existsSync(resolved)) {
|
|
138
|
+
return resolved;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
} catch {}
|
|
143
|
+
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The cloud's package service: the operations of `packages.ts`, run lockfile-only over files a
|
|
3
|
+
* request carries, so a host with no filesystem of its own (the cloud's working tree lives in a
|
|
4
|
+
* Durable Object) gets the same `package.json` and `package-lock.json` a desktop install writes.
|
|
5
|
+
*
|
|
6
|
+
* Stateless. Each request's files are written into a fresh temporary directory, the operation runs
|
|
7
|
+
* there with `lockfileOnly` (no `node_modules`, no tarball downloaded, no install script), the two
|
|
8
|
+
* files are read back, and the directory is removed whatever happened. The reply says which of the
|
|
9
|
+
* request's files the operation deleted (`bun.lock`, on the first install), so the caller can apply
|
|
10
|
+
* the whole result to its own tree as one change, or nothing when `result.ok` is false.
|
|
11
|
+
*
|
|
12
|
+
* The request is refused (400) for any operation but add, remove and set-versions, and any file but
|
|
13
|
+
* the three below, before anything is written. It is meant to be reachable only from the host that
|
|
14
|
+
* owns the working tree (a container binding, not a public route), and it reads nothing but the
|
|
15
|
+
* public registry.
|
|
16
|
+
*
|
|
17
|
+
* @docs studio/projects/dependencies
|
|
18
|
+
*/
|
|
19
|
+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
20
|
+
import { tmpdir } from "node:os";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
import { isBoolean, isJsonObject, isString } from "@jxsuite/schema/guards";
|
|
23
|
+
import type { JsonObject, JsonValue } from "@jxsuite/schema/types";
|
|
24
|
+
import type { PackageInfo, PackageOpResult } from "@jxsuite/protocol";
|
|
25
|
+
import { addPackage, removePackage, setPackageVersions } from "./packages.ts";
|
|
26
|
+
import type { PackageOpOptions } from "./packages.ts";
|
|
27
|
+
import { serve } from "#serve";
|
|
28
|
+
import type { ServeServer } from "./serve.ts";
|
|
29
|
+
|
|
30
|
+
/** The files a request may carry, and the only ones a reply returns or reports deleted. */
|
|
31
|
+
export const SERVICE_FILES = ["package.json", "package-lock.json", "bun.lock"] as const;
|
|
32
|
+
|
|
33
|
+
const SERVICE_FILE_NAMES = new Set<string>(SERVICE_FILES);
|
|
34
|
+
|
|
35
|
+
/** One of {@link SERVICE_FILES}. */
|
|
36
|
+
export type ServiceFile = (typeof SERVICE_FILES)[number];
|
|
37
|
+
|
|
38
|
+
/** A validated request. */
|
|
39
|
+
export type ServiceOp =
|
|
40
|
+
| { op: "add"; spec: string; dev: boolean }
|
|
41
|
+
| { op: "remove"; name: string }
|
|
42
|
+
| { op: "set-versions"; updates: PackageInfo[] };
|
|
43
|
+
|
|
44
|
+
/** What the service answers for an operation it ran. */
|
|
45
|
+
export interface ServiceReply {
|
|
46
|
+
result: PackageOpResult;
|
|
47
|
+
/** The files as the operation left them; on failure, the request's files unchanged. */
|
|
48
|
+
files: Partial<Record<ServiceFile, string>>;
|
|
49
|
+
/** Request files the operation removed. */
|
|
50
|
+
deleted: ServiceFile[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The largest request body accepted, in bytes: a lockfile is large, but not this large. */
|
|
54
|
+
const MAX_BODY_BYTES = 32 * 1024 * 1024;
|
|
55
|
+
|
|
56
|
+
/** True when `name` is one of {@link SERVICE_FILES}. */
|
|
57
|
+
function isServiceFile(name: string): name is ServiceFile {
|
|
58
|
+
return SERVICE_FILE_NAMES.has(name);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A version update read off the request, or null when one member has the wrong shape. */
|
|
62
|
+
function parseUpdate(value: JsonValue): PackageInfo | null {
|
|
63
|
+
if (!isJsonObject(value) || !isString(value.name) || !isString(value.version)) {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
const update: PackageInfo = { name: value.name, version: value.version };
|
|
67
|
+
if (value.dev !== undefined) {
|
|
68
|
+
if (!isBoolean(value.dev)) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
update.dev = value.dev;
|
|
72
|
+
}
|
|
73
|
+
return update;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The operation a request names, or the reason it is refused. */
|
|
77
|
+
export function parseOp(body: JsonObject): ServiceOp | { error: string } {
|
|
78
|
+
const { args } = body;
|
|
79
|
+
if (!isJsonObject(args)) {
|
|
80
|
+
return { error: "`args` must be an object" };
|
|
81
|
+
}
|
|
82
|
+
switch (body.op) {
|
|
83
|
+
case "add": {
|
|
84
|
+
if (!isString(args.spec) || (args.dev !== undefined && !isBoolean(args.dev))) {
|
|
85
|
+
return { error: "add takes { spec: string, dev?: boolean }" };
|
|
86
|
+
}
|
|
87
|
+
return { dev: args.dev === true, op: "add", spec: args.spec };
|
|
88
|
+
}
|
|
89
|
+
case "remove": {
|
|
90
|
+
return isString(args.name)
|
|
91
|
+
? { name: args.name, op: "remove" }
|
|
92
|
+
: { error: "remove takes { name: string }" };
|
|
93
|
+
}
|
|
94
|
+
case "set-versions": {
|
|
95
|
+
const updates = Array.isArray(args.updates) ? args.updates.map(parseUpdate) : null;
|
|
96
|
+
if (!updates || updates.includes(null)) {
|
|
97
|
+
return { error: "set-versions takes { updates: { name, version, dev? }[] }" };
|
|
98
|
+
}
|
|
99
|
+
return { op: "set-versions", updates: updates.filter((update) => update !== null) };
|
|
100
|
+
}
|
|
101
|
+
default: {
|
|
102
|
+
return { error: "op must be add, remove or set-versions" };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The request's files, or the reason they are refused. */
|
|
108
|
+
export function parseFiles(
|
|
109
|
+
value: JsonValue | undefined,
|
|
110
|
+
): Map<ServiceFile, string> | { error: string } {
|
|
111
|
+
if (!isJsonObject(value)) {
|
|
112
|
+
return { error: "`files` must be an object" };
|
|
113
|
+
}
|
|
114
|
+
const files = new Map<ServiceFile, string>();
|
|
115
|
+
for (const [name, contents] of Object.entries(value)) {
|
|
116
|
+
if (!isServiceFile(name)) {
|
|
117
|
+
return { error: `${name} is not a file this service reads` };
|
|
118
|
+
}
|
|
119
|
+
if (!isString(contents)) {
|
|
120
|
+
return { error: `${name} must be a string` };
|
|
121
|
+
}
|
|
122
|
+
files.set(name, contents);
|
|
123
|
+
}
|
|
124
|
+
if (!files.has("package.json")) {
|
|
125
|
+
return { error: "package.json is required" };
|
|
126
|
+
}
|
|
127
|
+
return files;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Run `op` over `files` in a scratch directory and report what it left. */
|
|
131
|
+
export async function runServiceOp(
|
|
132
|
+
op: ServiceOp,
|
|
133
|
+
files: Map<ServiceFile, string>,
|
|
134
|
+
options: Omit<PackageOpOptions, "lockfileOnly"> = {},
|
|
135
|
+
): Promise<ServiceReply> {
|
|
136
|
+
const dir = await mkdtemp(join(tmpdir(), "jx-packages-service-"));
|
|
137
|
+
try {
|
|
138
|
+
for (const [name, contents] of files) {
|
|
139
|
+
await writeFile(join(dir, name), contents, "utf8");
|
|
140
|
+
}
|
|
141
|
+
const opOptions: PackageOpOptions = { ...options, lockfileOnly: true };
|
|
142
|
+
let result: PackageOpResult;
|
|
143
|
+
if (op.op === "add") {
|
|
144
|
+
result = await addPackage(dir, op.spec, op.dev, opOptions);
|
|
145
|
+
} else if (op.op === "remove") {
|
|
146
|
+
result = await removePackage(dir, op.name, opOptions);
|
|
147
|
+
} else {
|
|
148
|
+
result = await setPackageVersions(dir, op.updates, opOptions);
|
|
149
|
+
}
|
|
150
|
+
const out: ServiceReply["files"] = {};
|
|
151
|
+
const deleted: ServiceFile[] = [];
|
|
152
|
+
for (const name of SERVICE_FILES) {
|
|
153
|
+
const contents = await readFile(join(dir, name), "utf8").catch(() => null);
|
|
154
|
+
if (contents !== null) {
|
|
155
|
+
out[name] = contents;
|
|
156
|
+
} else if (files.has(name)) {
|
|
157
|
+
deleted.push(name);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return { deleted, files: out, result };
|
|
161
|
+
} finally {
|
|
162
|
+
await rm(dir, { force: true, recursive: true });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The service's fetch handler: `POST /` with `{ op, args, files }`, answered with a
|
|
168
|
+
* {@link ServiceReply}, or 400 for a request it refuses. `GET /health` answers 200.
|
|
169
|
+
*/
|
|
170
|
+
export async function handlePackagesRequest(
|
|
171
|
+
req: Request,
|
|
172
|
+
options: Omit<PackageOpOptions, "lockfileOnly"> = {},
|
|
173
|
+
): Promise<Response> {
|
|
174
|
+
const { pathname } = new URL(req.url);
|
|
175
|
+
if (req.method === "GET" && pathname === "/health") {
|
|
176
|
+
return Response.json({ ok: true });
|
|
177
|
+
}
|
|
178
|
+
if (req.method !== "POST" || pathname !== "/") {
|
|
179
|
+
return Response.json({ error: "Not found" }, { status: 404 });
|
|
180
|
+
}
|
|
181
|
+
const length = Number(req.headers.get("content-length") ?? "0");
|
|
182
|
+
if (length > MAX_BODY_BYTES) {
|
|
183
|
+
return Response.json({ error: "Request too large" }, { status: 413 });
|
|
184
|
+
}
|
|
185
|
+
let body: JsonValue;
|
|
186
|
+
try {
|
|
187
|
+
// SAFETY: JSON.parse yields a JSON value; its shape is checked member by member below.
|
|
188
|
+
body = JSON.parse(await req.text()) as JsonValue;
|
|
189
|
+
} catch {
|
|
190
|
+
return Response.json({ error: "Body must be JSON" }, { status: 400 });
|
|
191
|
+
}
|
|
192
|
+
if (!isJsonObject(body)) {
|
|
193
|
+
return Response.json({ error: "Body must be an object" }, { status: 400 });
|
|
194
|
+
}
|
|
195
|
+
const op = parseOp(body);
|
|
196
|
+
if ("error" in op) {
|
|
197
|
+
return Response.json({ error: op.error }, { status: 400 });
|
|
198
|
+
}
|
|
199
|
+
const files = parseFiles(body.files);
|
|
200
|
+
if ("error" in files) {
|
|
201
|
+
return Response.json({ error: files.error }, { status: 400 });
|
|
202
|
+
}
|
|
203
|
+
return Response.json(await runServiceOp(op, files, options));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Start the service on `port` (all interfaces: it runs inside a container). */
|
|
207
|
+
export function startPackagesService(
|
|
208
|
+
port: number,
|
|
209
|
+
hostname = "0.0.0.0",
|
|
210
|
+
): Promise<ServeServer<undefined>> {
|
|
211
|
+
return serve({ fetch: (req) => handlePackagesRequest(req), hostname, port });
|
|
212
|
+
}
|
package/src/packages.ts
CHANGED
|
@@ -1,13 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* (`createPackageOps`)
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* Package operations, parameterized by a project root: npm's own installer (`@npmcli/arborist`) run
|
|
3
|
+
* in-process. Shared by the desktop app (`createPackageOps`), the dev server's
|
|
4
|
+
* `/__studio/packages*` endpoints and the cloud's lockfile service (`packages-service.ts`), so
|
|
5
|
+
* every backend installs the same way and writes the same `package-lock.json` (server.md §4.1).
|
|
6
|
+
*
|
|
7
|
+
* Strict, on purpose: install scripts never run, a peer conflict fails rather than being forced, a
|
|
8
|
+
* spec that is not a registry package is refused, and a failed operation leaves `package.json` and
|
|
9
|
+
* the lockfile byte-identical. The public registry is the only one: a project `.npmrc` is not read,
|
|
10
|
+
* so a private scope fails with an error that names the registry and the status.
|
|
11
|
+
*
|
|
12
|
+
* @docs studio/projects/dependencies
|
|
6
13
|
*/
|
|
7
14
|
|
|
8
15
|
import { existsSync } from "node:fs";
|
|
9
|
-
import { readFile, writeFile } from "node:fs/promises";
|
|
16
|
+
import { readFile, rm, writeFile } from "node:fs/promises";
|
|
10
17
|
import { resolve } from "node:path";
|
|
18
|
+
import Arborist from "@npmcli/arborist";
|
|
19
|
+
import npa from "npm-package-arg";
|
|
20
|
+
import { isNumber, isString } from "@jxsuite/schema/guards";
|
|
11
21
|
import type { PackageInfo, PackageOpResult, PackageVersionInfo } from "@jxsuite/protocol";
|
|
12
22
|
import type { JsonValue } from "@jxsuite/schema/types";
|
|
13
23
|
|
|
@@ -86,48 +96,232 @@ export function dependenciesNeedInstall(root: string): boolean {
|
|
|
86
96
|
return !existsSync(resolve(root, "node_modules"));
|
|
87
97
|
}
|
|
88
98
|
|
|
99
|
+
/** The registry every operation resolves against; a project `.npmrc` is not read (see below). */
|
|
100
|
+
export const NPM_REGISTRY = "https://registry.npmjs.org/";
|
|
101
|
+
|
|
89
102
|
/**
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
103
|
+
* How an operation runs. `lockfileOnly` resolves and writes `package.json` and `package-lock.json`
|
|
104
|
+
* without building `node_modules` or downloading a tarball: what the cloud service runs, where no
|
|
105
|
+
* `node_modules` exists and no package code may arrive. `registry` and `cache` exist for the tests'
|
|
106
|
+
* fixture registry and scratch cache; production callers leave them alone, which means the public
|
|
107
|
+
* registry and npm's own cache directory (`~/.npm/_cacache`, inside the sandbox container on the
|
|
108
|
+
* Mac App Store build).
|
|
94
109
|
*/
|
|
95
|
-
export
|
|
96
|
-
|
|
110
|
+
export interface PackageOpOptions {
|
|
111
|
+
cache?: string;
|
|
112
|
+
lockfileOnly?: boolean;
|
|
113
|
+
registry?: string;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The files an operation may rewrite, restored byte-for-byte when it fails. */
|
|
117
|
+
const GUARDED_FILES = ["package.json", "package-lock.json", "bun.lock"] as const;
|
|
118
|
+
|
|
119
|
+
/** One project's operations, run one after another: two reifies in one tree race on its files. */
|
|
120
|
+
const queues = new Map<string, Promise<PackageOpResult>>();
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Run `task` after every operation already queued for `root`, whether that one succeeded or not.
|
|
124
|
+
* `reify` turns every failure it can into a result, but a restore that itself fails still rejects,
|
|
125
|
+
* and the next operation must run regardless.
|
|
126
|
+
*/
|
|
127
|
+
function enqueue(root: string, task: () => Promise<PackageOpResult>): Promise<PackageOpResult> {
|
|
128
|
+
const previous = queues.get(root) ?? Promise.resolve();
|
|
129
|
+
const next = previous.then(task, task);
|
|
130
|
+
queues.set(root, next);
|
|
131
|
+
const forget = (): void => {
|
|
132
|
+
if (queues.get(root) === next) {
|
|
133
|
+
queues.delete(root);
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
void next.then(forget, forget);
|
|
137
|
+
return next;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** The guarded files' current bytes (null for an absent file). */
|
|
141
|
+
async function snapshot(root: string): Promise<Map<string, Buffer | null>> {
|
|
142
|
+
const saved = new Map<string, Buffer | null>();
|
|
143
|
+
for (const name of GUARDED_FILES) {
|
|
144
|
+
const path = resolve(root, name);
|
|
145
|
+
saved.set(name, existsSync(path) ? await readFile(path) : null);
|
|
146
|
+
}
|
|
147
|
+
return saved;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Put the guarded files back exactly as {@link snapshot} found them. */
|
|
151
|
+
async function restore(root: string, saved: Map<string, Buffer | null>): Promise<void> {
|
|
152
|
+
for (const [name, bytes] of saved) {
|
|
153
|
+
const path = resolve(root, name);
|
|
154
|
+
await (bytes === null ? rm(path, { force: true }) : writeFile(path, bytes));
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** An error as npm's fetch and resolver raise them: a code, and for HTTP a status and a URL. */
|
|
159
|
+
interface NpmError {
|
|
160
|
+
code?: string;
|
|
161
|
+
statusCode?: number;
|
|
162
|
+
uri?: string;
|
|
163
|
+
pkgid?: string;
|
|
164
|
+
message: string;
|
|
97
165
|
}
|
|
98
166
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
if (
|
|
109
|
-
|
|
167
|
+
/** Read the fields {@link describeFailure} reports off whatever an operation threw. */
|
|
168
|
+
function asNpmError(error: Error): NpmError {
|
|
169
|
+
const out: NpmError = { message: error.message };
|
|
170
|
+
if ("code" in error && isString(error.code)) {
|
|
171
|
+
out.code = error.code;
|
|
172
|
+
}
|
|
173
|
+
if ("statusCode" in error && isNumber(error.statusCode)) {
|
|
174
|
+
out.statusCode = error.statusCode;
|
|
175
|
+
}
|
|
176
|
+
if ("uri" in error && isString(error.uri)) {
|
|
177
|
+
out.uri = error.uri;
|
|
178
|
+
}
|
|
179
|
+
if ("pkgid" in error && isString(error.pkgid)) {
|
|
180
|
+
out.pkgid = error.pkgid;
|
|
181
|
+
}
|
|
182
|
+
return out;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The first line of a failed operation's log: what failed, in the terms the user can act on.
|
|
187
|
+
*
|
|
188
|
+
* A registry refusal names the package, the registry and the status, because the usual cause is a
|
|
189
|
+
* private scope or a mirror this installer does not read (`.npmrc` is not consulted). A peer
|
|
190
|
+
* conflict is reported as one, never forced through.
|
|
191
|
+
*/
|
|
192
|
+
function describeFailure(error: NpmError, registry: string): string {
|
|
193
|
+
if (error.statusCode !== undefined) {
|
|
194
|
+
const what = error.pkgid ?? error.uri ?? "a package";
|
|
195
|
+
return `The registry ${registry} answered ${error.statusCode} for ${what}. Private registries and .npmrc settings are not supported; install it from a terminal.`;
|
|
196
|
+
}
|
|
197
|
+
if (error.code === "ERESOLVE") {
|
|
198
|
+
return `The dependencies conflict (ERESOLVE): ${error.message}`;
|
|
199
|
+
}
|
|
200
|
+
if (error.code === "EUNSUPPORTEDPROTOCOL") {
|
|
201
|
+
return `A dependency uses a specifier this installer does not support: ${error.message}`;
|
|
110
202
|
}
|
|
111
|
-
return
|
|
203
|
+
return error.code ? `${error.code}: ${error.message}` : error.message;
|
|
112
204
|
}
|
|
113
205
|
|
|
114
|
-
/**
|
|
115
|
-
|
|
116
|
-
|
|
206
|
+
/**
|
|
207
|
+
* Run one Arborist reify for `root` under the queue, the snapshot and the `bun.lock` switch.
|
|
208
|
+
*
|
|
209
|
+
* Strict by construction: no `force`, no `legacyPeerDeps`, scripts never run, and nothing is left
|
|
210
|
+
* half-done. On failure the manifest and lockfiles are restored byte-for-byte (Arborist rolls
|
|
211
|
+
* `node_modules` back itself). On success a project that had only a `bun.lock` loses it, because
|
|
212
|
+
* `package-lock.json` is now the lockfile and two lockfiles drift.
|
|
213
|
+
*/
|
|
214
|
+
function reify(
|
|
215
|
+
root: string,
|
|
216
|
+
options: PackageOpOptions,
|
|
217
|
+
prepare: () => Promise<Partial<ReifyOptions>>,
|
|
218
|
+
): Promise<PackageOpResult> {
|
|
219
|
+
return enqueue(root, async () => {
|
|
220
|
+
const registry = options.registry ?? NPM_REGISTRY;
|
|
221
|
+
const hadBunLockOnly =
|
|
222
|
+
existsSync(resolve(root, "bun.lock")) && !existsSync(resolve(root, "package-lock.json"));
|
|
223
|
+
let saved: Map<string, Buffer | null> | null = null;
|
|
224
|
+
const lines: string[] = [];
|
|
225
|
+
const onLog = (level: string, ...args: unknown[]): void => {
|
|
226
|
+
if (level === "warn" || level === "error" || level === "notice") {
|
|
227
|
+
lines.push(args.map(String).join(" "));
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
process.on("log", onLog);
|
|
231
|
+
try {
|
|
232
|
+
saved = await snapshot(root);
|
|
233
|
+
const extra = await prepare();
|
|
234
|
+
const arboristOptions: ConstructorParameters<typeof Arborist>[0] = {
|
|
235
|
+
audit: false,
|
|
236
|
+
fund: false,
|
|
237
|
+
ignoreScripts: true,
|
|
238
|
+
lockfileVersion: 3,
|
|
239
|
+
packageLockOnly: options.lockfileOnly === true,
|
|
240
|
+
path: root,
|
|
241
|
+
registry,
|
|
242
|
+
save: true,
|
|
243
|
+
};
|
|
244
|
+
if (options.cache !== undefined) {
|
|
245
|
+
arboristOptions.cache = options.cache;
|
|
246
|
+
}
|
|
247
|
+
const arborist = new Arborist(arboristOptions);
|
|
248
|
+
await arborist.reify({ ...extra, save: true });
|
|
249
|
+
if (hadBunLockOnly) {
|
|
250
|
+
await rm(resolve(root, "bun.lock"), { force: true });
|
|
251
|
+
lines.push("Replaced bun.lock with package-lock.json, which Studio installs from.");
|
|
252
|
+
}
|
|
253
|
+
const result: PackageOpResult = { ok: true };
|
|
254
|
+
if (lines.length > 0) {
|
|
255
|
+
result.log = lines.join("\n");
|
|
256
|
+
}
|
|
257
|
+
return result;
|
|
258
|
+
} catch (error) {
|
|
259
|
+
if (saved) {
|
|
260
|
+
await restore(root, saved);
|
|
261
|
+
}
|
|
262
|
+
const npmError = asNpmError(error instanceof Error ? error : new Error(String(error)));
|
|
263
|
+
return { log: [describeFailure(npmError, registry), ...lines].join("\n"), ok: false };
|
|
264
|
+
} finally {
|
|
265
|
+
process.off("log", onLog);
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** What `reify` accepts beyond the constructor's options. */
|
|
271
|
+
type ReifyOptions = NonNullable<Parameters<Arborist["reify"]>[0]>;
|
|
272
|
+
|
|
273
|
+
/** Install the dependencies `package.json` declares, writing `package-lock.json`. */
|
|
274
|
+
export function installDependencies(
|
|
275
|
+
root: string,
|
|
276
|
+
options: PackageOpOptions = {},
|
|
277
|
+
): Promise<PackageOpResult> {
|
|
278
|
+
return reify(root, options, async () => ({}));
|
|
117
279
|
}
|
|
118
280
|
|
|
119
|
-
/**
|
|
281
|
+
/**
|
|
282
|
+
* A registry spec (`name`, `name@1.2.3`, `name@^1`, `name@latest`), or the reason it is refused.
|
|
283
|
+
* Git, file, URL, alias and `workspace:` specs are refused before Arborist sees them.
|
|
284
|
+
*/
|
|
285
|
+
function registrySpec(spec: string): { ok: true; name: string } | { ok: false; reason: string } {
|
|
286
|
+
let parsed: ReturnType<typeof npa>;
|
|
287
|
+
try {
|
|
288
|
+
parsed = npa(spec);
|
|
289
|
+
} catch (error) {
|
|
290
|
+
return { ok: false, reason: `"${spec}" is not a registry package: ${String(error)}` };
|
|
291
|
+
}
|
|
292
|
+
if (!REGISTRY_SPEC_TYPES.has(parsed.type) || !parsed.name) {
|
|
293
|
+
return {
|
|
294
|
+
ok: false,
|
|
295
|
+
reason: `"${spec}" is a ${parsed.type} specifier; only packages from the registry can be added.`,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
return { name: parsed.name, ok: true };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** The `npm-package-arg` types that name a package on the registry. */
|
|
302
|
+
const REGISTRY_SPEC_TYPES = new Set(["version", "range", "tag"]);
|
|
303
|
+
|
|
304
|
+
/** Add a dependency (a devDependency when `dev`), saving `^<resolved>` as npm does. */
|
|
120
305
|
export async function addPackage(
|
|
121
306
|
root: string,
|
|
122
|
-
|
|
307
|
+
spec: string,
|
|
123
308
|
dev = false,
|
|
309
|
+
options: PackageOpOptions = {},
|
|
124
310
|
): Promise<PackageOpResult> {
|
|
125
|
-
|
|
311
|
+
const checked = registrySpec(spec);
|
|
312
|
+
if (!checked.ok) {
|
|
313
|
+
return { log: checked.reason, ok: false };
|
|
314
|
+
}
|
|
315
|
+
return reify(root, options, async () => ({ add: [spec], saveType: dev ? "dev" : "prod" }));
|
|
126
316
|
}
|
|
127
317
|
|
|
128
|
-
/**
|
|
129
|
-
export async function removePackage(
|
|
130
|
-
|
|
318
|
+
/** Remove a dependency from whichever section holds it. */
|
|
319
|
+
export async function removePackage(
|
|
320
|
+
root: string,
|
|
321
|
+
name: string,
|
|
322
|
+
options: PackageOpOptions = {},
|
|
323
|
+
): Promise<PackageOpResult> {
|
|
324
|
+
return reify(root, options, async () => ({ rm: [name] }));
|
|
131
325
|
}
|
|
132
326
|
|
|
133
327
|
/**
|
|
@@ -203,11 +397,12 @@ export async function packageVersions(
|
|
|
203
397
|
/**
|
|
204
398
|
* Rewrite the version range of each named package in whichever section it already occupies
|
|
205
399
|
* (preserving dependencies/devDependencies placement; new names default to the caller's `dev`
|
|
206
|
-
* flag), then
|
|
400
|
+
* flag), then install once. A failed install leaves `package.json` as it was.
|
|
207
401
|
*/
|
|
208
402
|
export async function setPackageVersions(
|
|
209
403
|
root: string,
|
|
210
404
|
updates: { name: string; version: string; dev?: boolean }[],
|
|
405
|
+
options: PackageOpOptions = {},
|
|
211
406
|
): Promise<PackageOpResult> {
|
|
212
407
|
if (updates.length === 0) {
|
|
213
408
|
return { ok: true };
|
|
@@ -233,6 +428,9 @@ export async function setPackageVersions(
|
|
|
233
428
|
pkg[bucket] = { ...pkg[bucket], [update.name]: update.version };
|
|
234
429
|
}
|
|
235
430
|
}
|
|
236
|
-
|
|
237
|
-
return
|
|
431
|
+
// Written inside the operation, so the snapshot it restores on failure is the manifest before.
|
|
432
|
+
return reify(root, options, async () => {
|
|
433
|
+
await writeFile(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`, "utf8");
|
|
434
|
+
return {};
|
|
435
|
+
});
|
|
238
436
|
}
|