@nxisai/nxis 2.1.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/.github/workflows/deploy.yml +110 -0
- package/README.md +401 -0
- package/bin/deploy.sh +2 -0
- package/build/api.d.ts +244 -0
- package/build/auth.d.ts +91 -0
- package/build/core.d.ts +56 -0
- package/build/index.cjs.js +423 -0
- package/build/index.d.ts +33 -0
- package/build/index.esm.js +417 -0
- package/build/nxis.js +1 -0
- package/build/server.d.ts +77 -0
- package/docs/ssr.md +606 -0
- package/nxisbucket.json +16 -0
- package/package.json +53 -0
- package/rollup.config.js +50 -0
- package/src/api.ts +281 -0
- package/src/auth.ts +171 -0
- package/src/client.ts +104 -0
- package/src/core.ts +124 -0
- package/src/index.ts +33 -0
- package/src/server.ts +106 -0
- package/tsconfig.json +66 -0
package/rollup.config.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import typescript from "@rollup/plugin-typescript";
|
|
2
|
+
import terser from "@rollup/plugin-terser";
|
|
3
|
+
import resolve from "@rollup/plugin-node-resolve";
|
|
4
|
+
import injectProcessEnv from "rollup-plugin-inject-process-env";
|
|
5
|
+
|
|
6
|
+
// Browser bundle — self-executing IIFE with NXIS_API_HOST baked in
|
|
7
|
+
const browserConfig = {
|
|
8
|
+
input: "src/client.ts",
|
|
9
|
+
output: {
|
|
10
|
+
file: "build/nxis.js",
|
|
11
|
+
format: "es",
|
|
12
|
+
},
|
|
13
|
+
plugins: [
|
|
14
|
+
resolve({
|
|
15
|
+
customResolveOptions: {
|
|
16
|
+
moduleDirectory: "node_modules",
|
|
17
|
+
},
|
|
18
|
+
}),
|
|
19
|
+
typescript({ declaration: false }),
|
|
20
|
+
injectProcessEnv({ NXIS_API_HOST: process.env.NXIS_API_HOST }),
|
|
21
|
+
terser(),
|
|
22
|
+
],
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// SSR bundle — ESM + CJS for Node.js / SSR frameworks
|
|
26
|
+
const ssrConfig = {
|
|
27
|
+
input: "src/index.ts",
|
|
28
|
+
output: [
|
|
29
|
+
{
|
|
30
|
+
file: "build/index.esm.js",
|
|
31
|
+
format: "es",
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
file: "build/index.cjs.js",
|
|
35
|
+
format: "cjs",
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
plugins: [
|
|
39
|
+
typescript({ declaration: false }),
|
|
40
|
+
resolve({
|
|
41
|
+
customResolveOptions: {
|
|
42
|
+
moduleDirectory: "node_modules",
|
|
43
|
+
},
|
|
44
|
+
}),
|
|
45
|
+
],
|
|
46
|
+
// Mark uuid as external for the SSR bundle so consumers use their own copy
|
|
47
|
+
external: ["uuid"],
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export default [browserConfig, ssrConfig];
|
package/src/api.ts
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A URL object representing an HTTP or HTTPS URI.
|
|
3
|
+
*
|
|
4
|
+
* @remarks
|
|
5
|
+
* Used as the canonical type for page URIs throughout the client-side request path.
|
|
6
|
+
*/
|
|
7
|
+
export type HttpUri = URL
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A wrapper around an optional string identifier.
|
|
11
|
+
*
|
|
12
|
+
* @remarks
|
|
13
|
+
* Used to represent entity IDs (organization, site, resource, etc.) that may
|
|
14
|
+
* or may not be present in a given context.
|
|
15
|
+
*/
|
|
16
|
+
export type Uid = {
|
|
17
|
+
/** The string identifier value, or `null` if not set. */
|
|
18
|
+
value: string | null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Makes a type `T` nullable — allowing `null` or `undefined` in addition to `T`.
|
|
23
|
+
*/
|
|
24
|
+
export type Nullable<T> = T | null | undefined;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Configuration injected by the Nxis CDN into the browser script.
|
|
28
|
+
*
|
|
29
|
+
* @remarks
|
|
30
|
+
* The CDN extracts these values from the JWT token's claims and writes them
|
|
31
|
+
* into `window.__NXIS_CONFIG__` before the client script executes.
|
|
32
|
+
*/
|
|
33
|
+
export type NxisConfig = {
|
|
34
|
+
/** The organization identifier. */
|
|
35
|
+
org_id: string
|
|
36
|
+
/** The site identifier within the organization. */
|
|
37
|
+
site_id: string
|
|
38
|
+
/** The subscription identifier used for quota and entitlement checks. */
|
|
39
|
+
sub_id: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Parameters for a client-side structured data request sent to the
|
|
44
|
+
* `/v1/nxis:inject` endpoint.
|
|
45
|
+
*/
|
|
46
|
+
export type StructuredDataRequest = {
|
|
47
|
+
/** The organization that owns the site. */
|
|
48
|
+
organization_id: Nullable<Uid>
|
|
49
|
+
/** The site the page belongs to. */
|
|
50
|
+
site_id: Nullable<Uid>
|
|
51
|
+
/** The full URI of the page being enhanced. */
|
|
52
|
+
uri: HttpUri
|
|
53
|
+
/** The `document.referrer` value, if available. */
|
|
54
|
+
referrer?: string
|
|
55
|
+
/** A client-generated session ID for request correlation. */
|
|
56
|
+
session?: string
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The response body returned by both the `/v1/nxis:inject` and `/v1/nxis:ssr`
|
|
61
|
+
* endpoints on success.
|
|
62
|
+
*
|
|
63
|
+
* @remarks
|
|
64
|
+
* The `data` field corresponds to a `google.protobuf.Struct` in the proto
|
|
65
|
+
* definition, which serializes as a flat JSON object over HTTP transcoding.
|
|
66
|
+
*/
|
|
67
|
+
export type StructuredDataResponse = {
|
|
68
|
+
/** A JSON-LD structured data object ready for injection into the page. */
|
|
69
|
+
data: Record<string, unknown>
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A non-fatal warning indicating no structured data was found for a page.
|
|
74
|
+
*
|
|
75
|
+
* @remarks
|
|
76
|
+
* Thrown when the proxy returns a 200 response with an empty `data` object.
|
|
77
|
+
* Callers can catch this specifically to distinguish "no data" from actual errors.
|
|
78
|
+
*/
|
|
79
|
+
export class NxisWarning extends Error {
|
|
80
|
+
constructor(msg: string) {
|
|
81
|
+
super(msg);
|
|
82
|
+
// Set the prototype explicitly.
|
|
83
|
+
Object.setPrototypeOf(this, NxisWarning.prototype);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Configuration for initializing a {@link NxisClient} instance.
|
|
90
|
+
*
|
|
91
|
+
* @remarks
|
|
92
|
+
* Used to create a client that automatically manages OAuth tokens and
|
|
93
|
+
* fetches structured data from the Nxis AI Proxy.
|
|
94
|
+
*
|
|
95
|
+
* @example
|
|
96
|
+
* ```typescript
|
|
97
|
+
* const nxis = new NxisClient({
|
|
98
|
+
* clientId: process.env.NXIS_CLIENT_ID!,
|
|
99
|
+
* clientSecret: process.env.NXIS_CLIENT_SECRET!,
|
|
100
|
+
* });
|
|
101
|
+
* ```
|
|
102
|
+
*/
|
|
103
|
+
export type NxisClientConfig = {
|
|
104
|
+
/** OAuth client ID (format: `nxis_cid_[a-zA-Z0-9]{16}`). */
|
|
105
|
+
clientId: string;
|
|
106
|
+
/** OAuth client secret (format: `nxis_sec_[a-zA-Z0-9]{32}`). */
|
|
107
|
+
clientSecret: string;
|
|
108
|
+
/**
|
|
109
|
+
* Override for the OAuth token endpoint URL.
|
|
110
|
+
*
|
|
111
|
+
* @defaultValue Falls back to `NXIS_TOKEN_URL` env var, then `"https://authorization.nxis.ai/v1/oauth/token"`.
|
|
112
|
+
*/
|
|
113
|
+
tokenUrl?: string;
|
|
114
|
+
/**
|
|
115
|
+
* OAuth scope to request.
|
|
116
|
+
*
|
|
117
|
+
* @defaultValue `"jsonld:read"`
|
|
118
|
+
*/
|
|
119
|
+
scope?: string;
|
|
120
|
+
/**
|
|
121
|
+
* Override for the Nxis API host URL.
|
|
122
|
+
*
|
|
123
|
+
* @defaultValue Falls back to the `NXIS_API_HOST` environment variable.
|
|
124
|
+
*/
|
|
125
|
+
apiHost?: string;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Request parameters for fetching structured data for a specific page.
|
|
130
|
+
*
|
|
131
|
+
* @remarks
|
|
132
|
+
* Passed to {@link NxisClient.getStructuredData}. Contains only
|
|
133
|
+
* request-specific parameters — credentials and host config are
|
|
134
|
+
* set once at client initialization.
|
|
135
|
+
*
|
|
136
|
+
* @example
|
|
137
|
+
* ```typescript
|
|
138
|
+
* const jsonLd = await nxis.getStructuredData({
|
|
139
|
+
* uri: 'https://example.com/page',
|
|
140
|
+
* userAgent: req.headers['user-agent'],
|
|
141
|
+
* });
|
|
142
|
+
* ```
|
|
143
|
+
*/
|
|
144
|
+
export type NxisSSRRequest = {
|
|
145
|
+
/** The full URI of the page being rendered (e.g. `"https://example.com/about"`). */
|
|
146
|
+
uri: string;
|
|
147
|
+
/** The site identifier to scope the request to a specific site. */
|
|
148
|
+
siteId: string;
|
|
149
|
+
/**
|
|
150
|
+
* Optional User-Agent string forwarded to the proxy for bot detection.
|
|
151
|
+
*
|
|
152
|
+
* @remarks
|
|
153
|
+
* When provided, the proxy can distinguish search-engine crawlers and LLM
|
|
154
|
+
* agents from regular visitors and adjust response behavior accordingly.
|
|
155
|
+
*/
|
|
156
|
+
userAgent?: string;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* gRPC-compatible error codes used by the Nxis AI Proxy.
|
|
161
|
+
*
|
|
162
|
+
* @remarks
|
|
163
|
+
* These codes map directly to gRPC status codes and are included in
|
|
164
|
+
* structured error responses from the proxy. Use {@link errorCodeToHttp}
|
|
165
|
+
* to convert to the corresponding HTTP status code.
|
|
166
|
+
*/
|
|
167
|
+
export enum ErrorCode {
|
|
168
|
+
/** The request completed successfully. */
|
|
169
|
+
OK = 0,
|
|
170
|
+
/** The request contained an invalid argument (e.g. malformed URI). */
|
|
171
|
+
INVALID_ARGUMENT = 3,
|
|
172
|
+
/** The requested resource was not found. */
|
|
173
|
+
NOT_FOUND = 5,
|
|
174
|
+
/** The caller does not have permission to access the requested resource. */
|
|
175
|
+
PERMISSION_DENIED = 7,
|
|
176
|
+
/** The caller has exceeded their rate limit or quota. */
|
|
177
|
+
RESOURCE_EXHAUSTED = 8,
|
|
178
|
+
/** An internal server error occurred. */
|
|
179
|
+
INTERNAL = 13,
|
|
180
|
+
/** The request lacks valid authentication credentials. */
|
|
181
|
+
UNAUTHENTICATED = 16,
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Maps each {@link ErrorCode} to its conventional HTTP status code.
|
|
186
|
+
*
|
|
187
|
+
* @example
|
|
188
|
+
* ```typescript
|
|
189
|
+
* const httpStatus = errorCodeToHttp[ErrorCode.NOT_FOUND]; // 404
|
|
190
|
+
* ```
|
|
191
|
+
*/
|
|
192
|
+
export const errorCodeToHttp: Record<ErrorCode, number> = {
|
|
193
|
+
[ErrorCode.OK]: 200,
|
|
194
|
+
[ErrorCode.INVALID_ARGUMENT]: 400,
|
|
195
|
+
[ErrorCode.NOT_FOUND]: 404,
|
|
196
|
+
[ErrorCode.PERMISSION_DENIED]: 403,
|
|
197
|
+
[ErrorCode.RESOURCE_EXHAUSTED]: 429,
|
|
198
|
+
[ErrorCode.INTERNAL]: 500,
|
|
199
|
+
[ErrorCode.UNAUTHENTICATED]: 401,
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The structured JSON body returned by the proxy on error responses.
|
|
204
|
+
*/
|
|
205
|
+
export interface NxisErrorResponse {
|
|
206
|
+
/** The gRPC-style error code. */
|
|
207
|
+
code: ErrorCode;
|
|
208
|
+
/** A human-readable error message. */
|
|
209
|
+
message: string;
|
|
210
|
+
/** Optional array of protobuf `Any`-style detail messages. */
|
|
211
|
+
details?: Array<{ "@type": string; value: string }>;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* An error thrown when the Nxis AI Proxy returns a non-200 response.
|
|
216
|
+
*
|
|
217
|
+
* @remarks
|
|
218
|
+
* `ProxyError` carries both the gRPC {@link ErrorCode} and the HTTP status
|
|
219
|
+
* code so callers can decide how to surface or retry the failure.
|
|
220
|
+
*
|
|
221
|
+
* @example
|
|
222
|
+
* ```typescript
|
|
223
|
+
* try {
|
|
224
|
+
* const data = await getStructuredData({ token, uri });
|
|
225
|
+
* } catch (err) {
|
|
226
|
+
* if (err instanceof ProxyError && err.isRetryable) {
|
|
227
|
+
* // back off and retry
|
|
228
|
+
* }
|
|
229
|
+
* }
|
|
230
|
+
* ```
|
|
231
|
+
*/
|
|
232
|
+
export class ProxyError extends Error {
|
|
233
|
+
/**
|
|
234
|
+
* @param code - The gRPC-style {@link ErrorCode}.
|
|
235
|
+
* @param httpStatus - The HTTP status code from the proxy response.
|
|
236
|
+
* @param message - A human-readable description of the error.
|
|
237
|
+
*/
|
|
238
|
+
constructor(
|
|
239
|
+
public readonly code: ErrorCode,
|
|
240
|
+
public readonly httpStatus: number,
|
|
241
|
+
message: string,
|
|
242
|
+
) {
|
|
243
|
+
super(message);
|
|
244
|
+
this.name = "ProxyError";
|
|
245
|
+
Object.setPrototypeOf(this, ProxyError.prototype);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Whether this error represents a transient failure that may succeed on retry.
|
|
250
|
+
*
|
|
251
|
+
* @remarks
|
|
252
|
+
* Returns `true` for {@link ErrorCode.RESOURCE_EXHAUSTED} (rate limiting)
|
|
253
|
+
* and {@link ErrorCode.INTERNAL} (server errors).
|
|
254
|
+
*/
|
|
255
|
+
get isRetryable(): boolean {
|
|
256
|
+
return this.code === ErrorCode.RESOURCE_EXHAUSTED || this.code === ErrorCode.INTERNAL;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Whether this error indicates an authentication or authorization failure.
|
|
261
|
+
*
|
|
262
|
+
* @remarks
|
|
263
|
+
* Returns `true` for {@link ErrorCode.UNAUTHENTICATED} (missing/invalid token)
|
|
264
|
+
* and {@link ErrorCode.PERMISSION_DENIED} (valid token, insufficient access).
|
|
265
|
+
*/
|
|
266
|
+
get isAuthError(): boolean {
|
|
267
|
+
return this.code === ErrorCode.UNAUTHENTICATED || this.code === ErrorCode.PERMISSION_DENIED;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Creates a `ProxyError` from an HTTP status code and a parsed
|
|
272
|
+
* {@link NxisErrorResponse} body.
|
|
273
|
+
*
|
|
274
|
+
* @param status - The HTTP status code of the response.
|
|
275
|
+
* @param body - The parsed error response body from the proxy.
|
|
276
|
+
* @returns A new `ProxyError` instance.
|
|
277
|
+
*/
|
|
278
|
+
static fromResponse(status: number, body: NxisErrorResponse): ProxyError {
|
|
279
|
+
return new ProxyError(body.code, status, body.message);
|
|
280
|
+
}
|
|
281
|
+
}
|
package/src/auth.ts
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { ProxyError, ErrorCode } from './api';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Configuration for the OAuth client credentials flow.
|
|
5
|
+
*
|
|
6
|
+
* @internal
|
|
7
|
+
*/
|
|
8
|
+
export interface OAuthConfig {
|
|
9
|
+
/** OAuth client ID (format: `nxis_cid_[a-zA-Z0-9]{16}`). */
|
|
10
|
+
clientId: string;
|
|
11
|
+
/** OAuth client secret (format: `nxis_sec_[a-zA-Z0-9]{32}`). */
|
|
12
|
+
clientSecret: string;
|
|
13
|
+
/**
|
|
14
|
+
* The OAuth token endpoint URL.
|
|
15
|
+
*
|
|
16
|
+
* @defaultValue Falls back to `NXIS_TOKEN_URL` env var, then `"https://authorization.nxis.ai/v1/oauth/token"`.
|
|
17
|
+
*/
|
|
18
|
+
tokenUrl?: string;
|
|
19
|
+
/**
|
|
20
|
+
* OAuth scope to request.
|
|
21
|
+
*
|
|
22
|
+
* @defaultValue `"jsonld:read"`
|
|
23
|
+
*/
|
|
24
|
+
scope?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Response from the OAuth token endpoint (`POST /v1/oauth/token`).
|
|
29
|
+
*
|
|
30
|
+
* @remarks
|
|
31
|
+
* Matches the `IssueTokenResponse` message in the OAuth service proto.
|
|
32
|
+
*/
|
|
33
|
+
export interface IssueTokenResponse {
|
|
34
|
+
/** The issued access token. */
|
|
35
|
+
access_token: string;
|
|
36
|
+
/** Token type — always `"Bearer"`. */
|
|
37
|
+
token_type: string;
|
|
38
|
+
/** Seconds until the token expires. */
|
|
39
|
+
expires_in: number;
|
|
40
|
+
/** The granted scope. */
|
|
41
|
+
scope: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Default token endpoint URL. */
|
|
45
|
+
const DEFAULT_TOKEN_URL = 'https://authorization.nxis.ai/v1/oauth/token';
|
|
46
|
+
|
|
47
|
+
/** Refresh the token when it has this many seconds or fewer remaining. */
|
|
48
|
+
const REFRESH_BUFFER_SECONDS = 60;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Manages OAuth 2.0 access tokens using the client credentials grant.
|
|
52
|
+
*
|
|
53
|
+
* @remarks
|
|
54
|
+
* Tokens are cached in memory and automatically refreshed before expiration.
|
|
55
|
+
* Concurrent calls during a refresh all await the same in-flight request,
|
|
56
|
+
* preventing redundant token fetches.
|
|
57
|
+
*
|
|
58
|
+
* @example
|
|
59
|
+
* ```typescript
|
|
60
|
+
* const manager = new OAuthTokenManager({
|
|
61
|
+
* clientId: process.env.NXIS_CLIENT_ID!,
|
|
62
|
+
* clientSecret: process.env.NXIS_CLIENT_SECRET!,
|
|
63
|
+
* });
|
|
64
|
+
*
|
|
65
|
+
* const token = await manager.getAccessToken();
|
|
66
|
+
* // Use token in Authorization: Bearer <token> header
|
|
67
|
+
* ```
|
|
68
|
+
*/
|
|
69
|
+
export class OAuthTokenManager {
|
|
70
|
+
private readonly clientId: string;
|
|
71
|
+
private readonly clientSecret: string;
|
|
72
|
+
private readonly tokenUrl: string;
|
|
73
|
+
private readonly scope: string;
|
|
74
|
+
|
|
75
|
+
/** The currently cached access token, or `null` if none. */
|
|
76
|
+
private cachedToken: string | null = null;
|
|
77
|
+
|
|
78
|
+
/** Unix timestamp (ms) at which the cached token expires. */
|
|
79
|
+
private expiresAt: number = 0;
|
|
80
|
+
|
|
81
|
+
/** In-flight token request for concurrency deduplication. */
|
|
82
|
+
private pendingRequest: Promise<string> | null = null;
|
|
83
|
+
|
|
84
|
+
constructor(config: OAuthConfig) {
|
|
85
|
+
this.clientId = config.clientId;
|
|
86
|
+
this.clientSecret = config.clientSecret;
|
|
87
|
+
this.tokenUrl = config.tokenUrl
|
|
88
|
+
?? process.env.NXIS_TOKEN_URL
|
|
89
|
+
?? DEFAULT_TOKEN_URL;
|
|
90
|
+
this.scope = config.scope ?? 'jsonld:read';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Returns a valid access token, fetching or refreshing as needed.
|
|
95
|
+
*
|
|
96
|
+
* @remarks
|
|
97
|
+
* If the cached token has more than {@link REFRESH_BUFFER_SECONDS} seconds
|
|
98
|
+
* remaining, it is returned immediately. Otherwise a new token is requested
|
|
99
|
+
* from the OAuth endpoint.
|
|
100
|
+
*
|
|
101
|
+
* @returns A valid OAuth Bearer access token.
|
|
102
|
+
*
|
|
103
|
+
* @throws {@link ProxyError}
|
|
104
|
+
* Thrown if the token endpoint returns an error response.
|
|
105
|
+
*/
|
|
106
|
+
async getAccessToken(): Promise<string> {
|
|
107
|
+
if (this.cachedToken && Date.now() < this.expiresAt) {
|
|
108
|
+
return this.cachedToken;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Deduplicate concurrent refresh requests
|
|
112
|
+
if (this.pendingRequest) {
|
|
113
|
+
return this.pendingRequest;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
this.pendingRequest = this.fetchToken();
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
const token = await this.pendingRequest;
|
|
120
|
+
return token;
|
|
121
|
+
} finally {
|
|
122
|
+
this.pendingRequest = null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Fetches a new access token from the OAuth endpoint.
|
|
128
|
+
*
|
|
129
|
+
* @internal
|
|
130
|
+
*/
|
|
131
|
+
private async fetchToken(): Promise<string> {
|
|
132
|
+
const body = new URLSearchParams({
|
|
133
|
+
grant_type: 'client_credentials',
|
|
134
|
+
client_id: this.clientId,
|
|
135
|
+
client_secret: this.clientSecret,
|
|
136
|
+
scope: this.scope,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const response = await fetch(this.tokenUrl, {
|
|
140
|
+
method: 'POST',
|
|
141
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
142
|
+
body: body.toString(),
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
if (!response.ok) {
|
|
146
|
+
const statusToCode: Record<number, ErrorCode> = {
|
|
147
|
+
400: ErrorCode.INVALID_ARGUMENT,
|
|
148
|
+
401: ErrorCode.UNAUTHENTICATED,
|
|
149
|
+
403: ErrorCode.PERMISSION_DENIED,
|
|
150
|
+
429: ErrorCode.RESOURCE_EXHAUSTED,
|
|
151
|
+
};
|
|
152
|
+
const code = statusToCode[response.status] ?? ErrorCode.INTERNAL;
|
|
153
|
+
let message: string;
|
|
154
|
+
try {
|
|
155
|
+
const errorBody = await response.json();
|
|
156
|
+
message = errorBody.message ?? errorBody.error_description ?? `Token request failed with status ${response.status}`;
|
|
157
|
+
} catch {
|
|
158
|
+
message = `Token request failed with status ${response.status}`;
|
|
159
|
+
}
|
|
160
|
+
throw new ProxyError(code, response.status, message);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const data: IssueTokenResponse = await response.json();
|
|
164
|
+
|
|
165
|
+
this.cachedToken = data.access_token;
|
|
166
|
+
// Subtract buffer so we refresh before actual expiry
|
|
167
|
+
this.expiresAt = Date.now() + (data.expires_in - REFRESH_BUFFER_SECONDS) * 1000;
|
|
168
|
+
|
|
169
|
+
return this.cachedToken;
|
|
170
|
+
}
|
|
171
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { NxisWarning, NxisConfig, StructuredDataResponse } from './api';
|
|
2
|
+
import { buildParams, responseHandler } from './core';
|
|
3
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
4
|
+
|
|
5
|
+
declare global {
|
|
6
|
+
interface Window {
|
|
7
|
+
/** A unique session ID generated per page load for request correlation. */
|
|
8
|
+
NXIS_SESSION_ID: string;
|
|
9
|
+
/**
|
|
10
|
+
* Configuration injected by the Nxis CDN into the served script.
|
|
11
|
+
* Contains organization, site, and subscription identifiers extracted
|
|
12
|
+
* from the JWT token's claims.
|
|
13
|
+
*/
|
|
14
|
+
__NXIS_CONFIG__: NxisConfig;
|
|
15
|
+
ga: UniversalAnalytics.ga;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Injects JSON-LD structured data into the document `<head>`.
|
|
21
|
+
*
|
|
22
|
+
* @remarks
|
|
23
|
+
* Creates a `<script type="application/ld+json">` element containing the
|
|
24
|
+
* serialized JSON-LD array, wrapped with HTML comments for easy identification
|
|
25
|
+
* in the DOM (`<!-- Nxis.ai -->` … `<!-- End Nxis.ai -->`).
|
|
26
|
+
*
|
|
27
|
+
* Does nothing if the response data array is empty.
|
|
28
|
+
*
|
|
29
|
+
* @param response - The structured data response from the proxy.
|
|
30
|
+
*
|
|
31
|
+
* @internal
|
|
32
|
+
*/
|
|
33
|
+
function inject(response: StructuredDataResponse): void {
|
|
34
|
+
if (response.data && Object.keys(response.data).length > 0) {
|
|
35
|
+
const start = document.createComment("Nxis.ai");
|
|
36
|
+
const end = document.createComment("End Nxis.ai");
|
|
37
|
+
const script: Element = document.createElement('script');
|
|
38
|
+
script.setAttribute('type', 'application/ld+json');
|
|
39
|
+
script.textContent = JSON.stringify(response.data);
|
|
40
|
+
document.head.appendChild(script);
|
|
41
|
+
script.before(start);
|
|
42
|
+
script.after(end);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Handles errors from the client-side fetch pipeline.
|
|
48
|
+
*
|
|
49
|
+
* @remarks
|
|
50
|
+
* - {@link NxisWarning} instances are logged as `console.warn` — these
|
|
51
|
+
* indicate that no data was found for the current page, which is non-fatal.
|
|
52
|
+
* - All other errors are logged as `console.error`.
|
|
53
|
+
*
|
|
54
|
+
* @param e - The error caught from the fetch/response pipeline.
|
|
55
|
+
*
|
|
56
|
+
* @internal
|
|
57
|
+
*/
|
|
58
|
+
function errorHandler(e: Error): void {
|
|
59
|
+
if (e instanceof NxisWarning) {
|
|
60
|
+
console.warn(JSON.parse(e.message).message);
|
|
61
|
+
} else {
|
|
62
|
+
console.error(`Unable to retrieve structured data for ${window.location.href}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Client-side IIFE — auto-executes on script load.
|
|
68
|
+
*
|
|
69
|
+
* @remarks
|
|
70
|
+
* This is the main entry point for the browser bundle (`build/nxis.js`).
|
|
71
|
+
* It performs the following steps:
|
|
72
|
+
*
|
|
73
|
+
* 1. Generates a unique session ID and stores it on `window.NXIS_SESSION_ID`.
|
|
74
|
+
* 2. Reads the injected {@link NxisConfig} from `window.__NXIS_CONFIG__`.
|
|
75
|
+
* 3. Builds the request parameters from the current page URL, referrer,
|
|
76
|
+
* and session context.
|
|
77
|
+
* 4. Calls the `/v1/nxis:inject` proxy endpoint.
|
|
78
|
+
* 5. On success, injects the returned JSON-LD into the document `<head>`.
|
|
79
|
+
* 6. On failure, logs a warning or error to the console.
|
|
80
|
+
*/
|
|
81
|
+
(function () {
|
|
82
|
+
window.NXIS_SESSION_ID = uuidv4();
|
|
83
|
+
const API = process.env.NXIS_API_HOST;
|
|
84
|
+
const config: NxisConfig = window.__NXIS_CONFIG__;
|
|
85
|
+
|
|
86
|
+
const headers: Headers = new Headers();
|
|
87
|
+
const NXIS: URL = new URL(`${API}/v1/nxis:inject`);
|
|
88
|
+
|
|
89
|
+
NXIS.search = buildParams({
|
|
90
|
+
organization_id: { value: config?.org_id },
|
|
91
|
+
site_id: { value: config?.site_id },
|
|
92
|
+
uri: new URL(window.location.href),
|
|
93
|
+
referrer: document.referrer,
|
|
94
|
+
session: window.NXIS_SESSION_ID
|
|
95
|
+
}).toString();
|
|
96
|
+
if (config?.sub_id) headers.set("X-NXIS-SUBSCRIPTION", config.sub_id);
|
|
97
|
+
headers.set("Content-Type", "application/json");
|
|
98
|
+
headers.set("User-Agent", navigator.userAgent);
|
|
99
|
+
|
|
100
|
+
fetch(`${NXIS}`, { headers })
|
|
101
|
+
.then((res) => responseHandler(res, window.location.href))
|
|
102
|
+
.then(inject)
|
|
103
|
+
.catch(errorHandler);
|
|
104
|
+
})();
|