diff --git a/.eslintignore b/.eslintignore index 7862cff23..8e786af06 100644 --- a/.eslintignore +++ b/.eslintignore @@ -3,4 +3,5 @@ **/node_modules/** !.eslintrc.cjs !.mocharc.js -packages/plugin-babel/test/cases/**/*main.js \ No newline at end of file +packages/plugin-babel/test/cases/**/*main.js +TODO.md \ No newline at end of file diff --git a/.gitignore b/.gitignore index 1303492c0..a56ab6ff6 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,8 @@ coverage/ node_modules/ packages/**/test/**/yarn.lock packages/**/test/**/package-lock.json -public/ \ No newline at end of file +packages/**/test/**/netlify +packages/**/test/**/.netlify +packages/**/test/**/.vercel +public/ +adapter-outlet/ \ No newline at end of file diff --git a/.ls-lint.yml b/.ls-lint.yml index 6db0a5ef2..64ab822b4 100644 --- a/.ls-lint.yml +++ b/.ls-lint.yml @@ -14,4 +14,5 @@ ls: ignore: - .git - node_modules - - packages/init/node_modules \ No newline at end of file + - packages/init/node_modules + - packages/plugin-typescript/node_modules \ No newline at end of file diff --git a/lerna.json b/lerna.json index 5f90bc0a4..b1cd7d2e6 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "0.28.5", + "version": "0.29.0-alpha.6", "packages": [ "packages/*", "www" diff --git a/package.json b/package.json index cdcefd983..20bcc8e25 100644 --- a/package.json +++ b/package.json @@ -34,16 +34,16 @@ }, "devDependencies": { "@ls-lint/ls-lint": "^1.10.0", - "@typescript-eslint/eslint-plugin": "^4.28.2", - "@typescript-eslint/parser": "^4.28.2", + "@typescript-eslint/eslint-plugin": "^6.7.5", + "@typescript-eslint/parser": "^6.7.5", "babel-eslint": "^10.1.0", "c8": "^7.10.0", "chai": "^4.2.0", "cross-env": "^7.0.3", - "eslint": "^6.8.0", + "eslint": "^8.51.0", "eslint-plugin-markdown": "^3.0.0", "eslint-plugin-no-only-tests": "^2.6.0", - "gallinago": "^0.6.0", + "gallinago": "^0.7.0", "glob-promise": "^3.4.0", "jsdom": "^16.5.0", "lerna": "^3.16.4", diff --git a/packages/cli/package.json b/packages/cli/package.json index 23883d6e6..5c36fa21a 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@greenwood/cli", - "version": "0.28.5", + "version": "0.29.0-alpha.6", "description": "Greenwood CLI.", "type": "module", "repository": "https://github.com/ProjectEvergreen/greenwood", @@ -34,7 +34,6 @@ "@rollup/plugin-node-resolve": "^13.0.0", "@rollup/plugin-replace": "^2.3.4", "@rollup/plugin-terser": "^0.1.0", - "@web/rollup-plugin-import-meta-assets": "^1.0.0", "acorn": "^8.0.1", "acorn-walk": "^8.0.0", "commander": "^2.20.0", @@ -42,6 +41,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", @@ -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", diff --git a/packages/cli/src/commands/build.js b/packages/cli/src/commands/build.js index 1d4502195..fba4ef73f 100644 --- a/packages/cli/src/commands/build.js +++ b/packages/cli/src/commands/build.js @@ -71,6 +71,9 @@ const runProductionBuild = async (compilation) => { const prerenderPlugin = compilation.config.plugins.find(plugin => plugin.type === 'renderer') ? compilation.config.plugins.find(plugin => plugin.type === 'renderer').provider(compilation) : {}; + const adapterPlugin = compilation.config.plugins.find(plugin => plugin.type === 'adapter') + ? compilation.config.plugins.find(plugin => plugin.type === 'adapter').provider(compilation) + : null; if (!await checkResourceExists(outputDir)) { await fs.mkdir(outputDir, { @@ -98,7 +101,7 @@ const runProductionBuild = async (compilation) => { return Promise.resolve(server); })); - if (prerenderPlugin.workerUrl) { + if (prerenderPlugin.executeModuleUrl) { await trackResourcesForRoutes(compilation); await preRenderCompilationWorker(compilation, prerenderPlugin); } else { @@ -114,6 +117,10 @@ const runProductionBuild = async (compilation) => { await bundleCompilation(compilation); await copyAssets(compilation); + if (adapterPlugin) { + await adapterPlugin(); + } + resolve(); } catch (err) { reject(err); diff --git a/packages/cli/src/commands/develop.js b/packages/cli/src/commands/develop.js index a5ea59a5c..3c28226c0 100644 --- a/packages/cli/src/commands/develop.js +++ b/packages/cli/src/commands/develop.js @@ -6,11 +6,13 @@ const runDevServer = async (compilation) => { return new Promise(async (resolve, reject) => { try { - const { port } = compilation.config.devServer; + const { basePath, devServer } = compilation.config; + const { port } = devServer; + const postfixSlash = basePath === '' ? '' : '/'; (await getDevServer(compilation)).listen(port, () => { - console.info(`Started local development server at localhost:${port}`); + console.info(`Started local development server at http://localhost:${port}${basePath}${postfixSlash}`); const servers = [...compilation.config.plugins.filter((plugin) => { return plugin.type === 'server'; diff --git a/packages/cli/src/commands/serve.js b/packages/cli/src/commands/serve.js index f9c1dbfb1..d40983ad1 100644 --- a/packages/cli/src/commands/serve.js +++ b/packages/cli/src/commands/serve.js @@ -6,13 +6,14 @@ const runProdServer = async (compilation) => { return new Promise(async (resolve, reject) => { try { - const port = compilation.config.port; + const { basePath, port } = compilation.config; + const postfixSlash = basePath === '' ? '' : '/'; 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, () => { - console.info(`Started server at localhost:${port}`); + console.info(`Started server at http://localhost:${port}${basePath}${postfixSlash}`); }); } catch (err) { reject(err); diff --git a/packages/cli/src/config/rollup.config.js b/packages/cli/src/config/rollup.config.js index f3d0ebd4f..8aa6f56c2 100644 --- a/packages/cli/src/config/rollup.config.js +++ b/packages/cli/src/config/rollup.config.js @@ -1,20 +1,27 @@ -import fs from 'fs/promises'; +import fs from 'fs'; +import path from 'path'; import { checkResourceExists, normalizePathnameForWindows } from '../lib/resource-utils.js'; import { nodeResolve } from '@rollup/plugin-node-resolve'; import commonjs from '@rollup/plugin-commonjs'; -import { importMetaAssets } from '@web/rollup-plugin-import-meta-assets'; +import * as walk from 'acorn-walk'; -// specifically to handle escodegen using require for package.json +// https://github.com/rollup/rollup/issues/2121 +function cleanRollupId(id) { + return id.replace('\x00', ''); +} + +// specifically to handle escodegen and other node modules +// using require for package.json or other json files // https://github.com/estools/escodegen/issues/455 function greenwoodJsonLoader() { return { name: 'greenwood-json-loader', async load(id) { - const extension = id.split('.').pop(); + const idUrl = new URL(`file://${cleanRollupId(id)}`); + const extension = idUrl.pathname.split('.').pop(); if (extension === 'json') { - const url = new URL(`file://${id}`); - const json = JSON.parse(await fs.readFile(url, 'utf-8')); + const json = JSON.parse(await fs.promises.readFile(idUrl, 'utf-8')); const contents = `export default ${JSON.stringify(json)}`; return contents; @@ -33,11 +40,11 @@ function greenwoodResourceLoader (compilation) { return { name: 'greenwood-resource-loader', async resolveId(id) { - const normalizedId = id.replace(/\?type=(.*)/, ''); + const normalizedId = cleanRollupId(id); // idUrl.pathname; const { projectDirectory, userWorkspace } = compilation.context; - if (id.startsWith('.') && !id.startsWith(projectDirectory.pathname)) { - const prefix = id.startsWith('..') ? './' : ''; + if (normalizedId.startsWith('.') && !normalizedId.startsWith(projectDirectory.pathname)) { + const prefix = normalizedId.startsWith('..') ? './' : ''; const userWorkspaceUrl = new URL(`${prefix}${normalizedId.replace(/\.\.\//g, '')}`, userWorkspace); if (await checkResourceExists(userWorkspaceUrl)) { @@ -46,11 +53,13 @@ function greenwoodResourceLoader (compilation) { } }, async load(id) { - const pathname = id.indexOf('?') >= 0 ? id.slice(0, id.indexOf('?')) : id; + const idUrl = new URL(`file://${cleanRollupId(id)}`); + const { pathname } = idUrl; const extension = pathname.split('.').pop(); - if (extension !== '' && extension !== 'js') { - const url = new URL(`file://${pathname}?type=${extension}`); + // filter first for any bare specifiers + if (await checkResourceExists(idUrl) && extension !== '' && extension !== 'js') { + const url = new URL(`${idUrl.href}?type=${extension}`); const request = new Request(url.href); let response = new Response(''); @@ -116,12 +125,12 @@ function greenwoodSyncPageResourceBundlesPlugin(compilation) { compilation.resources.set(resource.sourcePathURL.pathname, { ...compilation.resources.get(resource.sourcePathURL.pathname), optimizedFileName: fileName, - optimizedFileContents: await fs.readFile(outputPath, 'utf-8'), + optimizedFileContents: await fs.promises.readFile(outputPath, 'utf-8'), contents }); if (noop) { - await fs.writeFile(outputPath, contents); + await fs.promises.writeFile(outputPath, contents); } } } @@ -130,6 +139,162 @@ function greenwoodSyncPageResourceBundlesPlugin(compilation) { }; } +function getMetaImportPath(node) { + return node.arguments[0].value.split('/').join(path.sep); +} + +function isNewUrlImportMetaUrl(node) { + return ( + node.type === 'NewExpression' && + node.callee.type === 'Identifier' && + node.callee.name === 'URL' && + node.arguments.length === 2 && + node.arguments[0].type === 'Literal' && + typeof getMetaImportPath(node) === 'string' && + node.arguments[1].type === 'MemberExpression' && + node.arguments[1].object.type === 'MetaProperty' && + node.arguments[1].property.type === 'Identifier' && + node.arguments[1].property.name === 'url' + ); +} + +// adapted from, and with credit to @web/rollup-plugin-import-meta-assets +// https://modern-web.dev/docs/building/rollup-plugin-import-meta-assets/ +function greenwoodImportMetaUrl(compilation) { + + return { + name: 'greenwood-import-meta-url', + + async transform(code, id) { + const resourcePlugins = compilation.config.plugins.filter((plugin) => { + return plugin.type === 'resource'; + }).map((plugin) => { + return plugin.provider(compilation); + }); + const customResourcePlugins = compilation.config.plugins.filter((plugin) => { + return plugin.type === 'resource' && !plugin.isGreenwoodDefaultPlugin; + }).map((plugin) => { + return plugin.provider(compilation); + }); + const idUrl = new URL(`file://${cleanRollupId(id)}`); + const { pathname } = idUrl; + const extension = pathname.split('.').pop(); + const urlWithType = new URL(`${idUrl.href}?type=${extension}`); + const request = new Request(urlWithType.href); + let canTransform = false; + let response = new Response(code); + + // handle any custom imports or pre-processing needed before passing to Rollup this.parse + if (await checkResourceExists(idUrl) && extension !== '' && extension !== 'json') { + for (const plugin of resourcePlugins) { + if (plugin.shouldServe && await plugin.shouldServe(urlWithType, request)) { + response = await plugin.serve(urlWithType, request); + canTransform = true; + } + } + + for (const plugin of resourcePlugins) { + if (plugin.shouldIntercept && await plugin.shouldIntercept(urlWithType, request, response.clone())) { + response = await plugin.intercept(urlWithType, request, response.clone()); + canTransform = true; + } + } + } + + if (!canTransform) { + return null; + } + + const ast = this.parse(await response.text()); + const assetUrls = []; + let modifiedCode = false; + + // aggregate all references of new URL + import.meta.url + walk.simple(ast, { + NewExpression(node) { + if (isNewUrlImportMetaUrl(node)) { + const absoluteScriptDir = path.dirname(id); + const relativeAssetPath = getMetaImportPath(node); + const absoluteAssetPath = path.resolve(absoluteScriptDir, relativeAssetPath); + const assetName = path.basename(absoluteAssetPath); + const assetExtension = assetName.split('.').pop(); + + assetUrls.push({ + url: new URL(`file://${absoluteAssetPath}?type=${assetExtension}`), + relativeAssetPath + }); + } + } + }); + + for (const assetUrl of assetUrls) { + const { url } = assetUrl; + const { pathname } = url; + const { relativeAssetPath } = assetUrl; + const assetName = path.basename(pathname); + const assetExtension = assetName.split('.').pop(); + const assetContents = await fs.promises.readFile(url, 'utf-8'); + const name = assetName.replace(`.${assetExtension}`, ''); + let bundleExtensions = ['js']; + + for (const plugin of customResourcePlugins) { + if (plugin.shouldServe && await plugin.shouldServe(url)) { + const response = await plugin.serve(url); + + if (response?.headers?.get('content-type') || ''.indexOf('text/javascript') >= 0) { + bundleExtensions = [...bundleExtensions, ...plugin.extensions]; + } + } + } + + const type = bundleExtensions.indexOf(assetExtension) >= 0 + ? 'chunk' + : 'asset'; + const emitConfig = type === 'chunk' + ? { type, id: normalizePathnameForWindows(url), name } + : { type, name: assetName, source: assetContents }; + const ref = this.emitFile(emitConfig); + // handle Windows style paths + const normalizedRelativeAssetPath = relativeAssetPath.replace(/\\/g, '/'); + const importRef = `import.meta.ROLLUP_FILE_URL_${ref}`; + + modifiedCode = code + .replace(`'${normalizedRelativeAssetPath}'`, importRef) + .replace(`"${normalizedRelativeAssetPath}"`, importRef); + } + + return { + code: modifiedCode ? modifiedCode : code, + map: null + }; + } + }; +} + +// TODO could we use this instead? +// https://github.com/rollup/rollup/blob/v2.79.1/docs/05-plugin-development.md#resolveimportmeta +// https://github.com/ProjectEvergreen/greenwood/issues/1087 +function greenwoodPatchSsrPagesEntryPointRuntimeImport() { + return { + name: 'greenwood-patch-ssr-pages-entry-point-runtime-import', + generateBundle(options, bundle) { + Object.keys(bundle).forEach((key) => { + if (key.startsWith('__')) { + // ___GWD_ENTRY_FILE_URL=${filename}___ + const needle = bundle[key].code.match(/___GWD_ENTRY_FILE_URL=(.*.)___/); + if (needle) { + const entryPathMatch = needle[1]; + + bundle[key].code = bundle[key].code.replace(/'___GWD_ENTRY_FILE_URL=(.*.)___'/, `new URL('./_${entryPathMatch}', import.meta.url)`); + } else { + console.warn(`Could not find entry path match for bundle => ${key}`); + } + } + }); + } + }; +} + const getRollupConfigForScriptResources = async (compilation) => { const { outputDir } = compilation.context; const input = [...compilation.resources.values()] @@ -153,6 +318,7 @@ const getRollupConfigForScriptResources = async (compilation) => { plugins: [ greenwoodResourceLoader(compilation), greenwoodSyncPageResourceBundlesPlugin(compilation), + greenwoodImportMetaUrl(compilation), ...customRollupPlugins ], context: 'window', @@ -192,8 +358,13 @@ const getRollupConfigForApis = async (compilation) => { const input = [...compilation.manifest.apis.values()] .map(api => normalizePathnameForWindows(new URL(`.${api.path}`, userWorkspace))); + // why is this needed? + await fs.promises.mkdir(new URL('./api/assets/', outputDir), { + recursive: true + }); + // TODO should routes and APIs have chunks? - // https://github.com/ProjectEvergreen/greenwood/issues/1008 + // https://github.com/ProjectEvergreen/greenwood/issues/1118 return [{ input, output: { @@ -203,9 +374,10 @@ const getRollupConfigForApis = async (compilation) => { }, plugins: [ greenwoodJsonLoader(), + greenwoodResourceLoader(compilation), nodeResolve(), commonjs(), - importMetaAssets() + greenwoodImportMetaUrl(compilation) ] }]; }; @@ -214,7 +386,7 @@ const getRollupConfigForSsr = async (compilation, input) => { const { outputDir } = compilation.context; // TODO should routes and APIs have chunks? - // https://github.com/ProjectEvergreen/greenwood/issues/1008 + // https://github.com/ProjectEvergreen/greenwood/issues/1118 return [{ input, output: { @@ -224,10 +396,35 @@ const getRollupConfigForSsr = async (compilation, input) => { }, plugins: [ greenwoodJsonLoader(), - nodeResolve(), + greenwoodResourceLoader(compilation), + // TODO let this through for lit to enable nodeResolve({ preferBuiltins: true }) + // https://github.com/lit/lit/issues/449 + // https://github.com/ProjectEvergreen/greenwood/issues/1118 + nodeResolve({ + preferBuiltins: true + }), commonjs(), - importMetaAssets() - ] + greenwoodImportMetaUrl(compilation), + greenwoodPatchSsrPagesEntryPointRuntimeImport() // TODO a little hacky but works for now + ], + onwarn: (errorObj) => { + const { code, message } = errorObj; + + switch (code) { + + case 'CIRCULAR_DEPENDENCY': + // TODO let this through for lit by suppressing it + // Error: the string "Circular dependency: ../../../../../node_modules/@lit-labs/ssr/lib/render-lit-html.js -> + // ../../../../../node_modules/@lit-labs/ssr/lib/lit-element-renderer.js -> ../../../../../node_modules/@lit-labs/ssr/lib/render-lit-html.js\n" was thrown, throw an Error :) + // https://github.com/lit/lit/issues/449 + // https://github.com/ProjectEvergreen/greenwood/issues/1118 + break; + default: + // otherwise, log all warnings from rollup + console.debug(message); + + } + } }]; }; diff --git a/packages/cli/src/lib/api-route-worker.js b/packages/cli/src/lib/api-route-worker.js index 5a87470ad..4af5fabc5 100644 --- a/packages/cli/src/lib/api-route-worker.js +++ b/packages/cli/src/lib/api-route-worker.js @@ -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) { @@ -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)); } diff --git a/packages/cli/src/lib/execute-route-module.js b/packages/cli/src/lib/execute-route-module.js new file mode 100644 index 000000000..483696fdc --- /dev/null +++ b/packages/cli/src/lib/execute-route-module.js @@ -0,0 +1,44 @@ +import { renderToString, renderFromHTML } from 'wc-compiler'; + +async function executeRouteModule({ moduleUrl, compilation, page = {}, prerender = false, htmlContents = null, scripts = [], request }) { + const data = { + template: null, + body: null, + frontmatter: null, + html: null + }; + + if (prerender) { + const scriptURLs = scripts.map(scriptFile => new URL(scriptFile)); + const { html } = await renderFromHTML(htmlContents, scriptURLs); + + data.html = html; + } else { + const module = await import(moduleUrl).then(module => module); + const { prerender = false, getTemplate = null, getBody = null, getFrontmatter = null } = module; + + if (module.default) { + const { html } = await renderToString(new URL(moduleUrl), false, request); + + data.body = html; + } else { + if (getBody) { + data.body = await getBody(compilation, page, request); + } + } + + if (getTemplate) { + data.template = await getTemplate(compilation, page); + } + + if (getFrontmatter) { + data.frontmatter = await getFrontmatter(compilation, page); + } + + data.prerender = prerender; + } + + return data; +} + +export { executeRouteModule }; \ No newline at end of file diff --git a/packages/cli/src/lib/resource-utils.js b/packages/cli/src/lib/resource-utils.js index 6f84e415e..a3d71e579 100644 --- a/packages/cli/src/lib/resource-utils.js +++ b/packages/cli/src/lib/resource-utils.js @@ -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 @@ -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 }); } @@ -169,11 +171,101 @@ 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 + }); +} + +// 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 + trackResourcesForRoute, + transformKoaRequestIntoStandardRequest }; \ No newline at end of file diff --git a/packages/cli/src/lib/ssr-route-worker.js b/packages/cli/src/lib/ssr-route-worker.js index 490624abe..239eb49a3 100644 --- a/packages/cli/src/lib/ssr-route-worker.js +++ b/packages/cli/src/lib/ssr-route-worker.js @@ -1,47 +1,13 @@ // https://github.com/nodejs/modules/issues/307#issuecomment-858729422 import { parentPort } from 'worker_threads'; -import { renderToString, renderFromHTML } from 'wc-compiler'; -async function executeRouteModule({ moduleUrl, compilation, route, label, id, prerender, htmlContents, scripts }) { - const parsedCompilation = JSON.parse(compilation); - const data = { - template: null, - body: null, - frontmatter: null, - html: null - }; - - if (prerender) { - const scriptURLs = JSON.parse(scripts).map(scriptFile => new URL(scriptFile)); - const { html } = await renderFromHTML(htmlContents, scriptURLs); - - data.html = html; - } else { - const module = await import(moduleUrl).then(module => module); - const { getTemplate = null, getBody = null, getFrontmatter = null } = module; - - if (module.default) { - const { html } = await renderToString(new URL(moduleUrl), false); - - data.body = html; - } else { - if (getBody) { - data.body = await getBody(parsedCompilation, route); - } - } - - if (getTemplate) { - data.template = await getTemplate(parsedCompilation, route); - } - - if (getFrontmatter) { - data.frontmatter = await getFrontmatter(parsedCompilation, route, label, id); - } - } +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), request }); parentPort.postMessage(data); } parentPort.on('message', async (task) => { - await executeRouteModule(task); + await executeModule(task); }); \ No newline at end of file diff --git a/packages/cli/src/lib/templating-utils.js b/packages/cli/src/lib/templating-utils.js index 39486a53e..9503e6aac 100644 --- a/packages/cli/src/lib/templating-utils.js +++ b/packages/cli/src/lib/templating-utils.js @@ -176,7 +176,18 @@ async function getAppTemplate(pageTemplateContents, context, customImports = [], return mergedTemplateContents; } -async function getUserScripts (contents, context) { +async function getUserScripts (contents, compilation) { + const { context, config } = compilation; + + contents = contents.replace('
', ` + + + `); + + // TODO get rid of lit polyfills in core + // https://github.com/ProjectEvergreen/greenwood/issues/728 // https://lit.dev/docs/tools/requirements/#polyfills if (process.env.__GWD_COMMAND__ === 'build') { // eslint-disable-line no-underscore-dangle const userPackageJson = await getPackageJson(context); diff --git a/packages/cli/src/lifecycles/bundle.js b/packages/cli/src/lifecycles/bundle.js index 974619223..e5a28fdd5 100644 --- a/packages/cli/src/lifecycles/bundle.js +++ b/packages/cli/src/lifecycles/bundle.js @@ -1,6 +1,7 @@ /* eslint-disable max-depth, max-len */ import fs from 'fs/promises'; import { getRollupConfigForApis, getRollupConfigForScriptResources, getRollupConfigForSsr } from '../config/rollup.config.js'; +import { getAppTemplate, getPageTemplate, getUserScripts } from '../lib/templating-utils.js'; import { hashString } from '../lib/hashing-utils.js'; import { checkResourceExists, mergeResponse, normalizePathnameForWindows } from '../lib/resource-utils.js'; import path from 'path'; @@ -11,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 { @@ -45,10 +44,10 @@ 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); + const outputDirUrl = new URL(`.${outputPath.replace('index.html', '').replace('404.html', '')}`, outputDir); const url = new URL(`http://localhost:${compilation.config.port}${route}`); const contents = await fs.readFile(new URL(`./${outputPath}`, scratchDir), 'utf-8'); const headers = new Headers({ 'Content-Type': 'text/html' }); @@ -71,7 +70,7 @@ async function optimizeStaticPages(compilation, plugins) { // clean up optimization markers const body = (await response.text()).replace(/data-gwd-opt=".*[a-z]"/g, ''); - await fs.writeFile(new URL(`./${outputPath}`, outputDir), body); + await fs.writeFile(new URL(`.${outputPath}`, outputDir), body); }) ); } @@ -174,108 +173,69 @@ async function bundleApiRoutes(compilation) { async function bundleSsrPages(compilation) { // https://rollupjs.org/guide/en/#differences-to-the-javascript-api - const { outputDir, pagesDir } = compilation.context; // 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) => { // return plugin.provider(compilation); // }); - + const hasSSRPages = compilation.graph.filter(page => page.isSSR).length > 0; const input = []; - if (!compilation.config.prerender) { - for (const page of compilation.graph) { - if (page.isSSR && !page.data.static) { - const { filename, path: pagePath } = page; - const scratchUrl = new URL(`./${filename}`, outputDir); - - // better way to write out inline code like this? - await fs.writeFile(scratchUrl, ` - import { Worker } from 'worker_threads'; - import { getAppTemplate, getPageTemplate, getUserScripts } from '@greenwood/cli/src/lib/templating-utils.js'; - - export async function handler(request, compilation) { - const routeModuleLocationUrl = new URL('./_${filename}', '${outputDir}'); - const routeWorkerUrl = '${compilation.config.plugins.find(plugin => plugin.type === 'renderer').provider().workerUrl}'; - const htmlOptimizer = compilation.config.plugins.find(plugin => plugin.name === 'plugin-standard-html').provider(compilation); - let body = ''; - let html = ''; - let frontmatter; - let template; - let templateType = 'page'; - let title = ''; - let imports = []; - - await new Promise((resolve, reject) => { - const worker = new Worker(new URL(routeWorkerUrl)); - - worker.on('message', (result) => { - if (result.body) { - body = result.body; - } - - if (result.template) { - template = result.template; - } - - if (result.frontmatter) { - frontmatter = result.frontmatter; - - if (frontmatter.title) { - title = frontmatter.title; - } - - if (frontmatter.template) { - templateType = frontmatter.template; - } - - if (frontmatter.imports) { - imports = imports.concat(frontmatter.imports); - } - } - - resolve(); - }); - - worker.on('error', reject); - worker.on('exit', (code) => { - if (code !== 0) { - reject(new Error(\`Worker stopped with exit code \${code}\`)); - } - }); - - worker.postMessage({ - moduleUrl: routeModuleLocationUrl.href, - compilation: \`${JSON.stringify(compilation)}\`, - route: '${pagePath}' - }); - }); + if (!compilation.config.prerender && hasSSRPages) { + const htmlOptimizer = compilation.config.plugins.find(plugin => plugin.name === 'plugin-standard-html').provider(compilation); + const { executeModuleUrl } = compilation.config.plugins.find(plugin => plugin.type === 'renderer').provider(); + const { executeRouteModule } = await import(executeModuleUrl); + const { pagesDir, scratchDir } = compilation.context; - html = template ? template : await getPageTemplate('', compilation.context, templateType, []); - html = await getAppTemplate(html, compilation.context, imports, [], false, title); - html = await getUserScripts(html, compilation.context); - html = html.replace(\/\