From 0def6ee3ef7fbeac859e226088d7efd2dca04d52 Mon Sep 17 00:00:00 2001 From: bbface <152608874+bb-face@users.noreply.github.com> Date: Tue, 6 Aug 2024 18:16:38 +0100 Subject: [PATCH] 127 customize gateway from bosconfigjson (#147) * Allow 'gateway' option in bos.config.json schema * Implement gateway precedence logic * Add gateway object * Fix gateway object * Fix bug * Enforce tagName and bundleUrl rules * Fix test * Gateway object for multi app * Tackle pr comments * Address pr comments * Restore gatewayInitPromise * Fix logs * Remove -g option from the cli * Remove notes * Add test to verify tagName * Remove redundant fallback --------- Co-authored-by: Elliot Braem <16282460+elliotBraem@users.noreply.github.com> --- README.md | 18 +++++++---- examples/single/bos.config.json | 6 +++- lib/cli.ts | 2 -- lib/config.ts | 6 ++++ lib/dev.ts | 42 +++++++++++++++++++++++-- lib/gateway.ts | 6 ++-- lib/server.ts | 56 ++++++++++++++++----------------- tests/unit/dev.ts | 8 +++-- tests/unit/gateway.ts | 24 ++++++++++---- tests/unit/server.ts | 28 ++++++++++------- 10 files changed, 131 insertions(+), 65 deletions(-) diff --git a/README.md b/README.md index e64db19..61037da 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,10 @@ A fully featured config may look like this: "uploadApi": "https://ipfs.near.social/add", "uploadApiHeaders": {}, }, + "gateway": { + "bundleUrl": "https://ipfs.web4.near.page/ipfs/bafybeibe63hqugbqr4writdxgezgl5swgujay6t5uptw2px7q63r7crk2q/", + "tagName": "near-social-viewer" + } } ``` @@ -131,6 +135,9 @@ The `bos.config.json` file consists of a base configuration that defines default * `format`: (Optional) Indicates whether to format code on build. Default value is `true`. * `aliases`: (Optional) Provides a list of alias files to use for replacing network-specific values with correct overrides. * `index`: (Optional) Default widget src to use when using a custom gateway dist. +* `gateway`: (Optional) Configures gateway object. + * `bundleUrl`: gateway url. + * `tagName`: element tag name. --- @@ -229,14 +236,13 @@ Running the bos-workspace dev server will start a local gateway with a standard bw dev --no-gateway ``` -However, there is an option to override this default gateway with a custom `/dist`. This is helpful when building widgets that utilize [custom VM elements](https://github.com/NEARBuilders/near-bos-webcomponent?tab=readme-ov-file#configuring-vm-custom-elements). To use this feature, use the `-g` flag with a path to the local custom distribution or link to package published on [nearfs](https://github.com/vgrichina/nearfs) or via cdn: +However, there is an option to override this default gateway with a custom `/dist`. This is helpful when building widgets that utilize [custom VM elements](https://github.com/NEARBuilders/near-bos-webcomponent?tab=readme-ov-file#configuring-vm-custom-elements). To use this feature, specify the gateway bundle url and the tag name in the `bos.config.json` file. ```cmd -bw dev -g path/to/dist -``` - -```cmd -bw dev -g https://ipfs.web4.near.page/ipfs/bafybeiancp5im5nfkdki3cfvo7ownl2knjovqh7bseegk4zvzsl4buryoi +"gateway": { + "bundleUrl": "https://ipfs.web4.near.page/ipfs/bafybeibe63hqugbqr4writdxgezgl5swgujay6t5uptw2px7q63r7crk2q/", + "tagName": "near-social-viewer" +} ``` This will automatically start the local gateway serving your widgets through the provided dist. diff --git a/examples/single/bos.config.json b/examples/single/bos.config.json index 273c159..7dec33c 100644 --- a/examples/single/bos.config.json +++ b/examples/single/bos.config.json @@ -8,5 +8,9 @@ "aliases": ["./aliases.testnet.json"], "index": "quickstart.testnet/widget/home" } - } + }, + "gateway": { + "bundleUrl": "https://ipfs.web4.near.page/ipfs/bafybeibe63hqugbqr4writdxgezgl5swgujay6t5uptw2px7q63r7crk2q/", + "tagName": "near-social-viewer" + } } diff --git a/lib/cli.ts b/lib/cli.ts index 0c576f9..6fe95de 100644 --- a/lib/cli.ts +++ b/lib/cli.ts @@ -26,7 +26,6 @@ async function run() { .option("-n, --network ", "network to build for", "mainnet") .option("-l, --loglevel ", "log level (ERROR, WARN, INFO, DEV, BUILD, DEBUG)", "DEV") .option("-p, --port ", "Port to run the server on", "8080") - .option("-g, --gateway ", "Path to custom gateway dist", true) .option("--no-gateway", "Disable the gateway") .option("--no-hot", "Disable hot reloading") .option("--no-open", "Disable opening the browser") @@ -62,7 +61,6 @@ async function run() { .option("-n, --network ", "network to build for", "mainnet") .option("-l, --loglevel ", "log level (ERROR, WARN, INFO, DEV, BUILD, DEBUG)") .option("-p, --port ", "Port to run the server on", "8080") - .option("-g, --gateway ", "Path to custom gateway dist", true) .option("--no-gateway", "Disable the gateway") .option("--no-hot", "Disable hot reloading") .option("--no-open", "Disable opening the browser") diff --git a/lib/config.ts b/lib/config.ts index b91f387..d5dba7f 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -2,6 +2,7 @@ import Joi from 'joi'; import { readJson } from '@/lib/utils/fs'; import { Network } from './types'; import path from 'path'; +import { GatewayConfig } from './dev'; export interface BaseConfig { account?: string; // default account to serve widgets from @@ -20,6 +21,7 @@ export interface BaseConfig { index?: string; // widget to use as index aliasPrefix?: string; // prefix to use for aliases, default is "alias" aliasesContainsPrefix?: boolean; // aliases keys contains prefix (default is false) + gateway?: GatewayConfig // gateway config object } interface NetworkConfig { @@ -62,6 +64,10 @@ const baseConfigSchema = Joi.object({ aliasPrefix: Joi.string().allow(null), aliasesContainsPrefix: Joi.boolean().allow(null), index: Joi.string().allow(null), + gateway: Joi.object({ + tagName: Joi.string(), + bundleUrl: Joi.string(), + }).and('tagName', 'bundleUrl').allow(null), }); const networkConfigSchema = Joi.object({ diff --git a/lib/dev.ts b/lib/dev.ts index e720055..0d4de13 100644 --- a/lib/dev.ts +++ b/lib/dev.ts @@ -17,16 +17,28 @@ var appDevOptions: null | DevOptions = null; let io: null | IoServer = null; let fileWatcher: null | FSWatcher = null; +export const DEFAULT_GATEWAY = { + enabled: true, + bundleUrl: "https://ipfs.web4.near.page/ipfs/bafybeibe63hqugbqr4writdxgezgl5swgujay6t5uptw2px7q63r7crk2q/", + tagName: "near-social-viewer" +}; + export type DevOptions = { port?: number; // port to run dev server hot?: boolean; // enable hot reloading open?: boolean; // open browser network?: Network; // network to use - gateway?: string | boolean; // path to custom gateway dist, or false to disable + gateway?: boolean; // path to custom gateway dist, or false to disable index?: string; // widget to use as index output?: string; // output directory }; +export type GatewayConfig = { + enabled: boolean; + tagName: string; + bundleUrl: string; +}; + /** * Build and watch app according to bos.config.json * @@ -38,8 +50,10 @@ export async function dev(src: string, dest: string, opts: DevOptions) { const dist = path.join(src, dest); const devJsonPath = path.join(dist, "bos-loader.json"); - // Build the app for the first time + // Build the app for the first timo + const config = await loadConfig(src, opts.network); + let devJson = await generateApp(src, dist, config, opts); await writeJson(devJsonPath, devJson); @@ -55,7 +69,10 @@ export async function dev(src: string, dest: string, opts: DevOptions) { appDevJsonPath = devJsonPath; opts.output = dist; appDevOptions = opts; - const server = startDevServer(appSrcs, appDists, appDevJsonPath, appDevOptions); + + const gatewayObject: GatewayConfig = buildGatewayObject(opts.gateway, config.gateway) + + const server = startDevServer(appSrcs, appDists, appDevJsonPath, appDevOptions, gatewayObject); // Start the socket server if hot reload is enabled if (opts.hot) { @@ -236,3 +253,22 @@ async function generateDevJson(src: string, config: BaseConfig): Promise { + startServer(server, opts, gateway, () => { const postData = JSON.stringify({ srcs: srcs.map((src) => path.resolve(src)), dists: dists.map((dist) => path.resolve(dist)) }); const options = { hostname: '127.0.0.1', @@ -96,8 +95,9 @@ export function startDevServer(srcs: string[], dists: string[], devJsonPath: str * (separated out to enable endpoint testing) * @param opts * @param devJsonPath + * @param gateway */ -export function createApp(devJsonPath: string, opts: DevOptions): Express.Application { +export function createApp(devJsonPath: string, opts: DevOptions, gateway: GatewayConfig): Express.Application { const app = express(); log.success("HTTP server setup successfully."); @@ -220,18 +220,15 @@ export function createApp(devJsonPath: string, opts: DevOptions): Express.Applic */ app.all('/api/proxy-rpc', proxyMiddleware(RPC_URL[opts.network])); - if (opts.gateway) { + if (gateway.enabled) { log.debug("Setting up gateway..."); if (opts.index) { - log.debug("Index provided. Using new gateway setup."); + // use new path - let gatewayUrl = typeof opts.gateway === 'string' ? opts.gateway : DEFAULT_REMOTE_GATEWAY_URL; - const isLocalPath = !gatewayUrl.startsWith('http'); - gatewayUrl = gatewayUrl.replace(/\/$/, ''); // remove trailing slash - opts.gateway = gatewayUrl; // standardize to url string + const isLocalPath = !gateway.bundleUrl.startsWith('http'); - initializeGateway(gatewayUrl, isLocalPath, opts, devJsonPath); + initializeGateway(gateway, isLocalPath, opts, devJsonPath); // Middleware to ensure gateway is initialized before handling requests app.use(async (req, res, next) => { @@ -255,7 +252,7 @@ export function createApp(devJsonPath: string, opts: DevOptions): Express.Applic log.debug(`Request for: ${req.path}`); if (isLocalPath) { - const fullUrl = path.join(__dirname, gatewayUrl, req.path); + const fullUrl = path.join(__dirname, gateway.bundleUrl, req.path); try { log.debug(`Attempting to serve file from local path: ${fullUrl}`); @@ -273,9 +270,9 @@ export function createApp(devJsonPath: string, opts: DevOptions): Express.Applic } } } else { - log.debug(`Proxying request to: ${gatewayUrl}${req.path}`); + log.debug(`Proxying request to: ${gateway.bundleUrl}${req.path}`); // Proxy the request to the remote gateway - proxy.web(req, res, { target: `${gatewayUrl}${req.path}`, agent: httpsAgent }); + proxy.web(req, res, { target: `${gateway.bundleUrl}${req.path}`, agent: httpsAgent }); } } else { // what about images? @@ -335,8 +332,8 @@ export function createApp(devJsonPath: string, opts: DevOptions): Express.Applic return app; } -function initializeGateway(gatewayUrl: string, isLocalPath: boolean, opts: DevOptions, devJsonPath: string) { - gatewayInitPromise = setupGateway(gatewayUrl, isLocalPath, opts, devJsonPath) +function initializeGateway(gateway: GatewayConfig, isLocalPath: boolean, opts: DevOptions, devJsonPath: string) { + gatewayInitPromise = setupGateway(gateway, isLocalPath, opts, devJsonPath) .then(() => { log.success("Gateway initialized successfully."); }) @@ -346,12 +343,12 @@ function initializeGateway(gatewayUrl: string, isLocalPath: boolean, opts: DevOp }); } -async function setupGateway(gatewayUrl: string, isLocalPath: boolean, opts: DevOptions, devJsonPath: string) { - log.debug(`Setting up ${isLocalPath ? "local " : ""}gateway: ${gatewayUrl}`); +async function setupGateway(gateway: GatewayConfig, isLocalPath: boolean, opts: DevOptions, devJsonPath: string) { + log.debug(`Setting up ${isLocalPath ? "local " : ""}gateway: ${gateway.bundleUrl}`); const manifestUrl = isLocalPath - ? path.join(gatewayUrl, "/asset-manifest.json") - : `${gatewayUrl}/asset-manifest.json`; + ? path.join(gateway.bundleUrl, "/asset-manifest.json") + : `${gateway.bundleUrl}/asset-manifest.json`; try { log.debug(`Fetching manifest from: ${manifestUrl}`); @@ -360,8 +357,8 @@ async function setupGateway(gatewayUrl: string, isLocalPath: boolean, opts: DevO log.debug(`Received manifest. Modifying HTML...`); const htmlContent = await readFile(path.join(__dirname, '../../public/index.html'), 'utf8'); - const dependencies = manifest.entrypoints.map((entrypoint: string) => isLocalPath ? `${entrypoint}` : `${gatewayUrl}/${entrypoint}`); - modifiedHtml = modifyIndexHtml(htmlContent, opts, dependencies); + const dependencies = manifest.entrypoints.map((entrypoint: string) => isLocalPath ? `${entrypoint}` : `${gateway.bundleUrl}/${entrypoint}`); + modifiedHtml = modifyIndexHtml(htmlContent, opts, dependencies, gateway); // log.debug(`Importing packages...`); <-- this used jpsm to create import map for wallet selector // modifiedHtml = await importPackages(modifiedHtml); // but didn't want it to run each time dev server started, so commented out @@ -402,10 +399,11 @@ async function fetchManifest(url: string): Promise { * Starts BosLoader Server and optionally opens gateway in browser * @param server http server * @param opts DevOptions + * @param gateway gateway object */ -export function startServer(server, opts, sendAddApps) { +export function startServer(server, opts, gateway, sendAddApps) { server.listen(opts.port, "127.0.0.1", () => { - if (opts.gateway && opts.open) { + if (gateway.enabled && opts.open) { // open gateway in browser let start = process.platform == "darwin" @@ -419,7 +417,7 @@ export function startServer(server, opts, sendAddApps) { log.log(` ┌─────────────────────────────────────────────────────────────┐ │ BosLoader Server is Up and Running │ - │ │${opts.gateway + │ │${gateway.enabled ? ` │ ➜ Local Gateway: \u001b[32mhttp://127.0.0.1:${opts.port}\u001b[0m │` : "" @@ -451,4 +449,4 @@ export function startServer(server, opts, sendAddApps) { process.exit(1); } }); -} +} \ No newline at end of file diff --git a/tests/unit/dev.ts b/tests/unit/dev.ts index 692876b..07dc403 100644 --- a/tests/unit/dev.ts +++ b/tests/unit/dev.ts @@ -1,6 +1,6 @@ import { buildApp } from "@/lib/build"; import { DEFAULT_CONFIG, loadConfig } from "@/lib/config"; -import { dev, DevOptions, addApps } from "@/lib/dev"; +import { dev, DevOptions, addApps, DEFAULT_GATEWAY } from "@/lib/dev"; import { Logger, LogLevel } from "@/lib/logger"; import { startDevServer } from "@/lib/server"; import { startSocket } from "@/lib/socket"; @@ -56,11 +56,13 @@ describe("dev", () => { expect(loadConfig).toHaveBeenCalledWith(mockSrc, mockOpts.network); }); - it("should call generateApp with src, dist, config, opts, and devJsonPath", async () => { + it("should call generateApp with src, dist, config, opts, gateway, and devJsonPath", async () => { await dev(mockSrc, "build", mockOpts); const mockDist = path.join(mockSrc, 'build'); const mockDevJsonPath = path.join(mockSrc, 'build', 'bos-loader.json'); - expect(startDevServer).toHaveBeenCalledWith([mockSrc], [mockDist], mockDevJsonPath, mockOpts); + const mockGateway = DEFAULT_GATEWAY; + + expect(startDevServer).toHaveBeenCalledWith([mockSrc], [mockDist], mockDevJsonPath, mockOpts, mockGateway); }); it("should start the socket server if hot reload is enabled", async () => { diff --git a/tests/unit/gateway.ts b/tests/unit/gateway.ts index 201958d..d66f99c 100644 --- a/tests/unit/gateway.ts +++ b/tests/unit/gateway.ts @@ -1,4 +1,4 @@ -import { DevOptions } from '@/lib/dev'; +import { DEFAULT_GATEWAY, DevOptions } from '@/lib/dev'; import { handleReplacements, modifyIndexHtml } from '@/lib/gateway'; import { Logger, LogLevel } from "@/lib/logger"; import { Network } from '@/lib/types'; @@ -50,7 +50,7 @@ describe("gateway", () => { it('adds script tags for dependencies', () => { const dependencies = ['dep1.js', 'dep2.js']; - const result = modifyIndexHtml(baseHtml, mockOpts, dependencies); + const result = modifyIndexHtml(baseHtml, mockOpts, dependencies, DEFAULT_GATEWAY); const dom = new JSDOM(result); const scripts = dom.window.document.querySelectorAll('script'); @@ -61,7 +61,7 @@ describe("gateway", () => { }); it('creates and configures near-social-viewer element', () => { - const result = modifyIndexHtml(baseHtml, mockOpts, []); + const result = modifyIndexHtml(baseHtml, mockOpts, [], DEFAULT_GATEWAY); const dom = new JSDOM(result); const viewer = dom.window.document.querySelector('near-social-viewer'); @@ -72,7 +72,7 @@ describe("gateway", () => { }); it('sets correct config attribute on near-social-viewer', () => { - const result = modifyIndexHtml(baseHtml, mockOpts, []); + const result = modifyIndexHtml(baseHtml, mockOpts, [], DEFAULT_GATEWAY); const dom = new JSDOM(result); const viewer = dom.window.document.querySelector('near-social-viewer'); @@ -82,7 +82,7 @@ describe("gateway", () => { }); it('appends near-social-viewer to the container', () => { - const result = modifyIndexHtml(baseHtml, mockOpts, []); + const result = modifyIndexHtml(baseHtml, mockOpts, [], DEFAULT_GATEWAY); const dom = new JSDOM(result); const container = dom.window.document.getElementById('bw-root'); @@ -97,7 +97,7 @@ describe("gateway", () => { network: 'mainnet' as Network, hot: false }; - const result = modifyIndexHtml(baseHtml, customOpts, []); + const result = modifyIndexHtml(baseHtml, customOpts, [], DEFAULT_GATEWAY); const dom = new JSDOM(result); const viewer = dom.window.document.querySelector('near-social-viewer'); @@ -108,4 +108,16 @@ describe("gateway", () => { const config = JSON.parse(viewer.getAttribute('config')); expect(config.dev.hotreload.enabled).toBe(false); }); + + it('uses the tag name from the gateway config', () => { + const tagName = "test-element" + const gatewayConfig = DEFAULT_GATEWAY; + gatewayConfig.tagName = tagName + + const result = modifyIndexHtml(baseHtml, mockOpts, [], gatewayConfig); + const dom = new JSDOM(result); + const container = dom.window.document.querySelector(tagName); + + expect(container.getAttribute('src')).toBe(mockOpts.index); + }); }); \ No newline at end of file diff --git a/tests/unit/server.ts b/tests/unit/server.ts index 1374d49..b6c538a 100644 --- a/tests/unit/server.ts +++ b/tests/unit/server.ts @@ -1,14 +1,15 @@ -import { DevOptions } from './../../lib/dev'; +import { buildGatewayObject, DEFAULT_GATEWAY, DevOptions } from '@/lib/dev'; import { Logger, LogLevel } from "@/lib/logger"; import { createApp, RPC_URL } from '@/lib/server'; import supertest from 'supertest'; import { TextEncoder } from 'util'; -import { Network } from './../../lib/types'; +import { Network } from '@/lib/types'; import { fetchJson } from "@near-js/providers"; import * as gateway from '@/lib/gateway'; import { vol } from 'memfs'; import path from 'path'; + jest.mock('fs', () => require('memfs').fs); jest.mock('fs/promises', () => require('memfs').fs.promises); jest.mock("@near-js/providers"); @@ -45,9 +46,9 @@ describe('createApp', () => { global.log = new Logger(LogLevel.DEV); - app = createApp(devJsonPath, opts); + app = createApp(devJsonPath, opts, DEFAULT_GATEWAY); - app = createApp(devJsonPath, opts); + app = createApp(devJsonPath, opts, DEFAULT_GATEWAY); }); afterEach(() => { @@ -57,14 +58,15 @@ describe('createApp', () => { }); it.skip('should set up the app correctly when opts.gateway is a valid local path', () => { - const mockGatewayPath = "/mock_gateway_1"; - opts.gateway = `${mockGatewayPath}/dist`; - vol.mkdirSync(path.join(mockGatewayPath, 'dist'), { recursive: true }); - vol.writeFileSync(path.join(mockGatewayPath, 'dist', 'index.html'), ''); + const gatewayObject = DEFAULT_GATEWAY + gatewayObject.bundleUrl = '/mock_gateway_1/dist' + + vol.mkdirSync(path.join(gatewayObject.bundleUrl, 'dist'), { recursive: true }); + vol.writeFileSync(path.join(gatewayObject.bundleUrl, 'dist', 'index.html'), ''); jest.spyOn(gateway, 'modifyIndexHtml').mockReturnValue('modified'); - app = createApp(devJsonPath, opts); + app = createApp(devJsonPath, opts, gatewayObject); expect(app).toBeDefined(); return supertest(app) @@ -75,12 +77,14 @@ describe('createApp', () => { }); it.skip('should log an error when opts.gateway is an invalid local path', () => { - const mockGatewayPath = '/invalid/gateway/path'; - opts.gateway = mockGatewayPath; + const gatewayObject = DEFAULT_GATEWAY + gatewayObject.bundleUrl = '/invalid/gateway/path'; + + buildGatewayObject(true, gatewayObject) const logSpy = jest.spyOn(global.log, 'error'); - app = createApp(devJsonPath, opts); + app = createApp(devJsonPath, opts, gatewayObject); expect(app).toBeDefined(); expect(logSpy).toHaveBeenCalledWith("Gateway not found. Skipping..."); });