@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 +97 -24
- package/dist/index.js +284 -75
- package/package.json +2 -6
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
|
-
*
|
|
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)
|
|
189
|
-
* - "s3-put": a single PUT to a presigned S3/R2 URL —
|
|
190
|
-
*
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
720
|
-
|
|
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(
|
|
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
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
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
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
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
|
-
|
|
965
|
-
|
|
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
|
-
|
|
974
|
-
}
|
|
975
|
-
|
|
1167
|
+
throw err;
|
|
1168
|
+
}
|
|
1169
|
+
if (!res.ok) {
|
|
1170
|
+
errors = res.errors ?? {};
|
|
1171
|
+
status = "idle";
|
|
976
1172
|
notify();
|
|
977
|
-
|
|
1173
|
+
return;
|
|
978
1174
|
}
|
|
979
|
-
|
|
980
|
-
status = "
|
|
1175
|
+
if (sessionId) client.reportProgress(sessionId, { completed: true });
|
|
1176
|
+
status = "submitted";
|
|
981
1177
|
notify();
|
|
982
|
-
|
|
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
|
-
|
|
1230
|
+
__filloCodeForm: true
|
|
1025
1231
|
};
|
|
1026
1232
|
}
|
|
1027
1233
|
function isCodeForm(form) {
|
|
1028
|
-
return typeof form === "object" && form !== null && "
|
|
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.
|
|
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
|
-
"
|
|
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",
|