From 6cc86ff58b5641ca30b1a9475abf65073a6a1c88 Mon Sep 17 00:00:00 2001 From: Daniel Salazar Date: Wed, 24 Dec 2025 13:48:36 -0800 Subject: [PATCH] feat: support extension divs headers and tags being inserted to puter homepage load (#2221) * 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 --- extensions/api.d.ts | 136 +++++++++++++----- extensions/example_gui_extension.js | 12 ++ src/backend/src/routers/_default.js | 12 +- .../src/services/PuterHomepageService.js | 77 +++++----- 4 files changed, 152 insertions(+), 85 deletions(-) create mode 100644 extensions/example_gui_extension.js diff --git a/extensions/api.d.ts b/extensions/api.d.ts index af35588c4..9fa9528a3 100644 --- a/extensions/api.d.ts +++ b/extensions/api.d.ts @@ -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: (string: T) => T extends keyof ServiceNameMap ? ServiceNameMap[T] : unknown } - actor: Actor, - rawBody: Buffer, + services: { + get: ( + 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 & { - 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 = 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 & MeteringService // TODO DS: squash into a single class without wrapper - 'puter-kvstore': DynamoKVStore - 'su': SUService - 'database': BaseDatabaseAccessService - 'user': UserService - 'web-server': WebServerService + meteringService: Pick & + 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, + exports: Record; span: ((label: string, fn: () => T) => () => T) & { run(label: string, fn: () => T): T; run(fn: () => T): T; - }, - config: Record, - on(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(module: T): T extends `service:${infer R extends keyof ServiceNameMap}` + }; + config: Record; + + on( + name: E, + listener: (event: ExtensionEventTypeMap[E]) => void | Promise + ): void; + on(name: string, listener: (event: unknown) => void | Promise): 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( + module: T + ): T extends `service:${infer R extends keyof ServiceNameMap}` ? ServiceNameMap[R] : unknown; } diff --git a/extensions/example_gui_extension.js b/extensions/example_gui_extension.js new file mode 100644 index 000000000..4139e777c --- /dev/null +++ b/extensions/example_gui_extension.js @@ -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 += ` + //
+ // test: ${ JSON.stringify(app)} + //
`; + // event.headContent += `` + // event.headContent += `` + } +}); \ No newline at end of file diff --git a/src/backend/src/routers/_default.js b/src/backend/src/routers/_default.js index 6290eb6ca..976c63b10 100644 --- a/src/backend/src/routers/_default.js +++ b/src/backend/src/routers/_default.js @@ -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); } diff --git a/src/backend/src/services/PuterHomepageService.js b/src/backend/src/services/PuterHomepageService.js index 22be35507..23b7df571 100644 --- a/src/backend/src/services/PuterHomepageService.js +++ b/src/backend/src/services/PuterHomepageService.js @@ -16,20 +16,17 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -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 ` ${e(title)} + @@ -323,21 +326,27 @@ class PuterHomepageService extends BaseService { - ${ - ((!bundled && manifest?.css_paths) + ${((!bundled && manifest?.css_paths) ? manifest.css_paths.map(path => `\n`) : []).join('') } + + + ${event.headContent || ''} + + + + ${event.bodyContent || ''} + + - ${ - custom_script_tags_str + ${custom_script_tags_str } - ${ - use_bundled_gui + ${use_bundled_gui ? '' : '' } @@ -369,19 +378,18 @@ class PuterHomepageService extends BaseService { }); - ${ - this.service_scripts + ${this.service_scripts .map(path => `\n`) .join('') } + `; }; generate_error_html ({ message }) { - const { encode } = require('html-entities'); return ` @@ -407,15 +415,10 @@ class PuterHomepageService extends BaseService { -

${ - encode(message, { mode: 'nonAsciiPrintable' }) +

${encode(message, { mode: 'nonAsciiPrintable' }) }

`; } } - -module.exports = { - PuterHomepageService, -};