@gjsify/rolldown-plugin-gjsify 0.3.14
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/lib/app/browser.d.ts +17 -0
- package/lib/app/browser.js +77 -0
- package/lib/app/gjs.d.ts +27 -0
- package/lib/app/gjs.js +211 -0
- package/lib/app/index.d.ts +6 -0
- package/lib/app/index.js +3 -0
- package/lib/app/node.d.ts +17 -0
- package/lib/app/node.js +102 -0
- package/lib/globals.d.ts +4 -0
- package/lib/globals.js +9 -0
- package/lib/index.d.ts +17 -0
- package/lib/index.js +15 -0
- package/lib/library/index.d.ts +2 -0
- package/lib/library/index.js +1 -0
- package/lib/library/lib.d.ts +16 -0
- package/lib/library/lib.js +118 -0
- package/lib/plugin.d.ts +25 -0
- package/lib/plugin.js +67 -0
- package/lib/plugins/alias.d.ts +5 -0
- package/lib/plugins/alias.js +45 -0
- package/lib/plugins/css-as-string.d.ts +2 -0
- package/lib/plugins/css-as-string.js +34 -0
- package/lib/plugins/gjs-imports-empty.d.ts +2 -0
- package/lib/plugins/gjs-imports-empty.js +26 -0
- package/lib/plugins/process-stub.d.ts +28 -0
- package/lib/plugins/process-stub.js +60 -0
- package/lib/plugins/rewrite-node-modules-paths.d.ts +38 -0
- package/lib/plugins/rewrite-node-modules-paths.js +132 -0
- package/lib/plugins/shebang.d.ts +8 -0
- package/lib/plugins/shebang.js +26 -0
- package/lib/shims/console-gjs.d.ts +24 -0
- package/lib/shims/console-gjs.js +24 -0
- package/lib/types/app.d.ts +1 -0
- package/lib/types/app.js +1 -0
- package/lib/types/index.d.ts +3 -0
- package/lib/types/index.js +3 -0
- package/lib/types/plugin-options.d.ts +46 -0
- package/lib/types/plugin-options.js +1 -0
- package/lib/types/resolve-alias-options.d.ts +2 -0
- package/lib/types/resolve-alias-options.js +1 -0
- package/lib/utils/alias.d.ts +12 -0
- package/lib/utils/alias.js +29 -0
- package/lib/utils/auto-globals.d.ts +72 -0
- package/lib/utils/auto-globals.js +193 -0
- package/lib/utils/detect-free-globals.d.ts +18 -0
- package/lib/utils/detect-free-globals.js +268 -0
- package/lib/utils/entry-points.d.ts +2 -0
- package/lib/utils/entry-points.js +38 -0
- package/lib/utils/extension.d.ts +1 -0
- package/lib/utils/extension.js +7 -0
- package/lib/utils/index.d.ts +7 -0
- package/lib/utils/index.js +7 -0
- package/lib/utils/inline-static-reads.d.ts +11 -0
- package/lib/utils/inline-static-reads.js +549 -0
- package/lib/utils/merge.d.ts +2 -0
- package/lib/utils/merge.js +23 -0
- package/lib/utils/scan-globals.d.ts +32 -0
- package/lib/utils/scan-globals.js +85 -0
- package/package.json +68 -0
- package/src/app/browser.ts +102 -0
- package/src/app/gjs.ts +260 -0
- package/src/app/index.ts +6 -0
- package/src/app/node.ts +128 -0
- package/src/globals.ts +11 -0
- package/src/index.ts +32 -0
- package/src/library/index.ts +2 -0
- package/src/library/lib.ts +142 -0
- package/src/plugin.ts +91 -0
- package/src/plugins/alias.ts +53 -0
- package/src/plugins/css-as-string.ts +37 -0
- package/src/plugins/gjs-imports-empty.ts +29 -0
- package/src/plugins/process-stub.ts +91 -0
- package/src/plugins/rewrite-node-modules-paths.ts +169 -0
- package/src/plugins/shebang.ts +33 -0
- package/src/shims/console-gjs.ts +25 -0
- package/src/types/app.ts +1 -0
- package/src/types/index.ts +3 -0
- package/src/types/plugin-options.ts +48 -0
- package/src/types/resolve-alias-options.ts +1 -0
- package/src/utils/alias.ts +46 -0
- package/src/utils/auto-globals.ts +283 -0
- package/src/utils/detect-free-globals.ts +278 -0
- package/src/utils/entry-points.ts +48 -0
- package/src/utils/extension.ts +7 -0
- package/src/utils/index.ts +7 -0
- package/src/utils/inline-static-reads.ts +541 -0
- package/src/utils/merge.ts +22 -0
- package/src/utils/scan-globals.ts +91 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
// Build-time inlining of statically-resolvable filesystem reads.
|
|
2
|
+
//
|
|
3
|
+
// Many node_modules packages locate their own resources (own package.json,
|
|
4
|
+
// locales, themes, ...) via `import.meta.url`-relative reads:
|
|
5
|
+
//
|
|
6
|
+
// const pkg = JSON.parse(readFileSync(
|
|
7
|
+
// new URL("../package.json", import.meta.url),
|
|
8
|
+
// "utf8",
|
|
9
|
+
// ));
|
|
10
|
+
//
|
|
11
|
+
// In a bundled GJS executable, `import.meta.url` no longer points at the
|
|
12
|
+
// original `node_modules/<pkg>/<file>` location, so the read fails with
|
|
13
|
+
// ENOENT once the bundle leaves the build site (gjsify dlx, manual move,
|
|
14
|
+
// CI artifact download, …).
|
|
15
|
+
//
|
|
16
|
+
// The clean fix is to evaluate the static expressions at build time and
|
|
17
|
+
// replace the entire `readFileSync(...)` (or `readdirSync(...)`, or the
|
|
18
|
+
// `JSON.parse(readFileSync(...))` composition) with a literal containing
|
|
19
|
+
// the file contents. The bundle is then a single self-contained file that
|
|
20
|
+
// behaves exactly like the original — same return value, same errors on
|
|
21
|
+
// missing files — but with no runtime dependency on the build-site layout.
|
|
22
|
+
//
|
|
23
|
+
// Patterns handled:
|
|
24
|
+
//
|
|
25
|
+
// readFileSync(<URL-derived-path>, "utf8" | "utf-8" | { encoding: "utf8" })
|
|
26
|
+
// → string literal
|
|
27
|
+
// readFileSync(<URL-derived-path>) → Uint8Array literal
|
|
28
|
+
// readdirSync(<URL-derived-path>) → array literal of names
|
|
29
|
+
// JSON.parse(readFileSync(...)) → object literal
|
|
30
|
+
// existsSync(<URL-derived-path>) → boolean literal
|
|
31
|
+
//
|
|
32
|
+
// Path expressions are evaluated against `import.meta.url` of the source
|
|
33
|
+
// file at build time, supporting compositions of:
|
|
34
|
+
//
|
|
35
|
+
// new URL(<lit>, import.meta.url) base resolution
|
|
36
|
+
// <expr>.href, <expr>.pathname property access
|
|
37
|
+
// fileURLToPath(<URL-expr>) url → fs path
|
|
38
|
+
// path.{join,dirname,resolve,basename,relative}(...) path arithmetic
|
|
39
|
+
// string-literal + string-literal concatenation
|
|
40
|
+
//
|
|
41
|
+
// Anything not statically resolvable is left untouched — the legacy
|
|
42
|
+
// `import.meta.url` rewriter still applies as a fallback.
|
|
43
|
+
|
|
44
|
+
import * as acorn from 'acorn';
|
|
45
|
+
import * as walk from 'acorn-walk';
|
|
46
|
+
import { dirname, join, resolve, basename, relative, extname } from 'node:path';
|
|
47
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
48
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* One in-place edit on the source string. Stored as half-open `[start, end)`
|
|
52
|
+
* byte offsets so we can apply replacements right-to-left without invalidating
|
|
53
|
+
* earlier offsets.
|
|
54
|
+
*/
|
|
55
|
+
interface Edit {
|
|
56
|
+
start: number;
|
|
57
|
+
end: number;
|
|
58
|
+
replacement: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface InlineContext {
|
|
62
|
+
/** `import.meta.url` of the source file being inlined (file:// URL). */
|
|
63
|
+
sourceUrl: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Run the inliner on a source string. Returns the rewritten source (or the
|
|
68
|
+
* original string when no inlining applied) and the count of edits applied.
|
|
69
|
+
*
|
|
70
|
+
* Safe to call on any JS source. Files that don't reference `readFileSync` /
|
|
71
|
+
* `readdirSync` / `existsSync` skip the AST parse entirely (cheap fast path).
|
|
72
|
+
*/
|
|
73
|
+
export function inlineStaticReads(
|
|
74
|
+
src: string,
|
|
75
|
+
sourceFilePath: string,
|
|
76
|
+
): { contents: string; inlined: number } {
|
|
77
|
+
if (
|
|
78
|
+
!src.includes('readFileSync') &&
|
|
79
|
+
!src.includes('readdirSync') &&
|
|
80
|
+
!src.includes('existsSync')
|
|
81
|
+
) {
|
|
82
|
+
return { contents: src, inlined: 0 };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let ast: acorn.Program;
|
|
86
|
+
try {
|
|
87
|
+
ast = acorn.parse(src, {
|
|
88
|
+
ecmaVersion: 'latest',
|
|
89
|
+
sourceType: 'module',
|
|
90
|
+
allowAwaitOutsideFunction: true,
|
|
91
|
+
allowReturnOutsideFunction: true,
|
|
92
|
+
allowImportExportEverywhere: true,
|
|
93
|
+
});
|
|
94
|
+
} catch {
|
|
95
|
+
// Source isn't valid JS (CJS source with shebangs, mixed module
|
|
96
|
+
// syntax, ...). Skip; the rest of the rewriter still runs.
|
|
97
|
+
return { contents: src, inlined: 0 };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const ctx: InlineContext = {
|
|
101
|
+
sourceUrl: pathToFileURL(sourceFilePath).href,
|
|
102
|
+
};
|
|
103
|
+
const edits: Edit[] = [];
|
|
104
|
+
|
|
105
|
+
walk.simple(ast, {
|
|
106
|
+
CallExpression(node: acorn.CallExpression) {
|
|
107
|
+
const edit = tryInlineCall(node, ctx, src);
|
|
108
|
+
if (edit) edits.push(edit);
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
if (edits.length === 0) return { contents: src, inlined: 0 };
|
|
113
|
+
|
|
114
|
+
// The walker visits both outer and inner CallExpressions, so a successful
|
|
115
|
+
// match on `JSON.parse(readFileSync(...))` produces an edit AT the same
|
|
116
|
+
// time that the inner `readFileSync(...)` also produces one. Applying both
|
|
117
|
+
// would corrupt the output. Keep only edits that are not contained in any
|
|
118
|
+
// other edit (= outermost wins).
|
|
119
|
+
const outermost: Edit[] = [];
|
|
120
|
+
edits.sort((a, b) => a.start - b.start || b.end - a.end);
|
|
121
|
+
for (const e of edits) {
|
|
122
|
+
const last = outermost[outermost.length - 1];
|
|
123
|
+
if (last && e.start >= last.start && e.end <= last.end) continue; // nested
|
|
124
|
+
outermost.push(e);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Apply right-to-left so earlier offsets remain valid.
|
|
128
|
+
outermost.sort((a, b) => b.start - a.start);
|
|
129
|
+
let out = src;
|
|
130
|
+
for (const e of outermost) {
|
|
131
|
+
out = out.slice(0, e.start) + e.replacement + out.slice(e.end);
|
|
132
|
+
}
|
|
133
|
+
return { contents: out, inlined: outermost.length };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Try to inline a single `CallExpression`. Returns an edit on success, or
|
|
138
|
+
* `undefined` if the call doesn't match an inlinable pattern or the path
|
|
139
|
+
* couldn't be resolved or the file doesn't exist.
|
|
140
|
+
*/
|
|
141
|
+
function tryInlineCall(
|
|
142
|
+
node: acorn.CallExpression,
|
|
143
|
+
ctx: InlineContext,
|
|
144
|
+
src: string,
|
|
145
|
+
): Edit | undefined {
|
|
146
|
+
const callee = node.callee;
|
|
147
|
+
|
|
148
|
+
// `JSON.parse(readFileSync(<path>, "utf8"))` — collapse the whole
|
|
149
|
+
// composition. Recognising it specifically lets us emit a parsed-JSON
|
|
150
|
+
// object literal instead of a `JSON.parse('…')` string-then-parse pair,
|
|
151
|
+
// which esbuild can dead-code-eliminate against.
|
|
152
|
+
if (
|
|
153
|
+
callee.type === 'MemberExpression' &&
|
|
154
|
+
!callee.computed &&
|
|
155
|
+
callee.object.type === 'Identifier' && callee.object.name === 'JSON' &&
|
|
156
|
+
callee.property.type === 'Identifier' && callee.property.name === 'parse' &&
|
|
157
|
+
node.arguments.length >= 1 &&
|
|
158
|
+
node.arguments[0].type === 'CallExpression'
|
|
159
|
+
) {
|
|
160
|
+
const inner = node.arguments[0] as acorn.CallExpression;
|
|
161
|
+
const innerEdit = tryInlineReadFile(inner, ctx, /*forceTextEncoding*/ true);
|
|
162
|
+
if (innerEdit !== undefined) {
|
|
163
|
+
// `innerEdit` is the literal source for the read result (a JSON
|
|
164
|
+
// string). Parse and re-emit as a JS-literal expression so the
|
|
165
|
+
// surrounding code sees an object directly.
|
|
166
|
+
try {
|
|
167
|
+
const parsed = JSON.parse(JSON.parse(innerEdit));
|
|
168
|
+
return {
|
|
169
|
+
start: node.start,
|
|
170
|
+
end: node.end,
|
|
171
|
+
replacement: jsLiteral(parsed),
|
|
172
|
+
};
|
|
173
|
+
} catch {
|
|
174
|
+
// Fall through — leave the original call alone.
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const calleeName = identifierName(callee);
|
|
180
|
+
|
|
181
|
+
if (calleeName === 'readFileSync') {
|
|
182
|
+
const replacement = tryInlineReadFile(node, ctx, /*forceTextEncoding*/ false);
|
|
183
|
+
if (replacement !== undefined) {
|
|
184
|
+
return { start: node.start, end: node.end, replacement };
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (calleeName === 'readdirSync') {
|
|
189
|
+
const path = evalPathExpr(node.arguments[0], ctx);
|
|
190
|
+
if (path && existsSyncSafe(path) && isDirectorySafe(path)) {
|
|
191
|
+
try {
|
|
192
|
+
const names = readdirSync(path);
|
|
193
|
+
return {
|
|
194
|
+
start: node.start,
|
|
195
|
+
end: node.end,
|
|
196
|
+
replacement: jsLiteral(names),
|
|
197
|
+
};
|
|
198
|
+
} catch {
|
|
199
|
+
/* skip */
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (calleeName === 'existsSync') {
|
|
205
|
+
const path = evalPathExpr(node.arguments[0], ctx);
|
|
206
|
+
if (path !== undefined) {
|
|
207
|
+
return {
|
|
208
|
+
start: node.start,
|
|
209
|
+
end: node.end,
|
|
210
|
+
replacement: existsSyncSafe(path) ? 'true' : 'false',
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
// `createRequire(<URL>)` from `node:module` returns a CJS-style require.
|
|
217
|
+
// In a bundled GJS executable, the deps that the runtime require would
|
|
218
|
+
// resolve are already inlined by esbuild, so the require() function is
|
|
219
|
+
// typically dead code. The createRequire CALL itself runs at module init,
|
|
220
|
+
// and Node's implementation rejects the rewritten URLs we produce when
|
|
221
|
+
// they don't point at an existing file (yargs-parser's `createRequire(
|
|
222
|
+
// import.meta.url)` blows up because the rewritten URL refers to a Yarn
|
|
223
|
+
// PnP zip path that doesn't exist outside the PnP runtime).
|
|
224
|
+
//
|
|
225
|
+
// Replace the call with a stub function: assignment succeeds, the bundle
|
|
226
|
+
// boots, and any actual `require()` invocation produces a clear error
|
|
227
|
+
// instead of an obscure URL-validation crash. Only fires when the URL
|
|
228
|
+
// argument can be statically resolved AND points at a non-existent file
|
|
229
|
+
// — the common case is exactly the broken one.
|
|
230
|
+
if (calleeName === 'createRequire') {
|
|
231
|
+
const path = evalPathExpr(node.arguments[0], ctx);
|
|
232
|
+
// Stub the call when:
|
|
233
|
+
// - the resolved path doesn't exist on disk (build site), OR
|
|
234
|
+
// - the path contains a `.zip/` segment (Yarn PnP virtual zip,
|
|
235
|
+
// where Node's PnP hooks make `existsSync` return true at build
|
|
236
|
+
// time but the path doesn't exist under GJS at runtime).
|
|
237
|
+
const isZip = path !== undefined && path.includes('.zip/');
|
|
238
|
+
if (path !== undefined && (isZip || !existsSyncSafe(path))) {
|
|
239
|
+
return {
|
|
240
|
+
start: node.start,
|
|
241
|
+
end: node.end,
|
|
242
|
+
replacement:
|
|
243
|
+
`(() => { ` +
|
|
244
|
+
`const _r = (id) => { throw new Error("[gjsify] createRequire stub: '" + id + "' was not bundled (anchor path: " + ${jsStringLiteral(path)} + ")"); }; ` +
|
|
245
|
+
`_r.resolve = _r; _r.cache = {}; _r.extensions = {}; _r.main = void 0; ` +
|
|
246
|
+
`return _r; ` +
|
|
247
|
+
`})()`,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return undefined;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Inline a `readFileSync(<path>, <enc>?)` call to a string or byte literal.
|
|
257
|
+
* Returns the source replacement, or `undefined` to leave the call alone.
|
|
258
|
+
*
|
|
259
|
+
* `forceTextEncoding`: caller (JSON.parse wrapper) demands an utf-8 read
|
|
260
|
+
* regardless of whether the syntactic argument provides an encoding.
|
|
261
|
+
*/
|
|
262
|
+
function tryInlineReadFile(
|
|
263
|
+
node: acorn.CallExpression,
|
|
264
|
+
ctx: InlineContext,
|
|
265
|
+
forceTextEncoding: boolean,
|
|
266
|
+
): string | undefined {
|
|
267
|
+
if (node.arguments.length < 1) return undefined;
|
|
268
|
+
const path = evalPathExpr(node.arguments[0], ctx);
|
|
269
|
+
if (!path) return undefined;
|
|
270
|
+
if (!existsSyncSafe(path) || isDirectorySafe(path)) return undefined;
|
|
271
|
+
|
|
272
|
+
let encoding: string | undefined;
|
|
273
|
+
if (forceTextEncoding) {
|
|
274
|
+
encoding = 'utf8';
|
|
275
|
+
} else if (node.arguments.length >= 2) {
|
|
276
|
+
encoding = evalEncodingExpr(node.arguments[1]);
|
|
277
|
+
if (encoding === undefined) return undefined; // unknown → bail
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
try {
|
|
281
|
+
if (encoding) {
|
|
282
|
+
const text = readFileSync(path, encoding as BufferEncoding);
|
|
283
|
+
return jsStringLiteral(text);
|
|
284
|
+
} else {
|
|
285
|
+
// Binary read → emit a Uint8Array constructor over a number array.
|
|
286
|
+
// Buffer-vs-Uint8Array semantic difference is mostly irrelevant in
|
|
287
|
+
// bundled GJS code (Buffer is polyfilled on top of Uint8Array).
|
|
288
|
+
const bytes = readFileSync(path);
|
|
289
|
+
return `new Uint8Array([${Array.from(bytes).join(',')}])`;
|
|
290
|
+
}
|
|
291
|
+
} catch {
|
|
292
|
+
return undefined;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Statically evaluate a node we expect to produce a filesystem path string.
|
|
298
|
+
* Returns the absolute path or `undefined` if any step is non-static.
|
|
299
|
+
*
|
|
300
|
+
* Recursively understands compositions of:
|
|
301
|
+
* - string literals, template literals (no expressions), `+` concatenation
|
|
302
|
+
* - `new URL(<lit>, <base-url-expr>)`
|
|
303
|
+
* - `<URL-expr>.href`, `<URL-expr>.pathname`
|
|
304
|
+
* - `fileURLToPath(<URL-expr>)` / `pathToFileURL(<path>).href`
|
|
305
|
+
* - `(path.)?{join,dirname,resolve,basename,relative,extname}(...)` over static args
|
|
306
|
+
* - `import.meta.url` (resolved against ctx.sourceUrl)
|
|
307
|
+
* - bare identifier `__dirname` / `__filename` (resolved against ctx.sourceUrl)
|
|
308
|
+
*
|
|
309
|
+
* Returns a path string OR a URL string, depending on context — callers
|
|
310
|
+
* that need a path use `evalPathExpr`, callers that need a URL use
|
|
311
|
+
* `evalUrlExpr`. They both come from the same recursive evaluator.
|
|
312
|
+
*/
|
|
313
|
+
function evalPathExpr(node: acorn.AnyNode | undefined, ctx: InlineContext): string | undefined {
|
|
314
|
+
const v = evalExpr(node, ctx);
|
|
315
|
+
if (v instanceof URL) {
|
|
316
|
+
if (v.protocol === 'file:') return fileURLToPath(v);
|
|
317
|
+
return undefined;
|
|
318
|
+
}
|
|
319
|
+
if (typeof v !== 'string') return undefined;
|
|
320
|
+
if (v.startsWith('file://')) return fileURLToPath(v);
|
|
321
|
+
if (v.startsWith('/')) return v;
|
|
322
|
+
return undefined;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
type EvalValue = string | URL | undefined;
|
|
326
|
+
|
|
327
|
+
function evalExpr(node: acorn.AnyNode | undefined, ctx: InlineContext): EvalValue {
|
|
328
|
+
if (!node) return undefined;
|
|
329
|
+
|
|
330
|
+
switch (node.type) {
|
|
331
|
+
case 'Literal':
|
|
332
|
+
if (typeof (node as acorn.Literal).value === 'string') {
|
|
333
|
+
return (node as acorn.Literal).value as string;
|
|
334
|
+
}
|
|
335
|
+
return undefined;
|
|
336
|
+
|
|
337
|
+
case 'TemplateLiteral': {
|
|
338
|
+
const tl = node as acorn.TemplateLiteral;
|
|
339
|
+
if (tl.expressions.length > 0) return undefined;
|
|
340
|
+
return tl.quasis.map((q) => q.value.cooked ?? '').join('');
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
case 'BinaryExpression': {
|
|
344
|
+
const be = node as acorn.BinaryExpression;
|
|
345
|
+
if (be.operator !== '+') return undefined;
|
|
346
|
+
const l = evalExpr(be.left, ctx);
|
|
347
|
+
const r = evalExpr(be.right, ctx);
|
|
348
|
+
if (typeof l !== 'string' || typeof r !== 'string') return undefined;
|
|
349
|
+
return l + r;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
case 'Identifier': {
|
|
353
|
+
const id = node as acorn.Identifier;
|
|
354
|
+
if (id.name === '__dirname') return fileURLToPath(new URL('.', ctx.sourceUrl));
|
|
355
|
+
if (id.name === '__filename') return fileURLToPath(ctx.sourceUrl);
|
|
356
|
+
return undefined;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
case 'MemberExpression': {
|
|
360
|
+
const me = node as acorn.MemberExpression;
|
|
361
|
+
// import.meta.url
|
|
362
|
+
if (
|
|
363
|
+
me.object.type === 'MetaProperty' &&
|
|
364
|
+
(me.object as acorn.MetaProperty).meta.name === 'import' &&
|
|
365
|
+
(me.object as acorn.MetaProperty).property.name === 'meta' &&
|
|
366
|
+
me.property.type === 'Identifier' &&
|
|
367
|
+
(me.property as acorn.Identifier).name === 'url'
|
|
368
|
+
) {
|
|
369
|
+
return ctx.sourceUrl;
|
|
370
|
+
}
|
|
371
|
+
// <expr>.href / .pathname
|
|
372
|
+
if (!me.computed && me.property.type === 'Identifier') {
|
|
373
|
+
const obj = evalExpr(me.object, ctx);
|
|
374
|
+
const prop = (me.property as acorn.Identifier).name;
|
|
375
|
+
if (obj instanceof URL) {
|
|
376
|
+
if (prop === 'href') return obj.href;
|
|
377
|
+
if (prop === 'pathname') return obj.pathname;
|
|
378
|
+
}
|
|
379
|
+
if (typeof obj === 'string') {
|
|
380
|
+
if (prop === 'href') return obj; // already a URL string
|
|
381
|
+
if (prop === 'pathname') {
|
|
382
|
+
try { return new URL(obj).pathname; } catch { return undefined; }
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return undefined;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
case 'NewExpression': {
|
|
390
|
+
const ne = node as acorn.NewExpression;
|
|
391
|
+
const calleeName = identifierName(ne.callee);
|
|
392
|
+
if (calleeName === 'URL') {
|
|
393
|
+
if (ne.arguments.length === 0) return undefined;
|
|
394
|
+
const first = evalExpr(ne.arguments[0], ctx);
|
|
395
|
+
if (typeof first !== 'string') return undefined;
|
|
396
|
+
if (ne.arguments.length === 1) {
|
|
397
|
+
try { return new URL(first); } catch { return undefined; }
|
|
398
|
+
}
|
|
399
|
+
const base = evalExpr(ne.arguments[1], ctx);
|
|
400
|
+
const baseStr = base instanceof URL ? base.href : (typeof base === 'string' ? base : undefined);
|
|
401
|
+
if (!baseStr) return undefined;
|
|
402
|
+
try { return new URL(first, baseStr); } catch { return undefined; }
|
|
403
|
+
}
|
|
404
|
+
return undefined;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
case 'CallExpression': {
|
|
408
|
+
const ce = node as acorn.CallExpression;
|
|
409
|
+
const name = identifierName(ce.callee);
|
|
410
|
+
|
|
411
|
+
if (name === 'fileURLToPath') {
|
|
412
|
+
const arg = evalExpr(ce.arguments[0], ctx);
|
|
413
|
+
const url = arg instanceof URL ? arg.href : (typeof arg === 'string' ? arg : undefined);
|
|
414
|
+
if (!url) return undefined;
|
|
415
|
+
try { return fileURLToPath(url); } catch { return undefined; }
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
if (name === 'pathToFileURL') {
|
|
419
|
+
const arg = evalExpr(ce.arguments[0], ctx);
|
|
420
|
+
if (typeof arg !== 'string') return undefined;
|
|
421
|
+
try { return pathToFileURL(arg); } catch { return undefined; }
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (name === 'join' || name === 'resolve') {
|
|
425
|
+
const args: string[] = [];
|
|
426
|
+
for (const a of ce.arguments) {
|
|
427
|
+
const v = evalExpr(a, ctx);
|
|
428
|
+
if (typeof v !== 'string') return undefined;
|
|
429
|
+
args.push(v);
|
|
430
|
+
}
|
|
431
|
+
return name === 'join' ? join(...args) : resolve(...args);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if (name === 'dirname' || name === 'basename' || name === 'extname') {
|
|
435
|
+
const v = evalExpr(ce.arguments[0], ctx);
|
|
436
|
+
if (typeof v !== 'string') return undefined;
|
|
437
|
+
if (name === 'dirname') return dirname(v);
|
|
438
|
+
if (name === 'basename') {
|
|
439
|
+
const ext = ce.arguments.length >= 2 ? evalExpr(ce.arguments[1], ctx) : undefined;
|
|
440
|
+
return basename(v, typeof ext === 'string' ? ext : undefined);
|
|
441
|
+
}
|
|
442
|
+
if (name === 'extname') return extname(v);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (name === 'relative') {
|
|
446
|
+
const a = evalExpr(ce.arguments[0], ctx);
|
|
447
|
+
const b = evalExpr(ce.arguments[1], ctx);
|
|
448
|
+
if (typeof a !== 'string' || typeof b !== 'string') return undefined;
|
|
449
|
+
return relative(a, b);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
return undefined;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
return undefined;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Evaluate an encoding argument to its canonical string form.
|
|
460
|
+
* "utf8" / "utf-8" → "utf8"
|
|
461
|
+
* { encoding: "utf8" } → "utf8"
|
|
462
|
+
* anything else → undefined (caller leaves the call alone)
|
|
463
|
+
*/
|
|
464
|
+
function evalEncodingExpr(node: acorn.AnyNode | undefined): string | undefined {
|
|
465
|
+
if (!node) return undefined;
|
|
466
|
+
if (node.type === 'Literal') {
|
|
467
|
+
const v = (node as acorn.Literal).value;
|
|
468
|
+
if (typeof v === 'string') return canonicalEncoding(v);
|
|
469
|
+
return undefined;
|
|
470
|
+
}
|
|
471
|
+
if (node.type === 'ObjectExpression') {
|
|
472
|
+
for (const p of (node as acorn.ObjectExpression).properties) {
|
|
473
|
+
if (p.type !== 'Property' || p.computed) continue;
|
|
474
|
+
const key = p.key.type === 'Identifier'
|
|
475
|
+
? (p.key as acorn.Identifier).name
|
|
476
|
+
: p.key.type === 'Literal' ? String((p.key as acorn.Literal).value) : undefined;
|
|
477
|
+
if (key !== 'encoding') continue;
|
|
478
|
+
if (p.value.type === 'Literal' && typeof (p.value as acorn.Literal).value === 'string') {
|
|
479
|
+
return canonicalEncoding((p.value as acorn.Literal).value as string);
|
|
480
|
+
}
|
|
481
|
+
return undefined;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
return undefined;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function canonicalEncoding(v: string): string | undefined {
|
|
488
|
+
const lc = v.toLowerCase();
|
|
489
|
+
if (lc === 'utf8' || lc === 'utf-8') return 'utf8';
|
|
490
|
+
if (lc === 'ascii') return 'ascii';
|
|
491
|
+
if (lc === 'latin1' || lc === 'binary') return 'latin1';
|
|
492
|
+
return undefined;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Get the leaf identifier name of a callee. Recognises:
|
|
497
|
+
* `foo` → "foo"
|
|
498
|
+
* `path.foo` → "foo"
|
|
499
|
+
* `node:path.foo` → "foo" (rare)
|
|
500
|
+
* `fs.foo` / `fs.promises.foo` → "foo"
|
|
501
|
+
* Returns `undefined` for computed/dynamic callees.
|
|
502
|
+
*/
|
|
503
|
+
function identifierName(node: acorn.AnyNode | undefined): string | undefined {
|
|
504
|
+
if (!node) return undefined;
|
|
505
|
+
if (node.type === 'Identifier') return (node as acorn.Identifier).name;
|
|
506
|
+
if (node.type === 'MemberExpression' && !(node as acorn.MemberExpression).computed) {
|
|
507
|
+
const me = node as acorn.MemberExpression;
|
|
508
|
+
if (me.property.type === 'Identifier') return (me.property as acorn.Identifier).name;
|
|
509
|
+
}
|
|
510
|
+
return undefined;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** Produce a JS source-fragment for a value the inliner produced. */
|
|
514
|
+
function jsLiteral(v: unknown): string {
|
|
515
|
+
if (typeof v === 'string') return jsStringLiteral(v);
|
|
516
|
+
if (typeof v === 'number') return Number.isFinite(v) ? String(v) : 'null';
|
|
517
|
+
if (typeof v === 'boolean') return v ? 'true' : 'false';
|
|
518
|
+
if (v === null) return 'null';
|
|
519
|
+
if (Array.isArray(v)) return '[' + v.map(jsLiteral).join(',') + ']';
|
|
520
|
+
if (typeof v === 'object') {
|
|
521
|
+
const parts: string[] = [];
|
|
522
|
+
for (const [k, val] of Object.entries(v as Record<string, unknown>)) {
|
|
523
|
+
parts.push(`${jsStringLiteral(k)}:${jsLiteral(val)}`);
|
|
524
|
+
}
|
|
525
|
+
return '{' + parts.join(',') + '}';
|
|
526
|
+
}
|
|
527
|
+
return 'undefined';
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/** JSON.stringify is the safest way to escape arbitrary strings into JS. */
|
|
531
|
+
function jsStringLiteral(s: string): string {
|
|
532
|
+
return JSON.stringify(s);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function existsSyncSafe(path: string): boolean {
|
|
536
|
+
try { return existsSync(path); } catch { return false; }
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function isDirectorySafe(path: string): boolean {
|
|
540
|
+
try { return statSync(path).isDirectory(); } catch { return false; }
|
|
541
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Deep merge objects (replaces lodash.merge) */
|
|
2
|
+
export function merge<T extends Record<string, any>>(target: T, ...sources: Record<string, any>[]): T {
|
|
3
|
+
for (const source of sources) {
|
|
4
|
+
if (!source) continue;
|
|
5
|
+
for (const key of Object.keys(source)) {
|
|
6
|
+
const targetVal = (target as any)[key];
|
|
7
|
+
const sourceVal = source[key];
|
|
8
|
+
if (sourceVal !== undefined) {
|
|
9
|
+
if (isPlainObject(targetVal) && isPlainObject(sourceVal)) {
|
|
10
|
+
merge(targetVal, sourceVal);
|
|
11
|
+
} else {
|
|
12
|
+
(target as any)[key] = sourceVal;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return target;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isPlainObject(val: unknown): val is Record<string, any> {
|
|
21
|
+
return typeof val === 'object' && val !== null && !Array.isArray(val) && Object.getPrototypeOf(val) === Object.prototype;
|
|
22
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Explicit `--globals` CLI flag support.
|
|
2
|
+
//
|
|
3
|
+
// This module resolves a user-provided comma-separated list of global
|
|
4
|
+
// identifiers (e.g. `fetch,Buffer,process,URL,crypto`) into the
|
|
5
|
+
// corresponding set of `@gjsify/<pkg>/register` subpaths and writes an
|
|
6
|
+
// ESM stub file that the esbuild plugin injects via its
|
|
7
|
+
// `autoGlobalsInject` option.
|
|
8
|
+
//
|
|
9
|
+
// gjsify does NOT scan user code to guess which globals are needed —
|
|
10
|
+
// the user declares them explicitly via `gjsify build --globals <list>`
|
|
11
|
+
// (or via the default script scaffolded by `@gjsify/create-app`). See
|
|
12
|
+
// the "Tree-shakeable Globals" section in AGENTS.md for the rationale.
|
|
13
|
+
|
|
14
|
+
import { writeFile, mkdir } from 'node:fs/promises';
|
|
15
|
+
import { createHash } from 'node:crypto';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
|
|
18
|
+
import { GJS_GLOBALS_MAP, GJS_GLOBALS_GROUPS } from '@gjsify/resolve-npm/globals-map';
|
|
19
|
+
|
|
20
|
+
const GLOBALS_MAP: Record<string, string> = GJS_GLOBALS_MAP;
|
|
21
|
+
const GLOBALS_GROUPS: Record<string, string[]> = GJS_GLOBALS_GROUPS;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Resolve a `--globals` CLI argument into the set of `/register` subpaths
|
|
25
|
+
* that must be injected into the build.
|
|
26
|
+
*
|
|
27
|
+
* The argument is a comma-separated list of identifiers or group names.
|
|
28
|
+
* Group names (`node`, `web`, `dom`) expand to all identifiers in that group.
|
|
29
|
+
* Unknown tokens are silently ignored. Empty or whitespace-only input returns
|
|
30
|
+
* an empty set.
|
|
31
|
+
*
|
|
32
|
+
* Examples:
|
|
33
|
+
* resolveGlobalsList('fetch,Buffer,process')
|
|
34
|
+
* → Set { 'fetch/register', '@gjsify/buffer/register', '@gjsify/node-globals/register' }
|
|
35
|
+
*
|
|
36
|
+
* resolveGlobalsList('node,web')
|
|
37
|
+
* → Set { '@gjsify/buffer/register', '@gjsify/node-globals/register', 'fetch/register', … }
|
|
38
|
+
*
|
|
39
|
+
* resolveGlobalsList('')
|
|
40
|
+
* → Set { }
|
|
41
|
+
*/
|
|
42
|
+
export function resolveGlobalsList(globalsArg: string): Set<string> {
|
|
43
|
+
const result = new Set<string>();
|
|
44
|
+
const trimmed = globalsArg.trim();
|
|
45
|
+
if (!trimmed) return result;
|
|
46
|
+
|
|
47
|
+
for (const rawToken of trimmed.split(',')) {
|
|
48
|
+
const token = rawToken.trim();
|
|
49
|
+
if (!token) continue;
|
|
50
|
+
const group = GLOBALS_GROUPS[token];
|
|
51
|
+
if (group) {
|
|
52
|
+
for (const id of group) {
|
|
53
|
+
const path = GLOBALS_MAP[id];
|
|
54
|
+
if (path) result.add(path);
|
|
55
|
+
}
|
|
56
|
+
} else {
|
|
57
|
+
const path = GLOBALS_MAP[token];
|
|
58
|
+
if (path) result.add(path);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return result;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Write a stub ESM file with `import` statements for the given register
|
|
66
|
+
* paths and return its absolute path, suitable for passing to esbuild's
|
|
67
|
+
* `inject` option via the plugin's `autoGlobalsInject` field.
|
|
68
|
+
*
|
|
69
|
+
* The file lives inside `<cwd>/node_modules/.cache/gjsify/` so esbuild's
|
|
70
|
+
* module resolver can follow the bare specifiers in the generated imports.
|
|
71
|
+
*
|
|
72
|
+
* The file name is hashed by content so repeated builds with the same
|
|
73
|
+
* set reuse the same file (no churn, idempotent on disk).
|
|
74
|
+
*/
|
|
75
|
+
export async function writeRegisterInjectFile(
|
|
76
|
+
registerPaths: Set<string>,
|
|
77
|
+
cwd: string = process.cwd(),
|
|
78
|
+
): Promise<string | null> {
|
|
79
|
+
if (registerPaths.size === 0) return null;
|
|
80
|
+
|
|
81
|
+
const sorted = [...registerPaths].sort();
|
|
82
|
+
const content = sorted.map((p) => `import '${p}';`).join('\n') + '\n';
|
|
83
|
+
const hash = createHash('sha1').update(content).digest('hex').slice(0, 10);
|
|
84
|
+
|
|
85
|
+
const cacheDir = join(cwd, 'node_modules', '.cache', 'gjsify');
|
|
86
|
+
await mkdir(cacheDir, { recursive: true });
|
|
87
|
+
|
|
88
|
+
const path = join(cacheDir, `auto-globals-${hash}.mjs`);
|
|
89
|
+
await writeFile(path, content, 'utf-8');
|
|
90
|
+
return path;
|
|
91
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"rootDir": "src",
|
|
4
|
+
"outDir": "lib",
|
|
5
|
+
"declaration": true,
|
|
6
|
+
"target": "ESNext",
|
|
7
|
+
"module": "ESNext",
|
|
8
|
+
"moduleResolution": "bundler",
|
|
9
|
+
"lib": ["ESNext", "DOM"],
|
|
10
|
+
"strict": false,
|
|
11
|
+
"esModuleInterop": true,
|
|
12
|
+
"skipLibCheck": true,
|
|
13
|
+
"types": ["node"]
|
|
14
|
+
},
|
|
15
|
+
"include": ["src/**/*"]
|
|
16
|
+
}
|