adding publishing, updating readme, updated CLI, added Sqlite

This commit is contained in:
Luke Hagar
2025-08-13 11:12:32 -05:00
parent c06fd7daea
commit a42b708dd0
10 changed files with 385 additions and 8 deletions

67
.github/workflows/publish.yml vendored Normal file
View File

@@ -0,0 +1,67 @@
name: Publish to npm
on:
push:
branches:
- main
workflow_dispatch: {}
concurrency:
group: publish-npm
cancel-in-progress: false
jobs:
publish:
name: Publish
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Use Node.js 20
uses: actions/setup-node@v4
with:
node-version: 20
registry-url: 'https://registry.npmjs.org'
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
- name: Build
run: npm run build
- name: Determine publish necessity
id: check
shell: bash
run: |
set -euo pipefail
PKG_NAME=$(node -p "require('./package.json').name")
PKG_VERSION=$(node -p "require('./package.json').version")
echo "Package: $PKG_NAME@$PKG_VERSION"
PUBLISHED_VERSION=$(npm view "$PKG_NAME" version || echo "0.0.0")
echo "Published: $PUBLISHED_VERSION"
if [ "$PKG_VERSION" = "$PUBLISHED_VERSION" ]; then
echo "should_publish=false" >> $GITHUB_OUTPUT
else
echo "should_publish=true" >> $GITHUB_OUTPUT
fi
- name: Publish to npm
if: steps.check.outputs.should_publish == 'true'
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
# Enable provenance for npm (requires id-token permission)
npm publish --provenance --access public
- name: Skip publish (version unchanged)
if: steps.check.outputs.should_publish != 'true'
run: echo "Skipping publish because version has not changed."

View File

@@ -1,12 +1,17 @@
FROM node:20-slim FROM node:20-slim
# Install build tools needed for better-sqlite3
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
# Copy package files # Copy package files
COPY package*.json ./ COPY package*.json ./
# Install dependencies # Install dependencies
RUN npm install RUN npm install --omit=dev
# Copy source code # Copy source code
COPY . . COPY . .
@@ -17,5 +22,8 @@ RUN npm run build
# Expose ports for proxy and docs servers # Expose ports for proxy and docs servers
EXPOSE 8080 9000 EXPOSE 8080 9000
# Persistent data directory
VOLUME ["/data"]
# Set default command # Set default command
CMD ["node", "dist/src/cli.js"] CMD ["node", "dist/src/cli.js"]

View File

@@ -19,6 +19,34 @@ Arbiter is a powerful API proxy and documentation generator that automatically c
## Getting Started ## Getting Started
### Installation
```bash
npm install -g arbiter
```
### Basic Usage
Start Arbiter by pointing it to your target API:
```bash
arbiter --target https://api.example.com
# with persistence
arbiter --target https://api.example.com --db-path ./arbiter.db
```
Then send requests through the proxy:
```bash
curl http://localhost:8080/users
```
And view the automatically generated documentation:
```bash
open http://localhost:9000/docs
```
### Docker Usage ### Docker Usage
You can run Arbiter using Docker: You can run Arbiter using Docker:
@@ -27,8 +55,13 @@ You can run Arbiter using Docker:
# Build the Docker image # Build the Docker image
docker build -t arbiter . docker build -t arbiter .
# Run the container # Run the container (ephemeral)
docker run -p 8080:8080 -p 9000:9000 arbiter --target https://api.example.com docker run -p 8080:8080 -p 9000:9000 arbiter --target https://api.example.com
# Run the container with persistent storage
docker run -p 8080:8080 -p 9000:9000 \
-v $(pwd)/data:/data \
arbiter --target https://api.example.com --db-path /data/arbiter.db
``` ```
The container exposes: The container exposes:
@@ -42,6 +75,7 @@ docker run -p 3000:3000 -p 3001:3001 arbiter \
--target https://api.example.com \ --target https://api.example.com \
--port 3000 \ --port 3000 \
--docs-port 3001 \ --docs-port 3001 \
--db-path /data/arbiter.db \
--verbose --verbose
``` ```
@@ -52,6 +86,7 @@ docker run -p 3000:3000 -p 3001:3001 arbiter \
| `-t, --target <url>` | Target API URL to proxy to | (required) | | `-t, --target <url>` | Target API URL to proxy to | (required) |
| `-p, --port <number>` | Port to run the proxy server on | 8080 | | `-p, --port <number>` | Port to run the proxy server on | 8080 |
| `-d, --docs-port <number>` | Port to run the documentation server on | 9000 | | `-d, --docs-port <number>` | Port to run the documentation server on | 9000 |
| `--db-path <path>` | Path to SQLite database file for persistence | (disabled) |
| `--docs-only` | Run only the documentation server | false | | `--docs-only` | Run only the documentation server | false |
| `--proxy-only` | Run only the proxy server | false | | `--proxy-only` | Run only the proxy server | false |
| `-v, --verbose` | Enable verbose logging | false | | `-v, --verbose` | Enable verbose logging | false |
@@ -149,6 +184,16 @@ app.listen(3000);
Contributions are welcome! Please feel free to submit a Pull Request. Contributions are welcome! Please feel free to submit a Pull Request.
## Publishing (maintainers)
This repo auto-publishes to npm on push to `main` if the version in `package.json` is newer than the version on npm.
Setup (one-time):
- Add a repository secret `NPM_TOKEN` with publish rights for the package.
Manual run:
- You can also trigger the workflow manually from the Actions tab (workflow_dispatch).
## License ## License
This project is licensed under the MIT License - see the LICENSE file for details. This project is licensed under the MIT License - see the LICENSE file for details.

View File

@@ -49,7 +49,8 @@
"http-proxy-middleware": "^3.0.3", "http-proxy-middleware": "^3.0.3",
"serve-static": "^1.16.2", "serve-static": "^1.16.2",
"swagger-ui-express": "^5.0.1", "swagger-ui-express": "^5.0.1",
"yaml": "^2.7.0" "yaml": "^2.7.0",
"better-sqlite3": "^9.6.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.3.0", "@eslint/eslintrc": "^3.3.0",

View File

@@ -16,6 +16,7 @@ program
.requiredOption('-t, --target <url>', 'target API URL to proxy to') .requiredOption('-t, --target <url>', 'target API URL to proxy to')
.option('-p, --port <number>', 'port to run the proxy server on', '8080') .option('-p, --port <number>', 'port to run the proxy server on', '8080')
.option('-d, --docs-port <number>', 'port to run the documentation server on', '9000') .option('-d, --docs-port <number>', 'port to run the documentation server on', '9000')
.option('--db-path <path>', 'path to SQLite database file for persistence')
.option('--docs-only', 'run only the documentation server') .option('--docs-only', 'run only the documentation server')
.option('--proxy-only', 'run only the proxy server') .option('--proxy-only', 'run only the proxy server')
.option('-v, --verbose', 'enable verbose logging') .option('-v, --verbose', 'enable verbose logging')
@@ -29,6 +30,7 @@ startServers({
proxyPort: parseInt(options.port as string, 10), proxyPort: parseInt(options.port as string, 10),
docsPort: parseInt(options.docsPort as string, 10), docsPort: parseInt(options.docsPort as string, 10),
verbose: options.verbose as boolean, verbose: options.verbose as boolean,
dbPath: options.dbPath as string | undefined,
}).catch((error: Error) => { }).catch((error: Error) => {
console.error(chalk.red('Failed to start servers:'), error.message); console.error(chalk.red('Failed to start servers:'), error.message);
process.exit(1); process.exit(1);

View File

@@ -3,7 +3,8 @@ import { createProxyMiddleware } from 'http-proxy-middleware';
import { createServer } from 'http'; import { createServer } from 'http';
import cors from 'cors'; import cors from 'cors';
import zlib from 'zlib'; import zlib from 'zlib';
import { openApiStore } from './store/openApiStore.js'; import { openApiStore, OpenAPIStore } from './store/openApiStore.js';
import { initStorage, storage } from './storage/index.js';
import chalk from 'chalk'; import chalk from 'chalk';
import { IncomingMessage, ServerResponse } from 'http'; import { IncomingMessage, ServerResponse } from 'http';
import type { SecurityInfo } from './store/openApiStore.js'; import type { SecurityInfo } from './store/openApiStore.js';
@@ -129,6 +130,7 @@ export interface ServerOptions {
proxyPort: number; proxyPort: number;
docsPort: number; docsPort: number;
verbose?: boolean; verbose?: boolean;
dbPath?: string;
} }
/** /**
@@ -139,10 +141,38 @@ export async function startServers({
proxyPort, proxyPort,
docsPort, docsPort,
verbose = false, verbose = false,
dbPath,
}: ServerOptions): Promise<{ }: ServerOptions): Promise<{
proxyServer: ReturnType<typeof createServer>; proxyServer: ReturnType<typeof createServer>;
docsServer: ReturnType<typeof createServer>; docsServer: ReturnType<typeof createServer>;
}> { }> {
// Initialize persistent storage if dbPath provided
if (dbPath) {
try {
await initStorage(dbPath);
if (verbose) console.log(`Initialized SQLite storage at ${dbPath}`);
// Hydrate OpenAPI store with persisted endpoints (minimal info)
const persisted = await storage().getAllEndpoints();
for (const ep of persisted) {
try {
openApiStore.recordEndpoint(
ep.path,
ep.method.toLowerCase(),
ep.data.request,
{
status: ep.data.response?.status || 200,
headers: ep.data.response?.headers || {},
contentType: ep.data.response?.contentType || 'application/json',
body: '[Raw data stored]',
rawData: Buffer.alloc(0),
}
);
} catch {}
}
} catch (e) {
console.error('Failed to initialize storage:', e);
}
}
// Set the target URL in the OpenAPI store // Set the target URL in the OpenAPI store
openApiStore.setTargetUrl(target); openApiStore.setTargetUrl(target);
@@ -368,8 +398,19 @@ export async function startServers({
_rawResponseBuffer: buffer, // Store for later processing if needed _rawResponseBuffer: buffer, // Store for later processing if needed
}; };
// Add the HAR entry to the store // Add the HAR entry to the store and persist if enabled
harStore.addEntry(harEntry); harStore.addEntry(harEntry);
if (dbPath) {
storage().saveHarEntry({
startedDateTime: harEntry.startedDateTime,
time: harEntry.time,
request: harEntry.request,
response: {
...harEntry.response,
// Do not persist raw buffer reference
},
}).catch(() => {});
}
// Extract security schemes from headers - minimal work // Extract security schemes from headers - minimal work
const securitySchemes: SecurityInfo[] = []; const securitySchemes: SecurityInfo[] = [];
@@ -415,6 +456,26 @@ export async function startServers({
} }
); );
// Persist endpoint minimal info for reconstruction
if (dbPath) {
storage().upsertEndpoint(path, method.toLowerCase(), {
path,
method: method.toLowerCase(),
request: {
query: queryParams,
headers: requestHeaders,
contentType: requestHeaders['content-type'] || 'application/json',
body: requestBody,
security: securitySchemes,
},
response: {
status: proxyRes.statusCode || 500,
headers: responseHeaders,
contentType: responseHeaders['content-type'] || 'application/json',
},
}).catch(() => {});
}
if (verbose) { if (verbose) {
console.log(`${method} ${path} -> ${proxyRes.statusCode}`); console.log(`${method} ${path} -> ${proxyRes.statusCode}`);
} }
@@ -432,18 +493,73 @@ export async function startServers({
docsApp.use(cors()); docsApp.use(cors());
// Create documentation endpoints // Create documentation endpoints
docsApp.get('/har', (req, res) => { docsApp.get('/har', async (req, res) => {
res.setHeader('Content-Type', 'application/json'); res.setHeader('Content-Type', 'application/json');
if (dbPath) {
try {
const log = await storage().getHarLog();
res.send(JSON.stringify(log));
return;
} catch {}
}
res.send(JSON.stringify(harStore.getHAR())); res.send(JSON.stringify(harStore.getHAR()));
}); });
docsApp.get('/openapi.json', (req, res) => { docsApp.get('/openapi.json', async (req, res) => {
res.setHeader('Content-Type', 'application/json'); res.setHeader('Content-Type', 'application/json');
if (dbPath) {
try {
const persisted = await storage().getAllEndpoints();
const tempStore = new OpenAPIStore();
tempStore.setTargetUrl(target);
for (const ep of persisted) {
try {
tempStore.recordEndpoint(
ep.path,
ep.method.toLowerCase(),
ep.data.request,
{
status: ep.data.response?.status || 200,
headers: ep.data.response?.headers || {},
contentType: ep.data.response?.contentType || 'application/json',
body: '[Raw data stored]'
}
);
} catch {}
}
res.send(JSON.stringify(tempStore.getOpenAPISpec()));
return;
} catch {}
}
res.send(JSON.stringify(openApiStore.getOpenAPISpec())); res.send(JSON.stringify(openApiStore.getOpenAPISpec()));
}); });
docsApp.get('/openapi.yaml', (req, res) => { docsApp.get('/openapi.yaml', async (req, res) => {
res.setHeader('Content-Type', 'text/plain'); res.setHeader('Content-Type', 'text/plain');
if (dbPath) {
try {
const persisted = await storage().getAllEndpoints();
const tempStore = new OpenAPIStore();
tempStore.setTargetUrl(target);
for (const ep of persisted) {
try {
tempStore.recordEndpoint(
ep.path,
ep.method.toLowerCase(),
ep.data.request,
{
status: ep.data.response?.status || 200,
headers: ep.data.response?.headers || {},
contentType: ep.data.response?.contentType || 'application/json',
body: '[Raw data stored]'
}
);
} catch {}
}
res.send(tempStore.getOpenAPISpecAsYAML());
return;
} catch {}
}
res.send(openApiStore.getOpenAPISpecAsYAML()); res.send(openApiStore.getOpenAPISpecAsYAML());
}); });

15
src/storage/index.ts Normal file
View File

@@ -0,0 +1,15 @@
import { sqliteStorage } from './sqlite.js';
import type { StorageAdapter } from './types.js';
let storageInstance: StorageAdapter = sqliteStorage;
export async function initStorage(dbPath: string): Promise<StorageAdapter> {
await storageInstance.init(dbPath);
return storageInstance;
}
export function storage(): StorageAdapter {
return storageInstance;
}

96
src/storage/sqlite.ts Normal file
View File

@@ -0,0 +1,96 @@
import Database from 'better-sqlite3';
import type { StorageAdapter } from './types.js';
export class SQLiteStorage implements StorageAdapter {
private db: Database.Database | null = null;
async init(dbPath: string): Promise<void> {
this.db = new Database(dbPath);
this.db.pragma('journal_mode = WAL');
this.db.exec(`
CREATE TABLE IF NOT EXISTS har_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
startedDateTime TEXT NOT NULL,
time INTEGER NOT NULL,
request TEXT NOT NULL,
response TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_har_started ON har_entries(startedDateTime);
CREATE TABLE IF NOT EXISTS endpoints (
id INTEGER PRIMARY KEY AUTOINCREMENT,
path TEXT NOT NULL,
method TEXT NOT NULL,
data TEXT NOT NULL,
UNIQUE(path, method)
);
CREATE INDEX IF NOT EXISTS idx_endpoints_path_method ON endpoints(path, method);
`);
}
isReady(): boolean {
return this.db !== null;
}
async close(): Promise<void> {
if (this.db) {
this.db.close();
this.db = null;
}
}
async saveHarEntry(entry: any): Promise<void> {
if (!this.db) return;
const stmt = this.db.prepare(
'INSERT INTO har_entries (startedDateTime, time, request, response) VALUES (?, ?, ?, ?)'
);
stmt.run(
entry.startedDateTime,
entry.time,
JSON.stringify(entry.request),
JSON.stringify(entry.response)
);
}
async getHarLog(): Promise<any> {
if (!this.db) return { log: { version: '1.2', creator: { name: 'Arbiter', version: '1.0.0' }, entries: [] } };
const rows = this.db.prepare('SELECT startedDateTime, time, request, response FROM har_entries ORDER BY id ASC').all();
const entries = rows.map((r) => ({
startedDateTime: r.startedDateTime,
time: r.time,
request: JSON.parse(r.request),
response: JSON.parse(r.response),
}));
return {
log: {
version: '1.2',
creator: { name: 'Arbiter', version: '1.0.0' },
entries,
},
};
}
async clearHar(): Promise<void> {
if (!this.db) return;
this.db.prepare('DELETE FROM har_entries').run();
}
async upsertEndpoint(path: string, method: string, data: any): Promise<void> {
if (!this.db) return;
const stmt = this.db.prepare(
'INSERT INTO endpoints (path, method, data) VALUES (?, ?, ?)
ON CONFLICT(path, method) DO UPDATE SET data=excluded.data'
);
stmt.run(path, method.toLowerCase(), JSON.stringify(data));
}
async getAllEndpoints(): Promise<Array<{ path: string; method: string; data: any }>> {
if (!this.db) return [];
const rows = this.db.prepare('SELECT path, method, data FROM endpoints').all();
return rows.map((r) => ({ path: r.path, method: r.method, data: JSON.parse(r.data) }));
}
}
export const sqliteStorage = new SQLiteStorage();

16
src/storage/types.ts Normal file
View File

@@ -0,0 +1,16 @@
export interface StorageAdapter {
init(dbPath: string): Promise<void>;
isReady(): boolean;
close(): Promise<void>;
// HAR persistence
saveHarEntry(entry: any): Promise<void>;
getHarLog(): Promise<any>;
clearHar(): Promise<void>;
// Endpoint persistence
upsertEndpoint(path: string, method: string, data: any): Promise<void>;
getAllEndpoints(): Promise<Array<{ path: string; method: string; data: any }>>;
}

View File

@@ -144,6 +144,17 @@ export class OpenAPIStore {
this.rawDataCache.clear(); this.rawDataCache.clear();
} }
// Persist/restore helpers
public getEndpoint(path: string, method: string): EndpointInfo | undefined {
const key = `${method.toLowerCase()} ${path}`;
return this.endpoints.get(key);
}
public importEndpoint(path: string, method: string, data: EndpointInfo): void {
const key = `${method.toLowerCase()} ${path}`;
this.endpoints.set(key, data);
}
private deepMergeSchemas(schemas: OpenAPIV3_1.SchemaObject[]): OpenAPIV3_1.SchemaObject { private deepMergeSchemas(schemas: OpenAPIV3_1.SchemaObject[]): OpenAPIV3_1.SchemaObject {
if (schemas.length === 0) return { type: 'object' }; if (schemas.length === 0) return { type: 'object' };
if (schemas.length === 1) return schemas[0]; if (schemas.length === 1) return schemas[0];