@pablopunk/webr 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 +661 -0
- package/README.md +17 -0
- package/bin/webr.mjs +11 -0
- package/dist/client/_astro/App.BxlQhLhN.css +1 -0
- package/dist/client/_astro/App.CzxEZ_Dw.js +2 -0
- package/dist/client/_astro/ClientRouter.astro_astro_type_script_index_0_lang.CTgtIFyY.js +1 -0
- package/dist/client/_astro/addon-fit.jgKqzwky.js +1 -0
- package/dist/client/_astro/addon-web-links.C9lyQRxI.js +1 -0
- package/dist/client/_astro/client.BDkzSRUN.js +1 -0
- package/dist/client/_astro/client.Bbg9OBwT.js +1 -0
- package/dist/client/_astro/client.CLSsMr2g.js +9 -0
- package/dist/client/_astro/jetbrains-mono.CFJaiWaY.woff2 +0 -0
- package/dist/client/_astro/symbols-nerd.CEdkKAK8.woff2 +0 -0
- package/dist/client/_astro/xterm.B-l5AIf6.js +7 -0
- package/dist/client/apple-touch-icon.png +0 -0
- package/dist/client/brand-mark.png +0 -0
- package/dist/client/favicon-16.png +0 -0
- package/dist/client/favicon-32.png +0 -0
- package/dist/client/favicon.ico +0 -0
- package/dist/client/icon-192.png +0 -0
- package/dist/client/icon-512.png +0 -0
- package/dist/client/icon-maskable-512.png +0 -0
- package/dist/client/manifest.webmanifest +30 -0
- package/dist/server/chunks/App_BmsGAwGf.mjs +4314 -0
- package/dist/server/chunks/_id__u0ueX6GO.mjs +34 -0
- package/dist/server/chunks/_tab__DL6wyr-j.mjs +35 -0
- package/dist/server/chunks/_virtual_astro_server-island-manifest_C1Q2srgE.mjs +5 -0
- package/dist/server/chunks/_virtual_astro_session-driver_DS5V7T-N.mjs +6 -0
- package/dist/server/chunks/compiler_CpXox_xS.mjs +35 -0
- package/dist/server/chunks/console_CucE1SwS.mjs +170 -0
- package/dist/server/chunks/errors_BgIp4W2B.mjs +383 -0
- package/dist/server/chunks/index_DyyPce_k.mjs +22 -0
- package/dist/server/chunks/new_G7XChx5p.mjs +33 -0
- package/dist/server/chunks/node_BlixNyYV.mjs +1816 -0
- package/dist/server/chunks/noop-entrypoint_Z3zFhrGC.mjs +4 -0
- package/dist/server/chunks/remote_BxuVLM0r.mjs +169 -0
- package/dist/server/chunks/server_DS45LC4q.mjs +2496 -0
- package/dist/server/chunks/settings_DLV2Js3e.mjs +31 -0
- package/dist/server/chunks/sharp_CgNvPCpY.mjs +152 -0
- package/dist/server/chunks/vnode-children_CRmaSs5G.mjs +24 -0
- package/dist/server/entry.mjs +5837 -0
- package/dist/server/virtual_astro_middleware.mjs +4 -0
- package/drizzle/0000_gateway.sql +13 -0
- package/drizzle/0001_device_sessions.sql +9 -0
- package/drizzle/0002_audit_events.sql +7 -0
- package/drizzle/meta/_journal.json +1 -0
- package/package.json +88 -0
- package/server/cli.ts +93 -0
- package/server/command.ts +9 -0
- package/server/entry.ts +3 -0
- package/server/herdr-check.ts +37 -0
- package/server/home.ts +4 -0
- package/server/log-file.ts +52 -0
- package/server/package-info.ts +6 -0
- package/server/plugin/config.ts +14 -0
- package/server/plugin/dev.ts +27 -0
- package/server/plugin/files.ts +10 -0
- package/server/plugin/herdr.ts +33 -0
- package/server/plugin/index.ts +68 -0
- package/server/plugin/probe.ts +17 -0
- package/server/plugin/stop.ts +39 -0
- package/server/port.ts +8 -0
- package/server/run.ts +35 -0
- package/server/ssr.ts +23 -0
- package/server/start.ts +5 -0
- package/server/update/hint.ts +26 -0
- package/server/update/index.ts +33 -0
- package/server/update/latest.ts +33 -0
- package/server/update/package-manager.ts +24 -0
- package/server/update/versions.ts +15 -0
- package/src/lib/avatars.ts +15 -0
- package/src/lib/launch.ts +16 -0
- package/src/lib/machines.ts +41 -0
- package/src/lib/mock-store.ts +82 -0
- package/src/lib/models.ts +139 -0
- package/src/lib/project-icons.ts +120 -0
- package/src/lib/terminal-split.ts +23 -0
- package/src/lib/uuid.ts +10 -0
- package/src/server/auth/access.ts +22 -0
- package/src/server/auth/audit.ts +15 -0
- package/src/server/auth/auth.ts +134 -0
- package/src/server/auth/pair-page.ts +85 -0
- package/src/server/auth/pairing.ts +91 -0
- package/src/server/auth/presence.ts +8 -0
- package/src/server/auth/sessions.ts +48 -0
- package/src/server/directories.ts +21 -0
- package/src/server/dist.ts +3 -0
- package/src/server/host.ts +166 -0
- package/src/server/perf-log.ts +23 -0
- package/src/server/protocol/deadlines.ts +24 -0
- package/src/server/protocol/native.ts +10 -0
- package/src/server/protocol/ndjson.ts +22 -0
- package/src/server/protocol/socket.ts +81 -0
- package/src/server/protocol/validate.ts +17 -0
- package/src/server/public-urls.ts +17 -0
- package/src/server/runtime/herdr-actions.ts +122 -0
- package/src/server/runtime/launch.ts +55 -0
- package/src/server/runtime/manager.ts +187 -0
- package/src/server/runtime/reconcile.ts +51 -0
- package/src/server/runtime/supervisor.ts +92 -0
- package/src/server/runtime/target.ts +37 -0
- package/src/server/runtime/workspace.ts +24 -0
- package/src/server/storage/database.ts +88 -0
- package/src/server/storage/drizzle-node-sqlite.ts +18 -0
- package/src/server/storage/node-sqlite-client.ts +44 -0
- package/src/server/storage/schema.ts +25 -0
- package/src/server/tailscale-host.ts +17 -0
- package/src/server/terminal/cli.ts +44 -0
- package/src/server/terminal/hub.ts +140 -0
- package/src/server/terminal/writer.ts +38 -0
- package/src/server/transport/herdr-target.ts +148 -0
- package/src/server/transport/icons.ts +65 -0
- package/src/server/transport/identity.ts +5 -0
- package/src/server/transport/local-session.ts +65 -0
- package/src/server/transport/model-discovery.ts +18 -0
- package/src/server/transport/native-locations.ts +17 -0
- package/src/server/transport/process.ts +21 -0
- package/src/server/transport/registry.ts +27 -0
- package/src/server/transport/ssh.ts +47 -0
- package/src/server/uploads.ts +40 -0
- package/src/server/websockets.ts +62 -0
- package/src/shared/agent-kinds.ts +4 -0
- package/src/shared/frame.ts +38 -0
- package/src/shared/projects.ts +1 -0
- package/src/shared/retry.ts +1 -0
- package/src/shared/runtime.ts +33 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
CREATE TABLE app_threads (id TEXT PRIMARY KEY NOT NULL, machine_id TEXT NOT NULL, session TEXT NOT NULL, alias TEXT, metadata TEXT NOT NULL, anchors TEXT NOT NULL, avatar INTEGER NOT NULL);
|
|
2
|
+
--> statement-breakpoint
|
|
3
|
+
CREATE UNIQUE INDEX native_alias ON app_threads(machine_id, session, alias);
|
|
4
|
+
--> statement-breakpoint
|
|
5
|
+
CREATE TABLE launch_operations (id TEXT PRIMARY KEY NOT NULL, account_id TEXT NOT NULL, key TEXT NOT NULL, hash TEXT NOT NULL, thread_id TEXT NOT NULL, input TEXT NOT NULL, state TEXT NOT NULL, step TEXT NOT NULL, result TEXT NOT NULL);
|
|
6
|
+
--> statement-breakpoint
|
|
7
|
+
CREATE UNIQUE INDEX account_operation_key ON launch_operations(account_id, key);
|
|
8
|
+
--> statement-breakpoint
|
|
9
|
+
CREATE TABLE app_settings (key TEXT PRIMARY KEY NOT NULL, value TEXT NOT NULL);
|
|
10
|
+
--> statement-breakpoint
|
|
11
|
+
CREATE TABLE machine_profiles (id TEXT PRIMARY KEY NOT NULL, session TEXT NOT NULL, config_version INTEGER NOT NULL, metadata TEXT NOT NULL);
|
|
12
|
+
--> statement-breakpoint
|
|
13
|
+
CREATE TABLE project_locations (id TEXT PRIMARY KEY NOT NULL, machine_id TEXT NOT NULL, project_id TEXT NOT NULL, path TEXT NOT NULL);
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
CREATE TABLE `device_sessions` (
|
|
2
|
+
`id` text PRIMARY KEY NOT NULL,
|
|
3
|
+
`token_hash` text NOT NULL,
|
|
4
|
+
`name` text NOT NULL,
|
|
5
|
+
`created_at` integer NOT NULL,
|
|
6
|
+
`last_seen_at` integer NOT NULL
|
|
7
|
+
);
|
|
8
|
+
--> statement-breakpoint
|
|
9
|
+
CREATE UNIQUE INDEX `device_session_token` ON `device_sessions` (`token_hash`);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":"7","dialect":"sqlite","entries":[{"idx":0,"version":"6","when":1790841600000,"tag":"0000_gateway","breakpoints":true},{"idx":1,"version":"6","when":1790928000000,"tag":"0001_device_sessions","breakpoints":true},{"idx":2,"version":"6","when":1791014400000,"tag":"0002_audit_events","breakpoints":true}]}
|
package/package.json
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pablopunk/webr",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Control your Herdr agents from anywhere.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"herdr",
|
|
7
|
+
"agents",
|
|
8
|
+
"terminal",
|
|
9
|
+
"web-ui",
|
|
10
|
+
"remote",
|
|
11
|
+
"cli"
|
|
12
|
+
],
|
|
13
|
+
"license": "AGPL-3.0-or-later",
|
|
14
|
+
"homepage": "https://github.com/pablopunk/webr#readme",
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/pablopunk/webr/issues"
|
|
17
|
+
},
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/pablopunk/webr.git"
|
|
21
|
+
},
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
},
|
|
25
|
+
"type": "module",
|
|
26
|
+
"bin": {
|
|
27
|
+
"webr": "bin/webr.mjs"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"bin",
|
|
31
|
+
"server",
|
|
32
|
+
"src/server",
|
|
33
|
+
"src/shared",
|
|
34
|
+
"src/lib",
|
|
35
|
+
"drizzle",
|
|
36
|
+
"dist"
|
|
37
|
+
],
|
|
38
|
+
"engines": {
|
|
39
|
+
"node": ">=22.16"
|
|
40
|
+
},
|
|
41
|
+
"scripts": {
|
|
42
|
+
"dev": "tsx scripts/dev.ts",
|
|
43
|
+
"build": "astro build",
|
|
44
|
+
"start": "tsx server/start.ts",
|
|
45
|
+
"check": "astro check && tsc --noEmit",
|
|
46
|
+
"test": "vitest run",
|
|
47
|
+
"icons": "tsx scripts/build-icons.ts",
|
|
48
|
+
"validate:offline": "tsx scripts/validate-protocol.ts",
|
|
49
|
+
"prepack": "astro build",
|
|
50
|
+
"smoke:install": "tsx scripts/smoke-install.ts"
|
|
51
|
+
},
|
|
52
|
+
"dependencies": {
|
|
53
|
+
"@astrojs/node": "11.1.6",
|
|
54
|
+
"@astrojs/react": "7.0.0",
|
|
55
|
+
"@base-ui/react": "1.8.0",
|
|
56
|
+
"@fastify/middie": "9.3.4",
|
|
57
|
+
"@fastify/static": "10.1.5",
|
|
58
|
+
"@fastify/websocket": "11.3.1",
|
|
59
|
+
"@tanstack/react-query": "5.104.0",
|
|
60
|
+
"@xterm/addon-fit": "0.10.0",
|
|
61
|
+
"@xterm/addon-web-links": "0.11.0",
|
|
62
|
+
"@xterm/xterm": "5.5.0",
|
|
63
|
+
"astro": "7.3.5",
|
|
64
|
+
"bot-avatars": "0.1.2",
|
|
65
|
+
"drizzle-orm": "0.45.3",
|
|
66
|
+
"fastify": "5.12.5",
|
|
67
|
+
"lucide-react": "0.468.0",
|
|
68
|
+
"react": "19.3.0",
|
|
69
|
+
"react-dom": "19.3.0",
|
|
70
|
+
"tsx": "4.23.15",
|
|
71
|
+
"uqr": "0.1.3",
|
|
72
|
+
"zod": "4.6.5",
|
|
73
|
+
"zustand": "5.0.15"
|
|
74
|
+
},
|
|
75
|
+
"devDependencies": {
|
|
76
|
+
"@astrojs/check": "0.9.10",
|
|
77
|
+
"@testing-library/react": "16.3.3",
|
|
78
|
+
"@testing-library/user-event": "14.6.7",
|
|
79
|
+
"@types/node": "22.20.4",
|
|
80
|
+
"@types/react": "19.3.0",
|
|
81
|
+
"@types/react-dom": "19.3.0",
|
|
82
|
+
"@types/ws": "8.18.2",
|
|
83
|
+
"jsdom": "30.1.1",
|
|
84
|
+
"typescript": "5.9.3",
|
|
85
|
+
"vitest": "5.0.3",
|
|
86
|
+
"ws": "8.22.0"
|
|
87
|
+
}
|
|
88
|
+
}
|
package/server/cli.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { parseArgs } from 'node:util';
|
|
3
|
+
import { renderUnicode } from 'uqr';
|
|
4
|
+
import { webrHome } from './home';
|
|
5
|
+
import { runServer } from './run';
|
|
6
|
+
import { serverPort } from './port';
|
|
7
|
+
import { defaultDeps, installPlugin, pluginStatus, restartPluginWebr, uninstallWebrPlugin } from './plugin';
|
|
8
|
+
import { checkHerdr } from './herdr-check';
|
|
9
|
+
import { packageInfo } from './package-info';
|
|
10
|
+
import { realUpdateEnvironment, runUpdate } from './update';
|
|
11
|
+
import { refreshLatestVersionCache, updateHint } from './update/hint';
|
|
12
|
+
|
|
13
|
+
const HELP = `Webr — control your Herdr agents from anywhere.
|
|
14
|
+
|
|
15
|
+
Usage
|
|
16
|
+
webr [start] [options] Run the server in this terminal
|
|
17
|
+
webr plugin install [options] Start Webr whenever the Herdr server starts
|
|
18
|
+
webr plugin uninstall Remove the Herdr plugin
|
|
19
|
+
webr plugin status Show whether the plugin is installed and Webr is running
|
|
20
|
+
webr invite [--port <port>] Print a one-time code and QR to connect a device
|
|
21
|
+
webr update Install the latest version and restart the Herdr plugin
|
|
22
|
+
|
|
23
|
+
Options
|
|
24
|
+
--port <port> Port to listen on (default 4321)
|
|
25
|
+
--lan Listen on every network interface so devices on your network can connect
|
|
26
|
+
--host <address> Listen on a specific address (default 127.0.0.1)
|
|
27
|
+
--origin <url> Public URL when served through a proxy, e.g. https://mac.tailnet.ts.net
|
|
28
|
+
-v, --version Print the version
|
|
29
|
+
-h, --help Print this help
|
|
30
|
+
|
|
31
|
+
Set WEBR_NO_UPDATE_CHECK=1 to stop Webr from checking for new versions.
|
|
32
|
+
Localhost never needs a token. Any other device asks for access and you approve it in Webr on this computer.
|
|
33
|
+
`;
|
|
34
|
+
|
|
35
|
+
const options = {
|
|
36
|
+
port: { type: 'string' }, host: { type: 'string' }, origin: { type: 'string' }, lan: { type: 'boolean' },
|
|
37
|
+
help: { type: 'boolean', short: 'h' }, version: { type: 'boolean', short: 'v' },
|
|
38
|
+
} as const;
|
|
39
|
+
|
|
40
|
+
function parseCommand(argv: string[]) { return parseArgs({ args: argv, options, allowPositionals: true }); }
|
|
41
|
+
|
|
42
|
+
function applyServerFlags(values: ReturnType<typeof parseCommand>['values']) {
|
|
43
|
+
if (values.port) process.env.PORT = String(serverPort(['--port', values.port], {}));
|
|
44
|
+
if (values.lan) process.env.HOST = '0.0.0.0'; else if (values.host) process.env.HOST = values.host;
|
|
45
|
+
if (values.origin) process.env.WEBR_ORIGIN = values.origin;
|
|
46
|
+
process.env.WEBR_DATABASE ??= join(webrHome(), 'gateway.sqlite');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function printUpdateHint() {
|
|
50
|
+
const hint = updateHint();
|
|
51
|
+
if (hint) console.error(hint);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function requireHerdr() {
|
|
55
|
+
const status = await checkHerdr();
|
|
56
|
+
if (!status.ok) throw new Error(status.problem);
|
|
57
|
+
if (status.note) console.log(status.note);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function invite(port: number) {
|
|
61
|
+
const origin = `http://localhost:${port}`;
|
|
62
|
+
const response = await fetch(`${origin}/api/pair/invites`, { method: 'POST', headers: { 'Content-Type': 'application/json', Origin: origin }, body: '{}' }).catch(() => undefined);
|
|
63
|
+
if (!response?.ok) throw new Error(`Webr is not running on port ${port}. Start Herdr, or run "webr start".`);
|
|
64
|
+
const { token, urls } = await response.json() as { token: string; urls: string[] };
|
|
65
|
+
if (urls[0]) console.log(`${renderUnicode(urls[0], { border: 1, invert: true })}\n${urls[0]}\n`);
|
|
66
|
+
console.log(`Code: ${token}\nExpires in 5 minutes. Works once.`);
|
|
67
|
+
if (!urls.length) console.log('Webr is only reachable from this computer. Restart it with --lan or --origin to connect other devices.');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const pluginSettings = (values: ReturnType<typeof parseCommand>['values']) => ({
|
|
71
|
+
...(values.port ? { port: serverPort(['--port', values.port], {}) } : {}),
|
|
72
|
+
...(values.lan ? { host: '0.0.0.0' } : values.host ? { host: values.host } : {}),
|
|
73
|
+
...(values.origin ? { origin: values.origin } : {}),
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
async function pluginCommand(action: string, values: ReturnType<typeof parseCommand>['values']) {
|
|
77
|
+
const deps = defaultDeps();
|
|
78
|
+
const lines = action === 'install' ? await installPlugin(pluginSettings(values), deps) : action === 'uninstall' ? await uninstallWebrPlugin(deps) : await pluginStatus(deps);
|
|
79
|
+
for (const line of lines.filter(Boolean)) console.log(line);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function main(argv: string[]) {
|
|
83
|
+
const { values, positionals } = parseCommand(argv);
|
|
84
|
+
const [command = 'start', subcommand] = positionals;
|
|
85
|
+
if (values.help || command === 'help') return void console.log(HELP);
|
|
86
|
+
if (values.version) { console.log(packageInfo().version); return printUpdateHint(); }
|
|
87
|
+
if (command === 'start') { applyServerFlags(values); await requireHerdr(); return runServer(); }
|
|
88
|
+
if (command === 'update') return void (await runUpdate(realUpdateEnvironment(() => restartPluginWebr(defaultDeps())))).forEach((line) => console.log(line));
|
|
89
|
+
if (command === 'refresh-update-cache') return refreshLatestVersionCache().catch(() => undefined);
|
|
90
|
+
if (command === 'invite') return invite(serverPort(values.port ? ['--port', values.port] : []));
|
|
91
|
+
if (command === 'plugin' && ['install', 'uninstall', 'status'].includes(subcommand ?? '')) return pluginCommand(subcommand!, values);
|
|
92
|
+
throw new Error(`Unknown command: ${positionals.join(' ')}\n\n${HELP}`);
|
|
93
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
export type CommandResult = { ok: boolean; output: string };
|
|
4
|
+
export type RunCommand = (command: string, args: string[], env?: NodeJS.ProcessEnv) => CommandResult;
|
|
5
|
+
|
|
6
|
+
export const realRun: RunCommand = (command, args, env): CommandResult => {
|
|
7
|
+
const result = spawnSync(command, args, { encoding: 'utf8', env: env ? { ...process.env, ...env } : undefined });
|
|
8
|
+
return { ok: result.status === 0, output: `${result.stdout ?? ''}${result.stderr ?? ''}${result.error?.message ?? ''}` };
|
|
9
|
+
};
|
package/server/entry.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { SocketApi } from '../src/server/protocol/socket';
|
|
2
|
+
import { discoverLocalSession } from '../src/server/transport/local-session';
|
|
3
|
+
import { localSocket, type TargetProfile } from '../src/server/transport/registry';
|
|
4
|
+
|
|
5
|
+
const NOT_INSTALLED = 'Herdr was not found. Install Herdr and make sure the "herdr" command is on your PATH, then try again.';
|
|
6
|
+
const NOT_RUNNING = 'Herdr is not running yet. Webr will start its default session when it connects.';
|
|
7
|
+
|
|
8
|
+
const UNRESPONSIVE = 'A Herdr session exists but does not answer. Quit Herdr, then start it again.';
|
|
9
|
+
|
|
10
|
+
const problems: [match: string, advice: string][] = [
|
|
11
|
+
['ENOENT', NOT_INSTALLED],
|
|
12
|
+
['multiple_herdr_sessions', 'More than one Herdr session is running. Start Webr from the session you want, or set HERDR_SESSION to its name.'],
|
|
13
|
+
['herdr_session_unreachable', UNRESPONSIVE],
|
|
14
|
+
['rpc_timeout', UNRESPONSIVE],
|
|
15
|
+
['missing_default_session', 'Herdr has no default session. Start Herdr once, then try again.'],
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
export const describeHerdrProblem = (error: unknown) => {
|
|
19
|
+
const text = `${(error as NodeJS.ErrnoException).code ?? ''} ${error instanceof Error ? error.message : String(error)}`;
|
|
20
|
+
return problems.find(([match]) => text.includes(match))?.[1] ?? `Webr could not reach Herdr: ${text.trim()}`;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
async function answers(profile: TargetProfile) {
|
|
24
|
+
const api = new SocketApi(localSocket(profile));
|
|
25
|
+
try { await api.request('ping'); return true; }
|
|
26
|
+
catch (error) { if (['ENOENT', 'ECONNREFUSED'].includes((error as NodeJS.ErrnoException).code ?? '')) return false; throw error; }
|
|
27
|
+
finally { api.close(); }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type HerdrStatus = { ok: true; note?: string } | { ok: false; problem: string };
|
|
31
|
+
|
|
32
|
+
export async function checkHerdr(discover: () => Promise<TargetProfile> = () => discoverLocalSession(), ping: (profile: TargetProfile) => Promise<boolean> = answers): Promise<HerdrStatus> {
|
|
33
|
+
try {
|
|
34
|
+
const profile = await discover();
|
|
35
|
+
return profile.transport !== 'local' || await ping(profile) ? { ok: true } : { ok: true, note: NOT_RUNNING };
|
|
36
|
+
} catch (error) { return { ok: false, problem: describeHerdrProblem(error) }; }
|
|
37
|
+
}
|
package/server/home.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync, renameSync, rmSync, statSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
|
|
4
|
+
const MAX_BYTES = 5 * 1024 * 1024;
|
|
5
|
+
const KEPT_BACKUPS = 3;
|
|
6
|
+
|
|
7
|
+
type Chunk = string | Uint8Array;
|
|
8
|
+
type OutputStream = { write: (chunk: Chunk, ...rest: never[]) => boolean };
|
|
9
|
+
type LogOptions = { maxBytes?: number; keep?: number };
|
|
10
|
+
|
|
11
|
+
const sizeOf = (path: string) => existsSync(path) ? statSync(path).size : 0;
|
|
12
|
+
const backupPath = (path: string, index: number) => `${path}.${index}`;
|
|
13
|
+
|
|
14
|
+
export class RotatingLog {
|
|
15
|
+
private size: number;
|
|
16
|
+
private readonly maxBytes: number;
|
|
17
|
+
private readonly keep: number;
|
|
18
|
+
|
|
19
|
+
constructor(private readonly path: string, { maxBytes = MAX_BYTES, keep = KEPT_BACKUPS }: LogOptions = {}) {
|
|
20
|
+
this.maxBytes = maxBytes;
|
|
21
|
+
this.keep = keep;
|
|
22
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
23
|
+
this.size = sizeOf(path);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
write(chunk: Chunk) {
|
|
27
|
+
const length = Buffer.byteLength(chunk);
|
|
28
|
+
if (this.size > 0 && this.size + length > this.maxBytes) this.rotate();
|
|
29
|
+
appendFileSync(this.path, chunk, { mode: 0o600 });
|
|
30
|
+
this.size += length;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
private rotate() {
|
|
34
|
+
rmSync(backupPath(this.path, this.keep), { force: true });
|
|
35
|
+
for (let index = this.keep - 1; index >= 1; index--) if (existsSync(backupPath(this.path, index))) renameSync(backupPath(this.path, index), backupPath(this.path, index + 1));
|
|
36
|
+
renameSync(this.path, backupPath(this.path, 1));
|
|
37
|
+
this.size = 0;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function redirectOutputToLogFile(path: string | undefined = process.env.WEBR_LOG_FILE, streams: OutputStream[] = [process.stdout, process.stderr], processEvents: Pick<NodeJS.Process, 'on' | 'exit'> = process) {
|
|
42
|
+
if (!path) return;
|
|
43
|
+
const log = new RotatingLog(path);
|
|
44
|
+
const append = (chunk: Chunk) => { try { log.write(chunk); } catch { return; } };
|
|
45
|
+
for (const stream of streams) stream.write = ((chunk: Chunk, ...rest: unknown[]) => {
|
|
46
|
+
append(chunk);
|
|
47
|
+
const done = rest.find((argument) => typeof argument === 'function') as (() => void) | undefined;
|
|
48
|
+
done?.();
|
|
49
|
+
return true;
|
|
50
|
+
}) as OutputStream['write'];
|
|
51
|
+
processEvents.on('uncaughtException', (error) => { append(`${error instanceof Error ? error.stack ?? error.message : String(error)}\n`); processEvents.exit(1); });
|
|
52
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
|
|
4
|
+
export type PackageInfo = { name: string; version: string };
|
|
5
|
+
|
|
6
|
+
export const packageInfo = (): PackageInfo => JSON.parse(readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8'));
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { CONFIG_FILE } from './files';
|
|
4
|
+
|
|
5
|
+
export type PluginConfig = { port?: number; host?: string; origin?: string };
|
|
6
|
+
|
|
7
|
+
export function writePluginConfig(configDir: string, config: PluginConfig) {
|
|
8
|
+
mkdirSync(configDir, { recursive: true });
|
|
9
|
+
writeFileSync(join(configDir, CONFIG_FILE), `${JSON.stringify(config, null, 2)}\n`);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function readPluginConfig(configDir: string): PluginConfig {
|
|
13
|
+
try { return JSON.parse(readFileSync(join(configDir, CONFIG_FILE), 'utf8')) as PluginConfig; } catch { return {}; }
|
|
14
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { realRun } from '../command';
|
|
4
|
+
import type { RunCommand } from '../command';
|
|
5
|
+
import { DEV_FILE, pluginStateDir } from './files';
|
|
6
|
+
import { installFromGithub, installedPlugin, linkLocalPlugin } from './herdr';
|
|
7
|
+
|
|
8
|
+
type Restore = () => void;
|
|
9
|
+
const nothingToRestore: Restore = () => {};
|
|
10
|
+
|
|
11
|
+
function restorePrevious(previous: ReturnType<typeof installedPlugin>, run: RunCommand) {
|
|
12
|
+
if (previous?.kind === 'github') return installFromGithub(run);
|
|
13
|
+
if (previous) return linkLocalPlugin(run, previous.root);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function enterPluginDevMode(checkout: string, run: RunCommand = realRun, stateDir = pluginStateDir()): Restore {
|
|
17
|
+
const previous = installedPlugin(run);
|
|
18
|
+
if (!previous) return nothingToRestore;
|
|
19
|
+
mkdirSync(stateDir, { recursive: true });
|
|
20
|
+
writeFileSync(join(stateDir, DEV_FILE), JSON.stringify({ checkout }));
|
|
21
|
+
linkLocalPlugin(run, join(checkout, 'plugin'));
|
|
22
|
+
console.log('Webr plugin linked to this checkout. It is restored when dev stops.');
|
|
23
|
+
return () => {
|
|
24
|
+
rmSync(join(stateDir, DEV_FILE), { force: true });
|
|
25
|
+
restorePrevious(previous, run);
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { PLUGIN_ID } from './herdr';
|
|
4
|
+
|
|
5
|
+
export const CONFIG_FILE = 'config.json';
|
|
6
|
+
export const DEV_FILE = 'dev.json';
|
|
7
|
+
export const PID_FILE = 'webr.pid';
|
|
8
|
+
export const LOG_FILE = 'webr.log';
|
|
9
|
+
|
|
10
|
+
export const pluginStateDir = (env: NodeJS.ProcessEnv = process.env) => join(env.XDG_STATE_HOME ?? join(homedir(), '.local', 'state'), 'herdr', 'plugins', PLUGIN_ID);
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { CommandResult, RunCommand } from '../command';
|
|
2
|
+
|
|
3
|
+
export const PLUGIN_ID = 'pablopunk.webr';
|
|
4
|
+
export const PLUGIN_SOURCE = 'pablopunk/webr/plugin';
|
|
5
|
+
|
|
6
|
+
export type InstalledPlugin = { root: string; kind: 'github' | 'local' };
|
|
7
|
+
|
|
8
|
+
const succeeded = (result: CommandResult, action: string) => {
|
|
9
|
+
if (!result.ok) throw new Error(`herdr could not ${action}: ${result.output.trim()}`);
|
|
10
|
+
return result.output;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function installedPlugin(run: RunCommand): InstalledPlugin | undefined {
|
|
14
|
+
const result = run('herdr', ['plugin', 'list', '--json']);
|
|
15
|
+
if (!result.ok) return undefined;
|
|
16
|
+
const plugins = (JSON.parse(result.output) as { result: { plugins: { plugin_id: string; plugin_root: string; source: { kind: InstalledPlugin['kind'] } }[] } }).result.plugins;
|
|
17
|
+
const found = plugins.find((plugin) => plugin.plugin_id === PLUGIN_ID);
|
|
18
|
+
return found && { root: found.plugin_root, kind: found.source.kind };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const requireHerdr = (run: RunCommand) => {
|
|
22
|
+
if (!run('herdr', ['--version']).ok) throw new Error('Herdr is not installed or not on your PATH. Install it from https://herdr.dev first.');
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const requireGit = (run: RunCommand) => {
|
|
26
|
+
if (!run('git', ['--version']).ok) throw new Error('Git is not installed or not on your PATH. Herdr needs it to install plugins from GitHub.');
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const pluginConfigDir = (run: RunCommand) => succeeded(run('herdr', ['plugin', 'config-dir', PLUGIN_ID]), 'find the plugin config folder').trim();
|
|
30
|
+
export const installFromGithub = (run: RunCommand) => succeeded(run('herdr', ['plugin', 'install', PLUGIN_SOURCE, '--yes']), 'install the plugin');
|
|
31
|
+
export const uninstallPlugin = (run: RunCommand) => succeeded(run('herdr', ['plugin', 'uninstall', PLUGIN_ID]), 'uninstall the plugin');
|
|
32
|
+
export const linkLocalPlugin = (run: RunCommand, directory: string) => succeeded(run('herdr', ['plugin', 'link', directory]), 'link the plugin');
|
|
33
|
+
export const unlinkLocalPlugin = (run: RunCommand) => succeeded(run('herdr', ['plugin', 'unlink', PLUGIN_ID]), 'unlink the plugin');
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { rmSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { realRun, type RunCommand } from '../command';
|
|
4
|
+
import { readPluginConfig, writePluginConfig, type PluginConfig } from './config';
|
|
5
|
+
import { pluginStateDir } from './files';
|
|
6
|
+
import { isPortOpen, probeAddress } from './probe';
|
|
7
|
+
import { stopPluginWebr } from './stop';
|
|
8
|
+
import { installFromGithub, installedPlugin, pluginConfigDir, requireGit, requireHerdr, uninstallPlugin, unlinkLocalPlugin } from './herdr';
|
|
9
|
+
|
|
10
|
+
export type PluginDeps = {
|
|
11
|
+
run: RunCommand;
|
|
12
|
+
stateDir: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const defaultDeps = (): PluginDeps => ({ run: realRun, stateDir: pluginStateDir() });
|
|
16
|
+
|
|
17
|
+
const startNow = (root: string, configDir: string, deps: PluginDeps) =>
|
|
18
|
+
deps.run(process.execPath, [join(root, 'launch.mjs')], { HERDR_PLUGIN_CONFIG_DIR: configDir, HERDR_PLUGIN_STATE_DIR: deps.stateDir }).output.trim();
|
|
19
|
+
|
|
20
|
+
export async function installPlugin(config: PluginConfig, deps: PluginDeps) {
|
|
21
|
+
requireHerdr(deps.run);
|
|
22
|
+
requireGit(deps.run);
|
|
23
|
+
const configDir = pluginConfigDir(deps.run);
|
|
24
|
+
writePluginConfig(configDir, config);
|
|
25
|
+
installFromGithub(deps.run);
|
|
26
|
+
const plugin = installedPlugin(deps.run);
|
|
27
|
+
if (!plugin) throw new Error('Herdr did not register the Webr plugin.');
|
|
28
|
+
return [
|
|
29
|
+
'Installed the Webr plugin. It starts Webr whenever the Herdr server starts.',
|
|
30
|
+
startNow(plugin.root, configDir, deps),
|
|
31
|
+
];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function uninstallWebrPlugin(deps: PluginDeps) {
|
|
35
|
+
requireHerdr(deps.run);
|
|
36
|
+
const plugin = installedPlugin(deps.run);
|
|
37
|
+
if (!plugin) return ['The Webr plugin is not installed.'];
|
|
38
|
+
const configDir = pluginConfigDir(deps.run);
|
|
39
|
+
const stopped = await stopPluginWebr(deps.stateDir, probeAddress(readPluginConfig(configDir)));
|
|
40
|
+
if (plugin.kind === 'github') uninstallPlugin(deps.run); else unlinkLocalPlugin(deps.run);
|
|
41
|
+
rmSync(configDir, { recursive: true, force: true });
|
|
42
|
+
rmSync(deps.stateDir, { recursive: true, force: true });
|
|
43
|
+
return ['Removed the Webr plugin.', ...(stopped ? ['Stopped the running Webr.'] : [])];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function restartPluginWebr(deps: PluginDeps) {
|
|
47
|
+
if (!deps.run('herdr', ['--version']).ok) return undefined;
|
|
48
|
+
const plugin = installedPlugin(deps.run);
|
|
49
|
+
if (!plugin) return undefined;
|
|
50
|
+
const configDir = pluginConfigDir(deps.run);
|
|
51
|
+
const address = probeAddress(readPluginConfig(configDir));
|
|
52
|
+
if ((await isPortOpen(address)) && !(await stopPluginWebr(deps.stateDir, address))) return [`Webr is running on port ${address.port} but the plugin did not start it. Stop it and run "webr plugin install" to use the new version.`];
|
|
53
|
+
return [startNow(plugin.root, configDir, deps)];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function pluginStatus(deps: PluginDeps) {
|
|
57
|
+
requireHerdr(deps.run);
|
|
58
|
+
const plugin = installedPlugin(deps.run);
|
|
59
|
+
if (!plugin) return ['The Webr plugin is not installed. Install it with "webr plugin install".'];
|
|
60
|
+
const config = readPluginConfig(pluginConfigDir(deps.run));
|
|
61
|
+
const address = probeAddress(config);
|
|
62
|
+
return [
|
|
63
|
+
`Installed (${plugin.kind === 'github' ? 'from GitHub' : `linked to ${plugin.root}`}).`,
|
|
64
|
+
`Webr is ${await isPortOpen(address) ? 'running' : 'not running'} on port ${address.port}.`,
|
|
65
|
+
`Settings: ${JSON.stringify(config)}`,
|
|
66
|
+
`Logs and state: ${deps.stateDir}`,
|
|
67
|
+
];
|
|
68
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { createConnection } from 'node:net';
|
|
2
|
+
import type { PluginConfig } from './config';
|
|
3
|
+
|
|
4
|
+
const DEFAULT_PORT = 4321;
|
|
5
|
+
const WILDCARD_HOSTS = ['0.0.0.0', '::'];
|
|
6
|
+
|
|
7
|
+
export const probeAddress = ({ port, host }: PluginConfig) => ({
|
|
8
|
+
port: port ?? DEFAULT_PORT,
|
|
9
|
+
host: !host || WILDCARD_HOSTS.includes(host) ? '127.0.0.1' : host,
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
export const isPortOpen = ({ host, port }: { host: string; port: number }) => new Promise<boolean>((done) => {
|
|
13
|
+
const socket = createConnection({ host, port });
|
|
14
|
+
socket.once('connect', () => { socket.destroy(); done(true); });
|
|
15
|
+
socket.once('error', () => done(false));
|
|
16
|
+
socket.setTimeout(1000, () => { socket.destroy(); done(false); });
|
|
17
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { readFileSync, rmSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { PID_FILE } from './files';
|
|
4
|
+
import { isPortOpen } from './probe';
|
|
5
|
+
|
|
6
|
+
type Address = Parameters<typeof isPortOpen>[0];
|
|
7
|
+
|
|
8
|
+
const STOP_ATTEMPTS = 50;
|
|
9
|
+
const STOP_POLL_MS = 100;
|
|
10
|
+
|
|
11
|
+
const pidPath = (stateDir: string) => join(stateDir, PID_FILE);
|
|
12
|
+
const sleep = (ms: number) => new Promise((done) => setTimeout(done, ms));
|
|
13
|
+
|
|
14
|
+
function recordedPid(stateDir: string) {
|
|
15
|
+
try {
|
|
16
|
+
const pid = Number(readFileSync(pidPath(stateDir), 'utf8').trim());
|
|
17
|
+
return Number.isInteger(pid) && pid > 0 ? pid : undefined;
|
|
18
|
+
} catch { return undefined; }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function isAlive(pid: number) {
|
|
22
|
+
try { process.kill(pid, 0); return true; } catch (error) { return (error as NodeJS.ErrnoException).code === 'EPERM'; }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function waitUntilClosed(address: Address) {
|
|
26
|
+
for (let attempt = 0; attempt < STOP_ATTEMPTS; attempt++) {
|
|
27
|
+
if (!(await isPortOpen(address))) return true;
|
|
28
|
+
await sleep(STOP_POLL_MS);
|
|
29
|
+
}
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function stopPluginWebr(stateDir: string, address: Address) {
|
|
34
|
+
const pid = recordedPid(stateDir);
|
|
35
|
+
rmSync(pidPath(stateDir), { force: true });
|
|
36
|
+
if (!pid || !isAlive(pid) || !(await isPortOpen(address))) return false;
|
|
37
|
+
process.kill(pid, 'SIGTERM');
|
|
38
|
+
return waitUntilClosed(address);
|
|
39
|
+
}
|
package/server/port.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
const DEFAULT_PORT = 4321;
|
|
2
|
+
const portArgument = (args: string[]) => { const index = args.indexOf('--port'); return index >= 0 ? args[index + 1] : args.find((arg) => arg.startsWith('--port='))?.slice('--port='.length); };
|
|
3
|
+
|
|
4
|
+
export function serverPort(args = process.argv.slice(2), env = process.env) {
|
|
5
|
+
const port = Number(portArgument(args) ?? env.PORT ?? DEFAULT_PORT);
|
|
6
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('The port must be a number from 1 to 65535.');
|
|
7
|
+
return port;
|
|
8
|
+
}
|
package/server/run.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { MetadataDatabase } from '../src/server/storage/database';
|
|
3
|
+
import { RuntimeManager } from '../src/server/runtime/manager';
|
|
4
|
+
import { loadRegistry } from '../src/server/transport/registry';
|
|
5
|
+
import { HerdrTarget } from '../src/server/transport/herdr-target';
|
|
6
|
+
import { createHost } from '../src/server/host';
|
|
7
|
+
import { publicUrls } from '../src/server/public-urls';
|
|
8
|
+
import { detectTailscaleHost } from '../src/server/tailscale-host';
|
|
9
|
+
import { isLoopbackHost } from '../src/server/auth/access';
|
|
10
|
+
import { serverPort } from './port';
|
|
11
|
+
import { builtSsr, devSsr } from './ssr';
|
|
12
|
+
|
|
13
|
+
const WILDCARD_HOSTS = ['0.0.0.0', '::'];
|
|
14
|
+
|
|
15
|
+
export async function runServer() {
|
|
16
|
+
const host = process.env.HOST ?? '127.0.0.1';
|
|
17
|
+
const port = serverPort();
|
|
18
|
+
const tlsEnabled = !!(process.env.WEBR_TLS_CERT && process.env.WEBR_TLS_KEY);
|
|
19
|
+
const origin = process.env.WEBR_ORIGIN ?? `${tlsEnabled ? 'https' : 'http'}://${WILDCARD_HOSTS.includes(host) ? 'localhost' : host}:${port}`;
|
|
20
|
+
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
|
+
if (!['http:', 'https:'].includes(new URL(origin).protocol)) throw new Error('WEBR_ORIGIN must use HTTP or HTTPS');
|
|
22
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('Invalid port');
|
|
23
|
+
const database = new MetadataDatabase(process.env.WEBR_DATABASE ?? '.data/gateway.sqlite');
|
|
24
|
+
const profiles = await loadRegistry(process.env.WEBR_TARGETS);
|
|
25
|
+
const manager = new RuntimeManager(database, profiles.map((profile) => new HerdrTarget(profile)));
|
|
26
|
+
const ssr = process.env.WEBR_DEV === '1' ? await devSsr(port, host) : await builtSsr();
|
|
27
|
+
const tls = tlsEnabled ? { cert: await readFile(process.env.WEBR_TLS_CERT!), key: await readFile(process.env.WEBR_TLS_KEY!) } : undefined;
|
|
28
|
+
const urls = publicUrls(origin, host, port, undefined, await detectTailscaleHost());
|
|
29
|
+
const app = await createHost(manager, origin, ssr.handler, tls, undefined, { publicUrls: urls });
|
|
30
|
+
await app.listen({ host, port });
|
|
31
|
+
manager.start();
|
|
32
|
+
console.log(`Webr listening at ${origin}`);
|
|
33
|
+
if (!isLoopbackHost(host) || urls.length) console.log(`Other devices connect at:\n${(urls.length ? urls : [origin]).map((url) => ` ${url}`).join('\n')}\nThey ask for access and you approve them in Webr on this computer.`);
|
|
34
|
+
for (const signal of ['SIGINT', 'SIGTERM'] as const) process.once(signal, () => { void Promise.all([app.close(), ssr.stop()]).finally(() => { database.close(); process.exit(0); }); });
|
|
35
|
+
}
|
package/server/ssr.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import { pathToFileURL } from 'node:url';
|
|
3
|
+
import { distRoot } from '../src/server/dist';
|
|
4
|
+
|
|
5
|
+
type Locals = Record<string, unknown>;
|
|
6
|
+
export type Ssr = { handler: (request: object, response: unknown, next: unknown, locals: Locals) => void; stop: () => Promise<void> };
|
|
7
|
+
|
|
8
|
+
const astroLocalsSymbol = Symbol.for('astro.locals');
|
|
9
|
+
|
|
10
|
+
export async function builtSsr(): Promise<Ssr> {
|
|
11
|
+
const { handler } = await import(pathToFileURL(resolve(distRoot(), 'server/entry.mjs')).href);
|
|
12
|
+
return { handler, stop: async () => {} };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function devSsr(port: number, host = '127.0.0.1'): Promise<Ssr> {
|
|
16
|
+
const { dev } = await import('astro');
|
|
17
|
+
const astro = await dev({ server: { port: port + 2, host }, vite: { server: { allowedHosts: true, hmr: { port: port + 1 } } } });
|
|
18
|
+
const handler: Ssr['handler'] = (request, response, _next, locals) => {
|
|
19
|
+
Reflect.set(request, astroLocalsSymbol, locals);
|
|
20
|
+
astro.handle(request as never, response as never);
|
|
21
|
+
};
|
|
22
|
+
return { handler, stop: () => astro.stop() };
|
|
23
|
+
}
|