Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion apps/small_apps/tic_tac_toe/tic_tac_toe.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<TicTacToeBoard
board={boardState.useState()}
Expand Down
5 changes: 4 additions & 1 deletion packages/springboard/core/engine/register.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import {Module} from 'springboard/module_registry/module_registry';
import {Module, DocumentMeta} from 'springboard/module_registry/module_registry';
import {CoreDependencies, ModuleDependencies} from 'springboard/types/module_types';
import type {ModuleAPI} from './module_api';
import React from 'react';

export type DocumentMetaFunction = (context: {path: string; params?: Record<string, string>}) => DocumentMeta | Promise<DocumentMeta>;

export type RegisterRouteOptions = {
hideApplicationShell?: boolean;
documentMeta?: DocumentMeta | DocumentMetaFunction;
};

export type ModuleCallback<ModuleReturnValue extends object> = (moduleAPI: ModuleAPI) =>
Expand Down
13 changes: 13 additions & 0 deletions packages/springboard/core/module_registry/module_registry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;

type RouteComponentProps = {
navigate: (routeName: string) => void;
};
Expand Down
78 changes: 74 additions & 4 deletions packages/springboard/server/src/hono_app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string> => {
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<string, string> | 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<string, string>;
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');
Expand Down Expand Up @@ -179,8 +251,6 @@ export const initApp = (kvDeps: WebsocketServerCoreDependencies): InitAppReturnV
}
});

let storedEngine: Springboard | undefined;

const nodeAppDependencies: NodeAppDependencies = {
rpc: {
remote: rpc,
Expand Down Expand Up @@ -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');
Expand Down
92 changes: 92 additions & 0 deletions packages/springboard/server/src/utils/inject_metadata.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import type {DocumentMeta} from 'springboard/module_registry/module_registry';

/**
* Injects document metadata into an HTML string.
* Replaces the <title> 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}</title>`);
} else {
modifiedHtml = modifiedHtml.replace(/<head>/i, `<head>\n <title>${escapedTitle}</title>`);
}
}

const metaTags: string[] = [];

if (meta.description) {
metaTags.push(`<meta name="description" content="${escapeHtml(meta.description)}">`);
}
if (meta.keywords) {
metaTags.push(`<meta name="keywords" content="${escapeHtml(meta.keywords)}">`);
}
if (meta.author) {
metaTags.push(`<meta name="author" content="${escapeHtml(meta.author)}">`);
}
if (meta.robots) {
metaTags.push(`<meta name="robots" content="${escapeHtml(meta.robots)}">`);
}

if (meta['Content-Security-Policy']) {
metaTags.push(`<meta http-equiv="Content-Security-Policy" content="${escapeHtml(meta['Content-Security-Policy'])}">`);
}

if (meta['og:title']) {
metaTags.push(`<meta property="og:title" content="${escapeHtml(meta['og:title'])}">`);
}
if (meta['og:description']) {
metaTags.push(`<meta property="og:description" content="${escapeHtml(meta['og:description'])}">`);
}
if (meta['og:image']) {
metaTags.push(`<meta property="og:image" content="${escapeHtml(meta['og:image'])}">`);
}
if (meta['og:url']) {
metaTags.push(`<meta property="og:url" content="${escapeHtml(meta['og:url'])}">`);
}

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(`<meta property="${escapeHtml(key)}" content="${escapeHtml(value)}">`);
} else {
metaTags.push(`<meta name="${escapeHtml(key)}" content="${escapeHtml(value)}">`);
}
}
}

if (metaTags.length > 0) {
const metaTagsString = '\n ' + metaTags.join('\n ');
modifiedHtml = modifiedHtml.replace(/<\/head>/i, `${metaTagsString}\n </head>`);
}

return modifiedHtml;
}

function escapeHtml(text: string): string {
const htmlEscapeMap: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
'\'': '&#39;',
};
return text.replace(/[&<>"']/g, (char) => htmlEscapeMap[char] || char);
}
113 changes: 113 additions & 0 deletions packages/springboard/server/src/utils/match_path.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | undefined>;
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<string, string | undefined>
);

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];
}
Loading