@fastify/fetch 0.1.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/.gitattributes ADDED
@@ -0,0 +1,2 @@
1
+ # Set default behavior to automatically convert line endings
2
+ * text=auto eol=lf
@@ -0,0 +1,53 @@
1
+ version: 2
2
+ updates:
3
+ - package-ecosystem: "github-actions"
4
+ directory: "/"
5
+ commit-message:
6
+ # Prefix all commit messages with "chore: "
7
+ prefix: "chore"
8
+ schedule:
9
+ interval: "weekly"
10
+ cooldown:
11
+ default-days: 7
12
+ allow:
13
+ - dependency-name: "*"
14
+ update-types:
15
+ - "version-update:semver-major"
16
+ open-pull-requests-limit: 10
17
+
18
+ - package-ecosystem: "npm"
19
+ directory: "/"
20
+ commit-message:
21
+ # Prefix all commit messages with "chore: "
22
+ prefix: "chore"
23
+ schedule:
24
+ interval: "weekly"
25
+ cooldown:
26
+ default-days: 7
27
+ versioning-strategy: "increase-if-necessary"
28
+ allow:
29
+ - dependency-name: "*"
30
+ update-types:
31
+ - "version-update:semver-major"
32
+ ignore:
33
+ # TODO: remove ignore until neostandard support ESLint 10
34
+ - dependency-name: "eslint"
35
+ - dependency-name: "neostandard"
36
+ - dependency-name: "@stylistic/*"
37
+ open-pull-requests-limit: 10
38
+ groups:
39
+ # Production dependencies with breaking changes
40
+ dependencies:
41
+ dependency-type: "production"
42
+ # ESLint related dependencies
43
+ dev-dependencies-eslint:
44
+ patterns:
45
+ - "eslint"
46
+ - "neostandard"
47
+ - "@stylistic/*"
48
+ # TypeScript related dependencies
49
+ dev-dependencies-typescript:
50
+ patterns:
51
+ - "@types/*"
52
+ - "tstyche"
53
+ - "typescript"
@@ -0,0 +1,33 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+ - next
8
+ - 'v*'
9
+ paths-ignore:
10
+ - 'docs/**'
11
+ - '*.md'
12
+ pull_request:
13
+ paths-ignore:
14
+ - 'docs/**'
15
+ - '*.md'
16
+
17
+ # This allows a subsequently queued workflow run to interrupt previous runs
18
+ concurrency:
19
+ group: "${{ github.workflow }}-${{ github.event.pull_request.head.label || github.head_ref || github.ref }}"
20
+ cancel-in-progress: true
21
+
22
+ permissions:
23
+ contents: read
24
+
25
+ jobs:
26
+ test:
27
+ permissions:
28
+ contents: write
29
+ pull-requests: write
30
+ uses: fastify/workflows/.github/workflows/plugins-ci.yml@v6
31
+ with:
32
+ license-check: true
33
+ lint: true
@@ -0,0 +1,19 @@
1
+ name: Lock Threads
2
+
3
+ on:
4
+ schedule:
5
+ - cron: '0 0 1 * *'
6
+ workflow_dispatch:
7
+
8
+ concurrency:
9
+ group: lock
10
+
11
+ permissions:
12
+ contents: read
13
+
14
+ jobs:
15
+ lock-threads:
16
+ permissions:
17
+ issues: write
18
+ pull-requests: write
19
+ uses: fastify/workflows/.github/workflows/lock-threads.yml@v6
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026-present The Fastify team <https://github.com/fastify/fastify#team>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,188 @@
1
+ # @fastify/fetch
2
+
3
+ [![CI](https://github.com/fastify/fastify-fetch/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/fastify/fastify-fetch/actions/workflows/ci.yml)
4
+ [![NPM version](https://img.shields.io/npm/v/@fastify/fetch.svg?style=flat)](https://www.npmjs.com/package/@fastify/fetch)
5
+ [![neostandard javascript style](https://img.shields.io/badge/code_style-neostandard-brightgreen?style=flat)](https://github.com/neostandard/neostandard)
6
+
7
+ Handle requests using the Web Standard [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) (`Request`/`Response`).
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ npm install @fastify/fetch
13
+ ```
14
+
15
+ ## Usage
16
+
17
+ ```javascript
18
+ import Fastify from 'fastify'
19
+ import fastifyFetch from '@fastify/fetch'
20
+
21
+ const fastify = Fastify()
22
+
23
+ await fastify.register(fastifyFetch)
24
+
25
+ fastify.fetch.get('/hello', async (request, ctx) => {
26
+ ctx.log.info('handling request')
27
+ return new Response('Hello World')
28
+ })
29
+
30
+ fastify.fetch.post('/data', async (request, ctx) => {
31
+ const body = await request.json()
32
+ return Response.json({ received: body })
33
+ })
34
+
35
+ await fastify.listen({ port: 3000 })
36
+ ```
37
+
38
+ ## API
39
+
40
+ ### Handler Signature
41
+
42
+ ```javascript
43
+ fastify.fetch.get(path, [options], handler)
44
+ fastify.fetch.post(path, [options], handler)
45
+ fastify.fetch.put(path, [options], handler)
46
+ fastify.fetch.delete(path, [options], handler)
47
+ fastify.fetch.patch(path, [options], handler)
48
+ fastify.fetch.options(path, [options], handler)
49
+ fastify.fetch.head(path, [options], handler)
50
+ ```
51
+
52
+ The handler receives two arguments:
53
+
54
+ - `request` - Web Standard [Request](https://developer.mozilla.org/en-US/docs/Web/API/Request) object
55
+ - `ctx` - Context object
56
+
57
+ The handler must return a Web Standard [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response) object.
58
+
59
+ ### Context Object
60
+
61
+ | Property | Type | Description |
62
+ |----------|------|-------------|
63
+ | `ctx.log` | `FastifyBaseLogger` | Fastify logger instance |
64
+ | `ctx.server` | `FastifyInstance` | Fastify server instance |
65
+ | `ctx.params` | `Record<string, string>` | Route parameters |
66
+ | `ctx.query` | `Record<string, string>` | Query string parameters |
67
+ | `ctx.request` | `FastifyRequest` | Original Fastify request |
68
+ | `ctx.reply` | `FastifyReply` | Original Fastify reply |
69
+ | `ctx.abortController` | `AbortController \| undefined` | AbortController instance when enabled for the route |
70
+
71
+ ### Route options
72
+
73
+ The optional `options` object accepts standard Fastify route options plus:
74
+
75
+ | Option | Type | Default | Description |
76
+ |--------|------|---------|-------------|
77
+ | `abortController` | `boolean` | `false` | Create an `AbortController` for the route and expose it as `ctx.abortController`. The Web `Request` signal is aborted when the client aborts the request or when the handler lifecycle ends. |
78
+
79
+ ```javascript
80
+ fastify.fetch.get('/long-running', { abortController: true }, async (request, ctx) => {
81
+ const result = await doWork({ signal: request.signal })
82
+ return Response.json(result)
83
+ })
84
+ ```
85
+
86
+ ### Hooks
87
+
88
+ Routes registered with `fastify.fetch.*` are standard Fastify routes, so all Fastify hooks are supported:
89
+
90
+ | Hook | Fires |
91
+ |------|-------|
92
+ | `onRequest` | Yes |
93
+ | `preParsing` | Yes |
94
+ | `preValidation` | Yes |
95
+ | `preHandler` | Yes |
96
+ | `onSend` | Yes |
97
+ | `onResponse` | Yes |
98
+ | `onError` | Yes (on errors) |
99
+
100
+ ```javascript
101
+ fastify.addHook('onRequest', async (request, reply) => {
102
+ // runs before the fetch handler
103
+ })
104
+
105
+ fastify.addHook('onResponse', async (request, reply) => {
106
+ // runs after the response is sent
107
+ })
108
+ ```
109
+
110
+ ## Examples
111
+
112
+ ### JSON Response
113
+
114
+ ```javascript
115
+ fastify.fetch.get('/users/:id', async (request, ctx) => {
116
+ const user = await getUser(ctx.params.id)
117
+ return Response.json(user)
118
+ })
119
+ ```
120
+
121
+ ### Custom Headers
122
+
123
+ ```javascript
124
+ fastify.fetch.get('/data', async (request, ctx) => {
125
+ return new Response('data', {
126
+ headers: {
127
+ 'X-Custom-Header': 'value',
128
+ 'Cache-Control': 'max-age=3600'
129
+ }
130
+ })
131
+ })
132
+ ```
133
+
134
+ ### Custom Status Code
135
+
136
+ ```javascript
137
+ fastify.fetch.post('/users', async (request, ctx) => {
138
+ const body = await request.json()
139
+ const user = await createUser(body)
140
+ return Response.json(user, { status: 201 })
141
+ })
142
+ ```
143
+
144
+ ### Reading Request Body
145
+
146
+ ```javascript
147
+ fastify.fetch.post('/upload', async (request, ctx) => {
148
+ // JSON
149
+ const json = await request.json()
150
+
151
+ // Text
152
+ const text = await request.text()
153
+
154
+ // FormData
155
+ const formData = await request.formData()
156
+
157
+ // ArrayBuffer
158
+ const buffer = await request.arrayBuffer()
159
+
160
+ return new Response('OK')
161
+ })
162
+ ```
163
+
164
+ ### Using Query Parameters
165
+
166
+ ```javascript
167
+ fastify.fetch.get('/search', async (request, ctx) => {
168
+ const { q, limit } = ctx.query
169
+ const results = await search(q, parseInt(limit))
170
+ return Response.json(results)
171
+ })
172
+ ```
173
+
174
+ ### Accessing Request URL
175
+
176
+ ```javascript
177
+ fastify.fetch.get('/info', async (request, ctx) => {
178
+ const url = new URL(request.url)
179
+ return Response.json({
180
+ pathname: url.pathname,
181
+ search: url.search
182
+ })
183
+ })
184
+ ```
185
+
186
+ ## License
187
+
188
+ Licensed under [MIT](./LICENSE).
@@ -0,0 +1,3 @@
1
+ 'use strict'
2
+
3
+ module.exports = require('neostandard')()
@@ -0,0 +1,40 @@
1
+ import Fastify from 'fastify'
2
+ import fastifyFetch from '../index.js'
3
+
4
+ const fastify = Fastify({ logger: true })
5
+
6
+ await fastify.register(fastifyFetch)
7
+
8
+ fastify.fetch.get('/hello', async (request, ctx) => {
9
+ ctx.log.info('handling request')
10
+ return new Response('Hello World')
11
+ })
12
+
13
+ fastify.fetch.get('/users/:id', async (request, ctx) => {
14
+ return Response.json({ id: ctx.params.id, name: 'John Doe' })
15
+ })
16
+
17
+ fastify.fetch.post('/data', async (request, ctx) => {
18
+ const body = await request.json()
19
+ return Response.json({ received: body })
20
+ })
21
+
22
+ fastify.fetch.get('/search', async (request, ctx) => {
23
+ return Response.json({ query: ctx.query })
24
+ })
25
+
26
+ await fastify.listen({ port: 3000 })
27
+
28
+ /**
29
+ * Test the endpoints using curl:
30
+ *
31
+ * curl http://localhost:3000/hello
32
+ *
33
+ * curl http://localhost:3000/users/42
34
+ *
35
+ * curl -X POST http://localhost:3000/data \
36
+ * -H "Content-Type: application/json" \
37
+ * -d '{"name": "John Doe", "age": 30}'
38
+ *
39
+ * curl "http://localhost:3000/search?q=test&limit=10"
40
+ */
package/index.js ADDED
@@ -0,0 +1,158 @@
1
+ 'use strict'
2
+
3
+ const fp = require('fastify-plugin')
4
+ const { Readable } = require('node:stream')
5
+
6
+ const kAbortController = Symbol('fastify-fetch.abortController')
7
+ const requestLifecycleEnded = new DOMException('Request lifecycle ended', 'AbortError')
8
+
9
+ function createWebRequest (fastifyRequest, signal) {
10
+ const url = new URL(fastifyRequest.url, `http://${fastifyRequest.headers.host}`)
11
+ const hasBody = !['GET', 'HEAD'].includes(fastifyRequest.method)
12
+ const body = fastifyRequest.body
13
+
14
+ let webBody
15
+ if (hasBody && body) {
16
+ if (body instanceof Readable) {
17
+ webBody = Readable.toWeb(body)
18
+ } else {
19
+ throw new Error('Request body must be a Readable stream')
20
+ }
21
+ }
22
+
23
+ if (signal === undefined) {
24
+ return new Request(url, {
25
+ method: fastifyRequest.method,
26
+ headers: new Headers(fastifyRequest.headers),
27
+ body: webBody,
28
+ duplex: webBody ? 'half' : undefined
29
+ })
30
+ }
31
+
32
+ return new Request(url, {
33
+ method: fastifyRequest.method,
34
+ headers: new Headers(fastifyRequest.headers),
35
+ body: webBody,
36
+ duplex: webBody ? 'half' : undefined,
37
+ signal
38
+ })
39
+ }
40
+
41
+ async function sendWebResponse (fastifyReply, webResponse) {
42
+ if (!(webResponse instanceof Response)) {
43
+ throw new Error('Handler must return a Response object')
44
+ }
45
+
46
+ fastifyReply.status(webResponse.status)
47
+
48
+ for (const [key, value] of webResponse.headers) {
49
+ fastifyReply.header(key, value)
50
+ }
51
+
52
+ const body = await webResponse.arrayBuffer()
53
+ fastifyReply.send(Buffer.from(body))
54
+ }
55
+
56
+ function abortWebRequest (request) {
57
+ request[kAbortController]?.abort()
58
+ }
59
+
60
+ function prepareRouteOptions (options, abortControllerEnabled) {
61
+ if (options === undefined) {
62
+ return undefined
63
+ }
64
+
65
+ const routeOptions = { ...options }
66
+ delete routeOptions.abortController
67
+ if (!abortControllerEnabled) {
68
+ return routeOptions
69
+ }
70
+
71
+ const hooks = routeOptions.onRequestAbort
72
+ if (hooks === undefined) {
73
+ routeOptions.onRequestAbort = abortWebRequest
74
+ } else if (Array.isArray(hooks)) {
75
+ routeOptions.onRequestAbort = [abortWebRequest, ...hooks]
76
+ } else {
77
+ routeOptions.onRequestAbort = [abortWebRequest, hooks]
78
+ }
79
+
80
+ return routeOptions
81
+ }
82
+
83
+ async function fastifyFetch (fastify, options) {
84
+ fastify.removeAllContentTypeParsers()
85
+ fastify.addContentTypeParser('*', function (request, payload, done) {
86
+ done(null, payload)
87
+ })
88
+
89
+ const methods = ['get', 'post', 'put', 'delete', 'patch', 'options', 'head']
90
+ const fetch = {}
91
+
92
+ for (const method of methods) {
93
+ fetch[method] = (path, options, handler) => {
94
+ if (handler === undefined) {
95
+ handler = options
96
+ options = undefined
97
+ }
98
+
99
+ const abortControllerEnabled = options?.abortController === true
100
+ const routeOptions = prepareRouteOptions(options, abortControllerEnabled)
101
+ let routeHandler
102
+
103
+ if (abortControllerEnabled) {
104
+ routeHandler = async function handleRequestWithAbortController (request, reply) {
105
+ const abortController = new AbortController()
106
+ request[kAbortController] = abortController
107
+
108
+ try {
109
+ const webRequest = createWebRequest(request, abortController.signal)
110
+ const ctx = {
111
+ log: request.log,
112
+ server: fastify,
113
+ params: request.params,
114
+ query: request.query,
115
+ request,
116
+ reply,
117
+ abortController
118
+ }
119
+
120
+ const webResponse = await handler(webRequest, ctx)
121
+ await sendWebResponse(reply, webResponse)
122
+ } finally {
123
+ delete request[kAbortController]
124
+ abortController.abort(requestLifecycleEnded)
125
+ }
126
+ }
127
+ } else {
128
+ routeHandler = async function handleRequest (request, reply) {
129
+ const webRequest = createWebRequest(request)
130
+ const ctx = {
131
+ log: request.log,
132
+ server: fastify,
133
+ params: request.params,
134
+ query: request.query,
135
+ request,
136
+ reply
137
+ }
138
+
139
+ const webResponse = await handler(webRequest, ctx)
140
+ await sendWebResponse(reply, webResponse)
141
+ }
142
+ }
143
+
144
+ if (routeOptions === undefined) {
145
+ fastify[method](path, routeHandler)
146
+ } else {
147
+ fastify[method](path, routeOptions, routeHandler)
148
+ }
149
+ }
150
+ }
151
+
152
+ fastify.decorate('fetch', fetch)
153
+ }
154
+
155
+ module.exports = fp(fastifyFetch, {
156
+ fastify: '>=5.0.0',
157
+ name: '@fastify/fetch'
158
+ })
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@fastify/fetch",
3
+ "version": "0.1.0",
4
+ "description": "Handle requests using the fetch standard",
5
+ "main": "index.js",
6
+ "type": "commonjs",
7
+ "types": "types/index.d.ts",
8
+ "scripts": {
9
+ "lint": "eslint",
10
+ "lint:fix": "eslint --fix",
11
+ "test": "borp && npm run test:types",
12
+ "test:types": "tstyche"
13
+ },
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/fastify/fastify-fetch.git"
17
+ },
18
+ "keywords": [
19
+ "fastify",
20
+ "fetch",
21
+ "web-standard",
22
+ "request",
23
+ "response"
24
+ ],
25
+ "author": "Matteo Collina <hello@matteocollina.com>",
26
+ "license": "MIT",
27
+ "bugs": {
28
+ "url": "https://github.com/fastify/fastify-fetch/issues"
29
+ },
30
+ "homepage": "https://github.com/fastify/fastify-fetch",
31
+ "funding": [
32
+ {
33
+ "type": "github",
34
+ "url": "https://github.com/sponsors/fastify"
35
+ },
36
+ {
37
+ "type": "opencollective",
38
+ "url": "https://opencollective.com/fastify"
39
+ }
40
+ ],
41
+ "dependencies": {
42
+ "fastify-plugin": "^6.0.0"
43
+ },
44
+ "devDependencies": {
45
+ "@types/node": "^26.0.1",
46
+ "borp": "^1.0.0",
47
+ "fastify": "^5.0.0",
48
+ "neostandard": "^0.13.0",
49
+ "tstyche": "^7.0.0"
50
+ }
51
+ }
@@ -0,0 +1,496 @@
1
+ 'use strict'
2
+
3
+ const { test, describe } = require('node:test')
4
+ const assert = require('node:assert')
5
+ const Fastify = require('fastify')
6
+ const fastifyFetch = require('../index.js')
7
+
8
+ describe('fastify-fetch', async () => {
9
+ test('GET request returns text response', async () => {
10
+ const fastify = Fastify()
11
+ await fastify.register(fastifyFetch)
12
+
13
+ fastify.fetch.get('/hello', async (request, ctx) => {
14
+ return new Response('Hello World')
15
+ })
16
+
17
+ const response = await fastify.inject({
18
+ method: 'GET',
19
+ url: '/hello'
20
+ })
21
+
22
+ assert.strictEqual(response.statusCode, 200)
23
+ assert.strictEqual(response.body, 'Hello World')
24
+ })
25
+
26
+ test('POST request with JSON body', async () => {
27
+ const fastify = Fastify()
28
+ await fastify.register(fastifyFetch)
29
+
30
+ fastify.fetch.post('/data', async (request, ctx) => {
31
+ const body = await request.json()
32
+ return Response.json({ received: body })
33
+ })
34
+
35
+ const response = await fastify.inject({
36
+ method: 'POST',
37
+ url: '/data',
38
+ headers: { 'content-type': 'application/json' },
39
+ payload: JSON.stringify({ name: 'test' })
40
+ })
41
+
42
+ assert.strictEqual(response.statusCode, 200)
43
+ assert.deepStrictEqual(JSON.parse(response.body), { received: { name: 'test' } })
44
+ })
45
+
46
+ test('Response headers are forwarded', async () => {
47
+ const fastify = Fastify()
48
+ await fastify.register(fastifyFetch)
49
+
50
+ fastify.fetch.get('/headers', async (request, ctx) => {
51
+ return new Response('ok', {
52
+ headers: { 'x-custom-header': 'custom-value' }
53
+ })
54
+ })
55
+
56
+ const response = await fastify.inject({
57
+ method: 'GET',
58
+ url: '/headers'
59
+ })
60
+
61
+ assert.strictEqual(response.headers['x-custom-header'], 'custom-value')
62
+ })
63
+
64
+ test('Response status codes work', async () => {
65
+ const fastify = Fastify()
66
+ await fastify.register(fastifyFetch)
67
+
68
+ fastify.fetch.get('/created', async (request, ctx) => {
69
+ return new Response('created', { status: 201 })
70
+ })
71
+
72
+ const response = await fastify.inject({
73
+ method: 'GET',
74
+ url: '/created'
75
+ })
76
+
77
+ assert.strictEqual(response.statusCode, 201)
78
+ })
79
+
80
+ test('Route parameters accessible via ctx.params', async () => {
81
+ const fastify = Fastify()
82
+ await fastify.register(fastifyFetch)
83
+
84
+ fastify.fetch.get('/users/:id', async (request, ctx) => {
85
+ return Response.json({ id: ctx.params.id })
86
+ })
87
+
88
+ const response = await fastify.inject({
89
+ method: 'GET',
90
+ url: '/users/123'
91
+ })
92
+
93
+ assert.deepStrictEqual(JSON.parse(response.body), { id: '123' })
94
+ })
95
+
96
+ test('Query string accessible via ctx.query', async () => {
97
+ const fastify = Fastify()
98
+ await fastify.register(fastifyFetch)
99
+
100
+ fastify.fetch.get('/search', async (request, ctx) => {
101
+ return Response.json({ q: ctx.query.q })
102
+ })
103
+
104
+ const response = await fastify.inject({
105
+ method: 'GET',
106
+ url: '/search?q=hello'
107
+ })
108
+
109
+ assert.deepStrictEqual(JSON.parse(response.body), { q: 'hello' })
110
+ })
111
+
112
+ test('Logger accessible via ctx.log', async () => {
113
+ const fastify = Fastify()
114
+ await fastify.register(fastifyFetch)
115
+
116
+ let logCalled = false
117
+ fastify.fetch.get('/log', async (request, ctx) => {
118
+ assert.ok(ctx.log)
119
+ assert.strictEqual(typeof ctx.log.info, 'function')
120
+ logCalled = true
121
+ return new Response('ok')
122
+ })
123
+
124
+ await fastify.inject({
125
+ method: 'GET',
126
+ url: '/log'
127
+ })
128
+
129
+ assert.ok(logCalled)
130
+ })
131
+
132
+ test('Server accessible via ctx.server', async () => {
133
+ const fastify = Fastify()
134
+ await fastify.register(fastifyFetch)
135
+
136
+ fastify.fetch.get('/server', async (request, ctx) => {
137
+ assert.strictEqual(ctx.server, fastify)
138
+ return new Response('ok')
139
+ })
140
+
141
+ await fastify.inject({
142
+ method: 'GET',
143
+ url: '/server'
144
+ })
145
+ })
146
+
147
+ test('AbortController is disabled by default', async () => {
148
+ const fastify = Fastify()
149
+ await fastify.register(fastifyFetch)
150
+
151
+ let cachedRequest
152
+
153
+ fastify.fetch.get('/abort-controller-disabled', async (request, ctx) => {
154
+ cachedRequest = request
155
+ assert.strictEqual(ctx.abortController, undefined)
156
+ return new Response('ok')
157
+ })
158
+
159
+ await fastify.inject({
160
+ method: 'GET',
161
+ url: '/abort-controller-disabled'
162
+ })
163
+
164
+ assert.strictEqual(cachedRequest.signal.aborted, false)
165
+ })
166
+
167
+ test('AbortController accessible via ctx.abortController when enabled', async () => {
168
+ const fastify = Fastify()
169
+ await fastify.register(fastifyFetch)
170
+
171
+ fastify.fetch.get('/abort-controller', { abortController: true }, async (request, ctx) => {
172
+ assert.ok(ctx.abortController instanceof AbortController)
173
+ assert.strictEqual(request.signal, ctx.abortController.signal)
174
+ return new Response('ok')
175
+ })
176
+
177
+ await fastify.inject({
178
+ method: 'GET',
179
+ url: '/abort-controller'
180
+ })
181
+ })
182
+
183
+ test('Fastify request accessible via ctx.request', async () => {
184
+ const fastify = Fastify()
185
+ await fastify.register(fastifyFetch)
186
+
187
+ fastify.fetch.get('/fastify-request', async (request, ctx) => {
188
+ assert.ok(ctx.request)
189
+ assert.strictEqual(ctx.request.method, 'GET')
190
+ return new Response('ok')
191
+ })
192
+
193
+ await fastify.inject({
194
+ method: 'GET',
195
+ url: '/fastify-request'
196
+ })
197
+ })
198
+
199
+ test('Fastify reply accessible via ctx.reply', async () => {
200
+ const fastify = Fastify()
201
+ await fastify.register(fastifyFetch)
202
+
203
+ fastify.fetch.get('/fastify-reply', async (request, ctx) => {
204
+ assert.ok(ctx.reply)
205
+ assert.strictEqual(typeof ctx.reply.send, 'function')
206
+ return new Response('ok')
207
+ })
208
+
209
+ await fastify.inject({
210
+ method: 'GET',
211
+ url: '/fastify-reply'
212
+ })
213
+ })
214
+
215
+ test('PUT method works', async () => {
216
+ const fastify = Fastify()
217
+ await fastify.register(fastifyFetch)
218
+
219
+ fastify.fetch.put('/update', async (request, ctx) => {
220
+ const body = await request.json()
221
+ return Response.json({ updated: body })
222
+ })
223
+
224
+ const response = await fastify.inject({
225
+ method: 'PUT',
226
+ url: '/update',
227
+ headers: { 'content-type': 'application/json' },
228
+ payload: JSON.stringify({ value: 'new' })
229
+ })
230
+
231
+ assert.deepStrictEqual(JSON.parse(response.body), { updated: { value: 'new' } })
232
+ })
233
+
234
+ test('DELETE method works', async () => {
235
+ const fastify = Fastify()
236
+ await fastify.register(fastifyFetch)
237
+
238
+ fastify.fetch.delete('/remove/:id', async (request, ctx) => {
239
+ return Response.json({ deleted: ctx.params.id })
240
+ })
241
+
242
+ const response = await fastify.inject({
243
+ method: 'DELETE',
244
+ url: '/remove/456'
245
+ })
246
+
247
+ assert.deepStrictEqual(JSON.parse(response.body), { deleted: '456' })
248
+ })
249
+
250
+ test('PATCH method works', async () => {
251
+ const fastify = Fastify()
252
+ await fastify.register(fastifyFetch)
253
+
254
+ fastify.fetch.patch('/patch', async (request, ctx) => {
255
+ const body = await request.json()
256
+ return Response.json({ patched: body })
257
+ })
258
+
259
+ const response = await fastify.inject({
260
+ method: 'PATCH',
261
+ url: '/patch',
262
+ headers: { 'content-type': 'application/json' },
263
+ payload: JSON.stringify({ field: 'patched' })
264
+ })
265
+
266
+ assert.deepStrictEqual(JSON.parse(response.body), { patched: { field: 'patched' } })
267
+ })
268
+
269
+ test('OPTIONS method works', async () => {
270
+ const fastify = Fastify()
271
+ await fastify.register(fastifyFetch)
272
+
273
+ fastify.fetch.options('/cors', async (request, ctx) => {
274
+ return new Response(null, {
275
+ status: 204,
276
+ headers: { 'access-control-allow-origin': '*' }
277
+ })
278
+ })
279
+
280
+ const response = await fastify.inject({
281
+ method: 'OPTIONS',
282
+ url: '/cors'
283
+ })
284
+
285
+ assert.strictEqual(response.statusCode, 204)
286
+ assert.strictEqual(response.headers['access-control-allow-origin'], '*')
287
+ })
288
+
289
+ test('HEAD method works', async () => {
290
+ const fastify = Fastify()
291
+ await fastify.register(fastifyFetch)
292
+
293
+ fastify.fetch.head('/head', async (request, ctx) => {
294
+ return new Response(null, {
295
+ headers: { 'x-custom': 'value' }
296
+ })
297
+ })
298
+
299
+ const response = await fastify.inject({
300
+ method: 'HEAD',
301
+ url: '/head'
302
+ })
303
+
304
+ assert.strictEqual(response.statusCode, 200)
305
+ assert.strictEqual(response.headers['x-custom'], 'value')
306
+ })
307
+
308
+ test('onRequest hook fires', async () => {
309
+ const fastify = Fastify()
310
+ let hookCalled = false
311
+
312
+ fastify.addHook('onRequest', async (request, reply) => {
313
+ hookCalled = true
314
+ })
315
+
316
+ await fastify.register(fastifyFetch)
317
+
318
+ fastify.fetch.get('/hook-test', async (request, ctx) => {
319
+ return new Response('ok')
320
+ })
321
+
322
+ await fastify.inject({
323
+ method: 'GET',
324
+ url: '/hook-test'
325
+ })
326
+
327
+ assert.strictEqual(hookCalled, true)
328
+ })
329
+
330
+ test('preHandler hook fires', async () => {
331
+ const fastify = Fastify()
332
+ let hookCalled = false
333
+
334
+ fastify.addHook('preHandler', async (request, reply) => {
335
+ hookCalled = true
336
+ })
337
+
338
+ await fastify.register(fastifyFetch)
339
+
340
+ fastify.fetch.get('/prehandler-test', async (request, ctx) => {
341
+ return new Response('ok')
342
+ })
343
+
344
+ await fastify.inject({
345
+ method: 'GET',
346
+ url: '/prehandler-test'
347
+ })
348
+
349
+ assert.strictEqual(hookCalled, true)
350
+ })
351
+
352
+ test('route options are forwarded', async () => {
353
+ const fastify = Fastify()
354
+ let hookCalled = false
355
+
356
+ await fastify.register(fastifyFetch)
357
+
358
+ fastify.fetch.get('/route-options', {
359
+ preHandler: async (request, reply) => {
360
+ hookCalled = true
361
+ }
362
+ }, async (request, ctx) => {
363
+ return new Response('ok')
364
+ })
365
+
366
+ const response = await fastify.inject({
367
+ method: 'GET',
368
+ url: '/route-options'
369
+ })
370
+
371
+ assert.strictEqual(response.statusCode, 200)
372
+ assert.strictEqual(hookCalled, true)
373
+ })
374
+
375
+ test('onSend hook fires', async () => {
376
+ const fastify = Fastify()
377
+ let hookCalled = false
378
+
379
+ fastify.addHook('onSend', async (request, reply, payload) => {
380
+ hookCalled = true
381
+ return payload
382
+ })
383
+
384
+ await fastify.register(fastifyFetch)
385
+
386
+ fastify.fetch.get('/onsend-test', async (request, ctx) => {
387
+ return new Response('ok')
388
+ })
389
+
390
+ await fastify.inject({
391
+ method: 'GET',
392
+ url: '/onsend-test'
393
+ })
394
+
395
+ assert.strictEqual(hookCalled, true)
396
+ })
397
+
398
+ test('onResponse hook fires', async () => {
399
+ const fastify = Fastify()
400
+ let hookCalled = false
401
+
402
+ fastify.addHook('onResponse', async (request, reply) => {
403
+ hookCalled = true
404
+ })
405
+
406
+ await fastify.register(fastifyFetch)
407
+
408
+ fastify.fetch.get('/onresponse-test', async (request, ctx) => {
409
+ return new Response('ok')
410
+ })
411
+
412
+ await fastify.inject({
413
+ method: 'GET',
414
+ url: '/onresponse-test'
415
+ })
416
+
417
+ assert.strictEqual(hookCalled, true)
418
+ })
419
+
420
+ test('onError hook fires on handler error', async () => {
421
+ const fastify = Fastify()
422
+ let hookCalled = false
423
+
424
+ fastify.addHook('onError', async (request, reply, error) => {
425
+ hookCalled = true
426
+ })
427
+
428
+ await fastify.register(fastifyFetch)
429
+
430
+ fastify.fetch.get('/onerror-test', async (request, ctx) => {
431
+ throw new Error('test error')
432
+ })
433
+
434
+ await fastify.inject({
435
+ method: 'GET',
436
+ url: '/onerror-test'
437
+ })
438
+
439
+ assert.strictEqual(hookCalled, true)
440
+ })
441
+
442
+ test('handler must return Response object', async () => {
443
+ const fastify = Fastify()
444
+ await fastify.register(fastifyFetch)
445
+
446
+ fastify.fetch.get('/invalid-response', async (request, ctx) => {
447
+ return 'not a response'
448
+ })
449
+
450
+ const response = await fastify.inject({
451
+ method: 'GET',
452
+ url: '/invalid-response'
453
+ })
454
+
455
+ assert.strictEqual(response.statusCode, 500)
456
+ assert.ok(response.body.includes('Handler must return a Response object'))
457
+ })
458
+
459
+ test('Request must be aborted after the handler completes when enabled', async () => {
460
+ const fastify = Fastify()
461
+ await fastify.register(fastifyFetch)
462
+
463
+ let cachedRequest
464
+
465
+ fastify.fetch.get('/caching-request', { abortController: true }, async (request, ctx) => {
466
+ cachedRequest = request
467
+ return new Response('response')
468
+ })
469
+
470
+ await fastify.inject({
471
+ method: 'GET',
472
+ url: '/caching-request'
473
+ })
474
+
475
+ assert.ok(cachedRequest.signal.aborted)
476
+ })
477
+
478
+ test('Request must be aborted after the handler throws an error when enabled', async () => {
479
+ const fastify = Fastify()
480
+ await fastify.register(fastifyFetch)
481
+
482
+ let cachedRequest
483
+
484
+ fastify.fetch.get('/throwing-error', { abortController: true }, async (request, ctx) => {
485
+ cachedRequest = request
486
+ throw new Error('Unexpected exception raised')
487
+ })
488
+
489
+ await fastify.inject({
490
+ method: 'GET',
491
+ url: '/throwing-error'
492
+ })
493
+
494
+ assert.ok(cachedRequest.signal.aborted)
495
+ })
496
+ })
@@ -0,0 +1,59 @@
1
+ import { FastifyPluginAsync, FastifyInstance, FastifyBaseLogger, FastifyRequest, FastifyReply, RouteShorthandOptions } from 'fastify'
2
+
3
+ declare module 'fastify' {
4
+ interface FastifyInstance {
5
+ fetch: fastifyFetch.FetchRoutes
6
+ }
7
+ }
8
+
9
+ type FastifyFetchPlugin = FastifyPluginAsync<NonNullable<fastifyFetch.FastifyFetchOptions>>
10
+
11
+ declare namespace fastifyFetch {
12
+ export interface FetchContext {
13
+ log: FastifyBaseLogger
14
+ server: FastifyInstance
15
+ params: Record<string, string>
16
+ query: Record<string, string>
17
+ request: FastifyRequest
18
+ reply: FastifyReply
19
+ abortController?: AbortController
20
+ }
21
+
22
+ export interface FetchContextWithAbortController extends FetchContext {
23
+ abortController: AbortController
24
+ }
25
+
26
+ export interface FetchRouteOptions extends RouteShorthandOptions {
27
+ abortController?: boolean
28
+ }
29
+
30
+ export type FetchHandler = (request: Request, ctx: FetchContext) => Response | Promise<Response>
31
+ export type FetchHandlerWithAbortController = (request: Request, ctx: FetchContextWithAbortController) => Response | Promise<Response>
32
+
33
+ export interface FetchRoute {
34
+ (path: string, handler: FetchHandler): void
35
+ (path: string, options: FetchRouteOptions & { abortController: true }, handler: FetchHandlerWithAbortController): void
36
+ (path: string, options: FetchRouteOptions, handler: FetchHandler): void
37
+ }
38
+
39
+ export interface FetchRoutes {
40
+ get: FetchRoute
41
+ post: FetchRoute
42
+ put: FetchRoute
43
+ delete: FetchRoute
44
+ patch: FetchRoute
45
+ options: FetchRoute
46
+ head: FetchRoute
47
+ }
48
+
49
+ export interface FastifyFetchOptions {
50
+ }
51
+
52
+ export const fastifyFetch: FastifyFetchPlugin
53
+
54
+ export { fastifyFetch as default }
55
+ }
56
+
57
+ declare function fastifyFetch (...params: Parameters<FastifyFetchPlugin>): ReturnType<FastifyFetchPlugin>
58
+
59
+ export = fastifyFetch
@@ -0,0 +1,108 @@
1
+ import fastify, {
2
+ FastifyInstance,
3
+ FastifyPluginAsync,
4
+ FastifyBaseLogger,
5
+ FastifyRequest,
6
+ FastifyReply
7
+ } from 'fastify'
8
+ import { expect } from 'tstyche'
9
+ import * as fastifyFetchStar from '..'
10
+ import fastifyFetch, {
11
+ FetchContext,
12
+ FetchContextWithAbortController,
13
+ FetchHandler,
14
+ FetchHandlerWithAbortController,
15
+ FetchRouteOptions,
16
+ FetchRoutes,
17
+ fastifyFetch as fastifyFetchNamed
18
+ } from '..'
19
+
20
+ import fastifyFetchCjsImport = require('..')
21
+ const fastifyFetchCjs = require('../..')
22
+
23
+ const app: FastifyInstance = fastify()
24
+
25
+ app.register(fastifyFetch)
26
+ app.register(fastifyFetchNamed)
27
+ app.register(fastifyFetchCjs)
28
+ app.register(fastifyFetchCjsImport.default)
29
+ app.register(fastifyFetchCjsImport.fastifyFetch)
30
+ app.register(fastifyFetchStar.default)
31
+ app.register(fastifyFetchStar.fastifyFetch)
32
+
33
+ expect(fastifyFetch).type.toBe<FastifyPluginAsync>()
34
+ expect(fastifyFetchNamed).type.toBe<FastifyPluginAsync>()
35
+ expect(fastifyFetchCjs).type.toBe<any>()
36
+
37
+ app.register(fastifyFetch).after(() => {
38
+ expect(app.fetch).type.toBe<FetchRoutes>()
39
+
40
+ app.fetch.get('/test', async (request, ctx) => {
41
+ expect(request).type.toBe<Request>()
42
+ expect(ctx).type.toBe<FetchContext>()
43
+ expect(ctx.log).type.toBe<FastifyBaseLogger>()
44
+ expect(ctx.server).type.toBe<FastifyInstance>()
45
+ expect(ctx.params).type.toBe<Record<string, string>>()
46
+ expect(ctx.query).type.toBe<Record<string, string>>()
47
+ expect(ctx.request).type.toBe<FastifyRequest>()
48
+ expect(ctx.reply).type.toBe<FastifyReply>()
49
+ expect(ctx.abortController).type.toBe<AbortController | undefined>()
50
+ return new Response('ok')
51
+ })
52
+
53
+ app.fetch.get('/abort', { abortController: true }, async (request, ctx) => {
54
+ expect(request).type.toBe<Request>()
55
+ expect(ctx).type.toBe<FetchContextWithAbortController>()
56
+ expect(ctx.abortController).type.toBe<AbortController>()
57
+ return new Response('ok')
58
+ })
59
+
60
+ app.fetch.post('/data', async (request, ctx) => {
61
+ return Response.json({ ok: true })
62
+ })
63
+
64
+ app.fetch.put('/update', async (request, ctx) => {
65
+ return new Response('updated')
66
+ })
67
+
68
+ app.fetch.delete('/remove', async (request, ctx) => {
69
+ return new Response('deleted')
70
+ })
71
+
72
+ app.fetch.patch('/patch', async (request, ctx) => {
73
+ return new Response('patched')
74
+ })
75
+
76
+ app.fetch.options('/options', async (request, ctx) => {
77
+ return new Response(null, { status: 204 })
78
+ })
79
+
80
+ app.fetch.head('/head', async (request, ctx) => {
81
+ return new Response(null)
82
+ })
83
+ })
84
+
85
+ const handler: FetchHandler = async (request, ctx) => {
86
+ return new Response('handler')
87
+ }
88
+
89
+ expect(handler).type.toBe<FetchHandler>()
90
+
91
+ const handlerWithAbortController: FetchHandlerWithAbortController = async (request, ctx) => {
92
+ expect(ctx.abortController).type.toBe<AbortController>()
93
+ return new Response('handler')
94
+ }
95
+
96
+ expect(handlerWithAbortController).type.toBe<FetchHandlerWithAbortController>()
97
+
98
+ const options: FetchRouteOptions = {
99
+ abortController: true,
100
+ schema: {
101
+ response: {
102
+ 200: {
103
+ type: 'string'
104
+ }
105
+ }
106
+ }
107
+ }
108
+ expect(options).type.toBe<FetchRouteOptions>()