@bun-native/cli 0.0.0-stage → 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.
- package/LICENSE +21 -0
- package/README.md +4 -2
- package/bun-native-source.json +131 -0
- package/package.json +33 -4
- package/src/.oxfmtrc.json +20 -0
- package/src/.oxlintrc.json +12 -0
- package/src/experiments/aot-gpui/compile.ts +41 -0
- package/src/experiments/aot-gpui/inspect-runtime.ts +303 -0
- package/src/experiments/aot-gpui/inspect.ts +65 -0
- package/src/scripts/native/artifact.ts +32 -0
- package/src/scripts/native/build.ts +250 -0
- package/src/scripts/native/doctor.ts +68 -0
- package/src/scripts/native/host-download.ts +139 -0
- package/src/scripts/native/host-header.ts +33 -0
- package/src/scripts/native/host.ts +22 -0
- package/src/scripts/native/inputs.ts +65 -0
- package/src/scripts/native/lint.ts +43 -0
- package/src/scripts/native/package-archive.ts +103 -0
- package/src/scripts/native/package-lock.ts +220 -0
- package/src/scripts/native/package-set.ts +408 -0
- package/src/scripts/native/paths.ts +20 -0
- package/src/scripts/native/project.ts +251 -0
- package/src/scripts/native/quality.ts +61 -0
- package/src/scripts/native/session.ts +296 -0
- package/src/scripts/native/standalone.ts +83 -0
- package/src/scripts/native/watch.ts +104 -0
- package/src/scripts/native.ts +219 -0
- package/src/scripts/solid/transform.ts +16 -0
- package/src/scripts/solid-build.ts +43 -0
- package/src/toolchain.json +64 -0
- package/src/tsconfig.json +45 -0
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { readdir, realpath, stat } from 'node:fs/promises'
|
|
3
|
+
import { dirname, join } from 'node:path'
|
|
4
|
+
import { isDeepStrictEqual } from 'node:util'
|
|
5
|
+
import toolchain from '../../toolchain.json'
|
|
6
|
+
import {
|
|
7
|
+
dependencyClosure,
|
|
8
|
+
lockedPackages,
|
|
9
|
+
localArchiveLock,
|
|
10
|
+
verifyConsumerLock,
|
|
11
|
+
type DependencyLock,
|
|
12
|
+
} from './package-lock.ts'
|
|
13
|
+
import { inside, packageArchivePath, packagePath } from './paths.ts'
|
|
14
|
+
import { fileDigest, type FileDigest } from './artifact.ts'
|
|
15
|
+
import { verifyPackageArchive } from './package-archive.ts'
|
|
16
|
+
|
|
17
|
+
export const packageNames = [
|
|
18
|
+
'@bun-native/cli',
|
|
19
|
+
'@bun-native/core',
|
|
20
|
+
'@bun-native/protocol',
|
|
21
|
+
'@bun-native/react',
|
|
22
|
+
'@bun-native/renderer',
|
|
23
|
+
'@bun-native/runtime',
|
|
24
|
+
'@bun-native/solid',
|
|
25
|
+
] as const
|
|
26
|
+
|
|
27
|
+
export type PackageManifest = {
|
|
28
|
+
name: string
|
|
29
|
+
version: string
|
|
30
|
+
private: true
|
|
31
|
+
type: 'module'
|
|
32
|
+
license: 'MIT'
|
|
33
|
+
files: string[]
|
|
34
|
+
engines: { bun: string }
|
|
35
|
+
dependencies?: Record<string, string>
|
|
36
|
+
exports?: string | Record<string, string>
|
|
37
|
+
bin?: Record<string, string>
|
|
38
|
+
}
|
|
39
|
+
export type LocalPackage = {
|
|
40
|
+
manifest: PackageManifest
|
|
41
|
+
archive: FileDigest & { file: string }
|
|
42
|
+
files: Record<string, FileDigest>
|
|
43
|
+
}
|
|
44
|
+
export type Consumer = {
|
|
45
|
+
dependencies: Record<string, string>
|
|
46
|
+
devDependencies: Record<string, string>
|
|
47
|
+
lock: DependencyLock
|
|
48
|
+
}
|
|
49
|
+
export type PackageSet = {
|
|
50
|
+
schemaVersion: 1
|
|
51
|
+
source: { commit: string; tree: string; files: Record<string, FileDigest> }
|
|
52
|
+
toolchain: typeof toolchain
|
|
53
|
+
compiler: { revision: string; os: string; architecture: string; executable: FileDigest }
|
|
54
|
+
packages: Record<string, LocalPackage>
|
|
55
|
+
consumers: { solid: Consumer; react: Consumer }
|
|
56
|
+
}
|
|
57
|
+
export type ReadPackageSet = { directory: string; manifest: PackageSet; text: string }
|
|
58
|
+
|
|
59
|
+
function object(value: unknown): Record<string, unknown> {
|
|
60
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
61
|
+
throw new Error('Invalid package-set object')
|
|
62
|
+
return value as Record<string, unknown>
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function keys(value: Record<string, unknown>, required: string[], optional: string[] = []): void {
|
|
66
|
+
if (
|
|
67
|
+
required.some((key) => !Object.hasOwn(value, key)) ||
|
|
68
|
+
Object.keys(value).some((key) => !required.includes(key) && !optional.includes(key))
|
|
69
|
+
)
|
|
70
|
+
throw new Error('Unexpected or missing package-set fields')
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function digest(value: unknown): FileDigest {
|
|
74
|
+
const fields = object(value)
|
|
75
|
+
if (
|
|
76
|
+
!Number.isSafeInteger(fields['bytes']) ||
|
|
77
|
+
(fields['bytes'] as number) < 0 ||
|
|
78
|
+
typeof fields['sha256'] !== 'string' ||
|
|
79
|
+
!/^[a-f0-9]{64}$/.test(fields['sha256'])
|
|
80
|
+
)
|
|
81
|
+
throw new Error('Invalid package-set file digest')
|
|
82
|
+
return { bytes: fields['bytes'] as number, sha256: fields['sha256'] }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function files(value: unknown): Record<string, FileDigest> {
|
|
86
|
+
const result: Record<string, FileDigest> = Object.create(null)
|
|
87
|
+
for (const [path, entry] of Object.entries(object(value))) {
|
|
88
|
+
if (!packagePath(path)) throw new Error(`Invalid package-set file path: ${path}`)
|
|
89
|
+
keys(object(entry), ['bytes', 'sha256'])
|
|
90
|
+
result[path] = digest(entry)
|
|
91
|
+
}
|
|
92
|
+
return result
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function versions(value: unknown): Record<string, string> {
|
|
96
|
+
const result: Record<string, string> = Object.create(null)
|
|
97
|
+
for (const [name, version] of Object.entries(object(value))) {
|
|
98
|
+
if (
|
|
99
|
+
!/^(?:@[a-z0-9._-]+\/)?[a-z0-9._-]+$/.test(name) ||
|
|
100
|
+
typeof version !== 'string' ||
|
|
101
|
+
!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(version)
|
|
102
|
+
)
|
|
103
|
+
throw new Error(`Package-set dependency must have an exact version: ${name}`)
|
|
104
|
+
result[name] = version
|
|
105
|
+
}
|
|
106
|
+
return result
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function exportedPaths(value: unknown): string[] {
|
|
110
|
+
return typeof value === 'string'
|
|
111
|
+
? [value]
|
|
112
|
+
: Object.values(object(value)).map((entry) => {
|
|
113
|
+
if (typeof entry !== 'string') throw new Error('Invalid package export/bin path')
|
|
114
|
+
return entry
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function packageManifest(
|
|
119
|
+
value: unknown,
|
|
120
|
+
name: string,
|
|
121
|
+
contents: Record<string, FileDigest>
|
|
122
|
+
): PackageManifest {
|
|
123
|
+
const manifest = object(value)
|
|
124
|
+
keys(
|
|
125
|
+
manifest,
|
|
126
|
+
['name', 'version', 'private', 'type', 'license', 'files', 'engines'],
|
|
127
|
+
['dependencies', 'exports', 'bin']
|
|
128
|
+
)
|
|
129
|
+
if (
|
|
130
|
+
manifest['name'] !== name ||
|
|
131
|
+
manifest['private'] !== true ||
|
|
132
|
+
manifest['type'] !== 'module' ||
|
|
133
|
+
manifest['license'] !== 'MIT' ||
|
|
134
|
+
!isDeepStrictEqual(manifest['engines'], { bun: toolchain.bun.version }) ||
|
|
135
|
+
!Array.isArray(manifest['files']) ||
|
|
136
|
+
manifest['files'].some((path) => !packageArchivePath(path))
|
|
137
|
+
)
|
|
138
|
+
throw new Error(`Invalid materialized package manifest: ${name}`)
|
|
139
|
+
versions({ [name]: manifest['version'] })
|
|
140
|
+
if (manifest['dependencies'] !== undefined) versions(manifest['dependencies'])
|
|
141
|
+
for (const field of ['exports', 'bin']) {
|
|
142
|
+
if (manifest[field] === undefined) continue
|
|
143
|
+
for (const exported of exportedPaths(manifest[field])) {
|
|
144
|
+
const path = exported.replace(/^\.\//, '')
|
|
145
|
+
if (!packagePath(path) || !Object.hasOwn(contents, path))
|
|
146
|
+
throw new Error(`Unpackaged ${field} target: ${name} ${exported}`)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (
|
|
150
|
+
!Object.hasOwn(contents, 'LICENSE') ||
|
|
151
|
+
!Object.hasOwn(contents, 'bun-native-source.json') ||
|
|
152
|
+
!Object.hasOwn(contents, 'package.json') ||
|
|
153
|
+
(name === '@bun-native/cli'
|
|
154
|
+
? !isDeepStrictEqual(manifest['bin'], { 'bun-native': './src/scripts/native.ts' }) ||
|
|
155
|
+
manifest['exports'] !== undefined
|
|
156
|
+
: manifest['exports'] === undefined || manifest['bin'] !== undefined)
|
|
157
|
+
)
|
|
158
|
+
throw new Error(`Missing package entry/license/provenance: ${name}`)
|
|
159
|
+
const text = JSON.stringify(manifest, null, 2) + '\n'
|
|
160
|
+
if (
|
|
161
|
+
!isDeepStrictEqual(contents['package.json'], {
|
|
162
|
+
bytes: Buffer.byteLength(text),
|
|
163
|
+
sha256: createHash('sha256').update(text).digest('hex'),
|
|
164
|
+
})
|
|
165
|
+
)
|
|
166
|
+
throw new Error(`Package manifest digest mismatch: ${name}`)
|
|
167
|
+
return manifest as PackageManifest
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function requiredPackages(set: PackageSet, framework: 'solid' | 'react'): string[] {
|
|
171
|
+
const pending = ['@bun-native/cli', '@bun-native/core', `@bun-native/${framework}`]
|
|
172
|
+
const required = new Set<string>()
|
|
173
|
+
for (let index = 0; index < pending.length; index += 1) {
|
|
174
|
+
const name = pending[index]!
|
|
175
|
+
if (required.has(name)) continue
|
|
176
|
+
const pkg = set.packages[name]
|
|
177
|
+
if (!pkg) throw new Error(`Missing local package: ${name}`)
|
|
178
|
+
required.add(name)
|
|
179
|
+
for (const [dependency, version] of Object.entries(pkg.manifest.dependencies ?? {})) {
|
|
180
|
+
if (!dependency.startsWith('@bun-native/')) continue
|
|
181
|
+
if (set.packages[dependency]?.manifest.version !== version)
|
|
182
|
+
throw new Error(`Inconsistent internal dependency: ${name} -> ${dependency}`)
|
|
183
|
+
pending.push(dependency)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return [...required].sort()
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function consumerArchiveLock(
|
|
190
|
+
set: PackageSet,
|
|
191
|
+
framework: 'solid' | 'react',
|
|
192
|
+
archiveDirectory = 'vendor/bun-native'
|
|
193
|
+
) {
|
|
194
|
+
return localArchiveLock(
|
|
195
|
+
requiredPackages(set, framework).map((name) => {
|
|
196
|
+
const pkg = set.packages[name]!
|
|
197
|
+
return {
|
|
198
|
+
name,
|
|
199
|
+
file: `${archiveDirectory ? archiveDirectory + '/' : ''}${pkg.archive.file}`,
|
|
200
|
+
sha256: pkg.archive.sha256,
|
|
201
|
+
...(pkg.manifest.dependencies ? { dependencies: pkg.manifest.dependencies } : {}),
|
|
202
|
+
...(pkg.manifest.bin ? { bin: pkg.manifest.bin } : {}),
|
|
203
|
+
}
|
|
204
|
+
})
|
|
205
|
+
)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Validates data only; archives are hashed separately before any destination is written. */
|
|
209
|
+
export function parsePackageSet(value: unknown): PackageSet {
|
|
210
|
+
const fields = object(value)
|
|
211
|
+
keys(fields, ['schemaVersion', 'source', 'toolchain', 'compiler', 'packages', 'consumers'])
|
|
212
|
+
if (fields['schemaVersion'] !== 1 || !isDeepStrictEqual(fields['toolchain'], toolchain))
|
|
213
|
+
throw new Error('Package set requires the exact pinned Bun Native toolchain')
|
|
214
|
+
const source = object(fields['source'])
|
|
215
|
+
keys(source, ['commit', 'tree', 'files'])
|
|
216
|
+
if (
|
|
217
|
+
![source['commit'], source['tree']].every(
|
|
218
|
+
(value) => typeof value === 'string' && /^[a-f0-9]{40}$/.test(value)
|
|
219
|
+
)
|
|
220
|
+
)
|
|
221
|
+
throw new Error('Package set requires exact source commit and tree identities')
|
|
222
|
+
files(source['files'])
|
|
223
|
+
const compiler = object(fields['compiler'])
|
|
224
|
+
keys(compiler, ['revision', 'os', 'architecture', 'executable'])
|
|
225
|
+
if (
|
|
226
|
+
compiler['revision'] !== toolchain.bun.revision ||
|
|
227
|
+
typeof compiler['os'] !== 'string' ||
|
|
228
|
+
typeof compiler['architecture'] !== 'string'
|
|
229
|
+
)
|
|
230
|
+
throw new Error('Invalid package producer compiler identity')
|
|
231
|
+
digest(compiler['executable'])
|
|
232
|
+
const packages = object(fields['packages'])
|
|
233
|
+
keys(packages, [...packageNames])
|
|
234
|
+
const archives = new Set<string>()
|
|
235
|
+
for (const [name, entry] of Object.entries(packages)) {
|
|
236
|
+
const pkg = object(entry)
|
|
237
|
+
keys(pkg, ['manifest', 'archive', 'files'])
|
|
238
|
+
const contents = files(pkg['files'])
|
|
239
|
+
if (Object.keys(contents).some((path) => !packageArchivePath(path)))
|
|
240
|
+
throw new Error(`Source archive contains reserved installer metadata: ${name}`)
|
|
241
|
+
packageManifest(pkg['manifest'], name, contents)
|
|
242
|
+
const archive = object(pkg['archive'])
|
|
243
|
+
keys(archive, ['file', 'bytes', 'sha256'])
|
|
244
|
+
digest(archive)
|
|
245
|
+
if (
|
|
246
|
+
!packagePath(archive['file']) ||
|
|
247
|
+
!/^archives\/[a-z0-9._-]+\.tgz$/.test(archive['file']) ||
|
|
248
|
+
archives.has(archive['file'])
|
|
249
|
+
)
|
|
250
|
+
throw new Error('Package archives must have unique contained relative paths')
|
|
251
|
+
archives.add(archive['file'])
|
|
252
|
+
}
|
|
253
|
+
const consumers = object(fields['consumers'])
|
|
254
|
+
keys(consumers, ['solid', 'react'])
|
|
255
|
+
const set = fields as PackageSet
|
|
256
|
+
for (const framework of ['solid', 'react'] as const) {
|
|
257
|
+
const consumer = object(consumers[framework])
|
|
258
|
+
keys(consumer, ['dependencies', 'devDependencies', 'lock'])
|
|
259
|
+
const runtime = versions(consumer['dependencies'])
|
|
260
|
+
const development = versions(consumer['devDependencies'])
|
|
261
|
+
if (
|
|
262
|
+
!runtime['@bun-native/core'] ||
|
|
263
|
+
!runtime[`@bun-native/${framework}`] ||
|
|
264
|
+
!runtime[framework === 'solid' ? 'solid-js' : 'react'] ||
|
|
265
|
+
!development['@bun-native/cli'] ||
|
|
266
|
+
!development['@types/bun'] ||
|
|
267
|
+
(framework === 'react' &&
|
|
268
|
+
(!development['@types/react'] || !development['@types/react-reconciler']))
|
|
269
|
+
)
|
|
270
|
+
throw new Error(`Missing required consumer dependencies for ${framework}`)
|
|
271
|
+
const dependencies = { ...runtime, ...development }
|
|
272
|
+
const external: Record<string, string> = Object.create(null)
|
|
273
|
+
for (const name of requiredPackages(set, framework)) {
|
|
274
|
+
for (const [dependency, version] of Object.entries(
|
|
275
|
+
set.packages[name]!.manifest.dependencies ?? {}
|
|
276
|
+
)) {
|
|
277
|
+
if (dependencies[dependency] !== undefined && dependencies[dependency] !== version)
|
|
278
|
+
throw new Error(`Inconsistent consumer dependency: ${dependency}`)
|
|
279
|
+
dependencies[dependency] = version
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
for (const [name, version] of Object.entries(dependencies)) {
|
|
283
|
+
if (name.startsWith('@bun-native/')) {
|
|
284
|
+
if (set.packages[name]?.manifest.version !== version)
|
|
285
|
+
throw new Error(`Unknown local dependency: ${name}`)
|
|
286
|
+
} else external[name] = version
|
|
287
|
+
}
|
|
288
|
+
const lock = lockedPackages(consumer['lock'])
|
|
289
|
+
if (
|
|
290
|
+
!isDeepStrictEqual(consumer['lock'], lock) ||
|
|
291
|
+
!isDeepStrictEqual(lock, dependencyClosure(lock, external))
|
|
292
|
+
)
|
|
293
|
+
throw new Error(`Unexpected dependency-lock entries for ${framework}`)
|
|
294
|
+
}
|
|
295
|
+
return set
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async function containedFile(directory: string, path: string): Promise<string> {
|
|
299
|
+
const target = await realpath(join(directory, path))
|
|
300
|
+
if (!inside(directory, target) || !(await stat(target)).isFile())
|
|
301
|
+
throw new Error(`Package file escapes its owned directory or is not a file: ${path}`)
|
|
302
|
+
return target
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export async function readPackageSet(path: string): Promise<ReadPackageSet> {
|
|
306
|
+
if (Bun.revision !== toolchain.bun.revision)
|
|
307
|
+
throw new Error('Use the pinned Bun for this package set')
|
|
308
|
+
const filename = await realpath(path)
|
|
309
|
+
const directory = dirname(filename)
|
|
310
|
+
const text = await Bun.file(filename).text()
|
|
311
|
+
const manifest = parsePackageSet(JSON.parse(text))
|
|
312
|
+
if (text !== JSON.stringify(manifest, null, 2) + '\n')
|
|
313
|
+
throw new Error('Package-set manifest must use its original canonical JSON encoding')
|
|
314
|
+
for (const [name, pkg] of Object.entries(manifest.packages)) {
|
|
315
|
+
const file = await containedFile(directory, pkg.archive.file)
|
|
316
|
+
if (!isDeepStrictEqual(await fileDigest(file), digest(pkg.archive)))
|
|
317
|
+
throw new Error(`Package archive digest mismatch: ${name}`)
|
|
318
|
+
await verifyPackageArchive(file, pkg.files)
|
|
319
|
+
}
|
|
320
|
+
return { directory, manifest, text }
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Validate the installed CLI too, before its lazy compiler/watch modules are imported. */
|
|
324
|
+
export async function verifyInstalledPackages(
|
|
325
|
+
directory: string,
|
|
326
|
+
set: PackageSet,
|
|
327
|
+
framework: 'solid' | 'react',
|
|
328
|
+
archiveDirectory = 'vendor/bun-native'
|
|
329
|
+
): Promise<void> {
|
|
330
|
+
let modules: string
|
|
331
|
+
try {
|
|
332
|
+
modules = await realpath(join(directory, 'node_modules'))
|
|
333
|
+
} catch (error) {
|
|
334
|
+
if (error instanceof Error && 'code' in error && error.code === 'ENOENT')
|
|
335
|
+
throw new Error(
|
|
336
|
+
"Standalone dependencies are not installed; follow this project's pinned offline-install instructions"
|
|
337
|
+
)
|
|
338
|
+
throw error
|
|
339
|
+
}
|
|
340
|
+
if (!inside(directory, modules))
|
|
341
|
+
throw new Error('Installed packages must belong to this consumer')
|
|
342
|
+
const required = requiredPackages(set, framework)
|
|
343
|
+
verifyConsumerLock(
|
|
344
|
+
Bun.JSONC.parse(await Bun.file(join(directory, 'bun.lock')).text()),
|
|
345
|
+
set.consumers[framework].lock,
|
|
346
|
+
true,
|
|
347
|
+
consumerArchiveLock(set, framework, archiveDirectory)
|
|
348
|
+
)
|
|
349
|
+
const locations = new Map<string, string>()
|
|
350
|
+
for (const name of required) {
|
|
351
|
+
const pkg = set.packages[name]!
|
|
352
|
+
const root = await realpath(join(modules, name))
|
|
353
|
+
if (!inside(modules, root))
|
|
354
|
+
throw new Error(`Installed package resolves outside this consumer: ${name}`)
|
|
355
|
+
locations.set(name, root)
|
|
356
|
+
if (Object.keys(pkg.files).some((path) => !packageArchivePath(path)))
|
|
357
|
+
throw new Error(`Source archive contains reserved installer metadata: ${name}`)
|
|
358
|
+
// Pinned Integrity::Display uses unpadded base64 followed by "==" for
|
|
359
|
+
// SHA-256. This is its 52-byte installer tag, not the canonical lock SRI.
|
|
360
|
+
const tag = `sha256-${Buffer.from(pkg.archive.sha256, 'hex').toString('base64').replace(/=+$/, '')}==`
|
|
361
|
+
const installedFiles: Record<string, FileDigest> = {
|
|
362
|
+
...pkg.files,
|
|
363
|
+
'.bun-tag': {
|
|
364
|
+
bytes: Buffer.byteLength(tag),
|
|
365
|
+
sha256: createHash('sha256').update(tag).digest('hex'),
|
|
366
|
+
},
|
|
367
|
+
}
|
|
368
|
+
const expected = Object.keys(installedFiles).sort()
|
|
369
|
+
const folders = new Set(
|
|
370
|
+
expected.flatMap((path) => {
|
|
371
|
+
const parts = path.split('/')
|
|
372
|
+
return parts.slice(1).map((_, index) => parts.slice(0, index + 1).join('/'))
|
|
373
|
+
})
|
|
374
|
+
)
|
|
375
|
+
const inventory: string[] = []
|
|
376
|
+
const visit = async (directory: string, prefix: string): Promise<void> => {
|
|
377
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
378
|
+
const path = prefix + entry.name
|
|
379
|
+
if (entry.isDirectory() && folders.has(path))
|
|
380
|
+
await visit(join(directory, entry.name), path + '/')
|
|
381
|
+
else if (entry.isFile() && Object.hasOwn(installedFiles, path)) inventory.push(path)
|
|
382
|
+
else throw new Error(`Installed package file inventory differs: ${name}/${path}`)
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
await visit(root, '')
|
|
386
|
+
if (!isDeepStrictEqual(inventory.sort(), expected))
|
|
387
|
+
throw new Error(`Installed package file inventory differs: ${name}`)
|
|
388
|
+
for (const [path, expected] of Object.entries(installedFiles)) {
|
|
389
|
+
const file = await containedFile(root, path)
|
|
390
|
+
if (!isDeepStrictEqual(await fileDigest(file), expected))
|
|
391
|
+
throw new Error(`Installed package digest mismatch: ${name}/${path}`)
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
for (const name of required) {
|
|
395
|
+
const root = locations.get(name)!
|
|
396
|
+
for (const dependency of Object.keys(set.packages[name]!.manifest.dependencies ?? {})) {
|
|
397
|
+
if (!dependency.startsWith('@bun-native/')) continue
|
|
398
|
+
const expected = locations.get(dependency)!
|
|
399
|
+
const resolved = await realpath(Bun.resolveSync(dependency, root))
|
|
400
|
+
const exported = set.packages[dependency]!.manifest.exports!
|
|
401
|
+
const entry = typeof exported === 'string' ? exported : exported['.']!
|
|
402
|
+
if (resolved !== (await realpath(join(expected, entry))))
|
|
403
|
+
throw new Error(
|
|
404
|
+
`Installed dependency resolves to a different package: ${name} -> ${dependency}`
|
|
405
|
+
)
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { isAbsolute, relative, sep } from 'node:path'
|
|
2
|
+
|
|
3
|
+
export function inside(parent: string, child: string): boolean {
|
|
4
|
+
const path = relative(parent, child)
|
|
5
|
+
return path !== '' && path !== '..' && !path.startsWith(`..${sep}`) && !isAbsolute(path)
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Package-set filenames are portable archive paths, never platform-specific paths. */
|
|
9
|
+
export function packagePath(value: unknown): value is string {
|
|
10
|
+
return (
|
|
11
|
+
typeof value === 'string' &&
|
|
12
|
+
/^[A-Za-z0-9_@./-]+$/.test(value) &&
|
|
13
|
+
value.split('/').every((part) => part !== '' && part !== '.' && part !== '..')
|
|
14
|
+
)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Installer-owned metadata must not originate in a source archive at any depth. */
|
|
18
|
+
export function packageArchivePath(value: unknown): value is string {
|
|
19
|
+
return packagePath(value) && !value.split('/').includes('.bun-tag')
|
|
20
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { mkdir, realpath, rm, stat, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
|
|
3
|
+
import { inside } from './paths.ts'
|
|
4
|
+
import { readPackageSet, verifyInstalledPackages } from './package-set.ts'
|
|
5
|
+
import { fileDigest, type FileDigest } from './artifact.ts'
|
|
6
|
+
import { copyPackageSet, standaloneFiles } from './standalone.ts'
|
|
7
|
+
|
|
8
|
+
export type Framework = 'solid' | 'react'
|
|
9
|
+
export type Language = 'typescript' | 'javascript'
|
|
10
|
+
export type Project = {
|
|
11
|
+
directory: string
|
|
12
|
+
framework: Framework
|
|
13
|
+
entry: string
|
|
14
|
+
packageSet?: { source: { commit: string; tree: string }; manifest: FileDigest }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function template(framework: Framework, language: Language): string {
|
|
18
|
+
const typed = language === 'typescript'
|
|
19
|
+
const solid = framework === 'solid'
|
|
20
|
+
const title = `Bun Native ${solid ? 'Solid' : 'React'}`
|
|
21
|
+
return `${solid ? '' : '/** @jsxImportSource @bun-native/react */\n'}import { createApplication, reportHealthy } from '@bun-native/core'
|
|
22
|
+
import { render${typed ? ', type JSX' : ''} } from '@bun-native/${framework}'
|
|
23
|
+
import { ${solid ? 'createSignal' : 'useState'} } from '${solid ? 'solid-js' : 'react'}'
|
|
24
|
+
|
|
25
|
+
const executable = process.env['BUN_NATIVE_HOST']
|
|
26
|
+
if (!executable)
|
|
27
|
+
throw new Error('Run the dev command with --host pointing to a prebuilt native host')
|
|
28
|
+
const application = await createApplication({ executable })
|
|
29
|
+
${typed ? `let view: ReturnType<typeof render> | undefined` : '/** @type {ReturnType<typeof render> | undefined} */\nlet view'}
|
|
30
|
+
const stopped = Promise.withResolvers${typed ? '<void>' : ''}()
|
|
31
|
+
const stop = () => stopped.resolve(${typed ? '' : 'undefined'})
|
|
32
|
+
${typed ? '' : '/** @param {unknown} message */\n'}function stopFromParent(message${typed ? ': unknown' : ''}) {
|
|
33
|
+
if (
|
|
34
|
+
message &&
|
|
35
|
+
typeof message === 'object' &&
|
|
36
|
+
'type' in message &&
|
|
37
|
+
message.type === 'bun-native:stop'
|
|
38
|
+
)
|
|
39
|
+
stop()
|
|
40
|
+
}
|
|
41
|
+
process.once('SIGINT', stop)
|
|
42
|
+
process.once('SIGTERM', stop)
|
|
43
|
+
process.on('message', stopFromParent)
|
|
44
|
+
|
|
45
|
+
function App()${typed ? ': JSX.Element' : ''} {
|
|
46
|
+
const [clicks, setClicks] = ${solid ? 'createSignal' : 'useState'}(0)
|
|
47
|
+
return (
|
|
48
|
+
<box style={{ direction: 'column', gap: 12, padding: 24 }}>
|
|
49
|
+
<text text="${title}" style={{ font_size: 28 }} />
|
|
50
|
+
<text text={'Clicks: ' + ${solid ? 'clicks()' : 'clicks'}} />
|
|
51
|
+
<button
|
|
52
|
+
text="Increment"
|
|
53
|
+
style={{ width: 160, height: 44 }}
|
|
54
|
+
onClick={() => {
|
|
55
|
+
setClicks((value) => value + 1)
|
|
56
|
+
}}
|
|
57
|
+
/>
|
|
58
|
+
</box>
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
const window = await application.openWindow({
|
|
64
|
+
title: '${title}',
|
|
65
|
+
width: 640,
|
|
66
|
+
height: 480,
|
|
67
|
+
})
|
|
68
|
+
view = render(${solid ? 'App' : '<App />'}, window)
|
|
69
|
+
await view.ready
|
|
70
|
+
// Confirms a pending update when the installed app was started by the verifying launcher.
|
|
71
|
+
await reportHealthy()
|
|
72
|
+
process.send?.({ type: 'bun-native:ready' })
|
|
73
|
+
await Promise.race([window.closed, view.closed, stopped.promise])
|
|
74
|
+
} finally {
|
|
75
|
+
process.off('SIGINT', stop)
|
|
76
|
+
process.off('SIGTERM', stop)
|
|
77
|
+
process.off('message', stopFromParent)
|
|
78
|
+
try {
|
|
79
|
+
await view?.dispose()
|
|
80
|
+
} finally {
|
|
81
|
+
await application.shutdown()
|
|
82
|
+
await application.closed
|
|
83
|
+
process.send?.({
|
|
84
|
+
type: 'bun-native:stopped',
|
|
85
|
+
application: application.diagnostics,
|
|
86
|
+
nodes: view?.diagnostics.nodes ?? 0,
|
|
87
|
+
})
|
|
88
|
+
if (process.connected) process.disconnect?.()
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
`
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function readme(name: string, framework: Framework, guide: string): string {
|
|
95
|
+
const solid = framework === 'solid'
|
|
96
|
+
const rules = solid
|
|
97
|
+
? `This app uses the Solid adapter (\`@bun-native/solid\`). Components run once and signals update only the native nodes that read them. Pass the component itself to \`render(App, window)\`. \`solid-js/web\` and DOM components are not available.`
|
|
98
|
+
: `This app uses the React adapter (\`@bun-native/react\`). Hooks, refs, effect cleanup and error boundaries work as in React. Pass an element to \`render(<App />, window)\`. \`react-dom\` and DOM components are not available.`
|
|
99
|
+
return `# ${name}
|
|
100
|
+
|
|
101
|
+
This project uses the surrounding Bun Native checkout and its pinned dependencies.
|
|
102
|
+
Run \`bun install --frozen-lockfile\` in the checkout first; no Rust compiler is needed when you supply a prebuilt host.
|
|
103
|
+
|
|
104
|
+
- \`bun run check\` checks TypeScript or checked JavaScript.
|
|
105
|
+
- \`bun run format\` applies Bun's built-in formatter; \`bun run format:check\` only reports differences.
|
|
106
|
+
- \`bun run lint\` runs the checkout's pinned oxlint with the checkout's lint rules, or this project's own \`.oxlintrc.json\`.
|
|
107
|
+
- \`bun run dev --host /absolute/path/to/bun-native-host\` bundles and opens the app once. Close the window or press Ctrl-C to end it.
|
|
108
|
+
- Add \`--watch\` to remount after source edits with joined teardown. State resets on every successful edit; state-preserving hot reload is not implemented.
|
|
109
|
+
- \`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.
|
|
110
|
+
|
|
111
|
+
${rules} Build the interface from native elements such as \`box\`, \`text\`, \`button\` and \`input\`; CSS selectors and browser events do not apply.
|
|
112
|
+
|
|
113
|
+
Keep the template's optional development IPC hooks: they report readiness and clean up the renderer and owned host before a replacement starts. The host is supplied explicitly and never downloaded or rebuilt.
|
|
114
|
+
|
|
115
|
+
See the checkout's [getting-started guide](${guide}) for the ${solid ? 'SolidJS' : 'React'} walkthrough, debugging and upgrade steps, and \`docs/developer-tooling.md\` for requirements and limitations.
|
|
116
|
+
`
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function createProject(
|
|
120
|
+
repository: string,
|
|
121
|
+
destination: string,
|
|
122
|
+
framework: Framework,
|
|
123
|
+
language: Language,
|
|
124
|
+
packages?: string
|
|
125
|
+
): Promise<string> {
|
|
126
|
+
const set = packages === undefined ? undefined : await readPackageSet(packages)
|
|
127
|
+
const root = await realpath(repository)
|
|
128
|
+
const directory = resolve(destination)
|
|
129
|
+
if (!set && !inside(root, directory))
|
|
130
|
+
throw new Error('Create the project inside this Bun Native checkout')
|
|
131
|
+
// Resolve existing parents before writing, so a symlink cannot escape the checkout.
|
|
132
|
+
let ancestor = dirname(directory)
|
|
133
|
+
let resolvedAncestor: string | undefined
|
|
134
|
+
while (resolvedAncestor === undefined) {
|
|
135
|
+
try {
|
|
136
|
+
resolvedAncestor = await realpath(ancestor)
|
|
137
|
+
} catch (error) {
|
|
138
|
+
if (!(error instanceof Error) || !('code' in error) || error.code !== 'ENOENT') throw error
|
|
139
|
+
const parent = dirname(ancestor)
|
|
140
|
+
if (parent === ancestor) throw error
|
|
141
|
+
ancestor = parent
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (!set && resolvedAncestor !== root && !inside(root, resolvedAncestor))
|
|
145
|
+
throw new Error('Project parent resolves outside this Bun Native checkout')
|
|
146
|
+
await mkdir(dirname(directory), { recursive: true })
|
|
147
|
+
const parent = await realpath(dirname(directory))
|
|
148
|
+
if (!set && parent !== root && !inside(root, parent))
|
|
149
|
+
throw new Error('Project parent resolves outside this Bun Native checkout')
|
|
150
|
+
await mkdir(directory) // Existing directories, including empty ones, are never overwritten.
|
|
151
|
+
try {
|
|
152
|
+
const entry = language === 'typescript' ? 'main.tsx' : 'main.jsx'
|
|
153
|
+
const cli = relative(directory, join(root, 'scripts/native.ts')).split(sep).join('/')
|
|
154
|
+
const tsconfig = relative(directory, join(root, 'tsconfig.json')).split(sep).join('/')
|
|
155
|
+
const guide = relative(directory, join(root, 'docs/getting-started.md')).split(sep).join('/')
|
|
156
|
+
const files: Record<string, string> = {
|
|
157
|
+
'bun-native.json': JSON.stringify({ schemaVersion: 1, framework, entry }, null, 2) + '\n',
|
|
158
|
+
'package.json':
|
|
159
|
+
JSON.stringify(
|
|
160
|
+
{
|
|
161
|
+
name: basename(directory),
|
|
162
|
+
private: true,
|
|
163
|
+
type: 'module',
|
|
164
|
+
scripts: {
|
|
165
|
+
// See docs/developer-tooling.md "Controller memory".
|
|
166
|
+
dev: `MIMALLOC_PURGE_DELAY=0 bun ${JSON.stringify(cli)} dev .`,
|
|
167
|
+
build: `bun ${JSON.stringify(cli)} build .`,
|
|
168
|
+
check: 'bun check --no-script --no-pretty',
|
|
169
|
+
format: `bun ${JSON.stringify(cli)} format .`,
|
|
170
|
+
'format:check': `bun ${JSON.stringify(cli)} format . --check`,
|
|
171
|
+
lint: `bun ${JSON.stringify(cli)} lint .`,
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
null,
|
|
175
|
+
2
|
|
176
|
+
) + '\n',
|
|
177
|
+
'tsconfig.json': `{
|
|
178
|
+
"extends": ${JSON.stringify(tsconfig)},
|
|
179
|
+
"compilerOptions": {
|
|
180
|
+
"jsxImportSource": "@bun-native/${framework}"
|
|
181
|
+
},
|
|
182
|
+
"include": [${JSON.stringify(entry)}],
|
|
183
|
+
"exclude": ["dist", ".bun-native"]
|
|
184
|
+
}
|
|
185
|
+
`,
|
|
186
|
+
'.gitignore': 'dist/\n.bun-native/\n',
|
|
187
|
+
'README.md': readme(basename(directory), framework, guide),
|
|
188
|
+
[entry]: template(framework, language),
|
|
189
|
+
}
|
|
190
|
+
if (set) {
|
|
191
|
+
Object.assign(files, standaloneFiles(set, framework, entry, basename(directory)))
|
|
192
|
+
await copyPackageSet(set, directory)
|
|
193
|
+
}
|
|
194
|
+
for (const [name, content] of Object.entries(files))
|
|
195
|
+
await writeFile(join(directory, name), content, { flag: 'wx' })
|
|
196
|
+
return directory
|
|
197
|
+
} catch (error) {
|
|
198
|
+
await rm(directory, { recursive: true, force: true })
|
|
199
|
+
throw error
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export async function readProject(repository: string, path: string): Promise<Project> {
|
|
204
|
+
const root = await realpath(repository)
|
|
205
|
+
const directory = await realpath(path)
|
|
206
|
+
const value: unknown = await Bun.file(join(directory, 'bun-native.json')).json()
|
|
207
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
208
|
+
throw new Error('Invalid bun-native.json project configuration')
|
|
209
|
+
const config = value as Record<string, unknown>
|
|
210
|
+
if (
|
|
211
|
+
Object.keys(config).some(
|
|
212
|
+
(key) => !['schemaVersion', 'framework', 'entry', 'packageSet'].includes(key)
|
|
213
|
+
) ||
|
|
214
|
+
config['schemaVersion'] !== 1 ||
|
|
215
|
+
(config['framework'] !== 'solid' && config['framework'] !== 'react') ||
|
|
216
|
+
typeof config['entry'] !== 'string' ||
|
|
217
|
+
isAbsolute(config['entry']) ||
|
|
218
|
+
!/\.[jt]sx?$/.test(config['entry'])
|
|
219
|
+
)
|
|
220
|
+
throw new Error(
|
|
221
|
+
'bun-native.json requires schemaVersion 1, framework solid|react and a relative TS/JS entry'
|
|
222
|
+
)
|
|
223
|
+
const entry = await realpath(join(directory, config['entry']))
|
|
224
|
+
if (!inside(directory, entry) || !(await stat(entry)).isFile())
|
|
225
|
+
throw new Error('Project entry must be a source file inside the project')
|
|
226
|
+
if (config['packageSet'] !== undefined) {
|
|
227
|
+
if (typeof config['packageSet'] !== 'string' || isAbsolute(config['packageSet']))
|
|
228
|
+
throw new Error('Project packageSet must be a relative manifest path inside the project')
|
|
229
|
+
const manifestPath = await realpath(join(directory, config['packageSet']))
|
|
230
|
+
if (!inside(directory, manifestPath))
|
|
231
|
+
throw new Error('Project packageSet resolves outside the project')
|
|
232
|
+
const set = await readPackageSet(manifestPath)
|
|
233
|
+
await verifyInstalledPackages(
|
|
234
|
+
directory,
|
|
235
|
+
set.manifest,
|
|
236
|
+
config['framework'],
|
|
237
|
+
relative(directory, set.directory).split(sep).join('/')
|
|
238
|
+
)
|
|
239
|
+
return {
|
|
240
|
+
directory,
|
|
241
|
+
framework: config['framework'],
|
|
242
|
+
entry,
|
|
243
|
+
packageSet: {
|
|
244
|
+
source: { commit: set.manifest.source.commit, tree: set.manifest.source.tree },
|
|
245
|
+
manifest: await fileDigest(manifestPath),
|
|
246
|
+
},
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
if (!inside(root, directory)) throw new Error('Project must be inside this Bun Native checkout')
|
|
250
|
+
return { directory, framework: config['framework'], entry }
|
|
251
|
+
}
|