@sentio/runtime 0.0.0-rc.a

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/service.ts ADDED
@@ -0,0 +1,505 @@
1
+ import { CallContext, ServerError, Status } from 'nice-grpc'
2
+ import { DebugInfo, RichServerError } from 'nice-grpc-error-details'
3
+ import { from } from 'ix/Ix.asynciterable'
4
+ import { withAbort } from 'ix/Ix.asynciterable.operators'
5
+
6
+ import {
7
+ DataBinding,
8
+ DeepPartial,
9
+ Empty,
10
+ EthCallParam,
11
+ HandlerType,
12
+ PreparedData,
13
+ PreprocessResult,
14
+ PreprocessStreamRequest,
15
+ PreprocessStreamResponse,
16
+ ProcessBindingResponse,
17
+ ProcessBindingsRequest,
18
+ ProcessConfigRequest,
19
+ ProcessConfigResponse,
20
+ ProcessorServiceImplementation,
21
+ ProcessResult,
22
+ ProcessStreamRequest,
23
+ ProcessStreamResponse,
24
+ StartRequest
25
+ } from '@sentio/protos'
26
+
27
+ import { PluginManager } from './plugin.js'
28
+ import { errorString, makeEthCallKey, mergeProcessResults } from './utils.js'
29
+ import { freezeGlobalConfig, GLOBAL_CONFIG } from './global-config.js'
30
+
31
+ import { StoreContext } from './db-context.js'
32
+ import { Subject } from 'rxjs'
33
+ import { getProvider } from './provider.js'
34
+ import { EthChainId } from '@sentio/chain'
35
+ import { Provider } from 'ethers'
36
+ import { decodeMulticallResult, encodeMulticallData, getMulticallAddress, Multicall3Call } from './multicall.js'
37
+
38
+ import { processMetrics } from './metrics.js'
39
+
40
+ const { process_binding_count, process_binding_time, process_binding_error } = processMetrics
41
+
42
+ ;(BigInt.prototype as any).toJSON = function () {
43
+ return this.toString()
44
+ }
45
+
46
+ export class ProcessorServiceImpl implements ProcessorServiceImplementation {
47
+ private started = false
48
+ // When there is unhandled error, stop process and return unavailable error
49
+ unhandled: Error
50
+ // private processorConfig: ProcessConfigResponse
51
+
52
+ private readonly loader: () => Promise<any>
53
+
54
+ private readonly shutdownHandler?: () => void
55
+
56
+ private readonly enablePreprocess: boolean
57
+
58
+ private preparedData: PreparedData | undefined
59
+
60
+ constructor(loader: () => Promise<any>, shutdownHandler?: () => void) {
61
+ this.loader = loader
62
+ this.shutdownHandler = shutdownHandler
63
+
64
+ this.enablePreprocess = process.env['ENABLE_PREPROCESS']
65
+ ? process.env['ENABLE_PREPROCESS'].toLowerCase() == 'true'
66
+ : false
67
+ }
68
+
69
+ async getConfig(request: ProcessConfigRequest, context: CallContext): Promise<ProcessConfigResponse> {
70
+ if (!this.started) {
71
+ throw new ServerError(Status.UNAVAILABLE, 'Service Not started.')
72
+ }
73
+ // if (!this.processorConfig) {
74
+ // throw new ServerError(Status.INTERNAL, 'Process config empty.')
75
+ // }
76
+
77
+ // Don't use .create to keep compatiblity
78
+ const newConfig = ProcessConfigResponse.fromPartial({})
79
+ await PluginManager.INSTANCE.configure(newConfig)
80
+ return newConfig
81
+ }
82
+
83
+ //
84
+ // async configure() {
85
+ // this.processorConfig = ProcessConfigResponse.fromPartial({})
86
+ // await PluginManager.INSTANCE.configure(this.processorConfig)
87
+ // }
88
+
89
+ async start(request: StartRequest, context: CallContext): Promise<Empty> {
90
+ if (this.started) {
91
+ return {}
92
+ }
93
+
94
+ freezeGlobalConfig()
95
+
96
+ try {
97
+ // for (const plugin of ['@sentio/sdk', '@sentio/sdk/eth']) {
98
+ // try {
99
+ // await import(plugin)
100
+ // } catch (e) {
101
+ // console.error('Failed to load plugin: ', plugin)
102
+ // }
103
+ // }
104
+ //
105
+ // for (const plugin of ['@sentio/sdk/aptos', '@sentio/sdk/solana']) {
106
+ // try {
107
+ // await import(plugin)
108
+ // } catch (e) {}
109
+ // }
110
+
111
+ await this.loader()
112
+ } catch (e) {
113
+ throw new ServerError(Status.INVALID_ARGUMENT, 'Failed to load processor: ' + errorString(e))
114
+ }
115
+
116
+ await PluginManager.INSTANCE.start(request)
117
+
118
+ // try {
119
+ // await this.configure()
120
+ // } catch (e) {
121
+ // throw new ServerError(Status.INTERNAL, 'Failed to start processor : ' + errorString(e))
122
+ // }
123
+ this.started = true
124
+ return {}
125
+ }
126
+
127
+ async stop(request: Empty, context: CallContext): Promise<Empty> {
128
+ console.log('Server Shutting down in 5 seconds')
129
+ if (this.shutdownHandler) {
130
+ setTimeout(this.shutdownHandler, 5000)
131
+ }
132
+ return {}
133
+ }
134
+
135
+ async processBindings(request: ProcessBindingsRequest, options?: CallContext): Promise<ProcessBindingResponse> {
136
+ const preparedData = this.enablePreprocess
137
+ ? await this.preprocessBindings(request.bindings, {}, undefined, options)
138
+ : { ethCallResults: {} }
139
+
140
+ const promises = []
141
+ for (const binding of request.bindings) {
142
+ const promise = this.processBinding(binding, preparedData)
143
+ if (GLOBAL_CONFIG.execution.sequential) {
144
+ await promise
145
+ }
146
+ promises.push(promise)
147
+ }
148
+ let promise
149
+ try {
150
+ promise = await Promise.all(promises)
151
+ processMetrics.process_binding_count.add(request.bindings.length)
152
+ } catch (e) {
153
+ processMetrics.process_binding_error.add(request.bindings.length)
154
+ throw e
155
+ }
156
+ const result = mergeProcessResults(promise)
157
+
158
+ // let updated = false
159
+ // if (PluginManager.INSTANCE.stateDiff(this.processorConfig)) {
160
+ // await this.configure()
161
+ // updated = true
162
+ // }
163
+
164
+ return {
165
+ result
166
+ }
167
+ }
168
+
169
+ async preprocessBindings(
170
+ bindings: DataBinding[],
171
+ preprocessStore: { [k: string]: any },
172
+ dbContext?: StoreContext,
173
+ options?: CallContext
174
+ ): Promise<PreparedData> {
175
+ // console.debug(`preprocessBindings start, bindings: ${bindings.length}`)
176
+ const promises = []
177
+ for (const binding of bindings) {
178
+ promises.push(this.preprocessBinding(binding, preprocessStore, dbContext, options))
179
+ }
180
+ let preprocessResults: PreprocessResult[]
181
+ try {
182
+ preprocessResults = await Promise.all(promises)
183
+ } catch (e) {
184
+ throw e
185
+ }
186
+ const groupedRequests = new Map<string, EthCallParam[]>()
187
+ const providers = new Map<string, Provider>()
188
+ for (const result of preprocessResults) {
189
+ for (const param of result.ethCallParams) {
190
+ const { chainId, blockTag } = param.context!
191
+ if (!providers.has(chainId)) {
192
+ providers.set(chainId, getProvider(chainId as EthChainId))
193
+ }
194
+ const key = [chainId, blockTag].join('|')
195
+ if (!groupedRequests.has(key)) {
196
+ groupedRequests.set(key, [])
197
+ }
198
+ groupedRequests.get(key)!.push(param)
199
+ }
200
+ }
201
+
202
+ const start = Date.now()
203
+ const MULTICALL_THRESHOLD = 1
204
+ const callPromises: Promise<[string, string]>[] = []
205
+ const multicallPromises: Promise<[string, string][]>[] = []
206
+
207
+ for (const params of groupedRequests.values()) {
208
+ const { chainId, blockTag } = params[0].context!
209
+ const multicallAddress = getMulticallAddress(chainId as EthChainId)
210
+ if (params.length <= MULTICALL_THRESHOLD || !multicallAddress) {
211
+ for (const param of params) {
212
+ callPromises.push(
213
+ providers
214
+ .get(chainId)!
215
+ .call({
216
+ to: param.context!.address,
217
+ data: param.calldata,
218
+ blockTag
219
+ })
220
+ .then((result) => [makeEthCallKey(param), result])
221
+ )
222
+ }
223
+ continue
224
+ }
225
+
226
+ // construct multicalls
227
+ const CHUNK_SIZE = 128
228
+ for (let i = 0; i < params.length; i += CHUNK_SIZE) {
229
+ const chunk = params.slice(i, i + CHUNK_SIZE)
230
+ const calls: Multicall3Call[] = chunk.map((param) => ({
231
+ target: param.context!.address,
232
+ callData: param.calldata
233
+ }))
234
+ const data = encodeMulticallData(calls)
235
+ multicallPromises.push(
236
+ providers
237
+ .get(chainId)!
238
+ .call({
239
+ to: multicallAddress,
240
+ data: data,
241
+ blockTag
242
+ })
243
+ .then((raw) => {
244
+ const result = decodeMulticallResult(raw).returnData
245
+ if (result.length != chunk.length) {
246
+ throw new Error(`multicall result length mismatch, params: ${chunk.length}, result: ${result.length}`)
247
+ }
248
+ const ret: [string, string][] = []
249
+ for (let i = 0; i < chunk.length; i++) {
250
+ ret.push([makeEthCallKey(chunk[i]), result[i]])
251
+ }
252
+ return ret
253
+ })
254
+ )
255
+ }
256
+ }
257
+
258
+ let results: { [p: string]: string } = {}
259
+ try {
260
+ results = Object.fromEntries(await Promise.all(callPromises))
261
+ for (const multicallResult of await Promise.all(multicallPromises)) {
262
+ results = {
263
+ ...results,
264
+ ...Object.fromEntries(multicallResult)
265
+ }
266
+ }
267
+ } catch (e) {
268
+ console.error(`eth call error: ${e}`)
269
+ }
270
+ // console.debug(
271
+ // `${Object.keys(results).length} calls finished, actual calls: ${callPromises.length + multicallPromises.length}, elapsed: ${Date.now() - start}ms`
272
+ // )
273
+ return {
274
+ ethCallResults: results
275
+ }
276
+ }
277
+
278
+ async preprocessBinding(
279
+ request: DataBinding,
280
+ preprocessStore: { [k: string]: any },
281
+ dbContext?: StoreContext,
282
+ options?: CallContext
283
+ ): Promise<PreprocessResult> {
284
+ if (!this.started) {
285
+ throw new ServerError(Status.UNAVAILABLE, 'Service Not started.')
286
+ }
287
+ if (this.unhandled) {
288
+ throw new RichServerError(
289
+ Status.UNAVAILABLE,
290
+ 'Unhandled exception/rejection in previous request: ' + errorString(this.unhandled),
291
+ [
292
+ DebugInfo.fromPartial({
293
+ detail: this.unhandled.message,
294
+ stackEntries: this.unhandled.stack?.split('\n')
295
+ })
296
+ ]
297
+ )
298
+ }
299
+ return await PluginManager.INSTANCE.preprocessBinding(request, preprocessStore, dbContext)
300
+ }
301
+
302
+ async processBinding(
303
+ request: DataBinding,
304
+ preparedData: PreparedData | undefined,
305
+ options?: CallContext
306
+ ): Promise<ProcessResult> {
307
+ if (!this.started) {
308
+ throw new ServerError(Status.UNAVAILABLE, 'Service Not started.')
309
+ }
310
+ if (this.unhandled) {
311
+ throw new RichServerError(
312
+ Status.UNAVAILABLE,
313
+ 'Unhandled exception/rejection in previous request: ' + errorString(this.unhandled),
314
+ [
315
+ DebugInfo.fromPartial({
316
+ detail: this.unhandled.message,
317
+ stackEntries: this.unhandled.stack?.split('\n')
318
+ })
319
+ ]
320
+ )
321
+ }
322
+
323
+ const result = await PluginManager.INSTANCE.processBinding(
324
+ request,
325
+ preparedData,
326
+ PluginManager.INSTANCE.dbContextLocalStorage.getStore()
327
+ )
328
+ recordRuntimeInfo(result, request.handlerType)
329
+ return result
330
+ }
331
+
332
+ async *processBindingsStream(requests: AsyncIterable<ProcessStreamRequest>, context: CallContext) {
333
+ if (!this.started) {
334
+ throw new ServerError(Status.UNAVAILABLE, 'Service Not started.')
335
+ }
336
+
337
+ const subject = new Subject<DeepPartial<ProcessStreamResponse>>()
338
+ this.handleRequests(requests, subject)
339
+ .then(() => {
340
+ if (this.preparedData) {
341
+ this.preparedData = { ethCallResults: {} }
342
+ }
343
+ subject.complete()
344
+ })
345
+ .catch((e) => {
346
+ console.error(e)
347
+ subject.error(e)
348
+ })
349
+ yield* from(subject).pipe(withAbort(context.signal))
350
+ }
351
+
352
+ async handlePreprocessRequests(
353
+ requests: AsyncIterable<PreprocessStreamRequest>,
354
+ subject: Subject<DeepPartial<PreprocessStreamResponse>>
355
+ ) {
356
+ const contexts = new Contexts()
357
+ const preprocessStore: { [k: string]: any } = {}
358
+
359
+ for await (const request of requests) {
360
+ try {
361
+ if (request.bindings) {
362
+ const bindings = request.bindings.bindings
363
+ const dbContext = contexts.new(request.processId, subject)
364
+ const start = Date.now()
365
+ this.preprocessBindings(bindings, preprocessStore, dbContext, undefined)
366
+ .then((preparedData) => {
367
+ // TODO maybe not proper to pass data in this way
368
+ this.preparedData = {
369
+ ethCallResults: {
370
+ ...this.preparedData?.ethCallResults,
371
+ ...preparedData.ethCallResults
372
+ }
373
+ }
374
+ subject.next({
375
+ processId: request.processId
376
+ })
377
+ })
378
+ .catch((e) => {
379
+ console.debug(e)
380
+ dbContext.error(request.processId, e)
381
+ })
382
+ .finally(() => {
383
+ const cost = Date.now() - start
384
+ console.debug('preprocessBinding', request.processId, ' took', cost, 'ms')
385
+ contexts.delete(request.processId)
386
+ })
387
+ }
388
+ if (request.dbResult) {
389
+ const dbContext = contexts.get(request.processId)
390
+ dbContext?.result(request.dbResult)
391
+ }
392
+ } catch (e) {
393
+ // should not happen
394
+ console.error('unexpect error during handle loop', e)
395
+ }
396
+ }
397
+ }
398
+
399
+ async *preprocessBindingsStream(requests: AsyncIterable<PreprocessStreamRequest>, context: CallContext) {
400
+ if (!this.started) {
401
+ throw new ServerError(Status.UNAVAILABLE, 'Service Not started.')
402
+ }
403
+
404
+ const subject = new Subject<DeepPartial<PreprocessStreamResponse>>()
405
+ this.handlePreprocessRequests(requests, subject)
406
+ .then(() => {
407
+ subject.complete()
408
+ })
409
+ .catch((e) => {
410
+ console.error(e)
411
+ subject.error(e)
412
+ })
413
+ yield* from(subject).pipe(withAbort(context.signal))
414
+ }
415
+
416
+ private async handleRequests(
417
+ requests: AsyncIterable<ProcessStreamRequest>,
418
+ subject: Subject<DeepPartial<ProcessStreamResponse>>
419
+ ) {
420
+ const contexts = new Contexts()
421
+ for await (const request of requests) {
422
+ try {
423
+ // console.debug('received request:', request)
424
+ if (request.binding) {
425
+ process_binding_count.add(1)
426
+
427
+ // Adjust binding will make some request become invalid by setting UNKNOWN HandlerType
428
+ // for older SDK version, so we just return empty result for them here
429
+ if (request.binding.handlerType === HandlerType.UNKNOWN) {
430
+ subject.next({
431
+ processId: request.processId,
432
+ result: ProcessResult.create()
433
+ })
434
+ continue
435
+ }
436
+
437
+ const binding = request.binding
438
+ const dbContext = contexts.new(request.processId, subject)
439
+ const start = Date.now()
440
+ PluginManager.INSTANCE.processBinding(binding, this.preparedData, dbContext)
441
+ .then(async (result) => {
442
+ // await all pending db requests
443
+ await dbContext.awaitPendings()
444
+ subject.next({
445
+ result,
446
+ processId: request.processId
447
+ })
448
+ recordRuntimeInfo(result, binding.handlerType)
449
+ })
450
+ .catch((e) => {
451
+ console.debug(e)
452
+ dbContext.error(request.processId, e)
453
+ process_binding_error.add(1)
454
+ })
455
+ .finally(() => {
456
+ const cost = Date.now() - start
457
+ process_binding_time.add(cost)
458
+ contexts.delete(request.processId)
459
+ })
460
+ }
461
+ if (request.dbResult) {
462
+ const dbContext = contexts.get(request.processId)
463
+ try {
464
+ dbContext?.result(request.dbResult)
465
+ } catch (e) {
466
+ subject.error(new Error('db result error, process should stop'))
467
+ }
468
+ }
469
+ } catch (e) {
470
+ // should not happen
471
+ console.error('unexpect error during handle loop', e)
472
+ }
473
+ }
474
+ }
475
+ }
476
+
477
+ export function recordRuntimeInfo(results: ProcessResult, handlerType: HandlerType) {
478
+ for (const list of [results.gauges, results.counters, results.events, results.exports]) {
479
+ list.forEach((e) => {
480
+ e.runtimeInfo = {
481
+ from: handlerType
482
+ }
483
+ })
484
+ }
485
+ }
486
+
487
+ class Contexts {
488
+ private contexts: Map<number, StoreContext> = new Map()
489
+
490
+ get(processId: number) {
491
+ return this.contexts.get(processId)
492
+ }
493
+
494
+ new(processId: number, subject: Subject<DeepPartial<ProcessStreamResponse>>) {
495
+ const context = new StoreContext(subject, processId)
496
+ this.contexts.set(processId, context)
497
+ return context
498
+ }
499
+
500
+ delete(processId: number) {
501
+ const context = this.get(processId)
502
+ context?.close()
503
+ this.contexts.delete(processId)
504
+ }
505
+ }
package/src/state.ts ADDED
@@ -0,0 +1,83 @@
1
+ export class State {
2
+ stateMap = new Map<string, any>()
3
+
4
+ static INSTANCE = new State()
5
+
6
+ static reset() {
7
+ State.INSTANCE = new State()
8
+ }
9
+ }
10
+
11
+ export abstract class StateStorage<T> {
12
+ // TODO learn how to define single instance for all subclasses
13
+
14
+ protected constructor() {
15
+ //
16
+ }
17
+
18
+ abstract initValue(): T
19
+
20
+ key(): string {
21
+ return this.constructor.name
22
+ }
23
+
24
+ getOrRegister(): T {
25
+ let metricState: T = State.INSTANCE.stateMap.get(this.key())
26
+ if (!metricState) {
27
+ metricState = this.initValue()
28
+ State.INSTANCE.stateMap.set(this.key(), metricState)
29
+ }
30
+ return metricState
31
+ }
32
+
33
+ unregister(): T {
34
+ const value = State.INSTANCE.stateMap.get(this.key())
35
+ State.INSTANCE.stateMap.delete(this.key())
36
+ return value
37
+ }
38
+ }
39
+
40
+ export abstract class MapStateStorage<T> extends StateStorage<Map<string, T>> {
41
+ initValue() {
42
+ return new Map<string, T>()
43
+ }
44
+
45
+ getValue(key: string): T | undefined {
46
+ const m = this.getOrRegister()
47
+ return m.get(key)
48
+ }
49
+
50
+ getValues(): T[] {
51
+ const m = this.getOrRegister()
52
+ return Array.from(m.values())
53
+ }
54
+
55
+ getOrSetValue(key: string, value: T): T {
56
+ const m = this.getOrRegister()
57
+ const oldValue = m.get(key)
58
+ if (oldValue) {
59
+ if (oldValue !== value) {
60
+ console.warn(key, 'has been registered twice, use the previous one')
61
+ }
62
+ return oldValue
63
+ }
64
+ m.set(key, value)
65
+ return value
66
+ }
67
+ }
68
+
69
+ export abstract class ListStateStorage<T> extends StateStorage<T[]> {
70
+ initValue() {
71
+ return []
72
+ }
73
+
74
+ getValues(): T[] {
75
+ return this.getOrRegister()
76
+ }
77
+
78
+ addValue(value: T): T {
79
+ const m = this.getOrRegister()
80
+ m.push(value)
81
+ return value
82
+ }
83
+ }
@@ -0,0 +1,16 @@
1
+ import { defineConfig } from 'tsup'
2
+
3
+ export default defineConfig({
4
+ esbuildOptions: (options) => {
5
+ options.banner = {
6
+ js: `import { createRequire as createRequireShim } from 'module'; const require = createRequireShim(import.meta.url);`
7
+ }
8
+ },
9
+ entry: ['src/index.ts', 'src/processor-runner.ts'],
10
+ outDir: 'lib',
11
+ minify: process.env['BRANCH'] === 'release',
12
+ sourcemap: true,
13
+ clean: true,
14
+ dts: true,
15
+ format: 'esm'
16
+ })
package/src/utils.ts ADDED
@@ -0,0 +1,93 @@
1
+ import { EthCallParam, ProcessResult } from '@sentio/protos'
2
+
3
+ // TODO better handling this, because old proto doesn't have this
4
+ import { StateResult, ProcessResult as ProcessResultFull } from './gen/processor/protos/processor.js'
5
+
6
+ import { Required } from 'utility-types'
7
+
8
+ export function mergeProcessResults(results: ProcessResult[]): Required<ProcessResult, 'states'> {
9
+ const res = {
10
+ ...ProcessResultFull.create(),
11
+ states: StateResult.create()
12
+ }
13
+
14
+ for (const r of results) {
15
+ res.counters = res.counters.concat(r.counters)
16
+ res.gauges = res.gauges.concat(r.gauges)
17
+ res.events = res.events.concat(r.events)
18
+ res.exports = res.exports.concat(r.exports)
19
+ res.states = {
20
+ configUpdated: res.states?.configUpdated || r.states?.configUpdated || false
21
+ }
22
+ }
23
+ return res
24
+ }
25
+
26
+ export function errorString(e: Error): string {
27
+ return e.message + '\n' + e.stack
28
+ }
29
+
30
+ export const USER_PROCESSOR = 'user_processor'
31
+
32
+ export function makeEthCallKey(param: EthCallParam) {
33
+ if (!param.context) {
34
+ throw new Error('null context for eth call')
35
+ }
36
+ const { chainId, address, blockTag } = param.context
37
+ return `${chainId}|${address}|${blockTag}|${param.calldata}`.toLowerCase()
38
+ }
39
+
40
+ export type Semver = {
41
+ semVer?: string
42
+ major: number
43
+ minor: number
44
+ patch: number
45
+ prerelease?: string
46
+ buildmetadata?: string
47
+ }
48
+
49
+ export function parseSemver(version: string): Semver {
50
+ const [semVer, major, minor, patch, prerelease, buildmetadata] =
51
+ version.match(
52
+ /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/
53
+ ) ?? []
54
+ return {
55
+ semVer,
56
+ major: parseInt(major),
57
+ minor: parseInt(minor),
58
+ patch: parseInt(patch),
59
+ prerelease,
60
+ buildmetadata
61
+ }
62
+ }
63
+
64
+ export function compareSemver(a: Semver, b: Semver) {
65
+ const { major: ma, minor: mia, patch: pa, prerelease: pra } = a
66
+ const { major: mb, minor: mib, patch: pb, prerelease: prb } = b
67
+
68
+ if (ma !== mb) {
69
+ return ma - mb
70
+ }
71
+ if (mia !== mib) {
72
+ return mia - mib
73
+ }
74
+
75
+ if (pa !== pb) {
76
+ return pa - pb
77
+ }
78
+ if (pra && prb) {
79
+ const [sa, va] = pra.split('.')
80
+ const [sb, vb] = prb.split('.')
81
+
82
+ if (sa !== sb) {
83
+ return sa.localeCompare(sb)
84
+ }
85
+
86
+ return parseInt(va) - parseInt(vb)
87
+ } else if (pra) {
88
+ return -1
89
+ } else if (prb) {
90
+ return 1
91
+ }
92
+ return 0
93
+ }