All files / sandbox / fetch.ts

100.00% Branches 60/60
100.00% Functions 4/4
100.00% Lines 107/107
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
 
 
 
x4
x4
x4
x4
x4
x4
x4
x4
 
 
 
x4
 
 
x4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
x46
x46
x46
x46
x46
x46
x46
x56
x56
x56
 
x56
x58
x54
x58
x58
x58
x58
x41
x41
 
x58
x1
x1
x58
x58
x40
x40
x40
x40
x40
x40
x40
x40
x40
x41
x41
x41
 
x54
x19
x19
x19
x54
x43
x43
x35
x46
x46
 
 
 
 
 
 
 
 
 
 
 
 
x4
x19
x19
 
 
x19
x1
x1
x19
x19
x18
x18
x22
x18
x18
x18
x18
x18
 
x20
x20
x20
x2
x2
x2
 
x19
x20
x15
x15
x15
x15
x15
 
x4
x4
x4
x4
x4
x12
x12
x3
x3
x3
x3
x12
x4
x4
x20
x18
x19
x19
x19
 
 
x4
x60
x60









































































































































































// Copyright (c) - 2025+ the lowlighter/libs authors. AGPL-3.0-or-later
import type { Arg, Optional } from "@libs/typing/types"
import type { Handler } from "./testing/mock.ts"
import { pick } from "@std/collections/pick"
import { STATUS_CODE as Status, STATUS_TEXT as StatusText } from "@std/http/status"
import { basename } from "@std/path/basename"
import { join } from "@std/path/join"
import { normalize } from "@std/path/posix/normalize"
import { fromFileUrl } from "@std/path/from-file-url"
import { getLogger, type Logger } from "@logtape/logtape"
import { filetype, list } from "@libs/toolbox/filesystem"
export type { Arg, Logger, Optional }

/** URL pattern to intercept mock requests. */
const testpattern = new URLPattern({ protocol: "mock" })

/** Symbol to access the original fetch implementation. */
export const $$fetch = Symbol("[[fetch]]")

/** Interceptor rules. */
export type FetchRules = Array<
  URLPatternInit & {
    /** Redirection URL. */
    redirect?: string
    /** Static mocked response. */
    respond?: StaticMockedResponse
    /** Whether to ignore case when matching URL components. */
    ignoreCase?: boolean
  }
>

/** Static mocked response. */
export type StaticMockedResponse = {
  /** HTTP status code. */
  status?: number
  /** HTTP headers. */
  headers?: Record<string, string>
  /** Response body. */
  body?: BodyInit
}

/** Fetch options. */
export type FetchOptions = {
  /** Logger categories forwarded to {@link https://logtape.org | LogTape}'s `getLogger()`. */
  logger?: string[]
  /** Paths to search for mock files and directories. */
  paths?: Array<Optional<string>>
  /** If `true`, disables all non-mocked requests (useful for browser environments). */
  browser?: boolean
}

/** Provides a fetch function with support for interceptors and mocks. */
export function sandboxedFetch(rules: FetchRules, { logger: category = ["fetch"], paths: _paths = [], browser = false }: FetchOptions = {}): typeof globalThis.fetch {
  const $fetch = (globalThis.fetch as unknown as { [$$fetch]?: typeof globalThis.fetch })?.[$$fetch] ?? globalThis.fetch
  const log = getLogger(category)
  const paths = _paths.filter((path): path is string => Boolean(path))
    .filter((path) => ["file:", "http:", "https:"].includes(`${URL.parse(path)?.protocol}`))
    .map((path) => fromFileUrl(new URL(".test", path)))
  return Object.assign(async function fetch(input: RequestInfo | URL, init?: RequestInit) {
    let request = input instanceof Request ? input : new Request(input, init)
    let respond = undefined as Arg<typeof mock, 1>["respond"]
    log.info(`${request.method} ${request.url}`)
    // Apply interceptors
    if (rules.length) {
      const interceptors = rules.map(({ redirect, respond, ignoreCase = false, ...pattern }) => ({ pattern: new URLPattern(pattern, { ignoreCase }), redirect, respond }))
      for (const interceptor of interceptors) {
        const url = new URL(request.url)
        const captured = interceptor.pattern.exec(url.href)
        if (!captured)
          continue
        log.debug(`${request.method} ${request.url} matched {pattern}`, format(interceptor.pattern, request.url))
        let redirect = url
        // Use static mocked response
        if (interceptor.respond) {
          respond = interceptor.respond
          redirect = new URL(`mock://${url.href.replace(url.protocol, "")}`)
        } // Compute redirection
        else if (interceptor.redirect) {
          redirect = URL.canParse(interceptor.redirect) ? new URL(interceptor.redirect) : interceptor.redirect.startsWith("/") ? new URL(`${url.protocol}//${url.host}${interceptor.redirect}`) : new URL(`${url.protocol}//${interceptor.redirect}`)
          if ((redirect.protocol === `mock:`) && (!redirect.host))
            redirect = new URL(`${redirect.protocol}//${url.host}`)
          redirect.pathname = join(redirect.pathname, captured.pathname.groups._ ?? url.pathname)
          redirect.username ||= captured.username.groups._ ?? ""
          redirect.password ||= captured.password.groups._ ?? ""
          redirect.hash ||= captured.hash.groups._ ?? ""
          new URLSearchParams(captured.search.groups._).forEach((value, key) => redirect.searchParams.set(key, value))
        }
        request = new Request(redirect, request)
        log.info(`${request.method} ${request.url} redirected → {redirect}`, { redirect: redirect.href })
      }
      // Use mock server for mock scheme
      if (testpattern.test(request.url)) {
        log.trace(`${request.method} ${request.url} matched {pattern}`, format(testpattern, request.url))
        return await mock(request, { log, paths, respond })
      }
    }
    if (browser)
      return null as unknown as Response
    return $fetch(request, init)
  }, { [$$fetch]: $fetch })
}

/**
 * Returns a mock response for intercepted requests.
 *
 * Mock handlers (whose filename match `/.${request.method}.ts`) are expected to export a default function that takes
 * a {@linkcode https://developer.mozilla.org/en-US/docs/Web/API/Request | Request} object and returns
 * a {@linkcode https://developer.mozilla.org/en-US/docs/Web/API/Response | Response} object.
 *
 * Mock candidates are evaluated sequentially, and the first *2XX response* is returned.
 *
 * A *502 Bad Gateway* response is returned when there are no more mock candidates to evaluate.
 */
async function mock(request: Request, options: Arg<Handler, 2> & { respond?: StaticMockedResponse }) {
  const log = options.log.getChild("mocks")
  let response = new Response(StatusText[Status.BadGateway], { status: Status.BadGateway })

  // Return static mocked response if configured
  if (options.respond) {
    log.trace(`${request.method} ${request.url} using: static response`)
    response = new Response(options.respond.body, { status: options.respond.status, headers: options.respond.headers })
  } // Find all mock candidates
  else {
    response = new Response(StatusText[Status.BadGateway], { status: Status.BadGateway })
    const url = new URL(request.url)
    const candidates = [...await Promise.all(options.paths.map(async (root) => await list("*", { root, relative: false, directories: true })))]
      .flat()
      .filter((mock) => basename(mock) === url.host && filetype(mock) === "directory")
    if (!candidates.length)
      log.warn(`${request.method} ${request.url} no mocks found: ${options.paths}`)
    for (const candidate of candidates) {
      // Check if a better response can be obtained
      if (response.status >= Status.OK && response.status <= Status.OK + 99)
        break
      if (response.status !== Status.BadGateway) {
        log.trace(`${request.method} ${request.url} current status: http ${response.status}`)
        log.trace(`${request.method} ${request.url} trying next candidate: ${candidate}`)
      }
      // Try executing mock file
      const module = join(candidate, `${url.pathname}/.${request.method.toLowerCase()}.ts`)
      if (filetype(module) === "file") {
        log.trace(`${request.method} ${request.url} using: handler ${module}`)
        const { default: mock } = await import(module)
        response = await mock(request, options)
        continue
      }
      // Try serving static directory
      if (filetype(candidate) === "directory") {
        log.trace(`${request.method} ${request.url} using: directory ${candidate}`)
        const path = join(candidate, normalize(decodeURIComponent(url.pathname)))
        response = new Response(StatusText[Status.NotFound], { status: Status.NotFound })
        for (const filepath of [path, `${path}.html`, join(path, "index.html")]) {
          log.trace(`${request.method} ${request.url} trying next file: ${filepath}`)
          if (filetype(filepath) === "file") {
            log.trace(`${request.method} ${request.url} using: file ${filepath}`)
            response = new Response(await Deno.readFile(filepath), { status: Status.OK })
            break
          }
        }
        continue
      }
    }
  }
  log.debug(`${request.method} ${request.url} sent: http ${response.status}`)
  return response
}

/** Formats a `URLPattern` into a simple object for logging. */
function format(pattern: URLPattern, url: string) {
  return { url, pattern: Object.fromEntries(Object.entries(pick(pattern, ["protocol", "username", "password", "hostname", "port", "pathname", "search", "hash"])).filter(([key, value]) => (key === "hostname") || (value !== "*"))) }
}