electron, webpack, typescript: electron-webpack dev findet Module nicht

Ich habe ein (künstliche reduziertes) Repro hier in GitHub abgelegt. Erst einmal handelt es sich um ein relativ normales electron-webpack-ts Projekt, allerdings mit einer Besonderheit: in einem Unterverzeichnis lib (im realen Szenario ein ganzes Repository als GIT submodule) ist eine Art Bibliothek eingebunden.

In dieser Bibliothek sind (aus historischen Gründen) die Typescript Deklarationen (typings) von den eigentlichen Quellen (src) getrennt. Eine tsconfig.json stellt sicher, dass die Bibliothek auch als eigenständiges Repository gebaut und insbesondere getestet werden kann – nicht im Repro Szenario, da fehlt alles außer dieser einen Konfigurationsdatei und selbst die habe ich auf das unmittelbar Notwendige reduziert. Auf die Struktur der Bibliothek habe ich keinen Einfluss, da diese in vielen anderen Projekten so wie sie ist erwartet wird.

Im eigentlichen Code (renderer) wird eine Methode aus der Bibliothek verwendet.

import * as React from "react"
import { render } from "react-dom"
import { createTest } from "lib"

render(
    <div>[HELLO WORLD]</div>,
    document.querySelector('body > div#app')
)

export const test2 = createTest()

Damit das Modul lib gefunden wird, werden die Typdeklarationen über die tsconfig.json eingebunden, nichts Besonderes soweit.

{
    "extends": "./node_modules/electron-webpack/tsconfig-base.json",
    "compilerOptions": {
        "jsx": "react"
    },
    "include": [
        "lib/typings/**/*.d.ts",
        "src/**/*.ts",
        "src/**/*.tsx"
    ]
}

Durch die Trennung von Code und Deklaration in der Bibliothek reicht das für webpack natürlich nicht für das Bundling, und wir müssen in der package.json eine Anpassung der webpack Konfiguration aktivieren.

    "electronWebpack": {
        "renderer": {
            "webpackConfig": "webpack.override.js"
        }
    },

In der webpack.override.js wird ein Alias für lib definiert.

    config.resolve = { ...config.resolve }

    config.resolve.alias = {
        ...config.resolve.alias,
        'lib': resolve(__dirname, 'lib/src'),
    }

Damit sollten wir eigentlich fertig sein und auch die exotische Struktur der Bibliothek nutzen können. In der Tat läuft auch electron-webpack sauber durch. Beim Starten der Entwicklungsumgebung mit electron-webpack dev gibt es aber leider eine böse Überraschung:

  ERROR in ./lib/src/createTest.ts
  Module not found: Error: Can't resolve 'api' in '.../electron-tsloader-watch-problem/lib/src'
   @ ./lib/src/createTest.ts 1:0-29 2:34-40
   @ ./lib/src/index.ts
   @ ./src/renderer/index.tsx
   @ multi css-hot-loader/hotModuleReplacement ./src/renderer/index.tsx

Warum gibt es plötzlich das Modul nicht mehr? Die Ursache ist wohl, dass zum schnelleren Start bei dev der ts-loader mit transpileOnly true verwendet wird. Meine Vermutung ist, dass hier die Modulreferenzen nicht vollständig aufgelöst werden und der folgende Übersetzungsvorgang (babel denke ich) im hot-reloader diese dann nicht mehr findet.

import { helper, ISomeTest } from "api"

export function createTest(data = helper.one): ISomeTest {
    return { data }
}

Interessant ist, dass der Effekt nicht auftritt, wenn nur Interfaces (hier ISomeTest) referenziert werden, nicht aber zum Beispiel eine enum (hier helper).

Den einfachsten Fix den ich gefunden habe, ist transpileOnly false zu erzwingen – was allerdings den Startvorgang in der Entwicklungsumgebung merklich verzögert. Die Implementierung dazu findet sich wieder in der webpack.override.js.

    const ts = config.module.rules
        .map((r) => r.use)
        .map((u) => Array.isArray(u) && u[0])
        .filter((p) => p && p.loader === 'ts-loader')
        .map((l) => l.options)[0]

    if (ts && ts.transpileOnly && process.env.APPLYWORKAROUND) {
        ts.transpileOnly = false
    }

Setzt man vor dem Aufruf von electron-webpack dev die Umgebungsvariable APPLYWORKAROUND auf irgendwas (e.g. yes) dann läuft dieser wie gewünscht durch.

Der Work-Around ist aber mehr als hässlich und stark von der konkreten Implementierung im electron-webpack abhängig. Eine richtige Lösung etwa in Form einer Konfiguration in der package.json wäre sicher schöner, aber immerhin hilft es in diesem besonderen Fall auch mit dieser Legacy Bibliothek arbeiten zu können.

Vielleicht erspart der Tipp dem einen oder anderen eine längere Suche – ich fand das nicht wirklich einfach zu lokalisieren und zu verstehen. Ich kann mir vorstellen, dass ähnliche Effekte vielleicht auch in weniger exotischen Anwendungen auftreten.

So long

Jochen

react-calendar vs. MobX

Wir verwenden den react-calender (3.4.0) im Zusammenspiel mit MobX (6.3.1) / mobx-react (7.2.0) und nach langem erfolgreichen Einsatz gibt es eine Kleinigkeit, die etwas überrascht hat. Hier eine kurze Beschreibung der Hintergründe und meine aktuelle Lösungsidee – nicht sehr elegant, aber aktuell einfach ein Nebenschauplatz. Das storybook als Beispiel habe ich unten angehängt (TypeScript).

Started man die Story, so wird in jede Zelle des Kalenders ein zusätzlicher Text eingeblendet.

Ändert man allerdings im Eingabefeld den Wert, so tut sich im Kalender nichts.
Wie kann das sein? Ich gebe hier mal meine Interpretation ab:
  • Im Beispiel (wie auch im realen Projekt) werden die Daten als observable Objekt (data) durchgereicht.
  • Für die Eingabe (InputTest im Beispiel) funktioniert das auch wie gewünscht / immer.
  • Der eigentlich wichtige Komponente (CalendarTest) ignoriert das aber.

Warum könnte das sein? Nun tatsächlich ist es ja der entscheidende Gag von MobX dass sich in der Tat data gar nicht ändert, sondern immer ein identischer Proxy auf die eigentlichen Daten ist. Erst der Einsatz des observer Dekorators sorgt dafür, dass Datenzugriffe in der render Methode vermerkt werden und Änderungen sehr detailliert zu Aktualisierungen führen. Aber hier passiert nichts, obwohl doch „offensichtlich“ auf props.data.text zugegriffen wird, um den zusätzliche Inhalt anzuzeigen.

Mein aktueller Kenntnisstand dazu: die Kalender Komponente erzeugt die Zellen (und damit auch den tileContent) nicht im render, sondern im static getDerivedStateFromProps – und tatsächlich haben sich aus Sicht von React die props ja gar nicht verändert! Ich vermute es handelt sich hier um eine Optimierung, die auch fast immer greift – bis so High Tech Lösungen via MobX eine Änderungsüberwachung jenseits von React einbringen.

Ich habe mich für den Moment entschieden, einen Work-Around zu verwenden – eine für fast alle Anwender von react-calendar traurige Lösung wäre auf das getDerivedStateFromProps zu verzichten, eine andere im MobX durch den observer auch getDerivedStateFromProps überwachen zu lassen, da gibt es wohl schon ähnliche Diskussionen in den Issues.

Als Lösung gibt man der Kalender Komponente einfach eine zusätzliche key Eigenschaft, die sich immer verändert, wenn die Zellen neu erstellt werden sollen – natürlich nicht wie hier manuell über eine Schaltfläche sondern elegant über eine MobX computed Eigenschaft, aber das Detail erspare ich mir hier einfach, jeder der MobX verwendet weiß sicher, was ich meine.

Drückt man in der Story die Schaltfläche, so wird über die veränderte Eigenschaft die Kalender Komponte gezwungen, ihr getDerivedStateFromProps neu aufzubauen – et voilà.

Have Fun!

Jochen

import { withKnobs } from '@storybook/addon-knobs'
import { observable } from 'mobx'
import { observer } from 'mobx-react'
import * as React from 'react'
import Calendar from 'react-calendar'

export default { decorators: [withKnobs], title: 'Fehler et al/Kalendar Tile Aktualisierung' }

interface IEntity {
    text: string
}

interface ICalendarTestProps {
    data: IEntity
    key: number
}

const CalendarTest = observer(
    (props: ICalendarTestProps): JSX.Element => (
        <Calendar
            key={props.key}
            closeCalendar={false}
            tileContent={() => <div>{props.data.text}</div>}
            value={null}
            view='month'
        />
    )
)

interface IInputTestTestProps {
    data: IEntity
}

const InputTest = observer(
    (props: IInputTestTestProps): JSX.Element => (
        <input type='text' value={props.data.text} onChange={(ev) => (props.data.text = ev.target.value)} />
    )
)

export const standard = ((): React.ReactNode => {
    const data = React.useMemo<IEntity>(() => observable({ text: 'C' }), [])
    const [key, setKey] = React.useState(0)

    return (
        <div>
            <button onClick={() => setKey(key + 1)}>UPDATE KEY</button>
            <InputTest data={data} />
            <CalendarTest key={key} data={data} />
        </div>
    )
}) as React.FC

JOIN – the MongoDb Way

Das Äquivalent zum relationalen SQL JOIN ist bei MongoDb die $lookup Aggregation. In der einfachsten Fassung trivial in der Handhabung, aber wenn es dann doch mal wieder etwas anspruchsvoller wird muss man vielleicht wie ich doch ins Handbuch schauen. Vielleicht hilft dem einen oder anderen ein kleines Beispiel, das neben der erweiterten Form von $lookup auch noch einige andere lustige Features verwendet.

Im (vereinfachten) Beispiel geht es um eine Collection (videos) mit Filmen. Relevant für das folgende sind nur diese Eigenschaften:

  • _id: die eindeutige Kennung eines Films
  • published: gesetzt (true), wenn der Film zur Benutzung freigegeben ist
  • tags: eine Verschlagwortung als Feld von Zeichenketten

Eine weitere Collection (playlists) fasst Filme zu Listen zusammen, die dann weitere Eigenschaft zur Vorauswahl enthalten können. Hier ist nur folgende Eigenschaft von Interesse:

  • videos: ein Feld mit eindeutigen Kennungen von Filmen

Die Aufgabe war es nun, alle Listen zu einer vorgegebenen Verschlagwortung zu finden und geeignet zu sortieren. Spannend an dem beschriebenen Ansatz ist, dass eine Liste keine eigene Verschlagwortung besitzt, sondern die seiner Filme erbt – natürlich hat das wie alles seine Vor- und Nachteile. Die Verschlagwortung von nicht freigegebenen Filmen wird nicht berücksichtigt.

Auf der playlist Collection beginnt die Aggregation Pipeline daher mit einem $lookup:

  • from: ‚videos‘ für ein JOIN mit den Filmen
  • as: ‚tags‘ als Name des neuen Feldes
  • let: { videos: ‚$videos‘ } definiert für den JOIN die Liste der Kennungen
  • pipeline: { $match: { $expr: { $in: [‚$_id‘, ‚$$videos‘] }, published: true } } verwendet diese Liste für ein konventionelles JOIN (als Fremdschlüssel auf den Primärschlüssel _id), beschränkt das Ergebnis aber auch auf freigegebene Filme

Das tags Feld von Filmen wird im nächsten Schritt (auch ein $addFields) der Pipeline in ein Feld von Schlagworten umgesetzt.

  • tags: { $reduce: { in: { $setUnion: [‚$$value‘, { $ifNull: [‚$$this.tags‘, []] }] }, initialValue: [], input: ‚$tags‘ } } verwendet nicht einfach $concatArrays sondern $setUnion und stellt so sicher, dass die berechnete Verschlagwortung jeder Liste jedes Schlagwort nur einmal enthält.

Im konkreten Beispiel wird ein Feld von Schlagworten (matchTags) vorgegeben und Listen werden danach bewertet, wie viele Schlagworte einer Liste tatsächlich übereinstimmen. Dazu werden weitere berechnete Felder mit einem separaten $addFields Schritt in die Pipeline aufgenommen.

  • tagMatch: { $size: { $setIntersection: [‚$tags‘, matchTags] } } bildet für jede Liste die Schnittmenge mit dem vorgegebenen Feld und daraus dann die Anzahl der passenden Schlagworte – 0 bedeutet, dass kein Schlagwort der Vorgabe entspricht, bei 1 passt genau ein Schlagwort und so weiter.
  • _random: { $rand: {} } kommt gleich noch und unterstützt eine mit MongoDb 4.4.2 eingeführte neue Funktionalität.

Mit einem $match Schritt kann die Pipeline erweitert werden, um die Listen auf eine Passung gemäß den vorgegebenen Schlagworten zu prüfen – Einschränkungen auf Listen an sich (sagen wir mal: nur Listen, die von einer bestimmten Person erstellt wurden) sollten natürlich ganz am Anfang der Pipeline stehen um das initiale $lookup auf die kleinste benötigte Anzahl von Listen zu beschränken.

Hier ein paar Beispiele:

  • { tagMatch: 0 } lässt nur Listen zu, in denen keine der Vorgaben passt
  • { tagMatch: {$gte: 2} } verlangt, dass eine Liste mindestens zwei der Schlagworte aus der Vorgaben haben muss

Im Ergebnis soll nun eine absteigende Sortierung nach der Passung (übereinstimmende Schlagworte gemäß der Vorgabe) erfolgen. Innerhalb eines Bereichs gleicher Passung sollen die Liste in zufälliger Reihenfolge erscheinen. Das macht dann ein $sort Schritt – wichtig ist natürlich die Reihenfolge der Sortierung, die Passung kommt immer zuerst:

  • { tagMatch: -1, _random: 1 } wobei die Richtung der Folgesortierung nach der Zufallszahl natürlich irrelevant ist und auch absteigend erfolgen kann.

Eigentlich ist unsere Aufgabe nun vollständig erledigt, aber auf Kosten einer sauberen Schnittstelle (API) werden im Ergebnis noch mit einem letzten Schritt in der Pipeline alle berechneten Felder wieder entfernt:

  • { $unset: [‚_random‘, ‚tags‘, ‚tagMatch‘] }

In der Zusammenfassung soll dieser Artikel auf folgende Aspekte einer MongoDb Aggregation neugierig machen:

  • $lookup mit let und pipeline – das vollständige Äquivalent eines relationalen SQL JOIN
  • $addFields mit $reduce, $setUnion und $setIntersection – Umgang mit Mengen statt Feldern
  • $rand – zufällige Werte, hier praktisch für eine zufällige Sortierung (nicht Auswahl, das wäre $sample)
  • $unset – für eine Block Box Funktionalität bereits in der Datenbank

Have Fun!

Jochen

[
    {
        $lookup: {
            as: 'tags',
            from: 'videos',
            let: { videos: '$videos' },
            pipeline: [
                {
                    $match: {
                        $expr: { $in: ['$_id', '$$videos'] },
                        published: true,
                    },
                },
            ],
        },
    },
    {
        $addFields: {
            tags: {
                $reduce: {
                    in: { $setUnion: [
                        '$$value', 
                        { $ifNull: ['$$this.tags', []] }
                    ] },
                    initialValue: [],
                    input: '$tags',
                },
            },
        },
    },
    {
        $addFields: {
            _random: { $rand: {} },
            tagMatch: { $size: { $setIntersection: ['$tags', matchTags] } },
        },
    },
    { $match: { tagMatch: { $gte: 1 } } },
    { $sort: { tagMatch: -1, _random: 1 } },
    { $unset: ['_random', 'tags', 'tagMatch'] },
]

Nützlich lernen…

Ausgehen von einem echten Bedarf habe ich mal wieder ein kleines Lernprojekt gestartet.

Gebraucht wurde ein kleines Werkzeug (eigentlich nur unter Windows), das in einem Dateibaum identisches Dateien (konkret Bilder) erkennt und bei der Bereinigung von Duplikaten unterstützt (auch einen eventuell unerfahrenen Anwender).

In verschiedenen Varianten (oft kostenpflichtig, zumeist als umfangreiches All-In-One Tool) gibt es so etwas schon, daher um kein zu schlechtes Gewissen zu bekommen habe ich daraus dann direkt ein Lernprojekt gemacht. Zum Beispiel:

  • das soll unter Windows und Linux laufen, daher habe ich mir electron als Entwicklungsplattform ausgesucht.
  • electron mit WebPack, CSS Modulen u.a. hatte ich noch nie probiert.
  • Ein Deployment mit dem electron Builder ist auch ganz spannend.
  • uns sonstiger Kleinkram…

Herausgekommen ist dann das hier: https://github.com/JMS-1/file-organizer – befindet sich noch in der Testphase und könnte sicher auch etwas optische Aufbereitung (e.g. via semantic-ui) brauchen, aber das ist nicht das Ziel gewesen.

Viel Spaß

Jochen

Quo Vadis Window 10 – oder R.I.P. S2-4600

Wie ja bekannt ist eines meiner Hobbyprojekte der VCR.NET Recording Service unter Windows. Die Tatsache der intensiven Eigennutzung führt nun dazu, dass ich immer noch auf Windows 10 1803 bin. Warum das? Nun ich habe hier mehrere DVB-S2 Geräte (Technotrend S2-4600) via USB im Einsatz und alle Windows Versionen ab 1809 führen zu einem BSOD, wenn dieses Gerät im Einsatz ist. Leider bin ich da wohl nicht der Einzige und bis heute gibt es dafür keine akzeptable Lösung.

Neue USB Karten kommen erst mal nicht in Frage – wer weiß, welche überhaupt noch gehen und wann es diese auch trifft. Da ich schon seit längerem auf Ubuntu (Linux) als primäre Entwicklungsumgebung umgeschwenkt bin habe ich mir mal angeschaut, wie es da aussieht. Tatsächlich läuft die Karte dort unter gleichen Bedingungen, ich würde sogar sagen etwas besser als unter Windows.

Nun kam mir die Idee, eine an ein Ubuntu angeschlossene Karte in VCR.NET zu nutzen. Es gäbe hier viel zu erzählen ich fasse mich aber mal so kurz wie möglich und erläutere nur den aktuellen Stand.

Die erste Idee war die Anbindung war die Ansteuerung über SAT/IP – etwas was in VCR.NET / DVB.NET schon lange aussteht. Allerdings wäre die Implementierung in DVB.NET doch etwas aufwändiger und der Ubuntu Proxy den ich mir angeschaut habe (tvheadend) hat mich nicht gerade begeistert – der könnte lokale DVB Karten via SAT/IP im Netz anbieten. Ich habe mich daher für eine Minimallösung mit Lerneffekt entschieden – sprich proprietär aber mit etwas API Spielereien.

Für Ubuntu gibt es dazu ein kleines Tool (GIT) auf Basis der DVBv3 API – die alte Version der Linux DVB API passt sehr schön zu meinem DVB Einstiegsprojekt vor vielen Jahren: der TechnoTrend API für die Premium Line (Hauppauge Nexus). Das Tool stellt einen TCP Kanal zur Verfügung, über den man Remote eine Karte reservieren und steuern kann. Die angeforderten Nutzdaten (Streams) werden dann über diesen Kanal an den Aufrufer ermittelt. Zurzeit weil es einfacher war über ein proprietäres Protokoll, was ich aber aufgrund von potentiellen Datenübertragungsfehlern auf TS umstellen sollte – im Moment tut es als PoC aber erst mal.

Auf der anderen (DVB.NET / Windows) Seite gibt es dann einen neuen Provider, der bis auf die Art der Kommunikation quasi dem der Nexus entspricht – und der ist nicht einmal besonders umfangreich (GIT).

Ich gehe mit der Version jetzt erst mal in den privaten Test. Tatsächlich gibt es durchaus noch diverse Probleme esp. mit der Stabilität nach Sendersuchlauf und Programmzeitschrift, aber dazu ist ja so eine Testphase da. Immerhin funktionierte ein sauberes Aufzeichnen aller (12 glaube ich) Sender eines Transponders (VOX) parallel – etwa 32 MBit/s im Netzwerk, was aber bei 1 GBit/s Problem ist. Die CPU Last auf Ubuntu war mit unter 5% überschaubar – allerdings auf meinem kräftigeren Entwicklungssystem mit einem i7-8700, i.e. ca. 60% eines Kerns ausgelastet. Wenn das alles klappt sollen alle aktuellen Karten im Haus (5 an 4 Windows Rechnern – nur einer ist der meine) an einen einzigen Linux Rechner angeschlossen werden.

Immerhin: mal wieder C/C++ unter Linux gemacht (wirklich hässlich, ist ja auch schon weit über 20 Jahre her gewesen) und noch mal C# aufgefrischt (ja, da muss ich mich wirklich wieder einarbeiten) und dabei auch richtig über die Performance Visual Studio Pro geärgert – VSCode ist schon cool…

Soweit dazu, schauen wir mal

Jochen