From 41ac72814873eb37e287694122c22fa562f99b74 Mon Sep 17 00:00:00 2001 From: Dominic Saadi Date: Sat, 20 Jan 2024 18:26:12 -0800 Subject: [PATCH] feat(server file): add `createServer` (#9845) This PR brings the server file out of experimental by implementing a `createServer` function. This is a continuation of the work started in https://github.com/redwoodjs/redwood/pull/8119. This API was designed in response to the feedback to #8119, which gave users as much control as possible by more or less ejecting the code in api-server. This resulted in users managing lot of code that really wasn't their concern. In general it didn't feel like the Redwood way. The new API still gives users control over how the server starts but encapsulates the low-level details. I've tried to make this PR as complete as possible. I feel like it's reached that state, but there's still a few things I'd like to do. In general I'd like to deduplicate all the repeated server code. - [x] bring the server file out of experimental - [x] type the `start` function - [x] figure out how to handle the graphql function - [x] double check that `yarn rw dev` works well (namely, the watching) - [x] double check that you can pass CLI args in dev and serve - [x] the `yarn rw serve` command needs start two processes instead of one with the server file - [x] double check that env vars are being loaded - [x] right now this is imported from `@redwoodojs/api-server`. long term i don't think this is the best place for it --------- Co-authored-by: Tobbe Lundberg Co-authored-by: Josh GM Walker <56300765+Josh-Walker-GM@users.noreply.github.com> --- packages/api-server/ambient.d.ts | 1 + packages/api-server/dist.test.ts | 1 + packages/api-server/package.json | 9 + .../src/__tests__/createServer.test.ts | 330 +++++++++++++++++ packages/api-server/src/cliHandlers.ts | 4 +- packages/api-server/src/createServer.ts | 345 ++++++++++++++++++ .../src => api-server/src/plugins}/graphql.ts | 61 ++-- .../api-server/src/plugins/lambdaLoader.ts | 21 +- .../src/requestHandlers/awsLambdaFastify.ts | 2 +- packages/api-server/src/watch.ts | 17 +- packages/babel-config/src/api.ts | 10 + .../default-graphql-function/code.js | 19 + .../default-graphql-function/output.js | 20 + .../evil-graphql-function/code.js | 36 ++ .../evil-graphql-function/output.js | 42 +++ .../function-graphql-function/code.js | 21 ++ .../function-graphql-function/output.js | 21 ++ .../modified-graphql-function/code.js | 36 ++ .../modified-graphql-function/output.js | 38 ++ ...in-redwood-graphql-options-extract.test.ts | 19 + ...-plugin-redwood-graphql-options-extract.ts | 69 ++++ .../experimental/setupServerFileHandler.js | 119 ------ .../experimental/templates/server.ts.template | 113 ------ packages/cli/src/commands/serve.js | 57 ++- packages/cli/src/commands/serveApiHandler.js | 41 +-- packages/cli/src/commands/serveBothHandler.js | 62 ++-- .../setup/realtime/realtimeHandler.js | 4 +- .../server-file/serverFile.js} | 13 +- .../setup/server-file/serverFileHandler.js | 62 ++++ .../server-file/templates/server.ts.template | 13 + packages/fastify/build.mjs | 1 - packages/fastify/package.json | 1 - packages/fastify/src/index.ts | 1 - packages/graphql-server/src/types.ts | 2 +- packages/web-server/src/server.ts | 4 +- yarn.lock | 9 +- 36 files changed, 1266 insertions(+), 358 deletions(-) create mode 100644 packages/api-server/ambient.d.ts create mode 100644 packages/api-server/src/__tests__/createServer.test.ts create mode 100644 packages/api-server/src/createServer.ts rename packages/{fastify/src => api-server/src/plugins}/graphql.ts (66%) create mode 100644 packages/babel-config/src/plugins/__tests__/__fixtures__/graphql-options-extract/default-graphql-function/code.js create mode 100644 packages/babel-config/src/plugins/__tests__/__fixtures__/graphql-options-extract/default-graphql-function/output.js create mode 100644 packages/babel-config/src/plugins/__tests__/__fixtures__/graphql-options-extract/evil-graphql-function/code.js create mode 100644 packages/babel-config/src/plugins/__tests__/__fixtures__/graphql-options-extract/evil-graphql-function/output.js create mode 100644 packages/babel-config/src/plugins/__tests__/__fixtures__/graphql-options-extract/function-graphql-function/code.js create mode 100644 packages/babel-config/src/plugins/__tests__/__fixtures__/graphql-options-extract/function-graphql-function/output.js create mode 100644 packages/babel-config/src/plugins/__tests__/__fixtures__/graphql-options-extract/modified-graphql-function/code.js create mode 100644 packages/babel-config/src/plugins/__tests__/__fixtures__/graphql-options-extract/modified-graphql-function/output.js create mode 100644 packages/babel-config/src/plugins/__tests__/babel-plugin-redwood-graphql-options-extract.test.ts create mode 100644 packages/babel-config/src/plugins/babel-plugin-redwood-graphql-options-extract.ts delete mode 100644 packages/cli/src/commands/experimental/setupServerFileHandler.js delete mode 100644 packages/cli/src/commands/experimental/templates/server.ts.template rename packages/cli/src/commands/{experimental/setupServerFile.js => setup/server-file/serverFile.js} (59%) create mode 100644 packages/cli/src/commands/setup/server-file/serverFileHandler.js create mode 100644 packages/cli/src/commands/setup/server-file/templates/server.ts.template 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: