@pylonsync/react 0.11.1 → 0.11.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/db.d.ts +4 -7
- package/dist/index.d.ts +33 -15
- package/package.json +3 -3
- package/src/db.ts +4 -3
- package/src/index.ts +78 -40
- package/src/uploadFile.test.ts +78 -0
package/dist/db.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { SyncEngine, type Row, type SyncEngineConfig } from "@pylonsync/sync";
|
|
2
2
|
import { type QueryOptions, type UseQueryReturn, type UseQueryOneReturn, type UseReactiveQueryReturn, type UseMutationReturn, type UseInfiniteQueryReturn, type AggregateSpec, type UseAggregateReturn, type SearchSpec, type UseSearchReturn } from "./hooks";
|
|
3
|
-
import { type UploadedFile } from "./index";
|
|
3
|
+
import { type UploadedFile, type UploadFileOptions } from "./index";
|
|
4
4
|
/**
|
|
5
5
|
* Initialize the pylon client. Call once at app startup.
|
|
6
6
|
*
|
|
@@ -172,11 +172,8 @@ export declare const db: {
|
|
|
172
172
|
* ```
|
|
173
173
|
*/
|
|
174
174
|
streamFn(name: string, args?: Record<string, unknown>): AsyncGenerator<string, unknown, unknown>;
|
|
175
|
-
/** Upload a file
|
|
176
|
-
uploadFile(input: File | Blob | ArrayBuffer | Uint8Array, options?:
|
|
177
|
-
|
|
178
|
-
contentType?: string;
|
|
179
|
-
}): Promise<UploadedFile>;
|
|
180
|
-
/** Upload via multipart/form-data with extra fields. */
|
|
175
|
+
/** Upload a file through /api/files/init, a PUT, and /api/files/confirm. */
|
|
176
|
+
uploadFile(input: File | Blob | ArrayBuffer | Uint8Array, options?: UploadFileOptions): Promise<UploadedFile>;
|
|
177
|
+
/** @deprecated Use `uploadFile`; extra fields were never stored. */
|
|
181
178
|
uploadFileMultipart(file: File | Blob, fields?: Record<string, string>): Promise<UploadedFile>;
|
|
182
179
|
};
|
package/dist/index.d.ts
CHANGED
|
@@ -237,32 +237,50 @@ export declare function listFns(): Promise<{
|
|
|
237
237
|
}[]>;
|
|
238
238
|
export interface UploadedFile {
|
|
239
239
|
id: string;
|
|
240
|
+
/** Where to read the file. Store this on your row. */
|
|
240
241
|
url: string;
|
|
241
242
|
size: number;
|
|
242
243
|
}
|
|
243
244
|
/**
|
|
244
|
-
*
|
|
245
|
+
* Who can read an uploaded file through `url`.
|
|
246
|
+
* - "private" (default): only the uploader.
|
|
247
|
+
* - "public": anyone, signed in or not. Use it for content every visitor
|
|
248
|
+
* sees, such as feed photos or listing images.
|
|
249
|
+
* Writes and deletes stay with the uploader either way. On a CDN-backed
|
|
250
|
+
* store (Stack0, a public bucket) every file is served by its URL, so the
|
|
251
|
+
* setting only changes reads that go through `/api/files/<id>`.
|
|
252
|
+
*/
|
|
253
|
+
export type FileVisibility = "private" | "public";
|
|
254
|
+
export interface UploadFileOptions {
|
|
255
|
+
filename?: string;
|
|
256
|
+
contentType?: string;
|
|
257
|
+
token?: string;
|
|
258
|
+
visibility?: FileVisibility;
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Upload a file (File, Blob, or raw bytes) in three steps:
|
|
262
|
+
* 1. POST /api/files/init with the name, type, size, and visibility;
|
|
263
|
+
* 2. PUT the bytes to the returned upload URL;
|
|
264
|
+
* 3. POST /api/files/confirm, which returns `{ id, url, size }`.
|
|
245
265
|
*
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
*
|
|
266
|
+
* The upload URL is either pylon's own receiver (local disk), which needs
|
|
267
|
+
* the session like any API call, or a presigned storage URL, whose
|
|
268
|
+
* signature is the credential. The session is never sent to a presigned
|
|
269
|
+
* URL on another origin.
|
|
249
270
|
*
|
|
250
271
|
* @example
|
|
251
272
|
* ```ts
|
|
252
|
-
* const
|
|
253
|
-
*
|
|
273
|
+
* const photo = await uploadFile(fileFromInput, { visibility: "public" });
|
|
274
|
+
* await db.insert("Post", { imageUrl: photo.url, caption });
|
|
254
275
|
* ```
|
|
255
276
|
*/
|
|
256
|
-
export declare function uploadFile(input: File | Blob | ArrayBuffer | Uint8Array, options?:
|
|
257
|
-
filename?: string;
|
|
258
|
-
contentType?: string;
|
|
259
|
-
token?: string;
|
|
260
|
-
}): Promise<UploadedFile>;
|
|
277
|
+
export declare function uploadFile(input: File | Blob | ArrayBuffer | Uint8Array, options?: UploadFileOptions): Promise<UploadedFile>;
|
|
261
278
|
/**
|
|
262
|
-
*
|
|
263
|
-
* fields
|
|
264
|
-
*
|
|
279
|
+
* @deprecated The multipart endpoint was removed in pylon 0.3.91 and never
|
|
280
|
+
* stored the extra fields. This uploads the file with `uploadFile` and
|
|
281
|
+
* ignores `fields`; send those to your own function instead.
|
|
265
282
|
*/
|
|
266
|
-
export declare function uploadFileMultipart(file: File | Blob,
|
|
283
|
+
export declare function uploadFileMultipart(file: File | Blob, _fields?: Record<string, string>, options?: {
|
|
267
284
|
token?: string;
|
|
285
|
+
visibility?: FileVisibility;
|
|
268
286
|
}): Promise<UploadedFile>;
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"publishConfig": {
|
|
4
4
|
"access": "public"
|
|
5
5
|
},
|
|
6
|
-
"version": "0.11.
|
|
6
|
+
"version": "0.11.3",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"main": "./src/index.ts",
|
|
9
9
|
"types": "./dist/index.d.ts",
|
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
"prepack": "bun run build"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@pylonsync/sdk": "0.11.
|
|
18
|
-
"@pylonsync/sync": "0.11.
|
|
17
|
+
"@pylonsync/sdk": "0.11.3",
|
|
18
|
+
"@pylonsync/sync": "0.11.3"
|
|
19
19
|
},
|
|
20
20
|
"peerDependencies": {
|
|
21
21
|
"react": ">=19.0.0"
|
package/src/db.ts
CHANGED
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
uploadFile,
|
|
32
32
|
uploadFileMultipart,
|
|
33
33
|
type UploadedFile,
|
|
34
|
+
type UploadFileOptions,
|
|
34
35
|
} from "./index";
|
|
35
36
|
|
|
36
37
|
// ---------------------------------------------------------------------------
|
|
@@ -331,15 +332,15 @@ export const db = {
|
|
|
331
332
|
return streamFn(name, args);
|
|
332
333
|
},
|
|
333
334
|
|
|
334
|
-
/** Upload a file
|
|
335
|
+
/** Upload a file through /api/files/init, a PUT, and /api/files/confirm. */
|
|
335
336
|
uploadFile(
|
|
336
337
|
input: File | Blob | ArrayBuffer | Uint8Array,
|
|
337
|
-
options?:
|
|
338
|
+
options?: UploadFileOptions
|
|
338
339
|
): Promise<UploadedFile> {
|
|
339
340
|
return uploadFile(input, options);
|
|
340
341
|
},
|
|
341
342
|
|
|
342
|
-
/**
|
|
343
|
+
/** @deprecated Use `uploadFile`; extra fields were never stored. */
|
|
343
344
|
uploadFileMultipart(
|
|
344
345
|
file: File | Blob,
|
|
345
346
|
fields?: Record<string, string>
|
package/src/index.ts
CHANGED
|
@@ -73,6 +73,7 @@ import {
|
|
|
73
73
|
defaultStorage,
|
|
74
74
|
pylonFetch,
|
|
75
75
|
pylonFetchRaw,
|
|
76
|
+
PylonHttpError,
|
|
76
77
|
type Storage as PylonStorage,
|
|
77
78
|
} from "@pylonsync/sync";
|
|
78
79
|
import { peekActiveEngine } from "./engine-registry";
|
|
@@ -911,94 +912,131 @@ export async function listFns(): Promise<
|
|
|
911
912
|
|
|
912
913
|
export interface UploadedFile {
|
|
913
914
|
id: string;
|
|
915
|
+
/** Where to read the file. Store this on your row. */
|
|
914
916
|
url: string;
|
|
915
917
|
size: number;
|
|
916
918
|
}
|
|
917
919
|
|
|
918
920
|
/**
|
|
919
|
-
*
|
|
921
|
+
* Who can read an uploaded file through `url`.
|
|
922
|
+
* - "private" (default): only the uploader.
|
|
923
|
+
* - "public": anyone, signed in or not. Use it for content every visitor
|
|
924
|
+
* sees, such as feed photos or listing images.
|
|
925
|
+
* Writes and deletes stay with the uploader either way. On a CDN-backed
|
|
926
|
+
* store (Stack0, a public bucket) every file is served by its URL, so the
|
|
927
|
+
* setting only changes reads that go through `/api/files/<id>`.
|
|
928
|
+
*/
|
|
929
|
+
export type FileVisibility = "private" | "public";
|
|
930
|
+
|
|
931
|
+
export interface UploadFileOptions {
|
|
932
|
+
filename?: string;
|
|
933
|
+
contentType?: string;
|
|
934
|
+
token?: string;
|
|
935
|
+
visibility?: FileVisibility;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
/**
|
|
939
|
+
* Upload a file (File, Blob, or raw bytes) in three steps:
|
|
940
|
+
* 1. POST /api/files/init with the name, type, size, and visibility;
|
|
941
|
+
* 2. PUT the bytes to the returned upload URL;
|
|
942
|
+
* 3. POST /api/files/confirm, which returns `{ id, url, size }`.
|
|
920
943
|
*
|
|
921
|
-
*
|
|
922
|
-
*
|
|
923
|
-
*
|
|
944
|
+
* The upload URL is either pylon's own receiver (local disk), which needs
|
|
945
|
+
* the session like any API call, or a presigned storage URL, whose
|
|
946
|
+
* signature is the credential. The session is never sent to a presigned
|
|
947
|
+
* URL on another origin.
|
|
924
948
|
*
|
|
925
949
|
* @example
|
|
926
950
|
* ```ts
|
|
927
|
-
* const
|
|
928
|
-
*
|
|
951
|
+
* const photo = await uploadFile(fileFromInput, { visibility: "public" });
|
|
952
|
+
* await db.insert("Post", { imageUrl: photo.url, caption });
|
|
929
953
|
* ```
|
|
930
954
|
*/
|
|
931
955
|
export async function uploadFile(
|
|
932
956
|
input: File | Blob | ArrayBuffer | Uint8Array,
|
|
933
|
-
options: {
|
|
934
|
-
filename?: string;
|
|
935
|
-
contentType?: string;
|
|
936
|
-
token?: string;
|
|
937
|
-
} = {}
|
|
957
|
+
options: UploadFileOptions = {}
|
|
938
958
|
): Promise<UploadedFile> {
|
|
939
959
|
let body: BodyInit;
|
|
960
|
+
let size: number;
|
|
940
961
|
let filename = options.filename;
|
|
941
962
|
let contentType = options.contentType;
|
|
942
963
|
|
|
943
964
|
if (typeof File !== "undefined" && input instanceof File) {
|
|
944
965
|
body = input;
|
|
966
|
+
size = input.size;
|
|
945
967
|
filename ??= input.name;
|
|
946
968
|
contentType ??= input.type || "application/octet-stream";
|
|
947
969
|
} else if (typeof Blob !== "undefined" && input instanceof Blob) {
|
|
948
970
|
body = input;
|
|
971
|
+
size = input.size;
|
|
949
972
|
contentType ??= input.type || "application/octet-stream";
|
|
950
973
|
} else if (input instanceof ArrayBuffer) {
|
|
951
974
|
body = input;
|
|
975
|
+
size = input.byteLength;
|
|
952
976
|
} else {
|
|
953
977
|
// Newer TS lib types refuse `Uint8Array<ArrayBufferLike>` as BodyInit
|
|
954
978
|
// directly even though every runtime accepts it. Hand fetch the
|
|
955
979
|
// underlying ArrayBuffer slice to sidestep the type narrowing.
|
|
956
980
|
const u8 = input as Uint8Array;
|
|
957
981
|
body = u8.buffer.slice(u8.byteOffset, u8.byteOffset + u8.byteLength) as ArrayBuffer;
|
|
982
|
+
size = u8.byteLength;
|
|
958
983
|
}
|
|
959
984
|
|
|
960
985
|
filename ??= "upload";
|
|
961
986
|
contentType ??= "application/octet-stream";
|
|
962
987
|
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
988
|
+
const transport = {
|
|
989
|
+
baseUrl: getBaseUrl(),
|
|
990
|
+
getToken: () => options.token ?? currentAuthToken() ?? undefined,
|
|
991
|
+
};
|
|
992
|
+
|
|
993
|
+
const slot = await pylonFetch<{ uploadUrl: string; assetId: string }>(
|
|
994
|
+
transport,
|
|
995
|
+
"/api/files/init",
|
|
969
996
|
{
|
|
970
997
|
method: "POST",
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
998
|
+
json: {
|
|
999
|
+
filename,
|
|
1000
|
+
mimeType: contentType,
|
|
1001
|
+
size,
|
|
1002
|
+
visibility: options.visibility ?? "private",
|
|
975
1003
|
},
|
|
976
1004
|
},
|
|
977
1005
|
);
|
|
1006
|
+
|
|
1007
|
+
const base = new URL(getBaseUrl() || "http://localhost");
|
|
1008
|
+
const target = new URL(slot.uploadUrl, base);
|
|
1009
|
+
const put =
|
|
1010
|
+
target.origin === base.origin
|
|
1011
|
+
? await pylonFetchRaw(transport, `${target.pathname}${target.search}`, {
|
|
1012
|
+
method: "PUT",
|
|
1013
|
+
body,
|
|
1014
|
+
headers: { "Content-Type": contentType },
|
|
1015
|
+
})
|
|
1016
|
+
: await fetch(target.toString(), {
|
|
1017
|
+
method: "PUT",
|
|
1018
|
+
body,
|
|
1019
|
+
headers: { "Content-Type": contentType },
|
|
1020
|
+
});
|
|
1021
|
+
if (!put.ok) {
|
|
1022
|
+
throw new PylonHttpError(`Upload of ${filename} failed: ${put.status}`, put.status);
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
return pylonFetch<UploadedFile>(transport, "/api/files/confirm", {
|
|
1026
|
+
method: "POST",
|
|
1027
|
+
json: { assetId: slot.assetId },
|
|
1028
|
+
});
|
|
978
1029
|
}
|
|
979
1030
|
|
|
980
1031
|
/**
|
|
981
|
-
*
|
|
982
|
-
* fields
|
|
983
|
-
*
|
|
1032
|
+
* @deprecated The multipart endpoint was removed in pylon 0.3.91 and never
|
|
1033
|
+
* stored the extra fields. This uploads the file with `uploadFile` and
|
|
1034
|
+
* ignores `fields`; send those to your own function instead.
|
|
984
1035
|
*/
|
|
985
1036
|
export async function uploadFileMultipart(
|
|
986
1037
|
file: File | Blob,
|
|
987
|
-
|
|
988
|
-
options: { token?: string } = {}
|
|
1038
|
+
_fields: Record<string, string> = {},
|
|
1039
|
+
options: { token?: string; visibility?: FileVisibility } = {}
|
|
989
1040
|
): Promise<UploadedFile> {
|
|
990
|
-
|
|
991
|
-
for (const [k, v] of Object.entries(fields)) {
|
|
992
|
-
form.append(k, v);
|
|
993
|
-
}
|
|
994
|
-
form.append("file", file);
|
|
995
|
-
|
|
996
|
-
return pylonFetch<UploadedFile>(
|
|
997
|
-
{
|
|
998
|
-
baseUrl: getBaseUrl(),
|
|
999
|
-
getToken: () => options.token ?? currentAuthToken() ?? undefined,
|
|
1000
|
-
},
|
|
1001
|
-
"/api/files/upload",
|
|
1002
|
-
{ method: "POST", body: form },
|
|
1003
|
-
);
|
|
1041
|
+
return uploadFile(file, options);
|
|
1004
1042
|
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// `uploadFile` used to POST to /api/files/upload, which the runtime removed in
|
|
2
|
+
// 0.3.91 and answers with 410, so every `<FileUpload>` failed. These tests pin
|
|
3
|
+
// the three-step flow and the one credential rule that matters: the session
|
|
4
|
+
// goes to pylon, never to a presigned storage URL on another origin.
|
|
5
|
+
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
7
|
+
import { getBaseUrl, uploadFile } from "./index";
|
|
8
|
+
|
|
9
|
+
type Seen = { url: string; method: string; headers: Record<string, string>; body: unknown };
|
|
10
|
+
|
|
11
|
+
describe("uploadFile", () => {
|
|
12
|
+
const realFetch = globalThis.fetch;
|
|
13
|
+
const realWindow = (globalThis as { window?: unknown }).window;
|
|
14
|
+
let seen: Seen[];
|
|
15
|
+
let uploadUrl: string;
|
|
16
|
+
// Whatever origin the client resolves to. Another test file in the same
|
|
17
|
+
// process may have configured one, and this test must not set one itself:
|
|
18
|
+
// configureClient is process-wide and would leak into those tests.
|
|
19
|
+
let base: string;
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
seen = [];
|
|
23
|
+
uploadUrl = "/api/files/local-put/f_1";
|
|
24
|
+
(globalThis as { window?: unknown }).window = { location: { origin: "https://app.example" } };
|
|
25
|
+
base = getBaseUrl();
|
|
26
|
+
globalThis.fetch = (async (input: any, init: any = {}) => {
|
|
27
|
+
const url = String(input?.url ?? input);
|
|
28
|
+
seen.push({ url, method: init.method ?? "GET", headers: (init.headers ?? {}) as Record<string, string>, body: init.body });
|
|
29
|
+
const json = (value: unknown) => new Response(JSON.stringify(value), { status: 200, headers: { "content-type": "application/json" } });
|
|
30
|
+
if (url.endsWith("/api/files/init")) return json({ uploadUrl, assetId: "f_1" });
|
|
31
|
+
if (url.endsWith("/api/files/confirm")) return json({ id: "f_1", url: "/api/files/f_1", size: 5 });
|
|
32
|
+
return new Response(null, { status: 200 });
|
|
33
|
+
}) as typeof fetch;
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
globalThis.fetch = realFetch;
|
|
38
|
+
(globalThis as { window?: unknown }).window = realWindow;
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("init, put, confirm, with the visibility on init", async () => {
|
|
42
|
+
const file = new File(["hello"], "cat.png", { type: "image/png" });
|
|
43
|
+
const out = await uploadFile(file, { visibility: "public", token: "tok" });
|
|
44
|
+
expect(out).toEqual({ id: "f_1", url: "/api/files/f_1", size: 5 });
|
|
45
|
+
expect(seen.map((r) => `${r.method} ${r.url}`)).toEqual([
|
|
46
|
+
`POST ${base}/api/files/init`,
|
|
47
|
+
`PUT ${base}/api/files/local-put/f_1`,
|
|
48
|
+
`POST ${base}/api/files/confirm`,
|
|
49
|
+
]);
|
|
50
|
+
expect(JSON.parse(String(seen[0].body))).toEqual({ filename: "cat.png", mimeType: "image/png", size: 5, visibility: "public" });
|
|
51
|
+
expect(seen[1].headers.Authorization).toBe("Bearer tok");
|
|
52
|
+
expect(seen[1].headers["Content-Type"]).toBe("image/png");
|
|
53
|
+
expect(JSON.parse(String(seen[2].body))).toEqual({ assetId: "f_1" });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("defaults to private", async () => {
|
|
57
|
+
await uploadFile(new Uint8Array([1, 2, 3]), { token: "tok" });
|
|
58
|
+
expect(JSON.parse(String(seen[0].body))).toEqual({ filename: "upload", mimeType: "application/octet-stream", size: 3, visibility: "private" });
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("a presigned URL on another origin gets no session", async () => {
|
|
62
|
+
uploadUrl = "https://bucket.s3.example/f_1?X-Amz-Signature=abc";
|
|
63
|
+
await uploadFile(new Blob(["hi"], { type: "text/plain" }), { token: "tok" });
|
|
64
|
+
expect(seen[1].url).toBe("https://bucket.s3.example/f_1?X-Amz-Signature=abc");
|
|
65
|
+
expect(seen[1].headers.Authorization).toBeUndefined();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("a refused PUT throws before confirm", async () => {
|
|
69
|
+
globalThis.fetch = (async (input: any, init: any = {}) => {
|
|
70
|
+
const url = String(input?.url ?? input);
|
|
71
|
+
seen.push({ url, method: init.method ?? "GET", headers: {}, body: init.body });
|
|
72
|
+
if (url.endsWith("/api/files/init")) return new Response(JSON.stringify({ uploadUrl, assetId: "f_1" }), { status: 200 });
|
|
73
|
+
return new Response("nope", { status: 404 });
|
|
74
|
+
}) as typeof fetch;
|
|
75
|
+
await expect(uploadFile(new Uint8Array([1]), { token: "tok" })).rejects.toThrow("failed: 404");
|
|
76
|
+
expect(seen.some((r) => r.url.endsWith("/api/files/confirm"))).toBe(false);
|
|
77
|
+
});
|
|
78
|
+
});
|