mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-03 19:47:10 +00:00
feat(bundler): BundleWeb + V8-side runtime shim
web-runtime.ts installs globalThis.__sanderling__ with extractor / action registries, plus __sanderlingExtractors__ + __sanderlingNextAction__ globals. BundleWeb composes user spec + runtime under esbuild's PlatformBrowser into one IIFE.
This commit is contained in:
1 parent
ee3737c831
commit
a31d9869b1
3 files changed
+668
No files matched your search
@@ -0,0 +1,113 @@
|
||||
package bundler
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
esbuild "github.com/evanw/esbuild/pkg/api"
|
||||
)
|
||||
|
||||
// WebOptions configures BundleWeb. WebRuntimeFile points at
|
||||
// pkg/spec/src/web-runtime.ts (resolved via the same upward search the regular
|
||||
// Bundle uses for @sanderling/spec). When empty, BundleWeb expects callers to
|
||||
// have set Aliases such that "@sanderling/spec/web-runtime" resolves.
|
||||
type WebOptions struct {
|
||||
EntryFile string
|
||||
WebRuntimeFile string
|
||||
Defines map[string]string
|
||||
Aliases map[string]string
|
||||
}
|
||||
|
||||
// BundleWeb compiles the user spec together with the V8-side runtime into a
|
||||
// single IIFE that, on evaluation, installs `globalThis.__sanderling__` and
|
||||
// the `__sanderlingExtractors__` / `__sanderlingNextAction__` globals. The
|
||||
// host injects the result via Page.AddScriptToEvaluateOnNewDocument.
|
||||
func BundleWeb(options WebOptions) (Result, error) {
|
||||
if options.EntryFile == "" {
|
||||
return Result{}, errors.New("EntryFile is required")
|
||||
}
|
||||
if options.WebRuntimeFile == "" {
|
||||
return Result{}, errors.New("WebRuntimeFile is required")
|
||||
}
|
||||
if _, err := filepath.Abs(options.EntryFile); err != nil {
|
||||
return Result{}, fmt.Errorf("entry path: %w", err)
|
||||
}
|
||||
runtimeAbs, err := filepath.Abs(options.WebRuntimeFile)
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("runtime path: %w", err)
|
||||
}
|
||||
specAbs, err := filepath.Abs(options.EntryFile)
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("entry path: %w", err)
|
||||
}
|
||||
|
||||
defines := map[string]string{}
|
||||
for key, value := range options.Defines {
|
||||
defines["process.env."+key] = quoteJSString(value)
|
||||
}
|
||||
|
||||
stdinContents := fmt.Sprintf(`import %q;
|
||||
import %q;
|
||||
`, runtimeAbs, specAbs)
|
||||
|
||||
output := esbuild.Build(esbuild.BuildOptions{
|
||||
Stdin: &esbuild.StdinOptions{
|
||||
Contents: stdinContents,
|
||||
ResolveDir: filepath.Dir(specAbs),
|
||||
Loader: esbuild.LoaderTS,
|
||||
},
|
||||
Bundle: true,
|
||||
Format: esbuild.FormatIIFE,
|
||||
Target: esbuild.ES2020,
|
||||
Platform: esbuild.PlatformBrowser,
|
||||
Define: defines,
|
||||
Alias: options.Aliases,
|
||||
Write: false,
|
||||
LogLevel: esbuild.LogLevelSilent,
|
||||
})
|
||||
|
||||
if len(output.Errors) > 0 {
|
||||
var messages []string
|
||||
for _, message := range output.Errors {
|
||||
messages = append(messages, message.Text)
|
||||
}
|
||||
return Result{}, fmt.Errorf("web bundle failed: %s", strings.Join(messages, "; "))
|
||||
}
|
||||
if len(output.OutputFiles) == 0 {
|
||||
return Result{}, errors.New("web bundle produced no output files")
|
||||
}
|
||||
|
||||
javascript := output.OutputFiles[0].Contents
|
||||
sum := sha256.Sum256(javascript)
|
||||
return Result{
|
||||
JavaScript: javascript,
|
||||
SHA256: hex.EncodeToString(sum[:]),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func quoteJSString(value string) string {
|
||||
var builder strings.Builder
|
||||
builder.WriteByte('"')
|
||||
for index := 0; index < len(value); index++ {
|
||||
c := value[index]
|
||||
switch c {
|
||||
case '"', '\\':
|
||||
builder.WriteByte('\\')
|
||||
builder.WriteByte(c)
|
||||
case '\n':
|
||||
builder.WriteString("\\n")
|
||||
case '\r':
|
||||
builder.WriteString("\\r")
|
||||
case '\t':
|
||||
builder.WriteString("\\t")
|
||||
default:
|
||||
builder.WriteByte(c)
|
||||
}
|
||||
}
|
||||
builder.WriteByte('"')
|
||||
return builder.String()
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package bundler
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const fakeRuntime = `
|
||||
(globalThis as Record<string, unknown>).__sanderling__ = {
|
||||
extract: (g: () => unknown) => ({ current: g(), previous: undefined }),
|
||||
always: () => ({ __sanderlingFormula: true }),
|
||||
now: () => ({ __sanderlingFormula: true }),
|
||||
next: () => ({ __sanderlingFormula: true }),
|
||||
eventually: () => ({ __sanderlingFormula: true }),
|
||||
actions: (g: () => unknown) => ({ __sanderlingActionGenerator: true, generate: g }),
|
||||
weighted: () => ({ __sanderlingActionGenerator: true }),
|
||||
from: () => ({ generate: () => null }),
|
||||
tap: () => null,
|
||||
inputText: () => null,
|
||||
swipe: () => null,
|
||||
pressKey: () => null,
|
||||
wait: () => null,
|
||||
taps: {},
|
||||
swipes: {},
|
||||
waitOnce: {},
|
||||
pressKeys: {},
|
||||
};
|
||||
(globalThis as Record<string, unknown>).__sanderlingExtractors__ = () => ({});
|
||||
(globalThis as Record<string, unknown>).__sanderlingNextAction__ = () => null;
|
||||
export {};
|
||||
`
|
||||
|
||||
const fakeSpec = `
|
||||
const handle = (globalThis as { __sanderling__: { extract: (g: () => unknown) => unknown } }).__sanderling__.extract(() => 42);
|
||||
(globalThis as { actions?: unknown }).actions = handle;
|
||||
export {};
|
||||
`
|
||||
|
||||
func TestBundleWeb_RegistersExpectedGlobals(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
runtimePath := filepath.Join(directory, "web-runtime.ts")
|
||||
specPath := filepath.Join(directory, "spec.ts")
|
||||
if err := os.WriteFile(runtimePath, []byte(fakeRuntime), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(specPath, []byte(fakeSpec), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
result, err := BundleWeb(WebOptions{
|
||||
EntryFile: specPath,
|
||||
WebRuntimeFile: runtimePath,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BundleWeb: %v", err)
|
||||
}
|
||||
source := string(result.JavaScript)
|
||||
for _, expected := range []string{
|
||||
"__sanderlingExtractors__",
|
||||
"__sanderlingNextAction__",
|
||||
"__sanderling__",
|
||||
} {
|
||||
if !strings.Contains(source, expected) {
|
||||
t.Errorf("bundle missing %q\nsource head:\n%s", expected, head(source, 500))
|
||||
}
|
||||
}
|
||||
if !strings.HasPrefix(strings.TrimSpace(source), "(()") {
|
||||
t.Errorf("expected IIFE format, head:\n%s", head(source, 200))
|
||||
}
|
||||
if result.SHA256 == "" {
|
||||
t.Errorf("expected non-empty sha256")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBundleWeb_RejectsMissingEntry(t *testing.T) {
|
||||
if _, err := BundleWeb(WebOptions{}); err == nil {
|
||||
t.Error("expected error for empty options")
|
||||
}
|
||||
if _, err := BundleWeb(WebOptions{EntryFile: "x.ts"}); err == nil {
|
||||
t.Error("expected error for missing runtime")
|
||||
}
|
||||
}
|
||||
|
||||
func head(text string, max int) string {
|
||||
if len(text) <= max {
|
||||
return text
|
||||
}
|
||||
return text[:max] + "..."
|
||||
}
|
||||
Reference in new issue
Block a user