@villagehq/sdk 0.0.0-stage → 0.1.0-beta.0
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/LICENSE +21 -0
- package/README.md +253 -2
- package/dist/core/display.d.ts +20 -0
- package/dist/core/display.js +27 -0
- package/dist/core/errors.d.ts +31 -0
- package/dist/core/errors.js +93 -0
- package/dist/core/identifiers.d.ts +16 -0
- package/dist/core/identifiers.js +65 -0
- package/dist/core/pages.d.ts +23 -0
- package/dist/core/pages.js +28 -0
- package/dist/core/sync-modal.d.ts +6 -0
- package/dist/core/sync-modal.js +90 -0
- package/dist/core/transport.d.ts +41 -0
- package/dist/core/transport.js +126 -0
- package/dist/core/user.d.ts +46 -0
- package/dist/core/user.js +117 -0
- package/dist/core/village.d.ts +190 -0
- package/dist/core/village.js +147 -0
- package/dist/generated/api.d.ts +9175 -0
- package/dist/generated/api.js +5 -0
- package/dist/react/react.d.ts +42 -0
- package/dist/react/react.js +38 -0
- package/dist/server/server.d.ts +181 -0
- package/dist/server/server.js +51 -0
- package/package.json +58 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Village
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,254 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @villagehq/sdk
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Build apps on Village: who your users know, warm intro paths, people and company search, and network sync.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm install @villagehq/sdk
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## How it fits together
|
|
10
|
+
|
|
11
|
+
Your server holds the secret key. It signs a token for each signed-in user. That token does everything else, from your server or from the browser.
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
// app/api/village/token/route.ts
|
|
15
|
+
import { createVillageApp } from '@villagehq/sdk/server';
|
|
16
|
+
|
|
17
|
+
const villageApp = createVillageApp({ secretKey: process.env.VILLAGE_SECRET_KEY! });
|
|
18
|
+
|
|
19
|
+
export async function POST(request: Request) {
|
|
20
|
+
const user = await requireSignedInUser(request);
|
|
21
|
+
const { token } = await villageApp.tokens.create({ id: user.id, email: user.email });
|
|
22
|
+
return Response.json({ token });
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Get your keys from the [Village developer platform](https://village.ai/platform). `createVillageApp` refuses to run in a browser.
|
|
27
|
+
|
|
28
|
+
In the browser, give the SDK a function that asks your server for that token:
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
async function readVillageToken() {
|
|
32
|
+
const response = await fetch('/api/village/token', { method: 'POST' });
|
|
33
|
+
if (!response.ok) throw new Error(`Token route answered ${response.status}`);
|
|
34
|
+
const { token } = await response.json();
|
|
35
|
+
return token;
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
The SDK calls it once and again only when Village rejects the token. If it throws a `VillageError`, that error reaches your code as it is; anything else becomes `token_failed`.
|
|
40
|
+
|
|
41
|
+
## The user's network
|
|
42
|
+
|
|
43
|
+
Every Village feature depends on where the user's network is. Ask once, and the answer tells you what works:
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
import { createVillage } from '@villagehq/sdk';
|
|
47
|
+
|
|
48
|
+
const village = createVillage({ token: readVillageToken });
|
|
49
|
+
const user = await village.user.get();
|
|
50
|
+
|
|
51
|
+
user.status; // 'not_connected' | 'syncing' | 'ready' | 'needs_attention'
|
|
52
|
+
user.integrations; // [{ id, type: 'google', account: 'ada@gmail.com', status: 'needs_reauth', last_synced_at }]
|
|
53
|
+
user.network_updated_at; // when intro paths were last rebuilt
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
| `status` | What works | What to show |
|
|
57
|
+
| ----------------- | ------------------------------------------------------------------------------------------ | ------------------------------------------------------------- |
|
|
58
|
+
| `not_connected` | Global people search. Enrichment too once `email` is set, meaning a Village user is linked | A "Connect your network" button that calls `village.sync()` |
|
|
59
|
+
| `syncing` | Everything except intro paths. Network search may be partial | Progress, from `integrations` |
|
|
60
|
+
| `ready` | Everything | The feature |
|
|
61
|
+
| `needs_attention` | Everything, on older data | "Reconnect" for the integration with `status: 'needs_reauth'` |
|
|
62
|
+
|
|
63
|
+
`isNetworkReady(user)` is `true` for `ready` and `needs_attention`. Village can link your user to an account with a different email; compare `user.email` with the one you know if that matters.
|
|
64
|
+
|
|
65
|
+
### Watch it change
|
|
66
|
+
|
|
67
|
+
`village.user` works with whatever holds your state:
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
// Any JavaScript: checks every five seconds while the network syncs, and stops when nobody listens.
|
|
71
|
+
const stop = village.user.subscribe(({ user, error }) => render(user));
|
|
72
|
+
|
|
73
|
+
// A background job or an agent
|
|
74
|
+
const user = await village.user.ready({ timeoutMs: 10 * 60_000 });
|
|
75
|
+
|
|
76
|
+
// React Query or SWR
|
|
77
|
+
useQuery({ queryKey: ['village-user'], queryFn: () => village.user.get() });
|
|
78
|
+
useSWR('village-user', () => village.user.get());
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Network sync
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
const user = await village.sync();
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Opens Village's sync modal and resolves with the fresh user when the modal closes. The user may have left before finishing, so check `user.status`. The modal needs a `full` token, which is the default. If your app reads status through its own server, call `openSyncModal({ token, appUrl })` and refresh your own way.
|
|
88
|
+
|
|
89
|
+
## React
|
|
90
|
+
|
|
91
|
+
`@villagehq/sdk/react` has no provider and no data library. Pass the client you created:
|
|
92
|
+
|
|
93
|
+
```tsx
|
|
94
|
+
import { createVillage } from '@villagehq/sdk';
|
|
95
|
+
import { useVillageSync, useVillageUser, VillageGate } from '@villagehq/sdk/react';
|
|
96
|
+
import { useState } from 'react';
|
|
97
|
+
|
|
98
|
+
function createUserVillage() {
|
|
99
|
+
return createVillage({ token: readVillageToken });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Render this only for a signed-in user, keyed by their id so a new user gets a new client.
|
|
103
|
+
function VillageSection() {
|
|
104
|
+
const [village] = useState(createUserVillage);
|
|
105
|
+
return (
|
|
106
|
+
<VillageGate
|
|
107
|
+
village={village}
|
|
108
|
+
fallback={(user, error) => (error ? <Retry /> : <ConnectNetwork village={village} />)}
|
|
109
|
+
>
|
|
110
|
+
<IntroList village={village} company="stripe.com" />
|
|
111
|
+
</VillageGate>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function ConnectNetwork({ village }) {
|
|
116
|
+
const { status } = useVillageUser(village);
|
|
117
|
+
const sync = useVillageSync(village);
|
|
118
|
+
if (status === 'syncing') return <p>Importing your network…</p>;
|
|
119
|
+
return (
|
|
120
|
+
<button disabled={sync.isOpen} onClick={sync.open}>
|
|
121
|
+
Connect your network
|
|
122
|
+
</button>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
| Export | Returns |
|
|
128
|
+
| -------------------------------- | ---------------------------------------------------------------------------------------------------- |
|
|
129
|
+
| `useVillageUser(village)` | `user`, `status`, `isLoading`, `error`, `refresh()`. Follows `village.user`. |
|
|
130
|
+
| `useVillageSync(village)` | `open()`, `isOpen`. `open()` resolves with the fresh user. |
|
|
131
|
+
| `<VillageGate village fallback>` | Its children once the network is ready. `fallback` can be a function of the user and the last error. |
|
|
132
|
+
|
|
133
|
+
Fetch everything else with your own data library and the client's methods. Calls that spend credits should keep their results and not retry on their own; in React Query that is `staleTime: Infinity, retry: false`.
|
|
134
|
+
|
|
135
|
+
## Any JavaScript
|
|
136
|
+
|
|
137
|
+
On your server, act as a user without passing tokens around:
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
const member = villageApp.as({ id: user.id, email: user.email });
|
|
141
|
+
await member.user.get();
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
`token` takes a string or a function. A function is called once, and again if Village rejects the token. When the signed-in user or their grant changes, create a new client.
|
|
145
|
+
|
|
146
|
+
### Search
|
|
147
|
+
|
|
148
|
+
```ts
|
|
149
|
+
const page = await village.people.search('CTOs in Berlin');
|
|
150
|
+
|
|
151
|
+
page.results;
|
|
152
|
+
page.hasMore;
|
|
153
|
+
const nextPage = await page.next();
|
|
154
|
+
|
|
155
|
+
for await (const person of page) {
|
|
156
|
+
// Every page, without repeats. Each page may spend credits.
|
|
157
|
+
}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
A string searches the user's network. Pass an object for anything else, such as `{ prompt, scope: 'global', limit: 25 }`. `village.companies.search` works the same way. `page.total` needs `include_counts: 'global'` in the request. Searches can take a minute, so the SDK waits up to two.
|
|
161
|
+
|
|
162
|
+
### Intro paths
|
|
163
|
+
|
|
164
|
+
Person and company arguments are plain strings.
|
|
165
|
+
|
|
166
|
+
```ts
|
|
167
|
+
await village.people.paths('linkedin.com/in/satyanadella'); // also a bare slug or a full URL
|
|
168
|
+
await village.companies.checkPaths('stripe.com'); // fast: has_paths, count, score, avatars
|
|
169
|
+
await village.companies.paths('stripe.com', { degrees: [2, 3], limit: 20 });
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
`people.paths` and `companies.paths` return `null` when Village doesn't know the person or company. `companies.checkPaths` returns `has_paths: false` instead.
|
|
173
|
+
|
|
174
|
+
### Everything else
|
|
175
|
+
|
|
176
|
+
```ts
|
|
177
|
+
await village.people.enrich('satyanadella');
|
|
178
|
+
await village.people.emails('satyanadella');
|
|
179
|
+
await village.companies.enrich('stripe.com');
|
|
180
|
+
|
|
181
|
+
await village.groups.create('Founders');
|
|
182
|
+
await village.groups.join(inviteLink); // joining twice is fine
|
|
183
|
+
await village.groups.leave(groupId); // 'left' | 'not_member' | 'admin_blocked'
|
|
184
|
+
await village.relationships.import([{ identifier: linkedinUrl, score: 8 }]);
|
|
185
|
+
|
|
186
|
+
const { data, error } = await village.api.GET('/v2/lists'); // any endpoint, fully typed
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Responses keep the API's field names, so the [API reference](https://docs.village.ai) applies as written.
|
|
190
|
+
|
|
191
|
+
### Helpers
|
|
192
|
+
|
|
193
|
+
```ts
|
|
194
|
+
import { initials, introducers, linkedinSlug, linkedinUrl, personName } from '@villagehq/sdk';
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
## Errors
|
|
198
|
+
|
|
199
|
+
Every failure is a `VillageError`, except an abort you asked for.
|
|
200
|
+
|
|
201
|
+
```ts
|
|
202
|
+
import { isVillageError } from '@villagehq/sdk';
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
await village.people.paths(person);
|
|
206
|
+
} catch (error) {
|
|
207
|
+
if (isVillageError(error, 'not_synced')) return <SyncPrompt />;
|
|
208
|
+
if (isVillageError(error, 'out_of_credits')) return <TopUp href={error.topupUrl} />;
|
|
209
|
+
throw error;
|
|
210
|
+
}
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
| `code` | Meaning |
|
|
214
|
+
| ---------------------------------------------- | ---------------------------------------------------- |
|
|
215
|
+
| `not_connected` | The user hasn't connected Village. Call `sync()`. |
|
|
216
|
+
| `not_synced` | The network sync hasn't finished. |
|
|
217
|
+
| `out_of_credits` | The app is out of credits. See `error.topupUrl`. |
|
|
218
|
+
| `unauthorized` | The key or token is invalid, or the app is inactive. |
|
|
219
|
+
| `forbidden` | The token can't do this. |
|
|
220
|
+
| `not_found`, `conflict`, `bad_request` | The API refused the request. |
|
|
221
|
+
| `validation` | The request is invalid. See `error.fields`. |
|
|
222
|
+
| `rate_limited`, `server`, `timeout`, `network` | Worth retrying. `error.isRetryable` is `true`. |
|
|
223
|
+
| `token_failed` | Your token function threw. See `error.cause`. |
|
|
224
|
+
| `invalid_response` | Village returned an empty response. |
|
|
225
|
+
|
|
226
|
+
`error.isExpected` is `true` for outcomes your UI should handle rather than report. Each error carries `requestId` and `traceId` for support.
|
|
227
|
+
|
|
228
|
+
## Monitoring
|
|
229
|
+
|
|
230
|
+
```ts
|
|
231
|
+
createVillage({
|
|
232
|
+
token,
|
|
233
|
+
onRequest: ({ operation, status, durationMs, requestId, error }) => {
|
|
234
|
+
if (error && !error.isExpected) reportError(error, { operation, status, requestId });
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
## Options
|
|
240
|
+
|
|
241
|
+
| Option | Default | |
|
|
242
|
+
| ----------- | ------------------------ | -------------------------------- |
|
|
243
|
+
| `apiUrl` | `https://api.village.ai` | |
|
|
244
|
+
| `appUrl` | `https://village.ai` | Where the sync modal loads from. |
|
|
245
|
+
| `fetch` | global `fetch` | Takes a `Request`. |
|
|
246
|
+
| `onRequest` | none | Called for every request. |
|
|
247
|
+
|
|
248
|
+
`createVillageApp` takes the same options, with `secretKey` instead of `token`; its `appUrl` applies to the clients `as()` returns.
|
|
249
|
+
|
|
250
|
+
Every method takes `{ signal, timeoutMs }` as its last argument.
|
|
251
|
+
|
|
252
|
+
## Developing
|
|
253
|
+
|
|
254
|
+
Regenerate the API types with `pnpm run generate` (live API) or `pnpm run generate:local` (this repo's backend, after `pnpm --dir ../../apps/backend-main run generate:openapi`). `pnpm run check:package` builds the package and type-checks a consumer against the output.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { components } from '../generated/api.js';
|
|
2
|
+
type Path = components['schemas']['Path'];
|
|
3
|
+
type NamedPerson = {
|
|
4
|
+
full_name?: string | null;
|
|
5
|
+
first_name?: string | null;
|
|
6
|
+
last_name?: string | null;
|
|
7
|
+
};
|
|
8
|
+
export declare function personName({ full_name, first_name, last_name }: NamedPerson): string;
|
|
9
|
+
export declare function initials(name: string): string;
|
|
10
|
+
export declare function introducers(paths: readonly Path[]): {
|
|
11
|
+
name: string;
|
|
12
|
+
profile: {
|
|
13
|
+
first_name: string;
|
|
14
|
+
last_name: string;
|
|
15
|
+
linkedin_url: string;
|
|
16
|
+
headline: string;
|
|
17
|
+
avatar?: string;
|
|
18
|
+
};
|
|
19
|
+
}[];
|
|
20
|
+
export {};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
const DIRECT_DEGREE = 1;
|
|
2
|
+
const MAXIMUM_INITIALS = 2;
|
|
3
|
+
export function personName({ full_name, first_name, last_name }) {
|
|
4
|
+
return full_name || [first_name, last_name].filter(Boolean).join(' ');
|
|
5
|
+
}
|
|
6
|
+
export function initials(name) {
|
|
7
|
+
return name
|
|
8
|
+
.trim()
|
|
9
|
+
.split(/\s+/)
|
|
10
|
+
.map((word) => word.charAt(0))
|
|
11
|
+
.join('')
|
|
12
|
+
.toUpperCase()
|
|
13
|
+
.slice(0, MAXIMUM_INITIALS);
|
|
14
|
+
}
|
|
15
|
+
export function introducers(paths) {
|
|
16
|
+
const seenIntroducers = new Set();
|
|
17
|
+
return paths.flatMap(({ degree, introducer }) => {
|
|
18
|
+
if (degree === DIRECT_DEGREE)
|
|
19
|
+
return [];
|
|
20
|
+
const name = personName(introducer.profile);
|
|
21
|
+
const introducerKey = introducer.profile.linkedin_url || name;
|
|
22
|
+
if (seenIntroducers.has(introducerKey))
|
|
23
|
+
return [];
|
|
24
|
+
seenIntroducers.add(introducerKey);
|
|
25
|
+
return [{ name, profile: introducer.profile }];
|
|
26
|
+
});
|
|
27
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export type VillageErrorCode = 'bad_request' | 'unauthorized' | 'out_of_credits' | 'forbidden' | 'not_connected' | 'not_found' | 'not_synced' | 'conflict' | 'validation' | 'rate_limited' | 'server' | 'timeout' | 'network' | 'token_failed' | 'invalid_response';
|
|
2
|
+
export type VillageFieldError = {
|
|
3
|
+
field: string;
|
|
4
|
+
message: string;
|
|
5
|
+
};
|
|
6
|
+
type VillageErrorDetails = {
|
|
7
|
+
code: VillageErrorCode;
|
|
8
|
+
message: string;
|
|
9
|
+
status?: number | null;
|
|
10
|
+
requestId?: string | null;
|
|
11
|
+
traceId?: string | null;
|
|
12
|
+
fields?: VillageFieldError[];
|
|
13
|
+
topupUrl?: string | null;
|
|
14
|
+
syncUrl?: string | null;
|
|
15
|
+
cause?: unknown;
|
|
16
|
+
};
|
|
17
|
+
export declare class VillageError extends Error {
|
|
18
|
+
readonly code: VillageErrorCode;
|
|
19
|
+
readonly status: number | null;
|
|
20
|
+
readonly requestId: string | null;
|
|
21
|
+
readonly traceId: string | null;
|
|
22
|
+
readonly fields: VillageFieldError[];
|
|
23
|
+
readonly topupUrl: string | null;
|
|
24
|
+
readonly syncUrl: string | null;
|
|
25
|
+
constructor({ code, message, status, requestId, traceId, fields, topupUrl, syncUrl, cause, }: VillageErrorDetails);
|
|
26
|
+
get isExpected(): boolean;
|
|
27
|
+
get isRetryable(): boolean;
|
|
28
|
+
}
|
|
29
|
+
export declare function isVillageError(error: unknown, code?: VillageErrorCode): error is VillageError;
|
|
30
|
+
export declare function toVillageError(response: Response, body: unknown): VillageError;
|
|
31
|
+
export {};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
const EXPECTED_CODES = new Set([
|
|
2
|
+
'not_connected',
|
|
3
|
+
'not_found',
|
|
4
|
+
'not_synced',
|
|
5
|
+
'conflict',
|
|
6
|
+
'out_of_credits',
|
|
7
|
+
]);
|
|
8
|
+
const RETRYABLE_CODES = new Set(['rate_limited', 'server', 'timeout', 'network']);
|
|
9
|
+
export class VillageError extends Error {
|
|
10
|
+
code;
|
|
11
|
+
status;
|
|
12
|
+
requestId;
|
|
13
|
+
traceId;
|
|
14
|
+
fields;
|
|
15
|
+
topupUrl;
|
|
16
|
+
syncUrl;
|
|
17
|
+
constructor({ code, message, status = null, requestId = null, traceId = null, fields = [], topupUrl = null, syncUrl = null, cause, }) {
|
|
18
|
+
super(message, { cause });
|
|
19
|
+
this.name = 'VillageError';
|
|
20
|
+
this.code = code;
|
|
21
|
+
this.status = status;
|
|
22
|
+
this.requestId = requestId;
|
|
23
|
+
this.traceId = traceId;
|
|
24
|
+
this.fields = fields;
|
|
25
|
+
this.topupUrl = topupUrl;
|
|
26
|
+
this.syncUrl = syncUrl;
|
|
27
|
+
}
|
|
28
|
+
get isExpected() {
|
|
29
|
+
return EXPECTED_CODES.has(this.code);
|
|
30
|
+
}
|
|
31
|
+
get isRetryable() {
|
|
32
|
+
return RETRYABLE_CODES.has(this.code);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export function isVillageError(error, code) {
|
|
36
|
+
if (!(error instanceof VillageError))
|
|
37
|
+
return false;
|
|
38
|
+
return code === undefined || error.code === code;
|
|
39
|
+
}
|
|
40
|
+
const CODE_BY_STATUS = {
|
|
41
|
+
400: 'bad_request',
|
|
42
|
+
401: 'unauthorized',
|
|
43
|
+
402: 'out_of_credits',
|
|
44
|
+
403: 'forbidden',
|
|
45
|
+
404: 'not_found',
|
|
46
|
+
409: 'conflict',
|
|
47
|
+
422: 'validation',
|
|
48
|
+
429: 'rate_limited',
|
|
49
|
+
};
|
|
50
|
+
const NOT_CONNECTED_DETAIL = /user not found or inactive|requires a registered village account/i;
|
|
51
|
+
const NOT_SYNCED_TYPE = 'network_not_synced';
|
|
52
|
+
const FIRST_SERVER_ERROR_STATUS = 500;
|
|
53
|
+
function isRecord(value) {
|
|
54
|
+
return typeof value === 'object' && value !== null;
|
|
55
|
+
}
|
|
56
|
+
function readString(source, key) {
|
|
57
|
+
if (!isRecord(source))
|
|
58
|
+
return null;
|
|
59
|
+
const value = source[key];
|
|
60
|
+
return typeof value === 'string' ? value : null;
|
|
61
|
+
}
|
|
62
|
+
function readFields(apiError) {
|
|
63
|
+
if (!isRecord(apiError) || !Array.isArray(apiError.errors))
|
|
64
|
+
return [];
|
|
65
|
+
return apiError.errors.flatMap((fieldError) => {
|
|
66
|
+
const message = readString(fieldError, 'message');
|
|
67
|
+
return message === null ? [] : [{ field: readString(fieldError, 'field') ?? '', message }];
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
function readCode(status, type, detail) {
|
|
71
|
+
if (type === NOT_SYNCED_TYPE)
|
|
72
|
+
return 'not_synced';
|
|
73
|
+
if (detail !== null && NOT_CONNECTED_DETAIL.test(detail))
|
|
74
|
+
return 'not_connected';
|
|
75
|
+
if (status >= FIRST_SERVER_ERROR_STATUS)
|
|
76
|
+
return 'server';
|
|
77
|
+
return CODE_BY_STATUS[status] ?? 'bad_request';
|
|
78
|
+
}
|
|
79
|
+
export function toVillageError(response, body) {
|
|
80
|
+
const apiError = isRecord(body) ? body.error : null;
|
|
81
|
+
const detail = readString(apiError, 'detail') ?? readString(body, 'message') ?? `Village responded with ${response.status}.`;
|
|
82
|
+
const meta = isRecord(apiError) ? apiError.meta : null;
|
|
83
|
+
return new VillageError({
|
|
84
|
+
code: readCode(response.status, readString(apiError, 'type'), detail),
|
|
85
|
+
message: detail,
|
|
86
|
+
status: response.status,
|
|
87
|
+
requestId: response.headers.get('x-request-id'),
|
|
88
|
+
traceId: readString(apiError, 'trace_id'),
|
|
89
|
+
fields: readFields(apiError),
|
|
90
|
+
topupUrl: readString(body, 'topup_url'),
|
|
91
|
+
syncUrl: readString(meta, 'sync_url'),
|
|
92
|
+
});
|
|
93
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export type PersonIdentifier = {
|
|
2
|
+
linkedin_url: string;
|
|
3
|
+
} | {
|
|
4
|
+
url: string;
|
|
5
|
+
};
|
|
6
|
+
export type CompanyIdentifier = {
|
|
7
|
+
domain: string;
|
|
8
|
+
} | {
|
|
9
|
+
linkedin_url: string;
|
|
10
|
+
} | {
|
|
11
|
+
url: string;
|
|
12
|
+
};
|
|
13
|
+
export declare function linkedinSlug(profile: string | null | undefined): string | null;
|
|
14
|
+
export declare function linkedinUrl(slug: string): string;
|
|
15
|
+
export declare function toPersonIdentifier(person: string): PersonIdentifier;
|
|
16
|
+
export declare function toCompanyIdentifier(company: string): CompanyIdentifier;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { VillageError } from './errors.js';
|
|
2
|
+
const LINKEDIN_HOST = 'linkedin.com';
|
|
3
|
+
const LINKEDIN_PROFILE_PATH = /^\/in\/([^/]+)\/?$/;
|
|
4
|
+
const BARE_DOMAIN_PATH = '/';
|
|
5
|
+
function parseUrl(value) {
|
|
6
|
+
const trimmed = value.trim();
|
|
7
|
+
try {
|
|
8
|
+
return new URL(trimmed.includes('://') ? trimmed : `https://${trimmed}`);
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
function isLinkedinUrl(url) {
|
|
15
|
+
const host = url.hostname.toLowerCase();
|
|
16
|
+
return host === LINKEDIN_HOST || host.endsWith(`.${LINKEDIN_HOST}`);
|
|
17
|
+
}
|
|
18
|
+
function decodeSlug(encodedSlug) {
|
|
19
|
+
try {
|
|
20
|
+
return decodeURIComponent(encodedSlug);
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return encodedSlug;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function requireValue(value, label) {
|
|
27
|
+
const trimmed = value.trim();
|
|
28
|
+
if (trimmed)
|
|
29
|
+
return trimmed;
|
|
30
|
+
throw new VillageError({ code: 'bad_request', message: `Enter a ${label}.` });
|
|
31
|
+
}
|
|
32
|
+
export function linkedinSlug(profile) {
|
|
33
|
+
if (!profile)
|
|
34
|
+
return null;
|
|
35
|
+
const url = parseUrl(profile);
|
|
36
|
+
if (!url || !isLinkedinUrl(url))
|
|
37
|
+
return null;
|
|
38
|
+
const encodedSlug = LINKEDIN_PROFILE_PATH.exec(url.pathname)?.[1];
|
|
39
|
+
return encodedSlug ? decodeSlug(encodedSlug).toLowerCase() : null;
|
|
40
|
+
}
|
|
41
|
+
export function linkedinUrl(slug) {
|
|
42
|
+
return `https://www.linkedin.com/in/${encodeURIComponent(slug)}`;
|
|
43
|
+
}
|
|
44
|
+
export function toPersonIdentifier(person) {
|
|
45
|
+
const value = requireValue(person, 'LinkedIn profile or URL');
|
|
46
|
+
const isBareSlug = !value.includes('.') && !value.includes('/');
|
|
47
|
+
if (isBareSlug)
|
|
48
|
+
return { linkedin_url: linkedinUrl(value) };
|
|
49
|
+
const url = parseUrl(value);
|
|
50
|
+
if (url && isLinkedinUrl(url))
|
|
51
|
+
return { linkedin_url: url.href };
|
|
52
|
+
return { url: url?.href ?? value };
|
|
53
|
+
}
|
|
54
|
+
export function toCompanyIdentifier(company) {
|
|
55
|
+
const value = requireValue(company, 'company domain or URL');
|
|
56
|
+
const url = parseUrl(value);
|
|
57
|
+
if (!url)
|
|
58
|
+
return { url: value };
|
|
59
|
+
if (isLinkedinUrl(url))
|
|
60
|
+
return { linkedin_url: url.href };
|
|
61
|
+
const isBareDomain = url.pathname === BARE_DOMAIN_PATH && !url.search && !url.hash;
|
|
62
|
+
if (isBareDomain)
|
|
63
|
+
return { domain: url.hostname.toLowerCase().replace(/^www\./, '') };
|
|
64
|
+
return { url: url.href };
|
|
65
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
type PageResponse = {
|
|
2
|
+
pagination: {
|
|
3
|
+
cursor?: string | null;
|
|
4
|
+
has_next_page: boolean;
|
|
5
|
+
};
|
|
6
|
+
};
|
|
7
|
+
type PageSource<Response extends PageResponse, Result> = {
|
|
8
|
+
response: Response;
|
|
9
|
+
results: Result[];
|
|
10
|
+
total: number | null;
|
|
11
|
+
keyOf: (result: Result) => unknown;
|
|
12
|
+
fetchNext: (cursor: string) => Promise<Page<Response, Result>>;
|
|
13
|
+
};
|
|
14
|
+
export type Page<Response, Result> = AsyncIterable<Result> & {
|
|
15
|
+
response: Response;
|
|
16
|
+
results: Result[];
|
|
17
|
+
total: number | null;
|
|
18
|
+
cursor: string | null;
|
|
19
|
+
hasMore: boolean;
|
|
20
|
+
next: () => Promise<Page<Response, Result> | null>;
|
|
21
|
+
};
|
|
22
|
+
export declare function createPage<Response extends PageResponse, Result>({ response, results, total, keyOf, fetchNext, }: PageSource<Response, Result>): Page<Response, Result>;
|
|
23
|
+
export {};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export function createPage({ response, results, total, keyOf, fetchNext, }) {
|
|
2
|
+
const cursor = response.pagination.cursor || null;
|
|
3
|
+
const hasMore = response.pagination.has_next_page && cursor !== null;
|
|
4
|
+
const next = async () => (hasMore && cursor ? fetchNext(cursor) : null);
|
|
5
|
+
const page = {
|
|
6
|
+
response,
|
|
7
|
+
results,
|
|
8
|
+
total,
|
|
9
|
+
cursor,
|
|
10
|
+
hasMore,
|
|
11
|
+
next,
|
|
12
|
+
async *[Symbol.asyncIterator]() {
|
|
13
|
+
const seenKeys = new Set();
|
|
14
|
+
let current = page;
|
|
15
|
+
while (current) {
|
|
16
|
+
for (const result of current.results) {
|
|
17
|
+
const key = keyOf(result);
|
|
18
|
+
if (seenKeys.has(key))
|
|
19
|
+
continue;
|
|
20
|
+
seenKeys.add(key);
|
|
21
|
+
yield result;
|
|
22
|
+
}
|
|
23
|
+
current = await current.next();
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
return page;
|
|
28
|
+
}
|