Compare commits

..

2 Commits

Author SHA1 Message Date
Ubuntu
8e01acd35c feat: complete query result controls
Co-authored-by: multica-agent <github@multica.ai>
2026-08-03 18:00:54 +00:00
Ubuntu
ec23258cc9 feat: launch DNS query MVP
Co-authored-by: multica-agent <github@multica.ai>
2026-08-03 17:42:44 +00:00
20 changed files with 257 additions and 353 deletions

View File

@ -1,5 +1,5 @@
.git
.gitignore
README.md
compose.yaml
*_test.go
node_modules
web/dist
*.md

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
web/node_modules/
web/dist/
.env

View File

@ -1,12 +1,26 @@
FROM golang:1.24-alpine AS build
FROM node:24.5.0-alpine3.22 AS web
WORKDIR /src/web
COPY web/package.json web/tsconfig.json web/index.html ./
COPY web/src ./src
RUN npm install --no-audit --no-fund && npm run build
FROM golang:1.24.5-alpine3.22 AS api
WORKDIR /src
COPY go.mod ./
COPY main.go ./
COPY web ./web
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /dns-orbit .
COPY cmd ./cmd
RUN CGO_ENABLED=0 go test ./... && CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /server ./cmd/server
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /dns-orbit /dns-orbit
FROM nginx:1.29.0-alpine3.22
RUN apk add --no-cache curl && rm -f /etc/nginx/conf.d/default.conf && \
sed -i 's#pid /run/nginx.pid;#pid /tmp/nginx.pid;#' /etc/nginx/nginx.conf && \
addgroup -S app && adduser -S -G app app && \
chown -R app:app /var/cache/nginx /var/run /var/log/nginx /etc/nginx/conf.d
COPY --from=web /src/web/dist /usr/share/nginx/html
COPY --from=api /server /usr/local/bin/server
COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf
COPY deploy/entrypoint.sh /entrypoint.sh
RUN chmod 755 /entrypoint.sh
USER app
EXPOSE 8080
USER nonroot:nonroot
ENTRYPOINT ["/dns-orbit"]
HEALTHCHECK --interval=20s --timeout=3s --start-period=5s --retries=3 CMD curl -fsS http://127.0.0.1:8080/healthz || exit 1
ENTRYPOINT ["/entrypoint.sh"]

View File

@ -1,9 +1,38 @@
# DNS Orbit
# DNS Global
面向全球公共 DNS 节点的实时解析观测工具。支持 A、AAAA、CNAME、MX、NS、TXT、SOA、CAA查询结果通过 SSE 流式返回。
无需注册的轻量 DNS 传播检查工具。React 前端通过 SSE 实时展示 Go API 并行查询 Cloudflare、Google、AdGuard 和 AliDNS 的结果。
> 当前节点是公共 DNS 服务商的 Anycast 解析器,只能反映不同服务商的缓存差异,不代表真实地理位置。生产版可将后端 `nodes` 替换为各区域自建探针。
## 快速启动
```bash
docker compose up --build
```
打开 `http://localhost:8080`。健康检查:`GET /api/health`。
打开 <http://localhost:8080>。停止服务:
```bash
docker compose down
```
## API
```bash
curl -X POST http://localhost:8080/api/dns/query \
-H 'Content-Type: application/json' \
-d '{"domain":"example.com","type":"A"}'
```
随后连接返回的查询 ID`GET /api/dns/query/{queryId}/events`。
支持 `A`、`AAAA`、`CNAME`、`MX`、`NS`、`TXT`、`SOA` 和 `CAA`。输入 URL 时会自动提取域名。查询任务仅在内存保留五分钟POST 接口按来源 IP 限制为每分钟 30 次。
## 验证
```bash
docker build -t dnsquery:test .
docker compose up -d
curl --fail http://localhost:8080/healthz
docker compose down
```

104
cmd/server/main.go Normal file
View File

@ -0,0 +1,104 @@
package main
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"net/url"
"os"
"os/signal"
"sort"
"strings"
"sync"
"syscall"
"time"
)
var allowedTypes = map[string]bool{"A": true, "AAAA": true, "CNAME": true, "MX": true, "NS": true, "TXT": true, "SOA": true, "CAA": true}
type resolver struct{ Name, Region, Country, City, URL string }
type record struct { Value string `json:"value"`; TTL uint32 `json:"ttl"` }
type result struct { Node resolverView `json:"node"`; Status string `json:"status"`; Records []record `json:"records"`; ResponseTime int64 `json:"responseTime"`; Error string `json:"error,omitempty"` }
type resolverView struct { Name string `json:"name"`; Region string `json:"region"`; Country string `json:"country"`; City string `json:"city"` }
type query struct { ID string `json:"queryId"`; Domain string `json:"domain"`; Type string `json:"type"`; Status string `json:"status"`; CreatedAt time.Time `json:"createdAt"`; Results []result `json:"results,omitempty"`; Subscribers map[chan result]struct{} `json:"-"`; mu sync.RWMutex }
type store struct { mu sync.RWMutex; queries map[string]*query }
var nodes = []resolver{
{"Cloudflare", "North America", "United States", "Global Anycast", "https://cloudflare-dns.com/dns-query"},
{"Google", "North America", "United States", "Global Anycast", "https://dns.google/resolve"},
{"AdGuard", "Europe", "Cyprus", "Global Anycast", "https://dns.adguard-dns.com/resolve"},
{"AliDNS", "Asia", "China", "Global Anycast", "https://dns.alidns.com/resolve"},
}
func main() {
s := &store{queries: make(map[string]*query)}
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json"); io.WriteString(w, `{"status":"ok"}`) })
mux.HandleFunc("POST /api/dns/query", s.create)
mux.HandleFunc("GET /api/dns/query/{id}", s.get)
mux.HandleFunc("GET /api/dns/query/{id}/events", s.events)
server := &http.Server{Addr: env("ADDR", ":8080"), Handler: security(rateLimit(mux)), ReadHeaderTimeout: 5 * time.Second}
go s.cleanup()
go func() { slog.Info("server started", "addr", server.Addr); if err := server.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { slog.Error("server stopped", "error", err); os.Exit(1) } }()
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM); defer stop(); <-ctx.Done()
shutdown, cancel := context.WithTimeout(context.Background(), 10*time.Second); defer cancel(); _ = server.Shutdown(shutdown)
}
func (s *store) create(w http.ResponseWriter, r *http.Request) {
var body struct { Domain string `json:"domain"`; Type string `json:"type"` }
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&body); err != nil { problem(w, 400, "请求格式无效"); return }
domain, err := normalizeDomain(body.Domain); kind := strings.ToUpper(strings.TrimSpace(body.Type))
if err != nil { problem(w, 400, err.Error()); return }; if !allowedTypes[kind] { problem(w, 400, "不支持的记录类型"); return }
q := &query{ID: newID(), Domain: domain, Type: kind, Status: "running", CreatedAt: time.Now().UTC(), Subscribers: make(map[chan result]struct{})}
s.mu.Lock(); s.queries[q.ID] = q; s.mu.Unlock(); go s.run(q)
writeJSON(w, 202, q)
}
func (s *store) get(w http.ResponseWriter, r *http.Request) { q := s.find(r.PathValue("id")); if q == nil { problem(w, 404, "查询不存在或已过期"); return }; q.mu.RLock(); defer q.mu.RUnlock(); writeJSON(w, 200, q) }
func (s *store) events(w http.ResponseWriter, r *http.Request) {
q := s.find(r.PathValue("id")); if q == nil { problem(w, 404, "查询不存在或已过期"); return }
flusher, ok := w.(http.Flusher); if !ok { problem(w, 500, "不支持事件流"); return }
w.Header().Set("Content-Type", "text/event-stream"); w.Header().Set("Cache-Control", "no-cache"); w.Header().Set("X-Accel-Buffering", "no")
ch := make(chan result, len(nodes)); q.mu.Lock(); existing := append([]result(nil), q.Results...); done := q.Status == "completed"; if !done { q.Subscribers[ch] = struct{}{} }; q.mu.Unlock()
defer func() { q.mu.Lock(); delete(q.Subscribers, ch); q.mu.Unlock() }()
for _, item := range existing { sendEvent(w, "result", item) }; if done { sendEvent(w, "complete", map[string]any{"count": len(existing)}); flusher.Flush(); return }; flusher.Flush()
for { select { case item, open := <-ch: if !open { sendEvent(w, "complete", map[string]any{"count": len(nodes)}); flusher.Flush(); return }; sendEvent(w, "result", item); flusher.Flush(); case <-r.Context().Done(): return } }
}
func (s *store) run(q *query) { var wg sync.WaitGroup; for _, n := range nodes { wg.Add(1); go func() { defer wg.Done(); item := resolve(q.Domain, q.Type, n); q.mu.Lock(); q.Results = append(q.Results, item); for ch := range q.Subscribers { ch <- item }; q.mu.Unlock() }() }; wg.Wait(); q.mu.Lock(); q.Status = "completed"; for ch := range q.Subscribers { close(ch); delete(q.Subscribers, ch) }; q.mu.Unlock() }
func resolve(domain, kind string, n resolver) result {
view := resolverView{n.Name, n.Region, n.Country, n.City}; started := time.Now(); ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second); defer cancel()
u, _ := url.Parse(n.URL); params := u.Query(); params.Set("name", domain); params.Set("type", kind); u.RawQuery = params.Encode()
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil); req.Header.Set("Accept", "application/dns-json"); resp, err := http.DefaultClient.Do(req); elapsed := time.Since(started).Milliseconds()
if err != nil { status := "failed"; if errors.Is(ctx.Err(), context.DeadlineExceeded) { status = "timeout" }; return result{Node: view, Status: status, ResponseTime: elapsed, Error: "解析器请求失败"} }; defer resp.Body.Close()
if resp.StatusCode != 200 { return result{Node: view, Status: "failed", ResponseTime: elapsed, Error: fmt.Sprintf("HTTP %d", resp.StatusCode)} }
var payload struct { Status int `json:"Status"`; Answer []struct { Data string `json:"data"`; TTL uint32 `json:"TTL"` } `json:"Answer"` }
if json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&payload) != nil || payload.Status != 0 { return result{Node: view, Status: "failed", ResponseTime: elapsed, Error: fmt.Sprintf("DNS 状态 %d", payload.Status)} }
if len(payload.Answer) == 0 { return result{Node: view, Status: "no_record", ResponseTime: elapsed, Records: []record{}} }
records := make([]record, 0, len(payload.Answer)); for _, a := range payload.Answer { records = append(records, record{strings.TrimSuffix(a.Data, "."), a.TTL}) }; sort.Slice(records, func(i,j int) bool{return records[i].Value<records[j].Value})
return result{Node: view, Status: "success", Records: records, ResponseTime: elapsed}
}
func normalizeDomain(input string) (string, error) { input = strings.TrimSpace(input); if !strings.Contains(input, "://") { input = "//" + input }; u, err := url.Parse(input); if err != nil || u.Hostname() == "" { return "", errors.New("请输入合法域名") }; host := strings.TrimSuffix(strings.ToLower(u.Hostname()), "."); if len(host)>253 || net.ParseIP(host)!=nil || host=="localhost" || !validLabels(host) { return "", errors.New("请输入合法的公网域名") }; return host,nil }
func validLabels(host string) bool { labels:=strings.Split(host,"."); if len(labels)<2{return false}; for _,l:=range labels { if len(l)<1||len(l)>63||l[0]=='-'||l[len(l)-1]=='-'{return false}; for _,c:=range l {if !(c>='a'&&c<='z'||c>='0'&&c<='9'||c=='-'){return false}} }; return true }
func (s *store) find(id string)*query{s.mu.RLock();defer s.mu.RUnlock();return s.queries[id]}
func (s *store) cleanup(){ticker:=time.NewTicker(time.Minute);defer ticker.Stop();for range ticker.C{s.mu.Lock();for id,q:=range s.queries{if time.Since(q.CreatedAt)>5*time.Minute{delete(s.queries,id)}};s.mu.Unlock()}}
func sendEvent(w io.Writer,name string,data any){b,_:=json.Marshal(data);fmt.Fprintf(w,"event: %s\ndata: %s\n\n",name,b)}
func writeJSON(w http.ResponseWriter,status int,v any){w.Header().Set("Content-Type","application/json");w.WriteHeader(status);_ = json.NewEncoder(w).Encode(v)}
func problem(w http.ResponseWriter,status int,msg string){writeJSON(w,status,map[string]string{"error":msg})}
func newID()string{b:=make([]byte,6);_,_=rand.Read(b);return "dns_"+hex.EncodeToString(b)}
func env(k,d string)string{if v:=os.Getenv(k);v!=""{return v};return d}
func security(next http.Handler) http.Handler{return http.HandlerFunc(func(w http.ResponseWriter,r *http.Request){w.Header().Set("X-Content-Type-Options","nosniff");w.Header().Set("X-Frame-Options","DENY");w.Header().Set("Referrer-Policy","same-origin");next.ServeHTTP(w,r)})}
var visitors sync.Map
func rateLimit(next http.Handler) http.Handler{return http.HandlerFunc(func(w http.ResponseWriter,r *http.Request){if r.Method=="POST" {ip,_,_:=net.SplitHostPort(r.RemoteAddr);now:=time.Now();raw,_:=visitors.LoadOrStore(ip,&struct{sync.Mutex;start time.Time;n int}{start:now});v:=raw.(*struct{sync.Mutex;start time.Time;n int});v.Lock();if now.Sub(v.start)>time.Minute{v.start=now;v.n=0};v.n++;blocked:=v.n>30;v.Unlock();if blocked{problem(w,429,"请求过于频繁,请稍后重试");return}};next.ServeHTTP(w,r)})}

5
cmd/server/main_test.go Normal file
View File

@ -0,0 +1,5 @@
package main
import "testing"
func TestNormalizeDomain(t *testing.T){tests:=[]struct{in,want string;ok bool}{{"https://WWW.Example.com/path","www.example.com",true},{"example.com/","example.com",true},{"localhost","",false},{"127.0.0.1","",false},{"bad_domain.com","",false}};for _,tt:=range tests{got,err:=normalizeDomain(tt.in);if (err==nil)!=tt.ok||got!=tt.want{t.Errorf("normalizeDomain(%q)=(%q,%v)",tt.in,got,err)}}}

View File

@ -1,13 +1,16 @@
services:
dns-orbit:
dnsquery:
build: .
ports:
- "8080:8080"
environment:
PORT: "8080"
restart: unless-stopped
healthcheck:
test: ["CMD", "/dns-orbit", "--healthcheck"]
interval: 30s
timeout: 3s
retries: 3
init: true
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
read_only: true
tmpfs:
- /var/cache/nginx
- /var/run
- /tmp

17
deploy/entrypoint.sh Normal file
View File

@ -0,0 +1,17 @@
#!/bin/sh
set -eu
ADDR=:8081 /usr/local/bin/server &
api_pid=$!
/usr/sbin/nginx -g 'daemon off;' &
web_pid=$!
shutdown() {
kill -TERM "$api_pid" 2>/dev/null || true
kill -QUIT "$web_pid" 2>/dev/null || true
wait "$api_pid" 2>/dev/null || true
wait "$web_pid" 2>/dev/null || true
}
trap shutdown INT TERM EXIT
wait "$api_pid"

13
deploy/nginx.conf Normal file
View File

@ -0,0 +1,13 @@
server {
listen 8080;
server_name _;
root /usr/share/nginx/html;
client_body_temp_path /tmp/client_temp;
proxy_temp_path /tmp/proxy_temp;
fastcgi_temp_path /tmp/fastcgi_temp;
uwsgi_temp_path /tmp/uwsgi_temp;
scgi_temp_path /tmp/scgi_temp;
location /api/ { proxy_pass http://127.0.0.1:8081; proxy_http_version 1.1; proxy_buffering off; proxy_read_timeout 30s; }
location = /healthz { proxy_pass http://127.0.0.1:8081; }
location / { try_files $uri $uri/ /index.html; }
}

1
go.mod
View File

@ -1,3 +1,4 @@
module dnsquery
go 1.24

278
main.go
View File

@ -1,278 +0,0 @@
package main
import (
"context"
"crypto/rand"
"embed"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"log"
"net"
"net/http"
"net/url"
"os"
"sort"
"strings"
"sync"
"time"
)
//go:embed web/*
var webFS embed.FS
type resolver struct{ Name, Country, City, Region, URL string }
type record struct {
Value string `json:"value"`
TTL int `json:"ttl"`
}
type result struct {
Resolver string `json:"resolver"`
Country string `json:"country"`
City string `json:"city"`
Region string `json:"region"`
Status string `json:"status"`
Records []record `json:"records"`
ResponseTime int64 `json:"responseTime"`
Error string `json:"error,omitempty"`
}
type query struct {
ID, Domain, Type string
Created time.Time
Results chan result
}
type app struct {
mu sync.RWMutex
queries map[string]*query
client *http.Client
}
var resolvers = []resolver{
{"Cloudflare", "全球", "Anycast", "全球", "https://cloudflare-dns.com/dns-query"},
{"Google", "全球", "Anycast", "全球", "https://dns.google/resolve"},
{"Quad9", "瑞士", "Zurich", "欧洲", "https://dns.quad9.net/dns-query"},
{"AdGuard", "塞浦路斯", "Limassol", "欧洲", "https://dns.adguard-dns.com/resolve"},
{"AliDNS", "中国", "Hangzhou", "亚洲", "https://dns.alidns.com/resolve"},
{"DNSPod", "中国", "Shanghai", "亚洲", "https://doh.pub/dns-query"},
{"360 Secure DNS", "中国", "Beijing", "亚洲", "https://doh.360.cn/dns-query"},
{"Tencent EdgeOne", "新加坡", "Singapore", "亚洲", "https://public.dns.iij.jp/dns-query"},
{"IIJ", "日本", "Tokyo", "亚洲", "https://public.dns.iij.jp/dns-query"},
{"Mullvad", "瑞典", "Gothenburg", "欧洲", "https://doh.mullvad.net/dns-query"},
{"Control D", "加拿大", "Toronto", "北美", "https://freedns.controld.com/p0"},
{"CleanBrowsing", "美国", "Virginia", "北美", "https://doh.cleanbrowsing.org/doh/security-filter/"},
{"OpenDNS", "美国", "San Francisco", "北美", "https://doh.opendns.com/dns-query"},
{"NextDNS", "美国", "Anycast", "北美", "https://dns.nextdns.io"},
{"LibreDNS", "德国", "Frankfurt", "欧洲", "https://doh.libredns.gr/dns-query"},
{"Digitale Gesellschaft", "瑞士", "Zurich", "欧洲", "https://dns.digitale-gesellschaft.ch/dns-query"},
{"Foundation RESTENA", "卢森堡", "Luxembourg", "欧洲", "https://kaitain.restena.lu/dns-query"},
{"CIRA Canadian Shield", "加拿大", "Ottawa", "北美", "https://private.canadianshield.cira.ca/dns-query"},
{"Australian DNS", "澳大利亚", "Sydney", "大洋洲", "https://dns0.eu/dns-query"},
{"DNS0.eu", "法国", "Paris", "欧洲", "https://dns0.eu/dns-query"},
}
var allowed = map[string]bool{"A": true, "AAAA": true, "CNAME": true, "MX": true, "NS": true, "TXT": true, "SOA": true, "CAA": true}
func main() {
if len(os.Args) > 1 && os.Args[1] == "--healthcheck" {
resp, err := (&http.Client{Timeout: 2 * time.Second}).Get("http://127.0.0.1:" + env("PORT", "8080") + "/api/health")
if err != nil || resp.StatusCode != http.StatusOK {
os.Exit(1)
}
resp.Body.Close()
return
}
a := &app{queries: map[string]*query{}, client: &http.Client{Timeout: 6 * time.Second}}
mux := http.NewServeMux()
mux.HandleFunc("POST /api/dns/query", a.start)
mux.HandleFunc("GET /api/dns/query/{id}/events", a.events)
mux.HandleFunc("GET /api/health", func(w http.ResponseWriter, _ *http.Request) {
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
})
mux.Handle("/web/", http.StripPrefix("/web", staticHandler()))
mux.Handle("/", staticHandler())
addr := ":" + env("PORT", "8080")
log.Printf("DNS Orbit listening on %s", addr)
log.Fatal(http.ListenAndServe(addr, securityHeaders(mux)))
}
func staticHandler() http.Handler {
assets, err := fs.Sub(webFS, "web")
if err != nil {
panic(err)
}
return http.FileServer(http.FS(assets))
}
func env(k, v string) string {
if x := os.Getenv(k); x != "" {
return x
}
return v
}
func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Referrer-Policy", "no-referrer")
next.ServeHTTP(w, r)
})
}
func normalizeDomain(raw string) (string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", errors.New("请输入域名")
}
if !strings.Contains(raw, "://") {
raw = "https://" + raw
}
u, err := url.Parse(raw)
if err != nil || u.Hostname() == "" {
return "", errors.New("域名格式无效")
}
d := strings.TrimSuffix(strings.ToLower(u.Hostname()), ".")
if len(d) > 253 || strings.Contains(d, "_") || net.ParseIP(d) != nil {
return "", errors.New("仅支持公共域名")
}
parts := strings.Split(d, ".")
if len(parts) < 2 {
return "", errors.New("请输入完整公共域名")
}
for _, p := range parts {
if p == "" || len(p) > 63 || p[0] == '-' || p[len(p)-1] == '-' {
return "", errors.New("域名格式无效")
}
for _, c := range p {
if !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '-') {
return "", errors.New("域名格式无效")
}
}
}
return d, nil
}
func (a *app) start(w http.ResponseWriter, r *http.Request) {
var in struct{ Domain, Type string }
if json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&in) != nil {
http.Error(w, "invalid json", 400)
return
}
d, err := normalizeDomain(in.Domain)
t := strings.ToUpper(in.Type)
if err != nil || !allowed[t] {
if err == nil {
err = errors.New("不支持该记录类型")
}
http.Error(w, err.Error(), 400)
return
}
b := make([]byte, 6)
rand.Read(b)
q := &query{ID: "dns_" + hex.EncodeToString(b), Domain: d, Type: t, Created: time.Now(), Results: make(chan result, len(resolvers))}
a.mu.Lock()
a.queries[q.ID] = q
a.mu.Unlock()
go a.run(q)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"queryId": q.ID, "domain": d, "type": t, "status": "running"})
}
func (a *app) run(q *query) {
var wg sync.WaitGroup
for _, res := range resolvers {
wg.Add(1)
go func(rs resolver) { defer wg.Done(); q.Results <- a.lookup(q, rs) }(res)
}
wg.Wait()
close(q.Results)
time.AfterFunc(5*time.Minute, func() { a.mu.Lock(); delete(a.queries, q.ID); a.mu.Unlock() })
}
func (a *app) lookup(q *query, rs resolver) result {
started := time.Now()
out := result{Resolver: rs.Name, Country: rs.Country, City: rs.City, Region: rs.Region, Status: "error", Records: []record{}}
u, err := url.Parse(rs.URL)
if err != nil {
out.Error = err.Error()
return out
}
params := u.Query()
params.Set("name", q.Domain)
params.Set("type", q.Type)
u.RawQuery = params.Encode()
ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, "GET", u.String(), nil)
req.Header.Set("Accept", "application/dns-json")
resp, err := a.client.Do(req)
out.ResponseTime = time.Since(started).Milliseconds()
if err != nil {
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
out.Status = "timeout"
}
out.Error = "查询失败"
return out
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
out.Error = fmt.Sprintf("HTTP %d", resp.StatusCode)
return out
}
var body struct {
Status int `json:"Status"`
Answer []struct {
Name string `json:"name"`
Type int `json:"type"`
TTL int `json:"TTL"`
Data string `json:"data"`
} `json:"Answer"`
}
if json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&body) != nil {
out.Error = "响应格式无效"
return out
}
if body.Status != 0 {
out.Status = "error"
out.Error = fmt.Sprintf("DNS RCODE %d", body.Status)
return out
}
if len(body.Answer) == 0 {
out.Status = "empty"
return out
}
for _, v := range body.Answer {
out.Records = append(out.Records, record{strings.Trim(v.Data, "\""), v.TTL})
}
sort.Slice(out.Records, func(i, j int) bool { return out.Records[i].Value < out.Records[j].Value })
out.Status = "success"
return out
}
func (a *app) events(w http.ResponseWriter, r *http.Request) {
a.mu.RLock()
q := a.queries[r.PathValue("id")]
a.mu.RUnlock()
if q == nil {
http.Error(w, "query not found", 404)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "stream unsupported", 500)
return
}
for x := range q.Results {
b, _ := json.Marshal(x)
fmt.Fprintf(w, "event: result\ndata: %s\n\n", b)
flusher.Flush()
}
fmt.Fprint(w, "event: complete\ndata: {}\n\n")
flusher.Flush()
}

View File

@ -1,36 +0,0 @@
package main
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestNormalizeDomain(t *testing.T) {
cases := map[string]string{"example.com": "example.com", "https://WWW.Example.com/a": "www.example.com", "example.com/": "example.com"}
for in, want := range cases {
got, err := normalizeDomain(in)
if err != nil || got != want {
t.Fatalf("%q: got %q, %v", in, got, err)
}
}
for _, in := range []string{"localhost", "127.0.0.1", "bad_domain.com", "-bad.com"} {
if _, err := normalizeDomain(in); err == nil {
t.Fatalf("expected %q invalid", in)
}
}
}
func TestWebAssetRoutes(t *testing.T) {
mux := http.NewServeMux()
mux.Handle("/web/", http.StripPrefix("/web", staticHandler()))
mux.Handle("/", staticHandler())
for _, path := range []string{"/", "/web/app.js", "/web/style.css"} {
recorder := httptest.NewRecorder()
mux.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, path, nil))
if recorder.Code != http.StatusOK {
t.Errorf("GET %s returned %d, want 200", path, recorder.Code)
}
}
}

View File

@ -1,6 +0,0 @@
const $=s=>document.querySelector(s), state={results:[],filter:'all',domain:'',type:''};
$('#theme').onclick=()=>{document.body.classList.toggle('light');localStorage.theme=document.body.classList.contains('light')?'light':'dark'};if(localStorage.theme==='light')document.body.classList.add('light');
$('#form').onsubmit=async e=>{e.preventDefault();$('#error').textContent='';$('#submit').disabled=true;state.results=[];render();try{const res=await fetch('/api/dns/query',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({domain:$('#domain').value,type:$('#type').value})});if(!res.ok)throw new Error(await res.text());const q=await res.json();state.domain=q.domain;state.type=q.type;$('#queryTitle').textContent=`${q.domain} / ${q.type}`;$('#dashboard').hidden=false;$('#empty').hidden=true;const es=new EventSource(`/api/dns/query/${q.queryId}/events`);es.addEventListener('result',x=>{state.results.push(JSON.parse(x.data));render()});es.addEventListener('complete',()=>{es.close();$('#submit').disabled=false;$('#finished').textContent='COMPLETED '+new Date().toLocaleTimeString()})}catch(err){$('#error').textContent=err.message.trim();$('#submit').disabled=false}};
function render(){const rs=state.results,ok=rs.filter(x=>x.status==='success'),bad=rs.filter(x=>x.status!=='success');$('#progress').textContent=`${rs.length} / 20`;$('#bar').style.width=`${rs.length*5}%`;$('#success').textContent=ok.length;$('#failed').textContent=bad.length;const signatures=new Set(ok.map(x=>x.records.map(r=>r.value).sort().join('|')));$('#consistent').textContent=!ok.length?'分析中':signatures.size===1?'一致':'不一致';$('#consistent').style.color=signatures.size>1?'var(--amber)':'var(--green)';let shown=rs.filter(x=>state.filter==='all'||state.filter==='success'&&x.status==='success'||state.filter==='issue'&&x.status!=='success');$('#rows').innerHTML=shown.map((x,i)=>`<tr><td><b>${esc(x.country)} · ${esc(x.city)}</b><small>${esc(x.region)} / ${esc(x.resolver)}</small></td><td><span class="status ${x.status==='error'?'error-s':x.status+'-s'}">${label(x.status)}</span></td><td>${x.records.length?x.records.map(r=>`<code>${esc(r.value)}</code>`).join('<br>'):'<span style="color:var(--muted)">—</span>'}</td><td>${x.records[0]?.ttl??'—'}</td><td>${x.responseTime?x.responseTime+' ms':'—'}</td><td><button class="copy" data-i="${state.results.indexOf(x)}">⧉</button></td></tr>`).join('');document.querySelectorAll('.copy').forEach(b=>b.onclick=()=>copyResult(state.results[+b.dataset.i]))}
function label(s){return({success:'成功',timeout:'超时',empty:'无记录',error:'失败'})[s]||s}function esc(s){return String(s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]))}function copyResult(x){navigator.clipboard.writeText(`${x.resolver}\t${x.status}\t${x.records.map(r=>r.value).join(', ')}`)}
$('#filters').onclick=e=>{if(!e.target.dataset.filter)return;document.querySelectorAll('#filters button').forEach(x=>x.classList.remove('active'));e.target.classList.add('active');state.filter=e.target.dataset.filter;render()};$('#copyAll').onclick=()=>navigator.clipboard.writeText(state.results.map(x=>`${x.country}/${x.city}\t${x.resolver}\t${label(x.status)}\t${x.records.map(r=>r.value).join(', ')}\t${x.responseTime}ms`).join('\n'));

View File

@ -1,10 +1 @@
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="description" content="DNS Orbit 全球公共 DNS 解析传播检测"><title>DNS Orbit · 全球解析观测站</title><link rel="stylesheet" href="/web/style.css"></head><body>
<div class="grid"></div><header><a class="brand" href="/"><span class="logo"></span><span>DNS ORBIT<small>GLOBAL RESOLUTION GRID</small></span></a><div class="live"><i></i> 20 NODES ONLINE</div><button id="theme" aria-label="切换主题"></button></header>
<main><section class="hero"><div class="eyebrow">/ GLOBAL DNS INTELLIGENCE</div><h1>看见域名解析<br><em>穿越全球的轨迹</em></h1><p>连接 20 个公共 DNS 解析节点,实时检测记录传播、一致性与响应延迟。</p>
<form id="form"><div class="input"><span></span><input id="domain" required autocomplete="off" placeholder="输入域名,例如 example.com"><select id="type" aria-label="记录类型"><option>A</option><option>AAAA</option><option>CNAME</option><option>MX</option><option>NS</option><option>TXT</option><option>SOA</option><option>CAA</option></select></div><button id="submit">启动全球扫描 <b></b></button></form><div id="error" class="error"></div></section>
<section id="dashboard" hidden><div class="query-head"><div><span class="eyebrow">/ LIVE TELEMETRY</span><h2 id="queryTitle"></h2></div><button id="copyAll">复制全部结果</button></div>
<div class="stats"><article><label>扫描进度</label><strong id="progress">0 / 20</strong><div class="bar"><i id="bar"></i></div></article><article><label>成功节点</label><strong id="success">0</strong><small>RESOLVED</small></article><article><label>异常节点</label><strong id="failed">0</strong><small>ISSUES</small></article><article><label>一致性</label><strong id="consistent">分析中</strong><small>CONSENSUS</small></article></div>
<div class="toolbar"><div id="filters"><button class="active" data-filter="all">全部</button><button data-filter="success">成功</button><button data-filter="issue">异常</button></div><span id="finished"></span></div>
<div class="table-wrap"><table><thead><tr><th>区域 / 节点</th><th>状态</th><th>DNS 结果</th><th>TTL</th><th>响应时间</th><th></th></tr></thead><tbody id="rows"></tbody></table></div></section>
<section id="empty" class="empty"><div class="radar"><i></i><i></i><i></i><b></b></div><p>等待扫描指令</p><small>AWAITING TARGET DOMAIN</small></section></main>
<footer><span>DNS ORBIT / 2026</span><span>NO LOGS · NO TRACKING · OPEN ACCESS</span></footer><script src="/web/app.js"></script></body></html>
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width,initial-scale=1.0"/><meta name="description" content="从多个公共 DNS 解析器实时检查域名解析结果"/><title>DNS Global</title></head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>

1
web/package.json Normal file
View File

@ -0,0 +1 @@
{"scripts":{"dev":"vite --host 0.0.0.0","build":"tsc -b && vite build"},"dependencies":{"@vitejs/plugin-react":"latest","vite":"latest","typescript":"latest","react":"latest","react-dom":"latest"},"devDependencies":{"@types/react":"latest","@types/react-dom":"latest"}}

37
web/src/main.tsx Normal file
View File

@ -0,0 +1,37 @@
import React, {useEffect, useState} from 'react';
import {createRoot} from 'react-dom/client';
import './styles.css';
type RecordValue={value:string;ttl:number};
type Result={node:{name:string;region:string;country:string;city:string};status:string;records:RecordValue[];responseTime:number;error?:string};
const types=['A','AAAA','CNAME','MX','NS','TXT','SOA','CAA'];
const statusLabel:Record<string,string>={success:'成功',no_record:'无记录',failed:'失败',timeout:'超时'};
function App(){
const [input,setInput]=useState('example.com'),[kind,setKind]=useState('A'),[domain,setDomain]=useState(''),[queryId,setQueryId]=useState(''),[results,setResults]=useState<Result[]>([]),[running,setRunning]=useState(false),[error,setError]=useState(''),[filter,setFilter]=useState('all'),[region,setRegion]=useState('all'),[completedAt,setCompletedAt]=useState(''),[dark,setDark]=useState(()=>localStorage.theme==='dark');
useEffect(()=>{document.documentElement.dataset.theme=dark?'dark':'light';localStorage.theme=dark?'dark':'light'},[dark]);
useEffect(()=>{if(!queryId)return;const stream=new EventSource(`/api/dns/query/${queryId}/events`);stream.addEventListener('result',e=>{const item=JSON.parse((e as MessageEvent).data) as Result;item.records??=[];setResults(old=>[...old,item])});stream.addEventListener('complete',()=>{setRunning(false);setCompletedAt(new Date().toLocaleString('zh-CN',{hour12:false}));stream.close()});stream.onerror=()=>{setRunning(false);stream.close()};return()=>stream.close()},[queryId]);
const query=async()=>{setError('');setResults([]);setQueryId('');setCompletedAt('');setFilter('all');setRegion('all');setRunning(true);try{const res=await fetch('/api/dns/query',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({domain:input,type:kind})});const data=await res.json();if(!res.ok)throw new Error(data.error||'查询失败');setDomain(data.domain);setQueryId(data.queryId)}catch(e){setError(e instanceof Error?e.message:'查询失败');setRunning(false)}};
const signatures=results.filter(x=>x.status==='success').map(x=>x.records.map(r=>r.value).sort().join('|'));
const consistent=signatures.length>1&&new Set(signatures).size===1;
const filtered=results.filter(x=>(filter==='all'||x.status===filter)&&(region==='all'||x.node.region===region));
const copyAll=()=>navigator.clipboard.writeText(results.flatMap(x=>x.records.map(r=>`${x.node.name}\t${r.value}\tTTL ${r.ttl}`)).join('\n'));
const success=results.filter(x=>x.status==='success').length;
return <main>
<header><div className="brand"><span className="logo">D</span><div><strong>DNS Global</strong><small> DNS </small></div></div><button className="theme" onClick={()=>setDark(!dark)} aria-label="切换主题">{dark?'☀︎':'◐'}</button></header>
<section className="hero"><span className="eyebrow">LIVE DNS LOOKUP</span><h1><br/><em></em></h1><p> DNS </p>
<div className="search"><input value={input} onChange={e=>setInput(e.target.value)} onKeyDown={e=>e.key==='Enter'&&query()} placeholder="输入域名,如 example.com" aria-label="域名"/><select value={kind} onChange={e=>setKind(e.target.value)} aria-label="记录类型">{types.map(x=><option key={x}>{x}</option>)}</select><button onClick={query} disabled={running}>{running?'查询中…':'全球查询 →'}</button></div>{error&&<p className="error">{error}</p>}
<div className="quick"><span></span>{['google.com','cloudflare.com','github.com'].map(x=><button key={x} onClick={()=>setInput(x)}>{x}</button>)}</div>
</section>
{(queryId||running)&&<section className="results"><div className="resultHead"><div><span className="live"> {running?'实时查询中':'查询完成'}</span><h2>{domain} <b>{kind}</b></h2>{completedAt&&<small className="finished"> {completedAt}</small>}</div><div className="actions"><button onClick={query} disabled={running}></button><button onClick={copyAll}></button></div></div>
<div className="stats"><Stat label="查询节点" value={`${results.length}/4`}/><Stat label="成功" value={String(success)} tone="green"/><Stat label="异常" value={String(results.length-success)} tone="red"/><Stat label="结果一致" value={results.length<2?'':consistent?'':''} tone={consistent?'green':'amber'}/></div>
<div className="toolbar"><div>{[['all','全部'],['success','成功'],['no_record','无记录'],['failed','失败']].map(([v,l])=><button className={filter===v?'active':''} onClick={()=>setFilter(v)} key={v}>{l}</button>)}</div><select value={region} onChange={e=>setRegion(e.target.value)} aria-label="地区筛选"><option value="all"></option>{[...new Set(results.map(x=>x.node.region))].map(x=><option key={x}>{x}</option>)}</select></div>
<div className="table"><div className="tr th"><span> / </span><span></span><span>DNS </span><span>TTL</span><span></span></div>{filtered.map((r,i)=><div className="tr" key={`${r.node.name}-${i}`}><span data-label="区域 / 解析器"><strong>{r.node.name}</strong><small>{r.node.region} · {r.node.country}</small></span><span data-label="状态"><i className={`dot ${r.status}`}></i>{statusLabel[r.status]||r.status}</span><span data-label="DNS 结果" className="values">{r.records.length?<>{r.records.map(x=><code key={x.value}>{x.value}</code>)}<button className="copyOne" onClick={()=>navigator.clipboard.writeText(r.records.map(x=>x.value).join('\n'))}></button></>:<span className="muted">{r.error||'—'}</span>}</span><span data-label="TTL">{r.records[0]?.ttl??'—'}</span><span data-label="响应时间">{r.responseTime?`${r.responseTime} ms`:'—'}</span></div>)}{running&&Array.from({length:Math.max(0,4-results.length)},(_,i)=><div className="tr loading" key={i}><span><b className="skeleton wide"></b></span><span><b className="skeleton"></b></span><span><b className="skeleton wide"></b></span><span></span><span></span></div>)}</div>
<p className="notice"> DNS Anycast </p>
</section>}
{!queryId&&!running&&<section className="features"><article><b>01</b><h3></h3><p> DNS </p></article><article><b>02</b><h3></h3><p> SSE </p></article><article><b>03</b><h3></h3><p></p></article></section>}
<footer>DNS Global · Lightweight propagation checker</footer>
</main>
}
function Stat({label,value,tone=''}:{label:string,value:string,tone?:string}){return <div><small>{label}</small><strong className={tone}>{value}</strong></div>}
createRoot(document.getElementById('root')!).render(<React.StrictMode><App/></React.StrictMode>);

5
web/src/styles.css Normal file
View File

@ -0,0 +1,5 @@
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Space+Grotesk:wght@500;600;700&display=swap');
:root{font-family:'DM Sans',sans-serif;color:#15221c;background:#f5f7f3;line-height:1.5;--card:#fff;--line:#dce3dd;--muted:#65736b;--ink:#15221c;--green:#1d684c;--lime:#d8f56b}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 80% 10%,#eaf4d3 0,transparent 28%),#f5f7f3;color:var(--ink)}[data-theme=dark]{--card:#17221d;--line:#33423a;--muted:#91a097;--ink:#eef5ef;background:#101713;color-scheme:dark}button,input,select{font:inherit}button{cursor:pointer}main{max-width:1180px;margin:auto;padding:0 28px}header{height:88px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line)}.brand{display:flex;gap:12px;align-items:center}.brand div{display:flex;flex-direction:column}.brand strong{font:700 18px 'Space Grotesk'}.brand small{font-size:11px;color:var(--muted);letter-spacing:.06em}.logo{display:grid;place-items:center;width:38px;height:38px;background:var(--green);color:white;border-radius:11px;font:700 20px 'Space Grotesk'}.theme{border:1px solid var(--line);background:var(--card);color:var(--ink);border-radius:50%;width:40px;height:40px}.hero{text-align:center;padding:82px 0 64px}.eyebrow{font:600 11px 'Space Grotesk';letter-spacing:.22em;color:var(--green)}h1{font:600 clamp(48px,7vw,82px)/.98 'Space Grotesk';letter-spacing:-.055em;margin:18px 0 24px}h1 em{font-style:normal;color:var(--green)}.hero>p{color:var(--muted);font-size:17px}.search{max-width:850px;margin:38px auto 17px;background:var(--card);padding:8px;border:1px solid var(--line);border-radius:16px;box-shadow:0 18px 45px #173b2720;display:flex}.search input{flex:1;min-width:0;border:0;background:transparent;padding:0 18px;color:var(--ink);outline:0}.search select{border:0;border-left:1px solid var(--line);background:transparent;color:var(--ink);padding:0 20px}.search button{border:0;background:var(--green);color:white;border-radius:10px;padding:15px 25px;font-weight:600}.search button:disabled{opacity:.6}.quick{display:flex;gap:9px;align-items:center;justify-content:center;color:var(--muted);font-size:12px}.quick button,.toolbar button{border:1px solid var(--line);border-radius:20px;padding:6px 12px;background:var(--card);color:var(--muted)}.error{color:#b53f3f!important}.features{display:grid;grid-template-columns:repeat(3,1fr);border-top:1px solid var(--line);border-bottom:1px solid var(--line)}.features article{padding:40px 36px;border-right:1px solid var(--line)}.features article:last-child{border:0}.features b{color:var(--green);font:600 12px 'Space Grotesk'}.features h3{font:600 20px 'Space Grotesk';margin:20px 0 8px}.features p{font-size:14px;color:var(--muted)}.results{padding:40px 0 20px}.resultHead{display:flex;align-items:end;justify-content:space-between}.resultHead h2{font:600 32px 'Space Grotesk';margin:8px 0}.resultHead h2 b{font-size:13px;background:var(--lime);color:#26320d;border-radius:5px;padding:5px 8px;vertical-align:middle}.resultHead>button{border:1px solid var(--line);background:var(--card);color:var(--ink);padding:10px 16px;border-radius:9px}.live{font-size:11px;color:var(--green);letter-spacing:.08em}.stats{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin:25px 0}.stats>div{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:18px}.stats small{display:block;color:var(--muted)}.stats strong{font:600 28px 'Space Grotesk'}.green{color:#2e966d}.red{color:#c15454}.amber{color:#bd8b27}.toolbar{display:flex;gap:8px;margin-bottom:12px}.toolbar .active{background:var(--ink);color:var(--card)}.table{background:var(--card);border:1px solid var(--line);border-radius:14px;overflow:hidden}.tr{display:grid;grid-template-columns:1.25fr .7fr 2fr .5fr .7fr;gap:15px;padding:17px 20px;border-bottom:1px solid var(--line);align-items:center;font-size:13px}.tr:last-child{border:0}.th{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.06em}.tr>span:first-child{display:flex;flex-direction:column}.tr small{color:var(--muted)}.dot{display:inline-block;width:7px;height:7px;border-radius:50%;margin-right:7px;background:#d3a337}.dot.success{background:#2e966d}.dot.failed,.dot.timeout{background:#c15454}.values{display:flex;flex-direction:column;align-items:flex-start;overflow:hidden}.values code{max-width:100%;overflow:hidden;text-overflow:ellipsis;color:var(--ink)}.muted,.notice{color:var(--muted)}.notice{text-align:center;font-size:12px}.skeleton{display:block;width:75px;height:12px;border-radius:4px;background:linear-gradient(90deg,var(--line),#edf1ed,var(--line));background-size:200%;animation:pulse 1.3s infinite}.skeleton.wide{width:130px}@keyframes pulse{to{background-position:-200%}}footer{padding:40px;text-align:center;color:var(--muted);font-size:12px}
@media(max-width:720px){main{padding:0 16px}.hero{padding:55px 0 40px}.search{flex-wrap:wrap}.search input{height:50px;width:70%}.search select{width:30%}.search button{width:100%}.quick{flex-wrap:wrap}.features{grid-template-columns:1fr}.features article{border-right:0;border-bottom:1px solid var(--line)}.stats{grid-template-columns:1fr 1fr}.th{display:none}.tr{display:block;padding:17px}.tr>span{display:flex!important;justify-content:space-between;margin:7px 0}.tr>span:before{content:attr(data-label);color:var(--muted);font-size:11px}.values{align-items:flex-end}.resultHead h2{font-size:24px}}
.actions{display:flex;gap:8px}.actions button,.copyOne{border:1px solid var(--line);background:var(--card);color:var(--ink);padding:10px 16px;border-radius:9px}.actions button:disabled{opacity:.5}.finished{color:var(--muted)}.toolbar{justify-content:space-between}.toolbar>div{display:flex;gap:8px}.toolbar select{border:1px solid var(--line);border-radius:9px;background:var(--card);color:var(--ink);padding:6px 12px}.values{position:relative;padding-right:45px}.copyOne{position:absolute;right:0;top:50%;transform:translateY(-50%);padding:4px 7px;font-size:11px}
@media(max-width:720px){.toolbar{align-items:flex-start;flex-direction:column}.toolbar>div{flex-wrap:wrap}.resultHead{align-items:flex-start}.actions{flex-direction:column}}

1
web/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

File diff suppressed because one or more lines are too long

1
web/tsconfig.json Normal file
View File

@ -0,0 +1 @@
{"compilerOptions":{"target":"ES2022","useDefineForClassFields":true,"lib":["ES2022","DOM","DOM.Iterable"],"allowJs":false,"skipLibCheck":true,"esModuleInterop":true,"allowSyntheticDefaultImports":true,"strict":true,"forceConsistentCasingInFileNames":true,"module":"ESNext","moduleResolution":"Bundler","resolveJsonModule":true,"isolatedModules":true,"noEmit":true,"jsx":"react-jsx"},"include":["src"],"references":[]}