├── .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 | --------------------------------------------------------------------------------