|
| 1 | +/* eslint-disable dot-notation */ |
| 2 | +import * as React from 'react'; |
| 3 | +import type { |
| 4 | + ComponentType, |
| 5 | + ElementRef, |
| 6 | + ComponentProps, |
| 7 | + ComponentPropsWithRef |
| 8 | +} from 'react'; |
| 9 | +import type { NativeSyntheticEvent } from 'react-native'; |
| 10 | +import { Feature } from './Feature'; |
| 11 | +import featuresLoaderScript from './features-loader.webjs'; |
| 12 | +import type { |
| 13 | + WebshellProps, |
| 14 | + WebshellInvariantProps, |
| 15 | + MinimalWebViewProps, |
| 16 | + PropsSpecs, |
| 17 | + PropDefinition |
| 18 | +} from './types'; |
| 19 | + |
| 20 | +interface WebViewMessage { |
| 21 | + data: string; |
| 22 | +} |
| 23 | + |
| 24 | +interface PostMessage { |
| 25 | + identifier: string; |
| 26 | + handlerName: string; |
| 27 | + type: 'feature' | 'error' | 'log'; |
| 28 | + severity: 'warn' | 'info'; |
| 29 | + body: any; |
| 30 | +} |
| 31 | + |
| 32 | +function parseJSONSafe(text: string) { |
| 33 | + try { |
| 34 | + return (JSON.parse(text) as unknown) ?? null; |
| 35 | + } catch (e) { |
| 36 | + return null; |
| 37 | + } |
| 38 | +} |
| 39 | + |
| 40 | +function isPostMessageObject(o: unknown): o is PostMessage { |
| 41 | + return ( |
| 42 | + typeof o === 'object' && |
| 43 | + o !== null && |
| 44 | + typeof o['identifier'] === 'string' && |
| 45 | + typeof o['type'] === 'string' && |
| 46 | + o['__isWebshellPostMessage'] === true |
| 47 | + ); |
| 48 | +} |
| 49 | + |
| 50 | +function serializeFeature(feature: Feature<any, PropsSpecs<any>>) { |
| 51 | + const propDef = feature.propSpecs.find((f) => f.type === 'handler'); |
| 52 | + return `{ |
| 53 | + source:${feature.script}, |
| 54 | + identifier:${JSON.stringify(feature.featureIdentifier)}, |
| 55 | + options:${JSON.stringify(feature.options || {})}, |
| 56 | + handlerName: ${JSON.stringify(propDef?.name || '')} |
| 57 | + }`; |
| 58 | +} |
| 59 | + |
| 60 | +function serializeFeatureList(feats: Feature<any, any>[]) { |
| 61 | + return `[${feats.map(serializeFeature).join(',')}]`; |
| 62 | +} |
| 63 | + |
| 64 | +function extractFeatureProps( |
| 65 | + props: WebshellProps<any, any>, |
| 66 | + propsMap: Record<string, PropDefinition<any>>, |
| 67 | + type: 'handler' | 'inert' | null = null |
| 68 | +): any { |
| 69 | + return Object.keys(props).reduce((obj, key) => { |
| 70 | + if (propsMap[key] && (type == null || propsMap[key].type === type)) { |
| 71 | + return { |
| 72 | + ...obj, |
| 73 | + [key]: props[key] |
| 74 | + }; |
| 75 | + } |
| 76 | + return obj; |
| 77 | + }, {}); |
| 78 | +} |
| 79 | + |
| 80 | +function filterWebViewProps<W>( |
| 81 | + props: WebshellProps<any, any>, |
| 82 | + propsMap: Record<string, PropDefinition<any>> |
| 83 | +): W { |
| 84 | + return Object.keys(props).reduce((obj, key) => { |
| 85 | + if (propsMap[key] || key.startsWith('webshell')) { |
| 86 | + return obj; |
| 87 | + } |
| 88 | + return { |
| 89 | + ...obj, |
| 90 | + [key]: props[key] |
| 91 | + }; |
| 92 | + }, {} as W); |
| 93 | +} |
| 94 | + |
| 95 | +function extractPropsSpecsMap(features: Feature<any, PropsSpecs<any>>[]) { |
| 96 | + return features |
| 97 | + .map((f: Feature<any, PropsSpecs<any>>) => f.propSpecs) |
| 98 | + .reduce((p, c) => [...p, ...c], []) |
| 99 | + .reduce( |
| 100 | + (map, spec: PropDefinition<any>) => ({ ...map, [spec.name]: spec }), |
| 101 | + {} |
| 102 | + ) as Record<string, PropDefinition<any>>; |
| 103 | +} |
| 104 | + |
| 105 | +export function assembleScript(serializedFeatureList: string) { |
| 106 | + return featuresLoaderScript |
| 107 | + .replace('$$___FEATURES___$$', serializedFeatureList) |
| 108 | + .replace('$$__DEBUG__$$', `${__DEV__}`); |
| 109 | +} |
| 110 | + |
| 111 | +/** |
| 112 | + * Creates a React component which decorates WebView component with additional |
| 113 | + * props to handle events from the DOM. |
| 114 | + * |
| 115 | + * @param WebView - A WebView component, typically exported from `react-native-webview`. |
| 116 | + * @param features - Features ready to be loaded in the WebView. |
| 117 | + * |
| 118 | + * @public |
| 119 | + */ |
| 120 | +export function makeWebshell< |
| 121 | + C extends ComponentType<any>, |
| 122 | + F extends Feature<any, any>[] |
| 123 | +>( |
| 124 | + WebView: C, |
| 125 | + ...features: F |
| 126 | +): React.ForwardRefExoticComponent< |
| 127 | + WebshellProps<React.ComponentPropsWithoutRef<C>, F> & |
| 128 | + React.RefAttributes<ElementRef<C>> |
| 129 | +> { |
| 130 | + const filteredFeatures = features.filter((f) => !!f); |
| 131 | + const propsMap = extractPropsSpecsMap(filteredFeatures); |
| 132 | + const serializedFeatureScripts = serializeFeatureList(filteredFeatures); |
| 133 | + const injectableScript = assembleScript(serializedFeatureScripts); |
| 134 | + const Webshell = ( |
| 135 | + props: WebshellProps<ComponentProps<C>, F> & { webViewRef: ElementRef<C> } |
| 136 | + ) => { |
| 137 | + const { |
| 138 | + onMessage, |
| 139 | + onDOMError, |
| 140 | + webshellDebug, |
| 141 | + ...otherProps |
| 142 | + } = props as WebshellInvariantProps & MinimalWebViewProps; |
| 143 | + const domHandlers = extractFeatureProps(otherProps, propsMap, 'handler'); |
| 144 | + const handleOnMessage = React.useCallback( |
| 145 | + ({ nativeEvent }: NativeSyntheticEvent<WebViewMessage>) => { |
| 146 | + const parsedJSON = parseJSONSafe(nativeEvent.data); |
| 147 | + if (isPostMessageObject(parsedJSON)) { |
| 148 | + const { type, identifier, body, handlerName, severity } = parsedJSON; |
| 149 | + if (type === 'feature') { |
| 150 | + const handler = |
| 151 | + typeof handlerName === 'string' ? domHandlers[handlerName] : null; |
| 152 | + if (propsMap[handlerName]) { |
| 153 | + if (typeof handler === 'function') { |
| 154 | + handler(body); |
| 155 | + } else { |
| 156 | + webshellDebug && |
| 157 | + console.info( |
| 158 | + `[Webshell]: script from feature "${identifier}" sent an event, but there ` + |
| 159 | + `is no handler prop named "${handlerName}" attached to the shell.` |
| 160 | + ); |
| 161 | + } |
| 162 | + } else { |
| 163 | + console.warn( |
| 164 | + `[Webshell]: script from feature "${identifier}" sent an event, but there is ` + |
| 165 | + `no handler named "${handlerName}" defined for this feature. ` + |
| 166 | + 'Use FeatureBuilder.withEventHandlerProp to register that handler, or make ' + |
| 167 | + 'sure its name is not misspell in the DOM script.' |
| 168 | + ); |
| 169 | + } |
| 170 | + } else if (type === 'error') { |
| 171 | + // Handle as an error message |
| 172 | + typeof onDOMError === 'function' && onDOMError(identifier, body); |
| 173 | + webshellDebug && |
| 174 | + console.warn( |
| 175 | + `[Webshell]: script from feature "${identifier}" raised an error: ${body}` |
| 176 | + ); |
| 177 | + return; |
| 178 | + } else if (type === 'log') { |
| 179 | + webshellDebug && severity === 'warn' && console.warn(body); |
| 180 | + webshellDebug && severity === 'info' && console.info(body); |
| 181 | + } |
| 182 | + } else { |
| 183 | + typeof onMessage === 'function' && onMessage(nativeEvent); |
| 184 | + } |
| 185 | + }, |
| 186 | + // eslint-disable-next-line react-hooks/exhaustive-deps |
| 187 | + [...Object.values(domHandlers), onDOMError, onMessage] |
| 188 | + ); |
| 189 | + const { webViewRef, injectedJavaScript, ...webViewProps } = props; |
| 190 | + const resultingJavascript = React.useMemo(() => { |
| 191 | + const safeUserscript = |
| 192 | + typeof injectedJavaScript === 'string' ? injectedJavaScript : ''; |
| 193 | + return `(function(){${safeUserscript};${injectableScript};})();true;`; |
| 194 | + }, [injectedJavaScript]); |
| 195 | + return ( |
| 196 | + <WebView |
| 197 | + {...filterWebViewProps(webViewProps, propsMap)} |
| 198 | + ref={webViewRef} |
| 199 | + injectedJavaScript={resultingJavascript} |
| 200 | + javaScriptEnabled={true} |
| 201 | + onMessage={handleOnMessage} |
| 202 | + /> |
| 203 | + ); |
| 204 | + }; |
| 205 | + Webshell.defaultProps = { |
| 206 | + webshellDebug: __DEV__ |
| 207 | + }; |
| 208 | + return React.forwardRef< |
| 209 | + ElementRef<C>, |
| 210 | + WebshellProps<ComponentPropsWithRef<C>, F> |
| 211 | + >((props, ref) => ( |
| 212 | + <Webshell |
| 213 | + webViewRef={ref} |
| 214 | + {...(props as WebshellProps<ComponentPropsWithRef<any>, F>)} |
| 215 | + /> |
| 216 | + )) as any; |
| 217 | +} |
0 commit comments