feat: support extension divs headers and tags being inserted to puter homepage load (#2221)
Docker Image CI / build-and-push-image (push) Has been cancelled
Maintain Release Merge PR / update-release-pr (push) Has been cancelled
release-please / release-please (push) Has been cancelled
test / test-backend (24.x) (push) Has been cancelled
test / API tests (node env, api-test) (24.x) (push) Has been cancelled
test / puterjs (node env, vitest) (24.x) (push) Has been cancelled

* feat: support extension divs headers and tags being inserted to puter homepage load

* wip: demo div

* fix: extension typing

* fix: extension typing

* feat: hompage gui add on
This commit is contained in:
Daniel Salazar
2025-12-24 13:48:36 -08:00
committed by GitHub
parent a32a16d306
commit 6cc86ff58b
4 changed files with 152 additions and 85 deletions
+97 -39
View File
@@ -10,32 +10,36 @@ import type { SUService } from '@heyputer/backend/src/services/SUService.js';
import type { IUser } from '@heyputer/backend/src/services/User.js';
import type { UserService } from '@heyputer/backend/src/services/UserService.d.ts';
import { Context } from '@heyputer/backend/src/util/context.js';
import kvjs from '@heyputer/kv.js';
import type { RequestHandler } from 'express';
import type FSNodeContext from '../src/backend/src/filesystem/FSNodeContext.js';
import type helpers from '../src/backend/src/helpers.js';
import type * as ExtensionControllerExports from './ExtensionController/src/ExtensionController.ts';
import kvjs from '@heyputer/kv.js';
declare global {
namespace Express {
interface Request {
services: { get: <T extends (keyof ServiceNameMap) | (string & {})>(string: T) => T extends keyof ServiceNameMap ? ServiceNameMap[T] : unknown }
actor: Actor,
rawBody: Buffer,
services: {
get: <T extends keyof ServiceNameMap | (string & {})>(
string: T,
) => T extends keyof ServiceNameMap ? ServiceNameMap[T] : unknown;
};
actor: Actor;
rawBody: Buffer;
/** @deprecated use actor instead */
user: IUser
user: IUser;
}
}
}
interface EndpointOptions {
allowedMethods?: string[]
subdomain?: string
noauth?: boolean
mw?: RequestHandler[]
allowedMethods?: string[];
subdomain?: string;
noauth?: boolean;
mw?: RequestHandler[];
otherOpts?: Record<string, unknown> & {
json?: boolean
noReallyItsJson?: boolean
}
json?: boolean;
noReallyItsJson?: boolean;
};
}
// Driver interface types
@@ -54,7 +58,11 @@ interface DriverInterface {
type HttpMethod = 'get' | 'post' | 'put' | 'delete' | 'patch';
export type AddRouteFunction = (path: string, options: EndpointOptions, handler: RequestHandler) => void;
export type AddRouteFunction = (
path: string,
options: EndpointOptions,
handler: RequestHandler,
) => void;
export type RouterMethods = {
[K in HttpMethod]: {
@@ -65,44 +73,94 @@ export type RouterMethods = {
interface CoreRuntimeModule {
util: {
helpers: typeof helpers,
}
Context: typeof Context,
APIError: typeof APIError
helpers: typeof helpers;
};
Context: typeof Context;
APIError: typeof APIError;
}
interface FilesystemModule {
FSNodeContext: FSNodeContext,
selectors: unknown,
FSNodeContext: FSNodeContext;
selectors: unknown;
}
type StripPrefix<TPrefix extends string, T extends string> = T extends `${TPrefix}.${infer R}` ? R : never;
type StripPrefix<
TPrefix extends string,
T extends string,
> = T extends `${TPrefix}.${infer R}` ? R : never;
// TODO DS: define this globally in core to use it there too
interface ServiceNameMap {
'meteringService': Pick<MeteringServiceWrapper, 'meteringService'> & MeteringService // TODO DS: squash into a single class without wrapper
'puter-kvstore': DynamoKVStore
'su': SUService
'database': BaseDatabaseAccessService
'user': UserService
'web-server': WebServerService
meteringService: Pick<MeteringServiceWrapper, 'meteringService'> &
MeteringService; // TODO DS: squash into a single class without wrapper
'puter-kvstore': DynamoKVStore;
su: SUService;
database: BaseDatabaseAccessService;
user: UserService;
'web-server': WebServerService;
}
export interface ExtensionEventTypeMap {
'create.drivers': {
createDriver: (interface: string, service: string, executors: any) => any;
};
'create.permissions': {
grant_to_everyone: (permission: string) => void;
grant_to_users: (permission: string) => void;
};
'create.interfaces': {
createInterface: (interface: string, interfaces: DriverInterface) => void;
};
'puter.gui.addons': {
bodyContent: string;
headContent: string;
guiParams: {
env: string;
app_origin: string;
api_origin: string;
gui_origin: string;
asset_dir: string;
launch_options: unknown;
app_name_regex: RegExp;
app_name_max_length: number;
app_title_max_length: number;
hosting_domain: string;
subdomain_regex: RegExp;
subdomain_max_length: number;
domain: string;
protocol: string;
api_base_url: string;
app?: unknown;
[key: string]: unknown;
};
};
}
interface Extension extends RouterMethods {
exports: Record<string, unknown>,
exports: Record<string, unknown>;
span: (<T>(label: string, fn: () => T) => () => T) & {
run<T>(label: string, fn: () => T): T;
run<T>(fn: () => T): T;
},
config: Record<string | number | symbol, any>,
on<T extends unknown[]>(event: string, listener: (...args: T) => void): void, // TODO DS: type events better
on(event: 'create.drivers', listener: (event: { createDriver: (interface: string, service: string, executors: any) => any }) => void),
on(event: 'create.permissions', listener: (event: { grant_to_everyone: (permission: string) => void, grant_to_users: (permission: string) => void }) => void)
on(event: 'create.interfaces', listener: (event: { createInterface: (interface: string, interfaces: DriverInterface) => void }) => void)
import(module: 'data'): { db: BaseDatabaseAccessService, kv: DynamoKVStore, cache: kvjs }
import(module: 'core'): CoreRuntimeModule,
import(module: 'fs'): FilesystemModule,
import(module: 'query'): typeof query,
import(module: 'extensionController'): typeof ExtensionControllerExports
import<T extends `service:${keyof ServiceNameMap}` | (string & {})>(module: T): T extends `service:${infer R extends keyof ServiceNameMap}`
};
config: Record<string | number | symbol, any>;
on<E extends keyof ExtensionEventTypeMap>(
name: E,
listener: (event: ExtensionEventTypeMap[E]) => void | Promise<void>
): void;
on(name: string, listener: (event: unknown) => void | Promise<void>): void
import(module: 'data'): {
db: BaseDatabaseAccessService;
kv: DynamoKVStore;
cache: kvjs;
};
import(module: 'core'): CoreRuntimeModule;
import(module: 'fs'): FilesystemModule;
import(module: 'query'): typeof query;
import(module: 'extensionController'): typeof ExtensionControllerExports;
import<T extends `service:${keyof ServiceNameMap}` | (string & {})>(
module: T
): T extends `service:${infer R extends keyof ServiceNameMap}`
? ServiceNameMap[R]
: unknown;
}
+12
View File
@@ -0,0 +1,12 @@
extension.on('puter.gui.addons', async (event) => {
if ( event.guiParams.app ) {
// disabled for now
// const app = event.guiParams.app;
// event.bodyContent += `
// <div style="position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; z-index: 9999999999; background: rgba(0,0,0,0.8); color: white; padding: 20px; overflow: auto;">
// test: ${ JSON.stringify(app)}
// </div>`;
// event.headContent += `<meta name="description" content="some additional description"/>`
// event.headContent += `<script> console.log("test1234"); </script>`
}
});
+3 -9
View File
@@ -329,6 +329,7 @@ router.all('*', async function (req, res, next) {
// GUI
// ------------------------
else {
let app;
let canonical_url = config.origin + path;
let app_name, app_title, app_description, app_icon, app_social_media_image;
let launch_options = {
@@ -353,7 +354,7 @@ router.all('*', async function (req, res, next) {
// /app/
else if ( path.startsWith('/app/') ) {
app_name = path.replace('/app/', '');
const app = await get_app({
app = await get_app({
follow_old_names: true,
name: app_name,
});
@@ -388,14 +389,6 @@ router.all('*', async function (req, res, next) {
path = '/';
}
const manifest =
_fs.existsSync(_path.join(config.assets.gui, 'puter-gui.json'))
? (() => {
const text = _fs.readFileSync(_path.join(config.assets.gui, 'puter-gui.json'), 'utf8');
return JSON.parse(text);
})()
: {};
// index.js
if ( path === '/' ) {
const svc_puterHomepage = Context.get('services').get('puter-homepage');
@@ -407,6 +400,7 @@ router.all('*', async function (req, res, next) {
company: 'Puter Technologies Inc.',
canonical_url: canonical_url,
icon: app_icon,
app: app,
}, launch_options);
}
@@ -16,20 +16,17 @@
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
const { PathBuilder } = require('../util/pathutil');
const BaseService = require('./BaseService');
const { is_valid_url } = require('../helpers');
const { Endpoint } = require('../util/expressutil');
const { Context } = require('../util/context');
import { encode } from 'html-entities';
import { is_valid_url } from '../helpers.js';
import { Endpoint } from '../util/expressutil.js';
import { PathBuilder } from '../util/pathutil.js';
import BaseService from './BaseService.js';
import fs from 'node:fs';
/**
* PuterHomepageService serves the initial HTML page that loads the Puter GUI
* and all of its assets.
*/
class PuterHomepageService extends BaseService {
static MODULES = {
fs: require('node:fs'),
};
export class PuterHomepageService extends BaseService {
_construct () {
this.service_scripts = [];
@@ -45,7 +42,7 @@ class PuterHomepageService extends BaseService {
async _init () {
// Load manifest
const config = this.global_config;
const manifest_raw = this.modules.fs.readFileSync(PathBuilder
const manifest_raw = fs.readFileSync(PathBuilder
.add(config.assets.gui, { allow_traversal: true })
.add('puter-gui.json')
.build(),
@@ -122,7 +119,7 @@ class PuterHomepageService extends BaseService {
// cloudflare turnstile site key
const turnstileSiteKey = config.services?.['cloudflare-turnstile']?.enabled ? config.services?.['cloudflare-turnstile']?.site_key : null;
return res.send(this.generate_puter_page_html({
return res.send(await this.generate_puter_page_html({
env: config.env,
app_origin: config.origin,
@@ -144,7 +141,7 @@ class PuterHomepageService extends BaseService {
app_name_max_length: config.app_name_max_length,
app_title_max_length: config.app_title_max_length,
hosting_domain: config.static_hosting_domain +
(config.pub_port !== 80 && config.pub_port !== 443 ? `:${ config.pub_port}` : ''),
(config.pub_port !== 80 && config.pub_port !== 443 ? `:${config.pub_port}` : ''),
subdomain_regex: config.subdomain_regex,
subdomain_max_length: config.subdomain_max_length,
domain: config.domain,
@@ -167,23 +164,19 @@ class PuterHomepageService extends BaseService {
}));
}
generate_puter_page_html ({
async generate_puter_page_html ({
env,
manifest,
gui_path,
gui_path: _gui_path,
use_bundled_gui,
app_origin,
api_origin,
meta,
launch_options,
gui_params,
}) {
const require = this.require;
const { encode } = require('html-entities');
const eventService = this.services.get('event');
const e = encode;
@@ -207,7 +200,7 @@ class PuterHomepageService extends BaseService {
};
const asset_dir = env === 'dev'
? '/src' : '/dist' ;
? '/src' : '/dist';
gui_params.asset_dir = asset_dir;
@@ -239,11 +232,21 @@ class PuterHomepageService extends BaseService {
custom_script_tags_str += tag;
}
// emit extension event
const event = {
bodyContent: '',
headContent: '',
guiParams: {
...gui_params,
},
};
await eventService.emit('puter.gui.addons', event);
return `<!DOCTYPE html>
<html lang="en">
<head>
<title>${e(title)}</title>
<meta name="author" content="${e(company)}">
<meta name="description" content="${e((description).replace(/\n/g, ' ').trim())}">
<meta name="facebook-domain-verification" content="e29w3hjbnnnypf4kzk2cewcdaxym1y" />
@@ -323,21 +326,27 @@ class PuterHomepageService extends BaseService {
</script>
<!-- Files from JSON (may be empty) -->
${
((!bundled && manifest?.css_paths)
${((!bundled && manifest?.css_paths)
? manifest.css_paths.map(path => `<link rel="stylesheet" href="${path}">\n`)
: []).join('')
}
<!-- END Files from JSON -->
<!-- Custom header content to be added tthe homepage by extensions -->
${event.headContent || ''}
<!-- END Custom header -->
</head>
<body>
<!-- Custom body content to be added to the homepage by extensions -->
${event.bodyContent || ''}
<!-- END Custom body content -->
<script>window.puter_gui_enabled = true;</script>
${
custom_script_tags_str
${custom_script_tags_str
}
${
use_bundled_gui
${use_bundled_gui
? '<script>window.gui_env = \'prod\';</script>'
: ''
}
@@ -369,19 +378,18 @@ class PuterHomepageService extends BaseService {
});
</script>
<!-- Initialize Service Scripts -->
${
this.service_scripts
${this.service_scripts
.map(path => `<script type="module" src="${path}"></script>\n`)
.join('')
}
<div id="templates" style="display: none;"></div>
</body>
</html>`;
};
generate_error_html ({ message }) {
const { encode } = require('html-entities');
return `
<!DOCTYPE html>
<html>
@@ -407,15 +415,10 @@ class PuterHomepageService extends BaseService {
</style>
</head>
<body>
<h1>${
encode(message, { mode: 'nonAsciiPrintable' })
<h1>${encode(message, { mode: 'nonAsciiPrintable' })
}</h1>
</body>
</html>
`;
}
}
module.exports = {
PuterHomepageService,
};