11import { once } from 'node:events'
22import { createWriteStream , type WriteStream } from 'node:fs'
3- import { lstat , mkdtemp , open , readlink , rename , rm } from 'node:fs/promises'
3+ import { link , lstat , mkdtemp , readlink , rename , rm } from 'node:fs/promises'
44import { dirname , join , resolve } from 'node:path'
55import { Readable , type Writable } from 'node:stream'
66import { pipeline } from 'node:stream/promises'
@@ -60,6 +60,15 @@ function combinedCleanupFailure(
6060 )
6161}
6262
63+ function unsupportedAtomicPublish ( target : string , error : unknown ) : SimApiError | null {
64+ const code = ( error as NodeJS . ErrnoException ) . code
65+ if ( ! [ 'ENOSYS' , 'ENOTSUP' , 'EOPNOTSUPP' , 'EPERM' ] . includes ( code ?? '' ) ) return null
66+ return new SimApiError (
67+ `Could not publish ${ target } without overwrite protection because this filesystem does not support atomic hard links. Re-run with --force to publish the completed download with an atomic rename.` ,
68+ 0
69+ )
70+ }
71+
6372/** Streams a fetch body to disk while honoring write-stream backpressure. */
6473export async function streamToFile (
6574 body : ReadableStream < Uint8Array > ,
@@ -73,36 +82,28 @@ export async function streamToFile(
7382 }
7483}
7584
76- async function saveNewFile ( body : ReadableStream < Uint8Array > , target : string ) : Promise < void > {
77- let created = false
78-
79- try {
80- const file = await open ( target , 'wx' )
81- created = true
82- await streamToFile ( body , file . createWriteStream ( ) , target )
83- } catch ( error ) {
84- const failure = normalizedWriteFailure ( target , error )
85- if ( ! created ) throw failure
86-
87- try {
88- await rm ( target , { force : true } )
89- } catch ( cleanupError ) {
90- throw combinedCleanupFailure ( failure , target , cleanupError )
91- }
92- throw failure
93- }
94- }
95-
96- async function saveForcedFile ( body : ReadableStream < Uint8Array > , target : string ) : Promise < void > {
85+ async function saveStagedFile (
86+ body : ReadableStream < Uint8Array > ,
87+ target : string ,
88+ force : boolean
89+ ) : Promise < void > {
9790 let temporaryDirectory : string | null = null
9891 let failure : SimApiError | null = null
9992
10093 try {
101- const publicationTarget = await forcedPublicationTarget ( target )
94+ const publicationTarget = force ? await forcedPublicationTarget ( target ) : target
10295 temporaryDirectory = await mkdtemp ( join ( dirname ( publicationTarget ) , '.sim-download-' ) )
10396 const temporaryPath = join ( temporaryDirectory , 'payload' )
10497 await streamToFile ( body , createWriteStream ( temporaryPath , { flags : 'wx' } ) , target )
105- await rename ( temporaryPath , publicationTarget )
98+ if ( force ) {
99+ await rename ( temporaryPath , publicationTarget )
100+ } else {
101+ try {
102+ await link ( temporaryPath , publicationTarget )
103+ } catch ( error ) {
104+ throw unsupportedAtomicPublish ( target , error ) ?? error
105+ }
106+ }
106107 } catch ( error ) {
107108 failure = normalizedWriteFailure ( target , error )
108109 }
@@ -122,14 +123,13 @@ async function saveForcedFile(body: ReadableStream<Uint8Array>, target: string):
122123 if ( failure ) throw failure
123124}
124125
125- /** Saves without overwriting by default; forced writes publish only after the body is complete . */
126+ /** Publishes a complete staged body atomically, with overwrite requiring explicit force . */
126127export async function saveToFile (
127128 body : ReadableStream < Uint8Array > ,
128129 target : string ,
129130 force : boolean
130131) : Promise < void > {
131- if ( force ) return saveForcedFile ( body , target )
132- return saveNewFile ( body , target )
132+ return saveStagedFile ( body , target , force )
133133}
134134
135135/** Streams a fetch body to stdout without closing the process-wide stream. */
0 commit comments