From 56801fb26539d6623f16623469ac2561236ccdd7 Mon Sep 17 00:00:00 2001 From: Matthew Phillips Date: Fri, 22 Jul 2022 14:38:25 -0400 Subject: [PATCH] Fixes Node adapter receiving a request body --- .changeset/smooth-seahorses-hear.md | 6 + packages/astro/src/core/app/node.ts | 30 ++++- packages/integrations/node/package.json | 6 +- packages/integrations/node/src/server.ts | 31 +++-- .../integrations/node/test/api-route.test.js | 37 ++++++ .../node/test/fixtures/api-route/package.json | 9 ++ .../fixtures/api-route/src/pages/recipes.js | 24 ++++ packages/integrations/node/test/test-utils.js | 41 +++++++ pnpm-lock.yaml | 106 ++++++++++++++++++ 9 files changed, 274 insertions(+), 16 deletions(-) create mode 100644 .changeset/smooth-seahorses-hear.md create mode 100644 packages/integrations/node/test/api-route.test.js create mode 100644 packages/integrations/node/test/fixtures/api-route/package.json create mode 100644 packages/integrations/node/test/fixtures/api-route/src/pages/recipes.js create mode 100644 packages/integrations/node/test/test-utils.js diff --git a/.changeset/smooth-seahorses-hear.md b/.changeset/smooth-seahorses-hear.md new file mode 100644 index 0000000000000..0c203dc1002ab --- /dev/null +++ b/.changeset/smooth-seahorses-hear.md @@ -0,0 +1,6 @@ +--- +'astro': patch +'@astrojs/node': patch +--- + +Fixes Node adapter to accept a request body diff --git a/packages/astro/src/core/app/node.ts b/packages/astro/src/core/app/node.ts index 7b6298809167a..11d93013eec21 100644 --- a/packages/astro/src/core/app/node.ts +++ b/packages/astro/src/core/app/node.ts @@ -7,15 +7,16 @@ import { App } from './index.js'; const clientAddressSymbol = Symbol.for('astro.clientAddress'); -function createRequestFromNodeRequest(req: IncomingMessage): Request { +function createRequestFromNodeRequest(req: IncomingMessage, body?: string): Request { let url = `http://${req.headers.host}${req.url}`; let rawHeaders = req.headers as Record; const entries = Object.entries(rawHeaders); let request = new Request(url, { method: req.method || 'GET', headers: new Headers(entries), + body }); - if (req.socket.remoteAddress) { + if (req.socket?.remoteAddress) { Reflect.set(request, clientAddressSymbol, req.socket.remoteAddress); } return request; @@ -26,6 +27,31 @@ export class NodeApp extends App { return super.match(req instanceof Request ? req : createRequestFromNodeRequest(req)); } render(req: IncomingMessage | Request) { + if('on' in req) { + let body: string | undefined = undefined; + let reqBodyComplete = new Promise((resolve, reject) => { + req.on('data', d => { + if(body === undefined) { + body = ''; + } + if(d instanceof Buffer) { + body += d.toString('utf-8'); + } else if(typeof d === 'string') { + body += d; + } + }); + req.on('end', () => { + resolve(body); + }); + req.on('error', err => { + reject(err); + }); + }); + + return reqBodyComplete.then(() => { + return super.render(req instanceof Request ? req : createRequestFromNodeRequest(req, body)); + }); + } return super.render(req instanceof Request ? req : createRequestFromNodeRequest(req)); } } diff --git a/packages/integrations/node/package.json b/packages/integrations/node/package.json index 7886e93fc351a..d39d49124453f 100644 --- a/packages/integrations/node/package.json +++ b/packages/integrations/node/package.json @@ -24,13 +24,15 @@ "scripts": { "build": "astro-scripts build \"src/**/*.ts\" && tsc", "build:ci": "astro-scripts build \"src/**/*.ts\"", - "dev": "astro-scripts dev \"src/**/*.ts\"" + "dev": "astro-scripts dev \"src/**/*.ts\"", + "test": "mocha --exit --timeout 20000 test/" }, "dependencies": { "@astrojs/webapi": "^0.12.0" }, "devDependencies": { "astro": "workspace:*", - "astro-scripts": "workspace:*" + "astro-scripts": "workspace:*", + "node-mocks-http": "^1.11.0" } } diff --git a/packages/integrations/node/src/server.ts b/packages/integrations/node/src/server.ts index c07b5a91b56fc..453ecb2d23771 100644 --- a/packages/integrations/node/src/server.ts +++ b/packages/integrations/node/src/server.ts @@ -12,21 +12,28 @@ export function createExports(manifest: SSRManifest) { const app = new NodeApp(manifest); return { async handler(req: IncomingMessage, res: ServerResponse, next?: (err?: unknown) => void) { - const route = app.match(req); + try { + const route = app.match(req); - if (route) { - try { - const response = await app.render(req); - await writeWebResponse(res, response); - } catch (err: unknown) { - if (next) { - next(err); - } else { - throw err; + if (route) { + try { + const response = await app.render(req); + await writeWebResponse(res, response); + } catch (err: unknown) { + if (next) { + next(err); + } else { + throw err; + } } + } else if (next) { + return next(); + } + } catch(err: unknown) { + if(!res.headersSent) { + res.writeHead(500, `Server error`); + res.end(); } - } else if (next) { - return next(); } }, }; diff --git a/packages/integrations/node/test/api-route.test.js b/packages/integrations/node/test/api-route.test.js new file mode 100644 index 0000000000000..963e0463aaea6 --- /dev/null +++ b/packages/integrations/node/test/api-route.test.js @@ -0,0 +1,37 @@ +import nodejs from '../dist/index.js'; +import { loadFixture, createRequestAndResponse, toPromise } from './test-utils.js'; +import { expect } from 'chai'; + + +describe('API routes', () => { + /** @type {import('./test-utils').Fixture} */ + let fixture; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/api-route/', + experimental: { + ssr: true, + }, + adapter: nodejs(), + }); + await fixture.build(); + }); + + it('Can get the request body', async () => { + const { handler } = await import('./fixtures/api-route/dist/server/entry.mjs'); + + let { req, res, done } = createRequestAndResponse({ + method: 'POST', + url: '/recipes' + }); + + handler(req, res); + req.send(JSON.stringify({ id: 2 })); + + let [ buffer ] = await done; + let json = JSON.parse(buffer.toString('utf-8')); + expect(json.length).to.equal(1); + expect(json[0].name).to.equal('Broccoli Soup'); + }); +}); diff --git a/packages/integrations/node/test/fixtures/api-route/package.json b/packages/integrations/node/test/fixtures/api-route/package.json new file mode 100644 index 0000000000000..c4d9bdd2b4eb4 --- /dev/null +++ b/packages/integrations/node/test/fixtures/api-route/package.json @@ -0,0 +1,9 @@ +{ + "name": "@test/nodejs-api-route", + "version": "0.0.0", + "private": true, + "dependencies": { + "astro": "workspace:*", + "@astrojs/node": "workspace:*" + } +} diff --git a/packages/integrations/node/test/fixtures/api-route/src/pages/recipes.js b/packages/integrations/node/test/fixtures/api-route/src/pages/recipes.js new file mode 100644 index 0000000000000..edbd15a0e2318 --- /dev/null +++ b/packages/integrations/node/test/fixtures/api-route/src/pages/recipes.js @@ -0,0 +1,24 @@ + +export async function post({ request }) { + let body = await request.json(); + const recipes = [ + { + id: 1, + name: 'Potato Soup' + }, + { + id: 2, + name: 'Broccoli Soup' + } + ]; + + let out = recipes.filter(r => { + return r.id === body.id; + }); + + return new Response(JSON.stringify(out), { + headers: { + 'Content-Type': 'application/json' + } + }); +} diff --git a/packages/integrations/node/test/test-utils.js b/packages/integrations/node/test/test-utils.js new file mode 100644 index 0000000000000..4bd42d557eb3f --- /dev/null +++ b/packages/integrations/node/test/test-utils.js @@ -0,0 +1,41 @@ +import { loadFixture as baseLoadFixture } from '../../../astro/test/test-utils.js'; +import httpMocks from 'node-mocks-http'; +import { EventEmitter } from 'events'; + +/** + * @typedef {import('../../../astro/test/test-utils').Fixture} Fixture + */ + +export function loadFixture(inlineConfig) { + if (!inlineConfig || !inlineConfig.root) + throw new Error("Must provide { root: './fixtures/...' }"); + + // resolve the relative root (i.e. "./fixtures/tailwindcss") to a full filepath + // without this, the main `loadFixture` helper will resolve relative to `packages/astro/test` + return baseLoadFixture({ + ...inlineConfig, + root: new URL(inlineConfig.root, import.meta.url).toString(), + }); +} + +export function createRequestAndResponse(reqOptions) { + let req = httpMocks.createRequest(reqOptions); + + let res = httpMocks.createResponse({ + eventEmitter: EventEmitter, + req + }); + + let done = toPromise(res); + + return { req, res, done }; +} + +export function toPromise(res) { + return new Promise(resolve => { + res.on('end', () => { + let chunks = res._getChunks(); + resolve(chunks); + }); + }); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bd34408fed257..592004ff871d6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2217,11 +2217,21 @@ importers: '@astrojs/webapi': ^0.12.0 astro: workspace:* astro-scripts: workspace:* + node-mocks-http: ^1.11.0 dependencies: '@astrojs/webapi': link:../../webapi devDependencies: astro: link:../../astro astro-scripts: link:../../../scripts + node-mocks-http: 1.11.0 + + packages/integrations/node/test/fixtures/api-route: + specifiers: + '@astrojs/node': workspace:* + astro: workspace:* + dependencies: + '@astrojs/node': link:../../.. + astro: link:../../../../../astro packages/integrations/partytown: specifiers: @@ -8786,6 +8796,14 @@ packages: event-target-shim: 5.0.1 dev: true + /accepts/1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + dev: true + /acorn-jsx/5.3.2_acorn@8.7.1: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -9603,6 +9621,13 @@ packages: resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==} dev: false + /content-disposition/0.5.4: + resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} + engines: {node: '>= 0.6'} + dependencies: + safe-buffer: 5.2.1 + dev: true + /convert-source-map/1.8.0: resolution: {integrity: sha512-+OQdjP49zViI/6i7nIJpA8rAl4sV/JdPfU9nZs3VqOwGIgizICvuN2ru6fMd+4llL0tar18UYJXfZ/TWtmhUjA==} dependencies: @@ -9890,6 +9915,11 @@ packages: resolution: {integrity: sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==} dev: false + /depd/1.1.2: + resolution: {integrity: sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==} + engines: {node: '>= 0.6'} + dev: true + /depd/2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -10791,6 +10821,11 @@ packages: /fraction.js/4.2.0: resolution: {integrity: sha512-MhLuK+2gUcnZe8ZHlaaINnQLl0xRIGRfcGk2yl8xoQAfHrSsL3rYu6FCmBdkdbhc9EPlwyGHewaRsvwRMJtAlA==} + /fresh/0.5.2: + resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} + engines: {node: '>= 0.6'} + dev: true + /fs-constants/1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} @@ -12333,6 +12368,11 @@ packages: resolution: {integrity: sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==} dev: false + /media-typer/0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} + dev: true + /meow/6.1.1: resolution: {integrity: sha512-3YffViIt2QWgTy6Pale5QpopX/IvU3LPL03jOTqp6pGj3VjesdO/U8CuHMKpnQr4shCNCM5fd5XFFvIIl6JBHg==} engines: {node: '>=8'} @@ -12350,6 +12390,10 @@ packages: yargs-parser: 18.1.3 dev: true + /merge-descriptors/1.0.1: + resolution: {integrity: sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==} + dev: true + /merge-stream/2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} @@ -12357,6 +12401,11 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} + /methods/1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + dev: true + /micromark-core-commonmark/1.0.6: resolution: {integrity: sha512-K+PkJTxqjFfSNkfAhp4GB+cZPfQd6dxtTXnf+RjZOV7T4EEXnvgzOcnp+eSTmpGk9d1S9sL6/lqrgSNn/s0HZA==} dependencies: @@ -12707,6 +12756,24 @@ packages: resolution: {integrity: sha512-pDEgWjUoCMBwME8z8UiCOO6FKH0It1LASFh8hFSk8uSyfyw6rqY4PBk2LiIEPaVHwtLDhozp4Pr0I+yAUfCpiA==} dev: false + /mime-db/1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + dev: true + + /mime-types/2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + dependencies: + mime-db: 1.52.0 + dev: true + + /mime/1.6.0: + resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} + engines: {node: '>=4'} + hasBin: true + dev: true + /mime/3.0.0: resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} engines: {node: '>=10.0.0'} @@ -12888,6 +12955,11 @@ packages: - supports-color dev: false + /negotiator/0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + dev: true + /netmask/2.0.2: resolution: {integrity: sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==} engines: {node: '>= 0.4.0'} @@ -12948,6 +13020,22 @@ packages: hasBin: true dev: false + /node-mocks-http/1.11.0: + resolution: {integrity: sha512-jS/WzSOcKbOeGrcgKbenZeNhxUNnP36Yw11+hL4TTxQXErGfqYZ+MaYNNvhaTiGIJlzNSqgQkk9j8dSu1YWSuw==} + engines: {node: '>=0.6'} + dependencies: + accepts: 1.3.8 + content-disposition: 0.5.4 + depd: 1.1.2 + fresh: 0.5.2 + merge-descriptors: 1.0.1 + methods: 1.1.2 + mime: 1.6.0 + parseurl: 1.3.3 + range-parser: 1.2.1 + type-is: 1.6.18 + dev: true + /node-pre-gyp/0.13.0: resolution: {integrity: sha512-Md1D3xnEne8b/HGVQkZZwV27WUi1ZRuZBij24TNaZwUPU3ZAFtvT6xxJGaUVillfmMKnn5oD1HoGsp2Ftik7SQ==} deprecated: 'Please upgrade to @mapbox/node-pre-gyp: the non-scoped node-pre-gyp package is deprecated and only the @mapbox scoped package will recieve updates in the future' @@ -13319,6 +13407,11 @@ packages: entities: 4.3.1 dev: true + /parseurl/1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + dev: true + /pascal-case/3.1.2: resolution: {integrity: sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==} dependencies: @@ -14009,6 +14102,11 @@ packages: safe-buffer: 5.2.1 dev: true + /range-parser/1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + dev: true + /raw-body/2.5.1: resolution: {integrity: sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==} engines: {node: '>= 0.8'} @@ -15583,6 +15681,14 @@ packages: engines: {node: '>=12.20'} dev: false + /type-is/1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + dev: true + /typescript/4.6.4: resolution: {integrity: sha512-9ia/jWHIEbo49HfjrLGfKbZSuWo9iTMwXO+Ca3pRsSpbsMbc7/IU8NKdCZVRRBafVPGnoJeFL76ZOAA84I9fEg==} engines: {node: '>=4.2.0'}