@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.
- package/LICENSE +21 -0
- package/README.md +4 -2
- package/bun-native-source.json +151 -0
- package/package.json +63 -4
- package/src/.oxfmtrc.json +20 -0
- package/src/.oxlintrc.json +12 -0
- package/src/experiments/aot-gpui/compile.ts +44 -0
- package/src/experiments/aot-gpui/inspect-runtime.ts +304 -0
- package/src/experiments/aot-gpui/inspect.ts +66 -0
- package/src/scripts/native/artifact.ts +73 -0
- package/src/scripts/native/build.ts +231 -0
- package/src/scripts/native/bundle.ts +121 -0
- package/src/scripts/native/consumer-files.ts +46 -0
- package/src/scripts/native/context.ts +95 -0
- package/src/scripts/native/doctor.ts +69 -0
- package/src/scripts/native/host-download.ts +179 -0
- package/src/scripts/native/host-header.ts +33 -0
- package/src/scripts/native/host.ts +22 -0
- package/src/scripts/native/inputs.ts +95 -0
- package/src/scripts/native/lint.ts +43 -0
- package/src/scripts/native/package-archive.ts +94 -0
- package/src/scripts/native/package-lock.ts +220 -0
- package/src/scripts/native/package-set.ts +417 -0
- package/src/scripts/native/package-stamp.ts +209 -0
- package/src/scripts/native/paths.ts +20 -0
- package/src/scripts/native/project.ts +271 -0
- package/src/scripts/native/quality.ts +76 -0
- package/src/scripts/native/registry-project.ts +90 -0
- package/src/scripts/native/session.ts +299 -0
- package/src/scripts/native/standalone.ts +64 -0
- package/src/scripts/native/ustar.ts +29 -0
- package/src/scripts/native/watch.ts +104 -0
- package/src/scripts/native.ts +237 -0
- package/src/scripts/solid/transform.ts +16 -0
- package/src/toolchain.json +74 -0
- 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
|
+
}
|