Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions apps/web/app/programmatic-api/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,34 @@ afterAll(() => Promise.all([github.close(), vercel.close()]))
<td>none</td>
<td>Inline seed data (same shape as YAML config)</td>
</tr>
<tr>
<td><code>tls</code></td>
<td>none</td>
<td>Serve over HTTPS. Object of the form <code>{`{ cert, key }`}</code> where each value is a PEM <code>string</code> or <code>Buffer</code>. When set, <code>url</code> uses the <code>https://</code> scheme.</td>
</tr>
</tbody>
</table>

### HTTPS

Pass a `tls` option with PEM-encoded cert and key material to serve the emulator over HTTPS. Material is accepted in-memory — load it from disk, generate it on the fly, or source it from a secret store.

```typescript
import { readFileSync } from 'node:fs'
import { createEmulator } from 'emulate'

const github = await createEmulator({
service: 'github',
port: 4001,
tls: {
cert: readFileSync('./certs/localhost.crt'),
key: readFileSync('./certs/localhost.key'),
},
})

github.url // 'https://localhost:4001'
```

### Instance methods

<table>
Expand Down
15 changes: 12 additions & 3 deletions packages/emulate/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,23 @@ import { SERVICE_REGISTRY } from "./registry.js";
export type { ServiceName } from "./registry.js";
import type { ServiceName } from "./registry.js";
import { serve } from "@hono/node-server";
import { createServer as createHttpsServer } from "node:https";

export interface SeedConfig {
tokens?: Record<string, { login: string; scopes?: string[] }>;
[service: string]: unknown;
}

export interface EmulatorTlsOptions {
cert: string | Buffer;
key: string | Buffer;
}

export interface EmulatorOptions {
service: ServiceName;
port?: number;
seed?: SeedConfig;
tls?: EmulatorTlsOptions;
}

export interface Emulator {
Expand All @@ -22,7 +29,7 @@ export interface Emulator {
}

export async function createEmulator(options: EmulatorOptions): Promise<Emulator> {
const { service, port = 4000, seed: seedConfig } = options;
const { service, port = 4000, seed: seedConfig, tls } = options;

const entry = SERVICE_REGISTRY[service];
if (!entry) {
Expand All @@ -41,7 +48,7 @@ export async function createEmulator(options: EmulatorOptions): Promise<Emulator
tokens["test_token_admin"] = { login: "admin", id: 2, scopes: ["repo", "user", "admin:org", "admin:repo_hook"] };
}

const baseUrl = `http://localhost:${port}`;
const baseUrl = `${tls ? "https" : "http"}://localhost:${port}`;

// eslint-disable-next-line prefer-const -- reassigned after closure captures it
let cachedResolver: AppKeyResolver | undefined;
Expand All @@ -63,7 +70,9 @@ export async function createEmulator(options: EmulatorOptions): Promise<Emulator
};
seed();

const httpServer = serve({ fetch: app.fetch, port });
const httpServer = tls
? serve({ fetch: app.fetch, port, serverOptions: { cert: tls.cert, key: tls.key }, createServer: createHttpsServer })
: serve({ fetch: app.fetch, port });

return {
url: baseUrl,
Expand Down
36 changes: 34 additions & 2 deletions packages/emulate/src/commands/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { createServer, type AppKeyResolver, type Store } from "@emulators/core";
import { SERVICE_REGISTRY, SERVICE_NAMES, type ServiceName } from "../registry.js";
import { serve } from "@hono/node-server";
import { readFileSync, existsSync } from "fs";
import { createServer as createHttpsServer } from "node:https";
import { resolve } from "path";
import { parse as parseYaml } from "yaml";
import pc from "picocolors";
Expand All @@ -13,6 +14,8 @@ export interface StartOptions {
port: number;
service?: string;
seed?: string;
tlsCert?: string;
tlsKey?: string;
}

interface SeedConfig {
Expand Down Expand Up @@ -68,6 +71,30 @@ function loadSeedConfig(seedPath?: string): LoadResult | null {
return null;
}

function loadTlsMaterial(certPath?: string, keyPath?: string): { cert: Buffer; key: Buffer } | null {
if (!certPath && !keyPath) return null;
if (!certPath || !keyPath) {
console.error("Both --tls-cert and --tls-key must be provided to enable HTTPS.");
process.exit(1);
}

const read = (label: string, path: string): Buffer => {
const fullPath = resolve(path);
if (!existsSync(fullPath)) {
console.error(`TLS ${label} file not found: ${fullPath}`);
process.exit(1);
}
try {
return readFileSync(fullPath);
} catch (err) {
console.error(`Failed to read TLS ${label} file ${fullPath}: ${err instanceof Error ? err.message : err}`);
process.exit(1);
}
};

return { cert: read("certificate", certPath), key: read("key", keyPath) };
}

function inferServicesFromConfig(config: SeedConfig): ServiceName[] | null {
const found = SERVICE_NAMES.filter((k) => k in config);
return found.length > 0 ? [...found] : null;
Expand All @@ -76,6 +103,9 @@ function inferServicesFromConfig(config: SeedConfig): ServiceName[] | null {
export async function startCommand(options: StartOptions): Promise<void> {
const { port: basePort } = options;

const tlsMaterial = loadTlsMaterial(options.tlsCert, options.tlsKey);
const scheme = tlsMaterial ? "https" : "http";

const loaded = loadSeedConfig(options.seed);
const seedConfig = loaded?.config ?? null;
const configSource = loaded?.source ?? null;
Expand Down Expand Up @@ -117,7 +147,7 @@ export async function startCommand(options: StartOptions): Promise<void> {

const svcSeedConfig = seedConfig?.[svc] as Record<string, unknown> | undefined;
const port = (svcSeedConfig?.port as number | undefined) ?? basePort + i;
const baseUrl = `http://localhost:${port}`;
const baseUrl = `${scheme}://localhost:${port}`;
serviceUrls.push({ name: svc, url: baseUrl });

// eslint-disable-next-line prefer-const -- reassigned after closure captures it
Expand All @@ -138,7 +168,9 @@ export async function startCommand(options: StartOptions): Promise<void> {
loadedSvc.seedFromConfig(store, baseUrl, svcSeedConfig);
}

const httpServer = serve({ fetch: app.fetch, port });
const httpServer = tlsMaterial
? serve({ fetch: app.fetch, port, serverOptions: tlsMaterial, createServer: createHttpsServer })
: serve({ fetch: app.fetch, port });
httpServers.push(httpServer);
}

Expand Down
4 changes: 4 additions & 0 deletions packages/emulate/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ program
.option("-p, --port <port>", "Base port", defaultPort)
.option("-s, --service <services>", "Comma-separated services to enable")
.option("--seed <file>", "Path to seed config file")
.option("--tls-cert <path>", "Path to TLS certificate (PEM) — enables HTTPS when set with --tls-key")
.option("--tls-key <path>", "Path to TLS private key (PEM) — enables HTTPS when set with --tls-cert")
.action(async (opts) => {
const port = parseInt(opts.port, 10);
if (Number.isNaN(port) || port < 1 || port > 65535) {
Expand All @@ -31,6 +33,8 @@ program
port,
service: opts.service,
seed: opts.seed,
tlsCert: opts.tlsCert,
tlsKey: opts.tlsKey,
});
});

Expand Down
3 changes: 3 additions & 0 deletions skills/emulate/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ emulate list
| `-p, --port` | `4000` | Base port (auto-increments per service) |
| `-s, --service` | all | Comma-separated services to enable |
| `--seed` | auto-detect | Path to seed config (YAML or JSON) |
| `--tls-cert` | none | Path to TLS certificate (PEM) — enables HTTPS when paired with `--tls-key` |
| `--tls-key` | none | Path to TLS private key (PEM) — enables HTTPS when paired with `--tls-cert` |

The port can also be set via `EMULATE_PORT` or `PORT` environment variables.

Expand Down Expand Up @@ -89,6 +91,7 @@ await vercel.close()
| `service` | *(required)* | `'vercel'`, `'github'`, `'google'`, `'slack'`, `'apple'`, `'microsoft'`, or `'aws'` |
| `port` | `4000` | Port for the HTTP server |
| `seed` | none | Inline seed data (same shape as YAML config) |
| `tls` | none | `{ cert, key }` with PEM-encoded `string` or `Buffer` material. When set, the emulator serves HTTPS and `url` uses the `https://` scheme. |

### Instance Methods

Expand Down