mirror of
https://github.com/versia-pub/server.git
synced 2025-12-06 08:28:19 +01:00
feat(config): ✨ Add support for HTTP proxies on outgoing requests
This commit is contained in:
parent
0ecb65de29
commit
b8b822e553
|
|
@ -63,6 +63,7 @@ export default class EmojiAdd extends BaseCommand<typeof EmojiAdd> {
|
||||||
headers: {
|
headers: {
|
||||||
"Accept-Encoding": "identity",
|
"Accept-Encoding": "identity",
|
||||||
},
|
},
|
||||||
|
proxy: config.http.proxy.address,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,7 @@ export default class EmojiImport extends BaseCommand<typeof EmojiImport> {
|
||||||
headers: {
|
headers: {
|
||||||
"Accept-Encoding": "identity",
|
"Accept-Encoding": "identity",
|
||||||
},
|
},
|
||||||
|
proxy: config.http.proxy.address,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|
|
||||||
|
|
@ -84,13 +84,10 @@ banned_user_agents = [
|
||||||
]
|
]
|
||||||
|
|
||||||
[http.proxy]
|
[http.proxy]
|
||||||
# For SOCKS proxies (e.g. Tor)
|
# For HTTP proxies (e.g. Tor proxies)
|
||||||
# Will be used for all outgoing requests
|
# Will be used for all outgoing requests
|
||||||
enabled = false
|
enabled = false
|
||||||
host = "127.0.0.1"
|
address = "http://localhost:8118"
|
||||||
port = 9050
|
|
||||||
# Can be socks4, socks4a or socks5
|
|
||||||
type = "socks5"
|
|
||||||
|
|
||||||
[http.tls]
|
[http.tls]
|
||||||
# If these values are set, Lysand will use these files for TLS
|
# If these values are set, Lysand will use these files for TLS
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import type { ServerMetadata } from "@lysand-org/federation/types";
|
import type { ServerMetadata } from "@lysand-org/federation/types";
|
||||||
import { db } from "~/drizzle/db";
|
import { db } from "~/drizzle/db";
|
||||||
import { Instances } from "~/drizzle/schema";
|
import { Instances } from "~/drizzle/schema";
|
||||||
|
import { config } from "~/packages/config-manager";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents an instance in the database.
|
* Represents an instance in the database.
|
||||||
|
|
@ -24,9 +25,9 @@ export const addInstanceIfNotExists = async (url: string) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch the instance configuration
|
// Fetch the instance configuration
|
||||||
const metadata = (await fetch(new URL("/.well-known/lysand", origin)).then(
|
const metadata = (await fetch(new URL("/.well-known/lysand", origin), {
|
||||||
(res) => res.json(),
|
proxy: config.http.proxy.address,
|
||||||
)) as ServerMetadata;
|
}).then((res) => res.json())) as ServerMetadata;
|
||||||
|
|
||||||
if (metadata.type !== "ServerMetadata") {
|
if (metadata.type !== "ServerMetadata") {
|
||||||
throw new Error("Invalid instance metadata (wrong type)");
|
throw new Error("Invalid instance metadata (wrong type)");
|
||||||
|
|
|
||||||
|
|
@ -448,7 +448,9 @@ export const federateNote = async (note: Note) => {
|
||||||
);
|
);
|
||||||
|
|
||||||
// Send request
|
// Send request
|
||||||
const response = await fetch(request);
|
const response = await fetch(request, {
|
||||||
|
proxy: config.http.proxy.address,
|
||||||
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
dualLogger.log(
|
dualLogger.log(
|
||||||
|
|
|
||||||
|
|
@ -175,7 +175,9 @@ export const followRequestUser = async (
|
||||||
);
|
);
|
||||||
|
|
||||||
// Send request
|
// Send request
|
||||||
const response = await fetch(request);
|
const response = await fetch(request, {
|
||||||
|
proxy: config.http.proxy.address,
|
||||||
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
dualLogger.log(
|
dualLogger.log(
|
||||||
|
|
@ -230,7 +232,9 @@ export const sendFollowAccept = async (follower: User, followee: User) => {
|
||||||
);
|
);
|
||||||
|
|
||||||
// Send request
|
// Send request
|
||||||
const response = await fetch(request);
|
const response = await fetch(request, {
|
||||||
|
proxy: config.http.proxy.address,
|
||||||
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
dualLogger.log(
|
dualLogger.log(
|
||||||
|
|
@ -258,7 +262,9 @@ export const sendFollowReject = async (follower: User, followee: User) => {
|
||||||
);
|
);
|
||||||
|
|
||||||
// Send request
|
// Send request
|
||||||
const response = await fetch(request);
|
const response = await fetch(request, {
|
||||||
|
proxy: config.http.proxy.address,
|
||||||
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
dualLogger.log(
|
dualLogger.log(
|
||||||
|
|
@ -375,6 +381,7 @@ export const resolveWebFinger = async (
|
||||||
headers: {
|
headers: {
|
||||||
Accept: "application/json",
|
Accept: "application/json",
|
||||||
},
|
},
|
||||||
|
proxy: config.http.proxy.address,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
110
index.ts
110
index.ts
|
|
@ -1,7 +1,7 @@
|
||||||
|
import { checkConfig } from "@/init";
|
||||||
import { dualLogger } from "@/loggers";
|
import { dualLogger } from "@/loggers";
|
||||||
import { connectMeili } from "@/meilisearch";
|
import { connectMeili } from "@/meilisearch";
|
||||||
import { errorResponse, response } from "@/response";
|
import { errorResponse, response } from "@/response";
|
||||||
import chalk from "chalk";
|
|
||||||
import { config } from "config-manager";
|
import { config } from "config-manager";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { LogLevel, LogManager, type MultiLogManager } from "log-manager";
|
import { LogLevel, LogManager, type MultiLogManager } from "log-manager";
|
||||||
|
|
@ -46,113 +46,7 @@ process.on("SIGINT", () => {
|
||||||
const postCount = await Note.getCount();
|
const postCount = await Note.getCount();
|
||||||
|
|
||||||
if (isEntry) {
|
if (isEntry) {
|
||||||
// Check if JWT private key is set in config
|
await checkConfig(config, dualServerLogger);
|
||||||
if (!config.oidc.jwt_key) {
|
|
||||||
await dualServerLogger.log(
|
|
||||||
LogLevel.Critical,
|
|
||||||
"Server",
|
|
||||||
"The JWT private key is not set in the config",
|
|
||||||
);
|
|
||||||
await dualServerLogger.log(
|
|
||||||
LogLevel.Critical,
|
|
||||||
"Server",
|
|
||||||
"Below is a generated key for you to copy in the config at oidc.jwt_key",
|
|
||||||
);
|
|
||||||
// Generate a key for them
|
|
||||||
const keys = await crypto.subtle.generateKey("Ed25519", true, [
|
|
||||||
"sign",
|
|
||||||
"verify",
|
|
||||||
]);
|
|
||||||
|
|
||||||
const privateKey = Buffer.from(
|
|
||||||
await crypto.subtle.exportKey("pkcs8", keys.privateKey),
|
|
||||||
).toString("base64");
|
|
||||||
|
|
||||||
const publicKey = Buffer.from(
|
|
||||||
await crypto.subtle.exportKey("spki", keys.publicKey),
|
|
||||||
).toString("base64");
|
|
||||||
|
|
||||||
await dualServerLogger.log(
|
|
||||||
LogLevel.Critical,
|
|
||||||
"Server",
|
|
||||||
chalk.gray(`${privateKey};${publicKey}`),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Hang until Ctrl+C is pressed
|
|
||||||
await Bun.sleep(Number.POSITIVE_INFINITY);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try and import the key
|
|
||||||
const privateKey = await crypto.subtle
|
|
||||||
.importKey(
|
|
||||||
"pkcs8",
|
|
||||||
Buffer.from(config.oidc.jwt_key.split(";")[0], "base64"),
|
|
||||||
"Ed25519",
|
|
||||||
false,
|
|
||||||
["sign"],
|
|
||||||
)
|
|
||||||
.catch((e) => e as Error);
|
|
||||||
|
|
||||||
// Try and import the key
|
|
||||||
const publicKey = await crypto.subtle
|
|
||||||
.importKey(
|
|
||||||
"spki",
|
|
||||||
Buffer.from(config.oidc.jwt_key.split(";")[1], "base64"),
|
|
||||||
"Ed25519",
|
|
||||||
false,
|
|
||||||
["verify"],
|
|
||||||
)
|
|
||||||
.catch((e) => e as Error);
|
|
||||||
|
|
||||||
if (privateKey instanceof Error || publicKey instanceof Error) {
|
|
||||||
await dualServerLogger.log(
|
|
||||||
LogLevel.Critical,
|
|
||||||
"Server",
|
|
||||||
"The JWT key could not be imported! You may generate a new one by removing the old one from the config and restarting the server (this will invalidate all current JWTs).",
|
|
||||||
);
|
|
||||||
|
|
||||||
// Hang until Ctrl+C is pressed
|
|
||||||
await Bun.sleep(Number.POSITIVE_INFINITY);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
config.validation.challenges.enabled &&
|
|
||||||
!config.validation.challenges.key
|
|
||||||
) {
|
|
||||||
await dualServerLogger.log(
|
|
||||||
LogLevel.Critical,
|
|
||||||
"Server",
|
|
||||||
"Challenges are enabled, but the challenge key is not set in the config",
|
|
||||||
);
|
|
||||||
|
|
||||||
await dualServerLogger.log(
|
|
||||||
LogLevel.Critical,
|
|
||||||
"Server",
|
|
||||||
"Below is a generated key for you to copy in the config at validation.challenges.key",
|
|
||||||
);
|
|
||||||
|
|
||||||
const key = await crypto.subtle.generateKey(
|
|
||||||
{
|
|
||||||
name: "HMAC",
|
|
||||||
hash: "SHA-256",
|
|
||||||
},
|
|
||||||
true,
|
|
||||||
["sign"],
|
|
||||||
);
|
|
||||||
|
|
||||||
const exported = await crypto.subtle.exportKey("raw", key);
|
|
||||||
|
|
||||||
const base64 = Buffer.from(exported).toString("base64");
|
|
||||||
|
|
||||||
await dualServerLogger.log(
|
|
||||||
LogLevel.Critical,
|
|
||||||
"Server",
|
|
||||||
`Generated key: ${chalk.gray(base64)}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Hang until Ctrl+C is pressed
|
|
||||||
await Bun.sleep(Number.POSITIVE_INFINITY);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const app = new Hono({
|
const app = new Hono({
|
||||||
|
|
|
||||||
|
|
@ -123,21 +123,16 @@ export const configValidator = z.object({
|
||||||
proxy: z
|
proxy: z
|
||||||
.object({
|
.object({
|
||||||
enabled: z.boolean().default(false),
|
enabled: z.boolean().default(false),
|
||||||
host: z.string().min(1).default("localhost"),
|
address: zUrl,
|
||||||
port: z
|
|
||||||
.number()
|
|
||||||
.int()
|
|
||||||
.min(1)
|
|
||||||
.max(2 ** 16 - 1)
|
|
||||||
.default(9050),
|
|
||||||
type: z.enum(["socks4", "socks4a", "socks5"]).default("socks5"),
|
|
||||||
})
|
})
|
||||||
.default({
|
.default({
|
||||||
enabled: false,
|
enabled: false,
|
||||||
host: "localhost",
|
address: "",
|
||||||
port: 9050,
|
})
|
||||||
type: "socks5",
|
.transform((arg) => ({
|
||||||
}),
|
...arg,
|
||||||
|
address: arg.enabled ? arg.address : undefined,
|
||||||
|
})),
|
||||||
tls: z
|
tls: z
|
||||||
.object({
|
.object({
|
||||||
enabled: z.boolean().default(false),
|
enabled: z.boolean().default(false),
|
||||||
|
|
|
||||||
|
|
@ -595,6 +595,7 @@ export class Note extends BaseInterface<typeof Notes, StatusWithRelations> {
|
||||||
headers: {
|
headers: {
|
||||||
Accept: "application/json",
|
Accept: "application/json",
|
||||||
},
|
},
|
||||||
|
proxy: config.http.proxy.address,
|
||||||
});
|
});
|
||||||
|
|
||||||
note = await new EntityValidator().Note(await response.json());
|
note = await new EntityValidator().Note(await response.json());
|
||||||
|
|
|
||||||
|
|
@ -253,6 +253,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
|
||||||
headers: {
|
headers: {
|
||||||
Accept: "application/json",
|
Accept: "application/json",
|
||||||
},
|
},
|
||||||
|
proxy: config.http.proxy.address,
|
||||||
});
|
});
|
||||||
|
|
||||||
const json = (await response.json()) as Partial<LysandUser>;
|
const json = (await response.json()) as Partial<LysandUser>;
|
||||||
|
|
@ -551,7 +552,9 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
|
||||||
);
|
);
|
||||||
|
|
||||||
// FIXME: Add to new queue system when it's implemented
|
// FIXME: Add to new queue system when it's implemented
|
||||||
fetch(federationRequest);
|
fetch(federationRequest, {
|
||||||
|
proxy: config.http.proxy.address,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { errorResponse, response } from "@/response";
|
||||||
import { zValidator } from "@hono/zod-validator";
|
import { zValidator } from "@hono/zod-validator";
|
||||||
import type { Hono } from "hono";
|
import type { Hono } from "hono";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { config } from "~/packages/config-manager";
|
||||||
|
|
||||||
export const meta = applyConfig({
|
export const meta = applyConfig({
|
||||||
allowedMethods: ["GET"],
|
allowedMethods: ["GET"],
|
||||||
|
|
@ -44,6 +45,7 @@ export default (app: Hono) =>
|
||||||
headers: {
|
headers: {
|
||||||
"Accept-Encoding": "br",
|
"Accept-Encoding": "br",
|
||||||
},
|
},
|
||||||
|
proxy: config.http.proxy.address,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check if file extension ends in svg or svg
|
// Check if file extension ends in svg or svg
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import type { ContentFormat } from "@lysand-org/federation/types";
|
import type { ContentFormat } from "@lysand-org/federation/types";
|
||||||
import { lookup } from "mime-types";
|
import { lookup } from "mime-types";
|
||||||
|
import { config } from "~/packages/config-manager";
|
||||||
|
|
||||||
export const getBestContentType = (content?: ContentFormat) => {
|
export const getBestContentType = (content?: ContentFormat) => {
|
||||||
if (!content) {
|
if (!content) {
|
||||||
|
|
@ -51,9 +52,10 @@ export const mimeLookup = async (url: string) => {
|
||||||
return naiveLookup;
|
return naiveLookup;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchLookup = fetch(url, { method: "HEAD" }).then(
|
const fetchLookup = fetch(url, {
|
||||||
(response) => response.headers.get("content-type") || "",
|
method: "HEAD",
|
||||||
);
|
proxy: config.http.proxy.address,
|
||||||
|
}).then((response) => response.headers.get("content-type") || "");
|
||||||
|
|
||||||
return fetchLookup;
|
return fetchLookup;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
183
utils/init.ts
Normal file
183
utils/init.ts
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
import chalk from "chalk";
|
||||||
|
import type { Config } from "~/packages/config-manager";
|
||||||
|
import {
|
||||||
|
LogLevel,
|
||||||
|
type LogManager,
|
||||||
|
type MultiLogManager,
|
||||||
|
} from "~/packages/log-manager";
|
||||||
|
|
||||||
|
export const checkConfig = async (
|
||||||
|
config: Config,
|
||||||
|
logger: LogManager | MultiLogManager,
|
||||||
|
) => {
|
||||||
|
await checkOidcConfig(config, logger);
|
||||||
|
|
||||||
|
await checkHttpProxyConfig(config, logger);
|
||||||
|
|
||||||
|
await checkChallengeConfig(config, logger);
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkHttpProxyConfig = async (
|
||||||
|
config: Config,
|
||||||
|
logger: LogManager | MultiLogManager,
|
||||||
|
) => {
|
||||||
|
if (config.http.proxy.enabled) {
|
||||||
|
if (!config.http.proxy.address) {
|
||||||
|
await logger.log(
|
||||||
|
LogLevel.Critical,
|
||||||
|
"Server",
|
||||||
|
"The HTTP proxy is enabled, but the proxy address is not set in the config",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Hang until Ctrl+C is pressed
|
||||||
|
await Bun.sleep(Number.POSITIVE_INFINITY);
|
||||||
|
}
|
||||||
|
|
||||||
|
await logger.log(
|
||||||
|
LogLevel.Info,
|
||||||
|
"Server",
|
||||||
|
`HTTP proxy enabled at ${chalk.gray(config.http.proxy.address)}, testing...`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test the proxy
|
||||||
|
const response = await fetch("https://api.ipify.org?format=json", {
|
||||||
|
proxy: config.http.proxy.address,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ip = (await response.json()).ip;
|
||||||
|
|
||||||
|
await logger.log(
|
||||||
|
LogLevel.Info,
|
||||||
|
"Server",
|
||||||
|
`Your IPv4 address is ${chalk.gray(ip)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
await logger.log(
|
||||||
|
LogLevel.Critical,
|
||||||
|
"Server",
|
||||||
|
"The HTTP proxy is enabled, but the proxy address is not reachable",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Hang until Ctrl+C is pressed
|
||||||
|
await Bun.sleep(Number.POSITIVE_INFINITY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkChallengeConfig = async (
|
||||||
|
config: Config,
|
||||||
|
logger: LogManager | MultiLogManager,
|
||||||
|
) => {
|
||||||
|
if (
|
||||||
|
config.validation.challenges.enabled &&
|
||||||
|
!config.validation.challenges.key
|
||||||
|
) {
|
||||||
|
await logger.log(
|
||||||
|
LogLevel.Critical,
|
||||||
|
"Server",
|
||||||
|
"Challenges are enabled, but the challenge key is not set in the config",
|
||||||
|
);
|
||||||
|
|
||||||
|
await logger.log(
|
||||||
|
LogLevel.Critical,
|
||||||
|
"Server",
|
||||||
|
"Below is a generated key for you to copy in the config at validation.challenges.key",
|
||||||
|
);
|
||||||
|
|
||||||
|
const key = await crypto.subtle.generateKey(
|
||||||
|
{
|
||||||
|
name: "HMAC",
|
||||||
|
hash: "SHA-256",
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
["sign"],
|
||||||
|
);
|
||||||
|
|
||||||
|
const exported = await crypto.subtle.exportKey("raw", key);
|
||||||
|
|
||||||
|
const base64 = Buffer.from(exported).toString("base64");
|
||||||
|
|
||||||
|
await logger.log(
|
||||||
|
LogLevel.Critical,
|
||||||
|
"Server",
|
||||||
|
`Generated key: ${chalk.gray(base64)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Hang until Ctrl+C is pressed
|
||||||
|
await Bun.sleep(Number.POSITIVE_INFINITY);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkOidcConfig = async (
|
||||||
|
config: Config,
|
||||||
|
logger: LogManager | MultiLogManager,
|
||||||
|
) => {
|
||||||
|
if (!config.oidc.jwt_key) {
|
||||||
|
await logger.log(
|
||||||
|
LogLevel.Critical,
|
||||||
|
"Server",
|
||||||
|
"The JWT private key is not set in the config",
|
||||||
|
);
|
||||||
|
await logger.log(
|
||||||
|
LogLevel.Critical,
|
||||||
|
"Server",
|
||||||
|
"Below is a generated key for you to copy in the config at oidc.jwt_key",
|
||||||
|
);
|
||||||
|
// Generate a key for them
|
||||||
|
const keys = await crypto.subtle.generateKey("Ed25519", true, [
|
||||||
|
"sign",
|
||||||
|
"verify",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const privateKey = Buffer.from(
|
||||||
|
await crypto.subtle.exportKey("pkcs8", keys.privateKey),
|
||||||
|
).toString("base64");
|
||||||
|
|
||||||
|
const publicKey = Buffer.from(
|
||||||
|
await crypto.subtle.exportKey("spki", keys.publicKey),
|
||||||
|
).toString("base64");
|
||||||
|
|
||||||
|
await logger.log(
|
||||||
|
LogLevel.Critical,
|
||||||
|
"Server",
|
||||||
|
chalk.gray(`${privateKey};${publicKey}`),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Hang until Ctrl+C is pressed
|
||||||
|
await Bun.sleep(Number.POSITIVE_INFINITY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try and import the key
|
||||||
|
const privateKey = await crypto.subtle
|
||||||
|
.importKey(
|
||||||
|
"pkcs8",
|
||||||
|
Buffer.from(config.oidc.jwt_key.split(";")[0], "base64"),
|
||||||
|
"Ed25519",
|
||||||
|
false,
|
||||||
|
["sign"],
|
||||||
|
)
|
||||||
|
.catch((e) => e as Error);
|
||||||
|
|
||||||
|
// Try and import the key
|
||||||
|
const publicKey = await crypto.subtle
|
||||||
|
.importKey(
|
||||||
|
"spki",
|
||||||
|
Buffer.from(config.oidc.jwt_key.split(";")[1], "base64"),
|
||||||
|
"Ed25519",
|
||||||
|
false,
|
||||||
|
["verify"],
|
||||||
|
)
|
||||||
|
.catch((e) => e as Error);
|
||||||
|
|
||||||
|
if (privateKey instanceof Error || publicKey instanceof Error) {
|
||||||
|
await logger.log(
|
||||||
|
LogLevel.Critical,
|
||||||
|
"Server",
|
||||||
|
"The JWT key could not be imported! You may generate a new one by removing the old one from the config and restarting the server (this will invalidate all current JWTs).",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Hang until Ctrl+C is pressed
|
||||||
|
await Bun.sleep(Number.POSITIVE_INFINITY);
|
||||||
|
}
|
||||||
|
};
|
||||||
Loading…
Reference in a new issue