opencode-mobile - Single mobile push notification plugin for OpenCode. Enables push notifications via Expo for mobile devices with tunnel management (ngrok/cloudflare/localtunnel). Built with TypeScript and Bun runtime.
# Run the plugin
bun run index.ts
# Type-check only (no emit)
npm run typecheck
npx tsc --noEmit
# Compile TypeScript
npx tsc
# Build (type-check + compile)
npm run build
# Testing with vitest
npx vitest run # Run all tests
npx vitest run src/tunnel/ # Run tunnel tests
npx vitest run src/tunnel/localtunnel.test.ts # Run specific test file
npx vitest run --reporter=verbose # Verbose output
npx vitest ui # Interactive UI (http://localhost:51204/__vitest__)
# Version and release
npm version patch && npm run build && npm publish # Patch releaseplugin/
├── index.ts # Entry point (plugin + HTTP server)
├── src/
│ ├── tunnel/ # Tunnel providers (ngrok, cloudflare, localtunnel)
│ │ ├── index.ts # Unified interface, orchestrator
│ │ ├── localtunnel.ts# Localtunnel provider (62 lines, testable)
│ │ ├── cloudflare.ts # Cloudflare tunnel (117 lines, testable)
│ │ ├── ngrok.ts # Ngrok with 4-strategy fallback (480 lines)
│ │ ├── qrcode.ts # QR code utilities
│ │ ├── types.ts # Type definitions
│ │ ├── *.test.ts # Unit tests (vitest)
│ │ └── vitest.config.ts
│ ├── push/ # Push notification logic
│ │ ├── index.ts # Barrel export
│ │ ├── types.ts # Push types
│ │ ├── token-store.ts# Token persistence
│ │ ├── formatter.ts # Notification formatting
│ │ ├── sender.ts # Expo API sender
│ │ └── notification-handler.ts # Session notification (commented)
│ └── proxy/ # Reverse proxy utilities
├── vitest.config.ts # Test configuration
├── tsconfig.json # TypeScript config (strict mode, bundler)
└── package.json # Dependencies + scripts
// Standard library - namespace imports
import * as fs from "fs";
import * as path from "path";
// External modules - named or default imports
import ngrok from "@ngrok/ngrok";
import qrcode from "qrcode";
// Types - use import type when only using types
import type { Plugin } from "@opencode-ai/plugin";
import type { TunnelConfig } from "./types";
// Group imports logically: types → external modules → internal modules
import type { Plugin } from "@opencode-ai/plugin";
import * as fs from "fs";
import * as path from "path";
import { startTunnel } from "./src/tunnel";- 2 spaces for indentation
- Single quotes for strings ons** at end- **Semicol of statements
- Trailing commas in multi-line objects/arrays
- Max line length: ~100 characters (soft limit)
// Interfaces for object shapes
interface PushToken {
token: string;
platform: "ios" | "android";
deviceId: string;
registeredAt: string;
}
// Type aliases for unions/primitives
type NotificationHandler = (notification: Notification) => Promise<void>;
// Explicit return types for public functions
function loadTokens(): PushToken[] {
// ...
}
// Avoid `any` - use `unknown` with type guards when uncertain
function safeParse(data: unknown): Record<string, unknown> {
if (typeof data === "string") {
try {
return JSON.parse(data);
} catch {
return {};
}
}
return data as Record<string, unknown>;
}| Pattern | Convention | Example |
|---|---|---|
| Constants | UPPER_SNAKE_CASE | TOKEN_FILE, BUN_SERVER_PORT |
| Functions/variables | camelCase | loadTokens, startTunnel |
| Interfaces/classes | PascalCase | PushToken, TunnelConfig |
| Private/internal | prefix _ |
_bunServer, _pluginInitialized |
| Booleans | prefix is, has, should |
isRunning, hasStarted |
// Always wrap async operations in try-catch
try {
await someAsyncOperation();
} catch (error: unknown) {
// Log errors with module prefix
console.error("[ModuleName] Error message:", error.message);
// Provide context in error messages
if (error.message?.includes("specific case")) {
console.error("[PushPlugin] Handle specific error:", error.message);
} else {
console.error("[PushPlugin] Unexpected error:", error.message);
}
}
// Handle specific error types when possible
if (error instanceof ValidationError) {
// Handle validation errors
}- Use module prefixes in all console output:
[PushPlugin],[Tunnel],[Proxy] - Use emojis for status indicators:
✅,❌,💡,ℹ️ - Log important steps and results
console.log('[PushPlugin] Starting...');
console.error('[PushPlugin] Failed:', error.message);
console.log(`[Tunnel] URL: ${url}`);
console.log('✅ Server started successfully');
console.log('❌ Connection failed:', error.message);// Use async/await over raw promises
async function startServer(): Promise<void> {
try {
await startProxy();
await startTunnel();
} catch (error) {
// handle error
}
}
// Never leave promises unhandled
// Use Promise.all() for parallel operations
const [result1, result2] = await Promise.all([
operation1(),
operation2(),
]);*.test.ts- Unit tests for modules- Located alongside the module being tested (e.g.,
localtunnel.ts→localtunnel.test.ts)
import { describe, it, expect, beforeEach, afterEach } from "vitest";
describe("module name", () => {
beforeEach(async () => {
// Setup before each test
const { clearState } = await import("./module");
clearState();
});
afterEach(async () => {
// Cleanup after each test
const { cleanup } = await import("./module");
await cleanup().catch(() => {});
});
describe("functionality group", () => {
it("should do something", async () => {
const { functionName } = await import("./module");
const result = await functionName();
expect(result).toHaveProperty("expected");
});
it("should throw on invalid input", async () => {
const { functionName } = await import("./module");
await expect(functionName(invalidInput)).rejects.toThrow("error message");
});
});
});Tunnel providers expose test helpers for isolation:
// State management helpers
clearInstance(); // Reset module state
setInstance(mock); // Set mock instance
getInstance(); // Get current instance
// Factory functions with DI
createLocaltunnel(config, { localtunnelModule: mockModule });
createCloudflareTunnel(config, mockSpawn, mockExistsSync, onUrl);All plugins must export a function matching the Plugin type from @opencode-ai/plugin:
import type { Plugin } from "@opencode-ai/plugin";
export const PushNotificationPlugin: Plugin = async (ctx) => {
// Initialize plugin
return {
event: async ({ event }) => {
// Handle event
},
};
};
export default PushNotificationPlugin;// Handle process signals for graceful shutdown
const signals = ["SIGINT", "SIGTERM", "SIGHUP"];
signals.forEach((signal) => {
process.on(signal, async () => {
await gracefulShutdown();
process.exit(0);
});
});- Single Entry Point:
index.tsis the only entry point (tsconfig includes only this file) - Plugin Pattern: Export a
Pluginfunction returning an event handler - Factory Functions: Tunnel providers use
create*functions for testability with dependency injection - State Helpers: Export
getInstance(),setInstance(),clearInstance()for testing - Graceful Shutdown: Listen for SIGINT/SIGTERM/SIGHUP
- Bun Server: Use
Bun.serve()for HTTP servers - Tunnel Providers: Support ngrok, cloudflare, localtunnel with fallback
- Ngrok Multi-Strategy: 4 fallback strategies if one fails
- Serve Mode Gate: Only start the LAN server + auto-tunnel when
process.argvincludesserve; do NOT infer serve mode fromctx.serverUrl(it can be present foropencode debug wait)
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["index.ts"],
"exclude": ["node_modules", "dist"]
}import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
include: ["**/*.test.ts"],
exclude: ["node_modules", "dist"],
coverage: {
provider: "v8",
reporter: ["text", "json", "html"],
},
},
});- Runtime: Bun (not Node.js)
- TypeScript: Strict mode enabled
- No ESLint/Prettier: Follow existing patterns manually
- Testing: Vitest with globals plugin
| Dependency | Purpose |
|---|---|
@opencode-ai/plugin |
Core plugin interface |
@ngrok/ngrok |
Ngrok SDK |
localtunnel |
Localtunnel provider |
qrcode |
QR code generation |
cloudflared |
Cloudflare tunnel binary |
- tsconfig.json includes only
index.ts- TypeScript follows imports automatically - Old test files (
test-tunnel.ts,test-utils.ts,test-tunnel.ts) should be deleted if found - Test files use
.test.tssuffix and live alongside the module they test - Use
npx vitest runfor CI,npx vitest uifor development