@juvio15/pi-cliproxyapi-provider 0.0.0-stage → 0.1.1
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 +21 -0
- package/package.json +51 -4
- package/src/core/catalog.ts +400 -0
- package/src/core/connection.ts +119 -0
- package/src/core/costs.ts +477 -0
- package/src/core/index.ts +189 -0
- package/src/core/oauth.ts +279 -0
- package/src/core/streams.ts +89 -0
- package/src/core/usage.ts +813 -0
- package/src/extension/completions.ts +20 -0
- package/src/extension/config.ts +314 -0
- package/src/extension/fast.ts +81 -0
- package/src/extension/files.ts +146 -0
- package/src/extension/footer.ts +229 -0
- package/src/extension/index.ts +477 -0
- package/src/extension/oauth.ts +203 -0
- package/src/extension/pause.ts +126 -0
- package/src/extension/prices.ts +205 -0
- package/src/extension/prompt-cache.ts +17 -0
- package/src/extension/provider.ts +386 -0
- package/src/extension/retry.ts +41 -0
- package/src/extension/status.ts +166 -0
- package/src/extension/tps.ts +215 -0
- package/src/extension/usage.ts +609 -0
- package/src/extension/web-search.ts +307 -0
- package/src/web-search/endpoint.ts +21 -0
- package/src/web-search/index.ts +21 -0
- package/src/web-search/model-selector.ts +93 -0
- package/src/web-search/search.ts +571 -0
- package/src/web-search/sse.ts +144 -0
- package/README.md +0 -3
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { RegisteredCommand } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
/** pi's AutocompleteItem, taken from the command type so pi-tui stays an indirect dependency. */
|
|
4
|
+
export type AutocompleteItem = NonNullable<
|
|
5
|
+
Awaited<ReturnType<NonNullable<RegisteredCommand["getArgumentCompletions"]>>>
|
|
6
|
+
>[number];
|
|
7
|
+
|
|
8
|
+
/** Completes a command argument from `values` by case-insensitive prefix, or `null` when nothing matches. */
|
|
9
|
+
export const completeFrom = (
|
|
10
|
+
values: readonly string[],
|
|
11
|
+
prefix: string
|
|
12
|
+
): AutocompleteItem[] | null => {
|
|
13
|
+
const needle = prefix.trim().toLowerCase();
|
|
14
|
+
|
|
15
|
+
const items = [...new Set(values)].flatMap((value) =>
|
|
16
|
+
value.toLowerCase().startsWith(needle) ? [{ label: value, value }] : []
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
return items.length > 0 ? items : null;
|
|
20
|
+
};
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
import type { OAuthCredentials } from "@earendil-works/pi-ai";
|
|
4
|
+
import { readStoredCredential } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { Type } from "typebox";
|
|
6
|
+
import { Value } from "typebox/value";
|
|
7
|
+
|
|
8
|
+
import { DEFAULT_BASE_URL, resolveConnection } from "../core/index.ts";
|
|
9
|
+
import type {
|
|
10
|
+
CliproxyModel,
|
|
11
|
+
ConnectionValues,
|
|
12
|
+
ResolvedConnection,
|
|
13
|
+
ThoughtSummary,
|
|
14
|
+
} from "../core/index.ts";
|
|
15
|
+
import {
|
|
16
|
+
memoByFile,
|
|
17
|
+
readJsonFile,
|
|
18
|
+
readJsonMemo,
|
|
19
|
+
writeJsonAtomic,
|
|
20
|
+
} from "./files.ts";
|
|
21
|
+
|
|
22
|
+
export const CONFIG_FILE_NAME = "cliproxyapi.json";
|
|
23
|
+
export const MODELS_CACHE_FILE_NAME = "cliproxyapi-provider-models.json";
|
|
24
|
+
export const DEFAULT_PROVIDER_ID = "cliproxyapi";
|
|
25
|
+
export const DEFAULT_PROVIDER_NAME = "CLIProxyAPI";
|
|
26
|
+
export const CREDENTIAL_TTL_MS = 100 * 365 * 24 * 60 * 60 * 1000;
|
|
27
|
+
|
|
28
|
+
export interface CliproxyConfig extends ConnectionValues {
|
|
29
|
+
providerId?: string;
|
|
30
|
+
providerName?: string;
|
|
31
|
+
fast?: boolean;
|
|
32
|
+
pause?: boolean;
|
|
33
|
+
/** CLIProxyAPI management key. Unlocks the usage footer and account-wide hiding. */
|
|
34
|
+
managementKey?: string;
|
|
35
|
+
thinkingDisplay?: Readonly<Record<string, ThoughtSummary>>;
|
|
36
|
+
searchModel?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ModelsCache {
|
|
40
|
+
baseUrl: string;
|
|
41
|
+
fast: boolean;
|
|
42
|
+
fastModelIds: string[];
|
|
43
|
+
fetchedAt: number;
|
|
44
|
+
models: CliproxyModel[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ExtensionIdentity {
|
|
48
|
+
providerId: string;
|
|
49
|
+
providerName: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface ResolveExtensionConnectionOptions {
|
|
53
|
+
agentDir: string;
|
|
54
|
+
env: Readonly<Record<string, string | undefined>>;
|
|
55
|
+
providerId: string;
|
|
56
|
+
stored?: ConnectionValues;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const firstNonEmpty = (
|
|
60
|
+
...values: (string | undefined)[]
|
|
61
|
+
): string | undefined => {
|
|
62
|
+
for (const value of values) {
|
|
63
|
+
const trimmed = value?.trim();
|
|
64
|
+
|
|
65
|
+
if (trimmed) {
|
|
66
|
+
return trimmed;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return undefined;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const OptionalString = Type.Optional(Type.String());
|
|
74
|
+
|
|
75
|
+
const ConfigFileSchema = Type.Object({
|
|
76
|
+
apiKey: OptionalString,
|
|
77
|
+
baseUrl: OptionalString,
|
|
78
|
+
fast: Type.Optional(Type.Boolean()),
|
|
79
|
+
managementKey: OptionalString,
|
|
80
|
+
pause: Type.Optional(Type.Boolean()),
|
|
81
|
+
providerId: OptionalString,
|
|
82
|
+
providerName: OptionalString,
|
|
83
|
+
searchModel: OptionalString,
|
|
84
|
+
thinkingDisplay: Type.Optional(
|
|
85
|
+
Type.Record(
|
|
86
|
+
Type.String(),
|
|
87
|
+
Type.Union([Type.Literal("summarized"), Type.Literal("omitted")])
|
|
88
|
+
)
|
|
89
|
+
),
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const RefreshMetaSchema = Type.Object({ baseUrl: OptionalString });
|
|
93
|
+
|
|
94
|
+
const SettingsFileSchema = Type.Object({
|
|
95
|
+
defaultModel: OptionalString,
|
|
96
|
+
defaultProvider: OptionalString,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const ModelsCacheFileSchema = Type.Object({
|
|
100
|
+
baseUrl: Type.String(),
|
|
101
|
+
fast: Type.Boolean(),
|
|
102
|
+
fastModelIds: Type.Array(Type.String()),
|
|
103
|
+
fetchedAt: Type.Optional(Type.Number()),
|
|
104
|
+
models: Type.Array(Type.Unknown()),
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const nonEmpty = (value: string | undefined): string | undefined =>
|
|
108
|
+
value?.trim() || undefined;
|
|
109
|
+
|
|
110
|
+
/** Read on every request, so it comes from the file memo. The result is a fresh object callers may change. */
|
|
111
|
+
export const loadCliproxyConfig = (agentDir: string): CliproxyConfig => {
|
|
112
|
+
const value = readJsonMemo(
|
|
113
|
+
path.join(agentDir, CONFIG_FILE_NAME),
|
|
114
|
+
ConfigFileSchema
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
if (!value) {
|
|
118
|
+
return {};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
apiKey: nonEmpty(value.apiKey),
|
|
123
|
+
baseUrl: nonEmpty(value.baseUrl),
|
|
124
|
+
fast: value.fast,
|
|
125
|
+
managementKey: nonEmpty(value.managementKey),
|
|
126
|
+
pause: value.pause,
|
|
127
|
+
providerId: nonEmpty(value.providerId),
|
|
128
|
+
providerName: nonEmpty(value.providerName),
|
|
129
|
+
searchModel: nonEmpty(value.searchModel),
|
|
130
|
+
thinkingDisplay: { ...value.thinkingDisplay },
|
|
131
|
+
};
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
export const saveCliproxyConfig = (
|
|
135
|
+
agentDir: string,
|
|
136
|
+
update: Partial<CliproxyConfig>
|
|
137
|
+
): void => {
|
|
138
|
+
writeJsonAtomic(path.join(agentDir, CONFIG_FILE_NAME), {
|
|
139
|
+
...loadCliproxyConfig(agentDir),
|
|
140
|
+
...update,
|
|
141
|
+
});
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
export const resolveIdentity = (
|
|
145
|
+
agentDir: string,
|
|
146
|
+
env: Readonly<Record<string, string | undefined>> = process.env
|
|
147
|
+
): ExtensionIdentity => {
|
|
148
|
+
const config = loadCliproxyConfig(agentDir);
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
providerId:
|
|
152
|
+
firstNonEmpty(
|
|
153
|
+
env.CLIPROXYAPI_PROVIDER_ID,
|
|
154
|
+
config.providerId,
|
|
155
|
+
DEFAULT_PROVIDER_ID
|
|
156
|
+
) ?? DEFAULT_PROVIDER_ID,
|
|
157
|
+
providerName:
|
|
158
|
+
firstNonEmpty(
|
|
159
|
+
env.CLIPROXYAPI_PROVIDER_NAME,
|
|
160
|
+
config.providerName,
|
|
161
|
+
DEFAULT_PROVIDER_NAME
|
|
162
|
+
) ?? DEFAULT_PROVIDER_NAME,
|
|
163
|
+
};
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
export const decodeRefreshMeta = (
|
|
167
|
+
refresh: string | undefined
|
|
168
|
+
): ConnectionValues => {
|
|
169
|
+
if (!refresh?.trim()) {
|
|
170
|
+
return {};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
try {
|
|
174
|
+
const value: unknown = JSON.parse(refresh);
|
|
175
|
+
|
|
176
|
+
return Value.Check(RefreshMetaSchema, value)
|
|
177
|
+
? { baseUrl: nonEmpty(value.baseUrl) }
|
|
178
|
+
: {};
|
|
179
|
+
} catch {
|
|
180
|
+
return {};
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
export const buildOAuthCredentials = (
|
|
185
|
+
baseUrl: string,
|
|
186
|
+
apiKey: string
|
|
187
|
+
): OAuthCredentials => ({
|
|
188
|
+
access: apiKey,
|
|
189
|
+
expires: Date.now() + CREDENTIAL_TTL_MS,
|
|
190
|
+
refresh: JSON.stringify({ baseUrl }),
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
export const loadStoredConnection = (
|
|
194
|
+
agentDir: string,
|
|
195
|
+
providerId: string
|
|
196
|
+
): ConnectionValues => {
|
|
197
|
+
try {
|
|
198
|
+
const authPath = path.join(agentDir, "auth.json");
|
|
199
|
+
|
|
200
|
+
const credential = memoByFile(
|
|
201
|
+
authPath,
|
|
202
|
+
() => readStoredCredential(providerId, authPath),
|
|
203
|
+
providerId
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
if (credential?.type === "oauth" && credential.access.trim()) {
|
|
207
|
+
return {
|
|
208
|
+
apiKey: credential.access.trim(),
|
|
209
|
+
...decodeRefreshMeta(credential.refresh),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (credential?.type === "api_key" && credential.key?.trim()) {
|
|
214
|
+
return { apiKey: credential.key.trim() };
|
|
215
|
+
}
|
|
216
|
+
} catch {
|
|
217
|
+
return {};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return {};
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
export const resolveExtensionConnection = (
|
|
224
|
+
options: ResolveExtensionConnectionOptions
|
|
225
|
+
): ResolvedConnection | null => {
|
|
226
|
+
const stored =
|
|
227
|
+
options.stored ??
|
|
228
|
+
loadStoredConnection(options.agentDir, options.providerId);
|
|
229
|
+
|
|
230
|
+
return resolveConnection(
|
|
231
|
+
options.env,
|
|
232
|
+
loadCliproxyConfig(options.agentDir),
|
|
233
|
+
stored
|
|
234
|
+
);
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
export const isModelReferencedAsDefault = (
|
|
238
|
+
agentDir: string,
|
|
239
|
+
providerId: string,
|
|
240
|
+
modelId: string
|
|
241
|
+
): boolean => {
|
|
242
|
+
try {
|
|
243
|
+
const settings = readJsonFile(
|
|
244
|
+
path.join(agentDir, "settings.json"),
|
|
245
|
+
SettingsFileSchema
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
const defaultModel = nonEmpty(settings?.defaultModel);
|
|
249
|
+
const defaultProvider = nonEmpty(settings?.defaultProvider);
|
|
250
|
+
|
|
251
|
+
if (!defaultModel) {
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return (
|
|
256
|
+
defaultModel === `${providerId}/${modelId}` ||
|
|
257
|
+
defaultModel === `${providerId}:${modelId}` ||
|
|
258
|
+
(defaultModel === modelId &&
|
|
259
|
+
(!defaultProvider || defaultProvider === providerId))
|
|
260
|
+
);
|
|
261
|
+
} catch {
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
export const defaultConnectionBaseUrl = (
|
|
267
|
+
agentDir: string,
|
|
268
|
+
providerId: string,
|
|
269
|
+
env: Readonly<Record<string, string | undefined>> = process.env
|
|
270
|
+
): string =>
|
|
271
|
+
firstNonEmpty(
|
|
272
|
+
env.CLIPROXYAPI_BASE_URL,
|
|
273
|
+
loadCliproxyConfig(agentDir).baseUrl,
|
|
274
|
+
loadStoredConnection(agentDir, providerId).baseUrl,
|
|
275
|
+
DEFAULT_BASE_URL
|
|
276
|
+
) ?? DEFAULT_BASE_URL;
|
|
277
|
+
|
|
278
|
+
export const loadModelsCache = (
|
|
279
|
+
agentDir: string,
|
|
280
|
+
baseUrl: string,
|
|
281
|
+
fast: boolean
|
|
282
|
+
): ModelsCache | undefined => {
|
|
283
|
+
try {
|
|
284
|
+
const value = readJsonFile(
|
|
285
|
+
path.join(agentDir, MODELS_CACHE_FILE_NAME),
|
|
286
|
+
ModelsCacheFileSchema
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
if (value?.baseUrl !== baseUrl || value.fast !== fast) {
|
|
290
|
+
return undefined;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return {
|
|
294
|
+
baseUrl,
|
|
295
|
+
fast,
|
|
296
|
+
fastModelIds: value.fastModelIds.filter((id) => id.trim()),
|
|
297
|
+
fetchedAt: value.fetchedAt ?? 0,
|
|
298
|
+
// SAFETY: This cache is written only from core's CliproxyModel mapping. Invalid JSON shapes are rejected by the provider refresh.
|
|
299
|
+
models: value.models as CliproxyModel[],
|
|
300
|
+
};
|
|
301
|
+
} catch {
|
|
302
|
+
return undefined;
|
|
303
|
+
}
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
export const saveModelsCache = (
|
|
307
|
+
agentDir: string,
|
|
308
|
+
cache: Omit<ModelsCache, "fetchedAt"> & { fetchedAt?: number }
|
|
309
|
+
): void => {
|
|
310
|
+
writeJsonAtomic(path.join(agentDir, MODELS_CACHE_FILE_NAME), {
|
|
311
|
+
...cache,
|
|
312
|
+
fetchedAt: cache.fetchedAt ?? Date.now(),
|
|
313
|
+
});
|
|
314
|
+
};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { saveCliproxyConfig } from "./config.ts";
|
|
2
|
+
|
|
3
|
+
/** Global Fast preference plus the capability set advertised by CLIProxyAPI. */
|
|
4
|
+
export class FastModeController {
|
|
5
|
+
private supportedModelIds = new Set<string>();
|
|
6
|
+
private enabled: boolean;
|
|
7
|
+
|
|
8
|
+
constructor(enabled: boolean) {
|
|
9
|
+
this.enabled = enabled;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
setEnabled(enabled: boolean): void {
|
|
13
|
+
this.enabled = enabled;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
setSupportedModelIds(modelIds: Iterable<string>): void {
|
|
17
|
+
this.supportedModelIds = new Set(
|
|
18
|
+
[...modelIds].flatMap((id) => {
|
|
19
|
+
const trimmed = id.trim();
|
|
20
|
+
|
|
21
|
+
return trimmed ? [trimmed] : [];
|
|
22
|
+
})
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
isEnabled = (): boolean => this.enabled;
|
|
27
|
+
|
|
28
|
+
isModelSupported(modelId: string): boolean {
|
|
29
|
+
return this.supportedModelIds.has(modelId.trim());
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
isEffectiveFor(modelId: string): boolean {
|
|
33
|
+
return this.enabled && this.isModelSupported(modelId);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ToggleFastModeOptions {
|
|
38
|
+
agentDir: string;
|
|
39
|
+
fast: FastModeController;
|
|
40
|
+
notify: (message: string, level?: "info" | "warning" | "error") => void;
|
|
41
|
+
refresh: () => Promise<void>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Toggle request behavior immediately, then refresh Fast pricing in place. */
|
|
45
|
+
export const toggleFastMode = async (
|
|
46
|
+
options: ToggleFastModeOptions
|
|
47
|
+
): Promise<boolean | undefined> => {
|
|
48
|
+
const previous = options.fast.isEnabled();
|
|
49
|
+
const enabled = !previous;
|
|
50
|
+
options.fast.setEnabled(enabled);
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
saveCliproxyConfig(options.agentDir, { fast: enabled });
|
|
54
|
+
await options.refresh();
|
|
55
|
+
} catch (error) {
|
|
56
|
+
options.fast.setEnabled(previous);
|
|
57
|
+
saveCliproxyConfig(options.agentDir, { fast: previous });
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
await options.refresh();
|
|
61
|
+
} catch {
|
|
62
|
+
// The original refresh error is more useful than a secondary rollback error.
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
options.notify(
|
|
66
|
+
`Failed to refresh Fast pricing: ${error instanceof Error ? error.message : String(error)}`,
|
|
67
|
+
"warning"
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
options.notify(
|
|
74
|
+
enabled
|
|
75
|
+
? "Fast mode is enabled globally."
|
|
76
|
+
: "Fast mode is disabled globally.",
|
|
77
|
+
"info"
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
return enabled;
|
|
81
|
+
};
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import {
|
|
2
|
+
mkdirSync,
|
|
3
|
+
readFileSync,
|
|
4
|
+
renameSync,
|
|
5
|
+
statSync,
|
|
6
|
+
writeFileSync,
|
|
7
|
+
} from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
|
|
10
|
+
import type { Static, TSchema } from "typebox";
|
|
11
|
+
import { Value } from "typebox/value";
|
|
12
|
+
|
|
13
|
+
/** Undefined when the file is missing. Throws, naming the file and field, when it breaks the schema. */
|
|
14
|
+
export const readJsonFile = <Schema extends TSchema>(
|
|
15
|
+
filePath: string,
|
|
16
|
+
schema: Schema
|
|
17
|
+
): Static<Schema> | undefined => {
|
|
18
|
+
let text: string;
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
text = readFileSync(filePath, "utf-8");
|
|
22
|
+
} catch (error) {
|
|
23
|
+
// SAFETY: Node filesystem errors carry the optional code field used to identify a missing file.
|
|
24
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const value: unknown = JSON.parse(text);
|
|
32
|
+
|
|
33
|
+
if (Value.Check(schema, value)) {
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const [first] = Value.Errors(schema, value);
|
|
38
|
+
|
|
39
|
+
throw new Error(
|
|
40
|
+
`Invalid ${path.basename(filePath)} at ${first?.instancePath || "/"}: ${first?.message ?? "unexpected shape"}`
|
|
41
|
+
);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** What identifies one version of a file. A rename gives a new inode, so atomic writes always differ. */
|
|
45
|
+
interface FileStamp {
|
|
46
|
+
ino: number;
|
|
47
|
+
mtimeMs: number;
|
|
48
|
+
size: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface MemoEntry {
|
|
52
|
+
stamp: FileStamp;
|
|
53
|
+
value: unknown;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Tells apart readers of one file: readJsonMemo's schema, or memoByFile's caller key. */
|
|
57
|
+
type MemoKey = TSchema | string;
|
|
58
|
+
|
|
59
|
+
/** Parsed values per file path, then per reader key. */
|
|
60
|
+
const memo = new Map<string, Map<MemoKey, MemoEntry>>();
|
|
61
|
+
|
|
62
|
+
const sameStamp = (left: FileStamp, right: FileStamp): boolean =>
|
|
63
|
+
left.ino === right.ino &&
|
|
64
|
+
left.mtimeMs === right.mtimeMs &&
|
|
65
|
+
left.size === right.size;
|
|
66
|
+
|
|
67
|
+
const remember = <Value, Missing>(
|
|
68
|
+
filePath: string,
|
|
69
|
+
key: MemoKey,
|
|
70
|
+
read: () => Value,
|
|
71
|
+
missing: () => Missing
|
|
72
|
+
): Value | Missing => {
|
|
73
|
+
const stats = statSync(filePath, { throwIfNoEntry: false });
|
|
74
|
+
|
|
75
|
+
if (!stats) {
|
|
76
|
+
memo.delete(filePath);
|
|
77
|
+
|
|
78
|
+
return missing();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const stamp: FileStamp = {
|
|
82
|
+
ino: stats.ino,
|
|
83
|
+
mtimeMs: stats.mtimeMs,
|
|
84
|
+
size: stats.size,
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const entries = memo.get(filePath) ?? new Map<MemoKey, MemoEntry>();
|
|
88
|
+
const cached = entries.get(key);
|
|
89
|
+
|
|
90
|
+
if (cached && sameStamp(cached.stamp, stamp)) {
|
|
91
|
+
// SAFETY: Each key is stored only by the reader that owns it, so the value has that reader's type.
|
|
92
|
+
return cached.value as Value;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const value = read();
|
|
96
|
+
entries.set(key, { stamp, value });
|
|
97
|
+
memo.set(filePath, entries);
|
|
98
|
+
|
|
99
|
+
return value;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Reads and validates a JSON file like readJsonFile, but reuses the last parse
|
|
104
|
+
* while the file's inode, mtime and size are unchanged, so writes from other
|
|
105
|
+
* pi processes are still seen. Callers share the returned object and must not
|
|
106
|
+
* mutate it.
|
|
107
|
+
*/
|
|
108
|
+
export const readJsonMemo = <Schema extends TSchema>(
|
|
109
|
+
filePath: string,
|
|
110
|
+
schema: Schema
|
|
111
|
+
): Static<Schema> | undefined =>
|
|
112
|
+
remember(
|
|
113
|
+
filePath,
|
|
114
|
+
schema,
|
|
115
|
+
() => readJsonFile(filePath, schema),
|
|
116
|
+
(): undefined => undefined
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Memoizes any reader of one file by the same stamp. `key` separates readers
|
|
121
|
+
* that pick different parts of the same file. A missing file is never cached,
|
|
122
|
+
* so `read` then runs every call and decides what a missing file means.
|
|
123
|
+
*/
|
|
124
|
+
export const memoByFile = <Value>(
|
|
125
|
+
filePath: string,
|
|
126
|
+
read: () => Value,
|
|
127
|
+
key = ""
|
|
128
|
+
): Value => remember(filePath, key, read, read);
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Writes pretty JSON to `<file>.<pid>.tmp`, then renames it over `file`, so
|
|
132
|
+
* readers in other pi processes never see a half-written file. Serializes
|
|
133
|
+
* first, so a value that cannot become JSON leaves the old file untouched.
|
|
134
|
+
* The file is owner-only (0600) like pi's auth.json, because cliproxyapi.json
|
|
135
|
+
* holds the API key and management key.
|
|
136
|
+
*/
|
|
137
|
+
const OWNER_ONLY = 0o600;
|
|
138
|
+
|
|
139
|
+
export const writeJsonAtomic = <Data>(filePath: string, value: Data): void => {
|
|
140
|
+
const text = `${JSON.stringify(value, null, 2)}\n`;
|
|
141
|
+
const temporary = `${filePath}.${process.pid}.tmp`;
|
|
142
|
+
mkdirSync(path.dirname(filePath), { recursive: true });
|
|
143
|
+
writeFileSync(temporary, text, { encoding: "utf-8", mode: OWNER_ONLY });
|
|
144
|
+
renameSync(temporary, filePath);
|
|
145
|
+
memo.delete(filePath);
|
|
146
|
+
};
|