@bun-native/cli 0.0.0-stage → 0.33.1

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.
Files changed (36) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +4 -2
  3. package/bun-native-source.json +151 -0
  4. package/package.json +63 -4
  5. package/src/.oxfmtrc.json +20 -0
  6. package/src/.oxlintrc.json +12 -0
  7. package/src/experiments/aot-gpui/compile.ts +44 -0
  8. package/src/experiments/aot-gpui/inspect-runtime.ts +304 -0
  9. package/src/experiments/aot-gpui/inspect.ts +66 -0
  10. package/src/scripts/native/artifact.ts +73 -0
  11. package/src/scripts/native/build.ts +231 -0
  12. package/src/scripts/native/bundle.ts +121 -0
  13. package/src/scripts/native/consumer-files.ts +46 -0
  14. package/src/scripts/native/context.ts +95 -0
  15. package/src/scripts/native/doctor.ts +69 -0
  16. package/src/scripts/native/host-download.ts +179 -0
  17. package/src/scripts/native/host-header.ts +33 -0
  18. package/src/scripts/native/host.ts +22 -0
  19. package/src/scripts/native/inputs.ts +95 -0
  20. package/src/scripts/native/lint.ts +43 -0
  21. package/src/scripts/native/package-archive.ts +94 -0
  22. package/src/scripts/native/package-lock.ts +220 -0
  23. package/src/scripts/native/package-set.ts +417 -0
  24. package/src/scripts/native/package-stamp.ts +209 -0
  25. package/src/scripts/native/paths.ts +20 -0
  26. package/src/scripts/native/project.ts +271 -0
  27. package/src/scripts/native/quality.ts +76 -0
  28. package/src/scripts/native/registry-project.ts +90 -0
  29. package/src/scripts/native/session.ts +299 -0
  30. package/src/scripts/native/standalone.ts +64 -0
  31. package/src/scripts/native/ustar.ts +29 -0
  32. package/src/scripts/native/watch.ts +104 -0
  33. package/src/scripts/native.ts +237 -0
  34. package/src/scripts/solid/transform.ts +16 -0
  35. package/src/toolchain.json +74 -0
  36. package/src/tsconfig.json +45 -0
@@ -0,0 +1,299 @@
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
+ // Remounts reuse the verification stamp; any change to a verified path re-verifies fully.
234
+ const project = await readProject(this.repository, this.projectDirectory, {
235
+ verification: 'stamp',
236
+ })
237
+ const directory = await mkdtemp(join(this.temporaryDirectory, 'generation-'))
238
+ let owner: OwnedProcess | undefined
239
+ const start = performance.now()
240
+ try {
241
+ // Compile before touching the live app, so an incomplete edit preserves it.
242
+ const entry = await bundleProject(project, directory)
243
+ const bundled = performance.now()
244
+ if (this.stopped) throw new Error('Development session is closed')
245
+ const previous = this.current
246
+ this.current = undefined
247
+ let teardown: TeardownReport | undefined
248
+ if (previous) {
249
+ try {
250
+ teardown = await this.stop(previous)
251
+ } catch (error) {
252
+ if (!previous.terminated) this.current = previous
253
+ this.stopped = true
254
+ this.completion.resolve(1)
255
+ throw error
256
+ }
257
+ }
258
+ const joined = performance.now()
259
+ if (this.stopped) throw new Error('Development session is closed')
260
+ owner = this.launch(entry, directory)
261
+ this.current = owner
262
+ await bounded(
263
+ owner.ready,
264
+ this.timeoutMs,
265
+ 'Application readiness timed out; preserve the template dev-control hooks'
266
+ )
267
+ const ready = performance.now()
268
+ return {
269
+ generation: ++this.generation,
270
+ pid: owner.child.pid,
271
+ bundle: entry,
272
+ bundleMs: bundled - start,
273
+ shutdownMs: joined - bundled,
274
+ mountMs: ready - joined,
275
+ editToReadyMs: ready - changedAt,
276
+ ...(teardown ? { previous: teardown } : {}),
277
+ }
278
+ } catch (error) {
279
+ if (owner) {
280
+ this.stopped = true
281
+ this.completion.resolve(1)
282
+ try {
283
+ await this.stop(owner)
284
+ } catch (cleanupError) {
285
+ throw new AggregateError(
286
+ [error, cleanupError],
287
+ 'Application startup failed and the session was stopped'
288
+ )
289
+ } finally {
290
+ if (owner.terminated) this.current = undefined
291
+ }
292
+ }
293
+ throw error
294
+ } finally {
295
+ if (!owner || (owner.terminated && owner !== this.current))
296
+ await rm(directory, { recursive: true, force: true })
297
+ }
298
+ }
299
+ }
@@ -0,0 +1,64 @@
1
+ import { constants } from 'node:fs'
2
+ import { copyFile, mkdir, writeFile } from 'node:fs/promises'
3
+ import { dirname, join } from 'node:path'
4
+ import { consumerScripts, consumerTsconfig, json } from './consumer-files.ts'
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
+ return {
26
+ 'bun-native.json': json({ schemaVersion: 1, framework, entry, packageSet: packageSetPath }),
27
+ 'package.json': json({
28
+ name,
29
+ private: true,
30
+ type: 'module',
31
+ packageManager: `bun@${set.manifest.toolchain.bun.version}`,
32
+ engines: { bun: set.manifest.toolchain.bun.version },
33
+ scripts: consumerScripts(),
34
+ dependencies,
35
+ devDependencies,
36
+ overrides,
37
+ }),
38
+ 'bun.lock': json(
39
+ seedConsumerLock(consumer.lock, { name, dependencies, devDependencies }, localLock)
40
+ ),
41
+ 'tsconfig.json': consumerTsconfig(framework, entry, [
42
+ 'dist',
43
+ 'node_modules',
44
+ '.bun-native',
45
+ 'vendor',
46
+ ]),
47
+ '.gitignore': 'dist/\n.bun-native/\nnode_modules/\n',
48
+ '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`,
49
+ }
50
+ }
51
+
52
+ /** Called only after the caller exclusively creates and owns the destination. */
53
+ export async function copyPackageSet(set: ReadPackageSet, directory: string): Promise<void> {
54
+ const destination = join(directory, dirname(packageSetPath))
55
+ await mkdir(destination, { recursive: true })
56
+ for (const pkg of Object.values(set.manifest.packages)) {
57
+ const path = join(destination, pkg.archive.file)
58
+ await mkdir(dirname(path), { recursive: true })
59
+ await copyFile(join(set.directory, pkg.archive.file), path, constants.COPYFILE_EXCL)
60
+ }
61
+ await writeFile(join(directory, packageSetPath), set.text, { flag: 'wx' })
62
+ // Re-hash the owned copies to catch a changed input between validation and copy.
63
+ await readPackageSet(join(directory, packageSetPath))
64
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * USTAR header primitives shared by the package-archive reader, the host-archive
3
+ * reader and the host-archive writer. Each reader keeps its own member rules and
4
+ * error messages; scripts/install-bun/archive.mjs keeps its own copy because it
5
+ * must run under Node before Bun exists.
6
+ */
7
+
8
+ export const USTAR_BLOCK = 512
9
+
10
+ /** A NUL/space-padded octal field as a safe integer, or undefined when malformed. */
11
+ export function ustarNumber(field: Uint8Array): number | undefined {
12
+ const text = Buffer.from(field.buffer, field.byteOffset, field.byteLength).toString('latin1')
13
+ if (!/^[\0 ]*[0-7]+[\0 ]*$/.test(text)) return undefined
14
+ const value = Number.parseInt(text.replaceAll('\0', '').trim(), 8)
15
+ return Number.isSafeInteger(value) ? value : undefined
16
+ }
17
+
18
+ /** The header checksum, counting the checksum field itself as eight spaces. */
19
+ export function ustarChecksum(header: Uint8Array): number {
20
+ let sum = 0
21
+ for (let index = 0; index < USTAR_BLOCK; index += 1)
22
+ sum += index >= 148 && index < 156 ? 0x20 : header[index]!
23
+ return sum
24
+ }
25
+
26
+ /** Whether a header's stored checksum field matches its bytes. */
27
+ export function ustarChecksumValid(header: Uint8Array): boolean {
28
+ return ustarNumber(header.subarray(148, 156)) === ustarChecksum(header)
29
+ }
@@ -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
+ }