doesitarm/helpers/scanner/file-api.ts
ThatGuySam cd41143f0d refactor(scanner): type plist and file-api helpers
Convert the scanner's plist parser and Node-style file shim to TypeScript and add small unit tests so common parser and file-reader failures are caught before we need to lean on Playwright or the higher-level scanner test.

Constraint: Must preserve current scanner behavior while tightening the lowest-level helper surface
Rejected: Jump straight to Mach-O parser conversion | harder to isolate regressions without first proving the smaller helper-test pattern
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Add small module-level regression tests for repeatable scanner breakage before expanding browser coverage
Tested: pnpm run typecheck; pnpm exec vitest run test/scanner/plist.test.ts test/scanner/file-api.test.ts test/scanner/client.test.ts; pnpm run test; pnpm run test:browser
Not-tested: Production deploy behavior prior to push
2026-04-04 18:13:32 -05:00

337 lines
8.9 KiB
TypeScript

import { EventEmitter } from 'events'
type ReadFormat = 'buffer' | 'binary' | 'dataUrl' | 'text'
type FileInput = string | Partial<NodeFile>
type NodeBuffer = ReturnType<typeof Buffer.from>
type FileReaderEventName = 'abort' | 'error' | 'load' | 'loadend' | 'loadstart' | 'progress'
interface FileReaderProgressEvent {
lengthComputable: boolean
loaded: number
total?: number
}
export interface FileReaderLoadEvent {
target: {
nodeBufferResult: NodeBuffer
result: NodeBuffer | string
}
}
interface FileReaderErrorEvent {
target: {
error: Error
}
}
type FileReaderEventPayload = FileReaderProgressEvent | FileReaderLoadEvent | FileReaderErrorEvent | undefined
type FileReaderListener = ( event?: FileReaderEventPayload ) => void
interface NodeFileStat {
mtime: Date
size: number
}
type NodeFileStream = EventEmitter
export interface NodeFile {
blob?: Blob
buffer?: NodeBuffer
jsdom?: boolean
lastModifiedDate?: Date
name: string
path?: string
size?: number
stat?: NodeFileStat
stream?: NodeFileStream
type?: string
}
function invokeIfFunction ( listener: unknown, args: unknown[], context: unknown ) {
if ( typeof listener === 'function' ) {
listener.apply( context, args )
}
}
function toDataUrl ( data: NodeBuffer, type?: string ) {
let dataUrl = 'data:'
if ( type ) {
dataUrl += `${ type };`
}
if ( /text/i.test( type || '' ) ) {
dataUrl += 'charset=utf-8,'
dataUrl += data.toString( 'utf8' )
} else {
dataUrl += 'base64,'
dataUrl += data.toString( 'base64' )
}
return dataUrl
}
function mapDataToFormat ( file: NodeFile, data: NodeBuffer, format: ReadFormat, encoding?: BufferEncoding ) {
switch ( format ) {
case 'buffer':
return data
case 'binary':
return data.toString( 'binary' )
case 'dataUrl':
return toDataUrl( data, file.type )
case 'text':
return data.toString( encoding || 'utf8' )
}
}
export class File implements NodeFile {
blob?: Blob
buffer?: Buffer
jsdom?: boolean
lastModifiedDate?: Date
name: string
path?: string
size?: number
stat?: NodeFileStat
stream?: NodeFileStream
type?: string
constructor ( input: FileInput ) {
if ( typeof input === 'string' ) {
this.path = input
} else {
Object.assign( this, input )
}
if ( !this.name ) {
throw new Error( 'No name' )
}
if ( !this.path ) {
if ( this.buffer ) {
this.size = this.buffer.length
} else if ( !this.stream ) {
throw new Error( 'No input, nor stream, nor buffer.' )
}
return
}
if ( !this.jsdom ) {
return
}
}
}
export class FileReader {
readonly EMPTY = 0
readonly LOADING = 1
readonly DONE = 2
error?: Error
onabort?: FileReaderListener
onerror?: FileReaderListener
onload?: FileReaderListener
onloadend?: FileReaderListener
onloadstart?: FileReaderListener
onprogress?: FileReaderListener
readyState = this.EMPTY
result?: NodeBuffer | string
private readonly emitter = new EventEmitter()
private file?: NodeFile
private fileStream?: NodeFileStream
private format?: ReadFormat
private encoding?: BufferEncoding
private readonly registeredEvents = new Set<FileReaderEventName>()
nodeChunkedEncoding = false
addEventListener ( eventName: FileReaderEventName, callback: FileReaderListener ) {
this.emitter.on( eventName, callback )
}
removeEventListener ( eventName: FileReaderEventName, callback: FileReaderListener ) {
this.emitter.removeListener( eventName, callback )
}
dispatchEvent ( eventName: FileReaderEventName, payload?: FileReaderEventPayload ) {
this.emitter.emit( eventName, payload )
}
on ( eventName: string | symbol, listener: ( ...args: any[] ) => void ) {
this.emitter.on( eventName, listener )
}
setNodeChunkedEncoding ( value: boolean ) {
this.nodeChunkedEncoding = value
}
abort () {
if ( this.readyState === this.DONE ) {
return
}
this.readyState = this.DONE
this.dispatchEvent( 'abort' )
}
readAsArrayBuffer ( file: NodeFile ) {
this.readFile( file, 'buffer' )
}
readAsBinaryString ( file: NodeFile ) {
this.readFile( file, 'binary' )
}
readAsDataURL ( file: NodeFile ) {
this.readFile( file, 'dataUrl' )
}
readAsText ( file: NodeFile, encoding?: BufferEncoding ) {
this.readFile( file, 'text', encoding )
}
private createFileStream () {
if ( this.file?.stream ) {
this.fileStream = this.file.stream
return
}
if ( this.file?.buffer ) {
const stream = new EventEmitter() as NodeFileStream
process.nextTick( () => {
stream.emit( 'data', this.file!.buffer! )
stream.emit( 'end' )
} )
this.file!.stream = stream
this.fileStream = stream
}
}
private registerUserEvents () {
if ( this.registeredEvents.size > 0 ) {
return
}
const userEvents: Array<[ FileReaderEventName, 'onloadstart' | 'onprogress' | 'onload' | 'onloadend' | 'onabort' ]> = [
[ 'loadstart', 'onloadstart' ],
[ 'progress', 'onprogress' ],
[ 'load', 'onload' ],
[ 'loadend', 'onloadend' ],
[ 'abort', 'onabort' ]
]
for ( const [ eventName, propertyName ] of userEvents ) {
this.emitter.on( eventName, ( event?: FileReaderEventPayload ) => {
invokeIfFunction( this[ propertyName ], [ event ], this )
} )
this.registeredEvents.add( eventName )
}
this.emitter.on( 'error', ( event?: FileReaderEventPayload ) => {
if ( typeof this.onerror === 'function' ) {
this.onerror( event )
return
}
const error = ( event as FileReaderErrorEvent | undefined )?.target.error
if ( error && this.emitter.listenerCount( 'error' ) <= 1 ) {
throw error
}
} )
this.registeredEvents.add( 'error' )
}
private mapStreamToEmitter () {
const stream = this.fileStream
if ( !stream || !this.file || !this.format ) {
return
}
const buffers: NodeBuffer[] = []
let dataLength = 0
stream.on( 'error', ( error: Error ) => {
if ( this.readyState === this.DONE ) {
return
}
this.readyState = this.DONE
this.error = error
this.dispatchEvent( 'error', {
target: {
error
}
} )
} )
stream.on( 'data', ( data: NodeBuffer ) => {
if ( this.readyState === this.DONE ) {
return
}
dataLength += data.length
buffers.push( data )
this.dispatchEvent( 'progress', {
lengthComputable: !Number.isNaN( this.file?.size ),
loaded: dataLength,
total: this.file?.size
} )
} )
stream.on( 'end', () => {
if ( this.readyState === this.DONE ) {
return
}
const data = buffers.length > 1
? Buffer.concat( buffers as unknown as readonly Uint8Array[] ) as NodeBuffer
: ( buffers[ 0 ] || Buffer.alloc( 0 ) )
this.readyState = this.DONE
this.result = mapDataToFormat( this.file!, data, this.format!, this.encoding )
const event = {
target: {
nodeBufferResult: data,
result: this.result
}
}
this.dispatchEvent( 'load', event )
this.dispatchEvent( 'loadend', event )
} )
}
private readFile ( file: NodeFile, format: ReadFormat, encoding?: BufferEncoding ) {
this.file = file
this.format = format
this.encoding = encoding
if ( !this.file || !this.file.name || !( this.file.path || this.file.stream || this.file.buffer ) ) {
throw new Error( `cannot read as File: ${ JSON.stringify( this.file ).slice( 0, 1000 ) }` )
}
if ( this.readyState !== this.EMPTY ) {
console.log( 'already loading, request to change format ignored' )
return
}
process.nextTick( () => {
this.readyState = this.LOADING
this.dispatchEvent( 'loadstart' )
this.createFileStream()
this.mapStreamToEmitter()
this.registerUserEvents()
} )
}
}