From 557d781eb224682f00afc947ce25dde02a25db86 Mon Sep 17 00:00:00 2001 From: Michael Kochell <6913320+mickmister@users.noreply.github.com> Date: Tue, 11 Nov 2025 06:21:19 -0500 Subject: [PATCH 1/5] registerRoute supports optional documentMeta for SEO --- apps/small_apps/tic_tac_toe/tic_tac_toe.tsx | 5 +- packages/springboard/core/engine/register.ts | 5 +- .../core/module_registry/module_registry.tsx | 13 ++ packages/springboard/server/src/hono_app.ts | 91 +++++++++++++- .../server/src/utils/inject_metadata.ts | 104 ++++++++++++++++ .../server/src/utils/match_path.ts | 113 ++++++++++++++++++ 6 files changed, 324 insertions(+), 7 deletions(-) create mode 100644 packages/springboard/server/src/utils/inject_metadata.ts create mode 100644 packages/springboard/server/src/utils/match_path.ts 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..08f80058 100644 --- a/packages/springboard/server/src/hono_app.ts +++ b/packages/springboard/server/src/hono_app.ts @@ -15,6 +15,10 @@ 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'; +import type {DocumentMeta} from 'springboard/module_registry/module_registry'; +import type {DocumentMetaFunction} from 'springboard/engine/register'; type InitAppReturnValue = { app: Hono; @@ -117,11 +121,90 @@ 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 => { + // Read and cache base HTML + if (!cachedBaseHtml) { + const fullPath = `${webappDistFolder}/index.html`; + const fs = await import('node:fs'); + cachedBaseHtml = await fs.promises.readFile(fullPath, 'utf-8'); + } + + // If engine not injected yet, return base HTML + if (!storedEngine) { + return cachedBaseHtml; + } + + // Get the request path + const requestPath = c.req.path; + + // Find matching route with metadata + 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)) { + // Check if route has metadata + if (!route.options?.documentMeta) { + continue; + } + + // Determine the full route path + // Routes starting with '/' are absolute, others are relative to /modules/{moduleId} + const fullRoutePath = routePath.startsWith('/') + ? routePath + : `/modules/${mod.moduleId}${routePath}`; + + // Use matchPath to check if the route matches + const match = matchPath(fullRoutePath, requestPath); + if (match) { + documentMetaOrFunction = route.options.documentMeta; + matchParams = match.params as Record; + break; + } + } + + if (documentMetaOrFunction) { + break; + } + } + + // Resolve metadata (handle both static objects and functions) + if (documentMetaOrFunction) { + let documentMeta: DocumentMeta; + + if (typeof documentMetaOrFunction === 'function') { + // Call the function with context + documentMeta = await documentMetaOrFunction({ + path: requestPath, + params: matchParams, + }); + } else { + // Use the static metadata + 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 +262,6 @@ export const initApp = (kvDeps: WebsocketServerCoreDependencies): InitAppReturnV } }); - let storedEngine: Springboard | undefined; - const nodeAppDependencies: NodeAppDependencies = { rpc: { remote: rpc, @@ -208,12 +289,12 @@ export const initApp = (kvDeps: WebsocketServerCoreDependencies): InitAppReturnV call(makeServerModuleAPI()); } - // Catch-all route for SPA + // Catch-all route for SPA with dynamic metadata injection 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('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..6e7b515c --- /dev/null +++ b/packages/springboard/server/src/utils/inject_metadata.ts @@ -0,0 +1,104 @@ +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; + + // Replace title tag + if (meta.title) { + const titleRegex = /<title>.*?<\/title>/i; + const escapedTitle = escapeHtml(meta.title); + if (titleRegex.test(modifiedHtml)) { + modifiedHtml = modifiedHtml.replace(titleRegex, `<title>${escapedTitle}`); + } else { + // If no title tag exists, add one at the start of + modifiedHtml = modifiedHtml.replace(//i, `\n ${escapedTitle}`); + } + } + + // Build meta tags string + const metaTags: string[] = []; + + // Standard meta tags + if (meta.description) { + metaTags.push(``); + } + if (meta.keywords) { + metaTags.push(``); + } + if (meta.author) { + metaTags.push(``); + } + if (meta.robots) { + metaTags.push(``); + } + + // HTTP-EQUIV meta tags + if (meta['Content-Security-Policy']) { + metaTags.push(``); + } + + // Open Graph meta tags + if (meta['og:title']) { + metaTags.push(``); + } + if (meta['og:description']) { + metaTags.push(``); + } + if (meta['og:image']) { + metaTags.push(``); + } + if (meta['og:url']) { + metaTags.push(``); + } + + // Handle any additional meta tags from the Record part + 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(``); + } + } + } + + // Inject meta tags into + if (metaTags.length > 0) { + const metaTagsString = '\n ' + metaTags.join('\n '); + // Insert before + modifiedHtml = modifiedHtml.replace(/<\/head>/i, `${metaTagsString}\n `); + } + + return modifiedHtml; +} + +/** + * Escapes HTML special characters to prevent XSS attacks + */ +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..b037b031 --- /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; + + let 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]; +} From a4eac12304fa4320a77c6364c289baf11decd648 Mon Sep 17 00:00:00 2001 From: Michael Kochell <6913320+mickmister@users.noreply.github.com> Date: Wed, 12 Nov 2025 13:03:19 -0500 Subject: [PATCH 2/5] fix lint --- packages/springboard/server/src/utils/inject_metadata.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/springboard/server/src/utils/inject_metadata.ts b/packages/springboard/server/src/utils/inject_metadata.ts index 6e7b515c..fd42fbc4 100644 --- a/packages/springboard/server/src/utils/inject_metadata.ts +++ b/packages/springboard/server/src/utils/inject_metadata.ts @@ -98,7 +98,7 @@ function escapeHtml(text: string): string { '<': '<', '>': '>', '"': '"', - "'": ''', + '\'': ''', }; return text.replace(/[&<>"']/g, (char) => htmlEscapeMap[char] || char); } From fdf23f28626d8134a52be402e59d2f57e9c524dc Mon Sep 17 00:00:00 2001 From: Michael Kochell <6913320+mickmister@users.noreply.github.com> Date: Wed, 12 Nov 2025 13:03:47 -0500 Subject: [PATCH 3/5] remove unnecessary comments --- packages/springboard/server/src/hono_app.ts | 21 +++++---------------- 1 file changed, 5 insertions(+), 16 deletions(-) diff --git a/packages/springboard/server/src/hono_app.ts b/packages/springboard/server/src/hono_app.ts index 08f80058..cd0622a9 100644 --- a/packages/springboard/server/src/hono_app.ts +++ b/packages/springboard/server/src/hono_app.ts @@ -10,6 +10,8 @@ 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'; @@ -17,8 +19,6 @@ import {RpcMiddleware, ServerModuleAPI, serverRegistry} from './register'; import {Springboard} from 'springboard/engine/engine'; import {injectDocumentMeta} from './utils/inject_metadata'; import {matchPath} from './utils/match_path'; -import type {DocumentMeta} from 'springboard/module_registry/module_registry'; -import type {DocumentMetaFunction} from 'springboard/engine/register'; type InitAppReturnValue = { app: Hono; @@ -124,26 +124,21 @@ 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 - */ + + // Serves index.html with dynamic metadata injection based on the route const serveIndexWithMetadata = async (c: Context): Promise => { - // Read and cache base HTML if (!cachedBaseHtml) { const fullPath = `${webappDistFolder}/index.html`; const fs = await import('node:fs'); cachedBaseHtml = await fs.promises.readFile(fullPath, 'utf-8'); } - // If engine not injected yet, return base HTML if (!storedEngine) { return cachedBaseHtml; } - // Get the request path const requestPath = c.req.path; - // Find matching route with metadata let documentMetaOrFunction: DocumentMeta | DocumentMetaFunction | undefined; let matchParams: Record | undefined; const modules = storedEngine.moduleRegistry.getModules(); @@ -154,18 +149,15 @@ export const initApp = (kvDeps: WebsocketServerCoreDependencies): InitAppReturnV } for (const [routePath, route] of Object.entries(mod.routes)) { - // Check if route has metadata if (!route.options?.documentMeta) { continue; } - // Determine the full route path // Routes starting with '/' are absolute, others are relative to /modules/{moduleId} const fullRoutePath = routePath.startsWith('/') ? routePath : `/modules/${mod.moduleId}${routePath}`; - // Use matchPath to check if the route matches const match = matchPath(fullRoutePath, requestPath); if (match) { documentMetaOrFunction = route.options.documentMeta; @@ -179,18 +171,15 @@ export const initApp = (kvDeps: WebsocketServerCoreDependencies): InitAppReturnV } } - // Resolve metadata (handle both static objects and functions) if (documentMetaOrFunction) { let documentMeta: DocumentMeta; if (typeof documentMetaOrFunction === 'function') { - // Call the function with context documentMeta = await documentMetaOrFunction({ path: requestPath, params: matchParams, }); } else { - // Use the static metadata documentMeta = documentMetaOrFunction; } @@ -289,7 +278,7 @@ export const initApp = (kvDeps: WebsocketServerCoreDependencies): InitAppReturnV call(makeServerModuleAPI()); } - // Catch-all route for SPA with dynamic metadata injection + // Catch-all route for SPA app.use('*', serveStatic({ root: webappDistFolder, path: 'index.html', From 9ad2165dbff47c48fb3626db3ecfcbc530048336 Mon Sep 17 00:00:00 2001 From: Michael Kochell <6913320+mickmister@users.noreply.github.com> Date: Wed, 12 Nov 2025 13:16:12 -0500 Subject: [PATCH 4/5] remove more comments --- .../springboard/server/src/utils/inject_metadata.ts | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/packages/springboard/server/src/utils/inject_metadata.ts b/packages/springboard/server/src/utils/inject_metadata.ts index fd42fbc4..8f044940 100644 --- a/packages/springboard/server/src/utils/inject_metadata.ts +++ b/packages/springboard/server/src/utils/inject_metadata.ts @@ -7,22 +7,18 @@ import type {DocumentMeta} from 'springboard/module_registry/module_registry'; export function injectDocumentMeta(html: string, meta: DocumentMeta): string { let modifiedHtml = html; - // Replace title tag if (meta.title) { const titleRegex = /.*?<\/title>/i; const escapedTitle = escapeHtml(meta.title); if (titleRegex.test(modifiedHtml)) { modifiedHtml = modifiedHtml.replace(titleRegex, `<title>${escapedTitle}`); } else { - // If no title tag exists, add one at the start of modifiedHtml = modifiedHtml.replace(//i, `\n ${escapedTitle}`); } } - // Build meta tags string const metaTags: string[] = []; - // Standard meta tags if (meta.description) { metaTags.push(``); } @@ -36,12 +32,10 @@ export function injectDocumentMeta(html: string, meta: DocumentMeta): string { metaTags.push(``); } - // HTTP-EQUIV meta tags if (meta['Content-Security-Policy']) { metaTags.push(``); } - // Open Graph meta tags if (meta['og:title']) { metaTags.push(``); } @@ -55,7 +49,6 @@ export function injectDocumentMeta(html: string, meta: DocumentMeta): string { metaTags.push(``); } - // Handle any additional meta tags from the Record part const knownKeys = new Set([ 'title', 'description', @@ -79,19 +72,14 @@ export function injectDocumentMeta(html: string, meta: DocumentMeta): string { } } - // Inject meta tags into if (metaTags.length > 0) { const metaTagsString = '\n ' + metaTags.join('\n '); - // Insert before modifiedHtml = modifiedHtml.replace(/<\/head>/i, `${metaTagsString}\n `); } return modifiedHtml; } -/** - * Escapes HTML special characters to prevent XSS attacks - */ function escapeHtml(text: string): string { const htmlEscapeMap: Record = { '&': '&', From cdf5849ba336f8394748754682dffa5293176bf5 Mon Sep 17 00:00:00 2001 From: Michael Kochell <6913320+mickmister@users.noreply.github.com> Date: Wed, 12 Nov 2025 13:40:47 -0500 Subject: [PATCH 5/5] fix lint --- packages/springboard/server/src/utils/match_path.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/springboard/server/src/utils/match_path.ts b/packages/springboard/server/src/utils/match_path.ts index b037b031..3bab90ef 100644 --- a/packages/springboard/server/src/utils/match_path.ts +++ b/packages/springboard/server/src/utils/match_path.ts @@ -50,7 +50,7 @@ export function matchPath(pattern: PathPattern, pathname: string): PathMatch | n const match = pathname.match(matcher); if (!match) return null; - let matchedPathname = match[0]; + const matchedPathname = match[0]; let pathnameBase = matchedPathname.replace(/(.)\/+$/, '$1'); const captureGroups = match.slice(1);