dsh-unlag 1.7.10 → 1.7.11
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/index.js +83 -6
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
2
|
+
import { appendFileSync, existsSync, readFileSync, statSync, watch, writeFileSync } from 'node:fs'
|
|
2
3
|
import { homedir } from 'node:os'
|
|
3
4
|
import { join, resolve } from 'node:path'
|
|
4
5
|
|
|
@@ -29,6 +30,13 @@ export const inject = ['webServer']
|
|
|
29
30
|
/** A transaction that has not finished in this long is stuck; the press reports it instead of hanging. */
|
|
30
31
|
const RELOAD_TIMEOUT_MS = 20_000
|
|
31
32
|
|
|
33
|
+
/**
|
|
34
|
+
* A saved file reaches the app through the reload service's transaction context. If a later version
|
|
35
|
+
* of the app stops exposing that context, the queue cannot be entered from a save; the profile's
|
|
36
|
+
* patch file is used instead, because the loader watches it and re-imports the modules that changed.
|
|
37
|
+
*/
|
|
38
|
+
const PROFILE_MARKER = '# dsh-unlag: apply saved plugin code'
|
|
39
|
+
|
|
32
40
|
const LOG_PATH = join(homedir(), '.dsh', 'unlag-reload.log')
|
|
33
41
|
/** Tests point this at a scratch file, so a test run leaves the live log readable. */
|
|
34
42
|
const logTarget = () => process.env.DSH_UNLAG_LOG ?? LOG_PATH
|
|
@@ -147,6 +155,66 @@ function worthReloading(filename) {
|
|
|
147
155
|
* The plugin half. One controller serves both callers: a saved file (debounced) and the header
|
|
148
156
|
* button (immediate). A swap never waits for turns, never interrupts them, and never re-enters.
|
|
149
157
|
*/
|
|
158
|
+
/**
|
|
159
|
+
* The service keeps its transaction context in an AsyncLocalStorage on the instance. Resolve it by
|
|
160
|
+
* type as well as by name, so a renamed field still works; the name stays the fast path.
|
|
161
|
+
*/
|
|
162
|
+
function transactionContext(service) {
|
|
163
|
+
try {
|
|
164
|
+
if (typeof service?.executing?.exit === 'function') return service.executing
|
|
165
|
+
for (const value of Object.values(service ?? {})) if (value instanceof AsyncLocalStorage) return value
|
|
166
|
+
} catch {}
|
|
167
|
+
return null
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** The patch file of the profile this plugin runs in, when the loader exposes the profile. */
|
|
171
|
+
function profilePatch(ctx) {
|
|
172
|
+
try {
|
|
173
|
+
const dir = ctx.get('profileContext')?.dir
|
|
174
|
+
if (typeof dir !== 'string' || dir === '') return null
|
|
175
|
+
const file = join(dir, 'cordis.patch.yml')
|
|
176
|
+
return existsSync(file) ? file : null
|
|
177
|
+
} catch {
|
|
178
|
+
return null
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Drop the marker line again without touching anything else the file holds. */
|
|
183
|
+
function clearProfileMarker(file) {
|
|
184
|
+
try {
|
|
185
|
+
const text = readFileSync(file, 'utf8')
|
|
186
|
+
if (!text.includes(PROFILE_MARKER)) return
|
|
187
|
+
const withoutBlankLine = text.replace(`\n\n${PROFILE_MARKER}\n`, '\n')
|
|
188
|
+
writeFileSync(file, withoutBlankLine.replace(`\n${PROFILE_MARKER}\n`, '').replace(PROFILE_MARKER, ''))
|
|
189
|
+
} catch (error) {
|
|
190
|
+
log(`a reload marker is still in the profile: ${error?.message ?? error}`)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Last resort when a save cannot be queued: one comment line in the profile's patch file makes the
|
|
196
|
+
* loader re-import the modules that changed, the same outcome a queued reload gives. The line is
|
|
197
|
+
* taken back out as soon as the loader has read it, and a line a crash leaves behind is removed at
|
|
198
|
+
* the next start, so the profile is left as the user wrote it.
|
|
199
|
+
* @returns whether the profile was asked, so the caller can say why the save did not apply.
|
|
200
|
+
*/
|
|
201
|
+
function markProfileForReload(ctx) {
|
|
202
|
+
const file = profilePatch(ctx)
|
|
203
|
+
if (file === null) return false
|
|
204
|
+
try {
|
|
205
|
+
const text = readFileSync(file, 'utf8')
|
|
206
|
+
if (text.includes(PROFILE_MARKER)) return false
|
|
207
|
+
appendFileSync(file, `\n${PROFILE_MARKER}\n`)
|
|
208
|
+
} catch (error) {
|
|
209
|
+
log(`the profile could not be asked for a reload: ${error?.message ?? error}`)
|
|
210
|
+
return false
|
|
211
|
+
}
|
|
212
|
+
log('the saved plugin code is applied by a profile reload instead')
|
|
213
|
+
const timer = setTimeout(() => clearProfileMarker(file), 3000)
|
|
214
|
+
timer.unref?.()
|
|
215
|
+
return true
|
|
216
|
+
}
|
|
217
|
+
|
|
150
218
|
function pluginReloader(ctx, config) {
|
|
151
219
|
/** HMR can start after this half, so ask per use instead of once at load. */
|
|
152
220
|
const hmrService = () => {
|
|
@@ -155,6 +223,9 @@ function pluginReloader(ctx, config) {
|
|
|
155
223
|
return hmr
|
|
156
224
|
}
|
|
157
225
|
const available = () => hmrService() !== null
|
|
226
|
+
/** A marker line this plugin left in the profile is not the user's; take it back out at start. */
|
|
227
|
+
const leftover = profilePatch(ctx)
|
|
228
|
+
if (leftover !== null) clearProfileMarker(leftover)
|
|
158
229
|
/** Opt-in: wait for a quiet moment instead of swapping at once. Off by default. */
|
|
159
230
|
const idleOnly = config?.idleOnly === true
|
|
160
231
|
const debounce = Number.isFinite(config?.debounce) ? config.debounce : 300
|
|
@@ -192,10 +263,11 @@ function pluginReloader(ctx, config) {
|
|
|
192
263
|
* that context first is what the service's own watcher does, and it keeps the request queued
|
|
193
264
|
* instead of running a second recomposition beside the first.
|
|
194
265
|
*/
|
|
266
|
+
const context = transactionContext(hmr)
|
|
195
267
|
const ask = (service) =>
|
|
196
|
-
|
|
197
|
-
? service.
|
|
198
|
-
: service.runExclusive(() => service.reconcileProfile([]))
|
|
268
|
+
context === null
|
|
269
|
+
? service.runExclusive(() => service.reconcileProfile([]))
|
|
270
|
+
: context.exit(() => service.runExclusive(() => service.reconcileProfile([])))
|
|
199
271
|
const abandoned = () =>
|
|
200
272
|
new Promise((_resolve, reject) => {
|
|
201
273
|
const limit = Number(process.env.DSH_UNLAG_TIMEOUT_MS ?? '') || RELOAD_TIMEOUT_MS
|
|
@@ -210,8 +282,13 @@ function pluginReloader(ctx, config) {
|
|
|
210
282
|
} catch (error) {
|
|
211
283
|
const message = String(error?.message ?? error)
|
|
212
284
|
if (message.includes('nested') === false) throw error
|
|
213
|
-
if (attempt >= 9)
|
|
214
|
-
|
|
285
|
+
if (attempt >= 9) {
|
|
286
|
+
/** No context to leave means a save cannot be queued at all: hand the file to the loader. */
|
|
287
|
+
if (context === null) markProfileForReload(ctx)
|
|
288
|
+
throw error
|
|
289
|
+
}
|
|
290
|
+
const step = Number(process.env.DSH_UNLAG_RETRY_MS ?? '') || 250
|
|
291
|
+
await new Promise((resolve) => setTimeout(resolve, step * (attempt + 1)))
|
|
215
292
|
}
|
|
216
293
|
}
|
|
217
294
|
const warnings = Array.isArray(result?.warnings) ? result.warnings : []
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-unlag",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.11",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "One press applies plugin edits right away and brings a laggy interface back; running turns, queued messages and unsent input are untouched, and nothing restarts.",
|
|
6
6
|
"license": "MIT",
|