backend.ts 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144
  1. import { ConfigProvider, Effect, Layer } from "effect"
  2. import { HttpRouter } from "effect/unstable/http"
  3. import { parse } from "./assertions"
  4. import { runtime, type Runtime } from "./runtime"
  5. import type { ActiveScenario, BackendApp, CallResult, CaptureMode, SeededContext } from "./types"
  6. type CallOptions = {
  7. auth?: {
  8. password?: string
  9. username?: string
  10. }
  11. }
  12. export function call(scenario: ActiveScenario, ctx: SeededContext<unknown>, options: CallOptions = {}) {
  13. return Effect.promise(async () =>
  14. capture(await app(await runtime(), options).request(toRequest(scenario, ctx)), scenario.capture),
  15. )
  16. }
  17. export function callAuthProbe(scenario: ActiveScenario, credentials: "missing" | "valid" = "missing") {
  18. return Effect.promise(async () => {
  19. const controller = new AbortController()
  20. return Promise.race([
  21. Promise.resolve(
  22. app(await runtime(), { auth: { password: "secret" } }).request(
  23. toAuthProbeRequest(scenario, credentials, controller.signal),
  24. ),
  25. ).then((response) => capture(response, scenario.capture)),
  26. Bun.sleep(1_000).then(() => {
  27. controller.abort("auth probe timed out")
  28. return {
  29. status: 0,
  30. contentType: "",
  31. text: "auth probe timed out",
  32. body: undefined,
  33. timedOut: true,
  34. }
  35. }),
  36. ])
  37. })
  38. }
  39. type CachedApp = BackendApp & { readonly dispose: () => Promise<void> }
  40. const appCache: Partial<Record<string, CachedApp>> = {}
  41. export async function disposeApps() {
  42. const apps = Object.values(appCache)
  43. for (const key of Object.keys(appCache)) delete appCache[key]
  44. await Promise.all(apps.flatMap((app) => (app === undefined ? [] : [app.dispose()])))
  45. }
  46. function app(modules: Runtime, options: CallOptions) {
  47. const username = options.auth?.username
  48. const password = options.auth?.password
  49. const cacheKey = `${username ?? ""}:${password ?? ""}`
  50. if (appCache[cacheKey]) return appCache[cacheKey]
  51. const web = HttpRouter.toWebHandler(
  52. modules.HttpApiApp.routes.pipe(
  53. Layer.provide(
  54. ConfigProvider.layer(
  55. ConfigProvider.fromUnknown({ KIRINCODE_SERVER_PASSWORD: password, KIRINCODE_SERVER_USERNAME: username }),
  56. ),
  57. ),
  58. ),
  59. { disableLogger: true, memoMap: modules.memoMap },
  60. )
  61. return (appCache[cacheKey] = {
  62. dispose: web.dispose,
  63. request(input: string | URL | Request, init?: RequestInit) {
  64. return web.handler(
  65. input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init),
  66. modules.HttpApiApp.context,
  67. )
  68. },
  69. })
  70. }
  71. function toRequest(scenario: ActiveScenario, ctx: SeededContext<unknown>) {
  72. const spec = scenario.request(ctx, ctx.state)
  73. return new Request(new URL(spec.path, "http://localhost"), {
  74. method: scenario.method,
  75. headers: spec.body === undefined ? spec.headers : { "content-type": "application/json", ...spec.headers },
  76. body: spec.body === undefined ? undefined : JSON.stringify(spec.body),
  77. })
  78. }
  79. function toAuthProbeRequest(scenario: ActiveScenario, credentials: "missing" | "valid", signal: AbortSignal) {
  80. const spec = scenario.authProbe ?? {
  81. path: authProbePath(scenario.path),
  82. body: scenario.method === "GET" ? undefined : {},
  83. }
  84. const headers = {
  85. ...(spec.body === undefined ? {} : { "content-type": "application/json" }),
  86. ...spec.headers,
  87. ...(credentials === "valid" ? { authorization: basic("kirincode", "secret") } : {}),
  88. }
  89. return new Request(new URL(spec.path, "http://localhost"), {
  90. method: scenario.method,
  91. headers,
  92. body: spec.body === undefined ? undefined : JSON.stringify(spec.body),
  93. signal,
  94. })
  95. }
  96. function basic(username: string, password: string) {
  97. return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
  98. }
  99. function authProbePath(path: string) {
  100. return path
  101. .replace(/\{([^}]+)\}/g, (_match, key: string) => `auth_${key}`)
  102. .replace(/:([^/]+)/g, (_match, key: string) => `auth_${key}`)
  103. }
  104. async function capture(response: Response, mode: CaptureMode): Promise<CallResult> {
  105. const text = mode === "stream" ? await captureStream(response) : await response.text()
  106. return {
  107. status: response.status,
  108. contentType: response.headers.get("content-type") ?? "",
  109. text,
  110. body: parse(text),
  111. timedOut: false,
  112. }
  113. }
  114. async function captureStream(response: Response) {
  115. if (!response.body) return ""
  116. const reader = response.body.getReader()
  117. const read = reader.read().then(
  118. (result) => ({ result }),
  119. (error: unknown) => ({ error }),
  120. )
  121. const winner = await Promise.race([read, Bun.sleep(1_000).then(() => ({ timeout: true }))])
  122. if ("timeout" in winner) {
  123. await reader.cancel("timed out waiting for stream chunk").catch(() => undefined)
  124. throw new Error("timed out waiting for stream chunk")
  125. }
  126. if ("error" in winner) throw winner.error
  127. await reader.cancel().catch(() => undefined)
  128. if (winner.result.done) return ""
  129. return new TextDecoder().decode(winner.result.value)
  130. }