dsh-lcx-codex 0.3.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.
@@ -0,0 +1,383 @@
1
+ import { outputDomains, outputLineRange, parseWebRunOutput } from './web-run-output.js'
2
+
3
+ export const HOSTED_SEARCH_PARAMETERS = {
4
+ type: 'object',
5
+ properties: {
6
+ query: {
7
+ type: 'string',
8
+ description: 'Search the web for the requested topic. Include date or freshness requirements in the query when needed.',
9
+ },
10
+ searchContextSize: { type: 'string', enum: ['low', 'medium', 'high'] },
11
+ allowedDomains: { type: 'array', items: { type: 'string' } },
12
+ blockedDomains: { type: 'array', items: { type: 'string' } },
13
+ userLocation: {
14
+ type: 'object',
15
+ properties: {
16
+ country: { type: 'string' },
17
+ city: { type: 'string' },
18
+ region: { type: 'string' },
19
+ timezone: { type: 'string' },
20
+ },
21
+ additionalProperties: false,
22
+ },
23
+ externalWebAccess: { type: 'boolean' },
24
+ returnTokenBudget: { type: 'string', enum: ['default', 'unlimited'] },
25
+ searchContentTypes: {
26
+ type: 'array',
27
+ items: { type: 'string', enum: ['text', 'image'] },
28
+ },
29
+ imageSettings: {
30
+ type: 'object',
31
+ properties: {
32
+ maxResults: { type: 'integer' },
33
+ caption: { type: 'boolean' },
34
+ },
35
+ additionalProperties: false,
36
+ },
37
+ },
38
+ required: ['query'],
39
+ additionalProperties: false,
40
+ }
41
+
42
+ export const HOSTED_SEARCH_OUTPUT = {
43
+ type: 'object',
44
+ properties: {
45
+ mode: { type: 'string', enum: ['hosted'] },
46
+ action: { type: 'string' },
47
+ emulation: { type: 'string', enum: ['native'] },
48
+ content: { type: 'string' },
49
+ sources: { type: 'array', items: { type: 'object' } },
50
+ citations: { type: 'array', items: { type: 'object' } },
51
+ images: { type: 'array', items: { type: 'object' } },
52
+ warnings: { type: 'array', items: { type: 'string' } },
53
+ outputBlocks: { type: 'array', items: { type: 'object' } },
54
+ domains: { type: 'array', items: { type: 'string' } },
55
+ lineRange: { type: 'object' },
56
+ requestId: { type: 'string' },
57
+ responseId: { type: 'string' },
58
+ retrievedAt: { type: 'string' },
59
+ truncated: { type: 'boolean' },
60
+ },
61
+ required: ['mode', 'action', 'emulation', 'content', 'sources', 'citations', 'images', 'warnings', 'requestId', 'retrievedAt', 'truncated'],
62
+ additionalProperties: false,
63
+ }
64
+
65
+ function failure(message, code) {
66
+ const error = new Error(message)
67
+ error.code = code
68
+ return error
69
+ }
70
+
71
+ export function normalizeHostedSearchArgs(args) {
72
+ if (args === null || typeof args !== 'object' || Array.isArray(args)) throw failure('websearch_gpt arguments must be an object', 'WEB_INVALID_REQUEST')
73
+ if (typeof args.query !== 'string' || args.query.trim().length === 0) throw failure('websearch_gpt.query must be a non-empty string', 'WEB_INVALID_REQUEST')
74
+ const known = new Set([
75
+ 'query',
76
+ 'searchContextSize',
77
+ 'allowedDomains',
78
+ 'blockedDomains',
79
+ 'userLocation',
80
+ 'externalWebAccess',
81
+ 'returnTokenBudget',
82
+ 'searchContentTypes',
83
+ 'imageSettings',
84
+ ])
85
+ if (Object.keys(args).some((key) => !known.has(key))) throw failure('websearch_gpt received an unknown field', 'WEB_INVALID_REQUEST')
86
+ const normalized = { query: args.query.trim() }
87
+ if (normalized.query.length > 16_000) throw failure('websearch_gpt.query is too long', 'WEB_INVALID_REQUEST')
88
+ if (args.searchContextSize !== undefined) {
89
+ if (!['low', 'medium', 'high'].includes(args.searchContextSize)) throw failure('websearch_gpt.searchContextSize is invalid', 'WEB_INVALID_REQUEST')
90
+ normalized.searchContextSize = args.searchContextSize
91
+ }
92
+ const allowedDomains = normalizeDomains(args.allowedDomains, 'allowedDomains')
93
+ const blockedDomains = normalizeDomains(args.blockedDomains, 'blockedDomains')
94
+ if (allowedDomains) normalized.allowedDomains = allowedDomains
95
+ if (blockedDomains) normalized.blockedDomains = blockedDomains
96
+ if (allowedDomains && blockedDomains) {
97
+ const blocked = new Set(blockedDomains)
98
+ if (allowedDomains.some((domain) => blocked.has(domain))) throw failure('websearch_gpt domain filters conflict', 'WEB_INVALID_REQUEST')
99
+ }
100
+ if (args.userLocation !== undefined) normalized.userLocation = normalizeUserLocation(args.userLocation)
101
+ if (args.externalWebAccess !== undefined) {
102
+ if (typeof args.externalWebAccess !== 'boolean') throw failure('websearch_gpt.externalWebAccess must be boolean', 'WEB_INVALID_REQUEST')
103
+ normalized.externalWebAccess = args.externalWebAccess
104
+ }
105
+ if (args.returnTokenBudget !== undefined) {
106
+ if (!['default', 'unlimited'].includes(args.returnTokenBudget)) throw failure('websearch_gpt.returnTokenBudget is invalid', 'WEB_INVALID_REQUEST')
107
+ normalized.returnTokenBudget = args.returnTokenBudget
108
+ }
109
+ if (args.searchContentTypes !== undefined) normalized.searchContentTypes = normalizeContentTypes(args.searchContentTypes)
110
+ if (args.imageSettings !== undefined) {
111
+ if (!normalized.searchContentTypes?.includes('image')) throw failure('websearch_gpt.imageSettings requires image search content', 'WEB_INVALID_REQUEST')
112
+ normalized.imageSettings = normalizeImageSettings(args.imageSettings)
113
+ }
114
+ return normalized
115
+ }
116
+
117
+ function normalizeDomains(value, field) {
118
+ if (value === undefined) return undefined
119
+ if (!Array.isArray(value) || value.length === 0 || value.length > 100) throw failure(`websearch_gpt.${field} must contain 1 to 100 domains`, 'WEB_INVALID_REQUEST')
120
+ const domains = value.map((item) => {
121
+ if (typeof item !== 'string') throw failure(`websearch_gpt.${field} contains an invalid domain`, 'WEB_INVALID_REQUEST')
122
+ const domain = item.trim().toLowerCase()
123
+ if (domain.length === 0 || domain.length > 253 || domain.includes('/') || domain.includes(':') || domain.endsWith('.')) {
124
+ throw failure(`websearch_gpt.${field} contains an invalid domain`, 'WEB_INVALID_REQUEST')
125
+ }
126
+ const labels = domain.split('.')
127
+ if (labels.length < 2 || labels.some((label) => !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/u.test(label))) {
128
+ throw failure(`websearch_gpt.${field} contains an invalid domain`, 'WEB_INVALID_REQUEST')
129
+ }
130
+ return domain
131
+ })
132
+ if (new Set(domains).size !== domains.length) throw failure(`websearch_gpt.${field} contains duplicate domains`, 'WEB_INVALID_REQUEST')
133
+ return domains
134
+ }
135
+
136
+ function normalizeUserLocation(value) {
137
+ if (!isRecord(value)) throw failure('websearch_gpt.userLocation must be an object', 'WEB_INVALID_REQUEST')
138
+ const known = new Set(['country', 'city', 'region', 'timezone'])
139
+ if (Object.keys(value).some((key) => !known.has(key))) throw failure('websearch_gpt.userLocation contains an unknown field', 'WEB_INVALID_REQUEST')
140
+ const result = {}
141
+ if (value.country !== undefined) {
142
+ if (typeof value.country !== 'string' || !/^[a-z]{2}$/iu.test(value.country.trim())) throw failure('websearch_gpt.userLocation.country must be an ISO alpha-2 code', 'WEB_INVALID_REQUEST')
143
+ result.country = value.country.trim().toUpperCase()
144
+ }
145
+ for (const field of ['city', 'region']) {
146
+ if (value[field] === undefined) continue
147
+ if (typeof value[field] !== 'string' || value[field].trim().length === 0 || value[field].trim().length > 200) throw failure(`websearch_gpt.userLocation.${field} is invalid`, 'WEB_INVALID_REQUEST')
148
+ result[field] = value[field].trim()
149
+ }
150
+ if (value.timezone !== undefined) {
151
+ if (typeof value.timezone !== 'string' || value.timezone.trim().length === 0) throw failure('websearch_gpt.userLocation.timezone is invalid', 'WEB_INVALID_REQUEST')
152
+ const timezone = value.timezone.trim()
153
+ try {
154
+ new Intl.DateTimeFormat('en-US', { timeZone: timezone }).format()
155
+ } catch {
156
+ throw failure('websearch_gpt.userLocation.timezone must be an IANA timezone', 'WEB_INVALID_REQUEST')
157
+ }
158
+ result.timezone = timezone
159
+ }
160
+ if (Object.keys(result).length === 0) throw failure('websearch_gpt.userLocation must contain a location field', 'WEB_INVALID_REQUEST')
161
+ return result
162
+ }
163
+
164
+ function normalizeContentTypes(value) {
165
+ if (!Array.isArray(value) || value.length === 0 || value.length > 2 || value.some((item) => !['text', 'image'].includes(item))) {
166
+ throw failure('websearch_gpt.searchContentTypes is invalid', 'WEB_INVALID_REQUEST')
167
+ }
168
+ if (new Set(value).size !== value.length) throw failure('websearch_gpt.searchContentTypes contains duplicates', 'WEB_INVALID_REQUEST')
169
+ return [...value]
170
+ }
171
+
172
+ function normalizeImageSettings(value) {
173
+ if (!isRecord(value) || Object.keys(value).some((key) => !['maxResults', 'caption'].includes(key))) throw failure('websearch_gpt.imageSettings is invalid', 'WEB_INVALID_REQUEST')
174
+ const result = {}
175
+ if (value.maxResults !== undefined) {
176
+ if (!Number.isInteger(value.maxResults) || value.maxResults < 1 || value.maxResults > 100) throw failure('websearch_gpt.imageSettings.maxResults is invalid', 'WEB_INVALID_REQUEST')
177
+ result.maxResults = value.maxResults
178
+ }
179
+ if (value.caption !== undefined) {
180
+ if (typeof value.caption !== 'boolean') throw failure('websearch_gpt.imageSettings.caption must be boolean', 'WEB_INVALID_REQUEST')
181
+ result.caption = value.caption
182
+ }
183
+ if (Object.keys(result).length === 0) throw failure('websearch_gpt.imageSettings must contain a setting', 'WEB_INVALID_REQUEST')
184
+ return result
185
+ }
186
+
187
+ export function buildHostedSearchBody(args, model) {
188
+ const tool = {
189
+ type: 'web_search',
190
+ ...(args.searchContextSize ? { search_context_size: args.searchContextSize } : {}),
191
+ ...(args.allowedDomains || args.blockedDomains ? {
192
+ filters: {
193
+ ...(args.allowedDomains ? { allowed_domains: args.allowedDomains } : {}),
194
+ ...(args.blockedDomains ? { blocked_domains: args.blockedDomains } : {}),
195
+ },
196
+ } : {}),
197
+ ...(args.userLocation ? { user_location: { type: 'approximate', ...args.userLocation } } : {}),
198
+ ...(args.externalWebAccess !== undefined ? { external_web_access: args.externalWebAccess } : {}),
199
+ ...(args.returnTokenBudget ? { return_token_budget: args.returnTokenBudget } : {}),
200
+ ...(args.searchContentTypes ? { search_content_types: args.searchContentTypes } : {}),
201
+ ...(args.imageSettings ? {
202
+ image_settings: {
203
+ ...(args.imageSettings.maxResults !== undefined ? { max_results: args.imageSettings.maxResults } : {}),
204
+ ...(args.imageSettings.caption !== undefined ? { caption: args.imageSettings.caption } : {}),
205
+ },
206
+ } : {}),
207
+ }
208
+ return {
209
+ model,
210
+ input: [{ role: 'user', content: [{ type: 'input_text', text: args.query }] }],
211
+ tools: [tool],
212
+ tool_choice: 'required',
213
+ include: [
214
+ 'web_search_call.action.sources',
215
+ ...(args.searchContentTypes?.includes('image') ? ['web_search_call.results'] : []),
216
+ ],
217
+ stream: false,
218
+ store: false,
219
+ }
220
+ }
221
+
222
+ function isRecord(value) {
223
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
224
+ }
225
+
226
+ function textFrom(value, seen = new Set()) {
227
+ if (typeof value === 'string') return value
228
+ if (!value || typeof value !== 'object' || seen.has(value)) return ''
229
+ seen.add(value)
230
+ if (Array.isArray(value)) return value.map((item) => textFrom(item, seen)).filter(Boolean).join('\n')
231
+ if (typeof value.text === 'string' && ['output_text', 'text', 'input_text'].includes(value.type)) return value.text
232
+ if (Array.isArray(value.content)) return textFrom(value.content, seen)
233
+ return ''
234
+ }
235
+
236
+ function sourceFrom(value) {
237
+ if (!isRecord(value) || typeof value.url !== 'string' || value.url.trim().length === 0) return undefined
238
+ return {
239
+ url: value.url,
240
+ ...(typeof value.title === 'string' && value.title ? { title: value.title } : {}),
241
+ ...(typeof value.snippet === 'string' && value.snippet ? { snippet: value.snippet } : {}),
242
+ ...(typeof value.publishedAt === 'string' && value.publishedAt ? { publishedAt: value.publishedAt } : {}),
243
+ ...(typeof value.published_at === 'string' && value.published_at ? { publishedAt: value.published_at } : {}),
244
+ ...(typeof value.ref_id === 'string' && value.ref_id ? { refId: value.ref_id } : {}),
245
+ ...(typeof value.domain === 'string' && value.domain ? { domain: value.domain } : {}),
246
+ }
247
+ }
248
+
249
+ function responseArtifacts(response) {
250
+ const sources = []
251
+ const citations = []
252
+ const images = []
253
+ const actions = []
254
+ for (const item of Array.isArray(response?.output) ? response.output : []) {
255
+ if (item?.type === 'web_search_call') {
256
+ actions.push(typeof item.action?.type === 'string' ? item.action.type : 'search')
257
+ if (Array.isArray(item.action?.sources)) {
258
+ for (const value of item.action.sources) {
259
+ const source = sourceFrom(value)
260
+ if (source) sources.push(source)
261
+ }
262
+ }
263
+ for (const value of Array.isArray(item.results) ? item.results : []) {
264
+ const image = imageFrom(value)
265
+ if (image) images.push(image)
266
+ }
267
+ }
268
+ if (item?.type !== 'message') continue
269
+ for (const part of Array.isArray(item.content) ? item.content : []) {
270
+ if (part?.type !== 'output_text') continue
271
+ for (const annotation of Array.isArray(part.annotations) ? part.annotations : []) {
272
+ if (annotation?.type !== 'url_citation') continue
273
+ const source = sourceFrom(annotation)
274
+ if (source) {
275
+ citations.push(source)
276
+ sources.push(source)
277
+ }
278
+ }
279
+ }
280
+ }
281
+ return { sources, citations, images, actions }
282
+ }
283
+
284
+ function httpUrl(value) {
285
+ try {
286
+ const url = new URL(value)
287
+ if (!['http:', 'https:'].includes(url.protocol)) return undefined
288
+ return url
289
+ } catch {
290
+ return undefined
291
+ }
292
+ }
293
+
294
+ function imageFrom(value) {
295
+ if (!isRecord(value) || value.type !== 'image_result') return undefined
296
+ const imageUrl = httpUrl(value.image_url)?.toString()
297
+ if (!imageUrl) return undefined
298
+ const thumbnailUrl = httpUrl(value.thumbnail_url)?.toString()
299
+ const sourceWebsiteUrl = httpUrl(value.source_website_url)?.toString()
300
+ return {
301
+ imageUrl,
302
+ ...(thumbnailUrl ? { thumbnailUrl } : {}),
303
+ ...(sourceWebsiteUrl ? { sourceWebsiteUrl } : {}),
304
+ ...(typeof value.caption === 'string' && value.caption ? { caption: value.caption } : {}),
305
+ }
306
+ }
307
+
308
+ function canonicalUrl(value) {
309
+ const url = httpUrl(value)
310
+ if (url) {
311
+ url.hash = ''
312
+ for (const key of [...url.searchParams.keys()]) if (/^(utm_|gclid$|fbclid$)/iu.test(key)) url.searchParams.delete(key)
313
+ return url.toString()
314
+ }
315
+ return undefined
316
+ }
317
+
318
+ function uniqueByUrl(values) {
319
+ const result = []
320
+ const seen = new Set()
321
+ for (const value of values) {
322
+ const key = canonicalUrl(value.url)
323
+ if (!key || seen.has(key)) continue
324
+ seen.add(key)
325
+ result.push(value)
326
+ }
327
+ return result
328
+ }
329
+
330
+ export function parseHostedSearchResponse(response, requestId, maxResults = 8, retrievedAt = new Date().toISOString()) {
331
+ if (response?.error) {
332
+ const error = failure(response.error.message ?? 'LCX hosted Web Search failed', 'LCX_WEB_PROVIDER_ERROR')
333
+ throw error
334
+ }
335
+ if (response?.status !== 'completed') {
336
+ throw failure(`LCX hosted Web Search returned response status: ${String(response?.status ?? 'missing')}`, 'WEB_RESPONSE_INCOMPLETE')
337
+ }
338
+ const output = typeof response?.output_text === 'string' ? response.output_text : textFrom(response?.output ?? response?.content)
339
+ const outputBlocks = parseWebRunOutput(output)
340
+ const artifacts = responseArtifacts(response)
341
+ if (artifacts.actions.length === 0) throw failure('LCX hosted Web Search completed without executing web_search', 'WEB_SEARCH_NOT_EXECUTED')
342
+ const allSources = [...artifacts.sources]
343
+ for (const block of outputBlocks) {
344
+ if (block.url) allSources.push({ url: block.url, ...(block.title ? { title: block.title } : {}) })
345
+ }
346
+ const sources = uniqueByUrl(allSources)
347
+ const citations = uniqueByUrl(artifacts.citations)
348
+ const limited = sources.slice(0, Math.max(1, maxResults))
349
+ const images = artifacts.images.slice(0, Math.max(1, maxResults))
350
+ if (!output && limited.length === 0 && images.length === 0) throw failure('LCX hosted Web Search returned no text, sources, or images', 'WEB_NO_SOURCES')
351
+ return {
352
+ mode: 'hosted',
353
+ action: artifacts.actions[0],
354
+ emulation: 'native',
355
+ content: output,
356
+ sources: limited,
357
+ citations,
358
+ images,
359
+ warnings: artifacts.actions.length > 1 ? [`Multiple hosted search actions were returned: ${artifacts.actions.join(', ')}`] : [],
360
+ outputBlocks,
361
+ domains: outputDomains(outputBlocks),
362
+ ...(outputLineRange(outputBlocks) ? { lineRange: outputLineRange(outputBlocks) } : {}),
363
+ requestId,
364
+ ...(typeof response?.id === 'string' && response.id ? { responseId: response.id } : {}),
365
+ retrievedAt,
366
+ truncated: sources.length > limited.length || artifacts.images.length > images.length,
367
+ }
368
+ }
369
+
370
+ export function renderHostedSearchResult(value) {
371
+ const parts = []
372
+ if (value.content) parts.push(value.content)
373
+ if (value.sources?.length) {
374
+ parts.push(`来源:\n${value.sources.map((source) => `- [${source.title ?? source.url}](${source.url})${source.publishedAt ? `(${source.publishedAt})` : ''}${source.snippet ? ` — ${source.snippet}` : ''}`).join('\n')}`)
375
+ }
376
+ if (value.images?.length) {
377
+ parts.push(`图片:\n${value.images.map((image) => `- [${image.caption ?? image.imageUrl}](${image.imageUrl})${image.sourceWebsiteUrl ? `([来源页面](${image.sourceWebsiteUrl}))` : ''}`).join('\n')}`)
378
+ }
379
+ if (value.warnings?.length) parts.push(value.warnings.map((warning) => `警告:${warning}`).join('\n'))
380
+ if (value.retrievedAt) parts.push(`检索时间:${value.retrievedAt}`)
381
+ if (value.truncated) parts.push('来源已截断;如需更多结果,请缩小查询范围。')
382
+ return [{ type: 'text', text: parts.join('\n\n') || '没有找到结果。' }]
383
+ }
@@ -0,0 +1,70 @@
1
+ import { AtomicWebSearchStore } from './web-search-store.js'
2
+
3
+ const VERSION = 1
4
+
5
+ function isRecord(value) {
6
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
7
+ }
8
+
9
+ function validHttpUrl(value) {
10
+ if (value === undefined) return true
11
+ try {
12
+ return ['http:', 'https:'].includes(new URL(value).protocol)
13
+ } catch {
14
+ return false
15
+ }
16
+ }
17
+
18
+ function validData(data) {
19
+ if (data?.version !== VERSION || !isRecord(data.sessions)) return false
20
+ return Object.entries(data.sessions).every(([sessionId, session]) =>
21
+ sessionId.length > 0 && isRecord(session) && typeof session.routeFingerprint === 'string' && session.routeFingerprint.length > 0 &&
22
+ typeof session.updatedAt === 'string' && !Number.isNaN(Date.parse(session.updatedAt)) && isRecord(session.refs) &&
23
+ Object.entries(session.refs).every(([refId, ref]) => refId.length > 0 && isRecord(ref) && ref.refId === refId && validHttpUrl(ref.url)))
24
+ }
25
+
26
+ function unavailable(refId) {
27
+ const error = new Error(`Alpha reference is unavailable in this session and route: ${String(refId)}`)
28
+ error.code = 'LCX_ALPHA_REF_UNAVAILABLE'
29
+ return error
30
+ }
31
+
32
+ export class AlphaRefStore {
33
+ constructor(file) {
34
+ this.store = new AtomicWebSearchStore(file, {
35
+ empty: () => ({ version: VERSION, sessions: {} }),
36
+ validate: validData,
37
+ corruptCode: 'LCX_ALPHA_REF_STORE_CORRUPT',
38
+ writeCode: 'LCX_ALPHA_REF_STORE_WRITE_FAILED',
39
+ })
40
+ }
41
+
42
+ record(sessionId, routeFingerprint, refs) {
43
+ if (typeof sessionId !== 'string' || sessionId.length === 0 || typeof routeFingerprint !== 'string' || routeFingerprint.length === 0 || !Array.isArray(refs)) {
44
+ throw unavailable('invalid-record')
45
+ }
46
+ this.store.update((current) => {
47
+ const previous = current.sessions[sessionId]
48
+ const previousRefs = previous?.routeFingerprint === routeFingerprint ? previous.refs : {}
49
+ const nextRefs = { ...previousRefs }
50
+ for (const value of refs) {
51
+ if (!isRecord(value) || typeof value.refId !== 'string' || value.refId.length === 0 || !validHttpUrl(value.url)) continue
52
+ nextRefs[value.refId] = { refId: value.refId, ...(value.url ? { url: value.url } : {}) }
53
+ }
54
+ const sessions = {
55
+ ...current.sessions,
56
+ [sessionId]: { routeFingerprint, refs: nextRefs, updatedAt: new Date().toISOString() },
57
+ }
58
+ const ordered = Object.entries(sessions).sort((left, right) => Date.parse(right[1].updatedAt) - Date.parse(left[1].updatedAt)).slice(0, 256)
59
+ return { version: VERSION, sessions: Object.fromEntries(ordered) }
60
+ })
61
+ }
62
+
63
+ assertUsable(sessionId, routeFingerprint, refId) {
64
+ this.store.refresh()
65
+ const session = this.store.data.sessions[sessionId]
66
+ const ref = session?.routeFingerprint === routeFingerprint ? session.refs?.[refId] : undefined
67
+ if (!ref) throw unavailable(refId)
68
+ return structuredClone(ref)
69
+ }
70
+ }
@@ -0,0 +1,181 @@
1
+ import { closeSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
2
+ import { basename, dirname, join } from 'node:path'
3
+ import { randomUUID } from 'node:crypto'
4
+ import { ensurePrivateFileAcl } from './private-file.js'
5
+
6
+ const LOCK_STALE_MS = 30 * 1000
7
+ const LOCK_WAIT_MS = 5 * 1000
8
+
9
+ function sleepSync(milliseconds) {
10
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds)
11
+ }
12
+
13
+ function processAlive(pid) {
14
+ if (!Number.isSafeInteger(pid) || pid <= 0) return false
15
+ try {
16
+ process.kill(pid, 0)
17
+ return true
18
+ } catch (error) {
19
+ return error?.code === 'EPERM'
20
+ }
21
+ }
22
+
23
+ function staleLockSnapshot(file) {
24
+ try {
25
+ const info = statSync(file)
26
+ const text = readFileSync(file, 'utf8')
27
+ const [pidText, startedAtText] = text.split(/\r?\n/u)
28
+ const pid = Number(pidText)
29
+ const startedAt = Number(startedAtText)
30
+ const age = Date.now() - Math.max(info.mtimeMs, Number.isFinite(startedAt) ? startedAt : 0)
31
+ if (age <= LOCK_STALE_MS || processAlive(pid)) return undefined
32
+ return { text, mtimeMs: info.mtimeMs, size: info.size }
33
+ } catch {
34
+ return undefined
35
+ }
36
+ }
37
+
38
+ function unlinkLockIfUnchanged(file, snapshot) {
39
+ if (!snapshot) return false
40
+ try {
41
+ const info = statSync(file)
42
+ if (info.mtimeMs !== snapshot.mtimeMs || info.size !== snapshot.size || readFileSync(file, 'utf8') !== snapshot.text) return false
43
+ unlinkSync(file)
44
+ return true
45
+ } catch {
46
+ return false
47
+ }
48
+ }
49
+
50
+ function signature(file) {
51
+ try {
52
+ const value = statSync(file)
53
+ return `${value.mtimeMs}:${value.ctimeMs}:${value.size}`
54
+ } catch (error) {
55
+ if (error?.code === 'ENOENT') return undefined
56
+ throw error
57
+ }
58
+ }
59
+
60
+ export class AtomicWebSearchStore {
61
+ constructor(file, { empty, validate, corruptCode, writeCode, maxBytes = 2 * 1024 * 1024 }) {
62
+ this.file = file
63
+ this.lockFile = `${file}.lock`
64
+ this.empty = empty
65
+ this.validate = validate
66
+ this.corruptCode = corruptCode
67
+ this.writeCode = writeCode
68
+ this.maxBytes = maxBytes
69
+ this.fileSignature = undefined
70
+ this.data = this.load()
71
+ }
72
+
73
+ error(message, code, cause) {
74
+ const error = new Error(message, cause === undefined ? undefined : { cause })
75
+ error.code = code
76
+ return error
77
+ }
78
+
79
+ load(repairAcl = true) {
80
+ try {
81
+ const text = readFileSync(this.file, 'utf8')
82
+ if (Buffer.byteLength(text, 'utf8') > this.maxBytes) throw this.error(`Web Search store exceeds ${this.maxBytes} bytes`, this.corruptCode)
83
+ const parsed = JSON.parse(text)
84
+ if (!this.validate(parsed)) throw this.error(`Invalid Web Search store ${this.file}`, this.corruptCode)
85
+ this.fileSignature = signature(this.file)
86
+ return parsed
87
+ } catch (error) {
88
+ if (error?.code === 'ENOENT') {
89
+ this.fileSignature = undefined
90
+ return this.empty()
91
+ }
92
+ if (repairAcl && ['EACCES', 'EPERM'].includes(error?.code) && ensurePrivateFileAcl(this.file)) {
93
+ return this.load(false)
94
+ }
95
+ if (error?.code === this.corruptCode) throw error
96
+ throw this.error(`Unable to load Web Search store ${this.file}`, this.corruptCode, error)
97
+ }
98
+ }
99
+
100
+ refresh() {
101
+ if (signature(this.file) !== this.fileSignature) this.data = this.load()
102
+ }
103
+
104
+ update(mutator) {
105
+ mkdirSync(dirname(this.file), { recursive: true })
106
+ ensurePrivateFileAcl(dirname(this.file))
107
+ const release = this.acquireLock()
108
+ try {
109
+ const current = this.load()
110
+ const next = mutator(structuredClone(current))
111
+ this.saveUnlocked(next)
112
+ return structuredClone(next)
113
+ } finally {
114
+ release()
115
+ }
116
+ }
117
+
118
+ acquireLock() {
119
+ const started = Date.now()
120
+ while (Date.now() - started <= LOCK_WAIT_MS) {
121
+ const token = randomUUID()
122
+ const lockText = `${process.pid}\n${Date.now()}\n${token}\n`
123
+ let fd
124
+ let created = false
125
+ try {
126
+ fd = openSync(this.lockFile, 'wx', 0o600)
127
+ created = true
128
+ if (!ensurePrivateFileAcl(this.lockFile)) throw new Error(`Unable to apply private ACL to Web Search store lock ${this.lockFile}`)
129
+ writeFileSync(fd, lockText, { encoding: 'utf8' })
130
+ fsyncSync(fd)
131
+ closeSync(fd)
132
+ fd = undefined
133
+ return () => {
134
+ try {
135
+ if (readFileSync(this.lockFile, 'utf8') === lockText) unlinkSync(this.lockFile)
136
+ } catch {
137
+ // A crashed owner may have left a lock that was recovered by another writer.
138
+ }
139
+ }
140
+ } catch (error) {
141
+ if (fd !== undefined) {
142
+ try { closeSync(fd) } catch {}
143
+ }
144
+ if (created) {
145
+ try { unlinkSync(this.lockFile) } catch {}
146
+ }
147
+ if (error?.code !== 'EEXIST') throw this.error(`Unable to acquire Web Search store lock ${this.lockFile}`, this.writeCode, error)
148
+ if (unlinkLockIfUnchanged(this.lockFile, staleLockSnapshot(this.lockFile))) continue
149
+ sleepSync(10)
150
+ }
151
+ }
152
+ throw this.error(`Timed out acquiring Web Search store lock ${this.lockFile}`, this.writeCode)
153
+ }
154
+
155
+ saveUnlocked(data) {
156
+ if (!this.validate(data)) throw this.error(`Refusing to write invalid Web Search store ${this.file}`, this.writeCode)
157
+ const serialized = JSON.stringify(data, null, 2)
158
+ if (Buffer.byteLength(serialized, 'utf8') > this.maxBytes) throw this.error(`Web Search store exceeds ${this.maxBytes} bytes`, this.writeCode)
159
+ const directory = dirname(this.file)
160
+ const temporary = join(directory, `.${basename(this.file)}.${randomUUID()}.tmp`)
161
+ let fd
162
+ try {
163
+ fd = openSync(temporary, 'wx', 0o600)
164
+ if (!ensurePrivateFileAcl(temporary)) throw new Error(`Unable to apply private ACL to Web Search store temporary file ${temporary}`)
165
+ writeFileSync(fd, serialized, { encoding: 'utf8' })
166
+ fsyncSync(fd)
167
+ closeSync(fd)
168
+ fd = undefined
169
+ renameSync(temporary, this.file)
170
+ ensurePrivateFileAcl(this.file)
171
+ this.fileSignature = signature(this.file)
172
+ this.data = data
173
+ } catch (error) {
174
+ if (fd !== undefined) {
175
+ try { closeSync(fd) } catch {}
176
+ }
177
+ try { unlinkSync(temporary) } catch {}
178
+ throw this.error(`Unable to atomically write Web Search store ${this.file}`, this.writeCode, error)
179
+ }
180
+ }
181
+ }