zaphyra's git: oeffisearch

fast and simple tripplanner

commit 4b032110dbc3561c620513dc6e72adc51b710147
parent b23392ddb5870f720393d98757ed3f52f9ee3ebd
Author: Katja Ramona Sophie Kwast (zaphyra) <git@zaphyra.eu>
Date: Wed, 19 Aug 2026 12:20:00 +0200

implement own request functions for `vendo-client` and `hafas-client`

To reduce amout of shimming-code and by that reduce bundle-size and
external dependencies.
11 files changed, 252 insertions(+), 78 deletions(-)
M
nginx.conf
|
16
++++++++--------
M
package.json
|
2
--
M
rollup.config.js
|
32
+++++++++++++++-----------------
M
src/hafasClient.js
|
18
+++++++++++++++---
A
src/hafasRequest.js
|
118
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
D
src/shim/cross-fetch.js
|
28
----------------------------
D
src/shim/crypto.js
|
8
--------
D
src/shim/https-proxy-agent.js
|
8
--------
D
src/shim/https.js
|
3
---
D
src/shim/net.js
|
1
-
A
src/vendoRequest.js
|
96
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
diff --git a/nginx.conf b/nginx.conf
@@ -34,18 +34,18 @@ http {
 
 	map $hafastarget $hafasurl {
 		default no;
-		nahsh   nah.sh.hafas.de;
-		rmv     www.rmv.de;
-		bvg     bvg-apps-ext.hafas.de;
-		oebb    fahrplan.oebb.at;
+		NAHSH   nah.sh.hafas.de;
+		RMV     www.rmv.de;
+		VBB     bvg-apps-ext.hafas.de;
+		OEBB    fahrplan.oebb.at;
 	}
 
 	map $hafastarget $hafaspath {
 		default no;
-		nahsh   '/bin/mgate.exe';
-		rmv     '/auskunft/bin/jp/mgate.exe';
-		bvg     '/bin/mgate.exe';
-		oebb    '/bin/mgate.exe';
+		NAHSH   '/bin/mgate.exe';
+		RMV     '/auskunft/bin/jp/mgate.exe';
+		VBB     '/bin/mgate.exe';
+		OEBB    '/bin/mgate.exe';
 	}
 
 	server {
diff --git a/package.json b/package.json
@@ -12,7 +12,6 @@
   "author": "Katja(ctucx), yuka",
   "license": "AGPL-3.0",
   "dependencies": {
-    "buffer": "^6.0.3",
     "db-vendo-client": "^6.10.10",
     "hafas-client": "^6.3.6",
     "ics": "^3.12.0",

@@ -32,7 +31,6 @@
     "rollup-plugin-copy": "^3.5.0",
     "rollup-plugin-delete": "^3.0.2",
     "rollup-plugin-html-literals": "^1.1.8",
-    "rollup-plugin-ignore": "^1.0.10",
     "rollup-plugin-lit-css": "^5.0.2",
     "rollup-plugin-summary": "^3.0.1",
     "rollup-plugin-workbox": "^8.1.3"
diff --git a/rollup.config.js b/rollup.config.js
@@ -2,7 +2,6 @@ import path from 'path';
 import del from 'rollup-plugin-delete';
 import copy from 'rollup-plugin-copy';
 import { fileURLToPath } from 'url';
-import ignore from "rollup-plugin-ignore"
 import replace from '@rollup/plugin-replace';
 import resolve from '@rollup/plugin-node-resolve';
 import terser from '@rollup/plugin-terser';

@@ -26,6 +25,18 @@ const gitVersion    = process.env.GIT_VERSION;
 const gitCommit     = process.env.GIT_COMMIT;
 const gitCommitDate = process.env.GIT_COMMITDATE
 
+const ignore = (list, options) => ({
+	resolveId(importee) { return (
+		(
+			importee === 'empty_shim'
+			|| list.includes(importee)
+		)
+		? 'empty_shim'
+		: null
+	); },
+	load(id) { return id === 'empty_shim' ? 'export default {}' : null; }
+});
+
 export default {
 	input: 'src/main.js',
 	output: {

@@ -60,27 +71,18 @@ export default {
 		// Minify HTML template literals
 		!isDevServer && minifyHtmlLiterals(),
 		// stub some modules
-		ignore([ 'http', 'url', 'tls', 'stream', 'assert', 'https-proxy-agent', 'db-hafas-stations', 'events' ]),
+		ignore([ 'db-hafas-stations' ]),
 		replace({
 			preventAssignment: true,
 			delimiters: ['', ''],
 			values: {
-				'import {createRequire} from \'module\';': '',
-	 			'const require = createRequire(import.meta.url);': '',
-				'console.log(url);': '',
-				'process.browser': JSON.stringify(),
-				'process.version': JSON.stringify("0.0"),
+				'import {request} from \'../lib/request.js\';': 'const request = null;',
 				'process.env.DEBUG':         JSON.stringify(),
-				'process.env.HTTPS_PROXY':   JSON.stringify(),
-				'process.env.HTTP_PROXY':    JSON.stringify(),
-				'process.env.LOCAL_ADDRESS': JSON.stringify(),
 			},
 		}),
 		replace({
 			preventAssignment: true,
 			values: {
-				'node:buffer':       'buffer',
-				'cross-fetch':       path.resolve(__dirname, 'src/shim/cross-fetch.js'),
 				'isDevServer':       isDevServer,
 				'APP_NAME':          JSON.stringify(appName),
 				'APP_REPOURL':       JSON.stringify(appRepoUrl),

@@ -97,11 +99,7 @@ export default {
 		// json import support
 		jsonImport(),
 		// Resolve bare module specifiers to relative paths
-		resolve({
-			browser: true,
-			preferBuiltins: false,
-			modulePaths: [ path.resolve(__dirname, 'src/shim') ],
-		}),
+		resolve({ browser: true, preferBuiltins: true }),
 		// Minify JS
 		!isDevServer && terser({
 			ecma: 2021,
diff --git a/src/hafasClient.js b/src/hafasClient.js
@@ -8,11 +8,23 @@ import { profile as rmvProfile         } from 'hafas-client/p/rmv/index.js';
 import { profile as oebbProfile        } from 'hafas-client/p/oebb/index.js';
 import { profile as rejseplanenProfile } from 'hafas-client/p/rejseplanen/index.js'
 
+import { vendoRequest } from './vendoRequest.js';
+import { hafasRequest } from './hafasRequest.js';
+
 const clients  = {};
 
+const vendoEndpoints = {
+	locationsEndpoint: '/db/vendo/locations',
+	stopEndpoint: '/db/vendo/location',
+	journeysEndpoint: '/db/vendo/journeys',
+	refreshJourneysEndpointTickets: '/db/vendo/journey',
+	boardEndpoint: '/db/vendo/departures',
+	tripEndpoint: '/db/vendo/trip',
+};
+
 export let   client;
 export const profiles = {
-	db:          { name: "DB",          backend: 'vendo', profile: dbNavProfile },
+	db:          { name: "DB",          backend: 'vendo', profile: { ...dbNavProfile, ...vendoEndpoints }},
 	bvg:         { name: "BVG",         backend: 'hafas', profile: bvgProfile },
 	nahsh:       { name: "NAH.SH",      backend: 'hafas', profile: nahshProfile },
 	rmv:         { name: "RMV",         backend: 'hafas', profile: rmvProfile },

@@ -26,12 +38,12 @@ export const getHafasClient = async profileName => {
 
 	if (!clients[profileName]) {
 		if (profiles[profileName].backend === 'vendo') {
-			clients[profileName] = createVendoClient(profiles[profileName].profile, APP_NAME, {enrichStations: false});
+			clients[profileName] = createVendoClient({ ...profiles[profileName].profile, request: vendoRequest }, APP_NAME, {enrichStations: false});
 			if (isDevServer) console.info('initialized vendo client with profile ' + profileName);
 		}
 
 		if (profiles[profileName].backend === 'hafas') {
-			clients[profileName] = createHafasClient(profiles[profileName].profile, APP_NAME);
+			clients[profileName] = createHafasClient({ ...profiles[profileName].profile, request: hafasRequest }, APP_NAME);
 			if (isDevServer) console.info('initialized hafas client with profile ' + profileName);
 		}
 	}
diff --git a/src/hafasRequest.js b/src/hafasRequest.js
@@ -0,0 +1,118 @@
+import { HafasError, byErrorCode } from 'hafas-client/lib/errors.js';
+
+const
+	randomBytesHexString = length => [...Array(length)].map(
+		() => Math.floor(Math.random() * 16).toString(16)
+	).join('')
+
+export const
+	checkIfHafasResponseIsOk = (_) => {
+		const {
+			body,
+			errProps: baseErrProps,
+		} = _;
+
+		const errProps = { ...baseErrProps };
+
+		if (body.id) errProps.hafasResponseId = body.id;
+
+		// Because we want more accurate stack traces, we don't construct the error here,
+		// but only return the constructor & error message.
+		const getError = (_) => {
+			// mutating here is ugly but pragmatic
+			if (_.errTxt) errProps.hafasMessage = _.errTxt;
+			if (_.errTxtOut) errProps.hafasDescription = _.errTxtOut;
+			if (_.err in byErrorCode) return byErrorCode[_.err];
+
+			return {
+				Error: HafasError,
+				message: body.errTxt || 'unknown error',
+				props: {},
+			};
+		};
+
+		if (body.err && body.err !== 'OK') {
+			const { Error: HafasError, message, props } = getError(body);
+			throw new HafasError(message, body.err, { ...errProps, ...props });
+		}
+
+		if (!body.svcResL || !body.svcResL[0])
+			throw new HafasError('invalid/unsupported response structure', null, errProps);
+
+		if (body.svcResL[0].err !== 'OK') {
+			const {Error: HafasError, message, props} = getError(body.svcResL[0]);
+			throw new HafasError(message, body.svcResL[0].err, { ...errProps, ...props });
+		}
+	},
+
+	hafasRequest = async (ctx, userAgent, reqData) => {
+		const {profile, opt} = ctx;
+
+		if (profile.addChecksum)
+			throw new Error('profile.addChecksum set but not implemented!');
+		if (profile.addMicMac)
+			throw new Error('profile.addMicMac set but not implemented!');
+
+		const
+			rawReqBody = profile.transformReqBody(ctx, {
+				// todo: is it `eng` actually?
+				// RSAG has `deu` instead of `de`
+				lang: opt.language || profile.defaultLanguage || 'en',
+				svcReqL: [reqData],
+
+				client: profile.client, // client identification
+				ext: profile.ext, // ?
+				ver: profile.ver, // HAFAS protocol version
+				auth: profile.auth, // static authentication
+			}),
+
+			reqId = randomBytesHexString(3).toString('hex'),
+			req = profile.transformReq(ctx, {
+				agent: null,
+				method: 'post',
+				// todo: CORS? referrer policy?
+				body: JSON.stringify(rawReqBody),
+				headers: {
+					'Content-Type': 'application/json',
+					'Accept-Encoding': 'gzip, br, deflate',
+					'Accept': 'application/json',
+					'user-agent': userAgent,
+					'connection': 'keep-alive', // prevent excessive re-connecting
+				},
+				redirect: 'follow',
+				query: {},
+			}),
+
+			url = `/hafas/${profile.client.id}?${(new URLSearchParams(req.query)).toString()}`,
+			fetchReq = new Request(url, req),
+			res = await fetch(url, req),
+			errProps = {
+				// todo [breaking]: assign as non-enumerable property
+				request: fetchReq,
+				// todo [breaking]: assign as non-enumerable property
+				response: res,
+				url,
+			};
+
+		if (!res.ok) {
+			// todo [breaking]: make this a FetchError or a HafasClientError?
+			const err = new Error(res.statusText);
+			Object.assign(err, errProps);
+			throw err;
+		}
+
+		let cType = res.headers.get('content-type');
+		if (!cType.startsWith('application/json'))
+			throw new HafasError('invalid/unsupported response content-type: ' + cType, null, errProps);
+
+		const body = JSON.parse(await res.text())
+
+		checkIfHafasResponseIsOk({ body, errProps });
+
+		const svcRes = body.svcResL[0].res;
+
+		return {
+			res: svcRes,
+			common: profile.parseCommon({...ctx, res: svcRes}),
+		};
+	};
diff --git a/src/shim/cross-fetch.js b/src/shim/cross-fetch.js
@@ -1,28 +0,0 @@
-export const Request = globalThis.Request;
-
-export const fetch = (resource, options) => {
-	if (!(resource instanceof Request)) resource = new Request(resource);
-
-	if (isDevServer) console.log('fetch(): ', resource, options);
-
-	const replacement = {
-		'https://app.services-bahn.de/mob/location/search':        '/db/vendo/locations',
-		'https://app.services-bahn.de/mob/angebote/fahrplan':      '/db/vendo/journeys',
-		'https://app.services-bahn.de/mob/angebote/recon':         '/db/vendo/journey',
-		'https://app.services-bahn.de/mob/bahnhofstafel/abfahrt':  '/db/vendo/departures',
-		'https://nah.sh.hafas.de/bin/mgate.exe?':                  '/hafas/nahsh',
-		'https://www.rmv.de/auskunft/bin/jp/mgate.exe?':           '/hafas/rmv',
-		'https://bvg-apps-ext.hafas.de/bin/mgate.exe?':            '/hafas/bvg',
-		'https://fahrplan.oebb.at/bin/mgate.exe?':                 '/hafas/oebb',
-	};
-
-	let url = replacement[resource.url];
-
-	if (url === undefined) {
-		url = resource.url.replace('https://app.services-bahn.de/mob/location/details', '/db/vendo/location');
-		url = url.replace('https://app.services-bahn.de/mob/zuglauf',                   '/db/vendo/trip');
-	}
-
-	resource = new Request(url, resource);
-	return globalThis.fetch(resource, options);
-}
diff --git a/src/shim/crypto.js b/src/shim/crypto.js
@@ -1,8 +0,0 @@
-import { Buffer } from "buffer";
-
-export const randomBytes = (len, cb) => {
-	if (cb) throw "async not supported";
-	const arr = new Uint8Array(len);
-	self.crypto.getRandomValues(arr);
-	return Buffer.from(arr.buffer, arr.byteOffset, arr.byteLength);
-};
diff --git a/src/shim/https-proxy-agent.js b/src/shim/https-proxy-agent.js
@@ -1,8 +0,0 @@
-import {Agent} from "https";
-
-export default class HttpsProxyAgent extends Agent {
-	constructor(proxy, opts) {
-		super(opts);
-		this.proxy = proxy;
-	}
-}
diff --git a/src/shim/https.js b/src/shim/https.js
@@ -1,3 +0,0 @@
-export class Agent {
-	Agent() {}
-}
diff --git a/src/shim/net.js b/src/shim/net.js
@@ -1 +0,0 @@
-export const isIP = () => {};
diff --git a/src/vendoRequest.js b/src/vendoRequest.js
@@ -0,0 +1,96 @@
+import { parse as parseContentType } from 'content-type';
+import { HafasError } from 'db-vendo-client/lib/errors.js';
+
+export const
+	checkIfVendoResponseIsOk = (_) => {
+		const
+			{ body, errProps: baseErrProps } = _,
+			errProps = { ...baseErrProps };
+
+		if (body.id)
+			errProps.hafasResponseId = body.id;
+
+		// Because we want more accurate stack traces, we don't construct the error here,
+		// but only return the constructor & error message.
+		const getError = (_) => {
+			// mutating here is ugly but pragmatic
+			if (_.fehlerNachricht.ueberschrift) errProps.hafasMessage = _.fehlerNachricht.ueberschrift;
+			if (_.fehlerNachricht.text) errProps.hafasDescription = _.fehlerNachricht.text;
+
+			return {
+				Error: HafasError,
+				message: errProps.hafasMessage || 'unknown error',
+				props: { code: _.fehlerNachricht.code },
+			};
+		};
+
+		if (body.fehlerNachricht || body.errors) { // TODO better handling
+			const {Error: HafasError, message, props} = getError(body);
+			throw new HafasError(message, body.err || body.errors, {...errProps, ...props});
+		}
+	},
+
+	vendoRequest = async (ctx, userAgent, reqData) => {
+		const
+			{profile, opt} = ctx,
+			endpoint = reqData.endpoint;
+
+		delete reqData.endpoint;
+
+		const
+			rawReqBody = profile.transformReqBody(ctx, reqData.body),
+			reqOptions = profile.transformReq(ctx, {
+				agent: null,
+				keepalive: true,
+				method: reqData.method,
+				redirect: 'follow',
+				body: JSON.stringify(rawReqBody),
+				query: reqData.query,
+				headers: {
+					'Content-Type': 'application/json',
+					// 'Accept-Encoding': 'gzip, deflate, br, zstd',
+					'Accept': 'application/json',
+					'Accept-Language': opt.language || profile.defaultLanguage || 'en',
+					'user-agent': userAgent,
+					...reqData.headers,
+				},
+			}),
+
+			url = `${[ endpoint, reqData?.path ].filter(x => typeof x === 'string' && x.length > 0).join('/')}?${(new URLSearchParams(reqOptions.query)).toString()}`,
+			fetchReq = new Request(url, reqOptions),
+			res = await fetch(url, reqOptions),
+			errProps = {
+				// todo [breaking]: assign as non-enumerable property
+				request: fetchReq,
+				// todo [breaking]: assign as non-enumerable property
+				response: res,
+				url,
+			};
+
+		if (!res.ok) {
+			// todo [breaking]: make this a FetchError or a HafasClientError?
+			console.log(JSON.stringify(res), await res.text());
+			const err = new Error(res.statusText);
+			Object.assign(err, errProps);
+			throw err;
+		}
+
+		const cType = res.headers.get('content-type');
+		if (cType) {
+			const { type } = parseContentType(cType);
+			// For some reason, the reqOptions.headers object is sometimes a plain object
+			// and sometimes a Headers object (In browser env). For the latter, .get() must
+			// be used.
+			if (type !== reqOptions.headers['Accept'] && type !== reqOptions.headers.get('Accept'))
+				throw new HafasError('invalid/unsupported response content-type: ' + cType, null, errProps);
+		}
+
+		const body = JSON.parse(await res.text());
+
+		checkIfVendoResponseIsOk({ body, errProps });
+
+		return {
+			res: body,
+			common: {},
+		};
+	};