Added cron
This commit is contained in:
parent
0e058001ac
commit
3742a016aa
21
main.ts
21
main.ts
|
|
@ -17,14 +17,31 @@ const run = async (cmd: string[]) => {
|
|||
console.log(`👋 Process ${commandText}(PID ${proc.pid}) exited with code ${status}`)
|
||||
}
|
||||
|
||||
return status
|
||||
return proc
|
||||
}
|
||||
|
||||
// Make sure we cleanup processes on exit
|
||||
const prepareCleanup = () => {
|
||||
const cleanup = async (signal: NodeJS.Signals | "exit") => {
|
||||
process.on(signal, () => {
|
||||
console.log(`🛑 Received ${signal}, cleaning up...`)
|
||||
procs.forEach((proc) => proc.kill("SIGTERM"))
|
||||
})
|
||||
}
|
||||
|
||||
cleanup("SIGINT")
|
||||
cleanup("SIGTERM")
|
||||
cleanup("SIGHUP")
|
||||
cleanup("exit")
|
||||
}
|
||||
|
||||
let procs: Bun.Subprocess[] = []
|
||||
try {
|
||||
prepareCleanup()
|
||||
const isDev = process.env.NODE_ENV !== "production"
|
||||
const noElide = isDev ? "--elide-lines=0" : ""
|
||||
|
||||
await Promise.all([
|
||||
procs = await Promise.all([
|
||||
run(["bun", "run", noElide, "--filter=@workshop/http", "start"]),
|
||||
run(["bun", "run", noElide, "--filter=@workshop/spike", "bot:discord"]),
|
||||
])
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@
|
|||
],
|
||||
"catalog": {
|
||||
"hono": "^4.8.0",
|
||||
"zod": "3.25.67"
|
||||
"zod": "3.25.67",
|
||||
"luxon": "^3.7.1"
|
||||
}
|
||||
},
|
||||
"prettier": {
|
||||
|
|
|
|||
|
|
@ -4,8 +4,13 @@
|
|||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "bun run --hot src/server",
|
||||
"start": "bun run src/server"
|
||||
"dev": "bun run --hot src/main.ts",
|
||||
"start": "bun run src/main.ts",
|
||||
"cron:constantly": "echo '💰 constantly'",
|
||||
"cron:hourly@0:25": "echo '⌛ hourly@:030'",
|
||||
"cron:daily@21:20": "echo '🏬 daily at midnight'",
|
||||
"cron:monthly@13:00": "echo '🏬 daily at noon'",
|
||||
"cron:daily@21:22 #fail": "echo '🍖 constantly with failure' && ls /nonexistant"
|
||||
},
|
||||
"prettier": {
|
||||
"printWidth": 110,
|
||||
|
|
@ -16,7 +21,9 @@
|
|||
"@workshop/shared": "workspace:*",
|
||||
"@workshop/todo": "workspace:*",
|
||||
"hono": "catalog:",
|
||||
"tailwind": "^4.0.0"
|
||||
"tailwind": "^4.0.0",
|
||||
"luxon": "catalog:",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest"
|
||||
|
|
|
|||
44
packages/http/src/auth.ts
Normal file
44
packages/http/src/auth.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
export const requireAuth = (handler: (req: Request) => Response | Promise<Response>) => {
|
||||
return async (req: Request) => {
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
return handler(req)
|
||||
}
|
||||
|
||||
const cookies = new Bun.CookieMap(req.headers.get("cookie") ?? "")
|
||||
if (cookies.get("auth") === "authenticated") {
|
||||
return handler(req)
|
||||
}
|
||||
|
||||
if (validAuth(req)) {
|
||||
const response = await handler(req)
|
||||
const newHeaders = new Headers(response.headers)
|
||||
|
||||
const url = new URL(req.url)
|
||||
const cookieDomain = `.${url.hostname.split(".").slice(-2).join(".")}`
|
||||
|
||||
const authCookie = new Bun.Cookie({
|
||||
name: "auth",
|
||||
value: "authenticated",
|
||||
domain: cookieDomain,
|
||||
path: "/",
|
||||
maxAge: 86400,
|
||||
httpOnly: true,
|
||||
})
|
||||
|
||||
newHeaders.set("Set-Cookie", authCookie.toString())
|
||||
|
||||
return new Response(response.body, { status: response.status, headers: newHeaders })
|
||||
}
|
||||
|
||||
return new Response("Unauthorized", {
|
||||
status: 401,
|
||||
headers: { "WWW-Authenticate": 'Basic realm="Workshop Access"' },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const validAuth = (req: Request): boolean => {
|
||||
const logins = ["spike:888", "defunkt:888", "probablycorey:888"]
|
||||
const auth = req.headers.get("authorization")
|
||||
return logins.some((login) => `Basic ${btoa(login)}` === auth)
|
||||
}
|
||||
59
packages/http/src/cron.test.ts
Normal file
59
packages/http/src/cron.test.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { type PackageInfo } from "@workshop/shared/packages"
|
||||
import { DateTime } from "luxon"
|
||||
import { test, expect } from "bun:test"
|
||||
import { CronJob, type CronSchedule } from "./cron"
|
||||
|
||||
const createCron = (cronString: string, lastRunAt?: DateTime) => {
|
||||
const cronJob = new CronJob(cronString, "", {json: {name: "test", version: "1.0.0"}, path: "."}, lastRunAt)
|
||||
return cronJob
|
||||
}
|
||||
|
||||
test("valid CronJobs", () => {
|
||||
expect(createCron("hourly").schedule).toEqual({ unit: "hourly", hour: 0, minute: 0 })
|
||||
expect(createCron("hourly@").schedule).toEqual({ unit: "hourly", hour: 0, minute: 0 })
|
||||
expect(createCron("hourly@0:30").schedule).toEqual({ unit: "hourly", hour: 0, minute: 30 })
|
||||
expect(createCron("hourly@10:59").schedule).toEqual({ unit: "hourly", hour: 10, minute: 59 })
|
||||
})
|
||||
|
||||
test("invalid CronJobs", () => {
|
||||
expect(() => createCron("")).toThrow()
|
||||
expect(() => createCron("hourl")).toThrow()
|
||||
expect(() => createCron("hourly@*")).toThrow()
|
||||
expect(() => createCron("hourly@45:59")).toThrow()
|
||||
expect(() => createCron("hourly@0:99")).toThrow()
|
||||
})
|
||||
|
||||
test("isDue", () => {
|
||||
const lastRunAt = DateTime.fromRFC2822("1 Jan 2025 00:00:00 GMT")
|
||||
|
||||
let cronJob = createCron("constantly", lastRunAt)
|
||||
expect(cronJob.isDue(lastRunAt.plus({ seconds: 5 }))).toBe(false)
|
||||
expect(cronJob.isDue(lastRunAt.plus({ seconds: 60 }))).toBe(true)
|
||||
|
||||
cronJob = createCron("hourly@0:15", lastRunAt)
|
||||
expect(cronJob.isDue(lastRunAt.plus({ minutes: 14 }))).toBe(false)
|
||||
|
||||
cronJob = createCron("hourly@0:30", lastRunAt.set({ minute: 45 }).minus({ hour: 1 }))
|
||||
expect(cronJob.isDue(lastRunAt.set({ minute: 15 }))).toBe(false)
|
||||
expect(cronJob.isDue(lastRunAt.set({ minute: 50 }))).toBe(true)
|
||||
|
||||
const dailyLastRunAt = lastRunAt.set({ hour: 1, minute: 30 }).minus({ days: 1 })
|
||||
cronJob = createCron("daily@1:20", dailyLastRunAt)
|
||||
expect(cronJob.isDue(dailyLastRunAt.plus({ hours: 20 }))).toBe(false)
|
||||
expect(cronJob.isDue(dailyLastRunAt.plus({ hours: 25 }))).toBe(true)
|
||||
|
||||
const weeklyLastRunAt = lastRunAt.set({ hour: 5, minute: 59 }).minus({ week: 1 })
|
||||
cronJob = createCron("weekly@5:59", weeklyLastRunAt)
|
||||
expect(cronJob.isDue(weeklyLastRunAt.plus({ days: 1 }))).toBe(false)
|
||||
expect(cronJob.isDue(weeklyLastRunAt.plus({ days: 7 }))).toBe(true)
|
||||
|
||||
const monthLastRunAt = lastRunAt.set({ hour: 1, minute: 0 }).minus({ days: 20 })
|
||||
cronJob = createCron("monthly@1:00", monthLastRunAt)
|
||||
expect(cronJob.isDue(monthLastRunAt.plus({ days: 5 }))).toBe(false)
|
||||
expect(cronJob.isDue(monthLastRunAt.plus({ days: 20 }))).toBe(true)
|
||||
|
||||
const shortMonthLastRunAt = DateTime.fromRFC2822("1 Feb 2025 00:00:00 GMT").set({ hour: 1, minute: 0 })
|
||||
cronJob = createCron("monthly@1:00", shortMonthLastRunAt)
|
||||
expect(cronJob.isDue(shortMonthLastRunAt.plus({ days: 5 }))).toBe(false)
|
||||
expect(cronJob.isDue(shortMonthLastRunAt.plus({ days: 29 }))).toBe(true)
|
||||
})
|
||||
211
packages/http/src/cron.ts
Normal file
211
packages/http/src/cron.ts
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
import { getErrorMessage } from "@workshop/shared/errors"
|
||||
import kv from "@workshop/shared/kv"
|
||||
import { getPackageInfo, type PackageInfo } from "@workshop/shared/packages"
|
||||
import { DateTime } from "luxon"
|
||||
import { z } from "zod"
|
||||
|
||||
export const startCron = async () => {
|
||||
const crons = await getCrons()
|
||||
await runCrons(crons)
|
||||
}
|
||||
|
||||
const runCrons = async (crons: CronJob[]) => {
|
||||
try {
|
||||
const promises = crons.map((cron) => {
|
||||
return cron.runIfDue()
|
||||
})
|
||||
|
||||
await Promise.allSettled(promises)
|
||||
} catch (error) {
|
||||
console.error("💥 Error running cron jobs:", error)
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
runCrons(crons)
|
||||
}, 500)
|
||||
}
|
||||
}
|
||||
|
||||
const getCrons = async () => {
|
||||
const packageInfo = await getPackageInfo()
|
||||
const cronScripts: CronJob[] = []
|
||||
|
||||
for (const info of packageInfo) {
|
||||
if (!info.json.scripts) continue
|
||||
for (const [scriptName, scriptCommand] of Object.entries(info.json.scripts)) {
|
||||
if (!scriptName.startsWith("cron:")) continue
|
||||
|
||||
try {
|
||||
const cronJob = new CronJob(scriptName, scriptCommand, info)
|
||||
|
||||
// Create an entry for everything so we see it in the UI
|
||||
await kv.update("cronJobs", {}, (jobs) => {
|
||||
if (jobs[cronJob.id]) return jobs
|
||||
jobs[cronJob.id] = []
|
||||
return jobs
|
||||
})
|
||||
|
||||
await cronJob.recoverLastRun()
|
||||
|
||||
cronScripts.push(cronJob)
|
||||
} catch (error) {
|
||||
console.error(`Invalid cron job "${scriptName}" in package "${info.json.name}":`, error)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
return cronScripts
|
||||
}
|
||||
|
||||
const DEBUG = false
|
||||
export class CronJob {
|
||||
schedule: CronSchedule
|
||||
id: string
|
||||
private lastRunAt: DateTime = DateTime.now().minus({ seconds: 60 })
|
||||
private scriptName: string
|
||||
private script: string
|
||||
private info: PackageInfo
|
||||
|
||||
constructor(scriptName: string, script: string, info: PackageInfo, lastRunAt?: DateTime) {
|
||||
this.scriptName = scriptName
|
||||
this.script = script
|
||||
this.info = info
|
||||
this.schedule = parseScriptName(this.scriptName)
|
||||
this.id = `${this.info.json.name} ${this.scriptName} ${this.script}`
|
||||
this.lastRunAt = lastRunAt || DateTime.now().minus({ seconds: 60 })
|
||||
}
|
||||
|
||||
static isValid(cron: string): boolean {
|
||||
try {
|
||||
parseScriptName(cron)
|
||||
return true
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
debug(...args: unknown[]) {
|
||||
if (!DEBUG) return
|
||||
console.debug("🪲", ...args)
|
||||
}
|
||||
|
||||
async recoverLastRun() {
|
||||
const cronJobs = await kv.get("cronJobs", {})
|
||||
const lastJob = cronJobs[this.id]?.[0]
|
||||
const defaultValue = DateTime.now().minus({ seconds: 60 })
|
||||
if (!lastJob) return defaultValue
|
||||
|
||||
const lastRunAt = DateTime.fromISO(lastJob.runAt)
|
||||
this.debug(`Recovering last run for "${this.scriptName}"`, lastRunAt.toISO())
|
||||
this.lastRunAt = lastRunAt.isValid ? lastRunAt : defaultValue
|
||||
}
|
||||
|
||||
toString() {
|
||||
const format = "LLL d, yyyy HH:mm:ss ZZZZ"
|
||||
return `CronJob({"${this.scriptName}": "${this.script}"} lastRun:"${this.lastRunAt.toFormat(format)}")`
|
||||
}
|
||||
|
||||
isDue(now: DateTime): boolean {
|
||||
if (this.schedule.unit === "constantly") {
|
||||
const diff = now.diff(this.lastRunAt, "minutes").minutes
|
||||
return diff >= 1
|
||||
}
|
||||
|
||||
let previousScheduledAt = now.setZone("UTC").set({
|
||||
hour: this.schedule.hour,
|
||||
minute: this.schedule.minute,
|
||||
second: 0,
|
||||
millisecond: 0,
|
||||
})
|
||||
|
||||
if (this.schedule.unit === "weekly") {
|
||||
previousScheduledAt = previousScheduledAt.startOf("week")
|
||||
} else if (this.schedule.unit === "monthly") {
|
||||
previousScheduledAt = previousScheduledAt.startOf("month")
|
||||
}
|
||||
|
||||
// If the scheduled time is in the future, go back one period
|
||||
if (previousScheduledAt > now) {
|
||||
const unitMap = {
|
||||
hourly: { hour: 1 },
|
||||
daily: { day: 1 },
|
||||
weekly: { week: 1 },
|
||||
monthly: { month: 1 },
|
||||
}
|
||||
previousScheduledAt = previousScheduledAt.minus(unitMap[this.schedule.unit])
|
||||
}
|
||||
|
||||
const shouldRun = this.lastRunAt < previousScheduledAt // If the last run is before the previous scheduled time, it's due
|
||||
this.debug(
|
||||
`${shouldRun ? "✅" : "❌"} ${
|
||||
this.scriptName
|
||||
}: Last run at "${this.lastRunAt.toISO()}" previous scheduled at "${previousScheduledAt.toISO()}"`
|
||||
)
|
||||
|
||||
return shouldRun
|
||||
}
|
||||
|
||||
async runIfDue() {
|
||||
const now = DateTime.now()
|
||||
if (!this.isDue(now)) return
|
||||
this.run(now)
|
||||
}
|
||||
|
||||
async run(now: DateTime) {
|
||||
this.lastRunAt = now
|
||||
|
||||
let runError: unknown
|
||||
let stdout: string | undefined
|
||||
let stderr: string | undefined
|
||||
|
||||
try {
|
||||
// --silent to suppress output of the command to stderr
|
||||
const proc = Bun.spawn(["bun", "run", "--silent", this.scriptName], {
|
||||
stderr: "pipe",
|
||||
stdout: "pipe",
|
||||
cwd: this.info.path,
|
||||
})
|
||||
|
||||
await proc.exited
|
||||
|
||||
stdout = typeof proc.stdout === "number" ? proc.stdout : await Bun.readableStreamToText(proc.stdout)
|
||||
stderr = typeof proc.stderr === "number" ? proc.stderr : await Bun.readableStreamToText(proc.stderr)
|
||||
|
||||
const status = proc.exitCode
|
||||
if (status !== 0) {
|
||||
throw new Error(`Failed with exit code ${status}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`💥 Error running cron job "${this.scriptName}" in "${this.info.json.name}":`, error)
|
||||
runError = error
|
||||
} finally {
|
||||
await kv.update("cronJobs", {}, (jobs) => {
|
||||
let history = jobs[this.id] || []
|
||||
history = history.slice(0, 9) // Keep only the last 10 runs
|
||||
history.unshift({
|
||||
runAt: now.toISO()!,
|
||||
error: runError ? getErrorMessage(runError) : undefined,
|
||||
stdout,
|
||||
stderr,
|
||||
})
|
||||
jobs[this.id] = history
|
||||
return jobs
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const parseScriptName = (scriptName: string): CronSchedule => {
|
||||
const cron = scriptName.replace("cron:", "").replace(/ #.*$/, "")
|
||||
const parts = cron.split("@")
|
||||
const [unit, time] = parts
|
||||
const [hour, minute] = time?.split(":") || []
|
||||
|
||||
return cronScheduleSchema.parse({ unit, hour, minute })
|
||||
}
|
||||
|
||||
export type CronSchedule = z.infer<typeof cronScheduleSchema>
|
||||
const cronScheduleSchema = z.object({
|
||||
unit: z.enum(["constantly", "hourly", "daily", "weekly", "monthly"]),
|
||||
hour: z.coerce.number().int().min(0).max(23).default(0),
|
||||
minute: z.coerce.number().int().min(0).max(59).default(0),
|
||||
})
|
||||
10
packages/http/src/main.ts
Normal file
10
packages/http/src/main.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { startCron } from "./cron"
|
||||
import { startServer } from "./server"
|
||||
|
||||
try {
|
||||
await startServer()
|
||||
await startCron()
|
||||
} catch (error) {
|
||||
console.error("💥 Error starting http:", error)
|
||||
process.exit(1)
|
||||
}
|
||||
|
|
@ -40,6 +40,7 @@ export const subdomainInfo = async () => {
|
|||
|
||||
subdomainInfo.push({
|
||||
path: info.path,
|
||||
name: basename(info.path),
|
||||
hasDevScript: Boolean(info.json.scripts?.["subdomain:dev"]),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,24 +1,66 @@
|
|||
import { subdomainPackageInfo } from "../orchestrator"
|
||||
import { subdomainInfo } from "../orchestrator"
|
||||
import type { LoaderProps } from "@workshop/nano-remix"
|
||||
import kv, { type CronJobHistory } from "@workshop/shared/kv"
|
||||
import { zone } from "@workshop/shared/utils"
|
||||
import { useMemo } from "hono/jsx"
|
||||
import { DateTime } from "luxon"
|
||||
|
||||
export const loader = async (_req: Request) => {
|
||||
const packagePaths = await subdomainPackageInfo()
|
||||
return { packagePaths }
|
||||
const info = await subdomainInfo()
|
||||
const cronJobs = await kv.get("cronJobs", {})
|
||||
return { subdomains: info.map((i) => i.name), cronJobs }
|
||||
}
|
||||
|
||||
export default function Index({ packagePaths }: LoaderProps<typeof loader>) {
|
||||
export default function Index({ subdomains, cronJobs }: LoaderProps<typeof loader>) {
|
||||
const url = new URL(import.meta.url)
|
||||
|
||||
const sortedEntries = useMemo(() => {
|
||||
return Object.entries(cronJobs).sort((a, b) => {
|
||||
const aHistory = a[1] ?? []
|
||||
const bHistory = b[1] ?? []
|
||||
const latestA = aHistory[0]
|
||||
const latestB = bHistory[0]
|
||||
if (!latestA && !latestB) return 0
|
||||
if (!latestA) return 1
|
||||
if (!latestB) return -1
|
||||
return DateTime.fromISO(latestB.runAt).diff(DateTime.fromISO(latestA.runAt)).as("milliseconds")
|
||||
})
|
||||
}, [cronJobs])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Subdomain Servers</h1>
|
||||
<h2>Subdomain Servers</h2>
|
||||
<ul>
|
||||
{packagePaths.map((pkg) => (
|
||||
<li key={pkg.packageName}>
|
||||
<a href={`${url.protocol}//${pkg.packageName}.${url.host}`}>{pkg.packageName}</a>
|
||||
{subdomains.map((subdomain) => (
|
||||
<li key={subdomain}>
|
||||
<a href={`${url.protocol}//${subdomain}.${url.host}`}>{subdomain}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<h2>CronJobs</h2>
|
||||
<ul>
|
||||
{sortedEntries.map(([id, history]) => (
|
||||
<li key={id}>
|
||||
<h4>{id}</h4>
|
||||
<JobHistory history={history} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const JobHistory = ({ history }: { history: CronJobHistory[] }) => {
|
||||
return (
|
||||
<ul>
|
||||
{history.slice(0, 2).map((run, index) => (
|
||||
<li key={index}>
|
||||
<strong>{DateTime.fromISO(run.runAt).setZone(zone).toRFC2822()}</strong>
|
||||
{run.error && <div style={{ color: "red" }}>Error: {run.error}</div>}
|
||||
{run.stdout && <pre>Output: {run.stdout}</pre>}
|
||||
{run.stderr && <pre style={{ color: "red" }}>Error Output: {run.stderr}</pre>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { startSubdomainServers } from "./orchestrator"
|
||||
import { nanoRemix } from "@workshop/nano-remix"
|
||||
import { requireAuth } from "./auth"
|
||||
import { join } from "node:path"
|
||||
|
||||
/**
|
||||
|
|
@ -9,27 +10,16 @@ import { join } from "node:path"
|
|||
* 1. Starts multiple subdomain servers on different ports (3001+)
|
||||
* 2. Main server (port 3000) acts as a proxy that routes requests based on subdomain
|
||||
* 3. All requests are protected with Basic HTTP auth + persistent cookies
|
||||
* 4. Once authenticated, cookies work across all subdomains (*.thedomain.com)
|
||||
* 4. Once authenticated, cookies work across all subdomains (*.theworkshop.cc)
|
||||
* 5. In development, uses subdomain:dev scripts if available for hot reloading
|
||||
*
|
||||
* This server is designed to be run in production with NODE_ENV=production. On development,
|
||||
* it allows unauthenticated access because YOLO.
|
||||
*/
|
||||
|
||||
const serve = async () => {
|
||||
try {
|
||||
const portMap = await startSubdomainServers()
|
||||
const server = startServer(portMap)
|
||||
logServerInfo(server, portMap)
|
||||
} catch (error) {
|
||||
console.error("❌ Error starting http package:", error)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
export const startServer = async () => {
|
||||
const portMap = await startSubdomainServers()
|
||||
|
||||
serve()
|
||||
|
||||
const startServer = (portMap: Record<string, number>) => {
|
||||
const server = Bun.serve({
|
||||
port: 3000,
|
||||
routes: {
|
||||
|
|
@ -69,12 +59,13 @@ const startServer = (portMap: Record<string, number>) => {
|
|||
},
|
||||
})
|
||||
|
||||
return server
|
||||
logServerInfo(server, portMap)
|
||||
}
|
||||
|
||||
const logServerInfo = (server: Bun.Server, portMap: Record<string, number>) => {
|
||||
console.log(`${server.url}`)
|
||||
const subdomainEntries = Object.entries(portMap)
|
||||
|
||||
subdomainEntries.forEach(([subdomain, port], index) => {
|
||||
const subdomainUrl = new URL(server.url)
|
||||
subdomainUrl.hostname = `${subdomain}.${subdomainUrl.hostname}`
|
||||
|
|
@ -85,48 +76,3 @@ const logServerInfo = (server: Bun.Server, portMap: Record<string, number>) => {
|
|||
})
|
||||
console.log("")
|
||||
}
|
||||
|
||||
const requireAuth = (handler: (req: Request) => Response | Promise<Response>) => {
|
||||
return async (req: Request) => {
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
return handler(req)
|
||||
}
|
||||
|
||||
const cookies = new Bun.CookieMap(req.headers.get("cookie") ?? "")
|
||||
if (cookies.get("auth") === "authenticated") {
|
||||
return handler(req)
|
||||
}
|
||||
|
||||
if (validAuth(req)) {
|
||||
const response = await handler(req)
|
||||
const newHeaders = new Headers(response.headers)
|
||||
|
||||
const url = new URL(req.url)
|
||||
const cookieDomain = `.${url.hostname.split(".").slice(-2).join(".")}`
|
||||
|
||||
const authCookie = new Bun.Cookie({
|
||||
name: "auth",
|
||||
value: "authenticated",
|
||||
domain: cookieDomain,
|
||||
path: "/",
|
||||
maxAge: 86400,
|
||||
httpOnly: true,
|
||||
})
|
||||
|
||||
newHeaders.set("Set-Cookie", authCookie.toString())
|
||||
|
||||
return new Response(response.body, { status: response.status, headers: newHeaders })
|
||||
}
|
||||
|
||||
return new Response("Unauthorized", {
|
||||
status: 401,
|
||||
headers: { "WWW-Authenticate": 'Basic realm="Workshop Access"' },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const validAuth = (req: Request): boolean => {
|
||||
const logins = ["spike:888", "defunkt:888", "probablycorey:888"]
|
||||
const auth = req.headers.get("authorization")
|
||||
return logins.some((login) => `Basic ${btoa(login)}` === auth)
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { mkdir } from "node:fs/promises"
|
||||
import { dirname, join } from "node:path"
|
||||
import { DateTime } from "luxon"
|
||||
|
||||
const set = async <T extends keyof Keys>(key: T, value: Keys[T]) => {
|
||||
try {
|
||||
|
|
@ -87,7 +88,14 @@ export default { set, get, remove, update }
|
|||
export type Keys = {
|
||||
threads: Record<string, TrackedThread> // threadId: thread metadata
|
||||
todos: Record<string, string> // todoId: todoText
|
||||
evaluations: EvalData[] // evaluation data for dashboard import
|
||||
cronJobs: Record<string, CronJobHistory[]> // jobId: array of job runs
|
||||
}
|
||||
|
||||
export type CronJobHistory = {
|
||||
error?: string
|
||||
stderr?: string
|
||||
stdout?: string
|
||||
runAt: string
|
||||
}
|
||||
|
||||
export type TrackedThread = {
|
||||
|
|
@ -98,18 +106,7 @@ export type TrackedThread = {
|
|||
const keyVersions: Record<keyof Keys, number> = {
|
||||
threads: 1,
|
||||
todos: 1,
|
||||
evaluations: 1,
|
||||
cronJobs: 1,
|
||||
} as const
|
||||
|
||||
export type Conversation = { message: string; role: "user" | "assistant" }
|
||||
|
||||
export type EvalData = {
|
||||
channel: string
|
||||
rating: "good" | "bad"
|
||||
context: {
|
||||
content: string
|
||||
role: "user" | "assistant"
|
||||
author?: string
|
||||
}[] // Last 5 messages leading up to the rated response
|
||||
ratedBy: string
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { readdir } from "node:fs/promises"
|
||||
import { join } from "node:path"
|
||||
|
||||
type PackageInfo = {
|
||||
export type PackageInfo = {
|
||||
path: string
|
||||
json?: {
|
||||
json: {
|
||||
name: string
|
||||
version: string
|
||||
scripts?: Record<string, string>
|
||||
|
|
@ -33,7 +33,6 @@ export const getPackageInfo = async (): Promise<PackageInfo[]> => {
|
|||
packageInfo.push({ path, json })
|
||||
} catch (error) {
|
||||
console.error(`Error processing package ${packageName}:`, error)
|
||||
packageInfo.push({ path })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,11 +16,10 @@
|
|||
"@openai/agents": "^0.0.10",
|
||||
"@workshop/shared": "workspace:*",
|
||||
"discord.js": "^14.19.3",
|
||||
"luxon": "^3.6.1",
|
||||
"luxon": "catalog:",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/luxon": "^3.6.2",
|
||||
"@types/bun": "latest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { respondToUserMessage } from "../ai"
|
||||
import { storeEvaluation } from "../eval"
|
||||
import { ActivityType, type Client } from "discord.js"
|
||||
import { runCommand } from "./commands"
|
||||
|
||||
|
|
@ -32,17 +31,6 @@ export const listenForEvents = (client: Client) => {
|
|||
}
|
||||
})
|
||||
|
||||
client.on("messageReactionAdd", async (reaction, user) => {
|
||||
if (user.bot) return
|
||||
|
||||
const emoji = reaction.emoji.name
|
||||
|
||||
// Only process evaluation emojis
|
||||
if (emoji === "✅" || emoji === "❌") {
|
||||
await storeEvaluation(reaction.message, emoji, user.username || user.id)
|
||||
}
|
||||
})
|
||||
|
||||
client.on("ready", () => {
|
||||
// set the bots description
|
||||
const branch = process.env.RENDER_GIT_BRANCH || "unknown"
|
||||
|
|
|
|||
|
|
@ -2,11 +2,7 @@ import { Client } from "discord.js"
|
|||
import { timeBomb } from "@workshop/shared/utils"
|
||||
import { DateTime } from "luxon"
|
||||
|
||||
timeBomb("7-15-2025", "Get rid of all these console messages Corey!")
|
||||
|
||||
// const timeout = 1000 * 60 * 30 // 30 minutes
|
||||
// const timeout = 1000 * 60 // 1 minute
|
||||
const timeout = 3000 // 1 minute
|
||||
const timeout = 1000 * 60 * 10 // 10 minutes
|
||||
export const keepThreadsAlive = async (client: Client) => {
|
||||
try {
|
||||
if (!client.isReady() || client.guilds.cache.size === 0) return
|
||||
|
|
@ -14,19 +10,14 @@ export const keepThreadsAlive = async (client: Client) => {
|
|||
for (const guild of client.guilds.cache.values()) {
|
||||
const fetchedThreads = await guild.channels.fetchActiveThreads()
|
||||
|
||||
for (const [threadId, thread] of fetchedThreads.threads) {
|
||||
for (const [_threadId, thread] of fetchedThreads.threads) {
|
||||
if (!thread.name.includes("💾")) continue
|
||||
const archiveAt = DateTime.fromMillis(thread.archiveTimestamp || 0)
|
||||
const result = await thread.setArchived(
|
||||
false,
|
||||
"Revived by Spike - keeping important discussion alive"
|
||||
)
|
||||
|
||||
// send message to the thread if it was archived
|
||||
if (thread.archived) {
|
||||
const message = `🔍 Revived: ${thread.name} -- ${archiveAt.toFormat("F")}`
|
||||
await thread.send(message)
|
||||
}
|
||||
console.log(`🔍 Revive: ${thread.name}: ${archiveAt.toFormat("F")} -- ${result.archived}`)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -1,82 +0,0 @@
|
|||
import kv from "@workshop/shared/kv"
|
||||
import type { EvalData } from "@workshop/shared/kv"
|
||||
import type { Message, PartialMessage } from "discord.js"
|
||||
|
||||
export const storeEvaluation = async (message: Message | PartialMessage, emoji: string, ratedBy: string) => {
|
||||
try {
|
||||
const rating = emoji === "✅" ? "good" : emoji === "❌" ? "bad" : null
|
||||
if (!rating) {
|
||||
throw new Error(`Invalid evaluation emoji: ${emoji}`)
|
||||
}
|
||||
|
||||
// If message is partial, fetch the full message
|
||||
let fullMessage: Message
|
||||
if (message.partial) {
|
||||
fullMessage = await message.fetch()
|
||||
} else {
|
||||
fullMessage = message
|
||||
}
|
||||
|
||||
// Fetch conversation context using Discord API
|
||||
const context = await fetchConversationContext(fullMessage)
|
||||
|
||||
let channelName = `${fullMessage.guild?.name} - ???`
|
||||
if ("name" in fullMessage.channel) {
|
||||
channelName = `${fullMessage.guild?.name} - ${fullMessage.channel.name}`
|
||||
}
|
||||
const evalData: EvalData = {
|
||||
channel: channelName,
|
||||
rating,
|
||||
context,
|
||||
ratedBy,
|
||||
}
|
||||
|
||||
// Add to evaluations
|
||||
await kv.update("evaluations", [], (prev) => {
|
||||
return [...prev, evalData]
|
||||
})
|
||||
|
||||
console.log(`📊 Evaluation stored: ${rating} rating for message ${fullMessage.id} by ${ratedBy}`)
|
||||
|
||||
// React with thumbs up to acknowledge
|
||||
await fullMessage.react("👍")
|
||||
|
||||
return evalData
|
||||
} catch (error) {
|
||||
console.error("Error storing evaluation:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch conversation context around a message using Discord API
|
||||
const fetchConversationContext = async (message: Message, contextSize = 5): Promise<EvalData["context"]> => {
|
||||
try {
|
||||
// Fetch messages before the target message (the conversation leading up to this response)
|
||||
const messagesBefore = await message.channel.messages.fetch({
|
||||
before: message.id,
|
||||
limit: contextSize,
|
||||
})
|
||||
|
||||
// Include the target message and sort by timestamp
|
||||
const allMessages = [...messagesBefore.values(), message].sort(
|
||||
(a, b) => a.createdTimestamp - b.createdTimestamp
|
||||
)
|
||||
|
||||
return allMessages.map((msg) => ({
|
||||
content: msg.content,
|
||||
role: msg.author.bot ? "assistant" : "user",
|
||||
author: msg.author.username,
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error("Error fetching conversation context:", error)
|
||||
|
||||
// Fallback to just the target message
|
||||
return [
|
||||
{
|
||||
content: message.content,
|
||||
role: message.author.bot ? "assistant" : "user",
|
||||
author: message.author.username,
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -21,7 +21,7 @@
|
|||
"@lezer/highlight": "^1.2.1",
|
||||
"@lezer/lr": "^1.4.2",
|
||||
"hono": "catalog:",
|
||||
"luxon": "^3.6.1",
|
||||
"luxon": "catalog:",
|
||||
"zzfx": "^1.3.0"
|
||||
},
|
||||
"prettier": {
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
"@workshop/nano-remix": "workspace:*",
|
||||
"@workshop/shared": "workspace:*",
|
||||
"hono": "catalog:",
|
||||
"luxon": "^3.7.1",
|
||||
"luxon": "catalog:",
|
||||
"pixabay-api": "^1.0.4",
|
||||
"pngjs": "^7.0.0",
|
||||
"tailwind": "^4.0.0",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user