UNPKG

@skybolt/server-adapter

Version:

Skybolt server adapter for Node.js/Bun - High-performance asset caching for multi-page applications

506 lines (454 loc) 15.2 kB
/** * @skybolt/server-adapter - Skybolt server adapter for Node.js/Bun * * High-performance asset caching for multi-page applications. * Eliminates HTTP requests for cached assets on repeat visits. * * @module @skybolt/server-adapter * @version 3.5.0 */ import { readFileSync } from 'node:fs' import { CacheDigest } from './cache-digest.js' // ============================================================================ // Skybolt Server Adapter // ============================================================================ /** * @typedef {Object} Asset * @property {string} url - The URL path to the asset * @property {string} hash - Content hash for cache invalidation * @property {number} size - File size in bytes * @property {string} content - Full asset content for inlining */ /** * @typedef {Object} LauncherConfig * @property {string} url - URL path to the launcher script * @property {string} hash - Content hash for cache invalidation * @property {string} content - Minified client launcher script content */ /** * @typedef {Object} ServiceWorkerConfig * @property {string} filename - Service worker filename * @property {string} path - URL path for service worker */ /** * @typedef {Object} RenderMap * @property {number} version - Render map schema version * @property {string} generated - ISO timestamp of generation * @property {string} skyboltVersion - Skybolt version used * @property {string} basePath - Base path for assets * @property {Record<string, Asset>} assets - Map of source paths to asset data * @property {LauncherConfig} launcher - Launcher script configuration * @property {ServiceWorkerConfig} serviceWorker - Service worker configuration */ /** * @typedef {Object} CookieDict * @description A dictionary of cookie names to values */ /** * Skybolt server adapter for Node.js and Bun. * * Reads the render map generated by @skybolt/vite-plugin and renders * HTML tags for CSS and JavaScript assets. On first visit, assets are * inlined; on repeat visits, external links are used (served from SW cache). * * @example * ```javascript * import { Skybolt } from '@skybolt/server-adapter' * * const skybolt = new Skybolt('./dist/.skybolt/render-map.json', req.cookies) * * const html = ` * <head> * ${skybolt.css('src/css/main.css')} * ${skybolt.script('src/js/app.js')} * ${skybolt.launchScript()} * </head> * ` * ``` */ export class Skybolt { /** @type {RenderMap} */ #map /** @type {CacheDigest | null} */ #cacheDigest /** @type {string | null} */ #cdnUrl /** @type {Map<string, {entry: string, hash: string}> | null} */ #urlToEntry = null /** * Create a new Skybolt instance. * * @param {string} renderMapPath - Path to the render-map.json file * @param {Record<string, string> | null} [cookies=null] - Request cookies object (e.g., req.cookies) * @param {string | null} [cdnUrl=null] - Optional CDN base URL to prefix asset URLs * @throws {Error} If render map cannot be read or parsed */ constructor(renderMapPath, cookies = null, cdnUrl = null) { const content = readFileSync(renderMapPath, 'utf-8') this.#map = JSON.parse(content) this.#cdnUrl = cdnUrl ? cdnUrl.replace(/\/$/, '') : null // Parse Cache Digest (Cuckoo filter) from sb_digest cookie this.#cacheDigest = this.#parseCacheDigest(cookies) } /** * Render a CSS asset. * * On first visit (or cache miss), returns an inlined `<style>` tag with * `sb-asset` and `sb-url` attributes for the client to cache. * * On repeat visits (cache hit), returns a `<link>` tag. The Service Worker * will serve the asset from cache (~5ms response time). * * @param {string} entry - Source path of the CSS file (e.g., 'src/css/main.css') * @param {Object} [options={}] - Options * @param {boolean} [options.async=false] - If true, load CSS asynchronously (non-blocking) * @returns {string} HTML string (`<style>` or `<link>` tag) * * @example * ```javascript * // Blocking CSS (in <head>) * skybolt.css('src/css/main.css') * * // Non-blocking CSS (above </body>) * skybolt.css('src/css/main.css', { async: true }) * ``` */ css(entry, { async = false } = {}) { const asset = this.#map.assets[entry] if (!asset) { return this.#comment(`Skybolt: asset not found: ${entry}`) } const url = this.#resolveUrl(asset.url) if (this.#hasCached(entry, asset.hash)) { // Repeat visit - use external link (SW serves from cache) if (async) { return this.#buildTag('link', { rel: 'stylesheet', href: url, media: 'print', onload: "this.media='all'" }) } return this.#buildTag('link', { rel: 'stylesheet', href: url }) } // First visit - inline the asset if (async) { return this.#buildTag('style', { 'sb-asset': `${entry}:${asset.hash}`, 'sb-url': url, media: 'print', onload: "this.media='all'" }, asset.content) } return this.#buildTag('style', { 'sb-asset': `${entry}:${asset.hash}`, 'sb-url': url }, asset.content) } /** * Render a JavaScript asset. * * On first visit (or cache miss), returns an inlined `<script>` tag with * `sb-asset` and `sb-url` attributes for the client to cache. * * On repeat visits (cache hit), returns an external `<script>` tag. The * Service Worker will serve the asset from cache (~5ms response time). * * @param {string} entry - Source path of the JS file (e.g., 'src/js/app.js') * @param {Object} [options={}] - Options * @param {boolean} [options.module=true] - If true, use type="module"; if false, classic script * @returns {string} HTML string (`<script>` tag) * * @example * ```javascript * // ES module (default) * skybolt.script('src/js/app.js') * * // Classic script * skybolt.script('src/js/legacy.js', { module: false }) * ``` */ script(entry, { module = true } = {}) { const asset = this.#map.assets[entry] if (!asset) { return this.#comment(`Skybolt: asset not found: ${entry}`) } const url = this.#resolveUrl(asset.url) /** @type {Record<string, string>} */ const attrs = {} if (module) { attrs.type = 'module' } if (this.#hasCached(entry, asset.hash)) { // Repeat visit - use external script (SW serves from cache) attrs.src = url return this.#buildTag('script', attrs, '') } // First visit - inline the asset attrs['sb-asset'] = `${entry}:${asset.hash}` attrs['sb-url'] = url return this.#buildTag('script', attrs, asset.content) } /** * Render a preload link for an asset. * * Preloads are useful for critical resources that should be fetched early. * Note: Preloaded resources are NOT cached by the Service Worker. * * @param {string} entry - Source path of the asset * @param {Object} [options={}] - Options * @param {string} [options.as] - Resource type (e.g., 'style', 'script', 'font', 'image') * @param {string} [options.type] - MIME type (e.g., 'font/woff2') * @param {string} [options.crossorigin] - Crossorigin attribute value * @param {string} [options.fetchpriority] - Fetch priority ('high', 'low', 'auto') * @returns {string} HTML string (`<link rel="preload">` tag) * * @example * ```javascript * // Preload a font * skybolt.preload('src/fonts/Inter.woff2', { * as: 'font', * type: 'font/woff2', * crossorigin: 'anonymous' * }) * ``` */ preload(entry, { as, type, crossorigin, fetchpriority } = {}) { const asset = this.#map.assets[entry] if (!asset) { return this.#comment(`Skybolt: asset not found: ${entry}`) } const url = this.#resolveUrl(asset.url) /** @type {Record<string, string>} */ const attrs = { rel: 'preload', href: url } if (as) attrs.as = as if (type) attrs.type = type if (crossorigin) attrs.crossorigin = crossorigin if (fetchpriority) attrs.fetchpriority = fetchpriority return this.#buildTag('link', attrs) } /** * Render the Skybolt client launcher script. * * This must be included once per page, typically at the end of `<head>` or * before `</body>`. It registers the Service Worker and processes any * inlined assets on the page. * * On first visit (or cache miss), the launcher is inlined with `sb-asset` * and `sb-url` attributes so the client can cache itself. * * On repeat visits (cache hit), returns an external script tag. The Service * Worker will serve the launcher from cache (~5ms response time). * * @returns {string} HTML string (meta tag + script tag) * * @example * ```javascript * // In your template * `<head> * ${skybolt.css('src/css/main.css')} * ${skybolt.launchScript()} * </head>` * ``` */ launchScript() { const swPath = this.#map.serviceWorker?.path ?? '/skybolt-sw.js' const launcher = this.#map.launcher // Config meta tag for client script const meta = this.#buildTag('meta', { name: 'skybolt-config', content: JSON.stringify({ swPath }) }) const url = this.#resolveUrl(launcher.url) if (this.#hasCached('skybolt-launcher', launcher.hash)) { // Repeat visit - use external script (SW serves from cache) const script = this.#buildTag('script', { type: 'module', src: url }, '') return meta + script } // First visit - inline with sb-asset and sb-url for self-caching const script = this.#buildTag('script', { type: 'module', 'sb-asset': `skybolt-launcher:${launcher.hash}`, 'sb-url': url }, launcher.content) return meta + script } /** * Get the URL for an asset. * * Useful when you need the asset URL for manual use (e.g., in a srcset * or for JavaScript dynamic imports). * * @param {string} entry - Source path of the asset * @returns {string | null} The asset URL, or null if not found * * @example * ```javascript * const url = skybolt.getAssetUrl('src/images/hero.webp') * // => '/assets/hero-Abc123.webp' * ``` */ getAssetUrl(entry) { const asset = this.#map.assets[entry] if (!asset) return null return this.#resolveUrl(asset.url) } /** * Get the content hash for an asset. * * Useful for cache busting or versioning. * * @param {string} entry - Source path of the asset * @returns {string | null} The asset hash, or null if not found * * @example * ```javascript * const hash = skybolt.getAssetHash('src/css/main.css') * // => 'Pw3rT8vL' * ``` */ getAssetHash(entry) { const asset = this.#map.assets[entry] return asset?.hash ?? null } /** * Check if an asset is currently cached by the client. * * @param {string} entry - Source path of the asset * @returns {boolean} True if the asset is cached with the current hash * * @example * ```javascript * if (skybolt.isCached('src/css/main.css')) { * // Client has this asset cached * } * ``` */ isCached(entry) { const asset = this.#map.assets[entry] if (!asset) return false return this.#hasCached(entry, asset.hash) } /** * Check if an asset URL is currently cached by the client. * * This is useful for Chain Lightning integration where we need to check * cache status by URL rather than source path. * * @param {string} url - The asset URL (e.g., '/assets/main-Abc123.css') * @returns {boolean} True if the asset is cached * * @example * ```javascript * if (skybolt.isCachedUrl('/assets/main-Abc123.css')) { * // Client has this asset cached * } * ``` */ isCachedUrl(url) { // Build URL to entry mapping if not already built if (!this.#urlToEntry) { this.#urlToEntry = new Map() for (const [entry, asset] of Object.entries(this.#map.assets)) { this.#urlToEntry.set(asset.url, { entry, hash: asset.hash }) } } const info = this.#urlToEntry.get(url) if (!info) return false return this.#hasCached(info.entry, info.hash) } /** * Resolve a URL with optional CDN prefix. * * @param {string} url - The URL to resolve * @returns {string} The resolved URL */ #resolveUrl(url) { // Don't prefix absolute URLs if (!this.#cdnUrl || url.startsWith('http://') || url.startsWith('https://') || url.startsWith('//')) { return url } return this.#cdnUrl + url } /** * Check if client has a specific asset version cached. * * @param {string} entry - Asset entry path * @param {string} hash - Expected hash * @returns {boolean} True if cached with matching hash */ #hasCached(entry, hash) { if (!this.#cacheDigest) return false return this.#cacheDigest.lookup(`${entry}:${hash}`) } /** * Parse the sb_digest cookie (Cache Digest / Cuckoo filter). * * @param {Record<string, string> | null} cookies - Cookies object * @returns {CacheDigest | null} Parsed filter or null if not present/invalid */ #parseCacheDigest(cookies) { if (!cookies) return null const digest = cookies['sb_digest'] if (!digest) return null const cd = CacheDigest.fromBase64(digest) return cd.isValid() ? cd : null } /** * Check if client has a specific entry:hash pair cached. * Useful for external integrations (like Chain Lightning) that manage * their own assets outside of Skybolt's render-map. * * @param {string} entry - Asset identifier (e.g., 'chain-lightning') * @param {string} hash - Expected hash * @returns {boolean} True if cached with matching hash */ hasCachedEntry(entry, hash) { if (!this.#cacheDigest) return false return this.#cacheDigest.lookup(`${entry}:${hash}`) } /** * Build an HTML tag string. * * @param {string} tag - Tag name * @param {Record<string, string>} attrs - Attributes * @param {string} [content] - Inner content (for non-void elements) * @returns {string} HTML string */ #buildTag(tag, attrs, content) { const attrStr = Object.entries(attrs) .map(([key, value]) => ` ${key}="${this.#escapeHtml(value)}"`) .join('') // Void elements if (tag === 'link' || tag === 'meta') { return `<${tag}${attrStr}>` } // Non-void elements (content not escaped - it's trusted CSS/JS) return `<${tag}${attrStr}>${content ?? ''}</${tag}>` } /** * Escape HTML special characters. * * @param {string} str - String to escape * @returns {string} Escaped string */ #escapeHtml(str) { return str .replace(/&/g, '&amp;') .replace(/</g, '&lt;') .replace(/>/g, '&gt;') .replace(/"/g, '&quot;') .replace(/'/g, '&#039;') } /** * Generate an HTML comment. * * @param {string} text - Comment text * @returns {string} HTML comment */ #comment(text) { // Escape -- in comments to prevent injection return `<!-- ${text.replace(/--/g, '\\-\\-')} -->` } } export default Skybolt