@bun-native/cli 0.0.0-bootstrap.0 → 0.33.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.
@@ -0,0 +1,61 @@
1
+ import { join, relative, sep } from 'node:path'
2
+ // Package the template style with the CLI so standalone projects without their own config use it.
3
+ import '../../.oxfmtrc.json'
4
+ import type { Project } from './project.ts'
5
+
6
+ /** The style the generated templates use; projects with a discoverable config keep theirs. */
7
+ export const templateFormatterConfig = join(import.meta.dir, '../../.oxfmtrc.json')
8
+
9
+ export type QualityStep = {
10
+ name: 'typecheck' | 'format' | 'lint'
11
+ command: string[]
12
+ exitCode: number
13
+ output: string
14
+ }
15
+
16
+ /** Runs the pinned Bun with `args` in the project directory and captures its combined output. */
17
+ export function runQualityStep(project: Project, args: string[]): Omit<QualityStep, 'name'> {
18
+ const command = [process.execPath, ...args]
19
+ const result = Bun.spawnSync(command, {
20
+ cwd: project.directory,
21
+ stdout: 'pipe',
22
+ stderr: 'pipe',
23
+ env: { ...process.env, NO_COLOR: '1' },
24
+ })
25
+ return {
26
+ command,
27
+ exitCode: result.exitCode ?? 1,
28
+ output: (result.stdout.toString() + result.stderr.toString()).trimEnd(),
29
+ }
30
+ }
31
+
32
+ /** Arguments for the built-in formatter, never a same-named package script. */
33
+ export function formatArguments(project: Project, check: boolean): string[] {
34
+ const entry = relative(project.directory, project.entry).split(sep).join('/')
35
+ const discovered = Bun.spawnSync(
36
+ [process.execPath, 'fmt', '--no-script', '--find-config-path', entry],
37
+ { cwd: project.directory, stdout: 'ignore', stderr: 'ignore' }
38
+ )
39
+ const args = ['--no-install', 'fmt', '--no-script', '--no-color']
40
+ if (check) args.push('--check')
41
+ if (discovered.exitCode !== 0) args.push('--config', templateFormatterConfig)
42
+ // Package archives and their canonical manifest are verified by digest, never reformatted.
43
+ if (project.packageSet) args.push('--ignore-pattern', 'vendor/**')
44
+ args.push('.')
45
+ return args
46
+ }
47
+
48
+ export function formatProject(project: Project, check: boolean): QualityStep {
49
+ return { name: 'format', ...runQualityStep(project, formatArguments(project, check)) }
50
+ }
51
+
52
+ /** Built-in type checking, then formatting; both steps always run so one report covers both. */
53
+ export function checkProject(project: Project): QualityStep[] {
54
+ return [
55
+ {
56
+ name: 'typecheck',
57
+ ...runQualityStep(project, ['--no-install', 'check', '--no-script', '--no-pretty']),
58
+ },
59
+ formatProject(project, true),
60
+ ]
61
+ }
@@ -0,0 +1,296 @@
1
+ import { mkdtempSync } from 'node:fs'
2
+ import { mkdtemp, rm } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { bundleProject } from './build.ts'
6
+ import { readProject } from './project.ts'
7
+
8
+ const diagnosticKeys = [
9
+ 'windows',
10
+ 'nodes',
11
+ 'callbacks',
12
+ 'queuedTransactions',
13
+ 'queuedCallbacks',
14
+ 'activeCallbacks',
15
+ 'serviceRequests',
16
+ ] as const
17
+
18
+ function record(value: unknown): Record<string, unknown> | undefined {
19
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
20
+ ? (value as Record<string, unknown>)
21
+ : undefined
22
+ }
23
+
24
+ type StoppedResources = {
25
+ application: Record<(typeof diagnosticKeys)[number], 0>
26
+ nodes: 0
27
+ }
28
+
29
+ export type TeardownReport = StoppedResources & { pid: number; clean: true }
30
+
31
+ function cleanStop(value: Record<string, unknown>): StoppedResources | undefined {
32
+ const application = record(value['application'])
33
+ if (
34
+ value['nodes'] !== 0 ||
35
+ application === undefined ||
36
+ !diagnosticKeys.every((key) => application[key] === 0)
37
+ )
38
+ return undefined
39
+ return {
40
+ // Copy only the counters validated above; retain no arbitrary child payload.
41
+ application: Object.fromEntries(
42
+ diagnosticKeys.map((key) => [key, application[key]])
43
+ ) as StoppedResources['application'],
44
+ nodes: 0,
45
+ }
46
+ }
47
+
48
+ async function bounded<T>(value: Promise<T>, milliseconds: number, message: string): Promise<T> {
49
+ let timer: ReturnType<typeof setTimeout> | undefined
50
+ try {
51
+ return await Promise.race([
52
+ value,
53
+ new Promise<never>((_, reject) => {
54
+ timer = setTimeout(() => reject(new Error(message)), milliseconds)
55
+ }),
56
+ ])
57
+ } finally {
58
+ clearTimeout(timer)
59
+ }
60
+ }
61
+
62
+ type OwnedProcess = {
63
+ child: Bun.Subprocess<'ignore', 'inherit', 'inherit'>
64
+ directory: string
65
+ ready: Promise<void>
66
+ exited: Promise<number>
67
+ disconnected: Promise<void>
68
+ terminated: boolean
69
+ mounted: boolean
70
+ stopping: boolean
71
+ resources: StoppedResources | undefined
72
+ }
73
+
74
+ export type RemountReport = {
75
+ generation: number
76
+ pid: number
77
+ bundle: string
78
+ bundleMs: number
79
+ shutdownMs: number
80
+ mountMs: number
81
+ editToReadyMs: number
82
+ previous?: TeardownReport
83
+ }
84
+
85
+ /** One app process at a time; its native host belongs to that app's public core. */
86
+ export class DevSession {
87
+ private current: OwnedProcess | undefined
88
+ private active: Promise<RemountReport> | undefined
89
+ private closing: Promise<TeardownReport | undefined> | undefined
90
+ private stopped = false
91
+ private generation = 0
92
+ private readonly completion = Promise.withResolvers<number>()
93
+ readonly exited = this.completion.promise
94
+ readonly temporaryDirectory: string
95
+
96
+ constructor(
97
+ private readonly repository: string,
98
+ private readonly projectDirectory: string,
99
+ private readonly host: string,
100
+ private readonly timeoutMs = 5_000
101
+ ) {
102
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 60_000)
103
+ throw new Error('Dev session timeout must be between 1 and 60000 milliseconds')
104
+ this.temporaryDirectory = mkdtempSync(join(tmpdir(), 'bun-native-dev-'))
105
+ }
106
+
107
+ reload(changedAt = performance.now()): Promise<RemountReport> {
108
+ if (this.stopped) return Promise.reject(new Error('Development session is closed'))
109
+ if (this.active) return Promise.reject(new Error('A reload is already running'))
110
+ this.active = this.replace(changedAt).finally(() => {
111
+ this.active = undefined
112
+ })
113
+ return this.active
114
+ }
115
+
116
+ close(): Promise<TeardownReport | undefined> {
117
+ this.stopped = true
118
+ return (this.closing ??= (async () => {
119
+ try {
120
+ await this.active?.catch(() => {})
121
+ const previous = this.current
122
+ this.current = undefined
123
+ let teardown: TeardownReport | undefined
124
+ if (previous) {
125
+ try {
126
+ teardown = await this.stop(previous)
127
+ } finally {
128
+ if (!previous.terminated) this.current = previous
129
+ }
130
+ }
131
+ this.completion.resolve(0)
132
+ return teardown
133
+ } finally {
134
+ // A failed app can already be reaped while its teardown error must still
135
+ // propagate. Remove only this session's root once no process owns it.
136
+ if (!this.current) await rm(this.temporaryDirectory, { recursive: true, force: true })
137
+ }
138
+ })())
139
+ }
140
+
141
+ private launch(entry: string, directory: string): OwnedProcess {
142
+ const ready = Promise.withResolvers<void>()
143
+ const disconnected = Promise.withResolvers<void>()
144
+ // A synchronous spawn failure or immediate exit must never orphan a rejection.
145
+ void ready.promise.catch(() => {})
146
+ let owner: OwnedProcess | undefined
147
+ const child = Bun.spawn([process.execPath, entry], {
148
+ cwd: this.projectDirectory,
149
+ env: { ...process.env, BUN_NATIVE_HOST: this.host },
150
+ stdin: 'ignore',
151
+ stdout: 'inherit',
152
+ stderr: 'inherit',
153
+ ipc(message: unknown) {
154
+ const value = record(message)
155
+ if (value?.['type'] === 'bun-native:ready' && owner && !owner.stopping) {
156
+ owner.mounted = true
157
+ ready.resolve()
158
+ } else if (value?.['type'] === 'bun-native:stopped' && owner) {
159
+ owner.resources = cleanStop(value)
160
+ }
161
+ },
162
+ onDisconnect: () => {
163
+ disconnected.resolve()
164
+ },
165
+ onExit: async (_child, code, signal) => {
166
+ ready.reject(new Error(`Application exited before readiness (${code ?? signal})`))
167
+ // Exit notification can precede the final IPC message on a different pipe.
168
+ await disconnected.promise
169
+ if (owner?.mounted && !owner.stopping) {
170
+ this.stopped = true
171
+ this.completion.resolve(code === 0 && owner.resources ? 0 : 1)
172
+ }
173
+ },
174
+ })
175
+ const exited = child.exited.then((code) => {
176
+ if (owner) owner.terminated = true
177
+ return code
178
+ })
179
+ owner = {
180
+ child,
181
+ directory,
182
+ ready: ready.promise,
183
+ exited,
184
+ disconnected: disconnected.promise,
185
+ terminated: false,
186
+ mounted: false,
187
+ stopping: false,
188
+ resources: undefined,
189
+ }
190
+ return owner
191
+ }
192
+
193
+ private async stop(owner: OwnedProcess): Promise<TeardownReport> {
194
+ owner.stopping = true
195
+ try {
196
+ if (!owner.terminated && !owner.resources) {
197
+ // The child may be closing normally while this request is in flight.
198
+ // Always join exit and validate its final acknowledgement below.
199
+ try {
200
+ owner.child.send({ type: 'bun-native:stop' })
201
+ } catch {
202
+ /* joined below */
203
+ }
204
+ }
205
+ const [code] = await bounded(
206
+ Promise.all([owner.exited, owner.disconnected]),
207
+ this.timeoutMs,
208
+ 'Application did not acknowledge shutdown in time'
209
+ )
210
+ if (code !== 0 || !owner.resources)
211
+ throw new Error(
212
+ `Application did not report clean teardown (exit ${code}); preserve the template dev-control hooks`
213
+ )
214
+ return { pid: owner.child.pid, clean: true, ...owner.resources }
215
+ } catch (error) {
216
+ if (!owner.terminated) {
217
+ owner.child.kill('SIGKILL')
218
+ await bounded(
219
+ Promise.all([owner.exited, owner.disconnected]),
220
+ this.timeoutMs,
221
+ 'Application did not exit after termination'
222
+ )
223
+ }
224
+ throw error
225
+ } finally {
226
+ if (owner.terminated) {
227
+ await rm(owner.directory, { recursive: true, force: true })
228
+ }
229
+ }
230
+ }
231
+
232
+ private async replace(changedAt: number): Promise<RemountReport> {
233
+ const project = await readProject(this.repository, this.projectDirectory)
234
+ const directory = await mkdtemp(join(this.temporaryDirectory, 'generation-'))
235
+ let owner: OwnedProcess | undefined
236
+ const start = performance.now()
237
+ try {
238
+ // Compile before touching the live app, so an incomplete edit preserves it.
239
+ const entry = await bundleProject(project, directory)
240
+ const bundled = performance.now()
241
+ if (this.stopped) throw new Error('Development session is closed')
242
+ const previous = this.current
243
+ this.current = undefined
244
+ let teardown: TeardownReport | undefined
245
+ if (previous) {
246
+ try {
247
+ teardown = await this.stop(previous)
248
+ } catch (error) {
249
+ if (!previous.terminated) this.current = previous
250
+ this.stopped = true
251
+ this.completion.resolve(1)
252
+ throw error
253
+ }
254
+ }
255
+ const joined = performance.now()
256
+ if (this.stopped) throw new Error('Development session is closed')
257
+ owner = this.launch(entry, directory)
258
+ this.current = owner
259
+ await bounded(
260
+ owner.ready,
261
+ this.timeoutMs,
262
+ 'Application readiness timed out; preserve the template dev-control hooks'
263
+ )
264
+ const ready = performance.now()
265
+ return {
266
+ generation: ++this.generation,
267
+ pid: owner.child.pid,
268
+ bundle: entry,
269
+ bundleMs: bundled - start,
270
+ shutdownMs: joined - bundled,
271
+ mountMs: ready - joined,
272
+ editToReadyMs: ready - changedAt,
273
+ ...(teardown ? { previous: teardown } : {}),
274
+ }
275
+ } catch (error) {
276
+ if (owner) {
277
+ this.stopped = true
278
+ this.completion.resolve(1)
279
+ try {
280
+ await this.stop(owner)
281
+ } catch (cleanupError) {
282
+ throw new AggregateError(
283
+ [error, cleanupError],
284
+ 'Application startup failed and the session was stopped'
285
+ )
286
+ } finally {
287
+ if (owner.terminated) this.current = undefined
288
+ }
289
+ }
290
+ throw error
291
+ } finally {
292
+ if (!owner || (owner.terminated && owner !== this.current))
293
+ await rm(directory, { recursive: true, force: true })
294
+ }
295
+ }
296
+ }
@@ -0,0 +1,83 @@
1
+ import { constants } from 'node:fs'
2
+ import { copyFile, mkdir, writeFile } from 'node:fs/promises'
3
+ import { dirname, join } from 'node:path'
4
+ import compilerConfig from '../../tsconfig.json'
5
+ import { seedConsumerLock } from './package-lock.ts'
6
+ import { consumerArchiveLock, readPackageSet, type ReadPackageSet } from './package-set.ts'
7
+
8
+ const packageSetPath = 'vendor/bun-native/manifest.json'
9
+
10
+ export function standaloneFiles(
11
+ set: ReadPackageSet,
12
+ framework: 'solid' | 'react',
13
+ entry: string,
14
+ name: string
15
+ ): Record<string, string> {
16
+ const consumer = set.manifest.consumers[framework]
17
+ const localLock = consumerArchiveLock(set.manifest, framework)
18
+ const { overrides } = localLock
19
+ const local = (dependencies: Record<string, string>) =>
20
+ Object.fromEntries(
21
+ Object.entries(dependencies).map(([name, version]) => [name, overrides[name] ?? version])
22
+ )
23
+ const dependencies = local(consumer.dependencies)
24
+ const devDependencies = local(consumer.devDependencies)
25
+ const cli = 'bun --no-install ./node_modules/@bun-native/cli/src/scripts/native.ts'
26
+ const json = (value: unknown) => JSON.stringify(value, null, 2) + '\n'
27
+ // The template formatter keeps short string arrays on one line, so format checks pass as generated.
28
+ const formattedJSON = (value: unknown) =>
29
+ json(value).replace(
30
+ /\[\n\s*("[^"\n]*"(?:,\n\s*"[^"\n]*")*)\n\s*\]/g,
31
+ (_, items: string) => `[${items.split(/,\n\s*/).join(', ')}]`
32
+ )
33
+ return {
34
+ 'bun-native.json': json({ schemaVersion: 1, framework, entry, packageSet: packageSetPath }),
35
+ 'package.json': json({
36
+ name,
37
+ private: true,
38
+ type: 'module',
39
+ packageManager: `bun@${set.manifest.toolchain.bun.version}`,
40
+ engines: { bun: set.manifest.toolchain.bun.version },
41
+ scripts: {
42
+ // The persistent watch controller returns freed bundler pages promptly;
43
+ // see docs/developer-tooling.md "Controller memory".
44
+ dev: `MIMALLOC_PURGE_DELAY=0 ${cli} dev .`,
45
+ build: `${cli} build .`,
46
+ check: 'bun --no-install check --no-script --no-pretty',
47
+ format: `${cli} format .`,
48
+ 'format:check': `${cli} format . --check`,
49
+ lint: `${cli} lint .`,
50
+ },
51
+ dependencies,
52
+ devDependencies,
53
+ overrides,
54
+ }),
55
+ 'bun.lock': json(
56
+ seedConsumerLock(consumer.lock, { name, dependencies, devDependencies }, localLock)
57
+ ),
58
+ 'tsconfig.json': formattedJSON({
59
+ compilerOptions: {
60
+ ...compilerConfig.compilerOptions,
61
+ jsxImportSource: `@bun-native/${framework}`,
62
+ },
63
+ include: [entry],
64
+ exclude: ['dist', 'node_modules', '.bun-native', 'vendor'],
65
+ }),
66
+ '.gitignore': 'dist/\n.bun-native/\nnode_modules/\n',
67
+ 'README.md': `# ${name}\n\nThis standalone project carries its local Bun Native package archives in \`vendor/bun-native\`. Commit that directory with this project. Its source snapshot is \`${set.manifest.source.commit}\`; use Bun revision \`${set.manifest.toolchain.bun.revision}\`. No checkout paths or workspace links are required.\n\nThe initial \`bun.lock\` contains every selected local archive tuple with SHA-256 integrity, matching local overrides, and the exact existing external dependency graph. No local root is left unresolved. First run \`bun install --offline --ignore-scripts --linker hoisted --backend copyfile\` with a populated package cache. An empty offline cache must fail; the local archives do not contain the third-party packages. Preserve the resulting lock, then use \`bun install --offline --frozen-lockfile --ignore-scripts --linker hoisted --backend copyfile\` for subsequent installs and relocation. The CLI never installs packages implicitly.\n\n- \`bun run check\` checks TypeScript or checked JavaScript.\n- \`bun run format\` applies Bun's built-in formatter in the template style unless the project supplies its own formatter config; \`bun run format:check\` only reports differences.\n- \`bun run lint\` runs the pinned \`oxlint\` with the CLI's template rules unless the project supplies its own \`.oxlintrc.json\`. It never lints \`vendor/\`, \`dist/\`, \`.bun-native/\` or \`node_modules/\`.\n- \`bun run dev --host /absolute/path/to/bun-native-host\` bundles and opens the app once.\n- Add \`--watch\` to remount after source edits with joined teardown. State resets; state-preserving hot reload is not implemented.\n- \`bun run build --host /absolute/path/to/bun-native-host --host-profile release\` creates an unsigned same-platform AOT app in \`dist\`. The output directory must be empty.\n\nSupply a separately acquired, compatible prebuilt native host. These commands never download it or run Cargo. Keep the template's optional development IPC hooks: they report readiness and join renderer/application/host cleanup. The archives are private experimental source packages, not a signed registry release. Their checksums establish consistency with the supplied manifest; verify that manifest's origin separately.\n\nSolid uses the universal JSX transform with \`jsx: preserve\`; its \`jsx-runtime\` export supplies types, not React-style automatic JSX functions. React uses its native JSX runtime. DOM/WebView APIs and browser component libraries are unsupported. See [the source-bound tooling documentation](https://github.com/0xPlayerOne/bun-native/blob/${set.manifest.source.commit}/docs/developer-tooling.md) for the native/platform and measurement limits.\n`,
68
+ }
69
+ }
70
+
71
+ /** Called only after the caller exclusively creates and owns the destination. */
72
+ export async function copyPackageSet(set: ReadPackageSet, directory: string): Promise<void> {
73
+ const destination = join(directory, dirname(packageSetPath))
74
+ await mkdir(destination, { recursive: true })
75
+ for (const pkg of Object.values(set.manifest.packages)) {
76
+ const path = join(destination, pkg.archive.file)
77
+ await mkdir(dirname(path), { recursive: true })
78
+ await copyFile(join(set.directory, pkg.archive.file), path, constants.COPYFILE_EXCL)
79
+ }
80
+ await writeFile(join(directory, packageSetPath), set.text, { flag: 'wx' })
81
+ // Re-hash the owned copies to catch a changed input between validation and copy.
82
+ await readPackageSet(join(directory, packageSetPath))
83
+ }
@@ -0,0 +1,104 @@
1
+ import { watch } from 'node:fs'
2
+ import type { FSWatcher } from 'node:fs'
3
+ import { projectFingerprint, rescanInput } from './inputs.ts'
4
+ import { DevSession, type RemountReport } from './session.ts'
5
+
6
+ function report(value: RemountReport): void {
7
+ console.log('bun-native:ready ' + JSON.stringify(value))
8
+ }
9
+
10
+ export async function watchProject(
11
+ repository: string,
12
+ directory: string,
13
+ host: string
14
+ ): Promise<void> {
15
+ const session = new DevSession(repository, directory, host)
16
+ const stopped = Promise.withResolvers<void>()
17
+ const failed = Promise.withResolvers<Error>()
18
+ const scanController = new AbortController()
19
+ let watcher: FSWatcher | undefined
20
+ let timer: ReturnType<typeof setTimeout> | undefined
21
+ let pending: number | undefined
22
+ let snapshot = ''
23
+ let scanning: Promise<string> | undefined
24
+ let running = true
25
+ let closing = false
26
+ const stop = () => {
27
+ closing = true
28
+ scanController.abort()
29
+ stopped.resolve()
30
+ // close() marks cancellation synchronously before joining any active build.
31
+ // Its rejection is also observed by the final cleanup below.
32
+ void session.close().catch(() => {})
33
+ }
34
+
35
+ const pump = async () => {
36
+ timer = undefined
37
+ if (running || closing || pending === undefined) return
38
+ const changedAt = pending
39
+ pending = undefined
40
+ running = true
41
+ try {
42
+ scanning = projectFingerprint(directory, scanController.signal)
43
+ const next = await scanning
44
+ scanning = undefined
45
+ if (closing || next === snapshot) return
46
+ // Record the attempted bytes before compiling. A rejected edit stays live
47
+ // without a retry loop, and edits during the build remain pending.
48
+ snapshot = next
49
+ report(await session.reload(changedAt))
50
+ } catch (error) {
51
+ if (!closing) {
52
+ console.error(error instanceof Error ? error.message : String(error))
53
+ if (error instanceof AggregateError)
54
+ for (const detail of error.errors) console.error(detail)
55
+ }
56
+ } finally {
57
+ scanning = undefined
58
+ running = false
59
+ if (!closing && pending !== undefined && timer === undefined) timer = setTimeout(pump, 50)
60
+ }
61
+ }
62
+
63
+ process.once('SIGINT', stop)
64
+ process.once('SIGTERM', stop)
65
+ try {
66
+ watcher = watch(directory, { recursive: true }, (_event, filename) => {
67
+ if (closing || !rescanInput(filename)) return
68
+ pending = performance.now()
69
+ clearTimeout(timer)
70
+ timer = setTimeout(pump, 50)
71
+ })
72
+ // Store watcher failure as a value so an error during the initial build is
73
+ // observed after it settles, without a detached rejected promise.
74
+ watcher.on('error', failed.resolve)
75
+ try {
76
+ // Observe first, then snapshot: events during initial scanning/building are
77
+ // coalesced and checked against these bytes after the initial mount.
78
+ snapshot = await projectFingerprint(directory, scanController.signal)
79
+ report(await session.reload())
80
+ } catch (error) {
81
+ if (closing) return
82
+ throw error
83
+ }
84
+ running = false
85
+ if (pending !== undefined && timer === undefined) timer = setTimeout(pump, 50)
86
+ console.log(
87
+ 'Watching project source. Each successful edit remounts the application and resets state.'
88
+ )
89
+ const exit = await Promise.race([session.exited, stopped.promise, failed.promise])
90
+ if (exit instanceof Error) throw exit
91
+ if (exit !== undefined && exit !== 0)
92
+ throw new Error('Application exited without clean teardown')
93
+ } finally {
94
+ closing = true
95
+ scanController.abort()
96
+ clearTimeout(timer)
97
+ watcher?.close()
98
+ process.off('SIGINT', stop)
99
+ process.off('SIGTERM', stop)
100
+ await scanning?.catch(() => {})
101
+ const teardown = await session.close()
102
+ if (teardown) console.log('bun-native:stopped ' + JSON.stringify(teardown))
103
+ }
104
+ }