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
12 changes: 8 additions & 4 deletions scripts/generate-gql-types.ts
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cleaned up my error handling - turns out graphql can accept Error instances as return values, not just when thrown. So this change updates the types to reflect that. This is now the dataloader package handles multiple errors, so this makes it easier to map dataloader outputs to graphql results.

Original file line number Diff line number Diff line change
Expand Up @@ -76,13 +76,16 @@ function capitalizeFirstLetter(str: string): string {
return str[0]!.toUpperCase() + str.substring(1);
}

function getTsTypeString(gqlType: any): string {
function getTsTypeString(gqlType: any, isReturn?: boolean): string {
if (gqlType.kind === "NON_NULL")
return `NonNullable<${getTsTypeString(gqlType.ofType)}>`;
return getTsTypeString(gqlType.ofType, isReturn).replace(
" | undefined",
"",
);
if (gqlType.kind === "LIST")
return `Array<${getTsTypeString(gqlType.ofType)}> | undefined`;
return `Array<${getTsTypeString(gqlType.ofType, isReturn)}> | undefined`;
if (gqlType.kind === "SCALAR" || gqlType.kind === "OBJECT")
return `${gqlType.name} | undefined`;
return `${gqlType.name}${isReturn ? " | Error" : ""} | undefined`;

logger.warn("Unknown GQL -> TS type", { gqlType });
return "unknown";
Expand Down Expand Up @@ -114,6 +117,7 @@ function writeObjectType(code: CodeBlockWriter, argTypes: any[], type: any) {
};
args = `(args: ${argsType.name}, ctx: WxtQueueCtx)`;
argTypes.push(argsType);
returnTypeStr = getTsTypeString(field.type, true);
returnTypeStr = `Promise<${returnTypeStr}> | ${returnTypeStr}`;
}
code.writeLine(`"${field.name}"${args}: ${returnTypeStr}`);
Expand Down
4 changes: 3 additions & 1 deletion src/@types/gql-ctx.d.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
declare namespace Gql {
export type WxtQueueCtx = import("../dependencies").Dependencies;
export type WxtQueueCtx = {
deps: import("../dependencies").Dependencies;
};
Comment on lines -2 to +4
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm now using transient services, so I need to provide the proxy object so they can be instantiated each time they're needed rather than instantiating them once at the beginning.

}
9 changes: 4 additions & 5 deletions src/apis/extension-store-apis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,10 @@ export const extensionStoreApis = createApp({
index: z.coerce.number().int().min(0),
}),
},
async ({ params, stores, set }) => {
const screenshotUrl = await stores[params.storeName].getScreenshotUrl(
params.id,
params.index,
);
async ({ params, deps, set }) => {
const screenshotUrl = await deps.stores[
params.storeName
].getScreenshotUrl(params.id, params.index);
if (!screenshotUrl)
throw new NotFoundHttpError("Extension or screenshot not found");

Expand Down
18 changes: 14 additions & 4 deletions src/dependencies.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,24 @@
import { createIocContainer } from "@aklinker1/zero-ioc";
import { createIocContainer, transient } from "@aklinker1/zero-ioc";
import { createChromeWebStore } from "./services/chrome-web-store";
import { createFirefoxAddonStore } from "./services/firefox-addon-store";
import { createEdgeAddonStore } from "./services/edge-addon-store";
import type { ExtensionStores } from "./services/extension-stores";
import { ExtensionStoreName } from "./enums";
import { createRedisCache } from "./services/redis-cache";
import { createInMemoryCache } from "./services/in-memory-cache";
import { createEdgeApi } from "./services/edge-api";
import { createFirefoxApi } from "./services/firefox-api";

export const container = createIocContainer()
.register("chromeWebStore", createChromeWebStore)
.register("firefoxAddonStore", createFirefoxAddonStore)
.register("edgeAddonStore", createEdgeAddonStore)
.register(
"cache",
Bun.redis.connected ? createRedisCache : createInMemoryCache,
)
.register("edgeApi", createEdgeApi)
.register("firefoxApi", createFirefoxApi)
.register("chromeWebStore", transient(createChromeWebStore))
.register("firefoxAddonStore", transient(createFirefoxAddonStore))
.register("edgeAddonStore", transient(createEdgeAddonStore))
Comment on lines +19 to +21
Copy link
Copy Markdown
Member Author

@aklinker1 aklinker1 Apr 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Registering these as transient means they'll be recreated every time they're accessed. I pulled out the API dependencies so those aren't recreated unnecessarily.

The data loaders inside each still have a cache, but they are no longer long-lived, and expire after each requesst.

Technically, the stores might be created more than once each request if the store is resolved multiple times per-request... they're not, but could be in the future.

.register(
"stores",
(deps) =>
Expand Down
6 changes: 5 additions & 1 deletion src/graphql/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,14 @@ export function createGraphql() {

logger.debug("Running query", { id, method, operationName });

const ctx: Gql.WxtQueueCtx = {
deps: container.registrations,
};

const response = await graphql({
schema,
source: query,
contextValue: container.registrations,
contextValue: ctx,
variableValues: variables,
rootValue: rootResolver,
});
Expand Down
14 changes: 8 additions & 6 deletions src/graphql/resolvers.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
export const rootResolver: Gql.RootResolver = {
chromeExtension: ({ id }, ctx) => ctx.chromeWebStore.getExtension(id),
chromeExtensions: ({ ids }, ctx) => ctx.chromeWebStore.getExtensions(ids),
firefoxAddon: ({ id }, ctx) => ctx.firefoxAddonStore.getExtension(id),
firefoxAddons: ({ ids }, ctx) => ctx.firefoxAddonStore.getExtensions(ids),
edgeAddon: ({ id }, ctx) => ctx.edgeAddonStore.getExtension(id),
edgeAddons: ({ ids }, ctx) => ctx.edgeAddonStore.getExtensions(ids),
chromeExtension: ({ id }, ctx) => ctx.deps.chromeWebStore.getExtension(id),
chromeExtensions: ({ ids }, ctx) =>
ctx.deps.chromeWebStore.getExtensions(ids),
firefoxAddon: ({ id }, ctx) => ctx.deps.firefoxAddonStore.getExtension(id),
firefoxAddons: ({ ids }, ctx) =>
ctx.deps.firefoxAddonStore.getExtensions(ids),
edgeAddon: ({ id }, ctx) => ctx.deps.edgeAddonStore.getExtension(id),
edgeAddons: ({ ids }, ctx) => ctx.deps.edgeAddonStore.getExtensions(ids),
};
2 changes: 1 addition & 1 deletion src/plugins/context-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@ import { createApp } from "@aklinker1/zeta";
import { container } from "../dependencies";

export const contextPlugin = createApp()
.decorate(container.resolveAll())
.decorate({ deps: container.registrations })
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here's where I'm actually passing in the proxy object instead of resolving all services once.

.export();
4 changes: 4 additions & 0 deletions src/services/cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export interface Cache {
get<T>(key: string): Promise<T | undefined>;
set<T>(key: string, value: T): Promise<void>;
}
2 changes: 1 addition & 1 deletion src/services/chrome-crawler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ function tryExtract<T>(
errors.push(error as Error);
}
}
errors.forEach((err) => console.error(err));
if (errors.length > 0) logger.error("Crawl errors", { errors });
throw new Error(`Could not extract "${field}" from HTML`, { cause: errors });
}

Expand Down
15 changes: 12 additions & 3 deletions src/services/chrome-web-store.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
import type { Cache } from "./cache";
import { crawlExtension } from "./chrome-crawler";
import { defineExtensionStore, type ExtensionStore } from "./extension-store";
import { ExtensionStore } from "./extension-store";

export type ChromeWebStore = ExtensionStore<Gql.ChromeExtension>;

export function createChromeWebStore() {
return defineExtensionStore((id) => crawlExtension(String(id), "en"));
export function createChromeWebStore({
cache,
}: {
cache: Cache;
}): ChromeWebStore {
return new ExtensionStore({
fetch: (id) => crawlExtension(String(id), "en"),
cacheKeyPrefix: "chrome-extension-",
cache,
});
}
21 changes: 15 additions & 6 deletions src/services/edge-addon-store.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
import { createEdgeApi } from "./edge-api";
import { defineExtensionStore, type ExtensionStore } from "./extension-store";
import type { Cache } from "./cache";
import type { EdgeApi } from "./edge-api";
import { ExtensionStore } from "./extension-store";

export type EdgeAddonStore = ExtensionStore<Gql.EdgeAddon>;

export function createEdgeAddonStore() {
const api = createEdgeApi();

return defineExtensionStore((id) => api.getAddon(String(id)));
export function createEdgeAddonStore({
cache,
edgeApi,
}: {
cache: Cache;
edgeApi: EdgeApi;
}): EdgeAddonStore {
return new ExtensionStore({
fetch: (id) => edgeApi.getAddon(String(id)),
cacheKeyPrefix: "edge-addon-",
cache,
});
}
6 changes: 2 additions & 4 deletions src/services/edge-api.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createLogger } from "@aklinker1/logger";
import { ExtensionStoreName } from "../enums";
import { buildScreenshotUrl } from "../utils/urls";
import { FetchError } from "../utils/errors";

const logger = createLogger("edge-api");

Expand Down Expand Up @@ -41,13 +42,10 @@ export function createEdgeApi(): EdgeApi {
const res = await fetch(
`https://microsoftedge.microsoft.com/addons/getproductdetailsbycrxid/${crxid}`,
);
if (res.status !== 200) {
throw Error("Edge API request failed", { cause: res });
}
if (res.status !== 200) throw new FetchError(res, await res.text());

const json = (await res.json()) as GetProductDetailsByCrxId200Response;
logger.debug("Addon result", { crxid, json });

return toGqlEdgeAddon(json);
};

Expand Down
104 changes: 48 additions & 56 deletions src/services/extension-store.ts
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Converted to a class to speed up creating instances of this object now that it's created every request instead of once up top.

In general, singleton objects are faster to create with a factory function once, but classes are faster when they need instantiated multiple times, like on every request.

Original file line number Diff line number Diff line change
@@ -1,74 +1,66 @@
import { createCachedDataLoader } from "../utils/cache";
import { HOUR_MS } from "../utils/time";
import DataLoader from "dataloader";
import type { Cache } from "./cache";

export type ExtensionId = string | number;

export interface ExtensionStore<TGqlExtension extends Gql.Extension> {
export class ExtensionStore<TGqlExtension extends Gql.Extension> {
private dataloader: DataLoader<ExtensionId, TGqlExtension>;

constructor(
readonly options: {
cacheKeyPrefix: string;
fetch: (id: ExtensionId) => Promise<TGqlExtension | undefined>;
cache: Cache;
},
) {
this.dataloader = new DataLoader<ExtensionId, TGqlExtension>(
async (ids): Promise<Array<TGqlExtension | Error>> => {
const results = await Promise.allSettled(
ids.map(async (id) => {
const cacheKey = options.cacheKeyPrefix + id;
const cached = await options.cache.get(cacheKey);
if (cached) return cached;

const result = await options.fetch(id);
if (result) await options.cache.set(cacheKey, result);

return result;
}),
);
return results.map((res) =>
res.status === "fulfilled" ? res.value : res.reason,
);
},
);
}

/**
* Get an extension by it's ID.
*/
getExtension: (
extensionId: ExtensionId,
) => Promise<TGqlExtension | undefined>;
getExtension(extensionId: ExtensionId): Promise<TGqlExtension> {
return this.dataloader.load(extensionId);
}

/**
* Get multiple extensions by their IDs.
*/
getExtensions: (
async getExtensions(
extensionIds: ExtensionId[],
) => Promise<(TGqlExtension | undefined)[]>;
): Promise<(TGqlExtension | Error)[]> {
return this.dataloader.loadMany(extensionIds);
}

/**
* Get a screenshot given an index.
*/
getScreenshotUrl(
async getScreenshotUrl(
extensionId: ExtensionId,
screenshotIndex: number,
): Promise<string | undefined>;
}

export function defineExtensionStore<TGqlExtension extends Gql.Extension>(
fetch: (id: ExtensionId) => Promise<TGqlExtension | undefined>,
): ExtensionStore<TGqlExtension> {
const loader = createCachedDataLoader<ExtensionId, TGqlExtension | undefined>(
HOUR_MS,
async (ids) => {
const results = await Promise.allSettled(ids.map((id) => fetch(id)));
return results.map((res) =>
res.status === "fulfilled" ? res.value : undefined,
);
},
);

const getExtension: ExtensionStore<TGqlExtension>["getExtension"] = (
extensionId,
) => loader.load(extensionId);

const getExtensions: ExtensionStore<TGqlExtension>["getExtensions"] = async (
extensionIds,
) => {
const result = await loader.loadMany(extensionIds);
return result.map((item, index) => {
if (item instanceof Error) {
console.warn("Error loading extension:", extensionIds[index], item);
return undefined;
}
return item;
});
};

const getScreenshotUrl: ExtensionStore<TGqlExtension>["getScreenshotUrl"] =
async (extensionId, screenshotIndex) => {
const extension = await getExtension(extensionId);
const screenshot = extension?.screenshots.find(
(screenshot) => screenshot.index == screenshotIndex,
);
return screenshot?.rawUrl;
};

return {
getExtension,
getExtensions,
getScreenshotUrl,
};
): Promise<string | undefined> {
const extension = await this.getExtension(extensionId);
const screenshot = extension.screenshots.find(
(screenshot) => screenshot.index == screenshotIndex,
);
return screenshot?.rawUrl;
}
}
21 changes: 15 additions & 6 deletions src/services/firefox-addon-store.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
import { createFirefoxApi } from "./firefox-api";
import { defineExtensionStore, type ExtensionStore } from "./extension-store";
import type { Cache } from "./cache";
import { ExtensionStore } from "./extension-store";
import type { FirefoxApi } from "./firefox-api";

export type FirefoxAddonStore = ExtensionStore<Gql.FirefoxAddon>;

export function createFirefoxAddonStore() {
const api = createFirefoxApi();

return defineExtensionStore((id) => api.getAddon(id));
export function createFirefoxAddonStore({
cache,
firefoxApi,
}: {
cache: Cache;
firefoxApi: FirefoxApi;
}): FirefoxAddonStore {
return new ExtensionStore({
fetch: (id) => firefoxApi.getAddon(String(id)),
cacheKeyPrefix: "firefox-addon-",
cache,
});
}
6 changes: 2 additions & 4 deletions src/services/firefox-api.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { buildScreenshotUrl } from "../utils/urls";
import { ExtensionStoreName } from "../enums";
import { createLogger } from "@aklinker1/logger";
import { FetchError } from "../utils/errors";

const logger = createLogger("firefox-api");

Expand Down Expand Up @@ -43,10 +44,7 @@ export function createFirefoxApi(): FirefoxApi {
`https://addons.mozilla.org/api/v5/addons/addon/${idOrSlugOrGuid}`,
);
const res = await fetch(url);
if (res.status !== 200)
throw Error(
`${url.href} failed with status: ${res.status} ${res.statusText}`,
);
if (res.status !== 200) throw new FetchError(res, await res.text());

const json = (await res.json()) as GetAddon200Response;
logger.debug("Addon result", { idOrSlugOrGuid, json });
Expand Down
23 changes: 23 additions & 0 deletions src/services/in-memory-cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { HOUR_MS } from "../utils/time";
import type { Cache } from "./cache";

const TTL = HOUR_MS;

export function createInMemoryCache(): Cache {
let cache: Record<string, any> = Object.create(null);
let ttl: Record<string, number> = Object.create(null);

return {
get: async (key: string) => {
if (ttl[key] && Date.now() > ttl[key]) {
delete cache[key];
delete ttl[key];
}
return cache[key];
},
set: async (key: string, value: any) => {
cache[key] = value;
ttl[key] = Date.now() + TTL;
},
};
}
Loading