feat(editor): New Code editor based on the TypeScript language service (#12285)

This commit is contained in:
Elias Meire
2025-01-08 11:28:56 +01:00
committed by GitHub
parent ac497c8a67
commit 52ae02abaa
58 changed files with 2861 additions and 758 deletions
@@ -112,23 +112,19 @@ export function expressionWithFirstItem(syntaxTree: Tree, expression: string): s
}
export function longestCommonPrefix(...strings: string[]) {
if (strings.length < 2) {
throw new Error('Expected at least two strings');
}
if (strings.length < 2) return '';
return strings.reduce((acc, next) => {
let i = 0;
while (acc[i] && next[i] && acc[i] === next[i]) {
i++;
return strings.reduce((prefix, str) => {
while (!str.startsWith(prefix)) {
prefix = prefix.slice(0, -1);
if (prefix === '') return '';
}
return acc.slice(0, i);
}, '');
return prefix;
}, strings[0]);
}
export const prefixMatch = (first: string, second: string) =>
first.toLocaleLowerCase().startsWith(second.toLocaleLowerCase()) && first !== second;
first.toLocaleLowerCase().startsWith(second.toLocaleLowerCase());
export const isPseudoParam = (candidate: string) => {
const PSEUDO_PARAMS = ['notice']; // user input disallowed
@@ -0,0 +1,47 @@
import { EditorSelection, Facet } from '@codemirror/state';
import type { EditorView } from '@codemirror/view';
import { formatWithCursor, type BuiltInParserName } from 'prettier';
import babelPlugin from 'prettier/plugins/babel';
import estreePlugin from 'prettier/plugins/estree';
export type CodeEditorLanguage = 'json' | 'html' | 'javaScript' | 'python';
export const languageFacet = Facet.define<CodeEditorLanguage, CodeEditorLanguage>({
combine: (values) => values[0] ?? 'javaScript',
});
export function formatDocument(view: EditorView) {
function format(parser: BuiltInParserName) {
void formatWithCursor(view.state.doc.toString(), {
cursorOffset: view.state.selection.main.anchor,
parser,
plugins: [babelPlugin, estreePlugin],
}).then(({ formatted, cursorOffset }) => {
view.dispatch({
changes: {
from: 0,
to: view.state.doc.length,
insert: formatted,
},
selection: EditorSelection.single(cursorOffset),
});
});
}
const langauge = view.state.facet(languageFacet);
switch (langauge) {
case 'javaScript':
format('babel');
break;
case 'html':
format('html');
break;
case 'json':
format('json');
break;
default:
return false;
}
return true;
}
+267 -46
View File
@@ -1,51 +1,83 @@
import {
acceptCompletion,
closeCompletion,
completionStatus,
deleteBracketPair,
moveCompletionSelection,
selectedCompletion,
startCompletion,
} from '@codemirror/autocomplete';
import { indentLess, indentMore, insertNewlineAndIndent, redo, undo } from '@codemirror/commands';
import type { EditorView, KeyBinding } from '@codemirror/view';
export const tabKeyMap = (blurOnTab = false): KeyBinding[] => [
{
any(view, event) {
if (
event.key === 'Tab' ||
(event.key === 'Escape' && completionStatus(view.state) !== null)
) {
event.stopPropagation();
}
return false;
},
},
{
key: 'Tab',
run: (view) => {
if (selectedCompletion(view.state)) {
return acceptCompletion(view);
}
if (!blurOnTab) return indentMore(view);
return false;
},
},
{ key: 'Shift-Tab', run: indentLess },
];
export const enterKeyMap: KeyBinding[] = [
{
key: 'Enter',
run: (view) => {
if (selectedCompletion(view.state)) {
return acceptCompletion(view);
}
return insertNewlineAndIndent(view);
},
},
];
import {
insertNewlineAndIndent,
cursorCharLeft,
selectCharLeft,
deleteLine,
moveLineDown,
moveLineUp,
copyLineDown,
copyLineUp,
selectLine,
cursorMatchingBracket,
indentMore,
indentLess,
cursorLineBoundaryBackward,
selectLineBoundaryBackward,
cursorDocStart,
selectDocStart,
cursorLineBoundaryForward,
selectLineBoundaryForward,
cursorDocEnd,
selectDocEnd,
cursorGroupLeft,
selectGroupLeft,
cursorPageDown,
cursorPageUp,
deleteCharBackward,
deleteCharForward,
deleteGroupBackward,
deleteGroupForward,
deleteToLineEnd,
deleteToLineStart,
selectAll,
selectPageDown,
selectPageUp,
cursorCharRight,
cursorGroupRight,
selectCharRight,
selectGroupRight,
cursorLineUp,
selectLineUp,
cursorLineDown,
selectLineDown,
cursorLineEnd,
cursorLineStart,
selectLineEnd,
selectLineStart,
splitLine,
transposeChars,
redo,
undo,
undoSelection,
toggleComment,
lineComment,
lineUncomment,
toggleBlockComment,
} from '@codemirror/commands';
import {
closeSearchPanel,
gotoLine,
openSearchPanel,
replaceAll,
selectMatches,
selectNextOccurrence,
selectSelectionMatches,
} from '@codemirror/search';
import { addCursorAtEachSelectionLine, addCursorDown, addCursorUp } from './multiCursor';
import { foldAll, foldCode, unfoldAll, unfoldCode } from '@codemirror/language';
import { nextDiagnostic, previousDiagnostic, openLintPanel } from '@codemirror/lint';
import { EditorSelection } from '@codemirror/state';
import { formatDocument } from './format';
const SELECTED_AUTOCOMPLETE_OPTION_SELECTOR = '.cm-tooltip-autocomplete li[aria-selected]';
const onAutocompleteNavigate = (dir: 'up' | 'down') => (view: EditorView) => {
@@ -59,7 +91,20 @@ const onAutocompleteNavigate = (dir: 'up' | 'down') => (view: EditorView) => {
return false;
};
export const autocompleteKeyMap: KeyBinding[] = [
// Keymap based on VSCode
export const editorKeymap: KeyBinding[] = [
{ key: 'Ctrl-Space', run: startCompletion },
{ key: 'Escape', run: closeCompletion },
{
key: 'Escape',
run: (view) => {
if (view.state.selection.ranges.length > 1) {
view.dispatch({ selection: EditorSelection.single(view.state.selection.main.head) });
return true;
}
return false;
},
},
{
key: 'ArrowDown',
run: onAutocompleteNavigate('down'),
@@ -68,9 +113,185 @@ export const autocompleteKeyMap: KeyBinding[] = [
key: 'ArrowUp',
run: onAutocompleteNavigate('up'),
},
];
{ key: 'PageDown', run: moveCompletionSelection(true, 'page') },
{ key: 'PageUp', run: moveCompletionSelection(false, 'page') },
{ key: 'Enter', run: acceptCompletion },
{ key: 'Tab', run: acceptCompletion },
export const historyKeyMap: KeyBinding[] = [
{ key: 'Mod-z', run: undo },
{ key: 'Mod-Shift-z', run: redo },
{ key: 'Mod-f', run: openSearchPanel, scope: 'editor search-panel' },
{ key: 'Escape', run: closeSearchPanel, scope: 'editor search-panel' },
{ key: 'Alt-Enter', run: selectMatches, scope: 'editor search-panel' },
{ key: 'Mod-Alt-Enter', run: replaceAll, scope: 'editor search-panel' },
{ key: 'Ctrl-g', run: gotoLine },
{ key: 'Mod-d', run: selectNextOccurrence, preventDefault: true },
{ key: 'Shift-Mod-l', run: selectSelectionMatches },
{ key: 'Enter', run: insertNewlineAndIndent, shift: insertNewlineAndIndent },
{
key: 'ArrowLeft',
run: cursorCharLeft,
shift: selectCharLeft,
preventDefault: true,
},
{
key: 'Mod-ArrowLeft',
mac: 'Alt-ArrowLeft',
run: cursorGroupLeft,
shift: selectGroupLeft,
},
{
key: 'ArrowRight',
run: cursorCharRight,
shift: selectCharRight,
preventDefault: true,
},
{
key: 'Mod-ArrowRight',
mac: 'Alt-ArrowRight',
run: cursorGroupRight,
shift: selectGroupRight,
},
{
key: 'ArrowUp',
run: cursorLineUp,
shift: selectLineUp,
preventDefault: true,
},
{
key: 'ArrowDown',
run: cursorLineDown,
shift: selectLineDown,
preventDefault: true,
},
{
key: 'Home',
run: cursorLineBoundaryBackward,
shift: selectLineBoundaryBackward,
},
{
mac: 'Cmd-ArrowLeft',
run: cursorLineBoundaryBackward,
shift: selectLineBoundaryBackward,
},
{ key: 'Mod-Home', run: cursorDocStart, shift: selectDocStart },
{ mac: 'Cmd-ArrowUp', run: cursorDocStart, shift: selectDocStart },
{ key: 'PageUp', run: cursorPageUp, shift: selectPageUp },
{ mac: 'Ctrl-ArrowUp', run: cursorPageUp, shift: selectPageUp },
{ key: 'PageDown', run: cursorPageDown, shift: selectPageDown },
{ mac: 'Ctrl-ArrowDown', run: cursorPageDown, shift: selectPageDown },
{
key: 'End',
run: cursorLineBoundaryForward,
shift: selectLineBoundaryForward,
},
{
mac: 'Cmd-ArrowRight',
run: cursorLineBoundaryForward,
shift: selectLineBoundaryForward,
},
{
key: 'Mod-Alt-ArrowUp',
linux: 'Shift-Alt-ArrowUp',
run: addCursorUp,
preventDefault: true,
},
{
key: 'Mod-Alt-ArrowDown',
linux: 'Shift-Alt-ArrowDown',
run: addCursorDown,
preventDefault: true,
},
{
key: 'Shift-Alt-i',
run: addCursorAtEachSelectionLine,
},
{ key: 'Mod-End', run: cursorDocEnd, shift: selectDocEnd },
{ mac: 'Cmd-ArrowDown', run: cursorDocEnd, shift: selectDocEnd },
{ key: 'Mod-a', run: selectAll },
{ key: 'Backspace', run: deleteBracketPair },
{ key: 'Backspace', run: deleteCharBackward, shift: deleteCharBackward },
{ key: 'Delete', run: deleteCharForward },
{ key: 'Mod-Backspace', mac: 'Alt-Backspace', run: deleteGroupBackward },
{ key: 'Mod-Delete', mac: 'Alt-Delete', run: deleteGroupForward },
{ mac: 'Mod-Backspace', run: deleteToLineStart },
{ mac: 'Mod-Delete', run: deleteToLineEnd },
{
mac: 'Ctrl-b',
run: cursorCharLeft,
shift: selectCharLeft,
preventDefault: true,
},
{ mac: 'Ctrl-f', run: cursorCharRight, shift: selectCharRight },
{ mac: 'Ctrl-p', run: cursorLineUp, shift: selectLineUp },
{ mac: 'Ctrl-n', run: cursorLineDown, shift: selectLineDown },
{ mac: 'Ctrl-a', run: cursorLineStart, shift: selectLineStart },
{ mac: 'Ctrl-e', run: cursorLineEnd, shift: selectLineEnd },
{ mac: 'Ctrl-d', run: deleteCharForward },
{ mac: 'Ctrl-h', run: deleteCharBackward },
{ mac: 'Ctrl-k', run: deleteToLineEnd },
{ mac: 'Ctrl-Alt-h', run: deleteGroupBackward },
{ mac: 'Ctrl-o', run: splitLine },
{ mac: 'Ctrl-t', run: transposeChars },
{ mac: 'Ctrl-v', run: cursorPageDown },
{ mac: 'Alt-v', run: cursorPageUp },
{ key: 'Shift-Mod-k', run: deleteLine },
{ key: 'Alt-ArrowDown', run: moveLineDown },
{ key: 'Alt-ArrowUp', run: moveLineUp },
{ win: 'Shift-Alt-ArrowDown', mac: 'Shift-Alt-ArrowDown', run: copyLineDown },
{ win: 'Shift-Alt-ArrowUp', mac: 'Shift-Alt-ArrowUp', run: copyLineUp },
{ key: 'Mod-l', run: selectLine, preventDefault: true },
{ key: 'Shift-Mod-\\', run: cursorMatchingBracket },
{
any(view, event) {
if (
event.key === 'Tab' ||
(event.key === 'Escape' && completionStatus(view.state) !== null)
) {
event.stopPropagation();
}
return false;
},
},
{ key: 'Tab', run: indentMore, shift: indentLess, preventDefault: true },
{ key: 'Mod-[', run: indentLess },
{ key: 'Mod-]', run: indentMore },
{ key: 'Ctrl-Shift-[', mac: 'Cmd-Alt-[', run: foldCode },
{ key: 'Ctrl-Shift-]', mac: 'Cmd-Alt-]', run: unfoldCode },
{ key: 'Mod-k Mod-0', run: foldAll },
{ key: 'Mod-k Mod-j', run: unfoldAll },
{ key: 'Mod-k Mod-c', run: lineComment },
{ key: 'Mod-k Mod-u', run: lineUncomment },
{ key: 'Mod-/', run: toggleComment },
{ key: 'Shift-Alt-a', run: toggleBlockComment },
{ key: 'Mod-z', run: undo, preventDefault: true },
{ key: 'Mod-y', run: redo, preventDefault: true },
{ key: 'Mod-Shift-z', run: redo, preventDefault: true },
{ key: 'Mod-u', run: undoSelection, preventDefault: true },
{ key: 'Mod-Shift-m', run: openLintPanel },
{ key: 'F8', run: nextDiagnostic },
{ key: 'Shift-F8', run: previousDiagnostic },
{ key: 'Shift-Alt-f', linux: 'Ctrl-Shift-i', run: formatDocument },
];
@@ -0,0 +1,52 @@
import { EditorSelection } from '@codemirror/state';
import type { Command } from '@codemirror/view';
const createAddCursor =
(direction: 'up' | 'down'): Command =>
(view) => {
const forward = direction === 'down';
let selection = view.state.selection;
for (const r of selection.ranges) {
selection = selection.addRange(view.moveVertically(r, forward));
}
view.dispatch({ selection });
return true;
};
export const addCursorUp = createAddCursor('up');
export const addCursorDown = createAddCursor('down');
export const addCursorAtEachSelectionLine: Command = (view) => {
let selection: EditorSelection | null = null;
for (const r of view.state.selection.ranges) {
if (r.empty) {
continue;
}
for (let pos = r.from; pos <= r.to; ) {
const line = view.state.doc.lineAt(pos);
const anchor = Math.min(line.to, r.to);
if (selection) {
selection = selection.addRange(EditorSelection.range(anchor, anchor));
} else {
selection = EditorSelection.single(anchor);
}
pos = line.to + 1;
}
}
if (!selection) {
return false;
}
view.dispatch({ selection });
return true;
};
@@ -0,0 +1,94 @@
import { escapeMappingString } from '@/utils/mappingUtils';
import {
insertCompletionText,
pickedCompletion,
type Completion,
type CompletionSource,
} from '@codemirror/autocomplete';
import {
autocompletableNodeNames,
longestCommonPrefix,
prefixMatch,
} from '../../completions/utils';
import { typescriptWorkerFacet } from './facet';
import { blockCommentSnippet, snippets } from './snippets';
const START_CHARACTERS = ['"', "'", '(', '.', '@'];
const START_CHARACTERS_REGEX = /[\.\(\'\"\@]/;
export const typescriptCompletionSource: CompletionSource = async (context) => {
const { worker } = context.state.facet(typescriptWorkerFacet);
let word = context.matchBefore(START_CHARACTERS_REGEX);
if (!word?.text) {
word = context.matchBefore(/[\"\'].*/);
}
if (!word?.text) {
word = context.matchBefore(/[\$\w]+/);
}
const blockComment = context.matchBefore(/\/\*?\*?/);
if (blockComment) {
// Autocomplete a block comment snippet
return { from: blockComment?.from, options: [blockCommentSnippet] };
}
if (!word) return null;
const completionResult = await worker.getCompletionsAtPos(context.pos);
if (!completionResult || context.aborted) return null;
const { result, isGlobal } = completionResult;
let options = [...result.options];
if (isGlobal) {
options = options
.flatMap((opt) => {
if (opt.label === '$') {
return [
opt,
...autocompletableNodeNames().map((name) => ({
...opt,
label: `$('${escapeMappingString(name)}')`,
})),
];
}
return opt;
})
.concat(snippets);
}
return {
from: word ? (START_CHARACTERS.includes(word.text) ? word.to : word.from) : context.pos,
filter: false,
getMatch(completion: Completion) {
const lcp = longestCommonPrefix(completion.label, word.text);
return [0, lcp.length];
},
options: options
.filter(
(option) =>
word.text === '' ||
START_CHARACTERS.includes(word.text) ||
prefixMatch(
option.label.replace(START_CHARACTERS_REGEX, ''),
word.text.replace(START_CHARACTERS_REGEX, ''),
),
)
.map((completion) => {
if (completion.label.endsWith('()')) {
completion.apply = (view, _, from, to) => {
const cursorPosition = from + completion.label.length - 1;
view.dispatch({
...insertCompletionText(view.state, completion.label, from, to),
annotations: pickedCompletion.of(completion),
selection: { anchor: cursorPosition, head: cursorPosition },
});
};
}
return completion;
}),
};
};
@@ -0,0 +1,12 @@
import { Facet, combineConfig } from '@codemirror/state';
import type { LanguageServiceWorker } from '../types';
import type * as Comlink from 'comlink';
export const typescriptWorkerFacet = Facet.define<
{ worker: Comlink.Remote<LanguageServiceWorker> },
{ worker: Comlink.Remote<LanguageServiceWorker> }
>({
combine(configs) {
return combineConfig(configs, {});
},
});
@@ -0,0 +1,54 @@
import type { hoverTooltip } from '@codemirror/view';
import { typescriptWorkerFacet } from './facet';
type HoverSource = Parameters<typeof hoverTooltip>[0];
export const typescriptHoverTooltips: HoverSource = async (view, pos) => {
const { worker } = view.state.facet(typescriptWorkerFacet);
const info = await worker.getHoverTooltip(pos);
if (!info) return null;
return {
pos: info.start,
end: info.end,
above: true,
create: () => {
const div = document.createElement('div');
div.classList.add('cm-tooltip-lint');
const wrapper = document.createElement('div');
wrapper.classList.add('cm-diagnostic');
div.appendChild(wrapper);
const text = document.createElement('div');
text.classList.add('cm-diagnosticText');
wrapper.appendChild(text);
if (info.quickInfo?.displayParts) {
for (const part of info.quickInfo.displayParts) {
const span = text.appendChild(document.createElement('span'));
if (
part.kind === 'keyword' &&
['string', 'number', 'boolean', 'object'].includes(part.text)
) {
span.className = 'ts-primitive';
} else if (part.kind === 'punctuation' && ['(', ')'].includes(part.text)) {
span.className = 'ts-text';
} else {
span.className = `ts-${part.kind}`;
}
span.innerText = part.text;
}
}
const documentation = info.quickInfo?.documentation?.find((doc) => doc.kind === 'text')?.text;
if (documentation) {
const docElement = document.createElement('div');
docElement.classList.add('cm-diagnosticDocs');
docElement.textContent = documentation;
wrapper.appendChild(docElement);
}
return { dom: div };
},
};
};
@@ -0,0 +1,11 @@
import type { LintSource } from '@codemirror/lint';
import { typescriptWorkerFacet } from './facet';
export const typescriptLintSource: LintSource = async (view) => {
const { worker } = view.state.facet(typescriptWorkerFacet);
const docLength = view.state.doc.length;
return (await worker.getDiagnostics()).filter((diag) => {
return diag.from < docLength && diag.to <= docLength && diag.from >= 0;
});
};
@@ -0,0 +1,60 @@
import { snippetCompletion } from '@codemirror/autocomplete';
export const blockCommentSnippet = snippetCompletion('/**\n * #{}\n */', {
label: '/**',
detail: 'Block Comment',
});
export const snippets = [
snippetCompletion('console.log(#{})', { label: 'log', detail: 'Log to console' }),
snippetCompletion('for (const #{1:element} of #{2:array}) {\n\t#{}\n}', {
label: 'forof',
detail: 'For-of Loop',
}),
snippetCompletion(
'for (const #{1:key} in #{2:object}) {\n\tif (Object.prototype.hasOwnProperty.call(#{2:object}, #{1:key})) {\n\t\tconst #{3:element} = #{2:object}[#{1:key}];\n\t\t#{}\n\t}\n}',
{
label: 'forin',
detail: 'For-in Loop',
},
),
snippetCompletion(
'for (let #{1:index} = 0; #{1:index} < #{2:array}.length; #{1:index}++) {\n\tconst #{3:element} = #{2:array}[#{1:index}];\n\t#{}\n}',
{
label: 'for',
detail: 'For Loop',
},
),
snippetCompletion('if (#{1:condition}) {\n\t#{}\n}', {
label: 'if',
detail: 'If Statement',
}),
snippetCompletion('if (#{1:condition}) {\n\t#{}\n} else {\n\t\n}', {
label: 'ifelse',
detail: 'If-Else Statement',
}),
snippetCompletion('function #{1:name}(#{2:params}) {\n\t#{}\n}', {
label: 'function',
detail: 'Function Statement',
}),
snippetCompletion('function #{1:name}(#{2:params}) {\n\t#{}\n}', {
label: 'fn',
detail: 'Function Statement',
}),
snippetCompletion(
'switch (#{1:key}) {\n\tcase #{2:value}:\n\t\t#{}\n\t\tbreak;\n\tdefault:\n\t\tbreak;\n}',
{
label: 'switch',
detail: 'Switch Statement',
},
),
snippetCompletion('try {\n\t#{}\n} catch (#{1:error}) {\n\t\n}', {
label: 'trycatch',
detail: 'Try-Catch Statement',
}),
snippetCompletion('while (#{1:condition}) {\n\t#{}\n}', {
label: 'while',
detail: 'While Statement',
}),
blockCommentSnippet,
];
@@ -0,0 +1,131 @@
import { useDataSchema } from '@/composables/useDataSchema';
import { useDebounce } from '@/composables/useDebounce';
import { useNodeHelpers } from '@/composables/useNodeHelpers';
import { autocompletableNodeNames } from '@/plugins/codemirror/completions/utils';
import useEnvironmentsStore from '@/stores/environments.ee.store';
import { useNDVStore } from '@/stores/ndv.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { forceParse } from '@/utils/forceParse';
import { executionDataToJson } from '@/utils/nodeTypesUtils';
import { autocompletion } from '@codemirror/autocomplete';
import { javascriptLanguage } from '@codemirror/lang-javascript';
import { LanguageSupport } from '@codemirror/language';
import { Text, type Extension } from '@codemirror/state';
import { EditorView, hoverTooltip } from '@codemirror/view';
import * as Comlink from 'comlink';
import { NodeConnectionType, type CodeExecutionMode, type INodeExecutionData } from 'n8n-workflow';
import { ref, toRef, toValue, watch, type MaybeRefOrGetter } from 'vue';
import type { LanguageServiceWorker, RemoteLanguageServiceWorkerInit } from '../types';
import { typescriptCompletionSource } from './completions';
import { typescriptWorkerFacet } from './facet';
import { typescriptHoverTooltips } from './hoverTooltip';
import { linter } from '@codemirror/lint';
import { typescriptLintSource } from './linter';
export function useTypescript(
view: MaybeRefOrGetter<EditorView | undefined>,
mode: MaybeRefOrGetter<CodeExecutionMode>,
id: MaybeRefOrGetter<string>,
) {
const { getInputDataWithPinned, getSchemaForExecutionData } = useDataSchema();
const ndvStore = useNDVStore();
const workflowsStore = useWorkflowsStore();
const { debounce } = useDebounce();
const activeNodeName = ndvStore.activeNodeName;
const worker = ref<Comlink.Remote<LanguageServiceWorker>>();
async function createWorker(): Promise<Extension> {
const { init } = Comlink.wrap<RemoteLanguageServiceWorkerInit>(
new Worker(new URL('../worker/typescript.worker.ts', import.meta.url), { type: 'module' }),
);
worker.value = await init(
{
id: toValue(id),
content: Comlink.proxy((toValue(view)?.state.doc ?? Text.empty).toJSON()),
allNodeNames: autocompletableNodeNames(),
variables: useEnvironmentsStore().variables.map((v) => v.key),
inputNodeNames: activeNodeName
? workflowsStore
.getCurrentWorkflow()
.getParentNodes(activeNodeName, NodeConnectionType.Main, 1)
: [],
mode: toValue(mode),
},
Comlink.proxy(async (nodeName) => {
const node = workflowsStore.getNodeByName(nodeName);
if (node) {
const inputData: INodeExecutionData[] = getInputDataWithPinned(node);
const schema = getSchemaForExecutionData(executionDataToJson(inputData), true);
const execution = workflowsStore.getWorkflowExecution;
const binaryData = useNodeHelpers()
.getBinaryData(
execution?.data?.resultData?.runData ?? null,
node.name,
ndvStore.ndvInputRunIndex ?? 0,
0,
)
.filter((data) => Boolean(data && Object.keys(data).length));
return {
json: schema,
binary: Object.keys(binaryData.reduce((acc, obj) => ({ ...acc, ...obj }), {})),
params: getSchemaForExecutionData([node.parameters]),
};
}
return undefined;
}),
);
const editor = toValue(view);
if (editor) {
forceParse(editor);
}
return [
typescriptWorkerFacet.of({ worker: worker.value }),
new LanguageSupport(javascriptLanguage, [
javascriptLanguage.data.of({ autocomplete: typescriptCompletionSource }),
]),
autocompletion({ icons: false, aboveCursor: true }),
linter(typescriptLintSource),
hoverTooltip(typescriptHoverTooltips, {
hideOnChange: true,
hoverTime: 500,
}),
EditorView.updateListener.of(async (update) => {
if (update.docChanged) {
void worker.value?.updateFile(update.changes.toJSON());
}
}),
];
}
async function onWorkflowDataChange() {
const editor = toValue(view);
if (!editor || !worker.value) return;
await worker.value.updateNodeTypes();
forceParse(editor);
}
watch(
[() => workflowsStore.getWorkflowExecution, () => workflowsStore.getWorkflowRunData],
debounce(onWorkflowDataChange, { debounceTime: 200, trailing: true }),
);
watch(toRef(mode), async (newMode) => {
const editor = toValue(view);
if (!editor || !worker.value) return;
await worker.value.updateMode(newMode);
forceParse(editor);
});
return {
createWorker,
};
}
@@ -0,0 +1,49 @@
import type { Schema } from '@/Interface';
import type { CompletionResult } from '@codemirror/autocomplete';
import type { Diagnostic } from '@codemirror/lint';
import type { CodeExecutionMode } from 'n8n-workflow';
import type ts from 'typescript';
import type * as Comlink from 'comlink';
import type { ChangeSet } from '@codemirror/state';
export interface HoverInfo {
start: number;
end: number;
typeDef?: readonly ts.DefinitionInfo[];
quickInfo: ts.QuickInfo | undefined;
}
export type WorkerInitOptions = {
id: string;
content: string[];
allNodeNames: string[];
inputNodeNames: string[];
variables: string[];
mode: CodeExecutionMode;
};
export type NodeData = { json: Schema | undefined; binary: string[]; params: Schema };
export type NodeDataFetcher = (nodeName: string) => Promise<NodeData | undefined>;
export type LanguageServiceWorker = {
updateFile(changes: ChangeSet): void;
updateMode(mode: CodeExecutionMode): void;
updateNodeTypes(): void;
getCompletionsAtPos(pos: number): Promise<{ result: CompletionResult; isGlobal: boolean } | null>;
getDiagnostics(): Diagnostic[];
getHoverTooltip(pos: number): HoverInfo | null;
};
export type LanguageServiceWorkerInit = {
init(
options: WorkerInitOptions,
nodeDataFetcher: NodeDataFetcher,
): Promise<LanguageServiceWorker>;
};
export type RemoteLanguageServiceWorkerInit = {
init(
options: WorkerInitOptions,
nodeDataFetcher: NodeDataFetcher,
): Comlink.Remote<LanguageServiceWorker>;
};
@@ -0,0 +1,130 @@
export type IndexedDbCache = Awaited<ReturnType<typeof indexedDbCache>>;
export async function indexedDbCache(dbName: string, storeName: string) {
let cache: Record<string, string> = {};
void (await loadCache());
async function loadCache() {
await transaction('readonly', async (store, db) => {
return await new Promise<void>((resolve, reject) => {
const request = store.openCursor();
request.onsuccess = (event) => {
const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result;
if (cursor) {
cache[cursor.key as string] = cursor.value.value;
cursor.continue();
} else {
db.close();
resolve();
}
};
request.onerror = (event) => {
db.close();
reject(event);
};
});
});
}
async function openDb(): Promise<IDBDatabase> {
return await new Promise((resolve, reject) => {
const request = indexedDB.open(dbName, 1);
request.onupgradeneeded = () => {
request.result.createObjectStore(storeName, { keyPath: 'key' });
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
function setItem(key: string, value: string): void {
cache[key] = value;
void persistToIndexedDB(key, value);
}
function getItem(key: string): string | null {
return cache[key] ?? null;
}
function removeItem(key: string): void {
delete cache[key];
void deleteFromIndexedDB(key);
}
function clear(): void {
cache = {};
void clearIndexedDB();
}
async function getAllWithPrefix(prefix: string) {
const keyRange = IDBKeyRange.bound(prefix, prefix + '\uffff', false, false);
const results: Record<string, string> = {};
return await transaction('readonly', async (store) => {
return await new Promise<Record<string, string>>((resolve, reject) => {
const request = store.openCursor(keyRange);
request.onsuccess = (event) => {
const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result;
if (cursor) {
results[cursor.key as string] = cursor.value.value;
cursor.continue();
} else {
resolve(results);
}
};
request.onerror = () => {
reject(request.error);
};
});
});
}
async function transaction<T>(
mode: 'readonly' | 'readwrite',
action: (store: IDBObjectStore, db: IDBDatabase) => Promise<T> | T,
): Promise<T> {
const db = await openDb();
const tx = db.transaction(storeName, mode);
const store = tx.objectStore(storeName);
const result = await action(store, db);
return await new Promise<T>((resolve, reject) => {
tx.oncomplete = () => {
db.close();
resolve(result);
};
tx.onerror = () => {
db.close();
reject(tx.error);
};
});
}
async function persistToIndexedDB(key: string, value: string) {
await transaction('readwrite', (store) => {
store.put({ key, value });
});
}
async function deleteFromIndexedDB(key: string) {
await transaction('readwrite', (store) => {
store.delete(key);
});
}
async function clearIndexedDB() {
await transaction('readwrite', (store) => {
store.clear();
});
}
return { getItem, removeItem, setItem, clear, getAllWithPrefix };
}
@@ -0,0 +1,60 @@
import type * as tsvfs from '@typescript/vfs';
import type ts from 'typescript';
import { type Completion } from '@codemirror/autocomplete';
import { TS_COMPLETE_BLOCKLIST, TYPESCRIPT_AUTOCOMPLETE_THRESHOLD } from './constants';
function convertTsKindtoEditorCompletionType(kind: ts.ScriptElementKind) {
if (!kind) return undefined;
const type = String(kind);
if (type === 'member') return 'property';
return type;
}
function typescriptCompletionToEditor(
completionInfo: ts.WithMetadata<ts.CompletionInfo>,
entry: ts.CompletionEntry,
): Completion {
const boost = -Number(entry.sortText) || 0;
const type = convertTsKindtoEditorCompletionType(entry.kind);
return {
label: type && ['method', 'function'].includes(type) ? entry.name + '()' : entry.name,
type: convertTsKindtoEditorCompletionType(entry.kind),
commitCharacters: entry.commitCharacters ?? completionInfo.defaultCommitCharacters,
detail: entry.labelDetails?.detail,
boost,
};
}
function filterTypescriptCompletions(
completionInfo: ts.WithMetadata<ts.CompletionInfo>,
entry: ts.CompletionEntry,
) {
return (
!TS_COMPLETE_BLOCKLIST.includes(entry.kind) &&
(entry.sortText < TYPESCRIPT_AUTOCOMPLETE_THRESHOLD ||
completionInfo.optionalReplacementSpan?.length)
);
}
export async function getCompletionsAtPos({
pos,
fileName,
env,
}: { pos: number; fileName: string; env: tsvfs.VirtualTypeScriptEnvironment }) {
const completionInfo = env.languageService.getCompletionsAtPosition(fileName, pos, {}, {});
if (!completionInfo) return null;
const options = completionInfo.entries
.filter((entry) => filterTypescriptCompletions(completionInfo, entry))
.map((entry) => typescriptCompletionToEditor(completionInfo, entry));
return {
result: { from: pos, options },
isGlobal: completionInfo.isGlobalCompletion,
};
}
@@ -0,0 +1,26 @@
import ts from 'typescript';
export const TS_COMPLETE_BLOCKLIST: ts.ScriptElementKind[] = [ts.ScriptElementKind.warning];
export const COMPILER_OPTIONS: ts.CompilerOptions = {
allowJs: true,
checkJs: true,
target: ts.ScriptTarget.ESNext,
lib: ['es2023'],
module: ts.ModuleKind.ESNext,
strict: true,
noUnusedLocals: true,
noUnusedParameters: true,
importHelpers: false,
skipDefaultLibCheck: true,
noEmit: true,
};
export const TYPESCRIPT_AUTOCOMPLETE_THRESHOLD = '15';
export const TYPESCRIPT_FILES = {
DYNAMIC_TYPES: 'n8n-dynamic.d.ts',
DYNAMIC_INPUT_TYPES: 'n8n-dynamic-input.d.ts',
DYNAMIC_VARIABLES_TYPES: 'n8n-variables.d.ts',
MODE_TYPES: 'n8n-mode-specific.d.ts',
N8N_TYPES: 'n8n.d.ts',
GLOBAL_TYPES: 'globals.d.ts',
};
export const LUXON_VERSION = '3.2.0';
@@ -0,0 +1,66 @@
import { schemaToTypescriptTypes } from './dynamicTypes';
describe('typescript worker dynamicTypes', () => {
describe('schemaToTypescriptTypes', () => {
it('should convert a schema to a typescript type', () => {
expect(
schemaToTypescriptTypes(
{
type: 'object',
value: [
{
key: 'test',
type: 'string',
value: '',
path: '.test',
},
{
type: 'object',
key: 'nested',
path: '.nested',
value: [
{
key: 'amount',
type: 'number',
value: '',
path: '.amount',
},
],
},
{
type: 'array',
key: 'nestedArray',
path: '.nestedArray',
value: [
{
type: 'object',
key: 'nested',
path: '.nestedArray.nested',
value: [
{
key: 'amount',
type: 'number',
value: '',
path: '.amount',
},
],
},
],
},
],
path: '',
},
'NodeName_1',
),
).toEqual(`interface NodeName_1 {
test: string;
nested: {
amount: number;
};
nestedArray: Array<{
amount: number;
}>;
}`);
});
});
});
@@ -0,0 +1,91 @@
import type { Schema } from '@/Interface';
import { pascalCase } from 'change-case';
import { globalTypeDefinition } from './utils';
function processSchema(schema: Schema): string {
switch (schema.type) {
case 'string':
case 'number':
case 'boolean':
case 'bigint':
case 'symbol':
case 'null':
case 'undefined':
return schema.type;
case 'function':
return 'Function';
case 'array':
if (Array.isArray(schema.value)) {
if (schema.value.length > 0) {
return `Array<${processSchema(schema.value[0])}>`;
}
return 'any[]';
}
return `${schema.value}[]`;
case 'object':
if (!Array.isArray(schema.value)) {
return '{}';
}
const properties = schema.value
.map((prop) => {
const key = prop.key ?? 'unknown';
const type = processSchema(prop);
return ` ${key}: ${type};`;
})
.join('\n');
return `{\n${properties}\n}`;
default:
return 'any';
}
}
export function schemaToTypescriptTypes(schema: Schema, interfaceName: string): string {
return `interface ${interfaceName} ${processSchema(schema)}`;
}
export async function getDynamicNodeTypes({
nodeNames,
loadedNodes,
}: { nodeNames: string[]; loadedNodes: Map<string, { type: string; typeName: string }> }) {
return globalTypeDefinition(`
type NodeName = ${nodeNames.map((name) => `'${name}'`).join(' | ')};
${Array.from(loadedNodes.values())
.map(({ type }) => type)
.join(';\n')}
interface NodeDataMap {
${Array.from(loadedNodes.entries())
.map(
([nodeName, { typeName }]) =>
`'${nodeName}': NodeData<${typeName}Context, ${typeName}Json, ${typeName}BinaryKeys, ${typeName}Params>`,
)
.join(';\n')}
}
`);
}
export async function getDynamicInputNodeTypes(inputNodeNames: string[]) {
const typeNames = inputNodeNames.map((nodeName) => pascalCase(nodeName));
return globalTypeDefinition(`
type N8nInputJson = ${typeNames.map((typeName) => `${typeName}Json`).join(' | ')};
type N8nInputBinaryKeys = ${typeNames.map((typeName) => `${typeName}BinaryKeys`).join(' | ')};
type N8nInputContext = ${typeNames.map((typeName) => `${typeName}Context`).join(' | ')};
type N8nInputParams = ${typeNames.map((typeName) => `${typeName}Params`).join(' | ')};
`);
}
export async function getDynamicVariableTypes(variables: string[]) {
return globalTypeDefinition(`
interface N8nVars {
${variables.map((key) => `${key}: string;`).join('\n')}
}`);
}
@@ -0,0 +1,60 @@
import * as tsvfs from '@typescript/vfs';
import { COMPILER_OPTIONS, TYPESCRIPT_FILES } from './constants';
import ts from 'typescript';
import type { IndexedDbCache } from './cache';
import globalTypes from './type-declarations/globals.d.ts?raw';
import n8nTypes from './type-declarations/n8n.d.ts?raw';
import type { CodeExecutionMode } from 'n8n-workflow';
import { wrapInFunction } from './utils';
type EnvOptions = {
code: {
content: string;
fileName: string;
};
mode: CodeExecutionMode;
cache: IndexedDbCache;
};
export function removeUnusedLibs(fsMap: Map<string, string>) {
for (const [name] of fsMap.entries()) {
if (
name === 'lib.d.ts' ||
name.startsWith('/lib.dom') ||
name.startsWith('/lib.webworker') ||
name.startsWith('/lib.scripthost') ||
name.endsWith('.full.d.ts')
) {
fsMap.delete(name);
}
}
}
export async function setupTypescriptEnv({ cache, code, mode }: EnvOptions) {
const fsMap = await tsvfs.createDefaultMapFromCDN(
COMPILER_OPTIONS,
ts.version,
true,
ts,
undefined,
undefined,
cache,
);
removeUnusedLibs(fsMap);
fsMap.set(TYPESCRIPT_FILES.N8N_TYPES, n8nTypes);
fsMap.set(TYPESCRIPT_FILES.GLOBAL_TYPES, globalTypes);
fsMap.set(code.fileName, wrapInFunction(code.content, mode));
const system = tsvfs.createSystem(fsMap);
return tsvfs.createVirtualTypeScriptEnvironment(
system,
Array.from(fsMap.keys()),
ts,
COMPILER_OPTIONS,
);
}
@@ -0,0 +1,24 @@
import type * as tsvfs from '@typescript/vfs';
export function getHoverTooltip({
pos,
fileName,
env,
}: { pos: number; fileName: string; env: tsvfs.VirtualTypeScriptEnvironment }) {
const quickInfo = env.languageService.getQuickInfoAtPosition(fileName, pos);
if (!quickInfo) return null;
const start = quickInfo.textSpan.start;
const typeDef =
env.languageService.getTypeDefinitionAtPosition(fileName, pos) ??
env.languageService.getDefinitionAtPosition(fileName, pos);
return {
start,
end: start + quickInfo.textSpan.length,
typeDef,
quickInfo,
};
}
@@ -0,0 +1,111 @@
import type { Diagnostic } from '@codemirror/lint';
import type * as tsvfs from '@typescript/vfs';
import ts from 'typescript';
import type { DiagnosticWithLocation } from 'typescript';
/**
* TypeScript has a set of diagnostic categories,
* which maps roughly onto CodeMirror's categories.
* Here, we do the mapping.
*/
export function tsCategoryToSeverity(
diagnostic: Pick<ts.DiagnosticWithLocation, 'category' | 'code'>,
): Diagnostic['severity'] {
switch (diagnostic.code) {
case 6133:
// No unused variables
return 'warning';
case 7027:
// Unreachable code detected
return 'warning';
default: {
switch (diagnostic.category) {
case ts.DiagnosticCategory.Error:
return 'error';
case ts.DiagnosticCategory.Message:
return 'info';
case ts.DiagnosticCategory.Warning:
return 'warning';
case ts.DiagnosticCategory.Suggestion:
return 'info';
}
}
}
}
/**
* Not all TypeScript diagnostic relate to specific
* ranges in source code: here we filter for those that
* do.
*/
function isDiagnosticWithLocation(
diagnostic: ts.Diagnostic,
): diagnostic is ts.DiagnosticWithLocation {
return !!(
diagnostic.file &&
typeof diagnostic.start === 'number' &&
typeof diagnostic.length === 'number'
);
}
function isIgnoredDiagnostic(diagnostic: ts.Diagnostic) {
// No implicit any
return diagnostic.code === 7006;
}
/**
* Get the message for a diagnostic. TypeScript
* is kind of weird: messageText might have the message,
* or a pointer to the message. This follows the chain
* to get a string, regardless of which case we're in.
*/
function tsDiagnosticMessage(diagnostic: Pick<ts.Diagnostic, 'messageText'>): string {
if (typeof diagnostic.messageText === 'string') {
return diagnostic.messageText;
}
// TODO: go through linked list
return diagnostic.messageText.messageText;
}
function tsDiagnosticClassName(diagnostic: ts.Diagnostic) {
switch (diagnostic.code) {
case 6133:
// No unused variables
return 'cm-faded';
default:
return undefined;
}
}
function convertTSDiagnosticToCM(d: ts.DiagnosticWithLocation): Diagnostic {
const start = d.start;
const message = tsDiagnosticMessage(d);
return {
from: start,
to: start + d.length,
message,
markClass: tsDiagnosticClassName(d),
severity: tsCategoryToSeverity(d),
};
}
export function getDiagnostics({
env,
fileName,
}: { env: tsvfs.VirtualTypeScriptEnvironment; fileName: string }) {
const exists = env.getSourceFile(fileName);
if (!exists) return [];
const tsDiagnostics = [
...env.languageService.getSemanticDiagnostics(fileName),
...env.languageService.getSyntacticDiagnostics(fileName),
];
const diagnostics = tsDiagnostics.filter(
(diagnostic): diagnostic is DiagnosticWithLocation =>
isDiagnosticWithLocation(diagnostic) && !isIgnoredDiagnostic(diagnostic),
);
return diagnostics.map((d) => convertTSDiagnosticToCM(d));
}
@@ -0,0 +1,60 @@
type NPMTreeMeta = {
default: string;
files: Array<{ name: string }>;
moduleName: string;
version: string;
};
const jsDelivrApi = {
async getFileTree(packageName: string, version = 'latest'): Promise<NPMTreeMeta> {
const url = `https://data.jsdelivr.com/v1/package/npm/${packageName}@${version}/flat`;
const res = await fetch(url);
return await res.json();
},
async getFileContent(packageName: string, fileName: string, version = 'latest'): Promise<string> {
const url = `https://cdn.jsdelivr.net/npm/${packageName}@${version}${fileName}`;
const res = await fetch(url);
return await res.text();
},
};
function isRequiredTypePackageFile(fileName: string) {
return fileName.endsWith('.d.ts') || fileName === '/package.json';
}
function toLocalFilePath(packageName: string, fileName: string) {
return `/node_modules/@types/${packageName}${fileName}`;
}
export const loadTypes = async (
packageName: string,
version: string,
onFileReceived: (path: string, content: string) => void,
): Promise<void> => {
const { files } = await loadTypesFileTree(packageName, version);
await Promise.all(
files
.filter((file) => isRequiredTypePackageFile(file.name))
.map(
async (file) =>
await loadFileContent(packageName, file.name, version).then((content) =>
onFileReceived(toLocalFilePath(packageName, file.name), content),
),
),
);
};
export const loadTypesFileTree = async (
packageName: string,
version: string,
): Promise<NPMTreeMeta> => {
return await jsDelivrApi.getFileTree(`@types/${packageName}`, version);
};
export const loadFileContent = async (
packageName: string,
fileName: string,
version = 'latest',
) => {
return await jsDelivrApi.getFileContent(`@types/${packageName}`, fileName, version);
};
@@ -0,0 +1,16 @@
export {};
import luxon from 'luxon';
declare global {
const DateTime: typeof luxon.DateTime;
type DateTime = luxon.DateTime;
/** [MDN Reference](https://developer.mozilla.org/docs/Web/API/console) */
interface Console {
/** [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/log_static) */
log(...data: any[]): void;
}
var console: Console;
}
@@ -0,0 +1,15 @@
export {};
declare global {
interface NodeData<C = any, J extends N8nJson = any, B extends string = string, P = any> {
context: C;
params: P;
all(branchIndex?: number, runIndex?: number): Array<N8nItem<J, B>>;
first(branchIndex?: number, runIndex?: number): N8nItem<J, B>;
last(branchIndex?: number, runIndex?: number): N8nItem<J, B>;
itemMatching(itemIndex: number): N8nItem<J, B>;
}
// @ts-expect-error N8nInputJson is populated dynamically
type N8nInput = NodeData<N8nInputContext, N8nInputJson, N8nInputBinaryKeys, N8nInputParams>;
}
@@ -0,0 +1,16 @@
export {};
declare global {
interface NodeData<C, J extends N8nJson, B extends string, P> {
context: C;
item: N8nItem<J, B>;
params: P;
}
// @ts-expect-error N8nInputJson is populated dynamically
type N8nInput = NodeData<{}, N8nInputJson, {}, {}>;
const $itemIndex: number;
const $json: N8nInput['item']['json'];
const $binary: N8nInput['item']['binary'];
}
@@ -0,0 +1,103 @@
import type { DateTime } from 'luxon';
export {};
declare global {
type OutputItemWithoutJsonKey = {
[key: string]: unknown;
} & { json?: never };
type OutputItemWithJsonKey = {
json: {
[key: string]: unknown;
};
};
type MaybePromise<T> = Promise<T> | T;
type OneOutputItem = OutputItemWithJsonKey | OutputItemWithoutJsonKey;
type AllOutputItems = OneOutputItem | Array<OneOutputItem>;
type N8nOutputItem = MaybePromise<OneOutputItem>;
type N8nOutputItems = MaybePromise<AllOutputItems>;
interface N8nJson {
[key: string]: any;
}
interface N8nBinary {
id: string;
fileName: string;
fileExtension: string;
fileType: string;
fileSize: string;
mimeType: string;
}
interface N8nVars {}
// TODO: populate dynamically
interface N8nParameter {}
interface N8nItem<J extends N8nJson = N8nJson, B extends string = string> {
json: J & N8nJson;
binary: Record<B, N8nBinary>;
}
interface N8nCustomData {
set(key: string, value: string): void;
get(key: string): string;
getAll(): Record<string, string>;
setAll(values: Record<string, string>): void;
}
type N8nExecutionMode = 'test' | 'production';
interface N8nExecution {
id: string;
mode: N8nExecutionMode;
resumeUrl?: string;
resumeFormUrl?: string;
customData: N8nCustomData;
}
interface N8nWorkflow {
id: string;
active: boolean;
name: string;
}
interface N8nPrevNode {
name: string;
outputIndex: number;
runIndex: number;
}
const $input: N8nInput;
const $execution: N8nExecution;
const $workflow: N8nWorkflow;
const $prevNode: N8nPrevNode;
const $runIndex: number;
const $now: DateTime;
const $today: DateTime;
const $parameter: N8nInput['params'];
const $vars: N8nVars;
const $nodeVersion: number;
function $jmespath(object: Object | Array<any>, expression: string): any;
function $if<B extends boolean, T, F>(
condition: B,
valueIfTrue: T,
valueIfFalse: F,
): B extends true ? T : T extends false ? F : T | F;
function $ifEmpty<V, E>(value: V, valueIfEmpty: E): V | E;
function $min(...numbers: number[]): number;
function $max(...numbers: number[]): number;
type SomeOtherString = string & NonNullable<unknown>;
// @ts-expect-error NodeName is created dynamically
function $<K extends NodeName>(
nodeName: K | SomeOtherString,
// @ts-expect-error NodeDataMap is created dynamically
): K extends keyof NodeDataMap ? NodeDataMap[K] : NodeData;
}
@@ -0,0 +1,224 @@
import * as Comlink from 'comlink';
import type { LanguageServiceWorker, LanguageServiceWorkerInit } from '../types';
import { indexedDbCache } from './cache';
import { bufferChangeSets, fnPrefix } from './utils';
import type { CodeExecutionMode } from 'n8n-workflow';
import { pascalCase } from 'change-case';
import { computed, reactive, ref, watch } from 'vue';
import { getCompletionsAtPos } from './completions';
import { LUXON_VERSION, TYPESCRIPT_FILES } from './constants';
import {
getDynamicInputNodeTypes,
getDynamicNodeTypes,
getDynamicVariableTypes,
schemaToTypescriptTypes,
} from './dynamicTypes';
import { setupTypescriptEnv } from './env';
import { getHoverTooltip } from './hoverTooltip';
import { getDiagnostics } from './linter';
import { getUsedNodeNames } from './typescriptAst';
import runOnceForAllItemsTypes from './type-declarations/n8n-once-for-all-items.d.ts?raw';
import runOnceForEachItemTypes from './type-declarations/n8n-once-for-each-item.d.ts?raw';
import { loadTypes } from './npmTypesLoader';
import { ChangeSet, Text } from '@codemirror/state';
import { until } from '@vueuse/core';
self.process = { env: {} } as NodeJS.Process;
const worker: LanguageServiceWorkerInit = {
async init(options, nodeDataFetcher) {
const loadedNodeTypesMap: Map<string, { type: string; typeName: string }> = reactive(new Map());
const inputNodeNames = options.inputNodeNames;
const allNodeNames = options.allNodeNames;
const codeFileName = `${options.id}.js`;
const mode = ref<CodeExecutionMode>(options.mode);
const busyApplyingChangesToCode = ref(false);
const cache = await indexedDbCache('typescript-cache', 'fs-map');
const env = await setupTypescriptEnv({
cache,
mode: mode.value,
code: { content: Text.of(options.content).toString(), fileName: codeFileName },
});
const prefix = computed(() => fnPrefix(mode.value));
function editorPositionToTypescript(pos: number) {
return pos + prefix.value.length;
}
function typescriptPositionToEditor(pos: number) {
return pos - prefix.value.length;
}
async function loadNodeTypes(nodeName: string) {
const data = await nodeDataFetcher(nodeName);
const typeName = pascalCase(nodeName);
const jsonType = data?.json
? schemaToTypescriptTypes(data.json, `${typeName}Json`)
: `type ${typeName}Json = N8nJson`;
const paramsType = data?.params
? schemaToTypescriptTypes(data.params, `${typeName}Params`)
: `type ${typeName}Params = {}`;
// Using || on purpose to handle empty string
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
const binaryType = `type ${typeName}BinaryKeys = ${data?.binary.map((key) => `'${key}'`).join(' | ') || 'string'}`;
const contextType = `type ${typeName}Context = {}`;
const type = [jsonType, binaryType, paramsType, contextType].join('\n');
loadedNodeTypesMap.set(nodeName, { type, typeName });
}
async function loadTypesIfNeeded() {
const file = env.getSourceFile(codeFileName);
if (!file) return;
const nodeNames = await getUsedNodeNames(file);
for (const nodeName of nodeNames) {
if (!loadedNodeTypesMap.has(nodeName)) {
await loadNodeTypes(nodeName);
}
}
}
async function loadLuxonTypes() {
if (cache.getItem('/node_modules/@types/luxon/package.json')) {
const fileMap = await cache.getAllWithPrefix('/node_modules/@types/luxon');
for (const [path, content] of Object.entries(fileMap)) {
updateFile(path, content);
}
} else {
await loadTypes('luxon', LUXON_VERSION, (path, types) => {
cache.setItem(path, types);
updateFile(path, types);
});
}
}
async function setVariableTypes() {
updateFile(
TYPESCRIPT_FILES.DYNAMIC_VARIABLES_TYPES,
await getDynamicVariableTypes(options.variables),
);
}
function updateFile(fileName: string, content: string) {
const exists = env.getSourceFile(fileName);
if (exists) {
env.updateFile(fileName, content);
} else {
env.createFile(fileName, content);
}
}
const loadInputNodes = options.inputNodeNames.map(
async (nodeName) => await loadNodeTypes(nodeName),
);
await Promise.all(
loadInputNodes.concat(loadTypesIfNeeded(), loadLuxonTypes(), setVariableTypes()),
);
watch(
loadedNodeTypesMap,
async (loadedNodes) => {
updateFile(
TYPESCRIPT_FILES.DYNAMIC_INPUT_TYPES,
await getDynamicInputNodeTypes(inputNodeNames),
);
updateFile(
TYPESCRIPT_FILES.DYNAMIC_TYPES,
await getDynamicNodeTypes({ nodeNames: allNodeNames, loadedNodes }),
);
},
{ immediate: true },
);
watch(
mode,
(newMode) => {
updateFile(
TYPESCRIPT_FILES.MODE_TYPES,
newMode === 'runOnceForAllItems' ? runOnceForAllItemsTypes : runOnceForEachItemTypes,
);
},
{ immediate: true },
);
watch(prefix, (newPrefix, oldPrefix) => {
env.updateFile(codeFileName, newPrefix, { start: 0, length: oldPrefix.length });
});
const applyChangesToCode = bufferChangeSets((bufferedChanges) => {
bufferedChanges.iterChanges((start, end, _fromNew, _toNew, text) => {
const length = end - start;
env.updateFile(codeFileName, text.toString(), {
start: editorPositionToTypescript(start),
length,
});
});
void loadTypesIfNeeded();
});
const waitForChangesAppliedToCode = async () => {
await until(busyApplyingChangesToCode).toBe(false, { timeout: 500 });
};
return Comlink.proxy<LanguageServiceWorker>({
updateFile: async (changes) => {
busyApplyingChangesToCode.value = true;
void applyChangesToCode(ChangeSet.fromJSON(changes)).then(() => {
busyApplyingChangesToCode.value = false;
});
},
async getCompletionsAtPos(pos) {
await waitForChangesAppliedToCode();
return await getCompletionsAtPos({
pos: editorPositionToTypescript(pos),
fileName: codeFileName,
env,
});
},
getDiagnostics() {
return getDiagnostics({ env, fileName: codeFileName }).map((diagnostic) => ({
...diagnostic,
from: typescriptPositionToEditor(diagnostic.from),
to: typescriptPositionToEditor(diagnostic.to),
}));
},
getHoverTooltip(pos) {
const tooltip = getHoverTooltip({
pos: editorPositionToTypescript(pos),
fileName: codeFileName,
env,
});
if (!tooltip) return null;
tooltip.start = typescriptPositionToEditor(tooltip.start);
tooltip.end = typescriptPositionToEditor(tooltip.end);
return tooltip;
},
async updateMode(newMode) {
mode.value = newMode;
},
async updateNodeTypes() {
const loadedNodeNames = Array.from(loadedNodeTypesMap.keys());
await Promise.all(loadedNodeNames.map(async (nodeName) => await loadNodeTypes(nodeName)));
},
});
},
};
Comlink.expose(worker);
@@ -0,0 +1,38 @@
import ts from 'typescript';
function findNodes(node: ts.Node, check: (node: ts.Node) => boolean): ts.Node[] {
const result: ts.Node[] = [];
// If the current node matches the condition, add it to the result
if (check(node)) {
result.push(node);
}
// Recursively check all child nodes
node.forEachChild((child) => {
result.push(...findNodes(child, check));
});
return result;
}
/**
* Get nodes mentioned in the code
* Check if code includes calls to $('Node A')
*/
export async function getUsedNodeNames(file: ts.SourceFile) {
const callExpressions = findNodes(
file,
(n) =>
n.kind === ts.SyntaxKind.CallExpression &&
(n as ts.CallExpression).expression.getText() === '$',
);
if (callExpressions.length === 0) return [];
const nodeNames = (callExpressions as ts.CallExpression[])
.map((e) => (e.arguments.at(0) as ts.StringLiteral)?.text)
.filter(Boolean);
return nodeNames;
}
@@ -0,0 +1,64 @@
import { ChangeSet } from '@codemirror/state';
import { type CodeExecutionMode } from 'n8n-workflow';
export const fnPrefix = (mode: CodeExecutionMode) => `(
/**
* @returns {${returnTypeForMode(mode)}}
*/
() => {\n`;
export function wrapInFunction(script: string, mode: CodeExecutionMode): string {
return `${fnPrefix(mode)}${script}\n})()`;
}
export function globalTypeDefinition(types: string) {
return `export {};
declare global {
${types}
}`;
}
export function returnTypeForMode(mode: CodeExecutionMode): string {
return mode === 'runOnceForAllItems' ? 'N8nOutputItems' : 'N8nOutputItem';
}
const MAX_CHANGE_BUFFER_CHAR_SIZE = 10_000_000;
const MIN_CHANGE_BUFFER_WINDOW_MS = 50;
const MAX_CHANGE_BUFFER_WINDOW_MS = 500;
// Longer buffer window for large code
function calculateBufferWindowMs(docSize: number, minDelay: number, maxDelay: number): number {
const clampedSize = Math.min(docSize, MAX_CHANGE_BUFFER_CHAR_SIZE);
const normalizedSize = clampedSize / MAX_CHANGE_BUFFER_CHAR_SIZE;
return Math.ceil(minDelay + (maxDelay - minDelay) * normalizedSize);
}
// Create a buffer function to accumulate and compose changesets
export function bufferChangeSets(fn: (changeset: ChangeSet) => void) {
let changeSet = ChangeSet.empty(0);
let timeoutId: NodeJS.Timeout | null = null;
return async (changes: ChangeSet) => {
changeSet = changeSet.compose(changes);
if (timeoutId) {
clearTimeout(timeoutId);
}
return await new Promise<void>((resolve) => {
timeoutId = setTimeout(
() => {
fn(changeSet);
resolve();
changeSet = ChangeSet.empty(0);
},
calculateBufferWindowMs(
changeSet.length,
MIN_CHANGE_BUFFER_WINDOW_MS,
MAX_CHANGE_BUFFER_WINDOW_MS,
),
);
});
};
}