mirror of
https://github.com/gohugoio/hugo.git
synced 2024-11-14 20:37:55 -05:00
Add build time math rendering
While very useful on its own (and combined with the passthrough render hooks), this also serves as a proof of concept of using WASI (WebAssembly System Interface) modules in Hugo. This will be marked _experimental_ in the documentation. Not because it will be removed or changed in a dramatic way, but we need to think a little more how to best set up/configure similar services, define where these WASM files gets stored, maybe we can allow user provided WASM files plugins via Hugo Modules mounts etc. See these issues for more context: * https://github.com/gohugoio/hugo/issues/12736 * https://github.com/gohugoio/hugo/issues/12737 See #11927
This commit is contained in:
parent
0c3a1c7288
commit
33c0938cd5
26 changed files with 1598 additions and 13 deletions
12
cache/filecache/filecache_config.go
vendored
12
cache/filecache/filecache_config.go
vendored
|
@ -46,6 +46,7 @@ const (
|
|||
CacheKeyAssets = "assets"
|
||||
CacheKeyModules = "modules"
|
||||
CacheKeyGetResource = "getresource"
|
||||
CacheKeyMisc = "misc"
|
||||
)
|
||||
|
||||
type Configs map[string]FileCacheConfig
|
||||
|
@ -70,10 +71,14 @@ var defaultCacheConfigs = Configs{
|
|||
MaxAge: -1,
|
||||
Dir: resourcesGenDir,
|
||||
},
|
||||
CacheKeyGetResource: FileCacheConfig{
|
||||
CacheKeyGetResource: {
|
||||
MaxAge: -1, // Never expire
|
||||
Dir: cacheDirProject,
|
||||
},
|
||||
CacheKeyMisc: {
|
||||
MaxAge: -1,
|
||||
Dir: cacheDirProject,
|
||||
},
|
||||
}
|
||||
|
||||
type FileCacheConfig struct {
|
||||
|
@ -120,6 +125,11 @@ func (f Caches) AssetsCache() *Cache {
|
|||
return f[CacheKeyAssets]
|
||||
}
|
||||
|
||||
// MiscCache gets the file cache for miscellaneous stuff.
|
||||
func (f Caches) MiscCache() *Cache {
|
||||
return f[CacheKeyMisc]
|
||||
}
|
||||
|
||||
// GetResourceCache gets the file cache for remote resources.
|
||||
func (f Caches) GetResourceCache() *Cache {
|
||||
return f[CacheKeyGetResource]
|
||||
|
|
6
cache/filecache/filecache_config_test.go
vendored
6
cache/filecache/filecache_config_test.go
vendored
|
@ -59,7 +59,7 @@ dir = "/path/to/c4"
|
|||
c.Assert(err, qt.IsNil)
|
||||
fs := afero.NewMemMapFs()
|
||||
decoded := testconfig.GetTestConfigs(fs, cfg).Base.Caches
|
||||
c.Assert(len(decoded), qt.Equals, 6)
|
||||
c.Assert(len(decoded), qt.Equals, 7)
|
||||
|
||||
c2 := decoded["getcsv"]
|
||||
c.Assert(c2.MaxAge.String(), qt.Equals, "11h0m0s")
|
||||
|
@ -106,7 +106,7 @@ dir = "/path/to/c4"
|
|||
c.Assert(err, qt.IsNil)
|
||||
fs := afero.NewMemMapFs()
|
||||
decoded := testconfig.GetTestConfigs(fs, cfg).Base.Caches
|
||||
c.Assert(len(decoded), qt.Equals, 6)
|
||||
c.Assert(len(decoded), qt.Equals, 7)
|
||||
|
||||
for _, v := range decoded {
|
||||
c.Assert(v.MaxAge, qt.Equals, time.Duration(0))
|
||||
|
@ -129,7 +129,7 @@ func TestDecodeConfigDefault(t *testing.T) {
|
|||
|
||||
fs := afero.NewMemMapFs()
|
||||
decoded := testconfig.GetTestConfigs(fs, cfg).Base.Caches
|
||||
c.Assert(len(decoded), qt.Equals, 6)
|
||||
c.Assert(len(decoded), qt.Equals, 7)
|
||||
|
||||
imgConfig := decoded[filecache.CacheKeyImages]
|
||||
jsonConfig := decoded[filecache.CacheKeyGetJSON]
|
||||
|
|
|
@ -81,3 +81,33 @@ func ToReadCloser(r io.Reader) io.ReadCloser {
|
|||
io.NopCloser(nil),
|
||||
}
|
||||
}
|
||||
|
||||
type ReadWriteCloser interface {
|
||||
io.Reader
|
||||
io.Writer
|
||||
io.Closer
|
||||
}
|
||||
|
||||
// PipeReadWriteCloser is a convenience type to create a pipe with a ReadCloser and a WriteCloser.
|
||||
type PipeReadWriteCloser struct {
|
||||
*io.PipeReader
|
||||
*io.PipeWriter
|
||||
}
|
||||
|
||||
// NewPipeReadWriteCloser creates a new PipeReadWriteCloser.
|
||||
func NewPipeReadWriteCloser() PipeReadWriteCloser {
|
||||
pr, pw := io.Pipe()
|
||||
return PipeReadWriteCloser{pr, pw}
|
||||
}
|
||||
|
||||
func (c PipeReadWriteCloser) Close() (err error) {
|
||||
if err = c.PipeReader.Close(); err != nil {
|
||||
return
|
||||
}
|
||||
err = c.PipeWriter.Close()
|
||||
return
|
||||
}
|
||||
|
||||
func (c PipeReadWriteCloser) WriteString(s string) (int, error) {
|
||||
return c.PipeWriter.Write([]byte(s))
|
||||
}
|
||||
|
|
8
deps/deps.go
vendored
8
deps/deps.go
vendored
|
@ -23,6 +23,7 @@ import (
|
|||
"github.com/gohugoio/hugo/helpers"
|
||||
"github.com/gohugoio/hugo/hugofs"
|
||||
"github.com/gohugoio/hugo/identity"
|
||||
"github.com/gohugoio/hugo/internal/warpc"
|
||||
"github.com/gohugoio/hugo/media"
|
||||
"github.com/gohugoio/hugo/resources/page"
|
||||
"github.com/gohugoio/hugo/resources/postpub"
|
||||
|
@ -93,6 +94,10 @@ type Deps struct {
|
|||
// This is common/global for all sites.
|
||||
BuildState *BuildState
|
||||
|
||||
// Holds RPC dispatchers for Katex etc.
|
||||
// TODO(bep) rethink this re. a plugin setup, but this will have to do for now.
|
||||
WasmDispatchers *warpc.Dispatchers
|
||||
|
||||
*globalErrHandler
|
||||
}
|
||||
|
||||
|
@ -343,6 +348,9 @@ func (d *Deps) Close() error {
|
|||
if d.MemCache != nil {
|
||||
d.MemCache.Stop()
|
||||
}
|
||||
if d.WasmDispatchers != nil {
|
||||
d.WasmDispatchers.Close()
|
||||
}
|
||||
return d.BuildClosers.Close()
|
||||
}
|
||||
|
||||
|
|
1
go.mod
1
go.mod
|
@ -69,6 +69,7 @@ require (
|
|||
github.com/spf13/pflag v1.0.5
|
||||
github.com/tdewolff/minify/v2 v2.20.37
|
||||
github.com/tdewolff/parse/v2 v2.7.15
|
||||
github.com/tetratelabs/wazero v1.7.4-0.20240805170331-2b12e189eeec
|
||||
github.com/yuin/goldmark v1.7.4
|
||||
github.com/yuin/goldmark-emoji v1.0.3
|
||||
go.uber.org/automaxprocs v1.5.3
|
||||
|
|
4
go.sum
4
go.sum
|
@ -233,8 +233,6 @@ github.com/gohugoio/httpcache v0.7.0 h1:ukPnn04Rgvx48JIinZvZetBfHaWE7I01JR2Q2RrQ
|
|||
github.com/gohugoio/httpcache v0.7.0/go.mod h1:fMlPrdY/vVJhAriLZnrF5QpN3BNAcoBClgAyQd+lGFI=
|
||||
github.com/gohugoio/hugo-goldmark-extensions/extras v0.2.0 h1:MNdY6hYCTQEekY0oAfsxWZU1CDt6iH+tMLgyMJQh/sg=
|
||||
github.com/gohugoio/hugo-goldmark-extensions/extras v0.2.0/go.mod h1:oBdBVuiZ0fv9xd8xflUgt53QxW5jOCb1S+xntcN4SKo=
|
||||
github.com/gohugoio/hugo-goldmark-extensions/passthrough v0.2.0 h1:PCtO5l++psZf48yen2LxQ3JiOXxaRC6v0594NeHvGZg=
|
||||
github.com/gohugoio/hugo-goldmark-extensions/passthrough v0.2.0/go.mod h1:g9CCh+Ci2IMbPUrVJuXbBTrA+rIIx5+hDQ4EXYaQDoM=
|
||||
github.com/gohugoio/hugo-goldmark-extensions/passthrough v0.3.0 h1:7PY5PIJ2mck7v6R52yCFvvYHvsPMEbulgRviw3I9lP4=
|
||||
github.com/gohugoio/hugo-goldmark-extensions/passthrough v0.3.0/go.mod h1:r8g5S7bHfdj0+9ShBog864ufCsVODKQZNjYYY8OnJpM=
|
||||
github.com/gohugoio/locales v0.14.0 h1:Q0gpsZwfv7ATHMbcTNepFd59H7GoykzWJIxi113XGDc=
|
||||
|
@ -461,6 +459,8 @@ github.com/tdewolff/parse/v2 v2.7.15/go.mod h1:3FbJWZp3XT9OWVN3Hmfp0p/a08v4h8J9W
|
|||
github.com/tdewolff/test v1.0.11-0.20231101010635-f1265d231d52/go.mod h1:6DAvZliBAAnD7rhVgwaM7DE5/d9NMOAJ09SqYqeK4QE=
|
||||
github.com/tdewolff/test v1.0.11-0.20240106005702-7de5f7df4739 h1:IkjBCtQOOjIn03u/dMQK9g+Iw9ewps4mCl1nB8Sscbo=
|
||||
github.com/tdewolff/test v1.0.11-0.20240106005702-7de5f7df4739/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
|
||||
github.com/tetratelabs/wazero v1.7.4-0.20240805170331-2b12e189eeec h1:KeQseLFSWb9qjW4PSWxciTBk1hbG7KsVx3rs1hIQnbQ=
|
||||
github.com/tetratelabs/wazero v1.7.4-0.20240805170331-2b12e189eeec/go.mod h1:ytl6Zuh20R/eROuyDaGPkp82O9C/DJfXAwJfQ3X6/7Y=
|
||||
github.com/ugorji/go/codec v1.2.7 h1:YPXUKf7fYbp/y8xloBqZOw2qaVggbfwMlI8WM3wZUJ0=
|
||||
github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY=
|
||||
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
|
|
|
@ -19,6 +19,7 @@ import (
|
|||
"fmt"
|
||||
"html/template"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
|
@ -34,6 +35,7 @@ import (
|
|||
"github.com/gohugoio/hugo/hugolib/doctree"
|
||||
"github.com/gohugoio/hugo/hugolib/pagesfromdata"
|
||||
"github.com/gohugoio/hugo/identity"
|
||||
"github.com/gohugoio/hugo/internal/warpc"
|
||||
"github.com/gohugoio/hugo/langs"
|
||||
"github.com/gohugoio/hugo/langs/i18n"
|
||||
"github.com/gohugoio/hugo/lazy"
|
||||
|
@ -157,6 +159,15 @@ func NewHugoSites(cfg deps.DepsCfg) (*HugoSites, error) {
|
|||
MemCache: memCache,
|
||||
TemplateProvider: tplimpl.DefaultTemplateProvider,
|
||||
TranslationProvider: i18n.NewTranslationProvider(),
|
||||
WasmDispatchers: warpc.AllDispatchers(
|
||||
warpc.Options{
|
||||
CompilationCacheDir: filepath.Join(conf.Dirs().CacheDir, "_warpc"),
|
||||
|
||||
// Katex is relatively slow.
|
||||
PoolSize: 8,
|
||||
Infof: logger.InfoCommand("wasm").Logf,
|
||||
},
|
||||
),
|
||||
}
|
||||
|
||||
if err := firstSiteDeps.Init(); err != nil {
|
||||
|
|
5
internal/warpc/build.sh
Executable file
5
internal/warpc/build.sh
Executable file
|
@ -0,0 +1,5 @@
|
|||
# TODO1 clean up when done.
|
||||
go generate ./gen
|
||||
javy compile js/greet.bundle.js -d -o wasm/greet.wasm
|
||||
javy compile js/renderkatex.bundle.js -d -o wasm/renderkatex.wasm
|
||||
touch warpc_test.go
|
55
internal/warpc/gen/main.go
Normal file
55
internal/warpc/gen/main.go
Normal file
|
@ -0,0 +1,55 @@
|
|||
//go:generate go run main.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/evanw/esbuild/pkg/api"
|
||||
)
|
||||
|
||||
var scripts = []string{
|
||||
"greet.js",
|
||||
"renderkatex.js",
|
||||
}
|
||||
|
||||
func main() {
|
||||
for _, script := range scripts {
|
||||
filename := filepath.Join("../js", script)
|
||||
err := buildJSBundle(filename)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func buildJSBundle(filename string) error {
|
||||
minify := true
|
||||
result := api.Build(
|
||||
api.BuildOptions{
|
||||
EntryPoints: []string{filename},
|
||||
Bundle: true,
|
||||
MinifyWhitespace: minify,
|
||||
MinifyIdentifiers: minify,
|
||||
MinifySyntax: minify,
|
||||
Target: api.ES2020,
|
||||
Outfile: strings.Replace(filename, ".js", ".bundle.js", 1),
|
||||
SourceRoot: "../js",
|
||||
})
|
||||
|
||||
if len(result.Errors) > 0 {
|
||||
return fmt.Errorf("build failed: %v", result.Errors)
|
||||
}
|
||||
if len(result.OutputFiles) != 1 {
|
||||
return fmt.Errorf("expected 1 output file, got %d", len(result.OutputFiles))
|
||||
}
|
||||
|
||||
of := result.OutputFiles[0]
|
||||
if err := os.WriteFile(filepath.FromSlash(of.Path), of.Contents, 0o644); err != nil {
|
||||
return fmt.Errorf("write file failed: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
2
internal/warpc/js/.gitignore
vendored
Normal file
2
internal/warpc/js/.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
node_modules/
|
||||
package-lock.json
|
56
internal/warpc/js/common.js
Normal file
56
internal/warpc/js/common.js
Normal file
|
@ -0,0 +1,56 @@
|
|||
// Read JSONL from stdin.
|
||||
export function readInput(handle) {
|
||||
const buffSize = 1024;
|
||||
let currentLine = [];
|
||||
const buffer = new Uint8Array(buffSize);
|
||||
|
||||
// Read all the available bytes
|
||||
while (true) {
|
||||
// Stdin file descriptor
|
||||
const fd = 0;
|
||||
let bytesRead = 0;
|
||||
try {
|
||||
bytesRead = Javy.IO.readSync(fd, buffer);
|
||||
} catch (e) {
|
||||
// IO.readSync fails with os error 29 when stdin closes.
|
||||
if (e.message.includes('os error 29')) {
|
||||
break;
|
||||
}
|
||||
throw new Error('Error reading from stdin');
|
||||
}
|
||||
|
||||
if (bytesRead < 0) {
|
||||
throw new Error('Error reading from stdin');
|
||||
break;
|
||||
}
|
||||
|
||||
if (bytesRead === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
currentLine = [...currentLine, ...buffer.subarray(0, bytesRead)];
|
||||
|
||||
// Split array into chunks by newline.
|
||||
let i = 0;
|
||||
for (let j = 0; i < currentLine.length; i++) {
|
||||
if (currentLine[i] === 10) {
|
||||
const chunk = currentLine.splice(j, i + 1);
|
||||
const arr = new Uint8Array(chunk);
|
||||
const json = JSON.parse(new TextDecoder().decode(arr));
|
||||
handle(json);
|
||||
j = i + 1;
|
||||
}
|
||||
}
|
||||
// Remove processed data.
|
||||
currentLine = currentLine.slice(i);
|
||||
}
|
||||
}
|
||||
|
||||
// Write JSONL to stdout
|
||||
export function writeOutput(output) {
|
||||
const encodedOutput = new TextEncoder().encode(JSON.stringify(output) + '\n');
|
||||
const buffer = new Uint8Array(encodedOutput);
|
||||
// Stdout file descriptor
|
||||
const fd = 1;
|
||||
Javy.IO.writeSync(fd, buffer);
|
||||
}
|
2
internal/warpc/js/greet.bundle.js
Normal file
2
internal/warpc/js/greet.bundle.js
Normal file
|
@ -0,0 +1,2 @@
|
|||
(()=>{function i(r){let e=[],a=new Uint8Array(1024);for(;;){let n=0;try{n=Javy.IO.readSync(0,a)}catch(o){if(o.message.includes("os error 29"))break;throw new Error("Error reading from stdin")}if(n<0)throw new Error("Error reading from stdin");if(n===0)break;e=[...e,...a.subarray(0,n)];let t=0;for(let o=0;t<e.length;t++)if(e[t]===10){let f=e.splice(o,t+1),s=new Uint8Array(f),u=JSON.parse(new TextDecoder().decode(s));r(u),o=t+1}e=e.slice(t)}}function d(r){let c=new TextEncoder().encode(JSON.stringify(r)+`
|
||||
`),e=new Uint8Array(c);Javy.IO.writeSync(1,e)}var l=function(r){d({header:r.header,data:{greeting:"Hello "+r.data.name+"!"}})};console.log("Greet module loaded");i(l);})();
|
9
internal/warpc/js/greet.js
Normal file
9
internal/warpc/js/greet.js
Normal file
|
@ -0,0 +1,9 @@
|
|||
import { readInput, writeOutput } from './common';
|
||||
|
||||
const greet = function (input) {
|
||||
writeOutput({ header: input.header, data: { greeting: 'Hello ' + input.data.name + '!' } });
|
||||
};
|
||||
|
||||
console.log('Greet module loaded');
|
||||
|
||||
readInput(greet);
|
14
internal/warpc/js/package.json
Normal file
14
internal/warpc/js/package.json
Normal file
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"name": "js",
|
||||
"version": "1.0.0",
|
||||
"main": "greet.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
"devDependencies": {
|
||||
"katex": "^0.16.11"
|
||||
}
|
||||
}
|
262
internal/warpc/js/renderkatex.bundle.js
Normal file
262
internal/warpc/js/renderkatex.bundle.js
Normal file
File diff suppressed because one or more lines are too long
11
internal/warpc/js/renderkatex.js
Normal file
11
internal/warpc/js/renderkatex.js
Normal file
|
@ -0,0 +1,11 @@
|
|||
import { readInput, writeOutput } from './common';
|
||||
import katex from 'katex';
|
||||
|
||||
const render = function (input) {
|
||||
const data = input.data;
|
||||
const expression = data.expression;
|
||||
const options = data.options;
|
||||
writeOutput({ header: input.header, data: { output: katex.renderToString(expression, options) } });
|
||||
};
|
||||
|
||||
readInput(render);
|
24
internal/warpc/katex.go
Normal file
24
internal/warpc/katex.go
Normal file
|
@ -0,0 +1,24 @@
|
|||
package warpc
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
)
|
||||
|
||||
//go:embed wasm/renderkatex.wasm
|
||||
var katexWasm []byte
|
||||
|
||||
// See https://katex.org/docs/options.html
|
||||
type KatexInput struct {
|
||||
Expression string `json:"expression"`
|
||||
Options KatexOptions `json:"options"`
|
||||
}
|
||||
|
||||
type KatexOptions struct {
|
||||
Output string `json:"output"` // html, mathml (default), htmlAndMathml
|
||||
DisplayMode bool `json:"displayMode"`
|
||||
ThrowOnError bool `json:"throwOnError"`
|
||||
}
|
||||
|
||||
type KatexOutput struct {
|
||||
Output string `json:"output"`
|
||||
}
|
552
internal/warpc/warpc.go
Normal file
552
internal/warpc/warpc.go
Normal file
|
@ -0,0 +1,552 @@
|
|||
package warpc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/gohugoio/hugo/common/hugio"
|
||||
"golang.org/x/sync/errgroup"
|
||||
|
||||
"github.com/tetratelabs/wazero"
|
||||
"github.com/tetratelabs/wazero/api"
|
||||
"github.com/tetratelabs/wazero/experimental"
|
||||
"github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1"
|
||||
)
|
||||
|
||||
const currentVersion = "v1"
|
||||
|
||||
//go:embed wasm/quickjs.wasm
|
||||
var quickjsWasm []byte
|
||||
|
||||
// Header is in both the request and response.
|
||||
type Header struct {
|
||||
Version string `json:"version"`
|
||||
ID uint32 `json:"id"`
|
||||
}
|
||||
|
||||
type Message[T any] struct {
|
||||
Header Header `json:"header"`
|
||||
Data T `json:"data"`
|
||||
}
|
||||
|
||||
func (m Message[T]) GetID() uint32 {
|
||||
return m.Header.ID
|
||||
}
|
||||
|
||||
type Dispatcher[Q, R any] interface {
|
||||
Execute(ctx context.Context, q Message[Q]) (Message[R], error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
func (p *dispatcherPool[Q, R]) getDispatcher() *dispatcher[Q, R] {
|
||||
i := int(p.counter.Add(1)) % len(p.dispatchers)
|
||||
return p.dispatchers[i]
|
||||
}
|
||||
|
||||
func (p *dispatcherPool[Q, R]) Close() error {
|
||||
return p.close()
|
||||
}
|
||||
|
||||
type dispatcher[Q, R any] struct {
|
||||
zero Message[R]
|
||||
|
||||
mu sync.RWMutex
|
||||
encMu sync.Mutex
|
||||
|
||||
pending map[uint32]*call[Q, R]
|
||||
|
||||
inOut *inOut
|
||||
|
||||
shutdown bool
|
||||
closing bool
|
||||
}
|
||||
|
||||
type inOut struct {
|
||||
sync.Mutex
|
||||
stdin hugio.ReadWriteCloser
|
||||
stdout hugio.ReadWriteCloser
|
||||
dec *json.Decoder
|
||||
enc *json.Encoder
|
||||
}
|
||||
|
||||
var ErrShutdown = fmt.Errorf("dispatcher is shutting down")
|
||||
|
||||
var timerPool = sync.Pool{}
|
||||
|
||||
func getTimer(d time.Duration) *time.Timer {
|
||||
if v := timerPool.Get(); v != nil {
|
||||
timer := v.(*time.Timer)
|
||||
timer.Reset(d)
|
||||
return timer
|
||||
}
|
||||
return time.NewTimer(d)
|
||||
}
|
||||
|
||||
func putTimer(t *time.Timer) {
|
||||
if !t.Stop() {
|
||||
select {
|
||||
case <-t.C:
|
||||
default:
|
||||
}
|
||||
}
|
||||
timerPool.Put(t)
|
||||
}
|
||||
|
||||
// Execute sends a request to the dispatcher and waits for the response.
|
||||
func (p *dispatcherPool[Q, R]) Execute(ctx context.Context, q Message[Q]) (Message[R], error) {
|
||||
d := p.getDispatcher()
|
||||
if q.GetID() == 0 {
|
||||
return d.zero, errors.New("ID must not be 0 (note that this must be unique within the current request set time window)")
|
||||
}
|
||||
|
||||
call, err := d.newCall(q)
|
||||
if err != nil {
|
||||
return d.zero, err
|
||||
}
|
||||
|
||||
if err := d.send(call); err != nil {
|
||||
return d.zero, err
|
||||
}
|
||||
|
||||
timer := getTimer(30 * time.Second)
|
||||
defer putTimer(timer)
|
||||
|
||||
select {
|
||||
case call = <-call.donec:
|
||||
case <-p.donec:
|
||||
return d.zero, p.Err()
|
||||
case <-ctx.Done():
|
||||
return d.zero, ctx.Err()
|
||||
case <-timer.C:
|
||||
return d.zero, errors.New("timeout")
|
||||
}
|
||||
|
||||
if call.err != nil {
|
||||
return d.zero, call.err
|
||||
}
|
||||
|
||||
return call.response, p.Err()
|
||||
}
|
||||
|
||||
func (d *dispatcher[Q, R]) newCall(q Message[Q]) (*call[Q, R], error) {
|
||||
call := &call[Q, R]{
|
||||
donec: make(chan *call[Q, R], 1),
|
||||
request: q,
|
||||
}
|
||||
|
||||
if d.shutdown || d.closing {
|
||||
call.err = ErrShutdown
|
||||
call.done()
|
||||
return call, nil
|
||||
}
|
||||
|
||||
d.mu.Lock()
|
||||
d.pending[q.GetID()] = call
|
||||
d.mu.Unlock()
|
||||
|
||||
return call, nil
|
||||
}
|
||||
|
||||
func (d *dispatcher[Q, R]) send(call *call[Q, R]) error {
|
||||
d.mu.RLock()
|
||||
if d.closing || d.shutdown {
|
||||
d.mu.RUnlock()
|
||||
return ErrShutdown
|
||||
}
|
||||
d.mu.RUnlock()
|
||||
|
||||
d.encMu.Lock()
|
||||
defer d.encMu.Unlock()
|
||||
err := d.inOut.enc.Encode(call.request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *dispatcher[Q, R]) input() {
|
||||
var inputErr error
|
||||
|
||||
for d.inOut.dec.More() {
|
||||
var r Message[R]
|
||||
if err := d.inOut.dec.Decode(&r); err != nil {
|
||||
inputErr = err
|
||||
break
|
||||
}
|
||||
|
||||
d.mu.Lock()
|
||||
call, found := d.pending[r.GetID()]
|
||||
if !found {
|
||||
d.mu.Unlock()
|
||||
panic(fmt.Errorf("call with ID %d not found", r.GetID()))
|
||||
}
|
||||
delete(d.pending, r.GetID())
|
||||
d.mu.Unlock()
|
||||
call.response = r
|
||||
call.done()
|
||||
}
|
||||
|
||||
// Terminate pending calls.
|
||||
d.shutdown = true
|
||||
if inputErr != nil {
|
||||
isEOF := inputErr == io.EOF || strings.Contains(inputErr.Error(), "already closed")
|
||||
if isEOF {
|
||||
if d.closing {
|
||||
inputErr = ErrShutdown
|
||||
} else {
|
||||
inputErr = io.ErrUnexpectedEOF
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
for _, call := range d.pending {
|
||||
call.err = inputErr
|
||||
call.done()
|
||||
}
|
||||
}
|
||||
|
||||
type call[Q, R any] struct {
|
||||
request Message[Q]
|
||||
response Message[R]
|
||||
err error
|
||||
donec chan *call[Q, R]
|
||||
}
|
||||
|
||||
func (call *call[Q, R]) done() {
|
||||
select {
|
||||
case call.donec <- call:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// Binary represents a WebAssembly binary.
|
||||
type Binary struct {
|
||||
// The name of the binary.
|
||||
// For quickjs, this must match the instance import name, "javy_quickjs_provider_v2".
|
||||
// For the main module, we only use this for caching.
|
||||
Name string
|
||||
|
||||
// THe wasm binary.
|
||||
Data []byte
|
||||
}
|
||||
|
||||
type Options struct {
|
||||
Ctx context.Context
|
||||
|
||||
Infof func(format string, v ...any)
|
||||
|
||||
// E.g. quickjs wasm. May be omitted if not needed.
|
||||
Runtime Binary
|
||||
|
||||
// The main module to instantiate.
|
||||
Main Binary
|
||||
|
||||
CompilationCacheDir string
|
||||
PoolSize int
|
||||
|
||||
// Memory limit in MiB.
|
||||
Memory int
|
||||
}
|
||||
|
||||
type CompileModuleContext struct {
|
||||
Opts Options
|
||||
Runtime wazero.Runtime
|
||||
}
|
||||
|
||||
type CompiledModule struct {
|
||||
// Runtime (e.g. QuickJS) may be nil if not needed (e.g. embedded in Module).
|
||||
Runtime wazero.CompiledModule
|
||||
|
||||
// If Runtime is not nil, this should be the name of the instance.
|
||||
RuntimeName string
|
||||
|
||||
// The main module to instantiate.
|
||||
// This will be insantiated multiple times in a pool,
|
||||
// so it does not need a name.
|
||||
Module wazero.CompiledModule
|
||||
}
|
||||
|
||||
// Start creates a new dispatcher pool.
|
||||
func Start[Q, R any](opts Options) (Dispatcher[Q, R], error) {
|
||||
if opts.Main.Data == nil {
|
||||
return nil, errors.New("Main.Data must be set")
|
||||
}
|
||||
if opts.Main.Name == "" {
|
||||
return nil, errors.New("Main.Name must be set")
|
||||
}
|
||||
|
||||
if opts.Runtime.Data != nil && opts.Runtime.Name == "" {
|
||||
return nil, errors.New("Runtime.Name must be set")
|
||||
}
|
||||
|
||||
if opts.PoolSize == 0 {
|
||||
opts.PoolSize = 1
|
||||
}
|
||||
|
||||
return newDispatcher[Q, R](opts)
|
||||
}
|
||||
|
||||
type dispatcherPool[Q, R any] struct {
|
||||
counter atomic.Uint32
|
||||
dispatchers []*dispatcher[Q, R]
|
||||
close func() error
|
||||
|
||||
errc chan error
|
||||
donec chan struct{}
|
||||
}
|
||||
|
||||
func (p *dispatcherPool[Q, R]) SendIfErr(err error) {
|
||||
if err != nil {
|
||||
p.errc <- err
|
||||
}
|
||||
}
|
||||
|
||||
func (p *dispatcherPool[Q, R]) Err() error {
|
||||
select {
|
||||
case err := <-p.errc:
|
||||
return err
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func newDispatcher[Q, R any](opts Options) (*dispatcherPool[Q, R], error) {
|
||||
if opts.Ctx == nil {
|
||||
opts.Ctx = context.Background()
|
||||
}
|
||||
|
||||
if opts.Infof == nil {
|
||||
opts.Infof = func(format string, v ...any) {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
|
||||
if opts.Memory <= 0 {
|
||||
// 32 MiB
|
||||
opts.Memory = 32
|
||||
}
|
||||
|
||||
ctx := opts.Ctx
|
||||
|
||||
// Page size is 64KB.
|
||||
numPages := opts.Memory * 1024 / 64
|
||||
runtimeConfig := wazero.NewRuntimeConfig().WithMemoryLimitPages(uint32(numPages))
|
||||
|
||||
if opts.CompilationCacheDir != "" {
|
||||
compilationCache, err := wazero.NewCompilationCacheWithDir(opts.CompilationCacheDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
runtimeConfig = runtimeConfig.WithCompilationCache(compilationCache)
|
||||
}
|
||||
|
||||
// Create a new WebAssembly Runtime.
|
||||
r := wazero.NewRuntimeWithConfig(opts.Ctx, runtimeConfig)
|
||||
|
||||
// Instantiate WASI, which implements system I/O such as console output.
|
||||
if _, err := wasi_snapshot_preview1.Instantiate(ctx, r); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
inOuts := make([]*inOut, opts.PoolSize)
|
||||
for i := 0; i < opts.PoolSize; i++ {
|
||||
var stdin, stdout hugio.ReadWriteCloser
|
||||
|
||||
stdin = hugio.NewPipeReadWriteCloser()
|
||||
stdout = hugio.NewPipeReadWriteCloser()
|
||||
|
||||
inOuts[i] = &inOut{
|
||||
stdin: stdin,
|
||||
stdout: stdout,
|
||||
dec: json.NewDecoder(stdout),
|
||||
enc: json.NewEncoder(stdin),
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
runtimeModule wazero.CompiledModule
|
||||
mainModule wazero.CompiledModule
|
||||
err error
|
||||
)
|
||||
|
||||
if opts.Runtime.Data != nil {
|
||||
runtimeModule, err = r.CompileModule(ctx, opts.Runtime.Data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
mainModule, err = r.CompileModule(ctx, opts.Main.Data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
toErr := func(what string, errBuff bytes.Buffer, err error) error {
|
||||
return fmt.Errorf("%s: %s: %w", what, errBuff.String(), err)
|
||||
}
|
||||
|
||||
run := func() error {
|
||||
g, ctx := errgroup.WithContext(ctx)
|
||||
for _, c := range inOuts {
|
||||
c := c
|
||||
g.Go(func() error {
|
||||
var errBuff bytes.Buffer
|
||||
ctx := context.WithoutCancel(ctx)
|
||||
configBase := wazero.NewModuleConfig().WithStderr(&errBuff).WithStdout(c.stdout).WithStdin(c.stdin).WithStartFunctions()
|
||||
if opts.Runtime.Data != nil {
|
||||
// This needs to be anonymous, it will be resolved in the import resolver below.
|
||||
runtimeInstance, err := r.InstantiateModule(ctx, runtimeModule, configBase.WithName(""))
|
||||
if err != nil {
|
||||
return toErr("quickjs", errBuff, err)
|
||||
}
|
||||
ctx = experimental.WithImportResolver(ctx,
|
||||
func(name string) api.Module {
|
||||
if name == opts.Runtime.Name {
|
||||
return runtimeInstance
|
||||
}
|
||||
return nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
mainInstance, err := r.InstantiateModule(ctx, mainModule, configBase.WithName(""))
|
||||
if err != nil {
|
||||
return toErr(opts.Main.Name, errBuff, err)
|
||||
}
|
||||
if _, err := mainInstance.ExportedFunction("_start").Call(ctx); err != nil {
|
||||
return toErr(opts.Main.Name, errBuff, err)
|
||||
}
|
||||
|
||||
// The console.log in the Javy/quickjs WebAssembly module will write to stderr.
|
||||
// In non-error situations, write that to the provided infof logger.
|
||||
if errBuff.Len() > 0 {
|
||||
opts.Infof("%s", errBuff.String())
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
return g.Wait()
|
||||
}
|
||||
|
||||
dp := &dispatcherPool[Q, R]{
|
||||
dispatchers: make([]*dispatcher[Q, R], len(inOuts)),
|
||||
|
||||
errc: make(chan error, 10),
|
||||
donec: make(chan struct{}),
|
||||
}
|
||||
|
||||
go func() {
|
||||
// This will block until stdin is closed or it encounters an error.
|
||||
err := run()
|
||||
dp.SendIfErr(err)
|
||||
close(dp.donec)
|
||||
}()
|
||||
|
||||
for i := 0; i < len(inOuts); i++ {
|
||||
d := &dispatcher[Q, R]{
|
||||
pending: make(map[uint32]*call[Q, R]),
|
||||
inOut: inOuts[i],
|
||||
}
|
||||
go d.input()
|
||||
dp.dispatchers[i] = d
|
||||
}
|
||||
|
||||
dp.close = func() error {
|
||||
for _, d := range dp.dispatchers {
|
||||
d.closing = true
|
||||
if err := d.inOut.stdin.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := d.inOut.stdout.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// We need to wait for the WebAssembly instances to finish executing before we can close the runtime.
|
||||
<-dp.donec
|
||||
|
||||
if err := r.Close(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Return potential late compilation errors.
|
||||
return dp.Err()
|
||||
}
|
||||
|
||||
return dp, dp.Err()
|
||||
}
|
||||
|
||||
type lazyDispatcher[Q, R any] struct {
|
||||
opts Options
|
||||
|
||||
dispatcher Dispatcher[Q, R]
|
||||
startOnce sync.Once
|
||||
started bool
|
||||
startErr error
|
||||
}
|
||||
|
||||
func (d *lazyDispatcher[Q, R]) start() (Dispatcher[Q, R], error) {
|
||||
d.startOnce.Do(func() {
|
||||
start := time.Now()
|
||||
d.dispatcher, d.startErr = Start[Q, R](d.opts)
|
||||
d.started = true
|
||||
d.opts.Infof("started dispatcher in %s", time.Since(start))
|
||||
})
|
||||
return d.dispatcher, d.startErr
|
||||
}
|
||||
|
||||
// Dispatchers holds all the dispatchers for the warpc package.
|
||||
type Dispatchers struct {
|
||||
katex *lazyDispatcher[KatexInput, KatexOutput]
|
||||
}
|
||||
|
||||
func (d *Dispatchers) Katex() (Dispatcher[KatexInput, KatexOutput], error) {
|
||||
return d.katex.start()
|
||||
}
|
||||
|
||||
func (d *Dispatchers) Close() error {
|
||||
var errs []error
|
||||
if d.katex.started {
|
||||
if err := d.katex.dispatcher.Close(); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
if len(errs) == 0 {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%v", errs)
|
||||
}
|
||||
|
||||
// AllDispatchers creates all the dispatchers for the warpc package.
|
||||
// Note that the individual dispatchers are started lazily.
|
||||
// Remember to call Close on the returned Dispatchers when done.
|
||||
func AllDispatchers(katexOpts Options) *Dispatchers {
|
||||
if katexOpts.Runtime.Data == nil {
|
||||
katexOpts.Runtime = Binary{Name: "javy_quickjs_provider_v2", Data: quickjsWasm}
|
||||
}
|
||||
if katexOpts.Main.Data == nil {
|
||||
katexOpts.Main = Binary{Name: "renderkatex", Data: katexWasm}
|
||||
}
|
||||
|
||||
if katexOpts.Infof == nil {
|
||||
katexOpts.Infof = func(format string, v ...any) {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
|
||||
return &Dispatchers{
|
||||
katex: &lazyDispatcher[KatexInput, KatexOutput]{opts: katexOpts},
|
||||
}
|
||||
}
|
439
internal/warpc/warpc_test.go
Normal file
439
internal/warpc/warpc_test.go
Normal file
|
@ -0,0 +1,439 @@
|
|||
package warpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
qt "github.com/frankban/quicktest"
|
||||
)
|
||||
|
||||
//go:embed wasm/greet.wasm
|
||||
var greetWasm []byte
|
||||
|
||||
type person struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func TestKatex(t *testing.T) {
|
||||
c := qt.New(t)
|
||||
|
||||
opts := Options{
|
||||
PoolSize: 8,
|
||||
Runtime: quickjsBinary,
|
||||
Main: katexBinary,
|
||||
}
|
||||
|
||||
d, err := Start[KatexInput, KatexOutput](opts)
|
||||
c.Assert(err, qt.IsNil)
|
||||
|
||||
defer d.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
input := KatexInput{
|
||||
Expression: "c = \\pm\\sqrt{a^2 + b^2}",
|
||||
Options: KatexOptions{
|
||||
Output: "html",
|
||||
DisplayMode: true,
|
||||
},
|
||||
}
|
||||
|
||||
message := Message[KatexInput]{
|
||||
Header: Header{
|
||||
Version: currentVersion,
|
||||
ID: uint32(32),
|
||||
},
|
||||
Data: input,
|
||||
}
|
||||
|
||||
result, err := d.Execute(ctx, message)
|
||||
c.Assert(err, qt.IsNil)
|
||||
|
||||
c.Assert(result.GetID(), qt.Equals, message.GetID())
|
||||
}
|
||||
|
||||
func TestGreet(t *testing.T) {
|
||||
c := qt.New(t)
|
||||
opts := Options{
|
||||
PoolSize: 1,
|
||||
Runtime: quickjsBinary,
|
||||
Main: greetBinary,
|
||||
Infof: t.Logf,
|
||||
}
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
func() {
|
||||
d, err := Start[person, greeting](opts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
c.Assert(d.Close(), qt.IsNil)
|
||||
}()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
inputMessage := Message[person]{
|
||||
Header: Header{
|
||||
Version: currentVersion,
|
||||
},
|
||||
Data: person{
|
||||
Name: "Person",
|
||||
},
|
||||
}
|
||||
|
||||
for j := 0; j < 20; j++ {
|
||||
inputMessage.Header.ID = uint32(j + 1)
|
||||
g, err := d.Execute(ctx, inputMessage)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if g.Data.Greeting != "Hello Person!" {
|
||||
t.Fatalf("got: %v", g)
|
||||
}
|
||||
if g.GetID() != inputMessage.GetID() {
|
||||
t.Fatalf("%d vs %d", g.GetID(), inputMessage.GetID())
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestGreetParallel(t *testing.T) {
|
||||
c := qt.New(t)
|
||||
|
||||
opts := Options{
|
||||
Runtime: quickjsBinary,
|
||||
Main: greetBinary,
|
||||
PoolSize: 4,
|
||||
}
|
||||
d, err := Start[person, greeting](opts)
|
||||
c.Assert(err, qt.IsNil)
|
||||
defer func() {
|
||||
c.Assert(d.Close(), qt.IsNil)
|
||||
}()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i := 1; i <= 10; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
for j := 0; j < 5; j++ {
|
||||
base := i * 100
|
||||
id := uint32(base + j)
|
||||
|
||||
inputPerson := person{
|
||||
Name: fmt.Sprintf("Person %d", id),
|
||||
}
|
||||
inputMessage := Message[person]{
|
||||
Header: Header{
|
||||
Version: currentVersion,
|
||||
ID: id,
|
||||
},
|
||||
Data: inputPerson,
|
||||
}
|
||||
g, err := d.Execute(ctx, inputMessage)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.Assert(g.Data.Greeting, qt.Equals, fmt.Sprintf("Hello Person %d!", id))
|
||||
c.Assert(g.GetID(), qt.Equals, inputMessage.GetID())
|
||||
|
||||
}
|
||||
}(i)
|
||||
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestKatexParallel(t *testing.T) {
|
||||
c := qt.New(t)
|
||||
|
||||
opts := Options{
|
||||
Runtime: quickjsBinary,
|
||||
Main: katexBinary,
|
||||
PoolSize: 6,
|
||||
}
|
||||
d, err := Start[KatexInput, KatexOutput](opts)
|
||||
c.Assert(err, qt.IsNil)
|
||||
defer func() {
|
||||
c.Assert(d.Close(), qt.IsNil)
|
||||
}()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i := 1; i <= 10; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
for j := 0; j < 1; j++ {
|
||||
base := i * 100
|
||||
id := uint32(base + j)
|
||||
|
||||
input := katexInputTemplate
|
||||
inputMessage := Message[KatexInput]{
|
||||
Header: Header{
|
||||
Version: currentVersion,
|
||||
ID: id,
|
||||
},
|
||||
Data: input,
|
||||
}
|
||||
|
||||
result, err := d.Execute(ctx, inputMessage)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
if result.GetID() != inputMessage.GetID() {
|
||||
t.Errorf("%d vs %d", result.GetID(), inputMessage.GetID())
|
||||
return
|
||||
}
|
||||
}
|
||||
}(i)
|
||||
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func BenchmarkExecuteKatex(b *testing.B) {
|
||||
opts := Options{
|
||||
Runtime: quickjsBinary,
|
||||
Main: katexBinary,
|
||||
}
|
||||
d, err := Start[KatexInput, KatexOutput](opts)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
input := katexInputTemplate
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
message := Message[KatexInput]{
|
||||
Header: Header{
|
||||
Version: currentVersion,
|
||||
ID: uint32(i + 1),
|
||||
},
|
||||
Data: input,
|
||||
}
|
||||
|
||||
result, err := d.Execute(ctx, message)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
|
||||
if result.GetID() != message.GetID() {
|
||||
b.Fatalf("%d vs %d", result.GetID(), message.GetID())
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkKatexStartStop(b *testing.B) {
|
||||
optsTemplate := Options{
|
||||
Runtime: quickjsBinary,
|
||||
Main: katexBinary,
|
||||
CompilationCacheDir: b.TempDir(),
|
||||
}
|
||||
|
||||
runBench := func(b *testing.B, opts Options) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
d, err := Start[KatexInput, KatexOutput](opts)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
if err := d.Close(); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, poolSize := range []int{1, 8, 16} {
|
||||
|
||||
name := fmt.Sprintf("PoolSize%d", poolSize)
|
||||
|
||||
b.Run(name, func(b *testing.B) {
|
||||
opts := optsTemplate
|
||||
opts.PoolSize = poolSize
|
||||
runBench(b, opts)
|
||||
})
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
var katexInputTemplate = KatexInput{
|
||||
Expression: "c = \\pm\\sqrt{a^2 + b^2}",
|
||||
Options: KatexOptions{Output: "html", DisplayMode: true},
|
||||
}
|
||||
|
||||
func BenchmarkExecuteKatexPara(b *testing.B) {
|
||||
optsTemplate := Options{
|
||||
Runtime: quickjsBinary,
|
||||
Main: katexBinary,
|
||||
}
|
||||
|
||||
runBench := func(b *testing.B, opts Options) {
|
||||
d, err := Start[KatexInput, KatexOutput](opts)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
b.ResetTimer()
|
||||
|
||||
var id atomic.Uint32
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
for pb.Next() {
|
||||
message := Message[KatexInput]{
|
||||
Header: Header{
|
||||
Version: currentVersion,
|
||||
ID: id.Add(1),
|
||||
},
|
||||
Data: katexInputTemplate,
|
||||
}
|
||||
|
||||
result, err := d.Execute(ctx, message)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
if result.GetID() != message.GetID() {
|
||||
b.Fatalf("%d vs %d", result.GetID(), message.GetID())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, poolSize := range []int{1, 8, 16} {
|
||||
name := fmt.Sprintf("PoolSize%d", poolSize)
|
||||
|
||||
b.Run(name, func(b *testing.B) {
|
||||
opts := optsTemplate
|
||||
opts.PoolSize = poolSize
|
||||
runBench(b, opts)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkExecuteGreet(b *testing.B) {
|
||||
opts := Options{
|
||||
Runtime: quickjsBinary,
|
||||
Main: greetBinary,
|
||||
}
|
||||
d, err := Start[person, greeting](opts)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
input := person{
|
||||
Name: "Person",
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
message := Message[person]{
|
||||
Header: Header{
|
||||
Version: currentVersion,
|
||||
ID: uint32(i + 1),
|
||||
},
|
||||
Data: input,
|
||||
}
|
||||
result, err := d.Execute(ctx, message)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
|
||||
if result.GetID() != message.GetID() {
|
||||
b.Fatalf("%d vs %d", result.GetID(), message.GetID())
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkExecuteGreetPara(b *testing.B) {
|
||||
opts := Options{
|
||||
Runtime: quickjsBinary,
|
||||
Main: greetBinary,
|
||||
PoolSize: 8,
|
||||
}
|
||||
|
||||
d, err := Start[person, greeting](opts)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
inputTemplate := person{
|
||||
Name: "Person",
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
|
||||
var id atomic.Uint32
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
for pb.Next() {
|
||||
message := Message[person]{
|
||||
Header: Header{
|
||||
Version: currentVersion,
|
||||
ID: id.Add(1),
|
||||
},
|
||||
Data: inputTemplate,
|
||||
}
|
||||
|
||||
result, err := d.Execute(ctx, message)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
if result.GetID() != message.GetID() {
|
||||
b.Fatalf("%d vs %d", result.GetID(), message.GetID())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type greeting struct {
|
||||
Greeting string `json:"greeting"`
|
||||
}
|
||||
|
||||
var (
|
||||
greetBinary = Binary{
|
||||
Name: "greet",
|
||||
Data: greetWasm,
|
||||
}
|
||||
|
||||
katexBinary = Binary{
|
||||
Name: "renderkatex",
|
||||
Data: katexWasm,
|
||||
}
|
||||
|
||||
quickjsBinary = Binary{
|
||||
Name: "javy_quickjs_provider_v2",
|
||||
Data: quickjsWasm,
|
||||
}
|
||||
)
|
BIN
internal/warpc/wasm/greet.wasm
Normal file
BIN
internal/warpc/wasm/greet.wasm
Normal file
Binary file not shown.
BIN
internal/warpc/wasm/quickjs.wasm
Normal file
BIN
internal/warpc/wasm/quickjs.wasm
Normal file
Binary file not shown.
BIN
internal/warpc/wasm/renderkatex.wasm
Normal file
BIN
internal/warpc/wasm/renderkatex.wasm
Normal file
Binary file not shown.
5
internal/warpc/watchtestscripts.sh
Executable file
5
internal/warpc/watchtestscripts.sh
Executable file
|
@ -0,0 +1,5 @@
|
|||
#!/bin/bash
|
||||
|
||||
trap exit SIGINT
|
||||
|
||||
while true; do find . -type f -name "*.js" | entr -pd ./build.sh; done
|
|
@ -18,16 +18,23 @@ import (
|
|||
"bytes"
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"html"
|
||||
"html/template"
|
||||
"io"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/gohugoio/hugo/cache/dynacache"
|
||||
"github.com/gohugoio/hugo/common/hashing"
|
||||
"github.com/gohugoio/hugo/common/hugio"
|
||||
"github.com/gohugoio/hugo/internal/warpc"
|
||||
"github.com/gohugoio/hugo/markup/converter/hooks"
|
||||
"github.com/gohugoio/hugo/markup/highlight"
|
||||
"github.com/gohugoio/hugo/markup/highlight/chromalexers"
|
||||
"github.com/gohugoio/hugo/resources"
|
||||
"github.com/gohugoio/hugo/tpl"
|
||||
"github.com/mitchellh/mapstructure"
|
||||
|
||||
"github.com/gohugoio/hugo/deps"
|
||||
"github.com/gohugoio/hugo/helpers"
|
||||
|
@ -42,17 +49,25 @@ func New(deps *deps.Deps) *Namespace {
|
|||
|
||||
return &Namespace{
|
||||
deps: deps,
|
||||
cache: dynacache.GetOrCreatePartition[string, *resources.StaleValue[any]](
|
||||
cacheUnmarshal: dynacache.GetOrCreatePartition[string, *resources.StaleValue[any]](
|
||||
deps.MemCache,
|
||||
"/tmpl/transform",
|
||||
"/tmpl/transform/unmarshal",
|
||||
dynacache.OptionsPartition{Weight: 30, ClearWhen: dynacache.ClearOnChange},
|
||||
),
|
||||
cacheMath: dynacache.GetOrCreatePartition[string, string](
|
||||
deps.MemCache,
|
||||
"/tmpl/transform/math",
|
||||
dynacache.OptionsPartition{Weight: 30, ClearWhen: dynacache.ClearNever},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// Namespace provides template functions for the "transform" namespace.
|
||||
type Namespace struct {
|
||||
cache *dynacache.Partition[string, *resources.StaleValue[any]]
|
||||
cacheUnmarshal *dynacache.Partition[string, *resources.StaleValue[any]]
|
||||
cacheMath *dynacache.Partition[string, string]
|
||||
|
||||
id atomic.Uint32
|
||||
deps *deps.Deps
|
||||
}
|
||||
|
||||
|
@ -182,7 +197,64 @@ func (ns *Namespace) Plainify(s any) (string, error) {
|
|||
return tpl.StripHTML(ss), nil
|
||||
}
|
||||
|
||||
// ToMath converts a LaTeX string to math in the given format, default MathML.
|
||||
// This uses KaTeX to render the math, see https://katex.org/.
|
||||
func (ns *Namespace) ToMath(ctx context.Context, args ...any) (string, error) {
|
||||
if len(args) < 1 {
|
||||
return "", errors.New("must provide at least one argument")
|
||||
}
|
||||
expression, err := cast.ToStringE(args[0])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
katexInput := warpc.KatexInput{
|
||||
Expression: expression,
|
||||
Options: warpc.KatexOptions{
|
||||
Output: "mathml",
|
||||
ThrowOnError: false,
|
||||
},
|
||||
}
|
||||
|
||||
if len(args) > 1 {
|
||||
if err := mapstructure.WeakDecode(args[1], &katexInput); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
s := hashing.HashString(args...)
|
||||
key := "tomath/" + s[:2] + "/" + s[2:]
|
||||
fileCache := ns.deps.ResourceSpec.FileCaches.MiscCache()
|
||||
|
||||
return ns.cacheMath.GetOrCreate(key, func(string) (string, error) {
|
||||
_, r, err := fileCache.GetOrCreate(key, func() (io.ReadCloser, error) {
|
||||
message := warpc.Message[warpc.KatexInput]{
|
||||
Header: warpc.Header{
|
||||
Version: "v1",
|
||||
ID: ns.id.Add(1),
|
||||
},
|
||||
Data: katexInput,
|
||||
}
|
||||
|
||||
k, err := ns.deps.WasmDispatchers.Katex()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result, err := k.Execute(ctx, message)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return hugio.NewReadSeekerNoOpCloserFromString(result.Data.Output), nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return hugio.ReadString(r)
|
||||
})
|
||||
}
|
||||
|
||||
// For internal use.
|
||||
func (ns *Namespace) Reset() {
|
||||
ns.cache.Clear()
|
||||
ns.cacheUnmarshal.Clear()
|
||||
}
|
||||
|
|
|
@ -133,3 +133,20 @@ Scar,"a "dead cat",11
|
|||
[[name description age] [Spot a nice dog 3] [Rover a big dog 5] [Felix a "malicious" cat 7] [Bella an "evil" cat 9] [Scar a "dead cat 11]]
|
||||
`)
|
||||
}
|
||||
|
||||
func TestToMath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
files := `
|
||||
-- hugo.toml --
|
||||
disableKinds = ['page','rss','section','sitemap','taxonomy','term']
|
||||
-- layouts/index.html --
|
||||
{{ $result := transform.ToMath "c = \\pm\\sqrt{a^2 + b^2}" }}
|
||||
{{ printf "%v" $result | safeHTML }}
|
||||
`
|
||||
b := hugolib.Test(t, files)
|
||||
|
||||
b.AssertFileContent("public/index.html", `
|
||||
<span class="katex"><math
|
||||
`)
|
||||
}
|
||||
|
|
|
@ -71,7 +71,7 @@ func (ns *Namespace) Unmarshal(args ...any) (any, error) {
|
|||
key += decoder.OptionsKey()
|
||||
}
|
||||
|
||||
v, err := ns.cache.GetOrCreate(key, func(string) (*resources.StaleValue[any], error) {
|
||||
v, err := ns.cacheUnmarshal.GetOrCreate(key, func(string) (*resources.StaleValue[any], error) {
|
||||
f := metadecoders.FormatFromStrings(r.MediaType().Suffixes()...)
|
||||
if f == "" {
|
||||
return nil, fmt.Errorf("MIME %q not supported", r.MediaType())
|
||||
|
@ -119,7 +119,7 @@ func (ns *Namespace) Unmarshal(args ...any) (any, error) {
|
|||
|
||||
key := hashing.MD5FromStringHexEncoded(dataStr)
|
||||
|
||||
v, err := ns.cache.GetOrCreate(key, func(string) (*resources.StaleValue[any], error) {
|
||||
v, err := ns.cacheUnmarshal.GetOrCreate(key, func(string) (*resources.StaleValue[any], error) {
|
||||
f := decoder.FormatFromContentString(dataStr)
|
||||
if f == "" {
|
||||
return nil, errors.New("unknown format")
|
||||
|
|
Loading…
Reference in a new issue