feat: add backend, database, and protocol foundations

This commit is contained in:
DiheChen committed 2026-10-30 01:32:19 +08:00
commit ec08d487e1
179 files changed
+27079

No files matched your search

+11
View File
@@ -0,0 +1,11 @@
package main
import (
"os"
"github.com/dihemoe/alisten/internal/bootstrap"
)
func main() {
os.Exit(bootstrap.Main(os.Args[1:]))
}
+173
View File
@@ -0,0 +1,173 @@
package main
import (
"bytes"
"fmt"
"go/format"
"maps"
"slices"
"strings"
)
type goPattern struct {
name, expr string
}
type goDecoder struct {
b bytes.Buffer
patterns []goPattern
}
func goDecode(d *idl) ([]byte, error) {
var g goDecoder
g.registry("decodeConnectionRequest", d.WS.Requests)
g.registry("decodeRoomRequest", d.Requests)
for _, e := range d.HTTP {
if e.Request != nil {
name := e.Request.name
fmt.Fprintf(&g.b, "\nfunc Decode%s(body []byte) (%s, error) {\nreturn decode%s(body, \"\")\n}\n", name, name, name)
}
}
for _, name := range d.typeNames() {
if !d.inbound[name] {
continue
}
if s := d.named[name]; s.Enum != nil {
g.enum(name, s)
} else {
g.object(name, s)
}
}
var b bytes.Buffer
b.WriteString(goHeader)
if len(g.patterns) == 0 {
b.WriteString("\nimport \"encoding/json\"\n")
} else {
b.WriteString("\nimport (\n\"encoding/json\"\n\"regexp\"\n)\n\nvar (\n")
for _, p := range g.patterns {
fmt.Fprintf(&b, "%s = regexp.MustCompile(%s)\n", p.name, goRawString(p.expr))
}
b.WriteString(")\n")
}
b.Write(g.b.Bytes())
return format.Source(b.Bytes())
}
func (g *goDecoder) registry(fn string, messages []*message) {
fmt.Fprintf(&g.b, "\nfunc %s(typ string, raw json.RawMessage, path string) (any, bool, error) {\nswitch typ {\n", fn)
for _, m := range messages {
fmt.Fprintf(&g.b, "case Request%s:\nv, err := decode%s(raw, path)\nreturn v, true, err\n", pascal(m.Type), m.Request.name)
}
g.b.WriteString("}\nreturn nil, false, nil\n}\n")
}
func (g *goDecoder) enum(name string, s *schema) {
consts := make([]string, len(s.Enum))
for i, v := range s.Enum {
consts[i] = name + pascal(v)
}
reason := "取值应为 " + strings.Join(s.Enum, "、") + " 之一"
fmt.Fprintf(&g.b, "\nfunc decode%s(raw json.RawMessage, path string) (%s, error) {\ns, err := decodeString(raw, path)\nif err != nil {\nreturn \"\", err\n}\nswitch v := %s(s); v {\ncase %s:\nreturn v, nil\n}\nreturn \"\", invalid(path, %q)\n}\n",
name, name, name, strings.Join(consts, ", "), reason)
}
func (g *goDecoder) object(name string, s *schema) {
fmt.Fprintf(&g.b, "\nfunc decode%s(raw json.RawMessage, path string) (%s, error) {\nvar v %s\n", name, name, name)
fields := slices.Sorted(maps.Keys(s.Properties))
if len(fields) == 0 {
g.b.WriteString("_, err := decodeObject(raw, path)\nreturn v, err\n}\n")
return
}
fmt.Fprintf(&g.b, "m, err := decodeObject(raw, path, %s)\nif err != nil {\nreturn v, err\n}\n", goStrings(fields))
if len(s.Required) > 0 {
fmt.Fprintf(&g.b, "if err = requireFields(m, path, %s); err != nil {\nreturn v, err\n}\n", goStrings(slices.Sorted(slices.Values(s.Required))))
}
if s.OneOf != nil {
groups := make([]string, len(s.OneOf))
reasons := make([]string, len(s.OneOf))
for i, alt := range s.OneOf {
groups[i] = "[]string{" + goStrings(alt.Required) + "}"
reasons[i] = strings.Join(alt.Required, "、")
}
optional := "nil"
if names := optionalFields(s.OneOf[0]); len(names) > 0 {
optional = "[]string{" + goStrings(names) + "}"
}
fmt.Fprintf(&g.b, "if err = checkOneOf(m, path, %q, %s, %s); err != nil {\nreturn v, err\n}\n",
"只能提供以下字段组合之一:"+strings.Join(reasons, ";"), optional, strings.Join(groups, ", "))
}
for _, f := range fields {
p := s.Properties[f]
at := fmt.Sprintf("field(path, %q)", f)
decode := "decodeString"
switch {
case p.Ref != "":
decode = "decode" + refName(p.Ref)
case p.Type == "integer":
decode = "decodeInteger"
}
if slices.Contains(s.Required, f) {
fmt.Fprintf(&g.b, "if v.%s, err = %s(m[%q], %s); err != nil {\nreturn v, err\n}\n", pascal(f), decode, f, at)
g.checks("v."+pascal(f), at, p, name+pascal(f))
continue
}
fmt.Fprintf(&g.b, "if r, ok := m[%q]; ok {\nx, err := %s(r, %s)\nif err != nil {\nreturn v, err\n}\n", f, decode, at)
g.checks("x", at, p, name+pascal(f))
fmt.Fprintf(&g.b, "v.%s = &x\n}\n", pascal(f))
}
for _, entry := range s.AllOf {
cond := entry.If.Required[0]
value := *entry.If.Properties[cond].Const
fmt.Fprintf(&g.b, "if v.%s == %s%s {\n", pascal(cond), refName(s.Properties[cond].Ref), pascal(value))
for _, f := range slices.Sorted(maps.Keys(entry.Then.Properties)) {
g.checks("v."+pascal(f), fmt.Sprintf("field(path, %q)", f), entry.Then.Properties[f], name+pascal(f)+"When"+pascal(value))
}
g.b.WriteString("}\n")
}
g.b.WriteString("return v, nil\n}\n")
}
func (g *goDecoder) checks(value, at string, s *schema, patternName string) {
check := func(fn, limit string) {
fmt.Fprintf(&g.b, "if err = %s(%s, %s, %s); err != nil {\nreturn v, err\n}\n", fn, value, at, limit)
}
if s.MinLength != nil {
check("checkMinLength", fmt.Sprint(*s.MinLength))
}
if s.MaxLength != nil {
check("checkMaxLength", fmt.Sprint(*s.MaxLength))
}
if s.MaxBytes != nil {
check("checkMaxBytes", fmt.Sprint(*s.MaxBytes))
}
if s.URL != nil {
fmt.Fprintf(&g.b, "if err = checkURL(%s, %s); err != nil {\nreturn v, err\n}\n", value, at)
}
if s.Pattern != "" {
name := "pattern" + patternName
g.patterns = append(g.patterns, goPattern{name: name, expr: s.Pattern})
check("checkPattern", name)
}
if s.Minimum != nil {
check("checkMinimum", fmt.Sprint(*s.Minimum))
}
if s.Maximum != nil {
check("checkMaximum", fmt.Sprint(*s.Maximum))
}
}
func goStrings(values []string) string {
quoted := make([]string, len(values))
for i, v := range values {
quoted[i] = fmt.Sprintf("%q", v)
}
return strings.Join(quoted, ", ")
}
func goRawString(s string) string {
if strings.Contains(s, "`") {
return fmt.Sprintf("%q", s)
}
return "`" + s + "`"
}
+199
View File
@@ -0,0 +1,199 @@
package main
import (
"bytes"
"fmt"
"go/format"
"maps"
"slices"
"strings"
)
const goHeader = "// Code generated by protocolgen from protocol/idl.json; DO NOT EDIT.\n\npackage protocol\n"
func goTypes(d *idl) ([]byte, error) {
var body bytes.Buffer
fmt.Fprintf(&body, "\nconst Version = %q\n", d.version)
marshal := false
for _, name := range d.typeNames() {
s := d.named[name]
if s.Enum != nil {
writeGoEnum(&body, name, s)
continue
}
writeGoStruct(&body, name, s)
marshal = writeGoMarshal(&body, name, s) || marshal
}
body.WriteString("\ntype ErrorResponse struct {\nError ErrorInfo `json:\"error\"`\n}\n")
writeGoMessageTypes(&body, d)
writeGoCloseCodes(&body, d.WS.CloseCodes)
writeGoRoutes(&body, d.HTTP)
writeGoRoomRoutes(&body, d)
writeGoHTTPStatus(&body, d)
var b bytes.Buffer
b.WriteString(goHeader)
if marshal {
b.WriteString("\nimport \"encoding/json\"\n")
}
b.Write(body.Bytes())
return format.Source(b.Bytes())
}
func writeGoEnum(b *bytes.Buffer, name string, s *schema) {
fmt.Fprintf(b, "\ntype %s string\n\nconst (\n", name)
for _, v := range s.Enum {
fmt.Fprintf(b, "%s%s %s = %q\n", name, pascal(v), name, v)
}
b.WriteString(")\n")
}
func writeGoStruct(b *bytes.Buffer, name string, s *schema) {
if len(s.Properties) == 0 {
fmt.Fprintf(b, "\ntype %s struct{}\n", name)
return
}
fmt.Fprintf(b, "\ntype %s struct {\n", name)
for _, p := range slices.Sorted(maps.Keys(s.Properties)) {
required := slices.Contains(s.Required, p)
tag := p
if !required {
tag += ",omitempty"
}
fmt.Fprintf(b, "%s %s `json:%q`\n", pascal(p), goFieldType(s.Properties[p], required), tag)
}
b.WriteString("}\n")
}
func goFieldType(s *schema, required bool) string {
switch {
case s.AnyOf != nil:
return "*" + goValueType(s.AnyOf[0])
case s.Type == "array":
return "[]" + goValueType(s.Items)
case !required:
return "*" + goValueType(s)
default:
return goValueType(s)
}
}
func goValueType(s *schema) string {
if s.Ref != "" {
return refName(s.Ref)
}
return map[string]string{"string": "string", "integer": "int64", "number": "float64", "boolean": "bool"}[s.Type]
}
func writeGoMarshal(b *bytes.Buffer, name string, s *schema) bool {
var arrays []string
for _, p := range slices.Sorted(maps.Keys(s.Properties)) {
if s.Properties[p].Type == "array" {
arrays = append(arrays, p)
}
}
if len(arrays) == 0 {
return false
}
fmt.Fprintf(b, "\nfunc (v %s) MarshalJSON() ([]byte, error) {\ntype plain %s\n", name, name)
for _, p := range arrays {
fmt.Fprintf(b, "if v.%s == nil {\nv.%s = []%s{}\n}\n", pascal(p), pascal(p), goValueType(s.Properties[p].Items))
}
b.WriteString("return json.Marshal(plain(v))\n}\n")
return true
}
func writeGoMessageTypes(b *bytes.Buffer, d *idl) {
b.WriteString("\nconst (\n")
for _, m := range slices.Concat(d.WS.Requests, d.Requests) {
fmt.Fprintf(b, "Request%s = %q\n", pascal(m.Type), m.Type)
}
b.WriteString(")\n\nconst (\n")
for _, e := range slices.Concat(d.WS.Events, d.Events) {
fmt.Fprintf(b, "Event%s = %q\n", pascal(e.Type), e.Type)
}
b.WriteString(")\n")
}
func writeGoCloseCodes(b *bytes.Buffer, codes []*closeCode) {
if len(codes) == 0 {
return
}
b.WriteString("\nconst (\n")
for _, c := range codes {
fmt.Fprintf(b, "Close%s = %d\n", pascal(c.Name), c.Code)
}
b.WriteString(")\n")
}
func writeGoRoutes(b *bytes.Buffer, endpoints []*endpoint) {
if len(endpoints) == 0 {
return
}
b.WriteString("\nvar (\n")
for _, e := range endpoints {
fmt.Fprintf(b, "Route%s = Route{Pattern: %q, Auth: Auth%s, Status: %d%s}\n", pascal(e.Name), e.Method+" "+e.Path, pascal(e.Auth), e.status(), goLocation(e.Location))
}
b.WriteString(")\n")
}
func goLocation(location bool) string {
if location {
return ", Location: true"
}
return ""
}
func writeGoRoomRoutes(b *bytes.Buffer, d *idl) {
if len(d.Requests) == 0 {
return
}
b.WriteString("\nvar RoomRoutes = []RoomRoute{\n")
for _, m := range d.Requests {
h := m.HTTP
fmt.Fprintf(b, "{Route: Route{Pattern: %q, Auth: AuthTicket, Status: %d%s}, Type: Request%s", h.Method+" "+h.Path, h.status(), goLocation(h.Location), pascal(m.Type))
params := pathParams(h.Path)
if len(params) > 0 {
fmt.Fprintf(b, ", params: []string{%s}", goStrings(params))
}
if h.Method != "GET" && h.Method != "DELETE" {
b.WriteString(", body: true")
} else if query := d.queryKinds(m.Request, params); len(query) > 0 {
fmt.Fprintf(b, ", query: map[string]queryKind{%s}", strings.Join(query, ", "))
}
b.WriteString("},\n")
}
b.WriteString("}\n")
}
func (d *idl) queryKinds(s *schema, params []string) []string {
var kinds []string
for _, name := range slices.Sorted(maps.Keys(s.Properties)) {
if slices.Contains(params, name) {
continue
}
kind := "queryString"
if d.jsonType(s.Properties[name]) == "integer" {
kind = "queryInteger"
}
kinds = append(kinds, fmt.Sprintf("%q: %s", name, kind))
}
return kinds
}
func writeGoHTTPStatus(b *bytes.Buffer, d *idl) {
for _, name := range d.typeNames() {
s := d.named[name]
if s.HTTPStatus == nil {
continue
}
table := lowerFirst(name) + "HTTPStatus"
fmt.Fprintf(b, "\nvar %s = map[%s]int{\n", table, name)
for _, v := range s.Enum {
if status, ok := s.HTTPStatus[v]; ok {
fmt.Fprintf(b, "%s%s: %d,\n", name, pascal(v), status)
}
}
fmt.Fprintf(b, "}\n\n// IDL 没有为某个取值指定 HTTP 状态码时返回 0。\nfunc (v %s) HTTPStatus() int {\nreturn %s[v]\n}\n", name, table)
}
}
File diff suppressed because it is too large. Load diff
+111
View File
@@ -0,0 +1,111 @@
package main
import (
"bytes"
"errors"
"flag"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
)
type output struct {
path string
data []byte
}
func main() {
os.Exit(run(os.Args[1:], os.Stderr))
}
func run(args []string, stderr io.Writer) int {
flags := flag.NewFlagSet("protocolgen", flag.ContinueOnError)
flags.SetOutput(stderr)
root := flags.String("root", ".", "仓库根目录")
check := flags.Bool("check", false, "只检查生成物是否与 IDL 一致,不写入文件")
if err := flags.Parse(args); err != nil {
return 2
}
outputs, err := generateFrom(filepath.Join(*root, "protocol", "idl.json"))
if err != nil {
report(stderr, "%v", err)
return 1
}
status := 0
for _, out := range outputs {
path := filepath.Join(*root, filepath.FromSlash(out.path))
if *check {
old, err := os.ReadFile(path)
switch {
case err != nil && !errors.Is(err, fs.ErrNotExist):
report(stderr, "读取 %s:%v", out.path, err)
status = 1
case !bytes.Equal(old, out.data):
report(stderr, "%s 不是最新的,运行 go run ./cmd/protocolgen 重新生成", out.path)
status = 1
}
continue
}
if err := writeOutput(path, out.data); err != nil {
report(stderr, "%v", err)
return 1
}
}
return status
}
func report(stderr io.Writer, format string, args ...any) {
_, _ = fmt.Fprintf(stderr, "protocolgen:"+format+"\n", args...)
}
func generateFrom(path string) ([]output, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
d, err := parseIDL(data)
if err != nil {
return nil, err
}
if err := d.checkHandwritten(filepath.Dir(path)); err != nil {
return nil, err
}
return generate(d)
}
// 生成函数只处理 check 接受过的 IDL,直接依赖它保证的结构,不再检查:引用都指向存在的类型(标量类型已经展开),数组都有 items,
// anyOf 是 [类型, {"type": "null"}],allOf 的每个条件只按必填的枚举字段判断,需要解码函数的类型都已标记在 inbound 中。
func generate(d *idl) ([]output, error) {
types, err := goTypes(d)
if err != nil {
return nil, err
}
decode, err := goDecode(d)
if err != nil {
return nil, err
}
api, err := openAPI(d)
if err != nil {
return nil, err
}
outputs := []output{
{"protocol/types.gen.go", types},
{"protocol/decode.gen.go", decode},
{"protocol/openapi.gen.json", api},
{"web/src/core/realtime/protocol.gen.ts", tsProtocol(d)},
{"web/src/core/api/http.gen.ts", tsHTTP(d)},
}
if len(d.urlDefs()) > 0 {
outputs = append(outputs, output{"web/src/core/realtime/protocol.gen.test.ts", tsValidatorTests(d)})
}
return outputs, nil
}
func writeOutput(path string, data []byte) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
return os.WriteFile(path, data, 0o644)
}
+54
View File
@@ -0,0 +1,54 @@
package main
import "strings"
var initialisms = map[string]string{"id": "ID", "qq": "QQ", "url": "URL"}
func words(s string) []string {
var out []string
for _, part := range strings.FieldsFunc(s, func(r rune) bool { return r == '.' || r == '_' }) {
start := 0
for i := 1; i < len(part); i++ {
if isUpper(part[i]) && !isUpper(part[i-1]) {
out = append(out, part[start:i])
start = i
}
}
out = append(out, part[start:])
}
return out
}
func isUpper(c byte) bool {
return 'A' <= c && c <= 'Z'
}
func pascal(s string) string {
var b strings.Builder
for _, w := range words(s) {
b.WriteString(title(w))
}
return b.String()
}
func camel(s string) string {
w := words(s)
var b strings.Builder
b.WriteString(strings.ToLower(w[0]))
for _, x := range w[1:] {
b.WriteString(title(x))
}
return b.String()
}
func title(w string) string {
lower := strings.ToLower(w)
if s, ok := initialisms[lower]; ok {
return s
}
return strings.ToUpper(lower[:1]) + lower[1:]
}
func lowerFirst(s string) string {
return strings.ToLower(s[:1]) + s[1:]
}
+252
View File
@@ -0,0 +1,252 @@
package main
import (
"bytes"
"encoding/json"
"maps"
"slices"
"strconv"
"strings"
)
func openAPI(d *idl) ([]byte, error) {
schemas := map[string]any{
"ErrorResponse": map[string]any{
"type": "object",
"additionalProperties": false,
"properties": map[string]any{"error": componentRef("ErrorInfo")},
"required": []string{"error"},
},
}
for name, s := range d.Defs {
if s.scalar() {
continue
}
if err := addComponent(schemas, name, s); err != nil {
return nil, err
}
}
paths := map[string]map[string]any{}
for _, e := range d.HTTP {
op := map[string]any{
"operationId": e.Name,
"responses": openAPIResponses(e.httpBinding, e.Response),
}
if e.Description != "" {
op["summary"] = e.Description
}
if params := pathParams(e.Path); len(params) > 0 {
list := make([]any, len(params))
for i, p := range params {
list[i] = map[string]any{"name": p, "in": "path", "required": true, "schema": map[string]any{"type": "string"}}
}
op["parameters"] = list
}
if e.Request != nil {
op["requestBody"] = openAPIBody(e.Request)
}
security := []any{}
if scheme := map[string]string{"member": "memberToken", "admin": "adminToken"}[e.Auth]; scheme != "" {
security = append(security, map[string]any{scheme: []any{}})
}
op["security"] = security
for _, s := range []*schema{e.Request, e.Response} {
if err := addInlineComponent(schemas, s); err != nil {
return nil, err
}
}
if paths[e.Path] == nil {
paths[e.Path] = map[string]any{}
}
paths[e.Path][strings.ToLower(e.Method)] = op
}
for _, m := range d.Requests {
op, err := roomOperation(schemas, m)
if err != nil {
return nil, err
}
if paths[m.HTTP.Path] == nil {
paths[m.HTTP.Path] = map[string]any{}
}
paths[m.HTTP.Path][strings.ToLower(m.HTTP.Method)] = op
}
bearer := map[string]any{"type": "http", "scheme": "bearer"}
ticket := map[string]any{"type": "http", "scheme": "bearer", "description": "进房票据或常驻房间的 API Key"}
doc := map[string]any{
"openapi": "3.1.0",
"info": map[string]any{"title": d.Title, "version": d.version},
"paths": paths,
"components": map[string]any{
"schemas": schemas,
"securitySchemes": map[string]any{"memberToken": bearer, "ticket": ticket, "adminToken": bearer},
},
}
var b bytes.Buffer
enc := json.NewEncoder(&b)
enc.SetEscapeHTML(false)
enc.SetIndent("", " ")
if err := enc.Encode(doc); err != nil {
return nil, err
}
return b.Bytes(), nil
}
func componentRef(name string) map[string]any {
return map[string]any{"$ref": "#/components/schemas/" + name}
}
func openAPIBody(s *schema) map[string]any {
return map[string]any{
"required": true,
"content": map[string]any{"application/json": map[string]any{"schema": componentRef(s.dataType())}},
}
}
func openAPIResponses(b httpBinding, s *schema) map[string]any {
success := map[string]any{"description": "成功"}
if s != nil {
success["content"] = map[string]any{"application/json": map[string]any{"schema": componentRef(s.dataType())}}
}
if b.Location {
success["headers"] = map[string]any{"Location": map[string]any{"description": "新资源的地址", "schema": map[string]any{"type": "string"}}}
}
return map[string]any{
strconv.Itoa(b.status()): success,
"default": map[string]any{
"description": "失败",
"content": map[string]any{"application/json": map[string]any{"schema": componentRef("ErrorResponse")}},
},
}
}
// 路径参数不在查询串和请求体中;204 的请求没有响应体。
func roomOperation(schemas map[string]any, m *message) (map[string]any, error) {
b := *m.HTTP
response := m.Response
if b.status() == 204 {
response = nil
}
op := map[string]any{
"operationId": camel(m.Type),
"responses": openAPIResponses(b, response),
"security": []any{map[string]any{"ticket": []any{}}},
}
if m.Description != "" {
op["summary"] = m.Description
}
if err := addInlineComponent(schemas, response); err != nil {
return nil, err
}
pathNames := pathParams(b.Path)
var params []any
for _, name := range pathNames {
s, err := openAPISchema(m.Request.Properties[name])
if err != nil {
return nil, err
}
params = append(params, map[string]any{"name": name, "in": "path", "required": true, "schema": s})
}
if b.Method == "GET" || b.Method == "DELETE" {
for _, name := range slices.Sorted(maps.Keys(m.Request.Properties)) {
if slices.Contains(pathNames, name) {
continue
}
s, err := openAPISchema(m.Request.Properties[name])
if err != nil {
return nil, err
}
params = append(params, map[string]any{"name": name, "in": "query", "required": slices.Contains(m.Request.Required, name), "schema": s})
}
} else {
body, err := roomBody(schemas, m.Request, pathNames)
if err != nil {
return nil, err
}
op["requestBody"] = body
}
if len(params) > 0 {
op["parameters"] = params
}
return op, nil
}
// 请求体没有字段时可以不带请求体。
func roomBody(schemas map[string]any, s *schema, pathNames []string) (map[string]any, error) {
var bodySchema any = componentRef(s.name)
fields := len(s.Properties)
if len(pathNames) == 0 {
if err := addInlineComponent(schemas, s); err != nil {
return nil, err
}
} else {
rest := *s
rest.Properties = maps.Clone(s.Properties)
for _, name := range pathNames {
delete(rest.Properties, name)
}
rest.Required = slices.DeleteFunc(slices.Clone(s.Required), func(name string) bool { return slices.Contains(pathNames, name) })
fields = len(rest.Properties)
v, err := openAPISchema(&rest)
if err != nil {
return nil, err
}
bodySchema = v
}
return map[string]any{
"required": fields > 0,
"content": map[string]any{"application/json": map[string]any{"schema": bodySchema}},
}, nil
}
func addInlineComponent(schemas map[string]any, s *schema) error {
if s == nil || s.Ref != "" {
return nil
}
return addComponent(schemas, s.name, s)
}
func addComponent(schemas map[string]any, name string, s *schema) error {
v, err := openAPISchema(s)
if err != nil {
return err
}
schemas[name] = v
return nil
}
func openAPISchema(s *schema) (any, error) {
data, err := json.Marshal(s)
if err != nil {
return nil, err
}
dec := json.NewDecoder(bytes.NewReader(data))
dec.UseNumber()
var v any
if err := dec.Decode(&v); err != nil {
return nil, err
}
return rewriteSchema(v), nil
}
func rewriteSchema(v any) any {
switch v := v.(type) {
case map[string]any:
for k, x := range v {
if s, ok := x.(string); ok && k == "$ref" {
v[k] = "#/components/schemas/" + refName(s)
} else {
v[k] = rewriteSchema(x)
}
}
// 服务端接受非 ASCII 的主机名和路径,所以 format 是 iri 而不是 uri。
if v["x-url"] == true {
v["format"] = "iri"
}
case []any:
for i, x := range v {
v[i] = rewriteSchema(x)
}
}
return v
}
+331
View File
@@ -0,0 +1,331 @@
package main
import (
"bytes"
"fmt"
"maps"
"slices"
"strings"
)
const tsHeader = "// Code generated by protocolgen from protocol/idl.json; DO NOT EDIT.\n"
func tsProtocol(d *idl) []byte {
httpTypes := d.httpTypeNames()
var b bytes.Buffer
b.WriteString(tsHeader)
fmt.Fprintf(&b, "\nexport const PROTOCOL_VERSION = '%s'\n", d.version)
for _, name := range d.typeNames() {
if !httpTypes[name] {
writeTSType(&b, name, d.named[name])
}
}
requests := slices.Concat(d.WS.Requests, d.Requests)
writeTSMap(&b, "RequestDataMap", requests, func(m *message) (string, *schema) { return m.Type, m.Request })
writeTSMap(&b, "ResponseDataMap", requests, func(m *message) (string, *schema) { return m.Type, m.Response })
writeTSMap(&b, "ConnectionEventDataMap", d.WS.Events, func(e *event) (string, *schema) { return e.Type, e.Data })
writeTSMap(&b, "RoomEventDataMap", d.Events, func(e *event) (string, *schema) { return e.Type, e.Data })
b.WriteString(tsEnvelopes)
b.WriteString("\nexport const CloseCode = {\n")
for _, c := range d.WS.CloseCodes {
fmt.Fprintf(&b, " %s: %d,\n", c.Name, c.Code)
}
b.WriteString("} as const\n")
b.WriteString("\nexport type Send = <T extends RequestType>(type: T, data: RequestDataMap[T]) => Promise<ResponseDataMap[T]>\n")
b.WriteString("\nexport function roomRequests(send: Send) {\n return {\n")
for _, m := range d.Requests {
if len(m.Request.Properties) == 0 {
fmt.Fprintf(&b, " %s: () => send('%s', {}),\n", camel(m.Type), m.Type)
} else {
fmt.Fprintf(&b, " %s: (data: %s) => send('%s', data),\n", camel(m.Type), m.Request.name, m.Type)
}
}
b.WriteString(" }\n}\n")
if names := d.urlDefs(); len(names) > 0 {
b.WriteString(tsURLRule)
for _, name := range names {
writeTSValidator(&b, name, d.Defs[name])
}
}
return b.Bytes()
}
// 与 protocol/decode.go 中的 validURL 是同一套规则,两边由 protocol/testdata 中共用的用例检查。
const tsURLRule = `
function validURL(value: string): boolean {
if (/[\u0000-\u0020\u007F\\]/.test(value))
return false
const scheme = /^https?:\/\//i.exec(value)
if (scheme === null)
return false
const rest = value.slice(scheme[0].length)
const end = rest.search(/[/?#]/)
const authority = end < 0 ? rest : rest.slice(0, end)
const tail = end < 0 ? '' : rest.slice(end)
let host = authority
let port = ''
if (authority.startsWith('[')) {
const close = authority.indexOf(']') + 1
host = authority.slice(0, close)
if (close === 0 || !/^\[[0-9A-Fa-f:.]+\]$/.test(host))
return false
const after = authority.slice(close)
if (after !== '') {
if (!after.startsWith(':'))
return false
port = after.slice(1)
}
}
else {
const colon = authority.indexOf(':')
if (colon >= 0) {
host = authority.slice(0, colon)
port = authority.slice(colon + 1)
}
if (!/^[\w.~\u0080-\u{10FFFF}-]+$/u.test(host))
return false
const labels = host.replace(/\.$/, '').split('.')
if (/^(?:\d+|0x[0-9a-f]*)$/i.test(labels[labels.length - 1]) && !/^(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)$/.test(host))
return false
}
if (!/^\d*$/.test(port) || Number(port) > 65535)
return false
const hash = tail.indexOf('#')
const path = (hash < 0 ? tail : tail.slice(0, hash)).split('?')[0]
const fragment = hash < 0 ? '' : tail.slice(hash + 1)
const badEscape = /%(?![0-9A-Fa-f]{2})/
if (badEscape.test(path) || badEscape.test(fragment))
return false
try {
return new URL(value).hostname !== ''
}
catch {
return false
}
}
`
func writeTSValidator(b *bytes.Buffer, name string, s *schema) {
var checks []string
if s.MinLength != nil {
checks = append(checks, fmt.Sprintf("[...value].length >= %d", *s.MinLength))
}
if s.MaxLength != nil {
checks = append(checks, fmt.Sprintf("[...value].length <= %d", *s.MaxLength))
}
if s.MaxBytes != nil {
checks = append(checks, fmt.Sprintf("new TextEncoder().encode(value).length <= %d", *s.MaxBytes))
}
checks = append(checks, "validURL(value)")
fmt.Fprintf(b, "\nexport function is%s(value: string): boolean {\n return %s\n}\n", name, strings.Join(checks, " && "))
}
func tsValidatorTests(d *idl) []byte {
names := d.urlDefs()
validators := make([]string, len(names))
for i, name := range names {
validators[i] = "is" + name
}
var b bytes.Buffer
b.WriteString(tsHeader)
fmt.Fprintf(&b, "\nimport { readFileSync } from 'node:fs'\nimport { describe, expect, it } from 'vitest'\nimport { %s } from './protocol.gen'\n", strings.Join(validators, ", "))
b.WriteString(`
function cases(name: string): { value: string, valid: boolean }[] {
return JSON.parse(readFileSync(new URL(` + "`../../../../protocol/testdata/${name}.json`" + `, import.meta.url), 'utf8')) as { value: string, valid: boolean }[]
}
`)
for _, name := range names {
fmt.Fprintf(&b, "\ndescribe('is%s', () => {\n it('对 protocol/testdata/%s.json 中的每个用例与服务端的判断相同', () => {\n expect(cases('%s').filter(c => is%s(c.value) !== c.valid).map(c => c.value)).toEqual([])\n })\n})\n", name, name, name, name)
}
return b.Bytes()
}
const tsEnvelopes = `
export type RequestType = keyof RequestDataMap
export type ConnectionEventType = keyof ConnectionEventDataMap
export type RoomEventType = keyof RoomEventDataMap
export interface RequestMessage<T extends RequestType = RequestType> {
kind: 'request'
type: T
id: string
data: RequestDataMap[T]
}
export type ResponseMessage<T extends RequestType = RequestType> = {
[K in T]: { kind: 'response', type: K, id: string } & (
| { data: ResponseDataMap[K], error?: undefined }
| { data: Empty, error: ErrorInfo }
)
}[T]
export type ConnectionEventMessage<T extends ConnectionEventType = ConnectionEventType> = {
[K in T]: { kind: 'event', type: K, data: ConnectionEventDataMap[K] }
}[T]
export type RoomEventMessage<T extends RoomEventType = RoomEventType> = {
[K in T]: { kind: 'event', type: K, term: number, seq: number, data: RoomEventDataMap[K] }
}[T]
export type ServerMessage = ResponseMessage | ConnectionEventMessage | RoomEventMessage
`
func tsHTTP(d *idl) []byte {
httpTypes := d.httpTypeNames()
var body bytes.Buffer
imports := map[string]bool{"ErrorInfo": true}
for _, name := range slices.Sorted(maps.Keys(httpTypes)) {
s := d.named[name]
writeTSType(&body, name, s)
for _, p := range s.Properties {
if ref := tsRef(p); ref != "" {
imports[ref] = true
}
}
}
body.WriteString(tsHTTPRuntime)
body.WriteString("\nexport function httpClient(send: HttpSend) {\n return {\n")
for _, e := range d.HTTP {
response := "void"
if e.Response != nil {
response = e.Response.dataType()
if e.Response.Ref != "" {
imports[response] = true
}
}
var params []string
path := e.Path
for _, p := range pathParams(e.Path) {
params = append(params, p+": string")
path = strings.Replace(path, "{"+p+"}", "${encodeURIComponent("+p+")}", 1)
}
quoted := "'" + path + "'"
if strings.Contains(path, "${") {
quoted = "`" + path + "`"
}
requestBody := ""
if e.Request != nil {
if len(e.Request.Properties) == 0 {
requestBody = ", body: {}"
} else {
params = append(params, "body: "+e.Request.name)
requestBody = ", body"
}
}
fmt.Fprintf(&body, " %s: (%s) => send<%s>({ method: '%s', path: %s, auth: '%s'%s }),\n", e.Name, strings.Join(params, ", "), response, e.Method, quoted, e.Auth, requestBody)
}
body.WriteString(" }\n}\n")
var b bytes.Buffer
b.WriteString(tsHeader)
fmt.Fprintf(&b, "\nimport type { %s } from '../realtime/protocol.gen'\n", strings.Join(slices.Sorted(maps.Keys(imports)), ", "))
b.Write(body.Bytes())
return b.Bytes()
}
const tsHTTPRuntime = `
export interface ErrorResponse {
error: ErrorInfo
}
export type Auth = 'none' | 'member' | 'admin'
export interface HttpRequest {
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
path: string
auth: Auth
body?: unknown
}
export type HttpSend = <T>(request: HttpRequest) => Promise<T>
`
func (d *idl) httpTypeNames() map[string]bool {
names := map[string]bool{}
for _, e := range d.HTTP {
for _, s := range []*schema{e.Request, e.Response} {
if s != nil && s.Ref == "" {
names[s.name] = true
}
}
}
return names
}
func writeTSMap[T any](b *bytes.Buffer, name string, items []T, entry func(T) (string, *schema)) {
fmt.Fprintf(b, "\nexport interface %s {\n", name)
for _, item := range items {
key, s := entry(item)
fmt.Fprintf(b, " '%s': %s\n", key, s.dataType())
}
b.WriteString("}\n")
}
func writeTSType(b *bytes.Buffer, name string, s *schema) {
switch {
case s.Enum != nil:
values := make([]string, len(s.Enum))
for i, v := range s.Enum {
values[i] = "'" + v + "'"
}
fmt.Fprintf(b, "\nexport type %s = %s\n", name, strings.Join(values, " | "))
case len(s.Properties) == 0:
fmt.Fprintf(b, "\nexport type %s = Record<string, never>\n", name)
case s.OneOf != nil:
optional := optionalFields(s.OneOf[0])
alternatives := make([]string, len(s.OneOf))
for i, alt := range s.OneOf {
fields := make([]string, 0, len(alt.Required)+len(optional))
for _, f := range alt.Required {
fields = append(fields, f+": "+tsFieldType(s.Properties[f]))
}
for _, f := range optional {
fields = append(fields, f+"?: "+tsFieldType(s.Properties[f]))
}
alternatives[i] = "{ " + strings.Join(fields, ", ") + " }"
}
fmt.Fprintf(b, "\nexport type %s = %s\n", name, strings.Join(alternatives, " | "))
default:
fmt.Fprintf(b, "\nexport interface %s {\n", name)
for _, p := range slices.Sorted(maps.Keys(s.Properties)) {
optional := ""
if !slices.Contains(s.Required, p) {
optional = "?"
}
fmt.Fprintf(b, " %s%s: %s\n", p, optional, tsFieldType(s.Properties[p]))
}
b.WriteString("}\n")
}
}
func tsFieldType(s *schema) string {
switch {
case s.AnyOf != nil:
return tsValueType(s.AnyOf[0]) + " | null"
case s.Type == "array":
return tsValueType(s.Items) + "[]"
default:
return tsValueType(s)
}
}
func tsValueType(s *schema) string {
if s.Ref != "" {
return refName(s.Ref)
}
return map[string]string{"string": "string", "integer": "number", "number": "number", "boolean": "boolean"}[s.Type]
}
func tsRef(s *schema) string {
switch {
case s.AnyOf != nil:
return refName(s.AnyOf[0].Ref)
case s.Type == "array":
return refName(s.Items.Ref)
default:
return refName(s.Ref)
}
}