@oxy.so/core 1.0.1 → 1.1.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.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/OxyServices.errors.js +28 -1
- package/dist/cjs/i18n/locales/en-US.json +14 -1
- package/dist/cjs/i18n/locales/es-ES.json +14 -1
- package/dist/cjs/i18n/locales/locales/en-US.json +14 -1
- package/dist/cjs/i18n/locales/locales/es-ES.json +14 -1
- package/dist/cjs/mixins/OxyServices.agency.js +113 -0
- package/dist/cjs/mixins/OxyServices.assets.js +90 -0
- package/dist/cjs/mixins/OxyServices.connectedApps.js +20 -3
- package/dist/cjs/mixins/OxyServices.utility.js +1 -21
- package/dist/cjs/mixins/index.js +3 -4
- package/dist/cjs/server/index.js +6 -1
- package/dist/cjs/server/traffic.js +107 -0
- package/dist/cjs/server/trafficNodeHttp.js +86 -0
- package/dist/cjs/utils/cache.js +28 -19
- package/dist/cjs/utils/textNormalization.js +1 -1
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/OxyServices.errors.js +26 -0
- package/dist/esm/i18n/locales/en-US.json +14 -1
- package/dist/esm/i18n/locales/es-ES.json +14 -1
- package/dist/esm/i18n/locales/locales/en-US.json +14 -1
- package/dist/esm/i18n/locales/locales/es-ES.json +14 -1
- package/dist/esm/mixins/OxyServices.agency.js +110 -0
- package/dist/esm/mixins/OxyServices.assets.js +91 -1
- package/dist/esm/mixins/OxyServices.connectedApps.js +20 -3
- package/dist/esm/mixins/OxyServices.utility.js +1 -21
- package/dist/esm/mixins/index.js +3 -4
- package/dist/esm/server/index.js +2 -0
- package/dist/esm/server/traffic.js +104 -0
- package/dist/esm/server/trafficNodeHttp.js +79 -0
- package/dist/esm/utils/cache.js +28 -19
- package/dist/esm/utils/textNormalization.js +1 -1
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/OxyServices.errors.d.ts +24 -0
- package/dist/types/index.d.ts +2 -1
- package/dist/types/mixins/OxyServices.agency.d.ts +135 -0
- package/dist/types/mixins/OxyServices.assets.d.ts +43 -1
- package/dist/types/mixins/OxyServices.connectedApps.d.ts +15 -0
- package/dist/types/mixins/OxyServices.utility.d.ts +0 -9
- package/dist/types/mixins/index.d.ts +2 -2
- package/dist/types/models/interfaces.d.ts +26 -0
- package/dist/types/server/index.d.ts +3 -0
- package/dist/types/server/traffic.d.ts +27 -0
- package/dist/types/server/trafficNodeHttp.d.ts +6 -0
- package/dist/types/utils/cache.d.ts +1 -4
- package/dist/types/utils/textNormalization.d.ts +1 -1
- package/package.json +4 -4
- package/src/OxyServices.errors.ts +36 -0
- package/src/i18n/locales/en-US.json +14 -1
- package/src/i18n/locales/es-ES.json +14 -1
- package/src/index.ts +15 -0
- package/src/mixins/OxyServices.agency.ts +277 -0
- package/src/mixins/OxyServices.assets.ts +105 -2
- package/src/mixins/OxyServices.connectedApps.ts +42 -4
- package/src/mixins/OxyServices.utility.ts +1 -26
- package/src/mixins/__tests__/OxyServices.serviceLinkedDownloadUrl.test.ts +139 -0
- package/src/mixins/__tests__/agency.test.ts +120 -0
- package/src/mixins/__tests__/connectedApps.test.ts +23 -0
- package/src/mixins/index.ts +4 -5
- package/src/models/interfaces.ts +27 -0
- package/src/server/__tests__/traffic.test.ts +49 -0
- package/src/server/__tests__/trafficNodeHttp.test.ts +38 -0
- package/src/server/index.ts +5 -0
- package/src/server/traffic.ts +116 -0
- package/src/server/trafficNodeHttp.ts +69 -0
- package/src/utils/__tests__/cacheLifecycle.test.ts +65 -0
- package/src/utils/cache.ts +22 -20
- package/src/utils/textNormalization.ts +1 -1
- package/dist/cjs/mixins/OxyServices.links.js +0 -72
- package/dist/esm/mixins/OxyServices.links.js +0 -69
- package/dist/types/mixins/OxyServices.links.d.ts +0 -104
- package/src/mixins/OxyServices.links.ts +0 -107
- package/src/mixins/__tests__/OxyServices.links.test.ts +0 -154
|
@@ -90,7 +90,7 @@ const LEADING_HORIZONTAL_WHITESPACE = /\n +/g;
|
|
|
90
90
|
const EXCESS_BLANK_LINES = /\n{3,}/g;
|
|
91
91
|
|
|
92
92
|
/**
|
|
93
|
-
* Normalize a SINGLE-LINE text value
|
|
93
|
+
* Normalize a SINGLE-LINE text value such as a page title or `siteName`,
|
|
94
94
|
* display names, image alt text, handles, profile field labels.
|
|
95
95
|
*
|
|
96
96
|
* A line break in such a value is never meaningful — it is an artifact of the
|
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.OxyServicesLinksMixin = OxyServicesLinksMixin;
|
|
4
|
-
const apiUtils_1 = require("../utils/apiUtils");
|
|
5
|
-
/**
|
|
6
|
-
* Maximum number of URLs sent per `POST /links/previews` request. Matches the
|
|
7
|
-
* server-side batch cap (`linkPreviewBatchRequestSchema`'s `.max(50)`); larger
|
|
8
|
-
* inputs are split into multiple chunked calls and the result maps merged,
|
|
9
|
-
* mirroring how `getUsersByIds` chunks at 100.
|
|
10
|
-
*/
|
|
11
|
-
const LINK_PREVIEWS_CHUNK_SIZE = 50;
|
|
12
|
-
function OxyServicesLinksMixin(Base) {
|
|
13
|
-
return class extends Base {
|
|
14
|
-
constructor(...args) {
|
|
15
|
-
super(...args);
|
|
16
|
-
}
|
|
17
|
-
/**
|
|
18
|
-
* Resolve a single link preview via `GET /links/preview?url=<encoded>&wait=0|1`.
|
|
19
|
-
*
|
|
20
|
-
* @param url - The URL to unfurl. Sent percent-encoded in the query string.
|
|
21
|
-
* @param opts.wait - When `true`, asks the server to resolve synchronously
|
|
22
|
-
* (`wait=1`) instead of returning a `'pending'` placeholder for a
|
|
23
|
-
* first-seen URL. Defaults to `false` (`wait=0`).
|
|
24
|
-
*
|
|
25
|
-
* Not cached at the SDK layer: a `'pending'` result can become `'resolved'`
|
|
26
|
-
* on a later read, so caching here would serve the stale placeholder.
|
|
27
|
-
*/
|
|
28
|
-
async getLinkPreview(url, opts) {
|
|
29
|
-
try {
|
|
30
|
-
const path = (0, apiUtils_1.buildUrl)('/links/preview', { url, wait: opts?.wait ? 1 : 0 });
|
|
31
|
-
return await this.makeRequest('GET', path, undefined, { cache: false });
|
|
32
|
-
}
|
|
33
|
-
catch (error) {
|
|
34
|
-
throw this.handleError(error);
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
/**
|
|
38
|
-
* Resolve multiple link previews via `POST /links/previews` (body `{ urls }`).
|
|
39
|
-
*
|
|
40
|
-
* Inputs are de-duplicated and split into chunks of {@link LINK_PREVIEWS_CHUNK_SIZE}
|
|
41
|
-
* (the server-side cap). Chunks run concurrently and their result maps are
|
|
42
|
-
* merged into a single result keyed by the REQUESTED url (the exact string
|
|
43
|
-
* passed in `urls`) — matching the batch contract — so a caller can always
|
|
44
|
-
* look its own input back up.
|
|
45
|
-
*
|
|
46
|
-
* `makeRequest` unwraps the API's top-level `{ data }` envelope (same as
|
|
47
|
-
* `getUsersByIds`'s `makeServiceRequest<User[]>`), so each chunk response is
|
|
48
|
-
* already the `Record<url, LinkPreview>` map — merge it directly.
|
|
49
|
-
*
|
|
50
|
-
* An empty / whitespace-only input resolves immediately with `{}` and
|
|
51
|
-
* performs no network call. A failure in any chunk surfaces (via
|
|
52
|
-
* `handleError`) rather than being swallowed.
|
|
53
|
-
*/
|
|
54
|
-
async getLinkPreviews(urls) {
|
|
55
|
-
const uniqueUrls = Array.from(new Set(urls.filter((u) => typeof u === 'string' && u.trim().length > 0)));
|
|
56
|
-
if (uniqueUrls.length === 0) {
|
|
57
|
-
return {};
|
|
58
|
-
}
|
|
59
|
-
const chunks = [];
|
|
60
|
-
for (let i = 0; i < uniqueUrls.length; i += LINK_PREVIEWS_CHUNK_SIZE) {
|
|
61
|
-
chunks.push(uniqueUrls.slice(i, i + LINK_PREVIEWS_CHUNK_SIZE));
|
|
62
|
-
}
|
|
63
|
-
try {
|
|
64
|
-
const responses = await Promise.all(chunks.map((chunk) => this.makeRequest('POST', '/links/previews', { urls: chunk }, { cache: false })));
|
|
65
|
-
return responses.reduce((merged, response) => Object.assign(merged, response ?? {}), {});
|
|
66
|
-
}
|
|
67
|
-
catch (error) {
|
|
68
|
-
throw this.handleError(error);
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
};
|
|
72
|
-
}
|
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
import { buildUrl } from '../utils/apiUtils.js';
|
|
2
|
-
/**
|
|
3
|
-
* Maximum number of URLs sent per `POST /links/previews` request. Matches the
|
|
4
|
-
* server-side batch cap (`linkPreviewBatchRequestSchema`'s `.max(50)`); larger
|
|
5
|
-
* inputs are split into multiple chunked calls and the result maps merged,
|
|
6
|
-
* mirroring how `getUsersByIds` chunks at 100.
|
|
7
|
-
*/
|
|
8
|
-
const LINK_PREVIEWS_CHUNK_SIZE = 50;
|
|
9
|
-
export function OxyServicesLinksMixin(Base) {
|
|
10
|
-
return class extends Base {
|
|
11
|
-
constructor(...args) {
|
|
12
|
-
super(...args);
|
|
13
|
-
}
|
|
14
|
-
/**
|
|
15
|
-
* Resolve a single link preview via `GET /links/preview?url=<encoded>&wait=0|1`.
|
|
16
|
-
*
|
|
17
|
-
* @param url - The URL to unfurl. Sent percent-encoded in the query string.
|
|
18
|
-
* @param opts.wait - When `true`, asks the server to resolve synchronously
|
|
19
|
-
* (`wait=1`) instead of returning a `'pending'` placeholder for a
|
|
20
|
-
* first-seen URL. Defaults to `false` (`wait=0`).
|
|
21
|
-
*
|
|
22
|
-
* Not cached at the SDK layer: a `'pending'` result can become `'resolved'`
|
|
23
|
-
* on a later read, so caching here would serve the stale placeholder.
|
|
24
|
-
*/
|
|
25
|
-
async getLinkPreview(url, opts) {
|
|
26
|
-
try {
|
|
27
|
-
const path = buildUrl('/links/preview', { url, wait: opts?.wait ? 1 : 0 });
|
|
28
|
-
return await this.makeRequest('GET', path, undefined, { cache: false });
|
|
29
|
-
}
|
|
30
|
-
catch (error) {
|
|
31
|
-
throw this.handleError(error);
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
/**
|
|
35
|
-
* Resolve multiple link previews via `POST /links/previews` (body `{ urls }`).
|
|
36
|
-
*
|
|
37
|
-
* Inputs are de-duplicated and split into chunks of {@link LINK_PREVIEWS_CHUNK_SIZE}
|
|
38
|
-
* (the server-side cap). Chunks run concurrently and their result maps are
|
|
39
|
-
* merged into a single result keyed by the REQUESTED url (the exact string
|
|
40
|
-
* passed in `urls`) — matching the batch contract — so a caller can always
|
|
41
|
-
* look its own input back up.
|
|
42
|
-
*
|
|
43
|
-
* `makeRequest` unwraps the API's top-level `{ data }` envelope (same as
|
|
44
|
-
* `getUsersByIds`'s `makeServiceRequest<User[]>`), so each chunk response is
|
|
45
|
-
* already the `Record<url, LinkPreview>` map — merge it directly.
|
|
46
|
-
*
|
|
47
|
-
* An empty / whitespace-only input resolves immediately with `{}` and
|
|
48
|
-
* performs no network call. A failure in any chunk surfaces (via
|
|
49
|
-
* `handleError`) rather than being swallowed.
|
|
50
|
-
*/
|
|
51
|
-
async getLinkPreviews(urls) {
|
|
52
|
-
const uniqueUrls = Array.from(new Set(urls.filter((u) => typeof u === 'string' && u.trim().length > 0)));
|
|
53
|
-
if (uniqueUrls.length === 0) {
|
|
54
|
-
return {};
|
|
55
|
-
}
|
|
56
|
-
const chunks = [];
|
|
57
|
-
for (let i = 0; i < uniqueUrls.length; i += LINK_PREVIEWS_CHUNK_SIZE) {
|
|
58
|
-
chunks.push(uniqueUrls.slice(i, i + LINK_PREVIEWS_CHUNK_SIZE));
|
|
59
|
-
}
|
|
60
|
-
try {
|
|
61
|
-
const responses = await Promise.all(chunks.map((chunk) => this.makeRequest('POST', '/links/previews', { urls: chunk }, { cache: false })));
|
|
62
|
-
return responses.reduce((merged, response) => Object.assign(merged, response ?? {}), {});
|
|
63
|
-
}
|
|
64
|
-
catch (error) {
|
|
65
|
-
throw this.handleError(error);
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
};
|
|
69
|
-
}
|
|
@@ -1,104 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Link Preview (unfurl) Mixin
|
|
3
|
-
*
|
|
4
|
-
* Resolves link previews ("unfurls") through the Oxy API so every Oxy app
|
|
5
|
-
* stops scraping link metadata locally. The API owns resolution and re-hosts
|
|
6
|
-
* the preview `image`/`favicon` on Oxy media (`cloud.oxy.so/<fileId>`), so
|
|
7
|
-
* consumers render the returned URLs directly with no per-app proxy.
|
|
8
|
-
*
|
|
9
|
-
* Wire shapes (`LinkPreview`, `LinkPreviewBatchResponse`) are the single source
|
|
10
|
-
* of truth in `@oxy.so/contracts`; this mixin imports them rather than
|
|
11
|
-
* redefining them so producer (oxy-api) and consumers cannot drift.
|
|
12
|
-
*
|
|
13
|
-
* Caching note: these GET/POST reads are NOT cached at the SDK layer (`cache:
|
|
14
|
-
* false`). A preview can be returned `'pending'` first and `'resolved'` on a
|
|
15
|
-
* later read, so an SDK GET cache would pin the stale `'pending'` snapshot.
|
|
16
|
-
* App-side caching (React Query / stores) owns this responsibility.
|
|
17
|
-
*/
|
|
18
|
-
import type { LinkPreview } from '@oxy.so/contracts';
|
|
19
|
-
import type { OxyServicesBase } from '../OxyServices.base';
|
|
20
|
-
export declare function OxyServicesLinksMixin<T extends typeof OxyServicesBase>(Base: T): {
|
|
21
|
-
new (...args: any[]): {
|
|
22
|
-
/**
|
|
23
|
-
* Resolve a single link preview via `GET /links/preview?url=<encoded>&wait=0|1`.
|
|
24
|
-
*
|
|
25
|
-
* @param url - The URL to unfurl. Sent percent-encoded in the query string.
|
|
26
|
-
* @param opts.wait - When `true`, asks the server to resolve synchronously
|
|
27
|
-
* (`wait=1`) instead of returning a `'pending'` placeholder for a
|
|
28
|
-
* first-seen URL. Defaults to `false` (`wait=0`).
|
|
29
|
-
*
|
|
30
|
-
* Not cached at the SDK layer: a `'pending'` result can become `'resolved'`
|
|
31
|
-
* on a later read, so caching here would serve the stale placeholder.
|
|
32
|
-
*/
|
|
33
|
-
getLinkPreview(url: string, opts?: {
|
|
34
|
-
wait?: boolean;
|
|
35
|
-
}): Promise<LinkPreview>;
|
|
36
|
-
/**
|
|
37
|
-
* Resolve multiple link previews via `POST /links/previews` (body `{ urls }`).
|
|
38
|
-
*
|
|
39
|
-
* Inputs are de-duplicated and split into chunks of {@link LINK_PREVIEWS_CHUNK_SIZE}
|
|
40
|
-
* (the server-side cap). Chunks run concurrently and their result maps are
|
|
41
|
-
* merged into a single result keyed by the REQUESTED url (the exact string
|
|
42
|
-
* passed in `urls`) — matching the batch contract — so a caller can always
|
|
43
|
-
* look its own input back up.
|
|
44
|
-
*
|
|
45
|
-
* `makeRequest` unwraps the API's top-level `{ data }` envelope (same as
|
|
46
|
-
* `getUsersByIds`'s `makeServiceRequest<User[]>`), so each chunk response is
|
|
47
|
-
* already the `Record<url, LinkPreview>` map — merge it directly.
|
|
48
|
-
*
|
|
49
|
-
* An empty / whitespace-only input resolves immediately with `{}` and
|
|
50
|
-
* performs no network call. A failure in any chunk surfaces (via
|
|
51
|
-
* `handleError`) rather than being swallowed.
|
|
52
|
-
*/
|
|
53
|
-
getLinkPreviews(urls: string[]): Promise<Record<string, LinkPreview>>;
|
|
54
|
-
httpService: import("../HttpService").HttpService;
|
|
55
|
-
cloudURL: string;
|
|
56
|
-
config: import("../OxyServices.base").OxyConfig;
|
|
57
|
-
__resetTokensForTests(): void;
|
|
58
|
-
makeRequest<T_1>(method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE", url: string, data?: any, options?: import("../HttpService").RequestOptions): Promise<T_1>;
|
|
59
|
-
getBaseURL(): string;
|
|
60
|
-
getClient(): import("../HttpService").HttpService;
|
|
61
|
-
createLinkedClient(config: import("../OxyServices.base").OxyConfig): import("..").LinkedHttpClient;
|
|
62
|
-
getMetrics(): {
|
|
63
|
-
totalRequests: number;
|
|
64
|
-
successfulRequests: number;
|
|
65
|
-
failedRequests: number;
|
|
66
|
-
cacheHits: number;
|
|
67
|
-
cacheMisses: number;
|
|
68
|
-
averageResponseTime: number;
|
|
69
|
-
};
|
|
70
|
-
clearCache(): void;
|
|
71
|
-
clearCacheEntry(key: string): void;
|
|
72
|
-
clearCacheByPrefix(prefix: string): number;
|
|
73
|
-
getCacheStats(): {
|
|
74
|
-
size: number;
|
|
75
|
-
hits: number;
|
|
76
|
-
misses: number;
|
|
77
|
-
hitRate: number;
|
|
78
|
-
};
|
|
79
|
-
getCloudURL(): string;
|
|
80
|
-
setTokens(accessToken: string): void;
|
|
81
|
-
clearTokens(): void;
|
|
82
|
-
onTokensChanged(listener: (accessToken: string | null) => void): () => void;
|
|
83
|
-
_cachedUserId: string | null | undefined;
|
|
84
|
-
_cachedAccessToken: string | null;
|
|
85
|
-
getCurrentUserId(): string | null;
|
|
86
|
-
hasValidToken(): boolean;
|
|
87
|
-
getAccessToken(): string | null;
|
|
88
|
-
getAccessTokenExpiry(): number | null;
|
|
89
|
-
waitForAuth(timeoutMs?: number): Promise<boolean>;
|
|
90
|
-
withAuthRetry<T_1>(operation: () => Promise<T_1>, operationName: string, options?: {
|
|
91
|
-
maxRetries?: number;
|
|
92
|
-
retryDelay?: number;
|
|
93
|
-
authTimeoutMs?: number;
|
|
94
|
-
}): Promise<T_1>;
|
|
95
|
-
validate(): Promise<boolean>;
|
|
96
|
-
handleError(error: unknown): Error;
|
|
97
|
-
healthCheck(): Promise<{
|
|
98
|
-
status: string;
|
|
99
|
-
users?: number;
|
|
100
|
-
timestamp?: string;
|
|
101
|
-
[key: string]: any;
|
|
102
|
-
}>;
|
|
103
|
-
};
|
|
104
|
-
} & T;
|
|
@@ -1,107 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Link Preview (unfurl) Mixin
|
|
3
|
-
*
|
|
4
|
-
* Resolves link previews ("unfurls") through the Oxy API so every Oxy app
|
|
5
|
-
* stops scraping link metadata locally. The API owns resolution and re-hosts
|
|
6
|
-
* the preview `image`/`favicon` on Oxy media (`cloud.oxy.so/<fileId>`), so
|
|
7
|
-
* consumers render the returned URLs directly with no per-app proxy.
|
|
8
|
-
*
|
|
9
|
-
* Wire shapes (`LinkPreview`, `LinkPreviewBatchResponse`) are the single source
|
|
10
|
-
* of truth in `@oxy.so/contracts`; this mixin imports them rather than
|
|
11
|
-
* redefining them so producer (oxy-api) and consumers cannot drift.
|
|
12
|
-
*
|
|
13
|
-
* Caching note: these GET/POST reads are NOT cached at the SDK layer (`cache:
|
|
14
|
-
* false`). A preview can be returned `'pending'` first and `'resolved'` on a
|
|
15
|
-
* later read, so an SDK GET cache would pin the stale `'pending'` snapshot.
|
|
16
|
-
* App-side caching (React Query / stores) owns this responsibility.
|
|
17
|
-
*/
|
|
18
|
-
import type { LinkPreview } from '@oxy.so/contracts';
|
|
19
|
-
import type { OxyServicesBase } from '../OxyServices.base';
|
|
20
|
-
import { buildUrl } from '../utils/apiUtils';
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Maximum number of URLs sent per `POST /links/previews` request. Matches the
|
|
24
|
-
* server-side batch cap (`linkPreviewBatchRequestSchema`'s `.max(50)`); larger
|
|
25
|
-
* inputs are split into multiple chunked calls and the result maps merged,
|
|
26
|
-
* mirroring how `getUsersByIds` chunks at 100.
|
|
27
|
-
*/
|
|
28
|
-
const LINK_PREVIEWS_CHUNK_SIZE = 50;
|
|
29
|
-
|
|
30
|
-
export function OxyServicesLinksMixin<T extends typeof OxyServicesBase>(Base: T) {
|
|
31
|
-
return class extends Base {
|
|
32
|
-
constructor(...args: any[]) {
|
|
33
|
-
super(...(args as [any]));
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Resolve a single link preview via `GET /links/preview?url=<encoded>&wait=0|1`.
|
|
38
|
-
*
|
|
39
|
-
* @param url - The URL to unfurl. Sent percent-encoded in the query string.
|
|
40
|
-
* @param opts.wait - When `true`, asks the server to resolve synchronously
|
|
41
|
-
* (`wait=1`) instead of returning a `'pending'` placeholder for a
|
|
42
|
-
* first-seen URL. Defaults to `false` (`wait=0`).
|
|
43
|
-
*
|
|
44
|
-
* Not cached at the SDK layer: a `'pending'` result can become `'resolved'`
|
|
45
|
-
* on a later read, so caching here would serve the stale placeholder.
|
|
46
|
-
*/
|
|
47
|
-
async getLinkPreview(url: string, opts?: { wait?: boolean }): Promise<LinkPreview> {
|
|
48
|
-
try {
|
|
49
|
-
const path = buildUrl('/links/preview', { url, wait: opts?.wait ? 1 : 0 });
|
|
50
|
-
return await this.makeRequest<LinkPreview>('GET', path, undefined, { cache: false });
|
|
51
|
-
} catch (error) {
|
|
52
|
-
throw this.handleError(error);
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Resolve multiple link previews via `POST /links/previews` (body `{ urls }`).
|
|
58
|
-
*
|
|
59
|
-
* Inputs are de-duplicated and split into chunks of {@link LINK_PREVIEWS_CHUNK_SIZE}
|
|
60
|
-
* (the server-side cap). Chunks run concurrently and their result maps are
|
|
61
|
-
* merged into a single result keyed by the REQUESTED url (the exact string
|
|
62
|
-
* passed in `urls`) — matching the batch contract — so a caller can always
|
|
63
|
-
* look its own input back up.
|
|
64
|
-
*
|
|
65
|
-
* `makeRequest` unwraps the API's top-level `{ data }` envelope (same as
|
|
66
|
-
* `getUsersByIds`'s `makeServiceRequest<User[]>`), so each chunk response is
|
|
67
|
-
* already the `Record<url, LinkPreview>` map — merge it directly.
|
|
68
|
-
*
|
|
69
|
-
* An empty / whitespace-only input resolves immediately with `{}` and
|
|
70
|
-
* performs no network call. A failure in any chunk surfaces (via
|
|
71
|
-
* `handleError`) rather than being swallowed.
|
|
72
|
-
*/
|
|
73
|
-
async getLinkPreviews(urls: string[]): Promise<Record<string, LinkPreview>> {
|
|
74
|
-
const uniqueUrls = Array.from(
|
|
75
|
-
new Set(urls.filter((u): u is string => typeof u === 'string' && u.trim().length > 0)),
|
|
76
|
-
);
|
|
77
|
-
if (uniqueUrls.length === 0) {
|
|
78
|
-
return {};
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
const chunks: string[][] = [];
|
|
82
|
-
for (let i = 0; i < uniqueUrls.length; i += LINK_PREVIEWS_CHUNK_SIZE) {
|
|
83
|
-
chunks.push(uniqueUrls.slice(i, i + LINK_PREVIEWS_CHUNK_SIZE));
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
try {
|
|
87
|
-
const responses = await Promise.all(
|
|
88
|
-
chunks.map((chunk) =>
|
|
89
|
-
this.makeRequest<Record<string, LinkPreview>>(
|
|
90
|
-
'POST',
|
|
91
|
-
'/links/previews',
|
|
92
|
-
{ urls: chunk },
|
|
93
|
-
{ cache: false },
|
|
94
|
-
),
|
|
95
|
-
),
|
|
96
|
-
);
|
|
97
|
-
|
|
98
|
-
return responses.reduce<Record<string, LinkPreview>>(
|
|
99
|
-
(merged, response) => Object.assign(merged, response ?? {}),
|
|
100
|
-
{},
|
|
101
|
-
);
|
|
102
|
-
} catch (error) {
|
|
103
|
-
throw this.handleError(error);
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
};
|
|
107
|
-
}
|
|
@@ -1,154 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Link-preview mixin tests.
|
|
3
|
-
*
|
|
4
|
-
* Stubs `makeRequest` so the tests run with no network, then asserts:
|
|
5
|
-
* - `getLinkPreview` builds `GET /links/preview?url=<percent-encoded>&wait=0|1`,
|
|
6
|
-
* percent-encoding the target URL so its `?`/`&`/`=` cannot break out of the
|
|
7
|
-
* query string, defaults `wait` to `0`, sends `wait=1` when `{ wait: true }`,
|
|
8
|
-
* reads uncached (`cache:false`), returns the preview, and surfaces errors.
|
|
9
|
-
* - `getLinkPreviews` de-duplicates input, no-ops to `{}` on empty input,
|
|
10
|
-
* chunks at 50 URLs/request (`POST /links/previews`), merges every chunk's
|
|
11
|
-
* `data` map keyed by the requested url, and surfaces a chunk failure.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import type { LinkPreview } from '@oxy.so/contracts';
|
|
15
|
-
import { OxyServices } from '../../OxyServices';
|
|
16
|
-
|
|
17
|
-
const sampleResolved: LinkPreview = {
|
|
18
|
-
url: 'https://news.example.com/a',
|
|
19
|
-
status: 'resolved',
|
|
20
|
-
title: 'Headline',
|
|
21
|
-
description: 'Lede',
|
|
22
|
-
image: 'https://cloud.oxy.so/img123',
|
|
23
|
-
siteName: 'Example News',
|
|
24
|
-
favicon: 'https://cloud.oxy.so/fav123',
|
|
25
|
-
resolvedAt: '2026-06-28T00:00:00.000Z',
|
|
26
|
-
};
|
|
27
|
-
|
|
28
|
-
describe('OxyServices.links', () => {
|
|
29
|
-
let oxy: OxyServices;
|
|
30
|
-
let makeRequestSpy: jest.SpyInstance;
|
|
31
|
-
|
|
32
|
-
beforeEach(() => {
|
|
33
|
-
oxy = new OxyServices({ baseURL: 'http://test.invalid' });
|
|
34
|
-
makeRequestSpy = jest.spyOn(oxy, 'makeRequest');
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
afterEach(() => {
|
|
38
|
-
jest.restoreAllMocks();
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
describe('getLinkPreview', () => {
|
|
42
|
-
it('percent-encodes the URL and defaults wait=0', async () => {
|
|
43
|
-
makeRequestSpy.mockResolvedValueOnce(sampleResolved);
|
|
44
|
-
|
|
45
|
-
const result = await oxy.getLinkPreview('https://news.example.com/a?b=c&d=e');
|
|
46
|
-
|
|
47
|
-
expect(result).toEqual(sampleResolved);
|
|
48
|
-
expect(makeRequestSpy).toHaveBeenCalledTimes(1);
|
|
49
|
-
expect(makeRequestSpy).toHaveBeenCalledWith(
|
|
50
|
-
'GET',
|
|
51
|
-
'/links/preview?url=https%3A%2F%2Fnews.example.com%2Fa%3Fb%3Dc%26d%3De&wait=0',
|
|
52
|
-
undefined,
|
|
53
|
-
{ cache: false },
|
|
54
|
-
);
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
it('sends wait=1 when opts.wait is true', async () => {
|
|
58
|
-
makeRequestSpy.mockResolvedValueOnce({ url: 'https://x.test/', status: 'pending' });
|
|
59
|
-
|
|
60
|
-
await oxy.getLinkPreview('https://x.test/', { wait: true });
|
|
61
|
-
|
|
62
|
-
expect(makeRequestSpy).toHaveBeenCalledWith(
|
|
63
|
-
'GET',
|
|
64
|
-
'/links/preview?url=https%3A%2F%2Fx.test%2F&wait=1',
|
|
65
|
-
undefined,
|
|
66
|
-
{ cache: false },
|
|
67
|
-
);
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
it('surfaces errors via handleError', async () => {
|
|
71
|
-
makeRequestSpy.mockRejectedValueOnce(new Error('boom'));
|
|
72
|
-
|
|
73
|
-
await expect(oxy.getLinkPreview('https://x.test/')).rejects.toThrow('boom');
|
|
74
|
-
});
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
describe('getLinkPreviews', () => {
|
|
78
|
-
it('returns {} and performs no network call for empty / whitespace input', async () => {
|
|
79
|
-
await expect(oxy.getLinkPreviews([])).resolves.toEqual({});
|
|
80
|
-
await expect(oxy.getLinkPreviews(['', ' '])).resolves.toEqual({});
|
|
81
|
-
expect(makeRequestSpy).not.toHaveBeenCalled();
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
it('de-duplicates and sends a single chunk for <= 50 unique URLs', async () => {
|
|
85
|
-
// makeRequest unwraps the API's `{ data }` envelope, so the resolved value
|
|
86
|
-
// is the bare record (NOT `{ data: {...} }`) — mirror that real shape here.
|
|
87
|
-
const record: Record<string, LinkPreview> = { 'https://a.test/': sampleResolved };
|
|
88
|
-
makeRequestSpy.mockResolvedValueOnce(record);
|
|
89
|
-
|
|
90
|
-
const result = await oxy.getLinkPreviews([
|
|
91
|
-
'https://a.test/',
|
|
92
|
-
'https://a.test/', // duplicate
|
|
93
|
-
' ', // dropped
|
|
94
|
-
]);
|
|
95
|
-
|
|
96
|
-
expect(result).toEqual(record);
|
|
97
|
-
expect(makeRequestSpy).toHaveBeenCalledTimes(1);
|
|
98
|
-
expect(makeRequestSpy).toHaveBeenCalledWith(
|
|
99
|
-
'POST',
|
|
100
|
-
'/links/previews',
|
|
101
|
-
{ urls: ['https://a.test/'] },
|
|
102
|
-
{ cache: false },
|
|
103
|
-
);
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
it('chunks at 50 URLs per request and merges each chunk data map', async () => {
|
|
107
|
-
const urls = Array.from({ length: 120 }, (_, i) => `https://site.test/${i}`);
|
|
108
|
-
|
|
109
|
-
makeRequestSpy.mockImplementation(
|
|
110
|
-
async (
|
|
111
|
-
_method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
|
|
112
|
-
_url: string,
|
|
113
|
-
data?: { urls: string[] },
|
|
114
|
-
): Promise<Record<string, LinkPreview>> => {
|
|
115
|
-
const chunkUrls = data?.urls ?? [];
|
|
116
|
-
const dataMap: Record<string, LinkPreview> = {};
|
|
117
|
-
for (const u of chunkUrls) {
|
|
118
|
-
dataMap[u] = { url: u, status: 'resolved', title: `t-${u}` };
|
|
119
|
-
}
|
|
120
|
-
return dataMap;
|
|
121
|
-
},
|
|
122
|
-
);
|
|
123
|
-
|
|
124
|
-
const result = await oxy.getLinkPreviews(urls);
|
|
125
|
-
|
|
126
|
-
// 120 unique URLs => 50 + 50 + 20 across three POSTs.
|
|
127
|
-
expect(makeRequestSpy).toHaveBeenCalledTimes(3);
|
|
128
|
-
const chunkSizes = makeRequestSpy.mock.calls.map((call) => (call[2] as { urls: string[] }).urls.length);
|
|
129
|
-
expect(chunkSizes).toEqual([50, 50, 20]);
|
|
130
|
-
|
|
131
|
-
// Every requested URL is present in the merged, request-keyed map.
|
|
132
|
-
expect(Object.keys(result)).toHaveLength(120);
|
|
133
|
-
expect(result['https://site.test/0']).toEqual({
|
|
134
|
-
url: 'https://site.test/0',
|
|
135
|
-
status: 'resolved',
|
|
136
|
-
title: 't-https://site.test/0',
|
|
137
|
-
});
|
|
138
|
-
expect(result['https://site.test/119']).toEqual({
|
|
139
|
-
url: 'https://site.test/119',
|
|
140
|
-
status: 'resolved',
|
|
141
|
-
title: 't-https://site.test/119',
|
|
142
|
-
});
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
it('surfaces a chunk failure via handleError', async () => {
|
|
146
|
-
const urls = Array.from({ length: 60 }, (_, i) => `https://site.test/${i}`);
|
|
147
|
-
makeRequestSpy
|
|
148
|
-
.mockResolvedValueOnce({ data: {} })
|
|
149
|
-
.mockRejectedValueOnce(new Error('chunk failed'));
|
|
150
|
-
|
|
151
|
-
await expect(oxy.getLinkPreviews(urls)).rejects.toThrow('chunk failed');
|
|
152
|
-
});
|
|
153
|
-
});
|
|
154
|
-
});
|