diff --git a/fetch/fetch.ts b/fetch/fetch.ts index 126cf87afba..76befad510d 100644 --- a/fetch/fetch.ts +++ b/fetch/fetch.ts @@ -79,12 +79,10 @@ namespace $ { export class $mol_fetch extends $mol_object2 { static request( input : RequestInfo , init : RequestInit = {} ) { - const native = globalThis.fetch ?? $node['undici'].fetch - const controller = new AbortController() let done = false - const promise = native( input , { + const promise = fetch( input , { ... init, signal: controller!.signal, } ).finally( ()=> { diff --git a/file/base/base.ts b/file/base/base.ts new file mode 100644 index 00000000000..f6ab55cc2f0 --- /dev/null +++ b/file/base/base.ts @@ -0,0 +1,409 @@ +namespace $ { + + export class $mol_file_base extends $mol_object { + + @ $mol_mem_key + static absolute(this: This, path : string ) { + return this.make({ + path : $mol_const( path ) + }) as InstanceType< This > + } + + static relative(this: This, path : string ) : InstanceType { + throw new Error( 'Not implemented yet' ) + } + + static base = '' + + path() { + return '.' + } + + parent() { + return this.resolve( '..' ) + } + + @ $mol_mem + protected stat(next? : $mol_file_stat | null, virt?: 'virt') { + + const path = this.path() + const parent = this.parent() + parent.watcher() + + // Отслеживать проверку наличия родительской папки не стоит до корня диска + // Лучше ограничить mam-ом + if ( path !== this.$.$mol_file_base.watch_root && path !== parent.path() ) { + /* + Если родитель удалился, надо ресетнуть все дочерние на любой глубине + Родитель может удалиться, потом создасться, а дочерняя папка только удалиться. + Поэтому parent.exists() не запустит перевычисления + + parent.version() меняется не только при удалении, будет ложное срабатывание + события вотчера addDir сбрасывает только parent.sub(), а parent.version() может остаться та же + тогда дочерний не перзапустится + Если addDir будет сбрасывать parent.version(), то будет лишний раз перевычислен parent, хоть и он сам не поменялся + */ + + parent.version() + } + + if( virt ) return next ?? null + + return next ?? this.info(path) + } + + protected static changed = new Set<$mol_file_base> + protected static added = new Set<$mol_file_base> + + protected static frame = null as null | $mol_after_timeout + + protected static changed_add(type: 'addDir' | 'unlinkDir' | 'add' | 'change' | 'unlink', path: string) { + const file = this.$.$mol_file_base.relative( path.at(-1) === '/' ? path.slice(0, -1) : path ) + + if (type === 'add') { + // добавился файл - у parent надо обновить список sub, если он был заюзан + this.added.add(file) + } + + if (type === 'change' || type === 'unlink') { + // обновился или удалился файл - ресетим + this.changed.add(file) + } + + if ( type === 'addDir' ) { + // добавилась папка, у parent обновляем список директорий в sub + // дочерние ресетим + // версию папки не меняем, т.к. иначе выполнится логика, связанная + this.added.add(file) + } + + if ( type === 'unlinkDir') { + // удалилась папка, ресетим ее + // stat у всех дочерних обновится сам, т.к. связан с parent.version() + this.changed.add(file) + } + + if (! this.watching) return + + this.frame?.destructor() + this.frame = new this.$.$mol_after_timeout(this.watch_debounce(), () => { + if (! this.watching) return + this.watching = false + $mol_wire_async(this).flush() + } ) + } + + static watch_debounce() { return 500 } + + @ $mol_action + static flush() { + // this.flush_counter() + // Пока flush работает, вотчер сюда не заходит, но может добавлять новые изменения + // на каждом перезапуске они применятся + // Пока run выполняется, изменения накапливаются, в конце run вызывается flush + // Пока применяются изменения, run должен ожидать конца flush + + for (const file of this.added) { + const parent = file.parent() + + try { + if ( $mol_wire_probe(() => parent.sub())) parent.sub(null) + file.reset() + } catch (error) { + if ($mol_fail_catch(error)) $mol_fail_log(error) + } + } + + for (const file of this.changed) { + try { + file.reset() + } catch (error) { + if ($mol_fail_catch(error)) $mol_fail_log(error) + } + } + + this.added.clear() + this.changed.clear() + + // Выставляем обратно в true, что б watch мог зайти сюда + this.watching = true + } + + protected static watching = true + + protected static lock = new $mol_lock + + @ $mol_action + protected static watching_off(path: string) { + this.watching = false + /* + watch запаздывает и событие может прилететь через 3 сек после окончания сайд эффекта + поэтому добавляем папку, которую меняет side_effect + Когда дойдет до выполнения flush, он ресетнет ее + + Иначе будут лишние срабатывания + Например, удалили hyoo/board, watch ресетит и exists начинает отдавать false, срабатывает git clone + Сразу после него событие addDir еще не успело прийти, + на следующем перезапуске вызывается git pull, т.к. + с точки зрения реактивной системы hyoo/board еще не существует. + */ + this.changed.add(this.$.$mol_file_base.absolute(path)) + } + + static watch_off(side_effect: () => Result, affected_dir: string) { + // ждем, пока выполнится предыдущий watch_off + const unlock = this.lock.grab() + this.watching_off(affected_dir) + + try { + const result = side_effect() + this.flush() + unlock() + return result + } catch(e) { + if (! $mol_promise_like(e)) { + this.flush() + unlock() + } + $mol_fail_hidden(e) + } + } + + reset() { + this.stat( null ) + } + + @ $mol_mem + modified() { return this.stat()?.mtime ?? null } + + @ $mol_mem + version() { + return this.modified()?.getTime().toString( 36 ).toUpperCase() ?? '' + } + + protected info( path: string ) { return null as null | $mol_file_stat } + protected ensure() {} + protected drop() {} + protected copy(to: string) {} + protected read() { return new Uint8Array } + protected write(buffer: Uint8Array) { } + protected kids() { + return [] as readonly this[] + } + static headers() { return {} as Record } + headers() { return (this.constructor as typeof $mol_file_base).headers() } + + @ $mol_mem_key + readable(opts: { start?: number, end?: number }) { + return new ReadableStream + } + + @ $mol_mem_key + writable(opts: { start?: number }) { + return new WritableStream + } + + // open( ... modes: readonly $mol_file_mode[] ) { return 0 } + + @ $mol_mem + buffer( next? : Uint8Array ) { + + if( next === undefined ) { + + // Если меняется файл, буфер надо перечитать + if (! this.version() ) return new Uint8Array + + next = this.read() + // try { + // } catch (error) { + // // Файл может удалиться между изменением version и read, обрабатываем эту ситуацию + // if (error instanceof $mol_file_error && error.cause.code === 'not found') { + // return new Uint8Array() + // } + // $mol_fail_hidden(error) + // } + + const prev = $mol_mem_cached( ()=> this.buffer() ) + + if( prev !== undefined && !$mol_compare_array( prev, next ) ) { + this.$.$mol_log3_rise({ + place: `$mol_file_node.buffer()`, + message: 'Changed' , + path: this.relate() , + }) + } + + return next + + } + + this.parent().exists( true ) + + this.stat( this.stat_make(next.length), 'virt' ) + + this.write(next) + + return next + + } + + @ $mol_action + stat_make(size: number) { + const now = new Date() + return { + type: 'file', + size, + atime: now, + mtime: now, + ctime: now, + } as const + } + + @ $mol_mem_key + clone(to: string) { + if (! this.exists() ) return null + + const target = (this.constructor as typeof $mol_file_base).absolute(to) as this + + try { + this.version() + target.parent().exists(true) + this.copy(to) + target.reset() + return target + } catch (error) { + if ( $mol_fail_catch(error)) { + console.error(error) + } + } + return null + } + + static watch_root = '' + + watcher() { + console.warn('$mol_file_web.watcher() not implemented') + + return { + destructor() {} + } + } + + @ $mol_mem + exists( next? : boolean ) { + + const exists = Boolean( this.stat() ) + + if( next === undefined ) return exists + if( next === exists ) return exists + + if( next ) { + this.parent().exists( true ) + this.ensure() + this.reset() + return next + } + + this.drop() + // удалили директорию, все дочерние потеряли актуальность + this.reset() + + return next + } + + @ $mol_mem + type() { + return this.stat()?.type ?? '' + } + + name() { + return this.path().replace( /^.*\//, '' ) + } + + ext() { + const match = /((?:\.\w+)+)$/.exec( this.path() ) + return match ? match[ 1 ].substring( 1 ) : '' + } + + text(next?: string, virt?: 'virt') { + // Если пушим в text, то при сбросе таргета надо перезапускать пуш + // Например файл удалили, потом снова создали, версия поменялась - перезаписываем + if (next !== undefined) this.version() + return this.text_int(next, virt) + } + + @ $mol_mem + text_int(next?: string, virt?: 'virt') { + if( virt ) { + this.stat( this.stat_make(0), 'virt' ) + return next! + } + + if( next === undefined ) { + return $mol_charset_decode( this.buffer( ) ) + } else { + const buffer = $mol_charset_encode( next ) + this.buffer( buffer ) + return next + } + } + + @ $mol_mem + sub(reset?: null) { + if (! this.exists() ) return [] + if ( this.type() !== 'dir') return [] + + this.stat() + + // Если дочерний file удалился, список надо обновить + return this.kids().filter(file => file.exists()) + } + + resolve(path: string): this { + throw new Error('implement') + } + + relate( base = ( this.constructor as typeof $mol_file_base ).relative( '.' )): string { + const base_path = base.path() + const path = this.path() + return path.startsWith(base_path) ? path.slice(base_path.length) : path + } + + find( + include? : RegExp , + exclude? : RegExp + ) { + const found = [] as typeof this[] + const sub = this.sub() + + for (const child of sub) { + const child_path = child.path() + + if( exclude && child_path.match( exclude ) ) continue + + if( !include || child_path.match( include ) ) found.push( child ) + + if( child.type() === 'dir' ) { + const sub_child = child.find( include , exclude ) + for (const child of sub_child) found.push(child) + } + } + + return found + } + + @ $mol_mem + size() { + switch( this.type() ) { + case 'file': return this.stat()?.size ?? 0 + default: return 0 + } + } + + toJSON() { + return this.path() + } + + } + +} diff --git a/file/file.node.ts b/file/file.node.ts index b949935e84c..7988a71c46d 100644 --- a/file/file.node.ts +++ b/file/file.node.ts @@ -125,11 +125,16 @@ namespace $ { try { return buffer_normalize($node.fs.readFileSync( path )) } catch( error: any ) { - if (this.$.$mol_fail_catch(error)) { - error.message += '\n' + path + + if (! $mol_promise_like(error)) { + error = new $mol_file_error( + error.message + '\n' + path, + { code: error.code === 'ENOENT' ? 'not found' : null }, + error + ) } - return this.$.$mol_fail_hidden( error ) + $mol_fail_hidden( error ) } } @@ -152,6 +157,7 @@ namespace $ { protected override kids() { const path = this.path() + try { const kids = $node.fs.readdirSync( path ) .filter( name => !/^\.+$/.test( name ) ) diff --git a/file/file.ts b/file/file.ts index 1a15eb59352..0c2e6bb896f 100644 --- a/file/file.ts +++ b/file/file.ts @@ -10,403 +10,8 @@ namespace $ { ctime: Date } - // export class $mol_file_not_found extends Error {} + export class $mol_file_error extends $mol_error_mix<{ code: 'not found' | null }> {} - export class $mol_file extends $mol_object { - - @ $mol_mem_key - static absolute(this: This, path : string ) { - return this.make({ - path : $mol_const( path ) - }) as InstanceType< This > - } - - static relative(this: This, path : string ) : InstanceType { - throw new Error( 'Not implemented yet' ) - } - - static base = '' - - path() { - return '.' - } - - parent() { - return this.resolve( '..' ) - } - - @ $mol_mem - protected stat(next? : $mol_file_stat | null, virt?: 'virt') { - - const path = this.path() - const parent = this.parent() - parent.watcher() - - // Отслеживать проверку наличия родительской папки не стоит до корня диска - // Лучше ограничить mam-ом - if ( path !== this.$.$mol_file.watch_root && path !== parent.path() ) { - /* - Если родитель удалился, надо ресетнуть все дочерние на любой глубине - Родитель может удалиться, потом создасться, а дочерняя папка только удалиться. - Поэтому parent.exists() не запустит перевычисления - - parent.version() меняется не только при удалении, будет ложное срабатывание - события вотчера addDir сбрасывает только parent.sub(), а parent.version() может остаться та же - тогда дочерний не перзапустится - Если addDir будет сбрасывать parent.version(), то будет лишний раз перевычислен parent, хоть и он сам не поменялся - */ - - parent.version() - } - - if( virt ) return next ?? null - - return next ?? this.info(path) - } - - protected static changed = new Set<$mol_file> - protected static added = new Set<$mol_file> - - protected static frame = null as null | $mol_after_timeout - - protected static changed_add(type: 'addDir' | 'unlinkDir' | 'add' | 'change' | 'unlink', path: string) { - const file = this.$.$mol_file.relative( path.at(-1) === '/' ? path.slice(0, -1) : path ) - - if (type === 'add') { - // добавился файл - у parent надо обновить список sub, если он был заюзан - this.added.add(file) - } - - if (type === 'change' || type === 'unlink') { - // обновился или удалился файл - ресетим - this.changed.add(file) - } - - if ( type === 'addDir' ) { - // добавилась папка, у parent обновляем список директорий в sub - // дочерние ресетим - // версию папки не меняем, т.к. иначе выполнится логика, связанная - this.added.add(file) - } - - if ( type === 'unlinkDir') { - // удалилась папка, ресетим ее - // stat у всех дочерних обновится сам, т.к. связан с parent.version() - this.changed.add(file) - } - - if (! this.watching) return - - this.frame?.destructor() - this.frame = new this.$.$mol_after_timeout(this.watch_debounce(), () => { - if (! this.watching) return - this.watching = false - $mol_wire_async(this).flush() - } ) - } - - static watch_debounce() { return 500 } - - @ $mol_action - static flush() { - // this.flush_counter() - // Пока flush работает, вотчер сюда не заходит, но может добавлять новые изменения - // на каждом перезапуске они применятся - // Пока run выполняется, изменения накапливаются, в конце run вызывается flush - // Пока применяются изменения, run должен ожидать конца flush - - for (const file of this.added) { - const parent = file.parent() - - try { - if ( $mol_wire_probe(() => parent.sub())) parent.sub(null) - file.reset() - } catch (error) { - if ($mol_fail_catch(error)) $mol_fail_log(error) - } - } - - for (const file of this.changed) { - try { - file.reset() - } catch (error) { - if ($mol_fail_catch(error)) $mol_fail_log(error) - } - } - - this.added.clear() - this.changed.clear() - - // Выставляем обратно в true, что б watch мог зайти сюда - this.watching = true - } - - protected static watching = true - - protected static lock = new $mol_lock - - @ $mol_action - protected static watching_off(path: string) { - this.watching = false - /* - watch запаздывает и событие может прилететь через 3 сек после окончания сайд эффекта - поэтому добавляем папку, которую меняет side_effect - Когда дойдет до выполнения flush, он ресетнет ее - - Иначе будут лишние срабатывания - Например, удалили hyoo/board, watch ресетит и exists начинает отдавать false, срабатывает git clone - Сразу после него событие addDir еще не успело прийти, - на следующем перезапуске вызывается git pull, т.к. - с точки зрения реактивной системы hyoo/board еще не существует. - */ - this.changed.add(this.$.$mol_file.absolute(path)) - } - - static watch_off(side_effect: () => Result, affected_dir: string) { - // ждем, пока выполнится предыдущий watch_off - const unlock = () => {} // this.lock.grab() - this.watching_off(affected_dir) - - try { - const result = side_effect() - this.flush() - unlock() - return result - } catch(e) { - if (! $mol_promise_like(e)) { - this.flush() - unlock() - } - $mol_fail_hidden(e) - } - } - - reset() { - this.stat( null ) - } - - @ $mol_mem - modified() { return this.stat()?.mtime ?? null } - - @ $mol_mem - version() { - return this.modified()?.getTime().toString( 36 ).toUpperCase() ?? '' - } - - protected info( path: string ) { return null as null | $mol_file_stat } - protected ensure() {} - protected drop() {} - protected copy(to: string) {} - protected read() { return new Uint8Array } - protected write(buffer: Uint8Array) { } - protected kids() { - return [] as readonly this[] - } - static headers() { return {} as Record } - headers() { return (this.constructor as typeof $mol_file).headers() } - - @ $mol_mem_key - readable(opts: { start?: number, end?: number }) { - return new ReadableStream - } - - @ $mol_mem_key - writable(opts: { start?: number }) { - return new WritableStream - } - - // open( ... modes: readonly $mol_file_mode[] ) { return 0 } - - @ $mol_mem - buffer( next? : Uint8Array ) { - - if( next === undefined ) { - - if( !this.stat() ) return new Uint8Array - - const prev = $mol_mem_cached( ()=> this.buffer() ) - - next = this.read() - - if( prev !== undefined && !$mol_compare_array( prev, next ) ) { - this.$.$mol_log3_rise({ - place: `$mol_file_node.buffer()`, - message: 'Changed' , - path: this.relate() , - }) - } - - return next - - } - - this.parent().exists( true ) - - this.stat( this.stat_make(next.length), 'virt' ) - - this.write(next) - - return next - - } - - @ $mol_action - stat_make(size: number) { - const now = new Date() - return { - type: 'file', - size, - atime: now, - mtime: now, - ctime: now, - } as const - } - - @ $mol_mem_key - clone(to: string) { - if (! this.exists() ) return null - - const target = (this.constructor as typeof $mol_file).absolute(to) as this - - try { - this.version() - target.parent().exists(true) - this.copy(to) - target.reset() - return target - } catch (error) { - if ( $mol_fail_catch(error)) { - console.error(error) - } - } - return null - } - - static watch_root = '' - - watcher() { - console.warn('$mol_file_web.watcher() not implemented') - - return { - destructor() {} - } - } - - @ $mol_mem - exists( next? : boolean ) { - - const exists = Boolean( this.stat() ) - - if( next === undefined ) return exists - if( next === exists ) return exists - - if( next ) { - this.parent().exists( true ) - this.ensure() - this.reset() - return next - } - - this.drop() - // удалили директорию, все дочерние потеряли актуальность - this.reset() - - return next - } - - @ $mol_mem - type() { - return this.stat()?.type ?? '' - } - - name() { - return this.path().replace( /^.*\//, '' ) - } - - ext() { - const match = /((?:\.\w+)+)$/.exec( this.path() ) - return match ? match[ 1 ].substring( 1 ) : '' - } - - text(next?: string, virt?: 'virt') { - // Если пушим в text, то при сбросе таргета надо перезапускать пуш - // Например файл удалили, потом снова создали, версия поменялась - перезаписываем - if (next !== undefined) this.version() - return this.text_int(next, virt) - } - - @ $mol_mem - text_int(next?: string, virt?: 'virt') { - if( virt ) { - this.stat( this.stat_make(0), 'virt' ) - return next! - } - - if( next === undefined ) { - return $mol_charset_decode( this.buffer( ) ) - } else { - const buffer = $mol_charset_encode( next ) - this.buffer( buffer ) - return next - } - } - - @ $mol_mem - sub(reset?: null) { - if (! this.exists() ) return [] - if ( this.type() !== 'dir') return [] - - this.stat() - - // Если дочерний file удалился, список надо обновить - return this.kids().filter(file => file.exists()) - } - - resolve(path: string): this { - throw new Error('implement') - } - - relate( base = ( this.constructor as typeof $mol_file ).relative( '.' )): string { - const base_path = base.path() - const path = this.path() - return path.startsWith(base_path) ? path.slice(base_path.length) : path - } - - find( - include? : RegExp , - exclude? : RegExp - ) { - const found = [] as typeof this[] - const sub = this.sub() - - for (const child of sub) { - const child_path = child.path() - - if( exclude && child_path.match( exclude ) ) continue - - if( !include || child_path.match( include ) ) found.push( child ) - - if( child.type() === 'dir' ) { - const sub_child = child.find( include , exclude ) - for (const child of sub_child) found.push(child) - } - } - - return found - } - - @ $mol_mem - size() { - switch( this.type() ) { - case 'file': return this.stat()?.size ?? 0 - default: return 0 - } - } - - toJSON() { - return this.path() - } - - } + export class $mol_file extends $mol_file_base {} } diff --git a/file/file.web.ts b/file/file.web.ts index f70d4942af8..ebf26286f4d 100644 --- a/file/file.web.ts +++ b/file/file.web.ts @@ -1,167 +1,11 @@ namespace $ { - export class $mol_file_web extends $mol_file { + export class $mol_file_web extends $mol_file_webdav { - static override relative(this: This, path : string ) { - return this.absolute( new URL( path , this.base ).toString() ) - } - - static base = $mol_dom_context.document?.currentScript + static override base = $mol_dom_context.document?.currentScript ? new URL( '.' , ($mol_dom_context.document.currentScript as any)['src'] ).toString() : '' - - protected override read() { - const response = this.$.$mol_fetch.response(this.path(), { - headers: this.headers() - }) - if (response.native.status === 404) return new Uint8Array - - return new Uint8Array(response.buffer()) - } - - @ $mol_mem - override buffer( next? : Uint8Array ) { - if (next !== undefined) throw new Error(`Saving content not supported: ${this.path}`) - - const response = $mol_fetch.response(this.path()) - if (response.native.status === 404) return new Uint8Array - // throw new $mol_file_not_found(`File not found: ${this.path()}`) - - return new Uint8Array(response.buffer()) - } - - protected fetch(init: RequestInit) { - return this.$.$mol_fetch.success(this.path(), { - ...init, - headers: { - ...this.headers(), - ...init.headers, - } - }) - } - - protected override write( body : Uint8Array ) { - this.fetch({ method: 'PUT', body }) - } - - @ $mol_mem_key - override readable( opts: { start?: number; end?: number } ) { - return this.fetch({ - headers: ! opts.start ? {} : { - 'Range': `bytes=${opts.start}-${opts.end ?? ''}` - } - }).stream() || $mol_fail(new Error('Not found')) - } - - override resolve( path : string ) { - let res = this.path() + '/' + path - - while( true ) { - let prev = res - // foo/../ -> / - res = res.replace( /\/[^\/.]+\/\.\.\// , '/' ) - if( prev === res ) break - } - - // http://localhost/.. -> http://localhost - res = res.replace(/\/\.\.\/?$/, '') - - if (res === this.path()) return this - - return ( this.constructor as typeof $mol_file ).absolute( res ) as this - } - - protected override ensure() { - this.fetch({ method: 'MKCOL' }) - } - - protected override drop() { - this.fetch({ method: 'DELETE' }) - } - - protected override copy(to: string) { - this.fetch({ - method: 'COPY', - headers: { Destination: to } - }) - } - - protected override kids() : readonly this[] { - const response = this.fetch({ method: 'PROPFIND' }) - const xml = response.xml() - const info = webdav_list(xml) - - const kids = info.map(({ path, stat }) => { - const file = this.resolve(path) - file.stat(stat, 'virt') - return file - }) - - return kids - } - - protected override info() { - return this.stat_make(0) - // return this.kids()?.at(0)?.stat() ?? null - } - - } - - function webdav_list(xml: Document) { - const result = [] - - for (const multistatus of xml.childNodes) { - if (multistatus.nodeName !== 'D:multistatus') continue - - for (const response of multistatus.childNodes) { - let path - - if (response.nodeName === 'D:href') path = response.textContent ?? '' - - if (! path ) continue - if ( response.nodeName !== 'D:propstat') continue - - const stat = webdav_stat(response) - - result.push({ path, stat }) - } - } - - return result - } - - function webdav_stat(prop_stat: ChildNode) { - const now = new Date() - const stat: $mol_file_stat = { - type: 'file', - size: 0, - atime: now, - mtime: now, - ctime: now, - } - - for (const prop of prop_stat.childNodes) { - if (prop.nodeName !== 'D:prop') continue - - for (const value of prop.childNodes) { - const name = value.nodeName - const text = value.textContent ?? '' - - if (name === 'D:getcontenttype') { - stat.type = text.endsWith('directory') ? 'dir' : 'file' - } - - if (name === 'D:getcontentlength') { - stat.size = Number(value.textContent || '0') - if (Number.isNaN(stat.size)) stat.size = 0 - } - - if (name === 'D:getlastmodified') stat.mtime = stat.atime = new Date(text) - if (name === 'D:creationdate') stat.ctime = new Date(text) - } - } - return stat } $.$mol_file = $mol_file_web diff --git a/file/webdav/webdav.ts b/file/webdav/webdav.ts new file mode 100644 index 00000000000..35a29d02fd9 --- /dev/null +++ b/file/webdav/webdav.ts @@ -0,0 +1,154 @@ +namespace $ { + export class $mol_file_webdav extends $mol_file_base { + static override relative(this: This, path : string ) { + return this.absolute( new URL( path , this.base ).toString() ) + } + + override watcher() { + console.warn('$mol_file_web.watcher() not implemented') + + return { + destructor() {} + } + } + + override resolve( path : string ) { + let res = this.path() + '/' + path + + while( true ) { + let prev = res + // foo/../ -> / + res = res.replace( /\/[^\/.]+\/\.\.\// , '/' ) + if( prev === res ) break + } + + // http://localhost/.. -> http://localhost + res = res.replace(/\/\.\.\/?$/, '') + + if (res === this.path()) return this + + return ( this.constructor as typeof $mol_file_base ).absolute( res ) as this + } + + headers() { return {} as Record } + + protected fetch(init: RequestInit) { + return this.$.$mol_fetch.success(this.path(), { + ...init, + headers: { + ...this.headers(), + ...init.headers, + } + }) + } + + protected override read() { + const response = this.$.$mol_fetch.response(this.path(), { + headers: this.headers() + }) + if (response.native.status === 404) return new Uint8Array + + return new Uint8Array(response.buffer()) + } + + protected override write( body : Uint8Array ) { this.fetch({ method: 'PUT', body }) } + protected override ensure() { this.fetch({ method: 'MKCOL' }) } + protected override drop() { this.fetch({ method: 'DELETE' }) } + + protected override copy(to: string) { + this.fetch({ + method: 'COPY', + headers: { Destination: to } + }) + } + + protected override kids() { + const response = this.fetch({ method: 'PROPFIND' }) + const xml = response.xml() + const result = [] + + for (const multistatus of xml.childNodes) { + if (multistatus.nodeName !== 'D:multistatus') continue + + for (const response of multistatus.childNodes) { + let path + + if (response.nodeName === 'D:href') path = response.textContent ?? '' + + if (! path ) continue + if ( response.nodeName !== 'D:propstat') continue + + const stat = webdav_stat(response) + + const file = this.resolve(path) + file.stat(stat, 'virt') + result.push(file) + } + } + + return result + } + + @ $mol_mem_key + override readable( opts: { start?: number; end?: number } ) { + return this.fetch({ + headers: ! opts.start ? {} : { + 'Range': `bytes=${opts.start}-${opts.end ?? ''}` + } + }).stream() || $mol_fail(new Error('Not found')) + } + + protected override info(): $mol_file_stat { + const response = this.fetch({ method: 'HEAD' }) + const headers = response.headers() + let size = Number(headers.get('Content-Length')) + if (Number.isNaN(size)) size = 0 + const last = headers.get('Last-Modified') + + const mtime = last ? new Date(last) : new Date() + + return { + type: 'file', + size, + mtime, + atime: mtime, + ctime: mtime, + } + } + } + + function webdav_stat(prop_stat: ChildNode) { + const now = new Date() + const stat: $mol_file_stat = { + type: 'file', + size: 0, + atime: now, + mtime: now, + ctime: now, + } + + for (const prop of prop_stat.childNodes) { + if (prop.nodeName !== 'D:prop') continue + + for (const value of prop.childNodes) { + const name = value.nodeName + const text = value.textContent ?? '' + + if (name === 'D:getcontenttype') { + stat.type = text.endsWith('directory') ? 'dir' : 'file' + } + + if (name === 'D:getcontentlength') { + stat.size = Number(value.textContent || '0') + if (Number.isNaN(stat.size)) stat.size = 0 + } + + if (name === 'D:getlastmodified') stat.mtime = stat.atime = new Date(text) + if (name === 'D:creationdate') stat.ctime = new Date(text) + } + } + + return stat + } + +}