Skip to content

Commit

Permalink
Single entrypoint for worker and server
Browse files Browse the repository at this point in the history
  • Loading branch information
vdiez committed Nov 5, 2024
1 parent 4995f50 commit 2330dad
Show file tree
Hide file tree
Showing 12 changed files with 420 additions and 323 deletions.
60 changes: 35 additions & 25 deletions bin/server.mjs
Original file line number Diff line number Diff line change
@@ -1,32 +1,42 @@
#!/usr/bin/env node

/**
* This script expects following arguments
*
* port - port number on which server.mjs should listen
* host - host address on which server.mjs should listen
* workDir - working directory from SonarQube API
* shouldUseTypeScriptParserForJS - whether TypeScript parser should be used for JS code (default true, can be set to false in case of perf issues)
* sonarlint - when running in SonarLint (used to not compute metrics, highlighting, etc)
* bundles - ; or : delimited paths to additional rule bundles
*/

import * as server from '../lib/server.js';
import { isMainThread } from 'node:worker_threads';
import * as server from '../lib/bridge/src/server.js';
import path from 'path';
import * as context from '../lib/shared/src/helpers/context.js';
import { pathToFileURL } from 'node:url';
import { createWorker } from '../lib/shared/src/helpers/worker.js';
import { getContext } from '../lib/shared/src/helpers/context.js';

const port = process.argv[2];
const host = process.argv[3];
const workDir = process.argv[4];
const shouldUseTypeScriptParserForJS = process.argv[5] !== 'false';
const sonarlint = process.argv[6] === 'true';
const debugMemory = process.argv[7] === 'true';
// import containing code which is only executed if it's a child process
import '../lib/bridge/src/worker.js';

let bundles = [];
if (process.argv[8]) {
bundles = process.argv[8].split(path.delimiter).map(bundleDir => pathToFileURL(bundleDir).href);
}
if (isMainThread) {
/**
* This script expects following arguments
*
* port - port number on which server.mjs should listen
* host - host address on which server.mjs should listen
* workDir - working directory from SonarQube API
* shouldUseTypeScriptParserForJS - whether TypeScript parser should be used for JS code (default true, can be set to false in case of perf issues)
* sonarlint - when running in SonarLint (used to not compute metrics, highlighting, etc)
* bundles - ; or : delimited paths to additional rule bundles
*/

const port = process.argv[2];
const host = process.argv[3];
const workDir = process.argv[4];
const shouldUseTypeScriptParserForJS = process.argv[5] !== 'false';
const sonarlint = process.argv[6] === 'true';
const debugMemory = process.argv[7] === 'true';

context.setContext({ workDir, shouldUseTypeScriptParserForJS, sonarlint, debugMemory, bundles });
server.start(Number.parseInt(port), host).catch(() => {});
let bundles = [];
if (process.argv[8]) {
bundles = process.argv[8].split(path.delimiter).map(bundleDir => pathToFileURL(bundleDir).href);
}

context.setContext({ workDir, shouldUseTypeScriptParserForJS, sonarlint, debugMemory, bundles });

server
.start(Number.parseInt(port), host, createWorker(import.meta.filename, getContext()))
.catch(() => {});
}
83 changes: 83 additions & 0 deletions packages/bridge/src/delegate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/*
* SonarQube JavaScript Plugin
* Copyright (C) 2011-2024 SonarSource SA
* mailto:info AT sonarsource DOT com
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import formData from 'form-data';
import express from 'express';
import { Worker } from 'node:worker_threads';
import { JsTsAnalysisOutput } from '../../jsts/src/analysis/analysis.js';
import { handleRequest } from './handle-request.js';

/**
* Returns a delegate function to handle an HTTP request
*/
export function createDelegator(worker: Worker | undefined) {
return function (type: string) {
return worker ? createWorkerHandler(worker, type) : createHandler(type);
};
}

function handleResult(message: any, response: express.Response, next: express.NextFunction) {
switch (message.type) {
case 'success':
if (message.format === 'multipart') {
sendFormData(message.result, response);
} else {
response.send(message.result);
}
break;

case 'failure':
next(message.error);
break;
}
}

function createHandler(type: string) {
return async (
request: express.Request,
response: express.Response,
next: express.NextFunction,
) => {
handleResult(await handleRequest({ type, data: request.body }), response, next);
};
}

function createWorkerHandler(worker: Worker, type: string) {
return async (
request: express.Request,
response: express.Response,
next: express.NextFunction,
) => {
worker.once('message', message => {
handleResult(message, response, next);
});
worker.postMessage({ type, data: request.body });
};
}

function sendFormData(result: JsTsAnalysisOutput, response: express.Response) {
const fd = new formData();
fd.append('ast', Buffer.from(result.ast!), { filename: 'ast' });
delete result.ast;
fd.append('json', JSON.stringify(result));
// this adds the boundary string that will be used to separate the parts
response.set('Content-Type', fd.getHeaders()['content-type']);
response.set('Content-Length', `${fd.getLengthSync()}`);
fd.pipe(response);
}
203 changes: 203 additions & 0 deletions packages/bridge/src/handle-request.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
import { analyzeCSS } from '../../css/src/analysis/analyzer.js';
import {
AnalysisInput,
AnalysisOutput,
MaybeIncompleteAnalysisInput,
} from '../../shared/src/types/analysis.js';
import { CssAnalysisInput } from '../../css/src/analysis/analysis.js';
import { analyzeHTML } from '../../html/src/index.js';
import { EmbeddedAnalysisInput } from '../../jsts/src/embedded/analysis/analysis.js';
import { analyzeJSTS } from '../../jsts/src/analysis/analyzer.js';
import { JsTsAnalysisInput } from '../../jsts/src/analysis/analysis.js';
import { analyzeProject } from '../../jsts/src/analysis/projectAnalysis/projectAnalyzer.js';
import { ProjectAnalysisInput } from '../../jsts/src/analysis/projectAnalysis/projectAnalysis.js';
import { analyzeYAML } from '../../yaml/src/index.js';
import { logHeapStatistics } from './memory.js';
import {
createAndSaveProgram,
createProgramOptions,
deleteProgram,
writeTSConfigFile,
} from '../../jsts/src/program/program.js';
import { TsConfigJson } from 'type-fest';
import { RuleConfig } from '../../jsts/src/linter/config/rule-config.js';
import { initializeLinter } from '../../jsts/src/linter/linters.js';
import { clearTypeScriptESLintParserCaches } from '../../jsts/src/parsers/eslint.js';
import { readFile } from '../../shared/src/helpers/files.js';
import { APIError, ErrorCode } from '../../shared/src/errors/error.js';

type RequestResult =
| {
type: 'success';
result: string | AnalysisOutput;
format?: string;
}
| {
type: 'failure';
error: ReturnType<typeof serializeError>;
};

export type RequestType =
| 'on-analyze-css'
| 'on-analyze-html'
| 'on-analyze-js'
| 'on-analyze-project'
| 'on-analyze-ts'
| 'on-analyze-with-program'
| 'on-analyze-yaml'
| 'on-create-program'
| 'on-create-tsconfig-file'
| 'on-delete-program'
| 'on-init-linter'
| 'on-new-tsconfig'
| 'on-tsconfig-files';

export async function handleRequest(message: any): Promise<RequestResult> {
try {
const { type, data } = message as { type: RequestType; data: unknown };
switch (type) {
case 'on-analyze-css': {
const output = await analyzeCSS(
(await readFileLazily(data as MaybeIncompleteAnalysisInput)) as CssAnalysisInput,
);
return { type: 'success', result: JSON.stringify(output) };
}

case 'on-analyze-html': {
const output = await analyzeHTML(
(await readFileLazily(data as MaybeIncompleteAnalysisInput)) as EmbeddedAnalysisInput,
);
return { type: 'success', result: JSON.stringify(output) };
}

case 'on-analyze-js': {
const output = analyzeJSTS(
(await readFileLazily(data as MaybeIncompleteAnalysisInput)) as JsTsAnalysisInput,
'js',
);
return {
type: 'success',
result: output,
format: output.ast ? 'multipart' : 'json',
};
}

case 'on-analyze-project': {
const output = await analyzeProject(data as ProjectAnalysisInput);
return { type: 'success', result: JSON.stringify(output) };
}

case 'on-analyze-ts':
case 'on-analyze-with-program': {
const output = analyzeJSTS(
(await readFileLazily(data as MaybeIncompleteAnalysisInput)) as JsTsAnalysisInput,
'ts',
);
return {
type: 'success',
result: output,
format: output.ast ? 'multipart' : 'json',
};
}

case 'on-analyze-yaml': {
const output = await analyzeYAML(
(await readFileLazily(data as MaybeIncompleteAnalysisInput)) as EmbeddedAnalysisInput,
);
return { type: 'success', result: JSON.stringify(output) };
}

case 'on-create-program': {
const { tsConfig } = data as { tsConfig: string };
logHeapStatistics();
const { programId, files, projectReferences, missingTsConfig } =
createAndSaveProgram(tsConfig);
return {
type: 'success',
result: JSON.stringify({ programId, files, projectReferences, missingTsConfig }),
};
}

case 'on-create-tsconfig-file': {
const tsConfigContent = data as TsConfigJson;
const tsConfigFile = await writeTSConfigFile(tsConfigContent);
return { type: 'success', result: JSON.stringify(tsConfigFile) };
}

case 'on-delete-program': {
const { programId } = data as { programId: string };
deleteProgram(programId);
logHeapStatistics();
return { type: 'success', result: 'OK!' };
}

case 'on-init-linter': {
const { rules, environments, globals, linterId, baseDir } = data as {
linterId: string;
environments: string[];
globals: string[];
baseDir: string;
rules: RuleConfig[];
};
await initializeLinter(rules, environments, globals, baseDir, linterId);
return { type: 'success', result: 'OK!' };
}

case 'on-new-tsconfig': {
clearTypeScriptESLintParserCaches();
return { type: 'success', result: 'OK!' };
}

case 'on-tsconfig-files': {
const { tsconfig } = data as { tsconfig: string };
const options = createProgramOptions(tsconfig);
return {
type: 'success',
result: JSON.stringify({
files: options.rootNames,
projectReferences: options.projectReferences
? options.projectReferences.map(ref => ref.path)
: [],
}),
};
}
}
} catch (err) {
return { type: 'failure', error: serializeError(err) };
}
}

/**
* In SonarQube context, an analysis input includes both path and content of a file
* to analyze. However, in SonarLint, we might only get the file path. As a result,
* we read the file if the content is missing in the input.
*/
async function readFileLazily(input: MaybeIncompleteAnalysisInput): Promise<AnalysisInput> {
if (!isCompleteAnalysisInput(input)) {
return {
...input,
fileContent: await readFile(input.filePath),
};
}
return input;
}

function isCompleteAnalysisInput(input: MaybeIncompleteAnalysisInput): input is AnalysisInput {
return 'fileContent' in input;
}

/**
* The default (de)serialization mechanism of the Worker Thread API cannot be used
* to (de)serialize Error instances. To address this, we turn those instances into
* regular JavaScript objects.
*/
function serializeError(err: Error) {
switch (true) {
case err instanceof APIError:
return { code: err.code, message: err.message, stack: err.stack, data: err.data };
case err instanceof Error:
return { code: ErrorCode.Unexpected, message: err.message, stack: err.stack };
default:
return { code: ErrorCode.Unexpected, message: err };
}
}
31 changes: 16 additions & 15 deletions packages/bridge/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,25 +19,26 @@
*/
import * as express from 'express';
import { Worker } from 'worker_threads';
import { delegate } from './worker.js';
import { createDelegator } from './delegate.js';

export default function (worker: Worker): express.Router {
export default function (worker?: Worker): express.Router {
const router = express.Router();
const delegate = createDelegator(worker);

/** Endpoints running on the worker thread */
router.post('/analyze-project', delegate(worker, 'on-analyze-project'));
router.post('/analyze-css', delegate(worker, 'on-analyze-css'));
router.post('/analyze-js', delegate(worker, 'on-analyze-js'));
router.post('/analyze-html', delegate(worker, 'on-analyze-html'));
router.post('/analyze-ts', delegate(worker, 'on-analyze-ts'));
router.post('/analyze-with-program', delegate(worker, 'on-analyze-with-program'));
router.post('/analyze-yaml', delegate(worker, 'on-analyze-yaml'));
router.post('/create-program', delegate(worker, 'on-create-program'));
router.post('/create-tsconfig-file', delegate(worker, 'on-create-tsconfig-file'));
router.post('/delete-program', delegate(worker, 'on-delete-program'));
router.post('/init-linter', delegate(worker, 'on-init-linter'));
router.post('/new-tsconfig', delegate(worker, 'on-new-tsconfig'));
router.post('/tsconfig-files', delegate(worker, 'on-tsconfig-files'));
router.post('/analyze-project', delegate('on-analyze-project'));
router.post('/analyze-css', delegate('on-analyze-css'));
router.post('/analyze-js', delegate('on-analyze-js'));
router.post('/analyze-html', delegate('on-analyze-html'));
router.post('/analyze-ts', delegate('on-analyze-ts'));
router.post('/analyze-with-program', delegate('on-analyze-with-program'));
router.post('/analyze-yaml', delegate('on-analyze-yaml'));
router.post('/create-program', delegate('on-create-program'));
router.post('/create-tsconfig-file', delegate('on-create-tsconfig-file'));
router.post('/delete-program', delegate('on-delete-program'));
router.post('/init-linter', delegate('on-init-linter'));
router.post('/new-tsconfig', delegate('on-new-tsconfig'));
router.post('/tsconfig-files', delegate('on-tsconfig-files'));

/** Endpoints running on the main thread */
router.get('/status', (_, response) => response.send('OK!'));
Expand Down
Loading

0 comments on commit 2330dad

Please sign in to comment.