@lijian-ui/dsh-file-manager 0.1.20
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/LICENSE +38 -0
- package/README.i18n.yaml +6 -0
- package/README.md +66 -0
- package/README.zh.md +66 -0
- package/cordis.patch.yml +13 -0
- package/lib/assets/mermaid.min.js +3587 -0
- package/lib/client.js +7621 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +3207 -0
- package/lib/tsconfig.client.tsbuildinfo +1 -0
- package/lib/tsconfig.host.tsbuildinfo +1 -0
- package/lib/types/client/FileManagerSettingsCard.d.ts +56 -0
- package/lib/types/client/FileManagerSettingsCard.d.ts.map +1 -0
- package/lib/types/client/PluginSettingsCard.d.ts +104 -0
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -0
- package/lib/types/client/api.d.ts +75 -0
- package/lib/types/client/api.d.ts.map +1 -0
- package/lib/types/client/chat/AttachedFilesDock.d.ts +51 -0
- package/lib/types/client/chat/AttachedFilesDock.d.ts.map +1 -0
- package/lib/types/client/chat/file-ref.d.ts +35 -0
- package/lib/types/client/chat/file-ref.d.ts.map +1 -0
- package/lib/types/client/chat/mermaid-chat.d.ts +30 -0
- package/lib/types/client/chat/mermaid-chat.d.ts.map +1 -0
- package/lib/types/client/chat/placeholder-hint.d.ts +16 -0
- package/lib/types/client/chat/placeholder-hint.d.ts.map +1 -0
- package/lib/types/client/components/ExplorerPanel.d.ts +26 -0
- package/lib/types/client/components/ExplorerPanel.d.ts.map +1 -0
- package/lib/types/client/components/FileIcon.d.ts +15 -0
- package/lib/types/client/components/FileIcon.d.ts.map +1 -0
- package/lib/types/client/components/ScmPanel.d.ts +23 -0
- package/lib/types/client/components/ScmPanel.d.ts.map +1 -0
- package/lib/types/client/components/a11y.d.ts +12 -0
- package/lib/types/client/components/a11y.d.ts.map +1 -0
- package/lib/types/client/components/icons.d.ts +41 -0
- package/lib/types/client/components/icons.d.ts.map +1 -0
- package/lib/types/client/components/overlay.d.ts +45 -0
- package/lib/types/client/components/overlay.d.ts.map +1 -0
- package/lib/types/client/drag/DragFileInlay.d.ts +31 -0
- package/lib/types/client/drag/DragFileInlay.d.ts.map +1 -0
- package/lib/types/client/drag/file-drag.d.ts +47 -0
- package/lib/types/client/drag/file-drag.d.ts.map +1 -0
- package/lib/types/client/drag.d.ts +31 -0
- package/lib/types/client/drag.d.ts.map +1 -0
- package/lib/types/client/file-source.d.ts +29 -0
- package/lib/types/client/file-source.d.ts.map +1 -0
- package/lib/types/client/fileType.d.ts +31 -0
- package/lib/types/client/fileType.d.ts.map +1 -0
- package/lib/types/client/floating.d.ts +27 -0
- package/lib/types/client/floating.d.ts.map +1 -0
- package/lib/types/client/hooks/useResizableSplit.d.ts +37 -0
- package/lib/types/client/hooks/useResizableSplit.d.ts.map +1 -0
- package/lib/types/client/hooks/useStore.d.ts +10 -0
- package/lib/types/client/hooks/useStore.d.ts.map +1 -0
- package/lib/types/client/index.d.ts +57 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/client/layout.d.ts +104 -0
- package/lib/types/client/layout.d.ts.map +1 -0
- package/lib/types/client/locales.d.ts +125 -0
- package/lib/types/client/locales.d.ts.map +1 -0
- package/lib/types/client/maximize.d.ts +24 -0
- package/lib/types/client/maximize.d.ts.map +1 -0
- package/lib/types/client/mention.d.ts +44 -0
- package/lib/types/client/mention.d.ts.map +1 -0
- package/lib/types/client/mount.d.ts +22 -0
- package/lib/types/client/mount.d.ts.map +1 -0
- package/lib/types/client/persist.d.ts +55 -0
- package/lib/types/client/persist.d.ts.map +1 -0
- package/lib/types/client/picker/FilePickerModal.d.ts +47 -0
- package/lib/types/client/picker/FilePickerModal.d.ts.map +1 -0
- package/lib/types/client/picker/file-picker.d.ts +62 -0
- package/lib/types/client/picker/file-picker.d.ts.map +1 -0
- package/lib/types/client/preview/PreviewPanel.d.ts +17 -0
- package/lib/types/client/preview/PreviewPanel.d.ts.map +1 -0
- package/lib/types/client/preview/PreviewTabs.d.ts +30 -0
- package/lib/types/client/preview/PreviewTabs.d.ts.map +1 -0
- package/lib/types/client/preview/PreviewToolbar.d.ts +37 -0
- package/lib/types/client/preview/PreviewToolbar.d.ts.map +1 -0
- package/lib/types/client/preview/content.d.ts +43 -0
- package/lib/types/client/preview/content.d.ts.map +1 -0
- package/lib/types/client/preview/markdown.d.ts +60 -0
- package/lib/types/client/preview/markdown.d.ts.map +1 -0
- package/lib/types/client/preview/mermaid.d.ts +78 -0
- package/lib/types/client/preview/mermaid.d.ts.map +1 -0
- package/lib/types/client/preview/selection.d.ts +26 -0
- package/lib/types/client/preview/selection.d.ts.map +1 -0
- package/lib/types/client/reference.d.ts +75 -0
- package/lib/types/client/reference.d.ts.map +1 -0
- package/lib/types/client/settings-form.d.ts +206 -0
- package/lib/types/client/settings-form.d.ts.map +1 -0
- package/lib/types/client/store.d.ts +279 -0
- package/lib/types/client/store.d.ts.map +1 -0
- package/lib/types/core/types.d.ts +132 -0
- package/lib/types/core/types.d.ts.map +1 -0
- package/lib/types/host/fs-service.d.ts +96 -0
- package/lib/types/host/fs-service.d.ts.map +1 -0
- package/lib/types/host/gate.d.ts +45 -0
- package/lib/types/host/gate.d.ts.map +1 -0
- package/lib/types/host/git-runner.d.ts +80 -0
- package/lib/types/host/git-runner.d.ts.map +1 -0
- package/lib/types/host/git-service.d.ts +130 -0
- package/lib/types/host/git-service.d.ts.map +1 -0
- package/lib/types/host/loopback.d.ts +25 -0
- package/lib/types/host/loopback.d.ts.map +1 -0
- package/lib/types/host/poll-guard.d.ts +55 -0
- package/lib/types/host/poll-guard.d.ts.map +1 -0
- package/lib/types/host/routes.d.ts +54 -0
- package/lib/types/host/routes.d.ts.map +1 -0
- package/lib/types/index.d.ts +35 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/mount-once.d.ts +25 -0
- package/lib/types/mount-once.d.ts.map +1 -0
- package/package.json +85 -0
- package/src/client/FileManagerSettingsCard.tsx +117 -0
- package/src/client/PluginSettingsCard.tsx +337 -0
- package/src/client/api.ts +141 -0
- package/src/client/chat/file-ref.ts +144 -0
- package/src/client/chat/mermaid-chat.tsx +102 -0
- package/src/client/chat/placeholder-hint.tsx +46 -0
- package/src/client/components/ExplorerPanel.tsx +534 -0
- package/src/client/components/FileIcon.tsx +46 -0
- package/src/client/components/ScmPanel.tsx +480 -0
- package/src/client/components/a11y.ts +20 -0
- package/src/client/components/icons.tsx +274 -0
- package/src/client/components/overlay.tsx +225 -0
- package/src/client/css-modules.d.ts +5 -0
- package/src/client/drag/DragFileInlay.tsx +89 -0
- package/src/client/drag/file-drag.ts +67 -0
- package/src/client/drag.ts +154 -0
- package/src/client/file-source.ts +58 -0
- package/src/client/fileType.ts +129 -0
- package/src/client/floating.ts +63 -0
- package/src/client/hooks/useResizableSplit.ts +91 -0
- package/src/client/hooks/useStore.ts +15 -0
- package/src/client/index.ts +389 -0
- package/src/client/layout.ts +546 -0
- package/src/client/locales.ts +247 -0
- package/src/client/maximize.ts +34 -0
- package/src/client/mount.tsx +74 -0
- package/src/client/persist.ts +194 -0
- package/src/client/picker/FilePickerModal.tsx +506 -0
- package/src/client/picker/file-picker.ts +86 -0
- package/src/client/preview/PreviewPanel.tsx +233 -0
- package/src/client/preview/PreviewTabs.tsx +159 -0
- package/src/client/preview/PreviewToolbar.tsx +160 -0
- package/src/client/preview/content.tsx +617 -0
- package/src/client/preview/markdown.ts +351 -0
- package/src/client/preview/mermaid.ts +274 -0
- package/src/client/preview/selection.ts +82 -0
- package/src/client/reference.ts +161 -0
- package/src/client/settings-card.module.css +316 -0
- package/src/client/settings-form.ts +451 -0
- package/src/client/store.ts +1290 -0
- package/src/client/styles/chip.module.css +38 -0
- package/src/client/styles/drag.module.css +30 -0
- package/src/client/styles/explorer.module.css +371 -0
- package/src/client/styles/picker.module.css +344 -0
- package/src/client/styles/preview.module.css +904 -0
- package/src/client/styles/scm.module.css +380 -0
- package/src/client/styles/tokens.module.css +480 -0
- package/src/core/types.ts +162 -0
- package/src/host/fs-service.ts +513 -0
- package/src/host/gate.ts +74 -0
- package/src/host/git-runner.ts +116 -0
- package/src/host/git-service.ts +395 -0
- package/src/host/loopback.ts +63 -0
- package/src/host/poll-guard.ts +109 -0
- package/src/host/routes.ts +695 -0
- package/src/index.ts +97 -0
- package/src/mount-once.ts +48 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// Generated by scripts/sync-shared.mjs from shared/host/git-runner.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
|
|
2
|
+
/**
|
|
3
|
+
* Shared host git subprocess plumbing: the run result shape, the runner seam,
|
|
4
|
+
* the collected-output cap, and the production runner over the subprocess
|
|
5
|
+
* service. Packages receive this file as a generated copy via
|
|
6
|
+
* scripts/sync-shared.mjs; edit the shared source and re-run the sync instead
|
|
7
|
+
* of editing a copy.
|
|
8
|
+
*
|
|
9
|
+
* The context shape is declared structurally so this module stays
|
|
10
|
+
* self-contained (shared/ has no cordis dependency): any context whose
|
|
11
|
+
* `subprocess` satisfies SubprocessServiceLike works, which the plugin
|
|
12
|
+
* contexts do.
|
|
13
|
+
* @module dsh-web-ui-shared/host/git-runner
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** One finished git invocation. */
|
|
17
|
+
export interface GitRunResult {
|
|
18
|
+
exitCode: number | null
|
|
19
|
+
stdout: string
|
|
20
|
+
stderr: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The spawn seam the service runs git through (subprocess service in production). */
|
|
24
|
+
export interface GitRunner {
|
|
25
|
+
run(argv: readonly string[], cwd: string): Promise<GitRunResult>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Collected-output cap for one git command. */
|
|
29
|
+
export const OUTPUT_CAP_BYTES = 1 << 20
|
|
30
|
+
|
|
31
|
+
/** The subprocess service surface this runner consumes (structural). */
|
|
32
|
+
export interface SubprocessServiceLike {
|
|
33
|
+
spawn(spec: {
|
|
34
|
+
argv: readonly string[]
|
|
35
|
+
cwd: string
|
|
36
|
+
stdio: {
|
|
37
|
+
stdin: 'ignore'
|
|
38
|
+
stdout: { maxBytes: number }
|
|
39
|
+
stderr: { maxBytes: number }
|
|
40
|
+
}
|
|
41
|
+
graceMs: number
|
|
42
|
+
}): {
|
|
43
|
+
done: Promise<{ exitCode: number | null }>
|
|
44
|
+
collected: {
|
|
45
|
+
stdout?: { readFrom(offset: number): { text: string } }
|
|
46
|
+
stderr?: { readFrom(offset: number): { text: string } }
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Per-package knobs for the shared production runner. */
|
|
52
|
+
export interface GitRunnerOptions {
|
|
53
|
+
/** Build the full spawn argv from the git args (default ['git', ...argv]). */
|
|
54
|
+
spawnArgv?: (argv: readonly string[]) => readonly string[]
|
|
55
|
+
/** degrade turns spawn/run failures into exitCode 127 results instead of throwing. */
|
|
56
|
+
failureMode?: 'throw' | 'degrade'
|
|
57
|
+
/** console.error tag for degrade mode. */
|
|
58
|
+
errorTag?: string
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Production runner over the subprocess service: one managed child per
|
|
63
|
+
* command, bounded collect on both streams. Degrade mode keeps the SCM tab
|
|
64
|
+
* showing the friendly "not a git repository" state instead of a bare 400
|
|
65
|
+
* when git is missing or the subprocess service fails.
|
|
66
|
+
* @param ctx - context carrying the subprocess service.
|
|
67
|
+
* @param options - per-package behavior knobs.
|
|
68
|
+
* @returns the runner.
|
|
69
|
+
*/
|
|
70
|
+
export function subprocessRunner(ctx: { subprocess: SubprocessServiceLike }, options: GitRunnerOptions = {}): GitRunner {
|
|
71
|
+
const spawnArgv = options.spawnArgv ?? ((argv) => ['git', ...argv])
|
|
72
|
+
const degrade = options.failureMode === 'degrade'
|
|
73
|
+
const errorTag = options.errorTag ?? 'git'
|
|
74
|
+
const failure = (prefix: string, error: unknown): GitRunResult => ({
|
|
75
|
+
exitCode: 127,
|
|
76
|
+
stdout: '',
|
|
77
|
+
stderr: prefix + (error instanceof Error ? error.message : String(error)),
|
|
78
|
+
})
|
|
79
|
+
return {
|
|
80
|
+
async run(argv, cwd) {
|
|
81
|
+
const spec = {
|
|
82
|
+
argv: spawnArgv(argv),
|
|
83
|
+
cwd,
|
|
84
|
+
stdio: {
|
|
85
|
+
stdin: 'ignore' as const,
|
|
86
|
+
stdout: { maxBytes: OUTPUT_CAP_BYTES },
|
|
87
|
+
stderr: { maxBytes: OUTPUT_CAP_BYTES },
|
|
88
|
+
},
|
|
89
|
+
graceMs: 10_000,
|
|
90
|
+
}
|
|
91
|
+
if (degrade) {
|
|
92
|
+
let handle
|
|
93
|
+
try {
|
|
94
|
+
handle = ctx.subprocess.spawn(spec)
|
|
95
|
+
} catch (error) {
|
|
96
|
+
console.error('[' + errorTag + '] git spawn failed:', error)
|
|
97
|
+
return failure('git: spawn failed: ', error)
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
const outcome = await handle.done
|
|
101
|
+
const stdout = handle.collected.stdout?.readFrom(0).text ?? ''
|
|
102
|
+
const stderr = handle.collected.stderr?.readFrom(0).text ?? ''
|
|
103
|
+
return { exitCode: outcome.exitCode, stdout, stderr }
|
|
104
|
+
} catch (error) {
|
|
105
|
+
console.error('[' + errorTag + '] git run failed:', error)
|
|
106
|
+
return failure('git: run failed: ', error)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const handle = ctx.subprocess.spawn(spec)
|
|
110
|
+
const outcome = await handle.done
|
|
111
|
+
const stdout = handle.collected.stdout?.readFrom(0).text ?? ''
|
|
112
|
+
const stderr = handle.collected.stderr?.readFrom(0).text ?? ''
|
|
113
|
+
return { exitCode: outcome.exitCode, stdout, stderr }
|
|
114
|
+
},
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host git service for the SCM tab: working-tree status (porcelain v1, -z),
|
|
3
|
+
* stage/unstage/discard batches, all scoped to the gated project root and
|
|
4
|
+
* executed through the managed subprocess seam. Parsing is pure and exported
|
|
5
|
+
* for tests; the service only wraps the runner. Discard never touches the
|
|
6
|
+
* staged side (the index is only ever rewritten by stage/unstage), matching
|
|
7
|
+
* the "discard = worktree side" contract.
|
|
8
|
+
* @module dsh-filemgr/host/git-service
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { join, relative } from 'node:path'
|
|
12
|
+
import { realpath } from 'node:fs/promises'
|
|
13
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
14
|
+
import type {} from '@deepseek-ai/dsh-subprocess'
|
|
15
|
+
import { subprocessRunner as sharedSubprocessRunner, type GitRunResult, type GitRunner } from './git-runner.ts'
|
|
16
|
+
import type { GitBatchResult, GitChangeRow, GitFileState, GitStatusView, PanelError } from '../core/types.ts'
|
|
17
|
+
import { isPathInside, type WorkspaceGate } from './gate.ts'
|
|
18
|
+
|
|
19
|
+
/** One finished git invocation (shared runner plumbing). */
|
|
20
|
+
export type { GitRunResult, GitRunner } from './git-runner.ts'
|
|
21
|
+
|
|
22
|
+
/** TTL for a positive repo-top-level verdict. */
|
|
23
|
+
const REPO_CACHE_TTL_MS = 60_000
|
|
24
|
+
/** TTL for a negative (null) repo-top-level verdict. */
|
|
25
|
+
const NO_REPO_CACHE_TTL_MS = 30_000
|
|
26
|
+
|
|
27
|
+
/** Production runner over `ctx.subprocess`: shared plumbing, degrade mode for the SCM tab. */
|
|
28
|
+
export function subprocessRunner(ctx: Context): GitRunner {
|
|
29
|
+
return sharedSubprocessRunner(ctx, { failureMode: 'degrade', errorTag: 'dsh-filemgr' })
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Map one porcelain letter to the row state (unknown letters stay unknown). */
|
|
33
|
+
export function porcelainState(letter: string): GitFileState {
|
|
34
|
+
switch (letter) {
|
|
35
|
+
case 'A': return 'created'
|
|
36
|
+
case 'M': return 'modified'
|
|
37
|
+
case 'D': return 'deleted'
|
|
38
|
+
case 'R': return 'renamed'
|
|
39
|
+
case 'C': return 'created'
|
|
40
|
+
case 'U': return 'conflicted'
|
|
41
|
+
case '?': return 'untracked'
|
|
42
|
+
default: return 'unknown'
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Parse `git status --porcelain=v1 -z` output into staged/unstaged/untracked
|
|
48
|
+
* rows. With -z every entry is NUL-terminated; rename entries carry two paths
|
|
49
|
+
* (old and new). Pure — exported for tests.
|
|
50
|
+
* @param output - raw porcelain v1 -z output.
|
|
51
|
+
* @returns the three change groups.
|
|
52
|
+
*/
|
|
53
|
+
export function parsePorcelain(output: string): {
|
|
54
|
+
staged: GitChangeRow[]
|
|
55
|
+
unstaged: GitChangeRow[]
|
|
56
|
+
untracked: GitChangeRow[]
|
|
57
|
+
} {
|
|
58
|
+
const staged: GitChangeRow[] = []
|
|
59
|
+
const unstaged: GitChangeRow[] = []
|
|
60
|
+
const untracked: GitChangeRow[] = []
|
|
61
|
+
if (output === '') return { staged, unstaged, untracked }
|
|
62
|
+
const fields = output.split('\0')
|
|
63
|
+
for (let i = 0; i < fields.length; i += 1) {
|
|
64
|
+
const field = fields[i]
|
|
65
|
+
if (field === '') continue
|
|
66
|
+
const x = field[0] ?? ' '
|
|
67
|
+
const y = field[1] ?? ' '
|
|
68
|
+
const path = field.slice(3)
|
|
69
|
+
if (x === '?' && y === '?') {
|
|
70
|
+
untracked.push({ path, state: 'untracked', staged: false })
|
|
71
|
+
continue
|
|
72
|
+
}
|
|
73
|
+
if (x === 'R' || x === 'C') {
|
|
74
|
+
// -z rename entries: XY old\0new — the path field holds the old path.
|
|
75
|
+
const oldPath = path
|
|
76
|
+
const newPath = fields[i + 1] ?? oldPath
|
|
77
|
+
i += 1
|
|
78
|
+
staged.push({ path: newPath, oldPath, state: porcelainState(x), staged: true })
|
|
79
|
+
if (y !== ' ') {
|
|
80
|
+
unstaged.push({ path: newPath, oldPath, state: porcelainState(y), staged: false })
|
|
81
|
+
}
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
84
|
+
if (x !== ' ') {
|
|
85
|
+
staged.push({ path, state: porcelainState(x), staged: true })
|
|
86
|
+
}
|
|
87
|
+
if (y !== ' ') {
|
|
88
|
+
unstaged.push({ path, state: porcelainState(y), staged: false })
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return { staged, unstaged, untracked }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Parse the porcelain row set into the status view shape. */
|
|
95
|
+
export function parseStatusView(root: string, branch: string, output: string): GitStatusView {
|
|
96
|
+
const { staged, unstaged, untracked } = parsePorcelain(output)
|
|
97
|
+
return { root, branch, staged, unstaged, untracked }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The not-a-repository verdict for status reads. */
|
|
101
|
+
const NO_REPO: PanelError = { code: 'git-unavailable', message: 'not a git repository' }
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Workspace-scoped git operations. Gated methods pass the gate, resolve the
|
|
105
|
+
* repository root, and reject non-repositories with a stable error; the
|
|
106
|
+
* `Canonical` variants trust an already-gated canonical root (the SSE poll)
|
|
107
|
+
* and skip the gate.
|
|
108
|
+
* @param runner - the spawn seam.
|
|
109
|
+
* @param gate - workspace-membership gate.
|
|
110
|
+
* @param fsDelete - delete seam for untracked discard (host: FsService.delete).
|
|
111
|
+
*/
|
|
112
|
+
export class GitService {
|
|
113
|
+
constructor(
|
|
114
|
+
private readonly runner: GitRunner,
|
|
115
|
+
private readonly gate: WorkspaceGate,
|
|
116
|
+
private readonly fsDelete: (root: string, rel: string) => Promise<{ ok: true } | PanelError>,
|
|
117
|
+
) {}
|
|
118
|
+
|
|
119
|
+
/** Cached one-shot git binary probe; never re-probes after the first call. */
|
|
120
|
+
private availablePromise: Promise<boolean> | undefined
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Cached repo-top-level resolution per canonical workspace, with a TTL so
|
|
124
|
+
* running `git init` (positive self-heal) or deleting `.git` (negative
|
|
125
|
+
* self-heal) is discovered by a later probe. Positive verdicts live 60s,
|
|
126
|
+
* negative (null) verdicts 30s; exitCode 127 is never cached because it
|
|
127
|
+
* means spawn/run failed rather than "not a repository".
|
|
128
|
+
*/
|
|
129
|
+
private readonly repoCache = new Map<string, { value: Promise<string | null>; expiresAt: number }>()
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Probe the git binary once (git --version) and cache the verdict for the
|
|
133
|
+
* service lifetime. A machine without git then degrades every operation to
|
|
134
|
+
* the stable "not a git repository" state after a single failed spawn,
|
|
135
|
+
* instead of re-spawning ENOENT on every poll tick. The cache stays false
|
|
136
|
+
* even if git is installed later; the host restart picks it up.
|
|
137
|
+
*/
|
|
138
|
+
gitAvailable(): Promise<boolean> {
|
|
139
|
+
if (this.availablePromise === undefined) {
|
|
140
|
+
this.availablePromise = this.runner
|
|
141
|
+
.run(['--version'], '/')
|
|
142
|
+
.then((result) => result.exitCode === 0)
|
|
143
|
+
.catch(() => false)
|
|
144
|
+
}
|
|
145
|
+
return this.availablePromise
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Resolve the repo top-level for one canonical root. Verdicts are cached
|
|
150
|
+
* with a TTL: a positive repo path for 60s, a negative null for 30s. After
|
|
151
|
+
* expiry the next call re-runs `rev-parse --show-toplevel`, so a repo
|
|
152
|
+
* created or removed while the host is running is picked up later. An
|
|
153
|
+
* exitCode 127 means the spawn/run itself failed; it returns null but is
|
|
154
|
+
* deliberately not cached so the next call retries. Any other failure is
|
|
155
|
+
* cached as a negative verdict for its TTL.
|
|
156
|
+
*/
|
|
157
|
+
private repoOf(root: string): Promise<string | null> {
|
|
158
|
+
const now = Date.now()
|
|
159
|
+
const cached = this.repoCache.get(root)
|
|
160
|
+
if (cached !== undefined && cached.expiresAt > now) return cached.value
|
|
161
|
+
// Infinity while the probe is in flight: concurrent callers share the
|
|
162
|
+
// same promise, and the real TTL is stamped once the verdict settles.
|
|
163
|
+
const entry: { value: Promise<string | null>; expiresAt: number } = {
|
|
164
|
+
value: Promise.resolve(null),
|
|
165
|
+
expiresAt: Number.POSITIVE_INFINITY,
|
|
166
|
+
}
|
|
167
|
+
entry.value = this.run(['rev-parse', '--show-toplevel'], root)
|
|
168
|
+
.then((result) => {
|
|
169
|
+
if (result.exitCode === 127) {
|
|
170
|
+
// Spawn/run failure is not a repo verdict: leave nothing cached.
|
|
171
|
+
if (this.repoCache.get(root) === entry) this.repoCache.delete(root)
|
|
172
|
+
return null
|
|
173
|
+
}
|
|
174
|
+
if (result.exitCode !== 0) {
|
|
175
|
+
entry.expiresAt = now + NO_REPO_CACHE_TTL_MS
|
|
176
|
+
return null
|
|
177
|
+
}
|
|
178
|
+
const repo = result.stdout.trim()
|
|
179
|
+
const found = repo !== '' && isPathInside(repo, root) ? repo : null
|
|
180
|
+
entry.expiresAt = now + (found === null ? NO_REPO_CACHE_TTL_MS : REPO_CACHE_TTL_MS)
|
|
181
|
+
return found
|
|
182
|
+
})
|
|
183
|
+
.catch(() => {
|
|
184
|
+
entry.expiresAt = now + NO_REPO_CACHE_TTL_MS
|
|
185
|
+
return null
|
|
186
|
+
})
|
|
187
|
+
this.repoCache.set(root, entry)
|
|
188
|
+
return entry.value
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Whether an already-gated canonical root is a git repository. Skips the
|
|
193
|
+
* workspace gate so the SSE poll does not double-gate every 2s tick; the
|
|
194
|
+
* underlying repoOf cache keeps rev-parse probes at TTL cadence.
|
|
195
|
+
*/
|
|
196
|
+
isRepositoryCanonical(canonicalRoot: string): Promise<boolean> {
|
|
197
|
+
return this.repoOf(canonicalRoot).then((repo) => repo !== null)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Whether a workspace root is a git repository. Gates the root first (POST
|
|
202
|
+
* route entry point); the SSE poll should use `isRepositoryCanonical`.
|
|
203
|
+
*/
|
|
204
|
+
async isRepository(root: string): Promise<boolean> {
|
|
205
|
+
const gated = await this.gate(root)
|
|
206
|
+
if (!gated.ok) return false
|
|
207
|
+
return await this.isRepositoryCanonical(gated.canonical)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Resolve the gated canonical root and the repository top-level. */
|
|
211
|
+
private async repo(root: string): Promise<{ ok: true; root: string; repo: string } | { ok: false; error: PanelError }> {
|
|
212
|
+
const gated = await this.gate(root)
|
|
213
|
+
if (!gated.ok) return { ok: false, error: gated.error }
|
|
214
|
+
const repo = await this.repoOf(gated.canonical)
|
|
215
|
+
if (repo === null) return { ok: false, error: NO_REPO }
|
|
216
|
+
return { ok: true, root: gated.canonical, repo }
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Run one git invocation and classify failures. */
|
|
220
|
+
private async run(argv: readonly string[], cwd: string): Promise<GitRunResult> {
|
|
221
|
+
return this.runner.run(argv, cwd)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** The repo status view; null when the root is not a repository. */
|
|
225
|
+
async status(root: string): Promise<GitStatusView | null | PanelError> {
|
|
226
|
+
// A missing git binary answers before any spawn: the probe runs once per
|
|
227
|
+
// service lifetime, so a git-less machine never re-spawns ENOENT here.
|
|
228
|
+
if (!(await this.gitAvailable())) return null
|
|
229
|
+
const repo = await this.repo(root)
|
|
230
|
+
if (!repo.ok) return repo.error.code === 'git-unavailable' ? null : repo.error
|
|
231
|
+
return this.statusAt(repo.root, repo.repo)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* The repo status view for an already-gated canonical root; null when it is
|
|
236
|
+
* not a repository. Skips the workspace gate (SSE subscribers were gated at
|
|
237
|
+
* connect) and reuses the same repoOf cache + status parsing as `status`.
|
|
238
|
+
*/
|
|
239
|
+
async statusCanonical(canonicalRoot: string): Promise<GitStatusView | null> {
|
|
240
|
+
const repo = await this.repoOf(canonicalRoot)
|
|
241
|
+
if (repo === null) return null
|
|
242
|
+
return this.statusAt(canonicalRoot, repo)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Run branch + porcelain status for one resolved repo and parse the view. */
|
|
246
|
+
private async statusAt(root: string, repo: string): Promise<GitStatusView> {
|
|
247
|
+
const [branchResult, statusResult] = await Promise.all([
|
|
248
|
+
this.run(['rev-parse', '--abbrev-ref', 'HEAD'], repo),
|
|
249
|
+
this.run(['status', '--porcelain=v1', '-z', '--untracked-files=all'], repo),
|
|
250
|
+
])
|
|
251
|
+
const branch = branchResult.stdout.trim() === 'HEAD' ? '' : branchResult.stdout.trim()
|
|
252
|
+
return parseStatusView(root, branch, statusResult.stdout)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** The repo root for the watch layer (null when not a repository). */
|
|
256
|
+
async repoRoot(root: string): Promise<string | null> {
|
|
257
|
+
const repo = await this.repo(root)
|
|
258
|
+
return repo.ok ? repo.repo : null
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* The unified diff of one path ('' when there is no diff to show). Staged
|
|
263
|
+
* paths diff the index against HEAD (`--cached`); unstaged paths diff the
|
|
264
|
+
* worktree against the index. Untracked paths have no index/HEAD entry, so
|
|
265
|
+
* they diff against /dev/null (the canonical new-file shape); its exit code
|
|
266
|
+
* is 1 — differences exist — which is a success here, not a failure.
|
|
267
|
+
*/
|
|
268
|
+
async diff(root: string, path: string, staged: boolean): Promise<{ content: string } | PanelError> {
|
|
269
|
+
const repo = await this.repo(root)
|
|
270
|
+
if (!repo.ok) return repo.error
|
|
271
|
+
const abs = join(repo.repo, path)
|
|
272
|
+
if (!isPathInside(repo.repo, abs)) return { code: 'path-outside-root', message: 'path outside the repository' }
|
|
273
|
+
const rel = relative(repo.repo, abs)
|
|
274
|
+
const tracked = await this.run(['ls-files', '--error-unmatch', '--', rel], repo.repo)
|
|
275
|
+
const result = tracked.exitCode !== 0
|
|
276
|
+
? await this.run(['diff', '--no-index', '--', '/dev/null', rel], repo.repo)
|
|
277
|
+
: staged
|
|
278
|
+
? await this.run(['diff', '--cached', '--', rel], repo.repo)
|
|
279
|
+
: await this.run(['diff', '--', rel], repo.repo)
|
|
280
|
+
// --no-index reports exit 1 when differences exist; a plain diff exits 0.
|
|
281
|
+
if (result.exitCode !== 0 && result.exitCode !== 1) {
|
|
282
|
+
return { code: 'git-failed', message: 'git diff failed' }
|
|
283
|
+
}
|
|
284
|
+
return { content: result.stdout }
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Verify paths stay inside the repo root (defense in depth). */
|
|
288
|
+
private pathsInside(repo: string, paths: string[]): string[] {
|
|
289
|
+
const abs = paths.map((p) => join(repo, p))
|
|
290
|
+
return abs.filter((p) => isPathInside(repo, p)).map((p) => p)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Stage paths (git add). Batch result reflects the post-op status. */
|
|
294
|
+
async stage(root: string, paths: string[]): Promise<GitBatchResult | PanelError> {
|
|
295
|
+
return this.batch(root, paths, async (repo, inside) => {
|
|
296
|
+
const result = await this.run(['add', '--', ...inside], repo)
|
|
297
|
+
return result.exitCode === 0
|
|
298
|
+
})
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Unstage paths (git restore --staged). */
|
|
302
|
+
async unstage(root: string, paths: string[]): Promise<GitBatchResult | PanelError> {
|
|
303
|
+
return this.batch(root, paths, async (repo, inside) => {
|
|
304
|
+
const result = await this.run(['restore', '--staged', '--', ...inside], repo)
|
|
305
|
+
return result.exitCode === 0
|
|
306
|
+
})
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Discard paths (worktree side only). Tracked paths are restored from the
|
|
311
|
+
* index; untracked paths are deleted through the fs seam. The batch reports
|
|
312
|
+
* applied/failed per path.
|
|
313
|
+
*/
|
|
314
|
+
async discard(root: string, paths: string[]): Promise<GitBatchResult | PanelError> {
|
|
315
|
+
const repo = await this.repo(root)
|
|
316
|
+
if (!repo.ok) return repo.error
|
|
317
|
+
const inside = this.pathsInside(repo.repo, paths)
|
|
318
|
+
const applied: string[] = []
|
|
319
|
+
const failed: string[] = []
|
|
320
|
+
// Membership is checked on the ABSOLUTE path (pathsInside resolves); the
|
|
321
|
+
// git commands below run with the repo-relative path (cwd = repo).
|
|
322
|
+
const eligible: string[] = []
|
|
323
|
+
for (const p of paths) {
|
|
324
|
+
if (inside.includes(join(repo.repo, p))) eligible.push(p)
|
|
325
|
+
else failed.push(p)
|
|
326
|
+
}
|
|
327
|
+
// One ls-files classifies every eligible path (its output IS the tracked
|
|
328
|
+
// set) instead of one --error-unmatch spawn per path.
|
|
329
|
+
const trackedSet = new Set<string>()
|
|
330
|
+
if (eligible.length > 0) {
|
|
331
|
+
const listed = await this.run(['ls-files', '-z', '--', ...eligible.map(p => ':(literal)' + p)], repo.repo)
|
|
332
|
+
for (const entry of listed.stdout.split('\0')) if (entry !== '') trackedSet.add(entry)
|
|
333
|
+
}
|
|
334
|
+
const tracked = eligible.filter(p => trackedSet.has(p))
|
|
335
|
+
const untracked = eligible.filter(p => !trackedSet.has(p))
|
|
336
|
+
// Tracked paths restore in one spawn; a batch failure falls back to
|
|
337
|
+
// per-path restores so the applied/failed split stays exact.
|
|
338
|
+
if (tracked.length > 0) {
|
|
339
|
+
const restored = await this.run(['restore', '--worktree', '--', ...tracked.map(p => ':(literal)' + p)], repo.repo)
|
|
340
|
+
if (restored.exitCode === 0) {
|
|
341
|
+
applied.push(...tracked)
|
|
342
|
+
} else {
|
|
343
|
+
for (const p of tracked) {
|
|
344
|
+
const single = await this.run(['restore', '--worktree', '--', ':(literal)' + p], repo.repo)
|
|
345
|
+
if (single.exitCode === 0) applied.push(p)
|
|
346
|
+
else failed.push(p)
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
for (const p of untracked) {
|
|
351
|
+
// Untracked paths are not restored by git restore; delete them directly.
|
|
352
|
+
// A symlink entry pointing outside the repo must not be deleted — the
|
|
353
|
+
// fs delete would follow the link away. Realpath-check before deleting.
|
|
354
|
+
try {
|
|
355
|
+
const real = await realpath(join(repo.repo, p))
|
|
356
|
+
if (!isPathInside(repo.repo, real)) {
|
|
357
|
+
failed.push(p)
|
|
358
|
+
continue
|
|
359
|
+
}
|
|
360
|
+
} catch {
|
|
361
|
+
// The path does not exist on disk (ENOENT): nothing to escape.
|
|
362
|
+
}
|
|
363
|
+
// The fs seam addresses the project ROOT (which may be a subdir of
|
|
364
|
+
// the repo); derive the root-relative path from the absolute one.
|
|
365
|
+
const rel = relative(repo.root, join(repo.repo, p))
|
|
366
|
+
// Untracked files that lie outside the session root cannot be deleted
|
|
367
|
+
// through the fs seam; refuse rather than delete a look-alike path.
|
|
368
|
+
if (rel === '..' || rel.startsWith('../')) {
|
|
369
|
+
failed.push(p)
|
|
370
|
+
continue
|
|
371
|
+
}
|
|
372
|
+
const deleted = await this.fsDelete(repo.root, rel)
|
|
373
|
+
if ('ok' in deleted && deleted.ok) applied.push(p)
|
|
374
|
+
else failed.push(p)
|
|
375
|
+
}
|
|
376
|
+
return { applied, failed }
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** Shared batch plumbing: gate, repo resolve, path filter, run the op. */
|
|
380
|
+
private async batch(
|
|
381
|
+
root: string,
|
|
382
|
+
paths: string[],
|
|
383
|
+
op: (repo: string, inside: string[]) => Promise<boolean>,
|
|
384
|
+
): Promise<GitBatchResult | PanelError> {
|
|
385
|
+
const repo = await this.repo(root)
|
|
386
|
+
if (!repo.ok) return repo.error
|
|
387
|
+
const inside = this.pathsInside(repo.repo, paths)
|
|
388
|
+
const ok = inside.length > 0 ? await op(repo.repo, inside) : true
|
|
389
|
+
if (!ok) return { code: 'git-failed', message: 'git operation failed' }
|
|
390
|
+
// Report the REQUESTED (repo-relative) spellings, not the resolved absolutes.
|
|
391
|
+
const applied = ok ? paths.filter((p) => inside.includes(join(repo.repo, p))) : []
|
|
392
|
+
const failed = paths.filter((p) => !inside.includes(join(repo.repo, p)))
|
|
393
|
+
return { applied, failed }
|
|
394
|
+
}
|
|
395
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Generated by scripts/sync-shared.mjs from shared/host/loopback.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
|
|
2
|
+
/**
|
|
3
|
+
* Loopback trust fence shared by the host route families: socket address,
|
|
4
|
+
* Host header, and browser same-origin markers. Packages receive this file as
|
|
5
|
+
* a generated copy via scripts/sync-shared.mjs; edit the shared source and
|
|
6
|
+
* re-run the sync instead of editing a copy.
|
|
7
|
+
*
|
|
8
|
+
* Semantics: RFC 5735 IPv4 127/8, ::1, IPv4-mapped ::ffff:127/8 (matching the
|
|
9
|
+
* remote-web-ui gate), localhost hostnames, plus the browser same-origin
|
|
10
|
+
* markers (sec-fetch-site and Origin) for the request-level fence.
|
|
11
|
+
* @module dsh-web-ui-shared/host/loopback
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { IncomingMessage } from 'node:http'
|
|
15
|
+
|
|
16
|
+
/** IPv4 127/8 predicate (four decimal octets, first == 127). */
|
|
17
|
+
export function isIPv4Loopback(v4: string): boolean {
|
|
18
|
+
const parts = v4.split('.')
|
|
19
|
+
return parts.length === 4
|
|
20
|
+
&& parts[0] === '127'
|
|
21
|
+
&& parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Whether a socket remote address names the loopback range (127/8, ::1, IPv4-mapped). */
|
|
25
|
+
export function isLoopbackAddress(address: string | undefined): boolean {
|
|
26
|
+
if (address === undefined) return false
|
|
27
|
+
const normalized = address.toLowerCase()
|
|
28
|
+
if (normalized === '::1') return true
|
|
29
|
+
if (normalized.startsWith('::ffff:')) return isIPv4Loopback(normalized.slice('::ffff:'.length))
|
|
30
|
+
return isIPv4Loopback(normalized)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Whether a normalized URL hostname names the loopback authority (localhost, [::1], 127/8). */
|
|
34
|
+
export function isLoopbackHostname(hostname: string): boolean {
|
|
35
|
+
if (hostname === 'localhost' || hostname === '[::1]') return true
|
|
36
|
+
return isIPv4Loopback(hostname)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Request-level trust fence: a loopback socket address AND a loopback Host
|
|
41
|
+
* header, plus browser same-origin markers. The socket address is
|
|
42
|
+
* authoritative; X-Forwarded-For is never trusted.
|
|
43
|
+
*/
|
|
44
|
+
export function isLoopbackRequest(request: IncomingMessage): boolean {
|
|
45
|
+
if (!isLoopbackAddress(request.socket.remoteAddress)) return false
|
|
46
|
+
const host = request.headers.host
|
|
47
|
+
if (typeof host !== 'string') return false
|
|
48
|
+
let hostUrl: URL
|
|
49
|
+
try {
|
|
50
|
+
hostUrl = new URL('http://' + host)
|
|
51
|
+
} catch {
|
|
52
|
+
return false
|
|
53
|
+
}
|
|
54
|
+
if (!isLoopbackHostname(hostUrl.hostname)) return false
|
|
55
|
+
if (request.headers['sec-fetch-site'] === 'cross-site') return false
|
|
56
|
+
const origin = request.headers.origin
|
|
57
|
+
if (origin === undefined) return true
|
|
58
|
+
try {
|
|
59
|
+
return new URL(origin).host === hostUrl.host
|
|
60
|
+
} catch {
|
|
61
|
+
return false
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Generated by scripts/sync-shared.mjs from shared/host/poll-guard.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
|
|
2
|
+
/**
|
|
3
|
+
* Poll-loop guard shared by the plugin family's Host halves (git-graph,
|
|
4
|
+
* filemgr): one bounded refresh loop that never overlaps itself,
|
|
5
|
+
* backs off on consecutive failures, and stops at a global deadline. Pure
|
|
6
|
+
* logic with injectable timers, so consumers test it without wall clocks.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Timer injection seam; defaults to the global setTimeout/clearTimeout pair. */
|
|
10
|
+
export interface PollTimers {
|
|
11
|
+
/** Schedule fn(ms). */
|
|
12
|
+
set: (fn: () => void, ms: number) => unknown
|
|
13
|
+
/** Cancel a scheduled handle. */
|
|
14
|
+
clear: (handle: unknown) => void
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const DEFAULT_TIMERS: PollTimers = {
|
|
18
|
+
set: (fn, ms) => setTimeout(fn, ms),
|
|
19
|
+
clear: (handle) => { clearTimeout(handle as ReturnType<typeof setTimeout>) },
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface PollGuardOptions {
|
|
23
|
+
/** Base interval between runs. */
|
|
24
|
+
intervalMs: number
|
|
25
|
+
/** Runs stop entirely once this much wall time passed since start(). */
|
|
26
|
+
deadlineMs: number
|
|
27
|
+
/** Cap for the failure backoff; each consecutive failure doubles the delay up to this. */
|
|
28
|
+
maxBackoffMs: number
|
|
29
|
+
/** Optional timer seam for tests. */
|
|
30
|
+
timers?: PollTimers
|
|
31
|
+
/** Invoked once when the deadline fires; no run starts afterwards. */
|
|
32
|
+
onDeadline?: () => void
|
|
33
|
+
/** Invoked after a run settles with the consecutive-failure count. */
|
|
34
|
+
onSettled?: (consecutiveFailures: number) => void
|
|
35
|
+
/** The task each tick runs; a rejection counts as a failure. */
|
|
36
|
+
onRun: () => Promise<void>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Owns one bounded poll loop.
|
|
41
|
+
*
|
|
42
|
+
* Guarantees: at most one task runs at a time (a scheduled tick whose turn
|
|
43
|
+
* arrives while a run is in flight is dropped); consecutive failures double
|
|
44
|
+
* the delay up to maxBackoffMs and reset on the first success; the loop
|
|
45
|
+
* stops forever at deadlineMs and cancels its timer.
|
|
46
|
+
*/
|
|
47
|
+
export class PollGuard {
|
|
48
|
+
private readonly options: Required<PollGuardOptions>
|
|
49
|
+
private handle: unknown
|
|
50
|
+
private running = false
|
|
51
|
+
private startedAt = 0
|
|
52
|
+
private stopped = false
|
|
53
|
+
private failures = 0
|
|
54
|
+
|
|
55
|
+
/** @param options - loop bounds; interval/deadline/backoff/onRun are required, the rest optional. */
|
|
56
|
+
constructor(options: PollGuardOptions) {
|
|
57
|
+
this.options = {
|
|
58
|
+
timers: DEFAULT_TIMERS,
|
|
59
|
+
onDeadline: () => {},
|
|
60
|
+
onSettled: () => {},
|
|
61
|
+
...options,
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Start the loop. Safe to call once; later calls are ignored. */
|
|
66
|
+
start(): void {
|
|
67
|
+
if (this.startedAt !== 0) return
|
|
68
|
+
this.startedAt = Date.now()
|
|
69
|
+
this.schedule(this.options.intervalMs)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Stop the loop permanently and drop any pending tick. */
|
|
73
|
+
stop(): void {
|
|
74
|
+
this.stopped = true
|
|
75
|
+
this.options.timers.clear(this.handle)
|
|
76
|
+
this.handle = undefined
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private schedule(delayMs: number): void {
|
|
80
|
+
if (this.stopped) return
|
|
81
|
+
this.handle = this.options.timers.set(() => { void this.tick() }, delayMs)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private delay(): number {
|
|
85
|
+
const backoff = this.options.intervalMs * 2 ** Math.min(this.failures, 8)
|
|
86
|
+
return Math.min(backoff, this.options.maxBackoffMs)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private async tick(): Promise<void> {
|
|
90
|
+
if (this.stopped) return
|
|
91
|
+
if (this.running) return // Anti-overlap: drop ticks that arrive mid-run.
|
|
92
|
+
if (Date.now() - this.startedAt >= this.options.deadlineMs) {
|
|
93
|
+
this.stopped = true
|
|
94
|
+
this.options.onDeadline()
|
|
95
|
+
return
|
|
96
|
+
}
|
|
97
|
+
this.running = true
|
|
98
|
+
try {
|
|
99
|
+
await this.options.onRun()
|
|
100
|
+
this.failures = 0
|
|
101
|
+
} catch {
|
|
102
|
+
this.failures += 1
|
|
103
|
+
} finally {
|
|
104
|
+
this.running = false
|
|
105
|
+
this.options.onSettled(this.failures)
|
|
106
|
+
this.schedule(this.delay())
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|