diff --git a/README.md b/README.md index ff8a7f1..4caefca 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ This is a port of [zenozeng/node-yaqrcode](https://github.com/zenozeng/node-yaqr ```ts import { qrcode } from "https://deno.land/x/qrcode/mod.ts"; -const base64Image = qrcode("bitcoin:ADDRESS?amount=0.5&label=ORDER"); // data:image/gif;base64,... +const base64Image = await qrcode("bitcoin:ADDRESS?amount=0.5&label=ORDER"); // data:image/gif;base64,... ``` ![QR code](https://upload.wikimedia.org/wikipedia/commons/thumb/d/d0/QR_code_for_mobile_English_Wikipedia.svg/240px-QR_code_for_mobile_English_Wikipedia.svg.png) @@ -26,6 +26,23 @@ You can also add a custom size by specifying `size` in the second parameter: ```ts const fixedSizeImage = await qrcode("bitcoin:ADDRESS?amount=0.5&label=ORDER", { size: 500 }); ``` + +### Deno.serve example + +The `examples/server.ts` file exposes QR codes over HTTP using `Deno.serve`: + +```bash +deno run --allow-net examples/server.ts +``` + +Then open a URL like this to receive a GIF response: + +```text +http://localhost:4000/?data=Hello%20World!&size=250&typeNumber=5&errorCorrection=L +``` + +Short query parameters are also supported for compact URLs: `d`, `s`, `v`, and `e`. + ### CLI with [DPX](https://github.com/denorg/dpx) After [installing DPX](https://github.com/denorg/dpx), you can directly use the CLI using the `dpx` command: diff --git a/examples/server.ts b/examples/server.ts new file mode 100644 index 0000000..7ae69d3 --- /dev/null +++ b/examples/server.ts @@ -0,0 +1,150 @@ +import { qrcode } from "../mod.ts"; + +const GIF_DATA_URL_PREFIX = "data:image/gif;base64,"; +const TYPE_NUMBERS = [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 37, + 38, + 39, + 40, +] as const; +const ERROR_CORRECTION_LEVELS = ["L", "M", "Q", "H"] as const; + +type TypeNumberOption = (typeof TYPE_NUMBERS)[number]; +type ErrorCorrectionLevelOption = (typeof ERROR_CORRECTION_LEVELS)[number]; + +function parseIntegerParam( + value: string | null, + fallback: number, + min: number, + max: number, +): number { + if (value === null || value.trim() === "") return fallback; + + const parsed = Number(value); + + if (!Number.isInteger(parsed)) return fallback; + + return Math.min(max, Math.max(min, parsed)); +} + +function parseTypeNumber(value: string | null): TypeNumberOption { + return TYPE_NUMBERS[parseIntegerParam(value, 4, 0, 40)] ?? 4; +} + +function parseErrorCorrectionLevel( + value: string | null, +): ErrorCorrectionLevelOption { + return ERROR_CORRECTION_LEVELS.includes(value as ErrorCorrectionLevelOption) + ? value as ErrorCorrectionLevelOption + : "M"; +} + +function dataUrlToBytes(dataUrl: string): Uint8Array { + if (!dataUrl.startsWith(GIF_DATA_URL_PREFIX)) { + throw new Error("Expected qrcode() to return a GIF data URL"); + } + + return Uint8Array.from( + atob(dataUrl.slice(GIF_DATA_URL_PREFIX.length)), + (character) => character.charCodeAt(0), + ); +} + +const commonHeaders = { + "access-control-allow-origin": "*", + "access-control-allow-methods": "GET, HEAD, OPTIONS", + "cache-control": "no-store", +}; + +export async function handleRequest(request: Request): Promise { + if (request.method === "OPTIONS") { + return new Response(null, { + status: 204, + headers: commonHeaders, + }); + } + + if (request.method !== "GET" && request.method !== "HEAD") { + return new Response("Method not allowed", { + status: 405, + headers: { + ...commonHeaders, + allow: "GET, HEAD, OPTIONS", + "content-type": "text/plain; charset=utf-8", + }, + }); + } + + const url = new URL(request.url); + const data = url.searchParams.get("data") ?? url.searchParams.get("d") ?? + "Hello World!"; + const size = parseIntegerParam( + url.searchParams.get("size") ?? url.searchParams.get("s"), + 250, + 100, + 1000, + ); + const typeNumber = parseTypeNumber( + url.searchParams.get("typeNumber") ?? url.searchParams.get("v"), + ); + const errorCorrectLevel = parseErrorCorrectionLevel( + url.searchParams.get("errorCorrection") ?? url.searchParams.get("e"), + ); + + const dataUrl = await qrcode(data, { + errorCorrectLevel, + size, + typeNumber, + }); + const gif = dataUrlToBytes(dataUrl); + + return new Response(request.method === "HEAD" ? null : gif, { + status: 200, + headers: { + ...commonHeaders, + "content-length": String(gif.byteLength), + "content-type": "image/gif", + }, + }); +} + +if (import.meta.main) { + Deno.serve({ port: 4000 }, handleRequest); +} diff --git a/examples/server_test.ts b/examples/server_test.ts new file mode 100644 index 0000000..2916b43 --- /dev/null +++ b/examples/server_test.ts @@ -0,0 +1,53 @@ +import { + assert, + assertEquals, +} from "https://deno.land/std@0.224.0/testing/asserts.ts"; +import { handleRequest } from "./server.ts"; + +Deno.test("server example returns a GIF response", async (): Promise => { + const response = await handleRequest( + new Request("http://localhost/?d=Hello%20World%21&v=5&e=L&s=250"), + ); + const bytes = new Uint8Array(await response.arrayBuffer()); + + assertEquals(response.status, 200); + assertEquals(response.headers.get("content-type"), "image/gif"); + assertEquals(new TextDecoder().decode(bytes.slice(0, 6)), "GIF87a"); + assert(bytes.byteLength > 0); +}); + +Deno.test("server example handles HEAD without a body", async (): Promise => { + const response = await handleRequest( + new Request("http://localhost/?data=Hello", { method: "HEAD" }), + ); + + assertEquals(response.status, 200); + assertEquals(response.headers.get("content-type"), "image/gif"); + assertEquals((await response.arrayBuffer()).byteLength, 0); +}); + +Deno.test("server example falls back when optional query params are missing or blank", async (): Promise => { + const [defaults, explicitFallbacks] = await Promise.all([ + handleRequest(new Request("http://localhost/?data=Hello&s=&v=&e=bad")), + handleRequest( + new Request( + "http://localhost/?data=Hello&size=250&typeNumber=4&errorCorrection=M", + ), + ), + ]); + + assertEquals(defaults.status, 200); + assertEquals( + new Uint8Array(await defaults.arrayBuffer()), + new Uint8Array(await explicitFallbacks.arrayBuffer()), + ); +}); + +Deno.test("server example rejects unsupported methods", async (): Promise => { + const response = await handleRequest( + new Request("http://localhost/", { method: "POST" }), + ); + + assertEquals(response.status, 405); + assertEquals(response.headers.get("allow"), "GET, HEAD, OPTIONS"); +});