ogthing 0.0.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +141 -0
- package/bin/dev-named.mjs +24 -0
- package/bin/ogthing.mjs +387 -0
- package/index.html +17 -0
- package/package.json +72 -0
- package/portless.json +4 -0
- package/server/metadata.ts +365 -0
- package/server/ogthing-api.ts +130 -0
- package/server/types.ts +53 -0
- package/src/App.tsx +311 -0
- package/src/icons.tsx +84 -0
- package/src/main.tsx +10 -0
- package/src/previews.tsx +242 -0
- package/src/styles.css +865 -0
- package/src/tabs.tsx +374 -0
- package/src/vite-env.d.ts +1 -0
- package/tsconfig.json +21 -0
- package/vite.config.ts +16 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 extoci
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# ogthing – inspect link metadata from your own machine
|
|
2
|
+
|
|
3
|
+
ogthing is golbat energy for your localhost: a metadata inspector that runs on
|
|
4
|
+
your device so the urls you actually care about — `localhost:3000`, your dev
|
|
5
|
+
preview, your coworker's half-finished branch deployed on port 8080 — can be
|
|
6
|
+
inspected directly. no tunnel, no deploy, no public exposure.
|
|
7
|
+
|
|
8
|
+
## usage
|
|
9
|
+
|
|
10
|
+
on your machine:
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
bunx ogthing
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
your browser opens the inspector. paste any url, including local ones:
|
|
17
|
+
|
|
18
|
+
```text
|
|
19
|
+
http://localhost:3000 # works. it is running next to you.
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
the exact url is printed in the terminal. if a compatible Portless proxy is
|
|
23
|
+
already running, ogthing reuses it and normally opens:
|
|
24
|
+
|
|
25
|
+
```text
|
|
26
|
+
https://ogthing.localhost
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
otherwise it starts its own unprivileged HTTP proxy, usually at:
|
|
30
|
+
|
|
31
|
+
```text
|
|
32
|
+
http://ogthing.localhost:1355
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
no sudo prompt is needed just to get a nice name for localhost.
|
|
36
|
+
|
|
37
|
+
if you want the clean HTTPS url, start Portless yourself before running ogthing:
|
|
38
|
+
|
|
39
|
+
```sh
|
|
40
|
+
portless proxy start --https --tld localhost
|
|
41
|
+
bunx ogthing
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## what it does
|
|
45
|
+
|
|
46
|
+
* fetches the page **from your machine**, then parses title, description,
|
|
47
|
+
Open Graph, Twitter/X card, favicon, canonical, robots and viewport tags
|
|
48
|
+
* previews how the link renders on Telegram, Discord, Slack, X, Facebook,
|
|
49
|
+
LinkedIn, and WhatsApp
|
|
50
|
+
* scores metadata quality out of 100 with per-check pass/fail dots
|
|
51
|
+
* picks the user agent the way real crawlers do: facebookbot, twitterbot,
|
|
52
|
+
slackbot, discordbot, telegrambot, WhatsApp, LinkedInBot, or a plain browser
|
|
53
|
+
(some sites only serve their social tags to known bots)
|
|
54
|
+
* validates image dimensions against recommended sizes (1200×630 for OG)
|
|
55
|
+
* checks for robots.txt and sitemap.xml
|
|
56
|
+
* copies the metadata as JSON or as ready-to-paste HTML meta tags
|
|
57
|
+
* keeps your last few inspected urls around
|
|
58
|
+
|
|
59
|
+
keyboard shortcuts: keys **1-7** switch tabs while results are showing.
|
|
60
|
+
|
|
61
|
+
because the fetch happens on your device rather than on some hosted inspector,
|
|
62
|
+
sites behind your firewall, your docker network, and plain old localhost are all
|
|
63
|
+
fair game. that is the whole point.
|
|
64
|
+
|
|
65
|
+
## requirements
|
|
66
|
+
|
|
67
|
+
* Bun `1.3.14` or newer
|
|
68
|
+
|
|
69
|
+
Portless ships with ogthing. you do not need to install it separately.
|
|
70
|
+
|
|
71
|
+
## environment variables
|
|
72
|
+
|
|
73
|
+
the useful ones:
|
|
74
|
+
|
|
75
|
+
* `OGTHING_PORT` – local app/API port (default: `4646`)
|
|
76
|
+
* `OGTHING_NO_OPEN=1` – start without opening a browser
|
|
77
|
+
* `OGTHING_FETCH_TIMEOUT_MS` – fetch timeout in ms (default: `15000`)
|
|
78
|
+
|
|
79
|
+
Portless knobs, if you need them:
|
|
80
|
+
|
|
81
|
+
* `OGTHING_PROXY_PORT` – preferred proxy port (default: `443`)
|
|
82
|
+
* `OGTHING_PROXY_TLS=0` – use HTTP for the preferred proxy
|
|
83
|
+
* `OGTHING_FALLBACK_PROXY_PORT` – unprivileged fallback port (default: `1355`)
|
|
84
|
+
* `OGTHING_FALLBACK_PROXY_TLS=1` – use HTTPS on the fallback port
|
|
85
|
+
* `OGTHING_PORTLESS_STATE_DIR` – state for the proxy ogthing starts (default: `~/.ogthing/portless`)
|
|
86
|
+
* `OGTHING_NO_PORTLESS=1` – serve directly on `127.0.0.1:<port>` instead
|
|
87
|
+
* `OGTHING_NO_PORTLESS_AUTOSTART=1` – fail instead of starting a proxy automatically
|
|
88
|
+
|
|
89
|
+
example:
|
|
90
|
+
|
|
91
|
+
```sh
|
|
92
|
+
OGTHING_NO_OPEN=1 OGTHING_PORT=5000 bunx ogthing
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## trust and privacy
|
|
96
|
+
|
|
97
|
+
ogthing does not send your urls anywhere except to the sites themselves. every
|
|
98
|
+
request goes straight from your machine to the target host.
|
|
99
|
+
|
|
100
|
+
note that the inspector will happily fetch internal addresses — that feature is
|
|
101
|
+
not a bug. do not expose your ogthing port or its Portless route to networks you
|
|
102
|
+
do not trust; it has no authentication and it makes requests on behalf of whoever
|
|
103
|
+
asks.
|
|
104
|
+
|
|
105
|
+
## troubleshooting
|
|
106
|
+
|
|
107
|
+
* **the browser opened the wrong url** – use the exact url printed by ogthing.
|
|
108
|
+
a fresh setup normally uses `http://ogthing.localhost:1355`, not HTTPS on port 443.
|
|
109
|
+
* **a site shows no og tags** – try a crawler user agent in the selector; some
|
|
110
|
+
sites gate social metadata on bot user agents.
|
|
111
|
+
* **Safari cannot resolve ogthing.localhost** – Chrome, Firefox, and Edge resolve
|
|
112
|
+
`.localhost` natively. for Safari, run `portless hosts sync` once.
|
|
113
|
+
|
|
114
|
+
## development
|
|
115
|
+
|
|
116
|
+
install dependencies and run the named local app:
|
|
117
|
+
|
|
118
|
+
```sh
|
|
119
|
+
bun install
|
|
120
|
+
bun run dev:named
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
run the checks:
|
|
124
|
+
|
|
125
|
+
```sh
|
|
126
|
+
bun run typecheck
|
|
127
|
+
bun run lint
|
|
128
|
+
bun run format:check
|
|
129
|
+
bun test
|
|
130
|
+
bun run build
|
|
131
|
+
bun run pack:check
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
for a direct smoke test without Portless or a browser:
|
|
135
|
+
|
|
136
|
+
```sh
|
|
137
|
+
OGTHING_NO_PORTLESS=1 OGTHING_NO_OPEN=1 bun bin/ogthing.mjs
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
the parser reads the html itself because asking permission from cheerio for
|
|
141
|
+
seven tags felt excessive.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
const packageRoot = fileURLToPath(new URL("..", import.meta.url));
|
|
7
|
+
const viteModule = fileURLToPath(await import.meta.resolve("vite"));
|
|
8
|
+
const viteBin = viteModule.replace(/dist[\\/]node[\\/]index\.js$/, "bin/vite.js");
|
|
9
|
+
const port = process.env.PORT || "4646";
|
|
10
|
+
|
|
11
|
+
const child = spawn(process.execPath, [viteBin, "--host", "127.0.0.1", "--port", port], {
|
|
12
|
+
cwd: packageRoot,
|
|
13
|
+
stdio: "inherit",
|
|
14
|
+
env: { ...process.env },
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const forwardSignal = (signal) => child.kill(signal);
|
|
18
|
+
process.on("SIGINT", () => forwardSignal("SIGINT"));
|
|
19
|
+
process.on("SIGTERM", () => forwardSignal("SIGTERM"));
|
|
20
|
+
|
|
21
|
+
child.on("exit", (code, signal) => {
|
|
22
|
+
if (signal) process.kill(process.pid, signal);
|
|
23
|
+
process.exit(code ?? 0);
|
|
24
|
+
});
|
package/bin/ogthing.mjs
ADDED
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { readFile } from "node:fs/promises";
|
|
5
|
+
import http from "node:http";
|
|
6
|
+
import https from "node:https";
|
|
7
|
+
import { createServer } from "node:net";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
|
|
12
|
+
const packageRoot = fileURLToPath(new URL("..", import.meta.url));
|
|
13
|
+
const packageMetadata = JSON.parse(
|
|
14
|
+
await readFile(new URL("../package.json", import.meta.url), "utf8"),
|
|
15
|
+
);
|
|
16
|
+
const packageVersion = packageMetadata.version || "unknown";
|
|
17
|
+
const args = process.argv.slice(2);
|
|
18
|
+
const portlessModule = fileURLToPath(await import.meta.resolve("portless"));
|
|
19
|
+
const portlessBin = portlessModule.replace(/dist[\\/]index\.js$/, "dist/cli.js");
|
|
20
|
+
const appEntry = process.env.OGTHING_TEST_APP_ENTRY || "bin/dev-named.mjs";
|
|
21
|
+
const appPort = Number(process.env.OGTHING_PORT || "4646");
|
|
22
|
+
const fallbackProxyPort = Number(process.env.OGTHING_FALLBACK_PROXY_PORT || "1355");
|
|
23
|
+
const ownedPortlessStateDir =
|
|
24
|
+
process.env.OGTHING_PORTLESS_STATE_DIR || join(homedir(), ".ogthing", "portless");
|
|
25
|
+
|
|
26
|
+
const usage = () => {
|
|
27
|
+
console.log(
|
|
28
|
+
`ogthing - inspect link metadata from your own machine\n\nUsage:\n bunx ogthing Inspect any url, localhost included, at ogthing.localhost\n bunx ogthing --version Print the installed ogthing version\n\nEnvironment:\n OGTHING_PORT Local app port (default: 4646)\n OGTHING_PROXY_PORT Portless proxy port (default: 443)\n OGTHING_PROXY_TLS Set to 0 for an HTTP Portless proxy\n OGTHING_FALLBACK_PROXY_PORT Unprivileged fallback port (default: 1355)\n OGTHING_NO_OPEN=1 Start without opening a browser\n OGTHING_NO_PORTLESS=1 Serve directly on 127.0.0.1 without Portless\n OGTHING_NO_PORTLESS_AUTOSTART Fail instead of starting a proxy automatically`,
|
|
29
|
+
);
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
33
|
+
usage();
|
|
34
|
+
process.exit(0);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (args.includes("--version") || args.includes("-v")) {
|
|
38
|
+
console.log(`ogthing ${packageVersion}`);
|
|
39
|
+
process.exit(0);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
console.log(`ogthing ${packageVersion}`);
|
|
43
|
+
|
|
44
|
+
const parsePort = (value, name) => {
|
|
45
|
+
const port = Number(value);
|
|
46
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
47
|
+
throw new Error(`${name} must be a port between 1 and 65535`);
|
|
48
|
+
}
|
|
49
|
+
return port;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
parsePort(appPort, "OGTHING_PORT");
|
|
54
|
+
} catch (error) {
|
|
55
|
+
console.error(`ogthing: ${error instanceof Error ? error.message : String(error)}`);
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const wait = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
60
|
+
|
|
61
|
+
const requestStatus = (url, { requirePortless = false, requireSuccess = true } = {}) =>
|
|
62
|
+
new Promise((resolve) => {
|
|
63
|
+
const parsed = new URL(url);
|
|
64
|
+
const isHttps = parsed.protocol === "https:";
|
|
65
|
+
let settled = false;
|
|
66
|
+
const finish = (value) => {
|
|
67
|
+
if (settled) return;
|
|
68
|
+
settled = true;
|
|
69
|
+
resolve(value);
|
|
70
|
+
};
|
|
71
|
+
const request = (isHttps ? https : http).request(
|
|
72
|
+
{
|
|
73
|
+
hostname: "127.0.0.1",
|
|
74
|
+
port: parsed.port || (isHttps ? 443 : 80),
|
|
75
|
+
path: `${parsed.pathname}${parsed.search}`,
|
|
76
|
+
method: "GET",
|
|
77
|
+
headers: { host: parsed.host },
|
|
78
|
+
rejectUnauthorized: false,
|
|
79
|
+
servername: parsed.hostname,
|
|
80
|
+
timeout: 750,
|
|
81
|
+
},
|
|
82
|
+
(response) => {
|
|
83
|
+
const portless = response.headers["x-portless"] === "1";
|
|
84
|
+
response.resume();
|
|
85
|
+
finish({
|
|
86
|
+
ok:
|
|
87
|
+
(!requireSuccess || (response.statusCode >= 200 && response.statusCode < 300)) &&
|
|
88
|
+
(!requirePortless || portless),
|
|
89
|
+
portless,
|
|
90
|
+
});
|
|
91
|
+
},
|
|
92
|
+
);
|
|
93
|
+
request.once("error", () => finish({ ok: false, portless: false }));
|
|
94
|
+
request.once("timeout", () => {
|
|
95
|
+
finish({ ok: false, portless: false });
|
|
96
|
+
request.destroy();
|
|
97
|
+
});
|
|
98
|
+
request.end();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const waitForUrl = async (url, options, attempts = 100, shouldStop = () => false) => {
|
|
102
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
103
|
+
if (shouldStop()) return false;
|
|
104
|
+
if ((await requestStatus(url, options)).ok) return true;
|
|
105
|
+
await wait(100);
|
|
106
|
+
}
|
|
107
|
+
return false;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const proxyUrlFor = (port, tls) => {
|
|
111
|
+
const protocol = tls ? "https" : "http";
|
|
112
|
+
const defaultProxyPort = tls ? 443 : 80;
|
|
113
|
+
return `${protocol}://ogthing.localhost${port === defaultProxyPort ? "" : `:${port}`}/`;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const stopProcess = (child) => {
|
|
117
|
+
if (!child || child.exitCode !== null || child.signalCode !== null) return;
|
|
118
|
+
child.kill("SIGTERM");
|
|
119
|
+
setTimeout(() => {
|
|
120
|
+
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
|
|
121
|
+
}, 2_000).unref();
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const portlessProxyArgs = (port, tls) => [
|
|
125
|
+
"proxy",
|
|
126
|
+
"start",
|
|
127
|
+
"--foreground",
|
|
128
|
+
"--port",
|
|
129
|
+
String(port),
|
|
130
|
+
tls ? "--https" : "--no-tls",
|
|
131
|
+
];
|
|
132
|
+
|
|
133
|
+
const portlessProxyEnv = (port, tls, stateDir) => ({
|
|
134
|
+
...process.env,
|
|
135
|
+
PORTLESS_HTTPS: tls ? "1" : "0",
|
|
136
|
+
PORTLESS_LAN: "0",
|
|
137
|
+
PORTLESS_PORT: String(port),
|
|
138
|
+
...(stateDir ? { PORTLESS_STATE_DIR: stateDir } : {}),
|
|
139
|
+
PORTLESS_SYNC_HOSTS: "0",
|
|
140
|
+
PORTLESS_TLD: "localhost",
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const hasCompatiblePortlessConfig = (port, tls) =>
|
|
144
|
+
new Promise((resolve) => {
|
|
145
|
+
const child = spawn(process.execPath, [portlessBin, ...portlessProxyArgs(port, tls)], {
|
|
146
|
+
cwd: packageRoot,
|
|
147
|
+
env: portlessProxyEnv(port, tls, process.env.PORTLESS_STATE_DIR),
|
|
148
|
+
stdio: "ignore",
|
|
149
|
+
});
|
|
150
|
+
let settled = false;
|
|
151
|
+
const finish = (compatible) => {
|
|
152
|
+
if (settled) return;
|
|
153
|
+
settled = true;
|
|
154
|
+
clearTimeout(timeout);
|
|
155
|
+
resolve(compatible);
|
|
156
|
+
};
|
|
157
|
+
const timeout = setTimeout(() => {
|
|
158
|
+
stopProcess(child);
|
|
159
|
+
finish(false);
|
|
160
|
+
}, 3_000);
|
|
161
|
+
child.once("error", () => finish(false));
|
|
162
|
+
child.once("exit", (code) => finish(code === 0));
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const findAvailablePort = () =>
|
|
166
|
+
new Promise((resolve, reject) => {
|
|
167
|
+
const server = createServer();
|
|
168
|
+
server.once("error", reject);
|
|
169
|
+
server.listen(0, "127.0.0.1", () => {
|
|
170
|
+
const address = server.address();
|
|
171
|
+
if (!address || typeof address === "string") {
|
|
172
|
+
reject(new Error("could not determine an available Portless port"));
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
server.close((error) => (error ? reject(error) : resolve(address.port)));
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
const startPortlessProxy = (port, tls, stateDir) => {
|
|
180
|
+
const child = spawn(process.execPath, [portlessBin, ...portlessProxyArgs(port, tls)], {
|
|
181
|
+
cwd: packageRoot,
|
|
182
|
+
env: portlessProxyEnv(port, tls, stateDir),
|
|
183
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
184
|
+
});
|
|
185
|
+
let exited = false;
|
|
186
|
+
let stderr = "";
|
|
187
|
+
child.stderr?.setEncoding("utf8");
|
|
188
|
+
child.stderr?.on("data", (chunk) => {
|
|
189
|
+
stderr = `${stderr}${chunk}`.slice(-3000);
|
|
190
|
+
});
|
|
191
|
+
child.once("exit", () => {
|
|
192
|
+
exited = true;
|
|
193
|
+
});
|
|
194
|
+
return {
|
|
195
|
+
child,
|
|
196
|
+
get exited() {
|
|
197
|
+
return exited;
|
|
198
|
+
},
|
|
199
|
+
get stderr() {
|
|
200
|
+
return stderr;
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const tryStartOwnedProxy = async (port, tls) => {
|
|
206
|
+
const url = proxyUrlFor(port, tls);
|
|
207
|
+
const started = startPortlessProxy(port, tls, ownedPortlessStateDir);
|
|
208
|
+
const ready = await waitForUrl(
|
|
209
|
+
url,
|
|
210
|
+
{ requirePortless: true, requireSuccess: false },
|
|
211
|
+
150,
|
|
212
|
+
() => started.exited,
|
|
213
|
+
);
|
|
214
|
+
if (!ready) {
|
|
215
|
+
stopProcess(started.child);
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
return { port, tls, url, owned: started.child, stateDir: ownedPortlessStateDir };
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
const ensurePortlessProxy = async (requestedPort, requestedTls) => {
|
|
222
|
+
const requestedUrl = proxyUrlFor(requestedPort, requestedTls);
|
|
223
|
+
const requestedProxyRunning = await waitForUrl(
|
|
224
|
+
requestedUrl,
|
|
225
|
+
{ requirePortless: true, requireSuccess: false },
|
|
226
|
+
3,
|
|
227
|
+
);
|
|
228
|
+
if (requestedProxyRunning && (await hasCompatiblePortlessConfig(requestedPort, requestedTls))) {
|
|
229
|
+
return {
|
|
230
|
+
port: requestedPort,
|
|
231
|
+
tls: requestedTls,
|
|
232
|
+
url: requestedUrl,
|
|
233
|
+
owned: null,
|
|
234
|
+
stateDir: process.env.PORTLESS_STATE_DIR,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (process.env.OGTHING_NO_PORTLESS_AUTOSTART === "1") {
|
|
239
|
+
throw new Error(
|
|
240
|
+
requestedProxyRunning
|
|
241
|
+
? `Portless at ${requestedUrl.replace(/\/$/, "")} is not configured for ogthing routing`
|
|
242
|
+
: `Portless is not running at ${requestedUrl.replace(/\/$/, "")}`,
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
let port = requestedPort;
|
|
247
|
+
let tls = requestedTls;
|
|
248
|
+
const needsPrivilege = port < 1024 && (process.getuid?.() ?? 1000) !== 0;
|
|
249
|
+
if (needsPrivilege || requestedProxyRunning) {
|
|
250
|
+
port = parsePort(fallbackProxyPort, "OGTHING_FALLBACK_PROXY_PORT");
|
|
251
|
+
tls = process.env.OGTHING_FALLBACK_PROXY_TLS === "1";
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
let url = proxyUrlFor(port, tls);
|
|
255
|
+
const fallbackRunning = await waitForUrl(
|
|
256
|
+
url,
|
|
257
|
+
{ requirePortless: true, requireSuccess: false },
|
|
258
|
+
3,
|
|
259
|
+
);
|
|
260
|
+
if (fallbackRunning && (await hasCompatiblePortlessConfig(port, tls))) {
|
|
261
|
+
return {
|
|
262
|
+
port,
|
|
263
|
+
tls,
|
|
264
|
+
url,
|
|
265
|
+
owned: null,
|
|
266
|
+
stateDir: process.env.PORTLESS_STATE_DIR,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
if (!fallbackRunning) {
|
|
270
|
+
const started = await tryStartOwnedProxy(port, tls);
|
|
271
|
+
if (started) return started;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// The fallback port is taken by something incompatible, or it refused us.
|
|
275
|
+
// Grab a random free port so ogthing still gets a named URL.
|
|
276
|
+
port = await findAvailablePort();
|
|
277
|
+
const started = await tryStartOwnedProxy(port, tls);
|
|
278
|
+
if (started) {
|
|
279
|
+
console.log(`ogthing: using fallback proxy port ${port}`);
|
|
280
|
+
return started;
|
|
281
|
+
}
|
|
282
|
+
throw new Error(`Portless could not start at ${url.replace(/\/$/, "")}`);
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
const openBrowser = (url) => {
|
|
286
|
+
if (process.env.OGTHING_NO_OPEN === "1") return;
|
|
287
|
+
const command =
|
|
288
|
+
process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
|
|
289
|
+
const commandArgs = process.platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
290
|
+
const browser = spawn(command, commandArgs, { detached: true, stdio: "ignore" });
|
|
291
|
+
browser.once("error", () => {});
|
|
292
|
+
browser.unref();
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
let localUrl = "";
|
|
296
|
+
try {
|
|
297
|
+
const requestedProxyPort = parsePort(
|
|
298
|
+
process.env.OGTHING_PROXY_PORT || "443",
|
|
299
|
+
"OGTHING_PROXY_PORT",
|
|
300
|
+
);
|
|
301
|
+
const requestedProxyTls = process.env.OGTHING_PROXY_TLS !== "0";
|
|
302
|
+
const bypassPortless = process.env.OGTHING_NO_PORTLESS === "1";
|
|
303
|
+
const proxy = bypassPortless
|
|
304
|
+
? {
|
|
305
|
+
port: requestedProxyPort,
|
|
306
|
+
tls: requestedProxyTls,
|
|
307
|
+
url: `http://127.0.0.1:${appPort}/`,
|
|
308
|
+
owned: null,
|
|
309
|
+
}
|
|
310
|
+
: await ensurePortlessProxy(requestedProxyPort, requestedProxyTls);
|
|
311
|
+
const { port: proxyPort, tls: proxyTls, url: proxyUrl, owned: ownedProxy } = proxy;
|
|
312
|
+
localUrl = bypassPortless ? proxy.url : proxyUrl;
|
|
313
|
+
const appUrl = `http://127.0.0.1:${appPort}/`;
|
|
314
|
+
|
|
315
|
+
const env = {
|
|
316
|
+
...process.env,
|
|
317
|
+
OGTHING_MODE: "server",
|
|
318
|
+
PORTLESS_HTTPS: proxyTls ? "1" : "0",
|
|
319
|
+
PORTLESS_PORT: String(proxyPort),
|
|
320
|
+
PORTLESS_TLD: "localhost",
|
|
321
|
+
PORTLESS_LAN: "0",
|
|
322
|
+
PORTLESS_SYNC_HOSTS: "0",
|
|
323
|
+
PORT: String(appPort),
|
|
324
|
+
...(proxy.stateDir ? { PORTLESS_STATE_DIR: proxy.stateDir } : {}),
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
const child = spawn(
|
|
328
|
+
process.execPath,
|
|
329
|
+
bypassPortless
|
|
330
|
+
? [appEntry]
|
|
331
|
+
: [
|
|
332
|
+
portlessBin,
|
|
333
|
+
"ogthing",
|
|
334
|
+
"--force",
|
|
335
|
+
"--app-port",
|
|
336
|
+
String(appPort),
|
|
337
|
+
process.execPath,
|
|
338
|
+
appEntry,
|
|
339
|
+
],
|
|
340
|
+
{ cwd: packageRoot, env, stdio: ["ignore", "ignore", "pipe"] },
|
|
341
|
+
);
|
|
342
|
+
|
|
343
|
+
let childExited = false;
|
|
344
|
+
let stderr = "";
|
|
345
|
+
child.stderr?.setEncoding("utf8");
|
|
346
|
+
child.stderr?.on("data", (chunk) => {
|
|
347
|
+
stderr = `${stderr}${chunk}`.slice(-3000);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
const forwardSignal = (signal) => {
|
|
351
|
+
if (childExited) return;
|
|
352
|
+
child.kill(signal);
|
|
353
|
+
stopProcess(ownedProxy);
|
|
354
|
+
setTimeout(() => {
|
|
355
|
+
if (!childExited) child.kill("SIGKILL");
|
|
356
|
+
}, 2000).unref();
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
process.once("SIGINT", () => forwardSignal("SIGINT"));
|
|
360
|
+
process.once("SIGTERM", () => forwardSignal("SIGTERM"));
|
|
361
|
+
|
|
362
|
+
child.once("error", (error) => {
|
|
363
|
+
stderr = `${stderr}\n${error.message}`.slice(-3000);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
child.once("exit", (code, signal) => {
|
|
367
|
+
childExited = true;
|
|
368
|
+
stopProcess(ownedProxy);
|
|
369
|
+
if (code !== 0 && code !== 130 && code !== 143 && signal === null) {
|
|
370
|
+
const detail = stderr.trim().split("\n").filter(Boolean).slice(-1)[0];
|
|
371
|
+
console.error(`ogthing: ${detail || `the app exited with code ${code}`}`);
|
|
372
|
+
}
|
|
373
|
+
process.exit(signal ? 128 : (code ?? 0));
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
if (await waitForUrl(appUrl, {}, 150, () => childExited)) {
|
|
377
|
+
if (ownedProxy) console.log(`ogthing: started Portless at ${proxyUrl}`);
|
|
378
|
+
console.log(`ogthing running at ${localUrl}`);
|
|
379
|
+
openBrowser(localUrl);
|
|
380
|
+
} else if (!childExited) {
|
|
381
|
+
console.error("ogthing: Portless could not route to the local app");
|
|
382
|
+
forwardSignal("SIGTERM");
|
|
383
|
+
}
|
|
384
|
+
} catch (error) {
|
|
385
|
+
console.error(`ogthing: ${error instanceof Error ? error.message : String(error)}`);
|
|
386
|
+
process.exit(1);
|
|
387
|
+
}
|
package/index.html
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<meta name="color-scheme" content="dark light" />
|
|
7
|
+
<link
|
|
8
|
+
rel="icon"
|
|
9
|
+
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='.9em' font-size='90'%3E%F0%9F%A6%87%3C/text%3E%3C/svg%3E"
|
|
10
|
+
/>
|
|
11
|
+
<title>ogthing</title>
|
|
12
|
+
</head>
|
|
13
|
+
<body>
|
|
14
|
+
<div id="root"></div>
|
|
15
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
16
|
+
</body>
|
|
17
|
+
</html>
|
package/package.json
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ogthing",
|
|
3
|
+
"version": "0.0.0-alpha.1",
|
|
4
|
+
"description": "Inspect Open Graph and social metadata from your own machine, localhost included.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"cli",
|
|
7
|
+
"opengraph",
|
|
8
|
+
"open-graph",
|
|
9
|
+
"metadata",
|
|
10
|
+
"seo",
|
|
11
|
+
"link-preview",
|
|
12
|
+
"twitter-card",
|
|
13
|
+
"bun"
|
|
14
|
+
],
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/extoci/ogthing.git"
|
|
19
|
+
},
|
|
20
|
+
"bin": {
|
|
21
|
+
"ogthing": "bin/ogthing.mjs"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"bin",
|
|
25
|
+
"server",
|
|
26
|
+
"src",
|
|
27
|
+
"index.html",
|
|
28
|
+
"tsconfig.json",
|
|
29
|
+
"vite.config.ts",
|
|
30
|
+
"portless.json",
|
|
31
|
+
"README.md",
|
|
32
|
+
"LICENSE"
|
|
33
|
+
],
|
|
34
|
+
"type": "module",
|
|
35
|
+
"scripts": {
|
|
36
|
+
"dev": "vite",
|
|
37
|
+
"dev:named": "bun bin/dev-named.mjs",
|
|
38
|
+
"build": "vite build",
|
|
39
|
+
"start": "vite preview",
|
|
40
|
+
"test": "bun run build && bun test",
|
|
41
|
+
"typecheck": "tsc --noEmit",
|
|
42
|
+
"lint": "oxlint bin server src tests vite.config.ts",
|
|
43
|
+
"format": "oxfmt --write bin server src tests vite.config.ts",
|
|
44
|
+
"format:check": "oxfmt --check bin server src tests vite.config.ts",
|
|
45
|
+
"pack:check": "bun pm pack --dry-run",
|
|
46
|
+
"prepublishOnly": "bun run typecheck && bun run lint && bun run format:check && bun test && bun run build"
|
|
47
|
+
},
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"@vitejs/plugin-react": "6.0.2",
|
|
50
|
+
"portless": "0.12.0",
|
|
51
|
+
"react": "19.2.6",
|
|
52
|
+
"react-dom": "19.2.6",
|
|
53
|
+
"vite": "8.0.13"
|
|
54
|
+
},
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"@types/node": "22.19.19",
|
|
57
|
+
"@types/react": "19.2.14",
|
|
58
|
+
"@types/react-dom": "19.2.3",
|
|
59
|
+
"bun-types": "1.3.14",
|
|
60
|
+
"oxfmt": "0.61.0",
|
|
61
|
+
"oxlint": "1.76.0",
|
|
62
|
+
"typescript": "7.0.2"
|
|
63
|
+
},
|
|
64
|
+
"engines": {
|
|
65
|
+
"bun": ">=1.3.14"
|
|
66
|
+
},
|
|
67
|
+
"publishConfig": {
|
|
68
|
+
"access": "public",
|
|
69
|
+
"tag": "alpha"
|
|
70
|
+
},
|
|
71
|
+
"packageManager": "bun@1.3.14"
|
|
72
|
+
}
|
package/portless.json
ADDED