diff --git a/adex/src/plugins/client-builder.js b/adex/src/plugins/client-builder.js new file mode 100644 index 0000000..822e21a --- /dev/null +++ b/adex/src/plugins/client-builder.js @@ -0,0 +1,110 @@ +import { dirname, join } from 'path' +import { readFileSync } from 'fs' +import { mergeConfig, build } from 'vite' +import preact from '@preact/preset-vite' +import { existsSync } from 'fs' +import { rm } from 'fs/promises' + +/** + * Create a plugin for building the client in SSR mode + * + * @param {object} options - Options for client builder + * @param {boolean} [options.ssr=true] - Whether to enable SSR + * @param {boolean} [options.islands=false] - Whether to enable islands architecture + * @returns {import("vite").Plugin} + */ +export function createClientBuilder({ ssr = true, islands = false } = {}) { + let baseUrl = '/' + return { + name: 'adex-client-builder', + config(cfg) { + const out = cfg.build.outDir ?? 'dist' + return { + appType: 'custom', + build: { + write: !islands, + manifest: 'manifest.json', + outDir: join(out, 'client'), + rollupOptions: { + input: 'virtual:adex:client', + }, + output: { + entryFileNames: '[name]-[hash].js', + format: 'esm', + }, + }, + } + }, + configResolved(cfg) { + baseUrl = cfg.base + return + }, + generateBundle(opts, bundle) { + let clientEntryPath + for (const key in bundle) { + if ( + ['_virtual_adex_client', '_app'].includes(bundle[key].name) && + 'isEntry' in bundle[key] && + bundle[key].isEntry + ) { + clientEntryPath = key + } + } + + const links = [ + // @ts-expect-error Vite types don't include viteMetadata but it exists at runtime + ...(bundle[clientEntryPath]?.viteMetadata?.importedCss ?? new Set()), + ].map(d => { + return `` + }) + + if (!ssr) { + this.emitFile({ + type: 'asset', + fileName: 'index.html', + source: ` + + ${links.join('\n')} + + +
+ + + `, + }) + } + }, + } +} + +/** + * Create a build preparation plugin + * + * @param {object} options - Configuration options + * @param {boolean} [options.islands=false] - Whether to enable islands architecture + * @returns {import("vite").Plugin} + */ +export function createBuildPrep({ islands = false }) { + return { + name: 'adex-build-prep', + apply: 'build', + async configResolved(config) { + if (!islands) return + + // Making it order safe + const outDirNormalized = config.build.outDir.endsWith('/server') + ? dirname(config.build.outDir) + : config.build.outDir + + // remove the `client` dir if islands are on, + // we don't generate the client assets and + // their existence adds the client entry into the bundle + const clientDir = join(outDirNormalized, 'client') + if (!existsSync(clientDir)) return + await rm(clientDir, { + recursive: true, + force: true, + }) + }, + } +} diff --git a/adex/src/plugins/dev-server.js b/adex/src/plugins/dev-server.js new file mode 100644 index 0000000..75a2b6f --- /dev/null +++ b/adex/src/plugins/dev-server.js @@ -0,0 +1,99 @@ +import { resolve } from 'path' + +/** + * Create a development server plugin + * + * @param {object} options - Configuration options + * @param {boolean} [options.islands=false] - Whether to enable islands architecture + * @returns {import("vite").Plugin} + */ +export function createDevServer({ islands = false } = {}) { + const devCSSMap = new Map() + let cfg + + return { + name: 'adex-dev-server', + apply: 'serve', + enforce: 'pre', + + config() { + return { + ssr: { + noExternal: ['adex/app'], + }, + } + }, + + configResolved(_cfg) { + cfg = _cfg + }, + + async resolveId(id, importer, meta) { + if (id.endsWith('.css')) { + if (!importer) return + const importerFromRoot = importer.replace(resolve(cfg.root), '') + const resolvedCss = await this.resolve(id, importer, meta) + if (resolvedCss) { + devCSSMap.set( + importerFromRoot, + (devCSSMap.get(importer) ?? []).concat(resolvedCss.id) + ) + } + return + } + }, + + configureServer(server) { + return () => { + server.middlewares.use(async function (req, res, next) { + const module = await server.ssrLoadModule('virtual:adex:handler') + if (!module) { + return next() + } + + try { + const { html, serverHandler, pageRoute } = await module.handler( + req, + res + ) + + if (serverHandler) { + return serverHandler(req, res) + } + + const cssLinks = devCSSMap.get(pageRoute) ?? [] + let renderedHTML = html.replace( + '', + ` + + ${cssLinks.map(d => { + return `` + })} + + ` + ) + + if (!islands) { + renderedHTML = html.replace( + '', + `` + ) + } + + const finalRenderedHTML = await server.transformIndexHtml( + req.url, + renderedHTML + ) + + res.setHeader('content-type', 'text/html') + res.write(finalRenderedHTML) + return res.end() + } catch (err) { + server.ssrFixStacktrace(err) + next(err) + } + }) + } + }, + } +} diff --git a/adex/src/plugins/guard-plugins.js b/adex/src/plugins/guard-plugins.js new file mode 100644 index 0000000..c0db0d2 --- /dev/null +++ b/adex/src/plugins/guard-plugins.js @@ -0,0 +1,65 @@ +import { resolve, dirname, join } from 'path' +import { fileURLToPath } from 'url' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const cwd = process.cwd() + +/** + * Create guard plugins to prevent misuse of environment variables + * + * @returns {import("vite").Plugin[]} + */ +export function createGuardPlugins() { + return [ + { + name: 'adex-guard-env', + enforce: 'pre', + async transform(code, id) { + // ignore usage of `process.env` in node_modules + // Still risky but hard to do anything about + const nodeMods = resolve(cwd, 'node_modules') + if (id.startsWith(nodeMods)) return + + // ignore usage of `process.env` in `adex/env` + const envLoadId = await this.resolve('adex/env') + if (id === envLoadId?.id) return + + if (code.includes('process.env')) { + this.error( + 'Avoid using `process.env` to access environment variables and secrets. Use `adex/env` instead' + ) + } + }, + writeBundle() { + const pagesPath = resolve(cwd, 'src/pages') + const info = this.getModuleInfo('adex/env') + const viteRef = this + + /** + * Check the import tree to ensure env is not used on client-side + * @param {string} importPath - Path of the module to check + * @param {string[]} importStack - Stack of imports leading to this module + */ + function checkTree(importPath, importStack = []) { + if (importPath.startsWith(pagesPath)) { + throw new Error( + `Cannot use/import \`adex/env\` on the client side, importerStack: ${importStack.join(' -> ')}` + ) + } + + // Get all importers of this module and check recursively + const moduleInfo = viteRef.getModuleInfo(importPath) + if (moduleInfo && moduleInfo.importers) { + moduleInfo.importers.forEach(d => + checkTree(d, [...importStack, importPath, d]) + ) + } + } + + if (info) { + info.importers.forEach(i => checkTree(i)) + } + }, + }, + ] +} diff --git a/adex/src/plugins/islands-builder.js b/adex/src/plugins/islands-builder.js new file mode 100644 index 0000000..951094b --- /dev/null +++ b/adex/src/plugins/islands-builder.js @@ -0,0 +1,165 @@ +import { + DEFAULT_TRANSPILED_IDENTIFIERS, + IMPORT_PATH_PLACEHOLDER, + findIslands, + generateClientTemplate, + getIslandName, + getServerTemplatePlaceholder, + injectIslandAST, + isFunctionIsland, + readSourceFile, +} from '@dumbjs/preland' +import { addImportToAST, codeFromAST } from '@dumbjs/preland/ast' +import preact from '@preact/preset-vite' +import { existsSync, mkdirSync, writeFileSync } from 'fs' +import { join } from 'path' +import { build, mergeConfig } from 'vite' + +const cwd = process.cwd() +const islandsDir = join(cwd, '.islands') +let runningIslandBuild = false + +/** + * Create plugins for islands architecture + * + * @returns {import("vite").Plugin[]} + */ +export function createIslandsBuilder() { + const clientVirtuals = {} + let isBuild = false + let outDir + + return [ + { + name: 'adex-islands', + enforce: 'pre', + config(d, e) { + outDir = d.build?.outDir ?? 'dist' + isBuild = e.command === 'build' + }, + transform(code, id, viteEnv) { + if (!/\.(js|ts)x$/.test(id)) return + + // if being imported by the client, don't send + // back the transformed server code, send the + // original component + if (!viteEnv?.ssr) return + + const islands = findIslands(readSourceFile(id), { + isFunctionIsland: node => + isFunctionIsland(node, { + transpiledIdentifiers: + DEFAULT_TRANSPILED_IDENTIFIERS.concat('_jsxDEV'), + }), + }) + if (!islands.length) return + + islands.forEach(node => { + //@ts-expect-error FIX: in preland + injectIslandAST(node.ast, node) + const clientCode = generateClientTemplate(node.id).replace( + IMPORT_PATH_PLACEHOLDER, + id + ) + + mkdirSync(islandsDir, { recursive: true }) + writeFileSync( + join(islandsDir, getIslandName(node.id) + '.js'), + clientCode, + 'utf8' + ) + + clientVirtuals[node.id] = clientCode + }) + + const addImport = addImportToAST(islands[0].ast) + addImport('h', 'preact', { named: true }) + addImport('Fragment', 'preact', { named: true }) + + let serverTemplateCode = codeFromAST(islands[0].ast) + islands.forEach(island => { + serverTemplateCode = serverTemplateCode.replace( + getServerTemplatePlaceholder(island.id), + !isBuild + ? `/virtual:adex:island-${island.id}` + : `/islands/${getIslandName(island.id) + '.js'}` + ) + }) + + return { + code: serverTemplateCode, + } + }, + }, + { + name: 'adex-island-builds', + enforce: 'post', + writeBundle: { + sequential: true, + async handler() { + if (Object.keys(clientVirtuals).length === 0) return + if (runningIslandBuild) return + + runningIslandBuild = true + try { + await build( + mergeConfig( + {}, + { + configFile: false, + plugins: [preact()], + build: { + ssr: false, + outDir: join(outDir, 'islands'), + emptyOutDir: true, + rollupOptions: { + output: { + format: 'esm', + entryFileNames: '[name].js', + }, + input: Object.fromEntries( + Object.entries(clientVirtuals).map(([k, v]) => { + const key = getIslandName(k) + return [key, join(islandsDir, key + '.js')] + }) + ), + }, + }, + } + ) + ) + } finally { + runningIslandBuild = false + } + }, + }, + }, + { + name: 'adex-island-virtuals', + resolveId(id) { + if ( + id.startsWith('virtual:adex:island') || + id.startsWith('/virtual:adex:island') + ) { + return `\0${id}` + } + }, + load(id) { + if ( + (id.startsWith('\0') && id.startsWith('\0virtual:adex:island')) || + id.startsWith('\0/virtual:adex:island') + ) { + const compName = id + .replace('\0', '') + .replace(/\/?(virtual\:adex\:island\-)/, '') + + if (clientVirtuals[compName]) { + return { + code: clientVirtuals[compName], + } + } + } + }, + }, + ] +} diff --git a/adex/src/plugins/preact-pages.js b/adex/src/plugins/preact-pages.js new file mode 100644 index 0000000..f7ecc98 --- /dev/null +++ b/adex/src/plugins/preact-pages.js @@ -0,0 +1,51 @@ +import { join } from 'path' +import { readFile } from 'fs/promises' +import { fileURLToPath } from 'url' +import { dirname } from 'path' + +const __dirname = dirname(fileURLToPath(import.meta.url)) + +/** + * Create a Vite plugin for Preact pages routing + * + * @param {object} options - Configuration options + * @param {string} [options.root='/src/pages'] - The root directory for pages + * @param {string} [options.id='~routes'] - The virtual module ID + * @param {string[]} [options.extensions=['js', 'ts', 'tsx', 'jsx']] - File extensions to include + * @param {string} [options.replacer=''] - String to replace in route paths + * @returns {import("vite").Plugin} + */ +export function preactPages({ + root = '/src/pages', + id: virtualId = '~routes', + extensions = ['js', 'ts', 'tsx', 'jsx'], + replacer = '', +} = {}) { + return { + name: 'adex-routes', + enforce: 'pre', + resolveId(id) { + if (id !== virtualId) { + return + } + return `/0${virtualId}` + }, + async load(id) { + if (id !== `/0${virtualId}`) { + return + } + + const extsString = extensions.join(',') + const code = ( + await readFile(join(__dirname, '../../runtime/pages.js'), 'utf8') + ) + .replaceAll('#{__PLUGIN_PAGES_ROOT}', root + `/**/*.{${extsString}}`) + .replaceAll('#{__PLUGIN_PAGES_ROOT_REGEX}', `^${root}`) + .replaceAll('#{__PLUGIN_PAGES_ROOT_REGEX_REPLACER}', replacer) + + return { + code, + } + }, + } +} diff --git a/adex/src/plugins/server-builder.js b/adex/src/plugins/server-builder.js new file mode 100644 index 0000000..ec9e41d --- /dev/null +++ b/adex/src/plugins/server-builder.js @@ -0,0 +1,132 @@ +import { join, dirname } from 'path' +import { build, mergeConfig } from 'vite' +import { readFileSync } from 'fs' +import preact from '@preact/preset-vite' + +import { + createVirtualModule, + createUserDefaultVirtualModule, +} from '../utils/virtual-modules.js' +import { preactPages } from './preact-pages.js' +import { getServerTemplate } from '../utils/server-template.js' +import { fileURLToPath } from 'url' + +/** + * Create a server builder plugin + * + * @param {object} options - Configuration options + * @param {import("../fonts.js").Options} [options.fonts] - Font options + * @param {string} [options.adapter='node'] - Server adapter + * @param {boolean} [options.islands=false] - Whether to enable islands + * @param {function} [options.islandBuilderFactory] - Factory for island builder plugins + * @param {function} [options.fontsPluginFactory] - Factory for fonts plugin + * @returns {import("vite").Plugin} + */ +export function createServerBuilder({ + fonts, + adapter, + islands, + islandBuilderFactory, + fontsPluginFactory, +}) { + let input = 'src/entry-server.js' + let cfg + + return { + name: `adex-server`, + enforce: 'pre', + apply: 'build', + + config(conf, env) { + if (env.command === 'build') { + input = 'virtual:adex:server' + } + }, + + configResolved(config) { + cfg = config + }, + + async generateBundle() { + const defOut = cfg.build?.outDir ?? 'dist' + const serverOutDir = defOut.endsWith('client') + ? join(dirname(defOut), 'server') + : join(defOut, 'server') + + console.log(`\nBuilding Server: ${serverOutDir}\n`) + + const runtimeFolder = join( + dirname(fileURLToPath(import.meta.url)), + '../../runtime' + ) + + // Filter out vite internal plugins and adex plugins that shouldn't be reused + const sanitizedPlugins = (cfg.plugins ?? []) + .filter(d => d.adexServer === false) + .filter(d => !d.name.startsWith('vite:')) + .filter(d => !d.name.startsWith('adex-')) + + await build({ + configFile: false, + ssr: { + external: ['preact', 'adex', 'preact-render-to-string'], + noExternal: [`adex-adapter-${adapter}`], + }, + resolve: cfg.resolve, + appType: 'custom', + plugins: [ + preact(), + preactPages({ + root: '/src/pages', + id: '~routes', + }), + preactPages({ + root: '/src/api', + id: '~apiRoutes', + replacer: '/api', + }), + createUserDefaultVirtualModule( + 'virtual:adex:global.css', + '', + '/src/global.css' + ), + createVirtualModule( + 'adex/app', + readFileSync(join(runtimeFolder, 'app.js'), 'utf8') + ), + createUserDefaultVirtualModule( + 'virtual:adex:client', + readFileSync(join(runtimeFolder, 'client.js'), 'utf8'), + '/src/_app' + ), + createVirtualModule( + 'virtual:adex:handler', + readFileSync(join(runtimeFolder, 'handler.js'), 'utf8') + ), + createVirtualModule( + 'virtual:adex:server', + getServerTemplate(`adex-adapter-${adapter}`) + ), + fontsPluginFactory(fonts), + islands && islandBuilderFactory(), + ...sanitizedPlugins, + ], + build: { + outDir: serverOutDir, + emptyOutDir: false, + assetsDir: 'assets', + ssrEmitAssets: true, + ssr: true, + manifest: 'manifest.json', + ssrManifest: 'ssr.manifest.json', + rollupOptions: { + input: { + index: input, + }, + external: ['adex/ssr'], + }, + }, + }) + }, + } +} diff --git a/adex/src/utils/server-template.js b/adex/src/utils/server-template.js new file mode 100644 index 0000000..e49520a --- /dev/null +++ b/adex/src/utils/server-template.js @@ -0,0 +1,70 @@ +/** + * Returns the standard server module template for adex + * + * @param {string} adapter - The adapter to use for the server + * @returns {string} - The server template code + */ +export function getServerTemplate(adapter) { + return `import { createServer } from '${adapter}' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { existsSync, readFileSync } from 'node:fs' +import { env } from 'adex/env' + +import 'virtual:adex:font.css' +import 'virtual:adex:global.css' + +const __dirname = dirname(fileURLToPath(import.meta.url)) + +const PORT = parseInt(env.get('PORT', '3000'), 10) +const HOST = env.get('HOST', 'localhost') + +const paths = { + assets: join(__dirname, './assets'), + islands: join(__dirname, './islands'), + client: join(__dirname, '../client'), +} + +function getServerManifest() { + const manifestPath = join(__dirname, 'manifest.json') + if (existsSync(manifestPath)) { + const manifestFile = readFileSync(manifestPath, 'utf8') + return parseManifest(manifestFile) + } + return {} +} + +function getClientManifest() { + const manifestPath = join(__dirname, '../client/manifest.json') + if (existsSync(manifestPath)) { + const manifestFile = readFileSync(manifestPath, 'utf8') + return parseManifest(manifestFile) + } + return {} +} + +function parseManifest(manifestString) { + try { + const manifestJSON = JSON.parse(manifestString) + return manifestJSON + } catch (err) { + return {} + } +} + +const server = createServer({ + port: PORT, + host: HOST, + adex:{ + manifests:{server:getServerManifest(),client:getClientManifest()}, + paths, + } +}) + +if ('run' in server) { + server.run() +} + +export default server.fetch +` +} diff --git a/adex/src/utils/virtual-modules.js b/adex/src/utils/virtual-modules.js new file mode 100644 index 0000000..2430530 --- /dev/null +++ b/adex/src/utils/virtual-modules.js @@ -0,0 +1,53 @@ +/** + * Creates a virtual module that can be imported in Vite + * + * @param {string} id - The module ID + * @param {string} content - The module content + * @returns {import("vite").Plugin} + */ +export function createVirtualModule(id, content) { + return { + name: `adex-virtual-${id}`, + enforce: 'pre', + resolveId(requestId) { + if (requestId === id || requestId === '/' + id) { + return `\0${id}` + } + }, + load(requestId) { + if (requestId === `\0${id}`) { + return content + } + }, + } +} + +/** + * Creates a virtual module that can fall back to a user-provided file + * + * @param {string} id - The virtual module ID + * @param {string} content - The default content if user file doesn't exist + * @param {string} userPath - The path to the user file + * @returns {import("vite").Plugin} + */ +export function createUserDefaultVirtualModule(id, content, userPath) { + return { + name: `adex-virtual-user-default-${id}`, + enforce: 'pre', + async resolveId(requestId) { + if ( + requestId === id || + requestId === '/' + id || + requestId === userPath + ) { + const userPathResolved = await this.resolve(userPath) + return userPathResolved ?? `\0${id}` + } + }, + load(requestId) { + if (requestId === `\0${id}`) { + return content + } + }, + } +} diff --git a/adex/src/vite.js b/adex/src/vite.js index 25cdbe0..b119038 100644 --- a/adex/src/vite.js +++ b/adex/src/vite.js @@ -1,33 +1,48 @@ -import { - DEFAULT_TRANSPILED_IDENTIFIERS, - IMPORT_PATH_PLACEHOLDER, - findIslands, - generateClientTemplate, - getIslandName, - getServerTemplatePlaceholder, - injectIslandAST, - isFunctionIsland, - readSourceFile, -} from '@dumbjs/preland' -import { addImportToAST, codeFromAST } from '@dumbjs/preland/ast' -import preact from '@preact/preset-vite' -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs' -import { readFile, rm } from 'fs/promises' +/** + * Adex Vite Plugin + * ---------------- + * This is the main export for the Adex framework, providing a set of Vite plugins + * for building applications with Preact, SSR, and islands architecture. + * + * The plugin system is organized into modular components: + * - Routing plugins: Handle page and API routes + * - Virtual module plugins: Provide runtime components and templates + * - Build plugins: Configure the build process for client and server + * - Conditional plugins: Enable features like islands architecture and SSR + * + * @module adex/vite + */ + import { dirname, join, resolve } from 'path' import { fileURLToPath } from 'url' -import { build, mergeConfig } from 'vite' -import { fonts as addFontsPlugin } from './fonts.js' +import { readFileSync } from 'fs' + +// Import plugin factories +import { preactPages } from './plugins/preact-pages.js' +import { + createClientBuilder, + createBuildPrep, +} from './plugins/client-builder.js' +import { createDevServer } from './plugins/dev-server.js' +import { createServerBuilder } from './plugins/server-builder.js' +import { createIslandsBuilder } from './plugins/islands-builder.js' +import { + createVirtualModule, + createUserDefaultVirtualModule, +} from './utils/virtual-modules.js' +import { getServerTemplate } from './utils/server-template.js' +import { fonts as createFontsPlugin } from './fonts.js' const __dirname = dirname(fileURLToPath(import.meta.url)) -const cwd = process.cwd() -const islandsDir = join(cwd, '.islands') -let runningIslandBuild = false -const adapterMap = { +// Map of available adapters +const ADAPTER_PACKAGES = { node: 'adex-adapter-node', } /** + * Creates a set of Vite plugins for the Adex framework + * * @param {import("./vite.js").AdexOptions} [options] * @returns {(import("vite").Plugin)[]} */ @@ -35,19 +50,33 @@ export function adex({ fonts, islands = false, ssr = true, - adapter: adapter = 'node', + adapter = 'node', } = {}) { - // @ts-expect-error probably because of the `.filter` - return [ + const adapterPackage = ADAPTER_PACKAGES[adapter] + if (!adapterPackage) { + throw new Error( + `Unknown adapter: ${adapter}. Available adapters: ${Object.keys(ADAPTER_PACKAGES).join(', ')}` + ) + } + + // Create the core plugins for routing + const routingPlugins = [ + // Page routes preactPages({ root: '/src/pages', id: '~routes', }), + + // API routes preactPages({ root: '/src/api', id: '~apiRoutes', replacer: '/api', }), + ] + + // Create virtual modules for static files and runtime components + const virtualModulePlugins = [ createUserDefaultVirtualModule( 'virtual:adex:global.css', '', @@ -68,759 +97,39 @@ export function adex({ ), createVirtualModule( 'virtual:adex:server', - `import { createServer } from '${adapterMap[adapter]}' - import { dirname, join } from 'node:path' - import { fileURLToPath } from 'node:url' - import { existsSync, readFileSync } from 'node:fs' - import { env } from 'adex/env' - - import 'virtual:adex:font.css' - import 'virtual:adex:global.css' - - const __dirname = dirname(fileURLToPath(import.meta.url)) - - const PORT = parseInt(env.get('PORT', '3000'), 10) - const HOST = env.get('HOST', 'localhost') - - const paths = { - assets: join(__dirname, './assets'), - islands: join(__dirname, './islands'), - client: join(__dirname, '../client'), - } - - function getServerManifest() { - const manifestPath = join(__dirname, 'manifest.json') - if (existsSync(manifestPath)) { - const manifestFile = readFileSync(manifestPath, 'utf8') - return parseManifest(manifestFile) - } - return {} - } - - function getClientManifest() { - const manifestPath = join(__dirname, '../client/manifest.json') - if (existsSync(manifestPath)) { - const manifestFile = readFileSync(manifestPath, 'utf8') - return parseManifest(manifestFile) - } - return {} - } - - function parseManifest(manifestString) { - try { - const manifestJSON = JSON.parse(manifestString) - return manifestJSON - } catch (err) { - return {} - } - } - - const server = createServer({ - port: PORT, - host: HOST, - adex:{ - manifests:{server:getServerManifest(),client:getClientManifest()}, - paths, - } - }) - - if ('run' in server) { - server.run() - } - - export default server.fetch - ` + getServerTemplate(adapterPackage) ), - addFontsPlugin(fonts), - adexDevServer({ islands }), - adexBuildPrep({ islands }), - adexClientBuilder({ ssr, islands }), - islands && adexIslandsBuilder(), - - // SSR/Render Server Specific plugins - ssr && adexServerBuilder({ fonts, adapter, islands }), - ].filter(Boolean) -} - -/** - * @returns {import("vite").Plugin} - */ -function adexClientBuilder({ ssr = true, islands = false } = {}) { - let baseUrl = '/' - return { - name: 'adex-client-builder', - config(cfg) { - const out = cfg.build.outDir ?? 'dist' - return { - appType: 'custom', - build: { - write: !islands, - manifest: 'manifest.json', - outDir: join(out, 'client'), - rollupOptions: { - input: 'virtual:adex:client', - }, - output: { - entryFileNames: '[name]-[hash].js', - format: 'esm', - }, - }, - } - }, - configResolved(cfg) { - baseUrl = cfg.base - return - }, - generateBundle(opts, bundle) { - let clientEntryPath - for (const key in bundle) { - if ( - ['_virtual_adex_client', '_app'].includes(bundle[key].name) && - 'isEntry' in bundle[key] && - bundle[key].isEntry - ) { - clientEntryPath = key - } - } - - const links = [ - // @ts-expect-error invalid types by vite? figure this out - ...(bundle[clientEntryPath]?.viteMetadata?.importedCss ?? new Set()), - ].map(d => { - return `` - }) - - if (!ssr) { - this.emitFile({ - type: 'asset', - fileName: 'index.html', - source: ` - - ${links.join('\n')} - - -
- - - `, - }) - } - }, - } -} - -/** - * @returns {import("vite").Plugin} - */ -function adexBuildPrep({ islands = false }) { - return { - name: 'adex-build-prep', - apply: 'build', - async configResolved(config) { - if (!islands) return - - // Making it order safe - const outDirNormalized = config.build.outDir.endsWith('/server') - ? dirname(config.build.outDir) - : config.build.outDir - - // remove the `client` dir if islands are on, - // we don't generate the client assets and - // their existence adds the client entry into the bundle - const clientDir = join(outDirNormalized, 'client') - if (!existsSync(clientDir)) return - await rm(clientDir, { - recursive: true, - force: true, - }) - }, - } -} - -/** - * @returns {import("vite").Plugin[]} - */ -function adexIslandsBuilder() { - const clientVirtuals = {} - let isBuild = false - let outDir - return [ - { - name: 'adex-islands', - enforce: 'pre', - config(d, e) { - outDir = d.build?.outDir ?? 'dist' - isBuild = e.command === 'build' - }, - transform(code, id, viteEnv) { - if (!/\.(js|ts)x$/.test(id)) return - - // if being imported by the client, don't send - // back the transformed server code, send the - // original component - if (!viteEnv?.ssr) return - - const islands = findIslands(readSourceFile(id), { - isFunctionIsland: node => - isFunctionIsland(node, { - transpiledIdentifiers: - DEFAULT_TRANSPILED_IDENTIFIERS.concat('_jsxDEV'), - }), - }) - if (!islands.length) return - - islands.forEach(node => { - //@ts-expect-error FIX: in preland - injectIslandAST(node.ast, node) - const clientCode = generateClientTemplate(node.id).replace( - IMPORT_PATH_PLACEHOLDER, - id - ) - - mkdirSync(islandsDir, { recursive: true }) - writeFileSync( - join(islandsDir, getIslandName(node.id) + '.js'), - clientCode, - 'utf8' - ) - - clientVirtuals[node.id] = clientCode - }) - - const addImport = addImportToAST(islands[0].ast) - addImport('h', 'preact', { named: true }) - addImport('Fragment', 'preact', { named: true }) - - let serverTemplateCode = codeFromAST(islands[0].ast) - islands.forEach(island => { - serverTemplateCode = serverTemplateCode.replace( - getServerTemplatePlaceholder(island.id), - !isBuild - ? `/virtual:adex:island-${island.id}` - : `/islands/${getIslandName(island.id) + '.js'}` - ) - }) - - return { - code: serverTemplateCode, - } - }, - }, - { - name: 'adex-island-builds', - enforce: 'post', - writeBundle: { - sequential: true, - async handler() { - if (Object.keys(clientVirtuals).length === 0) return - if (runningIslandBuild) return - - await build( - mergeConfig( - {}, - { - configFile: false, - plugins: [preact()], - build: { - ssr: false, - outDir: join(outDir, 'islands'), - emptyOutDir: true, - rollupOptions: { - output: { - format: 'esm', - entryFileNames: '[name].js', - }, - input: Object.fromEntries( - Object.entries(clientVirtuals).map(([k, v]) => { - const key = getIslandName(k) - return [key, join(islandsDir, key + '.js')] - }) - ), - }, - }, - } - ) - ) - }, - }, - }, - { - name: 'adex-island-virtuals', - resolveId(id) { - if ( - id.startsWith('virtual:adex:island') || - id.startsWith('/virtual:adex:island') - ) { - return `\0${id}` - } - }, - load(id) { - if ( - (id.startsWith('\0') && id.startsWith('\0virtual:adex:island')) || - id.startsWith('\0/virtual:adex:island') - ) { - const compName = id - .replace('\0', '') - .replace(/\/?(virtual\:adex\:island\-)/, '') - - if (clientVirtuals[compName]) { - return { - code: clientVirtuals[compName], - } - } - } - }, - }, ] -} -/** - * @returns {import("vite").Plugin} - */ -export function createVirtualModule(id, content) { - return { - name: `adex-virtual-${id}`, - enforce: 'pre', - resolveId(requestId) { - if (requestId === id || requestId === '/' + id) { - return `\0${id}` - } - }, - load(requestId) { - if (requestId === `\0${id}`) { - return content - } - }, - } -} - -/** - * @returns {import("vite").Plugin} - */ -function createUserDefaultVirtualModule(id, content, userPath) { - return { - name: `adex-virtual-user-default-${id}`, - enforce: 'pre', - async resolveId(requestId) { - if ( - requestId === id || - requestId === '/' + id || - requestId === userPath - ) { - const userPathResolved = await this.resolve(userPath) - return userPathResolved ?? `\0${id}` - } - }, - load(requestId) { - if (requestId === `\0${id}`) { - return content - } - }, - } -} - -/** - * @param {object} options - * @param {import('vite').UserConfig} options.config - * @returns {import("vite").Plugin} - */ -function adexClientSSRBuilder(opts) { - let options - return { - name: 'adex-client', - enforce: 'post', - config(conf) { - return { - appType: 'custom', - build: { - outDir: join(conf.build.outDir ?? 'dist', 'client'), - emptyOutDir: true, - ssr: false, - manifest: 'manifest.json', - rollupOptions: { - input: { - index: 'virtual:adex:client', - }, - output: { - entryFileNames: '[name]-[hash].js', - format: 'esm', - }, - }, - }, - } - }, - configResolved(config) { - options = config - }, - closeBundle() { - // process.nextTick(async () => { - // const usablePlugins = options.plugins - // .filter(d => !d.name.startsWith('vite:')) - // .filter(d => !d.name.startsWith('adex-') || d.name === 'adex-fonts') - // await build( - // mergeConfig(opts, { - // plugins: [ - // ...usablePlugins, - // createVirtualModule( - // 'virtual:adex:client', - // readFileSync(join(__dirname, '../runtime/client.js'), 'utf8') - // ), - // createUserDefaultVirtualModule( - // 'virtual:adex:index.html', - // '', - // 'src/index.html' - // ), - // preact({ prefreshEnabled: false }), - // ], - // build: { - // outDir: 'dist/client', - // emptyOutDir: true, - // ssr: false, - // manifest: 'manifest.json', - // rollupOptions: { - // input: { - // index: 'virtual:adex:client', - // }, - // output: { - // entryFileNames: '[name]-[hash].js', - // format: 'esm', - // }, - // }, - // }, - // }) - // ) - // }) - }, - } -} - -/** - * @returns {import("vite").Plugin} - */ -function adexDevServer({ islands = false } = {}) { - const devCSSMap = new Map() - let cfg - return { - name: 'adex-dev-server', - apply: 'serve', - enforce: 'pre', - config() { - return { - ssr: { - noExternal: ['adex/app'], - }, - } - }, - configResolved(_cfg) { - cfg = _cfg - }, - async resolveId(id, importer, meta) { - if (id.endsWith('.css')) { - if (!importer) return - const importerFromRoot = importer.replace(resolve(cfg.root), '') - const resolvedCss = await this.resolve(id, importer, meta) - if (resolvedCss) { - devCSSMap.set( - importerFromRoot, - (devCSSMap.get(importer) ?? []).concat(resolvedCss.id) - ) - } - return - } - }, - configureServer(server) { - return () => { - server.middlewares.use(async function (req, res, next) { - const module = await server.ssrLoadModule('virtual:adex:handler') - if (!module) { - return next() - } - try { - const { html, serverHandler, pageRoute } = await module.handler( - req, - res - ) - if (serverHandler) { - return serverHandler(req, res) - } - const cssLinks = devCSSMap.get(pageRoute) ?? [] - let renderedHTML = html.replace( - '', - ` - - ${cssLinks.map(d => { - return `` - })} - - ` - ) - if (!islands) { - renderedHTML = html.replace( - '', - `` - ) - } - const finalRenderedHTML = await server.transformIndexHtml( - req.url, - renderedHTML - ) - res.setHeader('content-type', 'text/html') - res.write(finalRenderedHTML) - return res.end() - } catch (err) { - server.ssrFixStacktrace(err) - next(err) - } - }) - } - }, - } -} - -/** - * @param {object} options - * @param {import("./fonts.js").Options} options.fonts - * @param {string} options.adapter - * @param {boolean} options.islands - * @returns {import("vite").Plugin} - */ -function adexServerBuilder({ fonts, adapter, islands }) { - let input = 'src/entry-server.js' - let cfg - return { - name: `adex-server`, - enforce: 'pre', - apply: 'build', - config(conf, env) { - if (env.command === 'build') { - input = 'virtual:adex:server' - } - }, - configResolved(config) { - cfg = config - }, - async generateBundle() { - const defOut = cfg.build?.outDir ?? 'dist' - const serverOutDir = defOut.endsWith('client') - ? join(dirname(defOut), 'server') - : join(defOut, 'server') - - console.log(`\nBuilding Server: ${serverOutDir}\n`) - - const sanitizedPlugins = (cfg.plugins ?? []) - .filter(d => d.adexServer === false) - .filter(d => !d.name.startsWith('vite:')) - .filter(d => !d.name.startsWith('adex-')) - - await build({ - configFile: false, - ssr: { - external: ['preact', 'adex', 'preact-render-to-string'], - noExternal: Object.values(adapterMap), - }, - resolve: cfg.resolve, - appType: 'custom', - plugins: [ - preact(), - preactPages({ - root: '/src/pages', - id: '~routes', - }), - preactPages({ - root: '/src/api', - id: '~apiRoutes', - replacer: '/api', - }), - createUserDefaultVirtualModule( - 'virtual:adex:global.css', - '', - '/src/global.css' - ), - createVirtualModule( - 'adex/app', - readFileSync(join(__dirname, '../runtime/app.js'), 'utf8') - ), - createUserDefaultVirtualModule( - 'virtual:adex:client', - readFileSync(join(__dirname, '../runtime/client.js'), 'utf8'), - '/src/_app' - ), - createVirtualModule( - 'virtual:adex:handler', - readFileSync(join(__dirname, '../runtime/handler.js'), 'utf8') - ), - createVirtualModule( - 'virtual:adex:server', - `import { createServer } from '${adapterMap[adapter]}' - import { dirname, join } from 'node:path' - import { fileURLToPath } from 'node:url' - import { existsSync, readFileSync } from 'node:fs' - import { env } from 'adex/env' - - import 'virtual:adex:font.css' - import 'virtual:adex:global.css' - - const __dirname = dirname(fileURLToPath(import.meta.url)) - - const PORT = parseInt(env.get('PORT', '3000'), 10) - const HOST = env.get('HOST', 'localhost') - - const paths = { - assets: join(__dirname, './assets'), - islands: join(__dirname, './islands'), - client: join(__dirname, '../client'), - } - - function getServerManifest() { - const manifestPath = join(__dirname, 'manifest.json') - if (existsSync(manifestPath)) { - const manifestFile = readFileSync(manifestPath, 'utf8') - return parseManifest(manifestFile) - } - return {} - } - - function getClientManifest() { - const manifestPath = join(__dirname, '../client/manifest.json') - if (existsSync(manifestPath)) { - const manifestFile = readFileSync(manifestPath, 'utf8') - return parseManifest(manifestFile) - } - return {} - } - - function parseManifest(manifestString) { - try { - const manifestJSON = JSON.parse(manifestString) - return manifestJSON - } catch (err) { - return {} - } - } - - const server = createServer({ - port: PORT, - host: HOST, - adex:{ - manifests:{server:getServerManifest(),client:getClientManifest()}, - paths, - } - }) - - if ('run' in server) { - server.run() - } - - export default server.fetch - ` - ), - addFontsPlugin(fonts), - islands && adexIslandsBuilder(), - ...sanitizedPlugins, - ], - build: { - outDir: serverOutDir, - emptyOutDir: false, - assetsDir: 'assets', - ssrEmitAssets: true, - ssr: true, - manifest: 'manifest.json', - ssrManifest: 'ssr.manifest.json', - rollupOptions: { - input: { - index: input, - }, - external: ['adex/ssr'], - }, - }, - }) - }, - } -} - -/** - * @returns {import("vite").Plugin[]} - */ -function adexGuards() { - return [ - { - name: 'adex-guard-env', - enforce: 'pre', - async transform(code, id) { - // ignore usage of `process.env` in node_modules - // Still risky but hard to do anything about - const nodeMods = resolve(cwd, 'node_modules') - if (id.startsWith(nodeMods)) return - - // ignore usage of `process.env` in `adex/env` - const envLoadId = await this.resolve('adex/env') - if (id === envLoadId?.id) return - - if (code.includes('process.env')) { - this.error( - 'Avoid using `process.env` to access environment variables and secrets. Use `adex/env` instead' - ) - } - }, - writeBundle() { - const pagesPath = resolve(cwd, 'src/pages') - const info = this.getModuleInfo('adex/env') - const viteRef = this - - function checkTree(importPath, importStack = []) { - if (importPath.startsWith(pagesPath)) { - throw new Error( - `Cannot use/import \`adex/env\` on the client side, importerStack: ${importStack.join(' -> ')}` - ) - } - viteRef - .getModuleInfo(importPath) - .importers.forEach(d => - checkTree(d, [...importStack, importPath, d]) - ) - } - if (info) { - info.importers.forEach(i => checkTree(i)) - } - }, - }, + // Create build and development plugins + const buildPlugins = [ + createFontsPlugin(fonts), + createDevServer({ islands }), + createBuildPrep({ islands }), + createClientBuilder({ ssr, islands }), ] -} - -/** - * @returns {import("vite").Plugin} - */ -function preactPages({ - root = '/src/pages', - id: virtualId = '~routes', - extensions = ['js', 'ts', 'tsx', 'jsx'], - replacer = '', -} = {}) { - return { - name: 'adex-routes', - enforce: 'pre', - resolveId(id) { - if (id !== virtualId) { - return - } - return `/0${virtualId}` - }, - async load(id) { - if (id !== `/0${virtualId}`) { - return - } - const extsString = extensions.join(',') - const code = ( - await readFile(join(__dirname, '../runtime/pages.js'), 'utf8') - ) - .replaceAll('#{__PLUGIN_PAGES_ROOT}', root + `/**/*.{${extsString}}`) - .replaceAll('#{__PLUGIN_PAGES_ROOT_REGEX}', `^${root}`) - .replaceAll('#{__PLUGIN_PAGES_ROOT_REGEX_REPLACER}', replacer) + // Create conditional plugins + const conditionalPlugins = [ + // Islands architecture support + islands && createIslandsBuilder(), + + // Server-side rendering support + ssr && + createServerBuilder({ + fonts, + adapter, + islands, + islandBuilderFactory: createIslandsBuilder, + fontsPluginFactory: createFontsPlugin, + }), + ] - return { - code, - } - }, - } + // Combine all plugins and filter out nulls + return [ + ...routingPlugins, + ...virtualModulePlugins, + ...buildPlugins, + ...conditionalPlugins.flat(), + ].filter(Boolean) }