Skip to content

Commit

Permalink
feature/discussion 1117 Isolation Mode (v1) (#1206)
Browse files Browse the repository at this point in the history
* isolation mode for SSR pages and API routes for greenwood serve

* documentation for isolation mode option and global config test case

* misc refactoring

* set isolation mode to true for Lit renderer plugin

* set isolation mode to true for Lit renderer plugin
  • Loading branch information
thescientist13 committed Mar 10, 2024
1 parent 787055f commit 55f36b7
Show file tree
Hide file tree
Showing 18 changed files with 258 additions and 35 deletions.
5 changes: 4 additions & 1 deletion packages/cli/src/lib/execute-route-module.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ async function executeRouteModule({ moduleUrl, compilation, page = {}, prerender
data.html = html;
} else {
const module = await import(moduleUrl).then(module => module);
const { prerender = false, getTemplate = null, getBody = null, getFrontmatter = null } = module;
const { prerender = false, getTemplate = null, getBody = null, getFrontmatter = null, isolation } = module;

if (module.default) {
const { html } = await renderToString(new URL(moduleUrl), false, request);
Expand All @@ -35,7 +35,10 @@ async function executeRouteModule({ moduleUrl, compilation, page = {}, prerender
data.frontmatter = await getFrontmatter(compilation, page);
}

// TODO cant we get these from just pulling from the file during the graph phase?
// https://github.com/ProjectEvergreen/greenwood/issues/991
data.prerender = prerender;
data.isolation = isolation;
}

return data;
Expand Down
14 changes: 14 additions & 0 deletions packages/cli/src/lib/ssr-route-worker-isolation-mode.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// https://github.com/nodejs/modules/issues/307#issuecomment-858729422
import { parentPort } from 'worker_threads';

async function executeModule({ routeModuleUrl, request, compilation }) {
const { handler } = await import(routeModuleUrl);
const response = await handler(request, compilation);
const html = await response.text();

parentPort.postMessage(html);
}

parentPort.on('message', async (task) => {
await executeModule(task);
});
11 changes: 10 additions & 1 deletion packages/cli/src/lifecycles/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ const defaultConfig = {
plugins: greenwoodPlugins,
markdown: { plugins: [], settings: {} },
prerender: false,
isolation: false,
pagesDirectory: 'pages',
templatesDirectory: 'templates'
};
Expand All @@ -76,7 +77,7 @@ const readAndMergeConfig = async() => {

if (hasConfigFile) {
const userCfgFile = (await import(configUrl)).default;
const { workspace, devServer, markdown, optimization, plugins, port, prerender, basePath, staticRouter, pagesDirectory, templatesDirectory, interpolateFrontmatter } = userCfgFile;
const { workspace, devServer, markdown, optimization, plugins, port, prerender, basePath, staticRouter, pagesDirectory, templatesDirectory, interpolateFrontmatter, isolation } = userCfgFile;

// workspace validation
if (workspace) {
Expand Down Expand Up @@ -223,6 +224,14 @@ const readAndMergeConfig = async() => {
customConfig.prerender = false;
}

if (isolation !== undefined) {
if (typeof isolation === 'boolean') {
customConfig.isolation = isolation;
} else {
reject(`Error: greenwood.config.js isolation must be a boolean; true or false. Passed value was typeof: ${typeof staticRouter}`);
}
}

if (staticRouter !== undefined) {
if (typeof staticRouter === 'boolean') {
customConfig.staticRouter = staticRouter;
Expand Down
50 changes: 31 additions & 19 deletions packages/cli/src/lifecycles/graph.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ const generateGraph = async (compilation) => {
data: {},
imports: [],
resources: [],
prerender: true
prerender: true,
isolation: false
}];

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

/*
* check if additional nested directories exist to correctly determine route (minus filename)
Expand Down Expand Up @@ -131,6 +133,7 @@ const generateGraph = async (compilation) => {

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

if (result.frontmatter) {
result.frontmatter.imports = result.frontmatter.imports || [];
Expand Down Expand Up @@ -201,6 +204,7 @@ const generateGraph = async (compilation) => {
* 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
* isolation: if this should be run in isolated mode
*/
pages.push({
data: customData || {},
Expand All @@ -220,7 +224,8 @@ const generateGraph = async (compilation) => {
template,
title,
isSSR: !isStatic,
prerender
prerender,
isolation
});
}
}
Expand All @@ -240,27 +245,34 @@ const generateGraph = async (compilation) => {
apis = await walkDirectoryForApis(filenameUrlAsDir, apis);
} else {
const extension = filenameUrl.pathname.split('.').pop();
const relativeApiPath = filenameUrl.pathname.replace(userWorkspace.pathname, '/');
const route = `${basePath}${relativeApiPath.replace(`.${extension}`, '')}`;

if (extension !== 'js') {
console.warn(`${filenameUrl} is not a JavaScript file, skipping...`);
} else {
/*
* API Properties (per route)
*----------------------
* filename: base filename of the page
* outputPath: the filename to write to when generating a build
* path: path to the file relative to the workspace
* route: URL route for a given page on outputFilePath
*/
apis.set(route, {
filename: filename,
outputPath: `/api/${filename}`,
path: relativeApiPath,
route
});
return;
}

const relativeApiPath = filenameUrl.pathname.replace(userWorkspace.pathname, '/');
const route = `${basePath}${relativeApiPath.replace(`.${extension}`, '')}`;
// TODO should this be run in isolation like SSR pages?
// https://github.com/ProjectEvergreen/greenwood/issues/991
const { isolation } = await import(filenameUrl).then(module => module);

/*
* API Properties (per route)
*----------------------
* filename: base filename of the page
* outputPath: the filename to write to when generating a build
* path: path to the file relative to the workspace
* route: URL route for a given page on outputFilePath
* isolation: if this should be run in isolated mode
*/
apis.set(route, {
filename: filename,
outputPath: `/api/${filename}`,
path: relativeApiPath,
route,
isolation
});
}
}

Expand Down
82 changes: 75 additions & 7 deletions packages/cli/src/lifecycles/serve.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ import fs from 'fs/promises';
import { hashString } from '../lib/hashing-utils.js';
import Koa from 'koa';
import { koaBody } from 'koa-body';
import { checkResourceExists, mergeResponse, transformKoaRequestIntoStandardRequest } from '../lib/resource-utils.js';
import { checkResourceExists, mergeResponse, transformKoaRequestIntoStandardRequest, requestAsObject } from '../lib/resource-utils.js';
import { Readable } from 'stream';
import { ResourceInterface } from '../lib/resource-interface.js';
import { Worker } from 'worker_threads';

async function getDevServer(compilation) {
const app = new Koa();
Expand Down Expand Up @@ -282,6 +283,7 @@ async function getStaticServer(compilation, composable) {
async function getHybridServer(compilation) {
const { graph, manifest, context, config } = compilation;
const { outputDir } = context;
const isolationMode = config.isolation;
const app = await getStaticServer(compilation, true);

app.use(koaBody());
Expand All @@ -294,17 +296,83 @@ async function getHybridServer(compilation) {
const request = transformKoaRequestIntoStandardRequest(url, ctx.request);

if (!config.prerender && matchingRoute.isSSR && !matchingRoute.prerender) {
const { handler } = await import(new URL(`./__${matchingRoute.filename}`, outputDir));
const response = await handler(request, compilation);
let html;

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

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

resolve();
});
worker.on('error', reject);
worker.on('exit', (code) => {
if (code !== 0) {
reject(new Error(`Worker stopped with exit code ${code}`));
}
});

worker.postMessage({
routeModuleUrl: new URL(`./__${matchingRoute.filename}`, outputDir).href,
request,
compilation: JSON.stringify(compilation)
});
});
} else {
const { handler } = await import(new URL(`./__${matchingRoute.filename}`, outputDir));
const response = await handler(request, compilation);

ctx.body = Readable.from(response.body);
html = Readable.from(response.body);
}

ctx.body = html;
ctx.set('Content-Type', 'text/html');
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, status, headers, statusText } = response;
let body, status, headers, statusText;

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

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

body = responseAsObject.body;
status = responseAsObject.status;
headers = new Headers(responseAsObject.headers);
statusText = responseAsObject.statusText;

resolve();
});
worker.on('error', reject);
worker.on('exit', (code) => {
if (code !== 0) {
reject(new Error(`Worker stopped with exit code ${code}`));
}
});

worker.postMessage({
href: new URL(`.${apiRoute.path}`, outputDir).href,
request: req
});
});
} else {
const { handler } = await import(new URL(`.${apiRoute.path}`, outputDir));
const response = await handler(request);

body = response.body;
status = response.status;
headers = response.headers;
statusText = response.statusText;
}

ctx.body = body ? Readable.from(body) : null;
ctx.status = status;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/*
* Use Case
* Run Greenwood build command with a bad value for isolation mode in a custom config.
*
* User Result
* Should throw an error.
*
* User Command
* greenwood build
*
* User Config
* {
* isolation: {}
* }
*
* User Workspace
* Greenwood default
*/
import chai from 'chai';
import path from 'path';
import { Runner } from 'gallinago';
import { fileURLToPath, URL } from 'url';

const expect = chai.expect;

describe('Build Greenwood With: ', function() {
const cliPath = path.join(process.cwd(), 'packages/cli/src/index.js');
const outputPath = fileURLToPath(new URL('.', import.meta.url));
let runner;

before(function() {
this.context = {
publicDir: path.join(outputPath, 'public')
};
runner = new Runner();
});

describe('Custom Configuration with a bad value for Isolation', function() {
it('should throw an error that isolation must be a boolean', function() {
try {
runner.setup(outputPath);
runner.runCommand(cliPath, 'build');
} catch (err) {
expect(err).to.contain('Error: greenwood.config.js isolation must be a boolean; true or false. Passed value was typeof: object');
}
});
});

});
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default {
isolation: {}
};
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
* User Workspace
* src/
* api/
* fragment.js
* fragment.js (isolation mode)
* greeting.js
* missing.js
* nothing.js
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/test/cases/serve.default.api/src/api/fragment.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { renderFromHTML } from 'wc-compiler';

export const isolation = true;

export async function handler(request) {
const params = new URLSearchParams(request.url.slice(request.url.indexOf('?')));
const name = params.has('name') ? params.get('name') : 'World';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
* artists.js
* index.js
* post.js
* users.js
* users.js (isolation = true)
* templates/
* app.html
*/
Expand Down
4 changes: 3 additions & 1 deletion packages/cli/test/cases/serve.default.ssr/src/pages/users.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,6 @@ export default class UsersPage extends HTMLElement {
${html}
`;
}
}
}

export const isolation = true;
2 changes: 2 additions & 0 deletions packages/plugin-renderer-lit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ customElements.define('artists-page', ArtistsPage);
export const tagName = 'artists-page';
```

> _By default, this plugin sets `isolation` mode to `true` for all SSR pages. See the [isolation configuration](https://www.greenwoodjs.io/docs/configuration/#isolation) docs for more information._
## Caveats

There are a few considerations to take into account when using a `LitElement` as your page component:
Expand Down
8 changes: 7 additions & 1 deletion packages/plugin-renderer-lit/src/execute-route-module.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,13 @@ async function executeRouteModule({ moduleUrl, compilation, page, prerender, htm
data.html = await getTemplateResultString(templateResult);
} else {
const module = await import(moduleUrl).then(module => module);
const { getTemplate = null, getBody = null, getFrontmatter = null } = module;
const { getTemplate = null, getBody = null, getFrontmatter = null, isolation = true } = module;

// TODO cant we get these from just pulling from the file during the graph phase?
// https://github.com/ProjectEvergreen/greenwood/issues/991
if (isolation) {
data.isolation = true;
}

if (module.default && module.tagName) {
const { tagName } = module;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
* greeting.js
* pages/
* artists.js
* users.js
* users.js (isolation = false)
* templates/
* app.html
*/
Expand Down

0 comments on commit 55f36b7

Please sign in to comment.