From c72041db8df3609fb68c487176ada5cb83bcbf35 Mon Sep 17 00:00:00 2001 From: Jason Miller Date: Fri, 23 Oct 2020 14:54:15 -0400 Subject: [PATCH 01/10] experimental: SSR (1) --- _ssr.js | 212 ++++++++++++++++++++++++++++++ demo/public/index.tsx | 4 +- demo/public/prerender.js | 8 ++ src/plugins/npm-plugin/resolve.js | 33 ++++- 4 files changed, 249 insertions(+), 8 deletions(-) create mode 100644 _ssr.js create mode 100644 demo/public/prerender.js diff --git a/_ssr.js b/_ssr.js new file mode 100644 index 000000000..c1f9e1be2 --- /dev/null +++ b/_ssr.js @@ -0,0 +1,212 @@ +import { join } from 'path'; +import { statSync } from 'fs'; +import { URL, pathToFileURL, fileURLToPath } from 'url'; +import { get as getHttp } from 'http'; +import { get as getHttps } from 'https'; +import { fork } from 'child_process'; +// import { Worker } from 'worker_threads'; + +const cwd = pathToFileURL(`${process.cwd()}/`).href; +let root = cwd; +try { + if (statSync(join(process.cwd(), 'public')).isDirectory()) { + root = pathToFileURL(`${process.cwd()}/public`).href; + } +} catch (e) {} + +let baseURL = process.env.URL || `http://0.0.0.0:${process.env.PORT || 8080}`; + +let ssr, proc, booted; + +process.on('beforeExit', () => { + try { + proc && proc.kill(); + // ssr && ssr.terminate(); + } catch (e) {} + try { + // ssr && ssr.kill(); + ssr && ssr.terminate(); + } catch (e) {} +}); + +if (process.env.WMRSSR_HOST) { + baseURL = process.env.WMRSSR_HOST; +} else { + proc = fork(fileURLToPath(new URL(`./src/cli.js`, import.meta.url)), [], { + stdio: 'pipe' + }); + proc.stderr.on('data', data => { + process.stderr.write(data); + }); + booted = new Promise(resolve => { + proc.stdout.on('data', data => { + process.stdout.write(data); + const m = String(data).match(/Listening on (https?:\/\/[^ ]+)/); + if (!m) return; + baseURL = m[1]; + resolve(); + }); + }); + + booted.then(() => { + if (!process.argv[2]) { + process.stderr.write(`No file specified:\n wmr ssr path/to/file.js\n`); + return process.exit(1); + } + // console.log({ + // file: process.argv[2], + // path: fileURLToPath(new URL('./' + process.argv[2], root)), + // root, + // cwd + // }); + + // import(process.argv[2]); + ssr = fork(fileURLToPath(new URL('./' + process.argv[2], root)), [], { + stdio: 'inherit', + execArgv: ['--experimental-modules', '--experimental-loader', import.meta.url], + env: { + WMRSSR_HOST: baseURL + } + }); + ssr.on('error', console.error); + ssr.once('exit', process.exit); + + let c = 0; + ssr.on('message', data => { + console.log('parent message: ', data); + if (data === 'init') { + ssr.send([++c, { url: '/' }]); + } else { + console.log(data); + } + }); + + // const workerCode = ` + // import("${fileURLToPath(new URL('./' + process.argv[2], root))}").then(m => { + // console.log(m); + // }); + // `; + // ssr = new Worker(`data:text/javascript,${encodeURIComponent(workerCode)}`, { + // eval: true, + // execArgv: ['--experimental-modules', '--experimental-loader', import.meta.url], + // env: { + // WMRSSR_HOST: baseURL, + // NODE_OPTIONS: `--experimental-modules --experimental-loader=${JSON.stringify(import.meta.url)}` + // } + // }); + // ssr.on('error', console.error); + // ssr.once('exit', code => { + // console.log('worker exited: ', code); + // process.exit(code); + // }); + }); +} + +const fetch = url => + new Promise((resolve, reject) => { + (url.startsWith('https://') ? getHttps : getHttp)(url, res => { + const text = new Promise(r => { + let text = ''; + res.on('data', chunk => { + text += chunk; + }); + res.once('end', () => r(text)); + }); + resolve({ + url: res.url, + ok: res.statusCode < 400, + status: res.statusCode, + text: () => text + }); + }).once('error', reject); + }); + +const CACHE = new Map(); + +console.log(process.env.WMRSSR_HOST, process.argv); + +// console.log(root); + +export function getGlobalPreloadCode() { + const loc = new URL(baseURL); + let location = {}; + for (let i in loc) { + try { + if (typeof loc[i] === 'string') { + location[i] = String(loc[i]); + } + } catch (e) {} + } + return ` + const require = getBuiltin('module').createRequire(process.cwd()); + globalThis.WebSocket = require('ws'); + globalThis.self = globalThis; + globalThis.location = ${JSON.stringify(location)}; + `; +} + +let first = true; +export async function resolve(specifier, context, defaultResolve) { + // console.log(specifier, process.argv[1]); + if (specifier.startsWith(root)) { + // console.log(specifier, specifier.slice(root.length)); + specifier = specifier.slice(root.length); + } + const url = new URL(specifier, context.parentURL || baseURL).href; + // console.log('RESOLVE', specifier, context.parentURL, url); + const res = await fetch(url); + const resolvedUrl = res.url || url; + CACHE.set(resolvedUrl, res); + return { url: resolvedUrl }; + // return { url }; + // return defaultResolve(specifier, context, defaultResolve); +} + +export function getFormat(url, context, defaultGetFormat) { + // console.log('GET FORMAT', url); + return { + format: 'module' + }; + // return defaultGetFormat(url, context, defaultGetFormat); +} + +export async function getSource(url, context, defaultGetSource) { + // console.log('GET SOURCE', url); + const spec = url.replace(baseURL, ''); + // const res = await fetch(url); + const res = CACHE.get(url) || (await fetch(url)); + let source = await res.text(); + if (res.status === 404) throw Error(`Module ${spec} not found`); + if (!res.ok) throw Error(spec + ': ' + res.status + '\n' + source); + if (first) { + // console.log('first', globalThis.location); + first = false; + source += ` + import { createHotContext as $$$cc } from '/_wmr.js'; + (function() { + const hot = $$$cc(import.meta.url); + let ssr; + process.send('init'); + process.on('message', async ([id, data]) => { + let s = await ssr; + console.log("got message", id, data); + try { + process.send([id, 1, await s(data)]); + } catch (e) { + process.send([id, 0, String(e)]); + } + }); + function reload() { + ssr = import(import.meta.url).then(m => { + ssr = m.ssr || m.default; + return ssr; + }); + } + hot.accept(reload); + reload(); + })(); + `; + } + return { source }; + // return defaultGetSource(url, context, defaultGetSource); +} diff --git a/demo/public/index.tsx b/demo/public/index.tsx index 9c6165d76..a625dd676 100644 --- a/demo/public/index.tsx +++ b/demo/public/index.tsx @@ -32,7 +32,9 @@ export function App() { ); } -render(, document.body); +if (typeof document !== 'undefined') { + render(, document.body); +} // @ts-ignore if (module.hot) module.hot.accept(u => render(, document.body)); diff --git a/demo/public/prerender.js b/demo/public/prerender.js new file mode 100644 index 000000000..3923e1e73 --- /dev/null +++ b/demo/public/prerender.js @@ -0,0 +1,8 @@ +import { App } from './index.tsx'; +import renderToString from 'preact-render-to-string'; + +export default function ssr({ url }) { + return renderToString(); +} + +// console.log('SSR: ', ssr()); diff --git a/src/plugins/npm-plugin/resolve.js b/src/plugins/npm-plugin/resolve.js index 8ba097654..0385c3202 100644 --- a/src/plugins/npm-plugin/resolve.js +++ b/src/plugins/npm-plugin/resolve.js @@ -93,20 +93,39 @@ function resolveExportMap(exp, entry, envKeys) { } let isFileListing; let isDirectoryExposed = false; - for (let i in exp) { - if (isFileListing === undefined) isFileListing = i[0] === '.'; - if (isFileListing) { - // {"exports":{".":"./index.js"}} + const keys = Object.keys(exp); + if (keys.length === 0) return false; + isFileListing = keys[0][0] === '.'; + if (isFileListing) { + for (const i of keys) { if (i === entry) { return resolveExportMap(exp[i], entry, envKeys); } if (!isDirectoryExposed && i.endsWith('/') && entry.startsWith(i)) { isDirectoryExposed = true; } - } else if (envKeys.includes(i)) { - // {"exports":{"import":"./foo.js"}} - return resolveExportMap(exp[i], entry, envKeys); + } + } else { + for (let i of envKeys) { + if (exp.hasOwnProperty(i)) { + return resolveExportMap(exp[i], entry, envKeys); + } } } + // for (let i in exp) { + // if (isFileListing === undefined) isFileListing = i[0] === '.'; + // if (isFileListing) { + // // {"exports":{".":"./index.js"}} + // if (i === entry) { + // return resolveExportMap(exp[i], entry, envKeys); + // } + // if (!isDirectoryExposed && i.endsWith('/') && entry.startsWith(i)) { + // isDirectoryExposed = true; + // } + // } else if (envKeys.includes(i)) { + // // {"exports":{"import":"./foo.js"}} + // return resolveExportMap(exp[i], entry, envKeys); + // } + // } return isDirectoryExposed; } From ffc14f54d0c2b65475ee6ff53b25761b036188e8 Mon Sep 17 00:00:00 2001 From: Jason Miller Date: Sat, 24 Oct 2020 17:32:18 -0400 Subject: [PATCH 02/10] working ssr as a middleware --- _ssr.js | 324 +++++++++++++++++++++++++++++++++----- demo/public/index.tsx | 24 +-- demo/public/lazy.js | 22 ++- demo/public/prerender.js | 8 - demo/public/ssr.js | 50 ++++++ package-lock.json | 98 ++++++------ src/lib/polkompress.js | 4 + src/lib/ssr-middleware.js | 120 ++++++++++++++ src/plugins/wmr/client.js | 14 +- src/start.js | 5 + 10 files changed, 559 insertions(+), 110 deletions(-) delete mode 100644 demo/public/prerender.js create mode 100644 demo/public/ssr.js create mode 100644 src/lib/ssr-middleware.js diff --git a/_ssr.js b/_ssr.js index c1f9e1be2..a644c7d2a 100644 --- a/_ssr.js +++ b/_ssr.js @@ -1,9 +1,11 @@ +import { builtinModules } from 'module'; import { join } from 'path'; import { statSync } from 'fs'; import { URL, pathToFileURL, fileURLToPath } from 'url'; import { get as getHttp } from 'http'; import { get as getHttps } from 'https'; import { fork } from 'child_process'; +// import { setupMaster, fork } from 'cluster'; // import { Worker } from 'worker_threads'; const cwd = pathToFileURL(`${process.cwd()}/`).href; @@ -32,6 +34,10 @@ process.on('beforeExit', () => { if (process.env.WMRSSR_HOST) { baseURL = process.env.WMRSSR_HOST; } else { + setupMaster({ + exec: fileURLToPath(new URL(`./src/cli.js`, import.meta.url)) + }); + proc = fork(); proc = fork(fileURLToPath(new URL(`./src/cli.js`, import.meta.url)), [], { stdio: 'pipe' }); @@ -53,12 +59,6 @@ if (process.env.WMRSSR_HOST) { process.stderr.write(`No file specified:\n wmr ssr path/to/file.js\n`); return process.exit(1); } - // console.log({ - // file: process.argv[2], - // path: fileURLToPath(new URL('./' + process.argv[2], root)), - // root, - // cwd - // }); // import(process.argv[2]); ssr = fork(fileURLToPath(new URL('./' + process.argv[2], root)), [], { @@ -72,14 +72,37 @@ if (process.env.WMRSSR_HOST) { ssr.once('exit', process.exit); let c = 0; + const p = new Map(); + function deferred() { + const deferred = {}; + deferred.promise = new Promise((resolve, reject) => { + deferred.resolve = resolve; + deferred.reject = reject; + }); + return deferred; + } + const ready = deferred(); + ssr.rpc = (fn, ...args) => + ready.promise.then(() => { + const id = ++c; + const controller = deferred(); + p.set(id, controller); + ssr.send([id, fn, ...args]); + return controller.promise; + }); ssr.on('message', data => { - console.log('parent message: ', data); + // console.log('parent message: ', data); if (data === 'init') { - ssr.send([++c, { url: '/' }]); - } else { - console.log(data); - } + // ssr.send([++c, { url: '/' }]); + ready.resolve(); + } else if (!Array.isArray(data)) return console.log('unknown message: ', data); + // console.log(data); + const [id, fn, ...args] = data; + if (fn === '$resolve$') p.get(id).resolve(args[0]); + else if (fn === '$reject$') p.get(id).reject(args[0]); + else console.log('missed RPC: ', fn, '(', ...args, ') [', id, ']'); }); + ssr.on('error', ready.reject); // const workerCode = ` // import("${fileURLToPath(new URL('./' + process.argv[2], root))}").then(m => { @@ -123,39 +146,210 @@ const fetch = url => const CACHE = new Map(); -console.log(process.env.WMRSSR_HOST, process.argv); - -// console.log(root); - export function getGlobalPreloadCode() { - const loc = new URL(baseURL); - let location = {}; - for (let i in loc) { - try { - if (typeof loc[i] === 'string') { - location[i] = String(loc[i]); - } - } catch (e) {} - } return ` const require = getBuiltin('module').createRequire(process.cwd()); globalThis.WebSocket = require('ws'); globalThis.self = globalThis; - globalThis.location = ${JSON.stringify(location)}; + globalThis.location = { + reload() { + console.warn("Skipping location.reload()"); + } + }; + const baseURL = ${JSON.stringify(baseURL)}; + function setLocation(url) { + const loc = new URL(url, baseURL); + for (let i in loc) { + try { + if (typeof loc[i] === 'string') { + globalThis.location[i] = String(loc[i]); + } + } catch (e) {} + } + } + setLocation(baseURL); + + const has = (s, n) => s ===n || Array.isArray(s) && s.includes(n); + + let started = false; + + // scripts and styles imported/injected by the current SSR process + let injects = new Map(); + + // imports that were resolved prior to ssr() being called, so are not necessarily client-side + const globalInjects = new Map(); + + const urlInjects = new Map(); + + function expandModuleGraph(resources, graph) { + const seen = new Set(); + for (const entry of resources) seen.add(entry.url); + for (const entry of resources) { + const meta = graph.get(entry.url); + if (!meta) continue; + for (const dep of meta.imports) { + const depMeta = graph.get(dep); + if (!seen.has(dep)) { + seen.add(dep); + resources.push(depMeta); + } + } + } + } + + function prepare(opts) { + const { requestId, url } = opts; + opts.res = new Response(opts.requestId); + started = true; + setLocation(url); + injects.clear(); + injects = new Map(); + + const ui = urlInjects.get(url); + if (ui && ui.size) { + opts.res.setHeader('Link', Array.from(ui.values()).map(i => \`<\${i.url}>;rel=preload;as=\${i.type};crossorigin\`).join(', ')); + } + + // let preload = [...new Map(urlInjects.get(url)).values()].map(i => \`<\${i.url}>;rel=preload;as=\${i.type};crossorigin\`); + // let preload = [...new Map(urlInjects.get(url)).values()].filter(i=>i.type=='style').map(i => \`<\${i.url}>;rel=preload;as=\${i.type};crossorigin\`); + // preload.push(...[...new Map(urlInjects.get(url)).values()].filter(i=>i.type=='script').map(i => \`<\${i.url}>;rel=modulepreload\`)); + // injects = new Map(urlInjects.get(url)); + // let preload = [...injects.values()].map(i => \`<\${i.url}>;rel=preload;as=\${i.type};crossorigin\`); + // if (preload.length) { + // opts.res.setHeader('Link', preload.join(', ')); + // } + } + const REQ = Symbol('requestId'); + class Response { + constructor(requestId) { + this[REQ] = requestId; + } + setHeader(name, value) { + process.send([-1, 'setHeader', this[REQ], name, value]); + } + flush() { + process.send([-1, 'flush', this[REQ]]); + } + } + function finish({ url, res }, result) { + if (result instanceof Error) return; + + // if this is the first time rendering this URL, store import/inject mappings + if (!urlInjects.get(url)) { + urlInjects.set(url, new Map(injects)); + } + + const resources = [...injects.values()]; + injects.clear(); + const before = resources.map(r => r.url); + + expandModuleGraph(resources, globalThis._GRAPH); + + console.log(' ' + resources.map(x => x.type + ' : ' + x.url + (before.includes(x.url)?'':' (inferred from dep graph)')).join('\\n ')); + + const styles = resources.filter(s => s.type === 'style'); + const scripts = resources.filter(s => s.type === 'script'); + let head = ''; + let body = ''; + for (const style of styles) { + head += \`\`; + } + //process.send([-1, 'setHeader', requestId, 'Link', scripts.map(script => \`<\${script.url}>;rel=preload;as=script;crossorigin\`).join(', ')]); + // for (const script of scripts) { + // head += \`\`; + // body += \`\`; + // } + if (/<\\/head>/i.test(result)) result = result.replace(/(<\\/head>)/i, head + '$1'); + else result = head + result; + if (/<\\/body>/i.test(result)) result = result.replace(/(<\\/body>)/i, body + '$1'); + else result += body; + + result = result.replace(/\`; - // } + // res.setHeader('Link', scripts.map(script => \`<\${script.url}>;rel=preload;as=script;crossorigin\`).join(', ')); + + for (const script of scripts) { + // head += \`\`; + body += \`\`; + } + if (/<\\/head>/i.test(result)) result = result.replace(/(<\\/head>)/i, head + '$1'); else result = head + result; if (/<\\/body>/i.test(result)) result = result.replace(/(<\\/body>)/i, body + '$1'); else result += body; - result = result.replace(/\`; - } - - if (/<\\/head>/i.test(result)) result = result.replace(/(<\\/head>)/i, head + '$1'); - else result = head + result; - if (/<\\/body>/i.test(result)) result = result.replace(/(<\\/body>)/i, body + '$1'); - else result += body; - - // result = result.replace(/`; + } + + if (/<\/head>/i.test(result)) result = result.replace(/(<\/head>)/i, head + '$1'); + else result = head + result; + if (/<\/body>/i.test(result)) result = result.replace(/(<\/body>)/i, body + '$1'); + else result += body; + + // result = result.replace(/