acrylblends 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +17 -16
- package/dist/index.js +39 -19
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/plugins/acryl-inspect/LICENSE +21 -0
- package/plugins/acryl-inspect/plugin.json +11 -0
- package/plugins/acryl-inspect/src/index.ts +3 -0
- package/plugins/acryl-inspect/src/report.ts +123 -0
- package/plugins/acryl-inspect/src/service.ts +66 -0
- package/plugins/acryl-inspect/tests/report.spec.ts +64 -0
- package/plugins/acryl-inspect/tests/service.spec.ts +120 -0
- package/plugins/acryl-inspect/upstream.json +18 -0
- package/plugins/acryl-node-server/LICENSE +21 -0
- package/plugins/acryl-node-server/plugin.json +12 -0
- package/plugins/acryl-node-server/src/index.ts +94 -0
- package/plugins/acryl-node-server/tests/server.spec.ts +105 -0
- package/plugins/acryl-node-server/upstream.json +18 -0
- package/plugins/acryl-router/LICENSE +21 -0
- package/plugins/acryl-router/plugin.json +12 -0
- package/plugins/acryl-router/src/compose.ts +21 -0
- package/plugins/acryl-router/src/context.ts +59 -0
- package/plugins/acryl-router/src/helpers/index.ts +3 -0
- package/plugins/acryl-router/src/helpers/read-body.ts +59 -0
- package/plugins/acryl-router/src/helpers/sse.ts +83 -0
- package/plugins/acryl-router/src/helpers/validate.ts +44 -0
- package/plugins/acryl-router/src/http-error.ts +21 -0
- package/plugins/acryl-router/src/index.ts +6 -0
- package/plugins/acryl-router/src/middleware/body-limit.ts +25 -0
- package/plugins/acryl-router/src/middleware/cors.ts +46 -0
- package/plugins/acryl-router/src/middleware/index.ts +6 -0
- package/plugins/acryl-router/src/middleware/logger.ts +21 -0
- package/plugins/acryl-router/src/middleware/loopback-only.ts +82 -0
- package/plugins/acryl-router/src/middleware/request-id.ts +24 -0
- package/plugins/acryl-router/src/middleware/secure-headers.ts +23 -0
- package/plugins/acryl-router/src/pattern.ts +89 -0
- package/plugins/acryl-router/src/registrar.ts +90 -0
- package/plugins/acryl-router/src/service.ts +164 -0
- package/plugins/acryl-router/src/table.ts +156 -0
- package/plugins/acryl-router/src/types.ts +73 -0
- package/plugins/acryl-router/src/util.ts +23 -0
- package/plugins/acryl-router/tests/api.spec.ts +223 -0
- package/plugins/acryl-router/tests/conformance.ts +170 -0
- package/plugins/acryl-router/tests/helpers.spec.ts +172 -0
- package/plugins/acryl-router/tests/live-install.spec.ts +64 -0
- package/plugins/acryl-router/tests/loader.spec.ts +71 -0
- package/plugins/acryl-router/tests/middleware.spec.ts +183 -0
- package/plugins/acryl-router/tests/service.spec.ts +211 -0
- package/plugins/acryl-router/tests/table.spec.ts +47 -0
- package/plugins/acryl-router/upstream.json +20 -0
- package/plugins/acryl-router-http/LICENSE +21 -0
- package/plugins/acryl-router-http/plugin.json +11 -0
- package/plugins/acryl-router-http/src/bridge.ts +52 -0
- package/plugins/acryl-router-http/src/index.ts +63 -0
- package/plugins/acryl-router-http/src/serve.ts +24 -0
- package/plugins/acryl-router-http/tests/adapter.spec.ts +170 -0
- package/plugins/acryl-router-http/tests/stream.spec.ts +133 -0
- package/plugins/acryl-router-http/tests/surfaces.spec.ts +99 -0
- package/plugins/acryl-router-http/upstream.json +22 -0
- package/plugins/acryl-state/LICENSE +21 -0
- package/plugins/acryl-state/plugin.json +12 -0
- package/plugins/acryl-state/src/cell.ts +67 -0
- package/plugins/acryl-state/src/index.ts +3 -0
- package/plugins/acryl-state/src/react.ts +11 -0
- package/plugins/acryl-state/src/service.ts +101 -0
- package/plugins/acryl-state/tests/cell.spec.ts +71 -0
- package/plugins/acryl-state/tests/react.spec.ts +39 -0
- package/plugins/acryl-state/tests/service.spec.ts +132 -0
- package/plugins/acryl-state/upstream.json +20 -0
- package/plugins/builder/script.ts +8 -9
- package/plugins/extensions/index.ts +18 -1
- package/plugins/openai/prompt.ts +9 -5
- package/plugins/tasks/index.ts +14 -0
- package/plugins/tsconfig.json +9 -1
- package/templates/agent/blend.yaml +5 -7
- package/templates/agent/src/app.ts +8 -8
- package/templates/agent/src/routes.ts +32 -41
- package/templates/agent/template.json +3 -3
- package/templates/agent/test/builder.test.ts +22 -14
- package/templates/agent/test/model.test.ts +2 -3
- package/templates/{app → blank}/blend.yaml +3 -3
- package/templates/{app → blank}/src/app.ts +5 -5
- package/templates/blank/src/routes.ts +14 -0
- package/templates/blank/template.json +1 -0
- package/templates/{app → blank}/test/app.test.ts +1 -1
- package/templates/todo/README.md +27 -0
- package/templates/todo/blend.yaml +25 -0
- package/templates/todo/gitignore +2 -0
- package/templates/todo/package.json +23 -0
- package/templates/todo/src/app.ts +71 -0
- package/templates/todo/src/main.ts +19 -0
- package/templates/todo/src/page.ts +28 -0
- package/templates/todo/src/todo.ts +96 -0
- package/templates/todo/template.json +1 -0
- package/templates/todo/test/todo.test.ts +145 -0
- package/templates/todo/tsconfig.json +19 -0
- package/templates/todo/upstream.json +12 -0
- package/plugins/events/index.ts +0 -42
- package/plugins/events/plugin.json +0 -9
- package/plugins/http/index.ts +0 -64
- package/plugins/http/plugin.json +0 -10
- package/plugins/server/index.ts +0 -138
- package/plugins/server/plugin.json +0 -10
- package/templates/app/src/routes.ts +0 -14
- package/templates/app/template.json +0 -1
- /package/templates/{app → blank}/README.md +0 -0
- /package/templates/{app → blank}/gitignore +0 -0
- /package/templates/{app → blank}/package.json +0 -0
- /package/templates/{app → blank}/src/main.ts +0 -0
- /package/templates/{app → blank}/tsconfig.json +0 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ACRYL router service (`ctx.router`): plugin-owned routes and middleware on Web-standard Request and Response.
|
|
3
|
+
*
|
|
4
|
+
* A route is registered from the owning plugin's context, so it disappears when that plugin unloads. `fetch(request)`
|
|
5
|
+
* runs a request in process: Web serves it behind HTTP (see spec 046, milestone M2), Desktop and the terminal UI call it
|
|
6
|
+
* directly, so one handler serves every surface without opening a socket. Disposal aborts in-flight requests through their
|
|
7
|
+
* `signal` and waits for them to settle.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { Context, Service } from '@deepseek-ai/cordis'
|
|
11
|
+
import z from '@deepseek-ai/schemastery'
|
|
12
|
+
import { createRegistrar, type Primitives, type Registrar, type RouteHandlers, type UseArguments } from './registrar.ts'
|
|
13
|
+
import { RouteTable } from './table.ts'
|
|
14
|
+
import type { ConnectionInfo, ErrorHandler, Method, Middleware, NotFoundHandler, RouteMethod, TrailingSlash } from './types.ts'
|
|
15
|
+
|
|
16
|
+
const DEFAULT_DISPOSE_GRACE_MS = 2000
|
|
17
|
+
|
|
18
|
+
/** Validated plugin configuration. */
|
|
19
|
+
export interface Config {
|
|
20
|
+
trailingSlash: TrailingSlash
|
|
21
|
+
disposeGraceMs: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const Config: z<Config> = z.object({
|
|
25
|
+
trailingSlash: z.union(['strict', 'ignore']).default('strict').description('Whether `/a` and `/a/` are different paths.'),
|
|
26
|
+
disposeGraceMs: z.number().min(0).default(DEFAULT_DISPOSE_GRACE_MS).description('How long disposal waits for aborted requests (and their waitUntil tasks) to finish before it gives up on them.'),
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The Web adapter's service (`ctx.routerHttp`, provided by `acryl-router-http`). Declared here, beside `router`, so a plugin can ask for its routes to be
|
|
31
|
+
* served over HTTP without depending on the adapter package.
|
|
32
|
+
*/
|
|
33
|
+
export interface RouterHttpService {
|
|
34
|
+
/** Serve `prefix` and everything below it through the router; returns a remover. The mount also goes away when the calling plugin unloads. */
|
|
35
|
+
mount(prefix: string): () => void
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
declare module '@deepseek-ai/cordis' {
|
|
39
|
+
interface Context {
|
|
40
|
+
router: Router
|
|
41
|
+
routerHttp: RouterHttpService
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Stable Cordis plugin name, equal to the package name and the Loader row id. */
|
|
46
|
+
export const name = 'acryl-router'
|
|
47
|
+
|
|
48
|
+
interface Flight {
|
|
49
|
+
readonly controller: AbortController
|
|
50
|
+
/** Settles when the handler is done and every `waitUntil` task it registered has settled. */
|
|
51
|
+
readonly settled: Promise<unknown>
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class Router extends Service {
|
|
55
|
+
private readonly table: RouteTable
|
|
56
|
+
private readonly flights = new Set<Flight>()
|
|
57
|
+
private closing = false
|
|
58
|
+
private readonly graceMs: number
|
|
59
|
+
|
|
60
|
+
constructor(ctx: Context, config: Partial<Config> = {}) {
|
|
61
|
+
super(ctx, 'router')
|
|
62
|
+
// The schema supplies these defaults when the Loader mounts the package; a plugin object mounted by hand skips the schema.
|
|
63
|
+
const { trailingSlash = 'strict', disposeGraceMs = DEFAULT_DISPOSE_GRACE_MS } = config
|
|
64
|
+
this.graceMs = disposeGraceMs
|
|
65
|
+
this.table = new RouteTable({
|
|
66
|
+
trailingSlash,
|
|
67
|
+
onError: error => ctx.logger.error(`acryl-router: handler failed: ${error instanceof Error ? error.message : String(error)}`),
|
|
68
|
+
})
|
|
69
|
+
ctx.effect(() => async () => {
|
|
70
|
+
this.closing = true
|
|
71
|
+
const open = [...this.flights]
|
|
72
|
+
for (const flight of open) flight.controller.abort()
|
|
73
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
74
|
+
const gaveUp = new Promise<'gave-up'>(resolve => { timer = setTimeout(() => { resolve('gave-up') }, this.graceMs) })
|
|
75
|
+
const outcome = await Promise.race([Promise.allSettled(open.map(flight => flight.settled)).then(() => 'settled' as const), gaveUp])
|
|
76
|
+
clearTimeout(timer)
|
|
77
|
+
if (outcome === 'gave-up') ctx.logger.warn(`acryl-router: ${String(this.flights.size)} request(s) did not finish ${String(this.graceMs)} ms after abort; disposal continued without them`)
|
|
78
|
+
}, 'acryl-router: abort and settle in-flight requests')
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Routes plus middleware currently registered; a leak check for reload tests. */
|
|
82
|
+
get size(): number {
|
|
83
|
+
return this.table.size
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private readonly primitives: Primitives = {
|
|
87
|
+
add: (owner, method, path, handler) => {
|
|
88
|
+
const remove = this.table.add(method, path, handler)
|
|
89
|
+
owner.effect(() => () => { remove() }, `acryl-router: ${method} ${path}`)
|
|
90
|
+
return remove
|
|
91
|
+
},
|
|
92
|
+
use: (owner, path, middleware) => {
|
|
93
|
+
const remove = this.table.use(path, middleware)
|
|
94
|
+
owner.effect(() => () => { remove() }, `acryl-router: use ${path}`)
|
|
95
|
+
return remove
|
|
96
|
+
},
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The scope the calling plugin registers into; `this.ctx` is the caller's context for the duration of a call made through `ctx.router`. */
|
|
100
|
+
private scope(): Registrar {
|
|
101
|
+
return createRegistrar(this.ctx, '', this.primitives)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
route(method: RouteMethod, path: string, ...handlers: RouteHandlers): () => void { return this.scope().route(method, path, ...handlers) }
|
|
105
|
+
get(path: string, ...handlers: RouteHandlers): () => void { return this.scope().get(path, ...handlers) }
|
|
106
|
+
post(path: string, ...handlers: RouteHandlers): () => void { return this.scope().post(path, ...handlers) }
|
|
107
|
+
put(path: string, ...handlers: RouteHandlers): () => void { return this.scope().put(path, ...handlers) }
|
|
108
|
+
patch(path: string, ...handlers: RouteHandlers): () => void { return this.scope().patch(path, ...handlers) }
|
|
109
|
+
delete(path: string, ...handlers: RouteHandlers): () => void { return this.scope().delete(path, ...handlers) }
|
|
110
|
+
all(path: string, ...handlers: RouteHandlers): () => void { return this.scope().all(path, ...handlers) }
|
|
111
|
+
on(methods: Method | readonly Method[], path: string, ...handlers: RouteHandlers): () => void { return this.scope().on(methods, path, ...handlers) }
|
|
112
|
+
use(...args: UseArguments): () => void { return this.scope().use(...args) }
|
|
113
|
+
group(prefix: string, ...middleware: Middleware[]): Registrar { return this.scope().group(prefix, ...middleware) }
|
|
114
|
+
|
|
115
|
+
/** Turn errors into responses. One handler per router; the calling plugin owns it. */
|
|
116
|
+
onError(handler: ErrorHandler): () => void {
|
|
117
|
+
const remove = this.table.setErrorHandler(handler)
|
|
118
|
+
this.ctx.effect(() => () => { remove() }, 'acryl-router: onError')
|
|
119
|
+
return remove
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Answer requests no route claimed. One handler per router; the calling plugin owns it. */
|
|
123
|
+
notFound(handler: NotFoundHandler): () => void {
|
|
124
|
+
const remove = this.table.setNotFoundHandler(handler)
|
|
125
|
+
this.ctx.effect(() => () => { remove() }, 'acryl-router: notFound')
|
|
126
|
+
return remove
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Call the router the way a test calls an app: a path or URL string, a `URL` or a `Request`, with the usual init.
|
|
131
|
+
* A relative path is resolved against `http://localhost`.
|
|
132
|
+
*/
|
|
133
|
+
request(input: string | URL | Request, init?: RequestInit, connection?: ConnectionInfo): Promise<Response> {
|
|
134
|
+
const request = input instanceof Request ? input : new Request(new URL(String(input), 'http://localhost'), init)
|
|
135
|
+
return this.fetch(request, connection)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Run a request in process. Never throws: failures become a 500, a disposed router answers 503. `connection` describes
|
|
140
|
+
* the transport (an HTTP adapter passes the socket's peer address); an in-process caller omits it.
|
|
141
|
+
*/
|
|
142
|
+
fetch(request: Request, connection: ConnectionInfo = {}): Promise<Response> {
|
|
143
|
+
if (this.closing) return Promise.resolve(new Response('Service Unavailable', { status: 503 }))
|
|
144
|
+
const controller = new AbortController()
|
|
145
|
+
const forward = (): void => controller.abort()
|
|
146
|
+
if (request.signal.aborted) controller.abort()
|
|
147
|
+
else request.signal.addEventListener('abort', forward, { once: true })
|
|
148
|
+
const tasks: Promise<unknown>[] = []
|
|
149
|
+
// The caller gets the Response the moment the handler returns it; the flight lasts until every waitUntil task settles.
|
|
150
|
+
const response = this.table.dispatch(request, controller.signal, connection, task => { tasks.push(task) })
|
|
151
|
+
const settled = response.then(() => Promise.allSettled(tasks))
|
|
152
|
+
const flight: Flight = { controller, settled }
|
|
153
|
+
this.flights.add(flight)
|
|
154
|
+
void settled.finally(() => {
|
|
155
|
+
request.signal.removeEventListener('abort', forward)
|
|
156
|
+
this.flights.delete(flight)
|
|
157
|
+
})
|
|
158
|
+
return response
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function apply(ctx: Context, config: Partial<Config> = {}): void {
|
|
163
|
+
new Router(ctx, config)
|
|
164
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { compose } from './compose.ts'
|
|
2
|
+
import { createContext } from './context.ts'
|
|
3
|
+
import { compareSpecificity, compilePattern, matchPattern, shapeOf, splitPath, type Pattern } from './pattern.ts'
|
|
4
|
+
import { HttpError } from './http-error.ts'
|
|
5
|
+
import type { ConnectionInfo, ErrorHandler, Handler, Middleware, NotFoundHandler, RouteContext, RouteMethod, TrailingSlash } from './types.ts'
|
|
6
|
+
|
|
7
|
+
interface RouteEntry {
|
|
8
|
+
readonly method: RouteMethod
|
|
9
|
+
readonly pattern: Pattern
|
|
10
|
+
readonly handler: Handler
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface MiddlewareEntry {
|
|
14
|
+
readonly pattern: Pattern
|
|
15
|
+
readonly middleware: Middleware
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface RouteTableOptions {
|
|
19
|
+
readonly trailingSlash: TrailingSlash
|
|
20
|
+
/** Called with anything a handler or middleware throws; the caller still receives a 500. */
|
|
21
|
+
readonly onError?: (error: unknown) => void
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The routing core: pure data and matching, no Cordis. Registrations return an idempotent remover, and the table keeps
|
|
26
|
+
* insertion order so middleware runs in registration order. Matching picks the most specific route (static before
|
|
27
|
+
* parameter before wildcard) among those that answer the request method.
|
|
28
|
+
*/
|
|
29
|
+
export class RouteTable {
|
|
30
|
+
private readonly routes = new Map<string, RouteEntry>()
|
|
31
|
+
private readonly middleware = new Map<symbol, MiddlewareEntry>()
|
|
32
|
+
private errorHandler: ErrorHandler | undefined
|
|
33
|
+
private notFoundHandler: NotFoundHandler | undefined
|
|
34
|
+
|
|
35
|
+
private readonly options: RouteTableOptions
|
|
36
|
+
|
|
37
|
+
constructor(options: RouteTableOptions) {
|
|
38
|
+
this.options = options
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Routes plus middleware currently registered. A reload that leaks registrations makes this grow. */
|
|
42
|
+
get size(): number {
|
|
43
|
+
return this.routes.size + this.middleware.size
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
add(method: RouteMethod, path: string, handler: Handler): () => void {
|
|
47
|
+
const pattern = compilePattern(path, this.options.trailingSlash)
|
|
48
|
+
const key = `${method} ${shapeOf(pattern)}`
|
|
49
|
+
if (this.routes.has(key)) throw new Error(`router: duplicate route ${method} ${path}`)
|
|
50
|
+
const entry: RouteEntry = { method, pattern, handler }
|
|
51
|
+
this.routes.set(key, entry)
|
|
52
|
+
return () => {
|
|
53
|
+
if (this.routes.get(key) === entry) this.routes.delete(key)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Register middleware for `path` and everything below it (`/` means every request). */
|
|
58
|
+
use(path: string, middleware: Middleware): () => void {
|
|
59
|
+
const compiled = compilePattern(path, this.options.trailingSlash)
|
|
60
|
+
const last = compiled.segments.at(-1)
|
|
61
|
+
const pattern: Pattern = last?.kind === 'wildcard'
|
|
62
|
+
? compiled
|
|
63
|
+
: { source: compiled.source, segments: [...compiled.segments, { kind: 'wildcard' }] }
|
|
64
|
+
const id = Symbol(path)
|
|
65
|
+
this.middleware.set(id, { pattern, middleware })
|
|
66
|
+
return () => { this.middleware.delete(id) }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Register the one handler that turns errors into responses. A second registration throws: the app has one voice for its errors. */
|
|
70
|
+
setErrorHandler(handler: ErrorHandler): () => void {
|
|
71
|
+
if (this.errorHandler !== undefined) throw new Error('router: an error handler is already registered')
|
|
72
|
+
this.errorHandler = handler
|
|
73
|
+
return () => { if (this.errorHandler === handler) this.errorHandler = undefined }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Register the one handler that answers requests no route claimed. A second registration throws. */
|
|
77
|
+
setNotFoundHandler(handler: NotFoundHandler): () => void {
|
|
78
|
+
if (this.notFoundHandler !== undefined) throw new Error('router: a not-found handler is already registered')
|
|
79
|
+
this.notFoundHandler = handler
|
|
80
|
+
return () => { if (this.notFoundHandler === handler) this.notFoundHandler = undefined }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private readonly notFoundResponse = (c: RouteContext): Response | Promise<Response> =>
|
|
84
|
+
this.notFoundHandler === undefined ? new Response('Not Found', { status: 404 }) : this.notFoundHandler(c)
|
|
85
|
+
|
|
86
|
+
private async errorResponse(error: unknown, context: RouteContext): Promise<Response> {
|
|
87
|
+
if (this.errorHandler !== undefined) {
|
|
88
|
+
try {
|
|
89
|
+
return await this.errorHandler(error, context)
|
|
90
|
+
} catch (inner) {
|
|
91
|
+
this.options.onError?.(inner)
|
|
92
|
+
return new Response('Internal Server Error', { status: 500 })
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (error instanceof HttpError) return error.getResponse()
|
|
96
|
+
this.options.onError?.(error)
|
|
97
|
+
return new Response('Internal Server Error', { status: 500 })
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private hasMethod(method: RouteMethod, parts: readonly string[]): boolean {
|
|
101
|
+
for (const entry of this.routes.values()) {
|
|
102
|
+
if (entry.method === method && matchPattern(entry.pattern, parts) !== undefined) return true
|
|
103
|
+
}
|
|
104
|
+
return false
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async dispatch(
|
|
108
|
+
req: Request,
|
|
109
|
+
signal: AbortSignal,
|
|
110
|
+
connection: ConnectionInfo = {},
|
|
111
|
+
track: (task: Promise<unknown>) => void = () => undefined,
|
|
112
|
+
): Promise<Response> {
|
|
113
|
+
const parts = splitPath(new URL(req.url).pathname, this.options.trailingSlash)
|
|
114
|
+
const method = req.method.toUpperCase()
|
|
115
|
+
|
|
116
|
+
let best: { entry: RouteEntry, params: Record<string, string> } | undefined
|
|
117
|
+
const allowed = new Set<string>()
|
|
118
|
+
// HEAD is answered by the GET route when no route claims HEAD itself, and the body is dropped below.
|
|
119
|
+
const headFromGet = method === 'HEAD' && !this.hasMethod('HEAD', parts)
|
|
120
|
+
for (const entry of this.routes.values()) {
|
|
121
|
+
const params = matchPattern(entry.pattern, parts)
|
|
122
|
+
if (params === undefined) continue
|
|
123
|
+
allowed.add(entry.method)
|
|
124
|
+
if (entry.method === 'GET') allowed.add('HEAD')
|
|
125
|
+
const answers = entry.method === '*' || entry.method === method || (headFromGet && entry.method === 'GET')
|
|
126
|
+
if (!answers) continue
|
|
127
|
+
if (best === undefined || isBetter(entry, best.entry)) best = { entry, params }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const chain: Middleware[] = []
|
|
131
|
+
for (const entry of this.middleware.values()) {
|
|
132
|
+
if (matchPattern(entry.pattern, parts) !== undefined) chain.push(entry.middleware)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const final: Handler = best !== undefined
|
|
136
|
+
? best.entry.handler
|
|
137
|
+
: allowed.size > 0 && !allowed.has('*')
|
|
138
|
+
? () => new Response('Method Not Allowed', { status: 405, headers: { allow: [...allowed].sort().join(', ') } })
|
|
139
|
+
: this.notFoundResponse
|
|
140
|
+
|
|
141
|
+
const context = createContext(req, best?.params ?? {}, signal, connection, track, this.notFoundResponse)
|
|
142
|
+
try {
|
|
143
|
+
const response = await compose(chain, final)(context)
|
|
144
|
+
return method === 'HEAD' ? new Response(null, { status: response.status, statusText: response.statusText, headers: response.headers }) : response
|
|
145
|
+
} catch (error) {
|
|
146
|
+
if (signal.aborted) return new Response('Request aborted', { status: 499 })
|
|
147
|
+
return await this.errorResponse(error, context)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function isBetter(candidate: RouteEntry, current: RouteEntry): boolean {
|
|
153
|
+
const order = compareSpecificity(candidate.pattern, current.pattern)
|
|
154
|
+
if (order !== 0) return order > 0
|
|
155
|
+
return current.method === '*' && candidate.method !== '*'
|
|
156
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/** What a `Response` can be constructed from: bytes, a string, a stream, or nothing. */
|
|
2
|
+
export type ResponseBody = ConstructorParameters<typeof Response>[0]
|
|
3
|
+
|
|
4
|
+
/** HTTP methods a route can answer; `*` answers any method not claimed by a more specific route. */
|
|
5
|
+
export type Method = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS'
|
|
6
|
+
export type RouteMethod = Method | '*'
|
|
7
|
+
|
|
8
|
+
/** How a trailing slash is read: `strict` keeps `/a` and `/a/` distinct, `ignore` treats them as one path. */
|
|
9
|
+
export type TrailingSlash = 'strict' | 'ignore'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Facts about the transport the request arrived on, supplied by the caller of `fetch` and never by the request itself.
|
|
13
|
+
* An in-process caller (the terminal UI) passes nothing, so a handler that needs a trusted peer must refuse it.
|
|
14
|
+
*/
|
|
15
|
+
export interface ConnectionInfo {
|
|
16
|
+
/** The peer address of the socket, when the request came over one (for example `127.0.0.1`). */
|
|
17
|
+
readonly remoteAddress?: string | undefined
|
|
18
|
+
/** The local port the socket is connected to, the real one a `Host` header should be checked against. */
|
|
19
|
+
readonly localPort?: number | undefined
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** What a handler or middleware sees. Deliberately small: the Web-standard request plus a few response helpers. */
|
|
23
|
+
export interface RouteContext {
|
|
24
|
+
/** The incoming request. */
|
|
25
|
+
readonly req: Request
|
|
26
|
+
/** Parsed `req.url`. */
|
|
27
|
+
readonly url: URL
|
|
28
|
+
/** Matched `:name` segments, and `*` for the remainder of a wildcard route. Values are URL-decoded. */
|
|
29
|
+
readonly params: Readonly<Record<string, string>>
|
|
30
|
+
/** The transport the request arrived on; empty for an in-process call. */
|
|
31
|
+
readonly connection: ConnectionInfo
|
|
32
|
+
/** Aborted when the caller aborts the request or when the router is disposed. Handlers should honor it. */
|
|
33
|
+
readonly signal: AbortSignal
|
|
34
|
+
/** Request-scoped values shared between middleware and the handler. */
|
|
35
|
+
get<T>(key: string): T | undefined
|
|
36
|
+
set(key: string, value: unknown): void
|
|
37
|
+
/**
|
|
38
|
+
* Keep this request "in flight" until `task` settles, even after the handler returned its `Response`. A handler that
|
|
39
|
+
* streams (server-sent events) registers the promise that resolves when its stream ends; disposing the router aborts
|
|
40
|
+
* `signal` and waits for it, so a stream never outlives the plugin that served it.
|
|
41
|
+
*/
|
|
42
|
+
waitUntil(task: Promise<unknown>): void
|
|
43
|
+
/**
|
|
44
|
+
* Set a header that the response helpers below (`json`, `text`, `html`, `body`, `redirect`, `notFound`) carry. A response built
|
|
45
|
+
* by hand with `new Response` is the handler's own and is left alone; middleware that must change any response reads it from `next()`.
|
|
46
|
+
*/
|
|
47
|
+
header(name: string, value: string): void
|
|
48
|
+
/** The status the response helpers use when they are not given one. */
|
|
49
|
+
status(code: number): void
|
|
50
|
+
/** One query parameter, or all of them as an object (repeated names keep the first). */
|
|
51
|
+
query(): Record<string, string>
|
|
52
|
+
query(name: string): string | undefined
|
|
53
|
+
/** One route parameter, or all of them. Same values as `params`. */
|
|
54
|
+
param(): Readonly<Record<string, string>>
|
|
55
|
+
param(name: string): string | undefined
|
|
56
|
+
json(data: unknown, status?: number): Response
|
|
57
|
+
text(body: string, status?: number): Response
|
|
58
|
+
html(body: string, status?: number): Response
|
|
59
|
+
/** A response with a raw body (bytes, a string, a stream, or nothing) and optional extra headers. */
|
|
60
|
+
body(data: ResponseBody, status?: number, headers?: Record<string, string>): Response
|
|
61
|
+
redirect(location: string, status?: 301 | 302 | 303 | 307 | 308): Response
|
|
62
|
+
/** The router's not-found response (or the registered `notFound` handler's). */
|
|
63
|
+
notFound(): Response | Promise<Response>
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type Handler = (c: RouteContext) => Response | Promise<Response>
|
|
67
|
+
export type Next = () => Promise<Response>
|
|
68
|
+
export type Middleware = (c: RouteContext, next: Next) => Response | Promise<Response>
|
|
69
|
+
|
|
70
|
+
/** Turns anything a handler or middleware threw into a response. Registered with `router.onError`. */
|
|
71
|
+
export type ErrorHandler = (error: unknown, c: RouteContext) => Response | Promise<Response>
|
|
72
|
+
/** Answers a request no route claimed. Registered with `router.notFound`. */
|
|
73
|
+
export type NotFoundHandler = (c: RouteContext) => Response | Promise<Response>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Small response utilities shared by the middleware. Not exported from the package. */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Set headers on a response a handler already built. Responses made with `new Response` are mutable; ones from `fetch` or `Response.redirect` are
|
|
5
|
+
* not, so on a failed write the response is rebuilt around the same body.
|
|
6
|
+
*/
|
|
7
|
+
export function setHeaders(response: Response, headers: Record<string, string>): Response {
|
|
8
|
+
try {
|
|
9
|
+
for (const [name, value] of Object.entries(headers)) response.headers.set(name, value)
|
|
10
|
+
return response
|
|
11
|
+
} catch {
|
|
12
|
+
const merged = new Headers(response.headers)
|
|
13
|
+
for (const [name, value] of Object.entries(headers)) merged.set(name, value)
|
|
14
|
+
return new Response(response.body, { status: response.status, statusText: response.statusText, headers: merged })
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function appendVary(response: Response, value: string): Response {
|
|
19
|
+
const current = response.headers.get('vary')
|
|
20
|
+
const present = current?.split(',').map(part => part.trim().toLowerCase()) ?? []
|
|
21
|
+
if (present.includes('*') || present.includes(value.toLowerCase())) return response
|
|
22
|
+
return setHeaders(response, { vary: current === null || current === '' ? value : `${current}, ${value}` })
|
|
23
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The registration surface (spec 046): several handlers per route, `all` and `on`, optional-path `use`, `group`, `onError` and
|
|
3
|
+
* `notFound`, `HttpError`, `request()`, and the context helpers. Everything runs through the real Cordis service, so ownership is checked too.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Context } from '@deepseek-ai/cordis'
|
|
7
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
8
|
+
import { apply, HttpError, name, type Router } from '../src/index.ts'
|
|
9
|
+
|
|
10
|
+
const contexts: Context[] = []
|
|
11
|
+
const settle = (): Promise<void> => new Promise(resolve => setTimeout(resolve, 0))
|
|
12
|
+
afterEach(async () => {
|
|
13
|
+
while (contexts.length > 0) await contexts.pop()?.fiber.dispose()
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
async function mount(): Promise<{ ctx: Context, router: Router }> {
|
|
17
|
+
const ctx = new Context()
|
|
18
|
+
contexts.push(ctx)
|
|
19
|
+
ctx.plugin({ name, apply })
|
|
20
|
+
await settle()
|
|
21
|
+
return { ctx, router: ctx.router }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const text = async (response: Response | Promise<Response>): Promise<string> => (await response).text()
|
|
25
|
+
|
|
26
|
+
describe('registration', () => {
|
|
27
|
+
it('runs route-level middleware before the handler, in order, for that route only', async () => {
|
|
28
|
+
const { router } = await mount()
|
|
29
|
+
const seen: string[] = []
|
|
30
|
+
router.get('/a', async (_c, next) => { seen.push('m1'); return next() }, async (_c, next) => { seen.push('m2'); return next() }, c => { seen.push('h'); return c.text('a') })
|
|
31
|
+
router.get('/b', c => c.text('b'))
|
|
32
|
+
expect(await text(router.request('/a'))).toBe('a')
|
|
33
|
+
expect(seen).toEqual(['m1', 'm2', 'h'])
|
|
34
|
+
seen.length = 0
|
|
35
|
+
await router.request('/b')
|
|
36
|
+
expect(seen).toEqual([])
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('answers any method with all(), and a chosen set with on()', async () => {
|
|
40
|
+
const { router } = await mount()
|
|
41
|
+
router.all('/any', c => c.text(c.req.method))
|
|
42
|
+
router.on(['GET', 'POST'], '/two', c => c.text(c.req.method))
|
|
43
|
+
expect(await text(router.request('/any', { method: 'PATCH' }))).toBe('PATCH')
|
|
44
|
+
expect(await text(router.request('/two', { method: 'POST' }))).toBe('POST')
|
|
45
|
+
expect((await router.request('/two', { method: 'DELETE' })).status).toBe(405)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('removes every method of an on() registration with the one remover it returns', async () => {
|
|
49
|
+
const { router } = await mount()
|
|
50
|
+
const remove = router.on(['GET', 'POST'], '/two', c => c.text('x'))
|
|
51
|
+
const before = router.size
|
|
52
|
+
remove()
|
|
53
|
+
expect(before - router.size).toBe(2)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('applies use() without a path to everything', async () => {
|
|
57
|
+
const { router } = await mount()
|
|
58
|
+
router.use(async (_c, next) => { const response = await next(); response.headers.set('x-seen', '1'); return response })
|
|
59
|
+
router.get('/x', c => c.text('x'))
|
|
60
|
+
expect((await router.request('/x')).headers.get('x-seen')).toBe('1')
|
|
61
|
+
expect((await router.request('/missing')).headers.get('x-seen')).toBe('1')
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
describe('group', () => {
|
|
66
|
+
it('prefixes paths, nests, and scopes group middleware to the prefix', async () => {
|
|
67
|
+
const { router } = await mount()
|
|
68
|
+
const seen: string[] = []
|
|
69
|
+
const api = router.group('/api', async (_c, next) => { seen.push('api'); return next() })
|
|
70
|
+
api.get('/', c => c.text('index'))
|
|
71
|
+
api.get('/items/:id', c => c.json({ id: c.param('id') }))
|
|
72
|
+
const admin = api.group('/admin')
|
|
73
|
+
admin.use(async (_c, next) => { seen.push('admin'); return next() })
|
|
74
|
+
admin.get('/stats', c => c.text('stats'))
|
|
75
|
+
router.get('/outside', c => c.text('outside'))
|
|
76
|
+
|
|
77
|
+
expect(await text(router.request('/api'))).toBe('index')
|
|
78
|
+
expect(await (await router.request('/api/items/7')).json()).toEqual({ id: '7' })
|
|
79
|
+
expect(await text(router.request('/api/admin/stats'))).toBe('stats')
|
|
80
|
+
expect(seen).toEqual(['api', 'api', 'api', 'admin'])
|
|
81
|
+
seen.length = 0
|
|
82
|
+
await router.request('/outside')
|
|
83
|
+
expect(seen).toEqual([])
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('belongs to the plugin that made it: its routes vanish when that plugin unloads', async () => {
|
|
87
|
+
const { ctx, router } = await mount()
|
|
88
|
+
const baseline = router.size
|
|
89
|
+
const fiber = ctx.plugin({
|
|
90
|
+
name: 'notes',
|
|
91
|
+
inject: ['router'],
|
|
92
|
+
apply(c: Context) {
|
|
93
|
+
const notes = c.router.group('/notes')
|
|
94
|
+
notes.get('/', x => x.text('list'))
|
|
95
|
+
notes.post('/', x => x.text('create', 201))
|
|
96
|
+
},
|
|
97
|
+
})
|
|
98
|
+
await settle()
|
|
99
|
+
expect(router.size - baseline).toBe(2)
|
|
100
|
+
expect(await text(router.request('/notes'))).toBe('list')
|
|
101
|
+
await fiber.dispose()
|
|
102
|
+
await settle()
|
|
103
|
+
expect(router.size).toBe(baseline)
|
|
104
|
+
expect((await router.request('/notes')).status).toBe(404)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('rejects a prefix that is not a plain path', async () => {
|
|
108
|
+
const { router } = await mount()
|
|
109
|
+
expect(() => router.group('/api/')).toThrow(/plain path/)
|
|
110
|
+
expect(() => router.group('/a/:id')).toThrow(/plain path/)
|
|
111
|
+
expect(() => router.group('/a/*')).toThrow(/plain path/)
|
|
112
|
+
expect(() => router.group('api')).toThrow(/plain path/)
|
|
113
|
+
})
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
describe('errors and not found', () => {
|
|
117
|
+
it('turns a thrown HttpError into its response without logging an error', async () => {
|
|
118
|
+
const { router } = await mount()
|
|
119
|
+
router.get('/secret', () => { throw new HttpError(401, { message: 'Sign in first' }) })
|
|
120
|
+
router.get('/custom', () => { throw new HttpError(418, { response: new Response('teapot', { status: 418, headers: { 'x-brew': 'yes' } }) }) })
|
|
121
|
+
const denied = await router.request('/secret')
|
|
122
|
+
expect(denied.status).toBe(401)
|
|
123
|
+
expect(await denied.text()).toBe('Sign in first')
|
|
124
|
+
const teapot = await router.request('/custom')
|
|
125
|
+
expect(teapot.status).toBe(418)
|
|
126
|
+
expect(teapot.headers.get('x-brew')).toBe('yes')
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('lets one onError handler own every error, including HttpError, and removes it with its plugin', async () => {
|
|
130
|
+
const { ctx, router } = await mount()
|
|
131
|
+
const fiber = ctx.plugin({
|
|
132
|
+
name: 'errors',
|
|
133
|
+
inject: ['router'],
|
|
134
|
+
apply(c: Context) {
|
|
135
|
+
c.router.onError((error, x) => x.json({ message: error instanceof Error ? error.message : 'unknown' }, error instanceof HttpError ? error.status : 500))
|
|
136
|
+
},
|
|
137
|
+
})
|
|
138
|
+
await settle()
|
|
139
|
+
router.get('/boom', () => { throw new Error('boom') })
|
|
140
|
+
router.get('/http', () => { throw new HttpError(403, { message: 'nope' }) })
|
|
141
|
+
expect(await (await router.request('/boom')).json()).toEqual({ message: 'boom' })
|
|
142
|
+
const forbidden = await router.request('/http')
|
|
143
|
+
expect(forbidden.status).toBe(403)
|
|
144
|
+
expect(await forbidden.json()).toEqual({ message: 'nope' })
|
|
145
|
+
expect(() => router.onError(() => new Response('second'))).toThrow(/already registered/)
|
|
146
|
+
await fiber.dispose()
|
|
147
|
+
await settle()
|
|
148
|
+
expect((await router.request('/boom')).status).toBe(500)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('answers 500 when the error handler itself throws', async () => {
|
|
152
|
+
const { router } = await mount()
|
|
153
|
+
router.onError(() => { throw new Error('handler broke') })
|
|
154
|
+
router.get('/boom', () => { throw new Error('boom') })
|
|
155
|
+
expect((await router.request('/boom')).status).toBe(500)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('uses a registered notFound handler, also through c.notFound()', async () => {
|
|
159
|
+
const { router } = await mount()
|
|
160
|
+
router.notFound(c => c.json({ missing: c.url.pathname }, 404))
|
|
161
|
+
router.get('/maybe', c => c.req.headers.has('x-found') ? c.text('found') : c.notFound())
|
|
162
|
+
const missing = await router.request('/nowhere')
|
|
163
|
+
expect(missing.status).toBe(404)
|
|
164
|
+
expect(await missing.json()).toEqual({ missing: '/nowhere' })
|
|
165
|
+
expect((await router.request('/maybe')).status).toBe(404)
|
|
166
|
+
expect(await text(router.request('/maybe', { headers: { 'x-found': '1' } }))).toBe('found')
|
|
167
|
+
expect(() => router.notFound(() => new Response('second'))).toThrow(/already registered/)
|
|
168
|
+
})
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
describe('context helpers', () => {
|
|
172
|
+
it('carries header() and status() into the response helpers', async () => {
|
|
173
|
+
const { router } = await mount()
|
|
174
|
+
router.get('/h', c => { c.header('x-app', 'acryl'); c.status(202); return c.json({ ok: true }) })
|
|
175
|
+
const response = await router.request('/h')
|
|
176
|
+
expect(response.status).toBe(202)
|
|
177
|
+
expect(response.headers.get('x-app')).toBe('acryl')
|
|
178
|
+
expect(response.headers.get('content-type')).toContain('application/json')
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('builds html, raw bodies and redirects', async () => {
|
|
182
|
+
const { router } = await mount()
|
|
183
|
+
router.get('/html', c => c.html('<h1>hi</h1>'))
|
|
184
|
+
router.get('/raw', c => c.body(new Uint8Array([1, 2, 3]), 200, { 'content-type': 'application/octet-stream' }))
|
|
185
|
+
router.get('/go', c => c.redirect('/html', 301))
|
|
186
|
+
router.get('/empty', c => c.body(null, 204))
|
|
187
|
+
const html = await router.request('/html')
|
|
188
|
+
expect(html.headers.get('content-type')).toContain('text/html')
|
|
189
|
+
expect(await html.text()).toBe('<h1>hi</h1>')
|
|
190
|
+
expect([...new Uint8Array(await (await router.request('/raw')).arrayBuffer())]).toEqual([1, 2, 3])
|
|
191
|
+
const moved = await router.request('/go')
|
|
192
|
+
expect(moved.status).toBe(301)
|
|
193
|
+
expect(moved.headers.get('location')).toBe('/html')
|
|
194
|
+
expect((await router.request('/empty')).status).toBe(204)
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it('reads query and path parameters one at a time or all at once', async () => {
|
|
198
|
+
const { router } = await mount()
|
|
199
|
+
router.get('/u/:name', c => c.json({ one: c.query('q'), all: c.query(), name: c.param('name'), params: c.param() }))
|
|
200
|
+
const body = await (await router.request('/u/zed?q=1&q=2&r=3')).json()
|
|
201
|
+
expect(body).toEqual({ one: '1', all: { q: '1', r: '3' }, name: 'zed', params: { name: 'zed' } })
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('shares values between middleware and handler with set() and get()', async () => {
|
|
205
|
+
const { router } = await mount()
|
|
206
|
+
router.use(async (c, next) => { c.set('user', 'ada'); return next() })
|
|
207
|
+
router.get('/me', c => c.text(c.get<string>('user') ?? '?'))
|
|
208
|
+
expect(await text(router.request('/me'))).toBe('ada')
|
|
209
|
+
})
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
describe('request()', () => {
|
|
213
|
+
it('accepts a relative path, a URL, and a Request, with init and connection info', async () => {
|
|
214
|
+
const { router } = await mount()
|
|
215
|
+
router.post('/echo', async c => c.json({ got: await c.req.text(), peer: c.connection.remoteAddress ?? null }))
|
|
216
|
+
const viaPath = await router.request('/echo', { method: 'POST', body: 'a' })
|
|
217
|
+
expect(await viaPath.json()).toEqual({ got: 'a', peer: null })
|
|
218
|
+
const viaUrl = await router.request(new URL('http://example.test/echo'), { method: 'POST', body: 'b' }, { remoteAddress: '127.0.0.1' })
|
|
219
|
+
expect(await viaUrl.json()).toEqual({ got: 'b', peer: '127.0.0.1' })
|
|
220
|
+
const viaRequest = await router.request(new Request('http://example.test/echo', { method: 'POST', body: 'c' }))
|
|
221
|
+
expect((await viaRequest.json() as { got: string }).got).toBe('c')
|
|
222
|
+
})
|
|
223
|
+
})
|