@usefillo/core 0.1.0 → 0.2.1

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
@@ -212,6 +212,11 @@ interface UploadSession {
212
212
  status: UploadStatus;
213
213
  /** Defaults to the Fillo protocol when omitted. */
214
214
  transport?: UploadTransport;
215
+ /**
216
+ * Per-session bearer returned at creation; sent back (X-Fillo-Upload-Token)
217
+ * on chunk/complete/status so only the creator can drive this upload.
218
+ */
219
+ token?: string;
215
220
  /** Set when status is "complete". */
216
221
  file?: FileValue;
217
222
  }
@@ -316,8 +321,12 @@ interface FormDraftSpec {
316
321
  declare function assembleForm(spec: FormDraftSpec): FormSchema;
317
322
 
318
323
  interface FilloClientOptions {
319
- /** Origin of the Fillo server, e.g. "https://forms.example.com". */
320
- baseUrl: string;
324
+ /**
325
+ * Where the SDK sends requests. Defaults to the hosted API (https://fillo.so).
326
+ * Only set this if you reverse-proxy Fillo through your own domain — e.g. to
327
+ * keep traffic first-party or dodge ad-blockers, like PostHog's api_host.
328
+ */
329
+ baseUrl?: string;
321
330
  /**
322
331
  * Publishable workspace key (pk_…) — safe to ship in client code. Required
323
332
  * only for syncing code-defined forms into the workspace.
@@ -358,6 +367,8 @@ interface UploadFileOptions {
358
367
  signal?: AbortSignal;
359
368
  /** Resume an interrupted session instead of starting fresh. */
360
369
  sessionId?: string;
370
+ /** Ownership token for the resumed session (from the original create call). */
371
+ uploadToken?: string;
361
372
  }
362
373
  declare class FilloError extends Error {
363
374
  status?: number | undefined;
@@ -369,7 +380,7 @@ declare class FilloClient {
369
380
  private fetch;
370
381
  /** Publishable workspace key, when configured. */
371
382
  readonly key?: string;
372
- constructor(options: FilloClientOptions);
383
+ constructor(options?: FilloClientOptions);
373
384
  private url;
374
385
  private json;
375
386
  /** Fetch a published form definition by id or slug. */
@@ -395,7 +406,7 @@ declare class FilloClient {
395
406
  completed?: boolean;
396
407
  }): void;
397
408
  /** Current state of an upload session — used to resume after interruption. */
398
- getUploadSession(sessionId: string): Promise<UploadSession>;
409
+ getUploadSession(sessionId: string, token?: string): Promise<UploadSession>;
399
410
  /**
400
411
  * Resumable chunked upload. Creates (or resumes) a session, streams the file
401
412
  * chunk by chunk with progress callbacks, and finalizes into a FileValue that
@@ -423,7 +434,72 @@ declare class FilloClient {
423
434
  */
424
435
  private s3PutUpload;
425
436
  }
426
- declare function createClient(options: FilloClientOptions): FilloClient;
437
+ declare function createClient(options?: FilloClientOptions): FilloClient;
438
+
439
+ type FormStatus = "idle" | "submitting" | "submitted" | "error";
440
+ interface FormControllerOptions {
441
+ form: FormSchema;
442
+ /** Target form id — required for client submission and funnel tracking. */
443
+ formId?: string;
444
+ /** Collect responses into a Fillo workspace. Omit and pass `onSubmit` to keep them yourself. */
445
+ client?: FilloClient;
446
+ initialData?: ResponseData;
447
+ onChange?: (data: ResponseData) => void;
448
+ onSubmitted?: (responseId: string | undefined, data: ResponseData) => void;
449
+ /** Handle submission yourself instead of the client. */
450
+ onSubmit?: (data: ResponseData) => Promise<void> | void;
451
+ /** Honeypot value, supplied by your renderer; must be empty for a human. */
452
+ getHoneypot?: () => string;
453
+ }
454
+ interface FormControllerState {
455
+ data: ResponseData;
456
+ errors: Record<string, string>;
457
+ status: FormStatus;
458
+ pageIndex: number;
459
+ pageCount: number;
460
+ /** The current page (after clamping). */
461
+ page: FormPage;
462
+ /** Blocks visible on the current page, after conditional logic. */
463
+ blocks: Block[];
464
+ isFirstPage: boolean;
465
+ isLastPage: boolean;
466
+ /** True while any file field is uploading. */
467
+ uploading: boolean;
468
+ }
469
+ interface FormController {
470
+ /** Stable snapshot — same reference until something changes (safe for useSyncExternalStore). */
471
+ getState(): FormControllerState;
472
+ /** Subscribe to state changes; returns an unsubscribe function. */
473
+ subscribe(listener: () => void): () => void;
474
+ setValue(fieldId: string, value: FieldValue): void;
475
+ /** Mark a file field busy/idle so `state.uploading` reflects it. */
476
+ setUploading(fieldId: string, busy: boolean): void;
477
+ /** Validate the current page, then advance. No-op on the last page or with errors. */
478
+ next(): void;
479
+ back(): void;
480
+ submit(): Promise<void>;
481
+ /**
482
+ * Late-bind inputs after creation, keeping current answers/page/status:
483
+ * - `form` updates the rendered schema in place (a builder live preview being
484
+ * edited), recomputing visible blocks and re-notifying.
485
+ * - `formId`/`client` set the submission target (e.g. a code-defined form id
486
+ * that resolves asynchronously).
487
+ */
488
+ setContext(ctx: {
489
+ form?: FormSchema;
490
+ formId?: string;
491
+ client?: FilloClient;
492
+ }): void;
493
+ /** Drop all listeners. */
494
+ destroy(): void;
495
+ }
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
+ declare function createFormController(options: FormControllerOptions): FormController;
427
503
 
428
504
  /**
429
505
  * A form whose structure lives in user code. Framework renderers can show it
@@ -472,7 +548,7 @@ declare function resolveText(text: string, data: ResponseData, form: FormSchema)
472
548
  /** Return a copy of a block with its visible text fields piped, or the block unchanged. */
473
549
  declare function pipeBlock(block: Block, data: ResponseData, form: FormSchema): Block;
474
550
 
475
- /** Dependency-free nanoid-style id. Crypto-random where available. */
551
+ /** Dependency-free nanoid-style id with an unbiased alphabet. Crypto-random. */
476
552
  declare function createId(size?: number): string;
477
553
 
478
- 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 FormDraftSpec, type FormPage, type FormSchema, type FormSettings, type FormTheme, type HeadingBlock, type HiddenField, type JsonValue, type LinearScaleField, type MatrixField, type NumberField, type ParagraphBlock, 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, createId, defineForm, formatAnswer, isBlockVisible, isCodeForm, isField, pipeBlock, prefillFromParams, resolveText, syncCodeForm, validateField, validateFormSchema, validateResponse, visibleBlocks, visibleFields };
554
+ 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 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, resolveText, syncCodeForm, validateField, validateFormSchema, validateResponse, visibleBlocks, visibleFields };
package/dist/index.js CHANGED
@@ -335,15 +335,21 @@ function formatAnswer(field, value) {
335
335
 
336
336
  // src/ids.ts
337
337
  var ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
338
+ var MASK = 256 - 256 % ALPHABET.length;
338
339
  function createId(size = 12) {
339
- const bytes = new Uint8Array(size);
340
- if (typeof globalThis.crypto !== "undefined") {
341
- globalThis.crypto.getRandomValues(bytes);
342
- } else {
343
- for (let i = 0; i < size; i++) bytes[i] = Math.floor(Math.random() * 256);
344
- }
345
340
  let id = "";
346
- for (let i = 0; i < size; i++) id += ALPHABET[bytes[i] % ALPHABET.length];
341
+ while (id.length < size) {
342
+ const bytes = new Uint8Array(size);
343
+ if (typeof globalThis.crypto !== "undefined") {
344
+ globalThis.crypto.getRandomValues(bytes);
345
+ } else {
346
+ for (let i = 0; i < size; i++) bytes[i] = Math.floor(Math.random() * 256);
347
+ }
348
+ for (let i = 0; i < size && id.length < size; i++) {
349
+ const b = bytes[i];
350
+ if (b < MASK) id += ALPHABET[b % ALPHABET.length];
351
+ }
352
+ }
347
353
  return id;
348
354
  }
349
355
 
@@ -564,14 +570,15 @@ function makeReporter(totalBytes, options) {
564
570
  fraction: totalBytes === 0 ? 1 : uploadedBytes / totalBytes
565
571
  });
566
572
  }
573
+ var DEFAULT_BASE_URL = "https://fillo.so";
567
574
  var FilloClient = class {
568
575
  /** Server origin this client targets, normalized (no trailing slash). */
569
576
  baseUrl;
570
577
  fetch;
571
578
  /** Publishable workspace key, when configured. */
572
579
  key;
573
- constructor(options) {
574
- this.baseUrl = options.baseUrl.replace(/\/$/, "");
580
+ constructor(options = {}) {
581
+ this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, "");
575
582
  this.key = options.key;
576
583
  this.fetch = options.fetch ?? ((...args) => globalThis.fetch(...args));
577
584
  }
@@ -657,8 +664,11 @@ var FilloClient = class {
657
664
  }
658
665
  }
659
666
  /** Current state of an upload session — used to resume after interruption. */
660
- getUploadSession(sessionId) {
661
- return this.json(`/uploads/${encodeURIComponent(sessionId)}`);
667
+ getUploadSession(sessionId, token) {
668
+ return this.json(
669
+ `/uploads/${encodeURIComponent(sessionId)}`,
670
+ token ? { headers: { "X-Fillo-Upload-Token": token } } : void 0
671
+ );
662
672
  }
663
673
  /**
664
674
  * Resumable chunked upload. Creates (or resumes) a session, streams the file
@@ -677,7 +687,7 @@ var FilloClient = class {
677
687
  const mime = file.type || "application/octet-stream";
678
688
  let session;
679
689
  if (options.sessionId) {
680
- session = await this.getUploadSession(options.sessionId);
690
+ session = await this.getUploadSession(options.sessionId, options.uploadToken);
681
691
  } else {
682
692
  session = await this.json(
683
693
  `/forms/${encodeURIComponent(formId)}/uploads`,
@@ -701,7 +711,7 @@ var FilloClient = class {
701
711
  }
702
712
  const done = await this.json(
703
713
  `/uploads/${encodeURIComponent(session.id)}/complete`,
704
- { method: "POST" }
714
+ { method: "POST", headers: session.token ? { "X-Fillo-Upload-Token": session.token } : void 0 }
705
715
  );
706
716
  if (!done.file) throw new FilloError("Upload completed but no file was returned");
707
717
  return done.file;
@@ -722,7 +732,8 @@ var FilloClient = class {
722
732
  method: "PUT",
723
733
  headers: {
724
734
  "Content-Type": "application/octet-stream",
725
- "X-Upload-Offset": String(offset)
735
+ "X-Upload-Offset": String(offset),
736
+ ...session.token ? { "X-Fillo-Upload-Token": session.token } : {}
726
737
  },
727
738
  body: chunk,
728
739
  signal: options.signal
@@ -742,7 +753,7 @@ var FilloClient = class {
742
753
  attempt += 1;
743
754
  if (attempt >= MAX_CHUNK_RETRIES) throw err;
744
755
  await new Promise((r) => setTimeout(r, 500 * 2 ** attempt));
745
- const fresh = await this.getUploadSession(session.id);
756
+ const fresh = await this.getUploadSession(session.id, session.token);
746
757
  offset = fresh.uploadedBytes;
747
758
  }
748
759
  }
@@ -790,7 +801,7 @@ var FilloClient = class {
790
801
  attempt += 1;
791
802
  if (attempt >= MAX_CHUNK_RETRIES) throw err;
792
803
  await new Promise((r) => setTimeout(r, 500 * 2 ** attempt));
793
- const fresh = await this.getUploadSession(session.id);
804
+ const fresh = await this.getUploadSession(session.id, session.token);
794
805
  offset = fresh.uploadedBytes;
795
806
  }
796
807
  }
@@ -825,10 +836,165 @@ var FilloClient = class {
825
836
  }
826
837
  }
827
838
  };
828
- function createClient(options) {
839
+ function createClient(options = {}) {
829
840
  return new FilloClient(options);
830
841
  }
831
842
 
843
+ // src/controller.ts
844
+ var EMPTY_PAGE = { id: "empty", blocks: [] };
845
+ function createFormController(options) {
846
+ const { onChange, onSubmitted, onSubmit } = options;
847
+ let form = options.form;
848
+ let formId = options.formId;
849
+ let client = options.client;
850
+ let data = { ...options.initialData ?? {} };
851
+ let errors = {};
852
+ let pageIndex = 0;
853
+ let status = "idle";
854
+ const uploadingFields = /* @__PURE__ */ new Set();
855
+ const listeners = /* @__PURE__ */ new Set();
856
+ const mountedAt = Date.now();
857
+ let sessionId = null;
858
+ let sessionStarted = false;
859
+ function computeSnapshot() {
860
+ const pageCount = form.pages.length;
861
+ const clamped = Math.min(pageIndex, Math.max(pageCount - 1, 0));
862
+ const page = form.pages[clamped] ?? EMPTY_PAGE;
863
+ return {
864
+ data,
865
+ errors,
866
+ status,
867
+ pageIndex: clamped,
868
+ pageCount,
869
+ page,
870
+ blocks: visibleBlocks(page, data),
871
+ isFirstPage: clamped === 0,
872
+ isLastPage: clamped === pageCount - 1,
873
+ uploading: uploadingFields.size > 0
874
+ };
875
+ }
876
+ let snapshot = computeSnapshot();
877
+ function notify() {
878
+ snapshot = computeSnapshot();
879
+ for (const listener of listeners) listener();
880
+ }
881
+ function validatePage(index) {
882
+ const target = form.pages[index];
883
+ if (!target) return {};
884
+ const pageErrors = {};
885
+ for (const block of visibleBlocks(target, data)) {
886
+ if (!isField(block)) continue;
887
+ const error = validateField(block, data[block.id]);
888
+ if (error) pageErrors[block.id] = error;
889
+ }
890
+ return pageErrors;
891
+ }
892
+ function setValue(fieldId, value) {
893
+ if (!sessionStarted && client && formId) {
894
+ sessionStarted = true;
895
+ void client.startSession(formId, form.pages.length).then((id) => sessionId = id);
896
+ }
897
+ data = { ...data, [fieldId]: value };
898
+ onChange?.(data);
899
+ if (fieldId in errors) {
900
+ errors = Object.fromEntries(Object.entries(errors).filter(([k]) => k !== fieldId));
901
+ }
902
+ notify();
903
+ }
904
+ function setUploading(fieldId, busy) {
905
+ if (busy) uploadingFields.add(fieldId);
906
+ else uploadingFields.delete(fieldId);
907
+ notify();
908
+ }
909
+ function next() {
910
+ const pageCount = form.pages.length;
911
+ const clamped = Math.min(pageIndex, Math.max(pageCount - 1, 0));
912
+ const pageErrors = validatePage(clamped);
913
+ errors = pageErrors;
914
+ if (Object.keys(pageErrors).length > 0) {
915
+ notify();
916
+ return;
917
+ }
918
+ pageIndex = Math.min(clamped + 1, pageCount - 1);
919
+ if (sessionId && client) client.reportProgress(sessionId, { furthestPage: pageIndex });
920
+ notify();
921
+ }
922
+ function back() {
923
+ errors = {};
924
+ pageIndex = Math.max(Math.min(pageIndex, form.pages.length - 1) - 1, 0);
925
+ notify();
926
+ }
927
+ async function submit() {
928
+ if (status === "submitting" || status === "submitted") return;
929
+ const result = validateResponse(form, data);
930
+ if (!result.ok) {
931
+ errors = result.errors;
932
+ const firstBad = form.pages.findIndex((p) => p.blocks.some((b) => b.id in result.errors));
933
+ if (firstBad >= 0) pageIndex = firstBad;
934
+ notify();
935
+ return;
936
+ }
937
+ status = "submitting";
938
+ notify();
939
+ try {
940
+ if (onSubmit) {
941
+ await onSubmit(result.data);
942
+ status = "submitted";
943
+ notify();
944
+ onSubmitted?.(void 0, result.data);
945
+ } else if (client && formId) {
946
+ const res = await client.submit(formId, result.data, {
947
+ hp: options.getHoneypot?.() ?? "",
948
+ elapsedMs: Date.now() - mountedAt
949
+ });
950
+ if (!res.ok) {
951
+ errors = res.errors ?? {};
952
+ status = "idle";
953
+ notify();
954
+ return;
955
+ }
956
+ if (sessionId) client.reportProgress(sessionId, { completed: true });
957
+ status = "submitted";
958
+ notify();
959
+ onSubmitted?.(res.responseId, result.data);
960
+ } else {
961
+ status = "submitted";
962
+ notify();
963
+ onSubmitted?.(void 0, result.data);
964
+ }
965
+ } catch (err) {
966
+ status = "idle";
967
+ notify();
968
+ throw err;
969
+ }
970
+ }
971
+ return {
972
+ getState: () => snapshot,
973
+ subscribe(listener) {
974
+ listeners.add(listener);
975
+ return () => {
976
+ listeners.delete(listener);
977
+ };
978
+ },
979
+ setValue,
980
+ setUploading,
981
+ next,
982
+ back,
983
+ submit,
984
+ setContext(ctx) {
985
+ if ("formId" in ctx) formId = ctx.formId;
986
+ if ("client" in ctx) client = ctx.client;
987
+ if (ctx.form && ctx.form !== form) {
988
+ form = ctx.form;
989
+ notify();
990
+ }
991
+ },
992
+ destroy() {
993
+ listeners.clear();
994
+ }
995
+ };
996
+ }
997
+
832
998
  // src/define.ts
833
999
  function defineForm(def) {
834
1000
  return {
@@ -958,6 +1124,7 @@ export {
958
1124
  createBlock,
959
1125
  createClient,
960
1126
  createEmptyForm,
1127
+ createFormController,
961
1128
  createId,
962
1129
  defineForm,
963
1130
  formatAnswer,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usefillo/core",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Form schema, validation, logic engine and JS client for Fillo. Framework-agnostic.",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -42,6 +42,7 @@
42
42
  "scripts": {
43
43
  "build": "tsup src/index.ts --format esm --dts --clean",
44
44
  "dev": "tsup src/index.ts --format esm --dts --watch",
45
- "typecheck": "tsc --noEmit"
45
+ "typecheck": "tsc --noEmit",
46
+ "test": "node --test test/*.test.mjs"
46
47
  }
47
48
  }