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
2 changes: 1 addition & 1 deletion .github/workflows/cost-calculator-pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ jobs:
working-directory: tools/cost_calculator
shell: pwsh
run: |
node --test calculator.test.mjs
node --test calculator.test.mjs server.test.mjs
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
node --check app.mjs
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
Expand Down
5 changes: 5 additions & 0 deletions samples/gpt_live/js/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,9 @@ only and answers `POST /token` only when the request's `Host` and `Origin` are i
`localhost` or `127.0.0.1` address, so other websites, including ones that rebind their DNS name
to `127.0.0.1`, cannot get a token. This suits a local development sample; a hosted app would
sign users in with their own identity, for example with MSAL.js.
Request targets that cannot be parsed as URLs receive HTTP 400 without stopping
the server or requesting a token. Later valid requests still work, and token
requests must still pass the same-origin checks.

#### Why the sample supplies its own WebSocket transport

Expand Down Expand Up @@ -157,6 +160,8 @@ Run the offline tests (no Azure resources, browser, or audio devices). They driv
Projects SDK client: the server's agent calls go to an in-memory HTTP client, and the page's
Live sessions talk to a scripted WebSocket transport. The browser transport runs on Node.js's
built-in WebSocket against a local `ws` server.
HTTP regressions cover malformed request targets followed by valid page,
configuration, and same-origin token requests.

```powershell
npm test
Expand Down
9 changes: 8 additions & 1 deletion samples/gpt_live/js/server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,18 @@ function describeTokenFailure(error) {
*/
export function createServer({ credential, agent, endpoint, keepAgent = true, log = () => {} }) {
const server = http.createServer(async (request, response) => {
const { pathname } = new URL(request.url, "http://localhost");
const send = (status, type, body) => {
response.writeHead(status, { "content-type": type, "cache-control": "no-store" });
response.end(body);
};
let pathname;
try {
pathname = new URL(request.url, "http://localhost").pathname;
} catch (error) {
if (error.code !== "ERR_INVALID_URL") throw error;
send(400, "text/plain", "Bad request");
return;
}
if (request.method === "GET" && pathname === "/config") {
send(
200,
Expand Down
28 changes: 28 additions & 0 deletions samples/gpt_live/js/test/server.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,10 @@ function request(port, path, { method = "GET", headers = {} } = {}) {
let body = "";
response.setEncoding("utf8");
response.on("data", (chunk) => (body += chunk));
response.on("error", reject);
response.on("end", () => resolve({ status: response.statusCode, type: response.headers["content-type"], body }));
});
outgoing.setTimeout(5000, () => outgoing.destroy(new Error("Request timed out")));
outgoing.on("error", reject);
outgoing.end();
});
Expand All @@ -70,6 +72,32 @@ const ownPage = (port, host = "localhost") => ({
});

describe("HTTP surface", () => {
it("rejects malformed targets without losing subsequent page, config, or token requests", () =>
withServer({}, async ({ running: { port }, scopes }) => {
for (const target of ["//[", "//%zz", "http://[", "http://localhost:bad/"]) {
for (const method of ["GET", "POST"]) {
const bad = await request(port, target, { method });
assert.equal(bad.status, 400, `${method} ${target}`);
assert.equal(bad.body, "Bad request");
assert.equal(bad.type, "text/plain");
assert.equal((await request(port, "/?value=%5B%20%25")).status, 200);
const config = await request(port, "/config?value=%5B%20%25");
assert.equal(config.status, 200);
assert.equal(JSON.parse(config.body).endpoint, ENDPOINT);
}
}
assert.deepEqual(scopes, []);
for (const path of ["/missing%20file", "/%63onfig"]) {
assert.equal((await request(port, path)).status, 404, path);
}
assert.equal((await request(port, "/token", { method: "POST" })).status, 403);
assert.deepEqual(scopes, []);
const token = await request(port, "/token?value=%5B%20%25", { method: "POST", headers: ownPage(port) });
assert.equal(token.status, 200);
assert.equal(JSON.parse(token.body).token, "offline-test-token");
assert.deepEqual(scopes, [TOKEN_SCOPE]);
}));

it("serves the page, the bundled page script, and the agent configuration", async () => {
const { running, project } = await start();
const { port } = running;
Expand Down
7 changes: 6 additions & 1 deletion tools/cost_calculator/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ Open **http://127.0.0.1:8095**. To change the port, set `$env:PORT = "8096"`
before running the command. The server listens only on the loopback interface.
Stop it with Ctrl+C. Opening `index.html` as `file://` will not work because
the page loads modules and the local price snapshot.
Request targets that cannot be parsed as URLs receive HTTP 400 without stopping
the local server; later valid requests are still served.

Choose hosting, pipeline, and model; change call assumptions or deployment
rates. The tool shows per-call, per-minute, call-volume list prices, component costs, and
Expand Down Expand Up @@ -210,5 +212,8 @@ captured.
## Checks

```powershell
node --test tools\cost_calculator\calculator.test.mjs
node --test tools\cost_calculator\calculator.test.mjs tools\cost_calculator\server.test.mjs
```

The server regression test starts the actual local Node process, checks malformed
request rejection and subsequent asset requests, then stops the process.
9 changes: 8 additions & 1 deletion tools/cost_calculator/server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,14 @@ const mime = { html: "text/html", css: "text/css", mjs: "text/javascript", json:
const port = Number(process.env.PORT || 8095);
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("PORT must be an integer from 1 to 65535.");
const server = createServer(async (req, res) => {
const path = new URL(req.url, "http://localhost").pathname;
let path;
try {
path = new URL(req.url, "http://localhost").pathname;
} catch (error) {
if (error.code !== "ERR_INVALID_URL") throw error;
res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store" }).end("Bad request");
return;
}
const file = path === "/" ? "index.html" : path.slice(1);
if (!["GET", "HEAD"].includes(req.method) || !allowed.has(file)) {
res.writeHead(404).end("Not found");
Expand Down
81 changes: 81 additions & 0 deletions tools/cost_calculator/server.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { once } from "node:events";
import http from "node:http";
import test from "node:test";
import { fileURLToPath } from "node:url";

function request(port, path, method = "GET") {
return new Promise((resolve, reject) => {
const outgoing = http.request({ host: "127.0.0.1", port, path, method, agent: false }, response => {
let body = "";
response.setEncoding("utf8");
response.on("data", chunk => { body += chunk; });
response.on("error", reject);
response.on("end", () => resolve({ status: response.statusCode, headers: response.headers, body }));
});
outgoing.setTimeout(5000, () => outgoing.destroy(new Error("Request timed out")));
outgoing.on("error", reject);
outgoing.end();
});
}

test("the calculator process rejects malformed targets and keeps serving assets", { timeout: 15000 }, async t => {
const reservation = http.createServer();
reservation.listen(0, "127.0.0.1");
await once(reservation, "listening");
const { port } = reservation.address();
await new Promise((resolve, reject) => reservation.close(error => error ? reject(error) : resolve()));

const child = spawn(process.execPath, [fileURLToPath(new URL("./server.mjs", import.meta.url))], {
env: { ...process.env, PORT: String(port) },
stdio: ["ignore", "pipe", "pipe"],
});
let stderr = "";
child.stderr.setEncoding("utf8").on("data", chunk => { stderr += chunk; });
const closed = new Promise(resolve => child.once("close", resolve));
t.after(async () => {
if (child.exitCode === null && child.signalCode === null) child.kill();
await closed;
});
await new Promise((resolve, reject) => {
let stdout = "";
const timer = setTimeout(() => reject(new Error(`Server startup timed out: ${stderr}`)), 5000);
child.once("error", error => { clearTimeout(timer); reject(error); });
child.once("exit", code => {
clearTimeout(timer);
reject(new Error(`Server exited during startup (${code}): ${stderr}`));
});
child.stdout.setEncoding("utf8").on("data", chunk => {
stdout += chunk;
if (stdout.includes(`http://127.0.0.1:${port}`)) {
clearTimeout(timer);
resolve();
}
});
});

for (const target of ["//[", "//%zz", "http://[", "http://localhost:bad/"]) {
for (const method of ["GET", "POST"]) {
const bad = await request(port, target, method);
assert.equal(bad.status, 400, `${method} ${target}`);
assert.equal(bad.body, "Bad request");
assert.match(bad.headers["content-type"], /^text\/plain/);
const page = await request(port, "/?value=%5B%20%25");
assert.equal(page.status, 200);
assert.match(page.body, /Voice Agent Cost Lab/);
}
}
for (const file of ["index.html", "styles.css", "app.mjs", "calculator.mjs", "prices.json", "favicon.svg"]) {
assert.equal((await request(port, `/${file}`)).status, 200, file);
const head = await request(port, `/${file}`, "HEAD");
assert.equal(head.status, 200, file);
assert.equal(head.body, "");
}
for (const path of ["/missing%20file", "/%69ndex.html", "/server.mjs"]) {
assert.equal((await request(port, path)).status, 404, path);
}
assert.equal((await request(port, "/", "POST")).status, 404);
assert.equal(child.exitCode, null, stderr);
assert.equal(stderr, "");
});
Loading