@usefillo/core 0.2.3 → 0.3.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/dist/index.d.ts CHANGED
@@ -161,6 +161,11 @@ interface FormTheme {
161
161
  radius?: string;
162
162
  fontFamily?: string;
163
163
  }
164
+ /** Workspace branding state, resolved by the server from the plan. */
165
+ interface FormBranding {
166
+ /** Show the "Powered by Fillo" badge. True on free plans; paid removes it. */
167
+ poweredBy: boolean;
168
+ }
164
169
  /** A completed upload, referenced from response data. */
165
170
  interface FileValue {
166
171
  fileId: string;
@@ -182,21 +187,37 @@ type FieldValue = string | number | boolean | string[] | FileValue[]
182
187
  type ResponseData = Record<string, FieldValue>;
183
188
  type UploadStatus = "pending" | "uploading" | "complete" | "aborted";
184
189
  /**
185
- * How the browser should move the bytes.
186
- * - "formwork": chunked PUTs to the Fillo API (X-Upload-Offset protocol).
190
+ * How the browser should move the bytes — always straight to the workspace's own
191
+ * storage, never through the Fillo server.
187
192
  * - "gdrive": direct to a Google Drive resumable session URL (Content-Range
188
- * protocol) — bytes never touch the Fillo server.
189
- * - "s3-put": a single PUT to a presigned S3/R2 URL — bytes go straight to the
190
- * bucket.
193
+ * protocol).
194
+ * - "s3-put": a single PUT to a presigned S3/R2 URL — straight to the bucket.
195
+ * - "box": direct to Box with a folder-scoped upload token — small files via a
196
+ * single multipart POST, large files via Box's chunked session (the client
197
+ * computes the SHA-1 digests Box requires). The server commits/verifies in the
198
+ * complete step.
191
199
  */
192
200
  type UploadTransport = {
193
- type: "formwork";
194
- } | {
195
201
  type: "gdrive";
196
202
  uploadUrl: string;
197
203
  } | {
198
204
  type: "s3-put";
199
205
  uploadUrl: string;
206
+ } | {
207
+ type: "box";
208
+ mode: "simple";
209
+ uploadUrl: string;
210
+ token: string;
211
+ folderId: string;
212
+ fileName: string;
213
+ } | {
214
+ type: "box";
215
+ mode: "chunked";
216
+ sessionUrl: string;
217
+ token: string;
218
+ folderId: string;
219
+ fileName: string;
220
+ size: number;
200
221
  };
201
222
  interface UploadSession {
202
223
  id: string;
@@ -341,6 +362,8 @@ interface PublishedForm {
341
362
  theme: FormTheme | null;
342
363
  /** True when the form has stopped accepting responses (closed/limit reached). */
343
364
  closed?: boolean;
365
+ /** Workspace branding state — absent means show the badge (default). */
366
+ branding?: FormBranding;
344
367
  }
345
368
  interface SubmitResult {
346
369
  ok: boolean;
@@ -354,6 +377,8 @@ interface SubmitMeta {
354
377
  hp?: string;
355
378
  /** Time from first render to submit. */
356
379
  elapsedMs?: number;
380
+ /** Embedding surface; "headless" submissions require a paid plan (server-enforced). */
381
+ surface?: "default" | "headless";
357
382
  }
358
383
  interface UploadProgress {
359
384
  uploadedBytes: number;
@@ -392,6 +417,7 @@ declare class FilloClient {
392
417
  syncForm(handle: string, schema: FormSchema, theme?: FormTheme | null): Promise<{
393
418
  formId: string;
394
419
  slug: string;
420
+ branding?: FormBranding;
395
421
  }>;
396
422
  /** Submit a response. Returns per-field errors instead of throwing on validation failure. */
397
423
  submit(formId: string, data: ResponseData, meta?: SubmitMeta): Promise<SubmitResult>;
@@ -420,7 +446,16 @@ declare class FilloClient {
420
446
  * form owner's Drive and never touch the Fillo server.
421
447
  */
422
448
  uploadFile(formId: string, file: File | Blob, options: UploadFileOptions): Promise<FileValue>;
423
- private formworkUploadLoop;
449
+ /**
450
+ * Box direct upload with a folder-scoped token (Box UI Elements pattern).
451
+ * Small files go up in one multipart POST; large files run Box's chunked
452
+ * session entirely from the client — create session, PUT each part with a
453
+ * SHA-1 `Digest`, then commit with the whole-file digest. Bytes go straight to
454
+ * Box; we return the new file id for /complete to verify server-side.
455
+ */
456
+ private boxUpload;
457
+ /** Retry a request with exponential backoff; never retries an aborted upload. */
458
+ private retry;
424
459
  /**
425
460
  * Google Drive resumable protocol: PUT chunks with Content-Range; Drive
426
461
  * answers 308 + a Range header while incomplete, 200/201 when done. On
@@ -473,15 +508,30 @@ interface FormControllerOptions {
473
508
  form: FormSchema;
474
509
  /** Target form id — required for client submission and funnel tracking. */
475
510
  formId?: string;
476
- /** Collect responses into a Fillo workspace. Omit and pass `onSubmit` to keep them yourself. */
511
+ /**
512
+ * Collect responses into a Fillo workspace. Without a client the form is
513
+ * render-only (previews/tests); to also deliver responses to your own
514
+ * backend, use webhooks.
515
+ */
477
516
  client?: FilloClient;
478
517
  initialData?: ResponseData;
479
- onChange?: (data: ResponseData) => void;
480
518
  onSubmitted?: (responseId: string | undefined, data: ResponseData) => void;
481
- /** Handle submission yourself instead of the client. */
482
- onSubmit?: (data: ResponseData) => Promise<void> | void;
519
+ onChange?: (data: ResponseData) => void;
483
520
  /** Honeypot value, supplied by your renderer; must be empty for a human. */
484
521
  getHoneypot?: () => string;
522
+ /**
523
+ * Let page navigation (next) move forward without validating the current page.
524
+ * For previews/builders where you want to flip through pages without filling
525
+ * the form in. Submission still validates.
526
+ */
527
+ skipValidation?: boolean;
528
+ /**
529
+ * Embedding surface. "headless" (a bare engine with no Fillo-rendered layout,
530
+ * i.e. createFormController / <FilloProvider>) is a paid capability the server
531
+ * enforces at submit. The framed renderers (<FilloForm>, renderForm) are
532
+ * "default". Defaults to "default".
533
+ */
534
+ surface?: "default" | "headless";
485
535
  }
486
536
  interface FormControllerState {
487
537
  data: ResponseData;
@@ -525,14 +575,14 @@ interface FormController {
525
575
  /** Drop all listeners. */
526
576
  destroy(): void;
527
577
  }
528
- /**
529
- * Framework-agnostic form engine: validation, conditional logic, pages, spam
530
- * signals, and submission as a subscribable store — with no rendering. Drive it
531
- * from vanilla JS, Vue, Svelte, or any framework and lay the fields out
532
- * yourself. (In React, `<FilloProvider>` / `useFillo()` wrap this same engine.)
533
- */
534
578
  declare function createFormController(options: FormControllerOptions): FormController;
535
579
 
580
+ /** Result of syncing a code-defined form: its canonical id, slug, and branding. */
581
+ interface SyncedForm {
582
+ formId: string;
583
+ slug: string;
584
+ branding?: FormBranding;
585
+ }
536
586
  /**
537
587
  * A form whose structure lives in user code. Framework renderers can show it
538
588
  * immediately, then sync it into a Fillo workspace when a publishable key is
@@ -543,7 +593,7 @@ interface CodeForm {
543
593
  id: string;
544
594
  schema: FormSchema;
545
595
  theme?: FormTheme;
546
- readonly __formworkCodeForm: true;
596
+ readonly __filloCodeForm: true;
547
597
  }
548
598
  declare function defineForm(def: {
549
599
  id: string;
@@ -554,14 +604,13 @@ declare function defineForm(def: {
554
604
  theme?: FormTheme;
555
605
  }): CodeForm;
556
606
  declare function isCodeForm(form: unknown): form is CodeForm;
607
+ /** Stable djb2 hash of a string — used to key code-form sync by content. */
608
+ declare function contentHash(input: string): string;
557
609
  /**
558
610
  * Sync a code-defined form into a workspace at most once per session for the
559
611
  * same client, handle, schema, and theme.
560
612
  */
561
- declare function syncCodeForm(client: FilloClient, form: CodeForm): Promise<{
562
- formId: string;
563
- slug: string;
564
- }>;
613
+ declare function syncCodeForm(client: FilloClient, form: CodeForm): Promise<SyncedForm>;
565
614
 
566
615
  /**
567
616
  * Build initial response data from URL query parameters — Tally-style
@@ -583,4 +632,28 @@ declare function pipeBlock(block: Block, data: ResponseData, form: FormSchema):
583
632
  /** Dependency-free nanoid-style id with an unbiased alphabet. Crypto-random. */
584
633
  declare function createId(size?: number): string;
585
634
 
586
- export { BLOCK_KIND_META, type BaseField, type Block, type BlockKind, CONTENT_KINDS, type CheckboxField, type ChoiceField, type CodeForm, type Condition, type ConditionOp, type ContentBlock, type ContentKind, type CustomField, DRAFT_KINDS, type DateField, type DividerBlock, type Field, type FieldKind, type FieldSpec, type FieldValue, type FileUploadField, type FileValue, FilloClient, type FilloClientOptions, FilloError, type FormController, type FormControllerOptions, type FormControllerState, type FormDraftSpec, type FormPage, type FormSchema, type FormSettings, type FormStatus, type FormTheme, type HeadingBlock, type HiddenField, type JsonValue, type LinearScaleField, type MatrixField, type NumberField, type ParagraphBlock, type ProvisionWorkspaceResult, type PublishedForm, type RankingField, type RatingField, type ResponseData, type SchemaValidationResult, type SelectOption, type SignatureField, type SubmitMeta, type SubmitResult, type TextField, type UploadFileOptions, type UploadProgress, type UploadSession, type UploadStatus, type UploadTransport, type ValidationResult, allFields, assembleForm, createBlock, createClient, createEmptyForm, createFormController, createId, defineForm, formatAnswer, isBlockVisible, isCodeForm, isField, pipeBlock, prefillFromParams, provisionWorkspace, resolveText, syncCodeForm, validateField, validateFormSchema, validateResponse, visibleBlocks, visibleFields };
635
+ /**
636
+ * Minimal streaming SHA-1 (FIPS 180-1). Vendored so the browser can checksum
637
+ * large uploads incrementally — Box requires a SHA-1 `Digest` on every chunk and
638
+ * on commit, and Web Crypto's `digest()` has no streaming API (it would force
639
+ * the whole file into memory). Validated against node:crypto in test/sha1.test.mjs.
640
+ *
641
+ * Only used by the Box upload transport; nothing else depends on it.
642
+ */
643
+ declare class Sha1 {
644
+ private h0;
645
+ private h1;
646
+ private h2;
647
+ private h3;
648
+ private h4;
649
+ private readonly block;
650
+ private blockLen;
651
+ private totalLen;
652
+ private readonly w;
653
+ update(data: Uint8Array): this;
654
+ digest(): Uint8Array;
655
+ private process;
656
+ }
657
+ declare const sha1Base64: (bytes: Uint8Array) => string;
658
+
659
+ export { BLOCK_KIND_META, type BaseField, type Block, type BlockKind, CONTENT_KINDS, type CheckboxField, type ChoiceField, type CodeForm, type Condition, type ConditionOp, type ContentBlock, type ContentKind, type CustomField, DRAFT_KINDS, type DateField, type DividerBlock, type Field, type FieldKind, type FieldSpec, type FieldValue, type FileUploadField, type FileValue, FilloClient, type FilloClientOptions, FilloError, type FormBranding, type FormController, type FormControllerOptions, type FormControllerState, type FormDraftSpec, type FormPage, type FormSchema, type FormSettings, type FormStatus, type FormTheme, type HeadingBlock, type HiddenField, type JsonValue, type LinearScaleField, type MatrixField, type NumberField, type ParagraphBlock, type ProvisionWorkspaceResult, type PublishedForm, type RankingField, type RatingField, type ResponseData, type SchemaValidationResult, type SelectOption, Sha1, type SignatureField, type SubmitMeta, type SubmitResult, type SyncedForm, type TextField, type UploadFileOptions, type UploadProgress, type UploadSession, type UploadStatus, type UploadTransport, type ValidationResult, allFields, assembleForm, contentHash, createBlock, createClient, createEmptyForm, createFormController, createId, defineForm, formatAnswer, isBlockVisible, isCodeForm, isField, pipeBlock, prefillFromParams, provisionWorkspace, resolveText, sha1Base64, syncCodeForm, validateField, validateFormSchema, validateResponse, visibleBlocks, visibleFields };
package/dist/index.js CHANGED
@@ -553,6 +553,116 @@ function assembleForm(spec) {
553
553
  return form;
554
554
  }
555
555
 
556
+ // src/sha1.ts
557
+ var Sha1 = class {
558
+ h0 = 1732584193;
559
+ h1 = 4023233417;
560
+ h2 = 2562383102;
561
+ h3 = 271733878;
562
+ h4 = 3285377520;
563
+ block = new Uint8Array(64);
564
+ blockLen = 0;
565
+ totalLen = 0;
566
+ w = new Int32Array(80);
567
+ update(data) {
568
+ this.totalLen += data.length;
569
+ let i = 0;
570
+ if (this.blockLen > 0) {
571
+ while (i < data.length && this.blockLen < 64) this.block[this.blockLen++] = data[i++];
572
+ if (this.blockLen === 64) {
573
+ this.process(this.block);
574
+ this.blockLen = 0;
575
+ }
576
+ }
577
+ while (i + 64 <= data.length) {
578
+ this.process(data.subarray(i, i + 64));
579
+ i += 64;
580
+ }
581
+ while (i < data.length) this.block[this.blockLen++] = data[i++];
582
+ return this;
583
+ }
584
+ digest() {
585
+ const bitsHi = Math.floor(this.totalLen / 536870912);
586
+ const bitsLo = this.totalLen * 8 >>> 0;
587
+ const pad = [128];
588
+ let len = this.blockLen + 1;
589
+ while (len % 64 !== 56) {
590
+ pad.push(0);
591
+ len++;
592
+ }
593
+ pad.push(bitsHi >>> 24 & 255, bitsHi >>> 16 & 255, bitsHi >>> 8 & 255, bitsHi & 255);
594
+ pad.push(bitsLo >>> 24 & 255, bitsLo >>> 16 & 255, bitsLo >>> 8 & 255, bitsLo & 255);
595
+ this.update(new Uint8Array(pad));
596
+ const out = new Uint8Array(20);
597
+ const h = [this.h0, this.h1, this.h2, this.h3, this.h4];
598
+ for (let i = 0; i < 5; i++) {
599
+ out[i * 4] = h[i] >>> 24 & 255;
600
+ out[i * 4 + 1] = h[i] >>> 16 & 255;
601
+ out[i * 4 + 2] = h[i] >>> 8 & 255;
602
+ out[i * 4 + 3] = h[i] & 255;
603
+ }
604
+ return out;
605
+ }
606
+ process(block) {
607
+ const w = this.w;
608
+ for (let i = 0; i < 16; i++) {
609
+ w[i] = block[i * 4] << 24 | block[i * 4 + 1] << 16 | block[i * 4 + 2] << 8 | block[i * 4 + 3];
610
+ }
611
+ for (let i = 16; i < 80; i++) {
612
+ const v = w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16];
613
+ w[i] = v << 1 | v >>> 31;
614
+ }
615
+ let a = this.h0;
616
+ let b = this.h1;
617
+ let c = this.h2;
618
+ let d = this.h3;
619
+ let e = this.h4;
620
+ for (let i = 0; i < 80; i++) {
621
+ let f;
622
+ let k;
623
+ if (i < 20) {
624
+ f = b & c | ~b & d;
625
+ k = 1518500249;
626
+ } else if (i < 40) {
627
+ f = b ^ c ^ d;
628
+ k = 1859775393;
629
+ } else if (i < 60) {
630
+ f = b & c | b & d | c & d;
631
+ k = 2400959708;
632
+ } else {
633
+ f = b ^ c ^ d;
634
+ k = 3395469782;
635
+ }
636
+ const temp = (a << 5 | a >>> 27) + f + e + k + w[i] | 0;
637
+ e = d;
638
+ d = c;
639
+ c = b << 30 | b >>> 2;
640
+ b = a;
641
+ a = temp;
642
+ }
643
+ this.h0 = this.h0 + a | 0;
644
+ this.h1 = this.h1 + b | 0;
645
+ this.h2 = this.h2 + c | 0;
646
+ this.h3 = this.h3 + d | 0;
647
+ this.h4 = this.h4 + e | 0;
648
+ }
649
+ };
650
+ var B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
651
+ function bytesToBase64(bytes) {
652
+ let out = "";
653
+ for (let i = 0; i < bytes.length; i += 3) {
654
+ const b0 = bytes[i];
655
+ const b1 = i + 1 < bytes.length ? bytes[i + 1] : 0;
656
+ const b2 = i + 2 < bytes.length ? bytes[i + 2] : 0;
657
+ out += B64[b0 >> 2];
658
+ out += B64[(b0 & 3) << 4 | b1 >> 4];
659
+ out += i + 1 < bytes.length ? B64[(b1 & 15) << 2 | b2 >> 6] : "=";
660
+ out += i + 2 < bytes.length ? B64[b2 & 63] : "=";
661
+ }
662
+ return out;
663
+ }
664
+ var sha1Base64 = (bytes) => bytesToBase64(new Sha1().update(bytes).digest());
665
+
556
666
  // src/client.ts
557
667
  var FilloError = class extends Error {
558
668
  constructor(message, status) {
@@ -570,6 +680,14 @@ function makeReporter(totalBytes, options) {
570
680
  fraction: totalBytes === 0 ? 1 : uploadedBytes / totalBytes
571
681
  });
572
682
  }
683
+ async function digestPartBase64(bytes) {
684
+ const subtle = globalThis.crypto?.subtle;
685
+ if (subtle) {
686
+ const buf = await subtle.digest("SHA-1", bytes);
687
+ return bytesToBase64(new Uint8Array(buf));
688
+ }
689
+ return sha1Base64(bytes);
690
+ }
573
691
  var DEFAULT_BASE_URL = "https://fillo.so";
574
692
  var FilloClient = class {
575
693
  /** Server origin this client targets, normalized (no trailing slash). */
@@ -702,63 +820,129 @@ var FilloClient = class {
702
820
  }
703
821
  );
704
822
  }
823
+ let completeBody;
705
824
  if (session.transport?.type === "gdrive") {
706
825
  await this.driveUploadLoop(session, session.transport.uploadUrl, file, options);
707
826
  } else if (session.transport?.type === "s3-put") {
708
827
  await this.s3PutUpload(session.transport.uploadUrl, file, options);
828
+ } else if (session.transport?.type === "box") {
829
+ completeBody = await this.boxUpload(session.transport, file, options);
709
830
  } else {
710
- await this.formworkUploadLoop(session, file, options);
831
+ throw new FilloError("This form's storage doesn't support uploads \u2014 connect Drive, S3, or Box.");
711
832
  }
712
833
  const done = await this.json(
713
834
  `/uploads/${encodeURIComponent(session.id)}/complete`,
714
- { method: "POST", headers: session.token ? { "X-Fillo-Upload-Token": session.token } : void 0 }
835
+ {
836
+ method: "POST",
837
+ headers: session.token ? { "X-Fillo-Upload-Token": session.token } : void 0,
838
+ ...completeBody ? { body: JSON.stringify(completeBody) } : {}
839
+ }
715
840
  );
716
841
  if (!done.file) throw new FilloError("Upload completed but no file was returned");
717
842
  return done.file;
718
843
  }
719
- async formworkUploadLoop(session, file, options) {
720
- let offset = session.uploadedBytes;
844
+ /**
845
+ * Box direct upload with a folder-scoped token (Box UI Elements pattern).
846
+ * Small files go up in one multipart POST; large files run Box's chunked
847
+ * session entirely from the client — create session, PUT each part with a
848
+ * SHA-1 `Digest`, then commit with the whole-file digest. Bytes go straight to
849
+ * Box; we return the new file id for /complete to verify server-side.
850
+ */
851
+ async boxUpload(transport, file, options) {
721
852
  const report = makeReporter(file.size, options);
722
- report(offset);
853
+ report(0);
854
+ const auth = { Authorization: `Bearer ${transport.token}` };
855
+ if (transport.mode === "simple") {
856
+ const form = new FormData();
857
+ form.append(
858
+ "attributes",
859
+ JSON.stringify({ name: transport.fileName, parent: { id: transport.folderId } })
860
+ );
861
+ form.append("file", file, transport.fileName);
862
+ const res = await this.retry(
863
+ options,
864
+ () => this.fetch(transport.uploadUrl, { method: "POST", headers: auth, body: form, signal: options.signal })
865
+ );
866
+ if (!res.ok) throw new FilloError(`Box upload failed: ${res.status}`, res.status);
867
+ const body = await res.json();
868
+ report(file.size);
869
+ return { providerFileId: body.entries?.[0]?.id };
870
+ }
871
+ if (transport.size !== file.size) {
872
+ throw new FilloError(
873
+ `File size changed since the upload was requested (${file.size} vs ${transport.size}).`,
874
+ 400
875
+ );
876
+ }
877
+ const sessionRes = await this.retry(
878
+ options,
879
+ () => this.fetch(transport.sessionUrl, {
880
+ method: "POST",
881
+ headers: { ...auth, "Content-Type": "application/json" },
882
+ body: JSON.stringify({
883
+ folder_id: transport.folderId,
884
+ file_size: transport.size,
885
+ file_name: transport.fileName
886
+ }),
887
+ signal: options.signal
888
+ })
889
+ );
890
+ if (!sessionRes.ok) throw new FilloError(`Box session failed: ${sessionRes.status}`, sessionRes.status);
891
+ const session = await sessionRes.json();
892
+ const whole = new Sha1();
893
+ const parts = [];
894
+ let offset = 0;
723
895
  while (offset < file.size) {
724
896
  options.signal?.throwIfAborted();
725
- const chunk = file.slice(offset, offset + session.chunkSize);
726
- let attempt = 0;
727
- for (; ; ) {
728
- try {
729
- const res = await this.fetch(
730
- this.url(`/uploads/${encodeURIComponent(session.id)}/chunk`),
731
- {
732
- method: "PUT",
733
- headers: {
734
- "Content-Type": "application/octet-stream",
735
- "X-Upload-Offset": String(offset),
736
- ...session.token ? { "X-Fillo-Upload-Token": session.token } : {}
737
- },
738
- body: chunk,
739
- signal: options.signal
740
- }
741
- );
742
- if (res.status === 409) {
743
- const body2 = await res.json();
744
- offset = body2.uploadedBytes;
745
- break;
746
- }
747
- if (!res.ok) throw new FilloError(`Chunk failed: ${res.status}`, res.status);
748
- const body = await res.json();
749
- offset = body.uploadedBytes;
750
- break;
751
- } catch (err) {
752
- if (options.signal?.aborted) throw err;
753
- attempt += 1;
754
- if (attempt >= MAX_CHUNK_RETRIES) throw err;
755
- await new Promise((r) => setTimeout(r, 500 * 2 ** attempt));
756
- const fresh = await this.getUploadSession(session.id, session.token);
757
- offset = fresh.uploadedBytes;
758
- }
759
- }
897
+ const end = Math.min(offset + session.part_size, file.size);
898
+ const bytes = new Uint8Array(await file.slice(offset, end).arrayBuffer());
899
+ whole.update(bytes);
900
+ const partDigest = await digestPartBase64(bytes);
901
+ const res = await this.retry(
902
+ options,
903
+ () => this.fetch(session.session_endpoints.upload_part, {
904
+ method: "PUT",
905
+ headers: {
906
+ ...auth,
907
+ "Content-Type": "application/octet-stream",
908
+ Digest: `sha=${partDigest}`,
909
+ "Content-Range": `bytes ${offset}-${end - 1}/${file.size}`
910
+ },
911
+ body: bytes,
912
+ signal: options.signal
913
+ })
914
+ );
915
+ if (!res.ok) throw new FilloError(`Box part failed: ${res.status}`, res.status);
916
+ parts.push((await res.json()).part);
917
+ offset = end;
760
918
  report(offset);
761
919
  }
920
+ const commitRes = await this.retry(
921
+ options,
922
+ () => this.fetch(session.session_endpoints.commit, {
923
+ method: "POST",
924
+ headers: { ...auth, "Content-Type": "application/json", Digest: `sha=${bytesToBase64(whole.digest())}` },
925
+ body: JSON.stringify({ parts }),
926
+ signal: options.signal
927
+ })
928
+ );
929
+ if (!commitRes.ok) throw new FilloError(`Box commit failed: ${commitRes.status}`, commitRes.status);
930
+ const committed = await commitRes.json();
931
+ return { providerFileId: committed.entries?.[0]?.id };
932
+ }
933
+ /** Retry a request with exponential backoff; never retries an aborted upload. */
934
+ async retry(options, fn) {
935
+ let attempt = 0;
936
+ for (; ; ) {
937
+ try {
938
+ return await fn();
939
+ } catch (err) {
940
+ if (options.signal?.aborted) throw err;
941
+ attempt += 1;
942
+ if (attempt >= MAX_CHUNK_RETRIES) throw err;
943
+ await new Promise((r) => setTimeout(r, 500 * 2 ** attempt));
944
+ }
945
+ }
762
946
  }
763
947
  /**
764
948
  * Google Drive resumable protocol: PUT chunks with Content-Range; Drive
@@ -788,7 +972,9 @@ var FilloClient = class {
788
972
  if (res.status === 308) {
789
973
  const range = res.headers.get("Range") ?? res.headers.get("range");
790
974
  const match = range?.match(/bytes=0-(\d+)/);
791
- offset = match ? Number(match[1]) + 1 : 0;
975
+ if (match) offset = Number(match[1]) + 1;
976
+ else if (range == null) offset = 0;
977
+ else offset = (await this.getUploadSession(session.id, session.token)).uploadedBytes;
792
978
  break;
793
979
  }
794
980
  if (res.ok) {
@@ -856,8 +1042,16 @@ async function provisionWorkspace(options) {
856
1042
 
857
1043
  // src/controller.ts
858
1044
  var EMPTY_PAGE = { id: "empty", blocks: [] };
1045
+ var warnedNoClient = false;
1046
+ function warnNoClient() {
1047
+ if (warnedNoClient) return;
1048
+ warnedNoClient = true;
1049
+ console.warn(
1050
+ "[fillo] This form has no `client`, so responses can't be submitted \u2014 it's render-only. Pass a client (createClient) to collect responses in Fillo, or forward them to your own backend with webhooks: https://fillo.so/docs"
1051
+ );
1052
+ }
859
1053
  function createFormController(options) {
860
- const { onChange, onSubmitted, onSubmit } = options;
1054
+ const { onChange, onSubmitted } = options;
861
1055
  let form = options.form;
862
1056
  let formId = options.formId;
863
1057
  let client = options.client;
@@ -892,6 +1086,13 @@ function createFormController(options) {
892
1086
  snapshot = computeSnapshot();
893
1087
  for (const listener of listeners) listener();
894
1088
  }
1089
+ function emitSubmitted(responseId, submitted) {
1090
+ try {
1091
+ onSubmitted?.(responseId, submitted);
1092
+ } catch (err) {
1093
+ console.error("[fillo] onSubmitted handler threw:", err);
1094
+ }
1095
+ }
895
1096
  function validatePage(index) {
896
1097
  const target = form.pages[index];
897
1098
  if (!target) return {};
@@ -923,11 +1124,13 @@ function createFormController(options) {
923
1124
  function next() {
924
1125
  const pageCount = form.pages.length;
925
1126
  const clamped = Math.min(pageIndex, Math.max(pageCount - 1, 0));
926
- const pageErrors = validatePage(clamped);
927
- errors = pageErrors;
928
- if (Object.keys(pageErrors).length > 0) {
929
- notify();
930
- return;
1127
+ if (!options.skipValidation) {
1128
+ const pageErrors = validatePage(clamped);
1129
+ errors = pageErrors;
1130
+ if (Object.keys(pageErrors).length > 0) {
1131
+ notify();
1132
+ return;
1133
+ }
931
1134
  }
932
1135
  pageIndex = Math.min(clamped + 1, pageCount - 1);
933
1136
  if (sessionId && client) client.reportProgress(sessionId, { furthestPage: pageIndex });
@@ -948,39 +1151,42 @@ function createFormController(options) {
948
1151
  notify();
949
1152
  return;
950
1153
  }
951
- status = "submitting";
952
- notify();
953
- try {
954
- if (onSubmit) {
955
- await onSubmit(result.data);
956
- status = "submitted";
957
- notify();
958
- onSubmitted?.(void 0, result.data);
959
- } else if (client && formId) {
960
- const res = await client.submit(formId, result.data, {
1154
+ if (client && formId) {
1155
+ status = "submitting";
1156
+ notify();
1157
+ let res;
1158
+ try {
1159
+ res = await client.submit(formId, result.data, {
961
1160
  hp: options.getHoneypot?.() ?? "",
962
- elapsedMs: Date.now() - mountedAt
1161
+ elapsedMs: Date.now() - mountedAt,
1162
+ surface: options.surface ?? "default"
963
1163
  });
964
- if (!res.ok) {
965
- errors = res.errors ?? {};
966
- status = "idle";
967
- notify();
968
- return;
969
- }
970
- if (sessionId) client.reportProgress(sessionId, { completed: true });
971
- status = "submitted";
1164
+ } catch (err) {
1165
+ status = "idle";
972
1166
  notify();
973
- onSubmitted?.(res.responseId, result.data);
974
- } else {
975
- status = "submitted";
1167
+ throw err;
1168
+ }
1169
+ if (!res.ok) {
1170
+ errors = res.errors ?? {};
1171
+ status = "idle";
976
1172
  notify();
977
- onSubmitted?.(void 0, result.data);
1173
+ return;
978
1174
  }
979
- } catch (err) {
980
- status = "idle";
1175
+ if (sessionId) client.reportProgress(sessionId, { completed: true });
1176
+ status = "submitted";
981
1177
  notify();
982
- throw err;
1178
+ emitSubmitted(res.responseId, result.data);
1179
+ return;
983
1180
  }
1181
+ if (options.skipValidation) {
1182
+ status = "submitted";
1183
+ notify();
1184
+ emitSubmitted(void 0, result.data);
1185
+ return;
1186
+ }
1187
+ warnNoClient();
1188
+ status = "error";
1189
+ notify();
984
1190
  }
985
1191
  return {
986
1192
  getState: () => snapshot,
@@ -1021,11 +1227,11 @@ function defineForm(def) {
1021
1227
  pages: def.pages,
1022
1228
  settings: def.settings ?? {}
1023
1229
  },
1024
- __formworkCodeForm: true
1230
+ __filloCodeForm: true
1025
1231
  };
1026
1232
  }
1027
1233
  function isCodeForm(form) {
1028
- return typeof form === "object" && form !== null && "__formworkCodeForm" in form;
1234
+ return typeof form === "object" && form !== null && "__filloCodeForm" in form;
1029
1235
  }
1030
1236
  var syncCache = /* @__PURE__ */ new Map();
1031
1237
  function contentHash(input) {
@@ -1133,8 +1339,10 @@ export {
1133
1339
  DRAFT_KINDS,
1134
1340
  FilloClient,
1135
1341
  FilloError,
1342
+ Sha1,
1136
1343
  allFields,
1137
1344
  assembleForm,
1345
+ contentHash,
1138
1346
  createBlock,
1139
1347
  createClient,
1140
1348
  createEmptyForm,
@@ -1149,6 +1357,7 @@ export {
1149
1357
  prefillFromParams,
1150
1358
  provisionWorkspace,
1151
1359
  resolveText,
1360
+ sha1Base64,
1152
1361
  syncCodeForm,
1153
1362
  validateField,
1154
1363
  validateFormSchema,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usefillo/core",
3
- "version": "0.2.3",
3
+ "version": "0.3.0",
4
4
  "description": "Form schema, validation, logic engine and JS client for Fillo. Framework-agnostic.",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -10,11 +10,7 @@
10
10
  "headless",
11
11
  "typescript"
12
12
  ],
13
- "repository": {
14
- "type": "git",
15
- "url": "https://github.com/jacobfunch/fillo.git",
16
- "directory": "packages/core"
17
- },
13
+ "homepage": "https://fillo.so",
18
14
  "type": "module",
19
15
  "sideEffects": false,
20
16
  "main": "./dist/index.js",