@thebtf/dsh-cliproxyapi-provider 0.4.3 → 0.4.4

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/src/catalog.js CHANGED
@@ -1,129 +1,129 @@
1
- import { credentialField } from './secrets.js'
2
-
3
- const CANONICAL_REASONING_LEVELS = new Set([
4
- 'off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max',
5
- ])
6
-
7
- function positiveInteger(...values) {
8
- for (const value of values) {
9
- if (typeof value === 'number' && Number.isInteger(value) && value > 0) return value
10
- }
11
- }
12
-
13
- function nonEmptyString(...values) {
14
- for (const value of values) {
15
- if (typeof value === 'string' && value.trim()) return value.trim()
16
- }
17
- }
18
-
19
- function normalizeReasoningLevel(value) {
20
- const wire = nonEmptyString(value)?.toLowerCase()
21
- if (!wire) return undefined
22
- if (wire === 'none' || wire === 'off') return { canonical: 'off', wire: 'none' }
23
- if (wire === 'ultra') return { canonical: 'max', wire }
24
- if (!CANONICAL_REASONING_LEVELS.has(wire)) return undefined
25
- return { canonical: wire, wire }
26
- }
27
-
28
- export function reasoningEffortsOf(entry) {
29
- if (!Array.isArray(entry?.supported_reasoning_levels)) return undefined
30
- const efforts = {}
31
- for (const raw of entry.supported_reasoning_levels) {
32
- const normalized = normalizeReasoningLevel(typeof raw === 'string' ? raw : raw?.effort)
33
- if (normalized && !(normalized.canonical in efforts)) efforts[normalized.canonical] = normalized.wire
34
- }
35
- // llm-pi-ai rejects a capability map that offers only "off". Omitting the
36
- // field correctly describes a non-reasoning model for a hand-declared route.
37
- if (Object.keys(efforts).every((level) => level === 'off')) return undefined
38
- return Object.keys(efforts).length ? efforts : undefined
39
- }
40
-
41
- function inputModalitiesOf(entry, fallback) {
42
- if (!Array.isArray(entry?.input_modalities)) return [...fallback]
43
- const modalities = []
44
- const seen = new Set()
45
- for (const raw of entry.input_modalities) {
46
- const value = nonEmptyString(raw)?.toLowerCase()
47
- if ((value === 'text' || value === 'image') && !seen.has(value)) {
48
- seen.add(value)
49
- modalities.push(value)
50
- }
51
- }
52
- return modalities.length ? modalities : [...fallback]
53
- }
54
- function safeMetadata(value) {
55
- if (Array.isArray(value)) return value.map(safeMetadata)
56
- if (!value || typeof value !== 'object') return value
57
- return Object.fromEntries(Object.entries(value).map(([key, item]) => [
58
- key,
59
- credentialField(key) ? '[redacted]' : safeMetadata(item),
60
- ]))
61
- }
62
-
63
-
64
- export function modelProfileOf(entry, options = {}) {
65
- const id = nonEmptyString(entry?.slug, entry?.id, entry?.model)
66
- if (!id || (!options.includeHiddenModels && entry?.visibility === 'hide')) return undefined
67
- const reasoningEfforts = reasoningEffortsOf(entry)
68
- return {
69
- id,
70
- name: nonEmptyString(entry?.display_name, entry?.name, entry?.description, id),
71
- contextWindow: positiveInteger(entry?.max_context_window, entry?.context_window, entry?.context_length, options.defaultContextWindow),
72
- maxTokens: positiveInteger(entry?.max_output_tokens, entry?.max_completion_tokens, entry?.max_tokens, options.defaultMaxTokens),
73
- input: inputModalitiesOf(entry, options.defaultInput ?? ['text']),
74
- ...(reasoningEfforts ? { reasoningEfforts } : {}),
75
- }
76
- }
77
-
78
- export function readCodexCatalogWithMetadata(body, options = {}) {
79
- if (!body || !Array.isArray(body.models)) throw new TypeError('CLIProxyAPI model catalog has no "models" array')
80
- const models = []
81
- const discovery = []
82
- const seen = new Set()
83
- for (const entry of body.models) {
84
- const model = modelProfileOf(entry, options)
85
- if (!model || seen.has(model.id)) continue
86
- seen.add(model.id)
87
- const override = options.overrides?.[model.id] ?? {}
88
- const overrideContext = positiveInteger(override.contextWindow)
89
- const overrideMax = positiveInteger(override.maxTokens)
90
- const overrideInput = Array.isArray(override.inputModalities) && override.inputModalities.length && override.inputModalities.every((value) => value === 'text' || value === 'image') ? [...new Set(override.inputModalities)] : undefined
91
- const overrideEfforts = override.reasoningEfforts && typeof override.reasoningEfforts === 'object' && !Array.isArray(override.reasoningEfforts) ? Object.fromEntries(Object.entries(override.reasoningEfforts).filter(([level, wire]) => CANONICAL_REASONING_LEVELS.has(level) && (typeof wire === 'string' && wire.length > 0 || level === 'off' && wire === null))) : undefined
92
- if (overrideContext) model.contextWindow = overrideContext
93
- if (typeof override.name === 'string' && override.name) model.name = override.name
94
- if (override.reasoningEfforts === false) delete model.reasoningEfforts
95
- if (override.compat && typeof override.compat === 'object') model.compat = override.compat
96
- if (overrideMax) model.maxTokens = overrideMax
97
- if (overrideInput) model.input = overrideInput
98
- if (overrideEfforts && Object.keys(overrideEfforts).some((level) => level !== 'off')) model.reasoningEfforts = overrideEfforts
99
- const defaultEffort = normalizeReasoningLevel(override.defaultReasoningEffort ?? entry.default_reasoning_level)?.canonical
100
- models.push(model)
101
- discovery.push({
102
- ...model,
103
- inputModalities: [...model.input],
104
- ...(defaultEffort && model.reasoningEfforts?.[defaultEffort] !== undefined ? { defaultReasoningEffort: defaultEffort } : {}),
105
- catalogMetadata: safeMetadata(entry),
106
- catalogSources: {
107
- contextWindow: overrideContext ? 'override' : positiveInteger(entry.max_context_window, entry.context_window, entry.context_length) ? 'cpa' : 'default',
108
- maxTokens: overrideMax ? 'override' : positiveInteger(entry.max_output_tokens, entry.max_completion_tokens, entry.max_tokens) ? 'cpa' : 'default',
109
- inputModalities: overrideInput ? 'override' : Array.isArray(entry.input_modalities) && entry.input_modalities.some((value) => ['text', 'image'].includes(nonEmptyString(value)?.toLowerCase())) ? 'cpa' : 'default',
110
- reasoningEfforts: overrideEfforts && Object.keys(overrideEfforts).some((level) => level !== 'off') ? 'override' : reasoningEffortsOf(entry) ? 'cpa' : 'default',
111
- },
112
- })
113
- }
114
- if (!models.length) throw new TypeError('CLIProxyAPI model catalog contains no usable models')
115
- return { models, discovery }
116
- }
117
-
118
- export function readCodexCatalog(body, options = {}) {
119
- return readCodexCatalogWithMetadata(body, options).models
120
- }
121
-
122
- export function catalogURL(baseURL) {
123
- const base = String(baseURL ?? '').trim()
124
- if (!base) throw new TypeError('CLIProxyAPI baseURL must not be empty')
125
- const url = new URL(base)
126
- url.pathname = url.pathname.replace(/\/+$/, '') + '/models'
127
- url.searchParams.set('client_version', 'dsh-cliproxyapi-provider')
128
- return url.toString()
129
- }
1
+ import { credentialField } from './secrets.js'
2
+
3
+ const CANONICAL_REASONING_LEVELS = new Set([
4
+ 'off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max',
5
+ ])
6
+
7
+ function positiveInteger(...values) {
8
+ for (const value of values) {
9
+ if (typeof value === 'number' && Number.isInteger(value) && value > 0) return value
10
+ }
11
+ }
12
+
13
+ function nonEmptyString(...values) {
14
+ for (const value of values) {
15
+ if (typeof value === 'string' && value.trim()) return value.trim()
16
+ }
17
+ }
18
+
19
+ function normalizeReasoningLevel(value) {
20
+ const wire = nonEmptyString(value)?.toLowerCase()
21
+ if (!wire) return undefined
22
+ if (wire === 'none' || wire === 'off') return { canonical: 'off', wire: 'none' }
23
+ if (wire === 'ultra') return { canonical: 'max', wire }
24
+ if (!CANONICAL_REASONING_LEVELS.has(wire)) return undefined
25
+ return { canonical: wire, wire }
26
+ }
27
+
28
+ export function reasoningEffortsOf(entry) {
29
+ if (!Array.isArray(entry?.supported_reasoning_levels)) return undefined
30
+ const efforts = {}
31
+ for (const raw of entry.supported_reasoning_levels) {
32
+ const normalized = normalizeReasoningLevel(typeof raw === 'string' ? raw : raw?.effort)
33
+ if (normalized && !(normalized.canonical in efforts)) efforts[normalized.canonical] = normalized.wire
34
+ }
35
+ // llm-pi-ai rejects a capability map that offers only "off". Omitting the
36
+ // field correctly describes a non-reasoning model for a hand-declared route.
37
+ if (Object.keys(efforts).every((level) => level === 'off')) return undefined
38
+ return Object.keys(efforts).length ? efforts : undefined
39
+ }
40
+
41
+ function inputModalitiesOf(entry, fallback) {
42
+ if (!Array.isArray(entry?.input_modalities)) return [...fallback]
43
+ const modalities = []
44
+ const seen = new Set()
45
+ for (const raw of entry.input_modalities) {
46
+ const value = nonEmptyString(raw)?.toLowerCase()
47
+ if ((value === 'text' || value === 'image') && !seen.has(value)) {
48
+ seen.add(value)
49
+ modalities.push(value)
50
+ }
51
+ }
52
+ return modalities.length ? modalities : [...fallback]
53
+ }
54
+ function safeMetadata(value) {
55
+ if (Array.isArray(value)) return value.map(safeMetadata)
56
+ if (!value || typeof value !== 'object') return value
57
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [
58
+ key,
59
+ credentialField(key) ? '[redacted]' : safeMetadata(item),
60
+ ]))
61
+ }
62
+
63
+
64
+ export function modelProfileOf(entry, options = {}) {
65
+ const id = nonEmptyString(entry?.slug, entry?.id, entry?.model)
66
+ if (!id || (!options.includeHiddenModels && entry?.visibility === 'hide')) return undefined
67
+ const reasoningEfforts = reasoningEffortsOf(entry)
68
+ return {
69
+ id,
70
+ name: nonEmptyString(entry?.display_name, entry?.name, entry?.description, id),
71
+ contextWindow: positiveInteger(entry?.max_context_window, entry?.context_window, entry?.context_length, options.defaultContextWindow),
72
+ maxTokens: positiveInteger(entry?.max_output_tokens, entry?.max_completion_tokens, entry?.max_tokens, options.defaultMaxTokens),
73
+ input: inputModalitiesOf(entry, options.defaultInput ?? ['text']),
74
+ ...(reasoningEfforts ? { reasoningEfforts } : {}),
75
+ }
76
+ }
77
+
78
+ export function readCodexCatalogWithMetadata(body, options = {}) {
79
+ if (!body || !Array.isArray(body.models)) throw new TypeError('CLIProxyAPI model catalog has no "models" array')
80
+ const models = []
81
+ const discovery = []
82
+ const seen = new Set()
83
+ for (const entry of body.models) {
84
+ const model = modelProfileOf(entry, options)
85
+ if (!model || seen.has(model.id)) continue
86
+ seen.add(model.id)
87
+ const override = options.overrides?.[model.id] ?? {}
88
+ const overrideContext = positiveInteger(override.contextWindow)
89
+ const overrideMax = positiveInteger(override.maxTokens)
90
+ const overrideInput = Array.isArray(override.inputModalities) && override.inputModalities.length && override.inputModalities.every((value) => value === 'text' || value === 'image') ? [...new Set(override.inputModalities)] : undefined
91
+ const overrideEfforts = override.reasoningEfforts && typeof override.reasoningEfforts === 'object' && !Array.isArray(override.reasoningEfforts) ? Object.fromEntries(Object.entries(override.reasoningEfforts).filter(([level, wire]) => CANONICAL_REASONING_LEVELS.has(level) && (typeof wire === 'string' && wire.length > 0 || level === 'off' && wire === null))) : undefined
92
+ if (overrideContext) model.contextWindow = overrideContext
93
+ if (typeof override.name === 'string' && override.name) model.name = override.name
94
+ if (override.reasoningEfforts === false) delete model.reasoningEfforts
95
+ if (override.compat && typeof override.compat === 'object') model.compat = override.compat
96
+ if (overrideMax) model.maxTokens = overrideMax
97
+ if (overrideInput) model.input = overrideInput
98
+ if (overrideEfforts && Object.keys(overrideEfforts).some((level) => level !== 'off')) model.reasoningEfforts = overrideEfforts
99
+ const defaultEffort = normalizeReasoningLevel(override.defaultReasoningEffort ?? entry.default_reasoning_level)?.canonical
100
+ models.push(model)
101
+ discovery.push({
102
+ ...model,
103
+ inputModalities: [...model.input],
104
+ ...(defaultEffort && model.reasoningEfforts?.[defaultEffort] !== undefined ? { defaultReasoningEffort: defaultEffort } : {}),
105
+ catalogMetadata: safeMetadata(entry),
106
+ catalogSources: {
107
+ contextWindow: overrideContext ? 'override' : positiveInteger(entry.max_context_window, entry.context_window, entry.context_length) ? 'cpa' : 'default',
108
+ maxTokens: overrideMax ? 'override' : positiveInteger(entry.max_output_tokens, entry.max_completion_tokens, entry.max_tokens) ? 'cpa' : 'default',
109
+ inputModalities: overrideInput ? 'override' : Array.isArray(entry.input_modalities) && entry.input_modalities.some((value) => ['text', 'image'].includes(nonEmptyString(value)?.toLowerCase())) ? 'cpa' : 'default',
110
+ reasoningEfforts: overrideEfforts && Object.keys(overrideEfforts).some((level) => level !== 'off') ? 'override' : reasoningEffortsOf(entry) ? 'cpa' : 'default',
111
+ },
112
+ })
113
+ }
114
+ if (!models.length) throw new TypeError('CLIProxyAPI model catalog contains no usable models')
115
+ return { models, discovery }
116
+ }
117
+
118
+ export function readCodexCatalog(body, options = {}) {
119
+ return readCodexCatalogWithMetadata(body, options).models
120
+ }
121
+
122
+ export function catalogURL(baseURL) {
123
+ const base = String(baseURL ?? '').trim()
124
+ if (!base) throw new TypeError('CLIProxyAPI baseURL must not be empty')
125
+ const url = new URL(base)
126
+ url.pathname = url.pathname.replace(/\/+$/, '') + '/models'
127
+ url.searchParams.set('client_version', 'dsh-cliproxyapi-provider')
128
+ return url.toString()
129
+ }
@@ -1,98 +1,98 @@
1
- import { createHash, randomUUID } from 'node:crypto'
2
- import { mkdir, open, rename, unlink, writeFile } from 'node:fs/promises'
3
- import { basename, dirname } from 'node:path'
4
- import { dshHomePath } from '@deepseek-ai/dsh-home-paths'
5
- import { ConnectionError, validateEndpoint } from './connection.js'
6
-
7
- // A connection snapshot that only this plugin owns. The settings row lives in
8
- // the profile patch file and can disappear when the host profile is rebuilt, a
9
- // row is denied by a core-compatibility gate, or the user reinstalls the
10
- // bundle. The snapshot is the reconnect source of truth for those cases.
11
- const MAX_SNAPSHOT_BYTES = 256 * 1024
12
- const IDENTITY_TYPES = new Set(['none', 'reference', 'record'])
13
-
14
- function snapshotPath(documentPath) {
15
- // Keyed by the profile, not by the profile path: a rebuilt profile has a new
16
- // patch file path but keeps its name, so the snapshot must survive the rebuild.
17
- const profile = documentPath ? basename(dirname(documentPath)) : 'default'
18
- const name = /^[\w.-]{1,64}$/.test(profile) ? profile : createHash('sha256').update(profile).digest('hex').slice(0, 16)
19
- return dshHomePath('llm-cliproxyapi', name + '.json')
20
- }
21
-
22
- function validIdentity(identity) {
23
- if (!identity || typeof identity !== 'object' || Array.isArray(identity)) return false
24
- if (!IDENTITY_TYPES.has(identity.type)) return false
25
- if (identity.type === 'reference') return typeof identity.ref === 'string' && identity.ref.length > 0
26
- if (identity.type === 'record') return typeof identity.key === 'string' && identity.key.length > 0
27
- return Object.keys(identity).length === 1
28
- }
29
-
30
- const plain = (value) => value !== null && typeof value === 'object' && !Array.isArray(value)
31
-
32
- export function snapshotOf(value) {
33
- if (!value || typeof value !== 'object' || Array.isArray(value)) throw new TypeError('A connection is required')
34
- if (typeof value.baseURL !== 'string' || !value.baseURL) throw new TypeError('A connection endpoint is required')
35
- if (typeof value.generation !== 'string' || !/^[a-f0-9]{64}$/.test(value.generation)) throw new TypeError('A connection generation is required')
36
- if (!validIdentity(value.authIdentity)) throw new TypeError('A connection identity is required')
37
- validateEndpoint(value.baseURL)
38
- if (value.policy !== undefined && !plain(value.policy)) throw new TypeError('A connection policy must be an object')
39
- if (value.overrides !== undefined && !plain(value.overrides)) throw new TypeError('Connection overrides must be an object')
40
- if (value.modelSelection !== undefined && (!plain(value.modelSelection) || Object.values(value.modelSelection).some((selected) => typeof selected !== 'boolean'))) throw new TypeError('A model selection must contain booleans')
41
- return {
42
- schemaVersion: 1,
43
- baseURL: value.baseURL,
44
- authIdentity: structuredClone(value.authIdentity),
45
- enabled: value.enabled !== false,
46
- policy: structuredClone(value.policy ?? {}),
47
- modelSelection: structuredClone(value.modelSelection ?? {}),
48
- overrides: structuredClone(value.overrides ?? {}),
49
- generation: value.generation,
50
- ...(typeof value.operationId === 'string' && value.operationId ? { operationId: value.operationId } : {}),
51
- }
52
- }
53
-
54
- export async function writeConnectionSnapshot(documentPath, value) {
55
- const snapshot = { ...snapshotOf(value), savedAt: Date.now() }
56
- const contents = JSON.stringify(snapshot)
57
- if (Buffer.byteLength(contents) > MAX_SNAPSHOT_BYTES) throw new RangeError('Connection snapshot exceeds 256 KiB')
58
- const file = snapshotPath(documentPath)
59
- await mkdir(dirname(file), { recursive: true })
60
- const temporary = `${file}.${randomUUID()}.tmp`
61
- try {
62
- await writeFile(temporary, contents, { encoding: 'utf8', flag: 'wx', mode: 0o600 })
63
- await rename(temporary, file)
64
- } catch (error) {
65
- await unlink(temporary).catch(() => { })
66
- throw error
67
- }
68
- return snapshot
69
- }
70
-
71
- export async function readConnectionSnapshot(documentPath) {
72
- let data
73
- try {
74
- const handle = await open(snapshotPath(documentPath), 'r')
75
- try {
76
- const buffer = Buffer.alloc(MAX_SNAPSHOT_BYTES + 1)
77
- const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0)
78
- if (bytesRead > MAX_SNAPSHOT_BYTES) return undefined
79
- data = buffer.subarray(0, bytesRead)
80
- } finally {
81
- await handle.close()
82
- }
83
- } catch (error) {
84
- if (error?.code === 'ENOENT') return undefined
85
- throw error
86
- }
87
- let value
88
- try { value = JSON.parse(data.toString('utf8')) } catch { return undefined }
89
- if (!value || value.schemaVersion !== 1 || !Number.isFinite(value.savedAt)) return undefined
90
- try { return snapshotOf(value) } catch { return undefined }
91
- }
92
-
93
- export async function removeConnectionSnapshot(documentPath) {
94
- try { await unlink(snapshotPath(documentPath)); return true } catch (error) {
95
- if (error?.code === 'ENOENT') return false
96
- throw new ConnectionError('SNAPSHOT_UNAVAILABLE', 'Connection snapshot could not be removed')
97
- }
98
- }
1
+ import { createHash, randomUUID } from 'node:crypto'
2
+ import { mkdir, open, rename, unlink, writeFile } from 'node:fs/promises'
3
+ import { basename, dirname } from 'node:path'
4
+ import { dshHomePath } from '@deepseek-ai/dsh-home-paths'
5
+ import { ConnectionError, validateEndpoint } from './connection.js'
6
+
7
+ // A connection snapshot that only this plugin owns. The settings row lives in
8
+ // the profile patch file and can disappear when the host profile is rebuilt, a
9
+ // row is denied by a core-compatibility gate, or the user reinstalls the
10
+ // bundle. The snapshot is the reconnect source of truth for those cases.
11
+ const MAX_SNAPSHOT_BYTES = 256 * 1024
12
+ const IDENTITY_TYPES = new Set(['none', 'reference', 'record'])
13
+
14
+ function snapshotPath(documentPath) {
15
+ // Keyed by the profile, not by the profile path: a rebuilt profile has a new
16
+ // patch file path but keeps its name, so the snapshot must survive the rebuild.
17
+ const profile = documentPath ? basename(dirname(documentPath)) : 'default'
18
+ const name = /^[\w.-]{1,64}$/.test(profile) ? profile : createHash('sha256').update(profile).digest('hex').slice(0, 16)
19
+ return dshHomePath('llm-cliproxyapi', name + '.json')
20
+ }
21
+
22
+ function validIdentity(identity) {
23
+ if (!identity || typeof identity !== 'object' || Array.isArray(identity)) return false
24
+ if (!IDENTITY_TYPES.has(identity.type)) return false
25
+ if (identity.type === 'reference') return typeof identity.ref === 'string' && identity.ref.length > 0
26
+ if (identity.type === 'record') return typeof identity.key === 'string' && identity.key.length > 0
27
+ return Object.keys(identity).length === 1
28
+ }
29
+
30
+ const plain = (value) => value !== null && typeof value === 'object' && !Array.isArray(value)
31
+
32
+ export function snapshotOf(value) {
33
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new TypeError('A connection is required')
34
+ if (typeof value.baseURL !== 'string' || !value.baseURL) throw new TypeError('A connection endpoint is required')
35
+ if (typeof value.generation !== 'string' || !/^[a-f0-9]{64}$/.test(value.generation)) throw new TypeError('A connection generation is required')
36
+ if (!validIdentity(value.authIdentity)) throw new TypeError('A connection identity is required')
37
+ validateEndpoint(value.baseURL)
38
+ if (value.policy !== undefined && !plain(value.policy)) throw new TypeError('A connection policy must be an object')
39
+ if (value.overrides !== undefined && !plain(value.overrides)) throw new TypeError('Connection overrides must be an object')
40
+ if (value.modelSelection !== undefined && (!plain(value.modelSelection) || Object.values(value.modelSelection).some((selected) => typeof selected !== 'boolean'))) throw new TypeError('A model selection must contain booleans')
41
+ return {
42
+ schemaVersion: 1,
43
+ baseURL: value.baseURL,
44
+ authIdentity: structuredClone(value.authIdentity),
45
+ enabled: value.enabled !== false,
46
+ policy: structuredClone(value.policy ?? {}),
47
+ modelSelection: structuredClone(value.modelSelection ?? {}),
48
+ overrides: structuredClone(value.overrides ?? {}),
49
+ generation: value.generation,
50
+ ...(typeof value.operationId === 'string' && value.operationId ? { operationId: value.operationId } : {}),
51
+ }
52
+ }
53
+
54
+ export async function writeConnectionSnapshot(documentPath, value) {
55
+ const snapshot = { ...snapshotOf(value), savedAt: Date.now() }
56
+ const contents = JSON.stringify(snapshot)
57
+ if (Buffer.byteLength(contents) > MAX_SNAPSHOT_BYTES) throw new RangeError('Connection snapshot exceeds 256 KiB')
58
+ const file = snapshotPath(documentPath)
59
+ await mkdir(dirname(file), { recursive: true })
60
+ const temporary = `${file}.${randomUUID()}.tmp`
61
+ try {
62
+ await writeFile(temporary, contents, { encoding: 'utf8', flag: 'wx', mode: 0o600 })
63
+ await rename(temporary, file)
64
+ } catch (error) {
65
+ await unlink(temporary).catch(() => { })
66
+ throw error
67
+ }
68
+ return snapshot
69
+ }
70
+
71
+ export async function readConnectionSnapshot(documentPath) {
72
+ let data
73
+ try {
74
+ const handle = await open(snapshotPath(documentPath), 'r')
75
+ try {
76
+ const buffer = Buffer.alloc(MAX_SNAPSHOT_BYTES + 1)
77
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0)
78
+ if (bytesRead > MAX_SNAPSHOT_BYTES) return undefined
79
+ data = buffer.subarray(0, bytesRead)
80
+ } finally {
81
+ await handle.close()
82
+ }
83
+ } catch (error) {
84
+ if (error?.code === 'ENOENT') return undefined
85
+ throw error
86
+ }
87
+ let value
88
+ try { value = JSON.parse(data.toString('utf8')) } catch { return undefined }
89
+ if (!value || value.schemaVersion !== 1 || !Number.isFinite(value.savedAt)) return undefined
90
+ try { return snapshotOf(value) } catch { return undefined }
91
+ }
92
+
93
+ export async function removeConnectionSnapshot(documentPath) {
94
+ try { await unlink(snapshotPath(documentPath)); return true } catch (error) {
95
+ if (error?.code === 'ENOENT') return false
96
+ throw new ConnectionError('SNAPSHOT_UNAVAILABLE', 'Connection snapshot could not be removed')
97
+ }
98
+ }