Example PRO

/**
 * HttpServer 端到端冒烟 demo
 *
 * 启动后会监听一个随机端口(`server.start({ port: 0 })`),并把多种 handler
 * 注册上去:
 *   - 同步 handler GET /sync (registerHandler,已 @deprecated)
 *   - 异步 handler GET /slow (服务端 sleep 200ms 再返回,registerAsyncHandler)
 *   - 路径变量 GET /user/:id (验证 request.params 的 key 没有 ":" 前缀)
 *   - queryParams GET /query?a=1&b=2
 *   - 静态文件 GET /readme (registerFile)
 *   - 目录服务 GET /static/:path (registerFilesFromDirectory)
 *   - 目录浏览 GET /browse/:path (registerDirectoryBrowser)
 *   - WebSocket /ws (echo)
 *   - async middleware:挂在所有路由前;请求带 X-Allow header 才放行,否则 401
 *   - 自定义 404 (setNotFoundHandler):把请求路径回显到 body
 *
 * UI 上有 "Run tests" 按钮:启动后在客户端 fetch 一遍每个 endpoint,
 * 并把每行 PASS/FAIL 输出到列表里。还有 "Stop server" 收尾。
 *
 * HTTPS sample:
 *   把下方 `enableHttps` 改成 true 并在 Script.directory 下放一个名为
 *   "server.p12" 的自签名证书(密码改成 P12_PASSWORD 常量),server 会以
 *   https 启动。`fetch` 自签名证书校验失败 — 验证手段是手动 curl -k 试。
 *   P12 也可以直接传 bytes:`tls.p12 = Keychain.getData("server.p12")`
 *   (Keychain 接口是同步的,直接返回 Data | null),接同一个 start API。
 */

import {
  Button, HStack, List, Navigation, NavigationStack, Script,
  Section, Spacer, Text, useState, VStack, Path,
  fetch,
} from "scripting"

const P12_PASSWORD = "swiftertest"
const enableHttps = false

type TestResult = {
  name: string
  pass: boolean
  detail: string
}

// Module-level singleton: 避免 useState lazy initializer 在每次 re-render
// 重跑,旧 HttpServer 被 GC 触发 deinit -> stop 的不可见路径
const server = new HttpServer()

function View() {
  const [port, setPort] = useState<number | null>(null)
  const [running, setRunning] = useState(false)
  const [results, setResults] = useState<TestResult[]>([])
  const [busy, setBusy] = useState(false)

  // 拼一个本地目录给 registerFile / registerFilesFromDirectory / registerDirectoryBrowser
  // 用 Script.directory 下临时建几个文件
  async function prepareFixtures(): Promise<string> {
    const dir = Path.join(Script.directory, "http_demo_root")
    await FileManager.createDirectory(dir, true)

    const indexHTML = Path.join(dir, "index.html")
    if (!FileManager.existsSync(indexHTML)) {
      await FileManager.writeAsString(indexHTML, "<h1>index ok</h1>")
    }
    const note = Path.join(dir, "note.txt")
    if (!FileManager.existsSync(note)) {
      await FileManager.writeAsString(note, "hello from note")
    }
    const subdir = Path.join(dir, "sub")
    await FileManager.createDirectory(subdir, true)
    const subFile = Path.join(subdir, "inside.txt")
    if (!FileManager.existsSync(subFile)) {
      await FileManager.writeAsString(subFile, "deep file")
    }
    return dir
  }

  async function startServer() {
    if (running) return
    setBusy(true)
    try {
      const fixtureDir = await prepareFixtures()

      server.registerHandler("/sync", _ => {
        return HttpResponse.ok(HttpResponseBody.text("sync ok"))
      })

      // 异步 handler:走 registerAsyncHandler,JS 端可以返回 Promise,
      // server 在 setAsync 路径里 await 之后再发响应
      server.registerAsyncHandler("/slow", async _ => {
        await new Promise(resolve => setTimeout(resolve, 200))
        return HttpResponse.ok(HttpResponseBody.text("slow ok"))
      })

      server.registerHandler("/user/:id", req => {
        const id = req.params["id"] ?? "<missing>"
        return HttpResponse.ok(HttpResponseBody.text(`user id = ${id}`))
      })

      server.registerHandler("/query", req => {
        const dump = req.queryParams.map(q => `${q.key}=${q.value}`).join("&")
        return HttpResponse.ok(HttpResponseBody.text(dump))
      })

      server.registerFile("/readme", Path.join(fixtureDir, "note.txt"))

      server.registerFilesFromDirectory("/static/:path", fixtureDir, {
        defaults: ["index.html"],
      })

      server.registerDirectoryBrowser("/browse/:path", fixtureDir)

      server.registerWebsocket("/ws", {
        handleText: (session, text) => {
          session.writeText(`echo:${text}`)
        },
      })

      // /protected 由 async middleware 守卫:必须带 x-auth header 才放行
      server.registerAsyncHandler("/protected", async _ => {
        return HttpResponse.ok(HttpResponseBody.text("welcome"))
      })

      // async middleware: 只对 /protected 生效,其它路由放行
      server.registerMiddleware(async req => {
        if (req.path === "/protected" && !req.headers["x-auth"]) {
          return HttpResponse.unauthorized(HttpResponseBody.text("missing x-auth"))
        }
        return null
      })

      // 自定义 async 404,把请求路径回显到 body
      server.setNotFoundHandler(async req => {
        return HttpResponse.notFound(HttpResponseBody.text(`no route: ${req.path}`))
      })

      const startOptions: any = { port: 0 }
      if (enableHttps) {
        startOptions.port = 0
        startOptions.tls = {
          p12: Path.join(Script.directory, "server.p12"),
          password: P12_PASSWORD,
        }
      }
      const err = server.start(startOptions)
      if (err) {
        console.error("server start failed:", err)
        Dialog.alert({ title: "Start failed", message: err })
        return
      }
      setPort(server.port)
      setRunning(true)
      console.log("server listening on port", server.port)
    } catch (e) {
      console.error(e)
      Dialog.alert({ title: "Start error", message: String(e) })
    } finally {
      setBusy(false)
    }
  }

  function stopServer() {
    server.stop()
    setRunning(false)
    setPort(null)
    setResults([])
  }

  async function runTests() {
    if (!running || port == null) return
    setBusy(true)
    const collected: TestResult[] = []
    const scheme = enableHttps ? "https" : "http"
    const base = `${scheme}://127.0.0.1:${port}`

    async function check(name: string, req: () => Promise<{ status: number; text: string }>, expect: (r: { status: number; text: string }) => string | null) {
      try {
        const r = await req()
        const failReason = expect(r)
        collected.push({
          name,
          pass: failReason == null,
          detail: failReason ?? `${r.status} ${r.text.slice(0, 80)}`,
        })
      } catch (e) {
        collected.push({ name, pass: false, detail: `threw: ${String(e)}` })
      }
    }

    async function fetchText(url: string, headers?: Record<string, string>): Promise<{ status: number; text: string }> {
      const resp = await fetch(url, headers ? { headers } : undefined)
      const text = await resp.text()
      return { status: resp.status, text }
    }

    await check("GET /sync", () => fetchText(`${base}/sync`), r =>
      r.status === 200 && r.text === "sync ok" ? null : `expected 200 "sync ok"`)

    await check("GET /slow (async, ~200ms)", () => fetchText(`${base}/slow`), r =>
      r.status === 200 && r.text === "slow ok" ? null : `expected 200 "slow ok"`)

    await check("GET /user/:id (params key without colon)", () => fetchText(`${base}/user/42`), r =>
      r.status === 200 && r.text === "user id = 42" ? null : `expected "user id = 42"`)

    await check("GET /query?a=1&b=2", () => fetchText(`${base}/query?a=1&b=2`), r =>
      r.status === 200 && r.text === "a=1&b=2" ? null : `expected "a=1&b=2"`)

    await check("GET /readme (registerFile)", () => fetchText(`${base}/readme`), r =>
      r.status === 200 && r.text === "hello from note" ? null : `expected note content`)

    await check("GET /static/ (default index.html)", () => fetchText(`${base}/static/`), r =>
      r.status === 200 && r.text.includes("index ok") ? null : `expected index.html content`)

    await check("GET /static/note.txt", () => fetchText(`${base}/static/note.txt`), r =>
      r.status === 200 && r.text === "hello from note" ? null : `expected note content`)

    // 关键回归:之前 fopen 在目录上不会失败,导致命中目录返回 200 + 空 body。
    // 修复后命中目录(/static/sub)应该是 404
    await check("GET /static/sub (directory hit -> 404)", () => fetchText(`${base}/static/sub`), r =>
      r.status === 404 ? null : `expected 404, got ${r.status} "${r.text}"`)

    await check("GET /browse/ (directoryBrowser root list)", () => fetchText(`${base}/browse/`), r =>
      r.status === 200 && r.text.toLowerCase().includes("note.txt") ? null : `expected listing with note.txt`)

    await check("GET /browse/note.txt (directoryBrowser file)", () => fetchText(`${base}/browse/note.txt`), r =>
      r.status === 200 && r.text === "hello from note" ? null : `expected note content`)

    // async middleware:无 x-auth header 时被 401 截胡
    await check("GET /protected without x-auth (middleware 401)", () => fetchText(`${base}/protected`), r =>
      r.status === 401 && r.text === "missing x-auth" ? null : `expected 401 "missing x-auth", got ${r.status} "${r.text}"`)

    // 带 x-auth header 时 middleware 放行,/protected handler 返回 welcome
    await check("GET /protected with x-auth (middleware pass-through)", () => fetchText(`${base}/protected`, { "x-auth": "token" }), r =>
      r.status === 200 && r.text === "welcome" ? null : `expected 200 "welcome", got ${r.status} "${r.text}"`)

    // 自定义 404: setNotFoundHandler 把请求路径回显到 body
    await check("GET /nope (setNotFoundHandler echoes path)", () => fetchText(`${base}/nope`), r =>
      r.status === 404 && r.text === "no route: /nope" ? null : `expected 404 "no route: /nope", got ${r.status} "${r.text}"`)

    // WebSocket echo:用 WebSocket 客户端连一下
    // 注:WebSocket.onmessage 直接收 string | Data,不是 event 对象
    await check("WebSocket /ws echo", async () => {
      const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`)
      const received = await new Promise<string>((resolve, reject) => {
        const t = setTimeout(() => reject(new Error("ws timeout")), 3000)
        ws.onopen = () => ws.send("ping")
        ws.onmessage = msg => {
          clearTimeout(t)
          const text = typeof msg === "string" ? msg : (msg.toRawString() ?? "")
          resolve(text)
        }
        ws.onerror = e => {
          clearTimeout(t)
          reject(e)
        }
      })
      ws.close()
      return { status: 200, text: received }
    }, r => r.text === "echo:ping" ? null : `expected "echo:ping", got "${r.text}"`)

    setResults(collected)
    setBusy(false)
    console.log("tests done", collected.filter(r => r.pass).length, "/", collected.length, "passed")
  }

  // 注:不能用 useEffect cleanup 来 stop server —— Scripting 的 hook
  // 在 setState 触发 re-render 时会重跑 cleanup,会把 server 关掉。
  // 改成 Close 按钮显式 stop + dismiss。
  const dismiss = Navigation.useDismiss()
  function closeAndStop() {
    server.stop()
    dismiss()
  }

  return <NavigationStack>
    <List
      navigationTitle="HttpServer demo"
      toolbar={{
        topBarTrailing: <Button title="Close" action={closeAndStop} />,
      }}
    >
      <Section title="Server">
        <HStack>
          <Text>State</Text>
          <Spacer />
          <Text>{server.state}{port != null ? ` :${port}` : ""}</Text>
        </HStack>
        {/* 注:多个 Button 在 List 的 Section 里要垂直直接排,不要塞在
            HStack 里 —— SwiftUI List 行里两个 Button 排一起,点击事件
            会扩散到整行,两个 Button 都被命中,Start/Stop 误触跑测试。 */}
        {running
          ? <Button title="Stop server" disabled={busy} action={stopServer} />
          : <Button title="Start server" disabled={busy} action={startServer} />}
        <Button title="Run tests" disabled={!running || busy} action={runTests} />
      </Section>
      {results.length > 0 ? <Section title={`Results (${results.filter(r => r.pass).length}/${results.length})`}>
        {results.map((r, i) =>
          <VStack key={String(i)} alignment="leading">
            <HStack>
              <Text foregroundStyle={r.pass ? "green" : "red"}>{r.pass ? "PASS" : "FAIL"}</Text>
              <Text>{r.name}</Text>
            </HStack>
            <Text font="caption" foregroundStyle="secondaryLabel">{r.detail}</Text>
          </VStack>
        )}
      </Section> : null}
    </List>
  </NavigationStack>
}

async function run() {
  await Navigation.present(<View />)
  Script.exit()
}

run()