diff --git a/packages/api-server/ambient.d.ts b/packages/api-server/ambient.d.ts new file mode 100644 index 000000000000..af5f80584cd1 --- /dev/null +++ b/packages/api-server/ambient.d.ts @@ -0,0 +1 @@ +declare module 'dotenv-defaults' diff --git a/packages/api-server/dist.test.ts b/packages/api-server/dist.test.ts index 5dbc290a5937..db82efffeb01 100644 --- a/packages/api-server/dist.test.ts +++ b/packages/api-server/dist.test.ts @@ -72,6 +72,7 @@ describe('dist', () => { "type": "string", }, }, + "createServer": [Function], "webCliOptions": { "apiHost": { "alias": "api-host", diff --git a/packages/api-server/package.json b/packages/api-server/package.json index 51a199b4da1b..c35c32665b1f 100644 --- a/packages/api-server/package.json +++ b/packages/api-server/package.json @@ -61,7 +61,16 @@ "@types/yargs": "17.0.32", "aws-lambda": "1.0.7", "jest": "29.7.0", + "pino-abstract-transport": "1.1.0", "typescript": "5.3.3" }, + "peerDependencies": { + "@redwoodjs/graphql-server": "6.0.7" + }, + "peerDependenciesMeta": { + "@redwoodjs/graphql-server": { + "optional": true + } + }, "gitHead": "3905ed045508b861b495f8d5630d76c7a157d8f1" } diff --git a/packages/api-server/src/__tests__/createServer.test.ts b/packages/api-server/src/__tests__/createServer.test.ts new file mode 100644 index 000000000000..5c8130322325 --- /dev/null +++ b/packages/api-server/src/__tests__/createServer.test.ts @@ -0,0 +1,330 @@ +import path from 'path' + +import pino from 'pino' +import build from 'pino-abstract-transport' + +import { getConfig } from '@redwoodjs/project-config' + +import { + createServer, + resolveOptions, + DEFAULT_CREATE_SERVER_OPTIONS, +} from '../createServer' + +// Set up RWJS_CWD. +let original_RWJS_CWD + +beforeAll(() => { + original_RWJS_CWD = process.env.RWJS_CWD + process.env.RWJS_CWD = path.join(__dirname, './fixtures/redwood-app') +}) + +afterAll(() => { + process.env.RWJS_CWD = original_RWJS_CWD +}) + +describe('createServer', () => { + // Create a server for most tests. Some that test initialization create their own + let server + + beforeAll(async () => { + server = await createServer() + }) + + afterAll(async () => { + await server?.close() + }) + + it('serves functions', async () => { + const res = await server.inject({ + method: 'GET', + url: '/hello', + }) + + expect(res.json()).toEqual({ data: 'hello function' }) + }) + + describe('warnings', () => { + let consoleWarnSpy + + beforeAll(() => { + consoleWarnSpy = jest.spyOn(console, 'warn') + }) + + afterAll(() => { + consoleWarnSpy.mockRestore() + }) + + it('warns about server.config.js', async () => { + await createServer() + + expect(consoleWarnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` + " + Ignoring \`config\` and \`configureServer\` in api/server.config.js. + Migrate them to api/src/server.{ts,js}: +  + \`\`\`js title="api/src/server.{ts,js}" + // Pass your config to \`createServer\` + const server = createServer({ +  fastifyServerOptions: myFastifyConfig + }) +  + // Then inline your \`configureFastify\` logic: + server.register(myFastifyPlugin) + \`\`\` + " + `) + }) + }) + + it('`apiRootPath` prefixes all routes', async () => { + const server = await createServer({ apiRootPath: '/api' }) + + const res = await server.inject({ + method: 'GET', + url: '/api/hello', + }) + + expect(res.json()).toEqual({ data: 'hello function' }) + + await server.close() + }) + + // We use `console.log` and `.warn` to output some things. + // Meanwhile, the server gets a logger that may not output to the same place. + // The server's logger also seems to output things out of order. + // + // This should be fixed so that all logs go to the same place + describe('logs', () => { + let consoleLogSpy + let consoleWarnSpy + + beforeAll(() => { + consoleLogSpy = jest.spyOn(console, 'log').mockImplementation() + consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation() + }) + + afterAll(() => { + consoleLogSpy.mockRestore() + consoleWarnSpy.mockRestore() + }) + + it("doesn't handle logs consistently", async () => { + // Here we create a logger that outputs to an array. + const loggerLogs: string[] = [] + const stream = build(async (source) => { + for await (const obj of source) { + loggerLogs.push(obj) + } + }) + const logger = pino(stream) + + // Generate some logs. + const server = await createServer({ logger }) + const res = await server.inject({ + method: 'GET', + url: '/hello', + }) + expect(res.json()).toEqual({ data: 'hello function' }) + await server.listen({ port: 8910 }) + await server.close() + + // We expect console log to be called with `withFunctions` logs. + expect(consoleLogSpy.mock.calls[0][0]).toMatch( + /Importing Server Functions/ + ) + + const lastCallIndex = consoleLogSpy.mock.calls.length - 1 + + expect(consoleLogSpy.mock.calls[lastCallIndex][0]).toMatch(/Listening on/) + + // `console.warn` will be used if there's a `server.config.js` file. + expect(consoleWarnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` + " + Ignoring \`config\` and \`configureServer\` in api/server.config.js. + Migrate them to api/src/server.{ts,js}: +  + \`\`\`js title="api/src/server.{ts,js}" + // Pass your config to \`createServer\` + const server = createServer({ +  fastifyServerOptions: myFastifyConfig + }) +  + // Then inline your \`configureFastify\` logic: + server.register(myFastifyPlugin) + \`\`\` + " + `) + + // Finally, the logger. Notice how the request/response logs come before the "server is listening..." logs. + expect(loggerLogs[0]).toMatchObject({ + reqId: 'req-1', + level: 30, + msg: 'incoming request', + req: { + hostname: 'localhost:80', + method: 'GET', + remoteAddress: '127.0.0.1', + url: '/hello', + }, + }) + expect(loggerLogs[1]).toMatchObject({ + reqId: 'req-1', + level: 30, + msg: 'request completed', + res: { + statusCode: 200, + }, + }) + + expect(loggerLogs[2]).toMatchObject({ + level: 30, + msg: 'Server listening at http://[::1]:8910', + }) + expect(loggerLogs[3]).toMatchObject({ + level: 30, + msg: 'Server listening at http://127.0.0.1:8910', + }) + }) + }) + + describe('`server.start`', () => { + it('starts the server using [api].port in redwood.toml if none is specified', async () => { + const server = await createServer() + await server.start() + + const address = server.server.address() + + if (!address || typeof address === 'string') { + throw new Error('No address or address is a string') + } + + expect(address.port).toBe(getConfig().api.port) + + await server.close() + }) + + it('the `REDWOOD_API_PORT` env var takes precedence over [api].port', async () => { + process.env.REDWOOD_API_PORT = '8920' + + const server = await createServer() + await server.start() + + const address = server.server.address() + + if (!address || typeof address === 'string') { + throw new Error('No address or address is a string') + } + + expect(address.port).toBe(+process.env.REDWOOD_API_PORT) + + await server.close() + + delete process.env.REDWOOD_API_PORT + }) + }) +}) + +describe('resolveOptions', () => { + it('nothing passed', () => { + const resolvedOptions = resolveOptions() + + expect(resolvedOptions).toEqual({ + apiRootPath: DEFAULT_CREATE_SERVER_OPTIONS.apiRootPath, + fastifyServerOptions: { + requestTimeout: + DEFAULT_CREATE_SERVER_OPTIONS.fastifyServerOptions.requestTimeout, + logger: DEFAULT_CREATE_SERVER_OPTIONS.logger, + }, + port: 8911, + }) + }) + + it('ensures `apiRootPath` has slashes', () => { + const expected = '/v1/' + + expect( + resolveOptions({ + apiRootPath: 'v1', + }).apiRootPath + ).toEqual(expected) + + expect( + resolveOptions({ + apiRootPath: '/v1', + }).apiRootPath + ).toEqual(expected) + + expect( + resolveOptions({ + apiRootPath: 'v1/', + }).apiRootPath + ).toEqual(expected) + }) + + it('moves `logger` to `fastifyServerOptions.logger`', () => { + const resolvedOptions = resolveOptions({ + logger: { level: 'info' }, + }) + + expect(resolvedOptions).toMatchObject({ + fastifyServerOptions: { + logger: { level: 'info' }, + }, + }) + }) + + it('`logger` overwrites `fastifyServerOptions.logger`', () => { + const resolvedOptions = resolveOptions({ + logger: false, + fastifyServerOptions: { + // @ts-expect-error this is invalid TS but valid JS + logger: true, + }, + }) + + expect(resolvedOptions).toMatchObject({ + fastifyServerOptions: { + logger: false, + }, + }) + }) + + it('`DEFAULT_CREATE_SERVER_OPTIONS` overwrites `fastifyServerOptions.logger`', () => { + const resolvedOptions = resolveOptions({ + fastifyServerOptions: { + // @ts-expect-error this is invalid TS but valid JS + logger: true, + }, + }) + + expect(resolvedOptions).toMatchObject({ + fastifyServerOptions: { + logger: DEFAULT_CREATE_SERVER_OPTIONS.logger, + }, + }) + }) + + it('parses `--port`', () => { + expect(resolveOptions({}, ['--port', '8930']).port).toEqual(8930) + }) + + it("throws if `--port` can't be converted to an integer", () => { + expect(() => { + resolveOptions({}, ['--port', 'eight-nine-ten']) + }).toThrowErrorMatchingInlineSnapshot(`"\`port\` must be an integer"`) + }) + + it('parses `--apiRootPath`', () => { + expect(resolveOptions({}, ['--apiRootPath', 'foo']).apiRootPath).toEqual( + '/foo/' + ) + }) + + it('the `--apiRootPath` flag has precedence', () => { + expect( + resolveOptions({ apiRootPath: 'foo' }, ['--apiRootPath', 'bar']) + .apiRootPath + ).toEqual('/bar/') + }) +}) diff --git a/packages/api-server/src/cliHandlers.ts b/packages/api-server/src/cliHandlers.ts index a1fadee69b47..b6850227592c 100644 --- a/packages/api-server/src/cliHandlers.ts +++ b/packages/api-server/src/cliHandlers.ts @@ -59,7 +59,6 @@ export const apiServerHandler = async (options: ApiServerArgs) => { process.stdout.write(c.dim(c.italic('Starting API Server...\n'))) if (loadEnvFiles) { - // @ts-expect-error for some reason ts can't find the types here but can find them for other packages const { config } = await import('dotenv-defaults') config({ @@ -197,3 +196,6 @@ function isFullyQualifiedUrl(url: string) { return false } } + +// Temporarily here till we refactor server code +export { createServer } from './createServer' diff --git a/packages/api-server/src/createServer.ts b/packages/api-server/src/createServer.ts new file mode 100644 index 000000000000..93c8a8175faa --- /dev/null +++ b/packages/api-server/src/createServer.ts @@ -0,0 +1,345 @@ +import fs from 'fs' +import path from 'path' +import { parseArgs } from 'util' + +import fastifyUrlData from '@fastify/url-data' +import c from 'ansi-colors' +import { config } from 'dotenv-defaults' +import fg from 'fast-glob' +import fastify from 'fastify' +import type { + FastifyListenOptions, + FastifyServerOptions, + FastifyInstance, + HookHandlerDoneFunction, +} from 'fastify' +import fastifyRawBody from 'fastify-raw-body' + +import type { GlobalContext } from '@redwoodjs/context' +import { getAsyncStoreInstance } from '@redwoodjs/context/dist/store' +import { getConfig, getPaths } from '@redwoodjs/project-config' + +import { + loadFunctionsFromDist, + lambdaRequestHandler, +} from './plugins/lambdaLoader' + +type StartOptions = Omit + +interface Server extends FastifyInstance { + start: (options?: StartOptions) => Promise +} + +// Load .env files if they haven't already been loaded. This makes importing this file effectful: +// +// ```js +// # Loads dotenv... +// import { createServer } from '@redwoodjs/api-server' +// ``` +// +// We do it here and not in the function below so that users can access env vars before calling `createServer` +if (process.env.RWJS_CWD && !process.env.REDWOOD_ENV_FILES_LOADED) { + config({ + path: path.join(getPaths().base, '.env'), + defaults: path.join(getPaths().base, '.env.defaults'), + multiline: true, + }) +} + +export interface CreateServerOptions { + /** + * The prefix for all routes. Defaults to `/`. + */ + apiRootPath?: string + + /** + * Logger instance or options. + */ + logger?: FastifyServerOptions['logger'] + + /** + * Options for the fastify server instance. + * Omitting logger here because we move it up. + */ + fastifyServerOptions?: Omit +} + +/** + * Creates a server for api functions: + * + * ```js + * import { createServer } from '@redwoodjs/api-server' + * + * import { logger } from 'src/lib/logger' + * + async function main() { + * const server = await createServer({ + * logger, + * apiRootPath: 'api' + * }) + * + * // Configure the returned fastify instance: + * server.register(myPlugin) + * + * // When ready, start the server: + * await server.start() + * } + * + * main() + * ``` + */ +export async function createServer(options: CreateServerOptions = {}) { + const { apiRootPath, fastifyServerOptions, port } = resolveOptions(options) + + // Warn about `api/server.config.js` + const serverConfigPath = path.join( + getPaths().base, + getConfig().api.serverConfig + ) + + if (fs.existsSync(serverConfigPath)) { + console.warn( + c.yellow( + [ + '', + `Ignoring \`config\` and \`configureServer\` in api/server.config.js.`, + `Migrate them to api/src/server.{ts,js}:`, + '', + `\`\`\`js title="api/src/server.{ts,js}"`, + '// Pass your config to `createServer`', + 'const server = createServer({', + ' fastifyServerOptions: myFastifyConfig', + '})', + '', + '// Then inline your `configureFastify` logic:', + 'server.register(myFastifyPlugin)', + '```', + '', + ].join('\n') + ) + ) + } + + // Initialize the fastify instance + const server: Server = Object.assign(fastify(fastifyServerOptions), { + // `start` will get replaced further down in this file + start: async () => { + throw new Error('Not implemented yet') + }, + }) + + server.addHook('onRequest', (_req, _reply, done) => { + getAsyncStoreInstance().run(new Map(), done) + }) + + await server.register(redwoodFastifyFunctions, { redwood: { apiRootPath } }) + + // If we can find `api/dist/functions/graphql.js`, register the GraphQL plugin + const [graphqlFunctionPath] = await fg('dist/functions/graphql.{ts,js}', { + cwd: getPaths().api.base, + absolute: true, + }) + + if (graphqlFunctionPath) { + const { redwoodFastifyGraphQLServer } = require('./plugins/graphql') + // This comes from a babel plugin that's applied to api/dist/functions/graphql.{ts,js} in user projects + const { __rw_graphqlOptions } = require(graphqlFunctionPath) + + await server.register(redwoodFastifyGraphQLServer, { + redwood: { + apiRootPath, + graphql: __rw_graphqlOptions, + }, + }) + } + + // For baremetal and pm2. See https://github.com/redwoodjs/redwood/pull/4744 + server.addHook('onReady', (done) => { + process.send?.('ready') + done() + }) + + // Just logging. The conditional here is to appease TS. + // `server.server.address()` can return a string, an AddressInfo object, or null. + // Note that the logging here ("Listening on...") seems to be duplicated, probably by `@redwoodjs/graphql-server` + server.addHook('onListen', (done) => { + const addressInfo = server.server.address() + + if (!addressInfo || typeof addressInfo === 'string') { + done() + return + } + + console.log( + `Listening on ${c.magenta( + `http://${addressInfo.address}:${addressInfo.port}${apiRootPath}` + )}` + ) + done() + }) + + /** + * A wrapper around `fastify.listen` that handles `--port`, `REDWOOD_API_PORT` and [api].port in redwood.toml + * + * The order of precedence is: + * - `--port` + * - `REDWOOD_API_PORT` + * - [api].port in redwood.toml + */ + server.start = (options: StartOptions = {}) => { + return server.listen({ + ...options, + port, + host: process.env.NODE_ENV === 'production' ? '0.0.0.0' : '::', + }) + } + + return server +} + +type ResolvedOptions = Required< + Omit & { + fastifyServerOptions: FastifyServerOptions + port: number + } +> + +export function resolveOptions( + options: CreateServerOptions = {}, + args?: string[] +) { + options.logger ??= DEFAULT_CREATE_SERVER_OPTIONS.logger + + let defaultPort: number | undefined + + if (process.env.REDWOOD_API_PORT === undefined) { + defaultPort = getConfig().api.port + } else { + defaultPort = parseInt(process.env.REDWOOD_API_PORT) + } + + // Set defaults. + const resolvedOptions: ResolvedOptions = { + apiRootPath: + options.apiRootPath ?? DEFAULT_CREATE_SERVER_OPTIONS.apiRootPath, + + fastifyServerOptions: options.fastifyServerOptions ?? { + requestTimeout: + DEFAULT_CREATE_SERVER_OPTIONS.fastifyServerOptions.requestTimeout, + logger: options.logger ?? DEFAULT_CREATE_SERVER_OPTIONS.logger, + }, + + port: defaultPort, + } + + // Merge fastifyServerOptions. + resolvedOptions.fastifyServerOptions.requestTimeout ??= + DEFAULT_CREATE_SERVER_OPTIONS.fastifyServerOptions.requestTimeout + resolvedOptions.fastifyServerOptions.logger = options.logger + + const { values } = parseArgs({ + options: { + apiRootPath: { + type: 'string', + }, + port: { + type: 'string', + short: 'p', + }, + }, + + // When running Jest, `process.argv` is... + // + // ```js + // [ + // 'path/to/node' + // 'path/to/jest.js' + // 'file/under/test.js' + // ] + // ``` + // + // `parseArgs` strips the first two, leaving the third, which is interpreted as a positional argument. + // Which fails our options. We'd still like to be strict, but can't do it for tests. + strict: process.env.NODE_ENV === 'test' ? false : true, + ...(args && { args }), + }) + + if (values.apiRootPath && typeof values.apiRootPath !== 'string') { + throw new Error('`apiRootPath` must be a string') + } + + if (values.apiRootPath) { + resolvedOptions.apiRootPath = values.apiRootPath + } + + // Format `apiRootPath` + if (resolvedOptions.apiRootPath.charAt(0) !== '/') { + resolvedOptions.apiRootPath = `/${resolvedOptions.apiRootPath}` + } + + if ( + resolvedOptions.apiRootPath.charAt( + resolvedOptions.apiRootPath.length - 1 + ) !== '/' + ) { + resolvedOptions.apiRootPath = `${resolvedOptions.apiRootPath}/` + } + + if (values.port) { + resolvedOptions.port = +values.port + + if (isNaN(resolvedOptions.port)) { + throw new Error('`port` must be an integer') + } + } + + return resolvedOptions +} + +type DefaultCreateServerOptions = Required< + Omit & { + fastifyServerOptions: Pick + } +> + +export const DEFAULT_CREATE_SERVER_OPTIONS: DefaultCreateServerOptions = { + apiRootPath: '/', + logger: { + level: process.env.NODE_ENV === 'development' ? 'debug' : 'warn', + }, + fastifyServerOptions: { + requestTimeout: 15_000, + }, +} + +export interface RedwoodFastifyAPIOptions { + redwood: { + apiRootPath: string + } +} + +export async function redwoodFastifyFunctions( + fastify: FastifyInstance, + opts: RedwoodFastifyAPIOptions, + done: HookHandlerDoneFunction +) { + fastify.register(fastifyUrlData) + await fastify.register(fastifyRawBody) + + fastify.addContentTypeParser( + ['application/x-www-form-urlencoded', 'multipart/form-data'], + { parseAs: 'string' }, + fastify.defaultTextParser + ) + + fastify.all(`${opts.redwood.apiRootPath}:routeName`, lambdaRequestHandler) + fastify.all(`${opts.redwood.apiRootPath}:routeName/*`, lambdaRequestHandler) + + await loadFunctionsFromDist({ + fastGlobOptions: { + ignore: ['**/dist/functions/graphql.js'], + }, + }) + + done() +} diff --git a/packages/fastify/src/graphql.ts b/packages/api-server/src/plugins/graphql.ts similarity index 66% rename from packages/fastify/src/graphql.ts rename to packages/api-server/src/plugins/graphql.ts index b3d1ef5d06b1..86bdb7980eba 100644 --- a/packages/fastify/src/graphql.ts +++ b/packages/api-server/src/plugins/graphql.ts @@ -1,4 +1,5 @@ import fastifyUrlData from '@fastify/url-data' +import fg from 'fast-glob' import type { FastifyInstance, HTTPMethods, @@ -9,16 +10,22 @@ import type { import fastifyRawBody from 'fastify-raw-body' import type { Plugin } from 'graphql-yoga' -import type { GlobalContext } from '@redwoodjs/context' -import { getAsyncStoreInstance } from '@redwoodjs/context/dist/store' -import type { GraphQLYogaOptions } from '@redwoodjs/graphql-server' import { createGraphQLYoga } from '@redwoodjs/graphql-server' +import type { GraphQLYogaOptions } from '@redwoodjs/graphql-server' +import { getPaths } from '@redwoodjs/project-config' /** * Transform a Fastify Request to an event compatible with the RedwoodGraphQLContext's event * which is based on the AWS Lambda event */ -import { lambdaEventForFastifyRequest as transformToRedwoodGraphQLContextEvent } from './lambda/index' +import { lambdaEventForFastifyRequest } from '../requestHandlers/awsLambdaFastify' + +export interface RedwoodFastifyGraphQLOptions { + redwood: { + apiRootPath: string + graphql?: GraphQLYogaOptions + } +} /** * Redwood GraphQL Server Fastify plugin based on GraphQL Yoga @@ -28,7 +35,7 @@ import { lambdaEventForFastifyRequest as transformToRedwoodGraphQLContextEvent } */ export async function redwoodFastifyGraphQLServer( fastify: FastifyInstance, - options: GraphQLYogaOptions, + options: RedwoodFastifyGraphQLOptions, done: HookHandlerDoneFunction ) { // These two plugins are needed to transform a Fastify Request to a Lambda event @@ -42,32 +49,39 @@ export async function redwoodFastifyGraphQLServer( try { const method = ['GET', 'POST', 'OPTIONS'] as HTTPMethods[] - // TODO: This should be refactored to only be defined once and it might not live here - // Ensure that each request has a unique global context - fastify.addHook('onRequest', (_req, _reply, done) => { - getAsyncStoreInstance().run(new Map(), done) - }) + // Load the graphql options from the graphql function if none are explicitly provided + if (!options.redwood.graphql) { + const [graphqlFunctionPath] = await fg('dist/functions/graphql.{ts,js}', { + cwd: getPaths().api.base, + absolute: true, + }) + + const { __rw_graphqlOptions } = await import(graphqlFunctionPath) + options.redwood.graphql = __rw_graphqlOptions as GraphQLYogaOptions + } + + const graphqlOptions = options.redwood.graphql // Here we can add any plugins that we want to use with GraphQL Yoga Server // that we do not want to add the the GraphQLHandler in the graphql-server // graphql function. // // These would be plugins that need a server instance such as Redwood Realtime - if (options.realtime) { + if (graphqlOptions.realtime) { const { useRedwoodRealtime } = await import('@redwoodjs/realtime') const originalExtraPlugins: Array> = - options.extraPlugins || [] - originalExtraPlugins.push(useRedwoodRealtime(options.realtime)) - options.extraPlugins = originalExtraPlugins + graphqlOptions.extraPlugins || [] + originalExtraPlugins.push(useRedwoodRealtime(graphqlOptions.realtime)) + graphqlOptions.extraPlugins = originalExtraPlugins // uses for SSE single connection mode with the `/graphql/stream` endpoint - if (options.realtime.subscriptions) { + if (graphqlOptions.realtime.subscriptions) { method.push('PUT') } } - const { yoga } = createGraphQLYoga(options) + const { yoga } = createGraphQLYoga(graphqlOptions) const graphQLYogaHandler = async ( req: FastifyRequest, @@ -76,7 +90,7 @@ export async function redwoodFastifyGraphQLServer( const response = await yoga.handleNodeRequest(req, { req, reply, - event: transformToRedwoodGraphQLContextEvent(req), + event: lambdaEventForFastifyRequest(req), requestContext: {}, }) @@ -91,14 +105,15 @@ export async function redwoodFastifyGraphQLServer( } const routePaths = ['', '/health', '/readiness', '/stream'] - - routePaths.forEach((routePath) => { + for (const routePath of routePaths) { fastify.route({ - url: `${yoga.graphqlEndpoint}${routePath}`, + url: `${options.redwood.apiRootPath}${formatGraphQLEndpoint( + yoga.graphqlEndpoint + )}${routePath}`, method, handler: async (req, reply) => await graphQLYogaHandler(req, reply), }) - }) + } fastify.ready(() => { console.info(`GraphQL Yoga Server endpoint at ${yoga.graphqlEndpoint}`) @@ -115,3 +130,7 @@ export async function redwoodFastifyGraphQLServer( console.log(e) } } + +function formatGraphQLEndpoint(endpoint: string) { + return endpoint.replace(/^\//, '').replace(/\/$/, '') +} diff --git a/packages/api-server/src/plugins/lambdaLoader.ts b/packages/api-server/src/plugins/lambdaLoader.ts index c345150c36b4..ebcdce6fb1bb 100644 --- a/packages/api-server/src/plugins/lambdaLoader.ts +++ b/packages/api-server/src/plugins/lambdaLoader.ts @@ -3,6 +3,7 @@ import path from 'path' import c from 'ansi-colors' import type { Handler } from 'aws-lambda' import fg from 'fast-glob' +import type { Options as FastGlobOptions } from 'fast-glob' import type { FastifyReply, FastifyRequest, @@ -54,9 +55,19 @@ export const setLambdaFunctions = async (foundFunctions: string[]) => { }) } +type LoadFunctionsFromDistOptions = { + fastGlobOptions?: FastGlobOptions +} + // TODO: Use v8 caching to load these crazy fast. -export const loadFunctionsFromDist = async () => { - const serverFunctions = findApiDistFunctions() +export const loadFunctionsFromDist = async ( + options: LoadFunctionsFromDistOptions = {} +) => { + const serverFunctions = findApiDistFunctions( + getPaths().api.base, + options?.fastGlobOptions + ) + // Place `GraphQL` serverless function at the start. const i = serverFunctions.findIndex((x) => x.indexOf('graphql') !== -1) if (i >= 0) { @@ -68,11 +79,15 @@ export const loadFunctionsFromDist = async () => { // NOTE: Copied from @redwoodjs/internal/dist/files to avoid depending on @redwoodjs/internal. // import { findApiDistFunctions } from '@redwoodjs/internal/dist/files' -function findApiDistFunctions(cwd: string = getPaths().api.base) { +function findApiDistFunctions( + cwd: string = getPaths().api.base, + options: FastGlobOptions = {} +) { return fg.sync('dist/functions/**/*.{ts,js}', { cwd, deep: 2, // We don't support deeply nested api functions, to maximise compatibility with deployment providers absolute: true, + ...options, }) } diff --git a/packages/api-server/src/requestHandlers/awsLambdaFastify.ts b/packages/api-server/src/requestHandlers/awsLambdaFastify.ts index e4b0efd32e8a..b68fc38a123c 100644 --- a/packages/api-server/src/requestHandlers/awsLambdaFastify.ts +++ b/packages/api-server/src/requestHandlers/awsLambdaFastify.ts @@ -8,7 +8,7 @@ import qs from 'qs' import { mergeMultiValueHeaders, parseBody } from './utils' -const lambdaEventForFastifyRequest = ( +export const lambdaEventForFastifyRequest = ( request: FastifyRequest ): APIGatewayProxyEvent => { return { diff --git a/packages/api-server/src/watch.ts b/packages/api-server/src/watch.ts index a4aa51a4fe12..1888a660304b 100644 --- a/packages/api-server/src/watch.ts +++ b/packages/api-server/src/watch.ts @@ -6,7 +6,6 @@ import fs from 'fs' import path from 'path' import c from 'ansi-colors' -import chalk from 'chalk' import chokidar from 'chokidar' import dotenv from 'dotenv' import { debounce } from 'lodash' @@ -32,7 +31,6 @@ const argv = yargs(hideBin(process.argv)) description: 'Debugging port', type: 'number', }) - // `port` is not used when server-file is used .option('port', { alias: 'p', description: 'Port', @@ -131,20 +129,13 @@ const buildAndRestart = async ({ // Start API server - // Check if experimental server file exists const serverFile = resolveFile(`${rwjsPaths.api.dist}/server`) if (serverFile) { - const separator = chalk.hex('#ff845e')('-'.repeat(79)) - console.log( - [ - separator, - `🧪 ${chalk.green('Experimental Feature')} 🧪`, - separator, - 'Using the experimental API server file at api/dist/server.js (in watch mode)', - separator, - ].join('\n') + httpServerProcess = fork( + serverFile, + ['--port', port.toString()], + forkOpts ) - httpServerProcess = fork(serverFile, [], forkOpts) } else { httpServerProcess = fork( path.join(__dirname, 'index.js'), diff --git a/packages/babel-config/src/api.ts b/packages/babel-config/src/api.ts index 53c05cc56801..0de4f471de19 100644 --- a/packages/babel-config/src/api.ts +++ b/packages/babel-config/src/api.ts @@ -152,6 +152,16 @@ export const getApiSideBabelConfigPath = () => { export const getApiSideBabelOverrides = () => { const overrides = [ + // Extract graphql options from the graphql function + // NOTE: this must come before the context wrapping + { + // match */api/src/functions/graphql.js|ts + test: /.+api(?:[\\|/])src(?:[\\|/])functions(?:[\\|/])graphql\.(?:js|ts)$/, + plugins: [ + require('./plugins/babel-plugin-redwood-graphql-options-extract') + .default, + ], + }, // Apply context wrapping to all functions { // match */api/src/functions/*.js|ts diff --git a/packages/babel-config/src/plugins/__tests__/__fixtures__/graphql-options-extract/default-graphql-function/code.js b/packages/babel-config/src/plugins/__tests__/__fixtures__/graphql-options-extract/default-graphql-function/code.js new file mode 100644 index 000000000000..f395c3b0f852 --- /dev/null +++ b/packages/babel-config/src/plugins/__tests__/__fixtures__/graphql-options-extract/default-graphql-function/code.js @@ -0,0 +1,19 @@ +import { createGraphQLHandler } from '@redwoodjs/graphql-server' + +import directives from 'src/directives/**/*.{js,ts}' +import sdls from 'src/graphql/**/*.sdl.{js,ts}' +import services from 'src/services/**/*.{js,ts}' + +import { db } from 'src/lib/db' +import { logger } from 'src/lib/logger' + +export const handler = createGraphQLHandler({ + loggerConfig: { logger, options: {} }, + directives, + sdls, + services, + onException: () => { + // Disconnect from your database with an unhandled exception. + db.$disconnect() + }, +}) diff --git a/packages/babel-config/src/plugins/__tests__/__fixtures__/graphql-options-extract/default-graphql-function/output.js b/packages/babel-config/src/plugins/__tests__/__fixtures__/graphql-options-extract/default-graphql-function/output.js new file mode 100644 index 000000000000..0bb586e9f8b7 --- /dev/null +++ b/packages/babel-config/src/plugins/__tests__/__fixtures__/graphql-options-extract/default-graphql-function/output.js @@ -0,0 +1,20 @@ +import { createGraphQLHandler } from '@redwoodjs/graphql-server' +import directives from 'src/directives/**/*.{js,ts}' +import sdls from 'src/graphql/**/*.sdl.{js,ts}' +import services from 'src/services/**/*.{js,ts}' +import { db } from 'src/lib/db' +import { logger } from 'src/lib/logger' +export const __rw_graphqlOptions = { + loggerConfig: { + logger, + options: {}, + }, + directives, + sdls, + services, + onException: () => { + // Disconnect from your database with an unhandled exception. + db.$disconnect() + }, +} +export const handler = createGraphQLHandler(__rw_graphqlOptions) \ No newline at end of file diff --git a/packages/babel-config/src/plugins/__tests__/__fixtures__/graphql-options-extract/evil-graphql-function/code.js b/packages/babel-config/src/plugins/__tests__/__fixtures__/graphql-options-extract/evil-graphql-function/code.js new file mode 100644 index 000000000000..b18cec542546 --- /dev/null +++ b/packages/babel-config/src/plugins/__tests__/__fixtures__/graphql-options-extract/evil-graphql-function/code.js @@ -0,0 +1,36 @@ +import { createGraphQLHandler } from '@redwoodjs/graphql-server' + +import directives from 'src/directives/**/*.{js,ts}' +import sdls from 'src/graphql/**/*.sdl.{js,ts}' +import services from 'src/services/**/*.{js,ts}' + +import { db } from 'src/lib/db' +import { logger } from 'src/lib/logger' + +export const handling = () => { + console.log("handling") +} + +const config = { + loggerConfig: { logger, options: {} }, + directives, + sdls, + services, + onException() { + // Disconnect from your database with an unhandled exception. + db.$disconnect() + }, + extraPlugins: [ + { + name: 'test', + function: () => {console.log('test')} + } + ], + graphiQLEndpoint: 'coolness', + allowGraphiQL: false, +} + +/** + * Comments... + */ +export const handler = createGraphQLHandler(process.env.EVIL ? config : {sadness: true}) diff --git a/packages/babel-config/src/plugins/__tests__/__fixtures__/graphql-options-extract/evil-graphql-function/output.js b/packages/babel-config/src/plugins/__tests__/__fixtures__/graphql-options-extract/evil-graphql-function/output.js new file mode 100644 index 000000000000..d69716078af2 --- /dev/null +++ b/packages/babel-config/src/plugins/__tests__/__fixtures__/graphql-options-extract/evil-graphql-function/output.js @@ -0,0 +1,42 @@ +import { createGraphQLHandler } from '@redwoodjs/graphql-server' +import directives from 'src/directives/**/*.{js,ts}' +import sdls from 'src/graphql/**/*.sdl.{js,ts}' +import services from 'src/services/**/*.{js,ts}' +import { db } from 'src/lib/db' +import { logger } from 'src/lib/logger' +export const handling = () => { + console.log('handling') +} +const config = { + loggerConfig: { + logger, + options: {}, + }, + directives, + sdls, + services, + onException() { + // Disconnect from your database with an unhandled exception. + db.$disconnect() + }, + extraPlugins: [ + { + name: 'test', + function: () => { + console.log('test') + }, + }, + ], + graphiQLEndpoint: 'coolness', + allowGraphiQL: false, +} + +/** + * Comments... + */ +export const __rw_graphqlOptions = process.env.EVIL + ? config + : { + sadness: true, + } +export const handler = createGraphQLHandler(__rw_graphqlOptions) \ No newline at end of file diff --git a/packages/babel-config/src/plugins/__tests__/__fixtures__/graphql-options-extract/function-graphql-function/code.js b/packages/babel-config/src/plugins/__tests__/__fixtures__/graphql-options-extract/function-graphql-function/code.js new file mode 100644 index 000000000000..a94bc8788cef --- /dev/null +++ b/packages/babel-config/src/plugins/__tests__/__fixtures__/graphql-options-extract/function-graphql-function/code.js @@ -0,0 +1,21 @@ +import { createGraphQLHandler } from '@redwoodjs/graphql-server' + +import directives from 'src/directives/**/*.{js,ts}' +import sdls from 'src/graphql/**/*.sdl.{js,ts}' +import services from 'src/services/**/*.{js,ts}' + +import { db } from 'src/lib/db' +import { logger } from 'src/lib/logger' + +const config = () => ({ + loggerConfig: { logger, options: {} }, + directives, + sdls, + services, + onException: () => { + // Disconnect from your database with an unhandled exception. + db.$disconnect() + }, +}) + +export const handler = createGraphQLHandler(config()) diff --git a/packages/babel-config/src/plugins/__tests__/__fixtures__/graphql-options-extract/function-graphql-function/output.js b/packages/babel-config/src/plugins/__tests__/__fixtures__/graphql-options-extract/function-graphql-function/output.js new file mode 100644 index 000000000000..b319ddf9e8b6 --- /dev/null +++ b/packages/babel-config/src/plugins/__tests__/__fixtures__/graphql-options-extract/function-graphql-function/output.js @@ -0,0 +1,21 @@ +import { createGraphQLHandler } from '@redwoodjs/graphql-server' +import directives from 'src/directives/**/*.{js,ts}' +import sdls from 'src/graphql/**/*.sdl.{js,ts}' +import services from 'src/services/**/*.{js,ts}' +import { db } from 'src/lib/db' +import { logger } from 'src/lib/logger' +const config = () => ({ + loggerConfig: { + logger, + options: {}, + }, + directives, + sdls, + services, + onException: () => { + // Disconnect from your database with an unhandled exception. + db.$disconnect() + }, +}) +export const __rw_graphqlOptions = config() +export const handler = createGraphQLHandler(__rw_graphqlOptions) \ No newline at end of file diff --git a/packages/babel-config/src/plugins/__tests__/__fixtures__/graphql-options-extract/modified-graphql-function/code.js b/packages/babel-config/src/plugins/__tests__/__fixtures__/graphql-options-extract/modified-graphql-function/code.js new file mode 100644 index 000000000000..7083f6d9313a --- /dev/null +++ b/packages/babel-config/src/plugins/__tests__/__fixtures__/graphql-options-extract/modified-graphql-function/code.js @@ -0,0 +1,36 @@ +import { createGraphQLHandler } from '@redwoodjs/graphql-server' + +import directives from 'src/directives/**/*.{js,ts}' +import sdls from 'src/graphql/**/*.sdl.{js,ts}' +import services from 'src/services/**/*.{js,ts}' + +import { db } from 'src/lib/db' +import { logger } from 'src/lib/logger' + +export const handling = () => { + console.log("handling") +} + +const config = { + loggerConfig: { logger, options: {} }, + directives, + sdls, + services, + onException() { + // Disconnect from your database with an unhandled exception. + db.$disconnect() + }, + extraPlugins: [ + { + name: 'test', + function: () => {console.log('test')} + } + ], + graphiQLEndpoint: 'coolness', + allowGraphiQL: false, +} + +/** + * Comments... + */ +export const handler = createGraphQLHandler(config) diff --git a/packages/babel-config/src/plugins/__tests__/__fixtures__/graphql-options-extract/modified-graphql-function/output.js b/packages/babel-config/src/plugins/__tests__/__fixtures__/graphql-options-extract/modified-graphql-function/output.js new file mode 100644 index 000000000000..bd29d0011d17 --- /dev/null +++ b/packages/babel-config/src/plugins/__tests__/__fixtures__/graphql-options-extract/modified-graphql-function/output.js @@ -0,0 +1,38 @@ +import { createGraphQLHandler } from '@redwoodjs/graphql-server' +import directives from 'src/directives/**/*.{js,ts}' +import sdls from 'src/graphql/**/*.sdl.{js,ts}' +import services from 'src/services/**/*.{js,ts}' +import { db } from 'src/lib/db' +import { logger } from 'src/lib/logger' +export const handling = () => { + console.log('handling') +} +const config = { + loggerConfig: { + logger, + options: {}, + }, + directives, + sdls, + services, + onException() { + // Disconnect from your database with an unhandled exception. + db.$disconnect() + }, + extraPlugins: [ + { + name: 'test', + function: () => { + console.log('test') + }, + }, + ], + graphiQLEndpoint: 'coolness', + allowGraphiQL: false, +} + +/** + * Comments... + */ +export const __rw_graphqlOptions = config +export const handler = createGraphQLHandler(__rw_graphqlOptions) \ No newline at end of file diff --git a/packages/babel-config/src/plugins/__tests__/babel-plugin-redwood-graphql-options-extract.test.ts b/packages/babel-config/src/plugins/__tests__/babel-plugin-redwood-graphql-options-extract.test.ts new file mode 100644 index 000000000000..86ffae891392 --- /dev/null +++ b/packages/babel-config/src/plugins/__tests__/babel-plugin-redwood-graphql-options-extract.test.ts @@ -0,0 +1,19 @@ +import path from 'path' + +import pluginTester from 'babel-plugin-tester' + +import redwoodGraphqlOptionsExtract from '../babel-plugin-redwood-graphql-options-extract' + +jest.mock('@redwoodjs/project-config', () => { + return { + getBaseDirFromFile: () => { + return '' + }, + } +}) + +pluginTester({ + plugin: redwoodGraphqlOptionsExtract, + pluginName: 'babel-plugin-redwood-graphql-options-extract', + fixtures: path.join(__dirname, '__fixtures__/graphql-options-extract'), +}) diff --git a/packages/babel-config/src/plugins/babel-plugin-redwood-graphql-options-extract.ts b/packages/babel-config/src/plugins/babel-plugin-redwood-graphql-options-extract.ts new file mode 100644 index 000000000000..34e39c7aa276 --- /dev/null +++ b/packages/babel-config/src/plugins/babel-plugin-redwood-graphql-options-extract.ts @@ -0,0 +1,69 @@ +// import type { NodePath, PluginObj, types } from '@babel/core' +import type { PluginObj, PluginPass, types } from '@babel/core' + +// This extracts the options passed to the graphql function and stores them in a file so they can be imported elsewhere. + +const exportVariableName = '__rw_graphqlOptions' as const + +function optionsConstNode( + t: typeof types, + value: + | types.ArgumentPlaceholder + | types.JSXNamespacedName + | types.SpreadElement + | types.Expression, + state: PluginPass +) { + if ( + t.isIdentifier(value) || + t.isObjectExpression(value) || + t.isCallExpression(value) || + t.isConditionalExpression(value) + ) { + return t.exportNamedDeclaration( + t.variableDeclaration('const', [ + t.variableDeclarator(t.identifier(exportVariableName), value), + ]) + ) + } else { + throw new Error( + `Unable to parse graphql function options in '${state.file.opts.filename}'` + ) + } +} + +export default function ({ types: t }: { types: typeof types }): PluginObj { + return { + name: 'babel-plugin-redwood-graphql-options-extract', + visitor: { + ExportNamedDeclaration(path, state) { + const declaration = path.node.declaration + if (declaration?.type !== 'VariableDeclaration') { + return + } + + const declarator = declaration.declarations[0] + if (declarator?.type !== 'VariableDeclarator') { + return + } + + const identifier = declarator.id + if (identifier?.type !== 'Identifier') { + return + } + if (identifier.name !== 'handler') { + return + } + + const init = declarator.init + if (init?.type !== 'CallExpression') { + return + } + + const options = init.arguments[0] + path.insertBefore(optionsConstNode(t, options, state)) + init.arguments[0] = t.identifier(exportVariableName) + }, + }, + } +} diff --git a/packages/cli/src/commands/experimental/setupServerFileHandler.js b/packages/cli/src/commands/experimental/setupServerFileHandler.js deleted file mode 100644 index b6140dae8971..000000000000 --- a/packages/cli/src/commands/experimental/setupServerFileHandler.js +++ /dev/null @@ -1,119 +0,0 @@ -import path from 'path' - -import fs from 'fs-extra' -import { Listr } from 'listr2' - -import { addApiPackages } from '@redwoodjs/cli-helpers' -import { getConfigPath } from '@redwoodjs/project-config' -import { errorTelemetry } from '@redwoodjs/telemetry' - -import { getPaths, transformTSToJS, writeFile } from '../../lib' -import c from '../../lib/colors' -import { isTypeScriptProject } from '../../lib/project' - -import { command, description, EXPERIMENTAL_TOPIC_ID } from './setupServerFile' -import { printTaskEpilogue } from './util' - -const { version } = JSON.parse( - fs.readFileSync(path.resolve(__dirname, '../../../package.json'), 'utf-8') -) - -export const setupServerFileTasks = (force = false) => { - const redwoodPaths = getPaths() - const ts = isTypeScriptProject() - - const serverFilePath = path.join( - redwoodPaths.api.src, - `server.${isTypeScriptProject() ? 'ts' : 'js'}` - ) - - return [ - { - title: 'Adding the experimental server files...', - task: () => { - const serverFileTemplateContent = fs.readFileSync( - path.resolve(__dirname, 'templates', 'server.ts.template'), - 'utf-8' - ) - - const setupScriptContent = ts - ? serverFileTemplateContent - : transformTSToJS(serverFilePath, serverFileTemplateContent) - - return [ - writeFile(serverFilePath, setupScriptContent, { - overwriteExisting: force, - }), - ] - }, - }, - { - title: 'Adding config to redwood.toml...', - task: (_ctx, task) => { - // - const redwoodTomlPath = getConfigPath() - const configContent = fs.readFileSync(redwoodTomlPath, 'utf-8') - if (!configContent.includes('[experimental.serverFile]')) { - // Use string replace to preserve comments and formatting - writeFile( - redwoodTomlPath, - configContent.concat( - `\n[experimental.serverFile]\n\tenabled = true\n` - ), - { - overwriteExisting: true, // redwood.toml always exists - } - ) - } else { - task.skip( - `The [experimental.serverFile] config block already exists in your 'redwood.toml' file.` - ) - } - }, - }, - addApiPackages([ - 'fastify', - 'chalk@4.1.2', - `@redwoodjs/fastify@${version}`, - `@redwoodjs/project-config@${version}`, - ]), - ] -} - -export async function handler({ force, verbose }) { - const tasks = new Listr( - [ - { - title: 'Confirmation', - task: async (_ctx, task) => { - const confirmation = await task.prompt({ - type: 'Confirm', - message: 'The server file is experimental. Continue?', - }) - - if (!confirmation) { - throw new Error('User aborted') - } - }, - }, - ...setupServerFileTasks(force), - { - task: () => { - printTaskEpilogue(command, description, EXPERIMENTAL_TOPIC_ID) - }, - }, - ], - { - rendererOptions: { collapseSubtasks: false, persistentOutput: true }, - renderer: verbose ? 'verbose' : 'default', - } - ) - - try { - await tasks.run() - } catch (e) { - errorTelemetry(process.argv, e.message) - console.error(c.error(e.message)) - process.exit(e?.exitCode || 1) - } -} diff --git a/packages/cli/src/commands/experimental/templates/server.ts.template b/packages/cli/src/commands/experimental/templates/server.ts.template deleted file mode 100644 index fedba89afd12..000000000000 --- a/packages/cli/src/commands/experimental/templates/server.ts.template +++ /dev/null @@ -1,113 +0,0 @@ -import { parseArgs } from 'node:util' -import path from 'path' - -import chalk from 'chalk' -import { config } from 'dotenv-defaults' -import Fastify from 'fastify' - -import { - coerceRootPath, - redwoodFastifyWeb, - redwoodFastifyAPI, - redwoodFastifyGraphQLServer, - DEFAULT_REDWOOD_FASTIFY_CONFIG, -} from '@redwoodjs/fastify' -import { getPaths, getConfig } from '@redwoodjs/project-config' - -import directives from 'src/directives/**/*.{js,ts}' -import sdls from 'src/graphql/**/*.sdl.{js,ts}' -import services from 'src/services/**/*.{js,ts}' - -// Import if using RedwoodJS authentication -// import { authDecoder } from '@redwoodjs/' -// import { getCurrentUser } from 'src/lib/auth' - -import { logger } from 'src/lib/logger' - -// Import if using RedwoodJS Realtime via `yarn rw exp setup-realtime` -// import { realtime } from 'src/lib/realtime' - -async function serve() { - // Parse server file args - const { values: args } = parseArgs({ - options: { - ['enable-web']: { - type: 'boolean', - default: false, - }, - }, - }) - const { ['enable-web']: enableWeb } = args - - // Load .env files - const redwoodProjectPaths = getPaths() - const redwoodConfig = getConfig() - - const apiRootPath = enableWeb ? coerceRootPath(redwoodConfig.web.apiUrl) : '' - const port = enableWeb ? redwoodConfig.web.port : redwoodConfig.api.port - - const tsServer = Date.now() - - config({ - path: path.join(redwoodProjectPaths.base, '.env'), - defaults: path.join(redwoodProjectPaths.base, '.env.defaults'), - multiline: true, - }) - - console.log(chalk.italic.dim('Starting API and Web Servers...')) - - // Configure Fastify - const fastify = Fastify({ - ...DEFAULT_REDWOOD_FASTIFY_CONFIG, - }) - - if (enableWeb) { - await fastify.register(redwoodFastifyWeb) - } - - await fastify.register(redwoodFastifyAPI, { - redwood: { - apiRootPath, - }, - }) - - await fastify.register(redwoodFastifyGraphQLServer, { - // If authenticating, be sure to import and add in - // authDecoder, - // getCurrentUser, - loggerConfig: { - logger: logger, - }, - graphiQLEndpoint: enableWeb ? '/.redwood/functions/graphql' : '/graphql', - sdls, - services, - directives, - allowIntrospection: true, - allowGraphiQL: true, - // Configure if using RedwoodJS Realtime - // realtime, - }) - - // Start - fastify.listen({ port }) - - fastify.ready(() => { - console.log(chalk.italic.dim('Took ' + (Date.now() - tsServer) + ' ms')) - const on = chalk.magenta(`http://localhost:${port}${apiRootPath}`) - if (enableWeb) { - const webServer = chalk.green(`http://localhost:${port}`) - console.log(`Web server started on ${webServer}`) - } - const apiServer = chalk.magenta(`http://localhost:${port}`) - console.log(`API serving from ${apiServer}`) - console.log(`API listening on ${on}`) - const graphqlEnd = chalk.magenta(`${apiRootPath}graphql`) - console.log(`GraphQL function endpoint at ${graphqlEnd}`) - }) - - process.on('exit', () => { - fastify.close() - }) -} - -serve() diff --git a/packages/cli/src/commands/serve.js b/packages/cli/src/commands/serve.js index 296ac6715871..af2e961b211f 100644 --- a/packages/cli/src/commands/serve.js +++ b/packages/cli/src/commands/serve.js @@ -13,7 +13,7 @@ import { webServerHandler, webSsrServerHandler } from './serveWebHandler' export const command = 'serve [side]' export const description = 'Run server for api or web in production' -function hasExperimentalServerFile() { +function hasServerFile() { const serverFilePath = path.join(getPaths().api.dist, 'server.js') return fs.existsSync(serverFilePath) } @@ -24,15 +24,34 @@ export const builder = async (yargs) => { .command({ command: '$0', description: 'Run both api and web servers', - builder: (yargs) => - yargs.options({ - port: { - default: getConfig().web?.port || 8910, - type: 'number', - alias: 'p', - }, - socket: { type: 'string' }, - }), + builder: (yargs) => { + if (!hasServerFile()) { + yargs.options({ + port: { + default: getConfig().web?.port || 8910, + type: 'number', + alias: 'p', + }, + socket: { type: 'string' }, + }) + + return + } + + yargs + .options({ + webPort: { + default: getConfig().web?.port || 8910, + type: 'number', + }, + }) + .options({ + apiPort: { + default: getConfig().api?.port || 8911, + type: 'number', + }, + }) + }, handler: async (argv) => { recordTelemetryAttributes({ command: 'serve', @@ -41,12 +60,12 @@ export const builder = async (yargs) => { socket: argv.socket, }) - // Run the experimental server file, if it exists, with web side also - if (hasExperimentalServerFile()) { - const { bothExperimentalServerFileHandler } = await import( + // Run the server file, if it exists, with web side also + if (hasServerFile()) { + const { bothServerFileHandler } = await import( './serveBothHandler.js' ) - await bothExperimentalServerFileHandler() + await bothServerFileHandler(argv) } else if ( getConfig().experimental?.rsc?.enabled || getConfig().experimental?.streamingSsr?.enabled @@ -96,12 +115,10 @@ export const builder = async (yargs) => { apiRootPath: argv.apiRootPath, }) - // Run the experimental server file, if it exists, api side only - if (hasExperimentalServerFile()) { - const { apiExperimentalServerFileHandler } = await import( - './serveApiHandler.js' - ) - await apiExperimentalServerFileHandler() + // Run the server file, if it exists, api side only + if (hasServerFile()) { + const { apiServerFileHandler } = await import('./serveApiHandler.js') + await apiServerFileHandler(argv) } else { const { apiServerHandler } = await import('./serveApiHandler.js') await apiServerHandler(argv) diff --git a/packages/cli/src/commands/serveApiHandler.js b/packages/cli/src/commands/serveApiHandler.js index 278d5031b51a..1b1195ab0ee4 100644 --- a/packages/cli/src/commands/serveApiHandler.js +++ b/packages/cli/src/commands/serveApiHandler.js @@ -6,15 +6,22 @@ import execa from 'execa' import { createFastifyInstance, redwoodFastifyAPI } from '@redwoodjs/fastify' import { getPaths } from '@redwoodjs/project-config' -export const apiExperimentalServerFileHandler = async () => { - logExperimentalHeader() - - await execa('yarn', ['node', path.join('dist', 'server.js')], { - cwd: getPaths().api.base, - stdio: 'inherit', - shell: true, - }) - return +export const apiServerFileHandler = async (argv) => { + await execa( + 'yarn', + [ + 'node', + path.join('dist', 'server.js'), + '--port', + argv.port, + '--apiRootPath', + argv.apiRootPath, + ], + { + cwd: getPaths().api.base, + stdio: 'inherit', + } + ) } export const apiServerHandler = async (options) => { @@ -71,19 +78,3 @@ export const apiServerHandler = async (options) => { function sendProcessReady() { return process.send && process.send('ready') } - -const separator = chalk.hex('#ff845e')( - '------------------------------------------------------------------' -) - -function logExperimentalHeader() { - console.log( - [ - separator, - `🧪 ${chalk.green('Experimental Feature')} 🧪`, - separator, - 'Using the experimental API server file at api/dist/server.js', - separator, - ].join('\n') - ) -} diff --git a/packages/cli/src/commands/serveBothHandler.js b/packages/cli/src/commands/serveBothHandler.js index c7892917116a..3dd24ac7c650 100644 --- a/packages/cli/src/commands/serveBothHandler.js +++ b/packages/cli/src/commands/serveBothHandler.js @@ -1,6 +1,7 @@ import path from 'path' import chalk from 'chalk' +import concurrently from 'concurrently' import execa from 'execa' import { @@ -10,10 +11,11 @@ import { redwoodFastifyWeb, } from '@redwoodjs/fastify' import { getConfig, getPaths } from '@redwoodjs/project-config' +import { errorTelemetry } from '@redwoodjs/telemetry' -export const bothExperimentalServerFileHandler = async () => { - logExperimentalHeader() +import { exitWithError } from '../lib/exit' +export const bothServerFileHandler = async (argv) => { if ( getConfig().experimental?.rsc?.enabled || getConfig().experimental?.streamingSsr?.enabled @@ -26,15 +28,43 @@ export const bothExperimentalServerFileHandler = async () => { shell: true, }) } else { - await execa( - 'yarn', - ['node', path.join('dist', 'server.js'), '--enable-web'], + const apiHost = `http://0.0.0.0:${argv.apiPort}` + + const { result } = concurrently( + [ + { + name: 'api', + command: `yarn node ${path.join('dist', 'server.js')} --port ${ + argv.apiPort + }`, + cwd: getPaths().api.base, + prefixColor: 'cyan', + }, + { + name: 'web', + command: `yarn rw-web-server --port ${argv.webPort} --api-host ${apiHost}`, + cwd: getPaths().base, + prefixColor: 'blue', + }, + ], { - cwd: getPaths().api.base, - stdio: 'inherit', - shell: true, + prefix: '{name} |', + timestampFormat: 'HH:mm:ss', + handleInput: true, } ) + + try { + await result + } catch (error) { + if (typeof error?.message !== 'undefined') { + errorTelemetry( + process.argv, + `Error concurrently starting sides: ${error.message}` + ) + exitWithError(error) + } + } } } @@ -122,22 +152,6 @@ function sendProcessReady() { return process.send && process.send('ready') } -const separator = chalk.hex('#ff845e')( - '------------------------------------------------------------------' -) - -function logExperimentalHeader() { - console.log( - [ - separator, - `🧪 ${chalk.green('Experimental Feature')} 🧪`, - separator, - 'Using the experimental API server file at api/dist/server.js', - separator, - ].join('\n') - ) -} - function logSkippingFastifyWebServer() { console.warn('') console.warn('⚠️ Skipping Fastify web server ⚠️') diff --git a/packages/cli/src/commands/setup/realtime/realtimeHandler.js b/packages/cli/src/commands/setup/realtime/realtimeHandler.js index 1c2d1f58107e..006258bdca7d 100644 --- a/packages/cli/src/commands/setup/realtime/realtimeHandler.js +++ b/packages/cli/src/commands/setup/realtime/realtimeHandler.js @@ -11,8 +11,8 @@ import { getPaths, transformTSToJS, writeFile } from '../../../lib' import c from '../../../lib/colors' import { isTypeScriptProject } from '../../../lib/project' // Move this check out of experimental when server file is moved as well -import { setupServerFileTasks } from '../../experimental/setupServerFileHandler' import { serverFileExists } from '../../experimental/util' +import { setupServerFileTasks } from '../server-file/serverFileHandler' const { version } = JSON.parse( fs.readFileSync(path.resolve(__dirname, '../../../../package.json'), 'utf-8') @@ -363,7 +363,7 @@ export async function handler({ force, includeExamples, verbose }) { try { if (!serverFileExists()) { - tasks.add(setupServerFileTasks(force)) + tasks.add(setupServerFileTasks({ force })) } await tasks.run() diff --git a/packages/cli/src/commands/experimental/setupServerFile.js b/packages/cli/src/commands/setup/server-file/serverFile.js similarity index 59% rename from packages/cli/src/commands/experimental/setupServerFile.js rename to packages/cli/src/commands/setup/server-file/serverFile.js index b842d3797263..46a126d7f062 100644 --- a/packages/cli/src/commands/experimental/setupServerFile.js +++ b/packages/cli/src/commands/setup/server-file/serverFile.js @@ -1,12 +1,8 @@ import { recordTelemetryAttributes } from '@redwoodjs/cli-helpers' -import { getEpilogue } from './util' +export const command = 'server-file' -export const EXPERIMENTAL_TOPIC_ID = 4851 - -export const command = 'setup-server-file' - -export const description = 'Setup the experimental server file' +export const description = 'Setup the server file' export function builder(yargs) { yargs @@ -22,15 +18,14 @@ export function builder(yargs) { description: 'Print more logs', type: 'boolean', }) - .epilogue(getEpilogue(command, description, EXPERIMENTAL_TOPIC_ID, true)) } export async function handler(options) { recordTelemetryAttributes({ - command: 'experimental setup-server-file', + command: 'setup server-file', force: options.force, verbose: options.verbose, }) - const { handler } = await import('./setupServerFileHandler.js') + const { handler } = await import('./serverFileHandler.js') return handler(options) } diff --git a/packages/cli/src/commands/setup/server-file/serverFileHandler.js b/packages/cli/src/commands/setup/server-file/serverFileHandler.js new file mode 100644 index 000000000000..6fb68e544ca6 --- /dev/null +++ b/packages/cli/src/commands/setup/server-file/serverFileHandler.js @@ -0,0 +1,62 @@ +import path from 'path' + +import fs from 'fs-extra' +import { Listr } from 'listr2' + +import { addApiPackages } from '@redwoodjs/cli-helpers' +import { errorTelemetry } from '@redwoodjs/telemetry' + +import { getPaths, transformTSToJS, writeFile } from '../../../lib' +import c from '../../../lib/colors' +import { isTypeScriptProject } from '../../../lib/project' + +const { version } = JSON.parse( + fs.readFileSync(path.resolve(__dirname, '../../../../package.json'), 'utf-8') +) + +export function setupServerFileTasks({ force = false } = {}) { + return [ + { + title: 'Adding the server file...', + task: () => { + const ts = isTypeScriptProject() + + const serverFilePath = path.join( + getPaths().api.src, + `server.${ts ? 'ts' : 'js'}` + ) + + const serverFileTemplateContent = fs.readFileSync( + path.join(__dirname, 'templates', 'server.ts.template'), + 'utf-8' + ) + + const setupScriptContent = ts + ? serverFileTemplateContent + : transformTSToJS(serverFilePath, serverFileTemplateContent) + + return [ + writeFile(serverFilePath, setupScriptContent, { + overwriteExisting: force, + }), + ] + }, + }, + addApiPackages([`@redwoodjs/api-server@${version}`]), + ] +} + +export async function handler({ force, verbose }) { + const tasks = new Listr(setupServerFileTasks({ force }), { + rendererOptions: { collapseSubtasks: false, persistentOutput: true }, + renderer: verbose ? 'verbose' : 'default', + }) + + try { + await tasks.run() + } catch (e) { + errorTelemetry(process.argv, e.message) + console.error(c.error(e.message)) + process.exit(e?.exitCode || 1) + } +} diff --git a/packages/cli/src/commands/setup/server-file/templates/server.ts.template b/packages/cli/src/commands/setup/server-file/templates/server.ts.template new file mode 100644 index 000000000000..429b9430a983 --- /dev/null +++ b/packages/cli/src/commands/setup/server-file/templates/server.ts.template @@ -0,0 +1,13 @@ +import { createServer } from '@redwoodjs/api-server' + +import { logger } from 'src/lib/logger' + +async function main() { + const server = await createServer({ + logger, + }) + + await server.start() +} + +main() diff --git a/packages/fastify/build.mjs b/packages/fastify/build.mjs index a4650dd27782..74a3ecf2f5e0 100644 --- a/packages/fastify/build.mjs +++ b/packages/fastify/build.mjs @@ -4,7 +4,6 @@ await esbuild.build({ entryPoints: [ 'src/api.ts', 'src/config.ts', - 'src/graphql.ts', 'src/index.ts', 'src/types.ts', 'src/web.ts', diff --git a/packages/fastify/package.json b/packages/fastify/package.json index 302c70c73ef2..0eb6d1619598 100644 --- a/packages/fastify/package.json +++ b/packages/fastify/package.json @@ -23,7 +23,6 @@ "@fastify/static": "6.12.0", "@fastify/url-data": "5.4.0", "@redwoodjs/context": "6.0.7", - "@redwoodjs/graphql-server": "6.0.7", "@redwoodjs/project-config": "6.0.7", "ansi-colors": "4.1.3", "fast-glob": "3.3.2", diff --git a/packages/fastify/src/index.ts b/packages/fastify/src/index.ts index e95488bea13c..e381ec32ed1c 100644 --- a/packages/fastify/src/index.ts +++ b/packages/fastify/src/index.ts @@ -11,7 +11,6 @@ export function createFastifyInstance(options?: FastifyServerOptions) { export { redwoodFastifyAPI } from './api.js' export { redwoodFastifyWeb } from './web.js' -export { redwoodFastifyGraphQLServer } from './graphql.js' export type * from './types.js' diff --git a/packages/graphql-server/src/types.ts b/packages/graphql-server/src/types.ts index 4876798e6c50..06fb3b755265 100644 --- a/packages/graphql-server/src/types.ts +++ b/packages/graphql-server/src/types.ts @@ -256,7 +256,7 @@ export type GraphQLYogaOptions = { * * Note: RedwoodRealtime is not supported */ -export type GraphQLHandlerOptions = Omit +export type GraphQLHandlerOptions = GraphQLYogaOptions export type GraphiQLOptions = Pick< GraphQLYogaOptions, diff --git a/packages/web-server/src/server.ts b/packages/web-server/src/server.ts index 974376a83c20..e6d7a8861fd2 100644 --- a/packages/web-server/src/server.ts +++ b/packages/web-server/src/server.ts @@ -123,7 +123,9 @@ async function serve() { if (options.socket) { console.log(`Web server started on ${options.socket}`) } else { - console.log(`Web server started on http://localhost:${options.port}`) + console.log( + `Web server started on http://${listenOptions.host}:${options.port}` + ) } }) diff --git a/yarn.lock b/yarn.lock index 715a8c3665c1..e3b6932ba660 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7525,12 +7525,18 @@ __metadata: fastify-raw-body: "npm:4.3.0" jest: "npm:29.7.0" lodash: "npm:4.17.21" + pino-abstract-transport: "npm:1.1.0" pretty-bytes: "npm:5.6.0" pretty-ms: "npm:7.0.1" qs: "npm:6.11.2" split2: "npm:4.2.0" typescript: "npm:5.3.3" yargs: "npm:17.7.2" + peerDependencies: + "@redwoodjs/graphql-server": 6.0.7 + peerDependenciesMeta: + "@redwoodjs/graphql-server": + optional: true bin: rw-api-server-watch: ./dist/watch.js rw-log-formatter: ./dist/logFormatter/bin.js @@ -8378,7 +8384,6 @@ __metadata: "@fastify/static": "npm:6.12.0" "@fastify/url-data": "npm:5.4.0" "@redwoodjs/context": "npm:6.0.7" - "@redwoodjs/graphql-server": "npm:6.0.7" "@redwoodjs/project-config": "npm:6.0.7" "@types/aws-lambda": "npm:8.10.126" "@types/lodash": "npm:4.14.201" @@ -27492,7 +27497,7 @@ __metadata: languageName: node linkType: hard -"pino-abstract-transport@npm:v1.1.0": +"pino-abstract-transport@npm:1.1.0, pino-abstract-transport@npm:v1.1.0": version: 1.1.0 resolution: "pino-abstract-transport@npm:1.1.0" dependencies: