turtb 0.5.0

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 (70) hide show
  1. package/LICENSE +661 -0
  2. package/bin/turtb.js +259 -0
  3. package/lib/display/h.js +204 -0
  4. package/lib/display/helpers.js +78 -0
  5. package/lib/display/render.js +177 -0
  6. package/lib/display/shapes.js +91 -0
  7. package/lib/turtle/Signer.js +120 -0
  8. package/lib/turtle/Signer.test.js +38 -0
  9. package/lib/turtle/TurtleBranch.js +147 -0
  10. package/lib/turtle/TurtleBranch.test.js +89 -0
  11. package/lib/turtle/TurtleDictionary.js +157 -0
  12. package/lib/turtle/TurtleDictionary.test.js +331 -0
  13. package/lib/turtle/U8aTurtle.js +203 -0
  14. package/lib/turtle/U8aTurtle.test.js +60 -0
  15. package/lib/turtle/Workspace.js +62 -0
  16. package/lib/turtle/Workspace.test.js +63 -0
  17. package/lib/turtle/codecs/CodecType.js +36 -0
  18. package/lib/turtle/codecs/CodecTypeVersion.js +37 -0
  19. package/lib/turtle/codecs/Commit.js +10 -0
  20. package/lib/turtle/codecs/CompositeCodec.js +86 -0
  21. package/lib/turtle/codecs/TreeNode.js +38 -0
  22. package/lib/turtle/codecs/codec.js +441 -0
  23. package/lib/turtle/connections/AbstractUpdater.js +176 -0
  24. package/lib/turtle/connections/TurtleBranchMultiplexer.js +102 -0
  25. package/lib/turtle/connections/TurtleBranchMultiplexer.test.js +26 -0
  26. package/lib/turtle/connections/TurtleBranchUpdater.js +47 -0
  27. package/lib/turtle/connections/TurtleDB.js +165 -0
  28. package/lib/turtle/connections/TurtleDB.test.js +45 -0
  29. package/lib/turtle/connections/TurtleTalker.js +34 -0
  30. package/lib/turtle/connections/TurtleTalker.test.js +101 -0
  31. package/lib/turtle/utils.js +192 -0
  32. package/lib/turtle/utils.test.js +158 -0
  33. package/lib/utils/Assert.js +115 -0
  34. package/lib/utils/NestedSet.js +68 -0
  35. package/lib/utils/NestedSet.test.js +30 -0
  36. package/lib/utils/OWN_KEYS.js +1 -0
  37. package/lib/utils/Recaller.js +175 -0
  38. package/lib/utils/Recaller.test.js +75 -0
  39. package/lib/utils/TestRunner.js +200 -0
  40. package/lib/utils/TestRunner.test.js +144 -0
  41. package/lib/utils/TestRunnerConstants.js +13 -0
  42. package/lib/utils/combineUint8ArrayLikes.js +18 -0
  43. package/lib/utils/combineUint8Arrays.js +23 -0
  44. package/lib/utils/components.js +88 -0
  45. package/lib/utils/crypto.js +17 -0
  46. package/lib/utils/deepEqual.js +16 -0
  47. package/lib/utils/deepEqual.test.js +27 -0
  48. package/lib/utils/fileTransformer.js +16 -0
  49. package/lib/utils/handleRedirect.js +93 -0
  50. package/lib/utils/logger.js +24 -0
  51. package/lib/utils/nextTick.js +47 -0
  52. package/lib/utils/noble-secp256k1.js +602 -0
  53. package/lib/utils/proxyWithRecaller.js +51 -0
  54. package/lib/utils/toCombinedVersion.js +14 -0
  55. package/lib/utils/toSubVersions.js +14 -0
  56. package/lib/utils/toVersionCount.js +5 -0
  57. package/lib/utils/webSocketMuxFactory.js +123 -0
  58. package/lib/utils/zabacaba.js +25 -0
  59. package/package.json +24 -0
  60. package/src/ArchiveUpdater.js +99 -0
  61. package/src/S3Updater.js +99 -0
  62. package/src/archiveSync.js +28 -0
  63. package/src/fileSync.js +155 -0
  64. package/src/getExistenceLength.js +19 -0
  65. package/src/manageCert.js +36 -0
  66. package/src/originSync.js +75 -0
  67. package/src/outletSync.js +50 -0
  68. package/src/proxyFolder.js +195 -0
  69. package/src/s3Sync.js +32 -0
  70. package/src/webSync.js +101 -0
@@ -0,0 +1,75 @@
1
+ import { createConnection } from 'net'
2
+ import { TurtleBranchMultiplexer } from '../lib/turtle/connections/TurtleBranchMultiplexer.js'
3
+ import { logError, logInfo, logWarn, logDebug } from '../lib/utils/logger.js'
4
+
5
+ export async function originSync (turtleDB, host, port) {
6
+ let t = 100
7
+ let connectionCount = 0
8
+ while (true) {
9
+ logInfo(() => console.log('-- creating new origin connection'))
10
+ logInfo(() => console.time('-- origin connection lifespan'))
11
+ const tbMux = new TurtleBranchMultiplexer(`origin_#${host}`, false, turtleDB)
12
+ for (const publicKey of turtleDB.getPublicKeys()) {
13
+ await tbMux.getTurtleBranchUpdater(tbMux.name, publicKey)
14
+ }
15
+ const tbMuxBinding = async (/** @type {TurtleBranchStatus} */ status) => {
16
+ try {
17
+ // logDebug(() => console.log('tbMuxBinding about to get next', status.publicKey))
18
+ const updater = await tbMux.getTurtleBranchUpdater(tbMux.name, status.publicKey, status.turtleBranch)
19
+ logDebug(() => console.log('updater about to await settle', updater.name, updater.turtleBranch.length))
20
+ await updater.settle
21
+ logDebug(() => console.log('updater settled', updater.turtleBranch.length))
22
+ } catch (error) {
23
+ logError(() => console.error(error))
24
+ }
25
+ }
26
+ turtleDB.bind(tbMuxBinding)
27
+ let _connectionCount
28
+ try {
29
+ const socket = createConnection(port, host)
30
+ socket.on('connect', () => {
31
+ ;(async () => {
32
+ try {
33
+ for await (const chunk of tbMux.makeReadableStream()) {
34
+ if (socket.closed) break
35
+ if (socket.write(chunk)) {
36
+ // logDebug(() => console.log('host outgoing data', chunk))
37
+ } else {
38
+ logWarn(() => console.warn('socket failed to write', chunk.length, 'bytes, ending connection', chunk))
39
+ // break
40
+ }
41
+ }
42
+ } catch (error) {
43
+ logError(() => console.error(error))
44
+ }
45
+ })()
46
+ t = 100
47
+ _connectionCount = ++connectionCount
48
+ logDebug(() => console.log('-- onopen', { _connectionCount }))
49
+ })
50
+ const streamWriter = tbMux.makeWritableStream().getWriter()
51
+ socket.on('data', buffer => {
52
+ // logDebug(() => console.log('host incoming data', buffer))
53
+ streamWriter.write(buffer)
54
+ })
55
+ await new Promise((resolve, reject) => {
56
+ socket.on('close', resolve)
57
+ socket.on('error', reject)
58
+ })
59
+ } catch (error) {
60
+ if (error?.code === 'ECONNREFUSED') {
61
+ logWarn(() => console.log('-- connection refused'))
62
+ } else {
63
+ logError(() => console.error(error))
64
+ throw error
65
+ }
66
+ }
67
+ tbMux.stop()
68
+ turtleDB.unbind(tbMuxBinding)
69
+ logInfo(() => console.timeEnd('-- origin connection lifespan'))
70
+ t = Math.min(t, 2 * 60 * 1000) // 2 minutes max (unjittered)
71
+ t = t * (1 + Math.random()) // exponential backoff and some jitter
72
+ logInfo(() => console.log(`-- waiting ${(t / 1000).toFixed(2)}s`))
73
+ await new Promise(resolve => setTimeout(resolve, t))
74
+ }
75
+ }
@@ -0,0 +1,50 @@
1
+ import { createServer } from 'net'
2
+ import { TurtleBranchMultiplexer } from '../lib/turtle/connections/TurtleBranchMultiplexer.js'
3
+ import { logError, logWarn, logInfo } from '../lib/utils/logger.js'
4
+
5
+ export async function outletSync (turtleDB, port) {
6
+ let connectionCount = 0
7
+ const server = createServer(async socket => {
8
+ let tbMux
9
+ const _connectionCount = ++connectionCount
10
+ try {
11
+ logInfo(() => console.log('turtle connection', _connectionCount))
12
+ tbMux = new TurtleBranchMultiplexer(`outlet_#${_connectionCount}`, true, turtleDB)
13
+ ;(async () => {
14
+ try {
15
+ for await (const chunk of tbMux.makeReadableStream()) {
16
+ if (socket.closed) break
17
+ if (socket.write(chunk)) {
18
+ // logDebug(() => console.log('origin.host outgoing data', chunk))
19
+ } else {
20
+ await new Promise(resolve => socket.once('drain', resolve))
21
+ }
22
+ }
23
+ } catch (error) {
24
+ logError(() => console.error(error))
25
+ }
26
+ })()
27
+ const streamWriter = tbMux.makeWritableStream().getWriter()
28
+ socket.on('data', buffer => {
29
+ // logDebug(() => console.log('turtleHost incoming data', buffer))
30
+ streamWriter.write(buffer)
31
+ })
32
+
33
+ await new Promise((resolve, reject) => {
34
+ socket.on('close', resolve)
35
+ socket.on('error', reject)
36
+ })
37
+ } catch (error) {
38
+ if (error.code === 'ECONNRESET') {
39
+ logWarn(() => console.warn('ECONNRESET', _connectionCount))
40
+ } else {
41
+ logError(() => console.error(error))
42
+ throw error
43
+ }
44
+ }
45
+ tbMux?.stop?.()
46
+ })
47
+ server.listen(port, () => {
48
+ logInfo(() => console.log('opened outlet.port:', port))
49
+ })
50
+ }
@@ -0,0 +1,195 @@
1
+ import { mkdirSync, readdirSync, readFileSync, symlinkSync, unlinkSync, writeFileSync, readlinkSync, lstatSync, rmdirSync } from 'fs'
2
+ import { dirname, join, relative } from 'path'
3
+ import { logError } from '../lib/utils/logger.js'
4
+ import { deepEqual } from '../lib/utils/deepEqual.js'
5
+ import { Recaller } from '../lib/utils/Recaller.js'
6
+ import { subscribe } from '@parcel/watcher'
7
+
8
+ export const isLinesOfTextExtension = path => path.match(/\.(html|css|js|svg|txt|gitignore|env|node_repl_history)$/)
9
+ export const isJSONExtension = path => path.match(/\.(json)$/)
10
+
11
+ export const encodeTextFile = object => {
12
+ if (!object || typeof object !== 'object') throw new Error('encodeFile requires an object')
13
+ if (Array.isArray(object) && object.every(value => typeof value === 'string')) return object.join('\n')
14
+ return JSON.stringify(object, undefined, 2)
15
+ }
16
+
17
+ const decodeBufferAsFileObject = (buffer, path) => {
18
+ if (!buffer?.isBuffer?.() && !(buffer instanceof Uint8Array)) return buffer
19
+ const uint8Array = new Uint8Array(buffer)
20
+ const isLinesOfText = isLinesOfTextExtension(path)
21
+ const isJSON = isJSONExtension(path)
22
+ if (!isLinesOfText && !isJSON) return uint8Array
23
+ const decoder = new TextDecoder('utf-8')
24
+ const str = decoder.decode(uint8Array)
25
+ if (isLinesOfText) return str.split(/\r?\n/)
26
+ try {
27
+ return JSON.parse(str)
28
+ } catch (err) {
29
+ logError(() => console.error(`error parsing (file:${path})`))
30
+ return str
31
+ }
32
+ }
33
+
34
+ export const equalFileObjects = (a, b, path) => {
35
+ const decodedA = decodeBufferAsFileObject(a, path)
36
+ const decodedB = decodeBufferAsFileObject(b, path)
37
+ return deepEqual(decodedA, decodedB)
38
+ }
39
+
40
+ export const UPDATES_HANDLER = Symbol('UPDATES_HANDLER')
41
+
42
+ /**
43
+ * @param {string} folder
44
+ * @param {Recaller} recaller
45
+ * @param {function(string, any, any):void)}
46
+ * @returns {Proxy}
47
+ */
48
+ export const proxyFolder = (folder, recaller = new Recaller(folder), updatesHandler) => {
49
+ const cleanEmptyDir = path => {
50
+ if (['', '.', '/'].includes(path)) return
51
+ const childPath = join(folder, path)
52
+ if (readdirSync(childPath).length) return
53
+ rmdirSync(childPath)
54
+ const parentDirname = dirname(path)
55
+ cleanEmptyDir(parentDirname)
56
+ }
57
+
58
+ const target = {}
59
+
60
+ const writeFileObject = (path, newFileObject) => {
61
+ const oldFileObject = target[path]
62
+ if (equalFileObjects(oldFileObject, newFileObject, path)) return true
63
+ const childPath = join(folder, path)
64
+ if (newFileObject) {
65
+ const folderPath = dirname(childPath)
66
+ if (folderPath) mkdirSync(folderPath, { recursive: true })
67
+ }
68
+ if (oldFileObject) unlinkSync(childPath)
69
+ if (!newFileObject) {
70
+ // no such thing, remove it
71
+ delete target[path]
72
+ cleanEmptyDir(dirname(path))
73
+ } else {
74
+ target[path] = newFileObject
75
+ if (newFileObject.symlink) {
76
+ symlinkSync(newFileObject.symlink, childPath)
77
+ } else if (newFileObject instanceof Uint8Array) {
78
+ writeFileSync(childPath, Buffer.from(newFileObject))
79
+ } else if (typeof newFileObject === 'object') {
80
+ writeFileSync(childPath, encodeTextFile(newFileObject), { encoding: 'utf8' })
81
+ } else {
82
+ throw new Error('encodeFile requires an object')
83
+ }
84
+ }
85
+ return true
86
+ }
87
+
88
+ const readFileObject = path => {
89
+ const childPath = join(folder, path)
90
+ let stats
91
+ try {
92
+ stats = lstatSync(childPath)
93
+ } catch (err) {
94
+ if (err.code !== 'ENOENT') throw err
95
+ }
96
+ let changed = ''
97
+ if (stats && (stats.isFile() || stats.isSymbolicLink())) {
98
+ let value
99
+ if (stats.isSymbolicLink()) {
100
+ const symlink = readlinkSync(childPath)
101
+ value = { symlink }
102
+ } else {
103
+ value = decodeBufferAsFileObject(readFileSync(childPath), childPath)
104
+ }
105
+ if (!equalFileObjects(value, target[path], path)) {
106
+ target[path] = value
107
+ changed = 'set'
108
+ }
109
+ } else {
110
+ if (target[path]) {
111
+ delete target[path]
112
+ changed = 'delete'
113
+ }
114
+ }
115
+ if (changed) {
116
+ recaller.reportKeyMutation(target, path, changed, `proxyFolder(${folder})`)
117
+ for (let parentDirname = dirname(path); !['', '.', '/'].includes(parentDirname); parentDirname = dirname(parentDirname)) {
118
+ recaller.reportKeyMutation(target, parentDirname, changed, `proxyFolder(${folder})`)
119
+ }
120
+ }
121
+ return target[path]
122
+ }
123
+
124
+ const readFileObjects = folder => {
125
+ // don't use recursive option because it follows symlinks
126
+ readdirSync(folder, { withFileTypes: true }).forEach(dirent => {
127
+ if (dirent.name.startsWith('.git/') || dirent.name.startsWith('node_modules/')) return
128
+ if (!dirent.isDirectory()) {
129
+ readFileObject(join(dirent.parentPath, dirent.name))
130
+ } else {
131
+ readFileObjects(join(folder, dirent.name))
132
+ }
133
+ })
134
+ }
135
+
136
+ readFileObjects(folder)
137
+
138
+ const proxy = new Proxy(target, {
139
+ get: (target, name) => {
140
+ recaller.reportKeyAccess(target, name, 'get', `proxyFolder(${folder})`)
141
+ if (name === UPDATES_HANDLER) {
142
+ return updatesHandler
143
+ } else if (target[name]) {
144
+ return target[name]
145
+ } else {
146
+ const matchingEntries = Object.entries(target).filter(([key]) => (key.startsWith(name + '/') || key === name))
147
+ if (!matchingEntries.length) return
148
+ return Object.fromEntries(matchingEntries)
149
+ }
150
+ },
151
+ set: (target, name, value) => {
152
+ if (name === UPDATES_HANDLER) {
153
+ updatesHandler = value
154
+ return true
155
+ }
156
+ return writeFileObject(name, value)
157
+ },
158
+ deleteProperty: (target, name) => {
159
+ recaller.reportKeyMutation(target, name, 'delete', `proxyFolder(${folder})`)
160
+ for (let parentDirname = dirname(name); !['', '.', '/'].includes(parentDirname); parentDirname = dirname(parentDirname)) {
161
+ recaller.reportKeyMutation(target, parentDirname, 'delete', `proxyFolder(${folder})`)
162
+ }
163
+ return writeFileObject(name)
164
+ }
165
+ })
166
+
167
+ let timeout
168
+ const modifiedFiles = new Set()
169
+ const handleFileChange = filename => {
170
+ clearTimeout(timeout)
171
+ modifiedFiles.add(filename)
172
+ timeout = setTimeout(() => {
173
+ const changes = {}
174
+ modifiedFiles.forEach(filename => {
175
+ const oldValue = target[filename]
176
+ const newValue = readFileObject(filename)
177
+ if (!equalFileObjects(oldValue, newValue, filename)) {
178
+ changes[filename] = { oldValue, newValue }
179
+ }
180
+ })
181
+ updatesHandler?.(changes)
182
+ modifiedFiles.clear()
183
+ }, 500)
184
+ }
185
+
186
+ subscribe(folder, (err, events) => {
187
+ if (err) throw err
188
+ events.forEach(({ path }) => {
189
+ const filename = relative(folder, path)
190
+ handleFileChange(filename)
191
+ })
192
+ })
193
+
194
+ return proxy
195
+ }
package/src/s3Sync.js ADDED
@@ -0,0 +1,32 @@
1
+ import { S3Client } from '@aws-sdk/client-s3'
2
+ import { S3Updater } from './S3Updater.js'
3
+ import { TurtleBranchUpdater } from '../lib/turtle/connections/TurtleBranchUpdater.js'
4
+ import { logDebug } from '../lib/utils/logger.js'
5
+
6
+ export async function s3Sync (turtleDB, recaller, endpoint, region, accessKeyId, secretAccessKey, bucket) {
7
+ /** @type {import('@aws-sdk/client-s3').S3ClientConfig} */
8
+ const s3Client = new S3Client({
9
+ requestHandler: { httpsAgent: { maxSockets: 500 } },
10
+ endpoint,
11
+ forcePathStyle: false,
12
+ region,
13
+ credentials: {
14
+ accessKeyId,
15
+ secretAccessKey
16
+ }
17
+ })
18
+ const tbMuxBinding = async (/** @type {TurtleBranchStatus} */ status) => {
19
+ const turtleBranch = status.turtleBranch
20
+ const name = turtleBranch.name
21
+ const publicKey = status.publicKey
22
+ const s3Updater = new S3Updater(`to_S3_#${name}`, publicKey, recaller, s3Client, bucket)
23
+ const tbUpdater = new TurtleBranchUpdater(`from_S3_#${name}`, turtleBranch, publicKey, false, recaller)
24
+ s3Updater.connect(tbUpdater)
25
+ s3Updater.start()
26
+ tbUpdater.start()
27
+ logDebug(() => console.log('tbUpdater about to await settle', tbUpdater.name))
28
+ if (!status.bindings.has(tbMuxBinding)) await tbUpdater.settle
29
+ logDebug(() => console.log('tbUpdater settled'))
30
+ }
31
+ turtleDB.bind(tbMuxBinding)
32
+ }
package/src/webSync.js ADDED
@@ -0,0 +1,101 @@
1
+ import express from 'express'
2
+ import { join } from 'path'
3
+ import { manageCert } from './manageCert.js'
4
+ import { createServer as createHttpsServer } from 'https'
5
+ import { createServer as createHttpServer } from 'http'
6
+ import { WebSocketServer } from 'ws'
7
+ import { TurtleBranchMultiplexer } from '../lib/turtle/connections/TurtleBranchMultiplexer.js'
8
+ import { randomUUID } from 'crypto'
9
+ import { logDebug, logInfo, logError } from '../lib/utils/logger.js'
10
+ import { handleRedirect } from '../lib/utils/handleRedirect.js'
11
+
12
+ /**
13
+ * @typedef {import('../lib/turtle/connections/TurtleDB.js').TurtleDB} TurtleDB
14
+ */
15
+
16
+ const uuid = randomUUID()
17
+
18
+ /**
19
+ * @param {number} port
20
+ * @param {string} basePublicKey
21
+ * @param {TurtleDB} turtleDB
22
+ * @param {boolean} https
23
+ * @param {boolean} insecure
24
+ * @param {string} certpath
25
+ * @param {string} [turtleDBFolder='.turtleDB']
26
+ */
27
+ export async function webSync (port, basePublicKey, turtleDB, https, insecure, certpath, turtleDBFolder = '.turtleDB') {
28
+ const root = join(process.cwd(), basePublicKey)
29
+ const app = express()
30
+ app.use((req, _res, next) => {
31
+ logDebug(() => console.log(req.method, req.url, req.originalUrl))
32
+ next()
33
+ })
34
+ app.use(async (req, res, next) => {
35
+ if (req.url === '/.well-known/appspecific/com.chrome.devtools.json') {
36
+ res.type('application/json')
37
+ res.send(JSON.stringify({ workspace: { uuid, root } }))
38
+ return
39
+ }
40
+ handleRedirect(req.url, +req.params.address, turtleDB, basePublicKey, turtleDBFolder, href => {
41
+ res.redirect(302, href)
42
+ }, (type, body) => {
43
+ if (!body) return next()
44
+ res.type(type)
45
+ res.send(body)
46
+ })
47
+ })
48
+ // app.use(express.static(root))
49
+
50
+ let server
51
+ if (https) {
52
+ if (insecure) process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'
53
+ const fullcertpath = join(process.cwd(), certpath)
54
+ const certOptions = await manageCert(fullcertpath)
55
+ server = createHttpsServer(certOptions, app)
56
+ } else {
57
+ server = createHttpServer(app)
58
+ }
59
+
60
+ const wss = new WebSocketServer({ server })
61
+ let connectionCount = 0
62
+
63
+ wss.on('connection', async ws => {
64
+ ++connectionCount
65
+ const _connectionCount = connectionCount
66
+ logDebug(() => console.log('new connection', _connectionCount))
67
+ // keep alive
68
+ const intervalId = setInterval(() => {
69
+ if (_connectionCount !== connectionCount) clearInterval(intervalId)
70
+ else ws.send(new Uint8Array())
71
+ }, 20000)
72
+ const tbMux = new TurtleBranchMultiplexer(`ws_connection_#${connectionCount}`, true, turtleDB)
73
+ ;(async () => {
74
+ for await (const u8aTurtle of tbMux.outgoingBranch.u8aTurtleGenerator()) {
75
+ if (ws.readyState !== ws.OPEN) break
76
+ ws.send(u8aTurtle.uint8Array)
77
+ }
78
+ })()
79
+ ws.on('message', buffer => tbMux.incomingBranch.append(new Uint8Array(buffer)))
80
+ ws.on('close', (code, reason) => logDebug(() => console.log('connection closed', _connectionCount)))
81
+ ws.on('error', error => logError(() => console.error('connection error', { name: error.name, message: error.message })))
82
+ await new Promise((resolve, reject) => {
83
+ ws.onclose = resolve
84
+ ws.onerror = reject
85
+ })
86
+ clearInterval(intervalId)
87
+ tbMux.stop()
88
+ })
89
+
90
+ server.listen(port, () => {
91
+ logInfo(() => console.log(`local webserver started: ${https ? 'https' : 'http'}://localhost:${port}
92
+
93
+ !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
94
+ !!! FUN-FACT: Self-signed certificates break service-workers !!!
95
+ !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
96
+ (HINT): On MacOS and in a browser started with this command ──╮
97
+ a service-worker can use a self-signed cert. │
98
+ ╭─────────────────────────────────────────────────────────────────╯
99
+ ╰─▶ open '/Applications/Google Chrome Canary.app' --args --ignore-certificate-errors https://localhost:${port}/`))
100
+ })
101
+ }