Einer meiner größten Kritikpunkte an JavaScript ist das Vorhandensein fieser Fallen. TypeScript ist schön und gut (und wird von mir fleißig verwendet), aber zur Not komme ich auch mit einem dynamischen Typsystem klar. Dass Dinge wie if und try keine Expressions sind, ist primitiv, aber die meisten Programmiersprachen haben auch ihre Rudimente aus der Steinzeit. Was mich hingegen wirklich auf die Palme bringt, ist alles, was wie eine bewusst gestellte Falle aussieht. Mein Lieblingsbeispiel hierfür ist das folgende Verhalten von parseInt():

// Euro
parseInt("0EUR"); // > 0

// Franc de la Coopération Financière en Afrique Centrale
parseInt("0XAF"); // > 175

Wird parseInt() kein zweites Argument für die Basis übergeben, wird mitnichten ein Standardwert verwendet, sondern es wird auf Basis des Input-Strings ein Wert erraten! Da der Währungscode des CFA-Franc nun mal XAF lautet und der dem Code vorangestellte Betrag 0 ist, denkt sich parseInt(), dass es einen Hexadezimalwert zu parsen hätte. Mit jedem anderen Betrag und Währungscode (oder einem explizit angegebenen zweiten Argument) taucht das Problem nicht auf:

parseInt("0EUR");  // > 0
parseInt("0GBP");  // > 0
parseInt("0USD");  // > 0
parseInt("0XAF");  // > 175
parseInt("10XAF"); // > 10
parseInt("7XAF");  // > 7

Das problematische Verhalten taucht unerwartet (denn ein Entwickler geht bei Standard-Library-Funktionen zurecht von sinnvollen Defaults aus) und nur für bestimmte Inputs auf. Es ist wirklich eine Falle, die überraschend zuschnappt und aus der man nur schwer wieder herauskommt. Mit Wachsamkeit und ESLint könnte man der Falle entgehen, aber dazu muss man die Falle grundsätzlich erst mal erwarten. Die Macken von parseInt() sind weithin bekannt, aber auch andere Standard-Funktionen in JavaScript haben ähnliches Zuschnapp-Potenzial.

Source Code als Modul-Import

Bei Warhol bin ich unter anderem für die Kern-Algorithmen verantwortlich, die Pattern Libraries erfassen und die erfassten Daten mit Production-Webseiten abgleichen. Besagte Algorithmen sind in gewöhnlichem Browser-JavaScript implementiert und werden durch automatische Browser-Fernsteuer-Prozesse (programmiert in Node.js) in Webseiten eingespeist, von wo aus die Algorithmen die ermittelten Daten nach Hause telefonieren. Das Einspeisen ist vergleichsweise knifflig, denn eine aus einem Algorithmus-Modul in ein Node.js-Script importierte JS-Funktion lässt sich nicht ohne weiteres in den Browser einspielen, der durch das Node.js-Script gesteuert wird – es handelt sich schließlich um komplett getrennte JavaScript-Runtimes!

Wie bekommt man also ein Bündel von Funktionen aus einer JS-Umgebung in eine andere JS-Umgebung? Eine denkbare Lösung wäre, das kontrollierende Node.js-Script die Algorithmus-Module nicht als Module importieren zu lassen, sondern stattdessen den Modul-Inhalt via fs.readFile() als String einzulesen. Dieser String ließe sich dann von Node.js aus bequem als Eval-Kommando in die Webseite einspeisen. Dazu müssten die Algorithmus-Module lediglich ein im Browser lauffähiges Bundle bereitstellen, was mit einem kleinen zusätzlichen Webpack-Kompilierschritt kein großes Problem darstellt. Diese Lösung ist aber nicht sehr robust, da hiermit die Büchse der Relative-Pfade-Pandora geöffnet wird. fs.readFile() benötigt den genauen Pfad der Datei, die eingelesen werden soll, doch bei einem per Package-Manager installierten Modul will man derlei ja eigentlich gar nicht wissen müssen! Kurzum: der pfadbasierte Ansatz sorgt für eine ziemlich suboptimale Developer Experience. Besser wäre doch, wenn so etwas funktionieren würde:

import { runtimeSource } from "@warhol/algorithm";
browserController.webpage.evalJSString(runtimeSource);

Der Source Code des Algorithmus als importierbarer String! Das würde die Benutzung des Algorithmus für die Browser-Fernsteuer-Scripts extrem bequem machen und reduziert das Problem auf einen zusätzlichen Schritt im Build-System des Algorithmus. Wie schwierig kann’s schon sein?

Ein besonders billiges Build-Script

Ziemlich schwierig, wie sich herausstellt! Es war auch nach mehrtägiger Recherche nicht möglich, die JS-API von Webpack dazu zu bringen, ein Bundle als JavaScript-String auszuspucken. Mein nächster Versuch führte mich zu babel-plugin-preval, einem Babel-Tool, das es ermöglicht, Code zur Compile-Zeit auszuführen. Vom Prinzip her wäre das genau die Lösung für mein Problem:

export const runtimeSource = preval`
  const fs = require('fs');
  module.exports = fs.readFileSync('/browserBundleSource.js', 'utf8');
`;

Doch als ich damals auf diesem Problem herumkaute, war Babel noch gar nicht Teil des Buildprozesses, da die Zielplattformen zu diesem Zeitpunkt nur modernste Browser und Node-Versionen waren. Extra für mein randständiges Build-Problem dieses eine Plugin (und dafür Babel) einzubauen erschien mir als Overkill.

Wenn etablierte Tools keine passende Lösung bieten, muss man sich selbst helfen. Und so kam das folgende Post-Build-Script in die Welt, um nach dem Übersetzen des Modul-Codes und dem Bauen des Webpack-Browser-Bundles beide Welten per Stringmanipulation zusammenzubringen:

const SOURCE = "./dist/browser/index.js";
const fs = require("fs");
const escape = require("js-string-escape");

const TARGETS = [
  "./dist/cjs/runtimeSource.js",
  "./dist/esm/runtimeSource.js",
];

const runtimeCode = escape(fs.readFileSync(SOURCE, { encoding: "utf-8" }));

for (const target of TARGETS) {
  const oldSource = fs.readFileSync(target, { encoding: "utf-8" });
  const newSource = oldSource.replace("__CODE_GOES_HERE__", runtimeCode);
  fs.writeFileSync(target, newSource);
  console.log("Added runtime code to", target);
}

Das Modul runtimeSource besteht nur aus export const runtimeSource = "__CODE_GOES_HERE__" und das Post-Build-Script ersetzt ganz einfach __CODE_GOES_HERE__ durch den Code, der im Webpack-Bundle steht. If it's stupid but it works, it isn't stupid! Und es hat lange Zeit ganz hervorragend funktioniert. Bis es irgendwann nicht mehr funktionierte, da ich unwissentlich in eine der Fallen der JavaScript-Standardbibliothek getappt war.

Heisenbug

Eines unschönen Abends vollführte ich ein Patch-Update der Projekt-Dependencies und der Code, der aus import { runtimeSource } from "@warhol/algorithm" kam, war plötzlich nicht mehr lauffähig. Interessant – debuggen wir das doch mal!

Es sei an dieser Stelle an den Matrjoschka-Charakter des Projekts erinnert:

  • Es gibt ein mit Webpack gebautes Browser-Bundle eines Moduls …
  • … das über das o.g. Build-Script als String in ein anderes Script eingefügt wird …
  • … damit dieser String in einen Node.js-Prozess importiert werden kann …
  • … um per Eval-Kommando in einem Browser-Kontext ausgeführt zu werden

Das Problem manifestierte sich bei Schritt 4, wo sinnvolles Debugging des in den ersten beiden Schritten erzeugten Codes naturgemäß nur noch bedingt möglich ist. Alles, was ich wusste, war, dass das Browser-Bundle (ein laaanger String aus durch mehrere Build-Schritte gejagtem JS-Code) nicht mehr funktionierte, nachdem es ein Update irgendwelcher Dependencies gegeben hatte. Nach einem Dependency-Rollback war wieder alles funktionsfähig, wurden die Patch-Releases wieder eingespielt, ging wieder alles kaputt.

Bei der Durchsicht des in Schritt 4 per evaluierten Browser-Bundles fiel auf, dass der dort enthaltene Code kein syntaktisch valides JavaScript war. Es machte den Anschein, als seien einzelne Code-Stücke (ohne Rücksicht auf Syntaxregeln) zufällig innerhalb des Bundles kopiert und eingefügt worden zu sein. Insbesondere tauchte verdächtig oft __CODE_GOES_HERE__ auf, was ja eigentlich hätte ersetzt werden sollen …

String.prototype.replace() ist eine Falle

Die replace(pattern, replacement)-Methode von Strings ist ausgesprochen vielseitig. Das pattern-Argument kann der zu ersetzende Substring oder ein regulärer Ausdruck sein, während für replacement entweder der neue String oder eine den neuen String generierende Funktion angegeben werden kann. Die Funktion bekommt Argumente übergeben, die für jeden pattern-Treffer z.B. einen Offset angeben, damit auch komplexere String-Manipulationen möglich sind. Allerdings sind besagte komplexere String-Manipulationen auch ohne Funktionen möglich, denn in replacement-Strings enthaltene besondere Patterns können ebenfalls komplexe String-Manipulationen beschreiben! Zu diesen besonderen Patterns gehört unter anderem $& – dieser Token soll vor dem Ersetzen durch den von pattern gematchten Substring ersetzt werden, etwa so:

"a b c".replace("b", "x$&");
// > "a xb c"

Anders gesagt: replace("b", "x$&") bedeutet nicht „ersetze b durch x$&“, sondern „ersetze b durch xb“. Das mag nützlich erscheinen, aber angenommen, der String für das zweite Argument würde auf die eine oder andere Art automatisch generiert und wäre nicht hardcoded oder anderweitig vorhersehbar …

oldSource.replace("__CODE_GOES_HERE__", autogeneratedTranspiledJsCode);
// Autsch :(

Nach dem Patch-Update der Dependencies enthielt der von Webpack erzeugte Code, der an die Stelle von __CODE_GOES_HERE__ gesetzt werden sollte, plötzlich diverse $&, die von String.prototype.replace() als magische Steuerzeichen interpretiert wurden. Dadurch wurde nicht einfach nur der JS-Code an die Stelle von, __CODE_GOES_HERE__ gesetzt, sondern vorher verändert und damit unbrauchbar gemacht.

Das Problem, wenn erst mal erkannt, ist natürlich relativ einfach zu reparieren:

// Automagische Pattern-Ersetzerei :(
oldSource.replace("__CODE_GOES_HERE__", autogeneratedTranspiledJsCode);

// Einfaches String-Ersetzen :)
oldSource.replace("__CODE_GOES_HERE__", () => autogeneratedTranspiledJsCode);

Wenn für replacement eine Funktion angegeben wird, dann kann diese die gleichen Pattern-Ersetz-Features wie ein String-replacement abbilden, kann es aber – anders als das String-replacement – auch unterlassen! Dadurch, dass autogeneratedTranspiledJsCode von einer Funktion zurückgegeben wird, werden Patterns wie $& nicht mehr als spezielle Steuerkommandos interpretiert, anders als wenn autogeneratedTranspiledJsCode selbst als zweites Argument übergeben wird.

Das Einfügen der Zeichenkette () => reparierte also meinen Heisenbug, wobei ich im Schnitt eine Stunde Arbeitszeit pro Zeichen aufgewendet habe (inkl. Schreiben dieses Artikels).

Falle oder Programmierfehler?

Es bleibt die Frage nach der Verantwortung: Sitzt der Auslöser für dieses Problem in JavaScript oder an der Tastatur, an der gerade diesen Artikel geschrieben wird? Ich bin, was meine Programmier-Fähigkeiten angeht, durchaus selbstkritisch. Meine Zimmerpflanzen müssen mich für den größten Stümper im gesamten Alpha-Quadranten halten, so oft wie ich laut über meine diversen Code-Unfälle vor mich hin schimpfe. Aber in diesem Fall bekommt JavaScript einen Gutteil meines Zorns ab.

Ich fühle mich von String.prototype.replace() in die Falle gelockt. Wenn man die Dokumentation nicht mit Argusaugen liest, könnte man sehr leicht auf den Gedanken kommen, das zweite Argument für replace(pattern, replacement) sei entweder ein einzusetzender String oder eine Factory-Function für den einzusetzenden String ist. Tatsächlich handelt es sich aber immer um eine Factory-Function für den einzusetzenden String, mit der besonderen Möglichkeit, diese Factory-Function auch als String mit magischen Steuerzeichen zu formulieren. Und was diese String-Factory-Function genau macht, hängt davon ab, was sie für magischen Steuerzeichen enthält.

RTFM halte ich an dieser Stelle für auch nicht besonders überzeugend. Natürlich könnte man von Nutzern einer Programmiersprache verlangen, alle Details der fraglichen Programmiersprache permanent im Kopf zu haben, aber das halte ich aus zweierlei Gründen für nicht besonders überzeugend. Zum einen könnte man damit jedwedes unerwünschte Verhalten in jeder Programmiersprache rechtfertigen; zum anderen ist das in der heutigen Welt mit absurd komplexen Programmiersprachen, Buildprozessen, Deploymentstrategien einfach von Normalsterblichen nicht mehr zu erwarten. Zugespitzt könnte man sagen, dass heutzutage fast jedes Computerproblem aus einem Homo Sapiens vor einem Bildschirm besteht und das, was auf dem Bildschirm stattfindet, trägt entweder zur Linderung oder zur Verschlimmerung des Problems bei. Es dürfte klar sein, welcher dieser zwei Kategorien String.prototype.replace() zuzuordnen ist.

Nun möchte ich nicht sagen, dass es diese Sting-Steuerzeichen-Option nicht geben sollte. Diese Funktionalität ist mindestens genauso sinnvoll, wie die Fähigkeit von parseInt(), hexadezimale Werte zu parsen. Was ich mir aber von einer Programmiersprache im Jahr 2019 wünschen würde, wäre, dass derlei Verhalten explizit angegeben wird und die Defaults nicht überraschend sind. Der zweite Parameter von parseInt() müsste als Standardwert einfach immer 10 sein (oder einfach nur immer gleich, von mir aus auch 16 oder 5), dann wäre an der Funktion nichts auszusetzen. Und bei String.prototype.replace() würde ich erwarten, dass es einen Steuerzeichen Opt-In gibt.

Bis auf Weiteres stellt someString.replace(a, b) mit einem String-Wert für b, der nicht hardcoded ist, sondern aus User-Input oder einer externen Datenquelle stammt, eine tickende Zeitbombe dar. Ich hoffe in eurer Codebase kommt so etwas nicht vor.