@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
JavaScript
/**
* @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, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''')
}
/**
* 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