@raster-app/sdk 0.1.0-canary-2e1834e
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 +83 -0
- package/dist/__generated__/openapi.d.ts +1096 -0
- package/dist/__generated__/openapi.js +2 -0
- package/dist/__generated__/openapi.js.map +1 -0
- package/dist/adapters/index.d.ts +31 -0
- package/dist/adapters/index.js +2 -0
- package/dist/adapters/index.js.map +1 -0
- package/dist/adapters/local-storage.d.ts +3 -0
- package/dist/adapters/local-storage.js +49 -0
- package/dist/adapters/local-storage.js.map +1 -0
- package/dist/adapters/memory.d.ts +7 -0
- package/dist/adapters/memory.js +19 -0
- package/dist/adapters/memory.js.map +1 -0
- package/dist/api/assets.d.ts +41 -0
- package/dist/api/assets.js +70 -0
- package/dist/api/assets.js.map +1 -0
- package/dist/api/errors.d.ts +22 -0
- package/dist/api/errors.js +80 -0
- package/dist/api/errors.js.map +1 -0
- package/dist/api/libraries.d.ts +16 -0
- package/dist/api/libraries.js +22 -0
- package/dist/api/libraries.js.map +1 -0
- package/dist/api/me.d.ts +40 -0
- package/dist/api/me.js +39 -0
- package/dist/api/me.js.map +1 -0
- package/dist/api/promote.d.ts +36 -0
- package/dist/api/promote.js +29 -0
- package/dist/api/promote.js.map +1 -0
- package/dist/api/search.d.ts +25 -0
- package/dist/api/search.js +21 -0
- package/dist/api/search.js.map +1 -0
- package/dist/api/transport.d.ts +43 -0
- package/dist/api/transport.js +90 -0
- package/dist/api/transport.js.map +1 -0
- package/dist/api/upload.d.ts +40 -0
- package/dist/api/upload.js +60 -0
- package/dist/api/upload.js.map +1 -0
- package/dist/auth/device.d.ts +32 -0
- package/dist/auth/device.js +70 -0
- package/dist/auth/device.js.map +1 -0
- package/dist/auth/http.d.ts +25 -0
- package/dist/auth/http.js +70 -0
- package/dist/auth/http.js.map +1 -0
- package/dist/auth/register.d.ts +14 -0
- package/dist/auth/register.js +21 -0
- package/dist/auth/register.js.map +1 -0
- package/dist/auth/session.d.ts +30 -0
- package/dist/auth/session.js +231 -0
- package/dist/auth/session.js.map +1 -0
- package/dist/auth/tokens.d.ts +10 -0
- package/dist/auth/tokens.js +20 -0
- package/dist/auth/tokens.js.map +1 -0
- package/dist/client.d.ts +138 -0
- package/dist/client.js +122 -0
- package/dist/client.js.map +1 -0
- package/dist/constants.d.ts +18 -0
- package/dist/constants.js +24 -0
- package/dist/constants.js.map +1 -0
- package/dist/format.d.ts +13 -0
- package/dist/format.js +83 -0
- package/dist/format.js.map +1 -0
- package/dist/images/blurhash-placeholder.d.ts +5 -0
- package/dist/images/blurhash-placeholder.js +47 -0
- package/dist/images/blurhash-placeholder.js.map +1 -0
- package/dist/images/blurhash.d.ts +2 -0
- package/dist/images/blurhash.js +77 -0
- package/dist/images/blurhash.js.map +1 -0
- package/dist/images/image-src.d.ts +42 -0
- package/dist/images/image-src.js +83 -0
- package/dist/images/image-src.js.map +1 -0
- package/dist/images/sizes.d.ts +15 -0
- package/dist/images/sizes.js +41 -0
- package/dist/images/sizes.js.map +1 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +17 -0
- package/dist/index.js.map +1 -0
- package/dist/types/asset.d.ts +50 -0
- package/dist/types/asset.js +23 -0
- package/dist/types/asset.js.map +1 -0
- package/dist/types/library.d.ts +21 -0
- package/dist/types/library.js +2 -0
- package/dist/types/library.js.map +1 -0
- package/dist/types/session.d.ts +59 -0
- package/dist/types/session.js +21 -0
- package/dist/types/session.js.map +1 -0
- package/package.json +42 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"me.js","sourceRoot":"","sources":["../../src/api/me.ts"],"names":[],"mappings":"AAyBA,kEAAkE;AAClE,MAAM,UAAU,eAAe,CAAC,EAAM;IACrC,IAAI,eAAe,IAAI,EAAE;QAAE,OAAO,EAAE,CAAC,aAAa,CAAA;IAClD,MAAM,EAAE,cAAc,EAAE,gBAAgB,EAAE,IAAI,EAAE,SAAS,EAAE,GAAG,EAAE,CAAA;IAChE,OAAO,CAAC,EAAE,cAAc,EAAE,gBAAgB,EAAE,IAAI,EAAE,SAAS,EAAE,CAAC,CAAA;AAC/D,CAAC;AAED,yDAAyD;AACzD,MAAM,UAAU,kBAAkB,CAAC,EAAM;IACxC,OAAO,eAAe,CAAC,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,YAAY,EAAE,EAAE,CAAC,CAAC;QACjD,EAAE,EAAE,YAAY,CAAC,cAAc;QAC/B,IAAI,EAAE,YAAY,CAAC,gBAAgB;QACnC,YAAY,EAAE,YAAY,CAAC,SAAS,CAAC,MAAM;KAC3C,CAAC,CAAC,CAAA;AACJ,CAAC;AAED,4EAA4E;AAC5E,MAAM,UAAU,yBAAyB,CAAC,EAAM;IAC/C,OAAO,eAAe,CAAC,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,YAAY,EAAE,EAAE,CAAC,CAAC;QACjD,EAAE,EAAE,YAAY,CAAC,cAAc;QAC/B,IAAI,EAAE,YAAY,CAAC,gBAAgB;KACnC,CAAC,CAAC,CAAA;AACJ,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,eAAe,CAC9B,EAAM,EACN,cAAsB,EACtB,SAAiB;IAEjB,MAAM,YAAY,GAAG,eAAe,CAAC,EAAE,CAAC,CAAC,IAAI,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,cAAc,KAAK,cAAc,CAAC,CAAA;IACjG,OAAO,YAAY,EAAE,SAAS,CAAC,IAAI,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,OAAO,KAAK,SAAS,CAAC,EAAE,MAAM,IAAI,IAAI,CAAA;AAC5F,CAAC;AAED;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,KAAK,CAC1B,SAAoB,EACpB,MAAe,EACf,MAAoB;IAEpB,OAAO,SAAS,CAAC,OAAO,CAAK,KAAK,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,CAAC,CAAA;AACxD,CAAC","sourcesContent":["import type { components } from '../__generated__/openapi.js'\nimport type { Organization } from '../types/library.js'\nimport type { CredentialOrganization } from '../types/session.js'\nimport type { Transport } from './transport.js'\n\n/**\n * The credential's scope, in one of two shapes. An API key, or a sign-in narrowed to one\n * organization at consent, has that organization on the top-level fields. A sign-in granted\n * the whole account has `organizations`, one entry per membership. Read either through\n * `meOrganizations`.\n */\nexport type Me = components['schemas']['Me']\n\n/**\n * One organization the credential can act on, with the libraries it reaches inside it. Its\n * `organizationId` is the slug every other endpoint takes in its path.\n */\nexport type MeOrganization = Extract<Me, { organizations: unknown }>['organizations'][number]\n\n/**\n * The credential's access to one library. `library` is the id the library list reports, unique\n * only within its organization; `write` covers reading too.\n */\nexport type MeLibraryAccess = MeOrganization['libraries'][number]\n\n/** Every organization `/me` reported, whichever shape it used. */\nexport function meOrganizations(me: Me): Array<MeOrganization> {\n\tif ('organizations' in me) return me.organizations\n\tconst { organizationId, organizationName, plan, libraries } = me\n\treturn [{ organizationId, organizationName, plan, libraries }]\n}\n\n/** The same list, as an organization picker needs it. */\nexport function meOrganizationList(me: Me): Array<Organization> {\n\treturn meOrganizations(me).map((organization) => ({\n\t\tid: organization.organizationId,\n\t\tname: organization.organizationName,\n\t\tlibraryCount: organization.libraries.length,\n\t}))\n}\n\n/** The same list, reduced to what is persisted alongside the credential. */\nexport function meCredentialOrganizations(me: Me): Array<CredentialOrganization> {\n\treturn meOrganizations(me).map((organization) => ({\n\t\tid: organization.organizationId,\n\t\tname: organization.organizationName,\n\t}))\n}\n\n/**\n * The credential's access to one library. Null when `/me` does not list it, which for a\n * library the list returned means this `/me` was read before the grant: treat it as no access.\n */\nexport function meLibraryAccess(\n\tme: Me,\n\torganizationId: string,\n\tlibraryId: string\n): MeLibraryAccess['access'] | null {\n\tconst organization = meOrganizations(me).find((entry) => entry.organizationId === organizationId)\n\treturn organization?.libraries.find((entry) => entry.library === libraryId)?.access ?? null\n}\n\n/**\n * Resolve what a credential can reach. It takes nothing but the credential, so it doubles as\n * validation: it returns the scope or throws (`INVALID_API_KEY`, `UNAUTHENTICATED`). Pass\n * `bearer` to check a credential before it is saved; otherwise the session's own is sent.\n */\nexport async function getMe(\n\ttransport: Transport,\n\tbearer?: string,\n\tsignal?: AbortSignal\n): Promise<Me> {\n\treturn transport.request<Me>('/me', { bearer, signal })\n}\n"]}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { paths } from '../__generated__/openapi.js';
|
|
2
|
+
import type { Asset } from '../types/asset.js';
|
|
3
|
+
import type { Transport } from './transport.js';
|
|
4
|
+
/**
|
|
5
|
+
* What `promote` reports back: the asset, the variant that becomes its default, and the new
|
|
6
|
+
* variant the previous default is preserved as (a fresh id, promotable back).
|
|
7
|
+
*/
|
|
8
|
+
export type PromoteResult = paths['/organizations/{organizationId}/libraries/{libraryId}/assets/{assetId}/promote']['post']['responses'][200]['content']['application/json']['data'];
|
|
9
|
+
/**
|
|
10
|
+
* Promote one of an asset's variants to be its default: the variant's file becomes what the
|
|
11
|
+
* asset serves, at the same URL. Raster queues the swap and answers first, so a read straight
|
|
12
|
+
* after can still show the old default; `waitUntilPromoted` waits for it. Once it lands, the
|
|
13
|
+
* previous default is a new variant and the promoted one's entry is gone, so refetch the
|
|
14
|
+
* variants rather than patch them.
|
|
15
|
+
*/
|
|
16
|
+
export declare function promoteVariant(transport: Transport, options: {
|
|
17
|
+
organizationId: string;
|
|
18
|
+
libraryId: string;
|
|
19
|
+
/** The parent asset whose default is being replaced. */
|
|
20
|
+
assetId: string;
|
|
21
|
+
/** The variant to make the new default. */
|
|
22
|
+
variantId: string;
|
|
23
|
+
signal?: AbortSignal;
|
|
24
|
+
}): Promise<PromoteResult>;
|
|
25
|
+
/**
|
|
26
|
+
* Wait until a promote has landed: the promoted variant has left the asset's `variants`.
|
|
27
|
+
* Resolves with the asset as it then reads, or with null if the swap had not landed when the
|
|
28
|
+
* wait gave up.
|
|
29
|
+
*/
|
|
30
|
+
export declare function waitUntilPromoted(transport: Transport, options: {
|
|
31
|
+
organizationId: string;
|
|
32
|
+
libraryId: string;
|
|
33
|
+
assetId: string;
|
|
34
|
+
variantId: string;
|
|
35
|
+
signal?: AbortSignal;
|
|
36
|
+
}): Promise<Asset | null>;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { pollAsset } from './assets.js';
|
|
2
|
+
/**
|
|
3
|
+
* Promote one of an asset's variants to be its default: the variant's file becomes what the
|
|
4
|
+
* asset serves, at the same URL. Raster queues the swap and answers first, so a read straight
|
|
5
|
+
* after can still show the old default; `waitUntilPromoted` waits for it. Once it lands, the
|
|
6
|
+
* previous default is a new variant and the promoted one's entry is gone, so refetch the
|
|
7
|
+
* variants rather than patch them.
|
|
8
|
+
*/
|
|
9
|
+
export async function promoteVariant(transport, options) {
|
|
10
|
+
const { organizationId, libraryId, assetId, variantId, signal } = options;
|
|
11
|
+
const url = `${transport.origin}/organizations/${encodeURIComponent(organizationId)}/libraries/${encodeURIComponent(libraryId)}/assets/${encodeURIComponent(assetId)}/promote`;
|
|
12
|
+
const response = await transport.fetch(url, {
|
|
13
|
+
method: 'POST',
|
|
14
|
+
headers: { 'Content-Type': 'application/json' },
|
|
15
|
+
body: JSON.stringify({ variantId }),
|
|
16
|
+
signal,
|
|
17
|
+
});
|
|
18
|
+
return transport.unwrap(response);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Wait until a promote has landed: the promoted variant has left the asset's `variants`.
|
|
22
|
+
* Resolves with the asset as it then reads, or with null if the swap had not landed when the
|
|
23
|
+
* wait gave up.
|
|
24
|
+
*/
|
|
25
|
+
export function waitUntilPromoted(transport, options) {
|
|
26
|
+
const { variantId, ...read } = options;
|
|
27
|
+
return pollAsset(transport, read, (asset) => !(asset.variants ?? []).some((variant) => variant?.id === variantId));
|
|
28
|
+
}
|
|
29
|
+
//# sourceMappingURL=promote.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"promote.js","sourceRoot":"","sources":["../../src/api/promote.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AAUvC;;;;;;GAMG;AACH,MAAM,CAAC,KAAK,UAAU,cAAc,CACnC,SAAoB,EACpB,OAQC;IAED,MAAM,EAAE,cAAc,EAAE,SAAS,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,EAAE,GAAG,OAAO,CAAA;IAEzE,MAAM,GAAG,GAAG,GAAG,SAAS,CAAC,MAAM,kBAAkB,kBAAkB,CAClE,cAAc,CACd,cAAc,kBAAkB,CAAC,SAAS,CAAC,WAAW,kBAAkB,CAAC,OAAO,CAAC,UAAU,CAAA;IAE5F,MAAM,QAAQ,GAAG,MAAM,SAAS,CAAC,KAAK,CAAC,GAAG,EAAE;QAC3C,MAAM,EAAE,MAAM;QACd,OAAO,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE;QAC/C,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,EAAE,SAAS,EAAE,CAAC;QACnC,MAAM;KACN,CAAC,CAAA;IAEF,OAAO,SAAS,CAAC,MAAM,CAAgB,QAAQ,CAAC,CAAA;AACjD,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,iBAAiB,CAChC,SAAoB,EACpB,OAMC;IAED,MAAM,EAAE,SAAS,EAAE,GAAG,IAAI,EAAE,GAAG,OAAO,CAAA;IACtC,OAAO,SAAS,CACf,SAAS,EACT,IAAI,EACJ,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC,QAAQ,IAAI,EAAE,CAAC,CAAC,IAAI,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,OAAO,EAAE,EAAE,KAAK,SAAS,CAAC,CAC/E,CAAA;AACF,CAAC","sourcesContent":["import type { paths } from '../__generated__/openapi.js'\nimport type { Asset } from '../types/asset.js'\nimport { pollAsset } from './assets.js'\nimport type { Transport } from './transport.js'\n\n/**\n * What `promote` reports back: the asset, the variant that becomes its default, and the new\n * variant the previous default is preserved as (a fresh id, promotable back).\n */\nexport type PromoteResult =\n\tpaths['/organizations/{organizationId}/libraries/{libraryId}/assets/{assetId}/promote']['post']['responses'][200]['content']['application/json']['data']\n\n/**\n * Promote one of an asset's variants to be its default: the variant's file becomes what the\n * asset serves, at the same URL. Raster queues the swap and answers first, so a read straight\n * after can still show the old default; `waitUntilPromoted` waits for it. Once it lands, the\n * previous default is a new variant and the promoted one's entry is gone, so refetch the\n * variants rather than patch them.\n */\nexport async function promoteVariant(\n\ttransport: Transport,\n\toptions: {\n\t\torganizationId: string\n\t\tlibraryId: string\n\t\t/** The parent asset whose default is being replaced. */\n\t\tassetId: string\n\t\t/** The variant to make the new default. */\n\t\tvariantId: string\n\t\tsignal?: AbortSignal\n\t}\n): Promise<PromoteResult> {\n\tconst { organizationId, libraryId, assetId, variantId, signal } = options\n\n\tconst url = `${transport.origin}/organizations/${encodeURIComponent(\n\t\torganizationId\n\t)}/libraries/${encodeURIComponent(libraryId)}/assets/${encodeURIComponent(assetId)}/promote`\n\n\tconst response = await transport.fetch(url, {\n\t\tmethod: 'POST',\n\t\theaders: { 'Content-Type': 'application/json' },\n\t\tbody: JSON.stringify({ variantId }),\n\t\tsignal,\n\t})\n\n\treturn transport.unwrap<PromoteResult>(response)\n}\n\n/**\n * Wait until a promote has landed: the promoted variant has left the asset's `variants`.\n * Resolves with the asset as it then reads, or with null if the swap had not landed when the\n * wait gave up.\n */\nexport function waitUntilPromoted(\n\ttransport: Transport,\n\toptions: {\n\t\torganizationId: string\n\t\tlibraryId: string\n\t\tassetId: string\n\t\tvariantId: string\n\t\tsignal?: AbortSignal\n\t}\n): Promise<Asset | null> {\n\tconst { variantId, ...read } = options\n\treturn pollAsset(\n\t\ttransport,\n\t\tread,\n\t\t(asset) => !(asset.variants ?? []).some((variant) => variant?.id === variantId)\n\t)\n}\n"]}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { components } from '../__generated__/openapi.js';
|
|
2
|
+
import type { Transport } from './transport.js';
|
|
3
|
+
/** Hits per request: the endpoint's documented maximum, so a grid fills before it pages. */
|
|
4
|
+
export declare const SEARCH_PAGE_SIZE = 50;
|
|
5
|
+
/**
|
|
6
|
+
* A search hit: a listed asset, plus what ranking adds. Search spans an organization, so a
|
|
7
|
+
* follow-up call takes its library from the hit's `libraryId`.
|
|
8
|
+
*/
|
|
9
|
+
export type SearchHit = components['schemas']['SearchHit'];
|
|
10
|
+
export type SearchResult = components['schemas']['SearchResult'];
|
|
11
|
+
/**
|
|
12
|
+
* Full-text search over name, description and tags, ranked by relevance, across the libraries
|
|
13
|
+
* the credential reaches in one organization, or a subset of them. A library the credential
|
|
14
|
+
* cannot reach fails the whole call, so pass only ids from `/me` or the library list. A blank
|
|
15
|
+
* query returns recent assets rather than matches, so skip the call when there is none.
|
|
16
|
+
*/
|
|
17
|
+
export declare function searchAssets(transport: Transport, options: {
|
|
18
|
+
organizationId: string;
|
|
19
|
+
query: string;
|
|
20
|
+
/** Narrow to these libraries; omitted, the search spans the organization. */
|
|
21
|
+
libraryIds?: Array<string>;
|
|
22
|
+
page?: number;
|
|
23
|
+
pageSize?: number;
|
|
24
|
+
signal?: AbortSignal;
|
|
25
|
+
}): Promise<SearchResult>;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Hits per request: the endpoint's documented maximum, so a grid fills before it pages. */
|
|
2
|
+
export const SEARCH_PAGE_SIZE = 50;
|
|
3
|
+
/**
|
|
4
|
+
* Full-text search over name, description and tags, ranked by relevance, across the libraries
|
|
5
|
+
* the credential reaches in one organization, or a subset of them. A library the credential
|
|
6
|
+
* cannot reach fails the whole call, so pass only ids from `/me` or the library list. A blank
|
|
7
|
+
* query returns recent assets rather than matches, so skip the call when there is none.
|
|
8
|
+
*/
|
|
9
|
+
export async function searchAssets(transport, options) {
|
|
10
|
+
const { organizationId, query, libraryIds, page, pageSize = SEARCH_PAGE_SIZE, signal } = options;
|
|
11
|
+
return transport.request(`/organizations/${encodeURIComponent(organizationId)}/search/assets`, {
|
|
12
|
+
searchParams: {
|
|
13
|
+
q: query,
|
|
14
|
+
libraries: libraryIds !== undefined && libraryIds.length > 0 ? libraryIds.join(',') : undefined,
|
|
15
|
+
page,
|
|
16
|
+
pageSize,
|
|
17
|
+
},
|
|
18
|
+
signal,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
//# sourceMappingURL=search.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"search.js","sourceRoot":"","sources":["../../src/api/search.ts"],"names":[],"mappings":"AAGA,4FAA4F;AAC5F,MAAM,CAAC,MAAM,gBAAgB,GAAG,EAAE,CAAA;AAUlC;;;;;GAKG;AACH,MAAM,CAAC,KAAK,UAAU,YAAY,CACjC,SAAoB,EACpB,OAQC;IAED,MAAM,EAAE,cAAc,EAAE,KAAK,EAAE,UAAU,EAAE,IAAI,EAAE,QAAQ,GAAG,gBAAgB,EAAE,MAAM,EAAE,GAAG,OAAO,CAAA;IAEhG,OAAO,SAAS,CAAC,OAAO,CACvB,kBAAkB,kBAAkB,CAAC,cAAc,CAAC,gBAAgB,EACpE;QACC,YAAY,EAAE;YACb,CAAC,EAAE,KAAK;YACR,SAAS,EACR,UAAU,KAAK,SAAS,IAAI,UAAU,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,UAAU,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,SAAS;YACrF,IAAI;YACJ,QAAQ;SACR;QACD,MAAM;KACN,CACD,CAAA;AACF,CAAC","sourcesContent":["import type { components } from '../__generated__/openapi.js'\nimport type { Transport } from './transport.js'\n\n/** Hits per request: the endpoint's documented maximum, so a grid fills before it pages. */\nexport const SEARCH_PAGE_SIZE = 50\n\n/**\n * A search hit: a listed asset, plus what ranking adds. Search spans an organization, so a\n * follow-up call takes its library from the hit's `libraryId`.\n */\nexport type SearchHit = components['schemas']['SearchHit']\n\nexport type SearchResult = components['schemas']['SearchResult']\n\n/**\n * Full-text search over name, description and tags, ranked by relevance, across the libraries\n * the credential reaches in one organization, or a subset of them. A library the credential\n * cannot reach fails the whole call, so pass only ids from `/me` or the library list. A blank\n * query returns recent assets rather than matches, so skip the call when there is none.\n */\nexport async function searchAssets(\n\ttransport: Transport,\n\toptions: {\n\t\torganizationId: string\n\t\tquery: string\n\t\t/** Narrow to these libraries; omitted, the search spans the organization. */\n\t\tlibraryIds?: Array<string>\n\t\tpage?: number\n\t\tpageSize?: number\n\t\tsignal?: AbortSignal\n\t}\n): Promise<SearchResult> {\n\tconst { organizationId, query, libraryIds, page, pageSize = SEARCH_PAGE_SIZE, signal } = options\n\n\treturn transport.request<SearchResult>(\n\t\t`/organizations/${encodeURIComponent(organizationId)}/search/assets`,\n\t\t{\n\t\t\tsearchParams: {\n\t\t\t\tq: query,\n\t\t\t\tlibraries:\n\t\t\t\t\tlibraryIds !== undefined && libraryIds.length > 0 ? libraryIds.join(',') : undefined,\n\t\t\t\tpage,\n\t\t\t\tpageSize,\n\t\t\t},\n\t\t\tsignal,\n\t\t}\n\t)\n}\n"]}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { SessionStore } from '../auth/session.js';
|
|
2
|
+
type QueryValue = string | number | undefined;
|
|
3
|
+
export type RequestOptions = {
|
|
4
|
+
searchParams?: Record<string, QueryValue>;
|
|
5
|
+
/**
|
|
6
|
+
* Aborts the request and frees its connection at once, so abandoned work cannot hold the
|
|
7
|
+
* pool and starve the requests that follow.
|
|
8
|
+
*/
|
|
9
|
+
signal?: AbortSignal;
|
|
10
|
+
/**
|
|
11
|
+
* Send this credential instead of the session's. Only for checking a credential before
|
|
12
|
+
* it is saved (`getMe`) — it is not refreshed on a 401.
|
|
13
|
+
*/
|
|
14
|
+
bearer?: string;
|
|
15
|
+
};
|
|
16
|
+
export type AuthorizedInit = {
|
|
17
|
+
method?: string;
|
|
18
|
+
/** No `Content-Type` for a multipart POST — `fetch` must set it itself to include the boundary. */
|
|
19
|
+
headers?: Record<string, string>;
|
|
20
|
+
body?: BodyInit;
|
|
21
|
+
signal?: AbortSignal;
|
|
22
|
+
/** See `RequestOptions.bearer`. */
|
|
23
|
+
bearer?: string;
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* What the API modules reach Raster through. Each client passes its own, so clients with
|
|
27
|
+
* different credentials can coexist on one page.
|
|
28
|
+
*/
|
|
29
|
+
export interface Transport {
|
|
30
|
+
readonly origin: string;
|
|
31
|
+
/** GET, with the `{ data }` envelope unwrapped. */
|
|
32
|
+
request<T>(path: string, options?: RequestOptions): Promise<T>;
|
|
33
|
+
/** Anything that is not a plain GET — the multipart upload, the JSON promote. */
|
|
34
|
+
fetch(url: string, init?: AuthorizedInit): Promise<Response>;
|
|
35
|
+
/** Read a response through the `{ data }` / `{ error }` envelope. */
|
|
36
|
+
unwrap<T>(response: Response): Promise<T>;
|
|
37
|
+
}
|
|
38
|
+
export declare function createTransport(options: {
|
|
39
|
+
origin: string;
|
|
40
|
+
session: SessionStore;
|
|
41
|
+
fetch: typeof globalThis.fetch;
|
|
42
|
+
}): Transport;
|
|
43
|
+
export {};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { API_VERSION } from '../constants.js';
|
|
2
|
+
import { RasterApiError, toFetchError } from './errors.js';
|
|
3
|
+
export function createTransport(options) {
|
|
4
|
+
const { origin, session, fetch: fetchImpl } = options;
|
|
5
|
+
/**
|
|
6
|
+
* `fetch` with the credential and the API version. A session's 401 refreshes the token and
|
|
7
|
+
* retries once, since a revoke or clock skew can end a token early. The retry keeps to the
|
|
8
|
+
* credential the request began under: a long upload can outlive a sign-in as somebody
|
|
9
|
+
* else, and must not then write to their account.
|
|
10
|
+
*/
|
|
11
|
+
async function authorizedFetch(url, init = {}) {
|
|
12
|
+
const { bearer, headers, ...rest } = init;
|
|
13
|
+
async function send(token) {
|
|
14
|
+
try {
|
|
15
|
+
return await fetchImpl(url, {
|
|
16
|
+
...rest,
|
|
17
|
+
headers: { ...headers, Authorization: `Bearer ${token}`, 'Api-Version': API_VERSION },
|
|
18
|
+
// A cached read after a mutation, such as a promote, would return the state
|
|
19
|
+
// before it and make the change look lost.
|
|
20
|
+
cache: 'no-store',
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
catch (caught) {
|
|
24
|
+
throw toFetchError(caught);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
if (bearer !== undefined)
|
|
28
|
+
return send(bearer);
|
|
29
|
+
// Read after `getAccessToken`, which hydrates the store: before it, an unhydrated store's
|
|
30
|
+
// null identity would decline every first retry.
|
|
31
|
+
const token = await session.getAccessToken();
|
|
32
|
+
const identity = session.credentialIdentity();
|
|
33
|
+
const response = await send(token);
|
|
34
|
+
if (response.status !== 401 || !session.isSignedIn())
|
|
35
|
+
return response;
|
|
36
|
+
const retry = await session.getAccessToken({ rejected: token });
|
|
37
|
+
// Compared only now: that refresh reads the store again, where another tab may have
|
|
38
|
+
// signed in as somebody else.
|
|
39
|
+
if (session.credentialIdentity() !== identity)
|
|
40
|
+
return response;
|
|
41
|
+
return send(retry);
|
|
42
|
+
}
|
|
43
|
+
/** A response's `data`. Anything else throws a `RasterApiError`. */
|
|
44
|
+
async function unwrap(response) {
|
|
45
|
+
// A 5xx may be an HTML error page rather than the JSON envelope.
|
|
46
|
+
const body = await response.json().catch(() => null);
|
|
47
|
+
const errorBody = readErrorBody(body);
|
|
48
|
+
if (errorBody !== null) {
|
|
49
|
+
throw new RasterApiError(errorBody.message, errorBody.code, response.status);
|
|
50
|
+
}
|
|
51
|
+
if (!response.ok) {
|
|
52
|
+
throw new RasterApiError(`Raster API request failed (HTTP ${response.status}).`, 'HTTP_ERROR', response.status);
|
|
53
|
+
}
|
|
54
|
+
if (typeof body !== 'object' || body === null || !('data' in body)) {
|
|
55
|
+
throw new RasterApiError('Unexpected response from the Raster API.', 'MALFORMED_RESPONSE', response.status);
|
|
56
|
+
}
|
|
57
|
+
return body.data;
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
origin,
|
|
61
|
+
async request(path, requestOptions = {}) {
|
|
62
|
+
const { searchParams, signal, bearer } = requestOptions;
|
|
63
|
+
const url = new URL(path, origin);
|
|
64
|
+
if (searchParams) {
|
|
65
|
+
for (const [key, value] of Object.entries(searchParams)) {
|
|
66
|
+
if (typeof value !== 'undefined')
|
|
67
|
+
url.searchParams.set(key, String(value));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const response = await authorizedFetch(url.toString(), { method: 'GET', signal, bearer });
|
|
71
|
+
return unwrap(response);
|
|
72
|
+
},
|
|
73
|
+
fetch: authorizedFetch,
|
|
74
|
+
unwrap,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
function readErrorBody(body) {
|
|
78
|
+
if (typeof body !== 'object' || body === null || !('error' in body))
|
|
79
|
+
return null;
|
|
80
|
+
const { error } = body;
|
|
81
|
+
if (typeof error !== 'object' || error === null)
|
|
82
|
+
return null;
|
|
83
|
+
const code = 'code' in error ? error.code : undefined;
|
|
84
|
+
const message = 'message' in error ? error.message : undefined;
|
|
85
|
+
return {
|
|
86
|
+
code: typeof code === 'string' ? code : 'UNKNOWN',
|
|
87
|
+
message: typeof message === 'string' ? message : 'The Raster API returned an error.',
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
//# sourceMappingURL=transport.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"transport.js","sourceRoot":"","sources":["../../src/api/transport.ts"],"names":[],"mappings":"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","sourcesContent":["import type { SessionStore } from '../auth/session.js'\nimport { API_VERSION } from '../constants.js'\nimport { RasterApiError, toFetchError } from './errors.js'\n\ntype QueryValue = string | number | undefined\n\nexport type RequestOptions = {\n\tsearchParams?: Record<string, QueryValue>\n\t/**\n\t * Aborts the request and frees its connection at once, so abandoned work cannot hold the\n\t * pool and starve the requests that follow.\n\t */\n\tsignal?: AbortSignal\n\t/**\n\t * Send this credential instead of the session's. Only for checking a credential before\n\t * it is saved (`getMe`) — it is not refreshed on a 401.\n\t */\n\tbearer?: string\n}\n\nexport type AuthorizedInit = {\n\tmethod?: string\n\t/** No `Content-Type` for a multipart POST — `fetch` must set it itself to include the boundary. */\n\theaders?: Record<string, string>\n\tbody?: BodyInit\n\tsignal?: AbortSignal\n\t/** See `RequestOptions.bearer`. */\n\tbearer?: string\n}\n\n/**\n * What the API modules reach Raster through. Each client passes its own, so clients with\n * different credentials can coexist on one page.\n */\nexport interface Transport {\n\treadonly origin: string\n\t/** GET, with the `{ data }` envelope unwrapped. */\n\trequest<T>(path: string, options?: RequestOptions): Promise<T>\n\t/** Anything that is not a plain GET — the multipart upload, the JSON promote. */\n\tfetch(url: string, init?: AuthorizedInit): Promise<Response>\n\t/** Read a response through the `{ data }` / `{ error }` envelope. */\n\tunwrap<T>(response: Response): Promise<T>\n}\n\nexport function createTransport(options: {\n\torigin: string\n\tsession: SessionStore\n\tfetch: typeof globalThis.fetch\n}): Transport {\n\tconst { origin, session, fetch: fetchImpl } = options\n\n\t/**\n\t * `fetch` with the credential and the API version. A session's 401 refreshes the token and\n\t * retries once, since a revoke or clock skew can end a token early. The retry keeps to the\n\t * credential the request began under: a long upload can outlive a sign-in as somebody\n\t * else, and must not then write to their account.\n\t */\n\tasync function authorizedFetch(url: string, init: AuthorizedInit = {}): Promise<Response> {\n\t\tconst { bearer, headers, ...rest } = init\n\n\t\tasync function send(token: string): Promise<Response> {\n\t\t\ttry {\n\t\t\t\treturn await fetchImpl(url, {\n\t\t\t\t\t...rest,\n\t\t\t\t\theaders: { ...headers, Authorization: `Bearer ${token}`, 'Api-Version': API_VERSION },\n\t\t\t\t\t// A cached read after a mutation, such as a promote, would return the state\n\t\t\t\t\t// before it and make the change look lost.\n\t\t\t\t\tcache: 'no-store',\n\t\t\t\t})\n\t\t\t} catch (caught) {\n\t\t\t\tthrow toFetchError(caught)\n\t\t\t}\n\t\t}\n\n\t\tif (bearer !== undefined) return send(bearer)\n\n\t\t// Read after `getAccessToken`, which hydrates the store: before it, an unhydrated store's\n\t\t// null identity would decline every first retry.\n\t\tconst token = await session.getAccessToken()\n\t\tconst identity = session.credentialIdentity()\n\t\tconst response = await send(token)\n\t\tif (response.status !== 401 || !session.isSignedIn()) return response\n\t\tconst retry = await session.getAccessToken({ rejected: token })\n\t\t// Compared only now: that refresh reads the store again, where another tab may have\n\t\t// signed in as somebody else.\n\t\tif (session.credentialIdentity() !== identity) return response\n\t\treturn send(retry)\n\t}\n\n\t/** A response's `data`. Anything else throws a `RasterApiError`. */\n\tasync function unwrap<T>(response: Response): Promise<T> {\n\t\t// A 5xx may be an HTML error page rather than the JSON envelope.\n\t\tconst body: { data: T } | null = await response.json().catch(() => null)\n\n\t\tconst errorBody = readErrorBody(body)\n\t\tif (errorBody !== null) {\n\t\t\tthrow new RasterApiError(errorBody.message, errorBody.code, response.status)\n\t\t}\n\t\tif (!response.ok) {\n\t\t\tthrow new RasterApiError(\n\t\t\t\t`Raster API request failed (HTTP ${response.status}).`,\n\t\t\t\t'HTTP_ERROR',\n\t\t\t\tresponse.status\n\t\t\t)\n\t\t}\n\n\t\tif (typeof body !== 'object' || body === null || !('data' in body)) {\n\t\t\tthrow new RasterApiError(\n\t\t\t\t'Unexpected response from the Raster API.',\n\t\t\t\t'MALFORMED_RESPONSE',\n\t\t\t\tresponse.status\n\t\t\t)\n\t\t}\n\n\t\treturn body.data\n\t}\n\n\treturn {\n\t\torigin,\n\n\t\tasync request<T>(path: string, requestOptions: RequestOptions = {}): Promise<T> {\n\t\t\tconst { searchParams, signal, bearer } = requestOptions\n\n\t\t\tconst url = new URL(path, origin)\n\t\t\tif (searchParams) {\n\t\t\t\tfor (const [key, value] of Object.entries(searchParams)) {\n\t\t\t\t\tif (typeof value !== 'undefined') url.searchParams.set(key, String(value))\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tconst response = await authorizedFetch(url.toString(), { method: 'GET', signal, bearer })\n\t\t\treturn unwrap<T>(response)\n\t\t},\n\n\t\tfetch: authorizedFetch,\n\t\tunwrap,\n\t}\n}\n\nfunction readErrorBody(body: unknown): { code: string; message: string } | null {\n\tif (typeof body !== 'object' || body === null || !('error' in body)) return null\n\n\tconst { error } = body\n\tif (typeof error !== 'object' || error === null) return null\n\n\tconst code = 'code' in error ? error.code : undefined\n\tconst message = 'message' in error ? error.message : undefined\n\treturn {\n\t\tcode: typeof code === 'string' ? code : 'UNKNOWN',\n\t\tmessage: typeof message === 'string' ? message : 'The Raster API returned an error.',\n\t}\n}\n"]}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { type Asset, type UploadedAsset } from '../types/asset.js';
|
|
2
|
+
import type { Transport } from './transport.js';
|
|
3
|
+
/**
|
|
4
|
+
* What can be uploaded: raw bytes, as Figma's `node.exportAsync` returns them, or a `Blob`
|
|
5
|
+
* such as the `File` a CMS plugin takes from an `<input type="file">`.
|
|
6
|
+
*/
|
|
7
|
+
export type UploadSource = Blob | {
|
|
8
|
+
bytes: Uint8Array;
|
|
9
|
+
filename: string;
|
|
10
|
+
contentType: string;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Upload an asset to a library: a new one, or, given `parentId`, a variant of that original in
|
|
14
|
+
* the same library. Raster returns the id and urls at once; the processed fields (dimensions,
|
|
15
|
+
* `sizes`, `blurhash`) and the asset's place in listings follow seconds later, so wait for
|
|
16
|
+
* them with `waitUntilProcessed`.
|
|
17
|
+
*/
|
|
18
|
+
export declare function uploadAsset(transport: Transport, options: {
|
|
19
|
+
organizationId: string;
|
|
20
|
+
libraryId: string;
|
|
21
|
+
/** The parent asset's id to attach under as a variant, or null for a new asset. */
|
|
22
|
+
parentId?: string | null;
|
|
23
|
+
source: UploadSource;
|
|
24
|
+
/** Overrides the name a `File` carries. A bare `Blob` without one is named `upload`. */
|
|
25
|
+
filename?: string;
|
|
26
|
+
signal?: AbortSignal;
|
|
27
|
+
}): Promise<UploadedAsset>;
|
|
28
|
+
/**
|
|
29
|
+
* Wait until Raster has processed an upload. Resolves with the asset — for a variant, its
|
|
30
|
+
* parent, once its `variants` carry the variant processed — or with null if it was still
|
|
31
|
+
* processing when the wait gave up.
|
|
32
|
+
*/
|
|
33
|
+
export declare function waitUntilProcessed(transport: Transport, options: {
|
|
34
|
+
organizationId: string;
|
|
35
|
+
libraryId: string;
|
|
36
|
+
assetId: string;
|
|
37
|
+
/** The parent the asset was uploaded under as a variant, if it was. */
|
|
38
|
+
parentId?: string | null;
|
|
39
|
+
signal?: AbortSignal;
|
|
40
|
+
}): Promise<Asset | null>;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { isProcessed } from '../types/asset.js';
|
|
2
|
+
import { pollAsset } from './assets.js';
|
|
3
|
+
import { RasterApiError } from './errors.js';
|
|
4
|
+
function toBlobAndName(source, filenameOverride) {
|
|
5
|
+
if (source instanceof Blob) {
|
|
6
|
+
// A `File` carries its own name; a bare `Blob` does not, and the API needs one.
|
|
7
|
+
const name = filenameOverride ?? ('name' in source && typeof source.name === 'string' ? source.name : null);
|
|
8
|
+
return { blob: source, filename: name ?? 'upload' };
|
|
9
|
+
}
|
|
10
|
+
const bytes = hasArrayBuffer(source.bytes) ? source.bytes : new Uint8Array(source.bytes);
|
|
11
|
+
const blob = new Blob([bytes], { type: source.contentType });
|
|
12
|
+
return { blob, filename: filenameOverride ?? source.filename };
|
|
13
|
+
}
|
|
14
|
+
/** Whether `Blob` takes these bytes as they are, which needs an `ArrayBuffer` behind them. */
|
|
15
|
+
function hasArrayBuffer(bytes) {
|
|
16
|
+
return bytes.buffer instanceof ArrayBuffer;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Upload an asset to a library: a new one, or, given `parentId`, a variant of that original in
|
|
20
|
+
* the same library. Raster returns the id and urls at once; the processed fields (dimensions,
|
|
21
|
+
* `sizes`, `blurhash`) and the asset's place in listings follow seconds later, so wait for
|
|
22
|
+
* them with `waitUntilProcessed`.
|
|
23
|
+
*/
|
|
24
|
+
export async function uploadAsset(transport, options) {
|
|
25
|
+
const { organizationId, libraryId, parentId, source, filename, signal } = options;
|
|
26
|
+
const url = `${transport.origin}/organizations/${encodeURIComponent(organizationId)}/libraries/${encodeURIComponent(libraryId)}/assets`;
|
|
27
|
+
const { blob, filename: name } = toBlobAndName(source, filename);
|
|
28
|
+
const form = new FormData();
|
|
29
|
+
// Field name is `files` even for a single-file upload — the API rejects any other name.
|
|
30
|
+
form.append('files', blob, name);
|
|
31
|
+
if (parentId != null && parentId !== '')
|
|
32
|
+
form.append('parentId', parentId);
|
|
33
|
+
const response = await transport.fetch(url, { method: 'POST', body: form, signal });
|
|
34
|
+
// The body is unchecked JSON: one without an asset must fail as malformed, not as a TypeError.
|
|
35
|
+
const data = await transport.unwrap(response);
|
|
36
|
+
const uploaded = Array.isArray(data?.assets) ? data.assets[0] : undefined;
|
|
37
|
+
if (!uploaded || typeof uploaded.id !== 'string') {
|
|
38
|
+
throw new RasterApiError('The upload succeeded but Raster returned no asset.', 'MALFORMED_RESPONSE', response.status);
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
id: uploaded.id,
|
|
42
|
+
parentId: uploaded.parentId ?? parentId ?? null,
|
|
43
|
+
url: uploaded.url ?? null,
|
|
44
|
+
appUrl: uploaded.appUrl ?? null,
|
|
45
|
+
name: uploaded.name ?? null,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Wait until Raster has processed an upload. Resolves with the asset — for a variant, its
|
|
50
|
+
* parent, once its `variants` carry the variant processed — or with null if it was still
|
|
51
|
+
* processing when the wait gave up.
|
|
52
|
+
*/
|
|
53
|
+
export function waitUntilProcessed(transport, options) {
|
|
54
|
+
const { assetId, parentId, ...read } = options;
|
|
55
|
+
if (parentId == null || parentId === '') {
|
|
56
|
+
return pollAsset(transport, { ...read, assetId }, isProcessed);
|
|
57
|
+
}
|
|
58
|
+
return pollAsset(transport, { ...read, assetId: parentId }, (parent) => (parent.variants ?? []).some((variant) => variant?.id === assetId && isProcessed(variant)));
|
|
59
|
+
}
|
|
60
|
+
//# sourceMappingURL=upload.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"upload.js","sourceRoot":"","sources":["../../src/api/upload.ts"],"names":[],"mappings":"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","sourcesContent":["import type { paths } from '../__generated__/openapi.js'\nimport { type Asset, type UploadedAsset, isProcessed } from '../types/asset.js'\nimport { pollAsset } from './assets.js'\nimport { RasterApiError } from './errors.js'\nimport type { Transport } from './transport.js'\n\n/**\n * What can be uploaded: raw bytes, as Figma's `node.exportAsync` returns them, or a `Blob`\n * such as the `File` a CMS plugin takes from an `<input type=\"file\">`.\n */\nexport type UploadSource = Blob | { bytes: Uint8Array; filename: string; contentType: string }\n\ntype UploadResponse =\n\tpaths['/organizations/{organizationId}/libraries/{libraryId}/assets']['post']['responses'][200]['content']['application/json']['data']\n\nfunction toBlobAndName(\n\tsource: UploadSource,\n\tfilenameOverride?: string\n): { blob: Blob; filename: string } {\n\tif (source instanceof Blob) {\n\t\t// A `File` carries its own name; a bare `Blob` does not, and the API needs one.\n\t\tconst name =\n\t\t\tfilenameOverride ?? ('name' in source && typeof source.name === 'string' ? source.name : null)\n\t\treturn { blob: source, filename: name ?? 'upload' }\n\t}\n\tconst bytes = hasArrayBuffer(source.bytes) ? source.bytes : new Uint8Array(source.bytes)\n\tconst blob = new Blob([bytes], { type: source.contentType })\n\treturn { blob, filename: filenameOverride ?? source.filename }\n}\n\n/** Whether `Blob` takes these bytes as they are, which needs an `ArrayBuffer` behind them. */\nfunction hasArrayBuffer(bytes: Uint8Array): bytes is Uint8Array<ArrayBuffer> {\n\treturn bytes.buffer instanceof ArrayBuffer\n}\n\n/**\n * Upload an asset to a library: a new one, or, given `parentId`, a variant of that original in\n * the same library. Raster returns the id and urls at once; the processed fields (dimensions,\n * `sizes`, `blurhash`) and the asset's place in listings follow seconds later, so wait for\n * them with `waitUntilProcessed`.\n */\nexport async function uploadAsset(\n\ttransport: Transport,\n\toptions: {\n\t\torganizationId: string\n\t\tlibraryId: string\n\t\t/** The parent asset's id to attach under as a variant, or null for a new asset. */\n\t\tparentId?: string | null\n\t\tsource: UploadSource\n\t\t/** Overrides the name a `File` carries. A bare `Blob` without one is named `upload`. */\n\t\tfilename?: string\n\t\tsignal?: AbortSignal\n\t}\n): Promise<UploadedAsset> {\n\tconst { organizationId, libraryId, parentId, source, filename, signal } = options\n\n\tconst url = `${transport.origin}/organizations/${encodeURIComponent(\n\t\torganizationId\n\t)}/libraries/${encodeURIComponent(libraryId)}/assets`\n\n\tconst { blob, filename: name } = toBlobAndName(source, filename)\n\n\tconst form = new FormData()\n\t// Field name is `files` even for a single-file upload — the API rejects any other name.\n\tform.append('files', blob, name)\n\tif (parentId != null && parentId !== '') form.append('parentId', parentId)\n\n\tconst response = await transport.fetch(url, { method: 'POST', body: form, signal })\n\n\t// The body is unchecked JSON: one without an asset must fail as malformed, not as a TypeError.\n\tconst data = await transport.unwrap<UploadResponse>(response)\n\tconst uploaded = Array.isArray(data?.assets) ? data.assets[0] : undefined\n\n\tif (!uploaded || typeof uploaded.id !== 'string') {\n\t\tthrow new RasterApiError(\n\t\t\t'The upload succeeded but Raster returned no asset.',\n\t\t\t'MALFORMED_RESPONSE',\n\t\t\tresponse.status\n\t\t)\n\t}\n\n\treturn {\n\t\tid: uploaded.id,\n\t\tparentId: uploaded.parentId ?? parentId ?? null,\n\t\turl: uploaded.url ?? null,\n\t\tappUrl: uploaded.appUrl ?? null,\n\t\tname: uploaded.name ?? null,\n\t}\n}\n\n/**\n * Wait until Raster has processed an upload. Resolves with the asset — for a variant, its\n * parent, once its `variants` carry the variant processed — or with null if it was still\n * processing when the wait gave up.\n */\nexport function waitUntilProcessed(\n\ttransport: Transport,\n\toptions: {\n\t\torganizationId: string\n\t\tlibraryId: string\n\t\tassetId: string\n\t\t/** The parent the asset was uploaded under as a variant, if it was. */\n\t\tparentId?: string | null\n\t\tsignal?: AbortSignal\n\t}\n): Promise<Asset | null> {\n\tconst { assetId, parentId, ...read } = options\n\tif (parentId == null || parentId === '') {\n\t\treturn pollAsset(transport, { ...read, assetId }, isProcessed)\n\t}\n\treturn pollAsset(transport, { ...read, assetId: parentId }, (parent) =>\n\t\t(parent.variants ?? []).some((variant) => variant?.id === assetId && isProcessed(variant))\n\t)\n}\n"]}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { OAuthSession } from '../types/session.js';
|
|
2
|
+
import { type AuthContext } from './http.js';
|
|
3
|
+
export type DeviceAuthorization = {
|
|
4
|
+
deviceCode: string;
|
|
5
|
+
/** What the user confirms in the browser, e.g. `BCDF-GHJK`. */
|
|
6
|
+
userCode: string;
|
|
7
|
+
/** The consent page with the code already filled in. */
|
|
8
|
+
verificationUrl: string;
|
|
9
|
+
/** Seconds to wait between polls. */
|
|
10
|
+
interval: number;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Sign in with the device grant (RFC 8628), which needs no redirect, so it works in a Figma
|
|
14
|
+
* plugin's null-origin iframe. `onCode` fires as soon as the code exists, so the host can show
|
|
15
|
+
* it and open the browser; the promise then settles when the user approves (a session), denies
|
|
16
|
+
* (`access_denied`), lets the code lapse (`expired_token`), or the caller aborts (`ABORTED`).
|
|
17
|
+
*/
|
|
18
|
+
export declare function signInWithDevice(context: AuthContext, options: {
|
|
19
|
+
clientName: string;
|
|
20
|
+
resource: string;
|
|
21
|
+
/**
|
|
22
|
+
* This install's existing registration, reused so Raster sees one client rather than a
|
|
23
|
+
* new one per sign-in. Re-registers when absent, or when Raster no longer knows it
|
|
24
|
+
* (`invalid_client`).
|
|
25
|
+
*/
|
|
26
|
+
clientId: string | null;
|
|
27
|
+
/** Told each client this sign-in registers, so a caller can keep it if the sign-in fails. */
|
|
28
|
+
onRegister?: (clientId: string) => void;
|
|
29
|
+
scope: 'user' | 'org';
|
|
30
|
+
onCode: (device: DeviceAuthorization) => void;
|
|
31
|
+
signal: AbortSignal;
|
|
32
|
+
}): Promise<OAuthSession>;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { RasterApiError } from '../api/errors.js';
|
|
2
|
+
import { authPost, sleep, toSession } from './http.js';
|
|
3
|
+
import { registerClient } from './register.js';
|
|
4
|
+
/** Back off by this much on `slow_down` (RFC 8628 §3.5). */
|
|
5
|
+
const SLOW_DOWN_SECONDS = 5;
|
|
6
|
+
async function startDeviceAuthorization(context, options) {
|
|
7
|
+
const started = await authPost(context, '/oauth/device_authorization', {
|
|
8
|
+
client_id: options.clientId,
|
|
9
|
+
scope: options.scope,
|
|
10
|
+
// Names the API as the audience (RFC 8707): the token is refused anywhere else.
|
|
11
|
+
resource: options.resource,
|
|
12
|
+
});
|
|
13
|
+
return {
|
|
14
|
+
deviceCode: started.device_code,
|
|
15
|
+
userCode: started.user_code,
|
|
16
|
+
verificationUrl: started.verification_uri_complete,
|
|
17
|
+
interval: started.interval,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Sign in with the device grant (RFC 8628), which needs no redirect, so it works in a Figma
|
|
22
|
+
* plugin's null-origin iframe. `onCode` fires as soon as the code exists, so the host can show
|
|
23
|
+
* it and open the browser; the promise then settles when the user approves (a session), denies
|
|
24
|
+
* (`access_denied`), lets the code lapse (`expired_token`), or the caller aborts (`ABORTED`).
|
|
25
|
+
*/
|
|
26
|
+
export async function signInWithDevice(context, options) {
|
|
27
|
+
const { clientName, resource, scope, onCode, signal } = options;
|
|
28
|
+
async function register() {
|
|
29
|
+
const registered = await registerClient(context, { clientName });
|
|
30
|
+
options.onRegister?.(registered);
|
|
31
|
+
return registered;
|
|
32
|
+
}
|
|
33
|
+
let clientId = options.clientId ?? (await register());
|
|
34
|
+
let device;
|
|
35
|
+
try {
|
|
36
|
+
device = await startDeviceAuthorization(context, { clientId, resource, scope });
|
|
37
|
+
}
|
|
38
|
+
catch (caught) {
|
|
39
|
+
if (!(caught instanceof RasterApiError) || caught.code !== 'invalid_client')
|
|
40
|
+
throw caught;
|
|
41
|
+
// The stored registration is gone; take a fresh one and carry on rather than making
|
|
42
|
+
// the user retry a sign-in that failed for a reason they cannot see.
|
|
43
|
+
clientId = await register();
|
|
44
|
+
device = await startDeviceAuthorization(context, { clientId, resource, scope });
|
|
45
|
+
}
|
|
46
|
+
onCode(device);
|
|
47
|
+
let interval = device.interval;
|
|
48
|
+
for (;;) {
|
|
49
|
+
await sleep(interval * 1000, signal);
|
|
50
|
+
try {
|
|
51
|
+
const token = await authPost(context, '/oauth/token', {
|
|
52
|
+
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
|
|
53
|
+
device_code: device.deviceCode,
|
|
54
|
+
client_id: clientId,
|
|
55
|
+
}, { signal });
|
|
56
|
+
return toSession(clientId, token);
|
|
57
|
+
}
|
|
58
|
+
catch (caught) {
|
|
59
|
+
const code = caught instanceof RasterApiError ? caught.code : null;
|
|
60
|
+
if (code === 'authorization_pending')
|
|
61
|
+
continue;
|
|
62
|
+
if (code === 'slow_down') {
|
|
63
|
+
interval += SLOW_DOWN_SECONDS;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
throw caught;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
//# sourceMappingURL=device.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"device.js","sourceRoot":"","sources":["../../src/auth/device.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAA;AAEjD,OAAO,EAAwC,QAAQ,EAAE,KAAK,EAAE,SAAS,EAAE,MAAM,WAAW,CAAA;AAC5F,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAA;AAE9C,4DAA4D;AAC5D,MAAM,iBAAiB,GAAG,CAAC,CAAA;AAY3B,KAAK,UAAU,wBAAwB,CACtC,OAAoB,EACpB,OAAsE;IAEtE,MAAM,OAAO,GAAG,MAAM,QAAQ,CAK3B,OAAO,EAAE,6BAA6B,EAAE;QAC1C,SAAS,EAAE,OAAO,CAAC,QAAQ;QAC3B,KAAK,EAAE,OAAO,CAAC,KAAK;QACpB,gFAAgF;QAChF,QAAQ,EAAE,OAAO,CAAC,QAAQ;KAC1B,CAAC,CAAA;IAEF,OAAO;QACN,UAAU,EAAE,OAAO,CAAC,WAAW;QAC/B,QAAQ,EAAE,OAAO,CAAC,SAAS;QAC3B,eAAe,EAAE,OAAO,CAAC,yBAAyB;QAClD,QAAQ,EAAE,OAAO,CAAC,QAAQ;KAC1B,CAAA;AACF,CAAC;AAED;;;;;GAKG;AACH,MAAM,CAAC,KAAK,UAAU,gBAAgB,CACrC,OAAoB,EACpB,OAcC;IAED,MAAM,EAAE,UAAU,EAAE,QAAQ,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,GAAG,OAAO,CAAA;IAE/D,KAAK,UAAU,QAAQ;QACtB,MAAM,UAAU,GAAG,MAAM,cAAc,CAAC,OAAO,EAAE,EAAE,UAAU,EAAE,CAAC,CAAA;QAChE,OAAO,CAAC,UAAU,EAAE,CAAC,UAAU,CAAC,CAAA;QAChC,OAAO,UAAU,CAAA;IAClB,CAAC;IAED,IAAI,QAAQ,GAAG,OAAO,CAAC,QAAQ,IAAI,CAAC,MAAM,QAAQ,EAAE,CAAC,CAAA;IACrD,IAAI,MAA2B,CAAA;IAC/B,IAAI,CAAC;QACJ,MAAM,GAAG,MAAM,wBAAwB,CAAC,OAAO,EAAE,EAAE,QAAQ,EAAE,QAAQ,EAAE,KAAK,EAAE,CAAC,CAAA;IAChF,CAAC;IAAC,OAAO,MAAM,EAAE,CAAC;QACjB,IAAI,CAAC,CAAC,MAAM,YAAY,cAAc,CAAC,IAAI,MAAM,CAAC,IAAI,KAAK,gBAAgB;YAAE,MAAM,MAAM,CAAA;QACzF,oFAAoF;QACpF,qEAAqE;QACrE,QAAQ,GAAG,MAAM,QAAQ,EAAE,CAAA;QAC3B,MAAM,GAAG,MAAM,wBAAwB,CAAC,OAAO,EAAE,EAAE,QAAQ,EAAE,QAAQ,EAAE,KAAK,EAAE,CAAC,CAAA;IAChF,CAAC;IACD,MAAM,CAAC,MAAM,CAAC,CAAA;IAEd,IAAI,QAAQ,GAAG,MAAM,CAAC,QAAQ,CAAA;IAC9B,SAAS,CAAC;QACT,MAAM,KAAK,CAAC,QAAQ,GAAG,IAAI,EAAE,MAAM,CAAC,CAAA;QACpC,IAAI,CAAC;YACJ,MAAM,KAAK,GAAG,MAAM,QAAQ,CAC3B,OAAO,EACP,cAAc,EACd;gBACC,UAAU,EAAE,8CAA8C;gBAC1D,WAAW,EAAE,MAAM,CAAC,UAAU;gBAC9B,SAAS,EAAE,QAAQ;aACnB,EACD,EAAE,MAAM,EAAE,CACV,CAAA;YACD,OAAO,SAAS,CAAC,QAAQ,EAAE,KAAK,CAAC,CAAA;QAClC,CAAC;QAAC,OAAO,MAAM,EAAE,CAAC;YACjB,MAAM,IAAI,GAAG,MAAM,YAAY,cAAc,CAAC,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAA;YAClE,IAAI,IAAI,KAAK,uBAAuB;gBAAE,SAAQ;YAC9C,IAAI,IAAI,KAAK,WAAW,EAAE,CAAC;gBAC1B,QAAQ,IAAI,iBAAiB,CAAA;gBAC7B,SAAQ;YACT,CAAC;YACD,MAAM,MAAM,CAAA;QACb,CAAC;IACF,CAAC;AACF,CAAC","sourcesContent":["import { RasterApiError } from '../api/errors.js'\nimport type { OAuthSession } from '../types/session.js'\nimport { type AuthContext, type TokenResponse, authPost, sleep, toSession } from './http.js'\nimport { registerClient } from './register.js'\n\n/** Back off by this much on `slow_down` (RFC 8628 §3.5). */\nconst SLOW_DOWN_SECONDS = 5\n\nexport type DeviceAuthorization = {\n\tdeviceCode: string\n\t/** What the user confirms in the browser, e.g. `BCDF-GHJK`. */\n\tuserCode: string\n\t/** The consent page with the code already filled in. */\n\tverificationUrl: string\n\t/** Seconds to wait between polls. */\n\tinterval: number\n}\n\nasync function startDeviceAuthorization(\n\tcontext: AuthContext,\n\toptions: { clientId: string; resource: string; scope: 'user' | 'org' }\n): Promise<DeviceAuthorization> {\n\tconst started = await authPost<{\n\t\tdevice_code: string\n\t\tuser_code: string\n\t\tverification_uri_complete: string\n\t\tinterval: number\n\t}>(context, '/oauth/device_authorization', {\n\t\tclient_id: options.clientId,\n\t\tscope: options.scope,\n\t\t// Names the API as the audience (RFC 8707): the token is refused anywhere else.\n\t\tresource: options.resource,\n\t})\n\n\treturn {\n\t\tdeviceCode: started.device_code,\n\t\tuserCode: started.user_code,\n\t\tverificationUrl: started.verification_uri_complete,\n\t\tinterval: started.interval,\n\t}\n}\n\n/**\n * Sign in with the device grant (RFC 8628), which needs no redirect, so it works in a Figma\n * plugin's null-origin iframe. `onCode` fires as soon as the code exists, so the host can show\n * it and open the browser; the promise then settles when the user approves (a session), denies\n * (`access_denied`), lets the code lapse (`expired_token`), or the caller aborts (`ABORTED`).\n */\nexport async function signInWithDevice(\n\tcontext: AuthContext,\n\toptions: {\n\t\tclientName: string\n\t\tresource: string\n\t\t/**\n\t\t * This install's existing registration, reused so Raster sees one client rather than a\n\t\t * new one per sign-in. Re-registers when absent, or when Raster no longer knows it\n\t\t * (`invalid_client`).\n\t\t */\n\t\tclientId: string | null\n\t\t/** Told each client this sign-in registers, so a caller can keep it if the sign-in fails. */\n\t\tonRegister?: (clientId: string) => void\n\t\tscope: 'user' | 'org'\n\t\tonCode: (device: DeviceAuthorization) => void\n\t\tsignal: AbortSignal\n\t}\n): Promise<OAuthSession> {\n\tconst { clientName, resource, scope, onCode, signal } = options\n\n\tasync function register(): Promise<string> {\n\t\tconst registered = await registerClient(context, { clientName })\n\t\toptions.onRegister?.(registered)\n\t\treturn registered\n\t}\n\n\tlet clientId = options.clientId ?? (await register())\n\tlet device: DeviceAuthorization\n\ttry {\n\t\tdevice = await startDeviceAuthorization(context, { clientId, resource, scope })\n\t} catch (caught) {\n\t\tif (!(caught instanceof RasterApiError) || caught.code !== 'invalid_client') throw caught\n\t\t// The stored registration is gone; take a fresh one and carry on rather than making\n\t\t// the user retry a sign-in that failed for a reason they cannot see.\n\t\tclientId = await register()\n\t\tdevice = await startDeviceAuthorization(context, { clientId, resource, scope })\n\t}\n\tonCode(device)\n\n\tlet interval = device.interval\n\tfor (;;) {\n\t\tawait sleep(interval * 1000, signal)\n\t\ttry {\n\t\t\tconst token = await authPost<TokenResponse>(\n\t\t\t\tcontext,\n\t\t\t\t'/oauth/token',\n\t\t\t\t{\n\t\t\t\t\tgrant_type: 'urn:ietf:params:oauth:grant-type:device_code',\n\t\t\t\t\tdevice_code: device.deviceCode,\n\t\t\t\t\tclient_id: clientId,\n\t\t\t\t},\n\t\t\t\t{ signal }\n\t\t\t)\n\t\t\treturn toSession(clientId, token)\n\t\t} catch (caught) {\n\t\t\tconst code = caught instanceof RasterApiError ? caught.code : null\n\t\t\tif (code === 'authorization_pending') continue\n\t\t\tif (code === 'slow_down') {\n\t\t\t\tinterval += SLOW_DOWN_SECONDS\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tthrow caught\n\t\t}\n\t}\n}\n"]}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { OAuthSession } from '../types/session.js';
|
|
2
|
+
/** The token endpoint's response, in OAuth's own snake_case. */
|
|
3
|
+
export type TokenResponse = {
|
|
4
|
+
access_token: string;
|
|
5
|
+
expires_in: number;
|
|
6
|
+
refresh_token: string;
|
|
7
|
+
};
|
|
8
|
+
export type AuthContext = {
|
|
9
|
+
authOrigin: string;
|
|
10
|
+
fetch: typeof globalThis.fetch;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* POST to the authorization server. Bodies are form-encoded as OAuth expects — a "simple"
|
|
14
|
+
* request, so no CORS preflight — except registration, whose metadata is JSON. Failures
|
|
15
|
+
* come back as `{ error, error_description }` and are thrown as `RasterApiError` with the
|
|
16
|
+
* OAuth error as `code`, so the poll loop can branch on `authorization_pending` and friends.
|
|
17
|
+
*/
|
|
18
|
+
export declare function authPost<T>(context: AuthContext, path: string, body: Record<string, unknown>, options?: {
|
|
19
|
+
json?: boolean;
|
|
20
|
+
signal?: AbortSignal;
|
|
21
|
+
}): Promise<T>;
|
|
22
|
+
/** Build the persisted session from a token response, resolving `expires_in` to a deadline. */
|
|
23
|
+
export declare function toSession(clientId: string, token: TokenResponse): OAuthSession;
|
|
24
|
+
/** A `setTimeout` that an `AbortSignal` can cut short, rejecting with `ABORTED`. */
|
|
25
|
+
export declare function sleep(ms: number, signal: AbortSignal): Promise<void>;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { RasterApiError, toFetchError } from '../api/errors.js';
|
|
2
|
+
/**
|
|
3
|
+
* POST to the authorization server. Bodies are form-encoded as OAuth expects — a "simple"
|
|
4
|
+
* request, so no CORS preflight — except registration, whose metadata is JSON. Failures
|
|
5
|
+
* come back as `{ error, error_description }` and are thrown as `RasterApiError` with the
|
|
6
|
+
* OAuth error as `code`, so the poll loop can branch on `authorization_pending` and friends.
|
|
7
|
+
*/
|
|
8
|
+
export async function authPost(context, path, body, options = {}) {
|
|
9
|
+
let response;
|
|
10
|
+
try {
|
|
11
|
+
response = await context.fetch(`${context.authOrigin}${path}`, {
|
|
12
|
+
method: 'POST',
|
|
13
|
+
headers: {
|
|
14
|
+
'Content-Type': options.json ? 'application/json' : 'application/x-www-form-urlencoded',
|
|
15
|
+
},
|
|
16
|
+
body: options.json
|
|
17
|
+
? JSON.stringify(body)
|
|
18
|
+
: new URLSearchParams(Object.entries(body).map(([key, value]) => [key, String(value)])).toString(),
|
|
19
|
+
// Raster answers these itself; a redirect comes from something in between, such as a
|
|
20
|
+
// captive portal or a protected preview. Followed, a 307 or 308 would post the body,
|
|
21
|
+
// refresh token included, to wherever it points.
|
|
22
|
+
redirect: 'error',
|
|
23
|
+
signal: options.signal,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
catch (caught) {
|
|
27
|
+
throw toFetchError(caught);
|
|
28
|
+
}
|
|
29
|
+
const payload = await response.json().catch(() => null);
|
|
30
|
+
if (!response.ok) {
|
|
31
|
+
const failure = typeof payload === 'object' && payload !== null ? payload : {};
|
|
32
|
+
const error = 'error' in failure ? failure.error : undefined;
|
|
33
|
+
const description = 'error_description' in failure ? failure.error_description : undefined;
|
|
34
|
+
throw new RasterApiError(typeof description === 'string'
|
|
35
|
+
? description
|
|
36
|
+
: `Raster sign-in failed (HTTP ${response.status}).`, typeof error === 'string' ? error : 'HTTP_ERROR', response.status);
|
|
37
|
+
}
|
|
38
|
+
// A 2xx without a JSON object came from something other than Raster, such as a login page.
|
|
39
|
+
if (typeof payload !== 'object' || payload === null) {
|
|
40
|
+
throw new RasterApiError('Unexpected response from the Raster sign-in server.', 'MALFORMED_RESPONSE', response.status);
|
|
41
|
+
}
|
|
42
|
+
return payload;
|
|
43
|
+
}
|
|
44
|
+
/** Build the persisted session from a token response, resolving `expires_in` to a deadline. */
|
|
45
|
+
export function toSession(clientId, token) {
|
|
46
|
+
return {
|
|
47
|
+
clientId,
|
|
48
|
+
accessToken: token.access_token,
|
|
49
|
+
refreshToken: token.refresh_token,
|
|
50
|
+
expiresAt: Date.now() + token.expires_in * 1000,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/** A `setTimeout` that an `AbortSignal` can cut short, rejecting with `ABORTED`. */
|
|
54
|
+
export function sleep(ms, signal) {
|
|
55
|
+
return new Promise((resolve, reject) => {
|
|
56
|
+
const timer = setTimeout(() => {
|
|
57
|
+
signal.removeEventListener('abort', onAbort);
|
|
58
|
+
resolve();
|
|
59
|
+
}, ms);
|
|
60
|
+
function onAbort() {
|
|
61
|
+
clearTimeout(timer);
|
|
62
|
+
reject(new RasterApiError('Sign-in cancelled.', 'ABORTED', 0));
|
|
63
|
+
}
|
|
64
|
+
if (signal.aborted)
|
|
65
|
+
onAbort();
|
|
66
|
+
else
|
|
67
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
//# sourceMappingURL=http.js.map
|