@olares/lares-core 0.15.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.
- package/README.md +23 -0
- package/brand/identity.d.ts +9 -0
- package/brand/identity.js +82 -0
- package/brand/manifest.js +15 -0
- package/drive/download.js +40 -0
- package/drive/execute.js +143 -0
- package/drive/files-path.js +55 -0
- package/drive/ls.js +126 -0
- package/drive/paths.js +320 -0
- package/drive/tools.js +206 -0
- package/drive/url-download.js +314 -0
- package/files/deliverables.js +32 -0
- package/files/disposition.js +20 -0
- package/files/filename.js +22 -0
- package/files/intake.js +217 -0
- package/files/limits.js +1 -0
- package/files/markdown.js +88 -0
- package/files/mention.js +64 -0
- package/files/preview-cache.js +100 -0
- package/files/preview-groups.js +21 -0
- package/files/preview-workspace.js +342 -0
- package/files/preview.js +305 -0
- package/files/upload-client.js +40 -0
- package/files/upload-http.js +22 -0
- package/files/upload.js +104 -0
- package/i18n/chat-model-switch.js +41 -0
- package/i18n/chat-model.js +40 -0
- package/i18n/files.js +39 -0
- package/i18n/mobile.js +115 -0
- package/i18n/preview.js +57 -0
- package/i18n/search.js +33 -0
- package/i18n/t.js +22 -0
- package/i18n/voice.js +70 -0
- package/icons/mark.js +22 -0
- package/larepass/chat.js +134 -0
- package/larepass/context-provenance.js +65 -0
- package/larepass/host.js +80 -0
- package/larepass/mux.js +194 -0
- package/larepass/questions.js +16 -0
- package/larepass/rpc.js +60 -0
- package/larepass/runtime.js +395 -0
- package/larepass/session-cache.js +104 -0
- package/larepass/settings.js +100 -0
- package/larepass/stage-copy.js +16 -0
- package/larepass/tool-row.js +132 -0
- package/larepass/transcript.js +332 -0
- package/media/ffmpeg-run.js +117 -0
- package/media/router-images.js +63 -0
- package/olares/entrance.js +45 -0
- package/olares/identity.js +190 -0
- package/olares/session-identity.js +48 -0
- package/olares/trusted-host.js +37 -0
- package/package.json +111 -0
- package/router/catalog-sync.js +87 -0
- package/router/catalog.d.ts +15 -0
- package/router/catalog.js +73 -0
- package/router/gateway.d.ts +4 -0
- package/router/gateway.js +21 -0
- package/router/models-client.js +39 -0
- package/router/models.d.ts +16 -0
- package/router/models.js +133 -0
- package/router/search.js +142 -0
- package/router/session-model.js +143 -0
- package/router/shim.js +134 -0
- package/router/stt.js +200 -0
- package/search/client.js +38 -0
- package/search/config.js +98 -0
- package/search/menu.js +22 -0
- package/tools/async.js +34 -0
- package/tools/crypto-uuid.js +26 -0
- package/tools/http.js +123 -0
- package/tools/json-file.js +18 -0
- package/voice/client.js +90 -0
- package/voice/config.js +59 -0
- package/voice/languages.js +29 -0
- package/voice/recorder.js +43 -0
- package/voice/service.js +83 -0
- package/workspace/env.js +13 -0
- package/workspace/home.js +9 -0
- package/workspace/path.js +80 -0
- package/workspace/seed.js +13 -0
- package/workspace/session.js +42 -0
package/router/shim.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { request as httpRequest } from "node:http";
|
|
2
|
+
import { request as httpsRequest } from "node:https";
|
|
3
|
+
import { readBody, sendError } from "../tools/http.js";
|
|
4
|
+
import { carriesWebpImage, transcodeWebpImages } from "../media/router-images.js";
|
|
5
|
+
import { routerAuthHeaders, routerGatewayUrl } from "./gateway.js";
|
|
6
|
+
import { STT_MAX_AUDIO_BYTES } from "./stt.js";
|
|
7
|
+
|
|
8
|
+
export const SHIM_PATH = "/llm/v1";
|
|
9
|
+
export const SHIM_CHAT_TIMEOUT_MS = 120_000;
|
|
10
|
+
export const SHIM_AUDIO_TIMEOUT_MS = 180_000;
|
|
11
|
+
export const SHIM_CHAT_MAX_BYTES = 16 * 1024 * 1024;
|
|
12
|
+
|
|
13
|
+
const DROPPED_REQ = new Set([
|
|
14
|
+
"authorization",
|
|
15
|
+
"connection",
|
|
16
|
+
"content-length",
|
|
17
|
+
"host",
|
|
18
|
+
"keep-alive",
|
|
19
|
+
"transfer-encoding",
|
|
20
|
+
"upgrade",
|
|
21
|
+
"x-olares-app-id",
|
|
22
|
+
"x-caller-appid",
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
const DROPPED_RES = new Set(["connection", "content-encoding", "content-length", "transfer-encoding"]);
|
|
26
|
+
|
|
27
|
+
export function llmShimSuffix(pathname) {
|
|
28
|
+
return pathname.replace(/^\/llm\/v1\/?/, "").replace(/^\/+/, "");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function isAudioShimPath(suffix) {
|
|
32
|
+
return /(^|\/)audio(\/|$)/.test(suffix);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function shimBudget(suffix) {
|
|
36
|
+
const audio = isAudioShimPath(suffix);
|
|
37
|
+
return {
|
|
38
|
+
audio,
|
|
39
|
+
timeoutMs: audio ? SHIM_AUDIO_TIMEOUT_MS : SHIM_CHAT_TIMEOUT_MS,
|
|
40
|
+
maxBytes: audio ? STT_MAX_AUDIO_BYTES : SHIM_CHAT_MAX_BYTES,
|
|
41
|
+
tooLargeMessage: audio ? "audio exceeds 25MB" : "LLM request exceeds 16MB",
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function shimRequestHeaders(incoming, env = process.env) {
|
|
46
|
+
const headers = {};
|
|
47
|
+
for (const [key, value] of Object.entries(incoming)) {
|
|
48
|
+
if (value == null || DROPPED_REQ.has(key.toLowerCase())) continue;
|
|
49
|
+
headers[key] = Array.isArray(value) ? value.join(",") : value;
|
|
50
|
+
}
|
|
51
|
+
Object.assign(
|
|
52
|
+
headers,
|
|
53
|
+
routerAuthHeaders(env.LARES_ROUTER_API_KEY?.trim() || null, env.OLARES_APP_ID?.trim() || "lares"),
|
|
54
|
+
);
|
|
55
|
+
return headers;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function shimResponseHeaders(incoming) {
|
|
59
|
+
const headers = {};
|
|
60
|
+
for (const [key, value] of Object.entries(incoming)) {
|
|
61
|
+
if (value == null || DROPPED_RES.has(key.toLowerCase())) continue;
|
|
62
|
+
headers[key] = value;
|
|
63
|
+
}
|
|
64
|
+
return headers;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function healthPayload(env = process.env) {
|
|
68
|
+
return {
|
|
69
|
+
ok: true,
|
|
70
|
+
app: "lares",
|
|
71
|
+
kernel: "dsh-web",
|
|
72
|
+
routerUrl: routerGatewayUrl(env),
|
|
73
|
+
olaresAppId: env.OLARES_APP_ID ?? "lares",
|
|
74
|
+
hasRouterKey: Boolean(env.LARES_ROUTER_API_KEY?.trim()),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function proxyToRouter(req, res, env = process.env) {
|
|
79
|
+
const routerUrl = routerGatewayUrl(env);
|
|
80
|
+
const rawUrl = req.url ?? "/";
|
|
81
|
+
const u = new URL(rawUrl, "http://x");
|
|
82
|
+
const suffix = llmShimSuffix(u.pathname);
|
|
83
|
+
const target = new URL(`${routerUrl}/${suffix}${u.search}`);
|
|
84
|
+
const budget = shimBudget(suffix);
|
|
85
|
+
const headers = shimRequestHeaders(req.headers, env);
|
|
86
|
+
const transport = target.protocol === "https:" ? httpsRequest : httpRequest;
|
|
87
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
88
|
+
|
|
89
|
+
const run = async () => {
|
|
90
|
+
let body;
|
|
91
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
92
|
+
body = await readBody(req, {
|
|
93
|
+
maxBytes: budget.maxBytes,
|
|
94
|
+
message: budget.tooLargeMessage,
|
|
95
|
+
});
|
|
96
|
+
if (!budget.audio && carriesWebpImage(body)) body = await transcodeWebpImages(body);
|
|
97
|
+
headers["content-length"] = String(body.length);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const upstream = transport(
|
|
101
|
+
target,
|
|
102
|
+
{ method, headers, timeout: budget.timeoutMs },
|
|
103
|
+
(up) => {
|
|
104
|
+
res.writeHead(up.statusCode ?? 502, shimResponseHeaders(up.headers));
|
|
105
|
+
up.pipe(res);
|
|
106
|
+
},
|
|
107
|
+
);
|
|
108
|
+
upstream.on("error", (err) => {
|
|
109
|
+
if (res.headersSent) {
|
|
110
|
+
res.destroy();
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
res.writeHead(502, { "content-type": "application/json" });
|
|
114
|
+
res.end(
|
|
115
|
+
JSON.stringify({
|
|
116
|
+
error: {
|
|
117
|
+
type: "upstream_unreachable",
|
|
118
|
+
message: `Cannot reach Router at ${routerUrl}: ${err.message}`,
|
|
119
|
+
},
|
|
120
|
+
}),
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
if (body) upstream.end(body);
|
|
124
|
+
else upstream.end();
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
void run().catch((err) => {
|
|
128
|
+
if (res.headersSent) {
|
|
129
|
+
res.destroy();
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
sendError(res, err, "llm_proxy_failed");
|
|
133
|
+
});
|
|
134
|
+
}
|
package/router/stt.js
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { createInFlightCoalescer } from "../tools/async.js";
|
|
3
|
+
import { routerCatalogRows } from "./catalog.js";
|
|
4
|
+
import { routerShimBaseUrl } from "./gateway.js";
|
|
5
|
+
|
|
6
|
+
const STT_HINTS = /whisper|\bstt\b|\basr\b|transcri/i;
|
|
7
|
+
const RETRYABLE_STATUS = new Set([429, 500, 502, 503, 504]);
|
|
8
|
+
const UNDECODABLE_HINTS = [
|
|
9
|
+
"end of file",
|
|
10
|
+
"format not recognised",
|
|
11
|
+
"format not recognized",
|
|
12
|
+
"invalid data found",
|
|
13
|
+
"moov atom not found",
|
|
14
|
+
"unknown file extension",
|
|
15
|
+
"failed to load audio",
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
export class VoiceError extends Error {
|
|
19
|
+
constructor(code, status, message) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.code = code;
|
|
22
|
+
this.status = status;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function sttModelIds(models) {
|
|
27
|
+
return models
|
|
28
|
+
.filter((model) => model.mode === "audio" && STT_HINTS.test(model.id))
|
|
29
|
+
.map((model) => model.id);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function pickSttModelId(models, preferred) {
|
|
33
|
+
const stt = sttModelIds(models);
|
|
34
|
+
const want = (preferred ?? "").trim();
|
|
35
|
+
if (want && stt.includes(want)) return want;
|
|
36
|
+
return stt[0] ?? null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function classifyFailure(status, body) {
|
|
40
|
+
const lowered = body.toLowerCase();
|
|
41
|
+
if (UNDECODABLE_HINTS.some((hint) => lowered.includes(hint))) return "voice_audio_unreadable";
|
|
42
|
+
if (status === 400 || status === 415 || status === 422) return "voice_audio_unreadable";
|
|
43
|
+
if (status === 404) return "voice_model_unavailable";
|
|
44
|
+
return "voice_failed";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function retryable(status) {
|
|
48
|
+
return RETRYABLE_STATUS.has(status);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const STT_MAX_AUDIO_BYTES = 25 * 1024 * 1024;
|
|
52
|
+
|
|
53
|
+
const AUDIO_EXTENSIONS = {
|
|
54
|
+
"audio/webm": "webm",
|
|
55
|
+
"audio/ogg": "ogg",
|
|
56
|
+
"audio/mp4": "mp4",
|
|
57
|
+
"audio/mpeg": "mp3",
|
|
58
|
+
"audio/aac": "aac",
|
|
59
|
+
"audio/wav": "wav",
|
|
60
|
+
"audio/wave": "wav",
|
|
61
|
+
"audio/x-wav": "wav",
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export function audioFilenameForContentType(contentType) {
|
|
65
|
+
const base = String(contentType ?? "").split(";", 1)[0].trim().toLowerCase();
|
|
66
|
+
return `voice.${AUDIO_EXTENSIONS[base] ?? "webm"}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const REQUEST_TIMEOUT_MS = 180_000;
|
|
70
|
+
const CATALOG_TIMEOUT_MS = 15_000;
|
|
71
|
+
const RETRY_BACKOFF_MS = [1_000, 3_000];
|
|
72
|
+
const RESOLVED_TTL_MS = 120_000;
|
|
73
|
+
|
|
74
|
+
const coalesceCatalog = createInFlightCoalescer();
|
|
75
|
+
/** @type {{ expires: number, rows: { id: string, mode: string | null }[] } | null} */
|
|
76
|
+
let catalog = null;
|
|
77
|
+
|
|
78
|
+
async function fetchCatalog() {
|
|
79
|
+
const res = await fetch(`${routerShimBaseUrl()}/models`, {
|
|
80
|
+
headers: { accept: "application/json" },
|
|
81
|
+
signal: AbortSignal.timeout(CATALOG_TIMEOUT_MS),
|
|
82
|
+
});
|
|
83
|
+
if (!res.ok) throw new VoiceError("voice_model_unavailable", 503, `Router /models returned ${res.status}`);
|
|
84
|
+
const rows = routerCatalogRows(await res.json()).map(({ id, mode }) => ({ id, mode }));
|
|
85
|
+
catalog = { expires: Date.now() + RESOLVED_TTL_MS, rows };
|
|
86
|
+
return rows;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** @returns {Promise<{ id: string, mode: string | null }[]>} */
|
|
90
|
+
export async function listModels(options) {
|
|
91
|
+
if (!options?.refresh && catalog && catalog.expires > Date.now()) return catalog.rows;
|
|
92
|
+
if (options?.refresh) return fetchCatalog();
|
|
93
|
+
return coalesceCatalog(fetchCatalog);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** @returns {Promise<string[]>} */
|
|
97
|
+
export async function listModelIds() {
|
|
98
|
+
return (await listModels()).map((model) => model.id);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** @type {{ expires: number, id: string, preferred: string } | null} */
|
|
102
|
+
let resolved = null;
|
|
103
|
+
|
|
104
|
+
/** Cached STT model id (short TTL). @param {string} [preferred] */
|
|
105
|
+
export async function resolveSttModel(preferred, options) {
|
|
106
|
+
const want = (preferred ?? "").trim();
|
|
107
|
+
if (
|
|
108
|
+
!options?.refresh
|
|
109
|
+
&& resolved
|
|
110
|
+
&& resolved.expires > Date.now()
|
|
111
|
+
&& resolved.preferred === want
|
|
112
|
+
) {
|
|
113
|
+
return resolved.id;
|
|
114
|
+
}
|
|
115
|
+
const id = pickSttModelId(await listModels(options), want);
|
|
116
|
+
resolved = id ? { expires: Date.now() + RESOLVED_TTL_MS, id, preferred: want } : null;
|
|
117
|
+
return id;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function forgetSttModel() {
|
|
121
|
+
resolved = null;
|
|
122
|
+
catalog = null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** @param {Record<string, string>} fields @param {{ filename: string, contentType: string, bytes: Buffer }} file */
|
|
126
|
+
function multipartBody(fields, file) {
|
|
127
|
+
const boundary = `----lares-voice-${randomBytes(12).toString("hex")}`;
|
|
128
|
+
const parts = [];
|
|
129
|
+
for (const [name, value] of Object.entries(fields)) {
|
|
130
|
+
parts.push(
|
|
131
|
+
Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${value}\r\n`),
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
parts.push(
|
|
135
|
+
Buffer.from(
|
|
136
|
+
`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${file.filename}"\r\n`
|
|
137
|
+
+ `Content-Type: ${file.contentType}\r\n\r\n`,
|
|
138
|
+
),
|
|
139
|
+
);
|
|
140
|
+
parts.push(file.bytes);
|
|
141
|
+
parts.push(Buffer.from(`\r\n--${boundary}--\r\n`));
|
|
142
|
+
return { boundary, body: Buffer.concat(parts) };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** @param {Response} res */
|
|
146
|
+
async function readTranscript(res) {
|
|
147
|
+
const text = await res.text();
|
|
148
|
+
try {
|
|
149
|
+
const payload = JSON.parse(text);
|
|
150
|
+
if (payload && typeof payload === "object" && payload.text != null) return String(payload.text).trim();
|
|
151
|
+
if (typeof payload === "string") return payload.trim();
|
|
152
|
+
} catch {
|
|
153
|
+
if (text.trim()) return text.trim();
|
|
154
|
+
}
|
|
155
|
+
throw new VoiceError("voice_failed", 502, "invalid transcription response");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* @param {{ bytes: Buffer, filename: string, contentType: string, model: string, language?: string, prompt?: string, preferred?: string }} take
|
|
160
|
+
* @returns {Promise<string>}
|
|
161
|
+
*/
|
|
162
|
+
export async function transcribe(take) {
|
|
163
|
+
let model = take.model;
|
|
164
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
165
|
+
const fields = { model };
|
|
166
|
+
if (take.language) fields.language = take.language;
|
|
167
|
+
if (take.prompt) fields.prompt = take.prompt;
|
|
168
|
+
const { boundary, body } = multipartBody(fields, {
|
|
169
|
+
filename: take.filename,
|
|
170
|
+
contentType: take.contentType,
|
|
171
|
+
bytes: take.bytes,
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
/** @type {VoiceError} */
|
|
175
|
+
let failure;
|
|
176
|
+
try {
|
|
177
|
+
const res = await fetch(`${routerShimBaseUrl()}/audio/transcriptions`, {
|
|
178
|
+
method: "POST",
|
|
179
|
+
headers: { "content-type": `multipart/form-data; boundary=${boundary}` },
|
|
180
|
+
body,
|
|
181
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
182
|
+
});
|
|
183
|
+
if (res.ok) return await readTranscript(res);
|
|
184
|
+
const text = await res.text();
|
|
185
|
+
failure = new VoiceError(classifyFailure(res.status, text), res.status, text.slice(0, 400));
|
|
186
|
+
if (!retryable(res.status)) throw failure;
|
|
187
|
+
} catch (err) {
|
|
188
|
+
if (err instanceof VoiceError) throw err;
|
|
189
|
+
const timedOut = err?.name === "TimeoutError" || err?.name === "AbortError";
|
|
190
|
+
failure = timedOut
|
|
191
|
+
? new VoiceError("voice_timeout", 504, "transcription timed out")
|
|
192
|
+
: new VoiceError("voice_failed", 502, `router unreachable: ${err?.message ?? err}`);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (attempt >= RETRY_BACKOFF_MS.length) throw failure;
|
|
196
|
+
await new Promise((resolve) => setTimeout(resolve, RETRY_BACKOFF_MS[attempt]));
|
|
197
|
+
const refreshed = await resolveSttModel(take.preferred, { refresh: true }).catch(() => null);
|
|
198
|
+
if (refreshed) model = refreshed;
|
|
199
|
+
}
|
|
200
|
+
}
|
package/search/client.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { createSnapshotStore } from "../tools/async.js";
|
|
2
|
+
|
|
3
|
+
export const API = "/api/lares/web-search";
|
|
4
|
+
|
|
5
|
+
const settings = createSnapshotStore();
|
|
6
|
+
|
|
7
|
+
export function rememberedSearchSettings() {
|
|
8
|
+
return settings.peek();
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function loadSearchSettings(options = {}) {
|
|
12
|
+
return settings.load(() => getJson("/config"), options);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function saveSearchDefault(id) {
|
|
16
|
+
return settings.remember(await postJson("/config/default", { defaultSearchModel: id }));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function getJson(path) {
|
|
20
|
+
const res = await fetch(`${API}${path}`);
|
|
21
|
+
if (!res.ok) throw new Error(`${path} → ${res.status}`);
|
|
22
|
+
return res.json();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function postJson(path, body) {
|
|
26
|
+
const res = await fetch(`${API}${path}`, {
|
|
27
|
+
method: "POST",
|
|
28
|
+
headers: { "content-type": "application/json" },
|
|
29
|
+
body: JSON.stringify(body ?? {}),
|
|
30
|
+
});
|
|
31
|
+
const payload = await res.json().catch(() => ({}));
|
|
32
|
+
if (!res.ok) {
|
|
33
|
+
const code = payload?.error?.code ?? String(res.status);
|
|
34
|
+
const message = payload?.error?.message ?? code;
|
|
35
|
+
throw new Error(message);
|
|
36
|
+
}
|
|
37
|
+
return payload;
|
|
38
|
+
}
|
package/search/config.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { readJsonFile, writeJsonFile } from "../tools/json-file.js";
|
|
2
|
+
import { HttpError } from "../tools/http.js";
|
|
3
|
+
import { dshPluginConfigPath } from "../workspace/home.js";
|
|
4
|
+
import { fetchRouterSearchModels } from "../router/search.js";
|
|
5
|
+
|
|
6
|
+
/** @typedef {{ defaultSearchModel: string | null }} WebSearchConfig */
|
|
7
|
+
|
|
8
|
+
/** @returns {WebSearchConfig} */
|
|
9
|
+
function defaults() {
|
|
10
|
+
return { defaultSearchModel: null };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function configPath() {
|
|
14
|
+
return dshPluginConfigPath("web-search");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** @param {unknown} raw @returns {WebSearchConfig} */
|
|
18
|
+
function normalize(raw) {
|
|
19
|
+
if (!raw || typeof raw !== "object") return defaults();
|
|
20
|
+
const body = /** @type {Record<string, unknown>} */ (raw);
|
|
21
|
+
const value = typeof body.defaultSearchModel === "string" ? body.defaultSearchModel.trim() : "";
|
|
22
|
+
return {
|
|
23
|
+
defaultSearchModel: value && value.length <= 512 && !/[\u0000-\u001f\u007f]/.test(value)
|
|
24
|
+
? value
|
|
25
|
+
: null,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** @returns {WebSearchConfig} */
|
|
30
|
+
export function readConfig() {
|
|
31
|
+
return normalize(readJsonFile(configPath()));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {WebSearchConfig} config
|
|
36
|
+
* @returns {WebSearchConfig}
|
|
37
|
+
*/
|
|
38
|
+
function persist(config) {
|
|
39
|
+
const path = configPath();
|
|
40
|
+
const normalized = normalize(config);
|
|
41
|
+
writeJsonFile(path, normalized);
|
|
42
|
+
return normalized;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @param {string | null} id
|
|
47
|
+
* @param {{ id: string }[]} available
|
|
48
|
+
*/
|
|
49
|
+
export function setDefaultSearchModel(id, available) {
|
|
50
|
+
if (id === null) {
|
|
51
|
+
return persist({ defaultSearchModel: null });
|
|
52
|
+
}
|
|
53
|
+
const model = typeof id === "string" ? id.trim() : "";
|
|
54
|
+
if (
|
|
55
|
+
!model
|
|
56
|
+
|| model.length > 512
|
|
57
|
+
|| /[\u0000-\u001f\u007f]/.test(model)
|
|
58
|
+
|| !available.some((item) => item.id === model)
|
|
59
|
+
) {
|
|
60
|
+
throw new HttpError("not_available", 400, "search service is not available from Router");
|
|
61
|
+
}
|
|
62
|
+
return persist({ defaultSearchModel: model });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function currentSearchConfig() {
|
|
66
|
+
const searchModels = await fetchRouterSearchModels();
|
|
67
|
+
return {
|
|
68
|
+
defaultSearchModel: readConfig().defaultSearchModel,
|
|
69
|
+
searchModels,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export const LARES_PROVIDER_ID = "lares";
|
|
74
|
+
export { SEARCH_NONE, searchDefaultReady, searchMenuValue, searchValueFromMenu } from "./menu.js";
|
|
75
|
+
|
|
76
|
+
export function configuredSearchModel() {
|
|
77
|
+
return readConfig().defaultSearchModel;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function defaultSearchModelFromBody(body) {
|
|
81
|
+
const id = body?.defaultSearchModel === null ? null : body?.defaultSearchModel;
|
|
82
|
+
if (id !== null && typeof id !== "string") {
|
|
83
|
+
throw new HttpError("bad_model", 400, "invalid defaultSearchModel");
|
|
84
|
+
}
|
|
85
|
+
return id;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function setDefaultSearchFromRequest(id) {
|
|
89
|
+
if (id !== null && typeof id !== "string") {
|
|
90
|
+
throw new HttpError("bad_model", 400, "invalid defaultSearchModel");
|
|
91
|
+
}
|
|
92
|
+
const searchModels = await fetchRouterSearchModels();
|
|
93
|
+
const saved = setDefaultSearchModel(id, searchModels);
|
|
94
|
+
return {
|
|
95
|
+
defaultSearchModel: saved.defaultSearchModel,
|
|
96
|
+
searchModels,
|
|
97
|
+
};
|
|
98
|
+
}
|
package/search/menu.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export const SEARCH_NONE = "none";
|
|
2
|
+
|
|
3
|
+
export function searchDefaultReady(models, defaultSearchModel) {
|
|
4
|
+
return Array.isArray(models) && models.some((model) => model.id === defaultSearchModel);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function searchMenuValue(defaultSearchModel) {
|
|
8
|
+
return defaultSearchModel ?? SEARCH_NONE;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function searchValueFromMenu(id) {
|
|
12
|
+
return id === SEARCH_NONE ? null : id;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function searchModelLabel(model) {
|
|
16
|
+
return model.name || model.id;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function searchSelectorItems(models, { none, empty }) {
|
|
20
|
+
if (models.length === 0) return [{ id: SEARCH_NONE, label: empty }];
|
|
21
|
+
return [{ id: SEARCH_NONE, label: none }, ...models.map((model) => ({ id: model.id, label: searchModelLabel(model) }))];
|
|
22
|
+
}
|
package/tools/async.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export function createInFlightCoalescer() {
|
|
2
|
+
let current = null;
|
|
3
|
+
return (start) => {
|
|
4
|
+
if (current) return current;
|
|
5
|
+
const shared = Promise.resolve().then(start).finally(() => {
|
|
6
|
+
if (current === shared) current = null;
|
|
7
|
+
});
|
|
8
|
+
current = shared;
|
|
9
|
+
return shared;
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Last successful payload survives remounts; Refresh / save pass `{ force: true }`. */
|
|
14
|
+
export function createSnapshotStore() {
|
|
15
|
+
let value = null;
|
|
16
|
+
const coalesce = createInFlightCoalescer();
|
|
17
|
+
return {
|
|
18
|
+
peek() {
|
|
19
|
+
return value;
|
|
20
|
+
},
|
|
21
|
+
remember(next) {
|
|
22
|
+
value = next;
|
|
23
|
+
return next;
|
|
24
|
+
},
|
|
25
|
+
load(start, options = {}) {
|
|
26
|
+
if (!options.force && value !== null) return Promise.resolve(value);
|
|
27
|
+
return coalesce(async () => {
|
|
28
|
+
const next = await start();
|
|
29
|
+
value = next;
|
|
30
|
+
return next;
|
|
31
|
+
});
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Olares LAN zone is plain http, so browsers withhold `crypto.randomUUID`.
|
|
3
|
+
* dsh mints host-RPC ids with it; `getRandomValues` stays available.
|
|
4
|
+
*/
|
|
5
|
+
export const UUID_SHIM = `(function () {
|
|
6
|
+
var c = globalThis.crypto;
|
|
7
|
+
if (c === undefined || typeof c.randomUUID === "function") return;
|
|
8
|
+
c.randomUUID = function randomUUID() {
|
|
9
|
+
var bytes = c.getRandomValues(new Uint8Array(16));
|
|
10
|
+
bytes[6] = (bytes[6] & 15) | 64;
|
|
11
|
+
bytes[8] = (bytes[8] & 63) | 128;
|
|
12
|
+
var hex = Array.from(bytes, function (byte) {
|
|
13
|
+
return byte.toString(16).padStart(2, "0");
|
|
14
|
+
}).join("");
|
|
15
|
+
return hex.slice(0, 8) + "-" + hex.slice(8, 12) + "-" + hex.slice(12, 16) + "-" + hex.slice(16, 20) + "-" + hex.slice(20);
|
|
16
|
+
};
|
|
17
|
+
})();`;
|
|
18
|
+
|
|
19
|
+
const HEAD_OPEN = /<head[^>]*>/i;
|
|
20
|
+
|
|
21
|
+
export function injectUuidShim(html) {
|
|
22
|
+
if (html.includes("data-lares-uuid-shim")) return html;
|
|
23
|
+
const tag = `<script data-lares-uuid-shim>${UUID_SHIM}</script>`;
|
|
24
|
+
const head = HEAD_OPEN.exec(html);
|
|
25
|
+
return head === null ? `${tag}${html}` : html.replace(HEAD_OPEN, `${head[0]}${tag}`);
|
|
26
|
+
}
|
package/tools/http.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
export class HttpError extends Error {
|
|
2
|
+
constructor(code, status, message) {
|
|
3
|
+
super(message);
|
|
4
|
+
this.code = code;
|
|
5
|
+
this.status = status;
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function sendJson(res, status, payload) {
|
|
10
|
+
res.writeHead(status, { "content-type": "application/json" });
|
|
11
|
+
res.end(JSON.stringify(payload));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {import('node:http').IncomingMessage} req
|
|
16
|
+
* @param {{ maxBytes?: number, code?: string, message?: string }} [options]
|
|
17
|
+
*/
|
|
18
|
+
export function readBody(req, options = {}) {
|
|
19
|
+
const maxBytes = options.maxBytes ?? 64 * 1024;
|
|
20
|
+
return new Promise((resolve, reject) => {
|
|
21
|
+
const chunks = [];
|
|
22
|
+
let total = 0;
|
|
23
|
+
let rejected = false;
|
|
24
|
+
req.on("data", (chunk) => {
|
|
25
|
+
if (rejected) return;
|
|
26
|
+
total += chunk.length;
|
|
27
|
+
if (total > maxBytes) {
|
|
28
|
+
rejected = true;
|
|
29
|
+
reject(new HttpError(
|
|
30
|
+
options.code ?? "body_too_large",
|
|
31
|
+
413,
|
|
32
|
+
options.message ?? `body exceeds ${maxBytes} bytes`,
|
|
33
|
+
));
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
chunks.push(chunk);
|
|
37
|
+
});
|
|
38
|
+
req.on("end", () => {
|
|
39
|
+
if (!rejected) resolve(Buffer.concat(chunks));
|
|
40
|
+
});
|
|
41
|
+
req.on("error", reject);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @param {import('node:http').IncomingMessage} req
|
|
47
|
+
* @param {{ maxBytes?: number }} [options]
|
|
48
|
+
* @returns {Promise<Record<string, unknown>>}
|
|
49
|
+
*/
|
|
50
|
+
export async function readJsonObject(req, options) {
|
|
51
|
+
const raw = await readBody(req, options);
|
|
52
|
+
if (raw.length === 0) return {};
|
|
53
|
+
try {
|
|
54
|
+
const body = JSON.parse(raw.toString("utf8"));
|
|
55
|
+
if (body && typeof body === "object" && !Array.isArray(body)) return body;
|
|
56
|
+
} catch {
|
|
57
|
+
// The common error below is the public contract for every malformed body.
|
|
58
|
+
}
|
|
59
|
+
throw new HttpError("bad_request", 400, "invalid JSON body");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function errorResponse(err, fallbackCode) {
|
|
63
|
+
if (
|
|
64
|
+
err
|
|
65
|
+
&& typeof err === "object"
|
|
66
|
+
&& typeof err.code === "string"
|
|
67
|
+
&& typeof err.status === "number"
|
|
68
|
+
) {
|
|
69
|
+
return {
|
|
70
|
+
status: err.status >= 400 ? err.status : 500,
|
|
71
|
+
code: err.code,
|
|
72
|
+
message: err instanceof Error ? err.message : String(err),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
status: 500,
|
|
77
|
+
code: fallbackCode,
|
|
78
|
+
message: err instanceof Error ? err.message : String(err),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function sendError(res, err, fallbackCode) {
|
|
83
|
+
const failure = errorResponse(err, fallbackCode);
|
|
84
|
+
sendJson(res, failure.status, {
|
|
85
|
+
error: { code: failure.code, message: failure.message },
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* @param {{
|
|
91
|
+
* prefix: string,
|
|
92
|
+
* routes: Record<string, Record<string, (req: any, res: any) => Promise<void> | void>>,
|
|
93
|
+
* fallbackCode: string
|
|
94
|
+
* }} options
|
|
95
|
+
*/
|
|
96
|
+
export function createRouteHandler(options) {
|
|
97
|
+
return (req, res) => {
|
|
98
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
99
|
+
const suffix = new URL(req.url ?? "/", "http://x").pathname.slice(options.prefix.length) || "/";
|
|
100
|
+
const path = suffix.replace(/\/+$/, "") || "/";
|
|
101
|
+
const handlers = options.routes[path];
|
|
102
|
+
if (!handlers) {
|
|
103
|
+
sendJson(res, 404, {
|
|
104
|
+
error: { code: "not_found", message: `no route ${path}` },
|
|
105
|
+
});
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const route = handlers[method];
|
|
109
|
+
if (!route) {
|
|
110
|
+
sendJson(res, 405, {
|
|
111
|
+
error: { code: "method_not_allowed", message: `${method} not allowed` },
|
|
112
|
+
});
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
Promise.resolve().then(() => route(req, res)).catch((err) => {
|
|
116
|
+
if (res.headersSent) {
|
|
117
|
+
res.destroy();
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
sendError(res, err, options.fallbackCode);
|
|
121
|
+
});
|
|
122
|
+
};
|
|
123
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
|
|
4
|
+
export function readJsonFile(path) {
|
|
5
|
+
if (!existsSync(path)) return null;
|
|
6
|
+
try {
|
|
7
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
8
|
+
} catch {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function writeJsonFile(path, value) {
|
|
14
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
15
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
16
|
+
writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
17
|
+
renameSync(tmp, path);
|
|
18
|
+
}
|