From c5185fdc8900eebeab4cddb1eb012c1987064e72 Mon Sep 17 00:00:00 2001 From: Sebastian Martinez Date: Mon, 12 Aug 2024 09:35:24 +0200 Subject: [PATCH] Provide a type safe config with sane defaults on type level --- .env.example | 7 + config/custom-environment-variables.json | 16 -- config/default.json | 14 +- config/test.json | 6 +- http-client/lib/fetcher.ts | 12 +- http-client/lib/shared.ts | 32 ++++ http-client/vite.config.ts | 50 ++++-- module.d.ts | 17 +- package-lock.json | 41 ++++- package.json | 7 +- playwright.config.ts | 2 +- src/lib/router.ts | 14 +- src/views/nodes/SeedSelector.svelte | 4 +- src/views/nodes/router.ts | 4 +- src/views/projects/Header/CloneButton.svelte | 4 +- src/views/projects/History.svelte | 2 +- src/views/projects/Share.svelte | 2 +- src/views/projects/router.ts | 2 +- vite.config.ts | 174 +++++++++++++------ 19 files changed, 269 insertions(+), 141 deletions(-) create mode 100644 .env.example delete mode 100644 config/custom-environment-variables.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000..c721cacc90 --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +PUBLIC_EXPLORER_URL="https://app.radicle.xyz/nodes/$host/$rid$path" +SUPPORTED_API_VERSION: "4.0.0" +DEFAULT_HTTPD_API_PORT: 443 +DEFAULT_HTTPD_SCHEME: "https" +HISTORY_COMMITS_PER_PAGE: 30 +SUPPORT_WEBSITE: "https://radicle.xyz" +PREFERRED_SEEDS: '[{ "hostname": "seed.radicle.garden", "port": 443, "scheme": "https" }]' diff --git a/config/custom-environment-variables.json b/config/custom-environment-variables.json deleted file mode 100644 index f2a45acb7a..0000000000 --- a/config/custom-environment-variables.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "nodes": { - "fallbackPublicExplorer": "FALLBACK_PUBLIC_EXPLORER", - "apiVersion": "API_VERSION", - "defaultHttpdPort": "DEFAULT_HTTPD_PORT", - "defaultHttpdScheme": "DEFAULT_HTTPD_SCHEME" - }, - "source": { - "commitsPerPage": "COMMITS_PER_PAGE" - }, - "supportWebsite": "SUPPORT_WEBSITE", - "fallbackPreferredSeed": { - "__name": "PREFERRED_SEEDS", - "__format": "json" - } -} diff --git a/config/default.json b/config/default.json index ade744673a..4a3ed0f986 100644 --- a/config/default.json +++ b/config/default.json @@ -1,13 +1,9 @@ { - "nodes": { - "fallbackPublicExplorer": "https://app.radicle.xyz/nodes/$host/$rid$path", - "apiVersion": "4.0.0", - "defaultHttpdPort": 443, - "defaultHttpdScheme": "https" - }, - "source": { - "commitsPerPage": 30 - }, + "publicExplorerUrl": "https://app.radicle.xyz/nodes/$host/$rid$path", + "supportedApiVersion": "4.0.0", + "defaultHttpdApiPort": 443, + "defaultHttpdScheme": "https", + "historyCommitsPerPage": 30, "supportWebsite": "https://radicle.zulipchat.com", "preferredSeeds": [ { diff --git a/config/test.json b/config/test.json index d8439a6e7d..411e9cda63 100644 --- a/config/test.json +++ b/config/test.json @@ -1,8 +1,6 @@ { - "nodes": { - "defaultHttpdPort": 8081, - "defaultHttpdScheme": "http" - }, + "defaultHttpdApiPort": 8081, + "defaultHttpdScheme": "http", "preferredSeeds": [ { "hostname": "127.0.0.1", diff --git a/http-client/lib/fetcher.ts b/http-client/lib/fetcher.ts index cce890853c..2c2dccfc91 100644 --- a/http-client/lib/fetcher.ts +++ b/http-client/lib/fetcher.ts @@ -61,14 +61,14 @@ export class ResponseParseError extends Error { let description: string; if ( apiVersion === undefined || - compare(apiVersion, config.nodes.apiVersion, "<") + compare(apiVersion, config.supportedApiVersion, "<") ) { - description = `The node you are fetching from seems to be outdated, make sure the httpd API version is at least ${config.nodes.apiVersion} currently ${apiVersion ?? "unknown"}.`; + description = `The node you are fetching from seems to be outdated, make sure the httpd API version is at least ${config.supportedApiVersion} currently ${apiVersion ?? "unknown"}.`; } else if ( - config.nodes.apiVersion === undefined || - compare(apiVersion, config.nodes.apiVersion, ">") + config.supportedApiVersion === undefined || + compare(apiVersion, config.supportedApiVersion, ">") ) { - description = `The web client you are using is outdated, make sure it supports at least ${apiVersion} to interact with this node currently ${config.nodes.apiVersion ?? "unknown"}.`; + description = `The web client you are using is outdated, make sure it supports at least ${apiVersion} to interact with this node currently ${config.supportedApiVersion ?? "unknown"}.`; } else { description = "This is usually due to a version mismatch between the seed and the web interface."; @@ -122,7 +122,7 @@ export class Fetcher { ): Promise> { const response = await this.fetch({ ...params, - query: { ...params.query, v: config.nodes.apiVersion }, + query: { ...params.query, v: config.supportedApiVersion }, }); if (!response.ok) { diff --git a/http-client/lib/shared.ts b/http-client/lib/shared.ts index b9e96dbd25..7428134834 100644 --- a/http-client/lib/shared.ts +++ b/http-client/lib/shared.ts @@ -121,3 +121,35 @@ export const authorSchema = object({ id: string(), alias: string().optional(), }); + +export type WebConfig = z.infer; + +export const webConfigSchema = object({ + publicExplorerUrl: string().default( + "https://app.radicle.xyz/nodes/$host/$rid$path", + ), + supportedApiVersion: string().default("4.0.0"), + defaultHttpdApiPort: number().default(443), + defaultHttpdScheme: string().default("https"), + historyCommitsPerPage: number().default(30), + supportWebsite: string().default("https://radicle.zulipchat.com"), + preferredSeeds: array( + object({ hostname: string(), port: number(), scheme: string() }), + ).default([ + { + hostname: "ash.radicle.garden", + port: 443, + scheme: "https", + }, + { + hostname: "seed.radicle.xyz", + port: 443, + scheme: "https", + }, + { + hostname: "seed.radicle.garden", + port: 443, + scheme: "https", + }, + ]), +}); diff --git a/http-client/vite.config.ts b/http-client/vite.config.ts index aeca19da4b..3643b0d7be 100644 --- a/http-client/vite.config.ts +++ b/http-client/vite.config.ts @@ -1,25 +1,37 @@ -import nodeConfig from "config"; import path from "node:path"; import virtual from "vite-plugin-virtual"; +import { configureAdapters } from "../vite.config"; import { defineConfig } from "vite"; +import { loadConfig } from "zod-config"; +import { webConfigSchema } from "./lib/shared"; -export default defineConfig({ - plugins: [ - virtual({ - "virtual:config": nodeConfig.util.toObject(), - }), - ], - test: { - environment: "node", - include: ["http-client/tests/*.test.ts"], - reporters: "verbose", - globalSetup: "./tests/support/globalSetup", - }, - resolve: { - alias: { - "@tests": path.resolve("./tests"), - "@app": path.resolve("./src"), - "@http-client": path.resolve("./http-client"), +export default defineConfig(async () => { + const adapters = await configureAdapters(); + const config = await loadConfig({ + schema: webConfigSchema, + adapters, + logger: { + warn: message => console.warn(`WARN [config]: ${message}`), }, - }, + }); + return { + plugins: [ + virtual({ + "virtual:config": config, + }), + ], + test: { + environment: "node", + include: ["http-client/tests/*.test.ts"], + reporters: "verbose", + globalSetup: "./tests/support/globalSetup", + }, + resolve: { + alias: { + "@tests": path.resolve("./tests"), + "@app": path.resolve("./src"), + "@http-client": path.resolve("./http-client"), + }, + }, + }; }); diff --git a/module.d.ts b/module.d.ts index b1f4abcb6b..a23193eb10 100644 --- a/module.d.ts +++ b/module.d.ts @@ -1,19 +1,6 @@ declare module "virtual:*" { - const config: { - nodes: { - apiVersion: string; - fallbackPublicExplorer: string; - defaultHttpdPort: number; - defaultLocalHttpdPort: number; - defaultHttpdScheme: string; - }; - source: { - commitsPerPage: number; - }; - reactions: string[]; - supportWebsite: string; - preferredSeeds: BaseUrl[]; - }; + import type { WebConfig } from "@http-client/lib/shared"; + const config: WebConfig; export default config; } diff --git a/package-lock.json b/package-lock.json index 9d92a12e9d..fd2320a5b2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "hasInstallScript": true, "dependencies": { + "@absxn/process-env-parser": "^1.1.1", "@efstajas/svelte-stored-writable": "^0.2.0", "@radicle/gray-matter": "4.1.0", "@wooorm/starry-night": "^3.4.0", @@ -31,7 +32,8 @@ "plausible-tracker": "^0.3.9", "svelte": "^4.2.18", "twemoji": "^14.0.2", - "zod": "^3.23.8" + "zod": "^3.23.8", + "zod-config": "^0.0.5" }, "devDependencies": { "@eslint/js": "^9.8.0", @@ -70,8 +72,16 @@ }, "engines": { "node": ">=18.17.1" + }, + "peerDependencies": { + "dotenv": "^16.4.5" } }, + "node_modules/@absxn/process-env-parser": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@absxn/process-env-parser/-/process-env-parser-1.1.1.tgz", + "integrity": "sha512-QhkpC22/vs8zi9gbcli/7OUaQyQeuRTa/r5QAzkB58giF8wxES2DbC9ubePm0wjcIN6sjhpOS/ot36XASon6Iw==" + }, "node_modules/@ampproject/remapping": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", @@ -2088,6 +2098,18 @@ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.1.6.tgz", "integrity": "sha512-cTOAhc36AalkjtBpfG6O8JimdTMWNXjiePT2xQH/ppBGi/4uIpmj8eKyIkMJErXWARyINV/sB38yf8JCLF5pbQ==" }, + "node_modules/dotenv": { + "version": "16.4.5", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", + "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/entities": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", @@ -5389,6 +5411,23 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "node_modules/zod-config": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/zod-config/-/zod-config-0.0.5.tgz", + "integrity": "sha512-tW7J4bwVPYD0eYeaDzIjEpv188118H+uCbb+DaBRIGwuZNCSL0WyMmpeOmgMpbsGzrJ9YjtC5XFpyWlj27vd8g==", + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "dotenv": ">=15", + "zod": "^3.x" + }, + "peerDependenciesMeta": { + "dotenv": { + "optional": true + } + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/package.json b/package.json index 08269156be..dfdfb01775 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "wait-on": "^7.2.0" }, "dependencies": { + "@absxn/process-env-parser": "^1.1.1", "@efstajas/svelte-stored-writable": "^0.2.0", "@radicle/gray-matter": "4.1.0", "@wooorm/starry-night": "^3.4.0", @@ -77,6 +78,10 @@ "plausible-tracker": "^0.3.9", "svelte": "^4.2.18", "twemoji": "^14.0.2", - "zod": "^3.23.8" + "zod": "^3.23.8", + "zod-config": "^0.0.5" + }, + "peerDependencies": { + "dotenv": "^16.4.5" } } diff --git a/playwright.config.ts b/playwright.config.ts index c112919425..c52ff4ca88 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -92,7 +92,7 @@ const config: PlaywrightTestConfig = { command: "npm run start -- --strictPort --port 3002", port: 3002, // eslint-disable-next-line @typescript-eslint/naming-convention - env: { COMMITS_PER_PAGE: "4" }, + env: { HISTORY_COMMITS_PER_PAGE: "4" }, }, ], }; diff --git a/src/lib/router.ts b/src/lib/router.ts index a982458750..36f01f9afa 100644 --- a/src/lib/router.ts +++ b/src/lib/router.ts @@ -148,15 +148,15 @@ export async function replace(newRoute: Route): Promise { function extractBaseUrl(hostAndPort: string): BaseUrl { if ( hostAndPort === "radicle.local" || - hostAndPort === `radicle.local:${config.nodes.defaultHttpdPort}` || + hostAndPort === `radicle.local:${config.defaultHttpdApiPort}` || hostAndPort === "0.0.0.0" || - hostAndPort === `0.0.0.0:${config.nodes.defaultHttpdPort}` || + hostAndPort === `0.0.0.0:${config.defaultHttpdApiPort}` || hostAndPort === "127.0.0.1" || - hostAndPort === `127.0.0.1:${config.nodes.defaultHttpdPort}` + hostAndPort === `127.0.0.1:${config.defaultHttpdApiPort}` ) { return { hostname: "127.0.0.1", - port: config.nodes.defaultHttpdPort, + port: config.defaultHttpdApiPort, scheme: "http", }; } else if (hostAndPort.includes(":")) { @@ -167,13 +167,13 @@ function extractBaseUrl(hostAndPort: string): BaseUrl { scheme: utils.isLocal(hostname) || utils.isOnion(hostname) ? "http" - : config.nodes.defaultHttpdScheme, + : config.defaultHttpdScheme, }; } else { return { hostname: hostAndPort, - port: config.nodes.defaultHttpdPort, - scheme: config.nodes.defaultHttpdScheme, + port: config.defaultHttpdApiPort, + scheme: config.defaultHttpdScheme, }; } } diff --git a/src/views/nodes/SeedSelector.svelte b/src/views/nodes/SeedSelector.svelte index 46cd047305..5045fb4fda 100644 --- a/src/views/nodes/SeedSelector.svelte +++ b/src/views/nodes/SeedSelector.svelte @@ -47,8 +47,8 @@ loading = true; const seed = { hostname: seedAddressInput.trim(), - port: config.nodes.defaultHttpdPort, - scheme: config.nodes.defaultHttpdScheme, + port: config.defaultHttpdApiPort, + scheme: config.defaultHttpdScheme, }; validationMessage = await validateInput(seed); if (validationMessage === undefined) { diff --git a/src/views/nodes/router.ts b/src/views/nodes/router.ts index d15d0e0b0e..95e685a8a4 100644 --- a/src/views/nodes/router.ts +++ b/src/views/nodes/router.ts @@ -31,9 +31,9 @@ export interface NodesLoadedRoute { } export function nodePath(baseUrl: BaseUrl) { - const port = baseUrl.port ?? config.nodes.defaultHttpdPort; + const port = baseUrl.port ?? config.defaultHttpdApiPort; - if (port === config.nodes.defaultHttpdPort) { + if (port === config.defaultHttpdApiPort) { return `/nodes/${baseUrl.hostname}`; } else { return `/nodes/${baseUrl.hostname}:${port}`; diff --git a/src/views/projects/Header/CloneButton.svelte b/src/views/projects/Header/CloneButton.svelte index 5ec1ca6712..1c76c96e20 100644 --- a/src/views/projects/Header/CloneButton.svelte +++ b/src/views/projects/Header/CloneButton.svelte @@ -19,8 +19,8 @@ $: radCloneUrl = `rad clone ${id}`; $: portFragment = - baseUrl.scheme === config.nodes.defaultHttpdScheme && - baseUrl.port === config.nodes.defaultHttpdPort + baseUrl.scheme === config.defaultHttpdScheme && + baseUrl.port === config.defaultHttpdApiPort ? "" : `:${baseUrl.port}`; $: gitCloneUrl = `git clone ${baseUrl.scheme}://${ diff --git a/src/views/projects/History.svelte b/src/views/projects/History.svelte index 3e92055b2d..4cccb3384d 100644 --- a/src/views/projects/History.svelte +++ b/src/views/projects/History.svelte @@ -61,7 +61,7 @@ const response = await api.project.getAllCommits(project.id, { parent: allCommitHeaders[0].id, page, - perPage: config.source.commitsPerPage, + perPage: config.historyCommitsPerPage, }); allCommitHeaders = [...allCommitHeaders, ...response]; } catch (e) { diff --git a/src/views/projects/Share.svelte b/src/views/projects/Share.svelte index 5feea5d780..771372a452 100644 --- a/src/views/projects/Share.svelte +++ b/src/views/projects/Share.svelte @@ -13,7 +13,7 @@ }, 1000); async function copy() { - const text = new URL(config.nodes.fallbackPublicExplorer).origin.concat( + const text = new URL(config.publicExplorerUrl).origin.concat( window.location.pathname, ); await toClipboard(text); diff --git a/src/views/projects/router.ts b/src/views/projects/router.ts index f3de93655c..8576d416fa 100644 --- a/src/views/projects/router.ts +++ b/src/views/projects/router.ts @@ -539,7 +539,7 @@ async function loadHistoryView( await api.project.getAllCommits(project.id, { parent: commitId, page: 0, - perPage: config.source.commitsPerPage, + perPage: config.historyCommitsPerPage, }), ]); diff --git a/vite.config.ts b/vite.config.ts index 916e6e2e64..f758d9a647 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,65 +1,133 @@ -import config from "config"; import path from "node:path"; import virtual from "vite-plugin-virtual"; +import { configDotenv } from "dotenv"; import { defineConfig } from "vite"; +import { directoryAdapter } from "zod-config/directory-adapter"; +import { jsonAdapter } from "zod-config/json-adapter"; +import { loadConfig, type Adapter } from "zod-config"; +import { parseEnvironmentVariables } from "@absxn/process-env-parser"; import { svelte } from "@sveltejs/vite-plugin-svelte"; -export default defineConfig({ - test: { - environment: "happy-dom", - include: ["tests/unit/**/*.test.ts"], - reporters: "verbose", - }, - plugins: [ - virtual({ - "virtual:config": config.util.toObject(), - }), - svelte({ - // Reference: https://github.com/sveltejs/vite-plugin-svelte/issues/270#issuecomment-1033190138 - dynamicCompileOptions({ filename }) { - if ( - path.basename(filename) === "Clipboard.svelte" || - path.basename(filename) === "ExternalLink.svelte" || - path.basename(filename) === "Icon.svelte" - ) { - return { customElement: true }; - } - }, - compilerOptions: { dev: process.env.NODE_ENV !== "production" }, - }), - ], - server: { - host: "localhost", - port: 3000, - watch: { - // reference: https://stackoverflow.com/a/75238360 - useFsEvents: false, +import { webConfigSchema } from "./http-client/lib/shared"; + +export default defineConfig(async () => { + const adapters = await configureAdapters(); + const config = await loadConfig({ + schema: webConfigSchema, + adapters, + logger: { + warn: message => console.warn(`WARN [config]: ${message}`), }, - }, - resolve: { - alias: { - "@app": path.resolve("./src"), - "@public": path.resolve("./public"), - "@http-client": path.resolve("./http-client"), - "@tests": path.resolve("./tests"), + }); + return { + test: { + environment: "happy-dom", + include: ["tests/unit/**/*.test.ts"], + reporters: "verbose", }, - }, - build: { - outDir: "build", - rollupOptions: { - output: { - manualChunks: id => { - if (id.includes("lodash")) { - return "lodash"; - } else if (id.includes("katex")) { - return "katex"; - } else if (id.includes("node_modules")) { - return "vendor"; - } else if (id.includes("components")) { - return "components"; + plugins: [ + virtual({ + "virtual:config": config, + }), + svelte({ + // Reference: https://github.com/sveltejs/vite-plugin-svelte/issues/270#issuecomment-1033190138 + dynamicCompileOptions({ filename }) { + if ( + path.basename(filename) === "Clipboard.svelte" || + path.basename(filename) === "ExternalLink.svelte" || + path.basename(filename) === "Icon.svelte" + ) { + return { customElement: true }; } }, + compilerOptions: { dev: process.env.NODE_ENV !== "production" }, + }), + ], + server: { + host: "localhost", + port: 3000, + watch: { + // reference: https://stackoverflow.com/a/75238360 + useFsEvents: false, + }, + }, + resolve: { + alias: { + "@app": path.resolve("./src"), + "@public": path.resolve("./public"), + "@http-client": path.resolve("./http-client"), + "@tests": path.resolve("./tests"), + }, + }, + build: { + outDir: "build", + rollupOptions: { + output: { + manualChunks: (id: string) => { + if (id.includes("lodash")) { + return "lodash"; + } else if (id.includes("katex")) { + return "katex"; + } else if (id.includes("node_modules")) { + return "vendor"; + } else if (id.includes("components")) { + return "components"; + } + }, + }, }, }, - }, + }; }); + +export async function configureAdapters() { + return [ + // Order is important here, options overwrite previous ones + directoryAdapter({ + paths: path.resolve("./config"), + adapters: [ + { + extensions: [".json"], + adapterFactory: (path: string) => jsonAdapter({ path }), + }, + ], + }), + customEnvAdapter(), + ]; +} + +function customEnvAdapter(): Adapter { + return { + name: "custom env adapter", + read: async () => { + configDotenv(); + + const result = parseEnvironmentVariables({ + /* eslint-disable @typescript-eslint/naming-convention */ + PUBLIC_EXPLORER_URL: {}, + SUPPORTED_API_VERSION: {}, + DEFAULT_HTTPD_API_PORT: { parser: parseInt }, + DEFAULT_HTTPD_SCHEME: {}, + HISTORY_COMMITS_PER_PAGE: { parser: parseInt }, + SUPPORT_WEBSITE: {}, + PREFERRED_SEEDS: { parser: JSON.parse }, + /* eslint-enable @typescript-eslint/naming-convention */ + }); + + if (result.success) { + const env = result.env; + return { + publicExplorerUrl: env.PUBLIC_EXPLORER_URL, + supportedApiVersion: env.SUPPORTED_API_VERSION, + defaultHttpdApiPort: env.DEFAULT_HTTPD_API_PORT, + defaultHttpdScheme: env.DEFAULT_HTTPD_SCHEME, + historyCommitsPerPage: env.HISTORY_COMMITS_PER_PAGE, + supportWebsite: env.SUPPORT_WEBSITE, + preferredSeeds: env.PREFERRED_SEEDS, + }; + } else { + return {}; + } + }, + }; +}