Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feature/discussion 1117 Isolation Mode (v1) #1206

Merged
merged 5 commits into from
Mar 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading
Loading