feat(codegen): Go-first tool emitting Zod schemas and TS types
Add tools/openapigen — a single-binary Go program that walks the
exported structs in database/model, web/entity, and xray via go/parser
and emits two committed artifacts under frontend/src/generated:
- zod.ts shared Zod schemas keyed off `validate:` tags (ports get
.min(1).max(65535), Inbound.protocol becomes a z.enum,
Node.scheme too, etc.)
- types.ts plain TS interfaces inferred from the same walk, so
consumers can import Inbound without dragging Zod along
The walker flattens embedded structs (AllSettingView.AllSetting),
honors json:"-" and omitempty, and accepts per-struct overrides so
the JSON-string-inside-JSON columns (Inbound.Settings/StreamSettings/
Sniffing, ClientRecord.Reverse, InboundClientIps.Ips) render as
z.unknown() instead of leaking the DB-storage type into the API
contract. Type aliases like model.Protocol are emitted as TS aliases
and Zod schemas in their own right.
Wires `npm run gen:zod` in frontend/package.json so the generator can
be re-run without leaving the frontend tree. The existing openapi.json
build (gen:api) is left alone for now; migrating the OpenAPI surface
to this generator is a follow-up.
PR2 of the planned Zod end-to-end rollout.
2026-05-25 17:29:44 +00:00
|
|
|
package main
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"fmt"
|
|
|
|
|
"io"
|
|
|
|
|
"sort"
|
|
|
|
|
"strings"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func emitZod(w io.Writer, schemas []Schema, aliases []Alias) error {
|
|
|
|
|
if _, err := fmt.Fprintln(w, zodHeader); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, a := range sortAliases(aliases) {
|
|
|
|
|
if _, err := fmt.Fprintf(w, "export const %sSchema = %s;\n", a.Name, zodTypeExpr(a.Underlying)); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if _, err := fmt.Fprintf(w, "export type %s = z.infer<typeof %sSchema>;\n\n", a.Name, a.Name); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, s := range sortSchemas(schemas) {
|
|
|
|
|
if _, err := fmt.Fprintf(w, "export const %sSchema = z.object({\n", s.Name); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
fields := append([]Field(nil), s.Fields...)
|
|
|
|
|
sort.SliceStable(fields, func(i, j int) bool { return fields[i].JSONName < fields[j].JSONName })
|
|
|
|
|
for _, f := range fields {
|
|
|
|
|
line := fmt.Sprintf(" %s: %s,\n", quoteIfNeeded(f.JSONName), zodExpr(f))
|
|
|
|
|
if _, err := fmt.Fprint(w, line); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if _, err := fmt.Fprintln(w, "});"); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if _, err := fmt.Fprintf(w, "export type %s = z.infer<typeof %sSchema>;\n\n", s.Name, s.Name); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func zodExpr(f Field) string {
|
|
|
|
|
expr := zodTypeExpr(f.Type)
|
|
|
|
|
expr = applyZodValidations(expr, f.Type, f.Validate)
|
|
|
|
|
if f.Optional {
|
|
|
|
|
expr += ".optional()"
|
|
|
|
|
}
|
|
|
|
|
return expr
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func zodTypeExpr(t TypeRef) string {
|
|
|
|
|
switch t.Kind {
|
|
|
|
|
case KindString:
|
|
|
|
|
return "z.string()"
|
|
|
|
|
case KindBool:
|
|
|
|
|
return "z.boolean()"
|
|
|
|
|
case KindInt:
|
|
|
|
|
return "z.number().int()"
|
|
|
|
|
case KindNumber:
|
|
|
|
|
return "z.number()"
|
|
|
|
|
case KindAny, KindUnknown:
|
|
|
|
|
return "z.unknown()"
|
|
|
|
|
case KindRaw:
|
|
|
|
|
return "z.unknown()"
|
|
|
|
|
case KindArray:
|
|
|
|
|
return "z.array(" + zodTypeExpr(*t.Element) + ")"
|
|
|
|
|
case KindMap:
|
|
|
|
|
return "z.record(" + zodTypeExpr(*t.Key) + ", " + zodTypeExpr(*t.Value) + ")"
|
|
|
|
|
case KindRef:
|
|
|
|
|
if t.Name == "nullable" {
|
|
|
|
|
return zodTypeExpr(*t.Inner) + ".nullable()"
|
|
|
|
|
}
|
|
|
|
|
return "z.lazy(() => " + t.Name + "Schema)"
|
|
|
|
|
}
|
|
|
|
|
return "z.unknown()"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func applyZodValidations(expr string, t TypeRef, rules []ValidateRule) string {
|
|
|
|
|
for _, r := range rules {
|
|
|
|
|
switch r.Name {
|
|
|
|
|
case "required":
|
|
|
|
|
continue
|
|
|
|
|
case "omitempty":
|
|
|
|
|
continue
|
|
|
|
|
case "gte":
|
|
|
|
|
if t.Kind == KindInt || t.Kind == KindNumber {
|
|
|
|
|
expr += fmt.Sprintf(".min(%s)", r.Param)
|
|
|
|
|
}
|
|
|
|
|
case "lte":
|
|
|
|
|
if t.Kind == KindInt || t.Kind == KindNumber {
|
|
|
|
|
expr += fmt.Sprintf(".max(%s)", r.Param)
|
|
|
|
|
}
|
|
|
|
|
case "gt":
|
|
|
|
|
if t.Kind == KindInt || t.Kind == KindNumber {
|
|
|
|
|
expr += fmt.Sprintf(".gt(%s)", r.Param)
|
|
|
|
|
}
|
|
|
|
|
case "lt":
|
|
|
|
|
if t.Kind == KindInt || t.Kind == KindNumber {
|
|
|
|
|
expr += fmt.Sprintf(".lt(%s)", r.Param)
|
|
|
|
|
}
|
|
|
|
|
case "min":
|
refactor(backend): retire hysteria2 as a top-level protocol
Hysteria v2 is not a separate xray protocol — it is plain "hysteria"
with streamSettings.version = 2. The frontend already dropped hysteria2
from the protocol enum in 5a90f7e3; the backend was still carrying the
literal as a compat alias.
Removed:
- model.Hysteria2 constant
- model.IsHysteria helper (only callers were buildProxy + genHysteriaLink)
- TestIsHysteria
- "hysteria2" from the Inbound.Protocol validate oneof enum
- All `case model.Hysteria, model.Hysteria2:` and `case "hysteria",
"hysteria2":` branches across client.go, inbound.go, outbound.go,
xray.go, port_conflict.go, xray/api.go, subService.go,
subJsonService.go, subClashService.go
- Stale #4081 comments
Kept (correctly — these are client-side URI/config schemes that are
independent of the xray protocol type):
- hysteria2:// share-link URI in subService.genHysteriaLink
- "hysteria2" Clash proxy type in subClashService.buildHysteriaProxy
- Comments referring to Hysteria v2 as a transport version
Note: this change does not include a DB migration. Existing rows with
protocol = 'hysteria2' will fall through to the default switch arms
after upgrade. A separate `UPDATE inbounds SET protocol = 'hysteria'
WHERE protocol = 'hysteria2'` is required for installs that still hold
legacy data.
2026-05-26 22:58:37 +00:00
|
|
|
switch t.Kind {
|
|
|
|
|
case KindString:
|
feat(codegen): Go-first tool emitting Zod schemas and TS types
Add tools/openapigen — a single-binary Go program that walks the
exported structs in database/model, web/entity, and xray via go/parser
and emits two committed artifacts under frontend/src/generated:
- zod.ts shared Zod schemas keyed off `validate:` tags (ports get
.min(1).max(65535), Inbound.protocol becomes a z.enum,
Node.scheme too, etc.)
- types.ts plain TS interfaces inferred from the same walk, so
consumers can import Inbound without dragging Zod along
The walker flattens embedded structs (AllSettingView.AllSetting),
honors json:"-" and omitempty, and accepts per-struct overrides so
the JSON-string-inside-JSON columns (Inbound.Settings/StreamSettings/
Sniffing, ClientRecord.Reverse, InboundClientIps.Ips) render as
z.unknown() instead of leaking the DB-storage type into the API
contract. Type aliases like model.Protocol are emitted as TS aliases
and Zod schemas in their own right.
Wires `npm run gen:zod` in frontend/package.json so the generator can
be re-run without leaving the frontend tree. The existing openapi.json
build (gen:api) is left alone for now; migrating the OpenAPI surface
to this generator is a follow-up.
PR2 of the planned Zod end-to-end rollout.
2026-05-25 17:29:44 +00:00
|
|
|
expr += fmt.Sprintf(".min(%s)", r.Param)
|
refactor(backend): retire hysteria2 as a top-level protocol
Hysteria v2 is not a separate xray protocol — it is plain "hysteria"
with streamSettings.version = 2. The frontend already dropped hysteria2
from the protocol enum in 5a90f7e3; the backend was still carrying the
literal as a compat alias.
Removed:
- model.Hysteria2 constant
- model.IsHysteria helper (only callers were buildProxy + genHysteriaLink)
- TestIsHysteria
- "hysteria2" from the Inbound.Protocol validate oneof enum
- All `case model.Hysteria, model.Hysteria2:` and `case "hysteria",
"hysteria2":` branches across client.go, inbound.go, outbound.go,
xray.go, port_conflict.go, xray/api.go, subService.go,
subJsonService.go, subClashService.go
- Stale #4081 comments
Kept (correctly — these are client-side URI/config schemes that are
independent of the xray protocol type):
- hysteria2:// share-link URI in subService.genHysteriaLink
- "hysteria2" Clash proxy type in subClashService.buildHysteriaProxy
- Comments referring to Hysteria v2 as a transport version
Note: this change does not include a DB migration. Existing rows with
protocol = 'hysteria2' will fall through to the default switch arms
after upgrade. A separate `UPDATE inbounds SET protocol = 'hysteria'
WHERE protocol = 'hysteria2'` is required for installs that still hold
legacy data.
2026-05-26 22:58:37 +00:00
|
|
|
case KindInt, KindNumber:
|
feat(codegen): Go-first tool emitting Zod schemas and TS types
Add tools/openapigen — a single-binary Go program that walks the
exported structs in database/model, web/entity, and xray via go/parser
and emits two committed artifacts under frontend/src/generated:
- zod.ts shared Zod schemas keyed off `validate:` tags (ports get
.min(1).max(65535), Inbound.protocol becomes a z.enum,
Node.scheme too, etc.)
- types.ts plain TS interfaces inferred from the same walk, so
consumers can import Inbound without dragging Zod along
The walker flattens embedded structs (AllSettingView.AllSetting),
honors json:"-" and omitempty, and accepts per-struct overrides so
the JSON-string-inside-JSON columns (Inbound.Settings/StreamSettings/
Sniffing, ClientRecord.Reverse, InboundClientIps.Ips) render as
z.unknown() instead of leaking the DB-storage type into the API
contract. Type aliases like model.Protocol are emitted as TS aliases
and Zod schemas in their own right.
Wires `npm run gen:zod` in frontend/package.json so the generator can
be re-run without leaving the frontend tree. The existing openapi.json
build (gen:api) is left alone for now; migrating the OpenAPI surface
to this generator is a follow-up.
PR2 of the planned Zod end-to-end rollout.
2026-05-25 17:29:44 +00:00
|
|
|
expr += fmt.Sprintf(".min(%s)", r.Param)
|
|
|
|
|
}
|
|
|
|
|
case "max":
|
refactor(backend): retire hysteria2 as a top-level protocol
Hysteria v2 is not a separate xray protocol — it is plain "hysteria"
with streamSettings.version = 2. The frontend already dropped hysteria2
from the protocol enum in 5a90f7e3; the backend was still carrying the
literal as a compat alias.
Removed:
- model.Hysteria2 constant
- model.IsHysteria helper (only callers were buildProxy + genHysteriaLink)
- TestIsHysteria
- "hysteria2" from the Inbound.Protocol validate oneof enum
- All `case model.Hysteria, model.Hysteria2:` and `case "hysteria",
"hysteria2":` branches across client.go, inbound.go, outbound.go,
xray.go, port_conflict.go, xray/api.go, subService.go,
subJsonService.go, subClashService.go
- Stale #4081 comments
Kept (correctly — these are client-side URI/config schemes that are
independent of the xray protocol type):
- hysteria2:// share-link URI in subService.genHysteriaLink
- "hysteria2" Clash proxy type in subClashService.buildHysteriaProxy
- Comments referring to Hysteria v2 as a transport version
Note: this change does not include a DB migration. Existing rows with
protocol = 'hysteria2' will fall through to the default switch arms
after upgrade. A separate `UPDATE inbounds SET protocol = 'hysteria'
WHERE protocol = 'hysteria2'` is required for installs that still hold
legacy data.
2026-05-26 22:58:37 +00:00
|
|
|
switch t.Kind {
|
|
|
|
|
case KindString:
|
feat(codegen): Go-first tool emitting Zod schemas and TS types
Add tools/openapigen — a single-binary Go program that walks the
exported structs in database/model, web/entity, and xray via go/parser
and emits two committed artifacts under frontend/src/generated:
- zod.ts shared Zod schemas keyed off `validate:` tags (ports get
.min(1).max(65535), Inbound.protocol becomes a z.enum,
Node.scheme too, etc.)
- types.ts plain TS interfaces inferred from the same walk, so
consumers can import Inbound without dragging Zod along
The walker flattens embedded structs (AllSettingView.AllSetting),
honors json:"-" and omitempty, and accepts per-struct overrides so
the JSON-string-inside-JSON columns (Inbound.Settings/StreamSettings/
Sniffing, ClientRecord.Reverse, InboundClientIps.Ips) render as
z.unknown() instead of leaking the DB-storage type into the API
contract. Type aliases like model.Protocol are emitted as TS aliases
and Zod schemas in their own right.
Wires `npm run gen:zod` in frontend/package.json so the generator can
be re-run without leaving the frontend tree. The existing openapi.json
build (gen:api) is left alone for now; migrating the OpenAPI surface
to this generator is a follow-up.
PR2 of the planned Zod end-to-end rollout.
2026-05-25 17:29:44 +00:00
|
|
|
expr += fmt.Sprintf(".max(%s)", r.Param)
|
refactor(backend): retire hysteria2 as a top-level protocol
Hysteria v2 is not a separate xray protocol — it is plain "hysteria"
with streamSettings.version = 2. The frontend already dropped hysteria2
from the protocol enum in 5a90f7e3; the backend was still carrying the
literal as a compat alias.
Removed:
- model.Hysteria2 constant
- model.IsHysteria helper (only callers were buildProxy + genHysteriaLink)
- TestIsHysteria
- "hysteria2" from the Inbound.Protocol validate oneof enum
- All `case model.Hysteria, model.Hysteria2:` and `case "hysteria",
"hysteria2":` branches across client.go, inbound.go, outbound.go,
xray.go, port_conflict.go, xray/api.go, subService.go,
subJsonService.go, subClashService.go
- Stale #4081 comments
Kept (correctly — these are client-side URI/config schemes that are
independent of the xray protocol type):
- hysteria2:// share-link URI in subService.genHysteriaLink
- "hysteria2" Clash proxy type in subClashService.buildHysteriaProxy
- Comments referring to Hysteria v2 as a transport version
Note: this change does not include a DB migration. Existing rows with
protocol = 'hysteria2' will fall through to the default switch arms
after upgrade. A separate `UPDATE inbounds SET protocol = 'hysteria'
WHERE protocol = 'hysteria2'` is required for installs that still hold
legacy data.
2026-05-26 22:58:37 +00:00
|
|
|
case KindInt, KindNumber:
|
feat(codegen): Go-first tool emitting Zod schemas and TS types
Add tools/openapigen — a single-binary Go program that walks the
exported structs in database/model, web/entity, and xray via go/parser
and emits two committed artifacts under frontend/src/generated:
- zod.ts shared Zod schemas keyed off `validate:` tags (ports get
.min(1).max(65535), Inbound.protocol becomes a z.enum,
Node.scheme too, etc.)
- types.ts plain TS interfaces inferred from the same walk, so
consumers can import Inbound without dragging Zod along
The walker flattens embedded structs (AllSettingView.AllSetting),
honors json:"-" and omitempty, and accepts per-struct overrides so
the JSON-string-inside-JSON columns (Inbound.Settings/StreamSettings/
Sniffing, ClientRecord.Reverse, InboundClientIps.Ips) render as
z.unknown() instead of leaking the DB-storage type into the API
contract. Type aliases like model.Protocol are emitted as TS aliases
and Zod schemas in their own right.
Wires `npm run gen:zod` in frontend/package.json so the generator can
be re-run without leaving the frontend tree. The existing openapi.json
build (gen:api) is left alone for now; migrating the OpenAPI surface
to this generator is a follow-up.
PR2 of the planned Zod end-to-end rollout.
2026-05-25 17:29:44 +00:00
|
|
|
expr += fmt.Sprintf(".max(%s)", r.Param)
|
|
|
|
|
}
|
|
|
|
|
case "url":
|
|
|
|
|
expr += ".url()"
|
|
|
|
|
case "email":
|
|
|
|
|
expr += ".email()"
|
|
|
|
|
case "oneof":
|
|
|
|
|
values := strings.Fields(r.Param)
|
|
|
|
|
quoted := make([]string, 0, len(values))
|
|
|
|
|
for _, v := range values {
|
|
|
|
|
quoted = append(quoted, fmt.Sprintf("'%s'", v))
|
|
|
|
|
}
|
|
|
|
|
expr = fmt.Sprintf("z.enum([%s])", strings.Join(quoted, ", "))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return expr
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func quoteIfNeeded(name string) string {
|
|
|
|
|
if name == "" {
|
|
|
|
|
return "''"
|
|
|
|
|
}
|
|
|
|
|
for i, r := range name {
|
|
|
|
|
if r >= 'a' && r <= 'z' {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if r >= 'A' && r <= 'Z' {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if r == '_' || r == '$' {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if i > 0 && r >= '0' && r <= '9' {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
return "'" + name + "'"
|
|
|
|
|
}
|
|
|
|
|
return name
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const zodHeader = `// Code generated by tools/openapigen. DO NOT EDIT.
|
|
|
|
|
import { z } from 'zod';`
|