Skip to content

Commit

Permalink
Rfc/issue 952 data loading strategies (#1157)
Browse files Browse the repository at this point in the history
* add support for SSR page loader with runtime request object

* refactor static export from getFrontmatter to export const prerender

* document loaders and prerendering for SSR routes

* adopt and document constructor props pattern for SSR page data loading

* update develop SSR test case for constructor props

* remove desribe.only

* refactor graphql plugin for ESM compat

* add test case for experimental prerendering with custom .gql imports

* upgrade website for breaking changes

* update website documentation and graphql plugin package README

* add test cases for adapter plugins and SSR constructor props

* upgrade wcc to 0.9.0

* misc PR cleanup
  • Loading branch information
thescientist13 authored Oct 31, 2023
1 parent 45facc4 commit 34156fc
Show file tree
Hide file tree
Showing 46 changed files with 738 additions and 231 deletions.
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@
"remark-rehype": "^7.0.0",
"rollup": "^2.58.0",
"unified": "^9.2.0",
"wc-compiler": "~0.8.0"
"wc-compiler": "~0.9.0"
},
"devDependencies": {
"@babel/runtime": "^7.10.4",
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/serve.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ const runProdServer = async (compilation) => {
try {
const port = compilation.config.port;
const hasApisDir = await checkResourceExists(compilation.context.apisDir);
const hasDynamicRoutes = compilation.graph.find(page => page.isSSR && !page.data.static);
const hasDynamicRoutes = compilation.graph.find(page => page.isSSR && !page.prerender);
const server = (hasDynamicRoutes && !compilation.config.prerender) || hasApisDir ? getHybridServer : getStaticServer;

(await server(compilation)).listen(port, () => {
Expand Down
10 changes: 6 additions & 4 deletions packages/cli/src/lib/execute-route-module.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { renderToString, renderFromHTML } from 'wc-compiler';

async function executeRouteModule({ moduleUrl, compilation, page = {}, prerender = false, htmlContents = null, scripts = [] }) {
async function executeRouteModule({ moduleUrl, compilation, page = {}, prerender = false, htmlContents = null, scripts = [], request }) {
const data = {
template: null,
body: null,
Expand All @@ -15,15 +15,15 @@ async function executeRouteModule({ moduleUrl, compilation, page = {}, prerender
data.html = html;
} else {
const module = await import(moduleUrl).then(module => module);
const { getTemplate = null, getBody = null, getFrontmatter = null } = module;
const { prerender = false, getTemplate = null, getBody = null, getFrontmatter = null } = module;

if (module.default) {
const { html } = await renderToString(new URL(moduleUrl), false);
const { html } = await renderToString(new URL(moduleUrl), false, request);

data.body = html;
} else {
if (getBody) {
data.body = await getBody(compilation, page);
data.body = await getBody(compilation, page, request);
}
}

Expand All @@ -34,6 +34,8 @@ async function executeRouteModule({ moduleUrl, compilation, page = {}, prerender
if (getFrontmatter) {
data.frontmatter = await getFrontmatter(compilation, page);
}

data.prerender = prerender;
}

return data;
Expand Down
52 changes: 52 additions & 0 deletions packages/cli/src/lib/resource-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -208,11 +208,63 @@ function transformKoaRequestIntoStandardRequest(url, request) {
});
}

// https://stackoverflow.com/questions/57447685/how-can-i-convert-a-request-object-into-a-stringifiable-object-in-javascript
async function requestAsObject (_request) {
if (!_request instanceof Request) {
throw Object.assign(
new Error(),
{ name: 'TypeError', message: 'Argument must be a Request object' }
);
}

const request = _request.clone();
const contentType = request.headers.get('content-type') || '';
let headers = Object.fromEntries(request.headers);
let format;

function stringifiableObject (obj) {
const filtered = {};
for (const key in obj) {
if (['boolean', 'number', 'string'].includes(typeof obj[key]) || obj[key] === null) {
filtered[key] = obj[key];
}
}
return filtered;
}

if (contentType.includes('application/x-www-form-urlencoded')) {
const formData = await request.formData();
const params = {};

for (const entry of formData.entries()) {
params[entry[0]] = entry[1];
}

// when using FormData, let Request set the correct headers
// or else it will come out as multipart/form-data
// for serialization between route workers, leave a special marker for Greenwood
// https://stackoverflow.com/a/43521052/417806
headers['content-type'] = 'x-greenwood/www-form-urlencoded';
format = JSON.stringify(params);
} else if (contentType.includes('application/json')) {
format = JSON.stringify(await request.json());
} else {
format = await request.text();
}

return {
...stringifiableObject(request),
body: format,
headers
};
}

export {
checkResourceExists,
mergeResponse,
modelResource,
normalizePathnameForWindows,
requestAsObject,
resolveForRelativeUrl,
trackResourcesForRoute,
transformKoaRequestIntoStandardRequest
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/lib/ssr-route-worker.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
// https://github.com/nodejs/modules/issues/307#issuecomment-858729422
import { parentPort } from 'worker_threads';

async function executeModule({ executeModuleUrl, moduleUrl, compilation, page, prerender = false, htmlContents = null, scripts = '[]' }) {
async function executeModule({ executeModuleUrl, moduleUrl, compilation, page, prerender = false, htmlContents = null, scripts = '[]', request }) {
const { executeRouteModule } = await import(executeModuleUrl);
const data = await executeRouteModule({ moduleUrl, compilation: JSON.parse(compilation), page: JSON.parse(page), prerender, htmlContents, scripts: JSON.parse(scripts) });
const data = await executeRouteModule({ moduleUrl, compilation: JSON.parse(compilation), page: JSON.parse(page), prerender, htmlContents, scripts: JSON.parse(scripts), request });

parentPort.postMessage(data);
}
Expand Down
9 changes: 5 additions & 4 deletions packages/cli/src/lifecycles/bundle.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ async function optimizeStaticPages(compilation, plugins) {
const { scratchDir, outputDir } = compilation.context;

return Promise.all(compilation.graph
.filter(page => !page.isSSR || (page.isSSR && page.data.static) || (page.isSSR && compilation.config.prerender))
.filter(page => !page.isSSR || (page.isSSR && page.prerender) || (page.isSSR && compilation.config.prerender))
.map(async (page) => {
const { route, outputPath } = page;
const outputDirUrl = new URL(`.${route}`, outputDir);
Expand Down Expand Up @@ -189,13 +189,14 @@ async function bundleSsrPages(compilation) {
const { pagesDir, scratchDir } = compilation.context;

for (const page of compilation.graph) {
if (page.isSSR && !page.data.static) {
if (page.isSSR && !page.prerender) {
const { filename, imports, route, template, title } = page;
const entryFileUrl = new URL(`./_${filename}`, scratchDir);
const moduleUrl = new URL(`./${filename}`, pagesDir);
const request = new Request(moduleUrl); // TODO not really sure how to best no-op this?
// TODO getTemplate has to be static (for now?)
// https://github.com/ProjectEvergreen/greenwood/issues/955
const data = await executeRouteModule({ moduleUrl, compilation, page, prerender: false, htmlContents: null, scripts: [] });
const data = await executeRouteModule({ moduleUrl, compilation, page, prerender: false, htmlContents: null, scripts: [], request });
let staticHtml = '';

staticHtml = data.template ? data.template : await getPageTemplate(staticHtml, compilation.context, template, []);
Expand All @@ -212,7 +213,7 @@ async function bundleSsrPages(compilation) {
const compilation = JSON.parse('${JSON.stringify(compilation)}');
const page = JSON.parse('${JSON.stringify(page)}');
const moduleUrl = '___GWD_ENTRY_FILE_URL=${filename}___';
const data = await executeRouteModule({ moduleUrl, compilation, page });
const data = await executeRouteModule({ moduleUrl, compilation, page, request });
let staticHtml = \`${staticHtml}\`;
if (data.body) {
Expand Down
21 changes: 16 additions & 5 deletions packages/cli/src/lifecycles/graph.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/* eslint-disable complexity, max-depth */
import fs from 'fs/promises';
import fm from 'front-matter';
import { checkResourceExists } from '../lib/resource-utils.js';
import { checkResourceExists, requestAsObject } from '../lib/resource-utils.js';
import toc from 'markdown-toc';
import { Worker } from 'worker_threads';

Expand All @@ -20,7 +20,8 @@ const generateGraph = async (compilation) => {
label: 'Index',
data: {},
imports: [],
resources: []
resources: [],
prerender: true
}];

const walkDirectoryForPages = async function(directory, pages = []) {
Expand All @@ -46,6 +47,7 @@ const generateGraph = async (compilation) => {
let imports = [];
let customData = {};
let filePath;
let prerender = true;

/*
* check if additional nested directories exist to correctly determine route (minus filename)
Expand Down Expand Up @@ -121,14 +123,19 @@ const generateGraph = async (compilation) => {

filePath = route;

await new Promise((resolve, reject) => {
await new Promise(async (resolve, reject) => {
const worker = new Worker(new URL('../lib/ssr-route-worker.js', import.meta.url));
// TODO "faux" new Request here, a better way?
const request = await requestAsObject(new Request(filenameUrl));

worker.on('message', async (result) => {
prerender = result.prerender;

if (result.frontmatter) {
result.frontmatter.imports = result.frontmatter.imports || [];
ssrFrontmatter = result.frontmatter;
}

resolve();
});
worker.on('error', reject);
Expand All @@ -151,7 +158,8 @@ const generateGraph = async (compilation) => {
.map((idPart) => {
return `${idPart.charAt(0).toUpperCase()}${idPart.substring(1)}`;
}).join(' ')
})
}),
request
});
});

Expand Down Expand Up @@ -190,6 +198,8 @@ const generateGraph = async (compilation) => {
* route: URL route for a given page on outputFilePath
* template: page template to use as a base for a generated component
* title: a default value that can be used for <title></title>
* isSSR: if this is a server side route
* prerednder: if this should be statically exported
*/
pages.push({
data: customData || {},
Expand All @@ -208,7 +218,8 @@ const generateGraph = async (compilation) => {
route,
template,
title,
isSSR: !isStatic
isSSR: !isStatic,
prerender
});
}
}
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/lifecycles/prerender.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ function getPluginInstances (compilation) {
}

async function preRenderCompilationWorker(compilation, workerPrerender) {
const pages = compilation.graph.filter(page => !page.isSSR || (page.isSSR && page.data.static) || (page.isSSR && compilation.config.prerender));
const pages = compilation.graph.filter(page => !page.isSSR || (page.isSSR && page.prerender) || (page.isSSR && compilation.config.prerender));
const { scratchDir } = compilation.context;
const plugins = getPluginInstances(compilation);

Expand Down Expand Up @@ -128,7 +128,7 @@ async function preRenderCompilationCustom(compilation, customPrerender) {

async function staticRenderCompilation(compilation) {
const { scratchDir } = compilation.context;
const pages = compilation.graph.filter(page => !page.isSSR || page.isSSR && page.data.static);
const pages = compilation.graph.filter(page => !page.isSSR || page.isSSR && page.prerender);
const plugins = getPluginInstances(compilation);

console.info('pages to generate', `\n ${pages.map(page => page.route).join('\n ')}`);
Expand Down
5 changes: 2 additions & 3 deletions packages/cli/src/lifecycles/serve.js
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ async function getStaticServer(compilation, composable) {
const matchingRoute = compilation.graph.find(page => page.route === url.pathname);
const isSPA = compilation.graph.find(page => page.isSPA);
const { isSSR } = matchingRoute || {};
const isStatic = matchingRoute && !isSSR || isSSR && compilation.config.prerender || isSSR && matchingRoute.data.static;
const isStatic = matchingRoute && !isSSR || isSSR && compilation.config.prerender || isSSR && matchingRoute.prerender;

if (isSPA || (matchingRoute && isStatic) || url.pathname.split('.').pop() === 'html') {
const pathname = isSPA
Expand Down Expand Up @@ -293,9 +293,8 @@ async function getHybridServer(compilation) {
const isApiRoute = manifest.apis.has(url.pathname);
const request = transformKoaRequestIntoStandardRequest(url, ctx.request);

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

ctx.body = Readable.from(response.body);
Expand Down
52 changes: 1 addition & 51 deletions packages/cli/src/plugins/resource/plugin-api-routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,59 +4,9 @@
*
*/
import { ResourceInterface } from '../../lib/resource-interface.js';
import { requestAsObject } from '../../lib/resource-utils.js';
import { Worker } from 'worker_threads';

// https://stackoverflow.com/questions/57447685/how-can-i-convert-a-request-object-into-a-stringifiable-object-in-javascript
async function requestAsObject (_request) {
if (!_request instanceof Request) {
throw Object.assign(
new Error(),
{ name: 'TypeError', message: 'Argument must be a Request object' }
);
}

const request = _request.clone();
const contentType = request.headers.get('content-type') || '';
let headers = Object.fromEntries(request.headers);
let format;

function stringifiableObject (obj) {
const filtered = {};
for (const key in obj) {
if (['boolean', 'number', 'string'].includes(typeof obj[key]) || obj[key] === null) {
filtered[key] = obj[key];
}
}
return filtered;
}

if (contentType.includes('application/x-www-form-urlencoded')) {
const formData = await request.formData();
const params = {};

for (const entry of formData.entries()) {
params[entry[0]] = entry[1];
}

// when using FormData, let Request set the correct headers
// or else it will come out as multipart/form-data
// for serialization between route workers, leave a special marker for Greenwood
// https://stackoverflow.com/a/43521052/417806
headers['content-type'] = 'x-greenwood/www-form-urlencoded';
format = JSON.stringify(params);
} else if (contentType.includes('application/json')) {
format = JSON.stringify(await request.json());
} else {
format = await request.text();
}

return {
...stringifiableObject(request),
body: format,
headers
};
}

class ApiRoutesResource extends ResourceInterface {
constructor(compilation, options) {
super(compilation, options);
Expand Down
8 changes: 5 additions & 3 deletions packages/cli/src/plugins/resource/plugin-standard-html.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import remarkParse from 'remark-parse';
import remarkRehype from 'remark-rehype';
import { ResourceInterface } from '../../lib/resource-interface.js';
import { getUserScripts, getPageTemplate, getAppTemplate } from '../../lib/templating-utils.js';
import { requestAsObject } from '../../lib/resource-utils.js';
import unified from 'unified';
import { Worker } from 'worker_threads';

Expand All @@ -33,7 +34,7 @@ class StandardHtmlResource extends ResourceInterface {
return protocol.startsWith('http') && (hasMatchingPageRoute || isSPA);
}

async serve(url) {
async serve(url, request) {
const { config, context } = this.compilation;
const { pagesDir, userWorkspace } = context;
const { interpolateFrontmatter } = config;
Expand Down Expand Up @@ -107,7 +108,7 @@ class StandardHtmlResource extends ResourceInterface {
const routeModuleLocationUrl = new URL(`./${matchingRoute.filename}`, pagesDir);
const routeWorkerUrl = this.compilation.config.plugins.find(plugin => plugin.type === 'renderer').provider().executeModuleUrl;

await new Promise((resolve, reject) => {
await new Promise(async (resolve, reject) => {
const worker = new Worker(new URL('../../lib/ssr-route-worker.js', import.meta.url));

worker.on('message', (result) => {
Expand Down Expand Up @@ -146,7 +147,8 @@ class StandardHtmlResource extends ResourceInterface {
executeModuleUrl: routeWorkerUrl.href,
moduleUrl: routeModuleLocationUrl.href,
compilation: JSON.stringify(this.compilation),
page: JSON.stringify(matchingRoute)
page: JSON.stringify(matchingRoute),
request: await requestAsObject(request)
});
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,12 +77,13 @@ async function getFrontmatter() {
],
data: {
author: 'Project Evergreen',
date: '01-01-2021',
static: true
date: '01-01-2021'
}
};
}

export const prerender = true;

export {
getTemplate,
getBody,
Expand Down
Loading

0 comments on commit 34156fc

Please sign in to comment.