Skip to content

Commit 41ac728

Browse files
jtoarTobbeJosh-Walker-GM
authored
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 #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 <tobbe@tlundberg.com> Co-authored-by: Josh GM Walker <56300765+Josh-Walker-GM@users.noreply.github.com>
1 parent 9245fe7 commit 41ac728

File tree

36 files changed

+1266
-358
lines changed

36 files changed

+1266
-358
lines changed

packages/api-server/ambient.d.ts

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
declare module 'dotenv-defaults'

packages/api-server/dist.test.ts

+1
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ describe('dist', () => {
7272
"type": "string",
7373
},
7474
},
75+
"createServer": [Function],
7576
"webCliOptions": {
7677
"apiHost": {
7778
"alias": "api-host",

packages/api-server/package.json

+9
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,16 @@
6161
"@types/yargs": "17.0.32",
6262
"aws-lambda": "1.0.7",
6363
"jest": "29.7.0",
64+
"pino-abstract-transport": "1.1.0",
6465
"typescript": "5.3.3"
6566
},
67+
"peerDependencies": {
68+
"@redwoodjs/graphql-server": "6.0.7"
69+
},
70+
"peerDependenciesMeta": {
71+
"@redwoodjs/graphql-server": {
72+
"optional": true
73+
}
74+
},
6675
"gitHead": "3905ed045508b861b495f8d5630d76c7a157d8f1"
6776
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,330 @@
1+
import path from 'path'
2+
3+
import pino from 'pino'
4+
import build from 'pino-abstract-transport'
5+
6+
import { getConfig } from '@redwoodjs/project-config'
7+
8+
import {
9+
createServer,
10+
resolveOptions,
11+
DEFAULT_CREATE_SERVER_OPTIONS,
12+
} from '../createServer'
13+
14+
// Set up RWJS_CWD.
15+
let original_RWJS_CWD
16+
17+
beforeAll(() => {
18+
original_RWJS_CWD = process.env.RWJS_CWD
19+
process.env.RWJS_CWD = path.join(__dirname, './fixtures/redwood-app')
20+
})
21+
22+
afterAll(() => {
23+
process.env.RWJS_CWD = original_RWJS_CWD
24+
})
25+
26+
describe('createServer', () => {
27+
// Create a server for most tests. Some that test initialization create their own
28+
let server
29+
30+
beforeAll(async () => {
31+
server = await createServer()
32+
})
33+
34+
afterAll(async () => {
35+
await server?.close()
36+
})
37+
38+
it('serves functions', async () => {
39+
const res = await server.inject({
40+
method: 'GET',
41+
url: '/hello',
42+
})
43+
44+
expect(res.json()).toEqual({ data: 'hello function' })
45+
})
46+
47+
describe('warnings', () => {
48+
let consoleWarnSpy
49+
50+
beforeAll(() => {
51+
consoleWarnSpy = jest.spyOn(console, 'warn')
52+
})
53+
54+
afterAll(() => {
55+
consoleWarnSpy.mockRestore()
56+
})
57+
58+
it('warns about server.config.js', async () => {
59+
await createServer()
60+
61+
expect(consoleWarnSpy.mock.calls[0][0]).toMatchInlineSnapshot(`
62+
"
63+
Ignoring \`config\` and \`configureServer\` in api/server.config.js.
64+
Migrate them to api/src/server.{ts,js}:
65+

66+
\`\`\`js title="api/src/server.{ts,js}"
67+
// Pass your config to \`createServer\`
68+
const server = createServer({
69+
 fastifyServerOptions: myFastifyConfig
70+
})
71+

72+
// Then inline your \`configureFastify\` logic:
73+
server.register(myFastifyPlugin)
74+
\`\`\`
75+
"
76+
`)
77+
})
78+
})
79+
80+
it('`apiRootPath` prefixes all routes', async () => {
81+
const server = await createServer({ apiRootPath: '/api' })
82+
83+
const res = await server.inject({
84+
method: 'GET',
85+
url: '/api/hello',
86+
})
87+
88+
expect(res.json()).toEqual({ data: 'hello function' })
89+
90+
await server.close()
91+
})
92+
93+
// We use `console.log` and `.warn` to output some things.
94+
// Meanwhile, the server gets a logger that may not output to the same place.
95+
// The server's logger also seems to output things out of order.
96+
//
97+
// This should be fixed so that all logs go to the same place
98+
describe('logs', () => {
99+
let consoleLogSpy
100+
let consoleWarnSpy
101+
102+
beforeAll(() => {
103+
consoleLogSpy = jest.spyOn(console, 'log').mockImplementation()
104+
consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation()
105+
})
106+
107+
afterAll(() => {
108+
consoleLogSpy.mockRestore()
109+
consoleWarnSpy.mockRestore()
110+
})
111+
112+
it("doesn't handle logs consistently", async () => {
113+
// Here we create a logger that outputs to an array.
114+
const loggerLogs: string[] = []
115+
const stream = build(async (source) => {
116+
for await (const obj of source) {
117+
loggerLogs.push(obj)
118+
}
119+
})
120+
const logger = pino(stream)
121+
122+
// Generate some logs.
123+
const server = await createServer({ logger })
124+
const res = await server.inject({
125+
method: 'GET',
126+
url: '/hello',
127+
})
128+
expect(res.json()).toEqual({ data: 'hello function' })
129+
await server.listen({ port: 8910 })
130+
await server.close()
131+
132+
// We expect console log to be called with `withFunctions` logs.
133+
expect(consoleLogSpy.mock.calls[0][0]).toMatch(
134+
/Importing Server Functions/
135+
)
136+
137+
const lastCallIndex = consoleLogSpy.mock.calls.length - 1
138+
139+
expect(consoleLogSpy.mock.calls[lastCallIndex][0]).toMatch(/Listening on/)
140+
141+
// `console.warn` will be used if there's a `server.config.js` file.
142+
expect(consoleWarnSpy.mock.calls[0][0]).toMatchInlineSnapshot(`
143+
"
144+
Ignoring \`config\` and \`configureServer\` in api/server.config.js.
145+
Migrate them to api/src/server.{ts,js}:
146+

147+
\`\`\`js title="api/src/server.{ts,js}"
148+
// Pass your config to \`createServer\`
149+
const server = createServer({
150+
 fastifyServerOptions: myFastifyConfig
151+
})
152+

153+
// Then inline your \`configureFastify\` logic:
154+
server.register(myFastifyPlugin)
155+
\`\`\`
156+
"
157+
`)
158+
159+
// Finally, the logger. Notice how the request/response logs come before the "server is listening..." logs.
160+
expect(loggerLogs[0]).toMatchObject({
161+
reqId: 'req-1',
162+
level: 30,
163+
msg: 'incoming request',
164+
req: {
165+
hostname: 'localhost:80',
166+
method: 'GET',
167+
remoteAddress: '127.0.0.1',
168+
url: '/hello',
169+
},
170+
})
171+
expect(loggerLogs[1]).toMatchObject({
172+
reqId: 'req-1',
173+
level: 30,
174+
msg: 'request completed',
175+
res: {
176+
statusCode: 200,
177+
},
178+
})
179+
180+
expect(loggerLogs[2]).toMatchObject({
181+
level: 30,
182+
msg: 'Server listening at http://[::1]:8910',
183+
})
184+
expect(loggerLogs[3]).toMatchObject({
185+
level: 30,
186+
msg: 'Server listening at http://127.0.0.1:8910',
187+
})
188+
})
189+
})
190+
191+
describe('`server.start`', () => {
192+
it('starts the server using [api].port in redwood.toml if none is specified', async () => {
193+
const server = await createServer()
194+
await server.start()
195+
196+
const address = server.server.address()
197+
198+
if (!address || typeof address === 'string') {
199+
throw new Error('No address or address is a string')
200+
}
201+
202+
expect(address.port).toBe(getConfig().api.port)
203+
204+
await server.close()
205+
})
206+
207+
it('the `REDWOOD_API_PORT` env var takes precedence over [api].port', async () => {
208+
process.env.REDWOOD_API_PORT = '8920'
209+
210+
const server = await createServer()
211+
await server.start()
212+
213+
const address = server.server.address()
214+
215+
if (!address || typeof address === 'string') {
216+
throw new Error('No address or address is a string')
217+
}
218+
219+
expect(address.port).toBe(+process.env.REDWOOD_API_PORT)
220+
221+
await server.close()
222+
223+
delete process.env.REDWOOD_API_PORT
224+
})
225+
})
226+
})
227+
228+
describe('resolveOptions', () => {
229+
it('nothing passed', () => {
230+
const resolvedOptions = resolveOptions()
231+
232+
expect(resolvedOptions).toEqual({
233+
apiRootPath: DEFAULT_CREATE_SERVER_OPTIONS.apiRootPath,
234+
fastifyServerOptions: {
235+
requestTimeout:
236+
DEFAULT_CREATE_SERVER_OPTIONS.fastifyServerOptions.requestTimeout,
237+
logger: DEFAULT_CREATE_SERVER_OPTIONS.logger,
238+
},
239+
port: 8911,
240+
})
241+
})
242+
243+
it('ensures `apiRootPath` has slashes', () => {
244+
const expected = '/v1/'
245+
246+
expect(
247+
resolveOptions({
248+
apiRootPath: 'v1',
249+
}).apiRootPath
250+
).toEqual(expected)
251+
252+
expect(
253+
resolveOptions({
254+
apiRootPath: '/v1',
255+
}).apiRootPath
256+
).toEqual(expected)
257+
258+
expect(
259+
resolveOptions({
260+
apiRootPath: 'v1/',
261+
}).apiRootPath
262+
).toEqual(expected)
263+
})
264+
265+
it('moves `logger` to `fastifyServerOptions.logger`', () => {
266+
const resolvedOptions = resolveOptions({
267+
logger: { level: 'info' },
268+
})
269+
270+
expect(resolvedOptions).toMatchObject({
271+
fastifyServerOptions: {
272+
logger: { level: 'info' },
273+
},
274+
})
275+
})
276+
277+
it('`logger` overwrites `fastifyServerOptions.logger`', () => {
278+
const resolvedOptions = resolveOptions({
279+
logger: false,
280+
fastifyServerOptions: {
281+
// @ts-expect-error this is invalid TS but valid JS
282+
logger: true,
283+
},
284+
})
285+
286+
expect(resolvedOptions).toMatchObject({
287+
fastifyServerOptions: {
288+
logger: false,
289+
},
290+
})
291+
})
292+
293+
it('`DEFAULT_CREATE_SERVER_OPTIONS` overwrites `fastifyServerOptions.logger`', () => {
294+
const resolvedOptions = resolveOptions({
295+
fastifyServerOptions: {
296+
// @ts-expect-error this is invalid TS but valid JS
297+
logger: true,
298+
},
299+
})
300+
301+
expect(resolvedOptions).toMatchObject({
302+
fastifyServerOptions: {
303+
logger: DEFAULT_CREATE_SERVER_OPTIONS.logger,
304+
},
305+
})
306+
})
307+
308+
it('parses `--port`', () => {
309+
expect(resolveOptions({}, ['--port', '8930']).port).toEqual(8930)
310+
})
311+
312+
it("throws if `--port` can't be converted to an integer", () => {
313+
expect(() => {
314+
resolveOptions({}, ['--port', 'eight-nine-ten'])
315+
}).toThrowErrorMatchingInlineSnapshot(`"\`port\` must be an integer"`)
316+
})
317+
318+
it('parses `--apiRootPath`', () => {
319+
expect(resolveOptions({}, ['--apiRootPath', 'foo']).apiRootPath).toEqual(
320+
'/foo/'
321+
)
322+
})
323+
324+
it('the `--apiRootPath` flag has precedence', () => {
325+
expect(
326+
resolveOptions({ apiRootPath: 'foo' }, ['--apiRootPath', 'bar'])
327+
.apiRootPath
328+
).toEqual('/bar/')
329+
})
330+
})

packages/api-server/src/cliHandlers.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,6 @@ export const apiServerHandler = async (options: ApiServerArgs) => {
5959
process.stdout.write(c.dim(c.italic('Starting API Server...\n')))
6060

6161
if (loadEnvFiles) {
62-
// @ts-expect-error for some reason ts can't find the types here but can find them for other packages
6362
const { config } = await import('dotenv-defaults')
6463

6564
config({
@@ -197,3 +196,6 @@ function isFullyQualifiedUrl(url: string) {
197196
return false
198197
}
199198
}
199+
200+
// Temporarily here till we refactor server code
201+
export { createServer } from './createServer'

0 commit comments

Comments
 (0)