├── .github
└── workflows
│ ├── main.yml
│ └── publish.yml
├── .gitignore
├── LICENSE
├── README.md
├── jest.config.js
├── package.json
├── src
├── core.ts
├── createExecutor.ts
├── createSubscription.ts
├── envelop.ts
├── helix-flare.ts
├── index.ts
├── sse
│ ├── fetchEventSource.ts
│ ├── getPushResponseSSE.ts
│ ├── parseSSE.ts
│ └── writeToStream.ts
└── utils
│ ├── HttpError.ts
│ ├── createAccessHeaders.ts
│ ├── createHelixRequest.ts
│ ├── getArguments.ts
│ └── getResponse.ts
├── test
├── createAccessHeaders.test.ts
├── createSubscription.test.ts
├── fetchEventSource.test.ts
└── integration
│ ├── durable-object.test.ts
│ ├── durable-object.worker.ts
│ ├── executor-error.worker.ts
│ ├── index.test.ts
│ ├── index.worker.ts
│ ├── sse.test.ts
│ ├── sse.worker.ts
│ └── utils.ts
├── tsconfig.json
├── tsup.config.ts
└── yarn.lock
/.github/workflows/main.yml:
--------------------------------------------------------------------------------
1 | name: Build and test
2 |
3 | on:
4 | push:
5 | branches: [ main ]
6 | tags-ignore:
7 | - '*'
8 | pull_request:
9 | branches: [ main ]
10 | workflow_dispatch:
11 |
12 | jobs:
13 | run:
14 | runs-on: ubuntu-latest
15 |
16 | steps:
17 | - uses: actions/checkout@v2
18 | - uses: actions/setup-node@v2
19 | with:
20 | node-version: '16'
21 | cache: 'yarn'
22 | - run: yarn install --frozen-lockfile
23 | - run: yarn build
24 | - run: yarn test
25 |
--------------------------------------------------------------------------------
/.github/workflows/publish.yml:
--------------------------------------------------------------------------------
1 | name: Publish
2 |
3 | on:
4 | push:
5 | branches:
6 | - "!*"
7 | tags:
8 | - "v*"
9 |
10 | jobs:
11 | publish:
12 | runs-on: ubuntu-latest
13 | steps:
14 | - uses: actions/checkout@v2
15 | - uses: actions/setup-node@v2
16 | with:
17 | node-version: '16'
18 | cache: 'yarn'
19 | - run: yarn install --frozen-lockfile
20 | - run: yarn build
21 | - run: yarn test
22 | - uses: JS-DevTools/npm-publish@v1
23 | with:
24 | token: ${{ secrets.NPM_TOKEN }}
25 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | dist
2 | node_modules
3 | coverage
4 | .env
5 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 launchport
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.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # helix-flare
2 |
3 | `helix-flare` helps you build GraphQL services on [Cloudflare Workers®](https://workers.cloudflare.com/) in an instant.
4 |
5 | With help of the great library [`graphql-helix`](https://github.com/contrawork/graphql-helix) this is made possible.
6 |
7 | ## Features
8 |
9 | - Build GraphQL server on Cloudflare Workers in seconds
10 | - Delegate execution to [Durable Objects](https://developers.cloudflare.com/workers/runtime-apis/durable-objects). Workers will only act as a proxy.
11 | - Have one schema, resolve some things in your DO, others in the Worker
12 | - Add middlewares and context
13 | - Live subscriptions (over [server-sent events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events))
14 | - Easy to use with [`envelop`](https://github.com/dotansimha/envelop)
15 | - Full type safety with Typescript
16 |
17 | ## Upcoming
18 |
19 | - Combine multiple worker to one endpoint (stitch)
20 |
21 | ## Installation
22 |
23 | ```sh
24 | yarn add helix-flare
25 |
26 | ## or
27 |
28 | npm install --save helix-flare
29 | ```
30 |
31 | ## API
32 |
33 | ### `helixFlare(request: Request, schema: GraphQLSchema)`
34 |
35 | **Returns: Promise<Response>
**
36 |
37 | This will take a request from a worker (or durable object) and return a response via GraphQL.
38 |
39 | All you need is:
40 |
41 | ```ts
42 | import helixFlare from 'helix-flare'
43 | import { makeExecutableSchema } from '@graphql-tools/schema'
44 |
45 | export default {
46 | async fetch(request: Request) {
47 | const typeDefs = /* GraphQL */ `
48 | type Query {
49 | hello: String!
50 | }
51 | `
52 | const schema = makeExecutableSchema({
53 | typeDefs,
54 | resolvers: {
55 | Query: { hello: () => 'Hello World 🌍' },
56 | },
57 | })
58 |
59 | return helixFlare(request, schema)
60 | },
61 | }
62 | ```
63 |
64 | With just a few lines you got your GraphQL server up and running.
65 |
66 | **Example call to worker:**
67 |
68 | ```ts
69 | const workerURL = 'https://my.worker.dev/graphql'
70 |
71 | fetch(workerURL, {
72 | body: JSON.stringify({ query: '{ hello }' }),
73 | method: 'POST',
74 | headers: { 'Content-Type': 'application/json' },
75 | })
76 |
77 | // ➜ 'Hello World 🌍'
78 | ```
79 |
80 | _Head to the [GraphQL docs](https://graphql.org/) for more information on how to build a GraphQL server._
81 |
82 | ### `createExecutor(request, selectDurableObject)`
83 |
84 | Allows you to resolve a query by forwarding the request to a durable object. The durable object can be selected by inspecting the graphql query in the **`selectDurableObject`** callback.
85 |
86 | **Returns: AsyncExecutor
**
87 |
88 | #### `request: Request`
89 |
90 | The request passed to the worker or durable object.
91 |
92 | #### `selectDurableObject: (args, context) => Promise`
93 |
94 | With this callback function you can select which durable object this request should be delegated to.
95 |
96 | ```ts
97 | import helixFlare, { createExecutor } from 'helix-flare'
98 | import { makeExecutableSchema } from '@graphql-tools/schema'
99 | import { wrapSchema } from '@graphql-tools/wrap'
100 |
101 | export default {
102 | async fetch(request, env) {
103 | const schema = wrapSchema({
104 | schema: makeExecutableSchema({
105 | // type defs and resolvers here…
106 | }),
107 | // with this executor the requests will be delegated a durable object
108 | executor: createExecutor(request, async (args) => {
109 | return env.DURABLE_OBJECT.get(args.userId)
110 | }),
111 | })
112 |
113 | return helixFlare(request, schema)
114 | },
115 | }
116 | ```
117 |
118 | ### `createSubscription(options)`
119 |
120 | **Returns: [emitter, resolver]
**
121 |
122 | Inspired by hooks this function returns an emitter and a resolver as a tuple.
123 | With the emitter you can publish new events to the client. The resolver can just be used as is and put into the resolvers of your schema.
124 |
125 | #### `topic`
126 |
127 | **Type: string
**
128 |
129 | An identifier for the subscription that is used internally.
130 |
131 | #### `resolve`
132 |
133 | **Type: Function
**
134 | **Default: (value) => value
**
135 |
136 | This is to make subscription emissions less verbose. _See example below for more clarity._
137 |
138 | #### `getInitialValue`
139 |
140 | **Type: Function
**
141 | **Default: undefined
**
142 |
143 | ```ts
144 | import helixFlare, { createSubscription } from 'helix-flare'
145 | import { makeExecutableSchema } from '@graphql-tools/schema'
146 |
147 | export default {
148 | async fetch(request, env) {
149 | const [emit, resolver] = createSubscription({
150 | topic: 'comments',
151 | })
152 |
153 | const typeDefs = /* GraphQL */ `
154 | type Subscription {
155 | comments($id: ID!): [String!]!
156 | }
157 | `
158 |
159 | const schema = makeExecutableSchema({
160 | typeDefs,
161 | resolvers: {
162 | comments: {
163 | subscribe: resolver,
164 | },
165 | },
166 | })
167 |
168 | return helixFlare(request, schema)
169 |
170 | // Now you can emit new comments like so:
171 | emit({ comments: 'This is a new comment 💬' })
172 | },
173 | }
174 | ```
175 |
176 | To avoid repeating the need to emit the structure of the subscription resolver everytime you can use the `resolve` option:
177 |
178 | ```ts
179 | const [emit, resolver] = createSubscription({
180 | topic: 'comments',
181 | resolve: (value) => ({ comments: value }),
182 | })
183 |
184 | // Now you can simply just emit the following
185 | emit('This is a new comment 💬')
186 | ```
187 |
188 | ### Usage with [`envelop`](https://github.com/dotansimha/envelop)
189 |
190 | ```ts
191 | import helixFlare from 'helix-flare/envelop'
192 | import { envelop, useSchema } from '@envelop/core'
193 |
194 | const schema = `…`
195 |
196 | const getEnvelopedFn = envelop({
197 | plugins: [
198 | useSchema(schema),
199 | // add other envelop plugins here…
200 | ],
201 | })
202 |
203 | // worker
204 | export default {
205 | fetch(request: Request) {
206 | return helixFlare(request, getEnvelopedFn)
207 | },
208 | }
209 | ```
210 |
211 | ## Examples
212 |
213 |
214 | Simple resolver with arguments
215 |
216 | ```ts
217 | import helixFlare from 'helix-flare'
218 | import { makeExecutableSchema } from '@graphql-tools/schema'
219 |
220 | export default {
221 | async fetch(request: Request) {
222 | const typeDefs = /* GraphQL */ `
223 | type Query {
224 | hello(name: String!): String!
225 | }
226 | `
227 |
228 | const schema = makeExecutableSchema({
229 | typeDefs,
230 | resolvers: {
231 | Query: {
232 | user: (_, { name }) => `Hello ${name}!`,
233 | },
234 | },
235 | })
236 |
237 | return helixFlare(request, schema)
238 | },
239 | }
240 | ```
241 |
242 |
243 |
244 |
245 | Delegate execution to durable objects
246 |
247 | ```ts
248 | // worker.ts
249 | import helixFlare, { createExecutor } from 'helix-flare'
250 | import { makeExecutableSchema } from '@graphql-tools/schema'
251 | import { wrapSchema } from '@graphql-tools/wrap'
252 |
253 | const typeDefs = /* GraphQL */ `
254 | type Post {
255 | id: Int!
256 | title: String
257 | votes: Int
258 | }
259 |
260 | type Mutation {
261 | upvotePost(postId: Int!): Post
262 | }
263 | `
264 | export default {
265 | async fetch(request: Request, env: Env) {
266 | const schema = wrapSchema({
267 | schema: makeExecutableSchema({ typeDefs }),
268 | executor: createExecutor<{ postId?: string }>(request, async (args) => {
269 | if (!args.postId) {
270 | throw new Error('No postId argument found')
271 | }
272 |
273 | const doId = env.PostDurableObject.idFromString(args.postId)
274 | return env.PostDurableObject.get(doId)
275 | }),
276 | })
277 |
278 | return helixFlare(request, schema)
279 | },
280 | }
281 | ```
282 |
283 |
284 |
285 |
286 | Subscriptions in a durable object over server-sent events
287 |
288 | Subscriptions work out of the box with [SSE](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events). They can be done in worker but will be used in durable objects most of the time.
289 |
290 | **Shared schema**:
291 |
292 | ```ts
293 | // schema.ts
294 | const schema = /* GraphQL */ `
295 | type Post {
296 | id: Int!
297 | votes: Int
298 | }
299 |
300 | type Subscription {
301 | """
302 | Returns the positions for given live Id
303 | """
304 | subscribePostVotes(postId: Int!): Int!
305 | }
306 |
307 | type Mutation {
308 | upvotePost(postId: Int!): Post
309 | }
310 | `
311 | export default schema
312 | ```
313 |
314 | ```ts
315 | // worker.ts
316 | import helixFlare, { createExecutor } from 'helix-flare'
317 | import { makeExecutableSchema } from '@graphql-tools/schema'
318 | import { wrapSchema } from '@graphql-tools/wrap'
319 | import typeDefs from './schema'
320 |
321 | export { Post } from './PostObject'
322 |
323 | // ExportedHandler from `@cloudflare/workers-types`
324 | type WorkerType = ExportedHandler<{ PostDurableObject: DurableObjectStub }>
325 |
326 | const Worker: WorkerType = {
327 | async fetch(request, env) {
328 | const schema = wrapSchema({
329 | schema: makeExecutableSchema({ typeDefs }),
330 | executor: createExecutor(request, async (args, context) => {
331 | if (!args.postId) {
332 | throw new Error('No postId argument found')
333 | }
334 |
335 | const doId = env.PostDurableObject.idFromString(args.postId)
336 |
337 | return env.PostDurableObject.get(doId)
338 | }),
339 | })
340 |
341 | return helixFlare(request, schema)
342 | },
343 | }
344 |
345 | export default Worker
346 | ```
347 |
348 | ```ts
349 | // PostObject.ts
350 | import { makeExecutableSchema } from '@graphql-tools/schema'
351 | import { wrapSchema } from '@graphql-tools/wrap'
352 | import helixFlare, { createExecutor, createSubscription } from 'helix-flare'
353 | import typeDefs from './typedefs'
354 |
355 | export class Post implements DurableObject {
356 | private likes = 0
357 |
358 | async fetch() {
359 | const [emitLikes, likesSubscriptionResolver] = createSubscription<
360 | number,
361 | { subscribePostVotes: number }
362 | >({
363 | topic: 'likes',
364 | resolve: (value) => ({ subscribePostVotes: value }),
365 | getInitialValue: () => this.likes,
366 | })
367 |
368 | const resolvers = {
369 | Mutation: {
370 | upvotePost: () => {
371 | this.likes++
372 | emitLikes(this.likes)
373 |
374 | return { likes: this.likes, id: this.state.id }
375 | },
376 | },
377 | Subscription: {
378 | subscribePostVotes: {
379 | subscribe: likesSubscriptionResolver,
380 | },
381 | },
382 | }
383 |
384 | const schema = makeExecutableSchema({
385 | resolvers,
386 | typeDefs,
387 | })
388 |
389 | return helixFlare(request, schema)
390 | }
391 | }
392 | ```
393 |
394 |
395 |
396 |
397 | Combine multiple worker to one endpoint (stitching)
398 |
399 | `@todo`
400 |
401 |
402 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('@jest/types').Config.InitialOptions} */
2 | const config = {
3 | testEnvironment: 'miniflare',
4 | preset: 'ts-jest/presets/default-esm',
5 | globals: { 'ts-jest': { useESM: true } },
6 | transform: {},
7 | moduleNameMapper: {
8 | '^(\\.{1,2}/.*)\\.js$': '$1',
9 | },
10 | }
11 |
12 | export default config
13 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "helix-flare",
3 | "version": "2.0.0",
4 | "license": "MIT",
5 | "contributors": [
6 | "dan-lee ",
7 | "mosch "
8 | ],
9 | "repository": "https://github.com/launchport/helix-flare",
10 | "description": "GraphQL Helix for your Cloudflare Workers",
11 | "type": "module",
12 | "main": "./dist/index.cjs",
13 | "types": "dist/index.d.ts",
14 | "exports": {
15 | ".": {
16 | "import": "./dist/index.js",
17 | "require": "./dist/index.cjs"
18 | },
19 | "./*": {
20 | "import": "./dist/*.js",
21 | "require": "./dist/*.cjs"
22 | }
23 | },
24 | "typesVersions": {
25 | "*": {
26 | "*": [
27 | "dist/index.d.ts"
28 | ],
29 | "envelop": [
30 | "dist/envelop.d.ts"
31 | ]
32 | }
33 | },
34 | "engines": {
35 | "node": ">=16"
36 | },
37 | "files": [
38 | "dist"
39 | ],
40 | "scripts": {
41 | "build": "tsup",
42 | "test": "NODE_OPTIONS=--experimental-vm-modules yarn jest --forceExit",
43 | "prepublishOnly": "yarn tsc --noEmit && yarn test && yarn build"
44 | },
45 | "prettier": {
46 | "singleQuote": true,
47 | "semi": false,
48 | "trailingComma": "all"
49 | },
50 | "keywords": [
51 | "graphql",
52 | "subscriptions",
53 | "sse",
54 | "cloudflare",
55 | "cloudflare-workers",
56 | "graphql-helix"
57 | ],
58 | "peerDependencies": {
59 | "@envelop/core": "^2.0.0",
60 | "graphql": "15 || 16"
61 | },
62 | "peerDependenciesMeta": {
63 | "@envelop/core": {
64 | "optional": true
65 | }
66 | },
67 | "resolutions": {
68 | "undici": "4.12.x"
69 | },
70 | "dependencies": {
71 | "event-iterator": "^2",
72 | "graphql-helix": "1.12.0",
73 | "graphql-middleware": "^6.1.21"
74 | },
75 | "devDependencies": {
76 | "@cloudflare/workers-types": "^3.4.0",
77 | "@envelop/core": "2.1.0",
78 | "@graphql-tools/schema": "^8.3.6",
79 | "@graphql-tools/wrap": "^8.4.10",
80 | "@types/jest": "27.4.1",
81 | "esbuild": "^0.14.29",
82 | "graphql": "16.3.0",
83 | "graphql-sse": "^1.1.0",
84 | "jest": "^27.5.1",
85 | "jest-environment-miniflare": "2.3.0",
86 | "jest-environment-node": "^27.5.1",
87 | "miniflare": "2.3.0",
88 | "nanoevents": "6.0.2",
89 | "prettier": "^2.6.1",
90 | "source-map-support": "^0.5.21",
91 | "tiny-glob": "0.2.9",
92 | "ts-jest": "^27.1.4",
93 | "tsup": "^5.12.1",
94 | "typescript": "4.6.3",
95 | "undici": "4.12.2"
96 | }
97 | }
98 |
--------------------------------------------------------------------------------
/src/core.ts:
--------------------------------------------------------------------------------
1 | import {
2 | getGraphQLParameters,
3 | getMultipartResponse,
4 | processRequest,
5 | type ProcessRequestOptions,
6 | } from 'graphql-helix'
7 |
8 | import {
9 | createAccessHeaders,
10 | type CreateAccessHeadersOptions,
11 | } from './utils/createAccessHeaders'
12 | import { createHelixRequest } from './utils/createHelixRequest'
13 | import getPushResponseSSE from './sse/getPushResponseSSE'
14 | import getResponse from './utils/getResponse'
15 |
16 | export type SharedOptions = {
17 | access?: CreateAccessHeadersOptions
18 | }
19 |
20 | type Options = SharedOptions & {
21 | request: Request
22 | } & Pick<
23 | ProcessRequestOptions,
24 | 'parse' | 'validate' | 'contextFactory' | 'execute' | 'schema'
25 | >
26 |
27 | const core = async ({
28 | request,
29 | schema,
30 | parse,
31 | validate,
32 | execute,
33 | contextFactory,
34 | access,
35 | }: Options) => {
36 | const cors = createAccessHeaders(access)
37 | const { isPreflight, headers } = cors(request)
38 |
39 | if (isPreflight) {
40 | return new Response(null, { status: 204, headers })
41 | }
42 |
43 | const helixRequest = await createHelixRequest(request)
44 |
45 | const { operationName, query, variables } = getGraphQLParameters(helixRequest)
46 |
47 | const result = await processRequest({
48 | operationName,
49 | query,
50 | variables,
51 | request: helixRequest,
52 | schema,
53 | parse,
54 | validate,
55 | execute,
56 | contextFactory,
57 | })
58 |
59 | switch (result.type) {
60 | case 'RESPONSE':
61 | return getResponse(result, headers)
62 | case 'PUSH':
63 | // @todo cors headers
64 | return getPushResponseSSE(result, request)
65 | case 'MULTIPART_RESPONSE':
66 | return getMultipartResponse(result, Response, ReadableStream as any)
67 | default:
68 | return new Response('Not supported.', { status: 405 })
69 | }
70 | }
71 |
72 | export default core
73 |
--------------------------------------------------------------------------------
/src/createExecutor.ts:
--------------------------------------------------------------------------------
1 | import {
2 | observableToAsyncIterable,
3 | type AsyncExecutor,
4 | } from '@graphql-tools/utils'
5 | import { print } from 'graphql'
6 |
7 | import { fetchEventSource } from './sse/fetchEventSource'
8 | import getArguments from './utils/getArguments'
9 |
10 | export function createExecutor<
11 | TArgs extends Record,
12 | TContext extends Record = Record,
13 | >(
14 | request: Request,
15 | selectDurableObject: (
16 | args: TArgs,
17 | context: TContext,
18 | ) => Promise,
19 | ): AsyncExecutor {
20 | return async ({ variables, document, info, context }) => {
21 | if (!info) {
22 | throw new Error('No query info available.')
23 | }
24 |
25 | const query = print(document)
26 |
27 | const args = getArguments(info)
28 | const durableObject = await selectDurableObject(
29 | args,
30 | context || ({} as TContext),
31 | )
32 |
33 | const body = JSON.stringify({ query, variables })
34 | const headers = Object.fromEntries(request.headers.entries())
35 |
36 | if (request.headers.get('accept') === 'text/event-stream') {
37 | return observableToAsyncIterable({
38 | subscribe: ({ next, complete, error }) => {
39 | fetchEventSource(request.url, {
40 | method: 'POST',
41 | body,
42 | headers,
43 | fetch: durableObject.fetch.bind(durableObject),
44 |
45 | onClose: () => complete(),
46 | onMessage: (message) => {
47 | // ping
48 | if (message.data === '' && message.event === '') {
49 | return
50 | }
51 |
52 | if (message.data) {
53 | next(JSON.parse(message.data))
54 |
55 | if (message.event === 'complete') {
56 | complete()
57 | }
58 | }
59 | },
60 | onError: (e) => error(e),
61 | })
62 |
63 | return {
64 | unsubscribe: () => undefined,
65 | }
66 | },
67 | })
68 | } else {
69 | try {
70 | const response = await durableObject.fetch(request.url, {
71 | method: request.method,
72 | body,
73 | headers,
74 | })
75 |
76 | return await response.json()
77 | } catch (e: any) {
78 | // for some reason the errors may not origin from `Error` in this case
79 | // we receive `Unexpected error value: {}` which is not very meaningful
80 | // wrapping it with Error will transport its original meaning
81 | if (e instanceof Error) {
82 | throw e
83 | } else {
84 | throw new Error(e)
85 | }
86 | }
87 | }
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/src/createSubscription.ts:
--------------------------------------------------------------------------------
1 | import { createNanoEvents } from 'nanoevents'
2 | import { EventIterator } from 'event-iterator'
3 |
4 | export const STOP = Symbol('STOP')
5 |
6 | const events = createNanoEvents()
7 |
8 | const subscribeToNanoEvent = (event: string) =>
9 | new EventIterator(({ push, stop }) => {
10 | const unsubscribe = events.on(event, (data: TValue | typeof STOP) => {
11 | if (data === STOP) {
12 | stop()
13 | } else {
14 | push(data)
15 | }
16 | })
17 |
18 | return () => unsubscribe()
19 | })
20 |
21 | export const createSubscription = ({
22 | topic,
23 | resolve = (value) => value as unknown as TResolved,
24 | getInitialValue,
25 | }: {
26 | topic: string
27 | resolve?: (value: TValue) => Promise | TResolved
28 | getInitialValue?: () => Promise | TValue
29 | }) => {
30 | const emitter = (value: TValue | typeof STOP) => events.emit(topic, value)
31 |
32 | const resolver = async function* () {
33 | const iterator = subscribeToNanoEvent(topic)
34 | if (getInitialValue) {
35 | yield await resolve(await getInitialValue())
36 | }
37 |
38 | for await (const value of iterator) {
39 | yield await resolve(value)
40 | }
41 | }
42 |
43 | return [emitter, resolver] as const
44 | }
45 |
--------------------------------------------------------------------------------
/src/envelop.ts:
--------------------------------------------------------------------------------
1 | import { type GetEnvelopedFn } from '@envelop/core'
2 | import core, { type SharedOptions } from './core'
3 |
4 | const helixFlareEnvelop = async (
5 | request: Request,
6 | getEnvelopedFn: GetEnvelopedFn,
7 | { access }: SharedOptions | undefined = {},
8 | ) => {
9 | const { schema, parse, validate, execute, contextFactory } = getEnvelopedFn({
10 | req: request,
11 | })
12 |
13 | return core({
14 | request,
15 | schema,
16 | parse,
17 | validate,
18 | execute,
19 | contextFactory,
20 | access,
21 | })
22 | }
23 |
24 | export default helixFlareEnvelop
25 |
--------------------------------------------------------------------------------
/src/helix-flare.ts:
--------------------------------------------------------------------------------
1 | import { applyMiddleware, type IMiddleware } from 'graphql-middleware'
2 | import {
3 | shouldRenderGraphiQL as helixShouldRenderGraphiQL,
4 | renderGraphiQL as helixRenderGraphiQL,
5 | type ProcessRequestOptions,
6 | } from 'graphql-helix'
7 | import type { GraphQLSchema } from 'graphql'
8 |
9 | import core, { type SharedOptions } from './core'
10 | import { createHelixRequest } from './utils/createHelixRequest'
11 |
12 | type Options = SharedOptions & {
13 | middlewares?: IMiddleware[]
14 | contextFactory?: ProcessRequestOptions['contextFactory']
15 | }
16 |
17 | export const shouldRenderGraphiQL = async (request: Request) => {
18 | try {
19 | return helixShouldRenderGraphiQL(request)
20 | } catch {
21 | return false
22 | }
23 | }
24 |
25 | export const getGraphiQLResponse = () => {
26 | return new Response(helixRenderGraphiQL(), {
27 | status: 200,
28 | headers: {
29 | 'Content-Type': 'text/html',
30 | },
31 | })
32 | }
33 |
34 | const helixFlare = async (
35 | request: Request,
36 | schema: GraphQLSchema,
37 | { middlewares = [], access, contextFactory }: Options = {},
38 | ) => {
39 | try {
40 | return core({
41 | request,
42 | schema: applyMiddleware(schema, ...middlewares),
43 | contextFactory,
44 | access,
45 | })
46 | } catch (e) {
47 | if (e instanceof SyntaxError) {
48 | return new Response('Bad request', {
49 | status: 400,
50 | headers: { 'Content-Type': 'text/plain' },
51 | })
52 | }
53 | throw e
54 | }
55 | }
56 |
57 | export default helixFlare
58 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | export {
2 | default as default,
3 | shouldRenderGraphiQL,
4 | getGraphiQLResponse,
5 | } from './helix-flare'
6 | export { createExecutor } from './createExecutor'
7 | export { createSubscription, STOP } from './createSubscription'
8 |
--------------------------------------------------------------------------------
/src/sse/fetchEventSource.ts:
--------------------------------------------------------------------------------
1 | /**
2 | This file was taken from https://github.com/Azure/fetch-event-source and has been modified for `helix-flare`
3 |
4 | MIT License
5 | Copyright (c) Microsoft Corporation.
6 | Permission is hereby granted, free of charge, to any person obtaining a copy
7 | of this software and associated documentation files (the "Software"), to deal
8 | in the Software without restriction, including without limitation the rights
9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10 | copies of the Software, and to permit persons to whom the Software is
11 | furnished to do so, subject to the following conditions:
12 |
13 | The above copyright notice and this permission notice shall be included in all
14 | copies or substantial portions of the Software.
15 |
16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 | SOFTWARE
23 | **/
24 | import { getBytes, getLines, getMessages } from './parseSSE'
25 | import type { EventSourceMessage } from './parseSSE'
26 |
27 | const DefaultRetryInterval = 1000
28 | const LastEventId = 'last-event-id'
29 |
30 | export interface FetchEventSourceInit extends RequestInit {
31 | headers?: Record
32 | onMessage?: (ev: EventSourceMessage) => void
33 | onClose?: () => void
34 | onError?: (err: any) => number | null | undefined | void
35 | fetch?: typeof fetch
36 | }
37 |
38 | export function fetchEventSource(
39 | input: Request | string,
40 | {
41 | signal: inputSignal,
42 | headers: inputHeaders,
43 | onMessage,
44 | onClose,
45 | onError,
46 | fetch: fetchFn = fetch,
47 | ...rest
48 | }: FetchEventSourceInit,
49 | ) {
50 | return new Promise((resolve, reject) => {
51 | const headers = { ...inputHeaders }
52 | if (!headers.accept) {
53 | headers.accept = 'text/event-stream'
54 | }
55 |
56 | const requestController = new AbortController()
57 |
58 | let retryInterval = DefaultRetryInterval
59 | let retryTimer: ReturnType
60 | function dispose() {
61 | clearTimeout(retryTimer)
62 | requestController.abort()
63 | }
64 |
65 | ;(inputSignal as any)?.addEventListener('abort', () => {
66 | dispose()
67 | resolve()
68 | })
69 |
70 | async function execute() {
71 | try {
72 | const response = await fetchFn(input, {
73 | ...rest,
74 | headers,
75 | signal: requestController.signal,
76 | })
77 |
78 | await getBytes(
79 | response.body!,
80 | getLines(
81 | getMessages(
82 | (id) => {
83 | if (id) {
84 | // store the id and send it back on the next retry:
85 | headers[LastEventId] = id
86 | } else {
87 | // don't send the last-event-id header anymore:
88 | delete headers[LastEventId]
89 | }
90 | },
91 | (retry) => {
92 | retryInterval = retry
93 | },
94 | onMessage,
95 | ),
96 | ),
97 | )
98 |
99 | onClose?.()
100 | dispose()
101 | resolve()
102 | } catch (err) {
103 | if (!requestController.signal.aborted) {
104 | // if we haven't aborted the request ourselves:
105 | try {
106 | // check if we need to retry:
107 | const interval: any = onError?.(err) ?? retryInterval
108 | clearTimeout(retryTimer)
109 | retryTimer = setTimeout(execute, interval)
110 | } catch (innerErr) {
111 | // we should not retry anymore:
112 | dispose()
113 | reject(innerErr)
114 | }
115 | }
116 | }
117 | }
118 | execute()
119 | })
120 | }
121 |
--------------------------------------------------------------------------------
/src/sse/getPushResponseSSE.ts:
--------------------------------------------------------------------------------
1 | import { writeToStream } from './writeToStream'
2 | import { type Push } from 'graphql-helix'
3 |
4 | const getPushResponseSSE = (result: Push, request: Request) => {
5 | const { readable, writable } = new TransformStream()
6 | const stream = writable.getWriter()
7 |
8 | const intervalId = setInterval(() => {
9 | writeToStream(stream, ':\n\n')
10 | }, 15000)
11 |
12 | ;(request.signal as any)?.addEventListener('abort', () => {
13 | clearInterval(intervalId)
14 | })
15 |
16 | result
17 | .subscribe(async (data) => {
18 | await writeToStream(stream, {
19 | event: 'next',
20 | data: JSON.stringify(data),
21 | })
22 | })
23 | .then(async () => {
24 | clearInterval(intervalId)
25 | await writeToStream(stream, { event: 'complete' })
26 | await stream.close()
27 | })
28 |
29 | return new Response(readable, {
30 | status: 200,
31 | headers: {
32 | 'Content-Type': 'text/event-stream',
33 | 'Cache-Control': 'no-cache',
34 | Connection: 'keep-alive',
35 | 'Access-Control-Allow-Origin': '*',
36 | 'Access-Control-Allow-Headers':
37 | 'Origin, X-Requested-With, Content-Type, Accept',
38 | },
39 | })
40 | }
41 |
42 | export default getPushResponseSSE
43 |
--------------------------------------------------------------------------------
/src/sse/parseSSE.ts:
--------------------------------------------------------------------------------
1 | /**
2 | This file was taken from https://github.com/Azure/fetch-event-source and has been modified for `helix-flare`
3 |
4 | MIT License
5 | Copyright (c) Microsoft Corporation.
6 | Permission is hereby granted, free of charge, to any person obtaining a copy
7 | of this software and associated documentation files (the "Software"), to deal
8 | in the Software without restriction, including without limitation the rights
9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10 | copies of the Software, and to permit persons to whom the Software is
11 | furnished to do so, subject to the following conditions:
12 |
13 | The above copyright notice and this permission notice shall be included in all
14 | copies or substantial portions of the Software.
15 |
16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 | SOFTWARE
23 | **/
24 |
25 | /**
26 | * Represents a message sent in an event stream
27 | * https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#Event_stream_format
28 | */
29 | export interface EventSourceMessage {
30 | /** The event ID to set the EventSource object's last event ID value. */
31 | id: string
32 | /** A string identifying the type of event described. */
33 | event: string
34 | /** The event data */
35 | data: string
36 | /** The reconnection interval (in milliseconds) to wait before retrying the connection */
37 | retry?: number
38 | }
39 |
40 | /**
41 | * Converts a ReadableStream into a callback pattern.
42 | */
43 | export async function getBytes(
44 | stream: ReadableStream,
45 | onChunk: (arr: Uint8Array) => void,
46 | ) {
47 | const reader = stream.getReader()
48 | let result: ReadableStreamReadResult
49 | while (!(result = await reader.read()).done) {
50 | onChunk(result.value)
51 | }
52 | }
53 |
54 | const enum ControlChars {
55 | NewLine = 10,
56 | CarriageReturn = 13,
57 | Space = 32,
58 | Colon = 58,
59 | }
60 |
61 | /**
62 | * Parses arbitrary byte chunks into EventSource line buffers.
63 | * Each line should be of the format "field: value" and ends with \r, \n, or \r\n.
64 | * @param onLine A function that will be called on each new EventSource line.
65 | * @returns A function that should be called for each incoming byte chunk.
66 | */
67 | export function getLines(
68 | onLine: (line: Uint8Array, fieldLength: number) => void,
69 | ) {
70 | let buffer: Uint8Array | undefined
71 | let position: number // current read position
72 | let fieldLength: number // length of the `field` portion of the line
73 | let discardTrailingNewline = false
74 |
75 | // return a function that can process each incoming byte chunk:
76 | return function onChunk(arr: Uint8Array) {
77 | if (buffer === undefined) {
78 | buffer = arr
79 | position = 0
80 | fieldLength = -1
81 | } else {
82 | // we're still parsing the old line. Append the new bytes into buffer:
83 | buffer = concat(buffer, arr)
84 | }
85 |
86 | const bufLength = buffer.length
87 | let lineStart = 0 // index where the current line starts
88 | while (position < bufLength) {
89 | if (discardTrailingNewline) {
90 | if (buffer[position] === ControlChars.NewLine) {
91 | lineStart = ++position // skip to next char
92 | }
93 |
94 | discardTrailingNewline = false
95 | }
96 |
97 | // start looking forward till the end of line:
98 | let lineEnd = -1 // index of the \r or \n char
99 | for (; position < bufLength && lineEnd === -1; ++position) {
100 | switch (buffer[position]) {
101 | case ControlChars.Colon:
102 | if (fieldLength === -1) {
103 | // first colon in line
104 | fieldLength = position - lineStart
105 | }
106 | break
107 | // @ts-ignore:7029 \r case below should fallthrough to \n:
108 | case ControlChars.CarriageReturn:
109 | discardTrailingNewline = true
110 | case ControlChars.NewLine:
111 | lineEnd = position
112 | break
113 | }
114 | }
115 |
116 | if (lineEnd === -1) {
117 | // We reached the end of the buffer but the line hasn't ended.
118 | // Wait for the next arr and then continue parsing:
119 | break
120 | }
121 |
122 | // we've reached the line end, send it out:
123 | onLine(buffer.subarray(lineStart, lineEnd), fieldLength)
124 | lineStart = position // we're now on the next line
125 | fieldLength = -1
126 | }
127 |
128 | if (lineStart === bufLength) {
129 | buffer = undefined // we've finished reading it
130 | } else if (lineStart !== 0) {
131 | // Create a new view into buffer beginning at lineStart so we don't
132 | // need to copy over the previous lines when we get the new arr:
133 | buffer = buffer.subarray(lineStart)
134 | position -= lineStart
135 | }
136 | }
137 | }
138 |
139 | /**
140 | * Parses line buffers into EventSourceMessages.
141 | * @param onId A function that will be called on each `id` field.
142 | * @param onRetry A function that will be called on each `retry` field.
143 | * @param onMessage A function that will be called on each message.
144 | * @returns A function that should be called for each incoming line buffer.
145 | */
146 | export function getMessages(
147 | onId: (id: string) => void,
148 | onRetry: (retry: number) => void,
149 | onMessage?: (msg: EventSourceMessage) => void,
150 | ) {
151 | let message = newMessage()
152 | const decoder = new TextDecoder()
153 |
154 | // return a function that can process each incoming line buffer:
155 | return function onLine(line: Uint8Array, fieldLength: number) {
156 | if (line.length === 0) {
157 | // empty line denotes end of message. Trigger the callback and start a new message:
158 | onMessage?.(message)
159 | message = newMessage()
160 | } else if (fieldLength > 0) {
161 | // exclude comments and lines with no values
162 | // line is of format ":" or ": "
163 | // https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation
164 | const field = decoder.decode(line.subarray(0, fieldLength))
165 | const valueOffset =
166 | fieldLength + (line[fieldLength + 1] === ControlChars.Space ? 2 : 1)
167 | const value = decoder.decode(line.subarray(valueOffset))
168 |
169 | switch (field) {
170 | case 'data':
171 | // if this message already has data, append the new value to the old.
172 | // otherwise, just set to the new value:
173 | message.data = message.data ? message.data + '\n' + value : value // otherwise,
174 | break
175 | case 'event':
176 | message.event = value
177 | break
178 | case 'id':
179 | onId((message.id = value))
180 | break
181 | case 'retry':
182 | const retry = parseInt(value, 10)
183 | if (!isNaN(retry)) {
184 | // per spec, ignore non-integers
185 | onRetry((message.retry = retry))
186 | }
187 | break
188 | }
189 | }
190 | }
191 | }
192 |
193 | function concat(a: Uint8Array, b: Uint8Array) {
194 | const res = new Uint8Array(a.length + b.length)
195 | res.set(a)
196 | res.set(b, a.length)
197 | return res
198 | }
199 |
200 | function newMessage(): EventSourceMessage {
201 | // data, event, and id must be initialized to empty strings:
202 | // https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation
203 | // retry should be initialized to undefined so we return a consistent shape
204 | // to the js engine all the time: https://mathiasbynens.be/notes/shapes-ics#takeaways
205 | return {
206 | data: '',
207 | event: '',
208 | id: '',
209 | retry: undefined,
210 | }
211 | }
212 |
--------------------------------------------------------------------------------
/src/sse/writeToStream.ts:
--------------------------------------------------------------------------------
1 | type Event = {
2 | id?: string
3 | data?: string
4 | event?: 'complete' | 'next'
5 | }
6 | type Payload = Event | string
7 |
8 | export const writeToStream = async (
9 | writer: WritableStreamDefaultWriter,
10 | payload: Payload,
11 | ) => {
12 | const encoder = new TextEncoder()
13 |
14 | if (typeof payload === 'string') {
15 | await writer.write(encoder.encode(payload))
16 | } else {
17 | const { event, id, data } = payload
18 |
19 | if (event) {
20 | await writer.write(encoder.encode('event: ' + event + '\n'))
21 | }
22 | if (id) {
23 | await writer.write(encoder.encode('id: ' + id + '\n'))
24 | }
25 | if (data) {
26 | await writer.write(encoder.encode('data: ' + data + '\n'))
27 | }
28 |
29 | await writer.write(encoder.encode('\n'))
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/src/utils/HttpError.ts:
--------------------------------------------------------------------------------
1 | export default class HttpError extends Error {
2 | status: number
3 | headers?: { name: string; value: string }[]
4 | graphqlErrors?: readonly Error[]
5 |
6 | constructor(
7 | status: number,
8 | message: string,
9 | details: {
10 | headers?: { name: string; value: string }[]
11 | graphqlErrors?: readonly Error[]
12 | } = {},
13 | ) {
14 | super(message)
15 | this.status = status
16 | this.headers = details.headers
17 | this.graphqlErrors = details.graphqlErrors
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/src/utils/createAccessHeaders.ts:
--------------------------------------------------------------------------------
1 | export type CreateAccessHeadersOptions = {
2 | origins?: Array boolean)>
3 | credentials?: boolean
4 | methods?: string[]
5 | maxAge?: number
6 | headers?: string[]
7 | }
8 |
9 | export const createAccessHeaders = ({
10 | origins = ['*'],
11 | credentials = false,
12 | methods = ['GET', 'HEAD', 'PUT', 'POST', 'DELETE', 'OPTIONS'],
13 | headers = [],
14 | maxAge,
15 | }: CreateAccessHeadersOptions = {}) => {
16 | return (request: Request) => {
17 | const isPreflight = request.method === 'OPTIONS'
18 | const origin = (request.headers.get('origin') || '').toLowerCase().trim()
19 |
20 | const responseHeaders = new Headers()
21 |
22 | if (credentials) {
23 | responseHeaders.set('Access-Control-Allow-Credentials', 'true')
24 | }
25 |
26 | const allowOrigin = origins.includes('*')
27 | ? '*'
28 | : origins.some((allowedOrigin) =>
29 | typeof allowedOrigin === 'function'
30 | ? allowedOrigin(request)
31 | : allowedOrigin instanceof RegExp
32 | ? allowedOrigin.test(origin)
33 | : allowedOrigin === origin,
34 | )
35 | ? origin
36 | : ''
37 |
38 | responseHeaders.set('Access-Control-Allow-Origin', allowOrigin)
39 |
40 | if (allowOrigin !== '*') {
41 | responseHeaders.set('Vary', 'Origin')
42 | }
43 |
44 | if (isPreflight) {
45 | if (maxAge !== undefined) {
46 | responseHeaders.set('Access-Control-Max-Age', String(maxAge))
47 | }
48 | if (methods.length) {
49 | responseHeaders.set('Access-Control-Allow-Methods', methods.join(', '))
50 | }
51 | if (!headers.length) {
52 | const reqHeaders = request.headers.get('access-control-request-headers')
53 |
54 | if (reqHeaders) {
55 | responseHeaders.set('Access-Control-Allow-Headers', reqHeaders)
56 | responseHeaders.append('Vary', 'Access-Control-Allow-Headers')
57 | }
58 | } else {
59 | responseHeaders.set('Access-Control-Allow-Headers', headers.join(', '))
60 | }
61 | }
62 |
63 | return {
64 | headers: Object.fromEntries(responseHeaders.entries()),
65 | isPreflight,
66 | }
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/src/utils/createHelixRequest.ts:
--------------------------------------------------------------------------------
1 | export const createHelixRequest = async (request: Request) => {
2 | const url = new URL(request.url)
3 | const query = Object.fromEntries(new URLSearchParams(url.search))
4 |
5 | const body =
6 | request.method === 'POST' ? await request.json() : undefined
7 |
8 | return {
9 | body,
10 | headers: request.headers,
11 | method: request.method,
12 | query,
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/src/utils/getArguments.ts:
--------------------------------------------------------------------------------
1 | import { getNullableType } from 'graphql'
2 | import { getArgumentValues } from '@graphql-tools/utils'
3 | import type { GraphQLObjectType, GraphQLResolveInfo } from 'graphql'
4 |
5 | const getType = (type: any): GraphQLObjectType => {
6 | const actualType = getNullableType(type)
7 | return actualType.ofType ? getType(actualType.ofType) : type
8 | }
9 |
10 | const getArguments = >(
11 | info: GraphQLResolveInfo,
12 | ) => {
13 | const name = info.fieldName
14 | const fieldDef = getType(info.parentType).getFields()[name]
15 | const [node] = info.fieldNodes
16 |
17 | return getArgumentValues(fieldDef, node, info.variableValues) as TArgs
18 | }
19 |
20 | export default getArguments
21 |
--------------------------------------------------------------------------------
/src/utils/getResponse.ts:
--------------------------------------------------------------------------------
1 | import type { Response } from 'graphql-helix'
2 |
3 | const getResponse = (
4 | result: Response,
5 | headers?: Record,
6 | ) => {
7 | const resultHeaders = Object.fromEntries(
8 | result.headers.map(({ name, value }) => [name, value]),
9 | )
10 |
11 | return new Response(JSON.stringify(result.payload), {
12 | status: result.status,
13 | headers: {
14 | 'Content-Type': 'application/json; charset=utf-8',
15 | ...resultHeaders,
16 | ...headers,
17 | },
18 | })
19 | }
20 |
21 | export default getResponse
22 |
--------------------------------------------------------------------------------
/test/createAccessHeaders.test.ts:
--------------------------------------------------------------------------------
1 | import { Headers, Request, Response, fetch } from 'undici'
2 | import { createAccessHeaders } from '../src/utils/createAccessHeaders'
3 |
4 | // polyfill
5 | ;(global as any).Headers = Headers
6 | ;(global as any).Request = Request
7 | ;(global as any).Response = Response
8 | ;(global as any).fetch = fetch
9 |
10 | describe('createAccessHeaders', () => {
11 | it('only OPTIONS should be preflight', () => {
12 | const optReq = new Request('https://example.io', { method: 'OPTIONS' })
13 | const getReq = new Request('https://example.io', { method: 'GET' })
14 |
15 | const cors = createAccessHeaders()
16 |
17 | expect(cors(optReq as any).isPreflight).toBe(true)
18 | expect(cors(getReq as any).isPreflight).toBe(false)
19 | })
20 | it('should send correct headers for methods', () => {
21 | const optReq = new Request('https://api.launchport.io/key', {
22 | method: 'OPTIONS',
23 | headers: { origin: 'https://launchport.io' },
24 | })
25 | const postReq = new Request('https://api.launchport.io/key', {
26 | method: 'POST',
27 | headers: { origin: 'https://launchport.io' },
28 | })
29 |
30 | const cors = createAccessHeaders({
31 | methods: ['PUT', 'DELETE'],
32 | origins: ['https://launchport.io', 'https://api.launchport.io'],
33 | credentials: true,
34 | })
35 |
36 | expect(cors(optReq as any).headers).toMatchInlineSnapshot(`
37 | Object {
38 | "access-control-allow-credentials": "true",
39 | "access-control-allow-methods": "PUT, DELETE",
40 | "access-control-allow-origin": "https://launchport.io",
41 | "vary": "Origin",
42 | }
43 | `)
44 | expect(cors(postReq as any).headers).toMatchInlineSnapshot(`
45 | Object {
46 | "access-control-allow-credentials": "true",
47 | "access-control-allow-origin": "https://launchport.io",
48 | "vary": "Origin",
49 | }
50 | `)
51 | })
52 | it('should allow exact access', () => {
53 | const cors = createAccessHeaders({
54 | origins: ['http://graphql.local'],
55 | credentials: true,
56 | })
57 |
58 | const preReq = new Request('https://example.io', {
59 | method: 'OPTIONS',
60 | headers: { origin: 'http://graphql.local' },
61 | })
62 |
63 | const req = new Request('https://example.io', {
64 | method: 'GET',
65 | headers: { origin: 'http://graphql.local' },
66 | })
67 |
68 | expect(cors(req as any).headers).toMatchInlineSnapshot(`
69 | Object {
70 | "access-control-allow-credentials": "true",
71 | "access-control-allow-origin": "http://graphql.local",
72 | "vary": "Origin",
73 | }
74 | `)
75 |
76 | expect(cors(preReq as any).headers).toMatchInlineSnapshot(`
77 | Object {
78 | "access-control-allow-credentials": "true",
79 | "access-control-allow-methods": "GET, HEAD, PUT, POST, DELETE, OPTIONS",
80 | "access-control-allow-origin": "http://graphql.local",
81 | "vary": "Origin",
82 | }
83 | `)
84 | })
85 |
86 | it('should allow regex access', () => {
87 | const cors = createAccessHeaders({ origins: [/graphql\.io/] })
88 |
89 | const req = new Request('https://example.io', {
90 | method: 'OPTIONS',
91 | headers: {
92 | origin: 'https://graphql.io',
93 | },
94 | })
95 |
96 | expect(
97 | cors(req as any).headers['access-control-allow-origin'],
98 | ).toMatchInlineSnapshot(`"https://graphql.io"`)
99 | })
100 |
101 | it('should allow callback access', () => {
102 | const cors = createAccessHeaders({
103 | origins: [(req) => req.headers.get('x-header') === '1'],
104 | })
105 |
106 | const req = new Request('https://example.io', {
107 | method: 'OPTIONS',
108 | headers: {
109 | 'x-header': '1',
110 | origin: 'https://graphql.io',
111 | },
112 | })
113 |
114 | expect(
115 | cors(req as any).headers['access-control-allow-origin'],
116 | ).toMatchInlineSnapshot(`"https://graphql.io"`)
117 | })
118 |
119 | it('should receive all headers', () => {
120 | const cors = createAccessHeaders({
121 | origins: [/graphql\.io/],
122 | methods: ['POST'],
123 | maxAge: 3600,
124 | credentials: true,
125 | })
126 |
127 | const req = new Request('https://example.io', {
128 | method: 'OPTIONS',
129 | headers: {
130 | origin: 'https://graphql.io',
131 | 'access-control-request-headers': 'cache-control',
132 | },
133 | })
134 |
135 | expect(cors(req as any)).toMatchInlineSnapshot(`
136 | Object {
137 | "headers": Object {
138 | "access-control-allow-credentials": "true",
139 | "access-control-allow-headers": "cache-control",
140 | "access-control-allow-methods": "POST",
141 | "access-control-allow-origin": "https://graphql.io",
142 | "access-control-max-age": "3600",
143 | "vary": "Origin, Access-Control-Allow-Headers",
144 | },
145 | "isPreflight": true,
146 | }
147 | `)
148 | })
149 |
150 | it('should respect custom allowed headers', () => {
151 | const cors = createAccessHeaders({
152 | headers: ['x-custom-request', 'content-type'],
153 | })
154 | const req = new Request('https://example.io', { method: 'OPTIONS' })
155 |
156 | expect(cors(req as any).headers['access-control-allow-headers']).toEqual(
157 | 'x-custom-request, content-type',
158 | )
159 | })
160 | })
161 |
--------------------------------------------------------------------------------
/test/createSubscription.test.ts:
--------------------------------------------------------------------------------
1 | import { createSubscription, STOP } from '../src'
2 |
3 | describe('createSubscription', () => {
4 | it('should emit and receive values', async () => {
5 | const [emit, resolver] = createSubscription<
6 | string,
7 | { subscribeValue: string }
8 | >({
9 | topic: 'test',
10 | resolve: (value) => ({ subscribeValue: value }),
11 | getInitialValue: () => 'initial',
12 | })
13 |
14 | const iterator = resolver()
15 |
16 | await expect(iterator.next()).resolves.toEqual(
17 | expect.objectContaining({ value: { subscribeValue: 'initial' } }),
18 | )
19 |
20 | emit('test')
21 | await expect(iterator.next()).resolves.toEqual(
22 | expect.objectContaining({ value: { subscribeValue: 'test' } }),
23 | )
24 | })
25 |
26 | it('should stop iterator', async () => {
27 | const [emit, resolver] = createSubscription({
28 | topic: 'test',
29 | getInitialValue: () => 'initial',
30 | })
31 |
32 | const iterator = resolver()
33 |
34 | // skip initial value
35 | await iterator.next()
36 |
37 | emit(STOP)
38 |
39 | await expect(iterator.next()).resolves.toEqual(
40 | expect.objectContaining({ done: true }),
41 | )
42 | })
43 | })
44 |
--------------------------------------------------------------------------------
/test/fetchEventSource.test.ts:
--------------------------------------------------------------------------------
1 | import { fetch } from 'undici'
2 |
3 | import http from 'node:http'
4 | import net from 'node:net'
5 |
6 | import { fetchEventSource } from '../src/sse/fetchEventSource'
7 |
8 | let url: string
9 | let server: http.Server
10 |
11 | beforeAll(async () => {
12 | server = http.createServer()
13 | await new Promise((resolve) => server.listen(0, resolve))
14 |
15 | const { port } = server.address() as net.AddressInfo
16 | url = `http://localhost:${port}`
17 | })
18 |
19 | afterAll(() => server.close())
20 | afterEach(() => server.removeAllListeners())
21 |
22 | describe('fetchEventSource', () => {
23 | it('should handle simple response', async () => {
24 | server.on('request', async (req, res) => {
25 | res.writeHead(200, {
26 | 'Content-Type': 'text/event-stream',
27 | 'Cache-Control': 'no-cache',
28 | Connection: 'keep-alive',
29 | })
30 | res.write('id: 1337\n')
31 | res.write('event: next\n')
32 | res.write('data: hello\n')
33 | res.write('\n')
34 | res.end()
35 | })
36 |
37 | const result = await new Promise((resolve) => {
38 | fetchEventSource(url, {
39 | onMessage: resolve,
40 | fetch: fetch as any,
41 | })
42 | })
43 |
44 | expect(result).toMatchInlineSnapshot(`
45 | Object {
46 | "data": "hello",
47 | "event": "next",
48 | "id": "1337",
49 | "retry": undefined,
50 | }
51 | `)
52 | })
53 |
54 | it('should handle split data in chunks', async () => {
55 | server.on('request', async (req, res) => {
56 | // `\r` should be ignored
57 | res.write('event: next\r\n')
58 | res.write('data:')
59 |
60 | await new Promise((resolve) => setTimeout(resolve, 100))
61 |
62 | res.write('hello world\n')
63 | res.write('\n')
64 | res.end()
65 | })
66 |
67 | const result = await new Promise((resolve) => {
68 | fetchEventSource(url, {
69 | onMessage: resolve,
70 | fetch: fetch as any,
71 | })
72 | })
73 |
74 | expect(result).toMatchInlineSnapshot(`
75 | Object {
76 | "data": "hello world",
77 | "event": "next",
78 | "id": "",
79 | "retry": undefined,
80 | }
81 | `)
82 | })
83 |
84 | it('should abort', async () => {
85 | server.on('request', async (req, res) => {
86 | res.writeHead(200, {
87 | 'Content-Type': 'text/event-stream',
88 | 'Cache-Control': 'no-cache',
89 | Connection: 'keep-alive',
90 | })
91 | res.write('event: next\n')
92 | res.write('data: hello\n')
93 | res.write('\n')
94 | })
95 |
96 | const ac = new AbortController()
97 |
98 | const sse = fetchEventSource(url, {
99 | signal: ac.signal,
100 | fetch: fetch as any,
101 | })
102 |
103 | process.nextTick(() => ac.abort())
104 |
105 | await expect(sse).resolves.toBeUndefined()
106 | expect(ac.signal.aborted).toBe(true)
107 | })
108 | })
109 |
--------------------------------------------------------------------------------
/test/integration/durable-object.test.ts:
--------------------------------------------------------------------------------
1 | import { buildWorkers, createWorker } from './utils'
2 |
3 | beforeAll(() => {
4 | buildWorkers()
5 | })
6 |
7 | describe('Durable object worker', () => {
8 | it('should mutate and resolve', async () => {
9 | const worker = createWorker('./durable-object.worker.ts', {
10 | durableObjects: {
11 | HELIX_OBJECT: 'HelixObject',
12 | },
13 | })
14 |
15 | const query = async (query: string, variables?: object) => {
16 | const res = await worker.dispatchFetch('file:', {
17 | method: 'POST',
18 | body: JSON.stringify({ query, variables }),
19 | })
20 |
21 | const result = await res.json()
22 |
23 | return result.data
24 | }
25 |
26 | let res = await query('mutation { start }')
27 |
28 | const doId = res.start
29 |
30 | res = await query(
31 | /* GraphQL */ `
32 | query ($doId: String!) {
33 | status(id: $doId)
34 | }
35 | `,
36 | { doId },
37 | )
38 | expect(res.status).toBe('started')
39 | })
40 | })
41 |
--------------------------------------------------------------------------------
/test/integration/durable-object.worker.ts:
--------------------------------------------------------------------------------
1 | import { makeExecutableSchema } from '@graphql-tools/schema'
2 | import { wrapSchema } from '@graphql-tools/wrap'
3 |
4 | import helixFlare, { createExecutor } from '../../src'
5 |
6 | const typeDefs = /* GraphQL */ `
7 | type Mutation {
8 | start: String!
9 | }
10 |
11 | type Query {
12 | status(id: String!): String!
13 | doId: String!
14 | }
15 | `
16 |
17 | const Worker: ExportedHandler<{ HELIX_OBJECT: DurableObjectNamespace }> = {
18 | async fetch(request, env) {
19 | const executor = createExecutor<{ id?: string }>(request, async (args) => {
20 | const doId = args.id
21 | ? env.HELIX_OBJECT.idFromString(args.id)
22 | : env.HELIX_OBJECT.idFromName('someRandomId')
23 |
24 | return env.HELIX_OBJECT.get(doId)
25 | })
26 |
27 | const schema = wrapSchema({
28 | schema: makeExecutableSchema({ typeDefs }),
29 | executor,
30 | })
31 |
32 | return helixFlare(request, schema)
33 | },
34 | }
35 |
36 | export class HelixObject {
37 | private state: DurableObjectState
38 | private status: 'started' | 'stopped' | 'paused' = 'stopped'
39 |
40 | constructor(state: DurableObjectState) {
41 | this.state = state
42 | }
43 |
44 | async fetch(request: Request) {
45 | const schema = makeExecutableSchema({
46 | typeDefs,
47 | resolvers: {
48 | Mutation: {
49 | start: () => {
50 | this.status = 'started'
51 | return this.state.id.toString()
52 | },
53 | },
54 | Query: {
55 | status: () => this.status,
56 | doId: () => this.state.id.toString(),
57 | },
58 | },
59 | })
60 |
61 | return helixFlare(request, schema)
62 | }
63 | }
64 | export default Worker
65 |
--------------------------------------------------------------------------------
/test/integration/executor-error.worker.ts:
--------------------------------------------------------------------------------
1 | import { makeExecutableSchema } from '@graphql-tools/schema'
2 | import { wrapSchema } from '@graphql-tools/wrap'
3 |
4 | import helixFlare, { createExecutor } from '../../src'
5 |
6 | const typeDefs = /* GraphQL */ `
7 | type Query {
8 | error(byArg: Boolean, byContext: Boolean): String!
9 | }
10 | `
11 |
12 | const Worker: ExportedHandler<{ HELIX_OBJECT: any }> = {
13 | async fetch(request) {
14 | const executor = createExecutor<
15 | { byArg?: boolean },
16 | { byContext?: boolean }
17 | >(request, async (args, context) => {
18 | if (args.byArg) {
19 | throw new Error('Error by arg')
20 | }
21 |
22 | if (context.byContext) {
23 | throw new Error('Error by context')
24 | }
25 |
26 | throw new Error('Unexpected error')
27 | })
28 |
29 | const schema = wrapSchema({
30 | schema: makeExecutableSchema({ typeDefs }),
31 | executor,
32 | })
33 |
34 | return helixFlare(request, schema, {
35 | middlewares: [
36 | (resolve, parent, args, context, info) => {
37 | if (args.byContext) {
38 | context.byContext = true
39 | }
40 |
41 | return resolve(parent, args, context, info)
42 | },
43 | ],
44 | })
45 | },
46 | }
47 |
48 | export default Worker
49 |
--------------------------------------------------------------------------------
/test/integration/index.test.ts:
--------------------------------------------------------------------------------
1 | import { buildWorkers, createWorker } from './utils'
2 |
3 | beforeAll(() => {
4 | buildWorkers()
5 | })
6 |
7 | describe('helix-flare', () => {
8 | it('should resolve a simple query', async () => {
9 | const worker = createWorker('./index.worker.ts')
10 |
11 | const res = await worker.dispatchFetch('file:', {
12 | method: 'POST',
13 | body: JSON.stringify({ query: 'query { user }' }),
14 | })
15 |
16 | await expect(res.json()).resolves.toMatchInlineSnapshot(`
17 | Object {
18 | "data": Object {
19 | "user": "John Doe",
20 | },
21 | }
22 | `)
23 | })
24 |
25 | it('should render GraphiQL', async () => {
26 | const worker = createWorker('./index.worker.ts')
27 | const res = await worker.dispatchFetch('file:', {
28 | method: 'GET',
29 | headers: { Accept: 'text/html' },
30 | })
31 |
32 | expect(res.headers.get('content-type')).toBe('text/html')
33 | expect(await res.text()).toMatch('GraphiQL')
34 | })
35 |
36 | it('should resolve via GET', async () => {
37 | const worker = createWorker('./index.worker.ts')
38 | const queryParams = new URLSearchParams({ query: 'query { user }' })
39 |
40 | const res = await worker.dispatchFetch(`file:?${queryParams.toString()}`, {
41 | method: 'GET',
42 | })
43 |
44 | await expect(res.json()).resolves.toMatchInlineSnapshot(`
45 | Object {
46 | "data": Object {
47 | "user": "John Doe",
48 | },
49 | }
50 | `)
51 | })
52 |
53 | it('should retain context in worker', async () => {
54 | const worker = createWorker('./index.worker.ts')
55 |
56 | const res = await worker.dispatchFetch('file:', {
57 | method: 'POST',
58 | body: JSON.stringify({ query: 'query { context }' }),
59 | })
60 |
61 | await expect(res.json()).resolves.toMatchInlineSnapshot(`
62 | Object {
63 | "data": Object {
64 | "context": "papaya",
65 | },
66 | }
67 | `)
68 | })
69 |
70 | it('should retain context in durable object', async () => {
71 | // @todo
72 | })
73 |
74 | it('should resolve errors from executor', async () => {
75 | const worker = createWorker('./executor-error.worker.ts')
76 |
77 | const res = await worker.dispatchFetch('file:///', {
78 | method: 'POST',
79 | body: JSON.stringify({ query: '{ error(byArg: true) }' }),
80 | })
81 | expect((await res.json()).errors[0].message).toMatchInlineSnapshot(
82 | `"Error by arg"`,
83 | )
84 |
85 | const res2 = await worker.dispatchFetch('file:///', {
86 | method: 'POST',
87 | body: JSON.stringify({ query: '{ error(byContext: true) }' }),
88 | })
89 | expect((await res2.json()).errors[0].message).toMatchInlineSnapshot(
90 | `"Error by context"`,
91 | )
92 | })
93 | })
94 |
--------------------------------------------------------------------------------
/test/integration/index.worker.ts:
--------------------------------------------------------------------------------
1 | import { makeExecutableSchema } from '@graphql-tools/schema'
2 |
3 | import helixFlare, {
4 | shouldRenderGraphiQL,
5 | getGraphiQLResponse,
6 | } from '../../src'
7 |
8 | export default {
9 | async fetch(request: Request) {
10 | if (await shouldRenderGraphiQL(request)) {
11 | return getGraphiQLResponse()
12 | }
13 |
14 | const typeDefs = /* GraphQL */ `
15 | type Query {
16 | user: String!
17 | context: String!
18 | }
19 | `
20 |
21 | const schema = makeExecutableSchema({
22 | typeDefs,
23 | resolvers: {
24 | Query: {
25 | user: () => 'John Doe',
26 | context: (_, __, context) => context.color,
27 | },
28 | },
29 | })
30 |
31 | return helixFlare(request, schema, {
32 | contextFactory: () => ({ color: 'papaya' }),
33 | })
34 | },
35 | }
36 |
--------------------------------------------------------------------------------
/test/integration/sse.test.ts:
--------------------------------------------------------------------------------
1 | import { buildWorkers, createWorker } from './utils'
2 | import { createClient } from 'graphql-sse'
3 |
4 | beforeAll(() => {
5 | buildWorkers()
6 | })
7 |
8 | describe('SSE', () => {
9 | it('should subscribe and update', async () => {
10 | const worker = createWorker('./sse.worker.ts', {
11 | durableObjects: {
12 | NEWS_ARTICLE_OBJECT: 'NewsArticleObject',
13 | },
14 | globalTimers: true,
15 | })
16 |
17 | const sseClient = createClient({
18 | url: 'http://localhost:8787/graphql',
19 | fetchFn: worker.dispatchFetch.bind(worker),
20 | })
21 |
22 | const subscriptionPromise = new Promise(async (resolve, reject) => {
23 | const unsub = sseClient.subscribe(
24 | {
25 | query: /* GraphQL */ `
26 | subscription {
27 | upvotes(articleId: "1")
28 | }
29 | `,
30 | },
31 | {
32 | next: (data) => {
33 | if (data.data?.upvotes === 1) {
34 | resolve()
35 | unsub()
36 | }
37 | },
38 | error: (error) => reject(error),
39 | complete: () => {},
40 | },
41 | )
42 |
43 | setTimeout(() => {
44 | worker.dispatchFetch('http://localhost:8787/graphql', {
45 | method: 'POST',
46 | body: JSON.stringify({
47 | query: /* GraphQL */ `
48 | mutation {
49 | upvote(articleId: "1")
50 | }
51 | `,
52 | }),
53 | })
54 | }, 100)
55 | })
56 |
57 | await expect(subscriptionPromise).resolves.toBeUndefined()
58 | })
59 |
60 | it('should handle multiple subscriptions correctly', async () => {
61 | const worker = createWorker('./sse.worker.ts', {
62 | durableObjects: {
63 | NEWS_ARTICLE_OBJECT: 'NewsArticleObject',
64 | },
65 | globalTimers: true,
66 | })
67 |
68 | const expectedUpvotes = 5
69 |
70 | const clients = Array.from({ length: 2 }, () => {
71 | return new Promise((resolve, reject) => {
72 | const sseClient = createClient({
73 | url: 'http://localhost:8787/graphql',
74 | fetchFn: worker.dispatchFetch.bind(worker),
75 | })
76 | let i = 0
77 | const unsub = sseClient.subscribe>(
78 | {
79 | query: /* GraphQL */ `
80 | subscription {
81 | upvotes(articleId: "1")
82 | }
83 | `,
84 | },
85 | {
86 | next: () => {
87 | if (++i === expectedUpvotes) {
88 | resolve(i)
89 | unsub()
90 | }
91 | },
92 | error: reject,
93 | complete: () => {},
94 | },
95 | )
96 | })
97 | })
98 |
99 | Array.from({ length: expectedUpvotes - 1 }).forEach(() => {
100 | worker.dispatchFetch('http://localhost:8787/graphql', {
101 | method: 'POST',
102 | body: JSON.stringify({ query: `mutation { upvote(articleId: "1") }` }),
103 | })
104 | })
105 |
106 | await expect(Promise.all(clients)).resolves.toEqual([
107 | expectedUpvotes,
108 | expectedUpvotes,
109 | ])
110 | })
111 | })
112 |
--------------------------------------------------------------------------------
/test/integration/sse.worker.ts:
--------------------------------------------------------------------------------
1 | import { makeExecutableSchema } from '@graphql-tools/schema'
2 | import { wrapSchema } from '@graphql-tools/wrap'
3 | import type { IResolvers } from 'graphql-middleware/dist/types'
4 |
5 | import helixFlare, { createExecutor, createSubscription } from '../../src'
6 |
7 | const typeDefs = /* GraphQL */ `
8 | type Query {
9 | upvotes(articleId: ID!): Int!
10 | }
11 |
12 | type Mutation {
13 | upvote(articleId: ID!): Boolean
14 | }
15 |
16 | type Subscription {
17 | upvotes(articleId: ID!): Int!
18 | }
19 | `
20 |
21 | export class NewsArticleObject implements DurableObject {
22 | private upvotes = 0
23 |
24 | async fetch(request: Request) {
25 | const [emitUpvote, upvoteResolver] = createSubscription<
26 | number,
27 | { upvotes: number }
28 | >({
29 | topic: 'UPVOTE',
30 | resolve: (value) => ({ upvotes: value }),
31 | getInitialValue: () => this.upvotes,
32 | })
33 |
34 | const resolvers: IResolvers = {
35 | Mutation: {
36 | upvote: () => {
37 | this.upvotes++
38 | emitUpvote(this.upvotes)
39 |
40 | return this.upvotes
41 | },
42 | },
43 | Subscription: {
44 | upvotes: {
45 | subscribe: upvoteResolver,
46 | },
47 | },
48 | }
49 |
50 | const schema = makeExecutableSchema({
51 | typeDefs,
52 | resolvers,
53 | })
54 |
55 | return helixFlare(request, schema)
56 | }
57 | }
58 |
59 | export default {
60 | async fetch(request: Request, env: any) {
61 | const url = new URL(request.url)
62 | if (url.pathname !== '/graphql') {
63 | return new Response('Not found', { status: 404 })
64 | }
65 |
66 | const schema = wrapSchema({
67 | schema: makeExecutableSchema({ typeDefs }),
68 | executor: createExecutor<{ articleId?: string }>(
69 | request,
70 | async (args) => {
71 | const doId = env.NEWS_ARTICLE_OBJECT.idFromName(args.articleId)
72 |
73 | return env.NEWS_ARTICLE_OBJECT.get(doId)
74 | },
75 | ),
76 | })
77 |
78 | return helixFlare(request, schema)
79 | },
80 | }
81 |
--------------------------------------------------------------------------------
/test/integration/utils.ts:
--------------------------------------------------------------------------------
1 | import path from 'node:path'
2 | import url from 'node:url'
3 |
4 | import { Miniflare, type MiniflareOptions } from 'miniflare'
5 | import { buildSync } from 'esbuild'
6 | import globSync from 'tiny-glob/sync'
7 |
8 | const __filename = url.fileURLToPath(import.meta.url)
9 | const __dirname = path.dirname(__filename)
10 |
11 | export const buildWorkers = () => {
12 | const entryPoints = globSync(path.resolve(__dirname, '*.worker.ts'))
13 |
14 | buildSync({
15 | entryPoints,
16 | bundle: true,
17 | logLevel: 'error',
18 | target: 'node16',
19 | platform: 'node',
20 | outdir: path.resolve(__dirname, 'dist'),
21 | sourcemap: true,
22 | format: 'esm',
23 | define: {
24 | setImmediate: 'setTimeout',
25 | 'process.env.NODE_ENV': JSON.stringify('production'),
26 | },
27 | })
28 | }
29 |
30 | export const createWorker = (
31 | relativeWorkerPath: string,
32 | options?: MiniflareOptions,
33 | ) => {
34 | const scriptPath = path.resolve(
35 | __dirname,
36 | 'dist',
37 | relativeWorkerPath.replace(/\.ts$/, '.js'),
38 | )
39 |
40 | return new Miniflare({
41 | scriptPath,
42 | modules: true,
43 | buildCommand: undefined,
44 | sourceMap: true,
45 | ...options,
46 | })
47 | }
48 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "moduleResolution": "node",
4 | "lib": ["esnext"],
5 | "esModuleInterop": true,
6 | "target": "es2020",
7 | "strict": true,
8 | "skipLibCheck": true,
9 | "module": "esnext",
10 | "types": ["@cloudflare/workers-types", "jest"]
11 | },
12 | "include": ["src", "test"],
13 | "exclude": ["node_modules", "dist"]
14 | }
15 |
--------------------------------------------------------------------------------
/tsup.config.ts:
--------------------------------------------------------------------------------
1 | import type { Options } from 'tsup'
2 | export const tsup: Options = {
3 | splitting: false,
4 | dts: true,
5 | clean: true,
6 | target: 'node16',
7 | format: ['esm', 'cjs'],
8 | entryPoints: ['src/index.ts', 'src/envelop.ts'],
9 | }
10 |
--------------------------------------------------------------------------------