@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.
@@ -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
+ })();