Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ Executes JavaScript, TypeScript Scripts.

## Changelog
### **WORK IN PROGRESS**
* (@GermanBluefox) Fixed: a script that did not handle a rejected `setStateAsync` (e.g. of a missing state) or another asynchronous function terminated the adapter
* (@agross) Fixed: TypeScript scripts that import the same module as a global script failed with "Identifier has already been declared"
* (@GermanBluefox) Fixed: the @-mention list of the AI chat showed only the first 13 scripts
* (@GermanBluefox) Fixed: selection of state if predefined

Expand Down
31 changes: 31 additions & 0 deletions build/lib/typescriptTools.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion build/lib/typescriptTools.js.map

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions build/main.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion build/main.js.map

Large diffs are not rendered by default.

33 changes: 33 additions & 0 deletions src/lib/typescriptTools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -687,6 +687,39 @@ export function transformGlobalDeclarations(decl: string): string {
return createPrinter(PRINTER_OPTIONS).printNode(EmitHint.Unspecified, result.transformed[0], sourceFile);
}

/**
* Isolates the module-local bindings emitted by TypeScript while retaining access to globals
* provided by ioBroker's shared scripts. Calling the wrapper with `exports` also matches the
* top-level `this` value a CommonJS module normally receives.
*
* The compiled source starts on the wrapper's first line so existing line-number correction
* remains valid.
*
* @param source The JavaScript emitted for a non-global TypeScript script
*/
export function wrapCompiledScriptInCommonJsScope(source: string): string {
return `(function () {${source}\n}).call(exports);`;
}

/**
* Isolates one compiled global TypeScript script from all other compiled scripts and publishes
* its transformed exports as ioBroker globals. Accessors preserve updates to exported variables.
*
* The wrapper only occupies the first line of the source and the line after it - the one that is
* empty without the wrapper -, so the line numbers in the combined global scripts stay the same.
*
* @param source The JavaScript emitted for a global TypeScript script
*/
export function wrapCompiledGlobalScriptInCommonJsScope(source: string): string {
// TypeScript ends its output with a new line; without one, a trailing line comment would swallow the wrapper
const body = source.endsWith('\n') ? source : `${source}\n`;
return (
`(function () { const scriptExports = {}; (function (exports) {${body}` +
'}).call(globalThis, scriptExports); for (const key of Object.keys(scriptExports)) { if (key === "__esModule") continue; ' +
'Object.defineProperty(globalThis, key, { configurable: true, enumerable: true, get: () => scriptExports[key], set: value => { scriptExports[key] = value; } }); } }).call(globalThis);'
);
}

/**
* Translates a script ID to a filename for the compiler
*
Expand Down
16 changes: 13 additions & 3 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ import {
scriptIdToTSFilename,
transformScriptBeforeCompilation,
transformGlobalDeclarations,
wrapCompiledGlobalScriptInCommonJsScope,
wrapCompiledScriptInCommonJsScope,
} from './lib/typescriptTools';
import type {
FileSubscriptionResult,
Expand Down Expand Up @@ -2204,7 +2206,7 @@ class JavaScript extends Adapter {
continue;
}
}
this.globalScript += `${compiled}\n`;
this.globalScript += `${wrapCompiledGlobalScriptInCommonJsScope(compiled || '')}\n`;
// if declarations were generated, remember them
if (declarations != null) {
this.provideDeclarationsForGlobalScript(obj._id, declarations);
Expand Down Expand Up @@ -3247,7 +3249,11 @@ class JavaScript extends Adapter {
const errors = tsCompiled.diagnostics.map(diag => diag.annotatedSource).join('\n');
return empty(`TypeScript compilation failed:\n${errors}`);
}
createdScript = this.createVM(`${this.globalScript}\n${tsCompiled.result || ''}`, name, false);
createdScript = this.createVM(
`${this.globalScript}\n${wrapCompiledScriptInCommonJsScope(tsCompiled.result || '')}`,
name,
false,
);
} else {
createdScript = this.createVM(`${this.globalScript}\n${source}`, name, true);
}
Expand Down Expand Up @@ -3714,7 +3720,11 @@ class JavaScript extends Adapter {
return false;
}
}
const createdScript: JsScript | false = this.createVM(`${this.globalScript}\n${compiled}`, name, false);
const createdScript: JsScript | false = this.createVM(
`${this.globalScript}\n${wrapCompiledScriptInCommonJsScope(compiled)}`,
name,
false,
);
if (!createdScript) {
return false;
}
Expand Down
83 changes: 83 additions & 0 deletions test/testTypeScript.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
const tsc = require('virtual-tsc');
const fs = require('node:fs');
const path = require('node:path');
const { Script } = require('node:vm');
const { tsCompilerOptions } = require('../build/lib/typescriptSettings');

const assert = require('node:assert').strict;
const {
scriptIdToTSFilename,
transformScriptBeforeCompilation,
transformGlobalDeclarations,
wrapCompiledGlobalScriptInCommonJsScope,
wrapCompiledScriptInCommonJsScope,
} = require('../build/lib/typescriptTools');

describe('TypeScript tools', () => {
Expand Down Expand Up @@ -143,6 +146,86 @@ export {};`
});
});

/**
* Regression tests for https://github.com/ioBroker/ioBroker.javascript/issues/2341
*
* The global scripts and the script itself run as one `vm.Script`. TypeScript emits the same binding
* (`const fixturelib_1 = require("fixturelib")`) for every script that imports the same module, so
* without a scope per script the second one fails with "Identifier has already been declared".
*/
describe('Scope isolation of compiled TypeScript scripts', () => {
const tsServer = new tsc.Server({ ...tsCompilerOptions, strict: true }, undefined);
tsServer.provideAmbientDeclarations({
'fixturelib.d.ts': `declare module "fixturelib" {
export class Observable<T> {
constructor(subscribe: (observer: { next(value: T): void }) => void);
}
}`,
});

const IMPORT = `import { Observable } from "fixturelib";
new Observable<string>(observer => observer.next("ok"));`;

function compile(filename, source, isGlobal) {
const compiled = tsServer.compile(filename, transformScriptBeforeCompilation(source, isGlobal));
assert.equal(compiled.success, true, compiled.diagnostics.map(d => d.description).join('\n'));
return compiled.result;
}

function run(code) {
const context = {
exports: {},
require: () => ({
Observable: class {
constructor(subscribe) {
subscribe({ next: () => undefined });
}
},
}),
};
new Script(code).runInNewContext(context);
return context;
}

it('fails without isolation (reproduces the issue)', () => {
const globalCompiled = compile('global.ts', IMPORT, true);
const localCompiled = compile('local.ts', IMPORT, false);
assert.throws(() => new Script(`${globalCompiled}\n${localCompiled}`), /already been declared/);
}).timeout(20000);

it('lets a global and a normal script import the same module', () => {
const globalCompiled = compile('global.ts', `${IMPORT}\nfunction fromGlobal() { return "global"; }`, true);
const localCompiled = compile('local.ts', `${IMPORT}\ndeclare function fromGlobal(): string;\ndeclare let result: string;\nresult = fromGlobal();`, false);

const context = run(
`var result;\n${wrapCompiledGlobalScriptInCommonJsScope(globalCompiled)}\n${wrapCompiledScriptInCommonJsScope(localCompiled)}`,
);
assert.equal(context.result, 'global');
}).timeout(20000);

it('lets two global scripts import the same module and publishes their exports', () => {
const first = compile('first.ts', `${IMPORT}\nfunction first() { return "one"; }\nlet counter = 1;`, true);
const second = compile('second.ts', `${IMPORT}\nfunction second() { return "two"; }`, true);

const context = run(`${wrapCompiledGlobalScriptInCommonJsScope(first)}
${wrapCompiledGlobalScriptInCommonJsScope(second)}
${wrapCompiledScriptInCommonJsScope('counter++; globalThis.result = `${first()}-${second()}-${counter}`;')}`);
assert.equal(context.result, 'one-two-2');
}).timeout(20000);

it('keeps the compiled source on the first line of the wrapper', () => {
assert.equal(wrapCompiledScriptInCommonJsScope('a;\nb;').split('\n')[0], '(function () {a;');
});

it('keeps the line numbers of the combined global scripts', () => {
// without the wrapper every global script takes its own lines plus the empty line after it
const source = 'a;\nb;\n';
const wrapped = wrapCompiledGlobalScriptInCommonJsScope(source);
assert.equal(`${wrapped}\n`.split('\n').length, `${source}\n`.split('\n').length);
assert.equal(wrapped.split('\n')[1], 'b;');
});
});

describe('TypeScript compilation regression tests (non-global scripts)', () => {
const tsServer = new tsc.Server(tsCompilerOptions, undefined);
const tsAmbient = {
Expand Down
Loading