goa: pure-Go polyglot embedding (Go, Python, TS/JS, Rust/WASM)
One Module/Engine interface with a JSON-in/JSON-out calling convention runs four pure-Go engines behind a Pool that makes single-threaded interpreters serve requests as parallel goroutines: native plain Go handlers, zero overhead goja + esbuild JavaScript and TypeScript (TS transpiled to ES2020) gpython Python, no cgo and no GIL -> true goroutine parallelism wazero Rust/WASM via a trivial string ABI Services mount from a manifest (lang + source + routes), so any Go, Python, Rust or JS backend collapses into a single static CGO_ENABLED=0 binary served on the zip/gofiber app. A Go-backed json stdlib shim lets Python backends keep importing the real module names while running at Go speed. Examples and tests cover every engine; the Rust guest is a real wasm32 fixture exercised end to end.
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
# Rust example build artifacts (the built echo.wasm fixture is committed)
|
||||
examples/echo-rust/target/
|
||||
|
||||
# Go
|
||||
*.test
|
||||
*.out
|
||||
@@ -0,0 +1,202 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,131 @@
|
||||
# goa
|
||||
|
||||
Pure-Go polyglot embedding. Run Go, Python, TypeScript/JavaScript, and
|
||||
Rust/WASM services in one process — as goroutines, no cgo, no subprocesses, no
|
||||
GIL — so a fleet of services collapses into a single static binary
|
||||
(`CGO_ENABLED=0`) that scales horizontally by replication.
|
||||
|
||||
```
|
||||
import "github.com/hanzoai/goa"
|
||||
```
|
||||
|
||||
## Why
|
||||
|
||||
hanzoai/cloud is one Go binary: subsystems register and mount onto a
|
||||
[zip](https://github.com/hanzoai/zip) (gofiber) app. Most of our backends are
|
||||
*not* Go — Langflow is Python, the migrated explorer services are Rust,
|
||||
assorted tools are TypeScript. The choice was a swarm of sidecar containers, or
|
||||
embed the runtimes. goa embeds them, in pure Go, preserving the static build.
|
||||
|
||||
## Model
|
||||
|
||||
One contract, every language: **JSON in, JSON out.**
|
||||
|
||||
```go
|
||||
type Module interface {
|
||||
Invoke(ctx context.Context, fn string, payload []byte) ([]byte, error)
|
||||
Funcs() []string
|
||||
Close() error
|
||||
}
|
||||
|
||||
type Engine interface {
|
||||
Lang() string
|
||||
Aliases() []string
|
||||
Load(ctx context.Context, src []byte, opt LoadOptions) (Module, error)
|
||||
}
|
||||
```
|
||||
|
||||
Four engines register themselves, all pure-Go:
|
||||
|
||||
| Lang | Engine | Notes |
|
||||
|------------------|-------------------------------------|--------------------------------------------------|
|
||||
| Go | native | `map[string]HandlerFunc`, zero overhead |
|
||||
| JavaScript / TS | [goja](https://github.com/dop251/goja) + [esbuild](https://github.com/evanw/esbuild) | TS transpiled to ES2020 CommonJS |
|
||||
| Python | [gpython](https://github.com/go-python/gpython) | no cgo, no GIL → true goroutine parallelism |
|
||||
| Rust / WASM | [wazero](https://github.com/tetratelabs/wazero) | wasm32 + WASI, trivial string ABI |
|
||||
|
||||
Engines that run single-threaded code (goja, gpython, a wasm instance) are made
|
||||
concurrent by a **Pool**: N independent interpreters, one per in-flight request,
|
||||
so every HTTP route is still just a goroutine.
|
||||
|
||||
## Use
|
||||
|
||||
### Embed source directly
|
||||
|
||||
```go
|
||||
m, _ := goa.Load(ctx, "python", []byte(`
|
||||
import json
|
||||
def greet(p):
|
||||
return json.dumps({"hello": json.loads(p)["name"]})
|
||||
`), goa.LoadOptions{Name: "greeter"})
|
||||
|
||||
out, _ := m.Invoke(ctx, "greet", []byte(`{"name":"ada"}`))
|
||||
// {"hello":"ada"}
|
||||
```
|
||||
|
||||
### Mount Go natively (zero overhead)
|
||||
|
||||
```go
|
||||
m := goa.NewNativeModule(map[string]goa.HandlerFunc{
|
||||
"greet": func(ctx context.Context, p []byte) ([]byte, error) { return p, nil },
|
||||
})
|
||||
```
|
||||
|
||||
### Drop-in services: a manifest + a source file
|
||||
|
||||
`hello-py.manifest.json`
|
||||
```json
|
||||
{
|
||||
"name": "hello-py",
|
||||
"lang": "python",
|
||||
"source": "hello.py",
|
||||
"pool": 8,
|
||||
"prefix": "/v1/hello-py",
|
||||
"routes": [{ "method": "POST", "path": "/greet", "func": "greet" }]
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
services, _ := goa.LoadDir(ctx, "services") // every *.manifest.json
|
||||
mux := http.NewServeMux()
|
||||
for _, s := range services {
|
||||
s.Mount(mux)
|
||||
}
|
||||
```
|
||||
|
||||
In hanzoai/cloud the same `Service` mounts onto the gofiber app via
|
||||
`zip.AdaptNetHTTP(svc.Handler())` — goa stays host-agnostic.
|
||||
|
||||
## Python stdlib
|
||||
|
||||
gpython is "batteries not included." goa ships **Go-backed** implementations of
|
||||
the modules our backends actually use, registered under their real names — so
|
||||
the Python keeps `import json` and runs at Go speed (the modules that are
|
||||
C-accelerated in CPython are Go-native here). `json` ships today; `re`,
|
||||
`hashlib`, `base64`, `datetime`, `uuid` extend the same `pyToGo`/`goToPy`
|
||||
pattern in `stdlib_gpython.go`.
|
||||
|
||||
## Two tiers
|
||||
|
||||
goa is **Tier 1**: in-process logic, pure-Go, for handlers and transforms.
|
||||
Anything that needs a real OS process — a long-running server, a GPU workload,
|
||||
heavy native Python (NumPy/Torch), an unported C extension — is **Tier 2**:
|
||||
run it out-of-process and front it with a native proxy module. Tier 1 is the
|
||||
default; reach for Tier 2 only when the workload genuinely needs an OS boundary.
|
||||
|
||||
## Rust / WASM ABI
|
||||
|
||||
A guest module exports:
|
||||
|
||||
```
|
||||
alloc(len: i32) -> i32 // buffer pointer for the payload
|
||||
dealloc(ptr: i32, len: i32) // optional
|
||||
<fn>(ptr: i32, len: i32) -> i64 // returns (resultPtr << 32 | resultLen)
|
||||
```
|
||||
|
||||
The host writes JSON at `alloc()`'s pointer, calls `<fn>`, reads JSON back. A
|
||||
`#[goa::handler]` proc-macro generates this boilerplate on the Rust side.
|
||||
|
||||
## License
|
||||
|
||||
Apache-2.0
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
package goa
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
esbuild "github.com/evanw/esbuild/pkg/api"
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// gojaEngine runs JavaScript and TypeScript. TS/JSX is transpiled to JS with
|
||||
// esbuild first (pure Go), then executed on goja (pure Go). The module is
|
||||
// loaded as CommonJS: handlers are read from module.exports or the global
|
||||
// scope. A handler is `function name(jsonString) { return jsonString }`.
|
||||
type gojaEngine struct{}
|
||||
|
||||
func init() { Register(gojaEngine{}) }
|
||||
|
||||
func (gojaEngine) Lang() string { return "javascript" }
|
||||
func (gojaEngine) Aliases() []string { return []string{"js", "ts", "typescript", "jsx", "tsx"} }
|
||||
|
||||
func (gojaEngine) Load(_ context.Context, src []byte, opt LoadOptions) (Module, error) {
|
||||
res := esbuild.Transform(string(src), esbuild.TransformOptions{
|
||||
Loader: esbuild.LoaderTS, // TS is a superset of JS, so this also accepts plain JS
|
||||
Format: esbuild.FormatCommonJS,
|
||||
Target: esbuild.ES2020,
|
||||
})
|
||||
if len(res.Errors) > 0 {
|
||||
return nil, fmt.Errorf("goa/js: esbuild: %s", res.Errors[0].Text)
|
||||
}
|
||||
|
||||
vm := goja.New()
|
||||
// Minimal CommonJS surface + injected env.
|
||||
module := vm.NewObject()
|
||||
exports := vm.NewObject()
|
||||
_ = module.Set("exports", exports)
|
||||
_ = vm.Set("module", module)
|
||||
_ = vm.Set("exports", exports)
|
||||
procEnv := vm.NewObject()
|
||||
for k, v := range opt.Env {
|
||||
_ = procEnv.Set(k, v)
|
||||
}
|
||||
proc := vm.NewObject()
|
||||
_ = proc.Set("env", procEnv)
|
||||
_ = vm.Set("process", proc)
|
||||
|
||||
if _, err := vm.RunString(string(res.Code)); err != nil {
|
||||
return nil, fmt.Errorf("goa/js: load %q: %w", opt.Name, err)
|
||||
}
|
||||
exp := module.Get("exports")
|
||||
expObj, _ := exp.(*goja.Object) // module.exports may have been reassigned
|
||||
if expObj == nil {
|
||||
expObj = exports
|
||||
}
|
||||
return &gojaModule{vm: vm, exports: expObj}, nil
|
||||
}
|
||||
|
||||
type gojaModule struct {
|
||||
mu sync.Mutex
|
||||
vm *goja.Runtime
|
||||
exports *goja.Object
|
||||
}
|
||||
|
||||
func (m *gojaModule) resolve(fn string) (goja.Callable, bool) {
|
||||
if v := m.exports.Get(fn); v != nil {
|
||||
if cb, ok := goja.AssertFunction(v); ok {
|
||||
return cb, true
|
||||
}
|
||||
}
|
||||
if v := m.vm.Get(fn); v != nil {
|
||||
if cb, ok := goja.AssertFunction(v); ok {
|
||||
return cb, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (m *gojaModule) Invoke(_ context.Context, fn string, payload []byte) ([]byte, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
cb, ok := m.resolve(fn)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("goa/js: no exported function %q", fn)
|
||||
}
|
||||
res, err := cb(goja.Undefined(), m.vm.ToValue(string(payload)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("goa/js: %s: %w", fn, err)
|
||||
}
|
||||
if res == nil || goja.IsUndefined(res) || goja.IsNull(res) {
|
||||
return []byte("null"), nil
|
||||
}
|
||||
return []byte(res.String()), nil
|
||||
}
|
||||
|
||||
func (m *gojaModule) Funcs() []string {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
var out []string
|
||||
for _, k := range m.exports.Keys() {
|
||||
if _, ok := goja.AssertFunction(m.exports.Get(k)); ok {
|
||||
out = append(out, k)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (m *gojaModule) Close() error { return nil }
|
||||
@@ -0,0 +1,92 @@
|
||||
package goa
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"github.com/go-python/gpython/py"
|
||||
_ "github.com/go-python/gpython/compile" // wires py.Compile
|
||||
_ "github.com/go-python/gpython/stdlib" // wires py.NewContext + built-in stdlib
|
||||
)
|
||||
|
||||
// gpythonEngine runs Python on gpython — a pure-Go interpreter. Because it is
|
||||
// plain Go with no shared CPython GIL, each loaded module owns an independent
|
||||
// py.Context and N modules in a Pool run N Python calls in true parallel, as
|
||||
// ordinary goroutines, inside the CGO_ENABLED=0 static binary. A handler is
|
||||
// `def name(payload: str) -> str` returning a JSON string.
|
||||
//
|
||||
// gpython is "batteries not included"; goa augments it with Go-backed stdlib
|
||||
// shims (see stdlib_gpython.go) so real backends keep working in Python while
|
||||
// the hot modules (json, re, hashlib, base64) run at Go speed.
|
||||
type gpythonEngine struct{}
|
||||
|
||||
func init() {
|
||||
registerGoBackedStdlib() // must run before any context is created
|
||||
Register(gpythonEngine{})
|
||||
}
|
||||
|
||||
func (gpythonEngine) Lang() string { return "python" }
|
||||
func (gpythonEngine) Aliases() []string { return []string{"py", "python3"} }
|
||||
|
||||
func (gpythonEngine) Load(_ context.Context, src []byte, opt LoadOptions) (Module, error) {
|
||||
pyctx := py.NewContext(py.DefaultContextOpts())
|
||||
// ModuleInit compiles src in ExecMode (whole module) and runs it, so every
|
||||
// top-level statement executes and all defs land in Globals. (py.RunSrc uses
|
||||
// SingleMode — interactive, first-statement-only — which silently drops
|
||||
// handlers defined after the first statement.)
|
||||
mod, err := pyctx.ModuleInit(&py.ModuleImpl{
|
||||
Info: py.ModuleInfo{Name: opt.Name, FileDesc: opt.Name + ".py"},
|
||||
CodeSrc: string(src),
|
||||
})
|
||||
if err != nil {
|
||||
_ = pyctx.Close()
|
||||
return nil, fmt.Errorf("goa/py: load %q: %w", opt.Name, err)
|
||||
}
|
||||
return &gpyModule{ctx: pyctx, globals: mod.Globals}, nil
|
||||
}
|
||||
|
||||
type gpyModule struct {
|
||||
mu sync.Mutex // a py.Context is single-threaded; Pool gives concurrency
|
||||
ctx py.Context
|
||||
globals py.StringDict
|
||||
}
|
||||
|
||||
func (m *gpyModule) Invoke(_ context.Context, fn string, payload []byte) ([]byte, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
obj, ok := m.globals[fn]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("goa/py: no function %q", fn)
|
||||
}
|
||||
res, err := py.Call(obj, py.Tuple{py.String(payload)}, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("goa/py: %s: %w", fn, err)
|
||||
}
|
||||
switch v := res.(type) {
|
||||
case py.String:
|
||||
return []byte(v), nil
|
||||
case py.Bytes:
|
||||
return []byte(v), nil
|
||||
case py.NoneType:
|
||||
return []byte("null"), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("goa/py: %s must return a JSON str/bytes, got %s", fn, res.Type().Name)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *gpyModule) Funcs() []string {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
var out []string
|
||||
for name, obj := range m.globals {
|
||||
if _, ok := obj.(*py.Function); ok {
|
||||
out = append(out, name)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func (m *gpyModule) Close() error { return m.ctx.Close() }
|
||||
@@ -0,0 +1,112 @@
|
||||
package goa
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/tetratelabs/wazero"
|
||||
"github.com/tetratelabs/wazero/api"
|
||||
"github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1"
|
||||
)
|
||||
|
||||
// wazeroEngine runs WebAssembly — the in-process path for Rust (and C, Zig,
|
||||
// TinyGo). Rust compiles cleanly to wasm32, so a Rust service becomes a .wasm
|
||||
// loaded here, sandboxed and pure-Go (no cgo). Like the others it serves the
|
||||
// JSON-in/JSON-out contract through a tiny string ABI the guest exports:
|
||||
//
|
||||
// alloc(len: i32) -> i32 // return a buffer pointer
|
||||
// dealloc(ptr: i32, len: i32) // optional, freed if present
|
||||
// <fn>(ptr: i32, len: i32) -> i64 // returns (resultPtr<<32 | resultLen)
|
||||
//
|
||||
// The host writes the JSON payload at alloc()'s pointer, calls <fn>, and reads
|
||||
// the JSON result back from linear memory. (A #[goa::handler] proc-macro on the
|
||||
// Rust side will generate this boilerplate; the ABI is intentionally trivial.)
|
||||
type wazeroEngine struct{}
|
||||
|
||||
func init() { Register(wazeroEngine{}) }
|
||||
|
||||
func (wazeroEngine) Lang() string { return "wasm" }
|
||||
func (wazeroEngine) Aliases() []string { return []string{"rust", "wat", "wasm32"} }
|
||||
|
||||
func (wazeroEngine) Load(ctx context.Context, src []byte, opt LoadOptions) (Module, error) {
|
||||
rt := wazero.NewRuntime(ctx)
|
||||
wasi_snapshot_preview1.MustInstantiate(ctx, rt) // many Rust/wasm targets need WASI
|
||||
mod, err := rt.InstantiateWithConfig(ctx, src,
|
||||
wazero.NewModuleConfig().WithName(opt.Name))
|
||||
if err != nil {
|
||||
_ = rt.Close(ctx)
|
||||
return nil, fmt.Errorf("goa/wasm: load %q: %w", opt.Name, err)
|
||||
}
|
||||
if mod.ExportedFunction("alloc") == nil {
|
||||
_ = rt.Close(ctx)
|
||||
return nil, fmt.Errorf("goa/wasm: %q must export alloc(i32)->i32 for the string ABI", opt.Name)
|
||||
}
|
||||
return &wasmModule{rt: rt, mod: mod}, nil
|
||||
}
|
||||
|
||||
type wasmModule struct {
|
||||
mu sync.Mutex // a wasm instance is single-threaded; Pool gives concurrency
|
||||
rt wazero.Runtime
|
||||
mod api.Module
|
||||
}
|
||||
|
||||
func (m *wasmModule) Invoke(ctx context.Context, fn string, payload []byte) ([]byte, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
alloc := m.mod.ExportedFunction("alloc")
|
||||
handler := m.mod.ExportedFunction(fn)
|
||||
if handler == nil {
|
||||
return nil, fmt.Errorf("goa/wasm: no export %q", fn)
|
||||
}
|
||||
mem := m.mod.Memory()
|
||||
|
||||
in, err := alloc.Call(ctx, uint64(len(payload)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("goa/wasm: alloc: %w", err)
|
||||
}
|
||||
inPtr := uint32(in[0])
|
||||
if !mem.Write(inPtr, payload) {
|
||||
return nil, fmt.Errorf("goa/wasm: write payload out of range")
|
||||
}
|
||||
|
||||
out, err := handler.Call(ctx, uint64(inPtr), uint64(len(payload)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("goa/wasm: %s: %w", fn, err)
|
||||
}
|
||||
if dealloc := m.mod.ExportedFunction("dealloc"); dealloc != nil {
|
||||
_, _ = dealloc.Call(ctx, uint64(inPtr), uint64(len(payload)))
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return []byte("null"), nil
|
||||
}
|
||||
packed := out[0]
|
||||
resPtr := uint32(packed >> 32)
|
||||
resLen := uint32(packed)
|
||||
data, ok := mem.Read(resPtr, resLen)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("goa/wasm: result out of range (ptr=%d len=%d)", resPtr, resLen)
|
||||
}
|
||||
result := make([]byte, resLen) // copy out of guest memory before reuse
|
||||
copy(result, data)
|
||||
if dealloc := m.mod.ExportedFunction("dealloc"); dealloc != nil {
|
||||
_, _ = dealloc.Call(ctx, uint64(resPtr), uint64(resLen))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *wasmModule) Funcs() []string {
|
||||
var out []string
|
||||
for name, def := range m.mod.ExportedFunctionDefinitions() {
|
||||
switch name {
|
||||
case "alloc", "dealloc", "_start", "memory":
|
||||
continue
|
||||
}
|
||||
_ = def
|
||||
out = append(out, name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (m *wasmModule) Close() error { return m.rt.Close(context.Background()) }
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "echo-rust",
|
||||
"lang": "rust",
|
||||
"source": "echo-rust/echo.wasm",
|
||||
"pool": 8,
|
||||
"prefix": "/v1/echo-rust",
|
||||
"routes": [
|
||||
{ "method": "POST", "path": "/echo", "func": "echo" }
|
||||
]
|
||||
}
|
||||
Generated
+7
@@ -0,0 +1,7 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "echo-rust"
|
||||
version = "0.1.0"
|
||||
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "echo-rust"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
# A goa Rust guest: compiles to a single self-contained wasm32 module with no
|
||||
# std, no external crates — just the goa string ABI. Build with:
|
||||
# cargo build --release --target wasm32-unknown-unknown
|
||||
# cp target/wasm32-unknown-unknown/release/echo_rust.wasm echo.wasm
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
panic = "abort"
|
||||
strip = true
|
||||
Executable
BIN
Binary file not shown.
@@ -0,0 +1,71 @@
|
||||
//! A goa Rust guest. It implements the goa string ABI so a Rust service runs
|
||||
//! in-process under wazero (pure-Go host, no cgo). No std, no external crates:
|
||||
//! a tiny bump allocator over a static arena backs alloc(), and a live-count in
|
||||
//! dealloc() resets the arena once every outstanding buffer is freed — so
|
||||
//! repeated calls never grow memory (the host frees both the payload and the
|
||||
//! result each invoke).
|
||||
//!
|
||||
//! alloc(len) -> ptr host's buffer for the JSON payload
|
||||
//! dealloc(ptr, len) frees; resets the arena when nothing is live
|
||||
//! <fn>(ptr, len) -> i64 returns (resultPtr << 32 | resultLen)
|
||||
//!
|
||||
//! A real service would swap the arena for std's allocator (build with the
|
||||
//! wasm32 std) or wee_alloc; the ABI surface is identical. The `#[goa::handler]`
|
||||
//! proc-macro will generate alloc/dealloc/pack so a service author writes only
|
||||
//! `fn echo(input: &[u8]) -> Vec<u8>`.
|
||||
#![no_std]
|
||||
|
||||
use core::panic::PanicInfo;
|
||||
use core::ptr::addr_of_mut;
|
||||
use core::slice;
|
||||
|
||||
#[panic_handler]
|
||||
fn panic(_: &PanicInfo) -> ! {
|
||||
loop {}
|
||||
}
|
||||
|
||||
const ARENA: usize = 1 << 16; // 64 KiB
|
||||
static mut HEAP: [u8; ARENA] = [0; ARENA];
|
||||
static mut TOP: usize = 0;
|
||||
static mut LIVE: i32 = 0;
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn alloc(len: i32) -> i32 {
|
||||
unsafe {
|
||||
let base = addr_of_mut!(HEAP) as usize;
|
||||
let off = TOP;
|
||||
TOP += len as usize;
|
||||
LIVE += 1;
|
||||
(base + off) as i32
|
||||
}
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn dealloc(_ptr: i32, _len: i32) {
|
||||
unsafe {
|
||||
LIVE -= 1;
|
||||
if LIVE <= 0 {
|
||||
LIVE = 0;
|
||||
TOP = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn pack(ptr: i32, len: i32) -> i64 {
|
||||
((ptr as i64) << 32) | ((len as i64) & 0xffff_ffff)
|
||||
}
|
||||
|
||||
/// echo(payload) -> {"echo": <payload>} — wraps the incoming JSON value.
|
||||
#[no_mangle]
|
||||
pub extern "C" fn echo(ptr: i32, len: i32) -> i64 {
|
||||
let input = unsafe { slice::from_raw_parts(ptr as *const u8, len as usize) };
|
||||
const PREFIX: &[u8] = b"{\"echo\":";
|
||||
let total = PREFIX.len() + input.len() + 1;
|
||||
let out = alloc(total as i32);
|
||||
let dst = unsafe { slice::from_raw_parts_mut(out as *mut u8, total) };
|
||||
dst[..PREFIX.len()].copy_from_slice(PREFIX);
|
||||
dst[PREFIX.len()..PREFIX.len() + input.len()].copy_from_slice(input);
|
||||
dst[total - 1] = b'}';
|
||||
pack(out, total as i32)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "hello-py",
|
||||
"lang": "python",
|
||||
"source": "hello.py",
|
||||
"pool": 8,
|
||||
"prefix": "/v1/hello-py",
|
||||
"routes": [
|
||||
{ "method": "POST", "path": "/greet", "func": "greet" },
|
||||
{ "method": "POST", "path": "/add", "func": "add" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "hello-ts",
|
||||
"lang": "typescript",
|
||||
"source": "hello.ts",
|
||||
"pool": 8,
|
||||
"prefix": "/v1/hello-ts",
|
||||
"routes": [
|
||||
{ "method": "POST", "path": "/greet", "func": "greet" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import json
|
||||
|
||||
|
||||
def greet(payload):
|
||||
"""A pure-Python handler. Runs interpreted by gpython, in-process, on a
|
||||
goroutine — no cgo, no GIL, no subprocess. json is Go-backed (Go speed)."""
|
||||
d = json.loads(payload)
|
||||
return json.dumps({"hello": d.get("name", "world"), "lang": "python"})
|
||||
|
||||
|
||||
def add(payload):
|
||||
d = json.loads(payload)
|
||||
return json.dumps({"sum": d["x"] + d["y"]})
|
||||
@@ -0,0 +1,12 @@
|
||||
// A TypeScript handler. esbuild transpiles it to ES2020 CommonJS, then goja
|
||||
// runs it in-process on a goroutine — no Node, no V8, no cgo.
|
||||
interface Greeting {
|
||||
hello: string;
|
||||
lang: string;
|
||||
}
|
||||
|
||||
export function greet(payload: string): string {
|
||||
const d: { name?: string } = JSON.parse(payload);
|
||||
const out: Greeting = { hello: d.name ?? "world", lang: "typescript" };
|
||||
return JSON.stringify(out);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// Command serve loads every *.manifest.json in the examples directory and
|
||||
// serves the polyglot services on :8080 — Python (gpython) and TypeScript
|
||||
// (goja) handlers running as goroutines inside one static Go binary.
|
||||
//
|
||||
// go run ./examples/serve
|
||||
// curl -s localhost:8080/v1/hello-py/greet -d '{"name":"ada"}'
|
||||
// curl -s localhost:8080/v1/hello-ts/greet -d '{"name":"ada"}'
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/hanzoai/goa"
|
||||
)
|
||||
|
||||
func main() {
|
||||
ctx := context.Background()
|
||||
services, err := goa.LoadDir(ctx, "examples")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
for _, s := range services {
|
||||
s.Mount(mux)
|
||||
log.Printf("mounted %s at %s (%d routes)", s.Name, s.Prefix, len(s.Routes))
|
||||
}
|
||||
log.Println("listening on :8080")
|
||||
log.Fatal(http.ListenAndServe(":8080", mux))
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
module github.com/hanzoai/goa
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/dop251/goja v0.0.0-20260627200808-0b76000cabdb
|
||||
github.com/evanw/esbuild v0.28.1
|
||||
github.com/go-python/gpython v0.2.0
|
||||
github.com/tetratelabs/wazero v1.12.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dlclark/regexp2/v2 v2.2.1 // indirect
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
|
||||
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect
|
||||
golang.org/x/sys v0.44.0 // indirect
|
||||
golang.org/x/text v0.3.8 // indirect
|
||||
)
|
||||
@@ -0,0 +1,25 @@
|
||||
github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
|
||||
github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||
github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0=
|
||||
github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
|
||||
github.com/dop251/goja v0.0.0-20260627200808-0b76000cabdb h1:qxbH9q0zvt7Fwy9UJrHdd9grPTlO07G/vUUE3eqfZ4w=
|
||||
github.com/dop251/goja v0.0.0-20260627200808-0b76000cabdb/go.mod h1:Sc+QOu1WruvaaeT/cxFez/pXHpI9ZDjg/E8QNfSVveI=
|
||||
github.com/evanw/esbuild v0.28.1 h1:ds+yuRyUaZGx++GR56CrCeuXh8PVhVM4xq8v7PNELFc=
|
||||
github.com/evanw/esbuild v0.28.1/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48=
|
||||
github.com/go-python/gpython v0.2.0 h1:MW7m7pFnbpzHL88vhAdIhT1pgG1QUZ0Q5jcF94z5MBI=
|
||||
github.com/go-python/gpython v0.2.0/go.mod h1:fUN4z1X+GFaOwPOoHOAM8MOPnh1NJatWo/cDqGlZDEI=
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
|
||||
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE2YxKWtnnQls6rQjjW5oV7qg2U=
|
||||
github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg=
|
||||
github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU=
|
||||
github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
@@ -0,0 +1,104 @@
|
||||
// Package goa is a pure-Go polyglot embedding runtime. It runs Go, JavaScript/
|
||||
// TypeScript (goja), Python (gpython) and Rust/WASM (wazero) behind ONE
|
||||
// interface, so every Hanzo cloud service — whatever language it is written in —
|
||||
// can be compiled into a single static (CGO_ENABLED=0) Go binary and served as
|
||||
// goroutines. No cgo, no subprocess, no per-language runtime to deploy.
|
||||
//
|
||||
// The one calling convention, shared by every engine and language, is
|
||||
// JSON-in / JSON-out: a handler receives a JSON payload (bytes) and returns a
|
||||
// JSON result (bytes). That keeps the boundary trivial to port to — a service
|
||||
// is just a function `(jsonIn) -> jsonOut` in its native language — and lets the
|
||||
// host (e.g. hanzoai/zip / gofiber) mount any handler as an HTTP/ZAP route via
|
||||
// the adapters in http.go.
|
||||
//
|
||||
// Concurrency: an engine's Module is single-threaded (a goja Runtime, a gpython
|
||||
// Context, a wasm instance are not safe to share). Pool (pool.go) keeps N
|
||||
// Modules per service and hands one to each in-flight call, so N goroutines run
|
||||
// N requests in true parallel — including Python, because gpython is plain Go
|
||||
// with no shared GIL.
|
||||
package goa
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Module is one loaded unit of code in some language. It is NOT required to be
|
||||
// goroutine-safe; Pool provides concurrency. Invoke runs the named exported
|
||||
// function with a JSON payload and returns a JSON result.
|
||||
type Module interface {
|
||||
// Invoke calls fn(payload) where payload is JSON and the return is JSON.
|
||||
Invoke(ctx context.Context, fn string, payload []byte) ([]byte, error)
|
||||
// Funcs lists the callable exports discovered in the module.
|
||||
Funcs() []string
|
||||
// Close releases the module's runtime resources.
|
||||
Close() error
|
||||
}
|
||||
|
||||
// Engine compiles source in one language into a Module. Engines MUST be pure Go
|
||||
// (no cgo) so the host binary stays statically linkable.
|
||||
type Engine interface {
|
||||
// Lang is the canonical language id: "go", "javascript", "python", "wasm".
|
||||
Lang() string
|
||||
// Aliases are additional ids that resolve to this engine (e.g. "ts","py").
|
||||
Aliases() []string
|
||||
// Load compiles src into a fresh Module.
|
||||
Load(ctx context.Context, src []byte, opt LoadOptions) (Module, error)
|
||||
}
|
||||
|
||||
// LoadOptions configures a single Load.
|
||||
type LoadOptions struct {
|
||||
Name string // logical module name (diagnostics, require keys)
|
||||
Env map[string]string // exposed as process.env / os.environ to the guest
|
||||
}
|
||||
|
||||
var (
|
||||
mu sync.RWMutex
|
||||
engines = map[string]Engine{} // keyed by canonical lang AND every alias
|
||||
)
|
||||
|
||||
// Register makes e resolvable by its Lang() and each of its Aliases(). Engines
|
||||
// register themselves from init(), so importing the package is enough.
|
||||
func Register(e Engine) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
engines[e.Lang()] = e
|
||||
for _, a := range e.Aliases() {
|
||||
engines[a] = e
|
||||
}
|
||||
}
|
||||
|
||||
// EngineFor returns the engine registered for a language id or alias.
|
||||
func EngineFor(lang string) (Engine, bool) {
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
e, ok := engines[lang]
|
||||
return e, ok
|
||||
}
|
||||
|
||||
// Langs returns the sorted set of canonical languages currently registered.
|
||||
func Langs() []string {
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
seen := map[string]struct{}{}
|
||||
for _, e := range engines {
|
||||
seen[e.Lang()] = struct{}{}
|
||||
}
|
||||
out := make([]string, 0, len(seen))
|
||||
for l := range seen {
|
||||
out = append(out, l)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// Load compiles src in the given language (id or alias) into a Module.
|
||||
func Load(ctx context.Context, lang string, src []byte, opt LoadOptions) (Module, error) {
|
||||
e, ok := EngineFor(lang)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("goa: no engine for language %q (have %v)", lang, Langs())
|
||||
}
|
||||
return e.Load(ctx, src, opt)
|
||||
}
|
||||
+210
@@ -0,0 +1,210 @@
|
||||
package goa
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// echoWasm is the goa Rust guest (examples/echo-rust) compiled to wasm32. Build:
|
||||
//
|
||||
// cd examples/echo-rust && cargo build --release --target wasm32-unknown-unknown
|
||||
// cp target/wasm32-unknown-unknown/release/echo_rust.wasm echo.wasm
|
||||
//
|
||||
//go:embed examples/echo-rust/echo.wasm
|
||||
var echoWasm []byte
|
||||
|
||||
func mustInvoke(t *testing.T, m Module, fn, payload string) string {
|
||||
t.Helper()
|
||||
out, err := m.Invoke(context.Background(), fn, []byte(payload))
|
||||
if err != nil {
|
||||
t.Fatalf("Invoke(%s): %v", fn, err)
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func TestNative(t *testing.T) {
|
||||
m := NewNativeModule(map[string]HandlerFunc{
|
||||
"echo": func(_ context.Context, p []byte) ([]byte, error) { return p, nil },
|
||||
})
|
||||
if got := mustInvoke(t, m, "echo", `{"a":1}`); got != `{"a":1}` {
|
||||
t.Fatalf("native echo = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoja(t *testing.T) {
|
||||
src := `function greet(p){ var d = JSON.parse(p); return JSON.stringify({hello:d.name, lang:"javascript"}); }`
|
||||
m, err := Load(context.Background(), "js", []byte(src), LoadOptions{Name: "g"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := mustInvoke(t, m, "greet", `{"name":"world"}`)
|
||||
assertJSONEq(t, got, `{"hello":"world","lang":"javascript"}`)
|
||||
}
|
||||
|
||||
func TestGojaTypeScript(t *testing.T) {
|
||||
// TS-only syntax (type annotations) must transpile via esbuild.
|
||||
src := `export function add(p: string): string { const d: {x:number,y:number} = JSON.parse(p); return JSON.stringify({sum: d.x + d.y}); }`
|
||||
m, err := Load(context.Background(), "ts", []byte(src), LoadOptions{Name: "t"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertJSONEq(t, mustInvoke(t, m, "add", `{"x":2,"y":3}`), `{"sum":5}`)
|
||||
}
|
||||
|
||||
func TestGpython(t *testing.T) {
|
||||
src := "import json\n" +
|
||||
"def greet(payload):\n" +
|
||||
" d = json.loads(payload)\n" +
|
||||
" return json.dumps({'hello': d['name'], 'lang': 'python'})\n"
|
||||
m, err := Load(context.Background(), "python", []byte(src), LoadOptions{Name: "g"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := mustInvoke(t, m, "greet", `{"name":"world"}`)
|
||||
assertJSONEq(t, got, `{"hello":"world","lang":"python"}`)
|
||||
}
|
||||
|
||||
func TestGpythonJSONRoundTrip(t *testing.T) {
|
||||
src := "import json\n" +
|
||||
"def rt(payload):\n" +
|
||||
" return json.dumps(json.loads(payload))\n"
|
||||
m, err := Load(context.Background(), "py", []byte(src), LoadOptions{Name: "rt"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
in := `{"n":42,"f":1.5,"b":true,"s":"x","a":[1,2,3],"o":{"k":"v"},"z":null}`
|
||||
assertJSONEq(t, mustInvoke(t, m, "rt", in), in)
|
||||
}
|
||||
|
||||
func TestPoolParallel(t *testing.T) {
|
||||
src := "def busy(payload):\n return payload\n"
|
||||
pool, err := LoadPool(context.Background(), "python", []byte(src), 4, LoadOptions{Name: "p"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pool.Close()
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 64; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
out, err := pool.Invoke(context.Background(), "busy", []byte(`"ok"`))
|
||||
if err != nil || string(out) != `"ok"` {
|
||||
t.Errorf("pool invoke = %q err=%v", out, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestServiceHTTP(t *testing.T) {
|
||||
src := "import json\n" +
|
||||
"def greet(payload):\n" +
|
||||
" return json.dumps({'hi': json.loads(payload)['name']})\n"
|
||||
pool, err := LoadPool(context.Background(), "python", []byte(src), 2, LoadOptions{Name: "greeter"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pool.Close()
|
||||
svc := &Service{Name: "greeter", Prefix: "/v1/greeter", Pool: pool,
|
||||
Routes: []Route{{Method: "POST", Path: "/greet", Func: "greet"}}}
|
||||
srv := httptest.NewServer(svc.Handler())
|
||||
defer srv.Close()
|
||||
|
||||
resp, err := http.Post(srv.URL+"/v1/greeter/greet", "application/json", strings.NewReader(`{"name":"ada"}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("status %d", resp.StatusCode)
|
||||
}
|
||||
var got map[string]string
|
||||
_ = json.NewDecoder(resp.Body).Decode(&got)
|
||||
if got["hi"] != "ada" {
|
||||
t.Fatalf("got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWazero(t *testing.T) {
|
||||
m, err := Load(context.Background(), "rust", echoWasm, LoadOptions{Name: "echo"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer m.Close()
|
||||
assertJSONEq(t, mustInvoke(t, m, "echo", `{"name":"ada"}`), `{"echo":{"name":"ada"}}`)
|
||||
|
||||
// Hammer it well past the 64 KiB arena: if dealloc didn't reset, the bump
|
||||
// allocator would overflow and the host's writes would trap.
|
||||
for i := 0; i < 5000; i++ {
|
||||
if _, err := m.Invoke(context.Background(), "echo", []byte(`{"i":1}`)); err != nil {
|
||||
t.Fatalf("repeat %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDir(t *testing.T) {
|
||||
services, err := LoadDir(context.Background(), "examples")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
byName := map[string]*Service{}
|
||||
for _, s := range services {
|
||||
byName[s.Name] = s
|
||||
defer s.Pool.Close()
|
||||
}
|
||||
cases := []struct{ svc, fn, in, want string }{
|
||||
{"hello-py", "greet", `{"name":"x"}`, `{"hello":"x","lang":"python"}`},
|
||||
{"hello-ts", "greet", `{"name":"x"}`, `{"hello":"x","lang":"typescript"}`},
|
||||
{"echo-rust", "echo", `{"name":"x"}`, `{"echo":{"name":"x"}}`},
|
||||
}
|
||||
for _, c := range cases {
|
||||
s := byName[c.svc]
|
||||
if s == nil {
|
||||
t.Errorf("service %q not loaded from manifests", c.svc)
|
||||
continue
|
||||
}
|
||||
out, err := s.Pool.Invoke(context.Background(), c.fn, []byte(c.in))
|
||||
if err != nil {
|
||||
t.Errorf("%s.%s: %v", c.svc, c.fn, err)
|
||||
continue
|
||||
}
|
||||
assertJSONEq(t, string(out), c.want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLangsRegistered(t *testing.T) {
|
||||
want := map[string]bool{"javascript": false, "python": false, "wasm": false}
|
||||
for _, l := range Langs() {
|
||||
if _, ok := want[l]; ok {
|
||||
want[l] = true
|
||||
}
|
||||
}
|
||||
for l, found := range want {
|
||||
if !found {
|
||||
t.Errorf("engine %q not registered", l)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertJSONEq(t *testing.T, got, want string) {
|
||||
t.Helper()
|
||||
var g, w interface{}
|
||||
if err := json.Unmarshal([]byte(got), &g); err != nil {
|
||||
t.Fatalf("got not JSON: %q (%v)", got, err)
|
||||
}
|
||||
if err := json.Unmarshal([]byte(want), &w); err != nil {
|
||||
t.Fatalf("want not JSON: %q", want)
|
||||
}
|
||||
gb, _ := json.Marshal(g)
|
||||
wb, _ := json.Marshal(w)
|
||||
if string(gb) != string(wb) {
|
||||
t.Fatalf("JSON mismatch:\n got=%s\nwant=%s", gb, wb)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package goa
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Route binds an HTTP method+path to an exported function in the module.
|
||||
type Route struct {
|
||||
Method string `json:"method"` // GET, POST, ...
|
||||
Path string `json:"path"` // e.g. /greet (combined with the service Prefix)
|
||||
Func string `json:"func"` // exported function name to invoke
|
||||
}
|
||||
|
||||
// Service is a mounted polyglot module: a Pool of interpreters plus the route
|
||||
// table that maps HTTP requests onto its functions. Handler() returns a plain
|
||||
// net/http.Handler so any host can serve it — in hanzoai/cloud it is mounted
|
||||
// onto the zip/gofiber app via zip.AdaptNetHTTP, keeping goa host-agnostic.
|
||||
type Service struct {
|
||||
Name string
|
||||
Prefix string // e.g. /v1/greeter ; routes are mounted under it
|
||||
Pool *Pool
|
||||
Routes []Route
|
||||
}
|
||||
|
||||
// Mount registers this service's routes onto an existing mux, so a host can
|
||||
// serve many services from one mux (the Go 1.22 "METHOD /path" patterns keep
|
||||
// them non-conflicting). This is the single route-registration path.
|
||||
func (s *Service) Mount(mux *http.ServeMux) {
|
||||
for _, r := range s.Routes {
|
||||
fn := r.Func
|
||||
pattern := fmt.Sprintf("%s %s%s", r.Method, s.Prefix, r.Path)
|
||||
mux.HandleFunc(pattern, func(w http.ResponseWriter, req *http.Request) {
|
||||
body, err := io.ReadAll(io.LimitReader(req.Body, 32<<20))
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
if len(body) == 0 {
|
||||
body = []byte("{}")
|
||||
}
|
||||
out, err := s.Pool.Invoke(req.Context(), fn, body)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(out)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Handler returns a standalone http.Handler serving just this service. In
|
||||
// hanzoai/cloud it is mounted onto the zip/gofiber app via zip.AdaptNetHTTP,
|
||||
// keeping goa host-agnostic. To serve many services from one mux, use Mount.
|
||||
func (s *Service) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
s.Mount(mux)
|
||||
return mux
|
||||
}
|
||||
|
||||
func writeErr(w http.ResponseWriter, code int, err error) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
b, _ := json.Marshal(map[string]string{"error": err.Error()})
|
||||
_, _ = w.Write(b)
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
package goa
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// Manifest describes a polyglot service to mount: which language, where the
|
||||
// source is, how many pooled interpreters, and the route table. Drop a
|
||||
// service.manifest.json next to a .py/.ts/.wasm file and the loader does the
|
||||
// rest — that is the "easy to port any Go/Python/Rust/JS service in" UX.
|
||||
//
|
||||
// {
|
||||
// "name": "greeter",
|
||||
// "lang": "python",
|
||||
// "source": "greet.py",
|
||||
// "pool": 8,
|
||||
// "prefix": "/v1/greeter",
|
||||
// "routes": [{ "method": "POST", "path": "/greet", "func": "greet" }]
|
||||
// }
|
||||
type Manifest struct {
|
||||
Name string `json:"name"`
|
||||
Lang string `json:"lang"`
|
||||
Source string `json:"source"` // path to source, relative to the manifest
|
||||
Pool int `json:"pool"` // pooled interpreter count (default = GOMAXPROCS-ish)
|
||||
Prefix string `json:"prefix"`
|
||||
Routes []Route `json:"routes"`
|
||||
Env map[string]string `json:"env,omitempty"`
|
||||
}
|
||||
|
||||
// LoadManifest reads and validates a manifest file.
|
||||
func LoadManifest(path string) (*Manifest, error) {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var m Manifest
|
||||
if err := json.Unmarshal(b, &m); err != nil {
|
||||
return nil, fmt.Errorf("goa: manifest %s: %w", path, err)
|
||||
}
|
||||
if m.Name == "" || m.Lang == "" || m.Source == "" {
|
||||
return nil, fmt.Errorf("goa: manifest %s: name, lang and source are required", path)
|
||||
}
|
||||
if m.Pool <= 0 {
|
||||
m.Pool = 8
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
// Build loads the manifest's source through its engine into a pooled Service.
|
||||
// baseDir is the directory the manifest's Source is resolved against.
|
||||
func (m *Manifest) Build(ctx context.Context, baseDir string) (*Service, error) {
|
||||
src, err := os.ReadFile(filepath.Join(baseDir, m.Source))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("goa: service %q source: %w", m.Name, err)
|
||||
}
|
||||
pool, err := LoadPool(ctx, m.Lang, src, m.Pool, LoadOptions{Name: m.Name, Env: m.Env})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Service{Name: m.Name, Prefix: m.Prefix, Pool: pool, Routes: m.Routes}, nil
|
||||
}
|
||||
|
||||
// LoadDir discovers every *.manifest.json under dir and builds the services.
|
||||
// This is what a host calls at boot to auto-mount a folder of polyglot
|
||||
// services. Failures are returned per service; successful ones are still built.
|
||||
func LoadDir(ctx context.Context, dir string) ([]*Service, error) {
|
||||
matches, err := filepath.Glob(filepath.Join(dir, "*.manifest.json"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var services []*Service
|
||||
var errs []error
|
||||
for _, path := range matches {
|
||||
man, err := LoadManifest(path)
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
continue
|
||||
}
|
||||
svc, err := man.Build(ctx, dir)
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
continue
|
||||
}
|
||||
services = append(services, svc)
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
return services, fmt.Errorf("goa: %d service(s) failed to load: %v", len(errs), errs)
|
||||
}
|
||||
return services, nil
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package goa
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// HandlerFunc is a native Go handler in the canonical JSON-in/JSON-out shape.
|
||||
type HandlerFunc func(ctx context.Context, payload []byte) ([]byte, error)
|
||||
|
||||
// NativeModule mounts plain Go functions through the same Module interface as
|
||||
// the interpreted engines — zero overhead, full type safety. This is how Go
|
||||
// services join the unified binary: register their handlers and they sit beside
|
||||
// the Python/JS/Rust ones behind the identical routing surface.
|
||||
//
|
||||
// NativeModule is goroutine-safe (the underlying Go funcs are expected to be),
|
||||
// so it needs no Pool — though you may still pool it uniformly if you like.
|
||||
type NativeModule struct {
|
||||
funcs map[string]HandlerFunc
|
||||
}
|
||||
|
||||
// NewNativeModule wraps a set of named Go handlers as a Module.
|
||||
func NewNativeModule(funcs map[string]HandlerFunc) *NativeModule {
|
||||
cp := make(map[string]HandlerFunc, len(funcs))
|
||||
for k, v := range funcs {
|
||||
cp[k] = v
|
||||
}
|
||||
return &NativeModule{funcs: cp}
|
||||
}
|
||||
|
||||
func (m *NativeModule) Invoke(ctx context.Context, fn string, payload []byte) ([]byte, error) {
|
||||
h, ok := m.funcs[fn]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("go: no handler %q", fn)
|
||||
}
|
||||
return h(ctx, payload)
|
||||
}
|
||||
|
||||
func (m *NativeModule) Funcs() []string {
|
||||
out := make([]string, 0, len(m.funcs))
|
||||
for k := range m.funcs {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func (m *NativeModule) Close() error { return nil }
|
||||
@@ -0,0 +1,73 @@
|
||||
package goa
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Pool keeps a fixed set of Modules for one service and lends one to each
|
||||
// Invoke, returning it when the call completes. Because each Module is used by
|
||||
// exactly one goroutine at a time, single-threaded engines (goja, gpython,
|
||||
// wasm instances) safely serve concurrent requests: N modules => N parallel
|
||||
// in-flight calls. This is the unit that makes "every route a goroutine" real.
|
||||
type Pool struct {
|
||||
idle chan Module
|
||||
all []Module
|
||||
}
|
||||
|
||||
// NewPool builds size Modules via factory and returns a ready Pool. size<1 is
|
||||
// treated as 1.
|
||||
func NewPool(size int, factory func() (Module, error)) (*Pool, error) {
|
||||
if size < 1 {
|
||||
size = 1
|
||||
}
|
||||
p := &Pool{idle: make(chan Module, size), all: make([]Module, 0, size)}
|
||||
for i := 0; i < size; i++ {
|
||||
m, err := factory()
|
||||
if err != nil {
|
||||
_ = p.Close()
|
||||
return nil, fmt.Errorf("goa: pool factory (entry %d/%d): %w", i+1, size, err)
|
||||
}
|
||||
p.all = append(p.all, m)
|
||||
p.idle <- m
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// LoadPool compiles src once per pool entry with the given engine, so each
|
||||
// entry is an independent interpreter/instance.
|
||||
func LoadPool(ctx context.Context, lang string, src []byte, size int, opt LoadOptions) (*Pool, error) {
|
||||
e, ok := EngineFor(lang)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("goa: no engine for language %q", lang)
|
||||
}
|
||||
return NewPool(size, func() (Module, error) { return e.Load(ctx, src, opt) })
|
||||
}
|
||||
|
||||
// Invoke borrows a Module (blocking until one is free or ctx is done), runs
|
||||
// fn(payload), and returns the Module to the pool.
|
||||
func (p *Pool) Invoke(ctx context.Context, fn string, payload []byte) ([]byte, error) {
|
||||
var m Module
|
||||
select {
|
||||
case m = <-p.idle:
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
defer func() { p.idle <- m }()
|
||||
return m.Invoke(ctx, fn, payload)
|
||||
}
|
||||
|
||||
// Close releases every Module. Safe to call once.
|
||||
func (p *Pool) Close() error {
|
||||
var errs []error
|
||||
for _, m := range p.all {
|
||||
if m != nil {
|
||||
if err := m.Close(); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
p.all = nil
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package goa
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/go-python/gpython/py"
|
||||
)
|
||||
|
||||
// registerGoBackedStdlib installs Go-native implementations of stdlib modules
|
||||
// gpython does not ship ("batteries not included"). The win is twofold: our
|
||||
// Python backends keep importing the real module names, and the hot,
|
||||
// C-accelerated-in-CPython modules run at Go speed instead of interpreted.
|
||||
//
|
||||
// This file ships `json` (encoding/json). The same pattern extends to re
|
||||
// (regexp), hashlib (crypto), base64 (encoding/base64), datetime (time), uuid —
|
||||
// each a thin module whose methods marshal py.Object <-> Go and call Go stdlib.
|
||||
func registerGoBackedStdlib() {
|
||||
py.RegisterModule(&py.ModuleImpl{
|
||||
Info: py.ModuleInfo{
|
||||
Name: "json",
|
||||
Doc: "Go-backed json (encoding/json) for gpython.",
|
||||
},
|
||||
Methods: []*py.Method{
|
||||
py.MustNewMethod("dumps", jsonDumps, 0, "dumps(obj) -> str"),
|
||||
py.MustNewMethod("loads", jsonLoads, 0, "loads(s) -> obj"),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func jsonDumps(_ py.Object, args py.Tuple) (py.Object, error) {
|
||||
if len(args) < 1 {
|
||||
return nil, py.ExceptionNewf(py.TypeError, "dumps() missing 1 argument")
|
||||
}
|
||||
gv, err := pyToGo(args[0])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b, err := json.Marshal(gv)
|
||||
if err != nil {
|
||||
return nil, py.ExceptionNewf(py.ValueError, "json.dumps: %v", err)
|
||||
}
|
||||
return py.String(b), nil
|
||||
}
|
||||
|
||||
func jsonLoads(_ py.Object, args py.Tuple) (py.Object, error) {
|
||||
if len(args) < 1 {
|
||||
return nil, py.ExceptionNewf(py.TypeError, "loads() missing 1 argument")
|
||||
}
|
||||
var s string
|
||||
switch v := args[0].(type) {
|
||||
case py.String:
|
||||
s = string(v)
|
||||
case py.Bytes:
|
||||
s = string(v)
|
||||
default:
|
||||
return nil, py.ExceptionNewf(py.TypeError, "loads() expects str/bytes, got %s", args[0].Type().Name)
|
||||
}
|
||||
var gv interface{}
|
||||
if err := json.Unmarshal([]byte(s), &gv); err != nil {
|
||||
return nil, py.ExceptionNewf(py.ValueError, "json.loads: %v", err)
|
||||
}
|
||||
return goToPy(gv), nil
|
||||
}
|
||||
|
||||
// pyToGo converts a gpython object into a json-marshalable Go value.
|
||||
func pyToGo(o py.Object) (interface{}, error) {
|
||||
switch v := o.(type) {
|
||||
case nil, py.NoneType:
|
||||
return nil, nil
|
||||
case py.String:
|
||||
return string(v), nil
|
||||
case py.Bytes:
|
||||
return string(v), nil
|
||||
case py.Bool:
|
||||
return bool(v), nil
|
||||
case py.Int:
|
||||
return int64(v), nil
|
||||
case py.Float:
|
||||
return float64(v), nil
|
||||
case py.Tuple:
|
||||
return seqToGo(v)
|
||||
case *py.List:
|
||||
return seqToGo(v.Items)
|
||||
case py.StringDict:
|
||||
// gpython represents every dict as a string-keyed StringDict.
|
||||
m := make(map[string]interface{}, len(v))
|
||||
for k, val := range v {
|
||||
gv, err := pyToGo(val)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m[k] = gv
|
||||
}
|
||||
return m, nil
|
||||
default:
|
||||
return nil, py.ExceptionNewf(py.TypeError, "json: cannot serialize %s", o.Type().Name)
|
||||
}
|
||||
}
|
||||
|
||||
func seqToGo(items []py.Object) (interface{}, error) {
|
||||
out := make([]interface{}, len(items))
|
||||
for i, it := range items {
|
||||
gv, err := pyToGo(it)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[i] = gv
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// goToPy converts a decoded JSON Go value into gpython objects.
|
||||
func goToPy(v interface{}) py.Object {
|
||||
switch t := v.(type) {
|
||||
case nil:
|
||||
return py.None
|
||||
case bool:
|
||||
if t {
|
||||
return py.True
|
||||
}
|
||||
return py.False
|
||||
case string:
|
||||
return py.String(t)
|
||||
case float64:
|
||||
// json numbers decode to float64; keep integers integral where exact.
|
||||
if t == float64(int64(t)) {
|
||||
return py.Int(int64(t))
|
||||
}
|
||||
return py.Float(t)
|
||||
case []interface{}:
|
||||
items := make([]py.Object, len(t))
|
||||
for i, e := range t {
|
||||
items[i] = goToPy(e)
|
||||
}
|
||||
return py.NewListFromItems(items)
|
||||
case map[string]interface{}:
|
||||
d := py.NewStringDict()
|
||||
for k, e := range t {
|
||||
d[k] = goToPy(e)
|
||||
}
|
||||
return d
|
||||
default:
|
||||
return py.String(fmt.Sprintf("%v", t))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user