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
15 changes: 6 additions & 9 deletions routes/caniuse/feature.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { Request, Response } from "express";
import { seconds } from "../../utils/misc.js";
import { BROWSERS, DEFAULT_BROWSERS } from "./lib/constants.js";
import { Data, getData } from "./lib/index.js";

Expand All @@ -11,25 +10,23 @@ export default async function route(req: IRequest, res: Response) {
const { feature } = req.params;
const browsers = normalizeBrowsers(req.query.browsers);


try {
const data = await getData(feature);
if (data === null) {
res.sendStatus(404);
const hint = feature.startsWith("wf-") && feature.length > 3
? ` The "wf-" prefix indicates a web-features ID. No matching caniuse data was found for "${feature}" or "${feature.slice(3)}".`
: "";
res.status(404).json({ error: `Feature "${feature}" not found.${hint}` });
return;
Comment thread
marcoscaceres marked this conversation as resolved.
Comment thread
marcoscaceres marked this conversation as resolved.
Comment thread
marcoscaceres marked this conversation as resolved.
}
const result = [];
for (const browser of browsers) {
result.push({ browser, ...getBrowserData(data?.all[browser]) });
result.push({ browser, ...getBrowserData(data.all[browser]) });
}
res.json({ result });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const errorCode = message === "INTERNAL_ERROR" ? 500 : 404;
console.error("caniuse feature route error", error);
res.status(errorCode);
res.setHeader("Content-Type", "text/plain");
res.send(errorCode === 500 ? "Internal Server Error" : "Not Found");
res.status(500).json({ error: message });
}
}

Expand Down
1 change: 1 addition & 0 deletions routes/caniuse/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,4 @@ export async function route(req: IRequest, res: Response) {
res.set("Cache-Control", `max-age=${seconds("24h")}`);
res.send(body);
}

27 changes: 24 additions & 3 deletions routes/caniuse/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
export { Data };

const DATA_DIR = env("DATA_DIR");
const CANIUSE_DIR = path.join(DATA_DIR, "caniuse");

interface Options {
feature: string;
Expand Down Expand Up @@ -106,11 +107,28 @@ function sanitizeBrowsersList(browsers?: string | string[]) {
}

export async function getData(feature: string) {
if (typeof feature !== "string" || !feature) return null;
if (!/^[a-z0-9][a-z0-9._-]*$/i.test(feature)) return null;
// Try the feature as-is first, then fall back to stripping the "wf-" prefix.
// E.g., "wf-css-grid" falls back to looking up "css-grid" in caniuse data.
const data = await readFeatureFile(feature);
if (data) return data;
if (feature.startsWith("wf-") && feature.length > 3) {
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
const fallbackData = await readFeatureFile(feature.slice(3));
if (fallbackData) {
cache.set(feature, fallbackData);
}
return fallbackData;
}
Comment thread
marcoscaceres marked this conversation as resolved.
return null;
}

async function readFeatureFile(feature: string) {
if (cache.has(feature)) {
return cache.get(feature) as Data;
}
const file = path.format({
dir: path.join(DATA_DIR, "caniuse"),
dir: CANIUSE_DIR,
name: `${feature}.json`,
});
Comment thread
marcoscaceres marked this conversation as resolved.
Comment thread
marcoscaceres marked this conversation as resolved.

Expand All @@ -120,8 +138,11 @@ export async function getData(feature: string) {
cache.set(feature, data);
return data;
} catch (error) {
console.error(error);
return null;
const code = (error as NodeJS.ErrnoException).code;
if (code === "ENOENT") {
return null;
}
throw error;
}
}

Expand Down
144 changes: 144 additions & 0 deletions tests/routes/caniuse/feature.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import os from "node:os";
import path from "node:path";
import { promises as fs } from "node:fs";

import route from "../../../build/routes/caniuse/feature.js";
import { cache } from "../../../build/routes/caniuse/lib/index.js";

const CANIUSE_DIR = path.join(os.tmpdir(), "caniuse");

const FIXTURE = {
all: {
chrome: [["100", ["y"]]],
firefox: [["99", ["n"]]],
edge: [["100", ["y"]]],
safari: [["16", ["y"]]],
and_chr: [["100", ["y"]]],
and_ff: [["99", ["n"]]],
ios_saf: [["16", ["y"]]],
samsung: [["19", ["y"]]],
},
summary: { chrome: [["100", ["y"]]] },
};

/** Builds a lightweight mock Express Response. */
function mockRes() {
const res = {
statusCode: 200,
body: null,
status(code) {
this.statusCode = code;
return this;
},
json(body) {
this.body = body;
return this;
},
send(body) {
this.body = body;
return this;
},
type(_t) {
return this;
},
};
return res;
}

/** Builds a minimal mock Express Request for the `/:feature` route. */
function mockReq(feature, query = {}) {
return { params: { feature }, query };
}

async function writeFixture(name, data = FIXTURE) {
await fs.mkdir(CANIUSE_DIR, { recursive: true });
await fs.writeFile(
path.join(CANIUSE_DIR, `${name}.json`),
JSON.stringify(data),
"utf8",
);
}

async function removeFixture(name) {
try {
await fs.unlink(path.join(CANIUSE_DIR, `${name}.json`));
} catch {
// ignore
}
}

describe("caniuse - feature route", () => {
beforeEach(() => cache.clear());

describe("404 responses", () => {
it("returns JSON 404 for a missing feature", async () => {
const res = mockRes();
await route(mockReq("nonexistent-xyz"), res);
expect(res.statusCode).toBe(404);
expect(res.body).toEqual(jasmine.objectContaining({ error: jasmine.any(String) }));
expect(res.body.error).toContain("nonexistent-xyz");
});

it("returns JSON 404 with a wf- hint for a missing wf- feature", async () => {
const res = mockRes();
await route(mockReq("wf-no-such-feature-xyz"), res);
expect(res.statusCode).toBe(404);
expect(res.body.error).toContain("wf-");
expect(res.body.error).toContain("web-features");
});

it("returns JSON 404 without wf- hint for the edge case 'wf-'", async () => {
const res = mockRes();
await route(mockReq("wf-"), res);
expect(res.statusCode).toBe(404);
expect(res.body.error).not.toContain("web-features");
});
});

describe("successful responses", () => {
it("returns 200 JSON with browser data for a known feature", async () => {
await writeFixture("css-grid");
try {
const res = mockRes();
await route(mockReq("css-grid"), res);
expect(res.statusCode).toBe(200);
expect(res.body).toEqual(jasmine.objectContaining({ result: jasmine.any(Array) }));
} finally {
await removeFixture("css-grid");
}
});

it("resolves wf- prefixed feature to its caniuse equivalent", async () => {
await writeFixture("css-grid");
try {
const res = mockRes();
await route(mockReq("wf-css-grid"), res);
expect(res.statusCode).toBe(200);
expect(res.body.result).toBeDefined();
} finally {
await removeFixture("css-grid");
}
});
});

describe("500 responses", () => {
it("returns JSON 500 when the feature file is malformed JSON", async () => {
await fs.mkdir(CANIUSE_DIR, { recursive: true });
await fs.writeFile(
path.join(CANIUSE_DIR, "broken-feature.json"),
"not valid json",
"utf8",
);
try {
const res = mockRes();
await route(mockReq("broken-feature"), res);
expect(res.statusCode).toBe(500);
expect(res.body).toEqual(jasmine.objectContaining({ error: jasmine.any(String) }));
} finally {
try {
await fs.unlink(path.join(CANIUSE_DIR, "broken-feature.json"));
} catch { /* ignore */ }
}
});
});
});
102 changes: 102 additions & 0 deletions tests/routes/caniuse/lib/index.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import os from "node:os";
import path from "node:path";
import { promises as fs } from "node:fs";

import {
getData,
cache,
} from "../../../../build/routes/caniuse/lib/index.js";

const CANIUSE_DIR = path.join(os.tmpdir(), "caniuse");

/** Minimal valid ScraperOutput fixture */
const FIXTURE = {
all: { chrome: [["100", ["y"]]], firefox: [["99", ["n"]]] },
summary: { chrome: [["100", ["y"]]] },
};

async function writeFixture(name, data = FIXTURE) {
await fs.mkdir(CANIUSE_DIR, { recursive: true });
await fs.writeFile(
path.join(CANIUSE_DIR, `${name}.json`),
JSON.stringify(data),
"utf8",
);
}

async function removeFixture(name) {
try {
await fs.unlink(path.join(CANIUSE_DIR, `${name}.json`));
} catch {
// ignore – file may not exist
}
}

describe("caniuse - getData", () => {
beforeEach(() => cache.clear());

it("returns null for empty feature string", async () => {
expect(await getData("")).toBeNull();
});

it("returns null for invalid characters (path traversal attempt)", async () => {
expect(await getData("../etc/passwd")).toBeNull();
expect(await getData("foo/bar")).toBeNull();
expect(await getData("feature name")).toBeNull();
});

it("returns null for non-existent feature", async () => {
expect(await getData("nonexistent-feature-xyz")).toBeNull();
});

it("returns data for a known feature", async () => {
await writeFixture("css-grid");
try {
const data = await getData("css-grid");
expect(data).toEqual(FIXTURE);
} finally {
await removeFixture("css-grid");
}
});

it("returns null for wf- edge case (exactly 'wf-')", async () => {
expect(await getData("wf-")).toBeNull();
});

it("returns null for wf- feature where stripped name also has no data", async () => {
expect(await getData("wf-no-such-feature-xyz")).toBeNull();
});

it("falls back from wf- prefixed key to the stripped feature name", async () => {
await writeFixture("css-grid");
try {
const data = await getData("wf-css-grid");
expect(data).toEqual(FIXTURE);
} finally {
await removeFixture("css-grid");
}
});

it("caches the result under the original wf- key after a successful fallback", async () => {
await writeFixture("css-grid");
try {
expect(cache.has("wf-css-grid")).toBeFalse();
await getData("wf-css-grid");
expect(cache.has("wf-css-grid")).toBeTrue();
} finally {
await removeFixture("css-grid");
}
});

it("serves subsequent wf- requests from cache without extra disk I/O", async () => {
await writeFixture("css-grid");
try {
await getData("wf-css-grid"); // warm up cache
await removeFixture("css-grid"); // remove file; only cache should serve it now
const data = await getData("wf-css-grid");
expect(data).toEqual(FIXTURE);
} finally {
await removeFixture("css-grid");
}
});
});
Loading