Engine in web worker

engine-in-web-worker
Jérémy Rialland 2023-07-06 20:39:06 +02:00
parent d3a59fffb5
commit 54bcc94227
8 changed files with 638 additions and 32 deletions

View File

@ -46,6 +46,7 @@ module.exports = {
files: ['**/*.{ts,tsx}'],
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
tsconfigRootDir,
project: ['*/tsconfig.json'],
@ -72,7 +73,10 @@ module.exports = {
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': [
'warn',
{ additionalHooks: 'useAsyncData' },
{
additionalHooks:
'usePromise|useLazyPromise|usePromiseOnSituationChange',
},
],
'@typescript-eslint/no-unsafe-call': 'warn',

View File

@ -0,0 +1,203 @@
import rawRules, { DottedName } from 'modele-social'
import Engine, { Rule } from 'publicodes'
import type { ProviderProps } from '@/components/Provider'
import i18n from './locales/i18n'
import ruleTranslations from './locales/rules-en.yaml'
import translateRules from './locales/translateRules'
type Rules = Record<DottedName, Rule>
const unitsTranslations = Object.entries(
i18n.getResourceBundle('fr', 'units') as Record<string, string>
)
const engineOptions = {
getUnitKey(unit: string): string {
const key = unitsTranslations
.find(([, trans]) => trans === unit)?.[0]
.replace(/_plural$/, '')
return key || unit
},
}
let warnCount = 0
let timeout: NodeJS.Timeout | null = null
const logger = {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
warn: (message: string) => {
// console.warn(message)
warnCount++
timeout !== null && clearTimeout(timeout)
timeout = setTimeout(() => {
// eslint-disable-next-line no-console
console.warn('⚠️', warnCount, 'warnings in the engine')
warnCount = 0
}, 1000)
},
error: (message: string) => {
// eslint-disable-next-line no-console
console.error(message)
},
log: (message: string) => {
// eslint-disable-next-line no-console
console.log(message)
},
}
export function engineFactory(rules: Rules, options = {}) {
return new Engine(rules, { ...engineOptions, ...options, logger })
}
//
export type Actions =
| {
action: 'init'
params: [{ basename: ProviderProps['basename'] }]
result: void
}
| {
action: 'setSituation'
params: Parameters<Engine<DottedName>['setSituation']>
result: void
}
| {
action: 'evaluate'
params: Parameters<Engine<DottedName>['evaluate']>
result: ReturnType<Engine<DottedName>['evaluate']>
}
| {
action: 'getRule'
params: Parameters<Engine<DottedName>['getRule']>
result: ReturnType<Engine<DottedName>['getRule']>
}
| {
action: 'getParsedRules'
params: []
result: ReturnType<Engine<DottedName>['getParsedRules']>
}
| {
action: 'shallowCopy'
params: []
result: void
}
| {
action: 'deleteShallowCopy'
params: [{ engineId: number }]
result: void
}
type GenericParams = {
/**
* The id of the engine to use, the default engine is 0
*/
engineId?: number
/**
* The id of the message, used to identify the response
*/
id: number
}
export type Action<T extends Actions['action']> = Extract<
Actions,
{ action: T }
>
let engines: (Engine<DottedName> | undefined)[] = []
let setDefaultEngineReady: (() => void) | null = null
const isDefaultEngineReady = new Promise(
(resolve) => (setDefaultEngineReady = resolve as () => void)
)
onmessage = async (e) => {
console.log('[onmessage]', e.data)
const { engineId = 0, id, action, params } = e.data as Actions & GenericParams
try {
if (action === 'init') {
const [{ basename }] = params
try {
let rules = rawRules
if (basename === 'infrance') {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
rules = translateRules('en', ruleTranslations, rules)
}
const engineId = engines.length
engines.push(engineFactory(rules))
console.log('[engine ready]', engines[engineId])
postMessage({ engineId, id })
setDefaultEngineReady?.()
} catch (e) {
console.error('[error]', e)
// postMessage('error')
}
return
}
await isDefaultEngineReady
const engine = engines[engineId]
if (!engine) {
throw new Error('Engine does not exist')
}
if (action === 'setSituation') {
// safeSetSituation(
// engine,
// ({ situation, <faultyDottedName> }) => {
// console.error('setSituation', { situation, faultyDottedName })
// },
// ...params
// )
engine.setSituation(...params)
return postMessage({ engineId, id })
} else if (action === 'evaluate') {
const result = engine.evaluate(...params)
console.log('[result]', result)
return postMessage({ engineId, id, result })
} else if (action === 'getRule') {
const result = engine.getRule(...params)
return postMessage({ engineId, id, result })
} else if (action === 'getParsedRules') {
const result = engine.getParsedRules()
return postMessage({ engineId, id, result })
} else if (action === 'shallowCopy') {
const result = engine.shallowCopy()
engines.push(result)
return postMessage({ engineId: engines.length - 1, id })
} else if (action === 'deleteShallowCopy') {
if (engineId === 0) {
throw new Error('Cannot delete the default engine')
}
delete engines[engineId]
// false positive warning from eslint
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call
const lastIndex: number = engines.findLastIndex(
(el) => el instanceof Engine
)
engines = lastIndex >= 0 ? engines.splice(0, lastIndex) : engines
console.log('[engines]', engines)
return postMessage({ engineId, id })
} else {
console.log('[Message inconu]', e.data)
}
} catch (error) {
return postMessage({ engineId, id, error })
}
}

View File

@ -1,22 +0,0 @@
import { DependencyList, useEffect, useState } from 'react'
/**
* Executes a function that returns a promise (when dependencies change)
* and returns the result of the promise when completed
*/
export const useAsyncData = <T, U = null>(
getAsyncData: () => Promise<T>,
defaultValue: U | null = null,
deps: DependencyList = []
): T | U | null => {
const [state, setState] = useState<T | U | null>(defaultValue)
useEffect(() => {
void (async () => {
setState(await getAsyncData())
})()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps)
return state
}

View File

@ -0,0 +1,51 @@
import { DependencyList, useCallback, useEffect, useState } from 'react'
/**
* Execute an asynchronous function and return its result (Return default value if the promise is not finished).
* The function is executed each time the dependencies change.
*/
export const usePromise = <T, Default = undefined>(
promise: () => Promise<T>,
deps: DependencyList = [],
defaultValue?: Default
) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
const [state, lazyPromise] = useLazyPromise(promise, deps, defaultValue)
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => void lazyPromise(), deps)
return state
}
/**
* Execute an asynchronous function and return its result (Return default value if the promise is not finished).
* Use this hook if you want to fire the promise manually.
*/
export const useLazyPromise = <
T,
Params extends unknown[],
Default = undefined
>(
promise: (...params: Params) => Promise<T>,
deps: DependencyList = [],
defaultValue?: Default
) => {
// console.log('===>', defaultValue)
const [state, setState] = useState<T | Default>(defaultValue as Default)
const lazyPromise = useCallback(
async (...params: Params) => {
// console.log('====', defaultValue)
const result = await promise(...params)
setState(result)
return result as T
},
// eslint-disable-next-line react-hooks/exhaustive-deps
deps
)
return [state, lazyPromise] as const
}

View File

@ -5,7 +5,7 @@ import { Strong } from '@/design-system/typography'
import { H5 } from '@/design-system/typography/heading'
import { Li, Ul } from '@/design-system/typography/list'
import { Body } from '@/design-system/typography/paragraphs'
import { useAsyncData } from '@/hooks/useAsyncData'
import { usePromise } from '@/hooks/usePromise'
import { capitalise0 } from '@/utils'
const lazyApeToGuichet = () => import('@/public/data/ape-to-guichet.json')
@ -16,8 +16,8 @@ type Guichet = typeof import('@/public/data/guichet.json')
export type GuichetEntry = Guichet[keyof Guichet]
export function useGuichetInfo(codeApe?: string): GuichetEntry[] | null {
const guichet = useAsyncData(lazyGuichet)?.default
const apeToGuichet = useAsyncData(lazyApeToGuichet)?.default
const guichet = usePromise(lazyGuichet)?.default
const apeToGuichet = usePromise(lazyApeToGuichet)?.default
return useMemo(() => {
if (!codeApe || !guichet || !apeToGuichet || !(codeApe in apeToGuichet)) {

View File

@ -11,7 +11,7 @@ import { VisibleRadio } from '@/design-system/field/Radio/Radio'
import { RadioCardSkeleton } from '@/design-system/field/Radio/RadioCard'
import { Spacing } from '@/design-system/layout'
import { SmallBody } from '@/design-system/typography/paragraphs'
import { useAsyncData } from '@/hooks/useAsyncData'
import { usePromise } from '@/hooks/usePromise'
import { Result } from './Result'
@ -102,12 +102,15 @@ export default function SearchCodeAPE({
[]
)
const lazyData = useAsyncData(() => import('@/public/data/ape-search.json'))
const lazyData = usePromise(() => import('@/public/data/ape-search.json'))
const lastIdxs = useRef<Record<string, UFuzzy.HaystackIdxs>>({})
const prevValue = useRef<string>(searchQuery)
const buildedResearch = useMemo(() => buildResearch(lazyData), [lazyData])
const buildedResearch = useMemo(
() => lazyData && buildResearch(lazyData),
[lazyData]
)
useEffect(() => {
if (!lazyData || !buildedResearch) {

View File

@ -283,8 +283,25 @@ export const generateUuid = () => {
}
/**
* Returns true if x is not null, useful for filtering out nulls from arrays
* Returns true if value is not null, useful for filtering out nulls from arrays
* @example [1, null, 2].filter(isNotNull) // [1, 2]
* @param x
* @param value
*/
export const isNotNull = <T>(x: T | null): x is T => x !== null
export const isNotNull = <T>(value: T | null): value is T => value !== null
/**
* Returns true if value is not undefined, useful for filtering out undefined from arrays
* @example [1, undefined, 2].filter(isDefined) // [1, 2]
* @param value
*/
export const isDefined = <T>(value: T | undefined): value is T =>
value !== undefined
/**
* Returns true if value is not null or undefined, useful for filtering out nulls and undefined from arrays
* @example [1, null, undefined, 2].filter(isNotNullOrUndefined) // [1, 2]
* @param value
*/
export const isNotNullOrUndefined = <T>(
value: T | null | undefined
): value is T => isNotNull(value) && isDefined(value)

View File

@ -0,0 +1,350 @@
import { DottedName } from 'modele-social'
import React, { DependencyList, useContext, useEffect, useState } from 'react'
import { ProviderProps } from './components/Provider'
import type { Action, Actions } from './engine.worker'
import EngineWorker from './engine.worker?worker'
import { usePromise } from './hooks/usePromise'
if (!window.Worker) {
throw new Error('Worker is not supported in this browser')
}
interface WorkerEngine {
worker: Worker
postMessage: <T extends Actions['action'], U extends Action<T>>(
engineId: number,
action: T,
...params: U['params']
) => Promise<U['result']>
isWorkerReady: Promise<void>
promises: {
resolve: (value: unknown) => void
reject: (value: unknown) => void
}[]
}
const initWorkerEngine = (
basename: ProviderProps['basename'],
setSituationVersion: (updater: (situationVersion: number) => number) => void
) => {
const newWorker: Worker = new EngineWorker()
const promises: WorkerEngine['promises'] = []
const postMessage = async <T extends Actions['action'], U extends Action<T>>(
engineId: number,
action: T,
...params: U['params']
) => {
if (action === 'setSituation') {
setSituationVersion((situationVersion) => situationVersion + 1)
}
const warning = setTimeout(() => {
console.log('promise waiting for too long, aborting!', action, params)
promises[id].reject?.(new Error('timeout'))
}, 5000)
const id = promises.length
const promise = new Promise<U['result']>((resolve, reject) => {
promises[id] = {
resolve(...params: unknown[]) {
clearTimeout(warning)
return resolve(...(params as Parameters<typeof resolve>))
},
reject(err) {
clearTimeout(warning)
return reject(err)
},
}
})
newWorker.postMessage({ engineId, action, params, id })
return promise
}
newWorker.onmessage = function (e) {
console.log('msg:', e.data)
if ('error' in e.data) {
return promises[e.data.id].reject?.(e.data.error)
}
promises[e.data.id].resolve?.(e.data.result)
}
const isWorkerReady = postMessage(0, 'init', { basename })
const workerEngine = {
worker: newWorker,
postMessage,
isWorkerReady,
promises,
}
return workerEngine
}
const sleepMs = (ms: number) =>
new Promise((resolve) => setTimeout(resolve, ms))
let worker: ReturnType<typeof initWorkerEngine> | null = null
/**
* This hook is used to create a worker engine.
* @param basename
*/
const useCreateWorkerEngine = (basename: ProviderProps['basename']) => {
const [situationVersion, setSituationVersion] = useState(0)
useEffect(() => {
worker = initWorkerEngine(basename, setSituationVersion)
console.time('init')
void worker.isWorkerReady.then(() => console.timeEnd('init'))
// example of usage
void Promise.all([
asyncEvaluate('SMIC').then((result) => console.log('result', result)),
asyncEvaluate('date').then((result) => console.log('result', result)),
])
return () => {
console.log('worker terminated!')
worker?.worker.terminate()
worker?.promises.forEach((promise) =>
promise.reject?.('worker terminated')
)
worker = null
}
}, [basename])
return situationVersion
}
// asynchronous setSituation function
/**
* This function is used to set the situation in the worker with a specific engineId.
*/
export const asyncSetSituationWithEngineId = async (
engineId: number,
...params: Action<'setSituation'>['params']
): Promise<Action<'setSituation'>['result']> => {
if (!worker) {
await sleepMs(10)
return asyncSetSituationWithEngineId(engineId, ...params)
}
return await worker.postMessage(engineId, 'setSituation', ...params)
}
/**
* * This function is used to set the situation in the worker.
*/
export const asyncSetSituation = async (
...params: Action<'setSituation'>['params']
) => asyncSetSituationWithEngineId(0, ...params)
// asynchronous evaluate function
/**
* This function is used to evaluate a publicodes expression in the worker with a specific engineId.
*/
export const asyncEvaluateWithEngineId = async (
engineId: number,
...params: Action<'evaluate'>['params']
): Promise<Action<'evaluate'>['result']> => {
if (!worker) {
await sleepMs(10)
return asyncEvaluateWithEngineId(engineId, ...params)
}
return await worker.postMessage(engineId, 'evaluate', ...params)
}
/**
* This function is used to evaluate a publicodes expression in the worker.
*/
export const asyncEvaluate = async (...params: Action<'evaluate'>['params']) =>
asyncEvaluateWithEngineId(0, ...params)
/**
* This hook is used to evaluate a publicodes expression in the worker.
* @param defaultValue
*/
// export const useAsyncEvaluate = <T extends unknown = undefined>(
// defaultValue?: T
// ) => {
// const [response, setResponse] = useState<Action<'evaluate'>['result'] | T>(
// defaultValue as T
// )
// const evaluate = useCallback(async (value: PublicodesExpression) => {
// const result = await asyncEvaluate(value)
// setResponse(result)
// return result
// }, [])
// return [response, evaluate] as const
// }
// asynchronous getRule function:
/**
* This function is used to get a publicodes rule that is in the worker with a specific EngineId.
*/
export const asyncGetRuleWithEngineId = async (
engineId: number,
...params: Action<'getRule'>['params']
): Promise<Action<'getRule'>['result']> => {
if (!worker) {
await sleepMs(10)
return asyncGetRuleWithEngineId(engineId, ...params)
}
return await worker.postMessage(engineId, 'getRule', ...params)
}
/**
* This function is used to get a rule in the worker.
*/
export const asyncGetRule = async (...params: Action<'getRule'>['params']) =>
asyncGetRuleWithEngineId(0, ...params)
/**
* This hook is used to get a rule in the worker.
* @param defaultValue
*/
export const useAsyncGetRule = <
Names extends DottedName,
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-constraint
T extends unknown = undefined
>(
dottedName: Names,
defaultValue?: T
) =>
usePromiseOnSituationChange(
() => asyncGetRule(dottedName),
[dottedName],
defaultValue
)
// asynchronous getParsedRules function
/**
* This function is used to get all the parsed rules in the worker with a specific engineId.
*/
export const asyncGetParsedRulesWithEngineId = async (
engineId: number
): Promise<Action<'getParsedRules'>['result']> => {
if (!worker) {
await sleepMs(10)
return asyncGetParsedRulesWithEngineId(engineId)
}
return await worker.postMessage(engineId, 'getParsedRules')
}
/**
* This function is used to get all the parsed rules in the worker.
*/
export const asyncGetParsedRules = async () =>
asyncGetParsedRulesWithEngineId(0)
/**
*
*/
export const useParsedRules = (engineId = 0) =>
usePromiseOnSituationChange(
async () => await asyncGetParsedRulesWithEngineId(engineId),
[engineId]
)
// asynchronous shallowCopy function
/**
* This function is used to shallow copy an engine in the worker with a specific engineId.
*/
export const asyncShallowCopyWithEngineId = async (
engineId: number
): Promise<Action<'shallowCopy'>['result']> => {
if (!worker) {
await sleepMs(10)
return asyncShallowCopyWithEngineId(engineId)
}
return await worker.postMessage(engineId, 'shallowCopy')
}
/**
* This function is used to shallow copy an engine in the worker.
*/
export const asyncShallowCopy = async () => asyncShallowCopyWithEngineId(0)
// asynchronous deleteShallowCopy function
/**
* * This function is used to delete a shallow copy of an engine in the worker.
*/
export const asyncDeleteShallowCopy = async (
engineId: number
): Promise<Action<'deleteShallowCopy'>['result']> => {
if (!worker) {
await sleepMs(10)
return asyncDeleteShallowCopy(engineId)
}
return await worker.postMessage(0, 'deleteShallowCopy', { engineId })
}
const SituationUpdated = React.createContext<number>(0)
export const SituationUpdatedProvider = ({
children,
basename,
}: {
children: React.ReactNode
basename: ProviderProps['basename']
}) => {
const situationVersion = useCreateWorkerEngine(basename)
return (
<SituationUpdated.Provider value={situationVersion}>
{children}
</SituationUpdated.Provider>
)
}
export const useSituationUpdated = () => {
const situationVersion = useContext(SituationUpdated)
return situationVersion
}
/**
* Wrapper around usePromise that adds the situation version to the dependencies
* @example const date = usePromiseOnSituationChange(() => asyncEvaluate('date'), []) // date will be updated when the situation changes
*/
export const usePromiseOnSituationChange = <T, Default = undefined>(
promise: () => Promise<T>,
deps: DependencyList,
defaultValue?: Default
): T | Default => {
const situationVersion = useSituationUpdated()
// eslint-disable-next-line react-hooks/exhaustive-deps
const state = usePromise(promise, [...deps, situationVersion], defaultValue)
return state
}