@decocms/apps-algolia 8.0.0 → 8.1.0-next.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@decocms/apps-algolia",
3
- "version": "8.0.0",
3
+ "version": "8.1.0-next.0",
4
4
  "type": "module",
5
- "description": "Deco commerce app: Algolia search integration",
5
+ "description": "Thin client for the Algolia Search REST API",
6
6
  "repository": {
7
7
  "type": "git",
8
8
  "url": "https://github.com/decocms/blocks.git",
@@ -22,12 +22,9 @@
22
22
  "lint:unused": "knip"
23
23
  },
24
24
  "dependencies": {
25
- "@decocms/blocks": "8.0.0",
26
- "@decocms/apps-commerce": "8.0.0"
25
+ "@decocms/blocks": "8.1.0-next.0"
27
26
  },
28
27
  "peerDependencies": {
29
- "react": "^19.0.0",
30
- "react-dom": "^19.0.0",
31
28
  "algoliasearch": "^5"
32
29
  },
33
30
  "peerDependenciesMeta": {
@@ -36,8 +33,6 @@
36
33
  }
37
34
  },
38
35
  "devDependencies": {
39
- "@types/react": "^19.0.0",
40
- "@types/react-dom": "^19.0.0",
41
36
  "algoliasearch": "^5.53.0",
42
37
  "knip": "^5.86.0",
43
38
  "typescript": "^5.9.0"
package/src/README.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Algolia app — initial scaffold
2
2
 
3
+ > **Next major:** the package root exports `createAlgoliaClient`, a thin
4
+ > client over the Algolia REST API built on `@decocms/blocks/fetch`
5
+ > (see `/next/upstream-clients`). Everything below is the v7 surface, which
6
+ > stays on the `./client`, `./loaders/client` and `./types` subpaths.
7
+
3
8
  This folder ports the Algolia integration from `deco-cx/apps/algolia`
4
9
  (Fresh/Deno) to `@decocms/apps/algolia` (TanStack Start/Node), following
5
10
  the same shape as `vtex/`, `magento/`, and `shopify/`.
@@ -12,10 +17,10 @@ surface plus the `loaders/client.ts` shim that matches the upstream
12
17
  Just enough for downstream sites with their own product loaders to wire
13
18
  Algolia and consume the SDK SearchClient directly.
14
19
 
15
- A real-world consumer (deco-sites/granadobr-tanstack) is migrating away
20
+ A real-world consumer (a production Algolia storefront) is migrating away
16
21
  from the legacy `ctx.invoke.algolia.loaders.client({})` proxy that
17
22
  existed in the Fresh runtime. The site keeps its own product loaders
18
- (custom Granado transforms over the upstream toProduct) and only needs
23
+ (custom storefront-specific transforms over the upstream toProduct) and only needs
19
24
  the SDK client from this package.
20
25
 
21
26
  ## What's here
@@ -47,16 +52,16 @@ etc.). Tracked here so the next PR series has a clear scope:
47
52
  | `workflows/index/product.ts` | `deco-cx/apps/algolia/workflows/index/product.ts` |
48
53
  | `sections/Analytics/Algolia.tsx` | `deco-cx/apps/algolia/sections/Analytics/Algolia.tsx` |
49
54
 
50
- The site-side `src/packs/algolia/products/*` in granadobr-tanstack
51
- contains a Granado-specific transform layer that is not portable as-is.
55
+ The site-side `src/packs/algolia/products/*` in a production Algolia storefront
56
+ contains a storefront-specific transform layer that is not portable as-is.
52
57
  Once `loaders/product/*` lands here, the upstream tract can be reused;
53
- the Granado overlays will keep living in the site.
58
+ the storefront-specific overlays will keep living in the site.
54
59
 
55
60
  ## Wiring in a site
56
61
 
57
62
  ```ts
58
63
  // src/setup.ts
59
- import { initAlgoliaFromBlocks } from "@decocms/apps/algolia";
64
+ import { initAlgoliaFromBlocks } from "@decocms/apps-algolia/client";
60
65
  import { blocks } from "./server/cms/blocks.gen";
61
66
 
62
67
  createSiteSetup({
@@ -0,0 +1,63 @@
1
+ /**
2
+ * The v8 Algolia client (/next/upstream-clients): typed REST calls through
3
+ * the instrumented fetch, credentials in headers, errors without bodies.
4
+ */
5
+ import { describe, expect, it, vi } from "vitest";
6
+ import { AlgoliaError, createAlgoliaClient } from "../index";
7
+
8
+ function fakeFetch(status: number, body: unknown) {
9
+ return vi.fn(
10
+ async (_input: string | URL | Request, _init?: RequestInit) =>
11
+ new Response(JSON.stringify(body), { status }),
12
+ );
13
+ }
14
+
15
+ const config = { applicationId: "APPID", apiKey: "search-key" };
16
+
17
+ describe("createAlgoliaClient", () => {
18
+ it("search posts every query to the multi-query endpoint", async () => {
19
+ const fetch = fakeFetch(200, { results: [{ hits: [{ objectID: "1" }], nbHits: 1 }] });
20
+ const algolia = createAlgoliaClient(config, { fetch });
21
+
22
+ const { results } = await algolia.search([
23
+ { indexName: "products", query: "linen shirt", hitsPerPage: 12 },
24
+ ]);
25
+
26
+ expect(results[0].hits[0].objectID).toBe("1");
27
+ const [url, init] = fetch.mock.calls[0];
28
+ expect(String(url)).toBe("https://APPID-dsn.algolia.net/1/indexes/*/queries");
29
+ expect(init?.method).toBe("POST");
30
+ expect(init?.headers).toMatchObject({
31
+ "x-algolia-application-id": "APPID",
32
+ "x-algolia-api-key": "search-key",
33
+ });
34
+ expect(JSON.parse(String(init?.body))).toEqual({
35
+ requests: [{ indexName: "products", query: "linen shirt", hitsPerPage: 12 }],
36
+ });
37
+ });
38
+
39
+ it("rejects an applicationId that would change the host the key is sent to", () => {
40
+ expect(() =>
41
+ createAlgoliaClient({ applicationId: "evil.example/#", apiKey: "search-key" }),
42
+ ).toThrow("invalid applicationId");
43
+ });
44
+
45
+ it("throws the operation and status, never the body or the key", async () => {
46
+ const fetch = fakeFetch(403, { message: "Invalid API key search-key" });
47
+ const algolia = createAlgoliaClient(config, { fetch });
48
+
49
+ const error = await algolia.search([{ indexName: "products" }]).catch((e: unknown) => e);
50
+
51
+ expect(error).toBeInstanceOf(AlgoliaError);
52
+ expect(error).toMatchObject({ operation: "search", status: 403 });
53
+ expect((error as Error).message).not.toContain("search-key");
54
+ });
55
+
56
+ it("does not retry", async () => {
57
+ const fetch = fakeFetch(503, {});
58
+ const algolia = createAlgoliaClient(config, { fetch });
59
+
60
+ await expect(algolia.search([{ indexName: "products" }])).rejects.toThrow(AlgoliaError);
61
+ expect(fetch).toHaveBeenCalledTimes(1);
62
+ });
63
+ });
package/src/index.ts CHANGED
@@ -1,12 +1,90 @@
1
1
  /**
2
- * Algolia app entry point for @decocms/apps.
3
- * Re-exports client config + initializer + types.
2
+ * `@decocms/apps-algolia`: a thin client for the Algolia Search REST API.
3
+ * See /next/upstream-clients.
4
4
  *
5
- * For loaders, use sub-path imports:
6
- * import client from "@decocms/apps/algolia/loaders/client"
5
+ * It calls the REST API directly rather than through the `algoliasearch`
6
+ * SDK, so every request goes through `createInstrumentedFetch` (provider
7
+ * `algolia`) like every other client. No retries or circuit breaker, and no
8
+ * response cache: caching upstream data is the framework binding's job.
7
9
  *
8
- * For the SDK SearchClient directly (no proxy hop on the server):
9
- * import { getAlgoliaClient } from "@decocms/apps/algolia/client"
10
+ * The v7 SDK wiring (`configureAlgolia`, `getAlgoliaClient`,
11
+ * `initAlgoliaFromBlocks`) stays on the `./client` and `./loaders/client`
12
+ * subpaths for v7 sites; it needs the optional `algoliasearch` peer.
10
13
  */
11
- export * from "./client";
12
- export type { AlgoliaConfig, Indices } from "./types";
14
+ import { createInstrumentedFetch } from "@decocms/blocks/fetch";
15
+
16
+ export interface AlgoliaClientConfig {
17
+ applicationId: string;
18
+ /** A search-only key is enough for search; never send an admin key to the browser. */
19
+ apiKey: string;
20
+ }
21
+
22
+ /** One query: the index plus search parameters as the REST API names them (`query`, `hitsPerPage`, `filters`, ...). */
23
+ export interface AlgoliaSearchRequest {
24
+ indexName: string;
25
+ query?: string;
26
+ [param: string]: unknown;
27
+ }
28
+
29
+ export type AlgoliaHit<T> = T & { objectID: string; [field: string]: unknown };
30
+
31
+ export interface AlgoliaSearchResponse<T = Record<string, unknown>> {
32
+ hits: AlgoliaHit<T>[];
33
+ nbHits: number;
34
+ page: number;
35
+ nbPages: number;
36
+ hitsPerPage: number;
37
+ processingTimeMS: number;
38
+ query: string;
39
+ params: string;
40
+ index?: string;
41
+ queryID?: string;
42
+ facets?: Record<string, Record<string, number>>;
43
+ [field: string]: unknown;
44
+ }
45
+
46
+ export class AlgoliaError extends Error {
47
+ constructor(
48
+ readonly operation: string,
49
+ readonly status: number,
50
+ ) {
51
+ super(`algolia ${operation} failed with HTTP ${status}`);
52
+ }
53
+ }
54
+
55
+ export function createAlgoliaClient(
56
+ config: AlgoliaClientConfig,
57
+ options: { fetch?: typeof fetch } = {},
58
+ ) {
59
+ // The id becomes part of the host; reject anything that could redirect the API key elsewhere.
60
+ if (!/^[A-Za-z0-9]+$/.test(config.applicationId))
61
+ throw new Error("algolia: invalid applicationId");
62
+ const request = createInstrumentedFetch({ provider: "algolia", fetch: options.fetch });
63
+ const host = `https://${config.applicationId}-dsn.algolia.net`;
64
+
65
+ async function post<R>(operation: string, path: string, body: unknown): Promise<R> {
66
+ const response = await request(`${host}${path}`, {
67
+ operation,
68
+ method: "POST",
69
+ headers: {
70
+ "content-type": "application/json",
71
+ "x-algolia-application-id": config.applicationId,
72
+ "x-algolia-api-key": config.apiKey,
73
+ },
74
+ body: JSON.stringify(body),
75
+ });
76
+ if (!response.ok) throw new AlgoliaError(operation, response.status);
77
+ return (await response.json()) as R;
78
+ }
79
+
80
+ return {
81
+ /** Runs several queries, on one or more indices, in one request. */
82
+ search<T = Record<string, unknown>>(
83
+ requests: AlgoliaSearchRequest[],
84
+ ): Promise<{ results: AlgoliaSearchResponse<T>[] }> {
85
+ return post("search", "/1/indexes/*/queries", { requests });
86
+ },
87
+ };
88
+ }
89
+
90
+ export type AlgoliaClient = ReturnType<typeof createAlgoliaClient>;