aboutsummaryrefslogtreecommitdiff
path: root/extension/src/features/framework/index.ts
blob: b545335857d14b6b167178394ea61d20ae8a6b4c (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226

// Copyright (C) 2024 Vaughn Nugent
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program.  If not, see <https://www.gnu.org/licenses/>.

import { runtime } from "webextension-polyfill";
import { serializeError, deserializeError } from 'serialize-error';
import { JsonObject } from "type-fest";
import { cloneDeep, isArray, isObjectLike, set } from "lodash";
import { debugLog } from "@vnuge/vnlib.browser";
import { ChannelContext, createMessageChannel } from "../../messaging";

export interface BgRuntime<T> {
    readonly state: T;
    onInstalled(callback: () => Promise<void>): void;
    onConnected(callback: () => Promise<void>): void;
}

export type FeatureApi = {
    [key: string]: (... args: any[]) => Promise<any>
};

export type SendMessageHandler = <T extends JsonObject | JsonObject[]>(action: string, data: any) => Promise<T>
export type VarArgsFunction<T> = (...args: any[]) => T
export type FeatureConstructor<TState, T extends FeatureApi> = () => IFeatureExport<TState, T>

export type DummyApiExport<T extends FeatureApi> = {
    [K in keyof T]: T[K] extends Function ? K : never
}[keyof T][]


export interface IFeatureExport<TState, TFeature extends FeatureApi> {
    /**
     * Initializes a feature for mapping in the background runtime context
     * @param bgRuntime The background runtime context 
     * @returns The feature's background api handlers that maps to the foreground
     */
    background(bgRuntime: BgRuntime<TState>): TFeature
    /**
     * Initializes the feature for mapping in any foreground runtime context
     * @returns The feature's foreground api stub methods for mapping. They must 
     * match the background api
     */
    foreground(): TFeature
}

export interface IForegroundUnwrapper {
    /**
     * Unwraps a foreground feature and builds it's method bindings to 
     * the background handler
     * @param feature The foreground feature that will be mapped to it's
     *  background handlers
     * @returns The foreground feature's api stub methods
     */
    use: <T extends FeatureApi>(feature: FeatureConstructor<any, T>) => T
}

export interface IBackgroundWrapper<TState> {
    register<T extends FeatureApi>(features: FeatureConstructor<TState, T>[]): void
}

export interface ProtectedFunction extends Function {
    readonly protection: ChannelContext[]
}

export const optionsOnly = <T extends Function>(func: T): T => protectMethod(func, 'options');
export const popupOnly = <T extends Function>(func: T): T => protectMethod(func, 'popup');
export const contentScriptOnly = <T extends Function>(func: T): T => protectMethod(func, 'content-script');
export const popupAndOptionsOnly = <T extends Function>(func: T): T => protectMethod(func, 'popup', 'options');

export const protectMethod = <T extends Function>(func: T, ...protection: ChannelContext[]): T => {
    (func as any).protection = protection
    return func;
}

/**
 * Creates a background runtime context for registering background 
 * script feature api handlers 
 */
export const useBackgroundFeatures = <TState>(state: TState): IBackgroundWrapper<TState> => {
    
    const { openOnMessageChannel } = createMessageChannel('background');
    const { onMessage } = openOnMessageChannel()


    const rt = {
        state,
        onConnected: runtime.onConnect.addListener,
        onInstalled: runtime.onInstalled.addListener,
    }   as BgRuntime<TState>

    /**
     * Each plugin will export named methods. Background methods
     * are captured and registered as on-message handlers that 
     * correspond to the method name. Foreground method calls 
     * are redirected to the send-message of the same unique name
     */

    return{
        register: <TFeature extends FeatureApi>(features: FeatureConstructor<TState, TFeature>[]) => {
            //Loop through features
            for (const feature of features) {

                //Init feature
                const f = feature().background(rt)

                //Get all exported function
                for (const externFuncName in f) {

                    //get exported function
                    const func = f[externFuncName] as Function

                    const onMessageFuncName = `${feature.name}-${externFuncName}`

                    //register method with api
                    onMessage<any>(onMessageFuncName, async (sender, payload) => {
                        try {

                            if ((func as ProtectedFunction).protection
                                && !(func as ProtectedFunction).protection.includes(sender)) {
                                throw new Error(`Unauthorized external call to ${onMessageFuncName}`)
                            }

                            const res = await func(...payload)
                            
                            if(isArray(res)){
                                return [...res]
                            }
                            else if(isObjectLike(res)){
                                return { ...res }
                            }
                            else{
                                return res
                            }
                        }
                        catch (e: any) {
                            debugLog(`Error in method ${onMessageFuncName}`, e)
                            const s = serializeError(e)
                            return {
                                bridgeMessageException: JSON.stringify(s),
                                axiosResponseError: JSON.stringify(e.response)
                            }
                        }
                    });
                }
            }
        }
    }
}

/**
 * Creates a foreground runtime context for unwrapping foreground stub 
 * methods and redirecting them to thier background handler
 */
export const useForegoundFeatures = (context: ChannelContext): IForegroundUnwrapper => {
    
    const { openChannel } = createMessageChannel(context);
    const { sendMessage } = openChannel()

    /**
     * The goal of this function is to get the foreground interface object
     * that should match the background implementation. All methods are
     * intercepted and redirected to the background via send-message
     */

    return{
        use: <T extends FeatureApi>(feature:FeatureConstructor<any, T>): T => {
            //Register the feature
            const api = feature().foreground()
            const featureName = feature.name
            const proxied : T = {} as T

            //Loop through all methods
            for(const funcName in api){
                
                //Create proxy for each method
                set(proxied, funcName, async (...args:any) => {
                    
                    //Check for exceptions
                    const result = await sendMessage(`${featureName}-${funcName}`, cloneDeep(args)) as any

                    if(result?.bridgeMessageException){
                        const str = JSON.parse(result.bridgeMessageException)
                        const err = deserializeError(str)
                        //Recover axios response
                        if(result.axiosResponseError){
                            (err as any).response = JSON.parse(result.axiosResponseError)
                        }

                        throw err;
                    }
                    
                    return result;
                })
            }

            return proxied;
        }
    }
}

export const exportForegroundApi = <T extends FeatureApi>(args: DummyApiExport<T>): () => T => {
    //Create the type from the array of type properties
    const type = {} as T

    //Loop through all properties
    for(const prop of args){
        //Default the property to an implementation error
        type[prop] = (async () => {
            throw new Error(`Method ${prop.toString()} not implemented`)
        }) as any 
    }

    return () => type
}