Action 与会话
服务端组件直接调用 Go。岛跑在浏览器里, 回到 Go 只能走 HTTP —— 但 HTTP 不用你写: 把 Go 方法列进 Actions, 在岛里 import 然后 await。路由、JSON 解码、同源与标头校验、错误映射和客户端桩由编译器生成, 返回类型来自 Go 签名。
1. 声明一个 action
只能是模块级方法; 第一个参数写 *gotsx.Req 就能拿到请求
// host/host.go — list the methods islands may call; hostgen types them as Promise<T>
func (d *DataModule) Like(id string) (int, error) { return d.Models.Like(id) }
func (d *DataModule) Rename(req *gotsx.Req, id, title string) error { … } // *gotsx.Req: session / cookies
var Registry = map[string]gotsx.HostModule{
"data": {Value: Data, Go: "host.Data", Actions: []string{"Like", "Rename"}},
}hostgen 把方法反射进 app/.gen/host.d.ts 变成 like(id: string): Promise<number> —— 参数名来自 Go 源码。参数必须是内建类型或 host 类型(返回值只要 hostgen 能反射即可)。
2. 在岛里调用
对 action 允许从 host:* 做值导入; 其它成员仍只能 import type
// app/islands/LikeButton.client.tsx — a typed action: no fetch, no untyped JSON
import { like } from "host:data"; // like(id: string): Promise<number>
export default function LikeButton({ id, likes }: { id: string; likes: number }) {
const [n, setN] = useState(likes);
return <button onClick={async () => setN(await like(id))}>{n}</button>;
}
// main.go: gotsx.Options{HostActions: gen.HostActions, …}
// errors: gotsx.Invalid(fields) → 422 (e.fields), gotsx.ErrNotFound → 404, else 500 — the call throws an Error with e.statusonClick={() => toggle(id)} 发出就不管; await 在 async 处理器里能拿到结果。3. 错误变成状态码
岛里的 catch 拿到 e.status、e.fields、e.message
// host/host.go — errors map to HTTP statuses; the island's catch sees e.status and e.fields
func (d *DataModule) Rename(req *gotsx.Req, id, title string) (Model, error) {
if req.Session().Get("user") == "" {
return Model{}, gotsx.Unauthorized("sign in first") // 401
}
if strings.TrimSpace(title) == "" {
return Model{}, gotsx.Invalid(map[string]string{"title": "required"}) // 422 + fields
}
m, err := d.Models.Rename(id, title) // %w gotsx.ErrNotFound → 404
if err == nil {
req.Session().Flash("ok", "Renamed") // shown by the next page as props.flash
}
return m, err
}// app/islands/RenameForm.client.tsx
import { rename } from "host:data";
const [errors, setErrors] = useState<Record<string, string>>({});
const submit = async () => {
try {
const m = await rename(id, title); // Promise<Model>
setErrors({});
emit("toast", m.title);
} catch (e) {
setErrors(e.status === 422 ? e.fields : { _: e.message });
}
};| Go | HTTP | 岛 |
|---|---|---|
gotsx.Invalid(fields) | 422 | e.fields |
gotsx.Fail(msg) | 400 | e.message |
gotsx.Unauthorized(msg) / Forbidden(msg) | 401 / 403 | e.status |
fmt.Errorf("%w", gotsx.ErrNotFound) | 404 | |
| 其它错误或 panic | 500 | 消息只在 dev 显示 |
4. 会话、flash 消息、CSRF
签名 cookie; 页面读, action 和 handler 写
页面拿到 props.session(只读键值)、props.flash(一次性消息, 渲染一次即消费)和 props.csrf(经典表单用的 token, 惰性生成, 不用它的页面不会种 cookie)。action 通过 req.Session().Set / Flash / Clear 写; Go handler 通过 gotsx.SessionOf(r) 和 sess.Save(w, r)。生产环境设置 SESSION_SECRET; 不设的话每次启动都用新的随机密钥。
// app/pages/todos.server.tsx — a classic form: no JavaScript involved
export default function Todos({ flash, csrf }: PageProps) {
return (
<>
{flash.map((f) => <div class={"alert alert-" + f.kind} role="status">{f.text}</div>)}
<form method="post" action="/todos">
<input type="hidden" name="_csrf" value={csrf} />
<input class="input" name="title" aria-label="New todo" />
<button class="btn btn-primary">Add</button>
</form>
</>
);
}// main.go — the handler behind the form (Options.Actions)
"POST /todos": func(w http.ResponseWriter, r *http.Request) {
if !gotsx.VerifyCSRF(r) {
http.Error(w, "invalid CSRF token", http.StatusForbidden)
return
}
sess := gotsx.SessionOf(r)
if _, err := host.Data.Todos.Add(r.FormValue("title")); err != nil {
sess.Flash("error", err.Error())
} else {
sess.Flash("ok", "Added")
}
sess.Save(w, r) // before writing the response
http.Redirect(w, r, "/todos", http.StatusSeeOther) // POST → redirect → GET
},5. 页面 meta
页面旁边 export function meta, 由布局渲染
// app/pages/p/[id].server.tsx
import type { PageProps, Meta } from "gotsx";
export function meta({ params }: PageProps): Meta {
const m = models.get(params.id); // same 404 semantics as the page
return { title: m.title, description: m.desc, image: m.image };
}
// app/pages/_layout.server.tsx
export default function Root({ meta, children }: LayoutProps) {
return (
<html><head>
<title>{meta.title ? meta.title + " · Shop" : "Shop"}</title>
{meta.description && <meta name="description" content={meta.description} />}
{meta.noIndex && <meta name="robots" content="noindex" />}
</head><body>{children}</body></html>
);
}字段: title、description、canonical、image、noIndex, 全部可选。meta 在同一请求里先于页面执行, 它调用的宿主方法要便宜。