@pablopunk/webr 0.1.6 → 0.2.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 +30 -1
- package/dist/client/_astro/App.1VFY1ClT.css +1 -0
- package/dist/client/_astro/App.CGyf2WDA.js +4 -0
- package/dist/server/chunks/{App_CmESMUHE.mjs → App_C2vmjHiY.mjs} +434 -65
- package/dist/server/chunks/{_id__CQzMt2xL.mjs → _id__BimjoFCd.mjs} +1 -1
- package/dist/server/chunks/{_tab__D-smYrCo.mjs → _tab__WmsbMlfC.mjs} +1 -1
- package/dist/server/chunks/{new_NXgn74q9.mjs → new_bTXSW7C3.mjs} +1 -1
- package/dist/server/chunks/{settings_D1jcCWzt.mjs → settings_DwzWmJBe.mjs} +1 -1
- package/dist/server/entry.mjs +5 -5
- package/package.json +4 -1
- package/server/cli.ts +0 -3
- package/server/plugin/stop.ts +10 -1
- package/server/run.ts +10 -3
- package/server/ssr.ts +4 -3
- package/src/server/auth/access.ts +1 -1
- package/src/server/auth/auth.ts +12 -4
- package/src/server/dev-hmr-proxy.ts +16 -0
- package/src/server/host.ts +11 -2
- package/src/server/runtime/manager.ts +7 -1
- package/src/server/runtime/reconcile.ts +5 -2
- package/src/server/tailscale-serve.ts +42 -0
- package/src/server/transport/icons.ts +1 -1
- package/src/server/voice/model.ts +35 -0
- package/src/server/voice/routes.ts +29 -0
- package/src/server/voice/transcriber.ts +80 -0
- package/src/server/voice/worker.mjs +23 -0
- package/dist/client/_astro/App.1PYvyCIo.css +0 -1
- package/dist/client/_astro/App.B3aX6cef.js +0 -2
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { r as __exportAll } from "./rolldown-runtime_BMI-E3GI.mjs";
|
|
2
2
|
import { S as createAstro, i as renderComponent, u as renderTemplate } from "./server_CnGYDmxV.mjs";
|
|
3
3
|
import { t as createComponent } from "./compiler_BiU10Vds.mjs";
|
|
4
|
-
import { n as $$Base, t as App } from "./
|
|
4
|
+
import { n as $$Base, t as App } from "./App_C2vmjHiY.mjs";
|
|
5
5
|
//#region src/pages/new.astro
|
|
6
6
|
var new_exports = /* @__PURE__ */ __exportAll({
|
|
7
7
|
default: () => $$New,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { r as __exportAll } from "./rolldown-runtime_BMI-E3GI.mjs";
|
|
2
2
|
import { S as createAstro, i as renderComponent, u as renderTemplate } from "./server_CnGYDmxV.mjs";
|
|
3
3
|
import { t as createComponent } from "./compiler_BiU10Vds.mjs";
|
|
4
|
-
import { n as $$Base, t as App } from "./
|
|
4
|
+
import { n as $$Base, t as App } from "./App_C2vmjHiY.mjs";
|
|
5
5
|
//#region src/pages/settings.astro
|
|
6
6
|
var settings_exports = /* @__PURE__ */ __exportAll({
|
|
7
7
|
default: () => $$Settings,
|
package/dist/server/entry.mjs
CHANGED
|
@@ -2427,10 +2427,10 @@ var renderers = [Object.assign({
|
|
|
2427
2427
|
//#endregion
|
|
2428
2428
|
//#region \0virtual:astro:pages
|
|
2429
2429
|
var _page0 = () => import("./chunks/node_BcIF-BVp.mjs").then((n) => n.t);
|
|
2430
|
-
var _page1 = () => import("./chunks/
|
|
2431
|
-
var _page2 = () => import("./chunks/
|
|
2432
|
-
var _page3 = () => import("./chunks/
|
|
2433
|
-
var _page4 = () => import("./chunks/
|
|
2430
|
+
var _page1 = () => import("./chunks/new_bTXSW7C3.mjs");
|
|
2431
|
+
var _page2 = () => import("./chunks/_tab__WmsbMlfC.mjs");
|
|
2432
|
+
var _page3 = () => import("./chunks/settings_DwzWmJBe.mjs");
|
|
2433
|
+
var _page4 = () => import("./chunks/_id__BimjoFCd.mjs");
|
|
2434
2434
|
var _page5 = () => import("./chunks/index_DRfLd22k.mjs");
|
|
2435
2435
|
var pageMap = /* @__PURE__ */ new Map([
|
|
2436
2436
|
["node_modules/.pnpm/astro@7.3.5_@types+node@22.20.4_tsx@4.23.15_yaml@2.9.1/node_modules/astro/dist/assets/endpoint/node.js", _page0],
|
|
@@ -2442,7 +2442,7 @@ var pageMap = /* @__PURE__ */ new Map([
|
|
|
2442
2442
|
]);
|
|
2443
2443
|
//#endregion
|
|
2444
2444
|
//#region \0virtual:astro:manifest
|
|
2445
|
-
var _manifest = deserializeManifest({"rootDir":"file:///home/runner/work/webr/webr/","cacheDir":"file:///home/runner/work/webr/webr/node_modules/.astro/","outDir":"file:///home/runner/work/webr/webr/dist/","srcDir":"file:///home/runner/work/webr/webr/src/","publicDir":"file:///home/runner/work/webr/webr/public/","buildClientDir":"file:///home/runner/work/webr/webr/dist/client/","buildServerDir":"file:///home/runner/work/webr/webr/dist/server/","adapterName":"@astrojs/node","assetsDir":"_astro","routes":[{"file":"","links":[],"scripts":[],"styles":[],"routeData":{"type":"page","component":"_server-islands.astro","params":["name"],"segments":[[{"content":"_server-islands","dynamic":false,"spread":false}],[{"content":"name","dynamic":true,"spread":false}]],"pattern":"^\\/_server-islands\\/([^/]+?)\\/?$","prerender":false,"isIndex":false,"fallbackRoutes":[],"route":"/_server-islands/[name]","origin":"internal","distURL":[],"_meta":{"trailingSlash":"ignore"}}},{"file":"","links":[],"scripts":[],"styles":[],"routeData":{"route":"/_image","component":"node_modules/.pnpm/astro@7.3.5_@types+node@22.20.4_tsx@4.23.15_yaml@2.9.1/node_modules/astro/dist/assets/endpoint/node.js","params":[],"pathname":"/_image","pattern":"^\\/_image\\/?$","segments":[[{"content":"_image","dynamic":false,"spread":false}]],"type":"endpoint","prerender":false,"fallbackRoutes":[],"distURL":[],"isIndex":false,"origin":"internal","_meta":{"trailingSlash":"ignore"}}},{"file":"","links":[],"scripts":[],"styles":[{"type":"external","src":"_astro/App.
|
|
2445
|
+
var _manifest = deserializeManifest({"rootDir":"file:///home/runner/work/webr/webr/","cacheDir":"file:///home/runner/work/webr/webr/node_modules/.astro/","outDir":"file:///home/runner/work/webr/webr/dist/","srcDir":"file:///home/runner/work/webr/webr/src/","publicDir":"file:///home/runner/work/webr/webr/public/","buildClientDir":"file:///home/runner/work/webr/webr/dist/client/","buildServerDir":"file:///home/runner/work/webr/webr/dist/server/","adapterName":"@astrojs/node","assetsDir":"_astro","routes":[{"file":"","links":[],"scripts":[],"styles":[],"routeData":{"type":"page","component":"_server-islands.astro","params":["name"],"segments":[[{"content":"_server-islands","dynamic":false,"spread":false}],[{"content":"name","dynamic":true,"spread":false}]],"pattern":"^\\/_server-islands\\/([^/]+?)\\/?$","prerender":false,"isIndex":false,"fallbackRoutes":[],"route":"/_server-islands/[name]","origin":"internal","distURL":[],"_meta":{"trailingSlash":"ignore"}}},{"file":"","links":[],"scripts":[],"styles":[],"routeData":{"route":"/_image","component":"node_modules/.pnpm/astro@7.3.5_@types+node@22.20.4_tsx@4.23.15_yaml@2.9.1/node_modules/astro/dist/assets/endpoint/node.js","params":[],"pathname":"/_image","pattern":"^\\/_image\\/?$","segments":[[{"content":"_image","dynamic":false,"spread":false}]],"type":"endpoint","prerender":false,"fallbackRoutes":[],"distURL":[],"isIndex":false,"origin":"internal","_meta":{"trailingSlash":"ignore"}}},{"file":"","links":[],"scripts":[],"styles":[{"type":"external","src":"_astro/App.1VFY1ClT.css"}],"routeData":{"route":"/new","isIndex":false,"type":"page","pattern":"^\\/new\\/?$","segments":[[{"content":"new","dynamic":false,"spread":false}]],"params":[],"component":"src/pages/new.astro","pathname":"/new","prerender":false,"fallbackRoutes":[],"distURL":[],"origin":"project","_meta":{"trailingSlash":"ignore"}}},{"file":"","links":[],"scripts":[],"styles":[{"type":"external","src":"_astro/App.1VFY1ClT.css"}],"routeData":{"route":"/sessions/[session]/tabs/[tab]","isIndex":false,"type":"page","pattern":"^\\/sessions\\/([^/]+?)\\/tabs\\/([^/]+?)\\/?$","segments":[[{"content":"sessions","dynamic":false,"spread":false}],[{"content":"session","dynamic":true,"spread":false}],[{"content":"tabs","dynamic":false,"spread":false}],[{"content":"tab","dynamic":true,"spread":false}]],"params":["session","tab"],"component":"src/pages/sessions/[session]/tabs/[tab].astro","prerender":false,"fallbackRoutes":[],"distURL":[],"origin":"project","_meta":{"trailingSlash":"ignore"}}},{"file":"","links":[],"scripts":[],"styles":[{"type":"external","src":"_astro/App.1VFY1ClT.css"}],"routeData":{"route":"/settings","isIndex":false,"type":"page","pattern":"^\\/settings\\/?$","segments":[[{"content":"settings","dynamic":false,"spread":false}]],"params":[],"component":"src/pages/settings.astro","pathname":"/settings","prerender":false,"fallbackRoutes":[],"distURL":[],"origin":"project","_meta":{"trailingSlash":"ignore"}}},{"file":"","links":[],"scripts":[],"styles":[{"type":"external","src":"_astro/App.1VFY1ClT.css"}],"routeData":{"route":"/threads/[id]","isIndex":false,"type":"page","pattern":"^\\/threads\\/([^/]+?)\\/?$","segments":[[{"content":"threads","dynamic":false,"spread":false}],[{"content":"id","dynamic":true,"spread":false}]],"params":["id"],"component":"src/pages/threads/[id].astro","prerender":false,"fallbackRoutes":[],"distURL":[],"origin":"project","_meta":{"trailingSlash":"ignore"}}},{"file":"","links":[],"scripts":[],"styles":[],"routeData":{"route":"/","isIndex":true,"type":"page","pattern":"^\\/$","segments":[],"params":[],"component":"src/pages/index.astro","pathname":"/","prerender":false,"fallbackRoutes":[],"distURL":[],"origin":"project","_meta":{"trailingSlash":"ignore"}}}],"serverLike":true,"middlewareMode":"classic","base":"/","trailingSlash":"ignore","compressHTML":"jsx","componentMetadata":[["/home/runner/work/webr/webr/src/pages/new.astro",{"propagation":"none","containsHead":true}],["/home/runner/work/webr/webr/src/pages/sessions/[session]/tabs/[tab].astro",{"propagation":"none","containsHead":true}],["/home/runner/work/webr/webr/src/pages/settings.astro",{"propagation":"none","containsHead":true}],["/home/runner/work/webr/webr/src/pages/threads/[id].astro",{"propagation":"none","containsHead":true}]],"renderers":[],"clientDirectives":[["idle","(()=>{var l=(n,t)=>{let i=async()=>{await(await n())()},e=typeof t.value==\"object\"?t.value:void 0,s={timeout:e==null?void 0:e.timeout};\"requestIdleCallback\"in window?window.requestIdleCallback(i,s):setTimeout(i,s.timeout||200)};(self.Astro||(self.Astro={})).idle=l;window.dispatchEvent(new Event(\"astro:idle\"));})();"],["load","(()=>{var e=async t=>{await(await t())()};(self.Astro||(self.Astro={})).load=e;window.dispatchEvent(new Event(\"astro:load\"));})();"],["media","(()=>{var n=(a,t)=>{let i=async()=>{await(await a())()};if(t.value){let e=matchMedia(t.value);e.matches?i():e.addEventListener(\"change\",i,{once:!0})}};(self.Astro||(self.Astro={})).media=n;window.dispatchEvent(new Event(\"astro:media\"));})();"],["only","(()=>{var e=async t=>{await(await t())()};(self.Astro||(self.Astro={})).only=e;window.dispatchEvent(new Event(\"astro:only\"));})();"],["visible","(()=>{var a=(s,i,o)=>{let r=async()=>{await(await s())()},t=typeof i.value==\"object\"?i.value:void 0,c={rootMargin:t==null?void 0:t.rootMargin},n=new IntersectionObserver(e=>{for(let l of e)if(l.isIntersecting){n.disconnect(),r();break}},c);for(let e of o.children)n.observe(e)};(self.Astro||(self.Astro={})).visible=a;window.dispatchEvent(new Event(\"astro:visible\"));})();"]],"entryModules":{"\u0000noop-middleware":"virtual_astro_middleware.mjs","\u0000virtual:astro:server-island-manifest":"chunks/_virtual_astro_server-island-manifest_C1Q2srgE.mjs","\u0000virtual:astro:session-driver":"chunks/_virtual_astro_session-driver_Dp7RXjOf.mjs","\u0000virtual:astro:actions/noop-entrypoint":"chunks/noop-entrypoint_Z3zFhrGC.mjs","/home/runner/work/webr/webr/node_modules/.pnpm/@astrojs+react@7.0.0_@types+node@22.20.4_@types+react-dom@19.3.0_@types+react@19.3.0__@_02aa85dceaccf3d763325676dd8540a8/node_modules/@astrojs/react/dist/vnode-children.js":"chunks/vnode-children_BSi0H4PL.mjs","@astrojs/node/server.js":"entry.mjs","\u0000virtual:astro:page:src/pages/threads/[id]@_@astro":"chunks/_id__BimjoFCd.mjs","\u0000virtual:astro:page:src/pages/sessions/[session]/tabs/[tab]@_@astro":"chunks/_tab__WmsbMlfC.mjs","/home/runner/work/webr/webr/node_modules/.pnpm/sharp@0.35.5_@types+node@22.20.4/node_modules/sharp/dist/index.mjs":"chunks/dist_CpTFMtqT.mjs","\u0000virtual:astro:page:src/pages/index@_@astro":"chunks/index_DRfLd22k.mjs","\u0000virtual:astro:page:src/pages/new@_@astro":"chunks/new_bTXSW7C3.mjs","\u0000virtual:astro:page:node_modules/.pnpm/astro@7.3.5_@types+node@22.20.4_tsx@4.23.15_yaml@2.9.1/node_modules/astro/dist/assets/endpoint/node@_@js":"chunks/node_BcIF-BVp.mjs","\u0000virtual:astro:page:src/pages/settings@_@astro":"chunks/settings_DwzWmJBe.mjs","/home/runner/work/webr/webr/node_modules/.pnpm/astro@7.3.5_@types+node@22.20.4_tsx@4.23.15_yaml@2.9.1/node_modules/astro/dist/assets/services/sharp.js":"chunks/sharp_qIaxzsTp.mjs","/home/runner/work/webr/webr/src/components/App.tsx":"_astro/App.CGyf2WDA.js","/home/runner/work/webr/webr/node_modules/.pnpm/astro@7.3.5_@types+node@22.20.4_tsx@4.23.15_yaml@2.9.1/node_modules/astro/components/ClientRouter.astro?astro&type=script&index=0&lang.ts":"_astro/ClientRouter.astro_astro_type_script_index_0_lang.CTgtIFyY.js","@astrojs/react/client.js":"_astro/client.BDkzSRUN.js","/home/runner/work/webr/webr/node_modules/.pnpm/@xterm+addon-fit@0.10.0_@xterm+xterm@5.5.0/node_modules/@xterm/addon-fit/lib/addon-fit.js":"_astro/addon-fit.jgKqzwky.js","/home/runner/work/webr/webr/node_modules/.pnpm/@xterm+addon-web-links@0.11.0_@xterm+xterm@5.5.0/node_modules/@xterm/addon-web-links/lib/addon-web-links.js":"_astro/addon-web-links.C9lyQRxI.js","/home/runner/work/webr/webr/node_modules/.pnpm/@xterm+xterm@5.5.0/node_modules/@xterm/xterm/lib/xterm.js":"_astro/xterm.B-l5AIf6.js","astro:scripts/before-hydration.js":""},"inlinedScripts":[],"assets":["/apple-touch-icon.png","/brand-mark.png","/favicon-16.png","/favicon-32.png","/favicon.ico","/icon-192.png","/icon-512.png","/icon-maskable-512.png","/manifest.webmanifest","/_astro/App.CGyf2WDA.js","/_astro/ClientRouter.astro_astro_type_script_index_0_lang.CTgtIFyY.js","/_astro/addon-fit.jgKqzwky.js","/_astro/addon-web-links.C9lyQRxI.js","/_astro/client.BDkzSRUN.js","/_astro/client.Bbg9OBwT.js","/_astro/client.CLSsMr2g.js","/_astro/xterm.B-l5AIf6.js","/_astro/App.1VFY1ClT.css","/_astro/jetbrains-mono.CFJaiWaY.woff2","/_astro/symbols-nerd.CEdkKAK8.woff2"],"buildFormat":"directory","checkOrigin":true,"actionBodySizeLimit":1048576,"serverIslandBodySizeLimit":1048576,"allowedDomains":[],"key":"vvK85JK/xnlGvvXG68Q9YW77Bippg1hPKqC86TYY3U8=","sessionConfig":{"driver":"unstorage/drivers/fs-lite","options":{"base":"/home/runner/work/webr/webr/node_modules/.astro/sessions"}},"image":{},"devToolbar":{"enabled":false,"debugInfoOutput":""},"logLevel":"info","shouldInjectCspMetaTags":false});
|
|
2446
2446
|
var manifestRoutes = _manifest.routes;
|
|
2447
2447
|
var manifest = Object.assign(_manifest, {
|
|
2448
2448
|
renderers,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pablopunk/webr",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Control your Herdr agents from anywhere.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"herdr",
|
|
@@ -84,5 +84,8 @@
|
|
|
84
84
|
"typescript": "5.9.3",
|
|
85
85
|
"vitest": "5.0.3",
|
|
86
86
|
"ws": "8.22.0"
|
|
87
|
+
},
|
|
88
|
+
"optionalDependencies": {
|
|
89
|
+
"sherpa-onnx-node": "^1.13.8"
|
|
87
90
|
}
|
|
88
91
|
}
|
package/server/cli.ts
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
|
-
import { join } from 'node:path';
|
|
2
1
|
import { parseArgs } from 'node:util';
|
|
3
2
|
import { renderUnicode } from 'uqr';
|
|
4
|
-
import { webrHome } from './home';
|
|
5
3
|
import { runServer } from './run';
|
|
6
4
|
import { explicitPort, knownPort } from './port';
|
|
7
5
|
import { defaultDeps, installPlugin, pluginStatus, uninstallWebrPlugin, updatePlugin } from './plugin';
|
|
@@ -48,7 +46,6 @@ function applyServerFlags(values: ReturnType<typeof parseCommand>['values']) {
|
|
|
48
46
|
if (values.port) process.env.PORT = String(explicitPort(['--port', values.port], {}));
|
|
49
47
|
if (values.host) process.env.HOST = values.host;
|
|
50
48
|
if (values.origin) process.env.WEBR_ORIGIN = values.origin;
|
|
51
|
-
process.env.WEBR_DATABASE ??= join(webrHome(), 'gateway.sqlite');
|
|
52
49
|
}
|
|
53
50
|
|
|
54
51
|
function printUpdateHint() {
|
package/server/plugin/stop.ts
CHANGED
|
@@ -7,6 +7,7 @@ type Address = Parameters<typeof isPortOpen>[0];
|
|
|
7
7
|
|
|
8
8
|
const STOP_ATTEMPTS = 50;
|
|
9
9
|
const STOP_POLL_MS = 100;
|
|
10
|
+
const START_ATTEMPTS = 100;
|
|
10
11
|
|
|
11
12
|
const pidPath = (stateDir: string) => join(stateDir, PID_FILE);
|
|
12
13
|
const sleep = (ms: number) => new Promise((done) => setTimeout(done, ms));
|
|
@@ -30,10 +31,18 @@ async function waitUntilClosed(address: Address) {
|
|
|
30
31
|
return false;
|
|
31
32
|
}
|
|
32
33
|
|
|
34
|
+
async function waitUntilListeningWhileAlive(pid: number, address: Address) {
|
|
35
|
+
for (let attempt = 0; attempt < START_ATTEMPTS && isAlive(pid); attempt++) {
|
|
36
|
+
if (await isPortOpen(address)) return true;
|
|
37
|
+
await sleep(STOP_POLL_MS);
|
|
38
|
+
}
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
|
|
33
42
|
export async function stopPluginWebr(stateDir: string, address: Address) {
|
|
34
43
|
const pid = recordedPid(stateDir);
|
|
35
44
|
rmSync(pidPath(stateDir), { force: true });
|
|
36
|
-
if (!pid || !isAlive(pid) || !(await
|
|
45
|
+
if (!pid || !isAlive(pid) || !(await waitUntilListeningWhileAlive(pid, address))) return false;
|
|
37
46
|
process.kill(pid, 'SIGTERM');
|
|
38
47
|
return waitUntilClosed(address);
|
|
39
48
|
}
|
package/server/run.ts
CHANGED
|
@@ -6,8 +6,13 @@ import { HerdrTarget } from '../src/server/transport/herdr-target';
|
|
|
6
6
|
import { createHost } from '../src/server/host';
|
|
7
7
|
import { publicUrls } from '../src/server/public-urls';
|
|
8
8
|
import { detectTailscaleHost } from '../src/server/tailscale-host';
|
|
9
|
+
import { ensureTailscaleHttps } from '../src/server/tailscale-serve';
|
|
9
10
|
import { isLoopbackHost } from '../src/server/auth/access';
|
|
10
11
|
import { resolvePort } from './port';
|
|
12
|
+
import { webrHome } from './home';
|
|
13
|
+
import { Transcriber } from '../src/server/voice/transcriber';
|
|
14
|
+
import { modelDirectory } from '../src/server/voice/model';
|
|
15
|
+
import { join } from 'node:path';
|
|
11
16
|
import { builtSsr, devSsr } from './ssr';
|
|
12
17
|
|
|
13
18
|
const WILDCARD_HOSTS = ['0.0.0.0', '::'];
|
|
@@ -20,13 +25,15 @@ export async function runServer() {
|
|
|
20
25
|
if (new URL(origin).origin !== origin || new URL(origin).username || new URL(origin).password) throw new Error('WEBR_ORIGIN must be a normalized HTTP or HTTPS origin without credentials or a path');
|
|
21
26
|
if (!['http:', 'https:'].includes(new URL(origin).protocol)) throw new Error('WEBR_ORIGIN must use HTTP or HTTPS');
|
|
22
27
|
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('Invalid port');
|
|
23
|
-
const database = new MetadataDatabase(process.env.WEBR_DATABASE ?? '
|
|
28
|
+
const database = new MetadataDatabase(process.env.WEBR_DATABASE ?? join(webrHome(), 'gateway.sqlite'));
|
|
24
29
|
const profiles = await loadRegistry(process.env.WEBR_TARGETS);
|
|
25
30
|
const manager = new RuntimeManager(database, profiles.map((profile) => new HerdrTarget(profile)));
|
|
26
31
|
const ssr = process.env.WEBR_DEV === '1' ? await devSsr(port, host) : await builtSsr();
|
|
27
32
|
const tls = tlsEnabled ? { cert: await readFile(process.env.WEBR_TLS_CERT!), key: await readFile(process.env.WEBR_TLS_KEY!) } : undefined;
|
|
28
|
-
const
|
|
29
|
-
const
|
|
33
|
+
const managesTailscale = !process.env.WEBR_ORIGIN && [...WILDCARD_HOSTS, '127.0.0.1'].includes(host);
|
|
34
|
+
const tailscale = managesTailscale ? await ensureTailscaleHttps(port) : undefined;
|
|
35
|
+
const urls = [...(tailscale?.status === 'ready' ? [tailscale.url] : []), ...publicUrls(origin, host, port, undefined, await detectTailscaleHost())];
|
|
36
|
+
const app = await createHost(manager, origin, ssr.handler, tls, undefined, { publicUrls: urls, tailscale: tailscale?.status }, { devHmrPort: ssr.hmrPort, transcriber: new Transcriber(modelDirectory(webrHome())) });
|
|
30
37
|
await app.listen({ host, port });
|
|
31
38
|
manager.start();
|
|
32
39
|
console.log(`Webr listening at ${origin}`);
|
package/server/ssr.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { resolve } from 'node:path';
|
|
2
2
|
import { pathToFileURL } from 'node:url';
|
|
3
3
|
import { distRoot } from '../src/server/dist';
|
|
4
|
+
import { HMR_PATH } from '../src/server/dev-hmr-proxy';
|
|
4
5
|
|
|
5
6
|
type Locals = Record<string, unknown>;
|
|
6
|
-
export type Ssr = { handler: (request: object, response: unknown, next: unknown, locals: Locals) => void; stop: () => Promise<void
|
|
7
|
+
export type Ssr = { handler: (request: object, response: unknown, next: unknown, locals: Locals) => void; stop: () => Promise<void>; hmrPort?: number };
|
|
7
8
|
|
|
8
9
|
const astroLocalsSymbol = Symbol.for('astro.locals');
|
|
9
10
|
|
|
@@ -14,10 +15,10 @@ export async function builtSsr(): Promise<Ssr> {
|
|
|
14
15
|
|
|
15
16
|
export async function devSsr(port: number, host = '127.0.0.1'): Promise<Ssr> {
|
|
16
17
|
const { dev } = await import('astro');
|
|
17
|
-
const astro = await dev({ server: { port: port + 2, host }, vite: { server: { allowedHosts: true, hmr: {
|
|
18
|
+
const astro = await dev({ server: { port: port + 2, host }, vite: { server: { allowedHosts: true, hmr: { path: HMR_PATH } } } });
|
|
18
19
|
const handler: Ssr['handler'] = (request, response, _next, locals) => {
|
|
19
20
|
Reflect.set(request, astroLocalsSymbol, locals);
|
|
20
21
|
astro.handle(request as never, response as never);
|
|
21
22
|
};
|
|
22
|
-
return { handler, stop: () => astro.stop() };
|
|
23
|
+
return { handler, stop: () => astro.stop(), hmrPort: port + 2 };
|
|
23
24
|
}
|
|
@@ -4,7 +4,7 @@ const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]']);
|
|
|
4
4
|
const FORWARDING_HEADERS = ['x-forwarded-for', 'x-forwarded-host', 'x-forwarded-proto', 'x-real-ip', 'forwarded', 'via'];
|
|
5
5
|
|
|
6
6
|
const hostnameOf = (host: string | undefined) => { try { return new URL(`http://${host}`).hostname; } catch { return undefined; } };
|
|
7
|
-
const isLoopbackAddress = (address: string | undefined) => !!address && (address === '::1' || address.startsWith('127.') || address.startsWith('::ffff:127.'));
|
|
7
|
+
export const isLoopbackAddress = (address: string | undefined) => !!address && (address === '::1' || address.startsWith('127.') || address.startsWith('::ffff:127.'));
|
|
8
8
|
const isForwarded = (headers: IncomingHttpHeaders) => FORWARDING_HEADERS.some((name) => name in headers) || Object.keys(headers).some((name) => name.startsWith('tailscale-'));
|
|
9
9
|
const isIpLiteral = (hostname: string) => hostname.startsWith('[') || /^\d{1,3}(\.\d{1,3}){3}$/.test(hostname);
|
|
10
10
|
const isNameControlledByNetwork = (hostname: string) => hostname.endsWith('.local') || hostname.endsWith('.ts.net');
|
package/src/server/auth/auth.ts
CHANGED
|
@@ -2,14 +2,14 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
|
|
2
2
|
import type { WebSocket } from 'ws';
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
import type { MetadataDatabase } from '../storage/database';
|
|
5
|
-
import { isLocalRequest, sourceAddress } from './access';
|
|
5
|
+
import { isLocalRequest, isLoopbackAddress, sourceAddress } from './access';
|
|
6
6
|
import { AuditLog } from './audit';
|
|
7
7
|
import { ApproverPresence } from './presence';
|
|
8
8
|
import { pairPage } from './pair-page';
|
|
9
9
|
import { PairingService, RateLimiter, formatInviteToken } from './pairing';
|
|
10
10
|
import { SessionStore, clearedSessionCookie, deviceNameFrom, sessionCookie, type DeviceSession } from './sessions';
|
|
11
11
|
|
|
12
|
-
export type AuthOptions = { publicUrls?: string[] };
|
|
12
|
+
export type AuthOptions = { publicUrls?: string[]; tailscale?: 'ready' | 'https-disabled' | 'unavailable' };
|
|
13
13
|
export type Access = { local: boolean; session?: DeviceSession };
|
|
14
14
|
|
|
15
15
|
const publicRoutes: [method: string, path: RegExp][] = [
|
|
@@ -47,8 +47,15 @@ export class Auth {
|
|
|
47
47
|
return reply.code(401).send({ error: 'unauthorized' });
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
private isHttpsThroughLocalProxy(request: FastifyRequest) { return request.headers['x-forwarded-proto'] === 'https' && isLoopbackAddress(request.socket.remoteAddress); }
|
|
51
|
+
|
|
52
|
+
private protocolOf(request: FastifyRequest) {
|
|
53
|
+
if (request.headers.host === this.origin.host) return this.origin.protocol;
|
|
54
|
+
return this.isHttpsThroughLocalProxy(request) ? 'https:' : `${request.protocol}:`;
|
|
55
|
+
}
|
|
56
|
+
|
|
50
57
|
expectedOrigin(request: FastifyRequest) {
|
|
51
|
-
return `${
|
|
58
|
+
return `${this.protocolOf(request)}//${request.headers.host}`;
|
|
52
59
|
}
|
|
53
60
|
|
|
54
61
|
routes(app: FastifyInstance) {
|
|
@@ -97,6 +104,7 @@ export class Auth {
|
|
|
97
104
|
this.audit.record('invite_created', { source: sourceOf(request) });
|
|
98
105
|
return { token: formatInviteToken(invite.token), expiresAt: invite.expiresAt, urls: (this.options.publicUrls ?? []).map((base) => `${base}/#token=${invite.token}`) };
|
|
99
106
|
});
|
|
107
|
+
app.get('/api/remote/tailscale', async () => ({ status: this.options.tailscale ?? 'ready' }));
|
|
100
108
|
app.get('/api/audit', async () => this.audit.recent());
|
|
101
109
|
app.get('/api/devices', async (request) => this.sessions.list().map((device) => ({ ...device, current: device.id === this.access(request).session?.id })));
|
|
102
110
|
app.post('/api/devices/:id/revoke', async (request, reply) => {
|
|
@@ -120,7 +128,7 @@ export class Auth {
|
|
|
120
128
|
return publicRoutes.some(([method, pattern]) => method === request.method && pattern.test(path));
|
|
121
129
|
}
|
|
122
130
|
|
|
123
|
-
private isSecure(request: FastifyRequest) { return this.
|
|
131
|
+
private isSecure(request: FastifyRequest) { return this.protocolOf(request) === 'https:'; }
|
|
124
132
|
|
|
125
133
|
private grant(request: FastifyRequest, reply: FastifyReply, deviceName: string) {
|
|
126
134
|
reply.header('Set-Cookie', sessionCookie(this.sessions.create(deviceName).token, this.isSecure(request)));
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { FastifyInstance } from 'fastify';
|
|
2
|
+
import { WebSocket } from 'ws';
|
|
3
|
+
|
|
4
|
+
export const HMR_PATH = '/__vite_hmr';
|
|
5
|
+
const HMR_PROTOCOL = 'vite-hmr';
|
|
6
|
+
|
|
7
|
+
export function registerDevHmrProxy(app: FastifyInstance, vitePort: number) {
|
|
8
|
+
app.get(HMR_PATH, { websocket: true }, (browser) => {
|
|
9
|
+
const vite = new WebSocket(`ws://127.0.0.1:${vitePort}${HMR_PATH}`, HMR_PROTOCOL);
|
|
10
|
+
const forwardedWhileConnecting: [Buffer, boolean][] = [];
|
|
11
|
+
browser.on('message', (data, isBinary) => { if (vite.readyState === WebSocket.OPEN) vite.send(data, { binary: isBinary }); else forwardedWhileConnecting.push([data as Buffer, isBinary]); });
|
|
12
|
+
vite.on('open', () => forwardedWhileConnecting.forEach(([data, isBinary]) => vite.send(data, { binary: isBinary })));
|
|
13
|
+
vite.on('message', (data, isBinary) => browser.send(data, { binary: isBinary }));
|
|
14
|
+
for (const [from, to] of [[browser, vite], [vite, browser]] as const) { from.on('close', () => to.close()); from.on('error', () => to.close()); }
|
|
15
|
+
});
|
|
16
|
+
}
|
package/src/server/host.ts
CHANGED
|
@@ -11,12 +11,15 @@ import { createWorkspace } from './runtime/workspace';
|
|
|
11
11
|
import { perfLog, perfLogEnabled } from './perf-log';
|
|
12
12
|
import { Auth, type AuthOptions } from './auth/auth';
|
|
13
13
|
import { isTrustedHost } from './auth/access';
|
|
14
|
+
import { registerDevHmrProxy } from './dev-hmr-proxy';
|
|
15
|
+
import { registerVoiceRoutes } from './voice/routes';
|
|
16
|
+
import type { Transcriber } from './voice/transcriber';
|
|
14
17
|
import { MAX_IMAGE_BYTES, UploadStore, uploadContentTypes } from './uploads';
|
|
15
18
|
|
|
16
19
|
const localOwner = 'local';
|
|
17
20
|
type SsrHandler = (request: FastifyRequest['raw'], response: import('node:http').ServerResponse, next: (error?: unknown) => void, locals: Record<string, unknown>) => void;
|
|
18
21
|
|
|
19
|
-
export async function createHost(manager: RuntimeManager, origin: string, ssr?: SsrHandler, tls?: { key: Buffer; cert: Buffer }, uploads = new UploadStore(), authOptions: AuthOptions = {}) {
|
|
22
|
+
export async function createHost(manager: RuntimeManager, origin: string, ssr?: SsrHandler, tls?: { key: Buffer; cert: Buffer }, uploads = new UploadStore(), authOptions: AuthOptions = {}, { devHmrPort, transcriber }: { devHmrPort?: number; transcriber?: Transcriber } = {}) {
|
|
20
23
|
const app = Fastify({ logger: false, bodyLimit: 16 * 1024, ...(tls ? { https: tls } : {}), requestTimeout: 10_000 });
|
|
21
24
|
const configuredOrigin = new URL(origin);
|
|
22
25
|
const auth = new Auth(manager.database, configuredOrigin, authOptions);
|
|
@@ -43,6 +46,8 @@ export async function createHost(manager: RuntimeManager, origin: string, ssr?:
|
|
|
43
46
|
await app.register(websocket, { options: { maxPayload: 32 * 1024, perMessageDeflate: false } });
|
|
44
47
|
const hub = registerWebsockets(app, manager);
|
|
45
48
|
auth.routes(app);
|
|
49
|
+
if (devHmrPort) registerDevHmrProxy(app, devHmrPort);
|
|
50
|
+
if (transcriber) registerVoiceRoutes(app, transcriber);
|
|
46
51
|
app.get('/api/catalog/machines', async () => manager.bootstrap().machines);
|
|
47
52
|
app.get('/api/catalog/:machineId', async (request) => {
|
|
48
53
|
const { machineId } = z.object({ machineId: opaqueId }).parse(request.params);
|
|
@@ -115,6 +120,10 @@ export async function createHost(manager: RuntimeManager, origin: string, ssr?:
|
|
|
115
120
|
const input = z.object({ machineId: opaqueId, archived: z.boolean() }).strict().parse(request.body);
|
|
116
121
|
manager.archive(input.machineId, id, input.archived); return { archived: input.archived };
|
|
117
122
|
});
|
|
123
|
+
app.post('/api/projects/rename', async (request) => {
|
|
124
|
+
const input = z.object({ machineId: opaqueId, logicalId: z.string().min(1).max(1000), name: z.string().trim().min(1).max(90).refine((name) => !/[\x00-\x1f\x7f]/.test(name)) }).strict().parse(request.body);
|
|
125
|
+
manager.renameProject(input.machineId, input.logicalId, input.name); return { name: input.name };
|
|
126
|
+
});
|
|
118
127
|
app.post('/api/threads/:id/rename', async (request) => {
|
|
119
128
|
const { id } = z.object({ id: z.uuid() }).parse(request.params);
|
|
120
129
|
const input = z.object({ machineId: opaqueId, title: z.string().trim().min(1).max(90).refine((title) => !/[\x00-\x1f\x7f]/.test(title)) }).strict().parse(request.body);
|
|
@@ -160,6 +169,6 @@ export async function createHost(manager: RuntimeManager, origin: string, ssr?:
|
|
|
160
169
|
ssr(request.raw, reply.raw, () => { if (!reply.raw.headersSent) { reply.raw.statusCode = 404; reply.raw.end('Not found'); } }, { runtime: manager, accountId: localOwner });
|
|
161
170
|
});
|
|
162
171
|
}
|
|
163
|
-
app.addHook('onClose', async () => manager.close());
|
|
172
|
+
app.addHook('onClose', async () => { transcriber?.close(); return manager.close(); });
|
|
164
173
|
return app;
|
|
165
174
|
}
|
|
@@ -3,7 +3,7 @@ import type { TargetAdapter, TerminalDirection } from './target';
|
|
|
3
3
|
import type { Thread } from '../../lib/models';
|
|
4
4
|
import { threadAgent, threadShell } from '../../lib/terminal-split';
|
|
5
5
|
import { TargetSupervisor } from './supervisor';
|
|
6
|
-
import { reconcile } from './reconcile';
|
|
6
|
+
import { projectNameKey, reconcile } from './reconcile';
|
|
7
7
|
import type { MetadataDatabase } from '../storage/database';
|
|
8
8
|
import type { Bootstrap, Projection } from '../../shared/runtime';
|
|
9
9
|
import type { Machine } from '../../lib/machines';
|
|
@@ -109,6 +109,12 @@ export class RuntimeManager extends EventEmitter {
|
|
|
109
109
|
this.database.saveThread({ ...row.metadata, archivedAt: archived ? new Date().toISOString() : undefined }, row.anchors, row.alias);
|
|
110
110
|
this.publish(supervisor);
|
|
111
111
|
}
|
|
112
|
+
renameProject(machineId: string, logicalId: string, name: string) {
|
|
113
|
+
const supervisor = this.supervisors.get(machineId);
|
|
114
|
+
if (!supervisor) throw new Error('machine_disconnected');
|
|
115
|
+
this.database.setSetting(projectNameKey(machineId, logicalId), name);
|
|
116
|
+
this.publish(supervisor);
|
|
117
|
+
}
|
|
112
118
|
async renameThread(machineId: string, threadId: string, title: string) {
|
|
113
119
|
const supervisor = this.supervisors.get(machineId);
|
|
114
120
|
const thread = this.projections.get(machineId)?.threads.find((thread) => thread.id === threadId);
|
|
@@ -4,9 +4,11 @@ import type { NativeSnapshot } from '../protocol/native';
|
|
|
4
4
|
import type { Project, Thread } from '../../lib/models';
|
|
5
5
|
import type { TargetAdapter } from './target';
|
|
6
6
|
|
|
7
|
+
export const projectNameKey = (machineId: string, logicalId: string) => `project-name:${machineId}:${logicalId}`;
|
|
7
8
|
export const projectIdentity = (machineId: string, repository: string) => createHash('sha256').update(machineId + '\0' + repository).digest('hex').slice(0, 32);
|
|
8
9
|
const sameBinding = (thread: Thread, target: TargetAdapter) => thread.session === target.session && thread.bindingFingerprint === target.fingerprint && thread.bindingConfigVersion === target.configVersion;
|
|
9
10
|
const paneStatus = (panes: NativeSnapshot['panes']) => { const agents = panes.filter((pane) => pane.agent); return ['blocked', 'working', 'done', 'idle', 'unknown'].find((status) => (agents.length ? agents : panes).some((pane) => pane.agent_status === status)) as Thread['status'] | undefined; };
|
|
11
|
+
const readingAwareStatus = (status: string) => status === 'done' ? 'idle' : status;
|
|
10
12
|
const threadTitle = (label: string | null | undefined, panes: NativeSnapshot['panes'], fallback = 'Terminal') => {
|
|
11
13
|
const namedTab = label?.trim();
|
|
12
14
|
if (namedTab && !/^\d+$/.test(namedTab)) return namedTab;
|
|
@@ -22,7 +24,8 @@ export function reconcile(database: MetadataDatabase, target: TargetAdapter, sna
|
|
|
22
24
|
const cwd = workspace.worktree?.repo_root ?? snapshot.panes.find((pane) => pane.workspace_id === workspace.workspace_id)?.cwd ?? '';
|
|
23
25
|
const location = target.locations.find((location) => location.workspaceId === workspace.workspace_id || location.path === cwd);
|
|
24
26
|
const id = location?.projectId ?? projectIdentity(target.id, workspace.worktree?.repo_key ?? (cwd || workspace.workspace_id));
|
|
25
|
-
|
|
27
|
+
const logicalId = location?.logicalId ?? workspace.worktree?.repo_key ?? id;
|
|
28
|
+
if (!projects.some((project) => project.id === id)) projects.push({ id, machineId: target.id, localId: location?.localId, logicalId, name: database.getSetting(projectNameKey(target.id, logicalId)) ?? workspace.worktree?.repo_name ?? workspace.label, path: location?.path ?? cwd, color: '#9b83df', initial: (workspace.label[0] ?? 'P').toUpperCase(), iconUrl: target.icon && location ? `/api/projects/${encodeURIComponent(target.id)}/${encodeURIComponent(id)}/icon` : undefined });
|
|
26
29
|
for (const tab of snapshot.tabs.filter((tab) => tab.workspace_id === workspace.workspace_id)) tabProjects.set(tab.tab_id, id);
|
|
27
30
|
}
|
|
28
31
|
const launching = database.operations().some((operation) => operation.input.machineId === target.id && ['pending', 'running'].includes(operation.state));
|
|
@@ -40,7 +43,7 @@ export function reconcile(database: MetadataDatabase, target: TargetAdapter, sna
|
|
|
40
43
|
const members = tab ? snapshot.panes.filter((pane) => pane.tab_id === tab.tab_id && pane.workspace_id === tab.workspace_id) : [];
|
|
41
44
|
const attached = sameBinding(row.metadata, target) && members.length > 0;
|
|
42
45
|
const panes = attached ? members : [];
|
|
43
|
-
const signature = JSON.stringify([tab?.label, panes.map((pane) => [pane.terminal_id, pane.pane_id, pane.agent, pane.agent_status, pane.title, pane.terminal_title_stripped
|
|
46
|
+
const signature = JSON.stringify([tab?.label, panes.map((pane) => [pane.terminal_id, pane.pane_id, pane.agent, readingAwareStatus(pane.agent_status), pane.title, pane.terminal_title_stripped])]);
|
|
44
47
|
const meaningfulChange = attached && row.metadata.semanticSignature !== signature;
|
|
45
48
|
const operation = operations.find((operation) => operation.threadId === row.id);
|
|
46
49
|
const thread: Thread = { ...row.metadata, title: attached ? threadTitle(tab?.label, panes, row.metadata.title) : row.metadata.title, projectId: attached ? tabProjects.get(tab!.tab_id) ?? row.metadata.projectId : row.metadata.projectId, bindingState: attached ? 'attached' : operation && !row.anchors.length ? 'pending' : 'detached', status: attached ? paneStatus(panes) ?? 'unknown' : 'unknown', agent: attached ? panes.find((pane) => pane.agent)?.agent ?? '' : row.metadata.agent, semanticSignature: attached ? signature : row.metadata.semanticSignature, updatedAt: meaningfulChange ? new Date().toISOString() : row.metadata.updatedAt, panes: panes.map((pane) => ({ id: pane.pane_id, terminalId: pane.terminal_id, title: pane.title ?? pane.terminal_title_stripped ?? pane.agent ?? 'Shell', kind: pane.agent ? 'agent' : 'shell' })), operation: operation ? { id: operation.id, state: operation.state, step: operation.step } : undefined };
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
|
|
4
|
+
const run = promisify(execFile);
|
|
5
|
+
const TAILSCALE_BINARIES = ['tailscale', '/Applications/Tailscale.app/Contents/MacOS/Tailscale'];
|
|
6
|
+
const HTTPS_PORTS = [443, 8443, 10000];
|
|
7
|
+
|
|
8
|
+
type ServeStatus = { TCP?: Record<string, unknown>; Web?: Record<string, { Handlers?: Record<string, { Proxy?: string }> }> };
|
|
9
|
+
type TailscaleStatus = { Self?: { DNSName?: string }; CertDomains?: string[] };
|
|
10
|
+
|
|
11
|
+
const withoutTrailingDot = (name: string) => name.replace(/\.$/, '');
|
|
12
|
+
const portOfProxy = (proxy: string | undefined) => { try { return Number(new URL(proxy ?? '').port); } catch { return undefined; } };
|
|
13
|
+
const proxiesTo = (status: ServeStatus, hostPort: string, port: number) => Object.values(status.Web?.[hostPort]?.Handlers ?? {}).some((handler) => portOfProxy(handler.Proxy) === port);
|
|
14
|
+
const isFree = (status: ServeStatus, name: string, httpsPort: number) => !(String(httpsPort) in (status.TCP ?? {})) && !(`${name}:${httpsPort}` in (status.Web ?? {}));
|
|
15
|
+
const urlFor = (name: string, httpsPort: number) => `https://${name}${httpsPort === 443 ? '' : `:${httpsPort}`}`;
|
|
16
|
+
|
|
17
|
+
export function choosePort(status: ServeStatus, name: string, webrPort: number) {
|
|
18
|
+
const reusable = HTTPS_PORTS.find((httpsPort) => proxiesTo(status, `${name}:${httpsPort}`, webrPort));
|
|
19
|
+
return reusable !== undefined ? { port: reusable, exists: true } : { port: HTTPS_PORTS.find((httpsPort) => isFree(status, name, httpsPort)), exists: false };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function tailscale(args: string[]) {
|
|
23
|
+
for (const binary of TAILSCALE_BINARIES) {
|
|
24
|
+
try { return (await run(binary, args, { timeout: 10_000 })).stdout; } catch { /* try the next binary */ }
|
|
25
|
+
}
|
|
26
|
+
throw new Error('tailscale unavailable');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type TailscaleHttps = { status: 'ready'; url: string } | { status: 'https-disabled' | 'unavailable' };
|
|
30
|
+
|
|
31
|
+
export async function ensureTailscaleHttps(webrPort: number): Promise<TailscaleHttps> {
|
|
32
|
+
try {
|
|
33
|
+
const { Self, CertDomains } = JSON.parse(await tailscale(['status', '--json'])) as TailscaleStatus;
|
|
34
|
+
const name = Self?.DNSName && withoutTrailingDot(Self.DNSName);
|
|
35
|
+
if (!name) return { status: 'unavailable' };
|
|
36
|
+
if (!CertDomains?.includes(name)) return { status: 'https-disabled' };
|
|
37
|
+
const { port, exists } = choosePort(JSON.parse(await tailscale(['serve', 'status', '--json'])) as ServeStatus, name, webrPort);
|
|
38
|
+
if (port === undefined) return { status: 'unavailable' };
|
|
39
|
+
if (!exists) await tailscale(['serve', '--bg', `--https=${port}`, `http://127.0.0.1:${webrPort}`]);
|
|
40
|
+
return { status: 'ready', url: urlFor(name, port) };
|
|
41
|
+
} catch { return { status: 'unavailable' }; }
|
|
42
|
+
}
|
|
@@ -2,7 +2,7 @@ import { readdir, realpath, stat, readFile } from 'node:fs/promises';
|
|
|
2
2
|
import { basename, dirname, extname, isAbsolute, join, relative, sep } from 'node:path';
|
|
3
3
|
|
|
4
4
|
const contentTypes: Record<string, string> = { '.ico': 'image/x-icon', '.png': 'image/png', '.svg': 'image/svg+xml', '.webp': 'image/webp' };
|
|
5
|
-
const directories = ['', 'public', 'static', 'assets', 'web/public', 'app/public', 'src/assets', 'src/app', 'frontend/public'];
|
|
5
|
+
const directories = ['', 'public', 'static', 'assets', 'web', 'web/public', 'app/public', 'src/assets', 'src/app', 'frontend/public'];
|
|
6
6
|
export type Icon = { bytes: Buffer; contentType: string };
|
|
7
7
|
|
|
8
8
|
function withinRoot(root: string, path: string) {
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { createWriteStream } from 'node:fs';
|
|
2
|
+
import { mkdir, rename, rm, stat } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { Readable } from 'node:stream';
|
|
5
|
+
import { pipeline } from 'node:stream/promises';
|
|
6
|
+
|
|
7
|
+
const REPOSITORY = 'https://huggingface.co/csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8/resolve/main';
|
|
8
|
+
const MODEL_FILES = { encoder: 'encoder.int8.onnx', decoder: 'decoder.int8.onnx', joiner: 'joiner.int8.onnx', tokens: 'tokens.txt' } as const;
|
|
9
|
+
|
|
10
|
+
export type ModelFiles = Record<keyof typeof MODEL_FILES, string>;
|
|
11
|
+
|
|
12
|
+
export const modelDirectory = (home: string) => join(home, 'models', 'parakeet-tdt-0.6b-v3-int8');
|
|
13
|
+
export const modelFiles = (directory: string) => Object.fromEntries(Object.entries(MODEL_FILES).map(([key, name]) => [key, join(directory, name)])) as ModelFiles;
|
|
14
|
+
|
|
15
|
+
const exists = (path: string) => stat(path).then((entry) => entry.size > 0, () => false);
|
|
16
|
+
|
|
17
|
+
export async function isModelDownloaded(directory: string) {
|
|
18
|
+
return (await Promise.all(Object.values(modelFiles(directory)).map(exists))).every(Boolean);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function downloadFile(url: string, destination: string) {
|
|
22
|
+
if (await exists(destination)) return;
|
|
23
|
+
const response = await fetch(url);
|
|
24
|
+
if (!response.ok || !response.body) throw new Error(`Could not download ${url}: HTTP ${response.status}`);
|
|
25
|
+
const partial = `${destination}.part`;
|
|
26
|
+
try {
|
|
27
|
+
await pipeline(Readable.fromWeb(response.body as never), createWriteStream(partial));
|
|
28
|
+
await rename(partial, destination);
|
|
29
|
+
} catch (error) { await rm(partial, { force: true }); throw error; }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function downloadModel(directory: string, repository = REPOSITORY) {
|
|
33
|
+
await mkdir(directory, { recursive: true });
|
|
34
|
+
for (const name of Object.values(MODEL_FILES)) await downloadFile(`${repository}/${name}`, join(directory, name));
|
|
35
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { FastifyInstance } from 'fastify';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import type { Transcriber } from './transcriber';
|
|
4
|
+
|
|
5
|
+
const PCM_CONTENT_TYPE = 'audio/l16';
|
|
6
|
+
const MAX_SECONDS = 600;
|
|
7
|
+
const MAX_SAMPLE_RATE = 48_000;
|
|
8
|
+
export const MAX_VOICE_BYTES = MAX_SECONDS * MAX_SAMPLE_RATE * 2;
|
|
9
|
+
|
|
10
|
+
const int16ToFloat32 = (pcm: Buffer) => {
|
|
11
|
+
const samples = new Float32Array(Math.floor(pcm.length / 2));
|
|
12
|
+
for (let index = 0; index < samples.length; index++) samples[index] = pcm.readInt16LE(index * 2) / 32768;
|
|
13
|
+
return samples;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function registerVoiceRoutes(app: FastifyInstance, transcriber: Transcriber) {
|
|
17
|
+
app.addContentTypeParser(PCM_CONTENT_TYPE, { parseAs: 'buffer', bodyLimit: MAX_VOICE_BYTES }, (_request, body, done) => done(null, body));
|
|
18
|
+
app.get('/api/voice', async () => ({ state: await transcriber.state() }));
|
|
19
|
+
app.post('/api/voice/warm', async () => {
|
|
20
|
+
void transcriber.warm().catch(() => undefined);
|
|
21
|
+
return { state: await transcriber.state() };
|
|
22
|
+
});
|
|
23
|
+
app.post('/api/voice/transcribe', { bodyLimit: MAX_VOICE_BYTES }, async (request, reply) => {
|
|
24
|
+
const { sampleRate } = z.object({ sampleRate: z.coerce.number().int().min(8000).max(MAX_SAMPLE_RATE) }).strict().parse(request.query);
|
|
25
|
+
if (!Buffer.isBuffer(request.body) || !request.body.length) return reply.code(400).send({ error: 'invalid_request' });
|
|
26
|
+
if (await transcriber.state() === 'unavailable') return reply.code(409).send({ error: 'voice_unavailable' });
|
|
27
|
+
return { text: await transcriber.transcribe(int16ToFloat32(request.body), sampleRate) };
|
|
28
|
+
});
|
|
29
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { fork, type ChildProcess } from 'node:child_process';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { downloadModel, isModelDownloaded, modelFiles } from './model';
|
|
5
|
+
|
|
6
|
+
export type VoiceState = 'unavailable' | 'needs-download' | 'downloading' | 'ready';
|
|
7
|
+
type Reply = { id: number; text?: string; error?: string };
|
|
8
|
+
type Pending = { resolve: (text: string) => void; reject: (error: Error) => void };
|
|
9
|
+
|
|
10
|
+
const IDLE_UNLOAD_MS = 5 * 60 * 1000;
|
|
11
|
+
const WORKER_PATH = fileURLToPath(new URL('./worker.mjs', import.meta.url));
|
|
12
|
+
|
|
13
|
+
const recognizerInstalled = () => { try { createRequire(import.meta.url).resolve('sherpa-onnx-node'); return true; } catch { return false; } };
|
|
14
|
+
|
|
15
|
+
export class Transcriber {
|
|
16
|
+
private download?: Promise<void>;
|
|
17
|
+
private worker?: Promise<ChildProcess>;
|
|
18
|
+
private pending = new Map<number, Pending>();
|
|
19
|
+
private nextId = 0;
|
|
20
|
+
private idleTimer?: ReturnType<typeof setTimeout>;
|
|
21
|
+
private readonly installed: boolean;
|
|
22
|
+
|
|
23
|
+
constructor(private readonly directory: string, private readonly idleUnloadMs = IDLE_UNLOAD_MS, installed = recognizerInstalled()) {
|
|
24
|
+
this.installed = installed;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async state(): Promise<VoiceState> {
|
|
28
|
+
if (!this.installed) return 'unavailable';
|
|
29
|
+
if (this.download) return 'downloading';
|
|
30
|
+
return await isModelDownloaded(this.directory) ? 'ready' : 'needs-download';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
warm() {
|
|
34
|
+
if (!this.installed) return Promise.reject(new Error('voice_unavailable'));
|
|
35
|
+
this.keepAliveWhileIdle();
|
|
36
|
+
return this.worker ??= this.ensureDownloaded().then(() => this.startWorker()).catch((error) => { this.worker = undefined; throw error; });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async transcribe(samples: Float32Array, sampleRate: number) {
|
|
40
|
+
const worker = await this.warm();
|
|
41
|
+
const id = this.nextId++;
|
|
42
|
+
const text = new Promise<string>((resolve, reject) => this.pending.set(id, { resolve, reject }));
|
|
43
|
+
worker.send({ id, samples, sampleRate });
|
|
44
|
+
try { return await text; } finally { this.keepAliveWhileIdle(); }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
close() { clearTimeout(this.idleTimer); void this.worker?.then((worker) => worker.kill(), () => undefined); this.worker = undefined; }
|
|
48
|
+
|
|
49
|
+
private ensureDownloaded() {
|
|
50
|
+
return this.download ??= isModelDownloaded(this.directory)
|
|
51
|
+
.then((ready) => ready ? undefined : downloadModel(this.directory))
|
|
52
|
+
.finally(() => { this.download = undefined; });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private startWorker() {
|
|
56
|
+
return new Promise<ChildProcess>((resolve, reject) => {
|
|
57
|
+
const worker = fork(WORKER_PATH, [], { serialization: 'advanced', stdio: ['ignore', 'inherit', 'inherit', 'ipc'], env: { ...process.env, WEBR_VOICE_MODEL: JSON.stringify(modelFiles(this.directory)) } });
|
|
58
|
+
worker.on('message', (message: Reply & { ready?: boolean }) => { if (message.ready) resolve(worker); else this.settle(message); });
|
|
59
|
+
worker.once('exit', (code) => { this.forgetWorker(worker, new Error(`voice_worker_exited_${code}`)); reject(new Error('voice_worker_failed')); });
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private settle({ id, text, error }: Reply) {
|
|
64
|
+
const request = this.pending.get(id);
|
|
65
|
+
this.pending.delete(id);
|
|
66
|
+
if (error !== undefined) request?.reject(new Error(error)); else request?.resolve(text ?? '');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private forgetWorker(worker: ChildProcess, reason: Error) {
|
|
70
|
+
void this.worker?.then((current) => { if (current === worker) this.worker = undefined; }, () => undefined);
|
|
71
|
+
for (const request of this.pending.values()) request.reject(reason);
|
|
72
|
+
this.pending.clear();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private keepAliveWhileIdle() {
|
|
76
|
+
clearTimeout(this.idleTimer);
|
|
77
|
+
this.idleTimer = setTimeout(() => { if (!this.pending.size) this.close(); }, this.idleUnloadMs);
|
|
78
|
+
this.idleTimer.unref();
|
|
79
|
+
}
|
|
80
|
+
}
|