diff --git a/sandpack-client/src/clients/node/client.utils.ts b/sandpack-client/src/clients/node/client.utils.ts index 7d9dc09b0..003f5ea81 100644 --- a/sandpack-client/src/clients/node/client.utils.ts +++ b/sandpack-client/src/clients/node/client.utils.ts @@ -6,6 +6,8 @@ import { invariant } from "outvariant"; import type { SandpackBundlerFiles } from "../.."; import { createError } from "../.."; +import { tokenize, TokenType } from "./taskManager"; + let counter = 0; export function generateRandomId() { @@ -45,13 +47,14 @@ export const fromBundlerFilesToFS = ( ); }; +type Command = [string, string[], ShellCommandOptions]; + /** * Figure out which script it must run to start a server */ -export const findStartScriptPackageJson = ( - packageJson: string -): [string, string[], ShellCommandOptions] => { +export const findStartScriptPackageJson = (packageJson: string): Command[] => { let scripts: Record = {}; + // TODO: support postinstall const possibleKeys = ["dev", "start"]; try { @@ -70,24 +73,48 @@ export const findStartScriptPackageJson = ( for (let index = 0; index < possibleKeys.length; index++) { if (possibleKeys[index] in scripts) { const script = possibleKeys[index]; - const candidate = scripts[script]; - const env = candidate - .match(/(\w+=\w+;)*\w+=\w+/g) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ?.reduce((acc, curr) => { - const [key, value] = curr.split("="); - acc[key] = value; - return acc; - }, {}); - - const [command, ...args] = candidate - .replace(/(\w+=\w+;)*\w+=\w+/g, "") - .trim() - .split(" "); - - return [command, args, { env }]; + const listOfCommands: Command[] = []; + const commandTokens = tokenize(candidate); + + let env = {}; + let command = ""; + let args: string[] = []; + + commandTokens.forEach((token, tokenIndex) => { + const commandNotFoundYet = command === ""; + + if (token.type === TokenType.EnvVar) { + env = token.value; + } + + if (token.type === TokenType.Command && commandNotFoundYet) { + command = token.value; + } + + if ( + token.type === TokenType.Argument || + (!commandNotFoundYet && token.type === TokenType.Command) + ) { + args.push(token.value); + } + + if ( + token.type === TokenType.AND || + tokenIndex === commandTokens.length - 1 + ) { + const nodeboxCommand: Command = [command, args, { env }]; + listOfCommands.push(nodeboxCommand); + + command = ""; + args = []; + } + + // TODO: support TokenType.OR, TokenType.PIPE + }); + + return listOfCommands; } } diff --git a/sandpack-client/src/clients/node/index.ts b/sandpack-client/src/clients/node/index.ts index 7eb94ca86..3bc76ec85 100644 --- a/sandpack-client/src/clients/node/index.ts +++ b/sandpack-client/src/clients/node/index.ts @@ -7,8 +7,8 @@ import type { FilesMap, ShellProcess, FSWatchEvent, + ShellInfo, } from "@codesandbox/nodebox"; -import type { ShellCommandOptions } from "@codesandbox/nodebox/build/modules/shell"; import type { ClientOptions, @@ -41,7 +41,6 @@ export class SandpackNode extends SandpackClient { private emulatorIframe!: HTMLIFrameElement; private emulator!: Nodebox; private emulatorShellProcess: ShellProcess | undefined; - private emulatorCommand: [string, string[], ShellCommandOptions] | undefined; private iframePreviewUrl: string | undefined; private _modulesCache = new Map(); private messageChannelId = generateRandomId(); @@ -127,18 +126,10 @@ export class SandpackNode extends SandpackClient { ): Promise<{ id: string }> { const packageJsonContent = readBuffer(files["/package.json"]); - this.emulatorCommand = findStartScriptPackageJson(packageJsonContent); + const emulatorCommand = findStartScriptPackageJson(packageJsonContent); this.emulatorShellProcess = this.emulator.shell.create(); - // Shell listeners - await this.emulatorShellProcess.on("exit", (exitCode) => { - this.dispatch({ - type: "action", - action: "notification", - notificationType: "error", - title: createError(`Error: process.exit(${exitCode}) called.`), - }); - }); + let globalIndexScript = 0; await this.emulatorShellProcess.on("progress", (data) => { if ( @@ -148,10 +139,10 @@ export class SandpackNode extends SandpackClient { this.dispatch({ type: "shell/progress", data: { - ...data, + state: "command_running", command: [ - this.emulatorCommand?.[0], - this.emulatorCommand?.[1].join(" "), + emulatorCommand?.[globalIndexScript][0], + emulatorCommand?.[globalIndexScript][1].join(" "), ].join(" "), }, }); @@ -170,7 +161,46 @@ export class SandpackNode extends SandpackClient { this.dispatch({ type: "stdout", payload: { data, type: "err" } }); }); - return await this.emulatorShellProcess.runCommand(...this.emulatorCommand); + await this.emulatorShellProcess.on("exit", (exitCode) => { + if (globalIndexScript === emulatorCommand.length - 1) { + this.dispatch({ + type: "action", + action: "notification", + notificationType: "error", + title: createError(`Error: process.exit(${exitCode}) called.`), + }); + } + }); + + let shellId: ShellInfo; + + for ( + let indexScript = 0; + indexScript < emulatorCommand.length; + indexScript++ + ) { + globalIndexScript = indexScript; + + shellId = await this.emulatorShellProcess.runCommand( + ...emulatorCommand[indexScript] + ); + + await new Promise(async (resolve) => { + await this.emulatorShellProcess?.on("exit", async () => { + if ( + this.emulatorShellProcess?.id && + this.emulatorShellProcess?.state === "running" + ) { + console.log(this.emulatorShellProcess); + await this.emulatorShellProcess.kill(); + } + + resolve(undefined); + }); + }); + } + + return shellId; } private async createPreviewURLFromId(id: string): Promise { @@ -360,7 +390,7 @@ export class SandpackNode extends SandpackClient { */ public async restartShellProcess(): Promise { - if (this.emulatorShellProcess && this.emulatorCommand) { + if (this.emulatorShellProcess) { // 1. Set the loading state and clean the URL this.dispatch({ type: "start", firstLoad: true }); diff --git a/sandpack-client/src/clients/node/taskManager.test.ts b/sandpack-client/src/clients/node/taskManager.test.ts new file mode 100644 index 000000000..fa595de81 --- /dev/null +++ b/sandpack-client/src/clients/node/taskManager.test.ts @@ -0,0 +1,124 @@ +import { tokenize } from "./taskManager"; + +describe(tokenize, () => { + it("parses environment variables", () => { + const input = tokenize("FOO=1 tsc -p"); + const output = [ + { type: "EnvVar", value: { FOO: "1" } }, + { type: "Command", value: "tsc" }, + { type: "Argument", value: "-p" }, + ]; + + expect(input).toEqual(output); + }); + + it("parses multiples envs environment variables", () => { + const input = tokenize("FOO=1 BAZ=bla tsc -p"); + const output = [ + { type: "EnvVar", value: { FOO: "1", BAZ: "bla" } }, + { type: "Command", value: "tsc" }, + { type: "Argument", value: "-p" }, + ]; + + expect(input).toEqual(output); + }); + + it("parses command and argument", () => { + const input = tokenize("tsc -p"); + const output = [ + { type: "Command", value: "tsc" }, + { type: "Argument", value: "-p" }, + ]; + + expect(input).toEqual(output); + }); + + it("parses two commands", () => { + const input = tokenize("tsc && node"); + const output = [ + { type: "Command", value: "tsc" }, + { type: "AND" }, + { type: "Command", value: "node" }, + ]; + + expect(input).toEqual(output); + }); + + it("parses two commands", () => { + const input = tokenize("tsc -p . && node index.js"); + const output = [ + { type: "Command", value: "tsc" }, + { type: "Argument", value: "-p" }, + { type: "Command", value: "." }, + { type: "AND" }, + { type: "Command", value: "node" }, + { type: "Command", value: "index.js" }, + ]; + + expect(input).toEqual(output); + }); + + it("parses multiple arguments", () => { + const input = tokenize("tsc --foo -- --foo"); + const output = [ + { type: "Command", value: "tsc" }, + { type: "Argument", value: "--foo" }, + { type: "Argument", value: "--" }, + { type: "Argument", value: "--foo" }, + ]; + + expect(input).toEqual(output); + }); + + it("parses pipe and string commands", () => { + const input = tokenize(`echo "Hello World" | wc -w`); + const output = [ + { type: "Command", value: "echo" }, + { type: "String", value: '"Hello World"' }, + { type: "PIPE" }, + { type: "Command", value: "wc" }, + { type: "Argument", value: "-w" }, + ]; + + expect(input).toEqual(output); + }); + + it("parses escaped characters", () => { + const input = tokenize(`echo "Hello | World" | wc -w`); + const output = [ + { type: "Command", value: "echo" }, + { type: "String", value: '"Hello | World"' }, + { type: "PIPE" }, + { type: "Command", value: "wc" }, + { type: "Argument", value: "-w" }, + ]; + + expect(input).toEqual(output); + }); + + it("parses escaped characters", () => { + const input = tokenize(`echo "Hello | World" | wc -w`); + const output = [ + { type: "Command", value: "echo" }, + { type: "String", value: '"Hello | World"' }, + { type: "PIPE" }, + { type: "Command", value: "wc" }, + { type: "Argument", value: "-w" }, + ]; + + expect(input).toEqual(output); + }); + + it("parses or", () => { + const input = tokenize(`echo "Hello | World" || wc -w`); + const output = [ + { type: "Command", value: "echo" }, + { type: "String", value: '"Hello | World"' }, + { type: "OR" }, + { type: "Command", value: "wc" }, + { type: "Argument", value: "-w" }, + ]; + + expect(input).toEqual(output); + }); +}); diff --git a/sandpack-client/src/clients/node/taskManager.ts b/sandpack-client/src/clients/node/taskManager.ts new file mode 100644 index 000000000..6a52ef39b --- /dev/null +++ b/sandpack-client/src/clients/node/taskManager.ts @@ -0,0 +1,178 @@ +function isCommand(char: string) { + return /[a-zA-Z.]/.test(char); +} + +function isAlpha(char: string) { + return /[a-zA-Z]/.test(char); +} + +function isWhitespace(char: string) { + return /\s/.test(char); +} + +function isOperator(char: string) { + return /[&|]/.test(char); +} + +function isArgument(char: string) { + return /-/.test(char); +} + +function isString(char: string) { + return /["']/.test(char); +} + +function isEnvVar(char: string) { + return isAlpha(char) && char === char.toUpperCase(); +} + +export enum TokenType { + OR = "OR", + AND = "AND", + PIPE = "PIPE", + Command = "Command", + Argument = "Argument", + String = "String", + EnvVar = "EnvVar", +} + +type Token = + | { type: TokenType.OR | TokenType.AND | TokenType.PIPE } + | { + type: TokenType.Command | TokenType.Argument | TokenType.String; + value: string; + } + | { + type: TokenType.EnvVar; + value: Record; + }; + +const operators = new Map([ + ["&&", { type: TokenType.AND }], + ["||", { type: TokenType.OR }], + ["|", { type: TokenType.PIPE }], + ["-", { type: TokenType.Argument }], +]); + +export function tokenize(input: string): Token[] { + let current = 0; + const tokens = []; + + function parseCommand(): Token { + let value = ""; + while (isCommand(input[current]) && current < input.length) { + value += input[current]; + current++; + } + + return { type: TokenType.Command, value }; + } + + function parseOperator(): { type: TokenType } { + let value = ""; + while (isOperator(input[current]) && current < input.length) { + value += input[current]; + current++; + } + + return operators.get(value)!; + } + + function parseArgument(): Token { + let value = ""; + while ( + (isArgument(input[current]) || isAlpha(input[current])) && + current < input.length + ) { + value += input[current]; + current++; + } + + return { type: TokenType.Argument, value }; + } + + function parseString(): Token { + const openCloseQuote = input[current]; + + let value = input[current]; + current++; + + while (input[current] !== openCloseQuote && current < input.length) { + value += input[current]; + current++; + } + + value += input[current]; + current++; + + return { type: TokenType.String, value }; + } + + function parseEnvVars(): Token { + const value: Record = {}; + + const parseSingleEnv = () => { + let key = ""; + let pair = ""; + + while (input[current] !== "=" && current < input.length) { + key += input[current]; + current++; + } + + // Skip equal + if (input[current] === "=") { + current++; + } + + while (input[current] !== " " && current < input.length) { + pair += input[current]; + current++; + } + + value[key] = pair; + }; + + while (isEnvVar(input[current]) && current < input.length) { + parseSingleEnv(); + + current++; + } + + return { type: TokenType.EnvVar, value }; + } + + while (current < input.length) { + const currentChar = input[current]; + + if (isWhitespace(currentChar)) { + current++; + continue; + } + + switch (true) { + case isEnvVar(currentChar): + tokens.push(parseEnvVars()); + break; + + case isCommand(currentChar): + tokens.push(parseCommand()); + break; + case isOperator(currentChar): + tokens.push(parseOperator()); + break; + case isArgument(currentChar): + tokens.push(parseArgument()); + break; + + case isString(currentChar): + tokens.push(parseString()); + break; + + default: + throw new Error(`Unknown character: ${currentChar}`); + } + } + + return tokens; +} diff --git a/sandpack-react/src/templates/node/vite-react-ts.ts b/sandpack-react/src/templates/node/vite-react-ts.ts index a8433f73f..311419f36 100644 --- a/sandpack-react/src/templates/node/vite-react-ts.ts +++ b/sandpack-react/src/templates/node/vite-react-ts.ts @@ -84,11 +84,18 @@ root.render( 2 ), }, + "/foo.js": { + code: `const fs = require('fs'); +fs.writeFile("fileeee.ts", "", console.error) + +process.exit() +`, + }, "/package.json": { code: JSON.stringify( { scripts: { - dev: "vite --force", + dev: "tsc -p tsconfig.json && node foo.js && vite --force", build: "tsc && vite build", preview: "vite preview", },