Skip to content

Commit

Permalink
Feature/issue 1048 handle merging additional Request / Response p…
Browse files Browse the repository at this point in the history
…roperties (#1132)

* support merging Response status property

* support body on incoming requests

* handle merging all custom response headers

* lock down content-type headers in test cases

* handle Response.statusText property

* full response support clean and TODOs cleanup

* update vercel adapter plugin specs for request and response handling

* update netlify adapter plugin specs for request and response handling

* add support for request.formData

* add request.formData support to adapter plugins

* variable name safe handler alias
  • Loading branch information
thescientist13 authored Aug 26, 2023
1 parent ae78778 commit 65a80d3
Show file tree
Hide file tree
Showing 49 changed files with 959 additions and 184 deletions.
1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"es-module-shims": "^1.2.0",
"front-matter": "^4.0.2",
"koa": "^2.13.0",
"koa-body": "^6.0.1",
"livereload": "^0.9.1",
"markdown-toc": "^1.2.0",
"node-html-parser": "^1.2.21",
Expand Down
23 changes: 18 additions & 5 deletions packages/cli/src/lib/api-route-worker.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// https://github.com/nodejs/modules/issues/307#issuecomment-858729422
import { parentPort } from 'worker_threads';
import { transformKoaRequestIntoStandardRequest } from './resource-utils.js';

// based on https://stackoverflow.com/questions/57447685/how-can-i-convert-a-request-object-into-a-stringifiable-object-in-javascript
async function responseAsObject (response) {
Expand All @@ -21,19 +22,31 @@ async function responseAsObject (response) {
return filtered;
}

// TODO handle full response
// https://github.com/ProjectEvergreen/greenwood/issues/1048
return {
...stringifiableObject(response),
headers: Object.fromEntries(response.headers),
// signal: stringifiableObject(request.signal),
body: await response.text()
};
}

async function executeRouteModule({ href, request }) {
const { handler } = await import(href);
const response = await handler(request);
const { body, headers = {}, method, url } = request;
const contentType = headers['content-type'] || '';
const { handler } = await import(new URL(href));
const format = contentType.startsWith('application/json')
? JSON.parse(body)
: body;

// handling of serialized FormData across Worker threads
if (contentType.startsWith('x-greenwood/www-form-urlencoded')) {
headers['content-type'] = 'application/x-www-form-urlencoded';
}

const response = await handler(transformKoaRequestIntoStandardRequest(new URL(url), {
method,
header: headers,
body: format
}));

parentPort.postMessage(await responseAsObject(response));
}
Expand Down
48 changes: 44 additions & 4 deletions packages/cli/src/lib/resource-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ async function modelResource(context, type, src = undefined, contents = undefine

function mergeResponse(destination, source) {
const headers = destination.headers || new Headers();
const status = source.status || destination.status;
const statusText = source.statusText || destination.statusText;

source.headers.forEach((value, key) => {
// TODO better way to handle Response automatically setting content-type
Expand All @@ -47,10 +49,10 @@ function mergeResponse(destination, source) {
}
});

// TODO handle merging in state (aborted, type, status, etc)
// https://github.com/ProjectEvergreen/greenwood/issues/1048
return new Response(source.body, {
headers
headers,
status,
statusText
});
}

Expand Down Expand Up @@ -169,11 +171,49 @@ function isLocalLink(url = '') {
return url !== '' && (url.indexOf('http') !== 0 && url.indexOf('//') !== 0);
}

// TODO handle full request
// https://github.com/ProjectEvergreen/greenwood/discussions/1146
function transformKoaRequestIntoStandardRequest(url, request) {
const { body, method, header } = request;
const headers = new Headers(header);
const contentType = headers.get('content-type') || '';
let format;

if (contentType.includes('application/x-www-form-urlencoded')) {
const formData = new FormData();

for (const key of Object.keys(body)) {
formData.append(key, body[key]);
}

// when using FormData, let Request set the correct headers
// or else it will come out as multipart/form-data
// https://stackoverflow.com/a/43521052/417806
headers.delete('content-type');

format = formData;
} else if (contentType.includes('application/json')) {
format = JSON.stringify(body);
} else {
format = body;
}

// https://developer.mozilla.org/en-US/docs/Web/API/Request/Request#parameters
return new Request(url, {
body: ['GET', 'HEAD'].includes(method.toUpperCase())
? null
: format,
method,
headers
});
}

export {
checkResourceExists,
mergeResponse,
modelResource,
normalizePathnameForWindows,
resolveForRelativeUrl,
trackResourcesForRoute
trackResourcesForRoute,
transformKoaRequestIntoStandardRequest
};
5 changes: 0 additions & 5 deletions packages/cli/src/lifecycles/bundle.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,6 @@ async function emitResources(compilation) {
const { resources, graph } = compilation;

// https://stackoverflow.com/a/56150320/417806
// TODO put into a util
// https://github.com/ProjectEvergreen/greenwood/issues/1008
await fs.writeFile(new URL('./resources.json', outputDir), JSON.stringify(resources, (key, value) => {
if (value instanceof Map) {
return {
Expand Down Expand Up @@ -176,7 +174,6 @@ async function bundleApiRoutes(compilation) {
async function bundleSsrPages(compilation) {
// https://rollupjs.org/guide/en/#differences-to-the-javascript-api
// TODO context plugins for SSR ?
// https://github.com/ProjectEvergreen/greenwood/issues/1008
// const contextPlugins = compilation.config.plugins.filter((plugin) => {
// return plugin.type === 'context';
// }).map((plugin) => {
Expand Down Expand Up @@ -207,8 +204,6 @@ async function bundleSsrPages(compilation) {
staticHtml = await (await htmlOptimizer.optimize(new URL(`http://localhost:8080${route}`), new Response(staticHtml))).text();

// better way to write out this inline code?
// TODO flesh out response properties
// https://github.com/ProjectEvergreen/greenwood/issues/1048
await fs.writeFile(entryFileUrl, `
import { executeRouteModule } from '${normalizePathnameForWindows(executeModuleUrl)}';
Expand Down
4 changes: 0 additions & 4 deletions packages/cli/src/lifecycles/compile.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ const generateCompilation = () => {
context: {},
config: {},
// TODO put resources into manifest
// https://github.com/ProjectEvergreen/greenwood/issues/1008
resources: new Map(),
manifest: {
apis: new Map()
Expand Down Expand Up @@ -45,7 +44,6 @@ const generateCompilation = () => {
if (await checkResourceExists(new URL('./manifest.json', outputDir))) {
console.info('Loading manifest from build output...');
// TODO put reviver into a utility?
// https://github.com/ProjectEvergreen/greenwood/issues/1008
const manifest = JSON.parse(await fs.readFile(new URL('./manifest.json', outputDir)), function reviver(key, value) {
if (typeof value === 'object' && value !== null) {
if (value.dataType === 'Map') {
Expand All @@ -61,7 +59,6 @@ const generateCompilation = () => {
if (await checkResourceExists(new URL('./resources.json', outputDir))) {
console.info('Loading resources from build output...');
// TODO put reviver into a utility?
// https://github.com/ProjectEvergreen/greenwood/issues/1008
const resources = JSON.parse(await fs.readFile(new URL('./resources.json', outputDir)), function reviver(key, value) {
if (typeof value === 'object' && value !== null) {
if (value.dataType === 'Map') {
Expand All @@ -85,7 +82,6 @@ const generateCompilation = () => {

// https://stackoverflow.com/a/56150320/417806
// TODO put reviver into a util?
// https://github.com/ProjectEvergreen/greenwood/issues/1008
await fs.writeFile(new URL('./manifest.json', scratchDir), JSON.stringify(compilation.manifest, (key, value) => {
if (value instanceof Map) {
return {
Expand Down
103 changes: 47 additions & 56 deletions packages/cli/src/lifecycles/serve.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import fs from 'fs/promises';
import { hashString } from '../lib/hashing-utils.js';
import Koa from 'koa';
import { checkResourceExists, mergeResponse } from '../lib/resource-utils.js';
import { koaBody } from 'koa-body';
import { checkResourceExists, mergeResponse, transformKoaRequestIntoStandardRequest } from '../lib/resource-utils.js';
import { Readable } from 'stream';
import { ResourceInterface } from '../lib/resource-interface.js';

Expand Down Expand Up @@ -30,14 +31,13 @@ async function getDevServer(compilation) {
})
];

app.use(koaBody());

// resolve urls to `file://` paths if applicable, otherwise default is `http://`
app.use(async (ctx, next) => {
try {
const url = new URL(`http://localhost:${compilation.config.port}${ctx.url}`);
const initRequest = new Request(url, {
method: ctx.request.method,
headers: new Headers(ctx.request.header)
});
const initRequest = transformKoaRequestIntoStandardRequest(url, ctx.request);
const request = await resourcePlugins.reduce(async (requestPromise, plugin) => {
const intermediateRequest = await requestPromise;
return plugin.shouldResolve && await plugin.shouldResolve(url, intermediateRequest.clone())
Expand All @@ -58,9 +58,9 @@ async function getDevServer(compilation) {
app.use(async (ctx, next) => {
try {
const url = new URL(ctx.url);
const { method, header } = ctx.request;
const { status } = ctx.response;
const request = new Request(url.href, { method, headers: new Headers(header) });
const request = transformKoaRequestIntoStandardRequest(url, ctx.request);
// intentionally ignore initial statusText to avoid false positives from 404s
let response = new Response(null, { status });

for (const plugin of resourcePlugins) {
Expand All @@ -74,14 +74,11 @@ async function getDevServer(compilation) {
}

ctx.body = response.body ? Readable.from(response.body) : '';
ctx.type = response.headers.get('Content-Type');
ctx.status = response.status;

// TODO automatically loop and apply all custom headers to Koa response, include Content-Type below
// https://github.com/ProjectEvergreen/greenwood/issues/1048
if (response.headers.has('Content-Length')) {
ctx.set('Content-Length', response.headers.get('Content-Length'));
}
ctx.message = response.statusText;
response.headers.forEach((value, key) => {
ctx.set(key, value);
});
} catch (e) {
ctx.status = 500;
console.error(e);
Expand All @@ -94,13 +91,12 @@ async function getDevServer(compilation) {
app.use(async (ctx, next) => {
try {
const url = new URL(ctx.url);
const request = new Request(url, {
method: ctx.request.method,
headers: new Headers(ctx.request.header)
});
const initResponse = new Response(ctx.body, {
status: ctx.response.status,
headers: new Headers(ctx.response.header)
const { header, status, message } = ctx.response;
const request = transformKoaRequestIntoStandardRequest(url, ctx.request);
const initResponse = new Response(status === 204 ? null : ctx.body, {
statusText: message,
status,
headers: new Headers(header)
});
const response = await resourcePlugins.reduce(async (responsePromise, plugin) => {
const intermediateResponse = await responsePromise;
Expand All @@ -115,12 +111,10 @@ async function getDevServer(compilation) {
}, Promise.resolve(initResponse.clone()));

ctx.body = response.body ? Readable.from(response.body) : '';
ctx.set('Content-Type', response.headers.get('Content-Type'));
// TODO automatically loop and apply all custom headers to Koa response, include Content-Type below
// https://github.com/ProjectEvergreen/greenwood/issues/1048
if (response.headers.has('Content-Length')) {
ctx.set('Content-Length', response.headers.get('Content-Length'));
}
ctx.message = response.statusText;
response.headers.forEach((value, key) => {
ctx.set(key, value);
});
} catch (e) {
ctx.status = 500;
console.error(e);
Expand All @@ -138,9 +132,11 @@ async function getDevServer(compilation) {
// and only run in development
if (process.env.__GWD_COMMAND__ === 'develop' && url.protocol === 'file:') { // eslint-disable-line no-underscore-dangle
// TODO there's probably a better way to do this with tee-ing streams but this works for now
const { header, status, message } = ctx.response;
const response = new Response(ctx.body, {
status: ctx.response.status,
headers: new Headers(ctx.response.header)
statusText: message,
status,
headers: new Headers(header)
}).clone();
const splitResponse = response.clone();
const contents = await splitResponse.text();
Expand All @@ -157,14 +153,11 @@ async function getDevServer(compilation) {
} else if (!inm || inm !== etagHash) {
ctx.body = Readable.from(response.body);
ctx.status = ctx.status;
ctx.set('Content-Type', ctx.response.header['content-type']);
ctx.set('Etag', etagHash);

// TODO automatically loop and apply all custom headers to Koa response, include Content-Type below
// https://github.com/ProjectEvergreen/greenwood/issues/1048
if (response.headers.has('Content-Length')) {
ctx.set('Content-Length', response.headers.get('Content-Length'));
}
ctx.message = response.statusText;
response.headers.forEach((value, key) => {
ctx.set(key, value);
});
}
}
});
Expand Down Expand Up @@ -224,7 +217,10 @@ async function getStaticServer(compilation, composable) {
const response = await proxyPlugin.serve(url, request);

ctx.body = Readable.from(response.body);
ctx.set('Content-Type', response.headers.get('Content-Type'));
response.headers.forEach((value, key) => {
ctx.set(key, value);
});
ctx.message = response.statusText;
}
}
} catch (e) {
Expand Down Expand Up @@ -261,14 +257,11 @@ async function getStaticServer(compilation, composable) {

if (response.ok) {
ctx.body = Readable.from(response.body);
ctx.type = response.headers.get('Content-Type');
ctx.status = response.status;

// TODO automatically loop and apply all custom headers to Koa response, include Content-Type below
// https://github.com/ProjectEvergreen/greenwood/issues/1048
if (response.headers.has('Content-Length')) {
ctx.set('Content-Length', response.headers.get('Content-Length'));
}
ctx.message = response.statusText;
response.headers.forEach((value, key) => {
ctx.set(key, value);
});
}
}
} catch (e) {
Expand All @@ -289,38 +282,36 @@ async function getHybridServer(compilation) {
const { outputDir } = context;
const app = await getStaticServer(compilation, true);

app.use(koaBody());

app.use(async (ctx) => {
try {
const url = new URL(`http://localhost:${config.port}${ctx.url}`);
const matchingRoute = graph.find((node) => node.route === url.pathname) || { data: {} };
const isApiRoute = manifest.apis.has(url.pathname);
const request = new Request(url.href, {
method: ctx.request.method,
headers: ctx.request.header
});
const request = transformKoaRequestIntoStandardRequest(url, ctx.request);

if (!config.prerender && matchingRoute.isSSR && !matchingRoute.data.static) {
const { handler } = await import(new URL(`./__${matchingRoute.filename}`, outputDir));
// TODO passing compilation this way too hacky?
// https://github.com/ProjectEvergreen/greenwood/issues/1008
const response = await handler(request, compilation);

ctx.body = Readable.from(response.body);
ctx.set('Content-Type', 'text/html');
// TODO should use status from response
// https://github.com/ProjectEvergreen/greenwood/issues/1048
ctx.status = 200;
} else if (isApiRoute) {
const apiRoute = manifest.apis.get(url.pathname);
const { handler } = await import(new URL(`.${apiRoute.path}`, outputDir));
const response = await handler(request);
const { body } = response;
const { body, status, headers, statusText } = response;

// TODO should use status from response
// https://github.com/ProjectEvergreen/greenwood/issues/1048
ctx.body = body ? Readable.from(body) : null;
ctx.status = 200;
ctx.set('Content-Type', response.headers.get('Content-Type'));
ctx.status = status;
ctx.message = statusText;

headers.forEach((value, key) => {
ctx.set(key, value);
});
}
} catch (e) {
ctx.status = 500;
Expand Down
Loading

0 comments on commit 65a80d3

Please sign in to comment.