cron #1
1
apps/cron/20260201-000000/.npmrc
Normal file
1
apps/cron/20260201-000000/.npmrc
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
registry=https://npm.nose.space
|
||||||
47
apps/cron/20260201-000000/bun.lock
Normal file
47
apps/cron/20260201-000000/bun.lock
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
{
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"configVersion": 1,
|
||||||
|
"workspaces": {
|
||||||
|
"": {
|
||||||
|
"name": "cron",
|
||||||
|
"dependencies": {
|
||||||
|
"@because/forge": "*",
|
||||||
|
"@because/hype": "*",
|
||||||
|
"@because/toes": "*",
|
||||||
|
"croner": "^9.0.0",
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bun": "latest",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"@because/forge": ["@because/forge@0.0.1", "https://npm.nose.space/@because/forge/-/forge-0.0.1.tgz", { "peerDependencies": { "typescript": "^5" } }, "sha512-QS5CK51gcWma91i4uECWe4HPJeNHcE+Af4SQHOcfEovyzOEa7VOTAjei+jIWr2i+abGWqQCEC9wIuFgPgyr2Bg=="],
|
||||||
|
|
||||||
|
"@because/hype": ["@because/hype@0.0.2", "https://npm.nose.space/@because/hype/-/hype-0.0.2.tgz", { "dependencies": { "hono": "^4.10.4", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5" } }, "sha512-fdKeII6USGC1loVVj+tPz086cKz+Bm+XozNee3NOnK4VP+q4yNPP2Fq1Yujw5xeDYE+ZvJn40gKwlngRvmX2hA=="],
|
||||||
|
|
||||||
|
"@because/toes": ["@because/toes@0.0.4", "https://npm.nose.space/@because/toes/-/toes-0.0.4.tgz", { "dependencies": { "@because/forge": "^0.0.1", "@because/hype": "^0.0.1", "commander": "^14.0.2", "diff": "^8.0.3", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5.9.2" }, "bin": { "toes": "src/cli/index.ts" } }, "sha512-/eZB84VoARYzSBtwJe00dV7Ilgqq7DRFj3vJlWhCHg87Jx5Yr2nTqPnzclLmiZ55XvWNogXqGTzyW8hApzXnJw=="],
|
||||||
|
|
||||||
|
"@types/bun": ["@types/bun@1.3.8", "https://npm.nose.space/@types/bun/-/bun-1.3.8.tgz", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="],
|
||||||
|
|
||||||
|
"@types/node": ["@types/node@25.2.0", "https://npm.nose.space/@types/node/-/node-25.2.0.tgz", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w=="],
|
||||||
|
|
||||||
|
"bun-types": ["bun-types@1.3.8", "https://npm.nose.space/bun-types/-/bun-types-1.3.8.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="],
|
||||||
|
|
||||||
|
"commander": ["commander@14.0.3", "https://npm.nose.space/commander/-/commander-14.0.3.tgz", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
|
||||||
|
|
||||||
|
"croner": ["croner@9.1.0", "https://npm.nose.space/croner/-/croner-9.1.0.tgz", {}, "sha512-p9nwwR4qyT5W996vBZhdvBCnMhicY5ytZkR4D1Xj0wuTDEiMnjwR57Q3RXYY/s0EpX6Ay3vgIcfaR+ewGHsi+g=="],
|
||||||
|
|
||||||
|
"diff": ["diff@8.0.3", "https://npm.nose.space/diff/-/diff-8.0.3.tgz", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
|
||||||
|
|
||||||
|
"hono": ["hono@4.11.7", "https://npm.nose.space/hono/-/hono-4.11.7.tgz", {}, "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw=="],
|
||||||
|
|
||||||
|
"kleur": ["kleur@4.1.5", "https://npm.nose.space/kleur/-/kleur-4.1.5.tgz", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
||||||
|
|
||||||
|
"typescript": ["typescript@5.9.3", "https://npm.nose.space/typescript/-/typescript-5.9.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||||
|
|
||||||
|
"undici-types": ["undici-types@7.16.0", "https://npm.nose.space/undici-types/-/undici-types-7.16.0.tgz", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||||
|
|
||||||
|
"@because/toes/@because/hype": ["@because/hype@0.0.1", "https://npm.nose.space/@because/hype/-/hype-0.0.1.tgz", { "dependencies": { "hono": "^4.10.4", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5" } }, "sha512-i92DNUXJOwt3J8dN1x8sh7i86blelcTCk8XDpwD839Ic8oe710lkDSVXJ7xYZb/i8YtzGhRg+L6eXDhaRiU2Pw=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
420
apps/cron/20260201-000000/index.tsx
Normal file
420
apps/cron/20260201-000000/index.tsx
Normal file
|
|
@ -0,0 +1,420 @@
|
||||||
|
import { Hype } from '@because/hype'
|
||||||
|
import { define, stylesToCSS } from '@because/forge'
|
||||||
|
import { baseStyles, initScript, theme } from '@because/toes/tools'
|
||||||
|
import { discoverCronJobs } from './lib/discovery'
|
||||||
|
import { scheduleJob, stopJob } from './lib/scheduler'
|
||||||
|
import { executeJob } from './lib/executor'
|
||||||
|
import { setJobs, getJob, getAllJobs, broadcast } from './lib/state'
|
||||||
|
import { SCHEDULES, type CronJob } from './lib/schedules'
|
||||||
|
import type { Child } from 'hono/jsx'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { mkdir, writeFile } from 'fs/promises'
|
||||||
|
import { existsSync, watch } from 'fs'
|
||||||
|
|
||||||
|
const APPS_DIR = process.env.APPS_DIR!
|
||||||
|
|
||||||
|
const app = new Hype({ prettyHTML: false })
|
||||||
|
|
||||||
|
// Styles (follow versions tool pattern)
|
||||||
|
const Container = define('Container', {
|
||||||
|
fontFamily: theme('fonts-sans'),
|
||||||
|
padding: '20px',
|
||||||
|
paddingTop: 0,
|
||||||
|
maxWidth: '900px',
|
||||||
|
margin: '0 auto',
|
||||||
|
color: theme('colors-text'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const JobList = define('JobList', {
|
||||||
|
listStyle: 'none',
|
||||||
|
padding: 0,
|
||||||
|
margin: '20px 0',
|
||||||
|
border: `1px solid ${theme('colors-border')}`,
|
||||||
|
borderRadius: theme('radius-md'),
|
||||||
|
overflow: 'hidden',
|
||||||
|
})
|
||||||
|
|
||||||
|
const JobItem = define('JobItem', {
|
||||||
|
padding: '12px 15px',
|
||||||
|
borderBottom: `1px solid ${theme('colors-border')}`,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '15px',
|
||||||
|
states: {
|
||||||
|
':last-child': { borderBottom: 'none' },
|
||||||
|
':hover': { backgroundColor: theme('colors-bgHover') },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const StatusDot = define('StatusDot', {
|
||||||
|
width: '10px',
|
||||||
|
height: '10px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
flexShrink: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const JobName = define('JobName', {
|
||||||
|
fontFamily: theme('fonts-mono'),
|
||||||
|
fontSize: '14px',
|
||||||
|
flex: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
const Schedule = define('Schedule', {
|
||||||
|
fontSize: '13px',
|
||||||
|
color: theme('colors-textMuted'),
|
||||||
|
minWidth: '80px',
|
||||||
|
})
|
||||||
|
|
||||||
|
const Time = define('Time', {
|
||||||
|
fontSize: '12px',
|
||||||
|
color: theme('colors-textMuted'),
|
||||||
|
minWidth: '100px',
|
||||||
|
})
|
||||||
|
|
||||||
|
const RunButton = define('RunButton', {
|
||||||
|
base: 'button',
|
||||||
|
padding: '4px 10px',
|
||||||
|
fontSize: '12px',
|
||||||
|
backgroundColor: theme('colors-primary'),
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: theme('radius-md'),
|
||||||
|
cursor: 'pointer',
|
||||||
|
states: {
|
||||||
|
':hover': { opacity: 0.9 },
|
||||||
|
':disabled': { opacity: 0.5, cursor: 'not-allowed' },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const EmptyState = define('EmptyState', {
|
||||||
|
padding: '40px 20px',
|
||||||
|
textAlign: 'center',
|
||||||
|
color: theme('colors-textMuted'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const ActionRow = define('ActionRow', {
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
marginBottom: '15px',
|
||||||
|
})
|
||||||
|
|
||||||
|
const NewButton = define('NewButton', {
|
||||||
|
base: 'a',
|
||||||
|
padding: '8px 16px',
|
||||||
|
fontSize: '14px',
|
||||||
|
backgroundColor: theme('colors-primary'),
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: theme('radius-md'),
|
||||||
|
cursor: 'pointer',
|
||||||
|
textDecoration: 'none',
|
||||||
|
display: 'inline-block',
|
||||||
|
states: {
|
||||||
|
':hover': { opacity: 0.9 },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const buttonStyles = {
|
||||||
|
padding: '8px 16px',
|
||||||
|
fontSize: '14px',
|
||||||
|
backgroundColor: theme('colors-primary'),
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: theme('radius-md'),
|
||||||
|
cursor: 'pointer',
|
||||||
|
}
|
||||||
|
|
||||||
|
const Form = define('Form', {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '16px',
|
||||||
|
maxWidth: '400px',
|
||||||
|
})
|
||||||
|
|
||||||
|
const FormGroup = define('FormGroup', {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '6px',
|
||||||
|
})
|
||||||
|
|
||||||
|
const Label = define('Label', {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 500,
|
||||||
|
})
|
||||||
|
|
||||||
|
const inputStyles = {
|
||||||
|
padding: '8px 12px',
|
||||||
|
fontSize: '14px',
|
||||||
|
border: `1px solid ${theme('colors-border')}`,
|
||||||
|
borderRadius: theme('radius-md'),
|
||||||
|
backgroundColor: theme('colors-bg'),
|
||||||
|
color: theme('colors-text'),
|
||||||
|
}
|
||||||
|
|
||||||
|
const ButtonRow = define('ButtonRow', {
|
||||||
|
display: 'flex',
|
||||||
|
gap: '10px',
|
||||||
|
marginTop: '10px',
|
||||||
|
})
|
||||||
|
|
||||||
|
const CancelButton = define('CancelButton', {
|
||||||
|
base: 'a',
|
||||||
|
padding: '8px 16px',
|
||||||
|
fontSize: '14px',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
color: theme('colors-text'),
|
||||||
|
border: `1px solid ${theme('colors-border')}`,
|
||||||
|
borderRadius: theme('radius-md'),
|
||||||
|
cursor: 'pointer',
|
||||||
|
textDecoration: 'none',
|
||||||
|
states: {
|
||||||
|
':hover': { backgroundColor: theme('colors-bgHover') },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Layout
|
||||||
|
function Layout({ title, children }: { title: string; children: Child }) {
|
||||||
|
return (
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>{title}</title>
|
||||||
|
<link rel="stylesheet" href="/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<script dangerouslySetInnerHTML={{ __html: initScript }} />
|
||||||
|
<Container>
|
||||||
|
{children}
|
||||||
|
</Container>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRelative(ts?: number): string {
|
||||||
|
if (!ts) return '-'
|
||||||
|
const diff = Date.now() - ts
|
||||||
|
if (diff < 0) {
|
||||||
|
const mins = Math.round(-diff / 60000)
|
||||||
|
if (mins < 60) return `in ${mins}m`
|
||||||
|
const hours = Math.round(mins / 60)
|
||||||
|
if (hours < 24) return `in ${hours}h`
|
||||||
|
return `in ${Math.round(hours / 24)}d`
|
||||||
|
}
|
||||||
|
const mins = Math.round(diff / 60000)
|
||||||
|
if (mins < 60) return `${mins}m ago`
|
||||||
|
const hours = Math.round(mins / 60)
|
||||||
|
if (hours < 24) return `${hours}h ago`
|
||||||
|
return `${Math.round(hours / 24)}d ago`
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusColor(job: CronJob): string {
|
||||||
|
if (job.state === 'running') return theme('colors-statusRunning')
|
||||||
|
if (job.state === 'disabled') return theme('colors-textMuted')
|
||||||
|
if (job.lastExitCode !== undefined && job.lastExitCode !== 0) return theme('colors-error')
|
||||||
|
return theme('colors-statusRunning')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Routes
|
||||||
|
app.get('/styles.css', c => c.text(baseStyles + stylesToCSS(), 200, {
|
||||||
|
'Content-Type': 'text/css; charset=utf-8',
|
||||||
|
}))
|
||||||
|
|
||||||
|
app.get('/', async c => {
|
||||||
|
const appFilter = c.req.query('app')
|
||||||
|
let jobs = getAllJobs()
|
||||||
|
|
||||||
|
if (appFilter) {
|
||||||
|
jobs = jobs.filter(j => j.app === appFilter)
|
||||||
|
}
|
||||||
|
|
||||||
|
jobs.sort((a, b) => a.id.localeCompare(b.id))
|
||||||
|
|
||||||
|
return c.html(
|
||||||
|
<Layout title="Cron Jobs">
|
||||||
|
<ActionRow>
|
||||||
|
<NewButton href={`/new?app=${appFilter || ''}`}>New Job</NewButton>
|
||||||
|
</ActionRow>
|
||||||
|
{jobs.length === 0 ? (
|
||||||
|
<EmptyState>
|
||||||
|
No cron jobs found.
|
||||||
|
<br />
|
||||||
|
Create a cron/*.ts file in any app to get started.
|
||||||
|
</EmptyState>
|
||||||
|
) : (
|
||||||
|
<JobList>
|
||||||
|
{jobs.map(job => (
|
||||||
|
<JobItem>
|
||||||
|
<StatusDot style={{ backgroundColor: statusColor(job) }} />
|
||||||
|
<JobName>{job.app}/{job.name}</JobName>
|
||||||
|
<Schedule>{job.schedule}</Schedule>
|
||||||
|
<Time title="Last run">{formatRelative(job.lastRun)}</Time>
|
||||||
|
<Time title="Next run">{formatRelative(job.nextRun)}</Time>
|
||||||
|
<form method="post" action={`/run/${job.app}/${job.name}`}>
|
||||||
|
<RunButton type="submit" disabled={job.state === 'running'}>
|
||||||
|
{job.state === 'running' ? 'Running...' : 'Run Now'}
|
||||||
|
</RunButton>
|
||||||
|
</form>
|
||||||
|
</JobItem>
|
||||||
|
))}
|
||||||
|
</JobList>
|
||||||
|
)}
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/new', async c => {
|
||||||
|
const appName = c.req.query('app') || ''
|
||||||
|
|
||||||
|
return c.html(
|
||||||
|
<Layout title="New Cron Job">
|
||||||
|
<form method="post" action="/new">
|
||||||
|
<Form>
|
||||||
|
<input type="hidden" name="app" value={appName} />
|
||||||
|
<FormGroup>
|
||||||
|
<Label>Job Name</Label>
|
||||||
|
<input name="name" placeholder="my-job" required pattern="[a-z0-9\-]+" title="lowercase letters, numbers, and hyphens only" style={inputStyles} />
|
||||||
|
</FormGroup>
|
||||||
|
<FormGroup>
|
||||||
|
<Label>Run every</Label>
|
||||||
|
<select name="schedule" style={inputStyles}>
|
||||||
|
{SCHEDULES.map(s => (
|
||||||
|
<option value={s} selected={s === 'day'}>{s}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</FormGroup>
|
||||||
|
<ButtonRow>
|
||||||
|
<CancelButton href="/">Cancel</CancelButton>
|
||||||
|
<button type="submit" style={buttonStyles}>Create Job</button>
|
||||||
|
</ButtonRow>
|
||||||
|
</Form>
|
||||||
|
</form>
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/new', async c => {
|
||||||
|
const body = await c.req.parseBody()
|
||||||
|
const appName = body.app as string
|
||||||
|
const name = body.name as string
|
||||||
|
const schedule = body.schedule as string
|
||||||
|
|
||||||
|
if (!appName || !name || !schedule) {
|
||||||
|
return c.redirect('/new?error=missing-fields')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate name (lowercase, numbers, hyphens)
|
||||||
|
if (!/^[a-z0-9-]+$/.test(name)) {
|
||||||
|
return c.redirect('/new?error=invalid-name')
|
||||||
|
}
|
||||||
|
|
||||||
|
const cronDir = join(APPS_DIR, appName, 'current', 'cron')
|
||||||
|
const filePath = join(cronDir, `${name}.ts`)
|
||||||
|
|
||||||
|
// Check if file already exists
|
||||||
|
if (existsSync(filePath)) {
|
||||||
|
return c.redirect('/new?error=already-exists')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create cron directory if needed
|
||||||
|
if (!existsSync(cronDir)) {
|
||||||
|
await mkdir(cronDir, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write the cron file
|
||||||
|
const content = `export const schedule = "${schedule}"
|
||||||
|
|
||||||
|
export default async function() {
|
||||||
|
console.log("${appName}/${name} executed at", new Date().toISOString())
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
await writeFile(filePath, content)
|
||||||
|
console.log(`[cron] Created ${appName}:${name}`)
|
||||||
|
|
||||||
|
// Trigger rediscovery
|
||||||
|
const jobs = await discoverCronJobs()
|
||||||
|
setJobs(jobs)
|
||||||
|
for (const job of jobs) {
|
||||||
|
if (job.id === `${appName}:${name}`) {
|
||||||
|
scheduleJob(job, broadcast)
|
||||||
|
console.log(`[cron] Scheduled ${job.id}: ${job.schedule} (${job.cronExpr})`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.redirect('/')
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/run/:app/:name', async c => {
|
||||||
|
const id = `${c.req.param('app')}:${c.req.param('name')}`
|
||||||
|
const job = getJob(id)
|
||||||
|
|
||||||
|
if (!job) {
|
||||||
|
return c.redirect('/?error=not-found')
|
||||||
|
}
|
||||||
|
|
||||||
|
await executeJob(job, broadcast)
|
||||||
|
|
||||||
|
const appFilter = c.req.query('app')
|
||||||
|
return c.redirect(appFilter ? `/?app=${appFilter}` : '/')
|
||||||
|
})
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
async function init() {
|
||||||
|
const jobs = await discoverCronJobs()
|
||||||
|
setJobs(jobs)
|
||||||
|
console.log(`[cron] Discovered ${jobs.length} jobs`)
|
||||||
|
|
||||||
|
for (const job of jobs) {
|
||||||
|
scheduleJob(job, broadcast)
|
||||||
|
console.log(`[cron] Scheduled ${job.id}: ${job.schedule} (${job.cronExpr})`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch for cron file changes
|
||||||
|
let debounceTimer: Timer | null = null
|
||||||
|
|
||||||
|
async function rediscover() {
|
||||||
|
const jobs = await discoverCronJobs()
|
||||||
|
const existing = getAllJobs()
|
||||||
|
|
||||||
|
// Stop removed jobs
|
||||||
|
for (const old of existing) {
|
||||||
|
if (!jobs.find(j => j.id === old.id)) {
|
||||||
|
stopJob(old.id)
|
||||||
|
console.log(`[cron] Removed ${old.id}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add/update jobs
|
||||||
|
for (const job of jobs) {
|
||||||
|
const old = existing.find(j => j.id === job.id)
|
||||||
|
if (!old || old.cronExpr !== job.cronExpr) {
|
||||||
|
scheduleJob(job, broadcast)
|
||||||
|
console.log(`[cron] Updated ${job.id}: ${job.schedule}`)
|
||||||
|
} else {
|
||||||
|
// Preserve runtime state
|
||||||
|
job.state = old.state
|
||||||
|
job.lastRun = old.lastRun
|
||||||
|
job.lastDuration = old.lastDuration
|
||||||
|
job.lastExitCode = old.lastExitCode
|
||||||
|
job.lastError = old.lastError
|
||||||
|
job.nextRun = old.nextRun
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setJobs(jobs)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(APPS_DIR, { recursive: true }, (_event, filename) => {
|
||||||
|
if (!filename?.includes('/cron/') && !filename?.includes('\\cron\\')) return
|
||||||
|
if (!filename.endsWith('.ts')) return
|
||||||
|
|
||||||
|
if (debounceTimer) clearTimeout(debounceTimer)
|
||||||
|
debounceTimer = setTimeout(rediscover, 100)
|
||||||
|
})
|
||||||
|
|
||||||
|
init()
|
||||||
|
|
||||||
|
export default app.defaults
|
||||||
65
apps/cron/20260201-000000/lib/discovery.ts
Normal file
65
apps/cron/20260201-000000/lib/discovery.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
import { readdir } from 'fs/promises'
|
||||||
|
import { existsSync } from 'fs'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { isValidSchedule, toCronExpr, type CronJob, type Schedule } from './schedules'
|
||||||
|
|
||||||
|
const APPS_DIR = process.env.APPS_DIR!
|
||||||
|
|
||||||
|
export async function getApps(): Promise<string[]> {
|
||||||
|
const entries = await readdir(APPS_DIR, { withFileTypes: true })
|
||||||
|
const apps: string[] = []
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isDirectory()) continue
|
||||||
|
// Check if it has a current symlink (valid app)
|
||||||
|
if (existsSync(join(APPS_DIR, entry.name, 'current'))) {
|
||||||
|
apps.push(entry.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return apps.sort()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function discoverCronJobs(): Promise<CronJob[]> {
|
||||||
|
const jobs: CronJob[] = []
|
||||||
|
const apps = await readdir(APPS_DIR, { withFileTypes: true })
|
||||||
|
|
||||||
|
for (const app of apps) {
|
||||||
|
if (!app.isDirectory()) continue
|
||||||
|
|
||||||
|
const cronDir = join(APPS_DIR, app.name, 'current', 'cron')
|
||||||
|
if (!existsSync(cronDir)) continue
|
||||||
|
|
||||||
|
const files = await readdir(cronDir)
|
||||||
|
for (const file of files) {
|
||||||
|
if (!file.endsWith('.ts')) continue
|
||||||
|
|
||||||
|
const filePath = join(cronDir, file)
|
||||||
|
const name = file.replace(/\.ts$/, '')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const mod = await import(filePath)
|
||||||
|
const schedule = mod.schedule as Schedule
|
||||||
|
|
||||||
|
if (!isValidSchedule(schedule)) {
|
||||||
|
console.error(`Invalid schedule in ${filePath}: ${schedule}`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
jobs.push({
|
||||||
|
id: `${app.name}:${name}`,
|
||||||
|
app: app.name,
|
||||||
|
name,
|
||||||
|
file: filePath,
|
||||||
|
schedule,
|
||||||
|
cronExpr: toCronExpr(schedule),
|
||||||
|
state: 'idle',
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Failed to load cron file ${filePath}:`, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return jobs
|
||||||
|
}
|
||||||
50
apps/cron/20260201-000000/lib/executor.ts
Normal file
50
apps/cron/20260201-000000/lib/executor.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { join } from 'path'
|
||||||
|
import type { CronJob } from './schedules'
|
||||||
|
import { getNextRun } from './scheduler'
|
||||||
|
|
||||||
|
const APPS_DIR = process.env.APPS_DIR!
|
||||||
|
|
||||||
|
export async function executeJob(job: CronJob, onUpdate: () => void): Promise<void> {
|
||||||
|
if (job.state === 'disabled') return
|
||||||
|
|
||||||
|
job.state = 'running'
|
||||||
|
job.lastRun = Date.now()
|
||||||
|
onUpdate()
|
||||||
|
|
||||||
|
const cwd = join(APPS_DIR, job.app, 'current')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const proc = Bun.spawn(['bun', 'run', job.file], {
|
||||||
|
cwd,
|
||||||
|
env: { ...process.env },
|
||||||
|
stdout: 'pipe',
|
||||||
|
stderr: 'pipe',
|
||||||
|
})
|
||||||
|
|
||||||
|
const [stdout, stderr] = await Promise.all([
|
||||||
|
new Response(proc.stdout).text(),
|
||||||
|
new Response(proc.stderr).text(),
|
||||||
|
])
|
||||||
|
|
||||||
|
const code = await proc.exited
|
||||||
|
|
||||||
|
job.lastDuration = Date.now() - job.lastRun
|
||||||
|
job.lastExitCode = code
|
||||||
|
job.lastError = code !== 0 ? stderr || 'Non-zero exit' : undefined
|
||||||
|
job.state = 'idle'
|
||||||
|
job.nextRun = getNextRun(job.id)
|
||||||
|
|
||||||
|
// Log result
|
||||||
|
console.log(`[cron] ${job.id} finished: code=${code} duration=${job.lastDuration}ms`)
|
||||||
|
if (stdout) console.log(stdout)
|
||||||
|
if (stderr) console.error(stderr)
|
||||||
|
} catch (e) {
|
||||||
|
job.lastDuration = Date.now() - job.lastRun
|
||||||
|
job.lastExitCode = 1
|
||||||
|
job.lastError = e instanceof Error ? e.message : String(e)
|
||||||
|
job.state = 'idle'
|
||||||
|
console.error(`[cron] ${job.id} failed:`, e)
|
||||||
|
}
|
||||||
|
|
||||||
|
onUpdate()
|
||||||
|
}
|
||||||
26
apps/cron/20260201-000000/lib/scheduler.ts
Normal file
26
apps/cron/20260201-000000/lib/scheduler.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { Cron } from 'croner'
|
||||||
|
import type { CronJob } from './schedules'
|
||||||
|
import { executeJob } from './executor'
|
||||||
|
|
||||||
|
const scheduled = new Map<string, Cron>()
|
||||||
|
|
||||||
|
export function scheduleJob(job: CronJob, onUpdate: () => void) {
|
||||||
|
// Stop existing if any
|
||||||
|
scheduled.get(job.id)?.stop()
|
||||||
|
|
||||||
|
const cron = new Cron(job.cronExpr, async () => {
|
||||||
|
await executeJob(job, onUpdate)
|
||||||
|
})
|
||||||
|
|
||||||
|
scheduled.set(job.id, cron)
|
||||||
|
job.nextRun = cron.nextRun()?.getTime()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopJob(jobId: string) {
|
||||||
|
scheduled.get(jobId)?.stop()
|
||||||
|
scheduled.delete(jobId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNextRun(jobId: string): number | undefined {
|
||||||
|
return scheduled.get(jobId)?.nextRun()?.getTime()
|
||||||
|
}
|
||||||
80
apps/cron/20260201-000000/lib/schedules.ts
Normal file
80
apps/cron/20260201-000000/lib/schedules.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
export type Schedule =
|
||||||
|
| "sunday" | "monday" | "tuesday" | "wednesday" | "thursday" | "friday" | "saturday"
|
||||||
|
| "week" | "day" | "midnight" | "noon" | "hour"
|
||||||
|
| "30 minutes" | "15 minutes" | "5 minutes" | "1 minute"
|
||||||
|
| "30minutes" | "15minutes" | "5minutes" | "1minute"
|
||||||
|
| 30 | 15 | 5 | 1
|
||||||
|
|
||||||
|
export type CronJob = {
|
||||||
|
id: string // "appname:filename"
|
||||||
|
app: string
|
||||||
|
name: string // filename without .ts
|
||||||
|
file: string // full path
|
||||||
|
schedule: Schedule
|
||||||
|
cronExpr: string
|
||||||
|
state: 'idle' | 'running' | 'disabled'
|
||||||
|
lastRun?: number
|
||||||
|
lastDuration?: number
|
||||||
|
lastExitCode?: number
|
||||||
|
lastError?: string
|
||||||
|
nextRun?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SCHEDULES = [
|
||||||
|
'1 minute',
|
||||||
|
'5 minutes',
|
||||||
|
'15 minutes',
|
||||||
|
'30 minutes',
|
||||||
|
'hour',
|
||||||
|
'noon',
|
||||||
|
'midnight',
|
||||||
|
'day',
|
||||||
|
'week',
|
||||||
|
'sunday',
|
||||||
|
'monday',
|
||||||
|
'tuesday',
|
||||||
|
'wednesday',
|
||||||
|
'thursday',
|
||||||
|
'friday',
|
||||||
|
'saturday',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const SCHEDULE_MAP: Record<string, string> = {
|
||||||
|
sunday: '0 0 * * 0',
|
||||||
|
monday: '0 0 * * 1',
|
||||||
|
tuesday: '0 0 * * 2',
|
||||||
|
wednesday: '0 0 * * 3',
|
||||||
|
thursday: '0 0 * * 4',
|
||||||
|
friday: '0 0 * * 5',
|
||||||
|
saturday: '0 0 * * 6',
|
||||||
|
week: '0 0 * * 0',
|
||||||
|
day: '0 0 * * *',
|
||||||
|
midnight: '0 0 * * *',
|
||||||
|
noon: '0 12 * * *',
|
||||||
|
hour: '0 * * * *',
|
||||||
|
'30 minutes': '0,30 * * * *',
|
||||||
|
'15 minutes': '0,15,30,45 * * * *',
|
||||||
|
'5 minutes': '*/5 * * * *',
|
||||||
|
'1 minute': '* * * * *',
|
||||||
|
'30minutes': '0,30 * * * *',
|
||||||
|
'15minutes': '0,15,30,45 * * * *',
|
||||||
|
'5minutes': '*/5 * * * *',
|
||||||
|
'1minute': '* * * * *',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toCronExpr(schedule: Schedule): string {
|
||||||
|
if (typeof schedule === 'number') {
|
||||||
|
return SCHEDULE_MAP[`${schedule}minutes`]
|
||||||
|
}
|
||||||
|
return SCHEDULE_MAP[schedule]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidSchedule(value: unknown): value is Schedule {
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return [1, 5, 15, 30].includes(value)
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return value in SCHEDULE_MAP
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
29
apps/cron/20260201-000000/lib/state.ts
Normal file
29
apps/cron/20260201-000000/lib/state.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import type { CronJob } from './schedules'
|
||||||
|
|
||||||
|
const jobs = new Map<string, CronJob>()
|
||||||
|
const listeners = new Set<() => void>()
|
||||||
|
|
||||||
|
export function setJobs(newJobs: CronJob[]) {
|
||||||
|
jobs.clear()
|
||||||
|
for (const job of newJobs) {
|
||||||
|
jobs.set(job.id, job)
|
||||||
|
}
|
||||||
|
broadcast()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getJob(id: string): CronJob | undefined {
|
||||||
|
return jobs.get(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAllJobs(): CronJob[] {
|
||||||
|
return Array.from(jobs.values())
|
||||||
|
}
|
||||||
|
|
||||||
|
export function broadcast() {
|
||||||
|
listeners.forEach(cb => cb())
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onChange(cb: () => void): () => void {
|
||||||
|
listeners.add(cb)
|
||||||
|
return () => listeners.delete(cb)
|
||||||
|
}
|
||||||
22
apps/cron/20260201-000000/package.json
Normal file
22
apps/cron/20260201-000000/package.json
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
{
|
||||||
|
"name": "cron",
|
||||||
|
"private": true,
|
||||||
|
"module": "index.tsx",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"toes": "bun run --watch index.tsx"
|
||||||
|
},
|
||||||
|
"toes": {
|
||||||
|
"tool": true,
|
||||||
|
"icon": "⏰"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@because/forge": "*",
|
||||||
|
"@because/hype": "*",
|
||||||
|
"@because/toes": "*",
|
||||||
|
"croner": "^9.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bun": "latest"
|
||||||
|
}
|
||||||
|
}
|
||||||
13
apps/cron/20260201-000000/tsconfig.json
Normal file
13
apps/cron/20260201-000000/tsconfig.json
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"types": ["bun"],
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "hono/jsx"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
apps/cron/current
Symbolic link
1
apps/cron/current
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
20260201-000000
|
||||||
81
docs/CRON.md
Normal file
81
docs/CRON.md
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
# Cron
|
||||||
|
|
||||||
|
A cron job is a TypeScript file that runs on a schedule.
|
||||||
|
|
||||||
|
The cron tool discovers jobs from all apps and runs them automatically.
|
||||||
|
|
||||||
|
## creating a cron job
|
||||||
|
|
||||||
|
Add a file to `cron/` in any app:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// apps/my-app/current/cron/daily-cleanup.ts
|
||||||
|
export const schedule = "day"
|
||||||
|
|
||||||
|
export default async function() {
|
||||||
|
console.log("Running cleanup...")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
That's it. The cron tool picks it up within 60 seconds.
|
||||||
|
|
||||||
|
## schedules
|
||||||
|
|
||||||
|
| value | when |
|
||||||
|
|-------|------|
|
||||||
|
| `1 minute` | every minute |
|
||||||
|
| `5 minutes` | every 5 minutes |
|
||||||
|
| `15 minutes` | every 15 minutes |
|
||||||
|
| `30 minutes` | every 30 minutes |
|
||||||
|
| `hour` | top of every hour |
|
||||||
|
| `noon` | 12:00 daily |
|
||||||
|
| `midnight` / `day` | 00:00 daily |
|
||||||
|
| `week` / `sunday` | 00:00 Sunday |
|
||||||
|
| `monday` - `saturday` | 00:00 that day |
|
||||||
|
|
||||||
|
Alternate forms work too: `"30minutes"`, `30`, `"30 minutes"`.
|
||||||
|
|
||||||
|
## environment
|
||||||
|
|
||||||
|
Jobs run with the app's working directory and inherit all env vars.
|
||||||
|
|
||||||
|
- `APPS_DIR` - path to `/apps` directory
|
||||||
|
|
||||||
|
## ui
|
||||||
|
|
||||||
|
The cron tool shows:
|
||||||
|
- Job name (`app/job`)
|
||||||
|
- Schedule
|
||||||
|
- Last run time
|
||||||
|
- Next run time
|
||||||
|
- Run Now button
|
||||||
|
|
||||||
|
Click **New Job** to create one from the UI.
|
||||||
|
|
||||||
|
## manual runs
|
||||||
|
|
||||||
|
Hit the **Run Now** button or POST to the tool:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3001/run/my-app/daily-cleanup
|
||||||
|
```
|
||||||
|
|
||||||
|
## job state
|
||||||
|
|
||||||
|
Jobs track:
|
||||||
|
- `idle` / `running` / `disabled`
|
||||||
|
- Last run timestamp
|
||||||
|
- Last duration
|
||||||
|
- Last exit code
|
||||||
|
- Last error (if any)
|
||||||
|
- Next scheduled run
|
||||||
|
|
||||||
|
## discovery
|
||||||
|
|
||||||
|
The cron tool:
|
||||||
|
1. Scans `APPS_DIR/*/current/cron/*.ts`
|
||||||
|
2. Imports each file to read `schedule`
|
||||||
|
3. Validates the schedule
|
||||||
|
4. Registers with croner
|
||||||
|
|
||||||
|
Re-scans every 60 seconds to pick up new/changed/removed jobs.
|
||||||
Loading…
Reference in New Issue
Block a user