@usefillo/core 0.2.2 → 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 +129 -24
- package/dist/index.js +299 -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
|
|
@@ -435,21 +470,68 @@ declare class FilloClient {
|
|
|
435
470
|
private s3PutUpload;
|
|
436
471
|
}
|
|
437
472
|
declare function createClient(options?: FilloClientOptions): FilloClient;
|
|
473
|
+
interface ProvisionWorkspaceResult {
|
|
474
|
+
/** Publishable key (pk_…) for the new workspace. Pass to createClient({ key }). */
|
|
475
|
+
key: string;
|
|
476
|
+
organizationId: string;
|
|
477
|
+
/** A claim link was emailed here so the owner can take ownership later. */
|
|
478
|
+
claim: {
|
|
479
|
+
email: string;
|
|
480
|
+
sent: boolean;
|
|
481
|
+
};
|
|
482
|
+
/** Caps applied until the workspace is claimed. */
|
|
483
|
+
limits: {
|
|
484
|
+
responses: number;
|
|
485
|
+
expiresAt: string;
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* Provision a Fillo workspace from an email — no signup — and get a publishable
|
|
490
|
+
* key back, so a form can collect real responses immediately. A claim link is
|
|
491
|
+
* emailed to `email`; until someone claims it the workspace runs as a capped
|
|
492
|
+
* preview (limited responses, for a limited time). Built for setup automation,
|
|
493
|
+
* e.g. a coding agent wiring Fillo into an app during integration.
|
|
494
|
+
*
|
|
495
|
+
* const { key } = await provisionWorkspace({ email: "you@co.com" });
|
|
496
|
+
* const client = createClient({ key });
|
|
497
|
+
*/
|
|
498
|
+
declare function provisionWorkspace(options: {
|
|
499
|
+
email: string;
|
|
500
|
+
baseUrl?: string;
|
|
501
|
+
/** Free-form provenance label, e.g. "agent" | "cli". */
|
|
502
|
+
source?: string;
|
|
503
|
+
fetch?: typeof fetch;
|
|
504
|
+
}): Promise<ProvisionWorkspaceResult>;
|
|
438
505
|
|
|
439
506
|
type FormStatus = "idle" | "submitting" | "submitted" | "error";
|
|
440
507
|
interface FormControllerOptions {
|
|
441
508
|
form: FormSchema;
|
|
442
509
|
/** Target form id — required for client submission and funnel tracking. */
|
|
443
510
|
formId?: string;
|
|
444
|
-
/**
|
|
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
|
+
*/
|
|
445
516
|
client?: FilloClient;
|
|
446
517
|
initialData?: ResponseData;
|
|
447
|
-
onChange?: (data: ResponseData) => void;
|
|
448
518
|
onSubmitted?: (responseId: string | undefined, data: ResponseData) => void;
|
|
449
|
-
|
|
450
|
-
onSubmit?: (data: ResponseData) => Promise<void> | void;
|
|
519
|
+
onChange?: (data: ResponseData) => void;
|
|
451
520
|
/** Honeypot value, supplied by your renderer; must be empty for a human. */
|
|
452
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";
|
|
453
535
|
}
|
|
454
536
|
interface FormControllerState {
|
|
455
537
|
data: ResponseData;
|
|
@@ -493,14 +575,14 @@ interface FormController {
|
|
|
493
575
|
/** Drop all listeners. */
|
|
494
576
|
destroy(): void;
|
|
495
577
|
}
|
|
496
|
-
/**
|
|
497
|
-
* Framework-agnostic form engine: validation, conditional logic, pages, spam
|
|
498
|
-
* signals, and submission as a subscribable store — with no rendering. Drive it
|
|
499
|
-
* from vanilla JS, Vue, Svelte, or any framework and lay the fields out
|
|
500
|
-
* yourself. (In React, `<FilloProvider>` / `useFillo()` wrap this same engine.)
|
|
501
|
-
*/
|
|
502
578
|
declare function createFormController(options: FormControllerOptions): FormController;
|
|
503
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
|
+
}
|
|
504
586
|
/**
|
|
505
587
|
* A form whose structure lives in user code. Framework renderers can show it
|
|
506
588
|
* immediately, then sync it into a Fillo workspace when a publishable key is
|
|
@@ -511,7 +593,7 @@ interface CodeForm {
|
|
|
511
593
|
id: string;
|
|
512
594
|
schema: FormSchema;
|
|
513
595
|
theme?: FormTheme;
|
|
514
|
-
readonly
|
|
596
|
+
readonly __filloCodeForm: true;
|
|
515
597
|
}
|
|
516
598
|
declare function defineForm(def: {
|
|
517
599
|
id: string;
|
|
@@ -522,14 +604,13 @@ declare function defineForm(def: {
|
|
|
522
604
|
theme?: FormTheme;
|
|
523
605
|
}): CodeForm;
|
|
524
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;
|
|
525
609
|
/**
|
|
526
610
|
* Sync a code-defined form into a workspace at most once per session for the
|
|
527
611
|
* same client, handle, schema, and theme.
|
|
528
612
|
*/
|
|
529
|
-
declare function syncCodeForm(client: FilloClient, form: CodeForm): Promise<
|
|
530
|
-
formId: string;
|
|
531
|
-
slug: string;
|
|
532
|
-
}>;
|
|
613
|
+
declare function syncCodeForm(client: FilloClient, form: CodeForm): Promise<SyncedForm>;
|
|
533
614
|
|
|
534
615
|
/**
|
|
535
616
|
* Build initial response data from URL query parameters — Tally-style
|
|
@@ -551,4 +632,28 @@ declare function pipeBlock(block: Block, data: ResponseData, form: FormSchema):
|
|
|
551
632
|
/** Dependency-free nanoid-style id with an unbiased alphabet. Crypto-random. */
|
|
552
633
|
declare function createId(size?: number): string;
|
|
553
634
|
|
|
554
|
-
|
|
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) {
|
|
@@ -839,11 +1025,33 @@ var FilloClient = class {
|
|
|
839
1025
|
function createClient(options = {}) {
|
|
840
1026
|
return new FilloClient(options);
|
|
841
1027
|
}
|
|
1028
|
+
async function provisionWorkspace(options) {
|
|
1029
|
+
const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, "");
|
|
1030
|
+
const doFetch = options.fetch ?? ((...args) => globalThis.fetch(...args));
|
|
1031
|
+
const res = await doFetch(`${baseUrl}/api/v1/workspaces/provision`, {
|
|
1032
|
+
method: "POST",
|
|
1033
|
+
headers: { "Content-Type": "application/json" },
|
|
1034
|
+
body: JSON.stringify({ email: options.email, source: options.source ?? "sdk" })
|
|
1035
|
+
});
|
|
1036
|
+
if (!res.ok) {
|
|
1037
|
+
const body = await res.text().catch(() => "");
|
|
1038
|
+
throw new FilloError(body || `Provision failed: ${res.status}`, res.status);
|
|
1039
|
+
}
|
|
1040
|
+
return await res.json();
|
|
1041
|
+
}
|
|
842
1042
|
|
|
843
1043
|
// src/controller.ts
|
|
844
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
|
+
}
|
|
845
1053
|
function createFormController(options) {
|
|
846
|
-
const { onChange, onSubmitted
|
|
1054
|
+
const { onChange, onSubmitted } = options;
|
|
847
1055
|
let form = options.form;
|
|
848
1056
|
let formId = options.formId;
|
|
849
1057
|
let client = options.client;
|
|
@@ -878,6 +1086,13 @@ function createFormController(options) {
|
|
|
878
1086
|
snapshot = computeSnapshot();
|
|
879
1087
|
for (const listener of listeners) listener();
|
|
880
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
|
+
}
|
|
881
1096
|
function validatePage(index) {
|
|
882
1097
|
const target = form.pages[index];
|
|
883
1098
|
if (!target) return {};
|
|
@@ -909,11 +1124,13 @@ function createFormController(options) {
|
|
|
909
1124
|
function next() {
|
|
910
1125
|
const pageCount = form.pages.length;
|
|
911
1126
|
const clamped = Math.min(pageIndex, Math.max(pageCount - 1, 0));
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
1127
|
+
if (!options.skipValidation) {
|
|
1128
|
+
const pageErrors = validatePage(clamped);
|
|
1129
|
+
errors = pageErrors;
|
|
1130
|
+
if (Object.keys(pageErrors).length > 0) {
|
|
1131
|
+
notify();
|
|
1132
|
+
return;
|
|
1133
|
+
}
|
|
917
1134
|
}
|
|
918
1135
|
pageIndex = Math.min(clamped + 1, pageCount - 1);
|
|
919
1136
|
if (sessionId && client) client.reportProgress(sessionId, { furthestPage: pageIndex });
|
|
@@ -934,39 +1151,42 @@ function createFormController(options) {
|
|
|
934
1151
|
notify();
|
|
935
1152
|
return;
|
|
936
1153
|
}
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
notify();
|
|
944
|
-
onSubmitted?.(void 0, result.data);
|
|
945
|
-
} else if (client && formId) {
|
|
946
|
-
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, {
|
|
947
1160
|
hp: options.getHoneypot?.() ?? "",
|
|
948
|
-
elapsedMs: Date.now() - mountedAt
|
|
1161
|
+
elapsedMs: Date.now() - mountedAt,
|
|
1162
|
+
surface: options.surface ?? "default"
|
|
949
1163
|
});
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
status = "idle";
|
|
953
|
-
notify();
|
|
954
|
-
return;
|
|
955
|
-
}
|
|
956
|
-
if (sessionId) client.reportProgress(sessionId, { completed: true });
|
|
957
|
-
status = "submitted";
|
|
1164
|
+
} catch (err) {
|
|
1165
|
+
status = "idle";
|
|
958
1166
|
notify();
|
|
959
|
-
|
|
960
|
-
}
|
|
961
|
-
|
|
1167
|
+
throw err;
|
|
1168
|
+
}
|
|
1169
|
+
if (!res.ok) {
|
|
1170
|
+
errors = res.errors ?? {};
|
|
1171
|
+
status = "idle";
|
|
962
1172
|
notify();
|
|
963
|
-
|
|
1173
|
+
return;
|
|
964
1174
|
}
|
|
965
|
-
|
|
966
|
-
status = "
|
|
1175
|
+
if (sessionId) client.reportProgress(sessionId, { completed: true });
|
|
1176
|
+
status = "submitted";
|
|
967
1177
|
notify();
|
|
968
|
-
|
|
1178
|
+
emitSubmitted(res.responseId, result.data);
|
|
1179
|
+
return;
|
|
969
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();
|
|
970
1190
|
}
|
|
971
1191
|
return {
|
|
972
1192
|
getState: () => snapshot,
|
|
@@ -1007,11 +1227,11 @@ function defineForm(def) {
|
|
|
1007
1227
|
pages: def.pages,
|
|
1008
1228
|
settings: def.settings ?? {}
|
|
1009
1229
|
},
|
|
1010
|
-
|
|
1230
|
+
__filloCodeForm: true
|
|
1011
1231
|
};
|
|
1012
1232
|
}
|
|
1013
1233
|
function isCodeForm(form) {
|
|
1014
|
-
return typeof form === "object" && form !== null && "
|
|
1234
|
+
return typeof form === "object" && form !== null && "__filloCodeForm" in form;
|
|
1015
1235
|
}
|
|
1016
1236
|
var syncCache = /* @__PURE__ */ new Map();
|
|
1017
1237
|
function contentHash(input) {
|
|
@@ -1119,8 +1339,10 @@ export {
|
|
|
1119
1339
|
DRAFT_KINDS,
|
|
1120
1340
|
FilloClient,
|
|
1121
1341
|
FilloError,
|
|
1342
|
+
Sha1,
|
|
1122
1343
|
allFields,
|
|
1123
1344
|
assembleForm,
|
|
1345
|
+
contentHash,
|
|
1124
1346
|
createBlock,
|
|
1125
1347
|
createClient,
|
|
1126
1348
|
createEmptyForm,
|
|
@@ -1133,7 +1355,9 @@ export {
|
|
|
1133
1355
|
isField,
|
|
1134
1356
|
pipeBlock,
|
|
1135
1357
|
prefillFromParams,
|
|
1358
|
+
provisionWorkspace,
|
|
1136
1359
|
resolveText,
|
|
1360
|
+
sha1Base64,
|
|
1137
1361
|
syncCodeForm,
|
|
1138
1362
|
validateField,
|
|
1139
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",
|