Skip to content

feat(javascript): add worker build #4249

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Dec 16, 2024
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
2 changes: 2 additions & 0 deletions clients/algoliasearch-client-javascript/base.tsup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ type PKG = {

const requesters = {
fetch: '@algolia/requester-fetch',
worker: '@algolia/requester-fetch',
http: '@algolia/requester-node-http',
xhr: '@algolia/requester-browser-xhr',
};
Expand Down Expand Up @@ -36,6 +37,7 @@ export function getDependencies(pkg: PKG, requester: Requester): string[] {
case 'xhr':
return deps.filter((dep) => dep !== requesters.fetch && dep !== requesters.http);
case 'fetch':
case 'worker':
return deps.filter((dep) => dep !== requesters.xhr && dep !== requesters.http);
default:
throw new Error('unknown requester', requester);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { expect, test, vi } from 'vitest';

import { LogLevelEnum } from '../../client-common/src/types';
import { createConsoleLogger } from '../../logger-console/src/logger';
import { algoliasearch as node_algoliasearch } from '../builds/node';
import { algoliasearch, apiClientVersion } from '../builds/worker';

test('sets the ua', () => {
const client = algoliasearch('APP_ID', 'API_KEY');
expect(client.transporter.algoliaAgent).toEqual({
add: expect.any(Function),
value: expect.stringContaining(`Algolia for JavaScript (${apiClientVersion}); Search (${apiClientVersion}); Worker`),
});
});

test('forwards node search helpers', () => {
const client = algoliasearch('APP_ID', 'API_KEY');
expect(client.generateSecuredApiKey).not.toBeUndefined();
expect(client.getSecuredApiKeyRemainingValidity).not.toBeUndefined();
expect(async () => {
const resp = await client.generateSecuredApiKey({ parentApiKey: 'foo', restrictions: { validUntil: 200 } });
client.getSecuredApiKeyRemainingValidity({ securedApiKey: resp });
}).not.toThrow();
});

test('web crypto implementation gives the same result as node crypto', async () => {
const client = algoliasearch('APP_ID', 'API_KEY');
const nodeClient = node_algoliasearch('APP_ID', 'API_KEY');
const resp = await client.generateSecuredApiKey({ parentApiKey: 'foo-bar', restrictions: { validUntil: 200 } });
const nodeResp = await nodeClient.generateSecuredApiKey({
parentApiKey: 'foo-bar',
restrictions: { validUntil: 200 },
});

expect(resp).toEqual(nodeResp);
});

test('with logger', () => {
vi.spyOn(console, 'debug');
vi.spyOn(console, 'info');
vi.spyOn(console, 'error');

const client = algoliasearch('APP_ID', 'API_KEY', {
logger: createConsoleLogger(LogLevelEnum.Debug),
});

expect(async () => {
await client.setSettings({ indexName: 'foo', indexSettings: {} });
expect(console.debug).toHaveBeenCalledTimes(1);
expect(console.info).toHaveBeenCalledTimes(1);
expect(console.error).toHaveBeenCalledTimes(1);
}).not.toThrow();
});
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,19 @@ export default defineWorkspace([
},
test: {
include: ['__tests__/algoliasearch.fetch.test.ts'],
name: 'miniflare',
name: 'miniflare fetch',
environment: 'miniflare',
},
},
{
resolve: {
alias: {
'@algolia/client-search': '../../client-search/builds/worker',
},
},
test: {
include: ['__tests__/algoliasearch.worker.test.ts'],
name: 'miniflare worker',
environment: 'miniflare',
},
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ public void processOpts() {
supportingFiles.add(new SupportingFile("client/builds/browser.mustache", "builds", "browser.ts"));
supportingFiles.add(new SupportingFile("client/builds/node.mustache", "builds", "node.ts"));
supportingFiles.add(new SupportingFile("client/builds/fetch.mustache", "builds", "fetch.ts"));
supportingFiles.add(new SupportingFile("client/builds/worker.mustache", "builds", "worker.ts"));
}
// `algoliasearch` related files
else {
Expand All @@ -86,6 +87,7 @@ public void processOpts() {
supportingFiles.add(new SupportingFile("algoliasearch/builds/definition.mustache", "builds", "browser.ts"));
supportingFiles.add(new SupportingFile("algoliasearch/builds/definition.mustache", "builds", "node.ts"));
supportingFiles.add(new SupportingFile("algoliasearch/builds/definition.mustache", "builds", "fetch.ts"));
supportingFiles.add(new SupportingFile("algoliasearch/builds/definition.mustache", "builds", "worker.ts"));
supportingFiles.add(new SupportingFile("algoliasearch/builds/models.mustache", "builds", "models.ts"));

// `lite` builds
Expand Down Expand Up @@ -160,7 +162,7 @@ private void setDefaultGeneratorOptions() {
additionalProperties.put("packageVersion", Helpers.getPackageJsonVersion(packageName));
additionalProperties.put("packageName", packageName);
additionalProperties.put("npmPackageName", isAlgoliasearchClient ? packageName : "@algolia/" + packageName);
additionalProperties.put("nodeSearchHelpers", CLIENT.equals("search") || isAlgoliasearchClient);
additionalProperties.put("searchHelpers", CLIENT.equals("search"));

if (isAlgoliasearchClient) {
var dependencies = new ArrayList<Map<String, Object>>();
Expand Down
29 changes: 1 addition & 28 deletions templates/javascript/clients/client/api/nodeHelpers.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -32,32 +32,5 @@ generateSecuredApiKey: ({
);

const queryParameters = serializeQueryParameters(mergedRestrictions);
return Buffer.from(
createHmac('sha256', parentApiKey)
.update(queryParameters)
.digest('hex') + queryParameters
).toString('base64');
},

/**
* Helper: Retrieves the remaining validity of the previous generated `securedApiKey`, the `ValidUntil` parameter must have been provided.
*
* @summary Helper: Retrieves the remaining validity of the previous generated `secured_api_key`, the `ValidUntil` parameter must have been provided.
* @param getSecuredApiKeyRemainingValidity - The `getSecuredApiKeyRemainingValidity` object.
* @param getSecuredApiKeyRemainingValidity.securedApiKey - The secured API key generated with the `generateSecuredApiKey` method.
*/
getSecuredApiKeyRemainingValidity: ({
securedApiKey,
}: GetSecuredApiKeyRemainingValidityOptions): number => {
const decodedString = Buffer.from(securedApiKey, 'base64').toString(
'ascii'
);
const regex = /validUntil=(\d+)/;
const match = decodedString.match(regex);

if (match === null) {
throw new Error('validUntil not found in given secured api key.');
}

return parseInt(match[1], 10) - Math.round(new Date().getTime() / 1000);
return Buffer.from(createHmac('sha256', parentApiKey).update(queryParameters).digest('hex') + queryParameters,).toString('base64');
},
20 changes: 20 additions & 0 deletions templates/javascript/clients/client/api/searchHelpers.mustache
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* Helper: Retrieves the remaining validity of the previous generated `securedApiKey`, the `ValidUntil` parameter must have been provided.
*
* @summary Helper: Retrieves the remaining validity of the previous generated `secured_api_key`, the `ValidUntil` parameter must have been provided.
* @param getSecuredApiKeyRemainingValidity - The `getSecuredApiKeyRemainingValidity` object.
* @param getSecuredApiKeyRemainingValidity.securedApiKey - The secured API key generated with the `generateSecuredApiKey` method.
*/
getSecuredApiKeyRemainingValidity: ({
securedApiKey,
}: GetSecuredApiKeyRemainingValidityOptions): number => {
const decodedString = atob(securedApiKey);
const regex = /validUntil=(\d+)/;
const match = decodedString.match(regex);

if (match === null) {
throw new Error('validUntil not found in given secured api key.');
}

return parseInt(match[1], 10) - Math.round(new Date().getTime() / 1000);
},
36 changes: 36 additions & 0 deletions templates/javascript/clients/client/api/workerHelpers.mustache
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/**
* Helper: Generates a secured API key based on the given `parentApiKey` and given `restrictions`.
*
* @summary Helper: Generates a secured API key based on the given `parentApiKey` and given `restrictions`.
* @param generateSecuredApiKey - The `generateSecuredApiKey` object.
* @param generateSecuredApiKey.parentApiKey - The base API key from which to generate the new secured one.
* @param generateSecuredApiKey.restrictions - A set of properties defining the restrictions of the secured API key.
*/
generateSecuredApiKey: async ({
parentApiKey,
restrictions = {},
}: GenerateSecuredApiKeyOptions): Promise<string> => {
let mergedRestrictions = restrictions;
if (restrictions.searchParams) {
// merge searchParams with the root restrictions
mergedRestrictions = {
...restrictions,
...restrictions.searchParams,
};

delete mergedRestrictions.searchParams;
}

mergedRestrictions = Object.keys(mergedRestrictions)
.sort()
.reduce(
(acc, key) => {
acc[key] = (mergedRestrictions as any)[key];
return acc;
},
{} as Record<string, unknown>
);

const queryParameters = serializeQueryParameters(mergedRestrictions);
return await generateBase64Hmac(parentApiKey, queryParameters);
},
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,6 @@ export * from '../model';
import type { GenerateSecuredApiKeyOptions, GetSecuredApiKeyRemainingValidityOptions, SearchClientNodeHelpers } from '../model';
{{/isSearchClient}}

{{#nodeSearchHelpers}}
import {createHmac} from 'node:crypto';
{{/nodeSearchHelpers}}

export function {{clientName}}(
appId: string,
apiKey: string,{{#hasRegionalHost}}region{{#fallbackToAliasHost}}?{{/fallbackToAliasHost}}: Region,{{/hasRegionalHost}}
Expand Down
15 changes: 10 additions & 5 deletions templates/javascript/clients/client/builds/fetch.mustache
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
// {{{generationBanner}}}

export type {{#lambda.titlecase}}{{clientName}}{{/lambda.titlecase}} = ReturnType<typeof create{{#lambda.titlecase}}{{clientName}}{{/lambda.titlecase}}>{{#nodeSearchHelpers}} & SearchClientNodeHelpers{{/nodeSearchHelpers}};
export type {{#lambda.titlecase}}{{clientName}}{{/lambda.titlecase}} = ReturnType<typeof create{{#lambda.titlecase}}{{clientName}}{{/lambda.titlecase}}>{{#searchHelpers}} & SearchClientNodeHelpers{{/searchHelpers}};

{{#searchHelpers}}
import { createHmac } from 'node:crypto';
{{/searchHelpers}}

{{> client/builds/definition}}
return {
Expand All @@ -13,15 +17,16 @@ export type {{#lambda.titlecase}}{{clientName}}{{/lambda.titlecase}} = ReturnTyp
write: {{x-timeouts.server.write}},
},
logger: createNullLogger(),
algoliaAgents: [{ segment: 'Fetch' }],
requester: createFetchRequester(),
algoliaAgents: [{ segment: 'Fetch' }],
responsesCache: createNullCache(),
requestsCache: createNullCache(),
hostsCache: createMemoryCache(),
...options,
}),
{{#nodeSearchHelpers}}
{{#searchHelpers}}
{{> client/api/nodeHelpers}}
{{/nodeSearchHelpers}}
{{> client/api/searchHelpers}}
{{/searchHelpers}}
}
}
}
13 changes: 9 additions & 4 deletions templates/javascript/clients/client/builds/node.mustache
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
// {{{generationBanner}}}

export type {{#lambda.titlecase}}{{clientName}}{{/lambda.titlecase}} = ReturnType<typeof create{{#lambda.titlecase}}{{clientName}}{{/lambda.titlecase}}>{{#nodeSearchHelpers}} & SearchClientNodeHelpers{{/nodeSearchHelpers}};
export type {{#lambda.titlecase}}{{clientName}}{{/lambda.titlecase}} = ReturnType<typeof create{{#lambda.titlecase}}{{clientName}}{{/lambda.titlecase}}>{{#searchHelpers}} & SearchClientNodeHelpers{{/searchHelpers}};

{{#searchHelpers}}
import { createHmac } from 'node:crypto';
{{/searchHelpers}}

{{> client/builds/definition}}
return {
Expand All @@ -20,8 +24,9 @@ export type {{#lambda.titlecase}}{{clientName}}{{/lambda.titlecase}} = ReturnTyp
hostsCache: createMemoryCache(),
...options,
}),
{{#nodeSearchHelpers}}
{{#searchHelpers}}
{{> client/api/nodeHelpers}}
{{/nodeSearchHelpers}}
{{> client/api/searchHelpers}}
{{/searchHelpers}}
}
}
}
58 changes: 58 additions & 0 deletions templates/javascript/clients/client/builds/worker.mustache
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// {{{generationBanner}}}

{{#searchHelpers}}
export type SearchClientWorkerHelpers = {
generateSecuredApiKey: (opts: GenerateSecuredApiKeyOptions) => Promise<string>;
getSecuredApiKeyRemainingValidity: (opts: GetSecuredApiKeyRemainingValidityOptions) => number;
}
{{/searchHelpers}}

export type {{#lambda.titlecase}}{{clientName}}{{/lambda.titlecase}} = ReturnType<typeof create{{#lambda.titlecase}}{{clientName}}{{/lambda.titlecase}}>{{#searchHelpers}} & SearchClientWorkerHelpers{{/searchHelpers}};

{{> client/builds/definition}}
return {
...create{{#lambda.titlecase}}{{clientName}}{{/lambda.titlecase}}({
appId,
apiKey,{{#hasRegionalHost}}region,{{/hasRegionalHost}}
timeouts: {
connect: {{x-timeouts.server.connect}},
read: {{x-timeouts.server.read}},
write: {{x-timeouts.server.write}},
},
logger: createNullLogger(),
requester: createFetchRequester(),
algoliaAgents: [{ segment: 'Worker' }],
responsesCache: createNullCache(),
requestsCache: createNullCache(),
hostsCache: createMemoryCache(),
...options,
}),
{{#searchHelpers}}
{{> client/api/workerHelpers}}
{{> client/api/searchHelpers}}
{{/searchHelpers}}
}
}

{{#searchHelpers}}
async function getCryptoKey(secret: string): Promise<CryptoKey> {
Copy link
Collaborator

Choose a reason for hiding this comment

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

no need to import CryptoKey ?

const secretBuf = new TextEncoder().encode(secret);
return await crypto.subtle.importKey('raw', secretBuf, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
}

async function generateHmacHex(cryptoKey: CryptoKey, queryParameters: string): Promise<string> {
const encoder = new TextEncoder();
const queryParametersUint8Array = encoder.encode(queryParameters);
const signature = await crypto.subtle.sign('HMAC', cryptoKey, queryParametersUint8Array);
return Array.from(new Uint8Array(signature))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}

async function generateBase64Hmac(parentApiKey: string, queryParameters: string): Promise<string> {
const crypotKey = await getCryptoKey(parentApiKey);
const hmacHex = await generateHmacHex(crypotKey, queryParameters);
const combined = hmacHex + queryParameters;
return btoa(combined);
}
{{/searchHelpers}}
8 changes: 4 additions & 4 deletions templates/javascript/clients/package.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@
"require": "./dist/builds/node.cjs"
},
"worker": {
"types": "./dist/fetch.d.ts",
"default": "./dist/builds/fetch.js"
"types": "./dist/worker.d.ts",
"default": "./dist/builds/worker.js"
},
"default": {
"types": "./dist/browser.d.ts",
Expand Down Expand Up @@ -75,8 +75,8 @@
"require": "./dist/node.cjs"
},
"worker": {
"types": "./dist/fetch.d.ts",
"default": "./dist/fetch.js"
"types": "./dist/worker.d.ts",
"default": "./dist/worker.js"
},
"default": {
"types": "./dist/browser.d.ts",
Expand Down
17 changes: 17 additions & 0 deletions templates/javascript/clients/tsup.config.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,14 @@ const nodeConfigs: Options[] = [
external: getDependencies(pkg, 'fetch'),
entry: ['builds/fetch.ts', 'src/*.ts'],
},
{
...nodeOptions,
format: 'esm',
name: `worker ${pkg.name} esm`,
dts: { entry: { 'worker': 'builds/worker.ts' } },
external: getDependencies(pkg, 'worker'),
entry: ['builds/worker.ts', 'src/*.ts'],
},
{{/isAlgoliasearchClient}}
{{#isAlgoliasearchClient}}
{
Expand Down Expand Up @@ -61,6 +69,15 @@ const nodeConfigs: Options[] = [
outDir: 'dist',
external: getDependencies(pkg, 'fetch'),
},
{
...nodeOptions,
format: 'esm',
name: 'worker algoliasearch esm',
dts: { entry: { 'worker': 'builds/worker.ts' } },
entry: ['builds/worker.ts'],
outDir: 'dist',
external: getDependencies(pkg, 'worker'),
},
{{/isAlgoliasearchClient}}
];

Expand Down