diff --git a/demo/package.json b/demo/package.json index 3c6863784..ca3ec58f4 100644 --- a/demo/package.json +++ b/demo/package.json @@ -1,5 +1,6 @@ { + "type": "module", "alias": { "react": "preact/compat" } -} +} \ No newline at end of file diff --git a/demo/plugins/ssr/index.js b/demo/plugins/ssr/index.js new file mode 100644 index 000000000..c204448ea --- /dev/null +++ b/demo/plugins/ssr/index.js @@ -0,0 +1,14 @@ +import middleware from './middleware.js'; + +export default function plugin(options) { + function configure(config) { + if (!config || config.mode === 'start') { + config.middleware.push(middleware(config, options)); + } + return config; + } + if (options.cwd) { + return configure(options); + } + return configure; +} diff --git a/demo/plugins/ssr/loader.js b/demo/plugins/ssr/loader.js new file mode 100644 index 000000000..1559786bf --- /dev/null +++ b/demo/plugins/ssr/loader.js @@ -0,0 +1,227 @@ +import { builtinModules } from 'module'; +import { join } from 'path'; +import { statSync, readFileSync } from 'fs'; +import { URL, pathToFileURL, fileURLToPath } from 'url'; +import { get as getHttp } from 'http'; +import { get as getHttps } from 'https'; + +/*global globalThis*/ + +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.WMRSSR_HOST || `http://0.0.0.0:${process.env.PORT || 8080}`; +globalThis.baseURL = baseURL; + +/** Tracks the module graph as imports are resolved */ +const GRAPH = { + modules: new Map(), + pendingModules: 0, + values() { + return this.modules.values(); + }, + hasModule(url) { + return this.modules.has(url); + }, + getModule(url, type) { + let mod = this.modules.get(url); + if (mod) return mod; + mod = { + type: type || 'script', + url, + imports: [], + dynamicImports: [], + completed: false + }; + this.modules.set(url, mod); + return mod; + }, + addDependency(url, type, importer) { + this.getModule(url, type); + const parent = this.getModule(importer); + const group = parent.completed ? parent.dynamicImports : parent.imports; + if (!group.includes(url)) group.push(url); + }, + resolveStaticDependencies(resources) { + const seen = new Set(); + for (const entry of resources) seen.add(entry.url); + for (const entry of resources) { + const meta = this.modules.get(entry.url); + if (!meta) continue; + for (const dep of meta.imports) { + const depMeta = this.modules.get(dep); + if (seen.has(dep)) continue; + seen.add(dep); + resources.push(depMeta); + } + } + } +}; + +// gross: expose module graph data for use in the injected scripts +globalThis._GRAPH = GRAPH; + +/** + * To keep things a little cleaner, specifier URLs are relative to the WMR host. + * http://localhost:8080/foo.js --> /foo.js + */ +function relativizeUrlSpecifier(url) { + if (url.startsWith(baseURL)) return url.slice(baseURL.length); + return url; +} + +const isBuiltIn = specifier => + specifier.startsWith('node:') || specifier.startsWith('nodejs:') || builtinModules.includes(specifier); + +// Node 15 switched from `nodejs:fs` to `node:fs` as a scheme for for built-in modules, so we detect it. +// @ts-ignore-next +const prefix = import('node:fs') + .then(() => 'node:') + .catch(() => 'nodejs:'); + +const HTTP_CACHE = new Map(); + +// We track the entry module (and any hot updates to it) in order to inject HMR code: +let isFirstResolve = true; +let firstUrl; + +export function getGlobalPreloadCode() { + return readFileSync(fileURLToPath(new URL('./ssr-environment.js', import.meta.url).href), 'utf-8'); +} + +export async function resolve(specifier, context, defaultResolve) { + // Exempt built-in modules from custom resolution: + const pfx = await prefix; + if (specifier.startsWith('/@node/')) { + return { url: pfx + specifier.slice(7) }; + } + if (specifier.startsWith('/@npm/') && isBuiltIn(specifier.slice(6))) { + return { url: pfx + specifier.slice(6) }; + } + + // Use the default strategy for data: URLs + if (specifier.startsWith('data:')) return { url: specifier }; + + // Strip any cwd from (entry) module filename: + if (specifier.startsWith(root)) specifier = specifier.slice(root.length); + + const url = new URL(specifier, context.parentURL || baseURL).href; + const relativeUrl = relativizeUrlSpecifier(url).replace(/\?t=\d+/g, ''); + const parentUrl = relativizeUrlSpecifier(context.parentURL || baseURL).replace(/\?t=\d+/g, ''); + + // Track in-flight resolves + if (!GRAPH.hasModule(relativeUrl)) GRAPH.pendingModules++; + // Register module and parent->child connection in the graph + GRAPH.addDependency(relativeUrl, 'script', parentUrl); + // Associate this module with the current SSR pass + if (globalThis.wmrssr) globalThis.wmrssr.collect('script', relativeUrl); + + // Resolve the module over HTTP: + const res = await fetch(url); + const resolvedUrl = res.url || url; + HTTP_CACHE.set(resolvedUrl, res); + return { url: resolvedUrl }; +} + +export function getFormat(url, context, defaultGetFormat) { + if (isBuiltIn(url)) return { format: 'builtin' }; + + // In our world, everything is a module: + return { format: 'module' }; +} + +export async function getSource(url, context, defaultGetSource) { + if (isBuiltIn(url)) { + return defaultGetSource(url, context, defaultGetSource); + } + + if (url.startsWith('data:')) { + const i = url.indexOf(','); + let source = url.substring(i + 1); + if (/;\s*base64$/.test(url.substring(0, i))) { + source = Buffer.from(source, 'base64').toString('utf-8'); + } + return { source }; + } + + GRAPH.pendingModules--; + + const spec = relativizeUrlSpecifier(url); + + // If we're loading source code for a module, that means we're done scanning its parent module's imports. + // We use this to mark the parent as `completed`, which causes future imports to be considered dynamicImports. + for (const m of GRAPH.values()) { + if (m.imports.includes(spec)) { + m.completed = true; + // console.log(`Marking ${m.url.replace(baseURL, '')} as complete because a child is being loaded`); + } + } + + // We've generally already fetched modules at this point as part of resolution. + const res = HTTP_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); + + // Inject SSR variant of the `style(url, id)` helper into the WMR runtime: + if (new URL(url).pathname === '/_wmr.js') { + // This is a little funky, but bear with me. + // Instead of injecting imported stylesheets, we register them in the module graph. + // The sheet's parent/importer module is obtained by inspecting the call stack from style(), + // which is always the parent module's program body (`import{style}from'/_wmr.js';style("x.css")`). + source += ` + style = function(url, id) { + const line = new Error().stack.split('\\n')[2]; + const index = line.indexOf(${JSON.stringify(baseURL)}); + if (index !== -1) { + const p = line.substring(index + ${baseURL.length}).replace(/\\:\\d+\\:\\d+$/g, ''); + globalThis._GRAPH.addDependency(url, 'style', p); + } + globalThis.wmrssr.collect('style', url, id); + }; + `; + } + + // Inject HMR into the entry module (any any replacement hot updates of that module): + if (isFirstResolve) firstUrl = url; + isFirstResolve = false; + if (url.replace(/\?t=\d+/g, '') === firstUrl) { + source += ` + import { createHotContext as $$$cc } from '/_wmr.js'; + (hot => { + hot.prepare(() => globalThis.wmrssr.setMod()); + hot.accept(({ module }) => globalThis.wmrssr.setMod(module)); + import(import.meta.url).then(m => globalThis.wmrssr.setMod(m)); + })($$$cc(import.meta.url)); + `; + } + + return { source }; +} + +// Helpers + +function fetch(url) { + return 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 || 0) < 400, + status: res.statusCode, + text: () => text + }); + }).once('error', reject); + }); +} diff --git a/demo/plugins/ssr/middleware.js b/demo/plugins/ssr/middleware.js new file mode 100644 index 000000000..0e41574bc --- /dev/null +++ b/demo/plugins/ssr/middleware.js @@ -0,0 +1,148 @@ +import { join } from 'path'; +import { fileURLToPath } from 'url'; +import { fork } from 'child_process'; + +/** + * @param {{ cwd: string, root?: string, port?: number, http2?: boolean }} options + * @param {{ }} [config] + * @returns {(req: Pick, res: import('http').ServerResponse, next:Function) => void} + */ +export default function ssrMiddleware(options, config) { + const RPC_METHODS = { + setHeader(requestId, name, value) { + const res = responses.get(requestId); + if (!res || res.headersSent) return; + if (typeof name === 'object') { + for (let i in name) { + res.setHeader(i, name[i]); + } + } else { + res.setHeader(name, value); + } + }, + flush(requestId) { + const res = responses.get(requestId); + if (!res || res.headersSent || !res.flush) return; + res.writeHead(200); + setTimeout(res.flush, 1); + }, + _unknown(fn, args) { + console.log('[SSR] Unknown RPC host method: ' + fn + '(', ...args, ')'); + } + }; + + // Slightly defer initialization to ensure `options.port` reflects automatic port selection. + function init() { + if (worker) return; + let { port, cwd, ssr, http2 } = options; + port = port || Number(process.env.PORT) || 8080; + worker = createWorker({ + entry: join(cwd, typeof ssr === 'string' ? ssr : 'ssr.js'), + baseURL: `http${http2 ? 's' : ''}://0.0.0.0:${port}`, + http2, + methods: RPC_METHODS + }); + } + setTimeout(init, 50); + + let worker; + let requestIdCounter = 0; + const responses = new Map(); + + return async (req, res, next) => { + // only handle navigation requests + if (!/text\/html/.test(req.headers.accept + '') || /(\?asset|\.[a-z]+$|^\/@npm)/.test(req.url + '')) { + return next(); + } + + // ensure the worker is initialized + init(); + + const requestId = ++requestIdCounter; + responses.set(requestId, res); + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.setHeader('X-Content-Type-Options', 'nosniff'); + + try { + let result = await worker.rpc(['ssr', 'default'], { + requestId, + url: req.url, + path: req.path, + headers: req.headers + }); + if (typeof result === 'string') { + result = { html: result }; + } + if (!res.headersSent) { + res.writeHead(result.status || 200, result.headers); + } + res.end(result.html); + } catch (e) { + next(new Error(`SSR(${req.url}) Error: ${e}`)); + } finally { + responses.delete(requestId); + } + }; +} + +function createWorker({ entry, baseURL, http2, methods = {} }) { + let rpcCounter = 0; + const p = new Map(); + const ready = deferred(); + + const proc = fork(entry, [], { + stdio: ['ipc', 'inherit', 'pipe'], + execArgv: [ + // force ESM to be enabled in Node 12 + '--experimental-modules', + // enable the Loader API in Node 12+ + '--experimental-loader', + fileURLToPath(new URL('./loader.js', import.meta.url).href) + ], + env: { + WMRSSR_HOST: baseURL, + NODE_TLS_REJECT_UNAUTHORIZED: http2 ? '0' : undefined + } + }); + // @ts-ignore-next + proc.stderr.on('data', m => { + if (!/^\(node:\d+\) ExperimentalWarning:/.test(m.toString('utf-8'))) process.stderr.write(m); + }); + proc.on('error', console.error); + proc.once('exit', process.exit); + proc.on('error', ready.reject); + proc.on('message', data => { + if (data === 'init') return ready.resolve(); + if (!Array.isArray(data)) return console.log('unknown message: ', data); + const [id, fn, ...args] = data; + if (fn === '$resolve$') return p.get(id).resolve(args[0]); + if (fn === '$reject$') return p.get(id).reject(args[0]); + const ret = Promise.resolve().then(() => (methods[fn] || methods._unknown)(...args)); + if (typeof id === 'number' && id > 0) { + ret.then( + ret => proc.send([id, '$resolve$', ret]), + err => proc.send([id, '$reject$', String(err)]) + ); + } + }); + + async function rpc(fn, ...args) { + await ready.promise; + const id = ++rpcCounter; + const controller = deferred(); + p.set(id, controller); + proc.send([id, fn, ...args]); + return controller.promise; + } + + return { proc, rpc, methods }; +} + +function deferred() { + const deferred = {}; + deferred.promise = new Promise((resolve, reject) => { + deferred.resolve = resolve; + deferred.reject = reject; + }); + return deferred; +} diff --git a/demo/plugins/ssr/package.json b/demo/plugins/ssr/package.json new file mode 100644 index 000000000..ddda8b61e --- /dev/null +++ b/demo/plugins/ssr/package.json @@ -0,0 +1,5 @@ +{ + "type": "module", + "main": "./index.js", + "exports": "./index.js" +} \ No newline at end of file diff --git a/demo/plugins/ssr/ssr-environment.js b/demo/plugins/ssr/ssr-environment.js new file mode 100644 index 000000000..8c1baa2a0 --- /dev/null +++ b/demo/plugins/ssr/ssr-environment.js @@ -0,0 +1,212 @@ +/*global globalThis,getBuiltin*/ + +const baseURL = globalThis.baseURL; +const GRAPH = globalThis._GRAPH; + +// @ts-ignore-next +const require = getBuiltin('module').createRequire(process.cwd()); + +globalThis.WebSocket = require('ws'); + +// @ts-ignore-next +globalThis.self = globalThis; + +// @ts-ignore-next +globalThis.location = { + reload() { + console.warn('Skipping location.reload()'); + } +}; + +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); + +// used to put injects prior to initial ssr pass into globalInjects +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(); + +const REQ = Symbol('requestId'); +class Response { + constructor(requestId) { + this[REQ] = requestId; + } + setHeader(name, value) { + if (process.send) process.send([-1, 'setHeader', this[REQ], name, value]); + } + flush() { + if (process.send) process.send([-1, 'flush', this[REQ]]); + } +} + +// called before SSR starts +function prepare(opts) { + opts.res = new Response(opts.requestId); + started = true; + setLocation(opts.url); + if (injects !== globalInjects) { + injects.clear(); + } + injects = new Map(); +} + +// called after SSR finishes +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()]; + const ui = urlInjects.get(url); + const resourceUrls = resources.map(r => r.url); + if (ui) + for (const m of ui.values()) { + if (!resourceUrls.includes(m.url)) { + resources.push(m); + } + } + injects.clear(); + injects = globalInjects; + // const before = resources.map(r => r.url); + + // Mark all current modules in the graph as being completed (future imports are considered dynamic) + for (const v of GRAPH.values()) v.completed = true; + + GRAPH.resolveStaticDependencies(resources); + + // 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 += ``; + } + 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; + + return result; +} + +const has = (s, n) => s === n || (Array.isArray(s) && s.includes(n)); + +let timer; +globalThis.wmrssr = { + cleanup() {}, + collect(type, url, id) { + url = url.replace(/\?t=\d+/g, ''); + const key = type + ':' + url; + const inject = { type, url, id }; + if (started) injects.set(key, inject); + else globalInjects.set(key, inject); + }, + before(method, args) { + if (has(method, 'ssr')) prepare(args[0]); + }, + commitSync(method, args, result) { + if (!has(method, 'ssr')) return; + // If SSR is taking a while but the module graph has settled, early-flush preload Links. + timer = setTimeout(() => { + if (GRAPH.pendingModules) return; + const { url, res } = args[0]; + const resources = Array.from(injects.values()); + const ui = urlInjects.get(url); + if (ui) for (const v of ui) if (!injects.has(v.url)) resources.push(v); + GRAPH.resolveStaticDependencies(resources); + res.setHeader('Link', resources.map(i => `<${i.url}>;rel=preload;as=${i.type};crossorigin`).join(', ')); + res.flush(); + }, 500); + }, + after(method, args, result) { + if (has(method, 'ssr')) { + clearTimeout(timer); + const r = finish(args[0], result[1] === '$reject$' ? Error(result[2]) : result[2]); + if (r != null) result[2] = r; + } + }, + setMod(m) { + if (m) { + if ('resolve' in entryModule) entryModule.resolve(m); + entryModule = m; + } else if (!('resolve' in entryModule) || 'value' in entryModule) { + entryModule = deferred(); + } + } +}; + +let entryModule = deferred(); + +function deferred() { + let resolve; + /** @type {Promise & { resolve?(): void, value? }} */ + const p = new Promise(r => (resolve = r)); + p.resolve = v => resolve((p.value = v)); + return p; +} + +entryModule.then(() => { + if (process.send) process.send('init'); +}); + +process.on('message', async ([id, method, ...args]) => { + let m, fn; + if (/^WMRSSR:/.test(method)) { + fn = globalThis.wmrssr[method.slice(7)]; + } else { + // wait for any in-flight hot reload: + m = await entryModule; + if (Array.isArray(method)) { + for (let name of method) { + if (name in m) { + fn = m[name]; + break; + } + } + } else { + fn = m[method]; + } + } + + await globalThis.wmrssr.before(method, args); + globalThis.wmrssr.commitSync(method, args); + const result = [id, '', null]; + try { + const r = fn(...args); + result[2] = await r; + result[1] = '$resolve$'; + } catch (e) { + result[2] = String((e && e.stack) || e.message || e); + result[1] = '$reject$'; + } finally { + await globalThis.wmrssr.after(method, args, result); + if (process.send) process.send(result); + } +}); diff --git a/demo/public/index.tsx b/demo/public/index.tsx index 08d5380fb..9f20f6075 100644 --- a/demo/public/index.tsx +++ b/demo/public/index.tsx @@ -1,4 +1,4 @@ -import { h, render } from 'preact'; +import { hydrate, render } from 'preact'; import { Loc, Router } from './lib/loc.js'; import lazy, { ErrorBoundary } from './lib/lazy.js'; import Home from './pages/home.js'; @@ -35,13 +35,17 @@ export function App() { } if (typeof window !== 'undefined') { - render(, document.body); + if (document.querySelector('.app')) { + hydrate(, document.body); + } else { + render(, document.body); + } + + // @ts-ignore + if (module.hot) module.hot.accept(u => render(, document.body)); } export async function prerender(data) { const { prerender } = await import('./lib/prerender.js'); return await prerender(); } - -// @ts-ignore -if (module.hot) module.hot.accept(u => render(, document.body)); diff --git a/demo/public/lib/lazy.js b/demo/public/lib/lazy.js index ae254f7e5..93f38ea0d 100644 --- a/demo/public/lib/lazy.js +++ b/demo/public/lib/lazy.js @@ -19,5 +19,6 @@ export function ErrorBoundary(props) { } function absorb(err) { if (err && err.then) this.__d = true; + // @ts-ignore-next else if (this.props.onError) this.props.onError(err); } diff --git a/demo/public/ssr.js b/demo/public/ssr.js new file mode 100644 index 000000000..92bcfc082 --- /dev/null +++ b/demo/public/ssr.js @@ -0,0 +1,17 @@ +import { promises as fs } from 'fs'; + +export async function ssr(data) { + let tpl = await fs.readFile('./public/index.html', 'utf-8'); + // The first script in the file is assumed to have a .prerender export: + let script = (tpl.match(/]*?)?\s+src=(['"]?)([^>]*?)\1(?:\s[^>]*?)?>/) || [])[2]; + if (!script) throw Error(`Unable to detect