From 1de93db7621ec140accbcf83ed8d83da2d4a87f8 Mon Sep 17 00:00:00 2001 From: Patrick Ziegler Date: Thu, 23 Oct 2025 15:48:11 +0200 Subject: [PATCH] Add Web UI to Spring Shell demo The Wasm code runs in a Web Worker, which is fed commands as the user types them in. --- .../native-image-wasm-spring-shell.yml | 3 +- native-image/wasm-spring-shell/README.md | 21 +- native-image/wasm-spring-shell/pom.xml | 112 ++++- .../{ => cli}/WasmSpringShellApplication.java | 15 +- .../{ => common}/MyCommands.java | 4 +- .../web/CustomResultHandler.java | 29 ++ .../web/DemoInputProvider.java | 72 +++ .../web/MyResultHandlerService.java | 14 + .../wasm_spring_shell/web/ShellRunner.java | 24 + .../example/wasm_spring_shell/web/Worker.java | 46 ++ .../WasmSpringShellApplicationTests.java | 19 - native-image/wasm-spring-shell/web/.gitignore | 1 + native-image/wasm-spring-shell/web/ansi_up.js | 455 ++++++++++++++++++ native-image/wasm-spring-shell/web/index.html | 179 +++++++ native-image/wasm-spring-shell/web/main.mjs | 74 +++ native-image/wasm-spring-shell/web/server.py | 20 + native-image/wasm-spring-shell/web/worker.js | 10 + 17 files changed, 1040 insertions(+), 58 deletions(-) rename native-image/wasm-spring-shell/src/main/java/com/example/wasm_spring_shell/{ => cli}/WasmSpringShellApplication.java (66%) rename native-image/wasm-spring-shell/src/main/java/com/example/wasm_spring_shell/{ => common}/MyCommands.java (92%) create mode 100644 native-image/wasm-spring-shell/src/main/java/com/example/wasm_spring_shell/web/CustomResultHandler.java create mode 100644 native-image/wasm-spring-shell/src/main/java/com/example/wasm_spring_shell/web/DemoInputProvider.java create mode 100644 native-image/wasm-spring-shell/src/main/java/com/example/wasm_spring_shell/web/MyResultHandlerService.java create mode 100644 native-image/wasm-spring-shell/src/main/java/com/example/wasm_spring_shell/web/ShellRunner.java create mode 100644 native-image/wasm-spring-shell/src/main/java/com/example/wasm_spring_shell/web/Worker.java delete mode 100644 native-image/wasm-spring-shell/src/test/java/com/example/wasm_spring_shell/WasmSpringShellApplicationTests.java create mode 100644 native-image/wasm-spring-shell/web/.gitignore create mode 100644 native-image/wasm-spring-shell/web/ansi_up.js create mode 100644 native-image/wasm-spring-shell/web/index.html create mode 100644 native-image/wasm-spring-shell/web/main.mjs create mode 100755 native-image/wasm-spring-shell/web/server.py create mode 100644 native-image/wasm-spring-shell/web/worker.js diff --git a/.github/workflows/native-image-wasm-spring-shell.yml b/.github/workflows/native-image-wasm-spring-shell.yml index 13bbdc8e8..d9b2ce9d2 100644 --- a/.github/workflows/native-image-wasm-spring-shell.yml +++ b/.github/workflows/native-image-wasm-spring-shell.yml @@ -38,6 +38,7 @@ jobs: - name: Run 'native-image/wasm-spring-shell' run: | cd native-image/wasm-spring-shell - ./mvnw --no-transfer-progress -Pnative native:compile + ./mvnw --no-transfer-progress -Pnative,cli package node target/wasm-spring-shell help node target/wasm-spring-shell hello Jane + ./mvnw --no-transfer-progress -Pnative,web package diff --git a/native-image/wasm-spring-shell/README.md b/native-image/wasm-spring-shell/README.md index 6baff91a5..66bf69166 100644 --- a/native-image/wasm-spring-shell/README.md +++ b/native-image/wasm-spring-shell/README.md @@ -12,9 +12,9 @@ This demo requires: ## Run Spring Shell on Node -1. Build the Wasm module with the `native` profile: +1. Build the Wasm module with the `native` and `cli` profiles: ```bash - $ ./mvnw -Pnative package + $ ./mvnw -Pnative,cli package ``` The demo uses the [Native Build Tools](https://graalvm.github.io/native-build-tools/latest/index.html) for building native images with GraalVM and Maven. This command generates a Wasm file and a corresponding JavaScript binding in the `target` directory. @@ -25,3 +25,20 @@ This demo requires: node target/wasm-spring-shell hello Jane ``` This requires Node.js 22 or later. + +## Run Spring Shell in the Browser + +1. Build the Wasm module with the `native` and `web` profiles: + ```bash + ./mvnw -Pnative,web package + ``` + This command generates a Wasm file and a corresponding JavaScript binding in the `web` directory. + +2. Run a local web server in the `web` directory: + ```bash + cd web + python server.py + ``` + This will serve the `web` directory locally on port `8000`. + +3. Navigate to http://localhost:8000 to run the Spring Shell in the browser. diff --git a/native-image/wasm-spring-shell/pom.xml b/native-image/wasm-spring-shell/pom.xml index 209bb1e19..af541495d 100644 --- a/native-image/wasm-spring-shell/pom.xml +++ b/native-image/wasm-spring-shell/pom.xml @@ -31,11 +31,15 @@ 3.4.1 + + org.graalvm.sdk + webimage-preview + 25.0.1 + org.springframework.shell spring-shell-starter - org.springframework.boot spring-boot-starter-test @@ -59,28 +63,88 @@ - - - - org.graalvm.buildtools - native-maven-plugin - - - - --tool:svm-wasm - - -g - - --initialize-at-build-time=apple.security.AppleProvider - --initialize-at-build-time=apple.security.AppleProvider$ProviderService - - - - - org.springframework.boot - spring-boot-maven-plugin - - - + + + cli + + + + org.graalvm.buildtools + native-maven-plugin + + + build-native + + compile-no-fork + + package + + + + + + --tool:svm-wasm + + -g + + --initialize-at-build-time=apple.security.AppleProvider + --initialize-at-build-time=apple.security.AppleProvider$ProviderService + + + + + org.springframework.boot + spring-boot-maven-plugin + + com.example.wasm_spring_shell.cli.WasmSpringShellApplication + + + + + + + web + + web + + + + + org.graalvm.buildtools + native-maven-plugin + + + build-native + + compile-no-fork + + package + + + + ../web/wasm-spring-shell + + + --tool:svm-wasm + + -g + + --initialize-at-build-time=apple.security.AppleProvider + --initialize-at-build-time=apple.security.AppleProvider$ProviderService + -H:-AutoRunVM + + + + + org.springframework.boot + spring-boot-maven-plugin + + com.example.wasm_spring_shell.web.Worker + + + + + + diff --git a/native-image/wasm-spring-shell/src/main/java/com/example/wasm_spring_shell/WasmSpringShellApplication.java b/native-image/wasm-spring-shell/src/main/java/com/example/wasm_spring_shell/cli/WasmSpringShellApplication.java similarity index 66% rename from native-image/wasm-spring-shell/src/main/java/com/example/wasm_spring_shell/WasmSpringShellApplication.java rename to native-image/wasm-spring-shell/src/main/java/com/example/wasm_spring_shell/cli/WasmSpringShellApplication.java index 5c227320a..2760e5ad2 100644 --- a/native-image/wasm-spring-shell/src/main/java/com/example/wasm_spring_shell/WasmSpringShellApplication.java +++ b/native-image/wasm-spring-shell/src/main/java/com/example/wasm_spring_shell/cli/WasmSpringShellApplication.java @@ -4,23 +4,18 @@ * Licensed under the Universal Permissive License v 1.0 as shown at https://opensource.org/license/UPL. */ -package com.example.wasm_spring_shell; +package com.example.wasm_spring_shell.cli; +import com.example.wasm_spring_shell.common.MyCommands; import org.springframework.boot.SpringApplication; import org.springframework.boot.ansi.AnsiOutput; import org.springframework.boot.ansi.AnsiOutput.Enabled; import org.springframework.boot.autoconfigure.SpringBootApplication; -@SpringBootApplication +@SpringBootApplication(scanBasePackageClasses = {WasmSpringShellApplication.class, MyCommands.class}) public class WasmSpringShellApplication { - public static void main(String[] args) { - SpringApplication.run(WasmSpringShellApplication.class, args); - } - - static { - /* Always enable colorful terminal output. */ AnsiOutput.setEnabled(Enabled.ALWAYS); - } - + SpringApplication.run(WasmSpringShellApplication.class, args); + } } diff --git a/native-image/wasm-spring-shell/src/main/java/com/example/wasm_spring_shell/MyCommands.java b/native-image/wasm-spring-shell/src/main/java/com/example/wasm_spring_shell/common/MyCommands.java similarity index 92% rename from native-image/wasm-spring-shell/src/main/java/com/example/wasm_spring_shell/MyCommands.java rename to native-image/wasm-spring-shell/src/main/java/com/example/wasm_spring_shell/common/MyCommands.java index a46ef6a07..8910cf539 100644 --- a/native-image/wasm-spring-shell/src/main/java/com/example/wasm_spring_shell/MyCommands.java +++ b/native-image/wasm-spring-shell/src/main/java/com/example/wasm_spring_shell/common/MyCommands.java @@ -4,7 +4,7 @@ * Licensed under the Universal Permissive License v 1.0 as shown at https://opensource.org/license/UPL. */ -package com.example.wasm_spring_shell; +package com.example.wasm_spring_shell.common; import org.springframework.shell.standard.ShellComponent; import org.springframework.shell.standard.ShellMethod; @@ -17,5 +17,5 @@ public class MyCommands { public String hello(@ShellOption(defaultValue = "Spring") String arg) { return "Hello " + arg + " from WebAssembly built with GraalVM!"; } - + } diff --git a/native-image/wasm-spring-shell/src/main/java/com/example/wasm_spring_shell/web/CustomResultHandler.java b/native-image/wasm-spring-shell/src/main/java/com/example/wasm_spring_shell/web/CustomResultHandler.java new file mode 100644 index 000000000..4d2b4ccfb --- /dev/null +++ b/native-image/wasm-spring-shell/src/main/java/com/example/wasm_spring_shell/web/CustomResultHandler.java @@ -0,0 +1,29 @@ +package com.example.wasm_spring_shell.web; + +import org.jline.utils.AttributedCharSequence; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; +import org.springframework.shell.ResultHandler; +import org.springframework.stereotype.Component; + +import java.io.PrintWriter; +import java.io.StringWriter; + +@Component +public class CustomResultHandler implements ResultHandler { + @Override + public void handleResult(Object result) { + switch (result) { + case null -> Worker.sendErrorMessage("null result"); + case AttributedCharSequence attributed -> { + System.out.println(attributed); + Worker.sendOutputMessage(attributed.toAnsi()); + } + case Throwable throwable -> { + StringWriter stringWriter = new StringWriter(); + throwable.printStackTrace(new PrintWriter(stringWriter)); + Worker.sendErrorMessage(stringWriter.toString()); + } + default -> Worker.sendOutputMessage(result.toString()); + } + } +} diff --git a/native-image/wasm-spring-shell/src/main/java/com/example/wasm_spring_shell/web/DemoInputProvider.java b/native-image/wasm-spring-shell/src/main/java/com/example/wasm_spring_shell/web/DemoInputProvider.java new file mode 100644 index 000000000..99e77723c --- /dev/null +++ b/native-image/wasm-spring-shell/src/main/java/com/example/wasm_spring_shell/web/DemoInputProvider.java @@ -0,0 +1,72 @@ +package com.example.wasm_spring_shell.web; + +import org.graalvm.webimage.api.JS; +import org.jline.reader.History; +import org.jline.reader.ParsedLine; +import org.jline.reader.impl.DefaultParser; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; +import org.springframework.shell.Input; +import org.springframework.shell.InputProvider; +import org.springframework.shell.Utils; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +public class DemoInputProvider implements InputProvider { + private static final Logger LOG = LoggerFactory.getLogger(DemoInputProvider.class); + + private final History history; + + public DemoInputProvider(History history) { + this.history = history; + } + + @Override + public Input readInput() { + var lineParser = new DefaultParser(); + Worker.sendReadyMessage(); + LOG.info("Waiting for message..."); + waitForMessage(); + String line = getMessage(); + LOG.info("Got message {}", line); + history.add(line); + ParsedLine parsedLine = lineParser.parse(line, line.length() + 1); + return new SimpleInput(parsedLine); + } + + @JS.Coerce + @JS(""" + const length = globalThis.sharedI32[1]; + const byteArray = new Uint8Array(length); + byteArray.set(new Uint8Array(globalThis.sharedBuffer, 8, length)) + return new TextDecoder().decode(byteArray); + """) + public static native String getMessage(); + + @JS(""" + Atomics.wait(globalThis.sharedI32, 0, 0); + Atomics.store(globalThis.sharedI32, 0, 0); + """) + public static native void waitForMessage(); + + public static class SimpleInput implements Input { + private final ParsedLine parsedLine; + + public SimpleInput(ParsedLine parsedLine) { + this.parsedLine = parsedLine; + } + + @Override + public String rawText() { + return parsedLine.line(); + } + + @Override + public List words() { + return Utils.sanitizeInput(parsedLine.words()); + } + } +} diff --git a/native-image/wasm-spring-shell/src/main/java/com/example/wasm_spring_shell/web/MyResultHandlerService.java b/native-image/wasm-spring-shell/src/main/java/com/example/wasm_spring_shell/web/MyResultHandlerService.java new file mode 100644 index 000000000..65ae001c7 --- /dev/null +++ b/native-image/wasm-spring-shell/src/main/java/com/example/wasm_spring_shell/web/MyResultHandlerService.java @@ -0,0 +1,14 @@ +package com.example.wasm_spring_shell.web; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; +import org.springframework.context.annotation.Primary; +import org.springframework.shell.result.GenericResultHandlerService; +import org.springframework.stereotype.Component; + +@Primary +@Component +public class MyResultHandlerService extends GenericResultHandlerService { + public MyResultHandlerService(CustomResultHandler handler) { + this.addResultHandler(handler); + } +} diff --git a/native-image/wasm-spring-shell/src/main/java/com/example/wasm_spring_shell/web/ShellRunner.java b/native-image/wasm-spring-shell/src/main/java/com/example/wasm_spring_shell/web/ShellRunner.java new file mode 100644 index 000000000..46f874a5b --- /dev/null +++ b/native-image/wasm-spring-shell/src/main/java/com/example/wasm_spring_shell/web/ShellRunner.java @@ -0,0 +1,24 @@ +package com.example.wasm_spring_shell.web; + +import org.springframework.boot.CommandLineRunner; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; +import org.springframework.context.ApplicationContext; +import org.springframework.shell.Shell; +import org.springframework.shell.context.InteractionMode; +import org.springframework.shell.context.ShellContext; +import org.springframework.stereotype.Component; + +@Component +public class ShellRunner implements CommandLineRunner { + private final ApplicationContext context; + + public ShellRunner(ApplicationContext context) { + this.context = context; + } + + @Override + public void run(String... args) throws Exception { + context.getBean(ShellContext.class).setInteractionMode(InteractionMode.INTERACTIVE); + context.getBean(Shell.class).run(context.getBean(DemoInputProvider.class)); + } +} diff --git a/native-image/wasm-spring-shell/src/main/java/com/example/wasm_spring_shell/web/Worker.java b/native-image/wasm-spring-shell/src/main/java/com/example/wasm_spring_shell/web/Worker.java new file mode 100644 index 000000000..496ea4973 --- /dev/null +++ b/native-image/wasm-spring-shell/src/main/java/com/example/wasm_spring_shell/web/Worker.java @@ -0,0 +1,46 @@ +package com.example.wasm_spring_shell.web; + +import com.example.wasm_spring_shell.common.MyCommands; +import org.graalvm.webimage.api.JS; +import org.graalvm.webimage.api.JSObject; +import org.graalvm.webimage.api.JSString; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.ansi.AnsiOutput; +import org.springframework.boot.ansi.AnsiOutput.Enabled; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication(scanBasePackageClasses = {Worker.class, MyCommands.class}) +public class Worker { + public static void main(String[] args) { + AnsiOutput.setEnabled(Enabled.NEVER); + SpringApplication.run(Worker.class, args); + } + + @JS(""" + postMessage(message); + """) + public static native void postMessage(JSObject message); + + public static JSObject message(String type) { + JSObject obj = JSObject.create(); + obj.set("type", JSString.of(type)); + return obj; + } + + public static void sendReadyMessage() { + postMessage(message("ready")); + } + + public static void sendErrorMessage(String message) { + JSObject error = message("error"); + error.set("message", JSString.of(message)); + postMessage(error); + } + + public static void sendOutputMessage(String output) { + JSObject obj = message("output"); + obj.set("message", JSString.of(output)); + postMessage(obj); + } + +} diff --git a/native-image/wasm-spring-shell/src/test/java/com/example/wasm_spring_shell/WasmSpringShellApplicationTests.java b/native-image/wasm-spring-shell/src/test/java/com/example/wasm_spring_shell/WasmSpringShellApplicationTests.java deleted file mode 100644 index 6b5075cb5..000000000 --- a/native-image/wasm-spring-shell/src/test/java/com/example/wasm_spring_shell/WasmSpringShellApplicationTests.java +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Copyright (c) 2025, Oracle and/or its affiliates. - * - * Licensed under the Universal Permissive License v 1.0 as shown at https://opensource.org/license/UPL. - */ - -package com.example.wasm_spring_shell; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -class WasmSpringShellApplicationTests { - - @Test - void contextLoads() { - } - -} diff --git a/native-image/wasm-spring-shell/web/.gitignore b/native-image/wasm-spring-shell/web/.gitignore new file mode 100644 index 000000000..f9f5b25a9 --- /dev/null +++ b/native-image/wasm-spring-shell/web/.gitignore @@ -0,0 +1 @@ +wasm-spring-shell* diff --git a/native-image/wasm-spring-shell/web/ansi_up.js b/native-image/wasm-spring-shell/web/ansi_up.js new file mode 100644 index 000000000..c462ec4dc --- /dev/null +++ b/native-image/wasm-spring-shell/web/ansi_up.js @@ -0,0 +1,455 @@ +/* + * (The MIT License) + * + * Copyright (c) 2011 github.com/drudru + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the + * 'Software'), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to + * the following conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +"use strict"; +var __makeTemplateObject = (this && this.__makeTemplateObject) || function (cooked, raw) { + if (Object.defineProperty) { Object.defineProperty(cooked, "raw", { value: raw }); } else { cooked.raw = raw; } + return cooked; +}; +var PacketKind; +(function (PacketKind) { + PacketKind[PacketKind["EOS"] = 0] = "EOS"; + PacketKind[PacketKind["Text"] = 1] = "Text"; + PacketKind[PacketKind["Incomplete"] = 2] = "Incomplete"; + PacketKind[PacketKind["ESC"] = 3] = "ESC"; + PacketKind[PacketKind["Unknown"] = 4] = "Unknown"; + PacketKind[PacketKind["SGR"] = 5] = "SGR"; + PacketKind[PacketKind["OSCURL"] = 6] = "OSCURL"; +})(PacketKind || (PacketKind = {})); +export class AnsiUp { + constructor() { + this.VERSION = "6.0.6"; + this.setup_palettes(); + this._use_classes = false; + this.bold = false; + this.faint = false; + this.italic = false; + this.underline = false; + this.fg = this.bg = null; + this._buffer = ''; + this._url_allowlist = { 'http': 1, 'https': 1 }; + this._escape_html = true; + this.boldStyle = 'font-weight:bold'; + this.faintStyle = 'opacity:0.7'; + this.italicStyle = 'font-style:italic'; + this.underlineStyle = 'text-decoration:underline'; + } + set use_classes(arg) { + this._use_classes = arg; + } + get use_classes() { + return this._use_classes; + } + set url_allowlist(arg) { + this._url_allowlist = arg; + } + get url_allowlist() { + return this._url_allowlist; + } + set escape_html(arg) { + this._escape_html = arg; + } + get escape_html() { + return this._escape_html; + } + set boldStyle(arg) { this._boldStyle = arg; } + get boldStyle() { return this._boldStyle; } + set faintStyle(arg) { this._faintStyle = arg; } + get faintStyle() { return this._faintStyle; } + set italicStyle(arg) { this._italicStyle = arg; } + get italicStyle() { return this._italicStyle; } + set underlineStyle(arg) { this._underlineStyle = arg; } + get underlineStyle() { return this._underlineStyle; } + setup_palettes() { + this.ansi_colors = + [ + [ + { rgb: [0, 0, 0], class_name: "ansi-black" }, + { rgb: [187, 0, 0], class_name: "ansi-red" }, + { rgb: [0, 187, 0], class_name: "ansi-green" }, + { rgb: [187, 187, 0], class_name: "ansi-yellow" }, + { rgb: [0, 0, 187], class_name: "ansi-blue" }, + { rgb: [187, 0, 187], class_name: "ansi-magenta" }, + { rgb: [0, 187, 187], class_name: "ansi-cyan" }, + { rgb: [255, 255, 255], class_name: "ansi-white" } + ], + [ + { rgb: [85, 85, 85], class_name: "ansi-bright-black" }, + { rgb: [255, 85, 85], class_name: "ansi-bright-red" }, + { rgb: [0, 255, 0], class_name: "ansi-bright-green" }, + { rgb: [255, 255, 85], class_name: "ansi-bright-yellow" }, + { rgb: [85, 85, 255], class_name: "ansi-bright-blue" }, + { rgb: [255, 85, 255], class_name: "ansi-bright-magenta" }, + { rgb: [85, 255, 255], class_name: "ansi-bright-cyan" }, + { rgb: [255, 255, 255], class_name: "ansi-bright-white" } + ] + ]; + this.palette_256 = []; + this.ansi_colors.forEach(palette => { + palette.forEach(rec => { + this.palette_256.push(rec); + }); + }); + let levels = [0, 95, 135, 175, 215, 255]; + for (let r = 0; r < 6; ++r) { + for (let g = 0; g < 6; ++g) { + for (let b = 0; b < 6; ++b) { + let col = { rgb: [levels[r], levels[g], levels[b]], class_name: 'truecolor' }; + this.palette_256.push(col); + } + } + } + let grey_level = 8; + for (let i = 0; i < 24; ++i, grey_level += 10) { + let gry = { rgb: [grey_level, grey_level, grey_level], class_name: 'truecolor' }; + this.palette_256.push(gry); + } + } + escape_txt_for_html(txt) { + if (!this._escape_html) + return txt; + return txt.replace(/[&<>"']/gm, (str) => { + if (str === "&") + return "&"; + if (str === "<") + return "<"; + if (str === ">") + return ">"; + if (str === "\"") + return """; + if (str === "'") + return "'"; + }); + } + append_buffer(txt) { + var str = this._buffer + txt; + this._buffer = str; + } + get_next_packet() { + var pkt = { + kind: PacketKind.EOS, + text: '', + url: '' + }; + var len = this._buffer.length; + if (len == 0) + return pkt; + var pos = this._buffer.indexOf("\x1B"); + if (pos == -1) { + pkt.kind = PacketKind.Text; + pkt.text = this._buffer; + this._buffer = ''; + return pkt; + } + if (pos > 0) { + pkt.kind = PacketKind.Text; + pkt.text = this._buffer.slice(0, pos); + this._buffer = this._buffer.slice(pos); + return pkt; + } + if (pos == 0) { + if (len < 3) { + pkt.kind = PacketKind.Incomplete; + return pkt; + } + var next_char = this._buffer.charAt(1); + if ((next_char != '[') && (next_char != ']') && (next_char != '(')) { + pkt.kind = PacketKind.ESC; + pkt.text = this._buffer.slice(0, 1); + this._buffer = this._buffer.slice(1); + return pkt; + } + if (next_char == '[') { + if (!this._csi_regex) { + this._csi_regex = rgx(templateObject_1 || (templateObject_1 = __makeTemplateObject(["\n ^ # beginning of line\n #\n # First attempt\n (?: # legal sequence\n \u001B[ # CSI\n ([<-?]?) # private-mode char\n ([d;]*) # any digits or semicolons\n ([ -/]? # an intermediate modifier\n [@-~]) # the command\n )\n | # alternate (second attempt)\n (?: # illegal sequence\n \u001B[ # CSI\n [ -~]* # anything legal\n ([\0-\u001F:]) # anything illegal\n )\n "], ["\n ^ # beginning of line\n #\n # First attempt\n (?: # legal sequence\n \\x1b\\[ # CSI\n ([\\x3c-\\x3f]?) # private-mode char\n ([\\d;]*) # any digits or semicolons\n ([\\x20-\\x2f]? # an intermediate modifier\n [\\x40-\\x7e]) # the command\n )\n | # alternate (second attempt)\n (?: # illegal sequence\n \\x1b\\[ # CSI\n [\\x20-\\x7e]* # anything legal\n ([\\x00-\\x1f:]) # anything illegal\n )\n "]))); + } + let match = this._buffer.match(this._csi_regex); + if (match === null) { + pkt.kind = PacketKind.Incomplete; + return pkt; + } + if (match[4]) { + pkt.kind = PacketKind.ESC; + pkt.text = this._buffer.slice(0, 1); + this._buffer = this._buffer.slice(1); + return pkt; + } + if ((match[1] != '') || (match[3] != 'm')) + pkt.kind = PacketKind.Unknown; + else + pkt.kind = PacketKind.SGR; + pkt.text = match[2]; + var rpos = match[0].length; + this._buffer = this._buffer.slice(rpos); + return pkt; + } + else if (next_char == ']') { + if (len < 4) { + pkt.kind = PacketKind.Incomplete; + return pkt; + } + if ((this._buffer.charAt(2) != '8') + || (this._buffer.charAt(3) != ';')) { + pkt.kind = PacketKind.ESC; + pkt.text = this._buffer.slice(0, 1); + this._buffer = this._buffer.slice(1); + return pkt; + } + if (!this._osc_st) { + this._osc_st = rgxG(templateObject_2 || (templateObject_2 = __makeTemplateObject(["\n (?: # legal sequence\n (\u001B\\) # ESC | # alternate\n (\u0007) # BEL (what xterm did)\n )\n | # alternate (second attempt)\n ( # illegal sequence\n [\0-\u0006] # anything illegal\n | # alternate\n [\b-\u001A] # anything illegal\n | # alternate\n [\u001C-\u001F] # anything illegal\n )\n "], ["\n (?: # legal sequence\n (\\x1b\\\\) # ESC \\\n | # alternate\n (\\x07) # BEL (what xterm did)\n )\n | # alternate (second attempt)\n ( # illegal sequence\n [\\x00-\\x06] # anything illegal\n | # alternate\n [\\x08-\\x1a] # anything illegal\n | # alternate\n [\\x1c-\\x1f] # anything illegal\n )\n "]))); + } + this._osc_st.lastIndex = 0; + { + let match = this._osc_st.exec(this._buffer); + if (match === null) { + pkt.kind = PacketKind.Incomplete; + return pkt; + } + if (match[3]) { + pkt.kind = PacketKind.ESC; + pkt.text = this._buffer.slice(0, 1); + this._buffer = this._buffer.slice(1); + return pkt; + } + } + { + let match = this._osc_st.exec(this._buffer); + if (match === null) { + pkt.kind = PacketKind.Incomplete; + return pkt; + } + if (match[3]) { + pkt.kind = PacketKind.ESC; + pkt.text = this._buffer.slice(0, 1); + this._buffer = this._buffer.slice(1); + return pkt; + } + } + if (!this._osc_regex) { + this._osc_regex = rgx(templateObject_3 || (templateObject_3 = __makeTemplateObject(["\n ^ # beginning of line\n #\n \u001B]8; # OSC Hyperlink\n [ -:<-~]* # params (excluding ;)\n ; # end of params\n ([!-~]{0,512}) # URL capture\n (?: # ST\n (?:\u001B\\) # ESC | # alternate\n (?:\u0007) # BEL (what xterm did)\n )\n ([ -~]+) # TEXT capture\n \u001B]8;; # OSC Hyperlink End\n (?: # ST\n (?:\u001B\\) # ESC | # alternate\n (?:\u0007) # BEL (what xterm did)\n )\n "], ["\n ^ # beginning of line\n #\n \\x1b\\]8; # OSC Hyperlink\n [\\x20-\\x3a\\x3c-\\x7e]* # params (excluding ;)\n ; # end of params\n ([\\x21-\\x7e]{0,512}) # URL capture\n (?: # ST\n (?:\\x1b\\\\) # ESC \\\n | # alternate\n (?:\\x07) # BEL (what xterm did)\n )\n ([\\x20-\\x7e]+) # TEXT capture\n \\x1b\\]8;; # OSC Hyperlink End\n (?: # ST\n (?:\\x1b\\\\) # ESC \\\n | # alternate\n (?:\\x07) # BEL (what xterm did)\n )\n "]))); + } + let match = this._buffer.match(this._osc_regex); + if (match === null) { + pkt.kind = PacketKind.ESC; + pkt.text = this._buffer.slice(0, 1); + this._buffer = this._buffer.slice(1); + return pkt; + } + pkt.kind = PacketKind.OSCURL; + pkt.url = match[1]; + pkt.text = match[2]; + var rpos = match[0].length; + this._buffer = this._buffer.slice(rpos); + return pkt; + } + else if (next_char == '(') { + pkt.kind = PacketKind.Unknown; + this._buffer = this._buffer.slice(3); + return pkt; + } + } + } + ansi_to_html(txt) { + this.append_buffer(txt); + var blocks = []; + while (true) { + var packet = this.get_next_packet(); + if ((packet.kind == PacketKind.EOS) + || (packet.kind == PacketKind.Incomplete)) + break; + if ((packet.kind == PacketKind.ESC) + || (packet.kind == PacketKind.Unknown)) + continue; + if (packet.kind == PacketKind.Text) + blocks.push(this.transform_to_html(this.with_state(packet))); + else if (packet.kind == PacketKind.SGR) + this.process_ansi(packet); + else if (packet.kind == PacketKind.OSCURL) + blocks.push(this.process_hyperlink(packet)); + } + return blocks.join(""); + } + with_state(pkt) { + return { bold: this.bold, faint: this.faint, italic: this.italic, underline: this.underline, fg: this.fg, bg: this.bg, text: pkt.text }; + } + process_ansi(pkt) { + let sgr_cmds = pkt.text.split(';'); + while (sgr_cmds.length > 0) { + let sgr_cmd_str = sgr_cmds.shift(); + let num = parseInt(sgr_cmd_str, 10); + if (isNaN(num) || num === 0) { + this.fg = null; + this.bg = null; + this.bold = false; + this.faint = false; + this.italic = false; + this.underline = false; + } + else if (num === 1) { + this.bold = true; + } + else if (num === 2) { + this.faint = true; + } + else if (num === 3) { + this.italic = true; + } + else if (num === 4) { + this.underline = true; + } + else if (num === 21) { + this.bold = false; + } + else if (num === 22) { + this.faint = false; + this.bold = false; + } + else if (num === 23) { + this.italic = false; + } + else if (num === 24) { + this.underline = false; + } + else if (num === 39) { + this.fg = null; + } + else if (num === 49) { + this.bg = null; + } + else if ((num >= 30) && (num < 38)) { + this.fg = this.ansi_colors[0][(num - 30)]; + } + else if ((num >= 40) && (num < 48)) { + this.bg = this.ansi_colors[0][(num - 40)]; + } + else if ((num >= 90) && (num < 98)) { + this.fg = this.ansi_colors[1][(num - 90)]; + } + else if ((num >= 100) && (num < 108)) { + this.bg = this.ansi_colors[1][(num - 100)]; + } + else if (num === 38 || num === 48) { + if (sgr_cmds.length > 0) { + let is_foreground = (num === 38); + let mode_cmd = sgr_cmds.shift(); + if (mode_cmd === '5' && sgr_cmds.length > 0) { + let palette_index = parseInt(sgr_cmds.shift(), 10); + if (palette_index >= 0 && palette_index <= 255) { + if (is_foreground) + this.fg = this.palette_256[palette_index]; + else + this.bg = this.palette_256[palette_index]; + } + } + if (mode_cmd === '2' && sgr_cmds.length > 2) { + let r = parseInt(sgr_cmds.shift(), 10); + let g = parseInt(sgr_cmds.shift(), 10); + let b = parseInt(sgr_cmds.shift(), 10); + if ((r >= 0 && r <= 255) && (g >= 0 && g <= 255) && (b >= 0 && b <= 255)) { + let c = { rgb: [r, g, b], class_name: 'truecolor' }; + if (is_foreground) + this.fg = c; + else + this.bg = c; + } + } + } + } + } + } + transform_to_html(fragment) { + let txt = fragment.text; + if (txt.length === 0) + return txt; + txt = this.escape_txt_for_html(txt); + if (!fragment.bold && !fragment.italic && !fragment.faint && !fragment.underline && fragment.fg === null && fragment.bg === null) + return txt; + let styles = []; + let classes = []; + let fg = fragment.fg; + let bg = fragment.bg; + if (fragment.bold) + styles.push(this._boldStyle); + if (fragment.faint) + styles.push(this._faintStyle); + if (fragment.italic) + styles.push(this._italicStyle); + if (fragment.underline) + styles.push(this._underlineStyle); + if (!this._use_classes) { + if (fg) + styles.push(`color:rgb(${fg.rgb.join(',')})`); + if (bg) + styles.push(`background-color:rgb(${bg.rgb})`); + } + else { + if (fg) { + if (fg.class_name !== 'truecolor') { + classes.push(`${fg.class_name}-fg`); + } + else { + styles.push(`color:rgb(${fg.rgb.join(',')})`); + } + } + if (bg) { + if (bg.class_name !== 'truecolor') { + classes.push(`${bg.class_name}-bg`); + } + else { + styles.push(`background-color:rgb(${bg.rgb.join(',')})`); + } + } + } + let class_string = ''; + let style_string = ''; + if (classes.length) + class_string = ` class="${classes.join(' ')}"`; + if (styles.length) + style_string = ` style="${styles.join(';')}"`; + return `${txt}`; + } + ; + process_hyperlink(pkt) { + let parts = pkt.url.split(':'); + if (parts.length < 1) + return ''; + if (!this._url_allowlist[parts[0]]) + return ''; + let result = `${this.escape_txt_for_html(pkt.text)}`; + return result; + } +} +function rgx(tmplObj, ...subst) { + let regexText = tmplObj.raw[0]; + let wsrgx = /^\s+|\s+\n|\s*#[\s\S]*?\n|\n/gm; + let txt2 = regexText.replace(wsrgx, ''); + return new RegExp(txt2); +} +function rgxG(tmplObj, ...subst) { + let regexText = tmplObj.raw[0]; + let wsrgx = /^\s+|\s+\n|\s*#[\s\S]*?\n|\n/gm; + let txt2 = regexText.replace(wsrgx, ''); + return new RegExp(txt2, 'g'); +} +var templateObject_1, templateObject_2, templateObject_3; diff --git a/native-image/wasm-spring-shell/web/index.html b/native-image/wasm-spring-shell/web/index.html new file mode 100644 index 000000000..0af637738 --- /dev/null +++ b/native-image/wasm-spring-shell/web/index.html @@ -0,0 +1,179 @@ + + + + + + Spring Shell on WebAssembly + + + + +
+
+ + + + +

Spring Shell on WebAssembly

+
+
+
+
+

+            
+
+
+ +
+ + + + diff --git a/native-image/wasm-spring-shell/web/main.mjs b/native-image/wasm-spring-shell/web/main.mjs new file mode 100644 index 000000000..6d33fb0b0 --- /dev/null +++ b/native-image/wasm-spring-shell/web/main.mjs @@ -0,0 +1,74 @@ +import { AnsiUp } from "./ansi_up.js"; +const ansi_up = new AnsiUp(); +const consolePrefix = "shell:>"; + +function moveCaretToEnd(el) { + el.focus(); + const range = document.createRange(); + range.selectNodeContents(el); + range.collapse(false); + const sel = window.getSelection(); + sel.removeAllRanges(); + sel.addRange(range); + el.scrollTop = el.scrollHeight; +} + +const output = document.getElementById("output"); +output.addEventListener("keydown", async (event) => { + if (event.key === "Enter") { + const lines = event.target.textContent.split("\n"); + const lastLine = lines[lines.length - 1]; + if (lastLine.startsWith(consolePrefix)) { + disable(); + output.innerHTML += "\n"; + const line = lastLine.slice(consolePrefix.length); + postLine(line); + } + } +}); + +function enable() { + output.setAttribute("contenteditable", "true"); + output.focus(); + output.innerHTML += consolePrefix; + moveCaretToEnd(output); +} + +function disable() { + output.setAttribute("contenteditable", "false"); +} + +window.appendOutput = (line) => { + const html = ansi_up.ansi_to_html(line); + output.innerHTML += html + "\n"; +}; + +const worker = new Worker("./worker.js"); + +function postLine(str) { + var bytes = new TextEncoder().encode(str); + sharedI32[1] = bytes.length; + new Uint8Array(sharedBuffer, 8).set(bytes); + Atomics.store(sharedI32, 0, 1); + Atomics.notify(sharedI32, 0, 1); +} + +worker.onmessage = (e) => { + const data = e.data; + const type = data.type; + + console.log(`Got message: ${type}`); + + if (type === "sab") { + globalThis.sharedBuffer = data.sab; + globalThis.sharedI32 = new Int32Array(globalThis.sharedBuffer); + } else if (type === "ready") { + enable(); + } else if (type === "output") { + appendOutput(data.message); + } else if (type === "error") { + appendOutput("Got error " + data.message); + } else { + appendOutput("Got unknown message: " + type); + } +}; diff --git a/native-image/wasm-spring-shell/web/server.py b/native-image/wasm-spring-shell/web/server.py new file mode 100755 index 000000000..4bb1ab6ac --- /dev/null +++ b/native-image/wasm-spring-shell/web/server.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python3 +import sys +from http.server import HTTPServer, SimpleHTTPRequestHandler, test + + +class CORSRequestHandler(SimpleHTTPRequestHandler): + def end_headers(self): + # These headers need to be set so that the SharedArrayBuffer can be + # passed from the worker to the main thread + self.send_header("Cross-Origin-Opener-Policy", "same-origin") + self.send_header("Cross-Origin-Embedder-Policy", "require-corp") + SimpleHTTPRequestHandler.end_headers(self) + + +if __name__ == "__main__": + test( + CORSRequestHandler, + HTTPServer, + port=int(sys.argv[1]) if len(sys.argv) > 1 else 8000, + ) diff --git a/native-image/wasm-spring-shell/web/worker.js b/native-image/wasm-spring-shell/web/worker.js new file mode 100644 index 000000000..c84ba1b93 --- /dev/null +++ b/native-image/wasm-spring-shell/web/worker.js @@ -0,0 +1,10 @@ +globalThis.sharedBuffer = new SharedArrayBuffer(65536); +globalThis.sharedI32 = new Int32Array(globalThis.sharedBuffer); +postMessage({ + type: "sab", + sab: globalThis.sharedBuffer, +}); +importScripts("./wasm-spring-shell.js"); +const config = new GraalVM.Config(); +config.wasm_path = "./wasm-spring-shell.js.wasm"; +GraalVM.run([], config);