sounding 0.0.4 → 0.1.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.
@@ -3,39 +3,197 @@ const path = require('node:path')
3
3
  const { createMailbox } = require('./create-mailbox')
4
4
  const { createMailCapture } = require('./create-mail-capture')
5
5
  const { createWorldEngine } = require('./create-world-engine')
6
- const { loadWorldFiles } = require('./create-world-loader')
6
+ const { createWorldLoaderCache, loadWorldFiles } = require('./create-world-loader')
7
7
  const { createHelperRunner } = require('./create-helper-runner')
8
8
  const { createRequestClient } = require('./create-request-client')
9
9
  const { createVisitClient } = require('./create-visit-client')
10
10
  const { createBrowserManager } = require('./create-browser-manager')
11
11
  const { createAuthHelpers } = require('./create-auth-helpers')
12
+ const { createSocketManager } = require('./create-socket-manager')
12
13
  const { getDefaultConfig } = require('./default-config')
13
14
  const { mergeConfig } = require('./merge-config')
14
15
  const { normalizeUserConfig } = require('./normalize-config')
15
16
  const { resolveDatastore } = require('./resolve-datastore')
17
+ const { createSoundingError } = require('./create-error')
18
+ const { validateConfig } = require('./validate-config')
16
19
 
20
+ /** @typedef {import('./types').SoundingBootResult} SoundingBootResult */
21
+ /** @typedef {import('./types').SoundingConfig} SoundingConfig */
22
+ /** @typedef {import('./types').SoundingRuntime} SoundingRuntime */
23
+ /** @typedef {import('./types').SoundingSailsApp} SoundingSailsApp */
24
+
25
+ /**
26
+ * @param {SoundingSailsApp} sails
27
+ * @returns {SoundingConfig}
28
+ */
17
29
  function resolveConfig(sails) {
18
- return mergeConfig(getDefaultConfig(), normalizeUserConfig(sails.config?.sounding || {}))
30
+ return resolveConfigValue(sails.config?.sounding || {})
31
+ }
32
+
33
+ /**
34
+ * @param {any} value
35
+ * @returns {string}
36
+ */
37
+ function getConfigSignature(value) {
38
+ try {
39
+ return JSON.stringify(value || {})
40
+ } catch (_error) {
41
+ return String(Date.now())
42
+ }
43
+ }
44
+
45
+ /**
46
+ * @param {any} userConfig
47
+ * @returns {SoundingConfig}
48
+ */
49
+ function resolveConfigValue(userConfig) {
50
+ return validateConfig(
51
+ /** @type {SoundingConfig} */ (
52
+ mergeConfig(getDefaultConfig(), normalizeUserConfig(userConfig || {}))
53
+ )
54
+ )
55
+ }
56
+
57
+ /**
58
+ * @param {SoundingSailsApp} sails
59
+ * @returns {(() => SoundingConfig) & { stats: { resolutions: number }, invalidate(): void }}
60
+ */
61
+ function createConfigResolver(sails) {
62
+ /** @type {any} */
63
+ let cachedInput = null
64
+ /** @type {string | null} */
65
+ let cachedSignature = null
66
+ /** @type {SoundingConfig | null} */
67
+ let cachedConfig = null
68
+ const stats = {
69
+ resolutions: 0,
70
+ }
71
+
72
+ const resolver = /** @type {(() => SoundingConfig) & { stats: { resolutions: number }, invalidate(): void }} */ (
73
+ function resolveCachedConfig() {
74
+ const input = sails.config?.sounding || {}
75
+ const signature = getConfigSignature(input)
76
+
77
+ if (cachedConfig && cachedInput === input && cachedSignature === signature) {
78
+ return cachedConfig
79
+ }
80
+
81
+ cachedInput = input
82
+ cachedSignature = signature
83
+ cachedConfig = resolveConfigValue(input)
84
+ stats.resolutions += 1
85
+ return cachedConfig
86
+ }
87
+ )
88
+
89
+ resolver.stats = stats
90
+ resolver.invalidate = function invalidateConfigCache() {
91
+ cachedInput = null
92
+ cachedSignature = null
93
+ cachedConfig = null
94
+ stats.resolutions = 0
95
+ }
96
+
97
+ return resolver
19
98
  }
20
99
 
100
+ /**
101
+ * @param {SoundingSailsApp} sails
102
+ * @param {SoundingConfig} config
103
+ * @returns {string}
104
+ */
21
105
  function resolveAppPath(sails, config) {
22
106
  const basePath = sails?.config?.appPath || process.cwd()
23
107
  return path.resolve(basePath, config.app?.path || '.')
24
108
  }
25
109
 
110
+ /**
111
+ * @param {unknown} error
112
+ * @returns {string}
113
+ */
114
+ function formatCleanupError(error) {
115
+ if (error instanceof Error) {
116
+ return error.message
117
+ }
118
+
119
+ return String(error)
120
+ }
121
+
122
+ /**
123
+ * @param {{ resource: string, cleanup: () => void | Promise<void> }[]} steps
124
+ * @returns {Promise<void>}
125
+ */
126
+ async function runCleanupSteps(steps) {
127
+ const failures = []
128
+
129
+ for (const step of steps) {
130
+ try {
131
+ await step.cleanup()
132
+ } catch (error) {
133
+ failures.push({
134
+ resource: step.resource,
135
+ error,
136
+ })
137
+ }
138
+ }
139
+
140
+ if (failures.length === 0) {
141
+ return
142
+ }
143
+
144
+ const resources = failures.map((failure) => failure.resource).join(', ')
145
+ const errors = failures.map(
146
+ (failure) =>
147
+ createSoundingError({
148
+ code: 'E_SOUNDING_CLEANUP_RESOURCE_FAILED',
149
+ message: `${failure.resource}: ${formatCleanupError(failure.error)}`,
150
+ details: {
151
+ resource: failure.resource,
152
+ },
153
+ cause: failure.error,
154
+ })
155
+ )
156
+
157
+ const error = /** @type {AggregateError & { code: string, resources: string[], details: { resources: string[] } }} */ (
158
+ new AggregateError(errors, `Sounding cleanup failed for ${resources}.`)
159
+ )
160
+ error.name = 'SoundingCleanupError'
161
+ error.code = 'E_SOUNDING_CLEANUP_FAILED'
162
+ error.resources = failures.map((failure) => failure.resource)
163
+ error.details = {
164
+ resources: error.resources,
165
+ }
166
+
167
+ throw error
168
+ }
169
+
170
+ /**
171
+ * Create a Sounding runtime bound to a loaded Sails app.
172
+ *
173
+ * @param {SoundingSailsApp} sails
174
+ * @returns {SoundingRuntime}
175
+ */
26
176
  function createRuntime(sails) {
27
177
  const mailbox = createMailbox()
28
178
  const world = createWorldEngine({ sails })
29
179
  const helpers = createHelperRunner({ sails })
180
+ const getConfig = createConfigResolver(sails)
181
+ const worldLoaderCache = createWorldLoaderCache()
30
182
  const request = createRequestClient({
31
183
  sails,
32
- getConfig: () => resolveConfig(sails),
184
+ getConfig,
185
+ world,
33
186
  })
34
187
  const visit = createVisitClient({ request })
188
+ const sockets = createSocketManager({
189
+ sails,
190
+ getConfig,
191
+ world,
192
+ })
35
193
  const browser = createBrowserManager({
36
194
  sails,
37
- getConfig: () => resolveConfig(sails),
38
- appPathResolver: () => resolveAppPath(sails, resolveConfig(sails)),
195
+ getConfig,
196
+ appPathResolver: () => resolveAppPath(sails, getConfig()),
39
197
  })
40
198
  const auth = createAuthHelpers({
41
199
  sails,
@@ -46,14 +204,14 @@ function createRuntime(sails) {
46
204
  const mailCapture = createMailCapture({
47
205
  sails,
48
206
  mailbox,
49
- getConfig: () => resolveConfig(sails),
207
+ getConfig,
50
208
  })
51
209
  let bootState = null
52
210
  let datastoreState = null
53
211
 
54
212
  return {
55
213
  get config() {
56
- return resolveConfig(sails)
214
+ return getConfig()
57
215
  },
58
216
 
59
217
  get appPath() {
@@ -72,11 +230,6 @@ function createRuntime(sails) {
72
230
  return helpers
73
231
  },
74
232
 
75
- // Temporary compatibility alias while the DX settles.
76
- get helper() {
77
- return helpers
78
- },
79
-
80
233
  get request() {
81
234
  return request
82
235
  },
@@ -85,6 +238,10 @@ function createRuntime(sails) {
85
238
  return visit
86
239
  },
87
240
 
241
+ get sockets() {
242
+ return sockets
243
+ },
244
+
88
245
  get browser() {
89
246
  return browser
90
247
  },
@@ -96,7 +253,7 @@ function createRuntime(sails) {
96
253
  configure() {
97
254
  datastoreState = resolveDatastore({
98
255
  sails,
99
- soundingConfig: this.config,
256
+ soundingConfig: getConfig(),
100
257
  })
101
258
 
102
259
  return datastoreState
@@ -106,27 +263,34 @@ function createRuntime(sails) {
106
263
  return datastoreState
107
264
  },
108
265
 
266
+ /**
267
+ * @param {{ mode?: string }} [options]
268
+ * @returns {Promise<SoundingBootResult>}
269
+ */
109
270
  async boot(options = {}) {
110
271
  if (!datastoreState) {
111
272
  datastoreState = this.configure()
112
273
  }
113
274
 
275
+ const config = getConfig()
276
+ const appPath = resolveAppPath(sails, config)
114
277
  world.reset({ preserveSequences: true })
115
278
  const loadedWorldFiles = await loadWorldFiles({
116
279
  world,
117
- appPath: this.appPath,
118
- config: this.config,
280
+ appPath,
281
+ config,
119
282
  sails,
283
+ cache: worldLoaderCache,
120
284
  })
121
285
  const captureInstalled = mailCapture.install()
122
286
 
123
287
  bootState = {
124
288
  bootedAt: new Date().toISOString(),
125
289
  mode: options.mode || 'unit',
126
- config: this.config,
290
+ config,
127
291
  datastore: datastoreState,
128
292
  mail: {
129
- captureEnabled: this.config.mail?.capture !== false,
293
+ captureEnabled: config.mail?.capture !== false,
130
294
  captureInstalled,
131
295
  },
132
296
  world: {
@@ -142,6 +306,7 @@ function createRuntime(sails) {
142
306
  world,
143
307
  request,
144
308
  visit,
309
+ sockets,
145
310
  browser,
146
311
  auth,
147
312
  login: auth.login,
@@ -151,20 +316,56 @@ function createRuntime(sails) {
151
316
  async lower() {
152
317
  bootState = null
153
318
  datastoreState = null
154
- await browser.close()
155
- mailCapture.uninstall()
156
- mailbox.clear()
157
- world.reset({ preserveSequences: true })
319
+
320
+ await runCleanupSteps([
321
+ {
322
+ resource: 'sockets',
323
+ cleanup: () => sockets.closeAll(),
324
+ },
325
+ {
326
+ resource: 'request session',
327
+ cleanup: () => request.clearSession(),
328
+ },
329
+ {
330
+ resource: 'browser',
331
+ cleanup: () => browser.close(),
332
+ },
333
+ {
334
+ resource: 'mail capture',
335
+ cleanup: () => mailCapture.uninstall(),
336
+ },
337
+ {
338
+ resource: 'mailbox',
339
+ cleanup: () => mailbox.clear(),
340
+ },
341
+ {
342
+ resource: 'world',
343
+ cleanup: () => world.reset({ preserveSequences: true }),
344
+ },
345
+ ])
158
346
  },
159
347
 
160
348
  get state() {
161
349
  return bootState
162
350
  },
351
+
352
+ get cacheStats() {
353
+ return {
354
+ config: { ...getConfig.stats },
355
+ worldLoader: { ...worldLoaderCache.stats },
356
+ }
357
+ },
358
+
359
+ invalidateCaches() {
360
+ getConfig.invalidate()
361
+ worldLoaderCache.clear()
362
+ },
163
363
  }
164
364
  }
165
365
 
166
366
  module.exports = {
167
367
  createRuntime,
368
+ createConfigResolver,
168
369
  resolveConfig,
169
370
  resolveAppPath,
170
371
  }