diff --git a/apps/small_apps/tic_tac_toe/tic_tac_toe.tsx b/apps/small_apps/tic_tac_toe/tic_tac_toe.tsx index b49a2353..63de214d 100644 --- a/apps/small_apps/tic_tac_toe/tic_tac_toe.tsx +++ b/apps/small_apps/tic_tac_toe/tic_tac_toe.tsx @@ -91,7 +91,10 @@ springboard.registerModule('TicTacToe', {}, async (moduleAPI) => { }, }); - moduleAPI.registerRoute('/', {}, () => { + moduleAPI.registerRoute('/', {documentMeta: async () => ({ + title: 'Tic Tac Toe! Yeah!', + description: 'A simple tic-tac-toe game', + })}, () => { return ( }) => DocumentMeta | Promise; + export type RegisterRouteOptions = { hideApplicationShell?: boolean; + documentMeta?: DocumentMeta | DocumentMetaFunction; }; export type ModuleCallback = (moduleAPI: ModuleAPI) => diff --git a/packages/springboard/core/module_registry/module_registry.tsx b/packages/springboard/core/module_registry/module_registry.tsx index b47202f1..639ab516 100644 --- a/packages/springboard/core/module_registry/module_registry.tsx +++ b/packages/springboard/core/module_registry/module_registry.tsx @@ -5,6 +5,19 @@ import {Subject} from 'rxjs'; import type {ModuleAPI} from '../engine/module_api'; import {RegisterRouteOptions} from '../engine/register'; +export type DocumentMeta = { + title?: string; + description?: string; + 'Content-Security-Policy'?: string; + keywords?: string; + author?: string; + robots?: string; + 'og:title'?: string; + 'og:description'?: string; + 'og:image'?: string; + 'og:url'?: string; +} & Record; + type RouteComponentProps = { navigate: (routeName: string) => void; }; diff --git a/packages/springboard/server/src/hono_app.ts b/packages/springboard/server/src/hono_app.ts index 7059fe7d..cd0622a9 100644 --- a/packages/springboard/server/src/hono_app.ts +++ b/packages/springboard/server/src/hono_app.ts @@ -10,11 +10,15 @@ import {NodeAppDependencies} from '@springboardjs/platforms-node/entrypoints/mai import {KVStoreFromKysely} from '@springboardjs/data-storage/kv_api_kysely'; import {NodeKVStoreService} from '@springboardjs/platforms-node/services/node_kvstore_service'; import {NodeLocalJsonRpcClientAndServer} from '@springboardjs/platforms-node/services/node_local_json_rpc'; +import type {DocumentMeta} from 'springboard/module_registry/module_registry'; +import type {DocumentMetaFunction} from 'springboard/engine/register'; import {NodeJsonRpcServer} from './services/server_json_rpc'; import {WebsocketServerCoreDependencies} from './ws_server_core_dependencies'; import {RpcMiddleware, ServerModuleAPI, serverRegistry} from './register'; import {Springboard} from 'springboard/engine/engine'; +import {injectDocumentMeta} from './utils/inject_metadata'; +import {matchPath} from './utils/match_path'; type InitAppReturnValue = { app: Hono; @@ -117,11 +121,79 @@ export const initApp = (kvDeps: WebsocketServerCoreDependencies): InitAppReturnV } }; + let cachedBaseHtml: string | undefined; + let storedEngine: Springboard | undefined; + + + // Serves index.html with dynamic metadata injection based on the route + const serveIndexWithMetadata = async (c: Context): Promise => { + if (!cachedBaseHtml) { + const fullPath = `${webappDistFolder}/index.html`; + const fs = await import('node:fs'); + cachedBaseHtml = await fs.promises.readFile(fullPath, 'utf-8'); + } + + if (!storedEngine) { + return cachedBaseHtml; + } + + const requestPath = c.req.path; + + let documentMetaOrFunction: DocumentMeta | DocumentMetaFunction | undefined; + let matchParams: Record | undefined; + const modules = storedEngine.moduleRegistry.getModules(); + + for (const mod of modules) { + if (!mod.routes) { + continue; + } + + for (const [routePath, route] of Object.entries(mod.routes)) { + if (!route.options?.documentMeta) { + continue; + } + + // Routes starting with '/' are absolute, others are relative to /modules/{moduleId} + const fullRoutePath = routePath.startsWith('/') + ? routePath + : `/modules/${mod.moduleId}${routePath}`; + + const match = matchPath(fullRoutePath, requestPath); + if (match) { + documentMetaOrFunction = route.options.documentMeta; + matchParams = match.params as Record; + break; + } + } + + if (documentMetaOrFunction) { + break; + } + } + + if (documentMetaOrFunction) { + let documentMeta: DocumentMeta; + + if (typeof documentMetaOrFunction === 'function') { + documentMeta = await documentMetaOrFunction({ + path: requestPath, + params: matchParams, + }); + } else { + documentMeta = documentMetaOrFunction; + } + + return injectDocumentMeta(cachedBaseHtml, documentMeta); + } + + return cachedBaseHtml; + }; + app.use('/', serveStatic({ root: webappDistFolder, path: 'index.html', getContent: async (path, c) => { - return serveFile('index.html', 'text/html', c); + return serveIndexWithMetadata(c); }, onFound: (path, c) => { // c.header('Cross-Origin-Embedder-Policy', 'require-corp'); @@ -179,8 +251,6 @@ export const initApp = (kvDeps: WebsocketServerCoreDependencies): InitAppReturnV } }); - let storedEngine: Springboard | undefined; - const nodeAppDependencies: NodeAppDependencies = { rpc: { remote: rpc, @@ -213,7 +283,7 @@ export const initApp = (kvDeps: WebsocketServerCoreDependencies): InitAppReturnV root: webappDistFolder, path: 'index.html', getContent: async (path, c) => { - return serveFile('index.html', 'text/html', c); + return serveIndexWithMetadata(c); }, onFound: (path, c) => { c.header('Cache-Control', 'no-store, no-cache, must-revalidate'); diff --git a/packages/springboard/server/src/utils/inject_metadata.ts b/packages/springboard/server/src/utils/inject_metadata.ts new file mode 100644 index 00000000..8f044940 --- /dev/null +++ b/packages/springboard/server/src/utils/inject_metadata.ts @@ -0,0 +1,92 @@ +import type {DocumentMeta} from 'springboard/module_registry/module_registry'; + +/** + * Injects document metadata into an HTML string. + * Replaces the tag and adds/updates meta tags in the <head> section. + */ +export function injectDocumentMeta(html: string, meta: DocumentMeta): string { + let modifiedHtml = html; + + if (meta.title) { + const titleRegex = /<title>.*?<\/title>/i; + const escapedTitle = escapeHtml(meta.title); + if (titleRegex.test(modifiedHtml)) { + modifiedHtml = modifiedHtml.replace(titleRegex, `<title>${escapedTitle}`); + } else { + modifiedHtml = modifiedHtml.replace(//i, `\n ${escapedTitle}`); + } + } + + const metaTags: string[] = []; + + if (meta.description) { + metaTags.push(``); + } + if (meta.keywords) { + metaTags.push(``); + } + if (meta.author) { + metaTags.push(``); + } + if (meta.robots) { + metaTags.push(``); + } + + if (meta['Content-Security-Policy']) { + metaTags.push(``); + } + + if (meta['og:title']) { + metaTags.push(``); + } + if (meta['og:description']) { + metaTags.push(``); + } + if (meta['og:image']) { + metaTags.push(``); + } + if (meta['og:url']) { + metaTags.push(``); + } + + const knownKeys = new Set([ + 'title', + 'description', + 'Content-Security-Policy', + 'keywords', + 'author', + 'robots', + 'og:title', + 'og:description', + 'og:image', + 'og:url', + ]); + + for (const [key, value] of Object.entries(meta)) { + if (!knownKeys.has(key) && typeof value === 'string') { + if (key.startsWith('og:')) { + metaTags.push(``); + } else { + metaTags.push(``); + } + } + } + + if (metaTags.length > 0) { + const metaTagsString = '\n ' + metaTags.join('\n '); + modifiedHtml = modifiedHtml.replace(/<\/head>/i, `${metaTagsString}\n `); + } + + return modifiedHtml; +} + +function escapeHtml(text: string): string { + const htmlEscapeMap: Record = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + '\'': ''', + }; + return text.replace(/[&<>"']/g, (char) => htmlEscapeMap[char] || char); +} diff --git a/packages/springboard/server/src/utils/match_path.ts b/packages/springboard/server/src/utils/match_path.ts new file mode 100644 index 00000000..3bab90ef --- /dev/null +++ b/packages/springboard/server/src/utils/match_path.ts @@ -0,0 +1,113 @@ +/** + * Route matching utilities copied from react-router + * Source: react-router@7.9.5/dist/development/index-react-server.js + * + * These functions are copied here to avoid importing React Router dependencies + * in server code, allowing us to use route matching without pulling in the + * full React Router library. + */ + +function warning(cond: boolean, message: string) { + if (!cond) { + if (typeof console !== 'undefined') console.warn(message); + try { + throw new Error(message); + } catch (e) { + // Intentionally empty + } + } +} + +type PathPattern = { + path: string; + caseSensitive?: boolean; + end?: boolean; +} | string; + +type PathMatch = { + params: Record; + pathname: string; + pathnameBase: string; + pattern: PathPattern; +}; + +type PathParam = { + paramName: string; + isOptional?: boolean; +}; + +export function matchPath(pattern: PathPattern, pathname: string): PathMatch | null { + if (typeof pattern === 'string') { + pattern = { path: pattern, caseSensitive: false, end: true }; + } + + const [matcher, compiledParams] = compilePath( + pattern.path, + pattern.caseSensitive, + pattern.end + ); + + const match = pathname.match(matcher); + if (!match) return null; + + const matchedPathname = match[0]; + let pathnameBase = matchedPathname.replace(/(.)\/+$/, '$1'); + const captureGroups = match.slice(1); + + const params = compiledParams.reduce( + (memo, { paramName, isOptional }, index) => { + if (paramName === '*') { + const splatValue = captureGroups[index] || ''; + pathnameBase = matchedPathname.slice(0, matchedPathname.length - splatValue.length).replace(/(.)\/+$/, '$1'); + } + const value = captureGroups[index]; + if (isOptional && !value) { + memo[paramName] = undefined; + } else { + memo[paramName] = (value || '').replace(/%2F/g, '/'); + } + return memo; + }, + {} as Record + ); + + return { + params, + pathname: matchedPathname, + pathnameBase, + pattern + }; +} + +function compilePath(path: string, caseSensitive = false, end = true): [RegExp, PathParam[]] { + warning( + path === '*' || !path.endsWith('*') || path.endsWith('/*'), + `Route path "${path}" will be treated as if it were "${path.replace(/\*$/, '/*')}" because the \`*\` character must always follow a \`/\` in the pattern. To get rid of this warning, please change the route path to "${path.replace(/\*$/, '/*')}".` + ); + + const params: PathParam[] = []; + let regexpSource = '^' + path + .replace(/\/*\*?$/, '') + .replace(/^\/*/, '/') + .replace(/[\\.*+^${}|()[\]]/g, '\\$&') + .replace( + /\/:([\w-]+)(\?)?/g, + (_, paramName, isOptional) => { + params.push({ paramName, isOptional: isOptional != null }); + return isOptional ? '/?([^\\/]+)?' : '/([^\\/]+)'; + } + ) + .replace(/\/([\w-]+)\?(\/|$)/g, '(/$1)?$2'); + + if (path.endsWith('*')) { + params.push({ paramName: '*' }); + regexpSource += path === '*' || path === '/*' ? '(.*)$' : '(?:\\/(.+)|\\/*)$'; + } else if (end) { + regexpSource += '\\/*$'; + } else if (path !== '' && path !== '/') { + regexpSource += '(?:(?=\\/|$))'; + } + + const matcher = new RegExp(regexpSource, caseSensitive ? undefined : 'i'); + return [matcher, params]; +}