@blastlauncher/engine 0.0.0-stage → 3.0.0-alpha.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.
@@ -0,0 +1,329 @@
1
+ // The subset of tsconfig `compilerOptions` that affects module resolution:
2
+ // `baseUrl` and `paths`. Extensions import their own modules through them
3
+ // (control-d's `common/...`), and esbuild never gets to apply them itself —
4
+ // the dependency plugin resolves every bare specifier first — so the plugin
5
+ // probes them here. Aliasing never reads outside the extension directory:
6
+ // package-preset and out-of-tree `extends` targets (also via symlinks) are
7
+ // skipped with a warning, and candidate paths are probed only inside it.
8
+ import { readFile, realpath, stat } from "node:fs/promises";
9
+ import { isAbsolute, join, relative, resolve } from "node:path";
10
+ /** File extensions tried for an extensionless alias target, esbuild's order. */
11
+ const RESOLVE_EXTENSIONS = [".tsx", ".ts", ".jsx", ".js", ".mjs", ".cjs"];
12
+ /**
13
+ * Parses the JSON with comments and trailing commas accepted by tsconfig.
14
+ * This stays local so loading a config cannot expand the host dependency
15
+ * surface. Strings are copied byte-for-byte, including URL-like `//` text.
16
+ */
17
+ function parseJsonc(source) {
18
+ // Editors on Windows often save tsconfig with a UTF-8 byte-order mark,
19
+ // which tsc accepts and JSON.parse does not.
20
+ const text = source.startsWith("\uFEFF") ? source.slice(1) : source;
21
+ let withoutComments = "";
22
+ let inString = false;
23
+ let escaped = false;
24
+ let inLineComment = false;
25
+ let inBlockComment = false;
26
+ for (let index = 0; index < text.length; index += 1) {
27
+ const character = text[index];
28
+ const next = text[index + 1];
29
+ if (inLineComment) {
30
+ if (character === "\n" || character === "\r") {
31
+ inLineComment = false;
32
+ withoutComments += character;
33
+ }
34
+ continue;
35
+ }
36
+ if (inBlockComment) {
37
+ if (character === "*" && next === "/") {
38
+ inBlockComment = false;
39
+ index += 1;
40
+ }
41
+ else if (character === "\n" || character === "\r") {
42
+ withoutComments += character;
43
+ }
44
+ continue;
45
+ }
46
+ if (inString) {
47
+ withoutComments += character;
48
+ if (escaped)
49
+ escaped = false;
50
+ else if (character === "\\")
51
+ escaped = true;
52
+ else if (character === '"')
53
+ inString = false;
54
+ continue;
55
+ }
56
+ if (character === '"') {
57
+ inString = true;
58
+ withoutComments += character;
59
+ }
60
+ else if (character === "/" && next === "/") {
61
+ inLineComment = true;
62
+ index += 1;
63
+ }
64
+ else if (character === "/" && next === "*") {
65
+ inBlockComment = true;
66
+ index += 1;
67
+ }
68
+ else {
69
+ withoutComments += character;
70
+ }
71
+ }
72
+ if (inString || inBlockComment)
73
+ throw new Error("unterminated JSONC token");
74
+ let json = "";
75
+ inString = false;
76
+ escaped = false;
77
+ for (let index = 0; index < withoutComments.length; index += 1) {
78
+ const character = withoutComments[index];
79
+ if (inString) {
80
+ json += character;
81
+ if (escaped)
82
+ escaped = false;
83
+ else if (character === "\\")
84
+ escaped = true;
85
+ else if (character === '"')
86
+ inString = false;
87
+ continue;
88
+ }
89
+ if (character === '"') {
90
+ inString = true;
91
+ json += character;
92
+ continue;
93
+ }
94
+ if (character === ",") {
95
+ let next = index + 1;
96
+ while (/\s/.test(withoutComments[next] ?? ""))
97
+ next += 1;
98
+ if (withoutComments[next] === "]" || withoutComments[next] === "}") {
99
+ index = next - 1;
100
+ continue;
101
+ }
102
+ }
103
+ json += character;
104
+ }
105
+ return JSON.parse(json);
106
+ }
107
+ async function kindOf(path) {
108
+ try {
109
+ const stats = await stat(path);
110
+ return stats.isFile() ? "file" : stats.isDirectory() ? "directory" : undefined;
111
+ }
112
+ catch {
113
+ return undefined;
114
+ }
115
+ }
116
+ /** True when `path` is strictly inside `directory` (lexical, symlink-blind). */
117
+ function inside(directory, path) {
118
+ const rel = relative(directory, path);
119
+ return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
120
+ }
121
+ /**
122
+ * Loads one tsconfig chain position: parents first (`extends`, lowest
123
+ * precedence first), then the file's own `compilerOptions` overriding per key.
124
+ * Returns `undefined` when the file cannot be used; every skip is explained
125
+ * in `warnings` so a broken config degrades to "no aliasing", never to a
126
+ * failed bundle.
127
+ */
128
+ async function loadChain(file, extensionDir, stack, warnings) {
129
+ const path = resolve(file);
130
+ // A cycle is an extension bug; stop instead of looping.
131
+ if (stack.has(path))
132
+ return undefined;
133
+ const name = relative(extensionDir, path) || path;
134
+ // Never read a config outside the extension, including through a relative
135
+ // `extends` or a symlink: bundling runs outside any sandbox.
136
+ const [realDir, realPath] = await Promise.all([
137
+ realpath(extensionDir).catch(() => resolve(extensionDir)),
138
+ realpath(path).catch(() => path),
139
+ ]);
140
+ if (!inside(realDir, realPath)) {
141
+ warnings.push(`Blast ignored tsconfig ${JSON.stringify(name)}: outside the extension, so its path aliases are off`);
142
+ return undefined;
143
+ }
144
+ let text;
145
+ try {
146
+ text = await readFile(path, "utf8");
147
+ }
148
+ catch {
149
+ warnings.push(`Blast skipped unreadable tsconfig ${name} for path aliases`);
150
+ return undefined;
151
+ }
152
+ let json;
153
+ try {
154
+ json = parseJsonc(text);
155
+ }
156
+ catch {
157
+ warnings.push(`Blast skipped tsconfig ${name}: not valid JSON, so path aliases are off`);
158
+ return undefined;
159
+ }
160
+ if (typeof json !== "object" || json === null || Array.isArray(json))
161
+ return undefined;
162
+ const config = json;
163
+ stack.add(path);
164
+ const chain = {};
165
+ const parents = Array.isArray(config.extends)
166
+ ? config.extends
167
+ : config.extends === undefined
168
+ ? []
169
+ : [config.extends];
170
+ for (const parent of parents) {
171
+ // Package presets (`@tsconfig/strictest/tsconfig.json`) are resolved by
172
+ // tsc through node_modules, which aliasing never reads.
173
+ if (typeof parent !== "string" || (!parent.startsWith(".") && !isAbsolute(parent))) {
174
+ warnings.push(`Blast ignored the tsconfig extends target ${JSON.stringify(String(parent))}: ` +
175
+ "outside the extension, so its path aliases are off");
176
+ continue;
177
+ }
178
+ let parentFile = resolve(join(path, ".."), parent);
179
+ if ((await kindOf(parentFile)) === "directory")
180
+ parentFile = join(parentFile, "tsconfig.json");
181
+ const inherited = await loadChain(parentFile, extensionDir, stack, warnings);
182
+ if (inherited?.baseUrl)
183
+ chain.baseUrl = inherited.baseUrl;
184
+ if (inherited?.paths)
185
+ chain.paths = inherited.paths;
186
+ }
187
+ stack.delete(path);
188
+ if (typeof config.compilerOptions === "object" && config.compilerOptions !== null) {
189
+ const options = config.compilerOptions;
190
+ for (const key of ["baseUrl", "paths"]) {
191
+ if (options[key] !== undefined)
192
+ chain[key] = { value: options[key], directory: join(path, "..") };
193
+ }
194
+ }
195
+ return chain;
196
+ }
197
+ function parsePaths(declared, baseUrl) {
198
+ const value = declared?.value;
199
+ if (typeof value !== "object" || value === null || Array.isArray(value))
200
+ return [];
201
+ // `paths` targets are relative to `baseUrl` when it is set (in any config
202
+ // of the chain), else to the config that declared `paths` (TS ≥ 4.1).
203
+ const base = baseUrl ?? declared.directory;
204
+ const patterns = [];
205
+ for (const [pattern, targets] of Object.entries(value)) {
206
+ if (!Array.isArray(targets))
207
+ continue;
208
+ const absolute = targets
209
+ .filter((target) => typeof target === "string")
210
+ .map((target) => resolve(base, target));
211
+ if (absolute.length > 0)
212
+ patterns.push([pattern, absolute]);
213
+ }
214
+ return patterns;
215
+ }
216
+ /** Exact patterns first, then wildcards by longest prefix, then longest suffix. */
217
+ function matchPatterns(patterns, specifier) {
218
+ const matches = [];
219
+ for (const [pattern, targets] of patterns) {
220
+ const star = pattern.indexOf("*");
221
+ if (star === -1) {
222
+ if (pattern === specifier)
223
+ matches.push({ targets, capture: "", prefix: Infinity, suffix: 0 });
224
+ continue;
225
+ }
226
+ const prefix = pattern.slice(0, star);
227
+ const suffix = pattern.slice(star + 1);
228
+ if (specifier.length >= prefix.length + suffix.length &&
229
+ specifier.startsWith(prefix) &&
230
+ specifier.endsWith(suffix)) {
231
+ matches.push({
232
+ targets,
233
+ capture: specifier.slice(prefix.length, specifier.length - suffix.length),
234
+ prefix: prefix.length,
235
+ suffix: suffix.length,
236
+ });
237
+ }
238
+ }
239
+ return matches
240
+ .toSorted((a, b) => b.prefix - a.prefix || b.suffix - a.suffix)
241
+ .map(({ targets, capture }) => ({ targets, capture }));
242
+ }
243
+ async function firstFile(candidates) {
244
+ for (const candidate of candidates) {
245
+ if ((await kindOf(candidate)) === "file")
246
+ return candidate;
247
+ }
248
+ return undefined;
249
+ }
250
+ /** A candidate is a file, a file with an implied extension, or a directory. */
251
+ async function probe(candidate) {
252
+ const kind = await kindOf(candidate);
253
+ if (kind === "file")
254
+ return candidate;
255
+ if (kind === "directory") {
256
+ // tsc honours a directory's package.json `main`; index files otherwise.
257
+ let main;
258
+ try {
259
+ const manifest = JSON.parse(await readFile(join(candidate, "package.json"), "utf8"));
260
+ if (typeof manifest.main === "string")
261
+ main = manifest.main;
262
+ }
263
+ catch {
264
+ // No package.json: fall back to index files.
265
+ }
266
+ if (main !== undefined) {
267
+ const direct = await firstFile([join(candidate, main)]);
268
+ if (direct)
269
+ return direct;
270
+ const implied = await firstFile(RESOLVE_EXTENSIONS.map((ext) => join(candidate, main + ext)));
271
+ if (implied)
272
+ return implied;
273
+ }
274
+ return firstFile(RESOLVE_EXTENSIONS.map((ext) => join(candidate, `index${ext}`)));
275
+ }
276
+ return firstFile(RESOLVE_EXTENSIONS.map((ext) => candidate + ext));
277
+ }
278
+ /**
279
+ * Reads the extension's tsconfig chain and returns a resolver for bare
280
+ * specifiers, or `undefined` when the extension has no `baseUrl`/`paths`.
281
+ */
282
+ export async function loadTsconfigAliases(extensionDir) {
283
+ const root = join(extensionDir, "tsconfig.json");
284
+ // Most extensions have no tsconfig; that is normal, not a warning.
285
+ if ((await kindOf(root)) !== "file")
286
+ return undefined;
287
+ const warnings = [];
288
+ const chain = await loadChain(root, extensionDir, new Set(), warnings);
289
+ if (!chain)
290
+ // The config exists but cannot be used at all; resolution stays
291
+ // package-only and the warning explains why.
292
+ return {
293
+ warnings,
294
+ resolve: () => Promise.resolve(undefined),
295
+ };
296
+ const baseUrlField = chain.baseUrl;
297
+ const baseUrl = typeof baseUrlField?.value === "string"
298
+ ? resolve(baseUrlField.directory, baseUrlField.value)
299
+ : undefined;
300
+ const patterns = parsePaths(chain.paths, baseUrl);
301
+ if (!baseUrl && patterns.length === 0)
302
+ return undefined;
303
+ const extensionDirAbsolute = resolve(extensionDir);
304
+ return {
305
+ warnings,
306
+ async resolve(specifier) {
307
+ // `paths` wins over `baseUrl`, which wins over packages — as tsc does.
308
+ for (const { targets, capture } of matchPatterns(patterns, specifier)) {
309
+ for (const template of targets) {
310
+ const candidate = template.replace("*", capture);
311
+ if (!inside(extensionDirAbsolute, candidate))
312
+ continue;
313
+ const found = await probe(candidate);
314
+ if (found)
315
+ return found;
316
+ }
317
+ }
318
+ if (baseUrl !== undefined) {
319
+ const candidate = join(baseUrl, specifier);
320
+ if (inside(extensionDirAbsolute, candidate)) {
321
+ const found = await probe(candidate);
322
+ if (found)
323
+ return found;
324
+ }
325
+ }
326
+ return undefined;
327
+ },
328
+ };
329
+ }
package/package.json CHANGED
@@ -1,6 +1,34 @@
1
1
  {
2
2
  "name": "@blastlauncher/engine",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "type": "module",
4
+ "exports": {
5
+ ".": {
6
+ "types": "./dist/index.d.ts",
7
+ "import": "./dist/index.js",
8
+ "default": "./dist/index.js"
9
+ }
10
+ },
11
+ "dependencies": {
12
+ "@blastlauncher/protocol": "3.0.0-alpha.1",
13
+ "@blastlauncher/runtime": "3.0.0-alpha.1",
14
+ "esbuild": "0.28.2",
15
+ "semver": "7.8.5"
16
+ },
17
+ "version": "3.0.0-alpha.1",
18
+ "license": "MIT",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/BlastLauncher/blast.git",
22
+ "directory": "packages/host"
23
+ },
24
+ "homepage": "https://github.com/BlastLauncher/blast#readme",
25
+ "engines": {
26
+ "node": ">=24.20.0"
27
+ },
28
+ "files": [
29
+ "dist",
30
+ "schema",
31
+ "LICENSE",
32
+ "README.md"
33
+ ]
34
+ }