@pylonsync/react 0.11.1 → 0.11.4

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 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 to /api/files/upload. */
176
- uploadFile(input: File | Blob | ArrayBuffer | Uint8Array, options?: {
177
- filename?: string;
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
- * Upload a file (File/Blob or raw bytes) to /api/files/upload.
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
- * For File / Blob inputs this sends a single raw binary request with the
247
- * filename and content-type as headers (the server short-circuits on this
248
- * shape so uploads avoid being coerced through string-based handling).
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 uploaded = await uploadFile(fileFromInput);
253
- * console.log(uploaded.url, uploaded.id, uploaded.size);
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
- * Upload via multipart/form-data. Useful when the app needs to pass extra
263
- * fields alongside the file (captions, categories, etc.), though only the
264
- * first file part is stored today.
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, fields?: Record<string, string>, options?: {
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.1",
6
+ "version": "0.11.4",
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.1",
18
- "@pylonsync/sync": "0.11.1"
17
+ "@pylonsync/sdk": "0.11.4",
18
+ "@pylonsync/sync": "0.11.4"
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 to /api/files/upload. */
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?: { filename?: string; contentType?: string }
338
+ options?: UploadFileOptions
338
339
  ): Promise<UploadedFile> {
339
340
  return uploadFile(input, options);
340
341
  },
341
342
 
342
- /** Upload via multipart/form-data with extra fields. */
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
- * Upload a file (File/Blob or raw bytes) to /api/files/upload.
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
- * For File / Blob inputs this sends a single raw binary request with the
922
- * filename and content-type as headers (the server short-circuits on this
923
- * shape so uploads avoid being coerced through string-based handling).
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 uploaded = await uploadFile(fileFromInput);
928
- * console.log(uploaded.url, uploaded.id, uploaded.size);
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
- return pylonFetch<UploadedFile>(
964
- {
965
- baseUrl: getBaseUrl(),
966
- getToken: () => options.token ?? currentAuthToken() ?? undefined,
967
- },
968
- "/api/files/upload",
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
- body,
972
- headers: {
973
- "Content-Type": contentType,
974
- "X-Filename": filename,
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
- * Upload via multipart/form-data. Useful when the app needs to pass extra
982
- * fields alongside the file (captions, categories, etc.), though only the
983
- * first file part is stored today.
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
- fields: Record<string, string> = {},
988
- options: { token?: string } = {}
1038
+ _fields: Record<string, string> = {},
1039
+ options: { token?: string; visibility?: FileVisibility } = {}
989
1040
  ): Promise<UploadedFile> {
990
- const form = new FormData();
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
+ });