Action 与会话

服务端组件直接调用 Go。岛跑在浏览器里, 回到 Go 只能走 HTTP —— 但 HTTP 不用你写: 把 Go 方法列进 Actions, 在岛里 import 然后 await。路由、JSON 解码、同源与标头校验、错误映射和客户端桩由编译器生成, 返回类型来自 Go 签名。

1. 声明一个 action

只能是模块级方法; 第一个参数写 *gotsx.Req 就能拿到请求

host/host.go
// 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
// 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.status
渲染是同步的
在组件体里调用 action 是编译错误。放进事件处理器或 effect 里: onClick={() => toggle(id)} 发出就不管; await 在 async 处理器里能拿到结果。

3. 错误变成状态码

岛里的 catch 拿到 e.status、e.fields、e.message

host/host.go
// 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
// 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 });
  }
};
GoHTTP
gotsx.Invalid(fields)422e.fields
gotsx.Fail(msg)400e.message
gotsx.Unauthorized(msg) / Forbidden(msg)401 / 403e.status
fmt.Errorf("%w", gotsx.ErrNotFound)404
其它错误或 panic500消息只在 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
// 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
// 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
// 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 在同一请求里先于页面执行, 它调用的宿主方法要便宜。