@fastrelay/js-sdk 0.1.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/README.md +134 -0
- package/dist/client.d.ts +159 -0
- package/dist/client.js +515 -0
- package/dist/error.d.ts +28 -0
- package/dist/error.js +24 -0
- package/dist/feed.d.ts +63 -0
- package/dist/feed.js +125 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +8 -0
- package/dist/polling.d.ts +30 -0
- package/dist/polling.js +82 -0
- package/dist/realtime.d.ts +101 -0
- package/dist/realtime.js +521 -0
- package/dist/types.d.ts +190 -0
- package/dist/types.js +3 -0
- package/dist/utils.d.ts +12 -0
- package/dist/utils.js +77 -0
- package/dist/video-upload.d.ts +44 -0
- package/dist/video-upload.js +97 -0
- package/package.json +29 -0
package/dist/utils.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { FeedActivityQuery } from './types.ts';
|
|
2
|
+
export type QueryValue = string | number | boolean | null | undefined | Array<string | number | boolean | null | undefined>;
|
|
3
|
+
export type QueryMap = Record<string, unknown>;
|
|
4
|
+
export declare function toAbsoluteUrl(baseUrl: string, path: string, query?: QueryMap): string;
|
|
5
|
+
export declare function buildFeedActivityQuery(options?: FeedActivityQuery): QueryMap;
|
|
6
|
+
export type FeedTarget = string | {
|
|
7
|
+
group: string;
|
|
8
|
+
id: string;
|
|
9
|
+
};
|
|
10
|
+
export declare function resolveFeedTarget(target: unknown): string;
|
|
11
|
+
export declare function splitFeedId(feedId: string): [group: string, id: string];
|
|
12
|
+
export declare function parseJsonSafely(text: string): unknown;
|
package/dist/utils.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
export function toAbsoluteUrl(baseUrl, path, query) {
|
|
2
|
+
const normalizedBase = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
|
|
3
|
+
const normalizedPath = path.startsWith('/') ? path.slice(1) : path;
|
|
4
|
+
const url = new URL(normalizedPath, normalizedBase);
|
|
5
|
+
if (query) {
|
|
6
|
+
for (const [key, value] of Object.entries(query)) {
|
|
7
|
+
if (value === null || value === undefined)
|
|
8
|
+
continue;
|
|
9
|
+
if (Array.isArray(value)) {
|
|
10
|
+
for (const item of value) {
|
|
11
|
+
if (item !== null && item !== undefined) {
|
|
12
|
+
url.searchParams.append(key, String(item));
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
else {
|
|
17
|
+
url.searchParams.append(key, String(value));
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return url.toString();
|
|
22
|
+
}
|
|
23
|
+
export function buildFeedActivityQuery(options) {
|
|
24
|
+
if (!options)
|
|
25
|
+
return {};
|
|
26
|
+
const query = {};
|
|
27
|
+
if ('limit' in options)
|
|
28
|
+
query.limit = options.limit;
|
|
29
|
+
if ('cursor' in options)
|
|
30
|
+
query.cursor = options.cursor;
|
|
31
|
+
if ('view' in options)
|
|
32
|
+
query.view = options.view;
|
|
33
|
+
if ('markSeen' in options)
|
|
34
|
+
query.markSeen = options.markSeen;
|
|
35
|
+
if ('markRead' in options) {
|
|
36
|
+
const markRead = options.markRead;
|
|
37
|
+
query.markRead = Array.isArray(markRead) ? markRead.join(',') : markRead;
|
|
38
|
+
}
|
|
39
|
+
if (options.filter && typeof options.filter === 'object') {
|
|
40
|
+
for (const [key, value] of Object.entries(options.filter)) {
|
|
41
|
+
query[`filter[${key}]`] = value;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return query;
|
|
45
|
+
}
|
|
46
|
+
export function resolveFeedTarget(target) {
|
|
47
|
+
if (typeof target === 'string' && target.length > 0) {
|
|
48
|
+
return target;
|
|
49
|
+
}
|
|
50
|
+
if (target && typeof target === 'object') {
|
|
51
|
+
const { group, id } = target;
|
|
52
|
+
if (typeof group === 'string' &&
|
|
53
|
+
group.length > 0 &&
|
|
54
|
+
typeof id === 'string' &&
|
|
55
|
+
id.length > 0) {
|
|
56
|
+
return `${group}:${id}`;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
throw new TypeError("target must be a feed string like 'user:john' or {group, id}.");
|
|
60
|
+
}
|
|
61
|
+
export function splitFeedId(feedId) {
|
|
62
|
+
const separatorIndex = feedId.indexOf(':');
|
|
63
|
+
if (separatorIndex <= 0 || separatorIndex === feedId.length - 1) {
|
|
64
|
+
throw new TypeError(`Invalid feed id '${feedId}'. Expected format '{group}:{id}'.`);
|
|
65
|
+
}
|
|
66
|
+
return [feedId.slice(0, separatorIndex), feedId.slice(separatorIndex + 1)];
|
|
67
|
+
}
|
|
68
|
+
export function parseJsonSafely(text) {
|
|
69
|
+
if (text.trim() === '')
|
|
70
|
+
return null;
|
|
71
|
+
try {
|
|
72
|
+
return JSON.parse(text);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return text;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { FastrelayClient } from './client.ts';
|
|
2
|
+
export interface FastrelayVideoUploadProgress {
|
|
3
|
+
bytesUploaded: number;
|
|
4
|
+
totalBytes: number;
|
|
5
|
+
fraction: number;
|
|
6
|
+
}
|
|
7
|
+
export interface FastrelayVideoUploadResult {
|
|
8
|
+
videoId: string;
|
|
9
|
+
uploadUrl: string;
|
|
10
|
+
bytesUploaded: number;
|
|
11
|
+
}
|
|
12
|
+
export declare class FastrelayVideoUploadError extends Error {
|
|
13
|
+
readonly statusCode?: number;
|
|
14
|
+
readonly cause?: unknown;
|
|
15
|
+
constructor(message: string, options?: {
|
|
16
|
+
statusCode?: number;
|
|
17
|
+
cause?: unknown;
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Drives a tus 1.0.0 upload of `data` to `uploadUrl` (the pre-authorized
|
|
22
|
+
* Cloudflare Stream endpoint returned by createVideoUploadUrl). Calls
|
|
23
|
+
* `onProgress` after each PATCH chunk; resolves with the total bytes accepted.
|
|
24
|
+
*/
|
|
25
|
+
export declare function tusUploadBytes({ uploadUrl, data, chunkSize, onProgress, fetchImpl, }: {
|
|
26
|
+
uploadUrl: string;
|
|
27
|
+
data: Uint8Array | ArrayBuffer;
|
|
28
|
+
chunkSize?: number;
|
|
29
|
+
onProgress?: (progress: FastrelayVideoUploadProgress) => void;
|
|
30
|
+
fetchImpl?: typeof fetch;
|
|
31
|
+
}): Promise<number>;
|
|
32
|
+
/**
|
|
33
|
+
* High-level helper: mints a tus upload URL via the backend, then uploads
|
|
34
|
+
* `data` directly to Cloudflare Stream. The caller listens for `video.ready`
|
|
35
|
+
* on the realtime channel (or polls client.getVideo) for the final state.
|
|
36
|
+
*/
|
|
37
|
+
export declare function uploadVideoBytes(client: FastrelayClient, { data, filename, mimeType, chunkSize, onProgress, fetchImpl, }: {
|
|
38
|
+
data: Uint8Array | ArrayBuffer;
|
|
39
|
+
filename: string;
|
|
40
|
+
mimeType: string;
|
|
41
|
+
chunkSize?: number;
|
|
42
|
+
onProgress?: (progress: FastrelayVideoUploadProgress) => void;
|
|
43
|
+
fetchImpl?: typeof fetch;
|
|
44
|
+
}): Promise<FastrelayVideoUploadResult>;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
export class FastrelayVideoUploadError extends Error {
|
|
2
|
+
statusCode;
|
|
3
|
+
cause;
|
|
4
|
+
constructor(message, options = {}) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = 'FastrelayVideoUploadError';
|
|
7
|
+
this.statusCode = options.statusCode;
|
|
8
|
+
this.cause = options.cause;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
const DEFAULT_CHUNK_SIZE = 50 * 1024 * 1024;
|
|
12
|
+
/**
|
|
13
|
+
* Drives a tus 1.0.0 upload of `data` to `uploadUrl` (the pre-authorized
|
|
14
|
+
* Cloudflare Stream endpoint returned by createVideoUploadUrl). Calls
|
|
15
|
+
* `onProgress` after each PATCH chunk; resolves with the total bytes accepted.
|
|
16
|
+
*/
|
|
17
|
+
export async function tusUploadBytes({ uploadUrl, data, chunkSize = DEFAULT_CHUNK_SIZE, onProgress, fetchImpl = fetch, }) {
|
|
18
|
+
if (uploadUrl.trim() === '') {
|
|
19
|
+
throw new TypeError('uploadUrl must not be empty.');
|
|
20
|
+
}
|
|
21
|
+
if (chunkSize <= 0) {
|
|
22
|
+
throw new TypeError('chunkSize must be positive.');
|
|
23
|
+
}
|
|
24
|
+
const bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
|
|
25
|
+
const total = bytes.byteLength;
|
|
26
|
+
const report = (bytesUploaded) => onProgress?.({
|
|
27
|
+
bytesUploaded,
|
|
28
|
+
totalBytes: total,
|
|
29
|
+
fraction: total <= 0 ? 0 : Math.min(1, Math.max(0, bytesUploaded / total)),
|
|
30
|
+
});
|
|
31
|
+
let offset = 0;
|
|
32
|
+
report(0);
|
|
33
|
+
while (offset < total) {
|
|
34
|
+
const end = Math.min(offset + chunkSize, total);
|
|
35
|
+
const chunk = bytes.slice(offset, end);
|
|
36
|
+
const response = await fetchImpl(uploadUrl, {
|
|
37
|
+
method: 'PATCH',
|
|
38
|
+
headers: {
|
|
39
|
+
'tus-resumable': '1.0.0',
|
|
40
|
+
'upload-offset': String(offset),
|
|
41
|
+
'content-type': 'application/offset+octet-stream',
|
|
42
|
+
},
|
|
43
|
+
body: chunk,
|
|
44
|
+
});
|
|
45
|
+
if (response.status !== 204 && response.status !== 200) {
|
|
46
|
+
const body = await response.text().catch(() => '');
|
|
47
|
+
throw new FastrelayVideoUploadError(`tus PATCH failed: ${response.status} ${response.statusText} ${body}`.trim(), { statusCode: response.status });
|
|
48
|
+
}
|
|
49
|
+
const reportedOffset = Number.parseInt(response.headers.get('upload-offset') ?? '', 10);
|
|
50
|
+
const nextOffset = Number.isNaN(reportedOffset)
|
|
51
|
+
? offset + chunk.byteLength
|
|
52
|
+
: reportedOffset;
|
|
53
|
+
// A non-advancing offset would loop forever; one past `total` would
|
|
54
|
+
// report success for bytes the server never accepted.
|
|
55
|
+
if (nextOffset <= offset || nextOffset > total) {
|
|
56
|
+
throw new FastrelayVideoUploadError(`tus server reported invalid upload-offset ${nextOffset} ` +
|
|
57
|
+
`(previous offset ${offset}, total ${total}).`, { statusCode: response.status });
|
|
58
|
+
}
|
|
59
|
+
offset = nextOffset;
|
|
60
|
+
report(offset);
|
|
61
|
+
}
|
|
62
|
+
return offset;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* High-level helper: mints a tus upload URL via the backend, then uploads
|
|
66
|
+
* `data` directly to Cloudflare Stream. The caller listens for `video.ready`
|
|
67
|
+
* on the realtime channel (or polls client.getVideo) for the final state.
|
|
68
|
+
*/
|
|
69
|
+
export async function uploadVideoBytes(client, { data, filename, mimeType, chunkSize = DEFAULT_CHUNK_SIZE, onProgress, fetchImpl, }) {
|
|
70
|
+
const bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
|
|
71
|
+
if (bytes.byteLength === 0) {
|
|
72
|
+
throw new TypeError('uploadVideoBytes requires non-empty data.');
|
|
73
|
+
}
|
|
74
|
+
if (filename.trim() === '') {
|
|
75
|
+
throw new TypeError('uploadVideoBytes requires a non-empty filename.');
|
|
76
|
+
}
|
|
77
|
+
if (!mimeType.trim().toLowerCase().startsWith('video/')) {
|
|
78
|
+
throw new TypeError(`uploadVideoBytes requires a video/* mimeType (got "${mimeType}").`);
|
|
79
|
+
}
|
|
80
|
+
const mint = await client.createVideoUploadUrl({
|
|
81
|
+
filename,
|
|
82
|
+
sizeBytes: bytes.byteLength,
|
|
83
|
+
mimeType,
|
|
84
|
+
});
|
|
85
|
+
const uploaded = await tusUploadBytes({
|
|
86
|
+
uploadUrl: mint.uploadUrl,
|
|
87
|
+
data: bytes,
|
|
88
|
+
chunkSize,
|
|
89
|
+
onProgress,
|
|
90
|
+
fetchImpl,
|
|
91
|
+
});
|
|
92
|
+
return {
|
|
93
|
+
videoId: mint.videoId,
|
|
94
|
+
uploadUrl: mint.uploadUrl,
|
|
95
|
+
bytesUploaded: uploaded,
|
|
96
|
+
};
|
|
97
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@fastrelay/js-sdk",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "JavaScript/TypeScript SDK for fastrelay feed APIs.",
|
|
5
|
+
"homepage": "https://github.com/The-Nexus-Collective/fastrelay-js-sdk",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "./dist/index.js",
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"default": "./dist/index.js"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist"
|
|
18
|
+
],
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=18"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "tsc",
|
|
24
|
+
"test": "node --test 'test/*.test.ts'"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"typescript": "^5.7.0"
|
|
28
|
+
}
|
|
29
|
+
}
|