diff --git a/go.mod b/go.mod index acc3f8e..5c9419b 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/priyanshujain/uatu go 1.26.1 require ( + github.com/evanw/esbuild v0.28.0 google.golang.org/grpc v1.80.0 google.golang.org/protobuf v1.36.11 ) diff --git a/go.sum b/go.sum index 98ff7bd..59ae952 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/evanw/esbuild v0.28.0 h1:V96ghtc5p5JnNUQIUsc5H3kr+AcFcMqOJll2ZmJW6Lo= +github.com/evanw/esbuild v0.28.0/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= @@ -24,6 +26,7 @@ go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6 go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= diff --git a/internal/bundler/bundler.go b/internal/bundler/bundler.go new file mode 100644 index 0000000..e0dc7ed --- /dev/null +++ b/internal/bundler/bundler.go @@ -0,0 +1,76 @@ +package bundler + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "strings" + + esbuild "github.com/evanw/esbuild/pkg/api" +) + +type Options struct { + EntryFile string + Defines map[string]string + Sourcemap bool +} + +type Result struct { + JavaScript []byte + SHA256 string +} + +// Bundle compiles the entry TypeScript file into a single IIFE JavaScript +// blob targeting ES2020. Defines are injected as literal string values for +// process.env. lookups in the spec source. +func Bundle(options Options) (Result, error) { + if options.EntryFile == "" { + return Result{}, errors.New("EntryFile is required") + } + + defines := map[string]string{} + for key, value := range options.Defines { + quoted, err := json.Marshal(value) + if err != nil { + return Result{}, fmt.Errorf("define %q: %w", key, err) + } + defines["process.env."+key] = string(quoted) + } + + sourcemap := esbuild.SourceMapNone + if options.Sourcemap { + sourcemap = esbuild.SourceMapInline + } + + output := esbuild.Build(esbuild.BuildOptions{ + EntryPoints: []string{options.EntryFile}, + Bundle: true, + Format: esbuild.FormatIIFE, + Target: esbuild.ES2020, + Platform: esbuild.PlatformNeutral, + Define: defines, + Sourcemap: sourcemap, + Write: false, + LogLevel: esbuild.LogLevelSilent, + }) + + if len(output.Errors) > 0 { + var messages []string + for _, err := range output.Errors { + messages = append(messages, err.Text) + } + return Result{}, fmt.Errorf("bundle failed: %s", strings.Join(messages, "; ")) + } + if len(output.OutputFiles) == 0 { + return Result{}, errors.New("bundle produced no output files") + } + + javascript := output.OutputFiles[0].Contents + sum := sha256.Sum256(javascript) + return Result{ + JavaScript: javascript, + SHA256: hex.EncodeToString(sum[:]), + }, nil +} diff --git a/internal/bundler/bundler_test.go b/internal/bundler/bundler_test.go new file mode 100644 index 0000000..7443b0d --- /dev/null +++ b/internal/bundler/bundler_test.go @@ -0,0 +1,166 @@ +package bundler + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func writeFixture(t *testing.T, name, contents string) string { + t.Helper() + directory := t.TempDir() + path := filepath.Join(directory, name) + if err := os.WriteFile(path, []byte(contents), 0o600); err != nil { + t.Fatal(err) + } + return path +} + +func TestBundle_RequiresEntryFile(t *testing.T) { + _, err := Bundle(Options{}) + if err == nil || !strings.Contains(err.Error(), "EntryFile") { + t.Fatalf("expected EntryFile error, got %v", err) + } +} + +func TestBundle_ProducesIIFE(t *testing.T) { + entry := writeFixture(t, "spec.ts", ` + const greeting: string = "hello"; + console.log(greeting); + `) + result, err := Bundle(Options{EntryFile: entry}) + if err != nil { + t.Fatal(err) + } + body := string(result.JavaScript) + if !strings.Contains(body, "(() =>") && !strings.Contains(body, "(function") { + t.Errorf("expected IIFE wrapping, got: %s", body) + } + if !strings.Contains(body, "hello") { + t.Errorf("expected literal to survive bundling: %s", body) + } + if result.SHA256 == "" { + t.Errorf("expected non-empty SHA256") + } +} + +func TestBundle_InlinesDefines(t *testing.T) { + entry := writeFixture(t, "spec.ts", ` + const phone = process.env.UATU_TEST_PHONE; + const otp = process.env.UATU_TEST_OTP; + export const credentials = { phone, otp }; + `) + result, err := Bundle(Options{ + EntryFile: entry, + Defines: map[string]string{ + "UATU_TEST_PHONE": "+919876543210", + "UATU_TEST_OTP": "123456", + }, + }) + if err != nil { + t.Fatal(err) + } + body := string(result.JavaScript) + if !strings.Contains(body, "+919876543210") { + t.Errorf("phone define not inlined: %s", body) + } + if !strings.Contains(body, "123456") { + t.Errorf("otp define not inlined: %s", body) + } +} + +func TestBundle_DeterministicHash(t *testing.T) { + entry := writeFixture(t, "spec.ts", `export const x = 1;`) + first, err := Bundle(Options{EntryFile: entry}) + if err != nil { + t.Fatal(err) + } + second, err := Bundle(Options{EntryFile: entry}) + if err != nil { + t.Fatal(err) + } + if first.SHA256 != second.SHA256 { + t.Errorf("hash should be stable: %s vs %s", first.SHA256, second.SHA256) + } +} + +func TestBundle_HashChangesWithDefines(t *testing.T) { + entry := writeFixture(t, "spec.ts", ` + export const phone = process.env.UATU_TEST_PHONE; + `) + first, err := Bundle(Options{EntryFile: entry, Defines: map[string]string{"UATU_TEST_PHONE": "1111"}}) + if err != nil { + t.Fatal(err) + } + second, err := Bundle(Options{EntryFile: entry, Defines: map[string]string{"UATU_TEST_PHONE": "2222"}}) + if err != nil { + t.Fatal(err) + } + if first.SHA256 == second.SHA256 { + t.Errorf("hash should change when defines change") + } +} + +func TestBundle_DefineEscapesJSONSpecialChars(t *testing.T) { + entry := writeFixture(t, "spec.ts", ` + export const value = process.env.WEIRD; + `) + result, err := Bundle(Options{ + EntryFile: entry, + Defines: map[string]string{"WEIRD": `quote " backslash \ newline`}, + }) + if err != nil { + t.Fatal(err) + } + // esbuild may pick single or double quoting; just confirm the meaningful + // fragments survive bundling intact. + body := string(result.JavaScript) + for _, fragment := range []string{`quote `, `backslash `, `newline`} { + if !strings.Contains(body, fragment) { + t.Errorf("missing fragment %q in bundle:\n%s", fragment, body) + } + } +} + +func TestBundle_ImportResolution(t *testing.T) { + directory := t.TempDir() + helperPath := filepath.Join(directory, "helper.ts") + entryPath := filepath.Join(directory, "entry.ts") + if err := os.WriteFile(helperPath, []byte(`export const helperValue = 42;`), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(entryPath, []byte(` + import { helperValue } from "./helper"; + console.log(helperValue); + `), 0o600); err != nil { + t.Fatal(err) + } + + result, err := Bundle(Options{EntryFile: entryPath}) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(result.JavaScript), "42") { + t.Errorf("imported value should be inlined: %s", result.JavaScript) + } +} + +func TestBundle_ReportsSyntaxErrors(t *testing.T) { + entry := writeFixture(t, "broken.ts", `const x = ;`) + _, err := Bundle(Options{EntryFile: entry}) + if err == nil || !strings.Contains(err.Error(), "bundle failed") { + t.Errorf("expected bundle failure, got %v", err) + } +} + +func TestBundle_SourcemapInlinedWhenRequested(t *testing.T) { + entry := writeFixture(t, "spec.ts", `export const x = 1;`) + result, err := Bundle(Options{EntryFile: entry, Sourcemap: true}) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(result.JavaScript), "sourceMappingURL") { + t.Errorf("expected inline sourcemap") + } +}