Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions contrib/render-plugins/example-wasm/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Go + WASM Render Plugin Example

This example shows how to build a frontend render plugin whose heavy lifting
runs inside a WebAssembly module compiled from Go. The plugin loads the WASM
binary in the browser, asks Go to post-process the fetched file content, and
then renders the result inside the file viewer.

## Files

- `manifest.json` — plugin metadata consumed by the Gitea backend
- `render.js` — the ES module entry point that initializes the Go runtime and
renders files handled by the plugin
- `wasm/` — contains the Go source that compiles to `plugin.wasm`
- `wasm_exec.js` — the Go runtime shim required by all Go-generated WASM
binaries (copied verbatim from the Go distribution)
- `build.sh` — helper script that builds `plugin.wasm` and produces a zip
archive ready for upload

## Build & Install

1. Build the WASM binary and zip archive:

```bash
cd contrib/render-plugins/example-wasm
./build.sh
```

The script requires Go 1.21+ on your PATH. It stores the compiled WASM and
an installable `example-go-wasm.zip` under `dist/`.

2. In the Gitea web UI, visit `Site Administration → Render Plugins`, upload
`dist/example-go-wasm.zip`, and enable the plugin.

3. Open any file whose name ends with `.wasmnote`; the viewer will show the
processed output from the Go code running inside WebAssembly.

## How It Works

- `wasm/main.go` exposes a single `wasmProcessFile` function to JavaScript. It
uppercases each line, prefixes it with the line number, and runs entirely
inside WebAssembly compiled from Go.
- `render.js` injects the Go runtime (`wasm_exec.js`), instantiates the compiled
module, and caches the exported `wasmProcessFile` function.
- During initialization the frontend passes the sniffed MIME type and the first
1 KiB of file data to the plugin (`options.mimeType`/`options.headChunk`),
allowing renderers to make decisions without issuing extra network requests.
- During rendering the plugin downloads the target file, passes the contents to
Go, and displays the transformed text with minimal styling.

Feel free to modify the Go source or the JS wrapper to experiment with richer
interfaces between JavaScript and WebAssembly.
26 changes: 26 additions & 0 deletions contrib/render-plugins/example-wasm/build.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"

BUILD_DIR="$SCRIPT_DIR/.build"
DIST_DIR="$SCRIPT_DIR/dist"
ARCHIVE_NAME="example-go-wasm.zip"

rm -rf "$BUILD_DIR"
mkdir -p "$BUILD_DIR" "$DIST_DIR"

export GOOS=js
export GOARCH=wasm

echo "[+] Building Go WASM binary..."
go build -o "$BUILD_DIR/plugin.wasm" ./wasm

cp manifest.json "$BUILD_DIR/"
cp render.js "$BUILD_DIR/"
cp wasm_exec.js "$BUILD_DIR/"

( cd "$BUILD_DIR" && zip -q "../dist/$ARCHIVE_NAME" manifest.json render.js wasm_exec.js plugin.wasm )

echo "[+] Wrote $DIST_DIR/$ARCHIVE_NAME"
11 changes: 11 additions & 0 deletions contrib/render-plugins/example-wasm/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"schemaVersion": 1,
"id": "example-go-wasm",
"name": "Example Go WASM Renderer",
"version": "0.1.0",
"description": "Demonstrates calling a Go-compiled WebAssembly module inside a Gitea render plugin.",
"entry": "render.js",
"filePatterns": [
"*.wasmnote"
]
}
163 changes: 163 additions & 0 deletions contrib/render-plugins/example-wasm/render.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
const wasmUrl = new URL('plugin.wasm', import.meta.url);
const wasmExecUrl = new URL('wasm_exec.js', import.meta.url);
let wasmBridgePromise;
let styleInjected = false;

function injectScriptOnce(url) {
return new Promise((resolve, reject) => {
const existing = document.querySelector(`script[data-go-runtime="${url.href}"]`);
if (existing) {
if (existing.dataset.loaded === 'true') {
resolve();
} else {
existing.addEventListener('load', resolve, {once: true});
existing.addEventListener('error', reject, {once: true});
}
return;
}
const script = document.createElement('script');
script.dataset.goRuntime = url.href;
script.src = url.href;
script.async = true;
script.addEventListener('load', () => {
script.dataset.loaded = 'true';
resolve();
}, {once: true});
script.addEventListener('error', reject, {once: true});
document.head.appendChild(script);
});
}

function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}

async function waitForExport(name, timeoutMs = 2000) {
const start = Date.now();
while (typeof globalThis[name] !== 'function') {
if (Date.now() - start > timeoutMs) {
throw new Error(`Go runtime did not expose ${name} within ${timeoutMs}ms`);
}
await sleep(20);
}
return globalThis[name];
}

async function ensureWasmBridge() {
if (!wasmBridgePromise) {
wasmBridgePromise = (async () => {
if (typeof globalThis.Go === 'undefined') {
await injectScriptOnce(wasmExecUrl);
}
if (typeof globalThis.Go === 'undefined') {
throw new Error('Go runtime (wasm_exec.js) is unavailable');
}
const go = new globalThis.Go();
let result;
const fetchRequest = fetch(wasmUrl);
if (WebAssembly.instantiateStreaming) {
try {
result = await WebAssembly.instantiateStreaming(fetchRequest, go.importObject);
} catch (err) {
console.warn('instantiateStreaming failed; falling back to ArrayBuffer', err);
const buffer = await (await fetchRequest).arrayBuffer();
result = await WebAssembly.instantiate(buffer, go.importObject);
}
} else {
const buffer = await (await fetchRequest).arrayBuffer();
result = await WebAssembly.instantiate(buffer, go.importObject);
}
go.run(result.instance);
const processFile = await waitForExport('wasmProcessFile');
return {
process(content) {
const output = processFile(content);
return typeof output === 'string' ? output : String(output ?? '');
},
};
})();
}
return wasmBridgePromise;
}

async function fetchFileText(fileUrl) {
const response = await window.fetch(fileUrl, {headers: {'Accept': 'text/plain'}});
if (!response.ok) {
throw new Error(`failed to fetch file (${response.status})`);
}
return response.text();
}

function ensureStyles() {
if (styleInjected) return;
styleInjected = true;
const style = document.createElement('style');
style.textContent = `
.go-wasm-renderer {
font-family: var(--fonts-proportional, system-ui);
border: 1px solid var(--color-secondary);
border-radius: 6px;
overflow: hidden;
}
.go-wasm-renderer__header {
margin: 0;
padding: 0.75rem 1rem;
background: var(--color-secondary-alpha-20);
font-weight: 600;
}
.go-wasm-renderer pre {
margin: 0;
padding: 1rem;
background: var(--color-box-body);
font-family: var(--fonts-monospace, SFMono-Regular, monospace);
white-space: pre;
overflow-x: auto;
}
.go-wasm-renderer__error {
color: var(--color-danger);
}
`;
document.head.appendChild(style);
}

function renderError(container, message) {
container.innerHTML = '';
const wrapper = document.createElement('div');
wrapper.className = 'go-wasm-renderer';
const header = document.createElement('div');
header.className = 'go-wasm-renderer__header';
header.textContent = 'Go WASM Renderer';
const body = document.createElement('pre');
body.className = 'go-wasm-renderer__error';
body.textContent = message;
wrapper.append(header, body);
container.appendChild(wrapper);
}

export default {
name: 'Go WASM Renderer',
async render(container, fileUrl) {
ensureStyles();
try {
const [bridge, content] = await Promise.all([
ensureWasmBridge(),
fetchFileText(fileUrl),
]);

const processed = await bridge.process(content);
const wrapper = document.createElement('div');
wrapper.className = 'go-wasm-renderer';
const header = document.createElement('div');
header.className = 'go-wasm-renderer__header';
header.textContent = 'Go WASM Renderer';
const body = document.createElement('pre');
body.textContent = processed;
wrapper.append(header, body);
container.innerHTML = '';
container.appendChild(wrapper);
} catch (err) {
console.error('Go WASM plugin failed', err);
renderError(container, `Unable to render file: ${err.message}`);
}
},
};
29 changes: 29 additions & 0 deletions contrib/render-plugins/example-wasm/wasm/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
//go:build js && wasm
// +build js,wasm

package main

import (
"fmt"
"strings"
"syscall/js"
)

func processFile(this js.Value, args []js.Value) any {
if len(args) == 0 {
return js.ValueOf("(no content)")
}
content := args[0].String()
lines := strings.Split(content, "\n")
var b strings.Builder
b.Grow(len(content) + len(lines)*8)
for i, line := range lines {
fmt.Fprintf(&b, "%4d │ %s\n", i+1, strings.ToUpper(line))
}
return js.ValueOf(b.String())
}

func main() {
js.Global().Set("wasmProcessFile", js.FuncOf(processFile))
select {}
}
Loading