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 |
|
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 }
const testpattern = new URLPattern({ protocol: "mock" })
export const $$fetch = Symbol("[[fetch]]")
export type FetchRules = Array<
URLPatternInit & {
redirect?: string
respond?: StaticMockedResponse
ignoreCase?: boolean
}
>
export type StaticMockedResponse = {
status?: number
headers?: Record<string, string>
body?: BodyInit
}
export type FetchOptions = {
logger?: string[]
paths?: Array<Optional<string>>
browser?: boolean
}
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}`)
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
if (interceptor.respond) {
respond = interceptor.respond
redirect = new URL(`mock://${url.href.replace(url.protocol, "")}`)
}
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 })
}
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 })
}
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 })
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 })
}
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) {
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}`)
}
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
}
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
}
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 !== "*"))) }
}
|