Initial commit

This commit is contained in:
Abhinav Sarkar 2024-10-10 19:12:48 +05:30
commit 57f821b30b
28 changed files with 1821 additions and 0 deletions

5
.envrc Normal file
View File

@ -0,0 +1,5 @@
#!/usr/bin/env bash
use nix
watch_file shell.nix
watch_file nix/sources.json
watch_file haskell-wasm-repl.cabal

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
dist-newstyle
result
.gcroots/
web/*.wasm

48
README.md Normal file
View File

@ -0,0 +1,48 @@
# Haskell WASM REPL Template
A template project to create [REPLs](https://en.wikipedia.org/wiki/REPL) with [Haskell](https://haskell.org) that run in the browser using [WASM](https://en.wikipedia.org/wiki/WebAssembly).
## Running
The project is orchestrated using [Nix]. After you enter the Nix shell by running `nix-shell`, you can the following commands:
- `clean`: Cleans the Cabal build directory.
- `compile`: Build the project using Cabal.
- `run`: Builds and run the project using Cabal.
- `build`: Builds the project using Nix. Builds dynamically linked executables.
- `build-static`: Builds the project using Nix. Builds statically linked executables. Requires either to be run on Linux, or to have a Nix [Linux builder](https://nixcademy.com/posts/macos-linux-builder/) configured.
- `build-docker`: Runs the previous step and creates a [Docker](https://en.wikipedia.org/wiki/Docker_(software)) image with the statically linked executables using Nix. Requires Docker to be set up on the build machine. The image will also contain the contents of the `data` directory.
- `build-wasm`: Runs the previous step and converts the Docker image into a WASM binary `out.wasm`, and copies it into the `web` directory.
- `serve`: Serves the contents of the `web` directory over HTTP locally.
The `build-static`, `build-docker`, and `build-wasm` commands take a long time to finish when run for the first time, maybe even hours. The built artifacts are cached so subsequent builds finish in minutes.
## Deploying
You can copy the contents of the `web` directory to your server and serve them as a static website. For the browser to load the WASM file, the server needs to send the following headers:
```http
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
```
It is recommended to configure your server to serve pre-compressed assets, and to pre-compress the `out.wasm` file using Brotli or Gzip. This reduces the browser load time of the website by more than half.
It is also recommended to configure cache-control headers for the assets in your server so that browsers cache them. If you change the `out.wasm` file frequently, it is recommended to figure out a cache-busting policy for it.
## Developing
This project uses the following tools/components:
- [Nix] is used to build statically linked executables and Docker images.
- [Niv](https://github.com/nmattia/niv) is used to pin Nix dependencies.
- [container2wasm](https://github.com/ktock/container2wasm) is used to convert Docker images to WASM binaries. The [WASI example](https://github.com/ktock/container2wasm/tree/main/examples/wasi-browser/htdocs) from container2wasm forms the basis of the `web` directory.
- [Xterm.js](https://github.com/xtermjs/xterm.js/) is used to create the web terminal in browsers.
- [Xterm-pty](https://github.com/mame/xterm-pty/) is used to run the WASM binary on Xterm.
- [Xterm-fit-addon](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-fit) is used to make the web terminal fullscreen.
## Using
For unknown reasons, the WASM REPL does not work on Safari. Please use it in Firefox or Chrome.
[Nix]: https://nixos.org

22
app/Main.hs Normal file
View File

@ -0,0 +1,22 @@
module Main where
import Control.Monad (forever)
import Data.Char (isSpace)
import Data.List (dropWhileEnd)
import System.IO (BufferMode (..), hFlush, hSetBuffering, stdin, stdout)
import System.IO.Error (catchIOError, isEOFError)
main :: IO ()
main = do
hSetBuffering stdin LineBuffering
hSetBuffering stdout LineBuffering
loop `catchIOError` \e -> if isEOFError e then return () else ioError e
where
loop = forever $ do
putStrLn "Please enter your name: "
putStr "> "
hFlush stdout
name <- dropWhileEnd isSpace . dropWhile isSpace <$> getLine
if null name
then loop
else putStrLn $ "Hello, " <> name <> "!\n"

0
data/a.txt Normal file
View File

11
default.nix Normal file
View File

@ -0,0 +1,11 @@
{ system ? builtins.currentSystem, static ? false }:
let
pkgs = import ./nix/nixpkgs.nix { inherit system static; };
static-deps = pkgs.callPackage ./nix/haskell-wasm-repl/static-deps.nix { };
in if !static then
pkgs.callPackage ./nix/haskell-wasm-repl { static = false; }
else
pkgs.callPackage ./nix/haskell-wasm-repl {
static = true;
inherit (static-deps) gmp6 libffi ncurses zlib;
}

12
docker.nix Normal file
View File

@ -0,0 +1,12 @@
{ system ? builtins.currentSystem }:
let
pkgs = import ./nix/nixpkgs.nix {
system = (builtins.head (builtins.split "-" system)) + "-linux";
};
haskell-wasm-repl = import ./. {
inherit system;
static = true;
};
in pkgs.callPackage ./nix/haskell-wasm-repl/docker.nix {
inherit haskell-wasm-repl;
}

22
haskell-wasm-repl.cabal Normal file
View File

@ -0,0 +1,22 @@
cabal-version: 3.4
name: haskell-wasm-repl
version: 0.1.0.0
synopsis:
A template for creating Haskell REPL executables that run in browser using WASM.
homepage: https://github.com/abhin4v/haskell-wasm-repl
license: NONE
author: Abhinav Sarkar
maintainer: abhinav@abhinavsarkar.net
category: Web
build-type: Simple
common warnings
ghc-options: -Wall
executable haskell-wasm-repl
import: warnings
main-is: Main.hs
build-depends: base ^>=4.18.2.1
hs-source-dirs: app
default-language: GHC2021

View File

@ -0,0 +1,21 @@
{ static, lib, haskell, haskellPackages, gmp6, libffi, ncurses, zlib }:
let hlib = haskell.lib.compose;
in lib.pipe (haskellPackages.callCabal2nix "haskell-wasm-repl"
(lib.cleanSource (lib.sourceFilesBySuffices ../../. [ ".hs" ".cabal" ])) { })
([
hlib.dontHaddock
(hlib.overrideCabal (old: { enableParallelBuilding = true; }))
] ++ lib.optionals static [
hlib.justStaticExecutables
hlib.disableSharedLibraries
hlib.enableDeadCodeElimination
(hlib.appendConfigureFlags [
"-O2"
"--ghc-option=-fPIC"
"--ghc-option=-optl=-static"
"--extra-lib-dirs=${gmp6}/lib"
"--extra-lib-dirs=${libffi}/lib"
"--extra-lib-dirs=${ncurses}/lib"
"--extra-lib-dirs=${zlib}/lib"
])
])

View File

@ -0,0 +1,13 @@
{ haskell-wasm-repl, dockerTools }:
dockerTools.buildImage {
name = "haskell-wasm-repl";
copyToRoot = [ ../../data ];
runAsRoot = ''
mkdir -p /data
mv *.txt /data/
'';
config = {
Cmd = [ "${haskell-wasm-repl}/bin/haskell-wasm-repl" ];
WorkingDir = "/data";
};
}

View File

@ -0,0 +1,9 @@
{ gmp6, libffi, ncurses, zlib }: {
gmp6 = gmp6.override { withStatic = true; };
libffi = libffi.overrideAttrs (old: {
dontDisableStatic = true;
doCheck = false;
});
ncurses = ncurses.override { enableStatic = true; };
zlib = zlib.static;
}

View File

@ -0,0 +1,19 @@
{ stdenv, docker, container2wasm, haskell-wasm-repl-docker }:
stdenv.mkDerivation {
pname = "haskell-wasm-repl-wasm";
version = "1.0";
buildInputs = [ docker container2wasm ];
unpackPhase = "true";
installPhase = ''
set -euo pipefail;
export HOME=$TMP
mkdir -p "$out/bin"
DOCKER_IMAGE_LABEL=`docker image load -i "${haskell-wasm-repl-docker}" | awk '{print $3}'`;
c2w "$DOCKER_IMAGE_LABEL" "$out/bin/out.wasm";
docker image rm "$DOCKER_IMAGE_LABEL";
'';
fixupPhase = "true";
}

46
nix/nixpkgs.nix Normal file
View File

@ -0,0 +1,46 @@
{ system ? builtins.currentSystem, static ? false }:
let
sources = import ./sources.nix;
nixpkgs = import sources.nixpkgs {
system = if !static then
system
else
(builtins.head (builtins.split "-" system)) + "-linux";
overlays = [
(final: prev: {
haskellPackages = if !static then
prev.haskellPackages
else
prev.haskellPackages.override {
ghc = prev.haskellPackages.ghc.override {
enableRelocatedStaticLibs = true;
enableShared = false;
enableDwarf = false;
};
buildHaskellPackages =
prev.haskellPackages.buildHaskellPackages.override
(old: { ghc = final.haskellPackages.ghc; });
};
})
(final: prev: {
haskell = if !static then
prev.haskell
else
prev.haskell // {
packageOverrides =
prev.lib.composeExtensions prev.haskell.packageOverrides
(hfinal: hprev: {
mkDerivation = args:
hprev.mkDerivation (args // {
doCheck = false;
doHaddock = false;
enableLibraryProfiling = false;
enableExecutableProfiling = false;
});
});
};
})
];
config = { };
};
in if static then nixpkgs.pkgsMusl else nixpkgs

14
nix/sources.json Normal file
View File

@ -0,0 +1,14 @@
{
"nixpkgs": {
"branch": "nixpkgs-unstable",
"description": "Nix Packages collection & NixOS",
"homepage": "",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "280db3decab4cbeb22a4599bd472229ab74d25e1",
"sha256": "17n9wji64l7d16s8r100ypwlxkmwrypll4q3wkkfjswbilxkqjr6",
"type": "tarball",
"url": "https://github.com/NixOS/nixpkgs/archive/280db3decab4cbeb22a4599bd472229ab74d25e1.tar.gz",
"url_template": "https://github.com/<owner>/<repo>/archive/<rev>.tar.gz"
}
}

198
nix/sources.nix Normal file
View File

@ -0,0 +1,198 @@
# This file has been generated by Niv.
let
#
# The fetchers. fetch_<type> fetches specs of type <type>.
#
fetch_file = pkgs: name: spec:
let
name' = sanitizeName name + "-src";
in
if spec.builtin or true then
builtins_fetchurl { inherit (spec) url sha256; name = name'; }
else
pkgs.fetchurl { inherit (spec) url sha256; name = name'; };
fetch_tarball = pkgs: name: spec:
let
name' = sanitizeName name + "-src";
in
if spec.builtin or true then
builtins_fetchTarball { name = name'; inherit (spec) url sha256; }
else
pkgs.fetchzip { name = name'; inherit (spec) url sha256; };
fetch_git = name: spec:
let
ref =
spec.ref or (
if spec ? branch then "refs/heads/${spec.branch}" else
if spec ? tag then "refs/tags/${spec.tag}" else
abort "In git source '${name}': Please specify `ref`, `tag` or `branch`!"
);
submodules = spec.submodules or false;
submoduleArg =
let
nixSupportsSubmodules = builtins.compareVersions builtins.nixVersion "2.4" >= 0;
emptyArgWithWarning =
if submodules
then
builtins.trace
(
"The niv input \"${name}\" uses submodules "
+ "but your nix's (${builtins.nixVersion}) builtins.fetchGit "
+ "does not support them"
)
{ }
else { };
in
if nixSupportsSubmodules
then { inherit submodules; }
else emptyArgWithWarning;
in
builtins.fetchGit
({ url = spec.repo; inherit (spec) rev; inherit ref; } // submoduleArg);
fetch_local = spec: spec.path;
fetch_builtin-tarball = name: throw
''[${name}] The niv type "builtin-tarball" is deprecated. You should instead use `builtin = true`.
$ niv modify ${name} -a type=tarball -a builtin=true'';
fetch_builtin-url = name: throw
''[${name}] The niv type "builtin-url" will soon be deprecated. You should instead use `builtin = true`.
$ niv modify ${name} -a type=file -a builtin=true'';
#
# Various helpers
#
# https://github.com/NixOS/nixpkgs/pull/83241/files#diff-c6f540a4f3bfa4b0e8b6bafd4cd54e8bR695
sanitizeName = name:
(
concatMapStrings (s: if builtins.isList s then "-" else s)
(
builtins.split "[^[:alnum:]+._?=-]+"
((x: builtins.elemAt (builtins.match "\\.*(.*)" x) 0) name)
)
);
# The set of packages used when specs are fetched using non-builtins.
mkPkgs = sources: system:
let
sourcesNixpkgs =
import (builtins_fetchTarball { inherit (sources.nixpkgs) url sha256; }) { inherit system; };
hasNixpkgsPath = builtins.any (x: x.prefix == "nixpkgs") builtins.nixPath;
hasThisAsNixpkgsPath = <nixpkgs> == ./.;
in
if builtins.hasAttr "nixpkgs" sources
then sourcesNixpkgs
else if hasNixpkgsPath && ! hasThisAsNixpkgsPath then
import <nixpkgs> { }
else
abort
''
Please specify either <nixpkgs> (through -I or NIX_PATH=nixpkgs=...) or
add a package called "nixpkgs" to your sources.json.
'';
# The actual fetching function.
fetch = pkgs: name: spec:
if ! builtins.hasAttr "type" spec then
abort "ERROR: niv spec ${name} does not have a 'type' attribute"
else if spec.type == "file" then fetch_file pkgs name spec
else if spec.type == "tarball" then fetch_tarball pkgs name spec
else if spec.type == "git" then fetch_git name spec
else if spec.type == "local" then fetch_local spec
else if spec.type == "builtin-tarball" then fetch_builtin-tarball name
else if spec.type == "builtin-url" then fetch_builtin-url name
else
abort "ERROR: niv spec ${name} has unknown type ${builtins.toJSON spec.type}";
# If the environment variable NIV_OVERRIDE_${name} is set, then use
# the path directly as opposed to the fetched source.
replace = name: drv:
let
saneName = stringAsChars (c: if (builtins.match "[a-zA-Z0-9]" c) == null then "_" else c) name;
ersatz = builtins.getEnv "NIV_OVERRIDE_${saneName}";
in
if ersatz == "" then drv else
# this turns the string into an actual Nix path (for both absolute and
# relative paths)
if builtins.substring 0 1 ersatz == "/" then /. + ersatz else /. + builtins.getEnv "PWD" + "/${ersatz}";
# Ports of functions for older nix versions
# a Nix version of mapAttrs if the built-in doesn't exist
mapAttrs = builtins.mapAttrs or (
f: set: with builtins;
listToAttrs (map (attr: { name = attr; value = f attr set.${attr}; }) (attrNames set))
);
# https://github.com/NixOS/nixpkgs/blob/0258808f5744ca980b9a1f24fe0b1e6f0fecee9c/lib/lists.nix#L295
range = first: last: if first > last then [ ] else builtins.genList (n: first + n) (last - first + 1);
# https://github.com/NixOS/nixpkgs/blob/0258808f5744ca980b9a1f24fe0b1e6f0fecee9c/lib/strings.nix#L257
stringToCharacters = s: map (p: builtins.substring p 1 s) (range 0 (builtins.stringLength s - 1));
# https://github.com/NixOS/nixpkgs/blob/0258808f5744ca980b9a1f24fe0b1e6f0fecee9c/lib/strings.nix#L269
stringAsChars = f: s: concatStrings (map f (stringToCharacters s));
concatMapStrings = f: list: concatStrings (map f list);
concatStrings = builtins.concatStringsSep "";
# https://github.com/NixOS/nixpkgs/blob/8a9f58a375c401b96da862d969f66429def1d118/lib/attrsets.nix#L331
optionalAttrs = cond: as: if cond then as else { };
# fetchTarball version that is compatible between all the versions of Nix
builtins_fetchTarball = { url, name ? null, sha256 }@attrs:
let
inherit (builtins) lessThan nixVersion fetchTarball;
in
if lessThan nixVersion "1.12" then
fetchTarball ({ inherit url; } // (optionalAttrs (name != null) { inherit name; }))
else
fetchTarball attrs;
# fetchurl version that is compatible between all the versions of Nix
builtins_fetchurl = { url, name ? null, sha256 }@attrs:
let
inherit (builtins) lessThan nixVersion fetchurl;
in
if lessThan nixVersion "1.12" then
fetchurl ({ inherit url; } // (optionalAttrs (name != null) { inherit name; }))
else
fetchurl attrs;
# Create the final "sources" from the config
mkSources = config:
mapAttrs
(
name: spec:
if builtins.hasAttr "outPath" spec
then
abort
"The values in sources.json should not have an 'outPath' attribute"
else
spec // { outPath = replace name (fetch config.pkgs name spec); }
)
config.sources;
# The "config" used by the fetchers
mkConfig =
{ sourcesFile ? if builtins.pathExists ./sources.json then ./sources.json else null
, sources ? if sourcesFile == null then { } else builtins.fromJSON (builtins.readFile sourcesFile)
, system ? builtins.currentSystem
, pkgs ? mkPkgs sources system
}: rec {
# The sources, i.e. the attribute set of spec name to spec
inherit sources;
# The "pkgs" (evaluated nixpkgs) to use for e.g. non-builtin fetchers
inherit pkgs;
};
in
mkSources (mkConfig { }) // { __functor = _: settings: mkSources (mkConfig settings); }

21
server.py Normal file
View File

@ -0,0 +1,21 @@
import http.server
import sys
class CustomHTTPRequestHandler(http.server.SimpleHTTPRequestHandler):
def __init__(self, *args, **kwargs):
super().__init__(directory='web', *args, **kwargs)
def end_headers(self):
self.send_header('Cross-Origin-Opener-Policy', 'same-origin')
self.send_header('Cross-Origin-Embedder-Policy', 'require-corp')
super().end_headers()
if __name__ == '__main__':
print(f"Serving HTTP on port 8000 (http://127.0.0.1:8000/) ...")
try:
server_address = ('', 8000)
httpd = http.server.HTTPServer(server_address, CustomHTTPRequestHandler)
httpd.serve_forever()
except KeyboardInterrupt:
print("\nKeyboard interrupt received, exiting.")
sys.exit(0)

41
shell.nix Normal file
View File

@ -0,0 +1,41 @@
{ system ? builtins.currentSystem }:
let
pkgs = import ./nix/nixpkgs.nix { inherit system; };
myHaskellPackages = pkgs.haskellPackages.extend
(final: prev: { haskell-wasm-repl = import ./. { inherit system; }; });
scripts = [
(pkgs.writeShellScriptBin "clean" "cabal clean")
(pkgs.writeShellScriptBin "compile"
''cabal build -j -O2 --ghc-options="-j" "$@"'')
(pkgs.writeShellScriptBin "run"
''cabal run -j -O2 --ghc-options="-j" "$@"'')
(pkgs.writeShellScriptBin "create-static-env-gcroot" ''
nix-store --realise --add-root .gcroots/static-env `nix-instantiate static-env.nix 2> /dev/null`
'')
(pkgs.writeShellScriptBin "build" "nom-build")
(pkgs.writeShellScriptBin "build-static" ''
set -xeuo pipefail;
nom-build --arg static true
create-static-env-gcroot
'')
(pkgs.writeShellScriptBin "build-docker" ''
set -xeuo pipefail;
nom-build docker.nix
create-static-env-gcroot
'')
(pkgs.writeShellScriptBin "build-wasm" ''
set -xeuo pipefail;
nom-build wasm.nix
cp -f result/bin/out.wasm web/
create-static-env-gcroot
'')
(pkgs.writeShellScriptBin "serve" ''
${pkgs.python3}/bin/python server.py
'')
];
in myHaskellPackages.shellFor {
name = "haskell-shell";
packages = p: [ p.haskell-wasm-repl ];
nativeBuildInputs = with pkgs;
[ cabal-install niv nix-output-monitor ] ++ scripts;
}

24
static-env.nix Normal file
View File

@ -0,0 +1,24 @@
{ system ? builtins.currentSystem }:
let
pkgs = import ./nix/nixpkgs.nix {
inherit system;
static = true;
};
haskell-wasm-repl = import ./. {
inherit system;
static = true;
};
ghc-env = pkgs.haskellPackages.ghc.withPackages
(_: pkgs.haskell.lib.getHaskellBuildInputs haskell-wasm-repl);
static-deps = pkgs.callPackage ./nix/haskell-wasm-repl/static-deps.nix { };
in pkgs.symlinkJoin {
name = "haskell-wasm-repl-static-env";
paths = [
ghc-env
pkgs.cabal2nix-unwrapped
static-deps.gmp6
static-deps.libffi
static-deps.ncurses
static-deps.zlib
];
}

8
wasm.nix Normal file
View File

@ -0,0 +1,8 @@
{ system ? builtins.currentSystem }:
let
pkgs = import ./nix/nixpkgs.nix {
system = (builtins.head (builtins.split "-" system)) + "-linux";
};
in pkgs.callPackage ./nix/haskell-wasm-repl/wasm.nix {
haskell-wasm-repl-docker = import ./docker.nix { inherit system; };
}

1
web/browser_wasi_shim/index.js Executable file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

58
web/index.html Executable file
View File

@ -0,0 +1,58 @@
<!DOCTYPE html>
<html>
<head>
<title>Haskell WASM REPL</title>
<link rel="preload" href="out.wasm" as="fetch">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@4.17.0/css/xterm.css">
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='34' height='23'%3E%3Cpath d='M 13.926758 17.000000 L 11.281250 17.000000 L 14.665039 7.718750 L 14.401367 7.068359 Q 14.287109 6.778320 14.124512 6.483887 Q 13.961914 6.189453 13.746582 5.952148 Q 13.531250 5.714844 13.271973 5.569824 Q 13.012695 5.424805 12.696289 5.424805 Q 12.546875 5.424805 12.371094 5.442383 Q 12.195312 5.459961 12.019531 5.477539 L 11.966797 3.649414 Q 12.107422 3.614258 12.283203 3.574707 Q 12.458984 3.535156 12.647949 3.508789 Q 12.836914 3.482422 13.025879 3.464844 Q 13.214844 3.447266 13.390625 3.447266 Q 14.032227 3.447266 14.528809 3.693359 Q 15.025391 3.939453 15.390137 4.299805 Q 15.754883 4.660156 15.996582 5.086426 Q 16.238281 5.512695 16.387695 5.864258 L 19.437500 13.633789 Q 19.525391 13.906250 19.648438 14.183105 Q 19.771484 14.459961 19.934082 14.679688 Q 20.096680 14.899414 20.294434 15.035645 Q 20.492188 15.171875 20.720703 15.171875 Q 20.791016 15.171875 20.830566 15.171875 Q 20.870117 15.171875 20.905273 15.167480 Q 20.940430 15.163086 20.984375 15.158691 Q 21.028320 15.154297 21.107422 15.145508 L 21.054688 17.061523 Q 20.878906 17.114258 20.681152 17.136230 Q 20.483398 17.158203 20.307617 17.158203 Q 19.648438 17.158203 19.138672 16.947266 Q 18.628906 16.736328 18.250977 16.380371 Q 17.873047 16.024414 17.604980 15.562988 Q 17.336914 15.101562 17.161133 14.609375 L 15.772461 10.733398 L 15.517578 11.955078 Z ' fill='rgb(255, 147, 0)' fill-opacity='1.000000' /%3E%3C/svg%3E" />
<style>
body {
height: calc(100vh - 2rem);
width: calc(100% - 2rem);
margin: 1rem;
background-color: #000;
}
#terminal {
height: 100%;
}
.xterm {
margin-bottom: 1rem;
}
</style>
</head>
<body>
<noscript style="color: lightgreen;">Please enable JavaScript to use this program.</noscript>
<div id="terminal"></div>
<script src="https://cdn.jsdelivr.net/npm/xterm@4.17.0/lib/xterm.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-pty@0.9.4/index.js"></script>
<script src=" https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.5.0/lib/xterm-addon-fit.min.js "></script>
<script src="./stack.js"></script>
<script src="./ws-delegate.js"></script>
<script>
const xterm = new Terminal();
const fitAddon = new FitAddon.FitAddon();
xterm.loadAddon(fitAddon);
xterm.open(document.getElementById("terminal"));
fitAddon.fit();
xterm.options.fontFamily = "'Fira mono', 'Source code pro', ui-monospace, 'Cascadia Mono', 'Segoe UI Mono', 'Ubuntu Mono', 'Roboto Mono', Menlo, Monaco, Consolas, monospace";
const { master, slave } = openpty();
termios = slave.ioctl("TCGETS");
termios.iflag &= ~(/*IGNBRK | BRKINT | PARMRK |*/ ISTRIP | INLCR | IGNCR | ICRNL | IXON);
termios.oflag &= ~(OPOST);
termios.lflag &= ~(ECHO | ECHONL | ICANON | ISIG | IEXTEN);
//termios.cflag &= ~(CSIZE | PARENB);
//termios.cflag |= CS8;
slave.ioctl("TCSETS", new Termios(termios.iflag, termios.oflag, termios.cflag, termios.lflag, termios.cc));
xterm.loadAddon(master);
slave.write('\x1B[1;32mLoading Haskell WASM REPL. Please wait for ~10 seconds.\x1B[0m\n');
slave.write('\x1B[1E');
const worker = new Worker("./worker.js"+location.search);
var workerImage = location.origin + "/out.wasm";
worker.postMessage({type: "init", imagename: workerImage});
new TtyServer(slave).start(worker, null);
</script>
</body>
</html>

245
web/stack-worker.js Executable file
View File

@ -0,0 +1,245 @@
importScripts(location.origin + "/browser_wasi_shim/index.js");
importScripts(location.origin + "/browser_wasi_shim/wasi_defs.js");
importScripts(location.origin + "/worker-util.js");
importScripts(location.origin + "/wasi-util.js");
onmessage = (msg) => {
serveIfInitMsg(msg);
var fds = [
undefined, // 0: stdin
undefined, // 1: stdout
undefined, // 2: stderr
undefined, // 3: receive certificates
undefined, // 4: socket listenfd
undefined, // 5: accepted socket fd (multi-connection is unsupported)
// 6...: used by wasi shim
];
var certfd = 3;
var listenfd = 4;
var args = ['arg0', '--certfd='+certfd, '--net-listenfd='+listenfd, '--debug'];
var env = [];
var wasi = new WASI(args, env, fds);
wasiHack(wasi, certfd, 5);
wasiHackSocket(wasi, listenfd, 5);
fetch(getImagename(), { credentials: 'same-origin' }).then((resp) => {
resp['arrayBuffer']().then((wasm) => {
WebAssembly.instantiate(wasm, {
"wasi_snapshot_preview1": wasi.wasiImport,
"env": envHack(wasi),
}).then((inst) => {
wasi.start(inst.instance);
});
})
});
};
// definition from wasi-libc https://github.com/WebAssembly/wasi-libc/blob/wasi-sdk-19/expected/wasm32-wasi/predefined-macros.txt
const ERRNO_INVAL = 28;
const ERRNO_AGAIN= 6;
function wasiHack(wasi, certfd, connfd) {
var certbuf = new Uint8Array(0);
var _fd_close = wasi.wasiImport.fd_close;
wasi.wasiImport.fd_close = (fd) => {
if (fd == certfd) {
sendCert(certbuf);
return 0;
}
return _fd_close.apply(wasi.wasiImport, [fd]);
}
var _fd_fdstat_get = wasi.wasiImport.fd_fdstat_get;
wasi.wasiImport.fd_fdstat_get = (fd, fdstat_ptr) => {
if (fd == certfd) {
return 0;
}
return _fd_fdstat_get.apply(wasi.wasiImport, [fd, fdstat_ptr]);
}
wasi.wasiImport.fd_fdstat_set_flags = (fd, fdflags) => {
// TODO
return 0;
}
var _fd_write = wasi.wasiImport.fd_write;
wasi.wasiImport.fd_write = (fd, iovs_ptr, iovs_len, nwritten_ptr) => {
if ((fd == 1) || (fd == 2) || (fd == certfd)) {
var buffer = new DataView(wasi.inst.exports.memory.buffer);
var buffer8 = new Uint8Array(wasi.inst.exports.memory.buffer);
var iovecs = Ciovec.read_bytes_array(buffer, iovs_ptr, iovs_len);
var wtotal = 0
for (i = 0; i < iovecs.length; i++) {
var iovec = iovecs[i];
var buf = buffer8.slice(iovec.buf, iovec.buf + iovec.buf_len);
if (buf.length == 0) {
continue;
}
console.log(new TextDecoder().decode(buf));
if (fd == certfd) {
certbuf = appendData(certbuf, buf);
}
wtotal += buf.length;
}
buffer.setUint32(nwritten_ptr, wtotal, true);
return 0;
}
console.log("fd_write: unknown fd " + fd);
return _fd_write.apply(wasi.wasiImport, [fd, iovs_ptr, iovs_len, nwritten_ptr]);
}
wasi.wasiImport.poll_oneoff = (in_ptr, out_ptr, nsubscriptions, nevents_ptr) => {
if (nsubscriptions == 0) {
return ERRNO_INVAL;
}
let buffer = new DataView(wasi.inst.exports.memory.buffer);
let in_ = Subscription.read_bytes_array(buffer, in_ptr, nsubscriptions);
let isReadPollStdin = false;
let isReadPollConn = false;
let isClockPoll = false;
let pollSubStdin;
let pollSubConn;
let clockSub;
let timeout = Number.MAX_VALUE;
for (let sub of in_) {
if (sub.u.tag.variant == "fd_read") {
if ((sub.u.data.fd != 0) && (sub.u.data.fd != connfd)) {
return ERRNO_INVAL; // only fd=0 and connfd is supported as of now (FIXME)
}
if (sub.u.data.fd == 0) {
isReadPollStdin = true;
pollSubStdin = sub;
} else {
isReadPollConn = true;
pollSubConn = sub;
}
} else if (sub.u.tag.variant == "clock") {
if (sub.u.data.timeout < timeout) {
timeout = sub.u.data.timeout
isClockPoll = true;
clockSub = sub;
}
} else {
return ERRNO_INVAL; // FIXME
}
}
let events = [];
if (isReadPollStdin || isReadPollConn || isClockPoll) {
var sockreadable = sockWaitForReadable(timeout / 1000000000);
if (isReadPollConn) {
if (sockreadable == errStatus) {
return ERRNO_INVAL;
} else if (sockreadable == true) {
let event = new Event();
event.userdata = pollSubConn.userdata;
event.error = 0;
event.type = new EventType("fd_read");
events.push(event);
}
}
if (isClockPoll) {
let event = new Event();
event.userdata = clockSub.userdata;
event.error = 0;
event.type = new EventType("clock");
events.push(event);
}
}
var len = events.length;
Event.write_bytes_array(buffer, out_ptr, events);
buffer.setUint32(nevents_ptr, len, true);
return 0;
}
}
function envHack(wasi){
return {
http_send: function(addressP, addresslen, reqP, reqlen, idP){
var buffer = new DataView(wasi.inst.exports.memory.buffer);
var address = new Uint8Array(wasi.inst.exports.memory.buffer, addressP, addresslen);
var req = new Uint8Array(wasi.inst.exports.memory.buffer, reqP, reqlen);
streamCtrl[0] = 0;
postMessage({
type: "http_send",
address: address,
req: req,
});
Atomics.wait(streamCtrl, 0, 0);
if (streamStatus[0] < 0) {
return ERRNO_INVAL;
}
var id = streamStatus[0];
buffer.setUint32(idP, id, true);
return 0;
},
http_writebody: function(id, bodyP, bodylen, nwrittenP, isEOF){
var buffer = new DataView(wasi.inst.exports.memory.buffer);
var body = new Uint8Array(wasi.inst.exports.memory.buffer, bodyP, bodylen);
streamCtrl[0] = 0;
postMessage({
type: "http_writebody",
id: id,
body: body,
isEOF: isEOF,
});
Atomics.wait(streamCtrl, 0, 0);
if (streamStatus[0] < 0) {
return ERRNO_INVAL;
}
buffer.setUint32(nwrittenP, bodylen, true);
return 0;
},
http_isreadable: function(id, isOKP){
var buffer = new DataView(wasi.inst.exports.memory.buffer);
streamCtrl[0] = 0;
postMessage({type: "http_isreadable", id: id});
Atomics.wait(streamCtrl, 0, 0);
if (streamStatus[0] < 0) {
return ERRNO_INVAL;
}
var readable = 0;
if (streamData[0] == 1) {
readable = 1;
}
buffer.setUint32(isOKP, readable, true);
return 0;
},
http_recv: function(id, respP, bufsize, respsizeP, isEOFP){
var buffer = new DataView(wasi.inst.exports.memory.buffer);
var buffer8 = new Uint8Array(wasi.inst.exports.memory.buffer);
streamCtrl[0] = 0;
postMessage({type: "http_recv", id: id, len: bufsize});
Atomics.wait(streamCtrl, 0, 0);
if (streamStatus[0] < 0) {
return ERRNO_INVAL;
}
var ddlen = streamLen[0];
var resp = streamData.slice(0, ddlen);
buffer8.set(resp, respP);
buffer.setUint32(respsizeP, ddlen, true);
if (streamStatus[0] == 1) {
buffer.setUint32(isEOFP, 1, true);
} else {
buffer.setUint32(isEOFP, 0, true);
}
return 0;
},
http_readbody: function(id, bodyP, bufsize, bodysizeP, isEOFP){
var buffer = new DataView(wasi.inst.exports.memory.buffer);
var buffer8 = new Uint8Array(wasi.inst.exports.memory.buffer);
streamCtrl[0] = 0;
postMessage({type: "http_readbody", id: id, len: bufsize});
Atomics.wait(streamCtrl, 0, 0);
if (streamStatus[0] < 0) {
return ERRNO_INVAL;
}
var ddlen = streamLen[0];
var body = streamData.slice(0, ddlen);
buffer8.set(body, bodyP);
buffer.setUint32(bodysizeP, ddlen, true);
if (streamStatus[0] == 1) {
buffer.setUint32(isEOFP, 1, true);
} else {
buffer.setUint32(isEOFP, 0, true);
}
return 0;
}
};
}

259
web/stack.js Executable file
View File

@ -0,0 +1,259 @@
function newStack(worker, workerImageName, stackWorker, stackImageName) {
let p2vbuf = {
buf: new Uint8Array(0) // proxy => vm
};
let v2pbuf = {
buf: new Uint8Array(0) // vm => proxy
};
var proxyConn = {
sendbuf: p2vbuf,
recvbuf: v2pbuf
};
var vmConn = {
sendbuf: v2pbuf,
recvbuf: p2vbuf
}
var proxyShared = new SharedArrayBuffer(12 + 4096);
var certbuf = {
buf: new Uint8Array(0),
done: false
}
stackWorker.onmessage = connect("proxy", proxyShared, proxyConn, certbuf);
stackWorker.postMessage({type: "init", buf: proxyShared, imagename: stackImageName});
var vmShared = new SharedArrayBuffer(12 + 4096);
worker.postMessage({type: "init", buf: vmShared, imagename: workerImageName});
return connect("vm", vmShared, vmConn, certbuf);
}
function connect(name, shared, conn, certbuf) {
var streamCtrl = new Int32Array(shared, 0, 1);
var streamStatus = new Int32Array(shared, 4, 1);
var streamLen = new Int32Array(shared, 8, 1);
var streamData = new Uint8Array(shared, 12);
var sendbuf = conn.sendbuf;
var recvbuf = conn.recvbuf;
let accepted = false;
var httpConnections = {};
var curID = 0;
var maxID = 0x7FFFFFFF; // storable in streamStatus(signed 32bits)
function getID() {
var startID = curID;
while (true) {
if (httpConnections[curID] == undefined) {
return curID;
}
if (curID >= maxID) {
curID = 0;
} else {
curID++;
}
if (curID == startID) {
return -1; // exhausted
}
}
return curID;
}
function serveData(data, len) {
var length = len;
if (length > streamData.byteLength)
length = streamData.byteLength;
if (length > data.byteLength)
length = data.byteLength
var buf = data.slice(0, length);
var remain = data.slice(length, data.byteLength);
streamLen[0] = buf.byteLength;
streamData.set(buf, 0);
return remain;
}
return function(msg){
const req_ = msg.data;
if (typeof req_ == "object" && req_.type) {
switch (req_.type) {
case "accept":
accepted = true;
streamData[0] = 1; // opened
streamStatus[0] = 0;
break;
case "send":
if (!accepted) {
console.log(name + ":" + "cannot send to unaccepted socket");
streamStatus[0] = -1;
break;
}
sendbuf.buf = appendData(sendbuf.buf, req_.buf);
streamStatus[0] = 0;
break;
case "recv":
if (!accepted) {
console.log(name + ":" + "cannot recv from unaccepted socket");
streamStatus[0] = -1;
break;
}
recvbuf.buf = serveData(recvbuf.buf, req_.len);
streamStatus[0] = 0;
break;
case "recv-is-readable":
var recvbufP = recvbuf.buf;
if (recvbufP.byteLength > 0) {
streamData[0] = 1; // ready for reading
} else {
if ((req_.timeout != undefined) && (req_.timeout > 0)) {
if (this.timeoutHandler) {
clearTimeout(this.timeoutHandler);
this.timeoutHandler = null;
}
this.timeoutHandler = setTimeout(() => {
if (this.timeoutHandler) {
clearTimeout(this.timeoutHandler);
this.timeoutHandler = null;
}
if (recvbuf.buf.byteLength > 0) {
streamData[0] = 1; // ready for reading
} else {
streamData[0] = 0; // timeout
}
streamStatus[0] = 0;
Atomics.store(streamCtrl, 0, 1);
Atomics.notify(streamCtrl, 0);
}, req_.timeout * 1000);
return;
}
streamData[0] = 0; // timeout
}
streamStatus[0] = 0;
break;
case "http_send":
var reqObj = JSON.parse(new TextDecoder().decode(req_.req));
reqObj.mode = "cors";
reqObj.credentials = "omit";
if (reqObj.headers && reqObj.headers["User-Agent"] != "") {
delete reqObj.headers["User-Agent"]; // Browser will add its own value.
}
var reqID = getID();
if (reqID < 0) {
console.log(name + ":" + "failed to get id");
streamStatus[0] = -1;
break;
}
var connObj = {
address: new TextDecoder().decode(req_.address),
request: reqObj,
requestSent: false,
reqBodybuf: new Uint8Array(0),
reqBodyEOF: false,
};
httpConnections[reqID] = connObj;
streamStatus[0] = reqID;
break;
case "http_writebody":
httpConnections[req_.id].reqBodybuf = appendData(httpConnections[req_.id].reqBodybuf, req_.body)
httpConnections[req_.id].reqBodyEOF = req_.isEOF;
streamStatus[0] = 0;
if (req_.isEOF && !httpConnections[req_.id].requestSent) {
httpConnections[req_.id].requestSent = true;
var connObj = httpConnections[req_.id];
if ((connObj.request.method != "HEAD") && (connObj.request.method != "GET")) {
connObj.request.body = connObj.reqBodybuf;
}
fetch(connObj.address, connObj.request).then((resp) => {
connObj.response = new TextEncoder().encode(JSON.stringify({
bodyUsed: resp.bodyUsed,
headers: resp.headers,
redirected: resp.redirected,
status: resp.status,
statusText: resp.statusText,
type: resp.type,
url: resp.url
})),
connObj.done = false;
connObj.respBodybuf = new Uint8Array(0);
if (resp.ok) {
resp.arrayBuffer().then((data) => {
connObj.respBodybuf = new Uint8Array(data);
connObj.done = true;
}).catch((error) => {
connObj.respBodybuf = new Uint8Array(0);
connObj.done = true;
console.log("failed to fetch body: " + error);
});
} else {
connObj.done = true;
}
}).catch((error) => {
connObj.response = new TextEncoder().encode(JSON.stringify({
status: 503,
statusText: "Service Unavailable",
}))
connObj.respBodybuf = new Uint8Array(0);
connObj.done = true;
});
}
break;
case "http_isreadable":
if ((httpConnections[req_.id] != undefined) && (httpConnections[req_.id].response != undefined)) {
streamData[0] = 1; // ready for reading
} else {
streamData[0] = 0; // nothing to read
}
streamStatus[0] = 0;
break;
case "http_recv":
if ((httpConnections[req_.id] == undefined) || (httpConnections[req_.id].response == undefined)) {
console.log(name + ":" + "response is not available");
streamStatus[0] = -1;
break;
}
httpConnections[req_.id].response = serveData(httpConnections[req_.id].response, req_.len);
streamStatus[0] = 0;
if (httpConnections[req_.id].response.byteLength == 0) {
streamStatus[0] = 1; // isEOF
}
break;
case "http_readbody":
if ((httpConnections[req_.id] == undefined) || (httpConnections[req_.id].response == undefined)) {
console.log(name + ":" + "response body is not available");
streamStatus[0] = -1;
break;
}
httpConnections[req_.id].respBodybuf = serveData(httpConnections[req_.id].respBodybuf, req_.len);
streamStatus[0] = 0;
if ((httpConnections[req_.id].done) && (httpConnections[req_.id].respBodybuf.byteLength == 0)) {
streamStatus[0] = 1;
delete httpConnections[req_.id]; // connection done
}
break;
case "send_cert":
certbuf.buf = appendData(certbuf.buf, req_.buf);
certbuf.done = true;
streamStatus[0] = 0;
break;
case "recv_cert":
if (!certbuf.done) {
streamStatus[0] = -1;
break;
}
certbuf.buf = serveData(certbuf.buf, req_.len);
streamStatus[0] = 0;
if (certbuf.buf.byteLength == 0) {
streamStatus[0] = 1; // isEOF
}
break;
default:
console.log(name + ":" + "unknown request: " + req_.type)
return;
}
Atomics.store(streamCtrl, 0, 1);
Atomics.notify(streamCtrl, 0);
} else {
console.log("UNKNOWN MSG " + msg);
}
}
}
function appendData(data1, data2) {
buf2 = new Uint8Array(data1.byteLength + data2.byteLength);
buf2.set(new Uint8Array(data1), 0);
buf2.set(new Uint8Array(data2), data1.byteLength);
return buf2;
}

127
web/wasi-util.js Executable file
View File

@ -0,0 +1,127 @@
////////////////////////////////////////////////////////////
//
// event-related classes adopted from the on-going discussion
// towards poll_oneoff support in browser_wasi_sim project.
// Ref: https://github.com/bjorn3/browser_wasi_shim/issues/14#issuecomment-1450351935
//
////////////////////////////////////////////////////////////
class EventType {
/*:: variant: "clock" | "fd_read" | "fd_write"*/
constructor(variant/*: "clock" | "fd_read" | "fd_write"*/) {
this.variant = variant;
}
static from_u8(data/*: number*/)/*: EventType*/ {
switch (data) {
case EVENTTYPE_CLOCK:
return new EventType("clock");
case EVENTTYPE_FD_READ:
return new EventType("fd_read");
case EVENTTYPE_FD_WRITE:
return new EventType("fd_write");
default:
throw "Invalid event type " + String(data);
}
}
to_u8()/*: number*/ {
switch (this.variant) {
case "clock":
return EVENTTYPE_CLOCK;
case "fd_read":
return EVENTTYPE_FD_READ;
case "fd_write":
return EVENTTYPE_FD_WRITE;
default:
throw "unreachable";
}
}
}
class Event {
/*:: userdata: UserData*/
/*:: error: number*/
/*:: type: EventType*/
/*:: fd_readwrite: EventFdReadWrite | null*/
write_bytes(view/*: DataView*/, ptr/*: number*/) {
view.setBigUint64(ptr, this.userdata, true);
view.setUint8(ptr + 8, this.error);
view.setUint8(ptr + 9, 0);
view.setUint8(ptr + 10, this.type.to_u8());
// if (this.fd_readwrite) {
// this.fd_readwrite.write_bytes(view, ptr + 16);
// }
}
static write_bytes_array(view/*: DataView*/, ptr/*: number*/, events/*: Array<Event>*/) {
for (let i = 0; i < events.length; i++) {
events[i].write_bytes(view, ptr + 32 * i);
}
}
}
class SubscriptionClock {
/*:: timeout: number*/
static read_bytes(view/*: DataView*/, ptr/*: number*/)/*: SubscriptionFdReadWrite*/ {
let self = new SubscriptionClock();
self.timeout = Number(view.getBigUint64(ptr + 8, true));
return self;
}
}
class SubscriptionFdReadWrite {
/*:: fd: number*/
static read_bytes(view/*: DataView*/, ptr/*: number*/)/*: SubscriptionFdReadWrite*/ {
let self = new SubscriptionFdReadWrite();
self.fd = view.getUint32(ptr, true);
return self;
}
}
class SubscriptionU {
/*:: tag: EventType */
/*:: data: SubscriptionClock | SubscriptionFdReadWrite */
static read_bytes(view/*: DataView*/, ptr/*: number*/)/*: SubscriptionU*/ {
let self = new SubscriptionU();
self.tag = EventType.from_u8(view.getUint8(ptr));
switch (self.tag.variant) {
case "clock":
self.data = SubscriptionClock.read_bytes(view, ptr + 8);
break;
case "fd_read":
case "fd_write":
self.data = SubscriptionFdReadWrite.read_bytes(view, ptr + 8);
break;
default:
throw "unreachable";
}
return self;
}
}
class Subscription {
/*:: userdata: UserData */
/*:: u: SubscriptionU */
static read_bytes(view/*: DataView*/, ptr/*: number*/)/*: Subscription*/ {
let subscription = new Subscription();
subscription.userdata = view.getBigUint64(ptr, true);
subscription.u = SubscriptionU.read_bytes(view, ptr + 8);
return subscription;
}
static read_bytes_array(view/*: DataView*/, ptr/*: number*/, len/*: number*/)/*: Array<Subscription>*/ {
let subscriptions = [];
for (let i = 0; i < len; i++) {
subscriptions.push(Subscription.read_bytes(view, ptr + 48 * i));
}
return subscriptions;
}
}

275
web/worker-util.js Executable file
View File

@ -0,0 +1,275 @@
var streamCtrl;
var streamStatus;
var streamLen;
var streamData;
function registerSocketBuffer(shared){
streamCtrl = new Int32Array(shared, 0, 1);
streamStatus = new Int32Array(shared, 4, 1);
streamLen = new Int32Array(shared, 8, 1);
streamData = new Uint8Array(shared, 12);
}
var imagename;
function serveIfInitMsg(msg) {
const req_ = msg.data;
if (typeof req_ == "object"){
if (req_.type == "init") {
if (req_.buf)
var shared = req_.buf;
registerSocketBuffer(shared);
if (req_.imagename)
imagename = req_.imagename;
return true;
}
}
return false;
}
function getImagename() {
return imagename;
}
const errStatus = {
val: 0,
};
function sockAccept(){
streamCtrl[0] = 0;
postMessage({type: "accept"});
Atomics.wait(streamCtrl, 0, 0);
return streamData[0] == 1;
}
function sockSend(data){
streamCtrl[0] = 0;
postMessage({type: "send", buf: data});
Atomics.wait(streamCtrl, 0, 0);
if (streamStatus[0] < 0) {
errStatus.val = streamStatus[0]
return errStatus;
}
}
function sockRecv(len){
streamCtrl[0] = 0;
postMessage({type: "recv", len: len});
Atomics.wait(streamCtrl, 0, 0);
if (streamStatus[0] < 0) {
errStatus.val = streamStatus[0]
return errStatus;
}
let ddlen = streamLen[0];
var res = streamData.slice(0, ddlen);
return res;
}
function sockWaitForReadable(timeout){
streamCtrl[0] = 0;
postMessage({type: "recv-is-readable", timeout: timeout});
Atomics.wait(streamCtrl, 0, 0);
if (streamStatus[0] < 0) {
errStatus.val = streamStatus[0]
return errStatus;
}
return streamData[0] == 1;
}
function sendCert(data){
streamCtrl[0] = 0;
postMessage({type: "send_cert", buf: data});
Atomics.wait(streamCtrl, 0, 0);
if (streamStatus[0] < 0) {
errStatus.val = streamStatus[0]
return errStatus;
}
}
function recvCert(){
var buf = new Uint8Array(0);
return new Promise((resolve, reject) => {
function getCert(){
streamCtrl[0] = 0;
postMessage({type: "recv_cert"});
Atomics.wait(streamCtrl, 0, 0);
if (streamStatus[0] < 0) {
setTimeout(getCert, 100);
return;
}
var ddlen = streamLen[0];
buf = appendData(buf, streamData.slice(0, ddlen));
if (streamStatus[0] == 0) {
resolve(buf); // EOF
} else {
setTimeout(getCert, 0);
return;
}
}
getCert();
});
}
function appendData(data1, data2) {
buf2 = new Uint8Array(data1.byteLength + data2.byteLength);
buf2.set(new Uint8Array(data1), 0);
buf2.set(new Uint8Array(data2), data1.byteLength);
return buf2;
}
function getCertDir(cert) {
var certDir = new PreopenDirectory("/.wasmenv", {
"proxy.crt": new File(cert)
});
var _path_open = certDir.path_open;
certDir.path_open = (e, r, s, n, a, d) => {
var ret = _path_open.apply(certDir, [e, r, s, n, a, d]);
if (ret.fd_obj != null) {
var o = ret.fd_obj;
ret.fd_obj.fd_pread = (view8, iovs, offset) => {
var old_offset = o.file_pos;
var r = o.fd_seek(offset, WHENCE_SET);
if (r.ret != 0) {
return { ret: -1, nread: 0 };
}
var read_ret = o.fd_read(view8, iovs);
r = o.fd_seek(old_offset, WHENCE_SET);
if (r.ret != 0) {
return { ret: -1, nread: 0 };
}
return read_ret;
}
}
return ret;
}
certDir.dir.contents["."] = certDir.dir;
return certDir;
}
function wasiHackSocket(wasi, listenfd, connfd) {
// definition from wasi-libc https://github.com/WebAssembly/wasi-libc/blob/wasi-sdk-19/expected/wasm32-wasi/predefined-macros.txt
const ERRNO_INVAL = 28;
const ERRNO_AGAIN= 6;
var connfdUsed = false;
var connbuf = new Uint8Array(0);
var _fd_close = wasi.wasiImport.fd_close;
wasi.wasiImport.fd_close = (fd) => {
if (fd == connfd) {
connfdUsed = false;
return 0;
}
return _fd_close.apply(wasi.wasiImport, [fd]);
}
var _fd_read = wasi.wasiImport.fd_read;
wasi.wasiImport.fd_read = (fd, iovs_ptr, iovs_len, nread_ptr) => {
if (fd == connfd) {
return wasi.wasiImport.sock_recv(fd, iovs_ptr, iovs_len, 0, nread_ptr, 0);
}
return _fd_read.apply(wasi.wasiImport, [fd, iovs_ptr, iovs_len, nread_ptr]);
}
var _fd_write = wasi.wasiImport.fd_write;
wasi.wasiImport.fd_write = (fd, iovs_ptr, iovs_len, nwritten_ptr) => {
if (fd == connfd) {
return wasi.wasiImport.sock_send(fd, iovs_ptr, iovs_len, 0, nwritten_ptr);
}
return _fd_write.apply(wasi.wasiImport, [fd, iovs_ptr, iovs_len, nwritten_ptr]);
}
var _fd_fdstat_get = wasi.wasiImport.fd_fdstat_get;
wasi.wasiImport.fd_fdstat_get = (fd, fdstat_ptr) => {
if ((fd == listenfd) || (fd == connfd) && connfdUsed){
let buffer = new DataView(wasi.inst.exports.memory.buffer);
// https://github.com/WebAssembly/WASI/blob/snapshot-01/phases/snapshot/docs.md#-fdstat-struct
buffer.setUint8(fdstat_ptr, 6); // filetype = 6 (socket_stream)
buffer.setUint8(fdstat_ptr + 1, 2); // fdflags = 2 (nonblock)
return 0;
}
return _fd_fdstat_get.apply(wasi.wasiImport, [fd, fdstat_ptr]);
}
var _fd_prestat_get = wasi.wasiImport.fd_prestat_get;
wasi.wasiImport.fd_prestat_get = (fd, prestat_ptr) => {
if ((fd == listenfd) || (fd == connfd)){ // reserve socket-related fds
let buffer = new DataView(wasi.inst.exports.memory.buffer);
buffer.setUint8(prestat_ptr, 1);
return 0;
}
return _fd_prestat_get.apply(wasi.wasiImport, [fd, prestat_ptr]);
}
wasi.wasiImport.sock_accept = (fd, flags, fd_ptr) => {
if (fd != listenfd) {
console.log("sock_accept: unknown fd " + fd);
return ERRNO_INVAL;
}
if (connfdUsed) {
console.log("sock_accept: multi-connection is unsupported");
return ERRNO_INVAL;
}
if (!sockAccept()) {
return ERRNO_AGAIN;
}
connfdUsed = true;
var buffer = new DataView(wasi.inst.exports.memory.buffer);
buffer.setUint32(fd_ptr, connfd, true);
return 0;
}
wasi.wasiImport.sock_send = (fd, iovs_ptr, iovs_len, si_flags/*not defined*/, nwritten_ptr) => {
if (fd != connfd) {
console.log("sock_send: unknown fd " + fd);
return ERRNO_INVAL;
}
var buffer = new DataView(wasi.inst.exports.memory.buffer);
var buffer8 = new Uint8Array(wasi.inst.exports.memory.buffer);
var iovecs = Ciovec.read_bytes_array(buffer, iovs_ptr, iovs_len);
var wtotal = 0
for (i = 0; i < iovecs.length; i++) {
var iovec = iovecs[i];
var buf = buffer8.slice(iovec.buf, iovec.buf + iovec.buf_len);
if (buf.length == 0) {
continue;
}
var ret = sockSend(buf.buffer.slice(0, iovec.buf_len));
if (ret == errStatus) {
return ERRNO_INVAL;
}
wtotal += buf.length;
}
buffer.setUint32(nwritten_ptr, wtotal, true);
return 0;
}
wasi.wasiImport.sock_recv = (fd, iovs_ptr, iovs_len, ri_flags, nread_ptr, ro_flags_ptr) => {
if (ri_flags != 0) {
console.log("ri_flags are unsupported"); // TODO
}
if (fd != connfd) {
console.log("sock_recv: unknown fd " + fd);
return ERRNO_INVAL;
}
var sockreadable = sockWaitForReadable();
if (sockreadable == errStatus) {
return ERRNO_INVAL;
} else if (sockreadable == false) {
return ERRNO_AGAIN;
}
var buffer = new DataView(wasi.inst.exports.memory.buffer);
var buffer8 = new Uint8Array(wasi.inst.exports.memory.buffer);
var iovecs = Iovec.read_bytes_array(buffer, iovs_ptr, iovs_len);
var nread = 0;
for (i = 0; i < iovecs.length; i++) {
var iovec = iovecs[i];
if (iovec.buf_len == 0) {
continue;
}
var data = sockRecv(iovec.buf_len);
if (data == errStatus) {
return ERRNO_INVAL;
}
buffer8.set(data, iovec.buf);
nread += data.length;
}
buffer.setUint32(nread_ptr, nread, true);
// TODO: support ro_flags_ptr
return 0;
}
wasi.wasiImport.sock_shutdown = (fd, sdflags) => {
if (fd == connfd) {
connfdUsed = false;
}
return 0;
}
}

212
web/worker.js Executable file
View File

@ -0,0 +1,212 @@
importScripts("https://cdn.jsdelivr.net/npm/xterm-pty@0.9.4/workerTools.js");
importScripts(location.origin + "/browser_wasi_shim/index.js");
importScripts(location.origin + "/browser_wasi_shim/wasi_defs.js");
importScripts(location.origin + "/worker-util.js");
importScripts(location.origin + "/wasi-util.js");
onmessage = (msg) => {
if (serveIfInitMsg(msg)) {
return;
}
var ttyClient = new TtyClient(msg.data);
var args = [];
var env = [];
var fds = [];
var netParam = null;
var listenfd = 3;
fetch(getImagename(), { credentials: 'same-origin' }).then((resp) => {
resp['arrayBuffer']().then((wasm) => {
if (netParam) {
if (netParam.mode == 'delegate') {
args = ['arg0', '--net=socket', '--mac', genmac()];
} else if (netParam.mode == 'browser') {
recvCert().then((cert) => {
var certDir = getCertDir(cert);
fds = [
undefined, // 0: stdin
undefined, // 1: stdout
undefined, // 2: stderr
certDir, // 3: certificates dir
undefined, // 4: socket listenfd
undefined, // 5: accepted socket fd (multi-connection is unsupported)
// 6...: used by wasi shim
];
args = ['arg0', '--net=socket=listenfd=4', '--mac', genmac()];
env = [
"SSL_CERT_FILE=/.wasmenv/proxy.crt",
"https_proxy=http://192.168.127.253:80",
"http_proxy=http://192.168.127.253:80",
"HTTPS_PROXY=http://192.168.127.253:80",
"HTTP_PROXY=http://192.168.127.253:80"
];
listenfd = 4;
startWasi(wasm, ttyClient, args, env, fds, listenfd, 5);
});
return;
}
}
startWasi(wasm, ttyClient, args, env, fds, listenfd, 5);
})
});
};
function startWasi(wasm, ttyClient, args, env, fds, listenfd, connfd) {
var wasi = new WASI(args, env, fds);
wasiHack(wasi, ttyClient, connfd);
wasiHackSocket(wasi, listenfd, connfd);
WebAssembly.instantiate(wasm, {
"wasi_snapshot_preview1": wasi.wasiImport,
}).then((inst) => {
wasi.start(inst.instance);
});
}
// wasiHack patches wasi object for integrating it to xterm-pty.
function wasiHack(wasi, ttyClient, connfd) {
// definition from wasi-libc https://github.com/WebAssembly/wasi-libc/blob/wasi-sdk-19/expected/wasm32-wasi/predefined-macros.txt
const ERRNO_INVAL = 28;
const ERRNO_AGAIN= 6;
var _fd_read = wasi.wasiImport.fd_read;
wasi.wasiImport.fd_read = (fd, iovs_ptr, iovs_len, nread_ptr) => {
if (fd == 0) {
var buffer = new DataView(wasi.inst.exports.memory.buffer);
var buffer8 = new Uint8Array(wasi.inst.exports.memory.buffer);
var iovecs = Iovec.read_bytes_array(buffer, iovs_ptr, iovs_len);
var nread = 0;
for (i = 0; i < iovecs.length; i++) {
var iovec = iovecs[i];
if (iovec.buf_len == 0) {
continue;
}
var data = ttyClient.onRead(iovec.buf_len);
buffer8.set(data, iovec.buf);
nread += data.length;
}
buffer.setUint32(nread_ptr, nread, true);
return 0;
} else {
console.log("fd_read: unknown fd " + fd);
return _fd_read.apply(wasi.wasiImport, [fd, iovs_ptr, iovs_len, nread_ptr]);
}
return ERRNO_INVAL;
}
var _fd_write = wasi.wasiImport.fd_write;
wasi.wasiImport.fd_write = (fd, iovs_ptr, iovs_len, nwritten_ptr) => {
if ((fd == 1) || (fd == 2)) {
var buffer = new DataView(wasi.inst.exports.memory.buffer);
var buffer8 = new Uint8Array(wasi.inst.exports.memory.buffer);
var iovecs = Ciovec.read_bytes_array(buffer, iovs_ptr, iovs_len);
var wtotal = 0
for (i = 0; i < iovecs.length; i++) {
var iovec = iovecs[i];
var buf = buffer8.slice(iovec.buf, iovec.buf + iovec.buf_len);
if (buf.length == 0) {
continue;
}
ttyClient.onWrite(Array.from(buf));
wtotal += buf.length;
}
buffer.setUint32(nwritten_ptr, wtotal, true);
return 0;
} else {
console.log("fd_write: unknown fd " + fd);
return _fd_write.apply(wasi.wasiImport, [fd, iovs_ptr, iovs_len, nwritten_ptr]);
}
return ERRNO_INVAL;
}
wasi.wasiImport.poll_oneoff = (in_ptr, out_ptr, nsubscriptions, nevents_ptr) => {
if (nsubscriptions == 0) {
return ERRNO_INVAL;
}
let buffer = new DataView(wasi.inst.exports.memory.buffer);
let in_ = Subscription.read_bytes_array(buffer, in_ptr, nsubscriptions);
let isReadPollStdin = false;
let isReadPollConn = false;
let isClockPoll = false;
let pollSubStdin;
let pollSubConn;
let clockSub;
let timeout = Number.MAX_VALUE;
for (let sub of in_) {
if (sub.u.tag.variant == "fd_read") {
if ((sub.u.data.fd != 0) && (sub.u.data.fd != connfd)) {
console.log("poll_oneoff: unknown fd " + sub.u.data.fd);
return ERRNO_INVAL; // only fd=0 and connfd is supported as of now (FIXME)
}
if (sub.u.data.fd == 0) {
isReadPollStdin = true;
pollSubStdin = sub;
} else {
isReadPollConn = true;
pollSubConn = sub;
}
} else if (sub.u.tag.variant == "clock") {
if (sub.u.data.timeout < timeout) {
timeout = sub.u.data.timeout
isClockPoll = true;
clockSub = sub;
}
} else {
console.log("poll_oneoff: unknown variant " + sub.u.tag.variant);
return ERRNO_INVAL; // FIXME
}
}
let events = [];
if (isReadPollStdin || isReadPollConn || isClockPoll) {
var readable = false;
if (isReadPollStdin || (isClockPoll && timeout > 0)) {
readable = ttyClient.onWaitForReadable(timeout / 1000000000);
}
if (readable && isReadPollStdin) {
let event = new Event();
event.userdata = pollSubStdin.userdata;
event.error = 0;
event.type = new EventType("fd_read");
events.push(event);
}
if (isReadPollConn) {
var sockreadable = sockWaitForReadable();
if (sockreadable == errStatus) {
return ERRNO_INVAL;
} else if (sockreadable == true) {
let event = new Event();
event.userdata = pollSubConn.userdata;
event.error = 0;
event.type = new EventType("fd_read");
events.push(event);
}
}
if (isClockPoll) {
let event = new Event();
event.userdata = clockSub.userdata;
event.error = 0;
event.type = new EventType("clock");
events.push(event);
}
}
var len = events.length;
Event.write_bytes_array(buffer, out_ptr, events);
buffer.setUint32(nevents_ptr, len, true);
return 0;
}
}
function getNetParam() {
var vars = location.search.substring(1).split('&');
for (var i = 0; i < vars.length; i++) {
var kv = vars[i].split('=');
if (decodeURIComponent(kv[0]) == 'net') {
return {
mode: kv[1],
param: kv[2],
};
}
}
return null;
}
function genmac(){
return "02:XX:XX:XX:XX:XX".replace(/X/g, function() {
return "0123456789ABCDEF".charAt(Math.floor(Math.random() * 16))
});
}

105
web/ws-delegate.js Executable file
View File

@ -0,0 +1,105 @@
function delegate(worker, workerImageName, address) {
var shared = new SharedArrayBuffer(8 + 4096);
var streamCtrl = new Int32Array(shared, 0, 1);
var streamStatus = new Int32Array(shared, 4, 1);
var streamLen = new Int32Array(shared, 8, 1);
var streamData = new Uint8Array(shared, 12);
worker.postMessage({type: "init", buf: shared, imagename: workerImageName});
var opts = 'binary';
var ongoing = false;
var opened = false;
var accepted = false;
var wsconn;
var connbuf = new Uint8Array(0);
return function(msg) {
const req_ = msg.data;
if (typeof req_ == "object" && req_.type) {
switch (req_.type) {
case "accept":
if (opened) {
streamData[0] = 1; // opened
accepted = true;
} else {
streamData[0] = 0; // not opened
if (!ongoing) {
ongoing = true;
wsconn = new WebSocket(address, opts);
wsconn.binaryType = 'arraybuffer';
wsconn.onmessage = function(event) {
buf2 = new Uint8Array(connbuf.length + event.data.byteLength);
var o = connbuf.length;
buf2.set(connbuf, 0);
buf2.set(new Uint8Array(event.data), o);
connbuf = buf2;
};
wsconn.onclose = function(event) {
console.log("websocket closed" + event.code + " " + event.reason + " " + event.wasClean);
opened = false;
accepted = false;
ongoing = false;
};
wsconn.onopen = function(event) {
opened = true;
accepted = false;
ongoing = false;
};
wsconn.onerror = function(error) {
console.log("websocket error: "+error.data);
opened = false;
accepted = false;
ongoing = false;
};
}
}
streamStatus[0] = 0;
break;
case "send":
if (!accepted) {
console.log("ERROR: cannot send to unaccepted websocket");
streamStatus[0] = -1;
break;
}
wsconn.send(req_.buf);
streamStatus[0] = 0;
break;
case "recv":
if (!accepted) {
console.log("ERROR: cannot receive from unaccepted websocket");
streamStatus[0] = -1;
break;
}
var length = req_.len;
if (length > streamData.length)
length = streamData.length;
if (length > connbuf.length)
length = connbuf.length
var buf = connbuf.slice(0, length);
var remain = connbuf.slice(length, connbuf.length);
connbuf = remain;
streamLen[0] = buf.length;
streamData.set(buf, 0);
streamStatus[0] = 0;
break;
case "recv-is-readable":
if (!accepted) {
console.log("ERROR: cannot poll unaccepted websocket");
streamStatus[0] = -1;
break;
}
if (connbuf.length > 0) {
streamData[0] = 1; // ready for reading
} else {
streamData[0] = 0; // timeout
}
streamStatus[0] = 0;
break;
default:
console.log("unknown request: " + req_.type)
return;
}
Atomics.store(streamCtrl, 0, 1);
Atomics.notify(streamCtrl, 0);
}
}
}