@kayahr/ed-journal
Version:
Typescript library to read/watch the player journal of Frontier's game Elite Dangerous
762 lines (692 loc) • 30.9 kB
text/typescript
/*
* Copyright (C) 2022 Klaus Reimer <k@ailis.de>
* See LICENSE.md for licensing information.
*/
// Import events which registers event updates
import "./events/carrier/CarrierNameChange.ts";
import "./events/carrier/CarrierStats.ts";
import "./events/travel/Docked.ts";
import "./events/station/EngineerCraft.ts";
import "./events/station/EngineerProgress.ts";
import "./events/station/Market.ts";
import "./events/travel/FSDJump.ts";
import "./events/travel/Location.ts";
import "./events/exploration/Scan.ts";
import "./events/startup/Statistics.ts";
import "./events/other/Synthesis.ts";
import { open, readFile, readdir, watch } from "node:fs/promises";
import { homedir } from "node:os";
import { join, resolve } from "node:path";
import type { AnyJournalEvent, JournalEventName } from "./AnyJournalEvent.ts";
import type { Backpack } from "./events/odyssey/Backpack.ts";
import type { ExtendedFCMaterials } from "./events/odyssey/FCMaterials.ts";
import type { ExtendedModuleInfo } from "./events/other/ModuleInfo.ts";
import type { Status } from "./events/other/Status.ts";
import type { ExtendedMarket } from "./events/station/Market.ts";
import type { ExtendedOutfitting } from "./events/station/Outfitting.ts";
import type { ExtendedShipyard } from "./events/station/Shipyard.ts";
import type { ExtendedNavRoute } from "./events/travel/NavRoute.ts";
import { JournalError } from "./JournalError.ts";
import { type JournalEvent, updateJournalEvent } from "./JournalEvent.ts";
import type { JournalPosition, NamedJournalPosition } from "./JournalPosition.ts";
import { sleep } from "./util/async.ts";
import { getErrorMessage, toError } from "./util/error.ts";
import { isDirectory, isPathReadable } from "./util/fs.ts";
import { LineReader } from "./util/LineReader.ts";
import { Notifier } from "./util/Notifier.ts";
/**
* Compare function to sort journal file names by time. Empty string is always earlier than any journal file.
*
* @param a - First filename to compare.
* @param b - Second filename to compare.
* @returns The comparison result to sort the journal file names fro oldest to newest.
*/
function journalTimeCompare(a: string, b: string): number {
if (a.length === b.length) {
// Same length means same date format which we can compare alphabetically
return a.localeCompare(b);
} else {
// Different length means the date format has changed. The newer one is longer
return a.length - b.length;
}
}
/** Regular expression to match the name of a journal file. */
const journalFileRegExp = /^Journal\.\d{12}|\d{4}-\d{2}-\d{2}T\d{6}\.\d{2}\.log$/;
/**
* Checks if given filename is a journal file.
*
* @param The filename to check.
* @returns True if filename is a journal file, false it not.
*/
function isJournalFile(filename: string): boolean {
return journalFileRegExp.test(filename);
}
/**
* Options for reading journal files.
*/
export interface JournalOptions {
/** Optional journal directory. If not specified then automatically determined by looking in popular spots. */
directory?: string;
/**
* Optional position within the journal where to start at. Can be either a specific journal position or a string with the following meaning:
*
* - "start" : Indicates the very beginning of the earliest journal file.
* - "end" : Indicates the end of the latest journal file. So only future events will be read (only makes sense in watch mode)
* - event name : Any other string is treated as a journal event name. Indicates the last (newest) position of the given event in the journal. When
* specifying 'FSDJump" for example then journal reading begins at the last FSDJump event found in the game. If there is no FSDJump in
* the whole journal then reading begins a the end of the journal (same as specifying position "end").
*
* Defaults to "start".
*/
position?: JournalPosition | NamedJournalPosition;
/**
* Set to true to watch the journal for new events. False (default) just reads the existing journal events and
* does not wait for new ones.
*/
watch?: boolean;
}
/**
* JSON reviver function which converts numbers of ID properties (property names ending with 'ID' or 'Address') to bigint if needed.
*
* @param key - The JSON property key.
* @param value - The parsed JSON property value.
* @param context - The reviver context containing the raw JSON source string.
* @returns The already parsed JSON property value if suitable or the raw source converted into a bigint.
*/
function jsonReviver(key: string, value: unknown, context?: { source: string }): unknown {
if (context != null && typeof value === "number" && (value > Number.MAX_SAFE_INTEGER || value < Number.MIN_SAFE_INTEGER)) {
const source = context.source;
if (key.endsWith("ID") || key.endsWith("Address")) {
return BigInt(source);
} else if (/^[-+]?\d+$/.test(source)) {
throw new JournalError(`Value of property '${key}' looks like a bigint (${source}) but was parsed as an imprecise number (${value})`);
}
}
return value;
}
/**
* Journal reader/watcher.
*
* Reads or watches a journal directory. It implements the AsyncIterable interface so for reading/watching the
* journal you simply iterate of the instance of this class with a for..of loop for example. If you prefer you can
* also use the {@link next} method to read the next event from the journal until this method returns null to indicate
* the end of the journal.
*
* In watch mode the iteration does not end and is continued every time a new event is appended to the journal by the
* game. Watch mode can be stopped by calling the {@link close} method. Iteration loops will end when journal is closed.
*/
export class Journal implements AsyncIterable<AnyJournalEvent>, AsyncDisposable {
/** The journal directory. */
private readonly directory: string;
/** The journal position pointing to the last read event. */
private lastPosition: JournalPosition;
/** The current journal position pointing to the next event to read. */
private position: JournalPosition;
/** The generator used for reading journal events. */
private readonly generator: AsyncGenerator<AnyJournalEvent>;
/** The currently open line reader. Null if currently none open. */
private lineReader: LineReader | null = null;
/** Controller used to abort watchers when journal is closed. */
private readonly abortController: AbortController;
/**
* Creates journal read from given directory at given position.
*
* @param directory - The journal directory.
* @param position - The position to start reading from.
* @param watch - True to watch journal, false to just read it.
*/
private constructor(directory: string, position: JournalPosition, watch: boolean) {
this.directory = directory;
this.position = position;
this.lastPosition = position;
this.abortController = new AbortController();
this.generator = this.createGenerator(watch);
}
/** @inheritdoc */
public [Symbol.asyncDispose](): PromiseLike<void> {
return this.close();
}
/**
* Opens the journal.
*/
public static async open({ directory, position = "start", watch = false }: JournalOptions = {}): Promise<Journal> {
directory ??= await this.findDirectory();
if (position === "start") {
position = await this.findStart(directory);
} else if (position === "end") {
position = await this.findEnd(directory);
} else if (typeof position === "string") {
position = await this.findLastEvent(directory, position);
} else {
position = { ...position };
}
return new Journal(directory, position, watch);
}
/**
* Searches for the journal directory in common spaces. First it checks for existence of directory specified with
* environment variable ED_JOURNAL_DIR. Then it looks into the standard directory on a windows system and then
* it checks to standard directory within Proton (for Linux).
*
* If you know more common journal locations then please let me know so I can improve the search.
*
* @returns The found journal directory.
* @throws JournalError - When journal directory was not found.
*/
public static async findDirectory(): Promise<string> {
const nativeHome = homedir();
const protonHome = ".local/share/Steam/steamapps/compatdata/359320/pfx/drive_c/users/steamuser";
const eliteDir = "Saved Games/Frontier Developments/Elite Dangerous";
const candidates = [
join(nativeHome, eliteDir),
join(nativeHome, protonHome, eliteDir)
];
const dirFromEnv = process.env["ED_JOURNAL_DIR"];
if (dirFromEnv != null) {
// Check ED_JOURNAL_DIR environment variable first if present
candidates.unshift(dirFromEnv);
}
for (const candidate of candidates) {
if (await isPathReadable(candidate) && await isDirectory(candidate)) {
return candidate;
}
}
throw new JournalError("Unable to find Elite Dangerous Journal directory");
}
/**
* Returns the journal directory.
*
* @returns The journal directory.
*/
public getDirectory(): string {
return this.directory;
}
/**
* Returns the current journal position which points to the next event to read.
*
* @returns The current journal position.
*/
public getPosition(): JournalPosition {
return { ...this.position };
}
/**
* Returns the journal position which points to the last read event.
*
* @returns The journal position of the last read event.
*/
public getLastPosition(): JournalPosition {
return { ...this.lastPosition };
}
/**
* Closes the journal by stopping the watcher (if any) and closing the line reader.
*/
public async close(): Promise<void> {
this.abortController.abort();
if (this.lineReader != null) {
await this.lineReader.close();
this.lineReader = null;
}
}
/**
* Finds the last position of the given event in the latest file of the journal and returns it. Returns end of journal if the latest journal file
* does not contain this event.
*
* @param directory - The journal directory.
* @param eventName - The event name to look for.
* @returns Last position of given event in latest journal file or end of journal if not found.
*/
public static async findLastEvent(directory: string, eventName: JournalEventName): Promise<JournalPosition> {
const files = (await readdir(directory)).filter(isJournalFile).sort(journalTimeCompare).reverse();
let lastEventPosition: JournalPosition | null = null;
let lastPosition: JournalPosition = { file: "", offset: 0, line: 1 };
for (const file of files) {
await using lineReader = await LineReader.create(join(directory, file));
lastPosition = { file, offset: lineReader.getOffset(), line: lineReader.getLine() };
for await (const line of lineReader) {
try {
const json = JSON.parse(line, jsonReviver) as AnyJournalEvent;
updateJournalEvent(json);
if (json.event === eventName) {
lastEventPosition = lastPosition;
}
lastPosition = { file, offset: lineReader.getOffset(), line: lineReader.getLine() };
} catch (error) {
throw new JournalError(`Parse error in ${lastPosition.file}:${lastPosition.line}: ${getErrorMessage(error)}: ${line.trim()}`);
}
}
if (lastEventPosition != null) {
break;
}
}
return lastEventPosition ?? this.findEnd(directory);
}
/**
* Finds the end position of the journal and returns it.
*
* @returns End position of the journal.
*/
public static async findStart(directory: string): Promise<JournalPosition> {
const filename = (await readdir(directory)).filter(isJournalFile).sort(journalTimeCompare)[0];
return { file: filename ?? "", offset: 0, line: 1 };
}
/**
* Finds the end position of the journal and returns it.
*
* @returns End position of the journal.
*/
public static async findEnd(directory: string): Promise<JournalPosition> {
const filename = (await readdir(directory)).filter(isJournalFile).sort(journalTimeCompare).reverse()[0];
if (filename == null) {
// No journal file found, return start as end
return { file: "", offset: 0, line: 1 };
}
// Find last line number and also create the end offset (which is the same as the file size) during the process
const file = await open(join(directory, filename), "r");
try {
const buffer = new Uint8Array(8192);
let offset = 0;
let line = 1;
let read: number;
while ((read = (await file.read({ buffer })).bytesRead) > 0) {
offset += read;
for (let i = 0; i < read; i++) {
if (buffer[i] === 10) {
line++;
}
}
}
return { file: filename, offset, line };
} finally {
await file.close();
}
}
/**
* Watches the journal for new or changed files and returns filenames sorted by date. An optional starting file
* can be specified to define the starting point for the watcher. If not specified then the whole journal
* directory is scanned and all found journal files are returned before starting to watch for changed or new
* files.
*
* @param startFile - Optional starting file. If not specified then the watcher starts with the oldest available
* journal file.
* @yields New/changed journal files in chronological order.
*/
private async *watchJournalFiles(startFile: string): AsyncGenerator<string> {
const signal = this.abortController.signal;
const notifier = new Notifier(signal);
const directory = resolve(this.directory);
const files: string[] = [];
let lastError: Error | null = null;
let initialized = false;
// Monitors journal directory for changes. This starts immediately, even when initial directories are still read, so we don't miss any changed or
// new file. When initialization is not done yet, then changed/new file is just recorded and taken into account during initialization. If
// initialization is done then changed/new files are reported right away.
const journalDirMonitor = (async () => {
try {
for await (const event of watch(directory, { signal })) {
const filename = event.filename;
if (filename != null && isJournalFile(filename)) {
if (initialized) {
if (journalTimeCompare(filename, startFile) >= 0) {
startFile = filename;
files.push(filename);
notifier.notify();
}
} /* node:coverage ignore next 2 */ /* Hard to time this situation in unit test */ else {
files.push(filename);
}
}
}
} catch (error) {
lastError = toError(error);
notifier.notify();
}
})();
// Asynchronous initialization. Reads all existing journal files and sorts them. While reading the directory the watcher can already contribute
// new/changed files.
const asyncInit = (async () => {
try {
for (const file of await this.listJournalFiles(startFile)) {
if (signal.aborted) {
break;
}
files.push(file);
}
files.sort(journalTimeCompare);
startFile = files.at(-1) ?? "";
initialized = true;
notifier.notify();
} catch (error) {
lastError = toError(error);
notifier.notify();
}
})();
// Waits for reported new/changed files and yields them. Aborts on error and simply exits when user aborts the watcher.
while (!signal.aborted) {
if (lastError != null) {
throw lastError as Error;
}
const nextFile = files.shift();
if (nextFile != null) {
yield nextFile;
} else {
await notifier.wait();
}
}
// Await background processes to make sure they existed correctly
await asyncInit;
await journalDirMonitor;
}
/**
* Lists journal files. An optional starting file can be specified to define the starting point for the watcher.
* If not specified then the whole journal directory is scanned and all found files are returned..
*
* @param startFile - Optional starting file. If not specified then the reader starts with the oldest available
* journal file.
* @returns The found journal files.
*/
private async listJournalFiles(startFile: string): Promise<string[]> {
return (await readdir(this.directory))
.filter(filename => isJournalFile(filename) && journalTimeCompare(filename, startFile) >= 0)
.sort(journalTimeCompare);
}
/**
* Parses a single journal event line. Additionally to parsing the string into a JSON object this function also
* updates old properties in the journal event to new ones if needed.
*
* @param line - The JSON line to parse.
* @returns The parsed journal event.
*/
private parseJournalEvent(line: string): AnyJournalEvent {
const json = JSON.parse(line, jsonReviver) as AnyJournalEvent;
updateJournalEvent(json);
return json;
}
/**
* Creates the journal event generator.
*
* @param watch - True to watch the journal instead of just reading it.
* @yields The created journal event generator.
*/
private async *createGenerator(watch: boolean): AsyncGenerator<AnyJournalEvent> {
// Get the list of journal files to read/watch. In watch mode this is a generator which produces changed/new
// files until journal is closed.
const files = watch
? this.watchJournalFiles(this.position.file)
: await this.listJournalFiles(this.position.file);
const signal = this.abortController.signal;
// Iterate over all journal files in chronological order. In watch mode, when the last line of the last file
// has been read this loop waits until the files generator reports the current journal file again when it has
// been changed or a new journal file was found. Reading is then continued at this point.
for await (const file of files) {
// When position is empty then initialize with first file we have seen
if (this.position.file === "") {
this.position.file = file;
}
// Create line reader or replace it when new journal file has been opened
let lineReader = this.lineReader;
if (lineReader == null || file !== this.position.file) {
if (lineReader != null) {
await lineReader.close();
}
lineReader = this.lineReader = await LineReader.create(
join(this.directory, file), file === this.position.file ? this.position.offset : 0,
file === this.position.file ? this.position.line : 1);
}
// Iterate over all lines of the journal file
this.lastPosition = { file, offset: lineReader.getOffset(), line: lineReader.getLine() };
for await (const line of lineReader) {
if (signal.aborted) {
break;
}
// Remember last read journal position for error messages and getLastPosition method
const lastPosition = this.lastPosition = this.position;
// Set position of next journal event
this.position = { file, offset: lineReader.getOffset(), line: lineReader.getLine() };
try {
// Parse the journal event and yield it
yield this.parseJournalEvent(line);
// this.lastPosition = position;
} catch (error) {
throw new JournalError(`Parse error in ${lastPosition.file}:${lastPosition.line}: `
+ `${getErrorMessage(error)}: ${line.trim()}`);
}
}
}
}
/**
* Returns async iterator for the journal events.
*
* @returns Async iterator for the events in this journal.
*/
public [Symbol.asyncIterator](): AsyncGenerator<AnyJournalEvent> {
return this.generator;
}
/**
* Returns the next event from the journal. When end of journal is reached then in watch mode this method waits
* until a new event arrives. When not in watch mode or when journal is closed this method returns null when no
* more events are available.
*
* @returns The next journal event or null when end is reached.
*/
public async next(): Promise<AnyJournalEvent | null> {
const result = await this.generator.next();
return result.done === true ? null : result.value;
}
/**
* Reads the given JSON file, parses it as at a journal event and returns it.
*
* @param filename - The filename of the JSON file to read. Relative to journal directory.
* @returns The parsed journal event. Null when file is not present.
*/
private async readFile<T extends JournalEvent>(filename: string): Promise<T | null> {
const path = join(this.directory, filename);
if (!(await isPathReadable(path))) {
return null;
}
const result = (await readFile(path)).toString();
if (!result.startsWith("{") || !result.endsWith("}\r\n")) {
// JSON file is not fully written yet. Wait a little bit and try again.
await sleep(25);
return this.readFile(filename);
}
return JSON.parse(result, jsonReviver) as T;
}
/**
* Watches the given JSON file for changes and reports any new content. It always reports the current content as
* first change.
*
* @param filename - The filename of the JSON file to read and watch. Relative to journal directory.
* @yields Async iterator watching content changes.
*/
private async *watchFile(filename: string): AsyncGenerator<string> {
const signal = this.abortController.signal;
yield filename;
try {
for await (const event of watch(this.directory, { signal })) {
if (event.filename === filename) {
yield filename;
}
}
} catch {
// Ignoring watch abort, generator still stops yielding values
}
}
private async *watchFileContent<T extends JournalEvent>(filename: string): AsyncGenerator<T> {
for await (const file of this.watchFile(filename)) {
const content = await this.readFile<T>(file);
if (content != null) {
yield content;
}
}
}
/**
* Returns the current backpack inventory read from the Backpack.json file.
*
* @returns The current backpack inventory. Null if Backpack.json file does not exist or is not readable.
*/
public readBackpack(): Promise<Backpack | null> {
return this.readFile("Backpack.json");
}
/**
* Watches the Backpack.json file for changes and reports any new data. It always reports the current data as
* first change.
*
* @returns Async iterator watching backpack inventory changes.
*/
public watchBackpack(): AsyncGenerator<Backpack> {
return this.watchFileContent("Backpack.json");
}
/**
* Returns the current cargo data read from the Cargo.json file.
*
* @returns The current cargo data. Null if Cargo.json file does not exist or is not readable.
*/
public readCargo(): Promise<Backpack | null> {
return this.readFile("Cargo.json");
}
/**
* Watches the Cargo.json file for changes and reports any new data. It always reports the current data as
* first change.
*
* @returns Async iterator watching cargo changes.
*/
public watchCargo(): AsyncGenerator<Backpack> {
return this.watchFileContent("Cargo.json");
}
/**
* Returns the current fleet carrier materials data read from the FCMaterials.json file.
*
* @returns The current fleet carrier materials data. Null if FCMaterials.json file does not exist or
* is not readable.
*/
public readFCMaterials(): Promise<ExtendedFCMaterials | null> {
return this.readFile("FCMaterials.json");
}
/**
* Watches the FCMaterials.json file for changes and reports any new data. It always reports the current data as
* first change.
*
* @returns Async iterator watching fleet carrier materials data changes.
*/
public watchFCMaterials(): AsyncGenerator<ExtendedFCMaterials> {
return this.watchFileContent("FCMaterials.json");
}
/**
* Returns the current market data read from the Market.json file.
*
* @returns The current market data. Null if Market.json file does not exist or is not readable.
*/
public readMarket(): Promise<ExtendedMarket | null> {
return this.readFile("Market.json");
}
/**
* Watches the Market.json file for changes and reports any new data. It always reports the current data as
* first change.
*
* @returns Async iterator watching market data changes.
*/
public watchMarket(): AsyncGenerator<ExtendedMarket> {
return this.watchFileContent("Market.json");
}
/**
* Returns the current modules info read from the ModulesInfo.json file.
*
* @returns The current modules info. Null if ModulesInfo.json file does not exist or is not readable.
*/
public readModulesInfo(): Promise<ExtendedModuleInfo | null> {
return this.readFile("ModulesInfo.json");
}
/**
* Watches the ModulesInfo.json file for changes and reports any new data. It always reports the current data as
* first change.
*
* @returns Async iterator watching modules info changes.
*/
public watchModulesInfo(): AsyncGenerator<ExtendedModuleInfo> {
return this.watchFileContent("ModulesInfo.json");
}
/**
* Returns the current nav route read from the NavRoute.json file.
*
* @returns The current nav route data. Null if NavRoute.json file does not exist or is not readable.
*/
public readNavRoute(): Promise<ExtendedNavRoute | null> {
return this.readFile("NavRoute.json");
}
/**
* Watches the NavRoute.json file for changes and reports any new data. It always reports the current data as
* first change.
*
* @returns Async iterator watching nav route data changes.
*/
public watchNavRoute(): AsyncGenerator<ExtendedNavRoute> {
return this.watchFileContent("NavRoute.json");
}
/**
* Returns the current outfitting data read from the Outfitting.json file.
*
* @returns The current outfitting data. Null if Outfitting.json file does not exist or is not readable.
*/
public readOutfitting(): Promise<ExtendedOutfitting | null> {
return this.readFile("Outfitting.json");
}
/**
* Watches the Outfitting.json file for changes and reports any new data. It always reports the current data as
* first change.
*
* @returns Async iterator watching outfitting data changes.
*/
public watchOutfitting(): AsyncGenerator<ExtendedOutfitting> {
return this.watchFileContent("Outfitting.json");
}
/**
* Returns the current contents of the ship locker from the ShipLocker.json file.
*
* @returns The current ship locker content. Null if ShipLocker.json file does not exist or is not readable.
*/
public readShipLocker(): Promise<ExtendedShipyard | null> {
return this.readFile("ShipLocker.json");
}
/**
* Watches the ShipLocker.json file for changes and reports any new data. It always reports the current data as
* first change.
*
* @returns Async iterator watching ship locker content changes.
*/
public watchShipLocker(): AsyncGenerator<ExtendedShipyard> {
return this.watchFileContent("ShipLocker.json");
}
/**
* Returns the current shipyard data read from the Shipyard.json file.
*
* @returns The current shipyard data. Null if Shipyard.json file does not exist or is not readable.
*/
public readShipyard(): Promise<ExtendedShipyard | null> {
return this.readFile("Shipyard.json");
}
/**
* Watches the Shipyard.json file for changes and reports any new data. It always reports the current data as
* first change.
*
* @returns Async iterator watching shipyard data changes.
*/
public watchShipyard(): AsyncGenerator<ExtendedShipyard> {
return this.watchFileContent("Shipyard.json");
}
/**
* Returns the current status read from the Status.json file.
*
* @returns The current status. Null if Status.json file does not exist or is not readable.
*/
public readStatus(): Promise<Status | null> {
return this.readFile("Status.json");
}
/**
* Watches the Status.json file for changes and reports any new status. It always reports the current status as
* first change.
*
* @returns Async iterator watching status changes.
*/
public watchStatus(): AsyncGenerator<Status> {
return this.watchFileContent("Status.json");
}
}