From 3ff66b49cdd30212603a9e92a2ecf5f5f21b4346 Mon Sep 17 00:00:00 2001 From: Kishikawa Katsumi Date: Sat, 23 Dec 2023 21:46:16 +0900 Subject: [PATCH] Refactor --- .github/workflows/codeql-analysis.yml | 2 +- .github/workflows/deploy.yml | 4 +- .github/workflows/spm.yml | 2 +- .github/workflows/test.yml | 2 +- Package.resolved | 9 - Public/js/app.js | 170 ++-------------- Public/js/console.js | 10 +- Public/js/runner.js | 283 +++++++++++++------------- Public/js/textlinesteam.js | 76 +++++++ Sources/App/routes.swift | 25 ++- package.json | 2 +- 11 files changed, 252 insertions(+), 333 deletions(-) create mode 100644 Public/js/textlinesteam.js diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index ce638573..6a795aa5 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -42,7 +42,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4 + uses: actions/checkout@v4 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 39262154..10fecab9 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -23,7 +23,7 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4 + - uses: actions/checkout@v4 - name: Update Package.swift.json run: | @@ -66,7 +66,7 @@ jobs: runs-on: ubuntu-latest needs: build steps: - - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4 + - uses: actions/checkout@v4 - uses: azure/setup-kubectl@v3 - uses: azure/login@v1 diff --git a/.github/workflows/spm.yml b/.github/workflows/spm.yml index dbcc6f6f..42e472f3 100644 --- a/.github/workflows/spm.yml +++ b/.github/workflows/spm.yml @@ -8,7 +8,7 @@ jobs: run: runs-on: macos-latest steps: - - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4 + - uses: actions/checkout@v4 - name: Build run: | set -ex diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9796cdfb..23c97c76 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,7 +11,7 @@ jobs: test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4 + - uses: actions/checkout@v4 - name: Build run: | set -ex diff --git a/Package.resolved b/Package.resolved index aba7d0a3..68e0a9d7 100644 --- a/Package.resolved +++ b/Package.resolved @@ -82,15 +82,6 @@ "version": "1.2.0" } }, - { - "package": "swift-backtrace", - "repositoryURL": "https://github.com/swift-server/swift-backtrace.git", - "state": { - "branch": null, - "revision": "80746bdd0ac8a7d83aad5d89dac3cbf15de652e6", - "version": "1.3.4" - } - }, { "package": "swift-collections", "repositoryURL": "https://github.com/apple/swift-collections.git", diff --git a/Public/js/app.js b/Public/js/app.js index c793affc..4af33406 100644 --- a/Public/js/app.js +++ b/Public/js/app.js @@ -298,7 +298,7 @@ export class App { reader.readAsText(files[0], "UTF-8"); } - run() { + async run() { if (runButton.classList.contains("disabled")) { return; } @@ -313,16 +313,6 @@ export class App { this.editor.clearMarkers(); - this.terminal.saveCursorPosition(); - this.terminal.switchAlternateBuffer(); - this.terminal.moveCursorTo(0, 0); - this.terminal.hideCursor(); - - const altBuffer = []; - const cancelToken = this.terminal.showSpinner("Running", () => { - return altBuffer.filter(Boolean); - }); - const params = { toolchain_version: this.versionPicker.selected, code: this.editor.getValue(), @@ -340,10 +330,6 @@ export class App { } const runner = new Runner(this.terminal); - runner.onmessage = (message) => { - altBuffer.length = 0; - altBuffer.push(...this.parseMessage(message)); - }; let stopRunner; if (stopButton) { @@ -354,154 +340,22 @@ export class App { stopButton.addEventListener("click", stopRunner); } - runner.run(params, (buffer, stderr, error, isCancel) => { - runButton.classList.remove("disabled"); - if (stopButton) { - stopButton.classList.add("disabled"); - } - - document.getElementById("run-button-icon").classList.remove("d-none"); - document.getElementById("run-button-spinner").classList.add("d-none"); - - this.terminal.hideSpinner(cancelToken); - this.terminal.switchNormalBuffer(); - this.terminal.showCursor(); - this.terminal.restoreCursorPosition(); - this.terminal.reset(); - - if (isCancel) { - buffer = altBuffer.map((b) => `${b.text}\n`); - } - - this.history.forEach((line) => { - const regex = - /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g; - const plainText = line.replace(regex, ""); - this.terminal.write(`\x1b[38;2;120;124;130m${plainText}\x1b[0m`); // #787C82 - }); - this.history.push(...buffer); - - buffer.forEach((line) => { - this.terminal.write(line); - }); - - const markers = this.parseErrorMessage(stderr); - this.editor.updateMarkers(markers); - this.editor.focus(); - - if (stopButton) { - stopButton.removeEventListener("click", stopRunner); - } - }); - } - - parseMessage(message) { - const lines = []; - - const data = JSON.parse(message); - const version = data.version; - const stderr = data.errors; - const stdout = data.output; + const markers = await runner.run(params); - if (version) { - lines.push( - ...version - .split("\n") - .filter(Boolean) - .map((line) => { - return { - text: `\x1b[38;2;127;168;183m${line}\x1b[0m`, // #7FA8B7 - numberOfLines: Math.ceil(line.length / this.terminal.cols), - }; - }) - ); - } - if (stderr) { - lines.push( - ...stderr - .split("\n") - .filter(Boolean) - .map((line) => { - return { - text: `\x1b[2m\x1b[37m${line}\x1b[0m`, - numberOfLines: Math.ceil(line.length / this.terminal.cols), - }; - }) - ); - } - if (stdout) { - lines.push( - ...stdout - .split("\n") - .filter(Boolean) - .map((line) => { - return { - text: `\x1b[37m${line}\x1b[0m`, - numberOfLines: Math.ceil(line.length / this.terminal.cols), - }; - }) - ); + runButton.classList.remove("disabled"); + if (stopButton) { + stopButton.classList.add("disabled"); } - return lines; - } + document.getElementById("run-button-icon").classList.remove("d-none"); + document.getElementById("run-button-spinner").classList.add("d-none"); - parseErrorMessage(message) { - const matches = message - .replace( - // Remove all ANSI colors/styles from strings - // https://stackoverflow.com/a/29497680/1733883 - // https://github.com/chalk/ansi-regex/blob/main/index.js#L3 - /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, - "" - ) - .matchAll( - /(?:\/main\.swift|):(\d+):(\d+): (error|warning|note): ([\s\S]*?)\n*(?=(?:\/|$))/gi - ); - return [...matches].map((match) => { - const row = +match[1]; - let column = +match[2]; - const text = match[4]; - const type = match[3]; - let severity; - switch (type) { - case "warning": - severity = 4; // monaco.MarkerSeverity.Warning; - break; - case "error": - severity = 8; // monaco.MarkerSeverity.Error; - break; - default: // monaco.MarkerSeverity.Info; - severity = 2; - break; - } - - let length; - if (text.match(/~+\^~+/)) { - // ~~~^~~~ - length = text.match(/~+\^~+/)[0].length; - column -= text.match(/~+\^/)[0].length - 1; - } else if (text.match(/\^~+/)) { - // ^~~~ - length = text.match(/\^~+/)[0].length; - } else if (text.match(/~+\^/)) { - // ~~~^ - length = text.match(/~+\^/)[0].length; - column -= length - 1; - } else if (text.match(/\^/)) { - // ^ - length = 1; - } + this.editor.updateMarkers(markers); + this.editor.focus(); - return { - startLineNumber: row, - startColumn: column, - endLineNumber: row, - endColumn: column + length, - message: text, - severity: severity, - }; - }); + if (stopButton) { + stopButton.removeEventListener("click", stopRunner); + } } saveEditState() { diff --git a/Public/js/console.js b/Public/js/console.js index 57d36ad3..45fb6f08 100644 --- a/Public/js/console.js +++ b/Public/js/console.js @@ -128,7 +128,7 @@ export class Console { this.terminal.write("\x9B?47h"); } - showSpinner(message, progress) { + showSpinner(message) { const self = this; const startTime = performance.now(); const interval = 200; @@ -149,17 +149,9 @@ export class Console { spins++; } - let numberOfLines = 0; updateSpinner(message); return setInterval(() => { this.eraseLine(); - const lines = progress(); - this.eraseLines(numberOfLines); - numberOfLines = 0; - lines.forEach((buffer) => { - numberOfLines += buffer.numberOfLines; - this.terminal.writeln(buffer.text); - }); updateSpinner(message); }, interval); } diff --git a/Public/js/runner.js b/Public/js/runner.js index e40e91b4..388dd713 100644 --- a/Public/js/runner.js +++ b/Public/js/runner.js @@ -1,11 +1,6 @@ "use strict"; -import Plausible from "plausible-tracker"; -const { trackEvent } = Plausible({ - domain: "swiftfiddle.com", -}); - -import ReconnectingWebSocket from "reconnecting-websocket"; +import { TextLineStream } from "./textlinesteam.js"; export class Runner { constructor(terminal) { @@ -14,155 +9,161 @@ export class Runner { this.onmessage = () => {}; } - run(params, completion) { - if (!window.appConfig.isEmbedded) { - this.connection = this.createConnection( - `wss://swiftfiddle.com/runner/${params.toolchain_version}/logs/${params._nonce}` - ); - } - - const startTime = performance.now(); - - const path = `/runner/${params.toolchain_version}/run`; - if (params.toolchain_version !== "5.8.1") { - trackEvent("run", { props: { path } }); - } + async run(params) { + const cancelToken = this.terminal.showSpinner("Running"); + + this.terminal.hideCursor(); + + try { + const path = `/runner/${params.toolchain_version}/run`; + params._streaming = true; + const response = await fetch(path, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify(params), + signal: this.abortController.signal, + }); - fetch(path, { - method: "POST", - headers: { - Accept: "application/json", - "Content-Type": "application/json", - }, - body: JSON.stringify(params), - signal: this.abortController.signal, - }) - .then((response) => { - if (!response.ok) { - throw new Error(response.statusText); - } - return response.json(); - }) - .then((response) => { - const endTime = performance.now(); - const execTime = ` ${((endTime - startTime) / 1000).toFixed(0)}s`; + const reader = response.body + .pipeThrough(new TextDecoderStream()) + .pipeThrough(new TextLineStream()) + .getReader(); + let result = await reader.read(); - const now = new Date(); - const timestamp = now.toLocaleString("en-US", { - hour: "numeric", - minute: "2-digit", - second: "2-digit", - hour12: false, - }); + this.terminal.hideSpinner(cancelToken); + this.printTimestamp(); - const buffer = []; - buffer.push( - `\x1b[38;2;127;168;183m${response.version // #7FA8B7 - .split("\n") - .map((line, i) => { - // prettier-ignore - const padding = this.terminal.cols - line.length - timestamp.length - execTime.length; - let _1 = ""; - if (padding < 0) { - _1 = `\x1b[0m${timestamp}${execTime}\n`; - } else { - _1 = ""; - } - let _2 = ""; - if (padding >= 0) { - _2 = `${" ".repeat(padding)}\x1b[0m${timestamp}${execTime}`; - } else { - _2 = ""; - } - if (i == 0) { - return `${_1}\x1b[38;2;127;168;183m${line}\x1b[0m${_2}`; // #7FA8B7 - } else { - return `\x1b[38;2;127;168;183m${line}\x1b[0m`; // #7FA8B7 - } - }) - .join("\n")}\x1b[0m` + if (!response.ok) { + this.terminal.writeln( + `\x1b[37m❌ ${response.status} ${response.statusText}\x1b[0m` ); - - const matchTimeout = response.errors.match( - /Maximum execution time of \d+ seconds exceeded\./ - ); - if (matchTimeout) { - buffer.push(`${response.errors.replace(matchTimeout[0], "")}\x1b[0m`); - } else { - buffer.push(`${response.errors}\x1b[0m`); - } - - if (response.output) { - buffer.push(`\x1b[37m${response.output}\x1b[0m`); - } else { - buffer.push(`\x1b[0m\x1b[1m*** No output. ***\x1b[0m\n`); - } - - if (matchTimeout) { - buffer.push(`\x1b[31;1m${matchTimeout[0]}\n`); // Timeout error message - } - - completion(buffer, response.errors, null); - }) - .catch((error) => { - const isCancel = error.name == "AbortError"; - completion([], "", error, isCancel); - if (!isCancel) { - this.terminal.writeln(`\x1b[37m❌ ${error}\x1b[0m`); + this.terminal.hideSpinner(cancelToken); + } + + const markers = []; + while (!result.done) { + const text = result.value; + if (text) { + console.log(text); + const response = JSON.parse(text); + switch (response.kind) { + case "stdout": + response.text + .split("\n") + .filter(Boolean) + .forEach((line) => { + this.terminal.writeln(`\x1b[37m${line}\x1b[0m`); + }); + break; + case "stderr": + response.text + .split("\n") + .filter(Boolean) + .forEach((line) => { + this.terminal.writeln(`\x1b[2m\x1b[37m${line}\x1b[0m`); + }); + markers.push(...parseErrorMessage(text)); + break; + case "version": + response.text + .split("\n") + .filter(Boolean) + .forEach((line) => { + this.terminal.writeln(`\x1b[38;2;127;168;183m${line}\x1b[0m`); // #7FA8B7 + }); + break; + default: + break; + } } - }) - .finally(() => { - if (this.connection) { - this.connection.close(); - this.connection = null; - } - }); + result = await reader.read(); + } + + return markers; + } catch (error) { + this.terminal.hideSpinner(cancelToken); + this.terminal.writeln(`\x1b[37m❌ ${error}\x1b[0m`); + } finally { + this.terminal.showCursor(); + } } stop() { this.abortController.abort(); } - createConnection(endpoint) { - if ( - this.connection && - (this.connection.readyState === WebSocket.CONNECTING || - this.connection.readyState === WebSocket.OPEN) - ) { - return this.connection; - } - - const connection = new ReconnectingWebSocket(endpoint, [], { - maxReconnectionDelay: 10000, - minReconnectionDelay: 1000, - reconnectionDelayGrowFactor: 1.3, - connectionTimeout: 10000, - maxRetries: Infinity, - debug: false, + printTimestamp() { + const now = new Date(); + const timestamp = now.toLocaleString("en-US", { + hour: "numeric", + minute: "2-digit", + second: "2-digit", + hour12: false, }); + const padding = this.terminal.cols - timestamp.length; + this.terminal.writeln( + `\x1b[2m\x1b[38;5;15;48;5;238m${" ".repeat(padding)}${timestamp}\x1b[0m` + ); + } +} - connection.onopen = () => { - document.addEventListener("visibilitychange", () => { - switch (document.visibilityState) { - case "hidden": - break; - case "visible": - if (this.connection) { - this.connection = this.createConnection(connection.url); - } - break; - } - }); - }; +function parseErrorMessage(message) { + const matches = message + .replace( + // Remove all ANSI colors/styles from strings + // https://stackoverflow.com/a/29497680/1733883 + // https://github.com/chalk/ansi-regex/blob/main/index.js#L3 + /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, + "" + ) + .matchAll( + /:(\d+): (error|warning|note): ([\s\S]*?)\n*(?=(?:\/|$))/gi + ); + return [...matches].map((match) => { + const row = +match[1]; + let column = +match[2]; + const text = match[4]; + const type = match[3]; + let severity; + switch (type) { + case "warning": + severity = 4; // monaco.MarkerSeverity.Warning; + break; + case "error": + severity = 8; // monaco.MarkerSeverity.Error; + break; + default: // monaco.MarkerSeverity.Info; + severity = 2; + break; + } - connection.onerror = (event) => { - connection.close(); - }; + let length; + if (text.match(/~+\^~+/)) { + // ~~~^~~~ + length = text.match(/~+\^~+/)[0].length; + column -= text.match(/~+\^/)[0].length - 1; + } else if (text.match(/\^~+/)) { + // ^~~~ + length = text.match(/\^~+/)[0].length; + } else if (text.match(/~+\^/)) { + // ~~~^ + length = text.match(/~+\^/)[0].length; + column -= length - 1; + } else if (text.match(/\^/)) { + // ^ + length = 1; + } - connection.onmessage = (event) => { - this.onmessage(event.data); + return { + startLineNumber: row, + startColumn: column, + endLineNumber: row, + endColumn: column + length, + message: text, + severity: severity, }; - - return connection; - } + }); } diff --git a/Public/js/textlinesteam.js b/Public/js/textlinesteam.js new file mode 100644 index 00000000..a30055ff --- /dev/null +++ b/Public/js/textlinesteam.js @@ -0,0 +1,76 @@ +"use strict"; + +// Vendored from Deno - +// https://github.com/denoland/deno_std/blob/main/streams/delimiter.ts +// Copyright 2018-2022 the Deno authors. All rights reserved. MIT license. + +/** Transform a stream into a stream where each chunk is divided by a newline, + * be it `\n` or `\r\n`. `\r` can be enabled via the `allowCR` option. + * + * ```ts + * import { TextLineStream } from "./delimiter.ts"; + * const res = await fetch("https://example.com"); + * const lines = res.body! + * .pipeThrough(new TextDecoderStream()) + * .pipeThrough(new TextLineStream()); + * ``` + */ +export class TextLineStream extends TransformStream { + #buf = ""; + #allowCR = false; + #returnEmptyLines = false; + #mapperFun = (line) => line; + + constructor(options) { + super({ + transform: (chunk, controller) => this.#handle(chunk, controller), + flush: (controller) => this.#handle("\r\n", controller), + }); + + this.#allowCR = options?.allowCR ?? false; + this.#returnEmptyLines = options?.returnEmptyLines ?? false; + this.#mapperFun = options?.mapperFun ?? this.#mapperFun; + } + + #handle(chunk, controller) { + chunk = this.#buf + chunk; + + for (;;) { + const lfIndex = chunk.indexOf("\n"); + + if (this.#allowCR) { + const crIndex = chunk.indexOf("\r"); + + if ( + crIndex !== -1 && + crIndex !== chunk.length - 1 && + (lfIndex === -1 || lfIndex - 1 > crIndex) + ) { + const curChunk = this.#mapperFun(chunk.slice(0, crIndex)); + if (this.#returnEmptyLines || curChunk) { + controller.enqueue(curChunk); + } + chunk = chunk.slice(crIndex + 1); + continue; + } + } + + if (lfIndex !== -1) { + let crOrLfIndex = lfIndex; + if (chunk[lfIndex - 1] === "\r") { + crOrLfIndex--; + } + const curChunk = this.#mapperFun(chunk.slice(0, crOrLfIndex)); + if (this.#returnEmptyLines || curChunk) { + controller.enqueue(curChunk); + } + chunk = chunk.slice(lfIndex + 1); + continue; + } + + break; + } + + this.#buf = chunk; + } +} diff --git a/Sources/App/routes.swift b/Sources/App/routes.swift index b9eec6b1..2654ed1b 100644 --- a/Sources/App/routes.swift +++ b/Sources/App/routes.swift @@ -112,19 +112,24 @@ func routes(_ app: Application) throws { return try await req.client.send(clientRequest) } - app.on(.POST, "runner", "*", "run", body: .collect(maxSize: "10mb")) { (req) -> ClientResponse in + app.on(.POST, "runner", "stable", "run", body: .collect(maxSize: "10mb")) { (req) -> ClientResponse in guard let data = req.body.data else { throw Abort(.badRequest) } - let latestVersion = (try? latestVersion()) ?? stableVersion() - let path: String - if req.url.path.contains("/stable/") { - path = "/runner/\(stableVersion())/run" - } else if req.url.path.contains("/latest/") { - path = "/runner/\(latestVersion)/run" - } else { - path = req.url.path - } + let path = "/runner/\(stableVersion())/run" + let clientRequest = ClientRequest( + method: .POST, + url: URI(scheme: .https, host: "swiftfiddle.com", path: path), + headers: HTTPHeaders([("Content-type", "application/json")]), + body: data + ) + return try await req.client.send(clientRequest) + } + + app.on(.POST, "runner", "latest", "run", body: .collect(maxSize: "10mb")) { (req) -> ClientResponse in + guard let data = req.body.data else { throw Abort(.badRequest) } + let latestVersion = (try? latestVersion()) ?? stableVersion() + let path = "/runner/\(latestVersion)/run" let clientRequest = ClientRequest( method: .POST, url: URI(scheme: .https, host: "swiftfiddle.com", path: path), diff --git a/package.json b/package.json index 3485867d..dbf9d595 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "scripts": { - "build": "webpack --progress --config webpack.prod.js", + "prod": "webpack --progress --config webpack.prod.js", "dev": "webpack --progress --config webpack.dev.js" }, "dependencies": {