@jesscss/plugin-js 2.0.0-alpha.5 → 2.0.0-alpha.7

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/index.js CHANGED
@@ -1,442 +1,557 @@
1
- import { AbstractPlugin } from '@jesscss/core';
2
- import * as fs from 'node:fs';
3
- import * as net from 'node:net';
4
- import * as path from 'node:path';
5
- import { fileURLToPath, pathToFileURL } from 'node:url';
6
- import { spawn, spawnSync } from 'node:child_process';
1
+ import { AbstractPlugin, Any, Color, ColorFormat, Declaration, Dimension, List, Quoted, Rules, Sequence } from "@jesscss/core";
2
+ import * as fs from "node:fs";
3
+ import * as net from "node:net";
4
+ import * as path from "node:path";
5
+ import { fileURLToPath, pathToFileURL } from "node:url";
6
+ import { spawn, spawnSync } from "node:child_process";
7
+ //#region src/bridge.ts
8
+ const isBridgeValue = (value) => typeof value === "object" && value !== null && value.__jessBridge === true && typeof value.kind === "string";
9
+ const colorFormatFromString = (value) => {
10
+ if (!value) return;
11
+ if (value in ColorFormat) return ColorFormat[value];
12
+ if (Object.values(ColorFormat).includes(value)) return value;
13
+ };
14
+ function encodeBridgeChildValue(value) {
15
+ const encoded = encodeBridgeValue(value);
16
+ if (isBridgeValue(encoded)) return encoded;
17
+ return {
18
+ __jessBridge: true,
19
+ kind: "scalar",
20
+ value: typeof encoded === "string" || typeof encoded === "number" || typeof encoded === "boolean" ? encoded : String(encoded)
21
+ };
22
+ }
23
+ function encodeBridgeValue(value) {
24
+ if (value instanceof Dimension) return {
25
+ __jessBridge: true,
26
+ kind: "dimension",
27
+ value: value.number,
28
+ unit: value.unit
29
+ };
30
+ if (value instanceof Color) return {
31
+ __jessBridge: true,
32
+ kind: "color",
33
+ rgb: value.rgb,
34
+ alpha: value.alpha,
35
+ format: value.options.format === void 0 ? void 0 : ColorFormat[value.options.format]
36
+ };
37
+ if (value instanceof Quoted) return {
38
+ __jessBridge: true,
39
+ kind: "quoted",
40
+ value: String(value.value),
41
+ quote: value.quote,
42
+ escaped: value.escaped
43
+ };
44
+ if (value instanceof Any) return {
45
+ __jessBridge: true,
46
+ kind: value.role === "keyword" ? "keyword" : "anonymous",
47
+ value: value.value
48
+ };
49
+ if (value instanceof List) return {
50
+ __jessBridge: true,
51
+ kind: "list",
52
+ items: value.value.map(encodeBridgeChildValue),
53
+ separator: value.options.sep
54
+ };
55
+ if (value instanceof Sequence) return {
56
+ __jessBridge: true,
57
+ kind: "sequence",
58
+ items: value.value.map(encodeBridgeChildValue)
59
+ };
60
+ if (value instanceof Rules) {
61
+ const rules = [];
62
+ for (const rule of value.rules ?? []) if (rule instanceof Declaration && typeof rule.name === "string") rules.push({
63
+ name: rule.name,
64
+ value: encodeBridgeChildValue(rule.value)
65
+ });
66
+ return {
67
+ __jessBridge: true,
68
+ kind: "detached",
69
+ rules
70
+ };
71
+ }
72
+ return value;
73
+ }
74
+ function decodeBridgeValue(value) {
75
+ if (!isBridgeValue(value)) return value;
76
+ switch (value.kind) {
77
+ case "scalar": return new Any(String(value.value));
78
+ case "dimension": return new Dimension({
79
+ number: value.value,
80
+ unit: value.unit
81
+ });
82
+ case "color": return new Color({
83
+ rgb: value.rgb,
84
+ alpha: value.alpha
85
+ }, { format: colorFormatFromString(value.format) });
86
+ case "quoted": return new Quoted(value.value, {
87
+ quote: value.quote,
88
+ escaped: value.escaped
89
+ });
90
+ case "keyword": return new Any(value.value, { role: "keyword" });
91
+ case "anonymous": return new Any(value.value);
92
+ case "list": return new List(value.items.map((item) => decodeBridgeValue(item)), { sep: value.separator });
93
+ case "sequence": return new Sequence(value.items.map((item) => decodeBridgeValue(item)));
94
+ }
95
+ }
96
+ function encodeBridgeArgs(args) {
97
+ return args.map(encodeBridgeValue);
98
+ }
99
+ //#endregion
100
+ //#region src/index.ts
7
101
  const SCRIPT_EXTENSIONS = new Set([
8
- '.js',
9
- '.mjs',
10
- '.cjs',
11
- '.ts',
12
- '.mts',
13
- '.cts'
102
+ ".js",
103
+ ".mjs",
104
+ ".cjs",
105
+ ".ts",
106
+ ".mts",
107
+ ".cts"
14
108
  ]);
15
109
  const RUNTIME_MISSING_MESSAGE = [
16
- 'Deno runtime is required for @jesscss/plugin-js, but no usable Deno binary was found.',
17
- 'If using pnpm, approve build scripts for "deno" (pnpm approve-builds).',
18
- 'If using npm with ignored scripts, reinstall with lifecycle scripts enabled.',
19
- 'Or install native Deno and ensure "deno" is on PATH.'
20
- ].join('\n');
21
- const BOOT_TIMEOUT_MS = 8000;
22
- const REQUEST_TIMEOUT_MS = 10_000;
110
+ "Deno runtime is required for @jesscss/plugin-js, but no usable Deno binary was found.",
111
+ "If using pnpm, approve build scripts for \"deno\" (pnpm approve-builds).",
112
+ "If using npm with ignored scripts, reinstall with lifecycle scripts enabled.",
113
+ "Or install native Deno and ensure \"deno\" is on PATH."
114
+ ].join("\n");
115
+ const BOOT_TIMEOUT_MS = 8e3;
116
+ const REQUEST_TIMEOUT_MS = 1e4;
117
+ const IDLE_SHUTDOWN_MS = 5e3;
23
118
  const isPathInside = (candidatePath, rootPath) => {
24
- const rel = path.relative(rootPath, candidatePath);
25
- return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
119
+ const rel = path.relative(rootPath, candidatePath);
120
+ return rel === "" || !rel.startsWith("..") && !path.isAbsolute(rel);
26
121
  };
27
122
  const canonicalPath = (p) => {
28
- try {
29
- return fs.realpathSync.native(p);
30
- }
31
- catch {
32
- return p;
33
- }
123
+ try {
124
+ return fs.realpathSync.native(p);
125
+ } catch {
126
+ return p;
127
+ }
34
128
  };
35
129
  const normalizePermissionPath = (value) => {
36
- if (!value) {
37
- return null;
38
- }
39
- if (value.startsWith('file://')) {
40
- try {
41
- return fileURLToPath(value);
42
- }
43
- catch {
44
- return value;
45
- }
46
- }
47
- return value;
130
+ if (!value) return null;
131
+ if (value.startsWith("file://")) try {
132
+ return fileURLToPath(value);
133
+ } catch {
134
+ return value;
135
+ }
136
+ return value;
48
137
  };
49
138
  const isFnsPath = (importPath) => {
50
- const normalized = importPath.replace(/\\/g, '/');
51
- const isFnsPackagePath = /(^|\/)(@jesscss\/fns|packages\/fns)(\/|$)/.test(normalized);
52
- return (normalized === '@jesscss/fns'
53
- || normalized.startsWith('@jesscss/fns/')
54
- || normalized === '#less'
55
- || normalized.startsWith('#less/')
56
- || normalized === '#sass'
57
- || normalized.startsWith('#sass/')
58
- || isFnsPackagePath);
139
+ const normalized = importPath.replace(/\\/g, "/");
140
+ const isFnsPackagePath = /(^|\/)(@jesscss\/fns|packages\/fns)(\/|$)/.test(normalized);
141
+ return normalized === "@jesscss/fns" || normalized.startsWith("@jesscss/fns/") || normalized === "#less" || normalized.startsWith("#less/") || normalized === "#sass" || normalized.startsWith("#sass/") || isFnsPackagePath;
59
142
  };
60
143
  const isJsonValue = (value) => {
61
- try {
62
- JSON.stringify(value);
63
- return true;
64
- }
65
- catch {
66
- return false;
67
- }
144
+ try {
145
+ JSON.stringify(value);
146
+ return true;
147
+ } catch {
148
+ return false;
149
+ }
150
+ };
151
+ var JsPlugin = class JsPlugin extends AbstractPlugin {
152
+ static cleanupRegistered = false;
153
+ static liveInstances = /* @__PURE__ */ new Set();
154
+ name = "js";
155
+ supportedExtensions = Array.from(SCRIPT_EXTENSIONS);
156
+ runtimeState = { status: "idle" };
157
+ shuttingDown = false;
158
+ brokerServer;
159
+ brokerSocketPath;
160
+ worker;
161
+ workerBuffer = "";
162
+ nextRequestId = 1;
163
+ pending = /* @__PURE__ */ new Map();
164
+ idleTimer;
165
+ constructor(opts = {}) {
166
+ super();
167
+ this.opts = opts;
168
+ JsPlugin.liveInstances.add(this);
169
+ JsPlugin.registerProcessCleanup();
170
+ }
171
+ prewarm() {
172
+ return this.ensureRuntime().catch(() => void 0);
173
+ }
174
+ static registerProcessCleanup() {
175
+ if (JsPlugin.cleanupRegistered) return;
176
+ JsPlugin.cleanupRegistered = true;
177
+ const cleanup = () => {
178
+ for (const instance of JsPlugin.liveInstances) try {
179
+ instance.dispose();
180
+ } catch {}
181
+ };
182
+ process.once("beforeExit", cleanup);
183
+ process.once("exit", cleanup);
184
+ }
185
+ clearIdleTimer() {
186
+ if (this.idleTimer) {
187
+ clearTimeout(this.idleTimer);
188
+ this.idleTimer = void 0;
189
+ }
190
+ }
191
+ scheduleIdleShutdown() {
192
+ this.clearIdleTimer();
193
+ if (this.pending.size > 0 || this.runtimeState.status !== "ready") return;
194
+ this.idleTimer = setTimeout(() => {
195
+ this.shutdown();
196
+ this.runtimeState = { status: "idle" };
197
+ }, IDLE_SHUTDOWN_MS);
198
+ this.idleTimer.unref?.();
199
+ }
200
+ ensureRuntimeAvailable() {
201
+ if (spawnSync(this.opts.denoCommand ?? "deno", ["--version"], { stdio: "ignore" }).status !== 0) throw new Error(RUNTIME_MISSING_MESSAGE);
202
+ }
203
+ ensureRuntime() {
204
+ if (this.runtimeState.status === "ready") {
205
+ this.clearIdleTimer();
206
+ return Promise.resolve();
207
+ }
208
+ if (this.runtimeState.status === "initializing") return this.runtimeState.promise;
209
+ if (this.runtimeState.status === "failed") return Promise.reject(this.runtimeState.error);
210
+ if (this.runtimeState.status === "disposed") return Promise.reject(/* @__PURE__ */ new Error("Deno worker has been disposed."));
211
+ const promise = this.startRuntime().then(() => {
212
+ this.runtimeState = { status: "ready" };
213
+ this.scheduleIdleShutdown();
214
+ }, (err) => {
215
+ this.runtimeState = {
216
+ status: "failed",
217
+ error: err
218
+ };
219
+ throw err;
220
+ });
221
+ this.runtimeState = {
222
+ status: "initializing",
223
+ promise
224
+ };
225
+ return promise;
226
+ }
227
+ createBrokerPath() {
228
+ if (process.platform === "win32") return `\\\\.\\pipe\\jess-deno-broker-${`${process.pid}-${Date.now()}-${Math.floor(Math.random() * 1e4)}`}`;
229
+ const rand = Math.floor(Math.random() * 1e4);
230
+ return `/tmp/jd-${process.pid}-${Date.now()}-${rand}.sock`;
231
+ }
232
+ isReadAllowed(value) {
233
+ const normalized = normalizePermissionPath(value);
234
+ if (!normalized) return false;
235
+ const requestedPath = canonicalPath(path.resolve(normalized));
236
+ const jsReadRoot = this.opts.jsReadRoot ? canonicalPath(path.resolve(this.opts.jsReadRoot)) : void 0;
237
+ if (jsReadRoot && isPathInside(requestedPath, jsReadRoot)) return true;
238
+ return requestedPath.includes(`${path.sep}node_modules${path.sep}`);
239
+ }
240
+ isNetAllowed(value) {
241
+ if (!this.opts.allowHttp) return false;
242
+ const allowHosts = this.opts.allowNetHosts ?? [];
243
+ if (allowHosts.length === 0) return true;
244
+ if (!value) return false;
245
+ const host = value.split(":")[0] ?? value;
246
+ return allowHosts.includes(host);
247
+ }
248
+ handleBrokerRequest(request) {
249
+ const deny = (reason) => ({
250
+ id: request.id,
251
+ result: "deny",
252
+ reason
253
+ });
254
+ switch (request.permission) {
255
+ case "read": return this.isReadAllowed(request.value) ? {
256
+ id: request.id,
257
+ result: "allow"
258
+ } : deny("Read access denied by Jess policy.");
259
+ case "net": return this.isNetAllowed(request.value) ? {
260
+ id: request.id,
261
+ result: "allow"
262
+ } : deny("Network access denied by Jess policy.");
263
+ case "env":
264
+ case "run":
265
+ case "ffi":
266
+ case "sys":
267
+ case "write": return deny(`${request.permission} permission denied by Jess policy.`);
268
+ default: return deny(`Permission "${request.permission}" denied by Jess policy.`);
269
+ }
270
+ }
271
+ async startBroker() {
272
+ const socketPath = this.createBrokerPath();
273
+ if (process.platform !== "win32" && fs.existsSync(socketPath)) fs.unlinkSync(socketPath);
274
+ const server = net.createServer((socket) => {
275
+ socket.unref();
276
+ let buf = "";
277
+ socket.setEncoding("utf8");
278
+ socket.on("data", (chunk) => {
279
+ buf += chunk;
280
+ let idx = buf.indexOf("\n");
281
+ while (idx >= 0) {
282
+ const line = buf.slice(0, idx).trim();
283
+ buf = buf.slice(idx + 1);
284
+ idx = buf.indexOf("\n");
285
+ if (!line) continue;
286
+ let req;
287
+ try {
288
+ req = JSON.parse(line);
289
+ } catch {
290
+ socket.write(JSON.stringify({
291
+ id: -1,
292
+ result: "deny",
293
+ reason: "Malformed permission request."
294
+ }) + "\n");
295
+ continue;
296
+ }
297
+ const response = this.handleBrokerRequest(req);
298
+ socket.write(`${JSON.stringify(response)}\n`);
299
+ }
300
+ });
301
+ });
302
+ await new Promise((resolve, reject) => {
303
+ server.once("error", reject);
304
+ server.listen(socketPath, () => resolve());
305
+ });
306
+ server.unref();
307
+ this.brokerServer = server;
308
+ this.brokerSocketPath = socketPath;
309
+ return socketPath;
310
+ }
311
+ startWorker(socketPath) {
312
+ const denoCommand = this.opts.denoCommand ?? "deno";
313
+ const moduleDir = path.dirname(fileURLToPath(import.meta.url));
314
+ const compiledWorkerPath = path.join(moduleDir, "runtime-worker.js");
315
+ const sourceWorkerPath = path.join(moduleDir, "runtime-worker.ts");
316
+ const child = spawn(denoCommand, [
317
+ "run",
318
+ "--no-prompt",
319
+ fs.existsSync(compiledWorkerPath) ? compiledWorkerPath : sourceWorkerPath,
320
+ `--runtime-api=${this.opts.runtimeApi ?? "module"}`
321
+ ], {
322
+ stdio: [
323
+ "pipe",
324
+ "pipe",
325
+ "pipe"
326
+ ],
327
+ env: {
328
+ ...process.env,
329
+ DENO_PERMISSION_BROKER_PATH: socketPath
330
+ }
331
+ });
332
+ this.worker = child;
333
+ child.unref();
334
+ child.stdin.unref?.();
335
+ child.stdout.unref?.();
336
+ child.stderr.unref?.();
337
+ child.stdout.setEncoding("utf8");
338
+ child.stderr.setEncoding("utf8");
339
+ child.stdout.on("data", (chunk) => this.onWorkerStdout(chunk));
340
+ child.on("exit", () => {
341
+ this.worker = void 0;
342
+ if (this.shuttingDown) {
343
+ this.shuttingDown = false;
344
+ return;
345
+ }
346
+ const err = /* @__PURE__ */ new Error("Deno worker exited unexpectedly.");
347
+ this.rejectAllPending(err);
348
+ if (this.runtimeState.status !== "failed") this.runtimeState = {
349
+ status: "failed",
350
+ error: err
351
+ };
352
+ });
353
+ return new Promise((resolve, reject) => {
354
+ let stderrText = "";
355
+ const timer = setTimeout(() => {
356
+ reject(/* @__PURE__ */ new Error(stderrText.trim() ? `Timed out waiting for Deno worker startup.\n${stderrText.trim()}` : "Timed out waiting for Deno worker startup."));
357
+ }, BOOT_TIMEOUT_MS);
358
+ const onStderr = (chunk) => {
359
+ stderrText += chunk;
360
+ };
361
+ const onData = (chunk) => {
362
+ this.workerBuffer += chunk;
363
+ let idx = this.workerBuffer.indexOf("\n");
364
+ while (idx >= 0) {
365
+ const line = this.workerBuffer.slice(0, idx).trim();
366
+ this.workerBuffer = this.workerBuffer.slice(idx + 1);
367
+ idx = this.workerBuffer.indexOf("\n");
368
+ if (!line) continue;
369
+ try {
370
+ if (JSON.parse(line).type === "ready") {
371
+ clearTimeout(timer);
372
+ child.stdout.off("data", onData);
373
+ child.stderr.off("data", onStderr);
374
+ resolve();
375
+ return;
376
+ }
377
+ } catch {}
378
+ }
379
+ };
380
+ child.stdout.on("data", onData);
381
+ child.stderr.on("data", onStderr);
382
+ child.once("error", (err) => {
383
+ clearTimeout(timer);
384
+ child.stdout.off("data", onData);
385
+ child.stderr.off("data", onStderr);
386
+ reject(err);
387
+ });
388
+ });
389
+ }
390
+ async startRuntime() {
391
+ this.ensureRuntimeAvailable();
392
+ const socketPath = await this.startBroker();
393
+ try {
394
+ await this.startWorker(socketPath);
395
+ } catch (err) {
396
+ this.shutdown();
397
+ throw err instanceof Error ? err : new Error(String(err));
398
+ }
399
+ }
400
+ onWorkerStdout(chunk) {
401
+ this.workerBuffer += chunk;
402
+ let idx = this.workerBuffer.indexOf("\n");
403
+ while (idx >= 0) {
404
+ const line = this.workerBuffer.slice(0, idx).trim();
405
+ this.workerBuffer = this.workerBuffer.slice(idx + 1);
406
+ idx = this.workerBuffer.indexOf("\n");
407
+ if (!line) continue;
408
+ let parsed;
409
+ try {
410
+ parsed = JSON.parse(line);
411
+ } catch {
412
+ continue;
413
+ }
414
+ if (!parsed || typeof parsed.id !== "number") continue;
415
+ const pending = this.pending.get(parsed.id);
416
+ if (!pending) continue;
417
+ this.pending.delete(parsed.id);
418
+ clearTimeout(pending.timeout);
419
+ pending.resolve(parsed);
420
+ if (this.pending.size === 0) this.scheduleIdleShutdown();
421
+ }
422
+ }
423
+ rejectAllPending(err) {
424
+ for (const pending of this.pending.values()) {
425
+ clearTimeout(pending.timeout);
426
+ pending.reject(err);
427
+ }
428
+ this.pending.clear();
429
+ }
430
+ async callWorker(request) {
431
+ await this.ensureRuntime();
432
+ this.clearIdleTimer();
433
+ if (!this.worker || !this.worker.stdin.writable) throw new Error("Deno worker is not available.");
434
+ const id = this.nextRequestId++;
435
+ const payload = {
436
+ ...request,
437
+ id
438
+ };
439
+ return await new Promise((resolve, reject) => {
440
+ const timeout = setTimeout(() => {
441
+ this.pending.delete(id);
442
+ reject(/* @__PURE__ */ new Error("Timed out waiting for Deno worker response."));
443
+ }, REQUEST_TIMEOUT_MS);
444
+ this.pending.set(id, {
445
+ resolve,
446
+ reject,
447
+ timeout
448
+ });
449
+ this.worker.stdin.write(`${JSON.stringify(payload)}\n`);
450
+ });
451
+ }
452
+ shutdown() {
453
+ this.clearIdleTimer();
454
+ if (this.worker && !this.worker.killed) {
455
+ this.shuttingDown = true;
456
+ this.worker.stdin.destroy();
457
+ this.worker.stdout.destroy();
458
+ this.worker.stderr.destroy();
459
+ this.worker.kill();
460
+ }
461
+ this.worker = void 0;
462
+ if (this.brokerServer) {
463
+ this.brokerServer.close();
464
+ this.brokerServer = void 0;
465
+ }
466
+ if (this.brokerSocketPath && process.platform !== "win32") try {
467
+ if (fs.existsSync(this.brokerSocketPath)) fs.unlinkSync(this.brokerSocketPath);
468
+ } catch {}
469
+ this.brokerSocketPath = void 0;
470
+ }
471
+ dispose() {
472
+ this.shutdown();
473
+ JsPlugin.liveInstances.delete(this);
474
+ this.runtimeState = { status: "disposed" };
475
+ }
476
+ assertAllowedPath(absoluteFilePath) {
477
+ const resolvedPath = path.resolve(absoluteFilePath);
478
+ const jsReadRoot = this.opts.jsReadRoot ? path.resolve(this.opts.jsReadRoot) : void 0;
479
+ if (!jsReadRoot) return;
480
+ if (isPathInside(resolvedPath, jsReadRoot)) return;
481
+ if (resolvedPath.includes(`${path.sep}node_modules${path.sep}`)) return;
482
+ throw new Error(`Script path "${resolvedPath}" is outside jsReadRoot "${jsReadRoot}"`);
483
+ }
484
+ async import(absoluteFilePath) {
485
+ const ext = path.extname(absoluteFilePath);
486
+ if (!SCRIPT_EXTENSIONS.has(ext)) throw new Error(`Plugin "${this.name}" cannot import "${absoluteFilePath}"`);
487
+ if (!isFnsPath(absoluteFilePath)) {
488
+ this.assertAllowedPath(absoluteFilePath);
489
+ await this.ensureRuntime();
490
+ const modulePath = path.resolve(absoluteFilePath);
491
+ const loadResult = await this.callWorker({
492
+ type: "load",
493
+ modulePath
494
+ });
495
+ if (!loadResult.ok) throw new Error(loadResult.error);
496
+ const moduleObject = {};
497
+ const exported = loadResult.exports ?? [];
498
+ for (const item of exported) if (item.kind === "function") moduleObject[item.name] = async (...args) => {
499
+ const invokeResult = await this.callWorker({
500
+ type: "invoke",
501
+ modulePath,
502
+ exportName: item.name,
503
+ args: encodeBridgeArgs(args)
504
+ });
505
+ if (!invokeResult.ok) throw new Error(invokeResult.error);
506
+ return decodeBridgeValue(invokeResult.value);
507
+ };
508
+ else moduleObject[item.name] = item.value;
509
+ return moduleObject;
510
+ }
511
+ const module = await import(pathToFileURL(path.resolve(absoluteFilePath)).href);
512
+ const safeModule = {};
513
+ for (const [key, value] of Object.entries(module)) if (typeof value === "function" || isJsonValue(value)) safeModule[key] = value;
514
+ return safeModule;
515
+ }
516
+ /**
517
+ * Loads a legacy Less `@plugin` wrapper file in Deno Less-compat mode.
518
+ *
519
+ * @deprecated Less `@plugin` is deprecated. Prefer `@-from` for
520
+ * ESM-style script imports or `@-use` for Sass-module-style namespace imports.
521
+ */
522
+ async importLessPlugin(absoluteFilePath) {
523
+ const ext = path.extname(absoluteFilePath);
524
+ if (!SCRIPT_EXTENSIONS.has(ext)) throw new Error(`Plugin "${this.name}" cannot import Less plugin "${absoluteFilePath}"`);
525
+ this.assertAllowedPath(absoluteFilePath);
526
+ await this.ensureRuntime();
527
+ const modulePath = path.resolve(absoluteFilePath);
528
+ const loadResult = await this.callWorker({
529
+ type: "loadLessPlugin",
530
+ modulePath
531
+ });
532
+ if (!loadResult.ok) throw new Error(loadResult.error);
533
+ const functions = {};
534
+ for (const functionName of loadResult.functions ?? []) functions[functionName] = async (...args) => {
535
+ const invokeResult = await this.callWorker({
536
+ type: "invokeLessPluginFunction",
537
+ modulePath,
538
+ functionName,
539
+ args: encodeBridgeArgs(args)
540
+ });
541
+ if (!invokeResult.ok) throw new Error(invokeResult.error);
542
+ return decodeBridgeValue(invokeResult.value);
543
+ };
544
+ return { functions };
545
+ }
68
546
  };
69
- export class JsPlugin extends AbstractPlugin {
70
- opts;
71
- name = 'js';
72
- supportedExtensions = Array.from(SCRIPT_EXTENSIONS);
73
- runtimeState = { status: 'idle' };
74
- brokerServer;
75
- brokerSocketPath;
76
- worker;
77
- workerBuffer = '';
78
- nextRequestId = 1;
79
- pending = new Map();
80
- constructor(opts = {}) {
81
- super();
82
- this.opts = opts;
83
- }
84
- prewarm() {
85
- return this.ensureRuntime().catch(() => undefined);
86
- }
87
- ensureRuntimeAvailable() {
88
- const denoCommand = this.opts.denoCommand ?? 'deno';
89
- const result = spawnSync(denoCommand, ['--version'], {
90
- stdio: 'ignore'
91
- });
92
- if (result.status !== 0) {
93
- throw new Error(RUNTIME_MISSING_MESSAGE);
94
- }
95
- }
96
- ensureRuntime() {
97
- if (this.runtimeState.status === 'ready') {
98
- return Promise.resolve();
99
- }
100
- if (this.runtimeState.status === 'initializing') {
101
- return this.runtimeState.promise;
102
- }
103
- if (this.runtimeState.status === 'failed') {
104
- return Promise.reject(this.runtimeState.error);
105
- }
106
- const promise = this.startRuntime().then(() => {
107
- this.runtimeState = { status: 'ready' };
108
- }, (err) => {
109
- this.runtimeState = { status: 'failed', error: err };
110
- throw err;
111
- });
112
- this.runtimeState = { status: 'initializing', promise };
113
- return promise;
114
- }
115
- createBrokerPath() {
116
- if (process.platform === 'win32') {
117
- const rand = `${process.pid}-${Date.now()}-${Math.floor(Math.random() * 10000)}`;
118
- return `\\\\.\\pipe\\jess-deno-broker-${rand}`;
119
- }
120
- // macOS temp dir paths can exceed unix socket limits. Keep this short.
121
- const rand = Math.floor(Math.random() * 10000);
122
- return `/tmp/jd-${process.pid}-${Date.now()}-${rand}.sock`;
123
- }
124
- isReadAllowed(value) {
125
- const normalized = normalizePermissionPath(value);
126
- if (!normalized) {
127
- return false;
128
- }
129
- const requestedPath = canonicalPath(path.resolve(normalized));
130
- const jsReadRoot = this.opts.jsReadRoot ? canonicalPath(path.resolve(this.opts.jsReadRoot)) : undefined;
131
- if (jsReadRoot && isPathInside(requestedPath, jsReadRoot)) {
132
- return true;
133
- }
134
- return requestedPath.includes(`${path.sep}node_modules${path.sep}`);
135
- }
136
- isNetAllowed(value) {
137
- if (!this.opts.allowHttp) {
138
- return false;
139
- }
140
- const allowHosts = this.opts.allowNetHosts ?? [];
141
- if (allowHosts.length === 0) {
142
- return true;
143
- }
144
- if (!value) {
145
- return false;
146
- }
147
- const host = value.split(':')[0] ?? value;
148
- return allowHosts.includes(host);
149
- }
150
- handleBrokerRequest(request) {
151
- const deny = (reason) => ({
152
- id: request.id,
153
- result: 'deny',
154
- reason
155
- });
156
- switch (request.permission) {
157
- case 'read':
158
- return this.isReadAllowed(request.value)
159
- ? { id: request.id, result: 'allow' }
160
- : deny('Read access denied by Jess policy.');
161
- case 'net':
162
- return this.isNetAllowed(request.value)
163
- ? { id: request.id, result: 'allow' }
164
- : deny('Network access denied by Jess policy.');
165
- case 'env':
166
- case 'run':
167
- case 'ffi':
168
- case 'sys':
169
- case 'write':
170
- return deny(`${request.permission} permission denied by Jess policy.`);
171
- default:
172
- return deny(`Permission "${request.permission}" denied by Jess policy.`);
173
- }
174
- }
175
- async startBroker() {
176
- const socketPath = this.createBrokerPath();
177
- if (process.platform !== 'win32' && fs.existsSync(socketPath)) {
178
- fs.unlinkSync(socketPath);
179
- }
180
- const server = net.createServer((socket) => {
181
- let buf = '';
182
- socket.setEncoding('utf8');
183
- socket.on('data', (chunk) => {
184
- buf += chunk;
185
- let idx = buf.indexOf('\n');
186
- while (idx >= 0) {
187
- const line = buf.slice(0, idx).trim();
188
- buf = buf.slice(idx + 1);
189
- idx = buf.indexOf('\n');
190
- if (!line) {
191
- continue;
192
- }
193
- let req;
194
- try {
195
- req = JSON.parse(line);
196
- }
197
- catch {
198
- socket.write(JSON.stringify({
199
- id: -1,
200
- result: 'deny',
201
- reason: 'Malformed permission request.'
202
- }) + '\n');
203
- continue;
204
- }
205
- const response = this.handleBrokerRequest(req);
206
- socket.write(`${JSON.stringify(response)}\n`);
207
- }
208
- });
209
- });
210
- await new Promise((resolve, reject) => {
211
- server.once('error', reject);
212
- server.listen(socketPath, () => resolve());
213
- });
214
- this.brokerServer = server;
215
- this.brokerSocketPath = socketPath;
216
- return socketPath;
217
- }
218
- startWorker(socketPath) {
219
- const denoCommand = this.opts.denoCommand ?? 'deno';
220
- const moduleDir = path.dirname(fileURLToPath(import.meta.url));
221
- const compiledWorkerPath = path.join(moduleDir, 'runtime-worker.js');
222
- const sourceWorkerPath = path.join(moduleDir, 'runtime-worker.ts');
223
- const workerScriptPath = fs.existsSync(compiledWorkerPath)
224
- ? compiledWorkerPath
225
- : sourceWorkerPath;
226
- const child = spawn(denoCommand, ['run', '--no-prompt', workerScriptPath], {
227
- stdio: ['pipe', 'pipe', 'pipe'],
228
- env: {
229
- ...process.env,
230
- DENO_PERMISSION_BROKER_PATH: socketPath
231
- }
232
- });
233
- this.worker = child;
234
- child.stdout.setEncoding('utf8');
235
- child.stderr.setEncoding('utf8');
236
- child.stdout.on('data', chunk => this.onWorkerStdout(chunk));
237
- child.on('exit', () => {
238
- const err = new Error('Deno worker exited unexpectedly.');
239
- this.rejectAllPending(err);
240
- if (this.runtimeState.status !== 'failed') {
241
- this.runtimeState = { status: 'failed', error: err };
242
- }
243
- });
244
- return new Promise((resolve, reject) => {
245
- const timer = setTimeout(() => {
246
- reject(new Error('Timed out waiting for Deno worker startup.'));
247
- }, BOOT_TIMEOUT_MS);
248
- const onData = (chunk) => {
249
- this.workerBuffer += chunk;
250
- let idx = this.workerBuffer.indexOf('\n');
251
- while (idx >= 0) {
252
- const line = this.workerBuffer.slice(0, idx).trim();
253
- this.workerBuffer = this.workerBuffer.slice(idx + 1);
254
- idx = this.workerBuffer.indexOf('\n');
255
- if (!line) {
256
- continue;
257
- }
258
- try {
259
- const parsed = JSON.parse(line);
260
- if (parsed.type === 'ready') {
261
- clearTimeout(timer);
262
- child.stdout.off('data', onData);
263
- resolve();
264
- return;
265
- }
266
- }
267
- catch {
268
- // ignore until ready payload appears
269
- }
270
- }
271
- };
272
- child.stdout.on('data', onData);
273
- child.once('error', (err) => {
274
- clearTimeout(timer);
275
- child.stdout.off('data', onData);
276
- reject(err);
277
- });
278
- });
279
- }
280
- async startRuntime() {
281
- this.ensureRuntimeAvailable();
282
- const socketPath = await this.startBroker();
283
- try {
284
- await this.startWorker(socketPath);
285
- }
286
- catch (err) {
287
- this.shutdown();
288
- throw err instanceof Error ? err : new Error(String(err));
289
- }
290
- }
291
- onWorkerStdout(chunk) {
292
- this.workerBuffer += chunk;
293
- let idx = this.workerBuffer.indexOf('\n');
294
- while (idx >= 0) {
295
- const line = this.workerBuffer.slice(0, idx).trim();
296
- this.workerBuffer = this.workerBuffer.slice(idx + 1);
297
- idx = this.workerBuffer.indexOf('\n');
298
- if (!line) {
299
- continue;
300
- }
301
- let parsed;
302
- try {
303
- parsed = JSON.parse(line);
304
- }
305
- catch {
306
- continue;
307
- }
308
- if (!parsed || typeof parsed.id !== 'number') {
309
- continue;
310
- }
311
- const pending = this.pending.get(parsed.id);
312
- if (!pending) {
313
- continue;
314
- }
315
- this.pending.delete(parsed.id);
316
- clearTimeout(pending.timeout);
317
- pending.resolve(parsed);
318
- }
319
- }
320
- rejectAllPending(err) {
321
- for (const pending of this.pending.values()) {
322
- clearTimeout(pending.timeout);
323
- pending.reject(err);
324
- }
325
- this.pending.clear();
326
- }
327
- async callWorker(request) {
328
- await this.ensureRuntime();
329
- if (!this.worker || !this.worker.stdin.writable) {
330
- throw new Error('Deno worker is not available.');
331
- }
332
- const id = this.nextRequestId++;
333
- const payload = { ...request, id };
334
- return await new Promise((resolve, reject) => {
335
- const timeout = setTimeout(() => {
336
- this.pending.delete(id);
337
- reject(new Error('Timed out waiting for Deno worker response.'));
338
- }, REQUEST_TIMEOUT_MS);
339
- this.pending.set(id, { resolve, reject, timeout });
340
- this.worker.stdin.write(`${JSON.stringify(payload)}\n`);
341
- });
342
- }
343
- shutdown() {
344
- if (this.worker && !this.worker.killed) {
345
- this.worker.kill();
346
- }
347
- this.worker = undefined;
348
- if (this.brokerServer) {
349
- this.brokerServer.close();
350
- this.brokerServer = undefined;
351
- }
352
- if (this.brokerSocketPath && process.platform !== 'win32') {
353
- try {
354
- if (fs.existsSync(this.brokerSocketPath)) {
355
- fs.unlinkSync(this.brokerSocketPath);
356
- }
357
- }
358
- catch {
359
- // ignore
360
- }
361
- }
362
- this.brokerSocketPath = undefined;
363
- }
364
- dispose() {
365
- this.shutdown();
366
- this.runtimeState = { status: 'idle' };
367
- }
368
- assertAllowedPath(absoluteFilePath) {
369
- const resolvedPath = path.resolve(absoluteFilePath);
370
- const jsReadRoot = this.opts.jsReadRoot ? path.resolve(this.opts.jsReadRoot) : undefined;
371
- if (!jsReadRoot) {
372
- return;
373
- }
374
- if (isPathInside(resolvedPath, jsReadRoot)) {
375
- return;
376
- }
377
- // pnpm layouts may resolve package files outside project root.
378
- if (resolvedPath.includes(`${path.sep}node_modules${path.sep}`)) {
379
- return;
380
- }
381
- throw new Error(`Script path "${resolvedPath}" is outside jsReadRoot "${jsReadRoot}"`);
382
- }
383
- async import(absoluteFilePath) {
384
- const ext = path.extname(absoluteFilePath);
385
- if (!SCRIPT_EXTENSIONS.has(ext)) {
386
- throw new Error(`Plugin "${this.name}" cannot import "${absoluteFilePath}"`);
387
- }
388
- if (!isFnsPath(absoluteFilePath)) {
389
- this.assertAllowedPath(absoluteFilePath);
390
- await this.ensureRuntime();
391
- const modulePath = path.resolve(absoluteFilePath);
392
- const loadResult = await this.callWorker({ type: 'load', modulePath });
393
- if (!loadResult.ok) {
394
- throw new Error(loadResult.error);
395
- }
396
- const moduleObject = {};
397
- const exported = loadResult.exports ?? [];
398
- for (const item of exported) {
399
- if (item.kind === 'function') {
400
- moduleObject[item.name] = async (...args) => {
401
- const invokeResult = await this.callWorker({
402
- type: 'invoke',
403
- modulePath,
404
- exportName: item.name,
405
- args
406
- });
407
- if (!invokeResult.ok) {
408
- throw new Error(invokeResult.error);
409
- }
410
- return invokeResult.value;
411
- };
412
- }
413
- else {
414
- moduleObject[item.name] = item.value;
415
- }
416
- }
417
- return moduleObject;
418
- }
419
- const modulePath = pathToFileURL(path.resolve(absoluteFilePath)).href;
420
- const module = await import(modulePath);
421
- const safeModule = {};
422
- for (const [key, value] of Object.entries(module)) {
423
- if (typeof value === 'function' || isJsonValue(value)) {
424
- safeModule[key] = value;
425
- }
426
- }
427
- return safeModule;
428
- }
429
- }
430
547
  /**
431
- * Global flag set when @jesscss/plugin-js is loaded.
432
- * less-compat checks this before running @plugin scripts (scripts must be run when plugin-js/Deno is present).
433
- */
434
- export const JESS_PLUGIN_JS_GLOBAL = '__JESS_PLUGIN_JS_AVAILABLE__';
435
- if (typeof globalThis !== 'undefined') {
436
- globalThis[JESS_PLUGIN_JS_GLOBAL] = true;
437
- }
548
+ * Global flag set when @jesscss/plugin-js is loaded.
549
+ * less-compat checks this before running @plugin scripts (scripts must be run when plugin-js/Deno is present).
550
+ */
551
+ const JESS_PLUGIN_JS_GLOBAL = "__JESS_PLUGIN_JS_AVAILABLE__";
552
+ if (typeof globalThis !== "undefined") globalThis[JESS_PLUGIN_JS_GLOBAL] = true;
438
553
  const jsPlugin = ((opts) => {
439
- return new JsPlugin(opts);
554
+ return new JsPlugin(opts);
440
555
  });
441
- export default jsPlugin;
442
- //# sourceMappingURL=index.js.map
556
+ //#endregion
557
+ export { JESS_PLUGIN_JS_GLOBAL, JsPlugin, jsPlugin as default };