diff --git a/packages/angular-universal/package.json b/packages/angular-universal/package.json index e5135e2b3..42eb70402 100644 --- a/packages/angular-universal/package.json +++ b/packages/angular-universal/package.json @@ -53,8 +53,7 @@ "@deepkit/http": "^1.0.1-alpha.102", "@deepkit/injector": "^1.0.1-alpha.102", "@deepkit/logger": "^1.0.1-alpha.102", - "@deepkit/type": "^1.0.1-alpha.102", - "@types/node": "^14.0.0" + "@deepkit/type": "^1.0.1-alpha.102" }, "jest": { "transform": { diff --git a/packages/app/src/module.ts b/packages/app/src/module.ts index bb34a57af..da6ad89a1 100644 --- a/packages/app/src/module.ts +++ b/packages/app/src/module.ts @@ -232,6 +232,27 @@ export function createModule(options: T, name: export type ListenerType = EventListener | ClassType; +/** + * The AppModule is the base class for all modules. + * + * You can use `createModule` to create a new module class or extend from `AppModule` manually. + * + * @example + * ```typescript + * + * class MyModule extends AppModule { + * providers = [MyService]; + * exports = [MyService]; + * + * constructor(config: MyConfig) { + * super(); + * this.setConfigDefinition(MyConfig); + * this.configure(config); + * this.name = 'myModule'; + * } + * } + * + */ export class AppModule = any> extends InjectorModule> { public setupConfigs: ((module: AppModule, config: any) => void)[] = []; @@ -244,7 +265,7 @@ export class AppModule void)[] = []; constructor( - public options: T, + public options: T = {} as T, public name: string = '', public setups: ((module: AppModule, config: any) => void)[] = [], public id: number = moduleId++, diff --git a/packages/broker/index.ts b/packages/broker/index.ts index 2613c50f3..7218b100c 100644 --- a/packages/broker/index.ts +++ b/packages/broker/index.ts @@ -11,3 +11,4 @@ export * from './src/client.js'; export * from './src/kernel.js'; export * from './src/model.js'; +export * from './src/broker.js'; diff --git a/packages/broker/package.json b/packages/broker/package.json index b0c10d33f..66ed723e5 100644 --- a/packages/broker/package.json +++ b/packages/broker/package.json @@ -27,6 +27,7 @@ "@deepkit/core-rxjs": "^1.0.1-alpha.39", "@deepkit/rpc": "^1.0.1-alpha.41", "@deepkit/type": "^1.0.1-alpha.40", + "@deepkit/event": "^1.0.1-alpha.40", "rxjs": "*" }, "devDependencies": { @@ -34,7 +35,8 @@ "@deepkit/core": "^1.0.1-alpha.100", "@deepkit/core-rxjs": "^1.0.1-alpha.100", "@deepkit/rpc": "^1.0.1-alpha.102", - "@deepkit/type": "^1.0.1-alpha.102" + "@deepkit/type": "^1.0.1-alpha.102", + "@deepkit/event": "^1.0.1-alpha.102" }, "jest": { "transform": { diff --git a/packages/broker/src/adapters/memory-adapter.ts b/packages/broker/src/adapters/memory-adapter.ts new file mode 100644 index 000000000..9479f9a7c --- /dev/null +++ b/packages/broker/src/adapters/memory-adapter.ts @@ -0,0 +1,70 @@ +import { BrokerAdapter, BrokerCacheOptions, BrokerLockOptions } from '../broker.js'; +import { Type } from '@deepkit/type'; +import { ProcessLock } from '@deepkit/core'; + +export class BrokerMemoryAdapter implements BrokerAdapter { + protected cache: { [key: string]: any } = {}; + protected channels: { [key: string]: ((m: any) => void)[] } = {}; + protected locks: { [key: string]: ProcessLock } = {}; + + async disconnect(): Promise { + } + + async lock(id: string, options: BrokerLockOptions): Promise { + const lock = new ProcessLock(id); + await lock.acquire(options.ttl, options.timeout); + this.locks[id] = lock; + } + + async tryLock(id: string, options: BrokerLockOptions): Promise { + const lock = new ProcessLock(id); + if (lock.tryLock(options.ttl)) { + this.locks[id] = lock; + return true; + } + return false; + } + + async release(id: string): Promise { + if (this.locks[id]) { + this.locks[id].unlock(); + delete this.locks[id]; + } + } + + async getCache(key: string): Promise { + return this.cache[key]; + } + + async setCache(key: string, value: any, options: BrokerCacheOptions) { + this.cache[key] = value; + } + + async increase(key: string, value: any): Promise { + if (!(key in this.cache)) this.cache[key] = 0; + this.cache[key] += value; + } + + async subscribe(key: string, callback: (message: any) => void, type: Type): Promise<{ unsubscribe: () => Promise }> { + if (!(key in this.channels)) this.channels[key] = []; + const fn = (m: any) => { + callback(m); + }; + this.channels[key].push(fn); + + return { + unsubscribe: async () => { + const index = this.channels[key].indexOf(fn); + if (index !== -1) this.channels[key].splice(index, 1); + } + }; + } + + async publish(key: string, message: T): Promise { + if (!(key in this.channels)) return; + for (const callback of this.channels[key]) { + callback(message); + } + } +} + diff --git a/packages/broker/src/broker.ts b/packages/broker/src/broker.ts new file mode 100644 index 000000000..f86755c04 --- /dev/null +++ b/packages/broker/src/broker.ts @@ -0,0 +1,209 @@ +import { ReceiveType, reflect, ReflectionKind, resolveReceiveType, Type } from '@deepkit/type'; +import { EventToken } from '@deepkit/event'; + +export interface BrokerLockOptions { + /** + * Time to live in seconds. Default 2 minutes. + * + * The lock is automatically released after this time. + * This is to prevent deadlocks. + */ + ttl: number; + + /** + * Timeout when acquiring the lock in seconds. Default 30 seconds. + * Ween a lock is not acquired after this time, an error is thrown. + */ + timeout: number; +} + +export interface BrokerAdapter { + lock(id: string, options: BrokerLockOptions): Promise; + + tryLock(id: string, options: BrokerLockOptions): Promise; + + release(id: string): Promise; + + getCache(key: string, type: Type): Promise; + + setCache(key: string, value: any, options: BrokerCacheOptions, type: Type): Promise; + + increase(key: string, value: any): Promise; + + publish(key: string, message: any, type: Type): Promise; + + subscribe(key: string, callback: (message: any) => void, type: Type): Promise<{ unsubscribe: () => Promise }>; + + disconnect(): Promise; +} + +export const onBrokerLock = new EventToken('broker.lock'); + +export interface BrokerCacheOptions { + ttl: number; + tags: string[]; +} + +export class CacheError extends Error { +} + +export type BrokerBusChannel = [Channel, Parameters, Type]; + +export type BrokerCacheKey = [Key, Parameters, Type]; + +export type CacheBuilder> = (parameters: T[1], options: BrokerCacheOptions) => T[2] | Promise; + +export class BrokerBus { + constructor( + private channel: string, + private adapter: BrokerAdapter, + private type: Type, + ) { + } + + async publish(message: T) { + return this.adapter.publish(this.channel, message, this.type); + } + + async subscribe(callback: (message: T) => void): Promise<{ unsubscribe: () => Promise }> { + return this.adapter.subscribe(this.channel, callback, this.type); + } +} + +export class BrokerCache> { + constructor( + private key: string, + private builder: CacheBuilder, + private options: BrokerCacheOptions, + private adapter: BrokerAdapter, + private type: Type, + ) { + } + + protected getCacheKey(parameters: T[1]): string { + //this.key contains parameters e.g. user/:id, id comes from parameters.id. let's replace all of it. + //note: we could create JIT function for this, but it's probably not worth it. + return this.key.replace(/:([a-zA-Z0-9_]+)/g, (v, name) => { + if (!(name in parameters)) throw new CacheError(`Parameter ${name} not given`); + return String(parameters[name]); + }); + } + + async set(parameters: T[1], value: T[2], options: Partial = {}) { + const cacheKey = this.getCacheKey(parameters); + await this.adapter.setCache(cacheKey, value, { ...this.options, ...options }, this.type); + } + + async increase(parameters: T[1], value: number) { + const cacheKey = this.getCacheKey(parameters); + await this.adapter.increase(cacheKey, value); + } + + async get(parameters: T[1]): Promise { + const cacheKey = this.getCacheKey(parameters); + let entry = await this.adapter.getCache(cacheKey, this.type); + if (entry !== undefined) return entry; + + const options: BrokerCacheOptions = { ...this.options }; + entry = await this.builder(parameters, options); + await this.adapter.setCache(cacheKey, entry, options, this.type); + + return entry; + } +} + +export class BrokerLock { + public acquired: boolean = false; + + constructor( + private id: string, + private adapter: BrokerAdapter, + private options: BrokerLockOptions, + ) { + } + + async acquire(): Promise { + await this.adapter.lock(this.id, this.options); + this.acquired = true; + } + + async try(): Promise { + if (this.acquired) return true; + + return this.acquired = await this.adapter.tryLock(this.id, this.options); + } + + async release(): Promise { + this.acquired = false; + await this.adapter.release(this.id); + } +} + +export class Broker { + constructor( + private readonly adapter: BrokerAdapter + ) { + } + + public lock(id: string, options: Partial = {}): BrokerLock { + return new BrokerLock(id, this.adapter, Object.assign({ ttl: 60*2, timeout: 30 }, options)); + } + + public disconnect(): Promise { + return this.adapter.disconnect(); + } + + protected cacheProvider: { [path: string]: (...args: any[]) => any } = {}; + + public provideCache>(provider: (options: T[1]) => T[2] | Promise, type?: ReceiveType) { + type = resolveReceiveType(type); + if (type.kind !== ReflectionKind.tuple) throw new CacheError(`Invalid type given`); + if (type.types[0].type.kind !== ReflectionKind.literal) throw new CacheError(`Invalid type given`); + const path = String(type.types[0].type.literal); + this.cacheProvider[path] = provider; + } + + public cache>(type?: ReceiveType): BrokerCache { + type = resolveReceiveType(type); + if (type.kind !== ReflectionKind.tuple) throw new CacheError(`Invalid type given`); + if (type.types[0].type.kind !== ReflectionKind.literal) throw new CacheError(`Invalid type given`); + const path = String(type.types[0].type.literal); + const provider = this.cacheProvider[path]; + if (!provider) throw new CacheError(`No cache provider for cache ${type.typeName} (${path}) registered`); + + return new BrokerCache(path, provider, { ttl: 30, tags: [] }, this.adapter, type.types[2].type); + } + + public async get(key: string, builder: (options: BrokerCacheOptions) => Promise, type?: ReceiveType): Promise { + if (!type) { + //type not manually provided via Broker.get, so we try to extract it from the builder. + const fn = reflect(builder); + if (fn.kind !== ReflectionKind.function) throw new CacheError(`Can not detect type of builder function`); + type = fn.return; + while (type.kind === ReflectionKind.promise) type = type.type; + } else { + type = resolveReceiveType(type); + } + + const cache = this.adapter.getCache(key, type); + if (cache !== undefined) return cache; + + const options: BrokerCacheOptions = { ttl: 30, tags: [] }; + const value = builder(options); + await this.adapter.setCache(key, value, options, type); + return value; + } + + public bus>(type?: ReceiveType): BrokerBus { + type = resolveReceiveType(type); + if (type.kind !== ReflectionKind.tuple) throw new CacheError(`Invalid type given`); + if (type.types[0].type.kind !== ReflectionKind.literal) throw new CacheError(`Invalid type given`); + const path = String(type.types[0].type.literal); + + return new BrokerBus(path, this.adapter, type.types[2].type); + } + + public queue(channel: string, type?: ReceiveType) { + + } +} diff --git a/packages/broker/src/client.ts b/packages/broker/src/client.ts index 02dbfb74d..0cd8ec5de 100644 --- a/packages/broker/src/client.ts +++ b/packages/broker/src/client.ts @@ -360,7 +360,6 @@ export class BrokerClient extends RpcBaseClient { } } - export class BrokerDirectClient extends BrokerClient { constructor(rpcKernel: BrokerKernel) { super(new RpcDirectClientAdapter(rpcKernel)); diff --git a/packages/broker/tests/broker.spec.ts b/packages/broker/tests/broker.spec.ts new file mode 100644 index 000000000..d2cf8dc12 --- /dev/null +++ b/packages/broker/tests/broker.spec.ts @@ -0,0 +1,74 @@ +import { expect, jest, test } from '@jest/globals'; +import { Broker, BrokerAdapter, BrokerBusChannel, BrokerCacheKey } from '../src/broker.js'; +import { BrokerMemoryAdapter } from '../src/adapters/memory-adapter.js'; + +jest.setTimeout(30000); + +export let adapterFactory: () => Promise = async () => new BrokerMemoryAdapter(); + +export function setAdapterFactory(factory: () => Promise) { + adapterFactory = factory; +} + +test('cache api', async () => { + const broker = new Broker(await adapterFactory()); + + type User = { id: number, username: string }; + + { + type UserCache = BrokerCacheKey; + broker.provideCache((parameters) => { + return { id: parameters.id, username: 'peter' }; + }); + + const userCache = broker.cache(); + + const entry = await userCache.get({ id: 2 }); + expect(entry).toEqual({ id: 2, username: 'peter' }); + } + + { + const entry = await broker.get('user/' + 2, async () => { + return { id: 2, username: 'peter' }; + }); + expect(entry).toEqual({ id: 2, username: 'peter' }); + } + + { + const entry = await broker.get('user/' + 2, async (): Promise => { + return { id: 2, username: 'peter' }; + }); + expect(entry).toEqual({ id: 2, username: 'peter' }); + } +}); + +test('bus api', async () => { + const broker = new Broker(await adapterFactory()); + + type Events = { type: 'user-created', id: number } | { type: 'user-deleted', id: number }; + type EventChannel = BrokerBusChannel; + + const channel = broker.bus(); + + await channel.subscribe((event) => { + expect(event).toEqual({ type: 'user-created', id: 2 }); + }); + + await channel.publish({ type: 'user-created', id: 2 }); +}); + +test('lock api', async () => { + const broker = new Broker(await adapterFactory()); + + const lock1 = broker.lock('my-lock', { ttl: 1000 }); + const lock2 = broker.lock('my-lock', { ttl: 1000 }); + + await lock1.acquire(); + expect(lock1.acquired).toBe(true); + expect(lock2.acquired).toBe(false); + expect(await lock2.try()).toBe(false); + expect(lock2.acquired).toBe(false); + + await lock1.release(); + expect(lock1.acquired).toBe(false); +}); diff --git a/packages/desktop-ui/src/components/app/state.ts b/packages/desktop-ui/src/components/app/state.ts index 44fd51919..689c10061 100644 --- a/packages/desktop-ui/src/components/app/state.ts +++ b/packages/desktop-ui/src/components/app/state.ts @@ -1,4 +1,4 @@ -import { deserialize, Excluded, metaAnnotation, ReflectionClass, ReflectionKind, resolveTypeMembers, serialize, Serializer, Type, TypeClass } from '@deepkit/type'; +import { deserialize, Excluded, metaAnnotation, ReflectionClass, ReflectionKind, resolveTypeMembers, serialize, Serializer, Type, TypeAnnotation, TypeClass } from '@deepkit/type'; import { ClassType, getClassTypeFromInstance, getPathValue, setPathValue, throttleTime } from '@deepkit/core'; import { EventToken } from '@deepkit/event'; import { ApplicationRef, Injector } from '@angular/core'; @@ -16,7 +16,7 @@ import onChange from 'on-change'; * } * ``` */ -export type PartOfUrl = { __meta?: never & ['partOfUrl'] }; +export type PartOfUrl = TypeAnnotation<'partOfUrl'>; export type FilterActions = { [name in keyof T]: T[name] extends (a: infer A extends [...a: any[]], ...args: any[]) => infer R ? (...args: A) => R : never }; @@ -151,7 +151,6 @@ const stateSerializer: Serializer = new class extends Serializer { export function provideState(stateClass: ClassType, localStorageKey: string = 'appState') { const stateType = ReflectionClass.from(stateClass).type; - return { provide: stateClass, deps: [Router, Injector], useFactory: (router: Router, injector: Injector) => { let state = new stateClass(injector); diff --git a/packages/desktop-ui/src/components/indicator/indicator.component.ts b/packages/desktop-ui/src/components/indicator/indicator.component.ts index 271b0a7bc..2048c9c62 100644 --- a/packages/desktop-ui/src/components/indicator/indicator.component.ts +++ b/packages/desktop-ui/src/components/indicator/indicator.component.ts @@ -20,7 +20,7 @@ import { Subscription } from 'rxjs'; styleUrls: ['./indicator.component.scss'] }) export class IndicatorComponent { - @Input() step = 0; + @Input() step: number = 0; } diff --git a/packages/example-app/app.ts b/packages/example-app/app.ts index 6eed9781b..7190acd46 100755 --- a/packages/example-app/app.ts +++ b/packages/example-app/app.ts @@ -10,7 +10,6 @@ import { RpcController } from './src/controller/rpc.controller.js'; import { ApiConsoleModule } from '@deepkit/api-console-module'; import { OrmBrowserModule } from '@deepkit/orm-browser'; import { OpenAPIModule } from 'deepkit-openapi'; -import { FilesystemLocalAdapter, provideFilesystem } from '@deepkit/filesystem'; const bookStoreCrud = createCrudRoutes([Author, Book]); @@ -18,7 +17,6 @@ const app = new App({ config: Config, providers: [ SQLiteDatabase, MainController, - provideFilesystem(() => new FilesystemLocalAdapter({ root: __dirname + '/public' })), ], controllers: [MainController, UsersCommand, RpcController], listeners: [ diff --git a/packages/framework-debug-api/src/api.ts b/packages/framework-debug-api/src/api.ts index 7971caf82..6d3c9b5fd 100644 --- a/packages/framework-debug-api/src/api.ts +++ b/packages/framework-debug-api/src/api.ts @@ -9,7 +9,7 @@ */ import { ControllerSymbol } from '@deepkit/rpc'; -import { DebugRequest } from './model.js'; +import { DebugRequest, MediaFile } from './model.js'; import { Subject } from 'rxjs'; import { deserializeType, entity, Excluded, Type } from '@deepkit/type'; @@ -41,6 +41,13 @@ export class Database { entities: DatabaseEntity[] = []; } +@entity.name('.deepkit/debugger/filesystem') +export class Filesystem { + name!: string; + adapter!: string; + options: { [name: string]: any } = {}; +} + @entity.name('.deepkit/debugger/config') export class Config { appConfig!: ConfigOption[]; @@ -175,6 +182,30 @@ export class ModuleApi { } } +export const DebugMediaInterface = ControllerSymbol('.deepkit/debug/media', [MediaFile]); + +export interface DebugMediaInterface { + getPublicUrl(fs: number, path: string): Promise; + + createFolder(fs: number, path: string): Promise; + + getFile(fs: number, path: string): Promise; + + getFiles(fs: number, path: string): Promise; + + // getMediaPreview(fs: number, path: string): Promise<{ file: MediaFile, data: Uint8Array } | false>; + + getMediaQuickLook(fs: number, path: string): Promise<{ file: MediaFile, data: Uint8Array } | false>; + + getMediaData(fs: number, path: string): Promise; + + renameFile(fs: number, path: string, newName: string): Promise; + + addFile(fs: number, name: string, dir: string, data: Uint8Array): Promise; + + remove(fs: number, paths: string[]): Promise; +} + export const DebugControllerInterface = ControllerSymbol('.deepkit/debug/controller', [Config, Database, Route, RpcAction, Workflow, Event, DebugRequest]); export interface DebugControllerInterface { @@ -186,6 +217,8 @@ export interface DebugControllerInterface { databases(): Database[]; + filesystems(): Filesystem[]; + routes(): Route[]; modules(): ModuleApi; diff --git a/packages/framework-debug-api/src/model.ts b/packages/framework-debug-api/src/model.ts index e26deb677..58ac43dd8 100644 --- a/packages/framework-debug-api/src/model.ts +++ b/packages/framework-debug-api/src/model.ts @@ -8,6 +8,7 @@ * You should have received a copy of the MIT License along with this program. */ +import { pathBasename, pathExtension, pathNormalize } from '@deepkit/core'; import { AutoIncrement, entity, PrimaryKey } from '@deepkit/type'; @entity.name('deepkit/debugger/request') @@ -46,9 +47,85 @@ export class DebugRequest { constructor( public method: string, - public url: string, public clientIp: string, ) { } } + +@entity.name('deepkit/debugger/media-file') +export class MediaFile { + + public filesystem: number = 0; + public size: number = 0; + public lastModified?: Date; + public visibility: string = 'unknown'; + + // not available in Filesystem + public created?: Date; + public mimeType: string = ''; + + constructor( + public path: string, + public type: string = 'file', + ) { + this.path = pathNormalize(path); + } + + get id(): string { + return this.path; + } + + /** + * Returns true if this file is a symbolic link. + */ + isFile(): boolean { + return this.type === 'file'; + } + + /** + * Returns true if this file is a directory. + */ + isDirectory(): boolean { + return this.type === 'directory'; + } + + /** + * Returns the name (basename) of the file. + */ + get name(): string { + return pathBasename(this.path); + } + + /** + * Returns true if this file is in the given directory. + * + * /folder/file.txt => / => true + * /folder/file.txt => /folder => true + * /folder/file.txt => /folder/ => true + * + * /folder2/file.txt => /folder/ => false + * /folder/file.txt => /folder/folder2 => false + */ + inDirectory(directory: string): boolean { + directory = pathNormalize(directory); + if (directory === '/') return true; + return (this.directory + '/').startsWith(directory + '/'); + } + + /** + * Returns the directory (dirname) of the file. + */ + get directory(): string { + const lastSlash = this.path.lastIndexOf('/'); + return lastSlash <= 0 ? '/' : this.path.slice(0, lastSlash); + } + + /** + * Returns the extension of the file, or an empty string if not existing or a directory. + */ + get extension(): string { + if (!this.isFile()) return ''; + return pathExtension(this.path); + } +} diff --git a/packages/framework-debug-gui/package.json b/packages/framework-debug-gui/package.json index 58ffd5e32..6e81ff8f6 100644 --- a/packages/framework-debug-gui/package.json +++ b/packages/framework-debug-gui/package.json @@ -9,6 +9,7 @@ "watch": "NODE_OPTIONS=--preserve-symlinks ng build --watch", "test": "NODE_OPTIONS=--preserve-symlinks ng test", "lint": "NODE_OPTIONS=--preserve-symlinks ng lint", + "serve": "NODE_OPTIONS=--preserve-symlinks ng serve", "e2e": "NODE_OPTIONS=--preserve-symlinks ng e2e" }, "publishConfig": { diff --git a/packages/framework-debug-gui/src/app/app-routing.module.ts b/packages/framework-debug-gui/src/app/app-routing.module.ts index 7d892c7a8..eb0574ed5 100644 --- a/packages/framework-debug-gui/src/app/app-routing.module.ts +++ b/packages/framework-debug-gui/src/app/app-routing.module.ts @@ -17,6 +17,7 @@ import { EventsComponent } from './views/events/events.component'; import { HttpRequestComponent } from './views/http/request/http-request.component'; import { ProfileComponent } from './views/profile/profile.component'; import { ModulesComponent } from './views/modules/modules.component'; +import { FilesystemComponent } from './views/filesystem/filesystem.component'; const routes: Routes = [ { path: '', pathMatch: 'full', redirectTo: 'configuration' }, @@ -26,6 +27,7 @@ const routes: Routes = [ { path: 'profiler', component: ProfileComponent }, { path: 'modules', component: ModulesComponent }, { path: 'events', component: EventsComponent }, + { path: 'filesystem/:id', component: FilesystemComponent }, { path: 'http/request/:id', component: HttpRequestComponent }, ]; diff --git a/packages/framework-debug-gui/src/app/app.component.spec.ts b/packages/framework-debug-gui/src/app/app.component.spec.ts deleted file mode 100644 index fc86d4efa..000000000 --- a/packages/framework-debug-gui/src/app/app.component.spec.ts +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Deepkit Framework - * Copyright (C) 2021 Deepkit UG, Marc J. Schmidt - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the MIT License. - * - * You should have received a copy of the MIT License along with this program. - */ - -import { TestBed } from '@angular/core/testing'; -import { RouterTestingModule } from '@angular/router/testing'; -import { AppComponent } from './app.component'; - -describe('AppComponent', () => { - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ - RouterTestingModule - ], - declarations: [ - AppComponent - ], - }).compileComponents(); - }); - - it('should create the app', () => { - const fixture = TestBed.createComponent(AppComponent); - const app = fixture.componentInstance; - expect(app).toBeTruthy(); - }); - - it(`should have as title 'framework-debug-gui'`, () => { - const fixture = TestBed.createComponent(AppComponent); - const app = fixture.componentInstance; - expect(app.title).toEqual('framework-debug-gui'); - }); - - it('should render title', () => { - const fixture = TestBed.createComponent(AppComponent); - fixture.detectChanges(); - const compiled = fixture.nativeElement; - expect(compiled.querySelector('.content span').textContent).toContain('framework-debug-gui app is running!'); - }); -}); diff --git a/packages/framework-debug-gui/src/app/app.component.ts b/packages/framework-debug-gui/src/app/app.component.ts index ddf06b91d..dd8ebc92a 100644 --- a/packages/framework-debug-gui/src/app/app.component.ts +++ b/packages/framework-debug-gui/src/app/app.component.ts @@ -10,7 +10,7 @@ import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { DuiApp, observe } from '@deepkit/desktop-ui'; -import { Database, DebugRequest } from '@deepkit/framework-debug-api'; +import { Database, DebugRequest, Filesystem } from '@deepkit/framework-debug-api'; import { Collection } from '@deepkit/rpc'; import { ControllerClient } from './client'; import { Router } from '@angular/router'; @@ -38,6 +38,8 @@ import { Router } from '@angular/router';
+ +
Connected @@ -67,6 +69,13 @@ import { Router } from '@angular/router'; Modules Profiler API + Broker + HTTP Requests + + Filesystem + + {{filesystem.name}} + Database @@ -99,6 +108,7 @@ import { Router } from '@angular/router'; }) export class AppComponent implements OnInit, OnDestroy { databases: Database[] = []; + filesystems: Filesystem[] = []; sidebarVisible: boolean = true; @@ -111,6 +121,9 @@ export class AppComponent implements OnInit, OnDestroy { protected cd: ChangeDetectorRef, public router: Router, ) { + client.client.transporter.reconnected.subscribe(() => { + this.load(); + }); } ngOnDestroy(): void { @@ -129,10 +142,15 @@ export class AppComponent implements OnInit, OnDestroy { // return this.requests; // } - async ngOnInit() { + async load() { this.databases = await this.client.debug.databases(); + this.filesystems = await this.client.debug.filesystems(); // this.requests = await this.controllerClient.getHttpRequests(); this.cd.detectChanges(); } + async ngOnInit() { + await this.load(); + } + } diff --git a/packages/framework-debug-gui/src/app/app.module.ts b/packages/framework-debug-gui/src/app/app.module.ts index e69b4bad0..654a80584 100644 --- a/packages/framework-debug-gui/src/app/app.module.ts +++ b/packages/framework-debug-gui/src/app/app.module.ts @@ -18,12 +18,14 @@ import { DuiCheckboxModule, DuiFormComponent, DuiIconModule, + DuiIndicatorModule, DuiInputModule, DuiListModule, DuiRadioboxModule, DuiSelectModule, DuiTableModule, DuiWindowModule, + provideState, } from '@deepkit/desktop-ui'; import { ConfigurationComponent } from './views/configuration/configuration.component'; import { HttpComponent } from './views/http/http.component'; @@ -32,7 +34,6 @@ import { FormsModule } from '@angular/forms'; import { RpcComponent } from './views/rpc/rpc.component'; import { WorkflowCardComponent, WorkflowComponent } from './components/workflow.component'; import { EventsComponent } from './views/events/events.component'; -import { DeepkitClient } from '@deepkit/rpc'; import { OverlayModule } from '@angular/cdk/overlay'; import { HttpRequestComponent } from './views/http/request/http-request.component'; import { OrmBrowserModule } from '@deepkit/orm-browser-gui'; @@ -42,6 +43,11 @@ import { ProfileTimelineComponent } from './views/profile/timeline.component'; import { ModulesComponent } from './views/modules/modules.component'; import { ModuleDetailComponent, ModuleDetailServiceComponent } from './views/modules/module-detail.component'; import { DeepkitUIModule } from '@deepkit/ui-library'; +import { FilesystemComponent } from './views/filesystem/filesystem.component'; +import { RpcWebSocketClient } from '@deepkit/rpc'; +import { State } from './state'; +import { MediaComponent, MediaFileCache, MediaFileDetail, MediaFileQuickLookCache, MediaFileThumbnail, MediaQuickLook } from './views/filesystem/media.component'; +import { FileUploaderComponent } from './components/file-uploader.component'; @NgModule({ declarations: [ @@ -58,6 +64,13 @@ import { DeepkitUIModule } from '@deepkit/ui-library'; ModulesComponent, ModuleDetailComponent, ModuleDetailServiceComponent, + FilesystemComponent, + + MediaComponent, + MediaQuickLook, + MediaFileThumbnail, + MediaFileDetail, + FileUploaderComponent, ], imports: [ BrowserModule, @@ -80,10 +93,14 @@ import { DeepkitUIModule } from '@deepkit/ui-library'; DuiIconModule, DuiListModule, DuiTableModule, + DuiIndicatorModule, ], providers: [ - { provide: DeepkitClient, useFactory: () => new DeepkitClient(ControllerClient.getServerHost()) }, + { provide: RpcWebSocketClient, useFactory: () => new RpcWebSocketClient(ControllerClient.getServerHost()) }, ControllerClient, + provideState(State), + MediaFileCache, + MediaFileQuickLookCache, ], bootstrap: [AppComponent] }) diff --git a/packages/framework-debug-gui/src/app/client.ts b/packages/framework-debug-gui/src/app/client.ts index fca493373..9b19e3e38 100644 --- a/packages/framework-debug-gui/src/app/client.ts +++ b/packages/framework-debug-gui/src/app/client.ts @@ -9,8 +9,8 @@ */ import { Injectable } from '@angular/core'; -import { Collection, DeepkitClient } from '@deepkit/rpc'; -import { DebugControllerInterface, DebugRequest, Workflow } from '@deepkit/framework-debug-api'; +import { Collection, RpcWebSocketClient } from '@deepkit/rpc'; +import { DebugControllerInterface, DebugMediaInterface, DebugRequest, Workflow } from '@deepkit/framework-debug-api'; @Injectable() export class ControllerClient { @@ -18,18 +18,23 @@ export class ControllerClient { protected workflows: { [name: string]: Promise } = {}; public readonly debug = this.client.controller(DebugControllerInterface); + public readonly media = this.client.controller(DebugMediaInterface); static getServerHost(): string { const proto = location.protocol === 'https:' ? 'wss://' : 'ws://'; return proto + (location.port === '4200' ? location.hostname + ':8080' : location.host) + location.pathname; } - constructor(public client: DeepkitClient) { + constructor(public client: RpcWebSocketClient) { client.transporter.disconnected.subscribe(() => { this.tryToConnect(); }); } + getUrl(path: string): string { + return location.protocol + '//' + (location.port === '4200' ? location.hostname + ':8080' : location.host) + path; + } + tryToConnect() { this.client.connect().catch(() => { setTimeout(() => { diff --git a/packages/framework-debug-gui/src/app/components/file-uploader.component.ts b/packages/framework-debug-gui/src/app/components/file-uploader.component.ts new file mode 100644 index 000000000..943bd7abb --- /dev/null +++ b/packages/framework-debug-gui/src/app/components/file-uploader.component.ts @@ -0,0 +1,71 @@ +import { EventDispatcher } from "@deepkit/event"; +import { fileQueuedEvent, FileToUpload, fileUploadedEvent, State } from "../state"; +import { ChangeDetectorRef, Component } from "@angular/core"; +import { ControllerClient } from "../client"; +import { ClientProgress } from "@deepkit/rpc"; + +@Component({ + selector: 'app-file-uploader', + styles: [` + :host { + display: block; + margin-right: 10px; + } + `], + template: ` + + Upload {{currentIndex}} of {{state.volatile.filesToUpload.length}}: + + Cancel + + ` +}) +export class FileUploaderComponent { + upload?: FileToUpload; + + constructor(private events: EventDispatcher, private cd: ChangeDetectorRef, public state: State, private client: ControllerClient) { + events.listen(fileQueuedEvent, () => { + this.checkNext(); + }); + } + + get filesToUpload(): FileToUpload[] { + return this.state.volatile.filesToUpload.filter(v => !v.done && !v.errored); + } + + get currentIndex(): number { + if (!this.upload) return 0; + return this.state.volatile.filesToUpload.indexOf(this.upload) + 1; + } + + cancel() { + + } + + checkNext() { + if (this.upload && !this.upload.done) return; + + const files = this.filesToUpload; + if (!files.length) return; + const file = files[0]; + console.log('next', file); + if (!file) return; + + this.upload = file; + file.progress = ClientProgress.track(); + this.cd.detectChanges(); + this.client.media.addFile(file.filesystem, file.name, file.dir, file.data).then(() => { + file.done = true; + this.cd.detectChanges(); + this.checkNext(); + this.events.dispatch(fileUploadedEvent); + }, error => { + file.errored = true; + this.checkNext(); + console.log('error', error); + this.cd.detectChanges(); + this.events.dispatch(fileUploadedEvent); + }); + } +} + diff --git a/packages/framework-debug-gui/src/app/state.ts b/packages/framework-debug-gui/src/app/state.ts new file mode 100644 index 000000000..b972605b9 --- /dev/null +++ b/packages/framework-debug-gui/src/app/state.ts @@ -0,0 +1,31 @@ +import { EfficientState } from '@deepkit/desktop-ui'; +import { EventToken } from '@deepkit/event'; +import { Progress } from '@deepkit/rpc'; +import { Excluded } from '@deepkit/type'; + +export const fileQueuedEvent = new EventToken('file.queued'); +export const fileAddedEvent = new EventToken('file.added'); +export const fileUploadedEvent = new EventToken('file.uploaded'); + +export interface FileToUpload { + filesystem: number; + name: string; + dir: string; + data: Uint8Array; + progress?: Progress; + done?: true; + errored?: true; +} + +export class VolatileState { + filesToUpload: FileToUpload[] = []; +} + +export class State extends EfficientState { + + volatile: VolatileState & Excluded = new VolatileState(); + + media: { view: 'icons' | 'list' } = { + view: 'icons' + }; +} diff --git a/packages/framework-debug-gui/src/app/utils.ts b/packages/framework-debug-gui/src/app/utils.ts new file mode 100644 index 000000000..969d07be8 --- /dev/null +++ b/packages/framework-debug-gui/src/app/utils.ts @@ -0,0 +1,23 @@ +import { Subscription } from 'rxjs'; +import { isFunction } from '@deepkit/core'; + +export function trackByIndex(index: number) { + return index; +} + +export class Lifecycle { + public callbackDestroyer: (() => any)[] = []; + + add(callback: Subscription | (() => any)) { + if (isFunction(callback)) { + this.callbackDestroyer.push(callback); + } else { + this.callbackDestroyer.push(() => callback.unsubscribe()); + } + } + + destroy() { + for (const i of this.callbackDestroyer) i(); + this.callbackDestroyer = []; + } +} diff --git a/packages/framework-debug-gui/src/app/views/configuration/configuration.component.ts b/packages/framework-debug-gui/src/app/views/configuration/configuration.component.ts index 512c620e2..5a50f55e1 100644 --- a/packages/framework-debug-gui/src/app/views/configuration/configuration.component.ts +++ b/packages/framework-debug-gui/src/app/views/configuration/configuration.component.ts @@ -8,104 +8,115 @@ * You should have received a copy of the MIT License along with this program. */ -import { ChangeDetectorRef, Component, OnInit } from '@angular/core'; +import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { ControllerClient } from '../../client'; import { ConfigOption } from '@deepkit/framework-debug-api'; +import { Lifecycle } from '../../utils'; @Component({ - template: ` -
-
-

Application configuration

- -
-

- Application config values from your root application module. -

- - - - - - - -
- -
-
-

Module configuration

- -
-

- Config values from core modules and your imported modules. -

- - - - - - - -
- `, - styles: [` - :host { - display: flex; - height: 100%; - max-width: 100%; - } + template: ` +
+
+

Application configuration

+ +
+

+ Application config values from your root application module. +

+ + + + + + + +
- .section { - flex: 1 1 auto; - height: 100%; - display: flex; - margin: 5px; - flex-direction: column; - overflow: hidden; - } +
+
+

Module configuration

+ +
+

+ Config values from core modules and your imported modules. +

+ + + + + + + +
+ `, + styles: [` + :host { + display: flex; + height: 100%; + max-width: 100%; + } - .header { - display: flex; - } + .section { + flex: 1 1 auto; + height: 100%; + display: flex; + margin: 5px; + flex-direction: column; + overflow: hidden; + } - .header dui-input { - margin-left: auto; - } + .header { + display: flex; + } - .section h4 { - margin-bottom: 10px; - } + .header dui-input { + margin-left: auto; + } - dui-table { - flex: 1; - } - `] + .section h4 { + margin-bottom: 10px; + } + + dui-table { + flex: 1; + } + `] }) -export class ConfigurationComponent implements OnInit { - public applicationConfigFilter: string = ''; - public configFilter: string = ''; +export class ConfigurationComponent implements OnInit, OnDestroy { + public applicationConfigFilter: string = ''; + public configFilter: string = ''; - public applicationConfig: ConfigOption[] = []; - public config: ConfigOption[] = []; + public applicationConfig: ConfigOption[] = []; + public config: ConfigOption[] = []; + lifecycle = new Lifecycle(); - constructor( - private controllerClient: ControllerClient, - public cd: ChangeDetectorRef, - ) { - } + constructor( + private client: ControllerClient, + public cd: ChangeDetectorRef, + ) { + this.lifecycle.add(client.client.transporter.reconnected.subscribe(() => this.load())); + } - filter(items: ConfigOption[], filter: string): any[] { - if (!filter) return items; + filter(items: ConfigOption[], filter: string): any[] { + if (!filter) return items; - return items.filter(v => v.name.includes(filter)); - } + return items.filter(v => v.name.includes(filter)); + } - async ngOnInit(): Promise { - const configuration = await this.controllerClient.debug.configuration(); + ngOnDestroy() { + this.lifecycle.destroy(); + } - this.applicationConfig = configuration.appConfig; - this.config = configuration.modulesConfig; + async ngOnInit(): Promise { + await this.load(); + } - this.cd.detectChanges(); - } + async load() { + const configuration = await this.client.debug.configuration(); + + this.applicationConfig = configuration.appConfig; + this.config = configuration.modulesConfig; + + this.cd.detectChanges(); + } } diff --git a/packages/framework-debug-gui/src/app/views/events/events.component.ts b/packages/framework-debug-gui/src/app/views/events/events.component.ts index bbd8a7313..d3720f7a4 100644 --- a/packages/framework-debug-gui/src/app/views/events/events.component.ts +++ b/packages/framework-debug-gui/src/app/views/events/events.component.ts @@ -8,63 +8,72 @@ * You should have received a copy of the MIT License along with this program. */ -import { ChangeDetectorRef, Component, OnInit } from '@angular/core'; +import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { ControllerClient } from '../../client'; import { Event } from '@deepkit/framework-debug-api'; +import { Lifecycle } from '../../utils'; @Component({ - template: ` -
-

Events

- -
- - - - - - - `, - styles: [` - :host { - display: flex; - flex-direction: column; - height: 100%; - } + template: ` +
+

Events

+ +
+ + + + + + + `, + styles: [` + :host { + display: flex; + flex-direction: column; + height: 100%; + } - .header { - display: flex; - margin-bottom: 15px; - } + .header { + display: flex; + margin-bottom: 15px; + } - .header dui-input { - margin-left: auto; - } - `] + .header dui-input { + margin-left: auto; + } + `] }) -export class EventsComponent implements OnInit { - public events: Event[] = []; +export class EventsComponent implements OnInit, OnDestroy { + public events: Event[] = []; + public filterQuery: string = ''; + lifecycle = new Lifecycle(); - public filterQuery: string = ''; + constructor( + private client: ControllerClient, + public cd: ChangeDetectorRef, + ) { + this.lifecycle.add(client.client.transporter.reconnected.subscribe(() => this.load())); + } - constructor( - private controllerClient: ControllerClient, - public cd: ChangeDetectorRef, - ) { - } + ngOnDestroy() { + this.lifecycle.destroy(); + } - filter(items: Event[], filter: string): any[] { - if (!filter) return items; + filter(items: Event[], filter: string): any[] { + if (!filter) return items; - return items.filter(v => (v.controller.includes(filter) || v.methodName.includes(filter))); - } + return items.filter(v => (v.controller.includes(filter) || v.methodName.includes(filter))); + } - async ngOnInit(): Promise { - this.events = await this.controllerClient.debug.events(); + async load() { + this.events = await this.client.debug.events(); + this.cd.detectChanges(); + } - this.cd.detectChanges(); - } + async ngOnInit(): Promise { + await this.load(); + } } diff --git a/packages/framework-debug-gui/src/app/views/filesystem/filesystem.component.ts b/packages/framework-debug-gui/src/app/views/filesystem/filesystem.component.ts new file mode 100644 index 000000000..3bffc3938 --- /dev/null +++ b/packages/framework-debug-gui/src/app/views/filesystem/filesystem.component.ts @@ -0,0 +1,25 @@ +import { Component } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; + + +@Component({ + template: ` + + ` +}) +export class FilesystemComponent { + filesystemId?: number; + + constructor( + private activatedRoute: ActivatedRoute + ) { + activatedRoute.params.subscribe(params => { + this.filesystemId = Number(params.id); + this.load(); + }); + } + + async load() { + if (undefined === this.filesystemId) return; + } +} diff --git a/packages/framework-debug-gui/src/app/views/filesystem/media-detail.component.scss b/packages/framework-debug-gui/src/app/views/filesystem/media-detail.component.scss new file mode 100644 index 000000000..613d25b9b --- /dev/null +++ b/packages/framework-debug-gui/src/app/views/filesystem/media-detail.component.scss @@ -0,0 +1,41 @@ +:host { + display: block; + position: relative; + width: 100%; + height: 100%; +} + +.image { + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + + &.fit img { + max-width: 100%; + max-height: 100%; + } +} + +.default, .pdf { + width: 100%; + height: 100%; +} + +.loading { + position: absolute; + left: 0; + top: 0; + width: 100%; + text-align: center; + padding: 25px; + opacity: 0; + transition: opacity 0.3s ease-in-out; + pointer-events: none; + + &.visible { + opacity: 1; + } +} + diff --git a/packages/framework-debug-gui/src/app/views/filesystem/media-file-quick-look.component.scss b/packages/framework-debug-gui/src/app/views/filesystem/media-file-quick-look.component.scss new file mode 100644 index 000000000..b8dbf6997 --- /dev/null +++ b/packages/framework-debug-gui/src/app/views/filesystem/media-file-quick-look.component.scss @@ -0,0 +1,35 @@ +:host { + display: flex; + flex-direction: column; + height: 100%; +} + +.quick-look-content { + flex: 1; + margin: 4px; + border-radius: 5px; + background: #1c1c1c; + + display: flex; + justify-content: center; + align-items: center; + + img { + max-width: 100%; + max-height: 100%; + } +} + + +.stats { + display: grid; + gap: 5px; + padding: 2px 8px; + grid-template-columns: repeat(auto-fill, minmax(50px, 90px)); + grid-auto-rows: max-content; + font-size: 12px; + + .label { + color: var(--text-light); + } +} diff --git a/packages/framework-debug-gui/src/app/views/filesystem/media-file-thumbnail.component.scss b/packages/framework-debug-gui/src/app/views/filesystem/media-file-thumbnail.component.scss new file mode 100644 index 000000000..5618f1d41 --- /dev/null +++ b/packages/framework-debug-gui/src/app/views/filesystem/media-file-thumbnail.component.scss @@ -0,0 +1,99 @@ +:host { + display: flex; + flex-direction: column; + //justify-content: center; + justify-items: center; + align-items: center; + height: 100%; + + .no-preview { + } + + .title { + display: inline; + text-overflow: ellipsis; + //overflow: hidden; + flex: 1; + max-width: 100%; + font-size: 12px; + height: 50px; + text-align: center; + //word-break: break-word; + word-break: break-all; + line-height: 14px; + + span { + max-width: 100%; + -webkit-box-decoration-break: clone; + box-decoration-break: clone; + padding: 2px 5px; + border-radius: 5px; + } + + textarea, input { + height: 100%; + max-width: 100%; + background-color: transparent; + border-radius: 3px; + border: 1px solid var(--dui-selection-light); + color: var(--text-grey); + text-align: center; + font: inherit; + } + + input { + width: 100%; + text-align: left; + } + } + + &.list { + flex-direction: row; + + img { + width: 18px; + height: 18px; + padding: 0; + } + + .title { + height: auto; + text-align: left; + } + } + + img { + padding: 5px 7px; + //max-width: 100%; + width: 80px; + height: 80px; + object-fit: contain; + margin-bottom: 3px; + max-width: 100%; + max-height: 100%; + + &.image-hidden { + padding: 0; + width: 0; + height: 0; + margin: 0; + } + } + + app-loading-spinner { + } + + &.selected:not(.list) { + img, app-loading-spinner { + border-radius: 5px; + //background-color: rgba(35, 35, 35, 0.87); + background-color: var(--line-color-light); + } + + .title span { + background-color: var(--dui-selection); + //box-shadow: inset 0px 0px 0px 1000px rgba(35, 35, 35, 0.87); + } + } + +} diff --git a/packages/framework-debug-gui/src/app/views/filesystem/media.component.scss b/packages/framework-debug-gui/src/app/views/filesystem/media.component.scss new file mode 100644 index 000000000..a01ea7dd0 --- /dev/null +++ b/packages/framework-debug-gui/src/app/views/filesystem/media.component.scss @@ -0,0 +1,55 @@ +:host { + display: flex; + flex-direction: column; + height: 100%; + width: 100%; +} + +dui-button-groups { + padding-left: 0; + padding-right: 0; +} + +dui-button.tight { + padding: 0 5px; +} + +.content { + border-top: 1px solid var(--line-color-light); + margin-top: 15px; + flex: 1; + + &.file { + overflow: hidden; + } + + dui-table { + height: 100%; + } + + &.folder { + display: grid; + gap: 15px; + padding: 15px; + grid-template-columns: repeat(auto-fill, minmax(50px, 110px)); + grid-auto-rows: max-content; + + &.list { + display: block; + padding: 0; + border-top: 0; + } + } + + &.file-drop-hover { + background-color: rgba(112, 112, 112, 0.14); + } + + .error { + color: red; + text-align: center; + opacity: 0.5; + grid-column: 1/-1; + } +} + diff --git a/packages/framework-debug-gui/src/app/views/filesystem/media.component.ts b/packages/framework-debug-gui/src/app/views/filesystem/media.component.ts new file mode 100644 index 000000000..4bd4cf1db --- /dev/null +++ b/packages/framework-debug-gui/src/app/views/filesystem/media.component.ts @@ -0,0 +1,1015 @@ +import { + ChangeDetectorRef, + Component, + ElementRef, + EventEmitter, + HostListener, + Injectable, + Input, + OnChanges, + OnDestroy, + OnInit, + Output, + QueryList, + SimpleChanges, + ViewChild, + ViewChildren +} from '@angular/core'; +import { ControllerClient } from '../../client'; +import { ActivatedRoute, Router } from '@angular/router'; +import { EventDispatcher } from '@deepkit/event'; +import { fileAddedEvent, fileQueuedEvent, fileUploadedEvent, State } from '../../state'; +import { DropdownComponent, DuiDialog, FilePickerItem } from '@deepkit/desktop-ui'; +import { ClientProgress, Progress } from '@deepkit/rpc'; +import { asyncOperation } from '@deepkit/core'; +import { MediaFile } from '@deepkit/framework-debug-api'; +import { Lifecycle, trackByIndex } from '../../utils'; + +function imageSize(data: Uint8Array): Promise<{ width: number, height: number } | undefined> { + const blob = new Blob([data]); + return new Promise((resolve, reject) => { + const img = new Image(); + img.src = URL.createObjectURL(blob); + img.onload = function () { + resolve({ width: img.naturalWidth, height: img.naturalHeight }); + }; + img.onerror = function () { + resolve(undefined); + }; + }); +} + +function isImage(file: MediaFile): boolean { + //determine based on mimeType if it's an image + return !!file.mimeType && file.mimeType.startsWith('image/'); +} + +function mimeTypeToLabel(mimeType?: string) { + if (!mimeType) return 'Unknown'; + + const map: { [name: string]: string } = { + 'application/octet-stream': 'Binary', + 'application/json': 'JSON', + 'application/xml': 'XML', + 'application/xhtml+xml': 'XHTML', + 'application/javascript': 'JavaScript', + 'application/typescript': 'TypeScript', + + 'application/pdf': 'PDF', + 'application/zip': 'ZIP', + 'application/x-rar-compressed': 'RAR', + 'application/x-7z-compressed': '7Z', + 'application/x-tar': 'TAR', + 'application/x-gzip': 'GZIP', + 'application/x-bzip2': 'BZIP2', + 'application/x-bzip': 'BZIP', + 'application/x-lzma': 'LZMA', + 'application/x-xz': 'XZ', + 'application/x-compress': 'COMPRESS', + 'application/x-apple-diskimage': 'DMG', + 'application/x-msdownload': 'EXE', + 'application/x-ms-dos-executable': 'EXE', + 'application/x-msi': 'MSI', + 'application/x-ms-shortcut': 'LNK', + 'application/x-shockwave-flash': 'SWF', + 'application/x-sqlite3': 'SQLITE', + 'application/x-iso9660-image': 'ISO', + 'application/x-ms-wim': 'WIM', + 'application/x-ms-xbap': 'XAP', + + 'video/x-msvideo': 'AVI', + 'video/x-ms-wmv': 'WMV', + 'video/x-ms-asf': 'ASF', + + 'video/mp4': 'MP4', + 'video/mpeg': 'MPEG', + 'video/quicktime': 'MOV', + 'audio/x-wav': 'WAV', + 'audio/x-aiff': 'AIFF', + 'audio/x-m4a': 'M4A', + 'audio/x-ms-wma': 'WMA', + 'audio/x-ms-wax': 'WAX', + 'audio/x-ms-wpl': 'WPL', + 'audio/x-mpegurl': 'M3U', + 'audio/x-scpls': 'PLS', + 'audio/x-flac': 'FLAC', + 'audio/x-ogg': 'OGG', + 'audio/x-matroska': 'MKV', + + 'image/jpeg': 'JPEG', + 'image/png': 'PNG', + 'image/gif': 'GIF', + 'image/bmp': 'BMP', + 'image/tiff': 'TIFF', + + 'text/plain': 'TXT', + 'text/html': 'HTML', + 'text/javascript': 'JS', + 'text/json': 'JSON', + + 'text/css': 'CSS', + 'text/csv': 'CSV', + 'text/calendar': 'ICS', + 'text/xml': 'XML', + }; + return map[mimeType] || (mimeType.split('/')[1] || 'Unknown').toUpperCase(); +} + +@Injectable() +export class MediaFileCache { + file: { [id: string]: { file: MediaFile, data: Uint8Array, loaded: Date } | false } = {}; + loadingState: { [id: string]: Promise<{ file: MediaFile, data: Uint8Array } | false> } = {}; + + //5 minutes + maxAge = 5 * 60 * 1000; + + constructor(protected client: ControllerClient) { + setInterval(() => { + this.clean(); + }, 30_000); + } + + clean() { + //remove files older than maxAge + const now = new Date; + for (const id in this.file) { + const file = this.file[id]; + if (file && now.getTime() - file.loaded.getTime() > this.maxAge) { + delete this.file[id]; + delete this.loadingState[id]; + } + } + } + + isLoading(id: string) { + return this.file[id] === undefined; + } + + getPreview(id: string) { + return this.file[id]; + } + + load(filesystem: number, id?: string): Promise<{ file: MediaFile, data: Uint8Array } | false> { + if (!id) return Promise.resolve(false); + if (this.loadingState[id] !== undefined) return this.loadingState[id]; + + return this.loadingState[id] = asyncOperation(async (resolve) => { + try { + const preview = await this.getData(filesystem, id); + this.file[id] = preview ? { ...preview, loaded: new Date } : preview; + resolve(preview); + } catch (error: any) { + console.log('error', error); + resolve(false); + } + }); + } + + async getData(fs: number, id: string): Promise<{ file: MediaFile, data: Uint8Array } | false> { + throw new Error('Not implemented'); + } +} + +@Injectable() +export class MediaFileQuickLookCache extends MediaFileCache { + async getData(fs: number, id: string) { + return await this.client.media.getMediaQuickLook(fs, id); + } +} + +@Component({ + selector: 'app-media-detail', + styleUrls: ['./media-detail.component.scss'], + template: ` +
+ +
+ +
+ Image +
+ + + + +
+ +
+
+ ` +}) +export class MediaFileDetail implements OnChanges, OnInit { + @Input() file!: { filesystem: number, id: string, mimeType?: string }; + @Input() data?: Uint8Array | false; + + progress?: Progress; + + get type(): string { + if (!this.file.mimeType) return 'unknown'; + + if (this.file.mimeType.startsWith('image/')) return 'image'; + if (this.file.mimeType.startsWith('video/')) return 'video'; + if (this.file.mimeType.startsWith('audio/')) return 'audio'; + if (this.file.mimeType.startsWith('application/pdf')) return 'pdf'; + + //all text types like txt, html, etc + if (this.file.mimeType.startsWith('text/')) return 'text'; + if (this.file.mimeType.startsWith('application/json')) return 'text'; + if (this.file.mimeType.startsWith('application/xml')) return 'text'; + if (this.file.mimeType.startsWith('application/javascript')) return 'text'; + + return 'unknown'; + } + + constructor(private client: ControllerClient) { + + } + + async ngOnInit() { + await this.load(); + } + + async ngOnChanges() { + await this.load(); + } + + async load() { + this.progress = ClientProgress.track(); + this.data = await this.client.media.getMediaData(this.file.filesystem, this.file.id); + } + + download() { + window.open(`/api/shop/media/${this.file.id}/download`); + } +} + +@Component({ + selector: 'app-media-file-thumbnail', + styleUrls: ['./media-file-thumbnail.component.scss'], + template: ` + Folder icon + File preview + + +
+ + + + + {{truncateFileName(file.name)}} +
+ `, + host: { + '[class.selected]': 'selected', + '[class.list]': `view === 'list'`, + } +}) +export class MediaFileThumbnail implements OnInit, OnDestroy, OnChanges { + loadedUrl = ''; + loading = true; + noPreview = false; + + newName = ''; + + @Input() view: 'icons' | 'list' = 'icons'; + + @Input() file!: MediaFile; + @Output() fileChange = new EventEmitter(); + @Input() withTitle: boolean = true; + @Input() selected: boolean = false; + @Input() rename: boolean = false; + @Output() renamed = new EventEmitter(); + + @ViewChild('input') set inputRef(ref: ElementRef) { + if (!!ref) { + setTimeout(() => { + ref.nativeElement.focus(); + ref.nativeElement.setSelectionRange(0, this.newName.lastIndexOf('.') || this.newName.length); + }); + } + } + + constructor(private client: ControllerClient, public elementRef: ElementRef, private cd: ChangeDetectorRef) { + } + + loaded(error: boolean = false) { + this.loading = false; + this.noPreview = error; + this.cd.detectChanges(); + } + + doRename(name: string | undefined) { + this.renamed.emit(name); + this.newName = this.file.name; + } + + onContextMenu() { + if (!this.selected) { + this.fileChange.next(this.file); + } + } + + selectFile($event: MouseEvent) { + this.fileChange.next(this.file); + $event.stopPropagation(); + $event.preventDefault(); + } + + ngOnInit() { + this.newName = this.file.name; + // this.loadPreview(); + } + + get url() { + if (!this.file.mimeType.startsWith('image/')) return ''; + + return this.client.getUrl('/_debug/api/media/' + this.file.filesystem + '/preview?path=' + encodeURIComponent(this.file.path)); + } + + ngOnDestroy() { + } + + ngOnChanges(changes: SimpleChanges) { + this.newName = this.file.name; + + this.loading = false; + this.noPreview = true; + + if (this.url) { + this.noPreview = false; + if (this.loadedUrl !== this.url) { + this.loading = true; + this.loadedUrl = this.url; + } + } + } + + // @observeAction + // async loadPreview() { + // await this.mediaFileCache.load(this.file.id); + // } + + truncateFileName(name: string) { + if (this.view === 'list') return name; + //if file name is too long, cut it in the middle and add ... + const maxLength = 25; + if (name.length > maxLength) { + return name.substr(0, maxLength / 2) + '...' + name.substr(name.length - maxLength / 2, name.length); + } + return name; + } +} + +@Component({ + selector: 'app-media-file-quick-look', + styleUrls: ['./media-file-quick-look.component.scss'], + template: ` + + + + + +
+ {{file.name}} +
+
+ + + +
+
+ + Folder icon + File preview + + + +
+
+
+
Size
+
+
{{file.size | fileSize}}
+
{{dim.width}}x{{dim.height}}
+
+
+
+
Type
+
{{mimeTypeToLabel(file.mimeType)}}
+
+ + + + +
+
Modified
+
{{file.lastModified | date:'medium'}}
+
+
+ `, + host: { + '[attr.tabindex]': '0', + } +}) +export class MediaQuickLook implements OnInit, OnDestroy, OnChanges { + mimeTypeToLabel = mimeTypeToLabel; + isImage = isImage; + imageSize = imageSize; + + @Input() files!: MediaFile[]; + + @Output() close = new EventEmitter(); + + dim?: { width: number, height: number }; + + file?: MediaFile; + + constructor( + public mediaFileCache: MediaFileQuickLookCache, + private client: ControllerClient, + private cd: ChangeDetectorRef, + ) { + } + + next() { + const index = this.files.indexOf(this.file!); + if (index < this.files.length - 1) { + this.select(this.files[index + 1]); + } else { + this.select(this.files[0]); + } + } + + prev() { + const index = this.files.indexOf(this.file!); + if (index > 0) { + this.select(this.files[index - 1]); + } else { + this.select(this.files[this.files.length - 1]); + } + } + + @HostListener('keydown', ['$event']) + onClose($event: KeyboardEvent) { + if ($event.key === 'Escape' || $event.key === ' ') { + $event.stopPropagation(); + this.close.next(); + } + } + + ngOnDestroy() { + } + + ngOnChanges(changes: SimpleChanges) { + this.load(); + } + + ngOnInit() { + this.load(); + } + + load() { + if (this.file && !this.files.includes(this.file)) { + this.file = undefined; + } + + if (!this.file) { + this.select(this.files[0]); + } + } + + async select(file: MediaFile) { + if (this.file === file) return; + this.file = file; + + const preview = await this.mediaFileCache.load(file.filesystem, file.id); + if (preview && isImage(file)) { + this.dim = await imageSize(preview.data); + } + this.cd.detectChanges(); + } +} + +function sortByName(a: MediaFile, b: MediaFile) { + return a.name.localeCompare(b.name); +} + +function sortByCreated(a: MediaFile, b: MediaFile) { + const at = a.created?.getTime() || 0; + const bt = b.created?.getTime() || 0; + return at - bt; +} + +function sortByModified(a: MediaFile, b: MediaFile) { + const at = a.lastModified?.getTime() || 0; + const bt = b.lastModified?.getTime() || 0; + return at - bt; +} + +function sortByMimeType(a: MediaFile, b: MediaFile) { + return (a.mimeType || '').localeCompare(b.mimeType || ''); +} + +function sortBySize(a: MediaFile, b: MediaFile) { + return a.size - b.size; +} + +// folder first +function sortByType(a: MediaFile, b: MediaFile) { + return a.type === 'directory' ? -1 : 1; +} + +@Component({ + selector: 'app-media', + styleUrls: ['./media.component.scss'], + template: ` + + + + + Folder + Upload + + + + Name + Created + Modified + Type + Size + + Ascending + Descending + + Folder first + + + + Icons + List + + + + + New + Sort + View + + + + + + + + + + + + + + + + + + + + + Open + Open Public URL + Open Private URL + + Delete + + Rename + + Quick Look + + + + + + + + + + + + +
+ +
+ +
Path {{path}} not found
+
+
+ + + + + + + + + + + + {{item.modified | date:'medium'}} + + + + + {{item.size | fileSize}} + + + + + {{item.type === 'directory' ? 'Folder' : mimeTypeToLabel(item.mimeType)}} + + + + + + + + + + + + + +
+ `, + host: { + '[attr.tabindex]': '0', + } +}) +export class MediaComponent implements OnInit, OnDestroy { + trackByIndex = trackByIndex; + mimeTypeToLabel = mimeTypeToLabel; + + @Input() filesystem: number = 0; + + path: string = '/'; + + media?: MediaFile | false; + files: MediaFile[] = []; + + sort = { by: 'name', direction: 'asc', folderFirst: true }; + + @Input() dialogMode: boolean = false; + @Input() selectMultiple: boolean = false; + + @Output() filesSelected = new EventEmitter(); + @Output() activeFile = new EventEmitter(); + + @ViewChild('quickLook', { read: DropdownComponent }) quickLook?: DropdownComponent; + + @ViewChildren(MediaFileThumbnail, { read: MediaFileThumbnail }) thumbnails?: QueryList; + + selected: string[] = []; + renameFile?: string; + + loading = false; + + lifecycle = new Lifecycle(); + + constructor( + private element: ElementRef, + private client: ControllerClient, + private dialog: DuiDialog, + public state: State, + public router: Router, + public activatedRoute: ActivatedRoute, + public events: EventDispatcher, + public cd: ChangeDetectorRef, + ) { + this.lifecycle.add(this.events.listen(fileAddedEvent, () => this.load())); + this.lifecycle.add(this.events.listen(fileUploadedEvent, () => this.load())); + activatedRoute.queryParams.subscribe(params => { + if (params.path) { + this.path = params.path; + this.load(); + } + }); + } + + tableSelect(selected: MediaFile[]) { + this.selected = selected.map(v => v.path); + } + + getSelectedFiles(): MediaFile[] { + return this.files.filter(file => this.selected.includes(file.id)); + } + + sortBy(by: string) { + this.sort.by = by; + this.sortFiles(); + } + + sortDirection(direction: 'asc' | 'desc') { + this.sort.direction = direction; + this.sortFiles(); + } + + toggleFolderFirst() { + this.sort.folderFirst = !this.sort.folderFirst; + this.sortFiles(); + } + + async fileRenamed(newName?: string) { + if (!this.renameFile) return; + try { + if (!newName) return; + await this.client.media.renameFile(this.filesystem, this.renameFile, newName); + } finally { + this.renameFile = undefined; + this.element.nativeElement.focus(); + } + await this.loadActiveFolder(); + } + + sortFiles() { + if (this.sort.by === 'name') { + this.files.sort(sortByName); + } else if (this.sort.by === 'size') { + this.files.sort(sortBySize); + } else if (this.sort.by === 'created') { + this.files.sort(sortByCreated); + } else if (this.sort.by === 'modified') { + this.files.sort(sortByModified); + } else if (this.sort.by === 'type') { + this.files.sort(sortByMimeType); + } + + if (this.sort.direction === 'desc') { + this.files.reverse(); + } + + if (this.sort.folderFirst) { + this.files.sort(sortByType); + } + } + + @HostListener('keydown', ['$event']) + keyDown(event: KeyboardEvent) { + console.log('keydown', event.key, this.renameFile); + if (this.renameFile) return; + + if (event.key === 'Enter') { + this.renameFile = this.selected[0]; + } + + if (event.key === 'Delete') { + this.deleteSelected(); + } else if (event.key === ' ') { + if (!this.quickLook) return; + if (this.quickLook.isOpen) { + this.quickLook.close(); + } else { + this.openQuickLook(); + } + } + + if (this.thumbnails) { + const thumbnails = this.thumbnails.toArray().map(v => ({ element: v.elementRef, file: v.file })); + const selected = thumbnails.find(t => t.file.id === this.selected[this.selected.length - 1]); + if (!selected) { + //special handling + return; + } + const selectedRect = selected?.element.nativeElement.getBoundingClientRect(); + let index = thumbnails.findIndex(t => t.file.id === this.selected[this.selected.length - 1]); + + if (event.key === 'ArrowLeft') { + while (index-- > 0) { + const thumbnailRect = thumbnails[index].element.nativeElement.getBoundingClientRect(); + if (thumbnailRect.top !== selectedRect.top) break; + if (thumbnailRect.left < selectedRect.left) { + this.select(thumbnails[index].file); + return; + } + } + if (index > 0) { + this.select(thumbnails[index].file); + } + } else if (event.key === 'ArrowRight') { + while (index++ < thumbnails.length - 1) { + const thumbnailRect = thumbnails[index].element.nativeElement.getBoundingClientRect(); + if (thumbnailRect.top !== selectedRect.top) break; + if (thumbnailRect.left > selectedRect.left) { + this.select(thumbnails[index].file); + return; + } + } + if (index < thumbnails.length - 1) { + this.select(thumbnails[index].file); + } + } else if (event.key === 'ArrowDown') { + while (index++ < thumbnails.length - 1) { + const thumbnailRect = thumbnails[index].element.nativeElement.getBoundingClientRect(); + if (thumbnailRect.left !== selectedRect.left) continue; + if (thumbnailRect.top > selectedRect.top) { + this.select(thumbnails[index].file); + return; + } + } + this.select(thumbnails[thumbnails.length - 1].file); + } else if (event.key === 'ArrowUp') { + while (index-- > 0) { + const thumbnailRect = thumbnails[index].element.nativeElement.getBoundingClientRect(); + if (thumbnailRect.left !== selectedRect.left) continue; + if (thumbnailRect.top < selectedRect.top) { + this.select(thumbnails[index].file); + return; + } + } + this.select(thumbnails[0].file); + } + } + } + + openQuickLook() { + if (!this.quickLook) return; + if (!this.selected.length) return; + const selected = this.getSelectedFileElementForQuickLook(); + this.quickLook.open('center', selected); + } + + @HostListener('dblclick', ['$event']) + onOpen() { + if (!this.selected.length || this.renameFile) return; + const files = this.getSelectedFiles(); + this.open(files[0].path, true); + } + + openPrivate(path: string) { + open(this.client.getUrl('/_debug/api/media/' + this.filesystem + '?path=' + encodeURIComponent(path)), '_blank'); + } + + async openPublic(path: string) { + const url = await this.client.media.getPublicUrl(this.filesystem, path); + open(url, '_blank'); + } + + + open(path: string, andSelect = false) { + //in dialog mode we just change path + if (this.dialogMode) { + this.path = path; + this.load(andSelect); + } else { + this.router.navigate([], { queryParams: { path }, queryParamsHandling: 'merge' }); + } + } + + goUp() { + const path = this.path.split('/').slice(0, -1).join('/') || '/'; + this.open(path); + } + + closeQuickLook() { + if (!this.quickLook) return; + this.quickLook.close(); + this.element.nativeElement.focus(); + } + + async deleteSelected() { + console.log('deleteSelected', this.selected); + if (!this.selected.length) return; + const a = await this.dialog.confirm('Delete', 'Are you sure you want to delete the selected files?'); + if (!a) return; + await this.client.media.remove(this.filesystem, this.selected); + await this.load(); + } + + selectBackground($event: MouseEvent) { + if (this.state.media.view === 'icons') { + this.selected = []; + if (this.quickLook) this.quickLook.close(); + } + } + + private getSelectedFileElementForQuickLook(): ElementRef | undefined { + return this.thumbnails?.toArray().find(t => t.file.id === this.selected[this.selected.length - 1])?.elementRef; + } + + select(file: MediaFile) { + if (window.event instanceof PointerEvent || window.event instanceof KeyboardEvent) { + if (window.event.shiftKey && this.selected.length) { + // select all files between first in `files` and `file` + const first = this.files.findIndex(v => v.id === this.selected[0]); + const last = this.files.indexOf(file); + this.selected = this.files.slice(Math.min(first, last), Math.max(first, last) + 1).map(v => v.id); + return; + } + + if (window.event.ctrlKey || window.event.metaKey) { + if (this.selected.includes(file.id)) { + this.selected.splice(this.selected.indexOf(file.id), 1); + } else { + this.selected.push(file.id); + } + } else { + this.selected = [file.id]; + } + } else { + this.selected = [file.id]; + } + + this.filesSelected.emit(this.selected.map(v => this.files.find(f => f.id === v)!)); + if (this.quickLook) this.quickLook.setInitiator(this.getSelectedFileElementForQuickLook()); + this.cd.detectChanges(); + } + + ngOnDestroy() { + this.lifecycle.destroy(); + } + + async upload(files: FilePickerItem | FilePickerItem[]) { + if (!this.media) return; + + if (!Array.isArray(files)) files = [files]; + for (const file of files) { + this.state.volatile.filesToUpload.push({ + filesystem: this.filesystem, + dir: this.media.type === 'directory' ? this.media.path : this.media.directory, + name: file.name, + data: file.data, + }); + } + await this.events.dispatch(fileQueuedEvent); + } + + getFolder() { + return this.path || '/'; + } + + async createFolder() { + const name = await this.dialog.prompt('Folder name', ''); + if (!name) return; + await this.client.media.createFolder(this.filesystem, this.getFolder() + '/' + name); + await this.load(); + } + + ngOnInit() { + this.load(); + } + + async load(andSelect = false) { + if (this.loading) return; + + this.loading = true; + try { + const path = this.getFolder(); + if (!this.dialogMode) { + this.router.navigate([], { queryParams: { path }, queryParamsHandling: 'merge' }); + } + this.media = await this.client.media.getFile(this.filesystem, path); + console.log('this.media', this.media); + if (this.media) this.activeFile.emit(this.media); + if (this.media && andSelect) { + //for what? + } + await this.loadActiveFolder(); + } finally { + this.loading = false; + this.cd.detectChanges(); + } + } + + async loadActiveFolder() { + if (this.media && this.media.type === 'directory') { + await this.loadFolder(this.media.path); + } + } + + async loadFolder(dir: string) { + this.files = await this.client.media.getFiles(this.filesystem, dir); + console.log('files', this.files); + this.sortFiles(); + this.cd.detectChanges(); + } +} diff --git a/packages/framework-debug-gui/src/assets/images/icons/file-icon-unknown.png b/packages/framework-debug-gui/src/assets/images/icons/file-icon-unknown.png new file mode 100644 index 000000000..4145dbcc0 Binary files /dev/null and b/packages/framework-debug-gui/src/assets/images/icons/file-icon-unknown.png differ diff --git a/packages/framework-debug-gui/src/assets/images/icons/folder-icon-dark.png b/packages/framework-debug-gui/src/assets/images/icons/folder-icon-dark.png new file mode 100644 index 000000000..270cf2744 Binary files /dev/null and b/packages/framework-debug-gui/src/assets/images/icons/folder-icon-dark.png differ diff --git a/packages/framework-debug-gui/src/assets/images/icons/folder-icon-light.png b/packages/framework-debug-gui/src/assets/images/icons/folder-icon-light.png new file mode 100644 index 000000000..90bf7eb66 Binary files /dev/null and b/packages/framework-debug-gui/src/assets/images/icons/folder-icon-light.png differ diff --git a/packages/framework-debug-gui/src/assets/images/icons/picture.svg b/packages/framework-debug-gui/src/assets/images/icons/picture.svg new file mode 100644 index 000000000..e51524d28 --- /dev/null +++ b/packages/framework-debug-gui/src/assets/images/icons/picture.svg @@ -0,0 +1,7 @@ + + + picture + + + + \ No newline at end of file diff --git a/packages/framework-debug-gui/tsconfig.json b/packages/framework-debug-gui/tsconfig.json index 3416c83d3..674ef4438 100644 --- a/packages/framework-debug-gui/tsconfig.json +++ b/packages/framework-debug-gui/tsconfig.json @@ -21,6 +21,11 @@ "dom" ] }, + "reflection": [ + "./src/app/state.ts", + "node_modules/@deepkit/api-console-gui/src/api.ts", + "node_modules/@deepkit/api-console-gui/src/app/store.ts" + ], "angularCompilerOptions": { "enableI18nLegacyMessageIdFormat": false, "strictInjectionParameters": true, diff --git a/packages/framework/package.json b/packages/framework/package.json index f843a59ab..0cb915e5f 100644 --- a/packages/framework/package.json +++ b/packages/framework/package.json @@ -57,12 +57,14 @@ "buffer": "^5.2.1", "compression": "^1.7.4", "date-fns": "^2.16.1", + "jimp": "^0.22.10", "enhanced-resolve": "^5.8.2", "faker": "5.4.0", "fs-extra": "^9.0.1", "get-parameter-names": "^0.3.0", "md5": "^2.2.1", "mime-types": "^2.1.27", + "image-size": "^1.0.2", "nice-table": "^1.1.0", "pirates": "^4.0.1", "selfsigned": "^2.1.1", diff --git a/packages/framework/src/debug/debug-http.controller.ts b/packages/framework/src/debug/debug-http.controller.ts new file mode 100644 index 000000000..6478417ca --- /dev/null +++ b/packages/framework/src/debug/debug-http.controller.ts @@ -0,0 +1,77 @@ +import { http, HttpQuery, HttpResponse } from '@deepkit/http'; +import { FilesystemRegistry } from '../filesystem.js'; +import { Filesystem } from '@deepkit/filesystem'; +import mime from 'mime-types'; +import jimp from 'jimp'; +import { imageSize } from 'image-size'; + +function send(response: HttpResponse, data: Uint8Array, name: string, mimeType?: string, lastModified?: Date) { + response.setHeader('Cache-Control', 'max-age=31536000'); + response.setHeader('Expires', new Date(Date.now() + 31536000 * 1000).toUTCString()); + if (lastModified) { + response.setHeader('Last-Modified', lastModified.toUTCString()); + response.setHeader('ETag', lastModified.getTime().toString()); + } + response.setHeader('Content-Disposition', 'inline; filename="' + name + '"'); + mimeType = mimeType || ''; + if (mimeType) response.setHeader('Content-Type', mimeType); + response.setHeader('Content-Length', data.byteLength); + response.end(data); +} + +export class DebugHttpController { + constructor( + protected filesystemRegistry: FilesystemRegistry, + ) { + } + + protected getFilesystem(id: number): Filesystem { + const fs = this.filesystemRegistry.getFilesystems()[id]; + if (!fs) throw new Error(`No filesystem with id ${id} found`); + return fs; + } + + + @http.GET('api/media/:fs') + async media(fs: number, path: HttpQuery, response: HttpResponse) { + const filesystem = this.getFilesystem(fs); + const file = await filesystem.get(path); + const mimeType = mime.lookup(path) || ''; + //todo stream + const data = await filesystem.read(path); + send(response, data, file.name, mimeType, file.lastModified); + } + + + @http.GET('api/media/:fs/preview') + async mediaPreview(fs: number, path: HttpQuery, response: HttpResponse) { + const filesystem = this.getFilesystem(fs); + const file = await filesystem.get(path); + const mimeType = mime.lookup(path) || ''; + + if (mimeType.startsWith('image/')) { + const data = await filesystem.read(path); + if (mimeType === 'image/svg+xml') { + send(response, data, file.name, mimeType, file.lastModified); + return; + } + + const size = imageSize(Buffer.from(data)); + if (size.width || 0 > 400 || size.height || 0 > 400) { + try { + const img = await jimp.read(Buffer.from(data)); + const buffer = await img.resize(800, 800).getBufferAsync(mimeType); + send(response, buffer, file.name, mimeType, file.lastModified); + return; + } catch (error: any) { + return response.status(404); + } + } + + send(response, data, file.name, mimeType, file.lastModified); + return; + } + + return response.status(404); + } +} diff --git a/packages/framework/src/debug/debug.controller.ts b/packages/framework/src/debug/debug.controller.ts index a57366159..029d91caf 100644 --- a/packages/framework/src/debug/debug.controller.ts +++ b/packages/framework/src/debug/debug.controller.ts @@ -14,6 +14,7 @@ import { DatabaseEntity, DebugControllerInterface, Event, + Filesystem, ModuleApi, ModuleImportedService, ModuleService, @@ -23,7 +24,7 @@ import { Workflow } from '@deepkit/framework-debug-api'; import { rpc, rpcClass } from '@deepkit/rpc'; -import { parseRouteControllerAction, HttpRouter } from '@deepkit/http'; +import { HttpRouter, parseRouteControllerAction } from '@deepkit/http'; import { changeClass, ClassType, getClassName, isClass } from '@deepkit/core'; import { EventDispatcher, isEventListenerContainerEntryService } from '@deepkit/event'; import { DatabaseAdapter, DatabaseRegistry } from '@deepkit/orm'; @@ -37,6 +38,7 @@ import { getScope, resolveToken, Token } from '@deepkit/injector'; import { AppModule, ServiceContainer } from '@deepkit/app'; import { RpcControllers } from '../rpc.js'; import { ReflectionClass, serializeType, stringifyType } from '@deepkit/type'; +import { FilesystemRegistry } from '../filesystem.js'; @rpc.controller(DebugControllerInterface) export class DebugController implements DebugControllerInterface { @@ -50,6 +52,7 @@ export class DebugController implements DebugControllerInterface { protected config: Pick, protected rpcControllers: RpcControllers, protected databaseRegistry: DatabaseRegistry, + protected filesystemRegistry: FilesystemRegistry, protected stopwatchStore?: FileStopwatchStore, // protected liveDatabase: LiveDatabase, ) { @@ -124,6 +127,17 @@ export class DebugController implements DebugControllerInterface { return databases; } + @rpc.action() + filesystems(): Filesystem[] { + const filesystems: Filesystem[] = []; + + for (const fs of this.filesystemRegistry.getFilesystems()) { + filesystems.push({ name: getClassName(fs), adapter: getClassName(fs.adapter), options: {} }); + } + + return filesystems; + } + @rpc.action() events(): Event[] { const events: Event[] = []; diff --git a/packages/framework/src/debug/media.controller.ts b/packages/framework/src/debug/media.controller.ts new file mode 100644 index 000000000..fc12c36c3 --- /dev/null +++ b/packages/framework/src/debug/media.controller.ts @@ -0,0 +1,112 @@ +/* + * Deepkit Framework + * Copyright (C) 2021 Deepkit UG, Marc J. Schmidt + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the MIT License. + * + * You should have received a copy of the MIT License along with this program. + */ +import { DebugMediaInterface, MediaFile } from '@deepkit/framework-debug-api'; +import { rpc } from '@deepkit/rpc'; +import { FilesystemRegistry } from '../filesystem.js'; +import { Filesystem, FilesystemFile } from '@deepkit/filesystem'; +import { pathDirectory, pathJoin } from '@deepkit/core'; +import mime from 'mime-types'; + +function mapFile(filesystem: number, file: FilesystemFile): MediaFile { + const mediaFile = new MediaFile(file.path, file.type); + mediaFile.size = file.size; + mediaFile.filesystem = filesystem; + mediaFile.lastModified = file.lastModified; + mediaFile.visibility = file.visibility; + mediaFile.mimeType = mime.lookup(file.path) || ''; + return mediaFile; +} + +@rpc.controller(DebugMediaInterface) +export class MediaController implements DebugMediaInterface { + constructor( + protected filesystemRegistry: FilesystemRegistry, + ) { + } + + protected getFilesystem(id: number): Filesystem { + const fs = this.filesystemRegistry.getFilesystems()[id]; + if (!fs) throw new Error(`No filesystem with id ${id} found`); + return fs; + } + + @rpc.action() + async getPublicUrl(fs: number, path: string): Promise { + const filesystem = this.getFilesystem(fs); + return filesystem.publicUrl(path); + } + + @rpc.action() + async createFolder(fs: number, path: string): Promise { + const filesystem = this.getFilesystem(fs); + await filesystem.makeDirectory(path); + } + + @rpc.action() + async getFile(fs: number, path: string): Promise { + const filesystem = this.getFilesystem(fs); + const file = await filesystem.getOrUndefined(path); + if (!file) return false; + + return mapFile(fs, file); + } + + @rpc.action() + async getFiles(fs: number, path: string): Promise { + const filesystem = this.getFilesystem(fs); + const files = await filesystem.files(path); + return files.map(file => mapFile(fs, file)); + } + + @rpc.action() + async getMediaData(fs: number, path: string): Promise { + const filesystem = this.getFilesystem(fs); + try { + return await filesystem.read(path); + } catch (error) { + return false; + } + } + + @rpc.action() + async getMediaQuickLook(fs: number, path: string): Promise<{ file: MediaFile; data: Uint8Array } | false> { + if (path === '/') return false; + + const filesystem = this.getFilesystem(fs); + const file = await filesystem.get(path); + const mimeType = mime.lookup(path) || ''; + + if (mimeType.startsWith('image/')) { + const data = await filesystem.read(path); + //todo: make smaller if too big + return { file: mapFile(fs, file), data: data }; + } + return false; + } + + @rpc.action() + async remove(fs: number, paths: string[]): Promise { + const filesystem = this.getFilesystem(fs); + await filesystem.delete(paths); + } + + @rpc.action() + async addFile(fs: number, name: string, dir: string, data: Uint8Array): Promise { + const filesystem = this.getFilesystem(fs); + await filesystem.write(pathJoin(dir, name), data); + } + + @rpc.action() + async renameFile(fs: number, path: string, newName: string): Promise { + const filesystem = this.getFilesystem(fs); + filesystem.move(path, pathJoin(pathDirectory(path), newName)); + return Promise.resolve(''); + } +} diff --git a/packages/framework/src/filesystem.ts b/packages/framework/src/filesystem.ts new file mode 100644 index 000000000..c49a21c0d --- /dev/null +++ b/packages/framework/src/filesystem.ts @@ -0,0 +1,29 @@ +import { Filesystem, FilesystemLocalAdapter } from '@deepkit/filesystem'; +import { AppModule } from '@deepkit/app'; +import { ClassType } from '@deepkit/core'; +import { InjectorContext } from '@deepkit/injector'; + +export class FilesystemRegistry { + protected filesystems: { classType: ClassType, module: AppModule }[] = []; + + constructor(protected injectorContext: InjectorContext) { + } + + addFilesystem(classType: ClassType, module: AppModule) { + this.filesystems.push({ classType, module }); + } + + getFilesystems(): Filesystem[] { + return this.filesystems.map(v => { + return this.injectorContext.get(v.classType, v.module); + }); + } +} + +export class PublicFilesystem extends Filesystem { + constructor(publicDir: string, publicBaseUrl: string) { + super(new FilesystemLocalAdapter({ root: publicDir }), { + baseUrl: publicBaseUrl, + }); + } +} diff --git a/packages/framework/src/module.ts b/packages/framework/src/module.ts index 38d75cf79..8d384a277 100644 --- a/packages/framework/src/module.ts +++ b/packages/framework/src/module.ts @@ -19,7 +19,7 @@ import { DebugDIController } from './cli/debug-di.js'; import { ServerStartController } from './cli/server-start.js'; import { DebugController } from './debug/debug.controller.js'; import { registerDebugHttpController } from './debug/http-debug.controller.js'; -import { HttpLogger, HttpModule, HttpRequest, serveStaticListener } from '@deepkit/http'; +import { http, HttpLogger, HttpModule, HttpRequest, serveStaticListener } from '@deepkit/http'; import { InjectorContext, injectorReference, ProviderWithScope, Token } from '@deepkit/injector'; import { FrameworkConfig } from './module.config.js'; import { LoggerInterface } from '@deepkit/logger'; @@ -40,6 +40,10 @@ import { ApiConsoleModule } from '@deepkit/api-console-module'; import { AppModule, ControllerConfig, createModule } from '@deepkit/app'; import { RpcControllers, RpcInjectorContext, RpcKernelWithStopwatch } from './rpc.js'; import { normalizeDirectory } from './utils.js'; +import { FilesystemRegistry, PublicFilesystem } from './filesystem.js'; +import { Filesystem } from '@deepkit/filesystem'; +import { MediaController } from './debug/media.controller.js'; +import { DebugHttpController } from './debug/debug-http.controller.js'; export class FrameworkModule extends createModule({ config: FrameworkConfig, @@ -50,6 +54,7 @@ export class FrameworkModule extends createModule({ RpcServer, MigrationProvider, DebugController, + FilesystemRegistry, { provide: DatabaseRegistry, useFactory: (ic: InjectorContext) => new DatabaseRegistry(ic) }, { provide: RpcKernel, @@ -131,6 +136,8 @@ export class FrameworkModule extends createModule({ ]; protected dbs: { module: AppModule, classType: ClassType }[] = []; + protected filesystems: { module: AppModule, classType: ClassType }[] = []; + protected rpcControllers = new RpcControllers; process() { @@ -147,7 +154,15 @@ export class FrameworkModule extends createModule({ this.getImportedModuleByClass(HttpModule).configure({ parser: this.config.httpParse }); if (this.config.publicDir) { - this.addListener(serveStaticListener(this, normalizeDirectory(this.config.publicDirPrefix), this.config.publicDir)); + const localPublicDir = join(process.cwd(), this.config.publicDir); + + this.addListener(serveStaticListener(this, normalizeDirectory(this.config.publicDirPrefix), localPublicDir)); + + this.addProvider({ + provide: PublicFilesystem, useFactory: () => { + return new PublicFilesystem(localPublicDir, this.config.publicDirPrefix); + } + }); } if (this.config.debug) { @@ -160,9 +175,16 @@ export class FrameworkModule extends createModule({ useFactory: (registry: DatabaseRegistry) => new OrmBrowserController(registry.getDatabases()) }); this.addController(DebugController); + this.addController(MediaController); this.addController(OrmBrowserController); registerDebugHttpController(this, this.config.debugUrl); + @http.controller(this.config.debugUrl) + class ScopedDebugHttpController extends DebugHttpController { + } + + this.addController(ScopedDebugHttpController); + //only register the RPC controller this.addImport(new ApiConsoleModule({ listen: false, markdown: '' }).rename('internalApi')); @@ -188,6 +210,10 @@ export class FrameworkModule extends createModule({ postProcess() { //all providers are known at this point this.setupDatabase(); + + for (const fs of this.filesystems) { + this.setupProvider().addFilesystem(fs.classType, fs.module); + } } protected setupDatabase() { @@ -208,6 +234,9 @@ export class FrameworkModule extends createModule({ if (isPrototypeOfBase(token, Database)) { this.dbs.push({ classType: token as ClassType, module }); } + if (isPrototypeOfBase(token, Filesystem)) { + this.filesystems.push({ classType: token as ClassType, module }); + } } processController(module: AppModule, config: ControllerConfig) { diff --git a/packages/framework/tests/broker.spec.ts b/packages/framework/tests/broker.spec.ts index 67b7e6392..08a94cffe 100644 --- a/packages/framework/tests/broker.spec.ts +++ b/packages/framework/tests/broker.spec.ts @@ -86,3 +86,74 @@ test('entity channel uuid', async () => { }); } }); + +// test('in app', () => { +// function provideBroker(...args: any[]): any { +// //todo +// } +// +// +// type Fn = (a: number, b: {}, c: Database) => any; +// +// type IsPrimitive = T extends string | number | boolean | undefined | null | symbol | bigint ? true : false; +// +// type OptionalNonPrimitives = T extends (...args: infer A) => any ? +// (...args: { [K in keyof A]: IsPrimitive extends true ? A[K] : (A[K] | undefined) }) => any +// : never; +// +// type NewFn = OptionalNonPrimitives; +// +// type BrokerCacheQuery> = { +// get(...args: D[1]): Promise; +// } +// +// interface BrokerCache { +// query>(cache: D): BrokerCacheQuery; +// } +// +// class MemoryBrokerAdapter {} +// +// +// type User = { id: number, username: string }; +// +// const userCache = defineCache( +// 'user/:id', +// async (id: number, database: Database): Promise => { +// return await database.query().filter({ id }).findOne(); +// } +// ); +// +// const app = new App({ +// providers: [ +// provideBroker(new MemoryBrokerAdapter, userCache) +// ] +// }); +// +// app.command('test', async (cache: BrokerCache) => { +// const user = await cache.query(userCache).get(2); +// }); +// +// app.command('test', async (userCacheQuery: BrokerCacheQuery) => { +// const user = await userCacheQuery.get(3); +// }); +// }); +// +// test('idea2', () =>{ +// type UserCache = BrokerCacheKey<'user/:id', {id: number}, User>; +// +// const app = new App({ +// providers: [ +// provideBroker( +// new MemoryBrokerAdapter, +// defineCache(async (options, database: Database) => { +// return await database.query().filter({ id: options.id }).findOne(); +// }) +// ) +// ] +// }); +// +// app.command('test', async (userCache: BrokerCache) => { +// +// }); +// +// }); diff --git a/tsconfig.esm.json b/tsconfig.esm.json index 3d43bab91..b9497b992 100644 --- a/tsconfig.esm.json +++ b/tsconfig.esm.json @@ -52,6 +52,9 @@ { "path": "packages/framework/tsconfig.esm.json" }, + { + "path": "packages/framework-debug-api/tsconfig.esm.json" + }, { "path": "packages/mongo/tsconfig.esm.json" },