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

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.
@@ -0,0 +1,44 @@
1
+ export interface AlgoliaClientConfig {
2
+ applicationId: string;
3
+ /** A search-only key is enough for search; never send an admin key to the browser. */
4
+ apiKey: string;
5
+ }
6
+ /** One query: the index plus search parameters as the REST API names them (`query`, `hitsPerPage`, `filters`, ...). */
7
+ export interface AlgoliaSearchRequest {
8
+ indexName: string;
9
+ query?: string;
10
+ [param: string]: unknown;
11
+ }
12
+ export type AlgoliaHit<T> = T & {
13
+ objectID: string;
14
+ [field: string]: unknown;
15
+ };
16
+ export interface AlgoliaSearchResponse<T = Record<string, unknown>> {
17
+ hits: AlgoliaHit<T>[];
18
+ nbHits: number;
19
+ page: number;
20
+ nbPages: number;
21
+ hitsPerPage: number;
22
+ processingTimeMS: number;
23
+ query: string;
24
+ params: string;
25
+ index?: string;
26
+ queryID?: string;
27
+ facets?: Record<string, Record<string, number>>;
28
+ [field: string]: unknown;
29
+ }
30
+ export declare class AlgoliaError extends Error {
31
+ readonly operation: string;
32
+ readonly status: number;
33
+ constructor(operation: string, status: number);
34
+ }
35
+ export declare function createAlgoliaClient(config: AlgoliaClientConfig, options?: {
36
+ fetch?: typeof globalThis.fetch;
37
+ }): {
38
+ /** Runs several queries, on one or more indices, in one request. */
39
+ search<T = Record<string, unknown>>(requests: AlgoliaSearchRequest[]): Promise<{
40
+ results: AlgoliaSearchResponse<T>[];
41
+ }>;
42
+ };
43
+ export type AlgoliaClient = ReturnType<typeof createAlgoliaClient>;
44
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAWA,MAAM,WAAW,mBAAmB;IAClC,aAAa,EAAE,MAAM,CAAC;IACtB,sFAAsF;IACtF,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,uHAAuH;AACvH,MAAM,WAAW,oBAAoB;IACnC,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC;CAC1B;AAED,MAAM,MAAM,UAAU,CAAC,CAAC,IAAI,CAAC,GAAG;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAA;CAAE,CAAC;AAE/E,MAAM,WAAW,qBAAqB,CAAC,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IAChE,IAAI,EAAE,UAAU,CAAC,CAAC,CAAC,EAAE,CAAC;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,MAAM,CAAC;IACpB,gBAAgB,EAAE,MAAM,CAAC;IACzB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IAChD,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC;CAC1B;AAED,qBAAa,YAAa,SAAQ,KAAK;IAEnC,QAAQ,CAAC,SAAS,EAAE,MAAM;IAC1B,QAAQ,CAAC,MAAM,EAAE,MAAM;IAFzB,YACW,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE,MAAM,EAGxB;CACF;AAED,wBAAgB,mBAAmB,CACjC,MAAM,EAAE,mBAAmB,EAC3B,OAAO,GAAE;IAAE,KAAK,CAAC,EAAE,OAAO,UAAU,CAAC,KAAK,CAAA;CAAO;IAwB/C,oEAAoE;IACpE,MAAM,CAAC,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,YACtB,oBAAoB,EAAE,GAC/B,OAAO,CAAC;QAAE,OAAO,EAAE,qBAAqB,CAAC,CAAC,CAAC,EAAE,CAAA;KAAE,CAAC;EAItD;AAED,MAAM,MAAM,aAAa,GAAG,UAAU,CAAC,OAAO,mBAAmB,CAAC,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,48 @@
1
+ /**
2
+ * `@decocms/apps-algolia`: a thin client for the Algolia Search REST API.
3
+ * See /next/upstream-clients.
4
+ *
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 site's job.
9
+ */
10
+ import { createInstrumentedFetch } from "@decocms/blocks/fetch";
11
+ export class AlgoliaError extends Error {
12
+ operation;
13
+ status;
14
+ constructor(operation, status) {
15
+ super(`algolia ${operation} failed with HTTP ${status}`);
16
+ this.operation = operation;
17
+ this.status = status;
18
+ }
19
+ }
20
+ export function createAlgoliaClient(config, options = {}) {
21
+ // The id becomes part of the host; reject anything that could redirect the API key elsewhere.
22
+ if (!/^[A-Za-z0-9]+$/.test(config.applicationId))
23
+ throw new Error("algolia: invalid applicationId");
24
+ const request = createInstrumentedFetch({ provider: "algolia", fetch: options.fetch });
25
+ const host = `https://${config.applicationId}-dsn.algolia.net`;
26
+ async function post(operation, path, body) {
27
+ const response = await request(`${host}${path}`, {
28
+ operation,
29
+ method: "POST",
30
+ headers: {
31
+ "content-type": "application/json",
32
+ "x-algolia-application-id": config.applicationId,
33
+ "x-algolia-api-key": config.apiKey,
34
+ },
35
+ body: JSON.stringify(body),
36
+ });
37
+ if (!response.ok)
38
+ throw new AlgoliaError(operation, response.status);
39
+ return (await response.json());
40
+ }
41
+ return {
42
+ /** Runs several queries, on one or more indices, in one request. */
43
+ search(requests) {
44
+ return post("search", "/1/indexes/*/queries", { requests });
45
+ },
46
+ };
47
+ }
48
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,EAAE,uBAAuB,EAAE,MAAM,uBAAuB,CAAC;AAgChE,MAAM,OAAO,YAAa,SAAQ,KAAK;IAE1B,SAAS;IACT,MAAM;IAFjB,YACW,SAAiB,EACjB,MAAc;QAEvB,KAAK,CAAC,WAAW,SAAS,qBAAqB,MAAM,EAAE,CAAC,CAAC;yBAHhD,SAAS;sBACT,MAAM;IAGjB,CAAC;CACF;AAED,MAAM,UAAU,mBAAmB,CACjC,MAA2B,EAC3B,OAAO,GAAwC,EAAE;IAEjD,8FAA8F;IAC9F,IAAI,CAAC,gBAAgB,CAAC,IAAI,CAAC,MAAM,CAAC,aAAa,CAAC;QAC9C,MAAM,IAAI,KAAK,CAAC,gCAAgC,CAAC,CAAC;IACpD,MAAM,OAAO,GAAG,uBAAuB,CAAC,EAAE,QAAQ,EAAE,SAAS,EAAE,KAAK,EAAE,OAAO,CAAC,KAAK,EAAE,CAAC,CAAC;IACvF,MAAM,IAAI,GAAG,WAAW,MAAM,CAAC,aAAa,kBAAkB,CAAC;IAE/D,KAAK,UAAU,IAAI,CAAI,SAAiB,EAAE,IAAY,EAAE,IAAa;QACnE,MAAM,QAAQ,GAAG,MAAM,OAAO,CAAC,GAAG,IAAI,GAAG,IAAI,EAAE,EAAE;YAC/C,SAAS;YACT,MAAM,EAAE,MAAM;YACd,OAAO,EAAE;gBACP,cAAc,EAAE,kBAAkB;gBAClC,0BAA0B,EAAE,MAAM,CAAC,aAAa;gBAChD,mBAAmB,EAAE,MAAM,CAAC,MAAM;aACnC;YACD,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC;SAC3B,CAAC,CAAC;QACH,IAAI,CAAC,QAAQ,CAAC,EAAE;YAAE,MAAM,IAAI,YAAY,CAAC,SAAS,EAAE,QAAQ,CAAC,MAAM,CAAC,CAAC;QACrE,OAAO,CAAC,MAAM,QAAQ,CAAC,IAAI,EAAE,CAAM,CAAC;IACtC,CAAC;IAED,OAAO;QACL,oEAAoE;QACpE,MAAM,CACJ,QAAgC;YAEhC,OAAO,IAAI,CAAC,QAAQ,EAAE,sBAAsB,EAAE,EAAE,QAAQ,EAAE,CAAC,CAAC;QAC9D,CAAC;KACF,CAAC;AACJ,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/apps-algolia",
3
- "version": "8.1.0-next.0",
3
+ "version": "8.1.0-next.2",
4
4
  "type": "module",
5
5
  "description": "Thin client for the Algolia Search REST API",
6
6
  "repository": {
@@ -8,34 +8,29 @@
8
8
  "url": "https://github.com/decocms/blocks.git",
9
9
  "directory": "packages/apps-algolia"
10
10
  },
11
- "main": "./src/index.ts",
11
+ "files": [
12
+ "dist",
13
+ "src",
14
+ "!src/**/*.test.ts",
15
+ "!src/**/__tests__"
16
+ ],
17
+ "main": "./dist/index.js",
18
+ "types": "./dist/index.d.ts",
12
19
  "exports": {
13
- ".": "./src/index.ts",
14
- "./client": "./src/client.ts",
15
- "./types": "./src/types.ts",
16
- "./loaders/*": "./src/loaders/*.ts"
20
+ ".": {
21
+ "types": "./dist/index.d.ts",
22
+ "source": "./src/index.ts",
23
+ "default": "./dist/index.js"
24
+ }
17
25
  },
18
26
  "scripts": {
19
- "build": "tsc",
27
+ "build": "node ../../scripts/tsc.mjs --clean dist -p tsconfig.build.json",
28
+ "prepack": "npm run build",
20
29
  "test": "vitest run --root ../.. packages/apps-algolia/",
21
- "typecheck": "tsc --noEmit",
22
- "lint:unused": "knip"
30
+ "typecheck": "node ../../scripts/tsc.mjs -p tsconfig.json"
23
31
  },
24
32
  "dependencies": {
25
- "@decocms/blocks": "8.1.0-next.0"
26
- },
27
- "peerDependencies": {
28
- "algoliasearch": "^5"
29
- },
30
- "peerDependenciesMeta": {
31
- "algoliasearch": {
32
- "optional": true
33
- }
34
- },
35
- "devDependencies": {
36
- "algoliasearch": "^5.53.0",
37
- "knip": "^5.86.0",
38
- "typescript": "^5.9.0"
33
+ "@decocms/blocks": "8.1.0-next.2"
39
34
  },
40
35
  "publishConfig": {
41
36
  "registry": "https://registry.npmjs.org",
package/src/index.ts CHANGED
@@ -5,11 +5,7 @@
5
5
  * It calls the REST API directly rather than through the `algoliasearch`
6
6
  * SDK, so every request goes through `createInstrumentedFetch` (provider
7
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.
9
- *
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.
8
+ * response cache: caching upstream data is the site's job.
13
9
  */
14
10
  import { createInstrumentedFetch } from "@decocms/blocks/fetch";
15
11
 
@@ -54,7 +50,7 @@ export class AlgoliaError extends Error {
54
50
 
55
51
  export function createAlgoliaClient(
56
52
  config: AlgoliaClientConfig,
57
- options: { fetch?: typeof fetch } = {},
53
+ options: { fetch?: typeof globalThis.fetch } = {},
58
54
  ) {
59
55
  // The id becomes part of the host; reject anything that could redirect the API key elsewhere.
60
56
  if (!/^[A-Za-z0-9]+$/.test(config.applicationId))
package/src/README.md DELETED
@@ -1,94 +0,0 @@
1
- # Algolia app — initial scaffold
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
-
8
- This folder ports the Algolia integration from `deco-cx/apps/algolia`
9
- (Fresh/Deno) to `@decocms/apps/algolia` (TanStack Start/Node), following
10
- the same shape as `vtex/`, `magento/`, and `shopify/`.
11
-
12
- ## Status
13
-
14
- **Initial scaffold** — covers the `configureAlgolia`/`getAlgoliaClient`
15
- surface plus the `loaders/client.ts` shim that matches the upstream
16
- `apps/algolia/loaders/client.ts` call site (`ctx.invoke.algolia.loaders.client({})`).
17
- Just enough for downstream sites with their own product loaders to wire
18
- Algolia and consume the SDK SearchClient directly.
19
-
20
- A real-world consumer (a production Algolia storefront) is migrating away
21
- from the legacy `ctx.invoke.algolia.loaders.client({})` proxy that
22
- existed in the Fresh runtime. The site keeps its own product loaders
23
- (custom storefront-specific transforms over the upstream toProduct) and only needs
24
- the SDK client from this package.
25
-
26
- ## What's here
27
-
28
- - `client.ts` — `configureAlgolia({ applicationId, searchApiKey,
29
- adminApiKey })` + `getAlgoliaConfig()` accessor + lazy
30
- `getAlgoliaClient()` cached singleton. Mirrors `configureMagento` /
31
- `configureVtex`.
32
- - `types.ts` — `AlgoliaConfig`, canonical `Indices` union.
33
- - `loaders/client.ts` — returns the configured `SearchClient` so legacy
34
- call sites (`invoke.algolia.loaders.client({})`) keep working when
35
- routed through the loader registry.
36
- - `index.ts` — re-export entry.
37
-
38
- ## Pending port (PR follow-ups)
39
-
40
- These exist as production code in `deco-cx/apps/algolia/` and need a
41
- Deno → Node pass (npm specifiers, `commerce/types.ts` shared import,
42
- etc.). Tracked here so the next PR series has a clear scope:
43
-
44
- | Path | Original location |
45
- |---|---|
46
- | `loaders/product/list.ts` | `deco-cx/apps/algolia/loaders/product/list.ts` |
47
- | `loaders/product/listingPage.ts` | idem |
48
- | `loaders/product/suggestions.ts` | idem |
49
- | `actions/setup.ts` | `deco-cx/apps/algolia/actions/setup.ts` |
50
- | `actions/index/{product,wait}.ts` | `deco-cx/apps/algolia/actions/index/*` |
51
- | `utils/{highlight,product}.ts` | `deco-cx/apps/algolia/utils/*` |
52
- | `workflows/index/product.ts` | `deco-cx/apps/algolia/workflows/index/product.ts` |
53
- | `sections/Analytics/Algolia.tsx` | `deco-cx/apps/algolia/sections/Analytics/Algolia.tsx` |
54
-
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.
57
- Once `loaders/product/*` lands here, the upstream tract can be reused;
58
- the storefront-specific overlays will keep living in the site.
59
-
60
- ## Wiring in a site
61
-
62
- ```ts
63
- // src/setup.ts
64
- import { initAlgoliaFromBlocks } from "@decocms/apps-algolia/client";
65
- import { blocks } from "./server/cms/blocks.gen";
66
-
67
- createSiteSetup({
68
- // ...
69
- initPlatform: (blocks) => {
70
- initAlgoliaFromBlocks(blocks); // default block key: "deco-algolia"
71
- },
72
- });
73
- ```
74
-
75
- Then in your loaders:
76
-
77
- ```ts
78
- import { getAlgoliaClient } from "@decocms/apps/algolia/client";
79
-
80
- export default async function loader(props, req) {
81
- const client = getAlgoliaClient();
82
- const { results } = await client.search([{
83
- indexName: "products",
84
- query: props.term,
85
- params: { hitsPerPage: 12 },
86
- }]);
87
- return results[0].hits;
88
- }
89
- ```
90
-
91
- The Secret-shaped `adminApiKey` in the CMS block
92
- (`{__resolveType: "website/loaders/secret.ts", name: "ADMIN_KEY"}`) is
93
- dereferenced via `process.env.ADMIN_KEY` at init time, matching how
94
- `magento/client.ts` handles secrets in this repo.
@@ -1,195 +0,0 @@
1
- /**
2
- * Tests for algolia/client.ts.
3
- *
4
- * The goal is to lock the contract that downstream sites depend on:
5
- * - configureAlgolia stores config and surfaces it via getAlgoliaConfig
6
- * - getAlgoliaConfig throws a useful error when init never happened
7
- * - getAlgoliaClient builds the SDK lazily and caches the instance
8
- * - initAlgoliaFromBlocks dereferences Secret-shaped admin keys via
9
- * `process.env` so prod CMS blocks (`{__resolveType:
10
- * "website/loaders/secret.ts", name: "ADMIN_KEY"}`) work
11
- *
12
- * The SDK itself is mocked — we don't want network or fetch polyfills
13
- * pulled into the test runner; we only care that we call into
14
- * `algoliasearch(applicationId, adminApiKey)` with the right args.
15
- */
16
- import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
17
-
18
- const algoliasearchSpy = vi.fn(() => ({ __mockClient: true }));
19
-
20
- vi.mock("algoliasearch", () => ({
21
- algoliasearch: (...args: unknown[]) =>
22
- algoliasearchSpy(...(args as Parameters<typeof algoliasearchSpy>)),
23
- }));
24
-
25
- // Importing after the mock so the production module picks up the
26
- // mocked SDK. resetModules() in beforeEach keeps module-global state
27
- // (cachedClient, config) isolated across tests.
28
- let mod: typeof import("../client");
29
-
30
- beforeEach(async () => {
31
- algoliasearchSpy.mockClear();
32
- vi.resetModules();
33
- mod = await import("../client");
34
- });
35
-
36
- afterEach(() => {
37
- delete process.env.TEST_ADMIN_KEY;
38
- });
39
-
40
- describe("configureAlgolia + getAlgoliaConfig", () => {
41
- it("returns the most recently configured values", () => {
42
- mod.configureAlgolia({ applicationId: "APP", searchApiKey: "S", adminApiKey: "A" });
43
- expect(mod.getAlgoliaConfig()).toEqual({
44
- applicationId: "APP",
45
- searchApiKey: "S",
46
- adminApiKey: "A",
47
- });
48
- });
49
-
50
- it("throws a helpful error when called before init", () => {
51
- expect(() => mod.getAlgoliaConfig()).toThrowError(/configureAlgolia/);
52
- });
53
- });
54
-
55
- describe("getAlgoliaClient", () => {
56
- it("constructs the SDK with applicationId + adminApiKey", () => {
57
- mod.configureAlgolia({ applicationId: "APP_X", searchApiKey: "S", adminApiKey: "ADMIN" });
58
- const client = mod.getAlgoliaClient();
59
- expect(algoliasearchSpy).toHaveBeenCalledExactlyOnceWith("APP_X", "ADMIN");
60
- expect(client).toEqual({ __mockClient: true });
61
- });
62
-
63
- it("caches the client across calls", () => {
64
- mod.configureAlgolia({ applicationId: "APP_X", searchApiKey: "S", adminApiKey: "ADMIN" });
65
- mod.getAlgoliaClient();
66
- mod.getAlgoliaClient();
67
- mod.getAlgoliaClient();
68
- expect(algoliasearchSpy).toHaveBeenCalledOnce();
69
- });
70
-
71
- it("rebuilds the client after configureAlgolia is called again", () => {
72
- mod.configureAlgolia({ applicationId: "APP_X", searchApiKey: "S", adminApiKey: "ADMIN1" });
73
- mod.getAlgoliaClient();
74
- mod.configureAlgolia({ applicationId: "APP_X", searchApiKey: "S", adminApiKey: "ADMIN2" });
75
- mod.getAlgoliaClient();
76
- expect(algoliasearchSpy).toHaveBeenCalledTimes(2);
77
- expect(algoliasearchSpy).toHaveBeenNthCalledWith(2, "APP_X", "ADMIN2");
78
- });
79
-
80
- it("throws when applicationId is missing", () => {
81
- mod.configureAlgolia({ applicationId: "", searchApiKey: "S", adminApiKey: "A" });
82
- expect(() => mod.getAlgoliaClient()).toThrowError(/applicationId/);
83
- });
84
-
85
- it("falls back to searchApiKey when adminApiKey is empty", () => {
86
- mod.configureAlgolia({ applicationId: "APP", searchApiKey: "SEARCH_ONLY", adminApiKey: "" });
87
- mod.getAlgoliaClient();
88
- expect(algoliasearchSpy).toHaveBeenCalledExactlyOnceWith("APP", "SEARCH_ONLY");
89
- });
90
-
91
- it("throws when both keys are empty", () => {
92
- mod.configureAlgolia({ applicationId: "APP", searchApiKey: "", adminApiKey: "" });
93
- expect(() => mod.getAlgoliaClient()).toThrowError(/adminApiKey or searchApiKey/);
94
- });
95
-
96
- it("prefers adminApiKey over searchApiKey when both present", () => {
97
- mod.configureAlgolia({ applicationId: "APP", searchApiKey: "S", adminApiKey: "ADMIN" });
98
- mod.getAlgoliaClient();
99
- expect(algoliasearchSpy).toHaveBeenCalledExactlyOnceWith("APP", "ADMIN");
100
- });
101
- });
102
-
103
- describe("initAlgoliaFromBlocks", () => {
104
- it("returns false and skips configure() when block is absent", async () => {
105
- const result = await mod.initAlgoliaFromBlocks({});
106
- expect(result).toBe(false);
107
- expect(() => mod.getAlgoliaConfig()).toThrowError(/configureAlgolia/);
108
- });
109
-
110
- it("reads applicationId + searchApiKey + adminApiKey from the block", async () => {
111
- const result = await mod.initAlgoliaFromBlocks({
112
- "deco-algolia": {
113
- applicationId: "APP",
114
- searchApiKey: "SEARCH",
115
- adminApiKey: "ADMIN_STRING",
116
- },
117
- });
118
- expect(result).toBe(true);
119
- expect(mod.getAlgoliaConfig()).toEqual({
120
- applicationId: "APP",
121
- searchApiKey: "SEARCH",
122
- adminApiKey: "ADMIN_STRING",
123
- });
124
- });
125
-
126
- it("dereferences a Secret-shaped adminApiKey via process.env", async () => {
127
- process.env.TEST_ADMIN_KEY = "from-env";
128
- await mod.initAlgoliaFromBlocks({
129
- "deco-algolia": {
130
- applicationId: "APP",
131
- searchApiKey: "SEARCH",
132
- adminApiKey: {
133
- __resolveType: "website/loaders/secret.ts",
134
- name: "TEST_ADMIN_KEY",
135
- },
136
- },
137
- });
138
- expect(mod.getAlgoliaConfig().adminApiKey).toBe("from-env");
139
- });
140
-
141
- it("falls back to empty string when env var is unset", async () => {
142
- await mod.initAlgoliaFromBlocks({
143
- "deco-algolia": {
144
- applicationId: "APP",
145
- searchApiKey: "SEARCH",
146
- adminApiKey: {
147
- __resolveType: "website/loaders/secret.ts",
148
- name: "UNDEFINED_ENV_VAR_DO_NOT_SET",
149
- },
150
- },
151
- });
152
- expect(mod.getAlgoliaConfig().adminApiKey).toBe("");
153
- });
154
-
155
- it("honors a custom block key", async () => {
156
- await mod.initAlgoliaFromBlocks(
157
- {
158
- "my-algolia": {
159
- applicationId: "X",
160
- searchApiKey: "Y",
161
- adminApiKey: "Z",
162
- },
163
- },
164
- "my-algolia",
165
- );
166
- expect(mod.getAlgoliaConfig().applicationId).toBe("X");
167
- });
168
-
169
- // Encrypted-secret flow: the CMS block ships `{ encrypted, name }`,
170
- // the framework's `resolveSecret` (from `@decocms/start/sdk/crypto`)
171
- // is supposed to AES-CBC decrypt `encrypted` using `DECO_CRYPTO_KEY`.
172
- // In a vitest worker `crypto.subtle` is available but the AES key
173
- // material isn't shipped to the runner — without `DECO_CRYPTO_KEY`,
174
- // `resolveSecret` skips the decrypt step and falls back to the env
175
- // var. That fallback path is what this test pins: prod sites either
176
- // set the env var on top OR (more commonly) rely on the decrypt to
177
- // succeed against the worker's `DECO_CRYPTO_KEY` binding.
178
- it("uses env var fallback when DECO_CRYPTO_KEY is unset and encrypted is present", async () => {
179
- delete process.env.DECO_CRYPTO_KEY;
180
- process.env.FALLBACK_ADMIN_KEY = "from-env-fallback";
181
- await mod.initAlgoliaFromBlocks({
182
- "deco-algolia": {
183
- applicationId: "APP",
184
- searchApiKey: "SEARCH",
185
- adminApiKey: {
186
- __resolveType: "website/loaders/secret.ts",
187
- encrypted: "deadbeef",
188
- name: "FALLBACK_ADMIN_KEY",
189
- },
190
- },
191
- });
192
- expect(mod.getAlgoliaConfig().adminApiKey).toBe("from-env-fallback");
193
- delete process.env.FALLBACK_ADMIN_KEY;
194
- });
195
- });
@@ -1,63 +0,0 @@
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/client.ts DELETED
@@ -1,133 +0,0 @@
1
- /**
2
- * Algolia client + config — module-global, set once at app boot.
3
- *
4
- * Mirrors `magento/client.ts` and `vtex/client.ts`'s `configureX` /
5
- * `getX` pattern so the same wiring contract works across commerce
6
- * apps. The SearchClient is constructed lazily on the first
7
- * `getAlgoliaClient()` call so the underlying `algoliasearch` SDK
8
- * (which pulls in fetch polyfills + an LRU) only loads when actually
9
- * used.
10
- *
11
- * Two reasons we don't pass config explicitly to every loader:
12
- * 1. CMS-resolved loader instances don't know where the config block
13
- * lives; the site's `initAlgoliaFromBlocks(blocks)` adapter is the
14
- * single source of truth.
15
- * 2. Matches the rest of @decocms/apps so a site touching VTEX,
16
- * Magento, and Algolia has consistent muscle memory.
17
- */
18
-
19
- import { algoliasearch, type SearchClient } from "algoliasearch";
20
-
21
- import type { AlgoliaConfig } from "./types";
22
-
23
- // ---------------------------------------------------------------------------
24
- // Module-global state
25
- // ---------------------------------------------------------------------------
26
-
27
- let config: AlgoliaConfig | null = null;
28
- let cachedClient: SearchClient | null = null;
29
-
30
- export function configureAlgolia(c: AlgoliaConfig): void {
31
- config = c;
32
- // Reset the cached client so the next getAlgoliaClient() call picks
33
- // up the new credentials. In practice this only happens during dev
34
- // hot-reload of the setup file.
35
- cachedClient = null;
36
- }
37
-
38
- export function getAlgoliaConfig(): AlgoliaConfig {
39
- if (!config) {
40
- throw new Error(
41
- "[Algolia] configureAlgolia() must be called before loaders run. " +
42
- "Wire it in your site's setup, e.g. configureAlgolia(blocks['deco-algolia']).",
43
- );
44
- }
45
- return config;
46
- }
47
-
48
- /**
49
- * Returns the configured `SearchClient` from `algoliasearch`. The
50
- * instance is cached so all loaders/actions in a worker share one
51
- * client (and therefore one in-memory request cache).
52
- *
53
- * Prefers `adminApiKey` (broader scope — needed for indexing/settings
54
- * actions) but falls back to `searchApiKey` so search-only sites that
55
- * never set the admin secret as a worker env var still serve hits.
56
- * Both keys live in the same SDK instance because v4's SearchClient
57
- * doesn't expose a key swap; downstream write actions that require
58
- * admin scope should check `getAlgoliaConfig().adminApiKey` themselves
59
- * and surface a clear "admin key missing" error.
60
- */
61
- export function getAlgoliaClient(): SearchClient {
62
- if (cachedClient) return cachedClient;
63
- const c = getAlgoliaConfig();
64
- if (!c.applicationId) {
65
- throw new Error("[Algolia] applicationId is required.");
66
- }
67
- const key = c.adminApiKey || c.searchApiKey;
68
- if (!key) {
69
- throw new Error(
70
- "[Algolia] Either adminApiKey or searchApiKey is required. " +
71
- "Set ADMIN_KEY (or the env var your CMS block's Secret references) " +
72
- "as a worker env var, or populate searchApiKey on the block.",
73
- );
74
- }
75
- // algoliasearch v5 uses the global `fetch` and `crypto` APIs by
76
- // default — works on Cloudflare Workers, Bun, Deno, modern Node.
77
- // v4 (with crypto / node:http imports) does not run on Workers.
78
- cachedClient = algoliasearch(c.applicationId, key);
79
- return cachedClient;
80
- }
81
-
82
- // ---------------------------------------------------------------------------
83
- // CMS block adapter
84
- // ---------------------------------------------------------------------------
85
-
86
- /**
87
- * Best-effort init from a CMS block — mirrors `initMagentoFromBlocks`.
88
- *
89
- * Resolves `adminApiKey` via the shared `resolveSecret` from
90
- * `@decocms/start/sdk/crypto`, which walks: plain string → `.get()`
91
- * accessor → AES-CBC decrypt of `.encrypted` (using `DECO_CRYPTO_KEY`)
92
- * → `process.env[name]` fallback. Previously this init had its own
93
- * local helper that only consulted `process.env`, which meant any
94
- * site relying on the encrypted-secret round-trip (the production
95
- * Deco CMS default) silently produced `adminApiKey: ""` and
96
- * `getAlgoliaClient()` either threw or fell back to `searchApiKey`.
97
- *
98
- * Async because the AES decrypt is async — site setups must `await`
99
- * the call before any algolia loader fires.
100
- *
101
- * The block is conventionally keyed `deco-algolia` (matches the prod
102
- * Fresh sites' admin block name), but a custom key can be passed for
103
- * sites that named theirs differently. Returns true if the block was
104
- * found and applied, false otherwise.
105
- */
106
- export async function initAlgoliaFromBlocks(
107
- blocks: Record<string, unknown>,
108
- blockKey = "deco-algolia",
109
- ): Promise<boolean> {
110
- const block = blocks[blockKey] as Record<string, unknown> | undefined;
111
- if (!block) return false;
112
-
113
- const { resolveSecret } = await import("@decocms/blocks/sdk/crypto");
114
-
115
- const applicationId = typeof block.applicationId === "string" ? block.applicationId : "";
116
- const searchApiKey = typeof block.searchApiKey === "string" ? block.searchApiKey : "";
117
-
118
- const adminApiKeyEnvName: string =
119
- block.adminApiKey &&
120
- typeof block.adminApiKey === "object" &&
121
- typeof (block.adminApiKey as { name?: unknown }).name === "string"
122
- ? (block.adminApiKey as { name: string }).name
123
- : "";
124
- const adminApiKey = (await resolveSecret(block.adminApiKey, adminApiKeyEnvName)) ?? "";
125
-
126
- configureAlgolia({ applicationId, searchApiKey, adminApiKey });
127
- return true;
128
- }
129
-
130
- // Re-exported for convenience so consumers can `import { SearchClient }
131
- // from "@decocms/apps/algolia/client"` without depending on the npm
132
- // path explicitly.
133
- export type { SearchClient };
@@ -1,22 +0,0 @@
1
- /**
2
- * Returns the configured Algolia SearchClient.
3
- *
4
- * Mirrors `apps/algolia/loaders/client.ts` (deco-cx/apps) so site code
5
- * doing `await invoke.algolia.loaders.client({})` keeps the same call
6
- * shape during the Fresh → TanStack migration. New code in the same
7
- * module can also `import { getAlgoliaClient } from
8
- * "@decocms/apps/algolia/client"` directly, skipping the invoke
9
- * round-trip when on the server.
10
- */
11
-
12
- import { getAlgoliaClient, type SearchClient } from "../client";
13
-
14
- /**
15
- * @title Algolia Search Client
16
- * @description Returns the SDK SearchClient configured at app boot.
17
- */
18
- export default function loader(): SearchClient {
19
- return getAlgoliaClient();
20
- }
21
-
22
- export type { SearchClient };
package/src/types.ts DELETED
@@ -1,44 +0,0 @@
1
- /**
2
- * Shared Algolia types.
3
- *
4
- * Kept as a separate module so consumers can import types without
5
- * pulling in the `algoliasearch` runtime (which is only needed by the
6
- * client/loader code).
7
- */
8
-
9
- /**
10
- * Subset of the storefront-shaped Algolia config the app boots from.
11
- * Mirrors the original `apps/algolia/mod.ts` props shape so existing
12
- * CMS blocks (`{__resolveType: "site/apps/deco/algolia.ts", ...}`)
13
- * keep working byte-for-byte during the migration.
14
- */
15
- export interface AlgoliaConfig {
16
- /**
17
- * Algolia application ID. Find it under
18
- * https://dashboard.algolia.com/account/api-keys/all
19
- */
20
- applicationId: string;
21
- /**
22
- * Search-only API key — safe to ship to the browser. Used by the
23
- * client-side search proxy and SSR loaders that don't need writes.
24
- */
25
- searchApiKey: string;
26
- /**
27
- * Admin API key (NEVER ship to the browser). Used by the SDK
28
- * instance because some operations (indexing, settings) require
29
- * admin scope. Server-side only.
30
- */
31
- adminApiKey: string;
32
- }
33
-
34
- /**
35
- * Canonical Algolia index slugs used by Deco storefronts. Stays here
36
- * so loaders share the same type without each one redeclaring the
37
- * union. Sites with custom index names just pass strings (loaders
38
- * accept `string`, this constant union is for autocomplete).
39
- */
40
- export type Indices =
41
- | "products"
42
- | "products_price_asc"
43
- | "products_price_desc"
44
- | "products_query_suggestions";
package/tsconfig.json DELETED
@@ -1,7 +0,0 @@
1
- {
2
- "extends": "../../tsconfig.base.json",
3
- "compilerOptions": {
4
- "outDir": "dist"
5
- },
6
- "include": ["src/**/*"]
7
- }