UNPKG

16.6 kBPlain TextView Raw
1export * from './types'
2export type { _IgnoreResponse } from './core/notify'
3export { JSONSerialization, NoSerialization } from './utils/serialization'
4export { notify } from './core/notify'
5export { batch } from './core/batch'
6
7import { NoSerialization } from './utils/serialization'
8import {
9 Request,
10 Response,
11 ErrorResponseMapped,
12 SuccessResponse,
13 hasKey,
14 isJSONRPCObject,
15 isObject,
16 ErrorResponse,
17 defaultErrorMapper,
18 ErrorResponseMethodNotFound,
19 ErrorResponseInvalidRequest,
20 ErrorResponseParseError,
21} from './utils/jsonrpc'
22import {
23 removeStackHeader,
24 RecoverError,
25 makeHostedMessage,
26 Err_Cannot_call_method_starts_with_rpc_dot_directly,
27 Err_Then_is_accessed_on_local_implementation_Please_explicitly_mark_if_it_is_thenable_in_the_options,
28} from './utils/error'
29import { generateRandomID } from './utils/generateRandomID'
30import { normalizeStrictOptions, normalizeLogOptions } from './utils/normalizeOptions'
31import { AsyncCallIgnoreResponse, AsyncCallNotify, AsyncCallBatch } from './utils/internalSymbol'
32import type { BatchQueue } from './core/batch'
33import type { CallbackBasedChannel, EventBasedChannel, AsyncCallOptions, ConsoleInterface, _AsyncVersionOf } from './types'
34import {
35 ERROR,
36 isArray,
37 isFunction,
38 isString,
39 Promise_reject,
40 Promise_resolve,
41 replayFunction,
42 undefined,
43} from './utils/constants'
44
45/**
46 * Create a RPC server & client.
47 *
48 * @remarks
49 * See {@link AsyncCallOptions}
50 *
51 * thisSideImplementation can be a Promise so you can write:
52 *
53 * ```ts
54 * export const service = AsyncCall(typeof window === 'object' ? {} : import('./backend/service.js'), {})
55 * ```
56 *
57 * @param thisSideImplementation - The implementation when this AsyncCall acts as a JSON RPC server. Can be a Promise.
58 * @param options - {@link AsyncCallOptions}
59 * @typeParam OtherSideImplementedFunctions - The type of the API that server expose. For any function on this interface, it will be converted to the async version.
60 * @returns Same as the `OtherSideImplementedFunctions` type parameter, but every function in that interface becomes async and non-function value is removed. Method called "then" are also removed.
61 * @public
62 */
63export function AsyncCall<OtherSideImplementedFunctions = {}>(
64 thisSideImplementation: null | undefined | object | Promise<object>,
65 options: AsyncCallOptions,
66): _AsyncVersionOf<OtherSideImplementedFunctions> {
67 let isThisSideImplementationPending = true
68 let resolvedThisSideImplementationValue: unknown = undefined
69 let rejectedThisSideImplementation: unknown = undefined
70 // This promise should never fail
71 const awaitThisSideImplementation = async () => {
72 try {
73 resolvedThisSideImplementationValue = await thisSideImplementation
74 } catch (e) {
75 rejectedThisSideImplementation = e
76 console_error('AsyncCall failed to start', e)
77 } finally {
78 isThisSideImplementationPending = false
79 }
80 }
81
82 const {
83 serializer = NoSerialization,
84 key: logKey = 'rpc',
85 strict = true,
86 log = true,
87 parameterStructures = 'by-position',
88 preferLocalImplementation = false,
89 idGenerator = generateRandomID,
90 mapError,
91 logger,
92 channel,
93 thenable,
94 } = options
95
96 if (thisSideImplementation instanceof Promise) awaitThisSideImplementation()
97 else {
98 resolvedThisSideImplementationValue = thisSideImplementation
99 isThisSideImplementationPending = false
100 }
101
102 const [banMethodNotFound, banUnknownMessage] = normalizeStrictOptions(strict)
103 const [
104 log_beCalled,
105 log_localError,
106 log_remoteError,
107 log_pretty,
108 log_requestReplay,
109 log_sendLocalStack,
110 ] = normalizeLogOptions(log)
111 const {
112 log: console_log,
113 error: console_error = console_log,
114 debug: console_debug = console_log,
115 groupCollapsed: console_groupCollapsed = console_log,
116 groupEnd: console_groupEnd = console_log,
117 warn: console_warn = console_log,
118 } = (logger || console) as ConsoleInterface
119 type PromiseParam = [resolve: (value?: any) => void, reject: (reason?: any) => void]
120 const requestContext = new Map<string | number, { f: PromiseParam; stack: string }>()
121 const onRequest = async (data: Request): Promise<Response | undefined> => {
122 if (isThisSideImplementationPending) await awaitThisSideImplementation()
123 else {
124 // not pending
125 if (rejectedThisSideImplementation) return makeErrorObject(rejectedThisSideImplementation, '', data)
126 }
127 let frameworkStack: string = ''
128 try {
129 const { params, method, id: req_id, remoteStack } = data
130 // ? We're mapping any method starts with 'rpc.' to a Symbol.for
131 const key = (method.startsWith('rpc.') ? Symbol.for(method) : method) as keyof object
132 const executor: unknown =
133 resolvedThisSideImplementationValue && (resolvedThisSideImplementationValue as any)[key]
134 if (!isFunction(executor)) {
135 if (!banMethodNotFound) {
136 if (log_localError) console_debug('Missing method', key, data)
137 return
138 } else return ErrorResponseMethodNotFound(req_id)
139 }
140 const args = isArray(params) ? params : [params]
141 frameworkStack = removeStackHeader(new Error().stack)
142 const promise = new Promise((resolve) => resolve(executor.apply(resolvedThisSideImplementationValue, args)))
143 if (log_beCalled) {
144 if (log_pretty) {
145 const logArgs: unknown[] = [
146 `${logKey}.%c${method}%c(${args.map(() => '%o').join(', ')}%c)\n%o %c@${req_id}`,
147 'color: #d2c057',
148 '',
149 ...args,
150 '',
151 promise,
152 'color: gray; font-style: italic;',
153 ]
154 if (log_requestReplay) {
155 // This function will be logged to the console so it must be 1 line
156 // prettier-ignore
157 const replay = () => { debugger; return executor.apply(resolvedThisSideImplementationValue, args) }
158 replay.toString = replayFunction
159 logArgs.push(replay)
160 }
161 if (remoteStack) {
162 console_groupCollapsed(...logArgs)
163 console_log(remoteStack)
164 console_groupEnd()
165 } else console_log(...logArgs)
166 } else console_log(`${logKey}.${method}(${[...args].toString()}) @${req_id}`)
167 }
168 const result = await promise
169 if (result === AsyncCallIgnoreResponse) return
170 return SuccessResponse(req_id, await promise)
171 } catch (e) {
172 return makeErrorObject(e, frameworkStack, data)
173 }
174 }
175 const onResponse = async (data: Response): Promise<void> => {
176 let errorMessage = '',
177 remoteErrorStack = '',
178 errorCode = 0,
179 errorType = ERROR
180 if (hasKey(data, 'error')) {
181 const e = data.error
182 errorMessage = e.message
183 errorCode = e.code
184 const detail = e.data
185
186 if (isObject(detail) && hasKey(detail, 'stack') && isString(detail.stack)) remoteErrorStack = detail.stack
187 else remoteErrorStack = '<remote stack not available>'
188
189 if (isObject(detail) && hasKey(detail, 'type') && isString(detail.type)) errorType = detail.type
190 else errorType = ERROR
191
192 if (log_remoteError)
193 log_pretty
194 ? console_error(
195 `${errorType}: ${errorMessage}(${errorCode}) %c@${data.id}\n%c${remoteErrorStack}`,
196 'color: gray',
197 '',
198 )
199 : console_error(`${errorType}: ${errorMessage}(${errorCode}) @${data.id}\n${remoteErrorStack}`)
200 }
201 if (data.id === null || data.id === undefined) return
202 const { f: [resolve, reject] = [null, null], stack: localErrorStack = '' } = requestContext.get(data.id) || {}
203 if (!resolve || !reject) return // drop this response
204 requestContext.delete(data.id)
205 if (hasKey(data, 'error')) {
206 reject(
207 RecoverError(
208 errorType,
209 errorMessage,
210 errorCode,
211 // ? We use \u0430 which looks like "a" to prevent browser think "at AsyncCall" is a real stack
212 remoteErrorStack + '\n \u0430t AsyncCall (rpc) \n' + localErrorStack,
213 ),
214 )
215 } else {
216 resolve(data.result)
217 }
218 return
219 }
220 const rawMessageReceiver = async (_: unknown): Promise<undefined | Response | (Response | undefined)[]> => {
221 let data: unknown
222 let result: Response | undefined = undefined
223 try {
224 data = await deserialization(_)
225 if (isJSONRPCObject(data)) {
226 return (result = await handleSingleMessage(data))
227 } else if (isArray(data) && data.every(isJSONRPCObject) && data.length !== 0) {
228 return Promise.all(data.map(handleSingleMessage))
229 } else {
230 if (banUnknownMessage) {
231 let id = (data as any).id
232 if (id === undefined) id = null
233 return ErrorResponseInvalidRequest(id)
234 } else {
235 // ? Ignore this message. The message channel maybe also used to transfer other message too.
236 return undefined
237 }
238 }
239 } catch (e) {
240 if (log_localError) console_error(e, data, result)
241 return ErrorResponseParseError(e, mapError || defaultErrorMapper(e && e.stack))
242 }
243 }
244 const rawMessageSender = async (res: undefined | Response | (Response | undefined)[]) => {
245 if (!res) return
246 if (isArray(res)) {
247 const reply = res.filter((x) => x && hasKey(x, 'id'))
248 if (reply.length === 0) return
249 return serialization(reply)
250 } else {
251 return serialization(res)
252 }
253 }
254 const serialization = (x: unknown) => serializer.serialization(x)
255 const deserialization = (x: unknown) => serializer.deserialization(x)
256 const isEventBasedChannel = (x: typeof channel): x is EventBasedChannel => hasKey(x, 'send') && isFunction(x.send)
257 const isCallbackBasedChannel = (x: typeof channel): x is CallbackBasedChannel =>
258 hasKey(x, 'setup') && isFunction(x.setup)
259
260 if (isCallbackBasedChannel(channel)) {
261 channel.setup(
262 (data) => rawMessageReceiver(data).then(rawMessageSender),
263 (data) => {
264 const _ = deserialization(data)
265 if (isJSONRPCObject(_)) return true
266 return Promise_resolve(_).then(isJSONRPCObject)
267 },
268 )
269 }
270 if (isEventBasedChannel(channel)) {
271 const m = channel as EventBasedChannel | CallbackBasedChannel
272 m.on &&
273 m.on((_) =>
274 rawMessageReceiver(_)
275 .then(rawMessageSender)
276 .then((x) => x && m.send!(x)),
277 )
278 }
279 function makeErrorObject(e: any, frameworkStack: string, data: Request) {
280 if (isObject(e) && hasKey(e, 'stack'))
281 e.stack = frameworkStack
282 .split('\n')
283 .reduce((stack, fstack) => stack.replace(fstack + '\n', ''), '' + e.stack)
284 if (log_localError) console_error(e)
285 return ErrorResponseMapped(data, e, mapError || defaultErrorMapper(log_sendLocalStack ? e.stack : undefined))
286 }
287
288 async function sendPayload(payload: unknown, removeQueueR = false) {
289 if (removeQueueR) payload = [...(payload as BatchQueue)]
290 const data = await serialization(payload)
291 return channel.send!(data)
292 }
293 function rejectsQueue(queue: BatchQueue, error: unknown) {
294 for (const x of queue) {
295 if (hasKey(x, 'id')) {
296 const ctx = requestContext.get(x.id!)
297 ctx && ctx.f[1](error)
298 }
299 }
300 }
301 const handleSingleMessage = async (
302 data: SuccessResponse | ErrorResponse | Request,
303 ): Promise<SuccessResponse | ErrorResponse | undefined> => {
304 if (hasKey(data, 'method')) {
305 const r = onRequest(data)
306 if (hasKey(data, 'id')) return r
307 try {
308 await r
309 } catch {}
310 return undefined // Does not care about return result for notifications
311 }
312 return onResponse(data) as Promise<undefined>
313 }
314 return new Proxy({ __proto__: null } as any, {
315 get(cache, method: string | symbol) {
316 if (method === 'then') {
317 if (thenable === undefined) {
318 console_warn(
319 makeHostedMessage(
320 Err_Then_is_accessed_on_local_implementation_Please_explicitly_mark_if_it_is_thenable_in_the_options,
321 new TypeError('RPC used as Promise: '),
322 ),
323 )
324 }
325 if (thenable !== true) return undefined
326 }
327 if (isString(method) && cache[method]) return cache[method]
328 const factory = (notify: boolean) => (...params: unknown[]) => {
329 let stack = removeStackHeader(new Error().stack)
330 let queue: BatchQueue | undefined = undefined
331 if (method === AsyncCallBatch) {
332 queue = params.shift() as any
333 method = params.shift() as any
334 }
335 if (typeof method === 'symbol') {
336 const RPCInternalMethod = Symbol.keyFor(method) || (method as any).description
337 if (RPCInternalMethod) {
338 if (RPCInternalMethod.startsWith('rpc.')) method = RPCInternalMethod
339 else return Promise_reject(new TypeError('Not start with rpc.'))
340 }
341 } else if (method.startsWith('rpc.'))
342 return Promise_reject(
343 makeHostedMessage(Err_Cannot_call_method_starts_with_rpc_dot_directly, new TypeError()),
344 )
345 return new Promise<void>((resolve, reject) => {
346 if (preferLocalImplementation && !isThisSideImplementationPending && isString(method)) {
347 const localImpl: unknown =
348 resolvedThisSideImplementationValue && (resolvedThisSideImplementationValue as any)[method]
349 if (isFunction(localImpl)) return resolve(localImpl(...params))
350 }
351 const id = idGenerator()
352 const [param0] = params
353 const sendingStack = log_sendLocalStack ? stack : ''
354 const param =
355 parameterStructures === 'by-name' && params.length === 1 && isObject(param0) ? param0 : params
356 const request = Request(notify ? undefined : id, method as string, param, sendingStack)
357 if (queue) {
358 queue.push(request)
359 if (!queue.r) queue.r = [() => sendPayload(queue, true), (e) => rejectsQueue(queue!, e)]
360 } else sendPayload(request).catch(reject)
361 if (notify) return resolve()
362 requestContext.set(id, {
363 f: [resolve, reject],
364 stack,
365 })
366 })
367 }
368 const f = factory(false)
369 // @ts-ignore
370 f[AsyncCallNotify] = factory(true)
371 // @ts-ignore
372 f[AsyncCallNotify][AsyncCallNotify] = f[AsyncCallNotify]
373 isString(method) && Object.defineProperty(cache, method, { value: f, configurable: true })
374 return f
375 },
376 }) as _AsyncVersionOf<OtherSideImplementedFunctions>
377}
378// Assume a console object in global if there is no custom logger provided
379declare const console: ConsoleInterface
380
\No newline at end of file