Skip to content
Merged
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
16 changes: 13 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

**Run an existing Twilio voice app on Bandwidth's network — without rewriting it.** Point your app at the adapter, change one URL, and its calls now run on Bandwidth. The code never changes.

> **Status:** Inbound/outbound calls, call control, recordings, and the two key webhooks are working and proven on real calls. Number **search** is live-verified; number **ordering/release** are experimental (see [Number lifecycle](#number-lifecycle)). 255 automated tests, typecheck clean.
> **Status:** Inbound/outbound calls, call control, recordings, and the two key webhooks are working and proven on real calls. Number **search** is live-verified; number **ordering/release** are experimental (see [Number lifecycle](#number-lifecycle)). 265 automated tests, typecheck clean.

---

Expand Down Expand Up @@ -57,6 +57,14 @@ The translation is a fixed rulebook driven by a single [compatibility matrix](sr

## Quickstart

**Migration Preflight playground (for demos — a double-click HTML file):**
```bash
npm install
npm run playground:build # writes dist/playground.html
open dist/playground.html # (macOS) or just double-click it
```
A single self-contained page — no server, no network, works offline. Paste a customer's TwiML (or pick a curated example) and see the live *works-as-is / heads-up / blocker* verdict, a migration-complexity score, the translated BXML, and a forwardable report. It runs the **real** translation engine in the browser, so the verdict matches what the adapter does in production. Built for sales engineers to drive live on a screen-share; see [`web/README.md`](web/README.md).

**See a migration report (30s, no accounts):**
```bash
npm install
Expand All @@ -69,7 +77,7 @@ Prints a migration-complexity score and a per-feature *works-as-is / heads-up /
**Run the adapter** (Node 20+):
```bash
npm start # adapter on :3000
npm test # 255 tests
npm test # 265 tests
npm run typecheck
```

Expand Down Expand Up @@ -136,7 +144,7 @@ Reproduce the live check (read-only by default) with [`scripts/verify-numbers-li

## How it's built

All translation is driven by one declarative compatibility matrix (`src/matrix/twilio-voice.json`) — the runtime adapter and the pre-flight report read the same data, so they can't disagree.
All translation is driven by one declarative compatibility matrix (`src/matrix/twilio-voice.json`) — the runtime adapter, the pre-flight report, and the Migration Preflight playground all read the same data, so they can't disagree.

```
src/translator TwiML → BXML translation
Expand All @@ -145,6 +153,8 @@ src/streams Media Streams bridge
src/numbers number-lifecycle facade (search/order/release)
src/server the proxy (Fastify) wiring it together
src/preflight static migration-complexity report
web Migration Preflight playground (double-click HTML demo)
scripts build-playground.ts + benches / live-verify helpers
```

Tests live in `test/` (Vitest); CI gates `npm run typecheck` + `npm test`.
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
"generate": "tsx src/generate/cli.ts",
"bench": "tsx scripts/bench-translate.ts",
"bench:overhead": "tsx scripts/bench-adapter-overhead.ts",
"latency:report": "tsx scripts/latency-report.ts"
"latency:report": "tsx scripts/latency-report.ts",
"playground:build": "tsx scripts/build-playground.ts"
},
"dependencies": {
"@fastify/formbody": "^8.0.2",
Expand All @@ -23,6 +24,7 @@
"devDependencies": {
"@types/node": "^25.9.2",
"@types/ws": "^8.18.1",
"esbuild": "^0.28.0",
"tsx": "^4.22.4",
"typescript": "^6.0.3",
"vitest": "^4.1.8"
Expand Down
87 changes: 87 additions & 0 deletions scripts/build-playground.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// Builds the self-contained Migration Preflight artifact: web/ sources + the
// real translation engine + DM Sans fonts, all inlined into a single
// dist/playground.html a salesperson can double-click. No server, no network.

import { build } from "esbuild";
import { readFile, writeFile, mkdir } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";

const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const webDir = join(root, "web");
const fontsDir = join(webDir, "fonts");
const outDir = join(root, "dist");
const outFile = join(outDir, "playground.html");

// DM Sans (Bandwidth brand face), inlined as base64 woff2 so the file is offline-safe.
const FONT_FACES: Array<{ file: string; weight: string; style: string }> = [
{ file: "dm-sans-latin-400-normal.woff2", weight: "400 500", style: "normal" },
{ file: "dm-sans-latin-700-normal.woff2", weight: "600 700", style: "normal" },
{ file: "dm-sans-latin-800-normal.woff2", weight: "800", style: "normal" },
{ file: "dm-sans-latin-400-italic.woff2", weight: "400 700", style: "italic" },
];

async function fontCss(): Promise<string> {
const faces = await Promise.all(
FONT_FACES.map(async ({ file, weight, style }) => {
const b64 = (await readFile(join(fontsDir, file))).toString("base64");
return `@font-face{font-family:"DM Sans";font-style:${style};font-weight:${weight};font-display:swap;src:url("data:font/woff2;base64,${b64}") format("woff2");}`;
}),
);
return faces.join("\n");
}

async function main(): Promise<void> {
const [html, css, fonts, bundle] = await Promise.all([
readFile(join(webDir, "index.html"), "utf8"),
readFile(join(webDir, "styles.css"), "utf8"),
fontCss(),
build({
entryPoints: [join(webDir, "app.ts")],
bundle: true,
format: "iife",
platform: "browser",
target: "es2020",
minify: true,
write: false,
legalComments: "none",
}).then((r) => r.outputFiles[0].text),
]);

const styleBlock = `<style>\n${fonts}\n${css}\n</style>`;
const scriptBlock = `<script>\n${bundle}</script>`;

// split/join, not replace(): the minified bundle contains "$" sequences that
// String.replace would interpret as special replacement patterns.
const out = html
.split("<!-- build injects <style> (fonts + styles.css) here -->")
.join(styleBlock)
.split("<!-- build injects bundled <script> here -->")
.join(scriptBlock);

if (out.includes("build injects")) {
throw new Error("Injection markers not replaced — index.html markers may have changed.");
}

// Self-contained check: no external *resource loads* (script/link/img src/href,
// CSS url(http…), @import). Anchor links to docs (https://…) are fine.
const offenders = [
/<script[^>]+src\s*=\s*["']https?:/i,
/<link[^>]+href\s*=\s*["']https?:/i,
/<img[^>]+src\s*=\s*["']https?:/i,
/url\(\s*["']?https?:/i,
/@import\s+["']?https?:/i,
].filter((re) => re.test(out));
if (offenders.length) {
throw new Error(`Output is not self-contained — external resource reference(s): ${offenders}`);
}

await mkdir(outDir, { recursive: true });
await writeFile(outFile, out, "utf8");
console.log(`Wrote ${outFile} (${(Buffer.byteLength(out) / 1024).toFixed(0)} KB)`);
}

main().catch((err) => {
console.error(err);
process.exit(1);
});
6 changes: 2 additions & 4 deletions src/matrix/load.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import { z } from "zod";
import { createRequire } from "node:module";

const require = createRequire(import.meta.url);
import matrixData from "./twilio-voice.json" with { type: "json" };

const Status = z.enum(["supported", "partial", "unsupported"]);

Expand Down Expand Up @@ -30,5 +28,5 @@ export type VerbMapping = z.infer<typeof VerbMappingSchema>;
export type CompatMatrix = z.infer<typeof MatrixSchema>;

export function loadMatrix(): CompatMatrix {
return MatrixSchema.parse(require("./twilio-voice.json"));
return MatrixSchema.parse(matrixData);
}
59 changes: 59 additions & 0 deletions test/playground-view.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { describe, it, expect } from "vitest";
import { buildView } from "../web/view-model.js";
import { EXAMPLES } from "../web/examples.js";

const byId = (id: string) => EXAMPLES.find((e) => e.id === id)!.twiml;

describe("buildView — input guards", () => {
it("rejects empty input", () => {
const v = buildView(" ");
expect(v.ok).toBe(false);
expect(v.error).toMatch(/paste/i);
});

it("rejects non-TwiML", () => {
const v = buildView("just some text");
expect(v.ok).toBe(false);
expect(v.error).toMatch(/Response/);
});
});

describe("buildView — curated examples", () => {
for (const ex of EXAMPLES) {
it(`${ex.label} analyzes cleanly with a bounded score and real BXML`, () => {
const v = buildView(ex.twiml);
expect(v.ok).toBe(true);
expect(v.score).toBeGreaterThanOrEqual(1);
expect(v.score).toBeLessThanOrEqual(10);
expect(v.bxml).toContain("<Response>");
expect(v.bxml).toContain("SpeakSentence"); // every example has a <Say>
const total = v.counts.clean + v.counts.headsUp + v.counts.blocker;
expect(total).toBe(v.blockers.length + v.headsUp.length + v.clean.length);
expect(total).toBeGreaterThan(0);
});
}

it("Queue example surfaces Enqueue as a blocker", () => {
const v = buildView(byId("queue"));
expect(v.blockers.some((c) => c.verb === "Enqueue")).toBe(true);
expect(v.blockers[0].pill).toBe("No equivalent");
});

it("Conference example flags the hold-music blocker", () => {
const v = buildView(byId("conference"));
expect(v.blockers.some((c) => c.verb === "Conference")).toBe(true);
});

it("IVR example maps the Polly voice as a heads-up, not a failure", () => {
const v = buildView(byId("ivr"));
expect(v.headsUp.some((c) => c.verb === "Say")).toBe(true);
expect(v.counts.blocker).toBe(0);
});

it("marks a supported 1:1 verb with a target and 'Maps 1:1'", () => {
const v = buildView(byId("ivr"));
const gather = v.clean.find((c) => c.verb === "Gather");
expect(gather?.pill).toBe("Maps 1:1");
expect(gather?.target).toBe("Gather");
});
});
2 changes: 1 addition & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,5 @@
"noEmit": true,
"types": ["node"]
},
"include": ["src", "test"]
"include": ["src", "test", "web"]
}
39 changes: 39 additions & 0 deletions web/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Migration Preflight playground

A single, self-contained HTML file a sales engineer can **double-click** and present to a prospect. Paste a customer's TwiML (or pick a curated example) and it shows — live — how much of that Twilio voice app runs on Bandwidth unchanged: a works-as-is / heads-up / blocker verdict, a migration-complexity score, the translated BXML, and a forwardable report.

No server, no install, no network. It works offline on a plane.

## Why it's trustworthy

The page runs the **real translation engine** (`src/translator`, `src/preflight`) and the real compatibility matrix (`src/matrix/twilio-voice.json`) in the browser — the exact same code the live adapter uses. It does not reimplement any translation or scoring logic. Because the same matrix drives the adapter and this page, the verdict shown here provably matches what happens in production. That invariant is the point; don't break it by hand-coding results into the UI.

## Build & run

```bash
npm run playground:build # writes dist/playground.html
open dist/playground.html # macOS — or just double-click the file
```

`dist/` is git-ignored (it's a build product); rebuild it whenever the engine, matrix, or UI changes.

## Layout

| File | Purpose |
|---|---|
| `view-model.ts` | The only new logic: `buildView(twiml)` calls the engine and shapes the result. Pure, DOM-free, unit-tested (`test/playground-view.test.ts`). |
| `app.ts` | DOM rendering + interactions (chips, BXML toggle, print, copy). Calls `buildView`. |
| `index.html` | Static shell (header, input, caveats, actions) with injection markers for the build. |
| `styles.css` | All styling. Brand tokens live in `:root` — swap them there to re-theme. |
| `examples.ts` | Curated TwiML examples. Verdicts are computed live, never hardcoded. |
| `fonts/` | DM Sans (Bandwidth brand face), woff2. Inlined as base64 at build time. |

The build (`scripts/build-playground.ts`, esbuild) bundles `app.ts` + the engine, inlines the CSS and fonts into `index.html`, and asserts the output has no external resource references before writing it.

## Design

The visual design comes from a Bandwidth design-system mockup (DM Sans, primary `#076ea8`). To re-theme, edit the `:root` custom properties in `styles.css` in one place.

## Scope

Intentionally narrow: one screen, TwiML-document input only. It does **not** scan source code (that's the CLI `npm run preflight`), place live calls, or talk to Bandwidth. Those need a server and are out of scope for this demo tool.
Loading