@decocms/apps-algolia 7.72.0 → 8.1.0-next.1
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 +10 -24
- package/src/index.ts +83 -9
- package/src/README.md +0 -89
- package/src/__tests__/client.test.ts +0 -195
- package/src/client.ts +0 -133
- package/src/loaders/client.ts +0 -22
- package/src/types.ts +0 -44
- package/tsconfig.json +0 -7
package/package.json
CHANGED
|
@@ -1,45 +1,31 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decocms/apps-algolia",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "8.1.0-next.1",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "
|
|
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",
|
|
9
9
|
"directory": "packages/apps-algolia"
|
|
10
10
|
},
|
|
11
|
+
"files": [
|
|
12
|
+
"src",
|
|
13
|
+
"!src/**/*.test.ts",
|
|
14
|
+
"!src/**/__tests__"
|
|
15
|
+
],
|
|
11
16
|
"main": "./src/index.ts",
|
|
12
17
|
"exports": {
|
|
13
|
-
".": "./src/index.ts"
|
|
14
|
-
"./client": "./src/client.ts",
|
|
15
|
-
"./types": "./src/types.ts",
|
|
16
|
-
"./loaders/*": "./src/loaders/*.ts"
|
|
18
|
+
".": "./src/index.ts"
|
|
17
19
|
},
|
|
18
20
|
"scripts": {
|
|
19
21
|
"build": "tsc",
|
|
20
22
|
"test": "vitest run --root ../.. packages/apps-algolia/",
|
|
21
|
-
"typecheck": "tsc --noEmit"
|
|
22
|
-
"lint:unused": "knip"
|
|
23
|
+
"typecheck": "tsc --noEmit"
|
|
23
24
|
},
|
|
24
25
|
"dependencies": {
|
|
25
|
-
"@decocms/blocks": "
|
|
26
|
-
"@decocms/apps-commerce": "7.72.0"
|
|
27
|
-
},
|
|
28
|
-
"peerDependencies": {
|
|
29
|
-
"react": "^19.0.0",
|
|
30
|
-
"react-dom": "^19.0.0",
|
|
31
|
-
"algoliasearch": "^5"
|
|
32
|
-
},
|
|
33
|
-
"peerDependenciesMeta": {
|
|
34
|
-
"algoliasearch": {
|
|
35
|
-
"optional": true
|
|
36
|
-
}
|
|
26
|
+
"@decocms/blocks": "8.1.0-next.1"
|
|
37
27
|
},
|
|
38
28
|
"devDependencies": {
|
|
39
|
-
"@types/react": "^19.0.0",
|
|
40
|
-
"@types/react-dom": "^19.0.0",
|
|
41
|
-
"algoliasearch": "^5.53.0",
|
|
42
|
-
"knip": "^5.86.0",
|
|
43
29
|
"typescript": "^5.9.0"
|
|
44
30
|
},
|
|
45
31
|
"publishConfig": {
|
package/src/index.ts
CHANGED
|
@@ -1,12 +1,86 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* `@decocms/apps-algolia`: a thin client for the Algolia Search REST API.
|
|
3
|
+
* See /next/upstream-clients.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* import { getAlgoliaClient } from "@decocms/apps/algolia/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 site's job.
|
|
10
9
|
*/
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
import { createInstrumentedFetch } from "@decocms/blocks/fetch";
|
|
11
|
+
|
|
12
|
+
export interface AlgoliaClientConfig {
|
|
13
|
+
applicationId: string;
|
|
14
|
+
/** A search-only key is enough for search; never send an admin key to the browser. */
|
|
15
|
+
apiKey: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** One query: the index plus search parameters as the REST API names them (`query`, `hitsPerPage`, `filters`, ...). */
|
|
19
|
+
export interface AlgoliaSearchRequest {
|
|
20
|
+
indexName: string;
|
|
21
|
+
query?: string;
|
|
22
|
+
[param: string]: unknown;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type AlgoliaHit<T> = T & { objectID: string; [field: string]: unknown };
|
|
26
|
+
|
|
27
|
+
export interface AlgoliaSearchResponse<T = Record<string, unknown>> {
|
|
28
|
+
hits: AlgoliaHit<T>[];
|
|
29
|
+
nbHits: number;
|
|
30
|
+
page: number;
|
|
31
|
+
nbPages: number;
|
|
32
|
+
hitsPerPage: number;
|
|
33
|
+
processingTimeMS: number;
|
|
34
|
+
query: string;
|
|
35
|
+
params: string;
|
|
36
|
+
index?: string;
|
|
37
|
+
queryID?: string;
|
|
38
|
+
facets?: Record<string, Record<string, number>>;
|
|
39
|
+
[field: string]: unknown;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export class AlgoliaError extends Error {
|
|
43
|
+
constructor(
|
|
44
|
+
readonly operation: string,
|
|
45
|
+
readonly status: number,
|
|
46
|
+
) {
|
|
47
|
+
super(`algolia ${operation} failed with HTTP ${status}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function createAlgoliaClient(
|
|
52
|
+
config: AlgoliaClientConfig,
|
|
53
|
+
options: { fetch?: typeof globalThis.fetch } = {},
|
|
54
|
+
) {
|
|
55
|
+
// The id becomes part of the host; reject anything that could redirect the API key elsewhere.
|
|
56
|
+
if (!/^[A-Za-z0-9]+$/.test(config.applicationId))
|
|
57
|
+
throw new Error("algolia: invalid applicationId");
|
|
58
|
+
const request = createInstrumentedFetch({ provider: "algolia", fetch: options.fetch });
|
|
59
|
+
const host = `https://${config.applicationId}-dsn.algolia.net`;
|
|
60
|
+
|
|
61
|
+
async function post<R>(operation: string, path: string, body: unknown): Promise<R> {
|
|
62
|
+
const response = await request(`${host}${path}`, {
|
|
63
|
+
operation,
|
|
64
|
+
method: "POST",
|
|
65
|
+
headers: {
|
|
66
|
+
"content-type": "application/json",
|
|
67
|
+
"x-algolia-application-id": config.applicationId,
|
|
68
|
+
"x-algolia-api-key": config.apiKey,
|
|
69
|
+
},
|
|
70
|
+
body: JSON.stringify(body),
|
|
71
|
+
});
|
|
72
|
+
if (!response.ok) throw new AlgoliaError(operation, response.status);
|
|
73
|
+
return (await response.json()) as R;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
/** Runs several queries, on one or more indices, in one request. */
|
|
78
|
+
search<T = Record<string, unknown>>(
|
|
79
|
+
requests: AlgoliaSearchRequest[],
|
|
80
|
+
): Promise<{ results: AlgoliaSearchResponse<T>[] }> {
|
|
81
|
+
return post("search", "/1/indexes/*/queries", { requests });
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export type AlgoliaClient = ReturnType<typeof createAlgoliaClient>;
|
package/src/README.md
DELETED
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
# Algolia app — initial scaffold
|
|
2
|
-
|
|
3
|
-
This folder ports the Algolia integration from `deco-cx/apps/algolia`
|
|
4
|
-
(Fresh/Deno) to `@decocms/apps/algolia` (TanStack Start/Node), following
|
|
5
|
-
the same shape as `vtex/`, `magento/`, and `shopify/`.
|
|
6
|
-
|
|
7
|
-
## Status
|
|
8
|
-
|
|
9
|
-
**Initial scaffold** — covers the `configureAlgolia`/`getAlgoliaClient`
|
|
10
|
-
surface plus the `loaders/client.ts` shim that matches the upstream
|
|
11
|
-
`apps/algolia/loaders/client.ts` call site (`ctx.invoke.algolia.loaders.client({})`).
|
|
12
|
-
Just enough for downstream sites with their own product loaders to wire
|
|
13
|
-
Algolia and consume the SDK SearchClient directly.
|
|
14
|
-
|
|
15
|
-
A real-world consumer (a production Algolia storefront) is migrating away
|
|
16
|
-
from the legacy `ctx.invoke.algolia.loaders.client({})` proxy that
|
|
17
|
-
existed in the Fresh runtime. The site keeps its own product loaders
|
|
18
|
-
(custom storefront-specific transforms over the upstream toProduct) and only needs
|
|
19
|
-
the SDK client from this package.
|
|
20
|
-
|
|
21
|
-
## What's here
|
|
22
|
-
|
|
23
|
-
- `client.ts` — `configureAlgolia({ applicationId, searchApiKey,
|
|
24
|
-
adminApiKey })` + `getAlgoliaConfig()` accessor + lazy
|
|
25
|
-
`getAlgoliaClient()` cached singleton. Mirrors `configureMagento` /
|
|
26
|
-
`configureVtex`.
|
|
27
|
-
- `types.ts` — `AlgoliaConfig`, canonical `Indices` union.
|
|
28
|
-
- `loaders/client.ts` — returns the configured `SearchClient` so legacy
|
|
29
|
-
call sites (`invoke.algolia.loaders.client({})`) keep working when
|
|
30
|
-
routed through the loader registry.
|
|
31
|
-
- `index.ts` — re-export entry.
|
|
32
|
-
|
|
33
|
-
## Pending port (PR follow-ups)
|
|
34
|
-
|
|
35
|
-
These exist as production code in `deco-cx/apps/algolia/` and need a
|
|
36
|
-
Deno → Node pass (npm specifiers, `commerce/types.ts` shared import,
|
|
37
|
-
etc.). Tracked here so the next PR series has a clear scope:
|
|
38
|
-
|
|
39
|
-
| Path | Original location |
|
|
40
|
-
|---|---|
|
|
41
|
-
| `loaders/product/list.ts` | `deco-cx/apps/algolia/loaders/product/list.ts` |
|
|
42
|
-
| `loaders/product/listingPage.ts` | idem |
|
|
43
|
-
| `loaders/product/suggestions.ts` | idem |
|
|
44
|
-
| `actions/setup.ts` | `deco-cx/apps/algolia/actions/setup.ts` |
|
|
45
|
-
| `actions/index/{product,wait}.ts` | `deco-cx/apps/algolia/actions/index/*` |
|
|
46
|
-
| `utils/{highlight,product}.ts` | `deco-cx/apps/algolia/utils/*` |
|
|
47
|
-
| `workflows/index/product.ts` | `deco-cx/apps/algolia/workflows/index/product.ts` |
|
|
48
|
-
| `sections/Analytics/Algolia.tsx` | `deco-cx/apps/algolia/sections/Analytics/Algolia.tsx` |
|
|
49
|
-
|
|
50
|
-
The site-side `src/packs/algolia/products/*` in a production Algolia storefront
|
|
51
|
-
contains a storefront-specific transform layer that is not portable as-is.
|
|
52
|
-
Once `loaders/product/*` lands here, the upstream tract can be reused;
|
|
53
|
-
the storefront-specific overlays will keep living in the site.
|
|
54
|
-
|
|
55
|
-
## Wiring in a site
|
|
56
|
-
|
|
57
|
-
```ts
|
|
58
|
-
// src/setup.ts
|
|
59
|
-
import { initAlgoliaFromBlocks } from "@decocms/apps/algolia";
|
|
60
|
-
import { blocks } from "./server/cms/blocks.gen";
|
|
61
|
-
|
|
62
|
-
createSiteSetup({
|
|
63
|
-
// ...
|
|
64
|
-
initPlatform: (blocks) => {
|
|
65
|
-
initAlgoliaFromBlocks(blocks); // default block key: "deco-algolia"
|
|
66
|
-
},
|
|
67
|
-
});
|
|
68
|
-
```
|
|
69
|
-
|
|
70
|
-
Then in your loaders:
|
|
71
|
-
|
|
72
|
-
```ts
|
|
73
|
-
import { getAlgoliaClient } from "@decocms/apps/algolia/client";
|
|
74
|
-
|
|
75
|
-
export default async function loader(props, req) {
|
|
76
|
-
const client = getAlgoliaClient();
|
|
77
|
-
const { results } = await client.search([{
|
|
78
|
-
indexName: "products",
|
|
79
|
-
query: props.term,
|
|
80
|
-
params: { hitsPerPage: 12 },
|
|
81
|
-
}]);
|
|
82
|
-
return results[0].hits;
|
|
83
|
-
}
|
|
84
|
-
```
|
|
85
|
-
|
|
86
|
-
The Secret-shaped `adminApiKey` in the CMS block
|
|
87
|
-
(`{__resolveType: "website/loaders/secret.ts", name: "ADMIN_KEY"}`) is
|
|
88
|
-
dereferenced via `process.env.ADMIN_KEY` at init time, matching how
|
|
89
|
-
`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
|
-
});
|
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 };
|
package/src/loaders/client.ts
DELETED
|
@@ -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";
|