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
660 changes: 660 additions & 0 deletions apps/jscpd-server/README.md

Large diffs are not rendered by default.

702 changes: 702 additions & 0 deletions apps/jscpd-server/__tests__/server.test.ts

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions apps/jscpd-server/bin/jscpd-server
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
#!/usr/bin/env node

require("../dist/bin/jscpd-server");
12 changes: 12 additions & 0 deletions apps/jscpd-server/bin/jscpd-server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { runServer } from "../src";

(async () => {
try {
await runServer(process.argv, process.exit)
} catch(e) {
console.error(e);
process.exit(1);
}
})()

export * from '../src'
7 changes: 7 additions & 0 deletions apps/jscpd-server/nodemon.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"$schema": "https://json.schemastore.org/nodemon.json",
"watch": ["./src/**"],
"ignoreRoot": [],
"ext": "ts,js",
"exec": "pnpm build"
}
72 changes: 72 additions & 0 deletions apps/jscpd-server/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
{
"name": "jscpd-server",
"version": "4.0.5",
"description": "jscpd server application",
"author": "Andrey Kucherenko <kucherenko.andrey@gmail.com>",
"homepage": "https://github.com/kucherenko/jscpd#readme",
"license": "MIT",
"main": "dist/src/index.js",
"module": "dist/src/index.mjs",
"typings": "dist/src/index.d.mts",
"exports": {
".": {
"types": "./dist/src/index.d.mts",
"import": "./dist/src/index.mjs",
"require": "./dist/src/index.js"
}
},
"bin": {
"jscpd-server": "./bin/jscpd-server"
},
"directories": {
"lib": "src",
"bin": "bin",
"test": "__tests__"
},
"files": [
"dist"
],
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "git+ssh://git@github.com/kucherenko/jscpd.git"
},
"scripts": {
"build": "tsup-node --dts",
"dev": "nodemon",
"test": "vitest run",
"typecheck": "tsc",
"cleanup": "rimraf ./dist .turbo"
},
"dependencies": {
"@jscpd/core": "workspace:*",
"@jscpd/finder": "workspace:*",
"@jscpd/html-reporter": "workspace:*",
"@jscpd/tokenizer": "workspace:*",
"colors": "^1.4.0",
"commander": "^5.1.0",
"express": "^4.22.1",
"fs-extra": "^11.3.3",
"gitignore-to-glob": "^0.3.0",
"jscpd-sarif-reporter": "workspace:*",
"morgan": "^1.10.1"
},
"devDependencies": {
"@tsconfig/node20": "^20.1.8",
"@types/express": "^4.17.25",
"@types/fs-extra": "^11.0.4",
"@types/morgan": "^1.9.10",
"@types/node": "^24.10.4",
"@types/supertest": "^6.0.3",
"@vitest/coverage-v8": "^2.1.9",
"nodemon": "^3.1.11",
"supertest": "^7.2.2",
"ts-node": "^10.9.2",
"tsup": "^8.5.1",
"typescript": "^5.9.3",
"vitest": "^2.1.9"
},
"preferGlobal": true
}
64 changes: 64 additions & 0 deletions apps/jscpd-server/src/detect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import {getDefaultOptions, IClone, IMapFrame, IOptions, IStore, Statistic} from '@jscpd/core';
import {grey, italic} from 'colors/safe';
import {EntryWithContent, getFilesToDetect, InFilesDetector} from '@jscpd/finder';
import {createHash} from 'crypto';
import {getStore} from './setup/store';
import {getSupportedFormats, Tokenizer} from '@jscpd/tokenizer';
import {registerReporters} from './setup/reporters';
import {registerSubscribers} from './setup/subscribers';
import {registerHooks} from './setup/hooks';

const TIMER_LABEL = 'Detection time:';

export type DetectorContext = {
options: IOptions;
store: IStore<IMapFrame>;
statistic: Statistic;
tokenizer: Tokenizer;
detector: InFilesDetector;
files: EntryWithContent[];
};

export function createBaseDetectorContext(
opts: Partial<IOptions>,
providedStore?: IStore<IMapFrame>
): DetectorContext {
const options = {...getDefaultOptions(), ...opts};

if (!options.format) {
options.format = getSupportedFormats();
}

if (!options.hashFunction) {
options.hashFunction = (value: string) => createHash('md5').update(value).digest('hex');
}

const store = providedStore || getStore(options.store);
const files = getFilesToDetect(options as IOptions);
const statistic = new Statistic();
const tokenizer = new Tokenizer();
const detector = new InFilesDetector(tokenizer, store, statistic, options as IOptions);

return {options: options as IOptions, store, statistic, tokenizer, detector, files};
}

export async function detectClones(
opts: IOptions,
store?: IStore<IMapFrame>
): Promise<IClone[]> {
const context = createBaseDetectorContext(opts, store);

registerReporters(context.options, context.detector);
registerSubscribers(context.options, context.detector);
registerHooks(context.options, context.detector);

if (context.options.silent) {
return context.detector.detect(context.files);
}

console.time(italic(grey(TIMER_LABEL)));
const clones = await context.detector.detect(context.files);
console.timeEnd(italic(grey(TIMER_LABEL)));

return clones;
}
66 changes: 66 additions & 0 deletions apps/jscpd-server/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { IOptions } from "@jscpd/core";
import { Command } from "commander";
import {
initOptionsFromCli,
readPackageJson,
createBaseCommand,
addCommonOptions,
getWorkingDirectory,
} from "./setup";
import type { JscpdServer } from "./server/server";

function initServerCli(packageJson: any, argv: string[]): Command {
const cli = createBaseCommand(packageJson);

cli
.usage("[options] <path>")
.description("Start jscpd as a server")
.helpOption("--help", "display help for command")
.option(
"-p, --port [number]",
"port to run the server on (Default is 3000)",
)
.option(
"-H, --host [string]",
"host to bind the server to (Default is 0.0.0.0)",
);

addCommonOptions(cli);

cli.parse(argv);

return cli as Command;
}

export async function runServer(
argv: string[],
exitCallback?: (code: number) => void,
): Promise<JscpdServer | null> {
const packageJson = readPackageJson();

const cli = initServerCli(packageJson, argv);
const options: IOptions = initOptionsFromCli(cli);

const serverOpts = cli.opts();
const workingDirectory = getWorkingDirectory(cli);

try {
const { startServer } = await import("./server");
const port = serverOpts.port ? parseInt(serverOpts.port, 10) : undefined;
if (port !== undefined && (isNaN(port) || port < 1 || port > 65535)) {
throw new Error(`Invalid port number: ${serverOpts.port}`);
}

const server = await startServer(workingDirectory, {
port,
host: serverOpts.host,
jscpdOptions: options,
});

return server;
} catch (error) {
console.error("Failed to start server:", error);
exitCallback?.(1);
return null;
}
}
35 changes: 35 additions & 0 deletions apps/jscpd-server/src/server/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { readPackageJson } from "../setup";

const packageJson = readPackageJson();
Comment on lines +1 to +3

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Module load failure if package.json is not found.

readPackageJson() is called at module load time. If it throws (package.json not found), the entire module fails to import, which will crash the application during startup. Consider handling this gracefully with a fallback version.

♻️ Suggested defensive approach
 import { readPackageJson } from "../setup";

-const packageJson = readPackageJson();
+let packageVersion = 'unknown';
+try {
+  packageVersion = readPackageJson().version;
+} catch {
+  console.warn('Could not read package.json for version info');
+}

 // ...

 export const API_INFO = {
   NAME: "jscpd-server",
-  VERSION: packageJson.version,
+  VERSION: packageVersion,
   DOCUMENTATION_URL: "https://github.com/kucherenko/jscpd",
 } as const;

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In @apps/jscpd-server/src/server/constants.ts around lines 1 - 3, The module
currently calls readPackageJson() at import time which can throw and crash
startup; change constants.ts to call readPackageJson() inside a try/catch (or
create a small helper like getPackageJsonSafe or getPackageVersion) and fall
back to a safe default version string (e.g. "0.0.0" or undefined) when the file
is missing or parsing fails, then export the same constant names (e.g.
packageJson or PACKAGE_VERSION) so callers aren’t broken but the import no
longer throws.


export const SERVER_DEFAULTS = {
PORT: 3000,
HOST: "0.0.0.0",
BODY_SIZE_LIMIT: "10mb",
} as const;

export const ERROR_MESSAGES = {
SCAN_IN_PROGRESS: "Please wait for initial scan to complete",
NOT_INITIALIZED:
"Server not initialized. Please wait for initial scan to complete.",
SOURCE_STORE_NOT_INITIALIZED: "Source store not initialized",
EMPTY_CODE: "Code snippet cannot be empty",
MISSING_REQUIRED_FIELD: (field: string) => `Missing required field: ${field}`,
INVALID_FIELD_TYPE: (field: string, expectedType: string) =>
`Field "${field}" must be a ${expectedType}`,
FIELD_CANNOT_BE_EMPTY: (field: string) => `Field "${field}" cannot be empty`,
} as const;

export const API_INFO = {
NAME: "jscpd-server",
VERSION: packageJson.version,
DOCUMENTATION_URL: "https://github.com/kucherenko/jscpd",
} as const;

export const HTTP_STATUS = {
OK: 200,
BAD_REQUEST: 400,
NOT_FOUND: 404,
INTERNAL_SERVER_ERROR: 500,
SERVICE_UNAVAILABLE: 503,
} as const;
36 changes: 36 additions & 0 deletions apps/jscpd-server/src/server/ephemeral-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { IMapFrame, IStore } from '@jscpd/core';

/**
* A hybrid store that delegates reads to a source store (for project data)
* and writes to an ephemeral store (for snippet data), ensuring snippet
* tokens don't contaminate the shared project store and are automatically
* discarded after detection.
*/
export class EphemeralHybridStore implements IStore<IMapFrame> {
constructor(
private readonly sourceStore: IStore<IMapFrame>,
private readonly ephemeralStore: IStore<IMapFrame>
) {}

namespace(name: string): void {
this.sourceStore.namespace(name);
this.ephemeralStore.namespace(name);
}

async get(key: string): Promise<IMapFrame> {
try {
return await this.ephemeralStore.get(key);
} catch {
return this.sourceStore.get(key);
}
}
Comment on lines +20 to +26

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Overly broad error handling masks legitimate errors.

The empty catch block on Line 23 swallows all errors from ephemeralStore.get(), not just "not found" errors. This could hide legitimate issues like permission errors, corruption, or connection failures. Additionally, sourceStore.get() on Line 24 can also throw but isn't handled.

🛡️ Proposed fix to handle only "not found" errors

If IStore.get() throws a specific error type for missing keys, catch only that error:

  async get(key: string): Promise<IMapFrame> {
    try {
      return await this.ephemeralStore.get(key);
-   } catch {
-     return this.sourceStore.get(key);
+   } catch (error) {
+     // Only fallback for "not found" errors; propagate others
+     if (isNotFoundError(error)) {
+       return this.sourceStore.get(key);
+     }
+     throw error;
    }
  }

Alternatively, if the store implementation returns undefined or null for missing keys instead of throwing:

  async get(key: string): Promise<IMapFrame> {
-   try {
-     return await this.ephemeralStore.get(key);
-   } catch {
-     return this.sourceStore.get(key);
-   }
+   const ephemeralResult = await this.ephemeralStore.get(key);
+   if (ephemeralResult) {
+     return ephemeralResult;
+   }
+   return this.sourceStore.get(key);
  }

Committable suggestion skipped: line range outside the PR's diff.


async set(key: string, value: IMapFrame): Promise<IMapFrame> {
return this.ephemeralStore.set(key, value);
}

close(): void {
this.ephemeralStore.close();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The close() method doesn't await the ephemeral store's close operation. If ephemeralStore.close() returns a Promise (which some store implementations might), this could lead to incomplete cleanup and potential resource leaks. The method should be async and await the close operation to ensure proper cleanup.

Fix it with Roo Code or mention @roomote and request a fix.

}
}

6 changes: 6 additions & 0 deletions apps/jscpd-server/src/server/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export * from './server';
export * from './service';
export * from './types';
export * from './routes';
export * from './middleware';
export * from './constants';
89 changes: 89 additions & 0 deletions apps/jscpd-server/src/server/middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { Request, Response, NextFunction } from 'express';
import { ErrorResponse } from './types';
import { ERROR_MESSAGES, HTTP_STATUS } from './constants';

interface FieldValidation {
name: string;
type: 'string' | 'number' | 'boolean';
required: boolean;
allowEmpty?: boolean;
}

function sendValidationError(res: Response, message: string): void {
const error: ErrorResponse = {
error: 'ValidationError',
message,
statusCode: HTTP_STATUS.BAD_REQUEST,
};
res.status(HTTP_STATUS.BAD_REQUEST).json(error);
}

function validateField(
value: unknown,
validation: FieldValidation
): string | null {
if (validation.required && (value === undefined || value === null)) {
return ERROR_MESSAGES.MISSING_REQUIRED_FIELD(validation.name);
}

if (value !== undefined && value !== null) {
if (typeof value !== validation.type) {
return ERROR_MESSAGES.INVALID_FIELD_TYPE(validation.name, validation.type);
}

if (validation.type === 'string' && !validation.allowEmpty) {
if ((value as string).trim().length === 0) {
return ERROR_MESSAGES.FIELD_CANNOT_BE_EMPTY(validation.name);
}
}
}

return null;
}

export function validateCheckRequest(
req: Request,
res: Response,
next: NextFunction
): void {
const validations: FieldValidation[] = [
{ name: 'code', type: 'string', required: true, allowEmpty: false },
{ name: 'format', type: 'string', required: true, allowEmpty: false },
];

for (const validation of validations) {
const error = validateField(req.body[validation.name], validation);
if (error) {
sendValidationError(res, error);
return;
}
}

next();
}

export function errorHandler(
err: Error,
_req: Request,
res: Response,
_next: NextFunction
): void {
console.error('Error:', err);

const error: ErrorResponse = {
error: err.name || 'InternalServerError',
message: err.message || 'An unexpected error occurred',
statusCode: HTTP_STATUS.INTERNAL_SERVER_ERROR,
};

res.status(error.statusCode).json(error);
}
Comment on lines +65 to +80

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Avoid logging potentially sensitive code snippets in errors.

The error handler logs the full error object. If errors contain user-submitted code snippets (e.g., from validation failures or processing errors), this could inadvertently log sensitive content. Consider logging only error name and message rather than the full error object.

♻️ Suggested improvement
   console.error('Error:', err);
+  // Consider: console.error('Error:', err.name, err.message);
+  // to avoid logging potentially sensitive stack traces or data
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function errorHandler(
err: Error,
_req: Request,
res: Response,
_next: NextFunction
): void {
console.error('Error:', err);
const error: ErrorResponse = {
error: err.name || 'InternalServerError',
message: err.message || 'An unexpected error occurred',
statusCode: HTTP_STATUS.INTERNAL_SERVER_ERROR,
};
res.status(error.statusCode).json(error);
}
export function errorHandler(
err: Error,
_req: Request,
res: Response,
_next: NextFunction
): void {
console.error('Error:', err.name, err.message);
const error: ErrorResponse = {
error: err.name || 'InternalServerError',
message: err.message || 'An unexpected error occurred',
statusCode: HTTP_STATUS.INTERNAL_SERVER_ERROR,
};
res.status(error.statusCode).json(error);
}
🤖 Prompt for AI Agents
In @apps/jscpd-server/src/server/middleware.ts around lines 65 - 80, The
errorHandler currently logs the full error object which may include sensitive
user-submitted code; change the logging to only emit safe fields (e.g., err.name
and err.message) and avoid printing err.stack or the entire err object. In the
errorHandler function, replace console.error('Error:', err) with a focused log
such as console.error('Error:', err.name, '-', err.message) or use the app's
logger to record only those fields, and keep constructing the ErrorResponse
(error, message, statusCode) unchanged so the response behavior remains the
same.


export function notFoundHandler(req: Request, res: Response): void {
const error: ErrorResponse = {
error: 'NotFound',
message: `Route ${req.method} ${req.path} not found`,
statusCode: HTTP_STATUS.NOT_FOUND,
};
res.status(HTTP_STATUS.NOT_FOUND).json(error);
}
Loading
Loading