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
This commit is contained in:
2023-08-13 13:59:31 +02:00
parent f23f268b60
commit edbbd2f0f6
13 changed files with 280 additions and 248 deletions

View File

@@ -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'

3
src/hooks.client.ts Normal file
View File

@@ -0,0 +1,3 @@
import { initServices } from '$lib/services/services';
await initServices();

View File

@@ -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();

View File

@@ -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<string, unknown>;
[x: string]: unknown;
}
export interface ServiceGroupConfig extends SectionConfig {
items: Record<string, ServiceConfig>;
[x: string]: unknown;
}
export interface Config extends SectionConfig {
services: Record<string, ServiceGroupConfig>;
[x: string]: unknown;
}
type DeepRequired<T> = T extends object
? {
[P in keyof T]: DeepRequired<T[P]>;
}
: T;
const requiredConfig: DeepRequired<Config> = {
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<string, unknown>;
function strip<Type extends SPOJO>(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);

View File

@@ -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');
});
});

146
src/lib/server/config.ts Normal file
View File

@@ -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<ServiceConfig>;
[x: string]: unknown;
}
export interface Config extends SectionConfig {
services: Array<ServiceGroupConfig>;
[x: string]: unknown;
}
const requiredService: Required<ServiceConfig> = {
title: '',
subtitle: '',
logo: '',
icon: '',
usemask: false,
url: '',
target: '',
type: ''
};
const requiredServiceGroup: Required<ServiceGroupConfig> = {
title: '',
subtitle: '',
logo: '',
icon: '',
usemask: false,
items: [requiredService]
};
export const requiredConfig: Required<Config> = {
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<string, unknown>;
function strip<Type extends SPOJO>(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<SPOJO>)[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<SPOJO>)[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<Config> = writable(mergeConfig(defaultConfig, configData));
export const clientConfig: Readable<Config> = 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;
}
})();
}

View File

@@ -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';

View File

@@ -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';

View File

@@ -5,7 +5,6 @@ interface ServiceHandlerArgs {
config: ServiceConfig;
}
export type ServiceHandler = (input: ServiceHandlerArgs) => {
data: Record<string, unknown>;
componentPath: string;
};
export type ServiceHandler = (input: ServiceHandlerArgs) => Record<string, unknown>;
export type ServiceComponentPath = string;

View File

@@ -1,33 +1,43 @@
import type { ServiceHandler } from './service';
const services: Record<string, [ServiceHandler, string]> = {};
type ServiceRecord = {
handler: ServiceHandler;
component: any;
};
function registerService(type: string, handler: ServiceHandler) {
services[type] = [handler, type];
const services: Record<string, ServiceRecord> = {};
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);
}
}

View File

@@ -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<Config> {
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<Array<unknown>> = [];
export const load: PageServerLoad = async ({ fetch, depends }) => {
depends('app:state');
const serverConfig = await reloadConfig();
const clientConfig = stripPrivateFields(serverConfig);
const groups: Array<string> = 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<unknown> = [];
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 };
};

View File

@@ -1,15 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { PageData } from './$types';
import { invalidate } from '$app/navigation';
export let data: PageData;
onMount(() => {
const interval = setInterval(() => {
invalidate('app:state');
}, 10000);
return () => clearInterval(interval);
});
</script>
<h1>{data.config.title}</h1>
@@ -17,4 +9,19 @@
<h2>{data.config.subtitle}</h2>
{/if}
<pre> {JSON.stringify(data.config, null, 4)} </pre>
<ul>
{#each data.config.services as group, i}
<li>
<p>{group.title}</p>
<ul>
{#each group.items as service, j}
<li>
{i},{j}
<pre> {JSON.stringify(service)}</pre>
<pre> {JSON.stringify(data.serviceData[i][j])}</pre>
</li>
{/each}
</ul>
</li>
{/each}
</ul>

View File

@@ -1,36 +0,0 @@
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ data }) => {
const config = { ...data.config };
const groups: Array<string> = Object.entries(config.services).map(([k, v]) => k);
const components: Record<string, any> = {};
for (const group of groups) {
const services: Array<string> = 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 };
};