Skip to content

Commit

Permalink
feat!: automatic reloading of application when config is updated (#86)
Browse files Browse the repository at this point in the history
  • Loading branch information
hywax authored Apr 14, 2024
1 parent ece7a0b commit a11eb1d
Show file tree
Hide file tree
Showing 14 changed files with 781 additions and 968 deletions.
5 changes: 3 additions & 2 deletions nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,8 +134,9 @@ export default defineNuxtConfig({
base: './data',
},
},
devServer: {
watch: ['./data'],
experimental: {
websocket: true,
tasks: true,
},
},
})
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,4 @@
"lint-staged": {
"*.ts": "yarn run lint"
}
}
}
65 changes: 65 additions & 0 deletions src/composables/useWebsocket.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import type { UseWebSocketReturn } from '@vueuse/core'
import type { ReceivedMessage, ReceivedMessageMap, SendMessage, SendMessageMap } from '~/types'

export type WsClientReturn = Pick<UseWebSocketReturn<string>, 'open' | 'close' | 'status'> & {
on: SendHook
off: SendHook
send: ReceiveHook
}
export type SendHook = <E extends SendMessage['event']>(event: E, handler: (data: Omit<SendMessageMap[E], 'event'>) => any) => void
export type ReceiveHook = <E extends ReceivedMessage['event']>(event: E, handler: (data: Omit<ReceivedMessageMap[E], 'event'>) => any) => void

function createEventMessage<E extends SendMessage['event'] | ReceivedMessage['event']>(event: E, data: Record<string, any> = {}): string {
return JSON.stringify({ event, ...data })
}

export function wsClient(): WsClientReturn {
const url = useRequestURL()
const isSecure = url.protocol === 'https:'
const endpoint = `${(isSecure ? 'wss://' : 'ws://') + url.host}/api/websocket`
const sendHandlers = new Map<SendMessage['event'], Set<Function>>()
const { status, open, close, send: _send } = useWebSocket(endpoint, {
heartbeat: {
message: createEventMessage('ping'),
interval: 30 * 1000,
},
autoReconnect: {
delay: 5 * 1000,
},
onMessage(_, e) {
const { event, ...data } = JSON.parse(e.data) as SendMessage

sendHandlers.get(event)?.forEach((handler) => handler(data))
},
})

const off: SendHook = (event, handler) => {
if (sendHandlers.has(event)) {
sendHandlers.get(event)?.delete(handler)
}
}

const on: SendHook = (event, handler) => {
if (!sendHandlers.has(event)) {
sendHandlers.set(event, new Set())
}

sendHandlers.get(event)!.add(handler)
tryOnScopeDispose(() => off(event, handler))
}

const send: ReceiveHook = (event, data) => {
_send(createEventMessage(event, data))
}

return {
status,
open,
close,
send,
on,
off,
}
}

export const useWebsocket = createSharedComposable(wsClient)
11 changes: 11 additions & 0 deletions src/plugins/settings.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
import type { CompleteConfig } from '~/types'

/**
* @todo create a hot reboot configuration. Receive data from websocket
*/
export default defineNuxtPlugin(async () => {
const { on } = useWebsocket()

on('config:update', () => {
reloadNuxtApp({
force: true,
})
})

const asyncData = await useFetch<CompleteConfig>('/api/settings')
const { services, ...settings } = asyncData.data.value!

Expand Down
2 changes: 1 addition & 1 deletion src/server/api/services/ip-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ const cachedIpApiData = defineCachedFunction(async (lang: string = 'en') => {

export default defineEventHandler(async (event) => {
const service = await getServiceWithDefaultData<IpApiService>(event)
const config = await getLocalConfig()
const config = await getConfig()
const ip = await cachedIpApiData(config?.lang)

return returnServiceWithData(service, ip)
Expand Down
2 changes: 1 addition & 1 deletion src/server/api/services/openweathermap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ const cachedOWMData = defineCachedFunction(async ({ lon, lat, units, apiKey, lan

export default defineEventHandler(async (event) => {
const service = await getServiceWithDefaultData<OpenWeatherMapService>(event)
const config = await getLocalConfig()
const config = await getConfig()
const { options, secrets } = service.config
const owm = await cachedOWMData({
lon: options.lon,
Expand Down
24 changes: 24 additions & 0 deletions src/server/api/websocket.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
const logger = useLogger('websocket')

export default defineWebSocketHandler({
async open(peer) {
logger.info('New peer', peer)

const storage = useStorage('data')
await storage.watch(async (_, key) => {
if (key !== `data:${configFileName}`) {
return
}

await runTask('config:update')
peer.send({ event: 'config:update' })
})
},
async message(peer, message) {
const { event } = JSON.parse(message as unknown as string)

if (event === 'ping') {
peer.send({ event: 'pong' })
}
},
})
6 changes: 6 additions & 0 deletions src/server/plugins/01.config-loader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/**
* @todo remove plugin when hooks appear in nitro task
*/
export default defineNitroPlugin(async () => {
runTask('config:update')
})
12 changes: 0 additions & 12 deletions src/server/plugins/1.config-loader.ts

This file was deleted.

13 changes: 13 additions & 0 deletions src/server/tasks/config/update.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export default defineTask({
meta: {
name: 'config:update',
},
async run() {
const config = await loadConfig()
await setConfig(config)

return {
result: {},
}
},
})
31 changes: 26 additions & 5 deletions src/server/utils/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ type DraftService = Omit<Service, 'id'>

type TagMap = Map<Tag['name'], Tag>

const logger = useLogger('config')

function determineService(items: DraftService[], tags: TagMap): Service[] {
return items.map((item) => ({
...item,
Expand All @@ -26,6 +28,8 @@ function determineService(items: DraftService[], tags: TagMap): Service[] {
}))
}

export const configFileName = 'config.yml'

export function getDefaultConfig(): CompleteConfig {
return {
title: 'Mafl Home Page',
Expand All @@ -48,17 +52,19 @@ function createTagMap(tags: Tag[]): TagMap {
}, new Map())
}

export async function loadLocalConfig(): Promise<CompleteConfig> {
/**
* Load config from storage
*/
export async function loadConfig(): Promise<CompleteConfig> {
const defaultConfig = getDefaultConfig()
const storage = useStorage('data')
const file = 'config.yml'

try {
if (!await storage.hasItem(file)) {
if (!await storage.hasItem(configFileName)) {
throw new Error('Config not found')
}

const raw = await storage.getItem<string>(file)
const raw = await storage.getItem<string>(configFileName)
const config = yaml.parse(raw || '') || {}
const services: CompleteConfig['services'] = []
const tags: TagMap = createTagMap(config.tags || [])
Expand Down Expand Up @@ -100,7 +106,22 @@ export async function loadLocalConfig(): Promise<CompleteConfig> {
return defaultConfig
}

export async function getLocalConfig(): Promise<CompleteConfig | null> {
/**
* Save config to memory storage
*/
export async function setConfig(config: CompleteConfig): Promise<void> {
const storage = useStorage('main')

await storage.setItem('config', config)
await storage.setItem('services', extractServicesFromConfig(config))

logger.success('Set "main" config')
}

/**
* Get config from memory storage
*/
export async function getConfig(): Promise<CompleteConfig | null> {
const storage = useStorage('main')
await storage.getKeys()

Expand Down
1 change: 1 addition & 0 deletions src/types/index.d.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './config'
export * from './services'
export * from './websocket'
24 changes: 24 additions & 0 deletions src/types/websocket.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
export interface PingEvent {
event: 'ping'
}

export interface PongEvent {
event: 'pong'
}

export interface ConfigUpdateEvent {
event: 'config:update'
}

export type ReceivedMessage = PingEvent

export interface ReceivedMessageMap {
ping: PingEvent
}

export type SendMessage = PongEvent | ConfigUpdateEvent

export interface SendMessageMap {
'pong': PongEvent
'config:update': ConfigUpdateEvent
}
Loading

0 comments on commit a11eb1d

Please sign in to comment.