@usehyper/cli 0.1.0
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 +31 -0
- package/package.json +40 -0
- package/registry-sources/agent-rules/README.md +12 -0
- package/registry-sources/agent-rules/files/.cursor/rules/hyper.md +178 -0
- package/registry-sources/agent-rules/files/AGENTS.md +64 -0
- package/registry-sources/agent-rules/manifest.json +15 -0
- package/src/__tests__/add.test.ts +125 -0
- package/src/__tests__/cli.test.ts +50 -0
- package/src/__tests__/security.test.ts +101 -0
- package/src/args.ts +38 -0
- package/src/bin.ts +77 -0
- package/src/commands/add.ts +232 -0
- package/src/commands/bench.ts +185 -0
- package/src/commands/build.ts +146 -0
- package/src/commands/client.ts +78 -0
- package/src/commands/dev.ts +53 -0
- package/src/commands/diff.ts +80 -0
- package/src/commands/env.ts +92 -0
- package/src/commands/help.ts +42 -0
- package/src/commands/init.ts +119 -0
- package/src/commands/list.ts +46 -0
- package/src/commands/mcp.ts +51 -0
- package/src/commands/openapi.ts +50 -0
- package/src/commands/routes.ts +45 -0
- package/src/commands/security.ts +233 -0
- package/src/commands/test.ts +191 -0
- package/src/commands/typecheck.ts +19 -0
- package/src/commands/update.ts +91 -0
- package/src/commands/version.ts +16 -0
- package/src/config/index.ts +30 -0
- package/src/config/io.ts +112 -0
- package/src/config/tsconfig.ts +138 -0
- package/src/config/types.ts +63 -0
- package/src/entry.ts +42 -0
- package/src/index.ts +57 -0
- package/src/load-app.ts +89 -0
- package/src/registry/__tests__/env-writer.test.ts +83 -0
- package/src/registry/apply.ts +268 -0
- package/src/registry/client.ts +127 -0
- package/src/registry/env-writer.ts +135 -0
- package/src/registry/index.ts +18 -0
- package/src/registry/rewrite.ts +177 -0
- package/src/registry/snapshot.ts +1018 -0
- package/src/registry/types.ts +62 -0
- package/src/templates.ts +141 -0
package/src/bin.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { parseArgs } from "./args.ts"
|
|
3
|
+
import { runAdd } from "./commands/add.ts"
|
|
4
|
+
import { runBench } from "./commands/bench.ts"
|
|
5
|
+
import { runBuild } from "./commands/build.ts"
|
|
6
|
+
import { runClient } from "./commands/client.ts"
|
|
7
|
+
import { runDev } from "./commands/dev.ts"
|
|
8
|
+
import { runDiff } from "./commands/diff.ts"
|
|
9
|
+
import { runEnvCheck } from "./commands/env.ts"
|
|
10
|
+
import { HELP_TEXT, runHelp } from "./commands/help.ts"
|
|
11
|
+
import { runInit } from "./commands/init.ts"
|
|
12
|
+
import { runList } from "./commands/list.ts"
|
|
13
|
+
import { runMcp } from "./commands/mcp.ts"
|
|
14
|
+
import { runOpenapi } from "./commands/openapi.ts"
|
|
15
|
+
import { runRoutes } from "./commands/routes.ts"
|
|
16
|
+
import { runSecurity } from "./commands/security.ts"
|
|
17
|
+
import { runTest } from "./commands/test.ts"
|
|
18
|
+
import { runTypecheck } from "./commands/typecheck.ts"
|
|
19
|
+
import { runUpdate } from "./commands/update.ts"
|
|
20
|
+
import { runVersion } from "./commands/version.ts"
|
|
21
|
+
|
|
22
|
+
async function main(): Promise<number> {
|
|
23
|
+
const args = parseArgs(process.argv.slice(2))
|
|
24
|
+
if (args.flags.help === true || args.flags.h === true || !args.command) {
|
|
25
|
+
return runHelp()
|
|
26
|
+
}
|
|
27
|
+
switch (args.command) {
|
|
28
|
+
case "init":
|
|
29
|
+
return runInit(args)
|
|
30
|
+
case "dev":
|
|
31
|
+
return runDev(args)
|
|
32
|
+
case "build":
|
|
33
|
+
return runBuild(args)
|
|
34
|
+
case "test":
|
|
35
|
+
return runTest(args)
|
|
36
|
+
case "typecheck":
|
|
37
|
+
return runTypecheck(args)
|
|
38
|
+
case "env":
|
|
39
|
+
return runEnvCheck(args)
|
|
40
|
+
case "routes":
|
|
41
|
+
return runRoutes(args)
|
|
42
|
+
case "client":
|
|
43
|
+
return runClient(args)
|
|
44
|
+
case "mcp":
|
|
45
|
+
return runMcp(args)
|
|
46
|
+
case "openapi":
|
|
47
|
+
return runOpenapi(args)
|
|
48
|
+
case "add":
|
|
49
|
+
return runAdd(args)
|
|
50
|
+
case "diff":
|
|
51
|
+
return runDiff(args)
|
|
52
|
+
case "update":
|
|
53
|
+
return runUpdate(args)
|
|
54
|
+
case "list":
|
|
55
|
+
case "search":
|
|
56
|
+
case "ls":
|
|
57
|
+
return runList(args)
|
|
58
|
+
case "bench":
|
|
59
|
+
return runBench(args)
|
|
60
|
+
case "security":
|
|
61
|
+
return runSecurity(args)
|
|
62
|
+
case "version":
|
|
63
|
+
case "--version":
|
|
64
|
+
case "-v":
|
|
65
|
+
return runVersion(args)
|
|
66
|
+
case "help":
|
|
67
|
+
return runHelp()
|
|
68
|
+
default:
|
|
69
|
+
console.error(`unknown command: ${args.command}\n`)
|
|
70
|
+
console.error(HELP_TEXT)
|
|
71
|
+
return 2
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
main().then((code) => {
|
|
76
|
+
process.exit(code)
|
|
77
|
+
})
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hyper add <component>...` — copy component source into the user's repo.
|
|
3
|
+
*
|
|
4
|
+
* hyper add cors # install cors + transitively required deps
|
|
5
|
+
* hyper add auth-jwt session # install multiple
|
|
6
|
+
* hyper add cors --info # print readme + file list, install nothing
|
|
7
|
+
* hyper add cors --force # overwrite local edits
|
|
8
|
+
* hyper add cors --dry-run # preview without writing
|
|
9
|
+
* hyper add list # list all available components
|
|
10
|
+
*
|
|
11
|
+
* Files are content-hash verified: a locally-edited file (sha differs from
|
|
12
|
+
* lockfile + differs from registry) refuses to overwrite without --force.
|
|
13
|
+
* If the local sha matches the lockfile but differs from the registry, that
|
|
14
|
+
* counts as a clean update and is applied.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { type ParsedArgs, isJson } from "../args.ts"
|
|
18
|
+
import { readConfig, readLock, writeLock } from "../config/index.ts"
|
|
19
|
+
import { applyComponents, createRegistryClient } from "../registry/index.ts"
|
|
20
|
+
|
|
21
|
+
export async function runAdd(args: ParsedArgs): Promise<number> {
|
|
22
|
+
const positional = args.positional
|
|
23
|
+
if (positional.length === 0) {
|
|
24
|
+
console.error("usage: hyper add <component>... [--force] [--dry-run] [--info] [--list]")
|
|
25
|
+
return 2
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const config = await readConfig()
|
|
29
|
+
const client = createRegistryClient({ url: config.registryUrl })
|
|
30
|
+
|
|
31
|
+
if (positional[0] === "list" || args.flags.list === true) {
|
|
32
|
+
return await listComponents(client, args)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (args.flags.info === true) {
|
|
36
|
+
return await infoComponent(positional, client, args)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const lock = await readLock()
|
|
40
|
+
const force = args.flags.force === true
|
|
41
|
+
const dryRun = args.flags["dry-run"] === true || args.flags.n === true
|
|
42
|
+
|
|
43
|
+
let outcome: Awaited<ReturnType<typeof applyComponents>>
|
|
44
|
+
try {
|
|
45
|
+
outcome = await applyComponents(positional, {
|
|
46
|
+
cwd: process.cwd(),
|
|
47
|
+
config,
|
|
48
|
+
client,
|
|
49
|
+
lock,
|
|
50
|
+
force,
|
|
51
|
+
dryRun,
|
|
52
|
+
})
|
|
53
|
+
} catch (err) {
|
|
54
|
+
console.error(`error: ${(err as Error).message}`)
|
|
55
|
+
return 1
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (outcome.conflicts.length > 0) {
|
|
59
|
+
console.error("conflicts (use --force to overwrite, or `hyper diff <component>` to inspect):")
|
|
60
|
+
for (const c of outcome.conflicts) console.error(` ${c.path} (${c.component})`)
|
|
61
|
+
return 1
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (!dryRun && outcome.written.length > 0) {
|
|
65
|
+
await writeLock(outcome.lock)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (isJson(args.flags)) {
|
|
69
|
+
const docsByComponent = collectDocs(outcome)
|
|
70
|
+
console.log(
|
|
71
|
+
JSON.stringify(
|
|
72
|
+
{
|
|
73
|
+
components: outcome.components.map((c) => ({
|
|
74
|
+
name: c.name,
|
|
75
|
+
version: c.version,
|
|
76
|
+
...(c.title !== undefined && { title: c.title }),
|
|
77
|
+
})),
|
|
78
|
+
written: outcome.written,
|
|
79
|
+
unchanged: outcome.unchanged,
|
|
80
|
+
peerDeps: outcome.peerDeps,
|
|
81
|
+
optionalPeerDeps: outcome.optionalPeerDeps,
|
|
82
|
+
envVars: outcome.envVars,
|
|
83
|
+
docs: docsByComponent,
|
|
84
|
+
warnings: outcome.warnings,
|
|
85
|
+
dryRun,
|
|
86
|
+
},
|
|
87
|
+
null,
|
|
88
|
+
2,
|
|
89
|
+
),
|
|
90
|
+
)
|
|
91
|
+
return 0
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
printOutcome(outcome, dryRun, config.alias)
|
|
95
|
+
return 0
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Map component name -> docs blurb, only for components that wrote files. */
|
|
99
|
+
function collectDocs(outcome: Awaited<ReturnType<typeof applyComponents>>): Record<string, string> {
|
|
100
|
+
const touched = new Set(outcome.written.map((w) => w.component))
|
|
101
|
+
const docs: Record<string, string> = {}
|
|
102
|
+
for (const c of outcome.components) {
|
|
103
|
+
if (c.docs && touched.has(c.name)) docs[c.name] = c.docs
|
|
104
|
+
}
|
|
105
|
+
return docs
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function listComponents(
|
|
109
|
+
client: ReturnType<typeof createRegistryClient>,
|
|
110
|
+
args: ParsedArgs,
|
|
111
|
+
): Promise<number> {
|
|
112
|
+
const all = await client.listComponents()
|
|
113
|
+
if (isJson(args.flags)) {
|
|
114
|
+
console.log(JSON.stringify(all, null, 2))
|
|
115
|
+
return 0
|
|
116
|
+
}
|
|
117
|
+
const w = Math.max(8, ...all.map((c) => c.name.length))
|
|
118
|
+
console.log(`${"name".padEnd(w)} version description`)
|
|
119
|
+
console.log(`${"".padEnd(w, "-")} ------- -----------`)
|
|
120
|
+
for (const c of all) {
|
|
121
|
+
const summary = c.title ? `${c.title} — ${c.description}` : c.description
|
|
122
|
+
console.log(`${c.name.padEnd(w)} ${c.version.padEnd(7)} ${summary}`)
|
|
123
|
+
}
|
|
124
|
+
return 0
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function infoComponent(
|
|
128
|
+
names: readonly string[],
|
|
129
|
+
client: ReturnType<typeof createRegistryClient>,
|
|
130
|
+
args: ParsedArgs,
|
|
131
|
+
): Promise<number> {
|
|
132
|
+
for (const name of names) {
|
|
133
|
+
const c = await client.getComponent(name).catch((err) => {
|
|
134
|
+
console.error(`error: ${(err as Error).message}`)
|
|
135
|
+
return null
|
|
136
|
+
})
|
|
137
|
+
if (!c) return 2
|
|
138
|
+
if (isJson(args.flags)) {
|
|
139
|
+
console.log(JSON.stringify(c, null, 2))
|
|
140
|
+
continue
|
|
141
|
+
}
|
|
142
|
+
console.log(`# ${c.name}@${c.version}`)
|
|
143
|
+
console.log(c.description)
|
|
144
|
+
if (c.registryDeps.length > 0) console.log(`\nneeds: ${c.registryDeps.join(", ")}`)
|
|
145
|
+
if (Object.keys(c.peerDeps).length > 0) {
|
|
146
|
+
console.log(
|
|
147
|
+
`peers: ${Object.entries(c.peerDeps)
|
|
148
|
+
.map(([k, v]) => `${k}@${v}`)
|
|
149
|
+
.join(", ")}`,
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
if (Object.keys(c.optionalPeerDeps).length > 0) {
|
|
153
|
+
console.log(
|
|
154
|
+
`optional peers: ${Object.entries(c.optionalPeerDeps)
|
|
155
|
+
.map(([k, v]) => `${k}@${v}`)
|
|
156
|
+
.join(", ")}`,
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
if (c.envVars && Object.keys(c.envVars).length > 0) {
|
|
160
|
+
console.log("\nenv vars (written to .env.local on install):")
|
|
161
|
+
for (const [k, v] of Object.entries(c.envVars)) console.log(` ${k}=${v}`)
|
|
162
|
+
}
|
|
163
|
+
console.log(`\nfiles (${c.files.length}):`)
|
|
164
|
+
for (const f of c.files) console.log(` ${f.path}`)
|
|
165
|
+
if (c.docs) {
|
|
166
|
+
console.log("\n# Setup notes\n")
|
|
167
|
+
console.log(c.docs)
|
|
168
|
+
}
|
|
169
|
+
if (c.readme) {
|
|
170
|
+
console.log(`\n${"-".repeat(60)}\n`)
|
|
171
|
+
console.log(c.readme)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return 0
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function printOutcome(
|
|
178
|
+
outcome: Awaited<ReturnType<typeof applyComponents>>,
|
|
179
|
+
dryRun: boolean,
|
|
180
|
+
alias: string,
|
|
181
|
+
): void {
|
|
182
|
+
const tag = dryRun ? "(dry-run) " : ""
|
|
183
|
+
const componentNames = outcome.components.map((c) => c.name).join(", ")
|
|
184
|
+
console.log(`${tag}installed: ${componentNames}`)
|
|
185
|
+
const newCount = outcome.written.filter((w) => w.reason === "new").length
|
|
186
|
+
const updatedCount = outcome.written.filter((w) => w.reason === "updated").length
|
|
187
|
+
const upToDateCount = outcome.unchanged.length
|
|
188
|
+
console.log(
|
|
189
|
+
`${tag}${newCount} new, ${updatedCount} updated, ${upToDateCount} up-to-date — alias: ${alias}`,
|
|
190
|
+
)
|
|
191
|
+
for (const w of outcome.written.slice(0, 30)) {
|
|
192
|
+
console.log(` ${w.reason === "new" ? "+" : "~"} ${w.path}`)
|
|
193
|
+
}
|
|
194
|
+
if (outcome.written.length > 30) console.log(` ... (${outcome.written.length - 30} more)`)
|
|
195
|
+
|
|
196
|
+
const peers = Object.entries(outcome.peerDeps)
|
|
197
|
+
const optionalPeers = Object.entries(outcome.optionalPeerDeps)
|
|
198
|
+
if (peers.length > 0) {
|
|
199
|
+
console.log(`\nrun: bun add ${peers.map(([k, v]) => `${k}@${v}`).join(" ")}`)
|
|
200
|
+
}
|
|
201
|
+
if (optionalPeers.length > 0) {
|
|
202
|
+
console.log(
|
|
203
|
+
`optional peers: ${optionalPeers.map(([k]) => k).join(", ")} (install only if you use them)`,
|
|
204
|
+
)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (outcome.envVars.added.length > 0) {
|
|
208
|
+
console.log(`\nenv: ${outcome.envVars.added.length} key(s) added to ${outcome.envVars.path}`)
|
|
209
|
+
for (const k of outcome.envVars.added) console.log(` + ${k}`)
|
|
210
|
+
}
|
|
211
|
+
if (outcome.envVars.preserved.length > 0) {
|
|
212
|
+
console.log(
|
|
213
|
+
`env: preserved existing ${outcome.envVars.preserved.length} key(s) in ${outcome.envVars.path}`,
|
|
214
|
+
)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (outcome.warnings.length > 0) {
|
|
218
|
+
console.log("\nwarnings:")
|
|
219
|
+
for (const w of outcome.warnings) console.log(` ! ${w}`)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Per-component setup notes — only for newly-installed components, so
|
|
223
|
+
// re-running `hyper add` doesn't re-spam the user.
|
|
224
|
+
const touched = new Set(outcome.written.map((w) => w.component))
|
|
225
|
+
for (const c of outcome.components) {
|
|
226
|
+
if (!c.docs || !touched.has(c.name)) continue
|
|
227
|
+
console.log(`\n${"-".repeat(60)}`)
|
|
228
|
+
console.log(`# ${c.title ?? c.name}`)
|
|
229
|
+
console.log()
|
|
230
|
+
console.log(c.docs)
|
|
231
|
+
}
|
|
232
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hyper bench` — minimal, reproducible latency benchmark.
|
|
3
|
+
*
|
|
4
|
+
* hyper bench → bench/<entry> and print stats
|
|
5
|
+
* hyper bench --json → emit JSON (CI perf gate)
|
|
6
|
+
* hyper bench --path /todos → pick a route to hammer
|
|
7
|
+
*
|
|
8
|
+
* We run everything in-process via app.fetch — no sockets, no port
|
|
9
|
+
* contention, no kernel scheduler noise. That gives us a stable signal
|
|
10
|
+
* for regression detection. The runner is warmup-aware and records
|
|
11
|
+
* allocation deltas so we can spot memory regressions.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { type ParsedArgs, isJson } from "../args.ts"
|
|
15
|
+
import { resolveEntry } from "../entry.ts"
|
|
16
|
+
import { loadApp } from "../load-app.ts"
|
|
17
|
+
|
|
18
|
+
export interface BenchReport {
|
|
19
|
+
readonly path: string
|
|
20
|
+
readonly method: string
|
|
21
|
+
readonly iterations: number
|
|
22
|
+
readonly warmup: number
|
|
23
|
+
readonly p50_us: number
|
|
24
|
+
readonly p95_us: number
|
|
25
|
+
readonly p99_us: number
|
|
26
|
+
readonly rps: number
|
|
27
|
+
readonly heapUsedDeltaMb: number
|
|
28
|
+
readonly targetP50Us: number
|
|
29
|
+
readonly targetP95Us: number
|
|
30
|
+
readonly passed: boolean
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function runBench(args: ParsedArgs): Promise<number> {
|
|
34
|
+
const entry = await resolveEntry(args.positional)
|
|
35
|
+
if (!entry) {
|
|
36
|
+
console.error("error: no entry file found")
|
|
37
|
+
return 2
|
|
38
|
+
}
|
|
39
|
+
const app = await loadApp(entry)
|
|
40
|
+
if (!app) {
|
|
41
|
+
console.error("error: entry did not export a Hyper app")
|
|
42
|
+
return 2
|
|
43
|
+
}
|
|
44
|
+
const iterations = readNumber(args.flags.n, 20_000)
|
|
45
|
+
const warmup = readNumber(args.flags.warmup, 2_000)
|
|
46
|
+
const targetP50 = readNumber(args.flags.p50, 250)
|
|
47
|
+
const targetP95 = readNumber(args.flags.p95, 800)
|
|
48
|
+
|
|
49
|
+
if (args.flags.tests === true) {
|
|
50
|
+
const reports: BenchReport[] = []
|
|
51
|
+
for (const r of app.routeList) {
|
|
52
|
+
if (r.path.includes(":")) continue
|
|
53
|
+
const report = await benchOne(app, r.path, r.method, {
|
|
54
|
+
iterations: Math.max(1_000, Math.floor(iterations / Math.max(1, app.routeList.length))),
|
|
55
|
+
warmup: Math.max(100, Math.floor(warmup / 2)),
|
|
56
|
+
targetP50,
|
|
57
|
+
targetP95,
|
|
58
|
+
})
|
|
59
|
+
reports.push(report)
|
|
60
|
+
}
|
|
61
|
+
const passed = reports.every((r) => r.passed)
|
|
62
|
+
if (isJson(args.flags)) {
|
|
63
|
+
console.log(JSON.stringify({ passed, routes: reports }))
|
|
64
|
+
} else {
|
|
65
|
+
console.log(`bench --tests ${reports.length} route(s) ${passed ? "PASS" : "FAIL"}`)
|
|
66
|
+
for (const r of reports) {
|
|
67
|
+
console.log(
|
|
68
|
+
` ${r.passed ? "PASS" : "FAIL"} ${r.method.padEnd(6)} ${r.path.padEnd(30)} p50=${r.p50_us}µs p95=${r.p95_us}µs rps=${r.rps}`,
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return passed ? 0 : 1
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const path = typeof args.flags.path === "string" ? args.flags.path : "/"
|
|
76
|
+
const method = typeof args.flags.method === "string" ? args.flags.method.toUpperCase() : "GET"
|
|
77
|
+
const url = `http://local${path}`
|
|
78
|
+
|
|
79
|
+
// Warmup.
|
|
80
|
+
for (let i = 0; i < warmup; i++) await app.fetch(new Request(url, { method }))
|
|
81
|
+
|
|
82
|
+
const samples = new Float64Array(iterations)
|
|
83
|
+
const start = process.memoryUsage().heapUsed
|
|
84
|
+
const t0 = Bun.nanoseconds()
|
|
85
|
+
for (let i = 0; i < iterations; i++) {
|
|
86
|
+
const s = Bun.nanoseconds()
|
|
87
|
+
await app.fetch(new Request(url, { method }))
|
|
88
|
+
samples[i] = (Bun.nanoseconds() - s) / 1000 // µs
|
|
89
|
+
}
|
|
90
|
+
const t1 = Bun.nanoseconds()
|
|
91
|
+
const heapUsedDelta = process.memoryUsage().heapUsed - start
|
|
92
|
+
const sorted = samples.slice().sort()
|
|
93
|
+
const p50 = percentile(sorted, 0.5)
|
|
94
|
+
const p95 = percentile(sorted, 0.95)
|
|
95
|
+
const p99 = percentile(sorted, 0.99)
|
|
96
|
+
const rps = iterations / ((t1 - t0) / 1_000_000_000)
|
|
97
|
+
const passed = p50 <= targetP50 * 1.05 && p95 <= targetP95 * 1.05
|
|
98
|
+
const report: BenchReport = {
|
|
99
|
+
path,
|
|
100
|
+
method,
|
|
101
|
+
iterations,
|
|
102
|
+
warmup,
|
|
103
|
+
p50_us: round(p50),
|
|
104
|
+
p95_us: round(p95),
|
|
105
|
+
p99_us: round(p99),
|
|
106
|
+
rps: round(rps),
|
|
107
|
+
heapUsedDeltaMb: round(heapUsedDelta / 1024 / 1024),
|
|
108
|
+
targetP50Us: targetP50,
|
|
109
|
+
targetP95Us: targetP95,
|
|
110
|
+
passed,
|
|
111
|
+
}
|
|
112
|
+
if (isJson(args.flags)) {
|
|
113
|
+
console.log(JSON.stringify(report))
|
|
114
|
+
} else {
|
|
115
|
+
console.log(`route ${method} ${path}`)
|
|
116
|
+
console.log(`iters ${iterations} (warmup ${warmup})`)
|
|
117
|
+
console.log(`p50/p95/p99 ${report.p50_us}µs / ${report.p95_us}µs / ${report.p99_us}µs`)
|
|
118
|
+
console.log(`rps ${report.rps}`)
|
|
119
|
+
console.log(`heapΔ ${report.heapUsedDeltaMb} MB`)
|
|
120
|
+
console.log(`target p50<=${targetP50}µs p95<=${targetP95}µs (5% slack)`)
|
|
121
|
+
console.log(`status ${passed ? "PASS" : "FAIL"}`)
|
|
122
|
+
}
|
|
123
|
+
return passed ? 0 : 1
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function percentile(sorted: Float64Array, p: number): number {
|
|
127
|
+
if (sorted.length === 0) return 0
|
|
128
|
+
const idx = Math.min(sorted.length - 1, Math.floor(p * sorted.length))
|
|
129
|
+
return sorted[idx] ?? 0
|
|
130
|
+
}
|
|
131
|
+
function round(n: number): number {
|
|
132
|
+
return Math.round(n * 100) / 100
|
|
133
|
+
}
|
|
134
|
+
function readNumber(flag: string | boolean | undefined, fallback: number): number {
|
|
135
|
+
if (typeof flag === "string") {
|
|
136
|
+
const n = Number.parseInt(flag, 10)
|
|
137
|
+
return Number.isFinite(n) && n > 0 ? n : fallback
|
|
138
|
+
}
|
|
139
|
+
return fallback
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function benchOne(
|
|
143
|
+
app: NonNullable<Awaited<ReturnType<typeof loadApp>>>,
|
|
144
|
+
path: string,
|
|
145
|
+
method: string,
|
|
146
|
+
opts: {
|
|
147
|
+
readonly iterations: number
|
|
148
|
+
readonly warmup: number
|
|
149
|
+
readonly targetP50: number
|
|
150
|
+
readonly targetP95: number
|
|
151
|
+
},
|
|
152
|
+
): Promise<BenchReport> {
|
|
153
|
+
const url = `http://local${path}`
|
|
154
|
+
for (let i = 0; i < opts.warmup; i++) await app.fetch(new Request(url, { method }))
|
|
155
|
+
const samples = new Float64Array(opts.iterations)
|
|
156
|
+
const start = process.memoryUsage().heapUsed
|
|
157
|
+
const t0 = Bun.nanoseconds()
|
|
158
|
+
for (let i = 0; i < opts.iterations; i++) {
|
|
159
|
+
const s = Bun.nanoseconds()
|
|
160
|
+
await app.fetch(new Request(url, { method }))
|
|
161
|
+
samples[i] = (Bun.nanoseconds() - s) / 1000
|
|
162
|
+
}
|
|
163
|
+
const t1 = Bun.nanoseconds()
|
|
164
|
+
const heapUsedDelta = process.memoryUsage().heapUsed - start
|
|
165
|
+
const sorted = samples.slice().sort()
|
|
166
|
+
const p50 = percentile(sorted, 0.5)
|
|
167
|
+
const p95 = percentile(sorted, 0.95)
|
|
168
|
+
const p99 = percentile(sorted, 0.99)
|
|
169
|
+
const rps = opts.iterations / ((t1 - t0) / 1_000_000_000)
|
|
170
|
+
const passed = p50 <= opts.targetP50 * 1.05 && p95 <= opts.targetP95 * 1.05
|
|
171
|
+
return {
|
|
172
|
+
path,
|
|
173
|
+
method,
|
|
174
|
+
iterations: opts.iterations,
|
|
175
|
+
warmup: opts.warmup,
|
|
176
|
+
p50_us: round(p50),
|
|
177
|
+
p95_us: round(p95),
|
|
178
|
+
p99_us: round(p99),
|
|
179
|
+
rps: round(rps),
|
|
180
|
+
heapUsedDeltaMb: round(heapUsedDelta / 1024 / 1024),
|
|
181
|
+
targetP50Us: opts.targetP50,
|
|
182
|
+
targetP95Us: opts.targetP95,
|
|
183
|
+
passed,
|
|
184
|
+
}
|
|
185
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { createHash } from "node:crypto"
|
|
2
|
+
import { mkdir, readFile, stat, writeFile } from "node:fs/promises"
|
|
3
|
+
import { dirname, join, resolve } from "node:path"
|
|
4
|
+
import { type ParsedArgs, isJson } from "../args.ts"
|
|
5
|
+
import { resolveEntry } from "../entry.ts"
|
|
6
|
+
import { loadApp } from "../load-app.ts"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* `hyper build` — bundle via Bun.build and emit the artifact manifest.
|
|
10
|
+
*
|
|
11
|
+
* v0.3 additions:
|
|
12
|
+
* - content-hash cache: skips bundling when entry+tsconfig+deps hash
|
|
13
|
+
* matches the previous build. Typical cache hit: <50ms incremental.
|
|
14
|
+
* - route graph: includes projection hints (mcp, subscription, action,
|
|
15
|
+
* deprecated, version) so downstream codegen can consume it directly.
|
|
16
|
+
* - per-route monomorphic hint: routes without dynamic segments are
|
|
17
|
+
* flagged `nativeEligible: true`, letting the adapter mount them on
|
|
18
|
+
* `Bun.serve({ routes: ... })` for faster dispatch.
|
|
19
|
+
* - .d.ts emission: when `--dts` is passed, emit isolated declaration
|
|
20
|
+
* files alongside the bundle via `tsgo` (falls back to `tsc`).
|
|
21
|
+
*/
|
|
22
|
+
export async function runBuild(args: ParsedArgs): Promise<number> {
|
|
23
|
+
const entry = await resolveEntry(args.positional)
|
|
24
|
+
if (!entry) {
|
|
25
|
+
console.error("error: no entry file found")
|
|
26
|
+
return 2
|
|
27
|
+
}
|
|
28
|
+
const outDir = typeof args.flags.outDir === "string" ? args.flags.outDir : "dist"
|
|
29
|
+
const absOut = resolve(process.cwd(), outDir)
|
|
30
|
+
await mkdir(absOut, { recursive: true })
|
|
31
|
+
|
|
32
|
+
if (typeof Bun === "undefined") {
|
|
33
|
+
console.error("error: hyper build requires Bun")
|
|
34
|
+
return 2
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const cacheKey = await computeCacheKey(entry)
|
|
38
|
+
const cacheFile = join(absOut, ".hyper-cache.json")
|
|
39
|
+
const cached = await readJsonOrNull<{ key: string; summary: unknown }>(cacheFile)
|
|
40
|
+
const bypassCache = args.flags.force === true
|
|
41
|
+
if (!bypassCache && cached?.key === cacheKey) {
|
|
42
|
+
if (isJson(args.flags)) console.log(JSON.stringify(cached.summary))
|
|
43
|
+
else console.log(`build cached -> ${absOut} (key ${cacheKey.slice(0, 8)})`)
|
|
44
|
+
return 0
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const result = await Bun.build({
|
|
48
|
+
entrypoints: [entry],
|
|
49
|
+
outdir: absOut,
|
|
50
|
+
target: "bun",
|
|
51
|
+
format: "esm",
|
|
52
|
+
sourcemap: "linked",
|
|
53
|
+
minify: args.flags.minify === true || args.flags.minify === "true",
|
|
54
|
+
})
|
|
55
|
+
if (!result.success) {
|
|
56
|
+
for (const log of result.logs) console.error(log)
|
|
57
|
+
return 1
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const app = await loadApp(entry)
|
|
61
|
+
if (app) {
|
|
62
|
+
const graph = {
|
|
63
|
+
routes: app.routeList.map((r) => ({
|
|
64
|
+
method: r.method,
|
|
65
|
+
path: r.path,
|
|
66
|
+
meta: r.meta,
|
|
67
|
+
kind: r.kind,
|
|
68
|
+
nativeEligible: !r.path.includes(":"),
|
|
69
|
+
staticResponse: r.kind === "static",
|
|
70
|
+
})),
|
|
71
|
+
summary: {
|
|
72
|
+
total: app.routeList.length,
|
|
73
|
+
staticCount: app.routeList.filter((r) => r.kind === "static").length,
|
|
74
|
+
nativeEligibleCount: app.routeList.filter((r) => !r.path.includes(":")).length,
|
|
75
|
+
},
|
|
76
|
+
}
|
|
77
|
+
const graphPath = resolve(absOut, "route-graph.json")
|
|
78
|
+
await mkdir(dirname(graphPath), { recursive: true })
|
|
79
|
+
await writeFile(graphPath, JSON.stringify(graph, null, 2))
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (args.flags.dts === true) {
|
|
83
|
+
await emitDts(absOut)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const summary = {
|
|
87
|
+
entry,
|
|
88
|
+
outDir: absOut,
|
|
89
|
+
artifacts: result.outputs.map((o) => o.path),
|
|
90
|
+
routeCount: app?.routeList.length ?? 0,
|
|
91
|
+
cacheKey,
|
|
92
|
+
}
|
|
93
|
+
await writeFile(cacheFile, JSON.stringify({ key: cacheKey, summary }))
|
|
94
|
+
if (isJson(args.flags)) {
|
|
95
|
+
console.log(JSON.stringify(summary))
|
|
96
|
+
} else {
|
|
97
|
+
console.log(`build ok -> ${absOut}`)
|
|
98
|
+
console.log(` ${summary.artifacts.length} artifact(s), ${summary.routeCount} route(s)`)
|
|
99
|
+
}
|
|
100
|
+
return 0
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function computeCacheKey(entry: string): Promise<string> {
|
|
104
|
+
const h = createHash("sha256")
|
|
105
|
+
h.update(entry)
|
|
106
|
+
await hashFile(h, entry)
|
|
107
|
+
await hashFile(h, resolve(process.cwd(), "tsconfig.json"))
|
|
108
|
+
await hashFile(h, resolve(process.cwd(), "package.json"))
|
|
109
|
+
await hashFile(h, resolve(process.cwd(), "bun.lockb"))
|
|
110
|
+
return h.digest("hex")
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function hashFile(h: import("node:crypto").Hash, path: string): Promise<void> {
|
|
114
|
+
try {
|
|
115
|
+
const s = await stat(path)
|
|
116
|
+
h.update(path)
|
|
117
|
+
h.update(s.mtimeMs.toString())
|
|
118
|
+
if (s.size < 1024 * 1024) {
|
|
119
|
+
const buf = await readFile(path)
|
|
120
|
+
h.update(buf)
|
|
121
|
+
}
|
|
122
|
+
} catch {
|
|
123
|
+
// Missing file — include absence in the key.
|
|
124
|
+
h.update(`missing:${path}`)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function readJsonOrNull<T>(path: string): Promise<T | null> {
|
|
129
|
+
try {
|
|
130
|
+
const buf = await readFile(path, "utf8")
|
|
131
|
+
return JSON.parse(buf) as T
|
|
132
|
+
} catch {
|
|
133
|
+
return null
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function emitDts(outDir: string): Promise<void> {
|
|
138
|
+
const { spawn } = await import("node:child_process")
|
|
139
|
+
await new Promise<void>((res) => {
|
|
140
|
+
const child = spawn("tsgo", ["--declaration", "--emitDeclarationOnly", "--outDir", outDir], {
|
|
141
|
+
stdio: "inherit",
|
|
142
|
+
})
|
|
143
|
+
child.on("exit", () => res())
|
|
144
|
+
child.on("error", () => res())
|
|
145
|
+
})
|
|
146
|
+
}
|