mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-25 22:10:25 +00:00
fa2755e183
* feat(materialize): headless op-log materializer bundle, goja runner and worker (TASK-2198 U2)
Builds the editor's own JS (Yjs -> y-tiptap -> Tiptap schema -> tiptap-markdown
-> the tab's flush pipeline) into one IIFE, embeds it, and runs it in goja. Not
wired into the server yet (U3 supervisor, U4 write path).
- web/src/lib/collab/materializer/: bundle entry (extension list mirrors
Editor.svelte's collab branch via the U1 shared modules), op-log replay
mirroring wsProvider's MESSAGE_SYNC arm, linkedom DOM shim and polyfills
from the spike, and a vendored patch of prosemirror-markdown
MarkdownSerializerState.atBlank (quadratic under goja; equivalent).
- web/scripts/build-materializer.mjs (esbuild) -> web/build-materializer/,
run by `npm run build`; embedded as pad.MaterializerJS.
- internal/materialize: Runner (Go-native atob/btoa/TextEncoder/TextDecoder/
getRandomValues, schema-version check, ctx interrupt with a clean VM after),
and RunWorker's length-prefixed JSON protocol behind the hidden
`pad __materialize-worker` command.
- Parity: vitest drives two real Editor.svelte mounts and compares
materialize() with the tab's flush on 50 seeded cases; the same cases are
committed as testdata/corpus.json and replayed in goja (TestCorpusParity).
- CI Go jobs, release tests, Docker and Nix produce the bundle; linkedom and
esbuild are new devDependencies.
Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
* test(materialize): pin the atBlank patch's equivalence under V8 too (TASK-2198 U2, codex r1)
Codex r1 claimed upstream's /(^|\n)$/ is true for a trailing CR in JS. It is
not (checked in V8: false for "\r" and "a\u2028", as the patch returns),
but the only equivalence test ran under goja and the patch header cited a
V8-side test that did not exist. This adds it, with the line-terminator edge
cases, and it fails when the patch checks CR instead of LF.
Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
* build(nix): vendorHash for goja (TASK-2198 U2)
Taken from the Nix job's recompute on c7fa1931 (specified sha256-iEqv…,
got sha256-P1bQ…), so main does not depend on the heal job after merge.
Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
231 lines
6.3 KiB
Go
231 lines
6.3 KiB
Go
package materialize
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"strings"
|
|
"unicode/utf16"
|
|
"unicode/utf8"
|
|
|
|
"github.com/dop251/goja"
|
|
)
|
|
|
|
// installGlobals gives the runtime the host APIs the bundle expects and goja
|
|
// lacks. They are Go-native because the pure-JS fallbacks in the bundle's
|
|
// polyfills.ts were measurably slower under goja (the TASK-2198 spike), and
|
|
// they are installed BEFORE the bundle runs so the fallbacks see them and
|
|
// stand down. console is a sink: nothing the bundle logs may reach the
|
|
// worker's stdout, which is its protocol channel.
|
|
func installGlobals(vm *goja.Runtime) error {
|
|
noop := func(goja.FunctionCall) goja.Value { return goja.Undefined() }
|
|
console := vm.NewObject()
|
|
for _, k := range []string{"log", "warn", "error", "info", "debug", "trace"} {
|
|
if err := console.Set(k, noop); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := vm.Set("console", console); err != nil {
|
|
return err
|
|
}
|
|
if err := vm.Set("atob", func(c goja.FunctionCall) goja.Value { return atob(vm, c) }); err != nil {
|
|
return err
|
|
}
|
|
if err := vm.Set("btoa", func(c goja.FunctionCall) goja.Value { return btoa(vm, c) }); err != nil {
|
|
return err
|
|
}
|
|
crypto := vm.NewObject()
|
|
if err := crypto.Set("subtle", vm.NewObject()); err != nil {
|
|
return err
|
|
}
|
|
if err := crypto.Set("getRandomValues", func(c goja.FunctionCall) goja.Value {
|
|
arg := c.Argument(0)
|
|
var b []byte
|
|
if err := vm.ExportTo(arg, &b); err != nil {
|
|
panic(vm.NewTypeError("getRandomValues: argument is not an integer typed array"))
|
|
}
|
|
if len(b) > 65536 {
|
|
panic(vm.NewGoError(errQuota))
|
|
}
|
|
// b aliases the typed array's backing store, so this fills it.
|
|
_, _ = rand.Read(b)
|
|
return arg
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
if err := vm.Set("crypto", crypto); err != nil {
|
|
return err
|
|
}
|
|
if err := vm.Set("TextEncoder", func(call goja.ConstructorCall) *goja.Object { return newTextEncoder(vm, call) }); err != nil {
|
|
return err
|
|
}
|
|
return vm.Set("TextDecoder", func(call goja.ConstructorCall) *goja.Object { return newTextDecoder(vm, call) })
|
|
}
|
|
|
|
type quotaError struct{}
|
|
|
|
func (quotaError) Error() string {
|
|
return "QuotaExceededError: getRandomValues is limited to 65536 bytes"
|
|
}
|
|
|
|
var errQuota = quotaError{}
|
|
|
|
// atob per the HTML spec's forgiving-base64 decode: ASCII whitespace is
|
|
// removed, one or two trailing '=' are optional, and the result is a string of
|
|
// code units 0-255, one per byte.
|
|
func atob(vm *goja.Runtime, c goja.FunctionCall) goja.Value {
|
|
s := strings.Map(func(r rune) rune {
|
|
switch r {
|
|
case '\t', '\n', '\f', '\r', ' ':
|
|
return -1
|
|
}
|
|
return r
|
|
}, c.Argument(0).String())
|
|
if len(s)%4 == 0 {
|
|
s = strings.TrimSuffix(strings.TrimSuffix(s, "="), "=")
|
|
}
|
|
if len(s)%4 == 1 {
|
|
panic(vm.NewTypeError("InvalidCharacterError: atob: invalid base64 length"))
|
|
}
|
|
b, err := base64.RawStdEncoding.DecodeString(s)
|
|
if err != nil {
|
|
panic(vm.NewTypeError("InvalidCharacterError: atob: " + err.Error()))
|
|
}
|
|
return vm.ToValue(latin1String(b))
|
|
}
|
|
|
|
// btoa: every code unit must be 0-255.
|
|
func btoa(vm *goja.Runtime, c goja.FunctionCall) goja.Value {
|
|
units := utf16.Encode([]rune(c.Argument(0).String()))
|
|
b := make([]byte, len(units))
|
|
for i, u := range units {
|
|
if u > 0xff {
|
|
panic(vm.NewTypeError("InvalidCharacterError: btoa: character out of range"))
|
|
}
|
|
b[i] = byte(u)
|
|
}
|
|
return vm.ToValue(base64.StdEncoding.EncodeToString(b))
|
|
}
|
|
|
|
// latin1String maps each byte to the code point of the same value.
|
|
func latin1String(b []byte) string {
|
|
ascii := true
|
|
for _, x := range b {
|
|
if x >= 0x80 {
|
|
ascii = false
|
|
break
|
|
}
|
|
}
|
|
if ascii {
|
|
return string(b)
|
|
}
|
|
var sb strings.Builder
|
|
sb.Grow(len(b) + len(b)/2)
|
|
for _, x := range b {
|
|
sb.WriteRune(rune(x))
|
|
}
|
|
return sb.String()
|
|
}
|
|
|
|
// bytesOf reads a BufferSource: an ArrayBuffer, a typed array or a DataView.
|
|
func bytesOf(vm *goja.Runtime, v goja.Value) ([]byte, bool) {
|
|
if ab, ok := v.Export().(goja.ArrayBuffer); ok {
|
|
return ab.Bytes(), true
|
|
}
|
|
var b []byte
|
|
if err := vm.ExportTo(v, &b); err != nil {
|
|
return nil, false
|
|
}
|
|
return b, true
|
|
}
|
|
|
|
func newTextEncoder(vm *goja.Runtime, call goja.ConstructorCall) *goja.Object {
|
|
u8 := vm.Get("Uint8Array")
|
|
_ = call.This.Set("encoding", "utf-8")
|
|
_ = call.This.Set("encode", func(c goja.FunctionCall) goja.Value {
|
|
s := ""
|
|
if a := c.Argument(0); !goja.IsUndefined(a) {
|
|
s = a.String()
|
|
}
|
|
// goja's String() turns a lone surrogate into U+FFFD, which is what
|
|
// the Encoding spec's encode does too.
|
|
o, err := vm.New(u8, vm.ToValue(vm.NewArrayBuffer([]byte(s))))
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return o
|
|
})
|
|
_ = call.This.Set("encodeInto", func(c goja.FunctionCall) goja.Value {
|
|
src := c.Argument(0).String()
|
|
var dst []byte
|
|
if err := vm.ExportTo(c.Argument(1), &dst); err != nil {
|
|
panic(vm.NewTypeError("encodeInto: destination is not a Uint8Array"))
|
|
}
|
|
n, read := 0, 0
|
|
for _, r := range src {
|
|
l := utf8.RuneLen(r)
|
|
if l < 0 {
|
|
r, l = utf8.RuneError, 3
|
|
}
|
|
if n+l > len(dst) {
|
|
break
|
|
}
|
|
utf8.EncodeRune(dst[n:], r)
|
|
n += l
|
|
if r >= 0x10000 {
|
|
read += 2
|
|
} else {
|
|
read++
|
|
}
|
|
}
|
|
res := vm.NewObject()
|
|
_ = res.Set("read", read)
|
|
_ = res.Set("written", n)
|
|
return res
|
|
})
|
|
return nil
|
|
}
|
|
|
|
func newTextDecoder(vm *goja.Runtime, call goja.ConstructorCall) *goja.Object {
|
|
if label := call.Argument(0); !goja.IsUndefined(label) {
|
|
switch strings.ToLower(strings.TrimSpace(label.String())) {
|
|
case "utf-8", "utf8", "unicode-1-1-utf-8":
|
|
default:
|
|
panic(vm.NewTypeError("TextDecoder: only utf-8 is supported"))
|
|
}
|
|
}
|
|
fatal, ignoreBOM := false, false
|
|
if o := call.Argument(1); !goja.IsUndefined(o) && !goja.IsNull(o) {
|
|
obj := o.ToObject(vm)
|
|
if v := obj.Get("fatal"); v != nil {
|
|
fatal = v.ToBoolean()
|
|
}
|
|
if v := obj.Get("ignoreBOM"); v != nil {
|
|
ignoreBOM = v.ToBoolean()
|
|
}
|
|
}
|
|
_ = call.This.Set("encoding", "utf-8")
|
|
_ = call.This.Set("fatal", fatal)
|
|
_ = call.This.Set("ignoreBOM", ignoreBOM)
|
|
_ = call.This.Set("decode", func(c goja.FunctionCall) goja.Value {
|
|
a := c.Argument(0)
|
|
if goja.IsUndefined(a) {
|
|
return vm.ToValue("")
|
|
}
|
|
b, ok := bytesOf(vm, a)
|
|
if !ok {
|
|
panic(vm.NewTypeError("TextDecoder.decode: argument is not a BufferSource"))
|
|
}
|
|
if !ignoreBOM && len(b) >= 3 && b[0] == 0xEF && b[1] == 0xBB && b[2] == 0xBF {
|
|
b = b[3:]
|
|
}
|
|
if !utf8.Valid(b) {
|
|
if fatal {
|
|
panic(vm.NewTypeError("The encoded data was not valid for encoding utf-8"))
|
|
}
|
|
b = []byte(strings.ToValidUTF8(string(b), "�"))
|
|
}
|
|
return vm.ToValue(string(b))
|
|
})
|
|
return nil
|
|
}
|