Initial commit: NestJS meshtastic bridge bot
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
coverage
|
||||
*.log
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
# compiled output
|
||||
/dist
|
||||
/node_modules
|
||||
|
||||
# logs
|
||||
*.log
|
||||
|
||||
# test coverage
|
||||
/coverage
|
||||
|
||||
# build info
|
||||
tsconfig.build.tsbuildinfo
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build && npm prune --omit=dev
|
||||
|
||||
FROM node:22-alpine
|
||||
ENV NODE_ENV=production
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/node_modules ./node_modules
|
||||
COPY --from=build /app/dist ./dist
|
||||
COPY package*.json ./
|
||||
EXPOSE 3000
|
||||
CMD ["node", "dist/main.js"]
|
||||
@@ -0,0 +1,42 @@
|
||||
# mesh-bot
|
||||
|
||||
Minimal NestJS bot for Meshtastic. Long-running service that TCP-connects to a
|
||||
Meshtastic serial bridge (raw socat pipe) at `192.168.1.4:4003`, parses inbound
|
||||
protobuf frames, logs received text messages, and exposes HTTP endpoints to send text.
|
||||
|
||||
## Local run
|
||||
|
||||
```sh
|
||||
npm i
|
||||
npm run start:dev
|
||||
```
|
||||
|
||||
## Docker
|
||||
|
||||
```sh
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Exposed via Traefik only (no published ports) at `http://bot.dev.kkhost.pl`.
|
||||
|
||||
## Endpoints
|
||||
|
||||
- `GET /status` — `{ connected, myNodeNum, host, port }`
|
||||
- `POST /send` — body `{ "text": "...", "to"?: <node number | "!b" | "broadcast"> }`, returns `{ id }`
|
||||
|
||||
```sh
|
||||
curl http://localhost:3000/status
|
||||
curl -X POST http://localhost:3000/send -H 'content-type: application/json' -d '{"text":"hi"}'
|
||||
curl -X POST http://localhost:3000/send -H 'content-type: application/json' -d '{"to":123456789,"text":"hello"}'
|
||||
```
|
||||
|
||||
`to` omitted, `"!b"`, or `"broadcast"` sends to the broadcast address `0xffffffff`.
|
||||
|
||||
## Env vars
|
||||
|
||||
- `MESH_HOST` (default `192.168.1.4`) — bridge host
|
||||
- `MESH_PORT` (default `4003`) — bridge port
|
||||
- `PORT` (default `3000`) — HTTP listen port
|
||||
|
||||
Note: the serial bridge accepts only ONE client at a time — if another app is
|
||||
connected to the radio, this bot will not get a connection.
|
||||
@@ -0,0 +1,18 @@
|
||||
services:
|
||||
bot:
|
||||
build: .
|
||||
environment:
|
||||
MESH_HOST: 192.168.1.4
|
||||
MESH_PORT: '4003'
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- dev_net
|
||||
labels:
|
||||
- 'traefik.enable=true'
|
||||
- 'traefik.http.routers.mesh-bot.rule=Host(`bot.dev.kkhost.pl`)'
|
||||
- 'traefik.http.routers.mesh-bot.entrypoints=web'
|
||||
- 'traefik.http.services.mesh-bot.loadbalancer.server.port=3000'
|
||||
|
||||
networks:
|
||||
dev_net:
|
||||
external: true
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/oxc-project/oxc/main/crates/oxc_linter/src/rules.rs",
|
||||
"rules": {
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-floating-promises": "warn"
|
||||
},
|
||||
"env": {
|
||||
"node": true
|
||||
}
|
||||
}
|
||||
Generated
+5676
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "mesh-bot",
|
||||
"version": "0.0.1",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"deploy": "nest deploy",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "oxlint src/ test/",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:cov": "vitest run --coverage",
|
||||
"test:debug": "vitest --inspect-brk --no-file-parallelism",
|
||||
"test:e2e": "vitest run --config ./vitest.config.e2e.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bufbuild/protobuf": "^2.15.0",
|
||||
"@meshtastic/protobufs": "^2.8.0",
|
||||
"@nestjs/common": "^12.0.1",
|
||||
"@nestjs/core": "^12.0.1",
|
||||
"@nestjs/platform-express": "^12.0.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^12.0.0",
|
||||
"@nestjs/mau": "^0.2.6",
|
||||
"@nestjs/schematics": "^12.0.0",
|
||||
"@nestjs/testing": "^12.0.1",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/supertest": "^7.0.0",
|
||||
"@vitest/coverage-v8": "^4.1.2",
|
||||
"oxlint": "^1.58.0",
|
||||
"prettier": "^3.4.2",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^7.0.0",
|
||||
"typescript": "^6.0.2",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"vitest": "^4.1.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MeshModule } from './mesh/mesh.module.js';
|
||||
|
||||
@Module({
|
||||
imports: [MeshModule],
|
||||
})
|
||||
export class AppModule {}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './app.module.js';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
app.enableShutdownHooks();
|
||||
await app.listen(process.env.PORT ?? 3000);
|
||||
}
|
||||
bootstrap();
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { extractFrames, frame } from './codec.js';
|
||||
|
||||
const F = (payload: number[]) => [
|
||||
0x94,
|
||||
0xc3,
|
||||
(payload.length >> 8) & 0xff,
|
||||
payload.length & 0xff,
|
||||
...payload,
|
||||
];
|
||||
|
||||
describe('extractFrames', () => {
|
||||
it('emits one complete frame and empties rest', () => {
|
||||
const { packets, rest } = extractFrames(Uint8Array.from(F([1, 2, 3])));
|
||||
expect(packets).toHaveLength(1);
|
||||
expect(Array.from(packets[0])).toEqual([1, 2, 3]);
|
||||
expect(rest.length).toBe(0);
|
||||
});
|
||||
|
||||
it('emits two frames from one chunk', () => {
|
||||
const buf = Uint8Array.from([...F([1]), ...F([2, 2])]);
|
||||
const { packets, rest } = extractFrames(buf);
|
||||
expect(packets.map((p) => Array.from(p))).toEqual([[1], [2, 2]]);
|
||||
expect(rest.length).toBe(0);
|
||||
});
|
||||
|
||||
it('reassembles a frame split across two feedings', () => {
|
||||
const full = Uint8Array.from(F([9, 8, 7]));
|
||||
const first = extractFrames(full.subarray(0, 5)); // header + 1 payload byte
|
||||
expect(first.packets).toHaveLength(0);
|
||||
expect(Array.from(first.rest)).toEqual(Array.from(full.subarray(0, 5)));
|
||||
|
||||
const second = extractFrames(
|
||||
Uint8Array.from([...first.rest, ...full.subarray(5)]),
|
||||
);
|
||||
expect(second.packets.map((p) => Array.from(p))).toEqual([[9, 8, 7]]);
|
||||
expect(second.rest.length).toBe(0);
|
||||
});
|
||||
|
||||
it('discards garbage bytes before the header', () => {
|
||||
const buf = Uint8Array.from([0xaa, 0xbb, ...F([5])]);
|
||||
const { packets, rest } = extractFrames(buf);
|
||||
expect(packets.map((p) => Array.from(p))).toEqual([[5]]);
|
||||
expect(rest.length).toBe(0);
|
||||
});
|
||||
|
||||
it('resyncs past a bogus len > 1024 and finds the real frame', () => {
|
||||
const buf = Uint8Array.from([0x94, 0xc3, 0xff, 0xfe, ...F([7, 6])]);
|
||||
const { packets, rest } = extractFrames(buf);
|
||||
expect(packets.map((p) => Array.from(p))).toEqual([[7, 6]]);
|
||||
expect(rest.length).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps a trailing lone 0x94 in rest', () => {
|
||||
const { packets, rest } = extractFrames(Uint8Array.from([0xaa, 0x94]));
|
||||
expect(packets).toHaveLength(0);
|
||||
expect(Array.from(rest)).toEqual([0x94]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('frame', () => {
|
||||
it('prepends the 0x94 0xC3 big-endian length header', () => {
|
||||
const out = frame(Uint8Array.from([1, 2, 3]));
|
||||
expect(Array.from(out)).toEqual([0x94, 0xc3, 0x00, 0x03, 1, 2, 3]);
|
||||
});
|
||||
|
||||
it('encodes lengths above 255 in two bytes', () => {
|
||||
const payload = new Uint8Array(300).fill(7);
|
||||
const out = frame(payload);
|
||||
expect(Array.from(out.subarray(0, 4))).toEqual([0x94, 0xc3, 0x01, 0x2c]);
|
||||
expect(out.length).toBe(304);
|
||||
});
|
||||
|
||||
it('round-trips through extractFrames', () => {
|
||||
const framed = frame(Uint8Array.from([4, 5]));
|
||||
const { packets, rest } = extractFrames(framed);
|
||||
expect(packets.map((p) => Array.from(p))).toEqual([[4, 5]]);
|
||||
expect(rest.length).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { create, fromBinary, toBinary } from '@bufbuild/protobuf';
|
||||
|
||||
let proto: typeof import('@meshtastic/protobufs') | null = null;
|
||||
|
||||
async function loadProto(): Promise<typeof import('@meshtastic/protobufs')> {
|
||||
if (!proto) proto = await import('@meshtastic/protobufs');
|
||||
return proto!;
|
||||
}
|
||||
|
||||
export const BROADCAST_ADDR = 0xffffffff;
|
||||
// official portnums.proto: TEXT_MESSAGE_APP=1 (task spec draft said 3 — the enum value wins)
|
||||
export const PORT_TEXT_MESSAGE_APP = 1;
|
||||
|
||||
const START1 = 0x94;
|
||||
const START2 = 0xc3;
|
||||
const MAX_LEN = 1024;
|
||||
|
||||
export function extractFrames(buf: Uint8Array): {
|
||||
packets: Uint8Array[];
|
||||
rest: Uint8Array;
|
||||
} {
|
||||
const packets: Uint8Array[] = [];
|
||||
let i = 0;
|
||||
for (;;) {
|
||||
if (i >= buf.length) return { packets, rest: new Uint8Array(0) };
|
||||
const isHeaderStart =
|
||||
buf[i] === START1 && i + 1 < buf.length && buf[i + 1] === START2;
|
||||
if (!isHeaderStart) {
|
||||
// keep a trailing lone 0x94 — it may be the first byte of a split header
|
||||
if (buf[i] === START1 && i + 1 >= buf.length)
|
||||
return { packets, rest: buf.subarray(i) };
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (i + 4 > buf.length) return { packets, rest: buf.subarray(i) }; // partial header
|
||||
const len = (buf[i + 2] << 8) | buf[i + 3];
|
||||
if (len > MAX_LEN) {
|
||||
i++; // bogus header — rescan from next byte
|
||||
continue;
|
||||
}
|
||||
if (i + 4 + len > buf.length) return { packets, rest: buf.subarray(i) }; // partial payload
|
||||
packets.push(buf.subarray(i + 4, i + 4 + len));
|
||||
i += 4 + len;
|
||||
}
|
||||
}
|
||||
|
||||
export function frame(payload: Uint8Array): Uint8Array {
|
||||
const out = new Uint8Array(4 + payload.length);
|
||||
out[0] = START1;
|
||||
out[1] = START2;
|
||||
out[2] = (payload.length >> 8) & 0xff;
|
||||
out[3] = payload.length & 0xff;
|
||||
out.set(payload, 4);
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function decodeFromRadio(
|
||||
bytes: Uint8Array,
|
||||
): Promise<import('@meshtastic/protobufs').Mesh.FromRadio> {
|
||||
const P = await loadProto();
|
||||
return fromBinary(P.Mesh.FromRadioSchema, bytes);
|
||||
}
|
||||
|
||||
export async function buildTextToRadio(
|
||||
to: number,
|
||||
text: string,
|
||||
): Promise<{ bytes: Uint8Array; id: number }> {
|
||||
const P = await loadProto();
|
||||
let id = Math.floor(Math.random() * 0x100000000) >>> 0;
|
||||
if (id === 0) id = 1; // non-zero uint32
|
||||
const meshPacket = create(P.Mesh.MeshPacketSchema, {
|
||||
to,
|
||||
id,
|
||||
wantAck: true,
|
||||
channel: 0,
|
||||
payloadVariant: {
|
||||
case: 'decoded',
|
||||
value: {
|
||||
portnum: P.Portnums.PortNum.TEXT_MESSAGE_APP,
|
||||
payload: new TextEncoder().encode(text),
|
||||
wantResponse: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
const toRadio = create(P.Mesh.ToRadioSchema, {
|
||||
payloadVariant: { case: 'packet', value: meshPacket },
|
||||
});
|
||||
return { bytes: frame(toBinary(P.Mesh.ToRadioSchema, toRadio)), id };
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
} from '@nestjs/common';
|
||||
import { BROADCAST_ADDR } from './codec.js';
|
||||
import { MeshService } from './mesh.service.js';
|
||||
|
||||
@Controller()
|
||||
export class MeshController {
|
||||
constructor(private readonly mesh: MeshService) {}
|
||||
|
||||
@Get('status')
|
||||
status() {
|
||||
return this.mesh.status();
|
||||
}
|
||||
|
||||
@Post('send')
|
||||
async send(@Body() body: { to?: string | number; text: string }) {
|
||||
if (typeof body?.text !== 'string' || body.text.length === 0) {
|
||||
throw new BadRequestException('text must be a non-empty string');
|
||||
}
|
||||
let to = BROADCAST_ADDR;
|
||||
const t = body.to;
|
||||
if (t !== undefined && t !== '!b' && t !== 'broadcast') {
|
||||
const n = typeof t === 'number' ? t : Number(String(t));
|
||||
if (!Number.isFinite(n))
|
||||
throw new BadRequestException(
|
||||
'to must be a decimal node number, "!b", or "broadcast"',
|
||||
);
|
||||
to = n;
|
||||
}
|
||||
return { id: await this.mesh.sendText(to, body.text) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MeshController } from './mesh.controller.js';
|
||||
import { MeshService } from './mesh.service.js';
|
||||
|
||||
@Module({
|
||||
controllers: [MeshController],
|
||||
providers: [MeshService],
|
||||
})
|
||||
export class MeshModule {}
|
||||
@@ -0,0 +1,124 @@
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
OnModuleDestroy,
|
||||
OnModuleInit,
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
import * as net from 'node:net';
|
||||
import {
|
||||
PORT_TEXT_MESSAGE_APP,
|
||||
buildTextToRadio,
|
||||
decodeFromRadio,
|
||||
extractFrames,
|
||||
} from './codec.js';
|
||||
|
||||
@Injectable()
|
||||
export class MeshService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(MeshService.name);
|
||||
private readonly host = process.env.MESH_HOST ?? '192.168.1.4';
|
||||
private readonly port = parseInt(process.env.MESH_PORT ?? '4003', 10);
|
||||
|
||||
private socket: net.Socket | null = null;
|
||||
private buffer: Uint8Array = new Uint8Array(0);
|
||||
private connected = false;
|
||||
private myNodeNum: number | null = null;
|
||||
private reconnectTimer: NodeJS.Timeout | null = null;
|
||||
private shuttingDown = false;
|
||||
|
||||
onModuleInit(): void {
|
||||
this.connect();
|
||||
}
|
||||
|
||||
onModuleDestroy(): void {
|
||||
this.shuttingDown = true;
|
||||
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
||||
this.socket?.destroy();
|
||||
}
|
||||
|
||||
status() {
|
||||
return {
|
||||
connected: this.connected,
|
||||
myNodeNum: this.myNodeNum,
|
||||
host: this.host,
|
||||
port: this.port,
|
||||
};
|
||||
}
|
||||
|
||||
async sendText(to: number, text: string): Promise<number> {
|
||||
if (!this.connected || !this.socket)
|
||||
throw new ServiceUnavailableException('bridge not connected');
|
||||
const { bytes, id } = await buildTextToRadio(to, text);
|
||||
this.socket.write(bytes);
|
||||
return id;
|
||||
}
|
||||
|
||||
private connect(): void {
|
||||
if (this.shuttingDown) return;
|
||||
const socket = net.createConnection({ host: this.host, port: this.port });
|
||||
this.socket = socket;
|
||||
socket.on('connect', () => {
|
||||
this.connected = true;
|
||||
this.logger.log(`bridge connected ${this.host}:${this.port}`);
|
||||
});
|
||||
socket.on('data', (chunk) => {
|
||||
const merged = new Uint8Array(this.buffer.length + chunk.length);
|
||||
merged.set(this.buffer, 0);
|
||||
merged.set(chunk, this.buffer.length);
|
||||
this.buffer = merged;
|
||||
const { packets, rest } = extractFrames(this.buffer);
|
||||
this.buffer = rest;
|
||||
for (const p of packets) {
|
||||
decodeFromRadio(p)
|
||||
.then((fr) => this.handleFromRadio(fr))
|
||||
.catch((err: unknown) =>
|
||||
this.logger.error(`decode failed: ${String(err)}`),
|
||||
);
|
||||
}
|
||||
});
|
||||
const onDown = () => {
|
||||
this.connected = false;
|
||||
this.scheduleReconnect();
|
||||
};
|
||||
socket.on('error', (err) => {
|
||||
this.logger.warn(`bridge error: ${err.message}`);
|
||||
onDown();
|
||||
});
|
||||
socket.on('close', onDown);
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
if (this.shuttingDown) return;
|
||||
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
||||
// ponytail: fixed 5s retry, no backoff — fine for single-bridge use
|
||||
this.reconnectTimer = setTimeout(() => this.connect(), 5000);
|
||||
}
|
||||
|
||||
private handleFromRadio(
|
||||
fr: import('@meshtastic/protobufs').Mesh.FromRadio,
|
||||
): void {
|
||||
const v = fr.payloadVariant;
|
||||
switch (v?.case) {
|
||||
case 'myInfo':
|
||||
this.myNodeNum = v.value.myNodeNum;
|
||||
this.logger.log(`my node num: ${this.myNodeNum}`);
|
||||
break;
|
||||
case 'configCompleteId':
|
||||
this.logger.log('bridge ready (config complete)');
|
||||
break;
|
||||
case 'packet': {
|
||||
const pkt = v.value;
|
||||
const decoded =
|
||||
pkt.payloadVariant?.case === 'decoded'
|
||||
? pkt.payloadVariant.value
|
||||
: undefined;
|
||||
if (decoded && decoded.portnum === PORT_TEXT_MESSAGE_APP) {
|
||||
this.logger.log(
|
||||
`[mesh] ${pkt.from} -> ${pkt.to}: "${new TextDecoder().decode(decoded.payload)}"`,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "nodenext",
|
||||
"moduleResolution": "nodenext",
|
||||
"resolvePackageJsonExports": true,
|
||||
"esModuleInterop": true,
|
||||
"isolatedModules": true,
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2023",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"strictPropertyInitialization": false,
|
||||
"types": ["vitest/globals", "node"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tsconfigPaths()],
|
||||
test: {
|
||||
globals: true,
|
||||
root: './',
|
||||
include: ['**/*.e2e-spec.ts'],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
|
||||
export default defineConfig({
|
||||
// Resolves the path aliases declared in tsconfig.json, including the ones
|
||||
// added by `nest g library`.
|
||||
plugins: [tsconfigPaths()],
|
||||
test: {
|
||||
globals: true,
|
||||
root: './',
|
||||
include: ['**/*.spec.ts'],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user