diff --git a/examples/nodejs-subscriptions/.dockerignore b/examples/nodejs-subscriptions/.dockerignore new file mode 100644 index 0000000..f965aed --- /dev/null +++ b/examples/nodejs-subscriptions/.dockerignore @@ -0,0 +1,15 @@ +node_modules +Dockerfile* +docker-compose* +.dockerignore +.git +.gitignore +README.md +LICENSE +.vscode +Makefile +helm-charts +.env +.editorconfig +.idea +coverage* diff --git a/examples/nodejs-subscriptions/.gitignore b/examples/nodejs-subscriptions/.gitignore new file mode 100644 index 0000000..f3150c1 --- /dev/null +++ b/examples/nodejs-subscriptions/.gitignore @@ -0,0 +1,175 @@ +# Logs + +logs +_.log +npm-debug.log_ +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) + +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# Runtime data + +pids +_.pid +_.seed +\*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover + +lib-cov + +# Coverage directory used by tools like istanbul + +coverage +\*.lcov + +# nyc test coverage + +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) + +.grunt + +# Bower dependency directory (https://bower.io/) + +bower_components + +# node-waf configuration + +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) + +build/Release + +# Dependency directories + +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) + +web_modules/ + +# TypeScript cache + +\*.tsbuildinfo + +# Optional npm cache directory + +.npm + +# Optional eslint cache + +.eslintcache + +# Optional stylelint cache + +.stylelintcache + +# Microbundle cache + +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history + +.node_repl_history + +# Output of 'npm pack' + +\*.tgz + +# Yarn Integrity file + +.yarn-integrity + +# dotenv environment variable files + +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) + +.cache +.parcel-cache + +# Next.js build output + +.next +out + +# Nuxt.js build / generate output + +.nuxt +dist + +# Gatsby files + +.cache/ + +# Comment in the public line in if your project uses Gatsby and not Next.js + +# https://nextjs.org/blog/next-9-1#public-directory-support + +# public + +# vuepress build output + +.vuepress/dist + +# vuepress v2.x temp and cache directory + +.temp +.cache + +# Docusaurus cache and generated files + +.docusaurus + +# Serverless directories + +.serverless/ + +# FuseBox cache + +.fusebox/ + +# DynamoDB Local files + +.dynamodb/ + +# TernJS port file + +.tern-port + +# Stores VSCode versions used for testing VSCode extensions + +.vscode-test + +# yarn v2 + +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.\* + +# wrangler project + +.dev.vars +.wrangler/ + +# Pylon project +.pylon diff --git a/examples/nodejs-subscriptions/Dockerfile b/examples/nodejs-subscriptions/Dockerfile new file mode 100644 index 0000000..1758ef4 --- /dev/null +++ b/examples/nodejs-subscriptions/Dockerfile @@ -0,0 +1,45 @@ +# Use the official Node.js 20 image as the base +FROM node:20-alpine as base + +LABEL description="Offical docker image for Pylon services (Node.js)" +LABEL org.opencontainers.image.source="https://github.com/getcronit/pylon" +LABEL maintainer="office@cronit.io" + +WORKDIR /usr/src/pylon + +# install dependencies into a temp directory +# this will cache them and speed up future builds +FROM base AS install +RUN mkdir -p /temp/dev +COPY package.json package-lock.json /temp/dev/ +RUN cd /temp/dev && npm ci + +# install with --production (exclude devDependencies) +RUN mkdir -p /temp/prod +COPY package.json package-lock.json /temp/prod/ +RUN cd /temp/prod && npm ci --only=production + +# copy node_modules from temp directory +# then copy all (non-ignored) project files into the image +FROM install AS prerelease +COPY --from=install /temp/dev/node_modules node_modules +COPY . . + +# [optional] tests & build +ENV NODE_ENV=production + +# Create .pylon folder (mkdir) +RUN mkdir -p .pylon +# RUN npm test +RUN npm run pylon build + +# copy production dependencies and source code into final image +FROM base AS release +COPY --from=install /temp/prod/node_modules node_modules +COPY --from=prerelease /usr/src/pylon/.pylon .pylon +COPY --from=prerelease /usr/src/pylon/package.json . + +# run the app +USER node +EXPOSE 3000/tcp +ENTRYPOINT [ "node", "/usr/src/pylon/.pylon/index.js" ] diff --git a/examples/nodejs-subscriptions/package.json b/examples/nodejs-subscriptions/package.json new file mode 100644 index 0000000..4ef7a1d --- /dev/null +++ b/examples/nodejs-subscriptions/package.json @@ -0,0 +1,23 @@ +{ + "name": "nodejs-subscriptions", + "private": true, + "version": "0.0.1", + "type": "module", + "description": "Generated with `npm create pylon`", + "scripts": { + "dev": "pylon dev -c \"node --enable-source-maps .pylon/index.js\"", + "build": "pylon build" + }, + "dependencies": { + "@getcronit/pylon": "^2.0.0", + "@hono/node-server": "^1.12.2" + }, + "devDependencies": { + "@getcronit/pylon-dev": "^1.0.0" + }, + "repository": { + "type": "git", + "url": "https://github.com/getcronit/pylon.git" + }, + "homepage": "https://pylon.cronit.io" +} diff --git a/examples/nodejs-subscriptions/pylon.d.ts b/examples/nodejs-subscriptions/pylon.d.ts new file mode 100644 index 0000000..39ffd23 --- /dev/null +++ b/examples/nodejs-subscriptions/pylon.d.ts @@ -0,0 +1,7 @@ +import '@getcronit/pylon' + +declare module '@getcronit/pylon' { + interface Bindings {} + + interface Variables {} +} diff --git a/examples/nodejs-subscriptions/src/index.ts b/examples/nodejs-subscriptions/src/index.ts new file mode 100644 index 0000000..ae20fe0 --- /dev/null +++ b/examples/nodejs-subscriptions/src/index.ts @@ -0,0 +1,43 @@ +import {app, experimentalCreatePubSub, ID} from '@getcronit/pylon' +import {serve} from '@hono/node-server' +import {randomUUID} from 'crypto' + +enum Events { + postCreated = 'postCreated' +} + +const pubSub = experimentalCreatePubSub<{ + [Events.postCreated]: [post: Post] +}>() + +class Post { + static create = (title: string, content: string) => { + const post = new Post(randomUUID(), title, content) + posts.push(post) + pubSub.publish(Events.postCreated, post) + return post + } + + constructor(public id: ID, public title: string, public content: string) {} +} + +const posts = [ + new Post(randomUUID(), 'Hello, world!', 'This is the first post'), + new Post(randomUUID(), 'Hello, world!', 'This is the second post') +] + +export const graphql = { + Query: { + posts + }, + Mutation: { + createPost: Post.create + }, + Subscription: { + postCreated: () => pubSub.subscribe(Events.postCreated) + } +} + +serve(app, info => { + console.log(`Server running at ${info.port}`) +}) diff --git a/examples/nodejs-subscriptions/tsconfig.json b/examples/nodejs-subscriptions/tsconfig.json new file mode 100644 index 0000000..e130051 --- /dev/null +++ b/examples/nodejs-subscriptions/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "@getcronit/pylon/tsconfig.pylon.json", + "include": ["pylon.d.ts", "src/**/*.ts"] +} diff --git a/packages/pylon-builder/src/schema/builder.ts b/packages/pylon-builder/src/schema/builder.ts index 33c75a7..2a83138 100644 --- a/packages/pylon-builder/src/schema/builder.ts +++ b/packages/pylon-builder/src/schema/builder.ts @@ -114,6 +114,7 @@ export class SchemaBuilder { const queryProperty = sfiType.getProperty('Query') const mutationProperty = sfiType.getProperty('Mutation') + const subscriptionProperty = sfiType.getProperty('Subscription') const queryType = queryProperty ? this.checker.getTypeOfSymbolAtLocation(queryProperty, this.sfiFile) @@ -121,12 +122,19 @@ export class SchemaBuilder { const mutationType = mutationProperty ? this.checker.getTypeOfSymbolAtLocation(mutationProperty, this.sfiFile) : undefined + const subscriptionType = subscriptionProperty + ? this.checker.getTypeOfSymbolAtLocation( + subscriptionProperty, + this.sfiFile + ) + : undefined const parser = new SchemaParser(this.checker, this.sfiFile, this.program) parser.parse({ Query: queryType, - Mutation: mutationType + Mutation: mutationType, + Subscription: subscriptionType }) return { diff --git a/packages/pylon-builder/src/schema/schema-parser.ts b/packages/pylon-builder/src/schema/schema-parser.ts index 5e82ca7..b524d5a 100644 --- a/packages/pylon-builder/src/schema/schema-parser.ts +++ b/packages/pylon-builder/src/schema/schema-parser.ts @@ -5,7 +5,8 @@ import { isFunction, isList, isPrimitive, - isPromise + isPromise, + isSubscriptionRepeater } from './types-helper.js' import { TypeDefinitionBuilder, @@ -101,6 +102,7 @@ interface ReferenceSchema { interface Index { Query?: ts.Type Mutation?: ts.Type + Subscription?: ts.Type } export class SchemaParser { @@ -153,6 +155,8 @@ export class SchemaParser { typeName = 'Query' } else if (index.Mutation === type) { typeName = 'Mutation' + } else if (index.Subscription === type) { + typeName = 'Subscription' } this.processSchemaReference(type, properties, typeName, 'types') @@ -715,6 +719,16 @@ export class SchemaParser { return } + if (isSubscriptionRepeater(type)) { + // type: Repeater<{ id: number; title: string; content: string; }, any, unknown> + + const repeaterItemType = this.checker.getTypeArguments(type as any)[0] + + recLoop(repeaterItemType, info, processing, [...path, 'REPEATER_ITEM']) + + return + } + // check if argType is a real type to ignore '[]' const wrongType = this.checker.typeToString(type) === '[]' @@ -942,6 +956,10 @@ export class SchemaParser { recLoop(index.Mutation) } + if (index.Subscription) { + recLoop(index.Subscription) + } + // Handle classes that implement interfaces of the schema const sourceFiles = this.program.getSourceFiles() diff --git a/packages/pylon-builder/src/schema/type-definition-builder.ts b/packages/pylon-builder/src/schema/type-definition-builder.ts index 7433e66..6fa1b28 100644 --- a/packages/pylon-builder/src/schema/type-definition-builder.ts +++ b/packages/pylon-builder/src/schema/type-definition-builder.ts @@ -8,6 +8,7 @@ import { isList, isPrimitive, isPromise, + isSubscriptionRepeater, safeTypeName } from './types-helper.js' import {Schema} from './schema-parser.js' @@ -58,6 +59,14 @@ export class TypeDefinitionBuilder { ): FieldDefinition => { const {type, wasOptional} = excludeNullUndefinedFromType(rawType) + if (isSubscriptionRepeater(type)) { + const repeaterItemType = this.checker.getTypeArguments(type as any)[0] + + if (repeaterItemType) { + return this.getTypeDefinition(repeaterItemType, options) + } + } + if (isPromise(type)) { const promiseType = getPromiseType(type) if (promiseType) { diff --git a/packages/pylon-builder/src/schema/types-helper.ts b/packages/pylon-builder/src/schema/types-helper.ts index d89bb5d..33c9887 100644 --- a/packages/pylon-builder/src/schema/types-helper.ts +++ b/packages/pylon-builder/src/schema/types-helper.ts @@ -179,3 +179,14 @@ export function getPublicPropertiesOfType( export const safeTypeName = (name: string) => { return name.replace(/[^0-9a-zA-Z_]/g, '_') } + +export const isSubscriptionRepeater = (type: ts.Type) => { + return !!type + .getSymbol() + ?.getDeclarations() + ?.some(d => { + const sourceFile = d.getSourceFile().fileName + + return sourceFile.includes('@repeaterjs') + }) +} diff --git a/packages/pylon/src/app/handler/graphql-handler.ts b/packages/pylon/src/app/handler/graphql-handler.ts index 0d15edb..469c022 100644 --- a/packages/pylon/src/app/handler/graphql-handler.ts +++ b/packages/pylon/src/app/handler/graphql-handler.ts @@ -11,6 +11,7 @@ export interface SchemaOptions { resolvers: { Query: Record Mutation: Record + Subscription: Record } config?: PylonConfig } diff --git a/packages/pylon/src/define-pylon.ts b/packages/pylon/src/define-pylon.ts index afa7c5f..9ca0907 100644 --- a/packages/pylon/src/define-pylon.ts +++ b/packages/pylon/src/define-pylon.ts @@ -4,15 +4,18 @@ import { FragmentDefinitionNode, GraphQLError, GraphQLErrorExtensions, + GraphQLObjectType, GraphQLResolveInfo, SelectionSetNode } from 'graphql' import {Context, asyncContext} from './context' +import {isAsyncIterable, Maybe} from 'graphql-yoga' export interface Resolvers { Query: Record Mutation: Record + Subscription: Record } type FunctionWrapper = (fn: (...args: any[]) => any) => (...args: any[]) => any @@ -82,6 +85,8 @@ async function wrapFunctionsRecursively( selectionSet, info ) + } else if (isAsyncIterable(obj)) { + return obj } else if (typeof obj === 'object') { that = obj @@ -164,7 +169,12 @@ export const resolversToGraphQLResolvers = ( // Define a root resolver function that maps a given resolver function or object to a GraphQL resolver. const rootGraphqlResolver = (fn: Function | object | Promise | Promise) => - async (_: object, args: Record, ctx: Context, info: any) => { + async ( + _: object, + args: Record, + ctx: Context, + info: GraphQLResolveInfo + ) => { return Sentry.withScope(async scope => { const ctx = asyncContext.getStore() @@ -187,18 +197,22 @@ export const resolversToGraphQLResolvers = ( // get query or mutation field - const isQuery = info.operation.operation === 'query' - const isMutation = info.operation.operation === 'mutation' - - if (!isQuery && !isMutation) { - throw new Error('Only queries and mutations are supported.') + let type: Maybe | null = null + + switch (info.operation.operation) { + case 'query': + type = info.schema.getQueryType() + break + case 'mutation': + type = info.schema.getMutationType() + break + case 'subscription': + type = info.schema.getSubscriptionType() + break + default: + throw new Error('Unknown operation') } - // Get the field metadata for the current query or mutation. - const type = isQuery - ? info.schema.getQueryType() - : info.schema.getMutationType() - const field = type?.getFields()[info.fieldName] // Get the list of arguments expected by the current query field. @@ -247,7 +261,9 @@ export const resolversToGraphQLResolvers = ( return wrappedFn } - return await wrappedFn(preparedArguments) + const res = await wrappedFn(preparedArguments) + + return res }) } @@ -285,6 +301,22 @@ export const resolversToGraphQLResolvers = ( } } + if ( + resolvers.Subscription && + Object.keys(resolvers.Subscription).length > 0 + ) { + if (!graphqlResolvers.Subscription) { + graphqlResolvers.Subscription = {} + } + + for (const [key, value] of Object.entries(resolvers.Subscription)) { + graphqlResolvers.Subscription[key] = { + subscribe: rootGraphqlResolver(value as Function | object), + resolve: (payload: any) => payload + } + } + } + // Query root type must be provided. if (!graphqlResolvers.Query) { // Custom Error for Query root type must be provided. @@ -304,7 +336,7 @@ export const graphql = { // Add extra resolvers (e.g. custom scalars) to the GraphQL resolvers. for (const key of Object.keys(resolvers)) { - if (key !== 'Query' && key !== 'Mutation') { + if (key !== 'Query' && key !== 'Mutation' && key !== 'Subscription') { graphqlResolvers[key] = resolvers[key] } } diff --git a/packages/pylon/src/index.ts b/packages/pylon/src/index.ts index c24dee5..5f90a9d 100644 --- a/packages/pylon/src/index.ts +++ b/packages/pylon/src/index.ts @@ -16,6 +16,7 @@ export {app} from './app/index.js' export {graphqlHandler} from './app/handler/graphql-handler.js' export {getEnv} from './get-env.js' export {createDecorator} from './create-decorator.js' +export {createPubSub as experimentalCreatePubSub} from 'graphql-yoga' export type PylonConfig = Pick, 'plugins'>