Skip to content

Commit

Permalink
feat: use tinygo instead of go to create smaller wasm binary
Browse files Browse the repository at this point in the history
This commit refactors the wasm and JavaScript parts to make use of
TinyGo for creating even smaller wasm binaries.
It also introduces another wasm-opt pass that shaves off ~500K.

The API has changed: Previously the exported `createLinter()` function
was synchronous and returned an async function to lint a single file.
Now the `createLinter()` function is asynchronous and the returned
linter function is synchronous.

```js
import { createLinter } from "actionlint";
const linter = await createLinter();
const results = linter("on: psuh", "borked.yml");
```

BREAKING CHANGE: The `createLinter()` function is now asynchronous and
resolves to a function that synchronously invokes the actionlint binary.
  • Loading branch information
ZauberNerd committed Apr 27, 2022
1 parent 1987395 commit bb9571e
Show file tree
Hide file tree
Showing 15 changed files with 189 additions and 147 deletions.
8 changes: 4 additions & 4 deletions .github/workflows/push.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,10 @@ jobs:
cat << EOF > test.mjs
import { createLinter } from 'actionlint';
createLinter()('on: psuh', 'push.yml').then(
(results) => process.exit(results.length > 0 ? 0 : 1),
(err) => { console.error(err); process.exit(1); }
);
createLinter().then(lint => {
const results = lint('on: psuh', 'push.yml');
process.exit(results.length > 0 ? 0 : 1)
}, (err) => { console.error(err); process.exit(1); });
EOF
# test that the linter works
Expand Down
4 changes: 2 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
main.wasm
wasm_exec.js
tmp.wasm
node_modules/
types/
types/
3 changes: 2 additions & 1 deletion .npmignore
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
.github/
.vscode/
node_modules/
go.mod
go.sum
globals.d.ts
main.go
Makefile
renovate.json
test.mjs
tmp.wasm
tsconfig.json
yarn.lock
2 changes: 1 addition & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,4 @@
"GOARCH": "wasm"
}
}
}
}
17 changes: 7 additions & 10 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,20 +1,17 @@
build: wasm_exec.js main.wasm types/node.d.mts
build: main.wasm types/node.d.mts

wasm_exec.js:
cp $$(go env GOROOT)/misc/wasm/wasm_exec.js wasm_exec.tmp.js
echo "// @ts-nocheck" > wasm_exec.js
cat wasm_exec.tmp.js >> wasm_exec.js
rm wasm_exec.tmp.js
main.wasm: tmp.wasm
wasm-opt -c -O4 -o main.wasm tmp.wasm

main.wasm: main.go go.mod
GOOS=js GOARCH=wasm go build -o main.wasm
tmp.wasm: main.go go.mod
tinygo build -o tmp.wasm -target wasm -panic trap -opt z main.go

types/node.d.mts: *.cjs *.mjs *.d.ts *.json yarn.lock
$$(yarn bin)/tsc -p .

clean:
rm main.wasm
rm wasm_exec.js
rm tmp.wasm
rm -rf types

.PHONY: build clean
.PHONY: build clean
101 changes: 61 additions & 40 deletions actionlint.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,54 +8,75 @@ require("./tiny_wasm_exec.js");

/**
* @param {WasmLoader} loader
* @returns {RunActionlint}
* @returns {Promise<RunActionlint>}
*/
module.exports.createActionlint = function createActionlint(loader) {
module.exports.createActionlint = async function createActionlint(loader) {
const encoder = new TextEncoder();
const decoder = new TextDecoder();
const go = new Go();

/** @type {(() => void)[] | undefined} */
let queued = undefined;
const wasm = await loader(go);
// Do not await this promise, because it only resolves once the go main()
// function has exited. But we need the main function to stay alive to be
// able to call the `runActionlint` function.
go.run(wasm.instance);

// This function gets called from go once the wasm module is ready and it
// executes the linter for all queued calls.
globalThis.actionlintInitialized = () => {
queued?.forEach((f) => f());
queued = globalThis.actionlintInitialized = undefined;
};
if (!(wasm.instance.exports.memory instanceof WebAssembly.Memory)) {
throw new Error("Could not get wasm memory");
}
const memory = wasm.instance.exports.memory;

if (!(wasm.instance.exports.WasmAlloc instanceof Function)) {
throw new Error("Could not get wasm alloc function");
}
const wasmAlloc = wasm.instance.exports.WasmAlloc;

if (!(wasm.instance.exports.WasmFree instanceof Function)) {
throw new Error("Could not get wasm free function");
}
const wasmFree = wasm.instance.exports.WasmFree;

loader(go).then((wasm) => {
// Do not await this promise, because it only resolves once the go main()
// function has exited. But we need the main function to stay alive to be
// able to call the `runActionlint` function.
go.run(wasm.instance);
});
if (!(wasm.instance.exports.RunActionlint instanceof Function)) {
throw new Error("Could not get wasm runActionLint function");
}
const runActionlint = wasm.instance.exports.RunActionlint;

/**
* @param {string} src
* @param {string} input
* @param {string} path
* @returns {Promise<LintResult[]>}
* @returns {LintResult[]}
*/
return async function runLint(src, path) {
// Return a promise, because we need to queue calls to `runLint()` while the
// wasm module is still loading and execute them once the wasm module is
//ready.
return new Promise((resolve, reject) => {
if (typeof runActionlint === "function") {
const [result, err] = runActionlint(src, path);
return err ? reject(err) : resolve(result);
}

if (!queued) {
queued = [];
}

queued.push(() => {
const [result, err] = runActionlint?.(src, path) ?? [
[],
new Error('"runActionlint" is not defined'),
];
return err ? reject(err) : resolve(result);
});
});
return function runLint(input, path) {
const workflow = encoder.encode(input);
const filePath = encoder.encode(path);

const workflowPointer = wasmAlloc(workflow.byteLength);
new Uint8Array(memory.buffer).set(workflow, workflowPointer);

const filePathPointer = wasmAlloc(filePath.byteLength);
new Uint8Array(memory.buffer).set(filePath, filePathPointer);

const resultPointer = runActionlint(
workflowPointer,
workflow.byteLength,
workflow.byteLength,
filePathPointer,
filePath.byteLength,
filePath.byteLength
);

wasmFree(workflowPointer);
wasmFree(filePathPointer);

const result = new Uint8Array(memory.buffer).subarray(resultPointer);
const end = result.indexOf(0);

const string = decoder.decode(result.subarray(0, end));

try {
return JSON.parse(string);
} catch {
throw new Error(string);
}
};
};
17 changes: 6 additions & 11 deletions browser.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,24 +6,19 @@ import { createActionlint } from "./actionlint.cjs";
* @typedef {import("./types").LintResult} LintResult
*/

/** @type {RunActionlint | undefined} */
let runLint = undefined;

/**
* @param {URL} url
* @returns {RunActionlint}
* @returns {Promise<RunActionlint>}
*/
export function createLinter(url = new URL("./main.wasm", import.meta.url)) {
if (runLint) {
return runLint;
}

return (runLint = createActionlint(
export async function createLinter(
url = new URL("./main.wasm", import.meta.url)
) {
return await createActionlint(
/** @type {WasmLoader} */ async (go) => {
return WebAssembly.instantiateStreaming(
fetch(url.toString()),
go.importObject
);
}
));
);
}
6 changes: 0 additions & 6 deletions globals.d.ts

This file was deleted.

92 changes: 61 additions & 31 deletions main.go
Original file line number Diff line number Diff line change
@@ -1,54 +1,84 @@
package main

import (
"container/list"
"io/ioutil"
"reflect"
"syscall/js"
"strconv"
"strings"

"github.com/rhysd/actionlint"
)

func toMap(input interface{}) map[string]interface{} {
out := make(map[string]interface{})
value := reflect.ValueOf(input)
if value.Kind() == reflect.Ptr {
value = value.Elem()
}
var Memory = list.New()
var OutputString = []byte{}

for i := 0; i < value.NumField(); i++ {
out[value.Type().Field(i).Name] = value.Field(i).Interface()
}
type block struct {
ptr *[]byte
value []byte
}

//export WasmAlloc
func WasmAlloc(size int) *[]byte {
slice := make([]byte, size)
block := block{
ptr: &slice,
value: slice,
}
Memory.PushBack(block)

return out
return block.ptr
}

func runActionlint(source string, filePath string) (interface{}, error) {
//export WasmFree
func WasmFree(ptr *[]byte) {
for e := Memory.Front(); e != nil; e = e.Next() {
block := e.Value.(block)
if block.ptr == ptr {
Memory.Remove(e)
return
}
}
}

func serialize(errors []*actionlint.Error, target *[]byte) {
*target = []byte("[")

for i, err := range errors {
*target = append(*target, `{
"file":"`+err.Filepath+`",
"line":`+strconv.FormatInt(int64(err.Line), 10)+`,
"column":`+strconv.FormatInt(int64(err.Column), 10)+`,
"message":"`+strings.ReplaceAll(err.Message, `"`, `\"`)+`",
"kind":"`+strings.ReplaceAll(err.Kind, `"`, `\"`)+`"
}`...)

if i < len(errors)-1 {
*target = append(*target, ',')
}
}

*target = append(*target, ']', 0)
}

//export RunActionlint
func RunActionlint(input []byte, path []byte) *byte {
opts := actionlint.LinterOptions{}

linter, err := actionlint.NewLinter(ioutil.Discard, &opts)
if err != nil {
return nil, err
OutputString = []byte(err.Error())
return &OutputString[0]
}

errs, err := linter.Lint(filePath, []byte(source), nil)
errs, err := linter.Lint(string(path), input, nil)
if err != nil {
return nil, err
OutputString = []byte(err.Error())
return &OutputString[0]
}

ret := make([]interface{}, 0, len(errs))
for _, err := range errs {
ret = append(ret, toMap(*err))
}
serialize(errs, &OutputString)

return ret, nil
return &OutputString[0]
}

func main() {
js.Global().Set("runActionlint", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
result, err := runActionlint(args[0].String(), args[1].String())
return js.Global().Get("Array").New(result, err)
}))

js.Global().Call("actionlintInitialized")

select {}
}
func main() {}
19 changes: 6 additions & 13 deletions node.cjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
const { join } = require("path");
const { pathToFileURL } = require("url");
const { join } = require("node:path");
const { pathToFileURL } = require("node:url");
const { readFile } = require("node:fs/promises");
const { createActionlint } = require("./actionlint.cjs");

Expand All @@ -9,23 +9,16 @@ const { createActionlint } = require("./actionlint.cjs");
* @typedef {import("./types").LintResult} LintResult
*/

/** @type {RunActionlint | undefined} */
let runLint = undefined;

/**
* @param {URL} url
* @returns {RunActionlint}
* @returns {Promise<RunActionlint>}
*/
module.exports.createLinter = function createLinter(
module.exports.createLinter = async function createLinter(
url = pathToFileURL(join(__dirname, "main.wasm"))
) {
if (runLint) {
return runLint;
}

return (runLint = createActionlint(
return await createActionlint(
/** @type {WasmLoader} */ async (go) => {
return WebAssembly.instantiate(await readFile(url), go.importObject);
}
));
);
};
Loading

0 comments on commit bb9571e

Please sign in to comment.