@linxin666/dsh-remote-web-ui 0.1.1

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.
Files changed (118) hide show
  1. package/LICENSE +28 -0
  2. package/README.md +346 -0
  3. package/cordis.patch.yml +10 -0
  4. package/lib/client.js +2817 -0
  5. package/lib/index.js +1455 -0
  6. package/lib/invariant.js +21 -0
  7. package/lib/types/client/PairFailedNotice.d.ts +12 -0
  8. package/lib/types/client/PairFailedNotice.d.ts.map +1 -0
  9. package/lib/types/client/PhoneIcon.d.ts +19 -0
  10. package/lib/types/client/PhoneIcon.d.ts.map +1 -0
  11. package/lib/types/client/PluginSettingsCard.d.ts +78 -0
  12. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -0
  13. package/lib/types/client/RemoteEntry.d.ts +10 -0
  14. package/lib/types/client/RemoteEntry.d.ts.map +1 -0
  15. package/lib/types/client/RemotePanel.d.ts +50 -0
  16. package/lib/types/client/RemotePanel.d.ts.map +1 -0
  17. package/lib/types/client/RemoteSettingsCard.d.ts +75 -0
  18. package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -0
  19. package/lib/types/client/deep-link.d.ts +40 -0
  20. package/lib/types/client/deep-link.d.ts.map +1 -0
  21. package/lib/types/client/index.d.ts +54 -0
  22. package/lib/types/client/index.d.ts.map +1 -0
  23. package/lib/types/client/locales.d.ts +144 -0
  24. package/lib/types/client/locales.d.ts.map +1 -0
  25. package/lib/types/client/pair-api.d.ts +100 -0
  26. package/lib/types/client/pair-api.d.ts.map +1 -0
  27. package/lib/types/client/settings-form.d.ts +119 -0
  28. package/lib/types/client/settings-form.d.ts.map +1 -0
  29. package/lib/types/gate.d.ts +58 -0
  30. package/lib/types/gate.d.ts.map +1 -0
  31. package/lib/types/index.d.ts +78 -0
  32. package/lib/types/index.d.ts.map +1 -0
  33. package/lib/types/invariant.d.ts +16 -0
  34. package/lib/types/invariant.d.ts.map +1 -0
  35. package/lib/types/lan.d.ts +15 -0
  36. package/lib/types/lan.d.ts.map +1 -0
  37. package/lib/types/mobile/api.d.ts +64 -0
  38. package/lib/types/mobile/api.d.ts.map +1 -0
  39. package/lib/types/mobile/index.d.ts +10 -0
  40. package/lib/types/mobile/index.d.ts.map +1 -0
  41. package/lib/types/mobile/messages.d.ts +103 -0
  42. package/lib/types/mobile/messages.d.ts.map +1 -0
  43. package/lib/types/mobile/messages.test.d.ts +2 -0
  44. package/lib/types/mobile/messages.test.d.ts.map +1 -0
  45. package/lib/types/mobile/mobile-styles.d.ts +6 -0
  46. package/lib/types/mobile/mobile-styles.d.ts.map +1 -0
  47. package/lib/types/mobile/mobile-theme.d.ts +22 -0
  48. package/lib/types/mobile/mobile-theme.d.ts.map +1 -0
  49. package/lib/types/mobile/mobile-theme.test.d.ts +2 -0
  50. package/lib/types/mobile/mobile-theme.test.d.ts.map +1 -0
  51. package/lib/types/mobile/mux.d.ts +52 -0
  52. package/lib/types/mobile/mux.d.ts.map +1 -0
  53. package/lib/types/mobile/rpc.d.ts +38 -0
  54. package/lib/types/mobile/rpc.d.ts.map +1 -0
  55. package/lib/types/mobile/theme-toggle.d.ts +12 -0
  56. package/lib/types/mobile/theme-toggle.d.ts.map +1 -0
  57. package/lib/types/mobile/views/App.d.ts +59 -0
  58. package/lib/types/mobile/views/App.d.ts.map +1 -0
  59. package/lib/types/mobile/views/ChatView.d.ts +39 -0
  60. package/lib/types/mobile/views/ChatView.d.ts.map +1 -0
  61. package/lib/types/mobile/views/ChatView.test.d.ts +2 -0
  62. package/lib/types/mobile/views/ChatView.test.d.ts.map +1 -0
  63. package/lib/types/mobile/views/SessionListView.d.ts +27 -0
  64. package/lib/types/mobile/views/SessionListView.d.ts.map +1 -0
  65. package/lib/types/mobile/views/SessionListView.test.d.ts +2 -0
  66. package/lib/types/mobile/views/SessionListView.test.d.ts.map +1 -0
  67. package/lib/types/mobile/views/WorkspaceView.d.ts +19 -0
  68. package/lib/types/mobile/views/WorkspaceView.d.ts.map +1 -0
  69. package/lib/types/mobile-api.d.ts +41 -0
  70. package/lib/types/mobile-api.d.ts.map +1 -0
  71. package/lib/types/mobile-routes.d.ts +16 -0
  72. package/lib/types/mobile-routes.d.ts.map +1 -0
  73. package/lib/types/pairing.d.ts +215 -0
  74. package/lib/types/pairing.d.ts.map +1 -0
  75. package/lib/types/routes.d.ts +58 -0
  76. package/lib/types/routes.d.ts.map +1 -0
  77. package/lib/types/tunnel.d.ts +99 -0
  78. package/lib/types/tunnel.d.ts.map +1 -0
  79. package/package.json +104 -0
  80. package/src/client/PairFailedNotice.tsx +33 -0
  81. package/src/client/PhoneIcon.tsx +31 -0
  82. package/src/client/PluginSettingsCard.tsx +207 -0
  83. package/src/client/RemoteEntry.tsx +202 -0
  84. package/src/client/RemotePanel.tsx +186 -0
  85. package/src/client/RemoteSettingsCard.tsx +220 -0
  86. package/src/client/deep-link.ts +137 -0
  87. package/src/client/index.ts +167 -0
  88. package/src/client/locales.ts +149 -0
  89. package/src/client/pair-api.ts +176 -0
  90. package/src/client/remote.module.css +422 -0
  91. package/src/client/settings-card.module.css +296 -0
  92. package/src/client/settings-form.ts +294 -0
  93. package/src/css-modules.d.ts +6 -0
  94. package/src/gate.ts +121 -0
  95. package/src/index.ts +299 -0
  96. package/src/invariant.ts +32 -0
  97. package/src/lan.ts +21 -0
  98. package/src/mobile/api.ts +106 -0
  99. package/src/mobile/index.tsx +27 -0
  100. package/src/mobile/messages.test.ts +222 -0
  101. package/src/mobile/messages.ts +555 -0
  102. package/src/mobile/mobile-styles.ts +853 -0
  103. package/src/mobile/mobile-theme.test.ts +84 -0
  104. package/src/mobile/mobile-theme.ts +76 -0
  105. package/src/mobile/mux.ts +126 -0
  106. package/src/mobile/rpc.ts +90 -0
  107. package/src/mobile/theme-toggle.tsx +47 -0
  108. package/src/mobile/views/App.tsx +152 -0
  109. package/src/mobile/views/ChatView.test.tsx +243 -0
  110. package/src/mobile/views/ChatView.tsx +650 -0
  111. package/src/mobile/views/SessionListView.test.tsx +139 -0
  112. package/src/mobile/views/SessionListView.tsx +186 -0
  113. package/src/mobile/views/WorkspaceView.tsx +108 -0
  114. package/src/mobile-api.ts +271 -0
  115. package/src/mobile-routes.ts +77 -0
  116. package/src/pairing.ts +390 -0
  117. package/src/routes.ts +346 -0
  118. package/src/tunnel.ts +264 -0
package/src/pairing.ts ADDED
@@ -0,0 +1,390 @@
1
+ /**
2
+ * Pairing state machine: one active one-time token, a device-session table,
3
+ * and presence tracking. Pure TypeScript with injected clock/randomness so
4
+ * the whole security semantics are unit-testable without cordis. The
5
+ * cordis-facing surfaces (routes, the api/gate listener) live next door.
6
+ *
7
+ * Security invariants:
8
+ * - One active token at a time; `issue()` replaces it, so a refreshed QR
9
+ * immediately invalidates the previous link.
10
+ * - A token is consumed by the first successful `accept()` — reuse is
11
+ * refused with `'used'`.
12
+ * - Tokens expire; `accept()` on an expired token is refused like an
13
+ * unknown one (no oracle for validity).
14
+ * - `stop()` revokes every device session and clears the token, so paired
15
+ * devices are cut off on their next gated request.
16
+ */
17
+
18
+ import { randomBytes } from 'node:crypto'
19
+
20
+ /** The observable pairing phases the panel renders. */
21
+ export type PairingPhase =
22
+ /** The server is not bound all-interfaces: no usable QR exists. */
23
+ | 'lan-required'
24
+ /** Remote control was stopped; a fresh QR (issue) re-enables it. */
25
+ | 'stopped'
26
+ /** A token is live and no device has paired with it yet. */
27
+ | 'waiting'
28
+ /** At least one paired device was active recently. */
29
+ | 'connected'
30
+ /** Devices are paired but none has been active within the offline window. */
31
+ | 'disconnected'
32
+
33
+ /** One issued pairing token (keyed by its secret). */
34
+ export interface TokenRecord {
35
+ /** Monotonic issue time (ms epoch), drives refresh ordering. */
36
+ issuedAt: number
37
+ /** Absolute expiry (ms epoch); accept() past this is refused. */
38
+ expiresAt: number
39
+ /** Consumed by the first successful accept(). */
40
+ consumed: boolean
41
+ /** Opaque, non-secret identifier surfaced in snapshots (never the pairing secret). */
42
+ id: string
43
+ /** Workspace the QR link should land the phone in (optional). */
44
+ workspaceId?: string
45
+ /** LAN IP literal the QR link was built from (optional; default first). */
46
+ address?: string
47
+ }
48
+
49
+ /** One paired device session, keyed by the device id stored in its cookie. */
50
+ export interface DeviceSession {
51
+ /** Pairing time (ms epoch). */
52
+ createdAt: number
53
+ /** Last time the device passed a gated request or heartbeat. */
54
+ lastSeenAt: number
55
+ }
56
+
57
+ /** One tunnel status frame (auto-tunnel only; undefined when disabled). */
58
+ export interface TunnelStatus {
59
+ /** starting: binary/process warming up; running: URL minted; failed: no URL. */
60
+ state: 'starting' | 'running' | 'failed'
61
+ /** The minted public URL, once the tunnel reports it. */
62
+ url?: string
63
+ /** Human-readable failure detail. */
64
+ error?: string
65
+ }
66
+
67
+ /** One snapshot frame pushed to desktop status streams. */
68
+ export interface PairingSnapshot {
69
+ phase: PairingPhase
70
+ /** Whether the server bind is all-interfaces (a QR is constructible). */
71
+ lanAvailable: boolean
72
+ /** The LAN IP literals a QR can be built from (interface order). */
73
+ lanAddresses: string[]
74
+ /** Configured public (tunneled) base URL, when present. */
75
+ publicUrl?: string
76
+ /** Auto-tunnel status, while the auto-tunnel feature is active. */
77
+ tunnel?: TunnelStatus
78
+ /** Opaque (non-secret) id of the active token (undefined when stopped/lan-required). */
79
+ tokenId?: string
80
+ /** Absolute expiry of the active token. */
81
+ tokenExpiresAt?: number
82
+ /** Count of ever-paired devices. */
83
+ deviceCount: number
84
+ /** Count of devices active within the offline window. */
85
+ onlineCount: number
86
+ }
87
+
88
+ /** Service tunables (config-validated upstream; plain numbers here). */
89
+ export interface PairingConfig {
90
+ /** Token lifetime; the QR stops working after this. */
91
+ tokenTtlMs: number
92
+ /** A device is "online" while its lastSeenAt is newer than this. */
93
+ offlineAfterMs: number
94
+ /** Hard cap on paired device sessions (oldest-evicted when full). */
95
+ maxDevices: number
96
+ /** Cookie name carrying the device id. */
97
+ cookieName: string
98
+ }
99
+
100
+ /** Result of one accept() attempt. */
101
+ export type AcceptResult =
102
+ | { ok: true; deviceId: string }
103
+ | { ok: false; code: 'invalid' | 'used' }
104
+
105
+ /** Thrown by issue() for an address outside the sampled LAN literals. */
106
+ export class UnknownLanAddressError extends Error {
107
+ /**
108
+ * @param address - the offending literal.
109
+ */
110
+ constructor(address: string) {
111
+ super(`remote-web-ui: unknown LAN address ${JSON.stringify(address)}`)
112
+ this.name = 'UnknownLanAddressError'
113
+ }
114
+ }
115
+
116
+ /** Clock and entropy injection for tests. */
117
+ export interface PairingClock {
118
+ now(): number
119
+ randomToken(): string
120
+ }
121
+
122
+ /** Real clock/entropy: 32 random hex chars per token. */
123
+ export const defaultClock: PairingClock = {
124
+ now: () => Date.now(),
125
+ randomToken: () => randomBytes(16).toString('hex'),
126
+ }
127
+
128
+ /**
129
+ * The pairing state machine. All mutations notify state listeners after the
130
+ * commit point that makes them true, and notification dedupes against the
131
+ * last emitted snapshot — time-driven transitions (a device aging offline)
132
+ * surface on the next sweep without any mutation.
133
+ */
134
+ export class PairingService {
135
+ private readonly tokens = new Map<string, TokenRecord>()
136
+ private readonly devices = new Map<string, DeviceSession>()
137
+ private readonly listeners = new Set<(snapshot: PairingSnapshot) => void>()
138
+ private lastEmitted: PairingSnapshot | undefined
139
+ private stopped = false
140
+ private tokenSerial = 0
141
+ /** LAN base URLs keyed by the advertised IP literal (interface order). */
142
+ private lanBases = new Map<string, string>()
143
+ /** Public (tunneled) base URL, e.g. a Cloudflare Tunnel quick URL. */
144
+ private publicBase: string | undefined
145
+ /** Auto-tunnel status, while the auto-tunnel feature is active. */
146
+ private tunnelStatus: TunnelStatus | undefined
147
+
148
+ /**
149
+ * @param config - tunables. The settings surface replaces the object (a
150
+ * fresh literal) when a committed section changes; every operation reads
151
+ * the current one.
152
+ * @param clock - clock/entropy source (injectable for tests).
153
+ */
154
+ constructor(
155
+ public config: PairingConfig,
156
+ private readonly clock: PairingClock = defaultClock,
157
+ ) {}
158
+
159
+ /** The default LAN base URL (the first interface; undefined when not LAN-reachable). */
160
+ get lanBaseUrl(): string | undefined {
161
+ return this.lanBases.values().next().value as string | undefined
162
+ }
163
+
164
+ /** The LAN base URL for one specific literal (undefined when not constructible). */
165
+ lanBaseUrlFor(address: string): string | undefined {
166
+ return this.lanBases.get(address)
167
+ }
168
+
169
+ /** The LAN IP literals QR links can be built from (interface order). */
170
+ get lanAddresses(): string[] {
171
+ return [...this.lanBases.keys()]
172
+ }
173
+
174
+ /** Set the LAN base URLs once the server bind is known (interface order). */
175
+ setLanBases(entries: readonly { address: string; base: string }[]): void {
176
+ this.lanBases = new Map(entries.map(entry => [entry.address, entry.base]))
177
+ this.notify()
178
+ }
179
+
180
+ /** The configured public (tunneled) base URL, when present. */
181
+ get publicBaseUrl(): string | undefined {
182
+ return this.publicBase
183
+ }
184
+
185
+ /** Set or clear the public base URL (a tunnel in front of this server). */
186
+ setPublicBaseUrl(url: string | undefined): void {
187
+ this.publicBase = url
188
+ this.notify()
189
+ }
190
+
191
+ /** Set or clear the auto-tunnel status frame (undefined when the feature is off). */
192
+ setTunnelStatus(status: TunnelStatus | undefined): void {
193
+ this.tunnelStatus = status
194
+ this.notify()
195
+ }
196
+
197
+ /**
198
+ * Issue a fresh token, replacing (invalidating) any previous one. A
199
+ * stopped service re-arms through this call (the panel's refresh button).
200
+ * @param workspaceId - optional workspace the QR link should land in.
201
+ * @param address - optional LAN IP literal the QR must be built from; the
202
+ * default is the public base (when configured) or the first interface.
203
+ * Unknown addresses are refused.
204
+ * @returns the token secret and its expiry.
205
+ * @throws {Error} when no reachable base exists (no all-interfaces bind and
206
+ * no public base) — callers surface this as the lan-required state instead
207
+ * of minting an unusable QR.
208
+ */
209
+ issue(workspaceId?: string, address?: string): { token: string; expiresAt: number } {
210
+ if (this.lanBases.size === 0 && this.publicBase === undefined) {
211
+ throw new Error('remote-web-ui: pairing requires a reachable bind (--host 0.0.0.0 or publicBaseUrl)')
212
+ }
213
+ if (address !== undefined && !this.lanBases.has(address)) {
214
+ throw new UnknownLanAddressError(address)
215
+ }
216
+ const now = this.clock.now()
217
+ const token = this.clock.randomToken()
218
+ this.tokens.clear()
219
+ this.stopped = false
220
+ this.tokenSerial += 1
221
+ this.tokens.set(token, {
222
+ id: `t${this.tokenSerial}`,
223
+ issuedAt: now,
224
+ expiresAt: now + this.config.tokenTtlMs,
225
+ consumed: false,
226
+ ...(workspaceId !== undefined ? { workspaceId } : {}),
227
+ ...(address !== undefined ? { address } : {}),
228
+ })
229
+ this.notify()
230
+ return { token, expiresAt: now + this.config.tokenTtlMs }
231
+ }
232
+
233
+ /**
234
+ * Consume a token and bind a device session. One-time: the second
235
+ * successful call for the same token is impossible because the first
236
+ * consumes it.
237
+ * @param token - the token secret from the QR link.
238
+ * @returns the new device id, or a refusal code.
239
+ */
240
+ accept(token: string): AcceptResult {
241
+ const record = this.tokens.get(token)
242
+ if (record === undefined || record.consumed || this.stopped || this.clock.now() > record.expiresAt) {
243
+ return { ok: false, code: record?.consumed === true ? 'used' : 'invalid' }
244
+ }
245
+ record.consumed = true
246
+ const deviceId = this.clock.randomToken()
247
+ const now = this.clock.now()
248
+ if (this.devices.size >= this.config.maxDevices) {
249
+ // Evict the oldest session (FIFO) before binding a new device.
250
+ let oldest: { id: string; createdAt: number } | undefined
251
+ for (const [id, session] of this.devices) {
252
+ if (oldest === undefined || session.createdAt < oldest.createdAt) oldest = { id, createdAt: session.createdAt }
253
+ }
254
+ if (oldest !== undefined) this.devices.delete(oldest.id)
255
+ }
256
+ this.devices.set(deviceId, { createdAt: now, lastSeenAt: now })
257
+ this.notify()
258
+ return { ok: true, deviceId }
259
+ }
260
+
261
+ /**
262
+ * Stop remote control: revoke every device session and clear the token.
263
+ * The phone's next gated /api request 403s; the panel falls back to
264
+ * stopped until a fresh QR is issued.
265
+ */
266
+ stop(): void {
267
+ this.tokens.clear()
268
+ this.devices.clear()
269
+ this.stopped = true
270
+ this.notify()
271
+ }
272
+
273
+ /**
274
+ * The api/gate path: record activity for a device id and report whether
275
+ * the request may proceed. Unknown or revoked ids (including any device
276
+ * after stop()) are refused.
277
+ * @param deviceId - the cookie value of the requesting device.
278
+ * @returns true when the device session is live and was refreshed.
279
+ */
280
+ touchDevice(deviceId: string): boolean {
281
+ const session = this.devices.get(deviceId)
282
+ if (session === undefined || this.stopped) return false
283
+ session.lastSeenAt = this.clock.now()
284
+ this.notify()
285
+ return true
286
+ }
287
+
288
+ /** Explicit presence heartbeat (the phone's client sends these). */
289
+ heartbeat(deviceId: string): boolean {
290
+ return this.touchDevice(deviceId)
291
+ }
292
+
293
+ /**
294
+ * Periodic sweep: re-evaluate the derived snapshot (a device aging past
295
+ * the offline window flips the phase to disconnected). Emits only when
296
+ * the snapshot actually changed.
297
+ */
298
+ sweep(): void {
299
+ this.notify()
300
+ }
301
+
302
+ /** The current snapshot (fresh object per call — stable between emits). */
303
+ snapshot(): PairingSnapshot {
304
+ const now = this.clock.now()
305
+ const onlineCount = [...this.devices.values()].filter(session => this.isOnlineAt(session, now)).length
306
+ const token = this.activeToken()
307
+ return {
308
+ phase: this.derivePhase(onlineCount, token !== undefined),
309
+ lanAvailable: this.lanBases.size > 0,
310
+ lanAddresses: [...this.lanBases.keys()],
311
+ ...(this.publicBase !== undefined ? { publicUrl: this.publicBase } : {}),
312
+ ...(this.tunnelStatus !== undefined ? { tunnel: this.tunnelStatus } : {}),
313
+ ...(token !== undefined ? { tokenId: token.record.id, tokenExpiresAt: token.record.expiresAt } : {}),
314
+ deviceCount: this.devices.size,
315
+ onlineCount,
316
+ }
317
+ }
318
+
319
+ /** Whether a cookie value names a currently live device session. */
320
+ hasDevice(deviceId: string): boolean {
321
+ const session = this.devices.get(deviceId)
322
+ return session !== undefined && !this.stopped
323
+ }
324
+
325
+ /** Subscribe to snapshot changes (each emit passes a fresh snapshot). */
326
+ onState(listener: (snapshot: PairingSnapshot) => void): () => void {
327
+ this.listeners.add(listener)
328
+ return () => { this.listeners.delete(listener) }
329
+ }
330
+
331
+ private activeToken(): { token: string; record: TokenRecord } | undefined {
332
+ for (const [token, record] of this.tokens) {
333
+ if (this.stopped) return undefined
334
+ if (this.clock.now() > record.expiresAt) continue
335
+ return { token, record }
336
+ }
337
+ return undefined
338
+ }
339
+
340
+ private derivePhase(onlineCount: number, hasToken: boolean): PairingPhase {
341
+ if (this.lanBases.size === 0 && this.publicBase === undefined) return 'lan-required'
342
+ if (this.stopped) return 'stopped'
343
+ if (onlineCount > 0) return 'connected'
344
+ if (this.devices.size > 0) return 'disconnected'
345
+ if (hasToken) return 'waiting'
346
+ return 'stopped'
347
+ }
348
+
349
+ private isOnlineAt(session: DeviceSession, now: number): boolean {
350
+ return now - session.lastSeenAt <= this.config.offlineAfterMs
351
+ }
352
+
353
+ private notify(): void {
354
+ const snapshot = this.snapshot()
355
+ if (this.lastEmitted !== undefined && snapshotsEqual(this.lastEmitted, snapshot)) return
356
+ this.lastEmitted = snapshot
357
+ for (const listener of this.listeners) {
358
+ try {
359
+ listener(snapshot)
360
+ } catch (error) {
361
+ // A throwing subscriber must not break the emit loop or the caller.
362
+ console.error('remote-web-ui: pairing state listener failed', error)
363
+ }
364
+ }
365
+ }
366
+ }
367
+
368
+ /** Structural equality over the snapshot's wire fields. */
369
+ function snapshotsEqual(a: PairingSnapshot, b: PairingSnapshot): boolean {
370
+ return a.phase === b.phase
371
+ && a.lanAvailable === b.lanAvailable
372
+ && sameStrings(a.lanAddresses, b.lanAddresses)
373
+ && a.publicUrl === b.publicUrl
374
+ && tunnelEqual(a.tunnel, b.tunnel)
375
+ && a.tokenId === b.tokenId
376
+ && a.tokenExpiresAt === b.tokenExpiresAt
377
+ && a.deviceCount === b.deviceCount
378
+ && a.onlineCount === b.onlineCount
379
+ }
380
+
381
+ /** Tunnel frame equality (undefined equals undefined; fields compared shallowly). */
382
+ function tunnelEqual(a: TunnelStatus | undefined, b: TunnelStatus | undefined): boolean {
383
+ return a === b || (a !== undefined && b !== undefined
384
+ && a.state === b.state && a.url === b.url && a.error === b.error)
385
+ }
386
+
387
+ /** Element-wise string list equality (interface order is meaningful). */
388
+ function sameStrings(a: readonly string[], b: readonly string[]): boolean {
389
+ return a.length === b.length && a.every((value, index) => value === b[index])
390
+ }