From edbbd2f0f666f57686fbb6456f066a598771f5c6 Mon Sep 17 00:00:00 2001 From: Alexandre Tuleu Date: Sun, 13 Aug 2023 13:59:31 +0200 Subject: [PATCH] Refactors loading function * Initialize service component and handler in hooks * Dynamically watch the config instead of a reload on each page * Uses derived store for public server config * Moves service data in its own page data property * Reverts to a array to store group/services --- src/config.yml | 41 ++++---- src/hooks.client.ts | 3 + src/hooks.server.ts | 6 ++ src/lib/config.ts | 99 ----------------- src/lib/{ => server}/config.test.ts | 40 ++++--- src/lib/server/config.ts | 146 ++++++++++++++++++++++++++ src/lib/services/pihole/+service.ts | 12 +-- src/lib/services/prowlarr/+service.ts | 6 +- src/lib/services/service.ts | 7 +- src/lib/services/services.ts | 50 +++++---- src/routes/+page.server.ts | 57 ++++------ src/routes/+page.svelte | 25 +++-- src/routes/+page.ts | 36 ------- 13 files changed, 280 insertions(+), 248 deletions(-) create mode 100644 src/hooks.client.ts delete mode 100644 src/lib/config.ts rename src/lib/{ => server}/config.test.ts (54%) create mode 100644 src/lib/server/config.ts delete mode 100644 src/routes/+page.ts diff --git a/src/config.yml b/src/config.yml index 622269d..2ab3a21 100644 --- a/src/config.yml +++ b/src/config.yml @@ -2,24 +2,23 @@ title: 'Hello World !!' subtitle: 'actually, I am a new pilot.' services: - cloud: - title: '/Cloud' - subtitle: 'Private Cloud Utilities' - icon: 'fas fa-cloud' - items: - nas: - title: 'NAS' - subtitle: 'Network Attached Storage' - icon: 'fas fa-hard-drive' - target: '_blank' - url: '/NAS' - keywords: 'cloud storage files' - type: prowlarr - pihole: - title: 'PiHole' - subtitle: 'A DNS Hole' - icon: 'fas fa-hard-drive' - target: '_blank' - url: '/pihole' - type: 'pihole' - keywords: 'cloud storage files' + - title: '/Cloud' + subtitle: 'Private Cloud Utilities' + icon: 'fas fa-cloud' + items: + - title: 'NAS' + subtitle: 'Network Attached Storage' + icon: 'fas fa-hard-drive' + target: '_blank' + url: '/NAS' + type: prowlarr + + keywords: 'cloud storage files' + + - title: 'PiHole' + subtitle: 'A DNS Hole' + icon: 'fas fa-hard-drive' + target: '_blank' + url: '/pihole' + type: 'pihole' + keywords: 'cloud storage files' diff --git a/src/hooks.client.ts b/src/hooks.client.ts new file mode 100644 index 0000000..962e18c --- /dev/null +++ b/src/hooks.client.ts @@ -0,0 +1,3 @@ +import { initServices } from '$lib/services/services'; + +await initServices(); diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 962e18c..22e5bba 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,3 +1,9 @@ +import { dev } from '$app/environment'; +import { watchDymamicConfig } from '$lib/server/config'; import { initServices } from '$lib/services/services'; +if (!dev) { + watchDymamicConfig(); +} + await initServices(); diff --git a/src/lib/config.ts b/src/lib/config.ts deleted file mode 100644 index afcb8b4..0000000 --- a/src/lib/config.ts +++ /dev/null @@ -1,99 +0,0 @@ -import configData from '../config.yml'; - -export interface BrandConfig { - logo?: string; - icon?: string; - usemask?: boolean; -} - -export interface SectionConfig extends BrandConfig { - title: string; - subtitle?: string; -} - -export interface ServiceConfig extends SectionConfig { - url: string; - target?: string; - type?: string; - data?: Record; - - [x: string]: unknown; -} - -export interface ServiceGroupConfig extends SectionConfig { - items: Record; - - [x: string]: unknown; -} - -export interface Config extends SectionConfig { - services: Record; - - [x: string]: unknown; -} - -type DeepRequired = T extends object - ? { - [P in keyof T]: DeepRequired; - } - : T; - -const requiredConfig: DeepRequired = { - title: '', - subtitle: '', - services: { - default: { - title: '', - items: { - default: { - title: '', - url: '' - } - } - } - } -}; - -export function mergeConfig(a: Config, b: any): Config { - return { ...a, ...b }; -} - -export const defaultConfig: Config = { - title: 'Flanders', - - services: {} -}; - -type SPOJO = Record; - -function strip(toStrip: Type, reference: Type): Type { - const res: Type = { ...toStrip }; - const referenceNames = Object.entries(reference).map(([key, value]) => key); - - const allowAny: boolean = referenceNames.length == 1 && referenceNames[0] == 'default'; - for (const [key, value] of Object.entries(res)) { - if (referenceNames.includes(key) == false && allowAny == false) { - // remove the object - delete res[key]; - continue; - } - if (typeof value != 'object') { - continue; - } - - // it is a child object, we strip it further - const stripped: SPOJO = {}; - const childReference: SPOJO = reference[allowAny ? 'default' : key] as SPOJO; - stripped[key] = strip(value as SPOJO, childReference); - Object.assign(res, stripped); - } - return res; -} - -export function stripPrivateFields(config: Config): Config { - return strip(config, requiredConfig); -} - -export const config: Config = mergeConfig(defaultConfig, configData); - -export const clientConfig: Config = stripPrivateFields(config); diff --git a/src/lib/config.test.ts b/src/lib/server/config.test.ts similarity index 54% rename from src/lib/config.test.ts rename to src/lib/server/config.test.ts index 5950b69..eee6d0b 100644 --- a/src/lib/config.test.ts +++ b/src/lib/server/config.test.ts @@ -1,9 +1,17 @@ import { describe, expect, it } from 'vitest'; -import { config, defaultConfig, mergeConfig, type Config, stripPrivateFields } from './config'; +import { + defaultConfig, + mergeConfig, + type Config, + stripPrivateFields, + serverConfig, + clientConfig +} from './config'; describe('Config', () => { - it('should be export a build time config', () => { - expect(config).toBeTruthy(); + it('should be export a build time server and client config store', () => { + expect(serverConfig).toBeTruthy(); + expect(clientConfig).toBeTruthy(); }); it('should be able to merge with POJO', () => { @@ -34,25 +42,33 @@ describe('Config', () => { const custom: Config = { title: 'custom', secret: { secret: 'some secret' }, - services: { - top: { + services: [ + { title: 'top services', secret: 'secret', - items: { - top: { + items: [ + { title: 'top service', url: 'somewhere', - secret: 'secret' + secret: 'secret', + type: 'foo' } - } + ] } - } + ] }; const stripped: Config = stripPrivateFields(custom); expect(stripped.secret).toBeUndefined(); - expect(stripped.services.top.secret).toBeUndefined(); - expect(stripped.services.top.items.top.secret).toBeUndefined(); + expect(custom.secret).toEqual({ secret: 'some secret' }); + expect(stripped.services[0].secret).toBeUndefined(); + expect(custom.services[0].secret).toEqual('secret'); + expect(stripped.services[0].title).toEqual('top services'); + expect(stripped.services[0].items[0].secret).toBeUndefined(); + expect(custom.services[0].items[0].secret).toEqual('secret'); + expect(stripped.services[0].items[0].title).toEqual('top service'); + expect(stripped.services[0].items[0].url).toEqual('somewhere'); + expect(stripped.services[0].items[0].type).toEqual('foo'); }); }); diff --git a/src/lib/server/config.ts b/src/lib/server/config.ts new file mode 100644 index 0000000..20e1c8e --- /dev/null +++ b/src/lib/server/config.ts @@ -0,0 +1,146 @@ +import { writable, type Readable, type Writable, derived } from 'svelte/store'; +import configData from '../../config.yml'; +import { readFile, watch } from 'fs/promises'; +import * as yml from 'js-yaml'; + +export interface BrandConfig { + logo?: string; + icon?: string; + usemask?: boolean; +} + +export interface SectionConfig extends BrandConfig { + title: string; + subtitle?: string; +} + +export interface ServiceConfig extends SectionConfig { + url: string; + target?: string; + type?: string; + + [x: string]: unknown; +} + +export interface ServiceGroupConfig extends SectionConfig { + items: Array; + + [x: string]: unknown; +} + +export interface Config extends SectionConfig { + services: Array; + + [x: string]: unknown; +} + +const requiredService: Required = { + title: '', + subtitle: '', + logo: '', + icon: '', + usemask: false, + url: '', + target: '', + type: '' +}; + +const requiredServiceGroup: Required = { + title: '', + subtitle: '', + logo: '', + icon: '', + usemask: false, + items: [requiredService] +}; + +export const requiredConfig: Required = { + title: '', + subtitle: '', + logo: '', + icon: '', + usemask: false, + services: [requiredServiceGroup] +}; + +export function mergeConfig(a: Config, b: any): Config { + return { ...a, ...b }; +} + +export const defaultConfig: Config = { + title: 'Flanders', + + services: [] +}; + +type SPOJO = Record; + +function strip(toStrip: Type, reference: Type): Type { + const res: Type = { ...toStrip }; + const referenceNames = Object.entries(reference).map(([key, value]) => key); + + for (const [key, value] of Object.entries(res)) { + if (referenceNames.includes(key) == false) { + // remove the object + delete res[key]; + continue; + } + if (typeof value != 'object') { + continue; + } + + if (value instanceof Array) { + const newValue = []; + const childReference: SPOJO = (reference[key] as Array)[0]; + for (const child of value) { + newValue.push(strip(child, childReference)); + } + (res as SPOJO)[key] = newValue; + continue; + } + + // it is a child object, we strip it further + const stripped: SPOJO = {}; + const childReference: SPOJO = (reference[key] as Array)[0]; + stripped[key] = strip(value as SPOJO, childReference); + (res as SPOJO)[key] = stripped; + } + return res; +} + +export function stripPrivateFields(config: Config): Config { + return strip(config, requiredConfig); +} + +export const serverConfig: Writable = writable(mergeConfig(defaultConfig, configData)); + +export const clientConfig: Readable = derived(serverConfig, ($config) => + stripPrivateFields($config) +); + +export function watchDymamicConfig() { + const __filepath = '/dynamic/config.yml'; + + const reloadConfig = async () => { + try { + const dynamicConfig = yml.load(await readFile(__filepath, 'utf8')); + serverConfig.set(mergeConfig(defaultConfig, dynamicConfig)); + } catch (err) { + console.error('could not read or parse config: ' + err); + } + }; + + reloadConfig(); + + (async () => { + try { + const watcher = watch(__filepath); + for await (const event of watcher) { + reloadConfig(); + } + } catch (err) { + console.error('could not watch config: ' + err); + return; + } + })(); +} diff --git a/src/lib/services/pihole/+service.ts b/src/lib/services/pihole/+service.ts index 14da7a3..df33679 100644 --- a/src/lib/services/pihole/+service.ts +++ b/src/lib/services/pihole/+service.ts @@ -1,9 +1,7 @@ -import { type ServiceHandler } from '../service'; +import { type ServiceComponentPath, type ServiceHandler } from '../service'; -export const handle: ServiceHandler = ({ config }) => { - const data = {}; - if (config.apikey != undefined) { - //TODO: fetch data - } - return { data, componentPath: 'pihole/PiHoleContent.svelte' }; +export const handle: ServiceHandler = () => { + return { piholedata: 'coucou' }; }; + +export const path: ServiceComponentPath = 'pihole/PiHoleContent.svelte'; diff --git a/src/lib/services/prowlarr/+service.ts b/src/lib/services/prowlarr/+service.ts index 561cf4e..651d28a 100644 --- a/src/lib/services/prowlarr/+service.ts +++ b/src/lib/services/prowlarr/+service.ts @@ -1,5 +1,7 @@ -import type { ServiceHandler } from '../service'; +import type { ServiceComponentPath, ServiceHandler } from '../service'; export const handle: ServiceHandler = () => { - return { data: {}, componentPath: 'prowlarr/ProwlarrContent.svelte' }; + return { prowlardata: 'coucou' }; }; + +export const path: ServiceComponentPath = 'prowlarr/ProwlarrContent.svelte'; diff --git a/src/lib/services/service.ts b/src/lib/services/service.ts index c344ccc..1da2e15 100644 --- a/src/lib/services/service.ts +++ b/src/lib/services/service.ts @@ -5,7 +5,6 @@ interface ServiceHandlerArgs { config: ServiceConfig; } -export type ServiceHandler = (input: ServiceHandlerArgs) => { - data: Record; - componentPath: string; -}; +export type ServiceHandler = (input: ServiceHandlerArgs) => Record; + +export type ServiceComponentPath = string; diff --git a/src/lib/services/services.ts b/src/lib/services/services.ts index 9e75b32..143c874 100644 --- a/src/lib/services/services.ts +++ b/src/lib/services/services.ts @@ -1,33 +1,43 @@ import type { ServiceHandler } from './service'; -const services: Record = {}; +type ServiceRecord = { + handler: ServiceHandler; + component: any; +}; -function registerService(type: string, handler: ServiceHandler) { - services[type] = [handler, type]; +const services: Record = {}; + +function registerService(type: string, handler: ServiceHandler, component: any) { + services[type] = { handler, component }; } -export function getService(type: string): [ServiceHandler, string] { - const handler = services[type]; - if (handler == undefined) { - return [ - () => { - return { data: {}, componentPath: '' }; - }, - '' - ]; - } - return handler; +export function getServiceHandler(type: string): ServiceHandler | undefined { + return services[type]?.handler; +} + +export function getServiceComponent(type: string): any { + return services[type]?.component; } export async function initServices() { const services = import.meta.glob('/src/lib/services/**/+service.ts'); - for (const [path, load] of Object.entries(services)) { - const { handle } = (await load()) as any; - if (handle == undefined) { - continue; + for (const [modulePath, load] of Object.entries(services)) { + try { + const { handle, path } = (await load()) as any; + if (handle == undefined) { + throw new Error(`${modulePath} does not export 'handle'`); + } + if (path == undefined) { + throw new Error(`${modulePath} does not export 'path'`); + } + + const module = await import(/* @vite-ignore */ '/src/lib/services/' + path); + + const typeName = modulePath.slice(18, -12); + registerService(typeName, handle, module.default); + } catch (err) { + console.error(`Could not load service definition from '${modulePath}': ${err}`); } - const typeName = path.slice(18, -12); - registerService(typeName, handle); } } diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts index b1339a7..115ff9d 100644 --- a/src/routes/+page.server.ts +++ b/src/routes/+page.server.ts @@ -1,44 +1,25 @@ -import { dev } from '$app/environment'; -import { config, stripPrivateFields, type Config, type ServiceConfig } from '$lib/config'; +import { clientConfig, serverConfig } from '$lib/server/config'; +import { get } from 'svelte/store'; import type { PageServerLoad } from './$types'; -import * as yml from 'js-yaml'; -import { readFile } from 'fs/promises'; -import { getService } from '$lib/services/services'; +import { getServiceHandler } from '$lib/services/services'; -async function reloadConfig(): Promise { - if (dev) { - return config; - } - try { - const dynamic = yml.load(await readFile('/dynamic/config.yml', 'utf8')); - return { ...config, ...dynamic }; - } catch (err) { - return config; - } -} +export const load: PageServerLoad = ({ fetch }) => { + const config = get(clientConfig); + const serviceData: Array> = []; -export const load: PageServerLoad = async ({ fetch, depends }) => { - depends('app:state'); - - const serverConfig = await reloadConfig(); - const clientConfig = stripPrivateFields(serverConfig); - const groups: Array = Object.entries(serverConfig.services).map(([k, v]) => k); - for (const group of groups) { - const serverGroup = serverConfig.services[group]; - const clientGroup = clientConfig.services[group]; - - const services = Object.entries(serverGroup.items).map(([k, v]) => k); - - for (const service of services) { - const serverService = serverGroup.items[service] as ServiceConfig; - const clientService = clientGroup.items[service] as ServiceConfig; - - const [handler, type] = getService(serverService.type || ''); - - clientService.type = type; - Object.assign(clientService, handler({ fetch, config: serverService })); + for (const [i, group] of get(serverConfig).services.entries()) { + const groupData: Array = []; + serviceData.push(groupData); + for (const [j, service] of group.items.entries()) { + const handler = getServiceHandler(service.type || ''); + if (handler == undefined) { + config.services[i].items[j].type = undefined; + groupData.push(undefined); + } else { + groupData.push(handler({ fetch, config: service })); + } } } - clientConfig.timestamp = new Date(); - return { config: clientConfig, cool: true }; + + return { config, serviceData }; }; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 0843d60..23b8987 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,15 +1,7 @@

{data.config.title}

@@ -17,4 +9,19 @@

{data.config.subtitle}

{/if} -
 {JSON.stringify(data.config, null, 4)} 
+
    + {#each data.config.services as group, i} +
  • +

    {group.title}

    +
      + {#each group.items as service, j} +
    • + {i},{j} +
       {JSON.stringify(service)}
      +
       {JSON.stringify(data.serviceData[i][j])}
      +
    • + {/each} +
    +
  • + {/each} +
diff --git a/src/routes/+page.ts b/src/routes/+page.ts deleted file mode 100644 index c546c8b..0000000 --- a/src/routes/+page.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { PageLoad } from './$types'; - -export const load: PageLoad = async ({ data }) => { - const config = { ...data.config }; - const groups: Array = Object.entries(config.services).map(([k, v]) => k); - - const components: Record = {}; - - for (const group of groups) { - const services: Array = Object.entries(config.services[group].items).map( - ([k, v]) => k - ); - for (const s of services) { - const service = data.config.services[group].items[s]; - if (service.componentPath == '' || service.type == '') { - delete service.componentPath; - continue; - } - - const componentType = service.type || ''; - if (components[componentType] != undefined) { - continue; - } - - const path = '../lib/services/' + service.componentPath; - - const module = await import(/* @vite-ignore */ path); - - components[componentType] = module.default; - - //service.componentPath = undefined; - } - } - - return { config, components }; -};