@usefillo/core 0.1.0 → 0.2.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
@@ -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
  }
@@ -358,6 +363,8 @@ interface UploadFileOptions {
358
363
  signal?: AbortSignal;
359
364
  /** Resume an interrupted session instead of starting fresh. */
360
365
  sessionId?: string;
366
+ /** Ownership token for the resumed session (from the original create call). */
367
+ uploadToken?: string;
361
368
  }
362
369
  declare class FilloError extends Error {
363
370
  status?: number | undefined;
@@ -395,7 +402,7 @@ declare class FilloClient {
395
402
  completed?: boolean;
396
403
  }): void;
397
404
  /** Current state of an upload session — used to resume after interruption. */
398
- getUploadSession(sessionId: string): Promise<UploadSession>;
405
+ getUploadSession(sessionId: string, token?: string): Promise<UploadSession>;
399
406
  /**
400
407
  * Resumable chunked upload. Creates (or resumes) a session, streams the file
401
408
  * chunk by chunk with progress callbacks, and finalizes into a FileValue that
@@ -425,6 +432,71 @@ declare class FilloClient {
425
432
  }
426
433
  declare function createClient(options: FilloClientOptions): FilloClient;
427
434
 
435
+ type FormStatus = "idle" | "submitting" | "submitted" | "error";
436
+ interface FormControllerOptions {
437
+ form: FormSchema;
438
+ /** Target form id — required for client submission and funnel tracking. */
439
+ formId?: string;
440
+ /** Collect responses into a Fillo workspace. Omit and pass `onSubmit` to keep them yourself. */
441
+ client?: FilloClient;
442
+ initialData?: ResponseData;
443
+ onChange?: (data: ResponseData) => void;
444
+ onSubmitted?: (responseId: string | undefined, data: ResponseData) => void;
445
+ /** Handle submission yourself instead of the client. */
446
+ onSubmit?: (data: ResponseData) => Promise<void> | void;
447
+ /** Honeypot value, supplied by your renderer; must be empty for a human. */
448
+ getHoneypot?: () => string;
449
+ }
450
+ interface FormControllerState {
451
+ data: ResponseData;
452
+ errors: Record<string, string>;
453
+ status: FormStatus;
454
+ pageIndex: number;
455
+ pageCount: number;
456
+ /** The current page (after clamping). */
457
+ page: FormPage;
458
+ /** Blocks visible on the current page, after conditional logic. */
459
+ blocks: Block[];
460
+ isFirstPage: boolean;
461
+ isLastPage: boolean;
462
+ /** True while any file field is uploading. */
463
+ uploading: boolean;
464
+ }
465
+ interface FormController {
466
+ /** Stable snapshot — same reference until something changes (safe for useSyncExternalStore). */
467
+ getState(): FormControllerState;
468
+ /** Subscribe to state changes; returns an unsubscribe function. */
469
+ subscribe(listener: () => void): () => void;
470
+ setValue(fieldId: string, value: FieldValue): void;
471
+ /** Mark a file field busy/idle so `state.uploading` reflects it. */
472
+ setUploading(fieldId: string, busy: boolean): void;
473
+ /** Validate the current page, then advance. No-op on the last page or with errors. */
474
+ next(): void;
475
+ back(): void;
476
+ submit(): Promise<void>;
477
+ /**
478
+ * Late-bind inputs after creation, keeping current answers/page/status:
479
+ * - `form` updates the rendered schema in place (a builder live preview being
480
+ * edited), recomputing visible blocks and re-notifying.
481
+ * - `formId`/`client` set the submission target (e.g. a code-defined form id
482
+ * that resolves asynchronously).
483
+ */
484
+ setContext(ctx: {
485
+ form?: FormSchema;
486
+ formId?: string;
487
+ client?: FilloClient;
488
+ }): void;
489
+ /** Drop all listeners. */
490
+ destroy(): void;
491
+ }
492
+ /**
493
+ * Framework-agnostic form engine: validation, conditional logic, pages, spam
494
+ * signals, and submission as a subscribable store — with no rendering. Drive it
495
+ * from vanilla JS, Vue, Svelte, or any framework and lay the fields out
496
+ * yourself. (In React, `<FilloProvider>` / `useFillo()` wrap this same engine.)
497
+ */
498
+ declare function createFormController(options: FormControllerOptions): FormController;
499
+
428
500
  /**
429
501
  * A form whose structure lives in user code. Framework renderers can show it
430
502
  * immediately, then sync it into a Fillo workspace when a publishable key is
@@ -472,7 +544,7 @@ declare function resolveText(text: string, data: ResponseData, form: FormSchema)
472
544
  /** Return a copy of a block with its visible text fields piped, or the block unchanged. */
473
545
  declare function pipeBlock(block: Block, data: ResponseData, form: FormSchema): Block;
474
546
 
475
- /** Dependency-free nanoid-style id. Crypto-random where available. */
547
+ /** Dependency-free nanoid-style id with an unbiased alphabet. Crypto-random. */
476
548
  declare function createId(size?: number): string;
477
549
 
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 };
550
+ 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
 
@@ -657,8 +663,11 @@ var FilloClient = class {
657
663
  }
658
664
  }
659
665
  /** Current state of an upload session — used to resume after interruption. */
660
- getUploadSession(sessionId) {
661
- return this.json(`/uploads/${encodeURIComponent(sessionId)}`);
666
+ getUploadSession(sessionId, token) {
667
+ return this.json(
668
+ `/uploads/${encodeURIComponent(sessionId)}`,
669
+ token ? { headers: { "X-Fillo-Upload-Token": token } } : void 0
670
+ );
662
671
  }
663
672
  /**
664
673
  * Resumable chunked upload. Creates (or resumes) a session, streams the file
@@ -677,7 +686,7 @@ var FilloClient = class {
677
686
  const mime = file.type || "application/octet-stream";
678
687
  let session;
679
688
  if (options.sessionId) {
680
- session = await this.getUploadSession(options.sessionId);
689
+ session = await this.getUploadSession(options.sessionId, options.uploadToken);
681
690
  } else {
682
691
  session = await this.json(
683
692
  `/forms/${encodeURIComponent(formId)}/uploads`,
@@ -701,7 +710,7 @@ var FilloClient = class {
701
710
  }
702
711
  const done = await this.json(
703
712
  `/uploads/${encodeURIComponent(session.id)}/complete`,
704
- { method: "POST" }
713
+ { method: "POST", headers: session.token ? { "X-Fillo-Upload-Token": session.token } : void 0 }
705
714
  );
706
715
  if (!done.file) throw new FilloError("Upload completed but no file was returned");
707
716
  return done.file;
@@ -722,7 +731,8 @@ var FilloClient = class {
722
731
  method: "PUT",
723
732
  headers: {
724
733
  "Content-Type": "application/octet-stream",
725
- "X-Upload-Offset": String(offset)
734
+ "X-Upload-Offset": String(offset),
735
+ ...session.token ? { "X-Fillo-Upload-Token": session.token } : {}
726
736
  },
727
737
  body: chunk,
728
738
  signal: options.signal
@@ -742,7 +752,7 @@ var FilloClient = class {
742
752
  attempt += 1;
743
753
  if (attempt >= MAX_CHUNK_RETRIES) throw err;
744
754
  await new Promise((r) => setTimeout(r, 500 * 2 ** attempt));
745
- const fresh = await this.getUploadSession(session.id);
755
+ const fresh = await this.getUploadSession(session.id, session.token);
746
756
  offset = fresh.uploadedBytes;
747
757
  }
748
758
  }
@@ -790,7 +800,7 @@ var FilloClient = class {
790
800
  attempt += 1;
791
801
  if (attempt >= MAX_CHUNK_RETRIES) throw err;
792
802
  await new Promise((r) => setTimeout(r, 500 * 2 ** attempt));
793
- const fresh = await this.getUploadSession(session.id);
803
+ const fresh = await this.getUploadSession(session.id, session.token);
794
804
  offset = fresh.uploadedBytes;
795
805
  }
796
806
  }
@@ -829,6 +839,161 @@ function createClient(options) {
829
839
  return new FilloClient(options);
830
840
  }
831
841
 
842
+ // src/controller.ts
843
+ var EMPTY_PAGE = { id: "empty", blocks: [] };
844
+ function createFormController(options) {
845
+ const { onChange, onSubmitted, onSubmit } = options;
846
+ let form = options.form;
847
+ let formId = options.formId;
848
+ let client = options.client;
849
+ let data = { ...options.initialData ?? {} };
850
+ let errors = {};
851
+ let pageIndex = 0;
852
+ let status = "idle";
853
+ const uploadingFields = /* @__PURE__ */ new Set();
854
+ const listeners = /* @__PURE__ */ new Set();
855
+ const mountedAt = Date.now();
856
+ let sessionId = null;
857
+ let sessionStarted = false;
858
+ function computeSnapshot() {
859
+ const pageCount = form.pages.length;
860
+ const clamped = Math.min(pageIndex, Math.max(pageCount - 1, 0));
861
+ const page = form.pages[clamped] ?? EMPTY_PAGE;
862
+ return {
863
+ data,
864
+ errors,
865
+ status,
866
+ pageIndex: clamped,
867
+ pageCount,
868
+ page,
869
+ blocks: visibleBlocks(page, data),
870
+ isFirstPage: clamped === 0,
871
+ isLastPage: clamped === pageCount - 1,
872
+ uploading: uploadingFields.size > 0
873
+ };
874
+ }
875
+ let snapshot = computeSnapshot();
876
+ function notify() {
877
+ snapshot = computeSnapshot();
878
+ for (const listener of listeners) listener();
879
+ }
880
+ function validatePage(index) {
881
+ const target = form.pages[index];
882
+ if (!target) return {};
883
+ const pageErrors = {};
884
+ for (const block of visibleBlocks(target, data)) {
885
+ if (!isField(block)) continue;
886
+ const error = validateField(block, data[block.id]);
887
+ if (error) pageErrors[block.id] = error;
888
+ }
889
+ return pageErrors;
890
+ }
891
+ function setValue(fieldId, value) {
892
+ if (!sessionStarted && client && formId) {
893
+ sessionStarted = true;
894
+ void client.startSession(formId, form.pages.length).then((id) => sessionId = id);
895
+ }
896
+ data = { ...data, [fieldId]: value };
897
+ onChange?.(data);
898
+ if (fieldId in errors) {
899
+ errors = Object.fromEntries(Object.entries(errors).filter(([k]) => k !== fieldId));
900
+ }
901
+ notify();
902
+ }
903
+ function setUploading(fieldId, busy) {
904
+ if (busy) uploadingFields.add(fieldId);
905
+ else uploadingFields.delete(fieldId);
906
+ notify();
907
+ }
908
+ function next() {
909
+ const pageCount = form.pages.length;
910
+ const clamped = Math.min(pageIndex, Math.max(pageCount - 1, 0));
911
+ const pageErrors = validatePage(clamped);
912
+ errors = pageErrors;
913
+ if (Object.keys(pageErrors).length > 0) {
914
+ notify();
915
+ return;
916
+ }
917
+ pageIndex = Math.min(clamped + 1, pageCount - 1);
918
+ if (sessionId && client) client.reportProgress(sessionId, { furthestPage: pageIndex });
919
+ notify();
920
+ }
921
+ function back() {
922
+ errors = {};
923
+ pageIndex = Math.max(Math.min(pageIndex, form.pages.length - 1) - 1, 0);
924
+ notify();
925
+ }
926
+ async function submit() {
927
+ if (status === "submitting" || status === "submitted") return;
928
+ const result = validateResponse(form, data);
929
+ if (!result.ok) {
930
+ errors = result.errors;
931
+ const firstBad = form.pages.findIndex((p) => p.blocks.some((b) => b.id in result.errors));
932
+ if (firstBad >= 0) pageIndex = firstBad;
933
+ notify();
934
+ return;
935
+ }
936
+ status = "submitting";
937
+ notify();
938
+ try {
939
+ if (onSubmit) {
940
+ await onSubmit(result.data);
941
+ status = "submitted";
942
+ notify();
943
+ onSubmitted?.(void 0, result.data);
944
+ } else if (client && formId) {
945
+ const res = await client.submit(formId, result.data, {
946
+ hp: options.getHoneypot?.() ?? "",
947
+ elapsedMs: Date.now() - mountedAt
948
+ });
949
+ if (!res.ok) {
950
+ errors = res.errors ?? {};
951
+ status = "idle";
952
+ notify();
953
+ return;
954
+ }
955
+ if (sessionId) client.reportProgress(sessionId, { completed: true });
956
+ status = "submitted";
957
+ notify();
958
+ onSubmitted?.(res.responseId, result.data);
959
+ } else {
960
+ status = "submitted";
961
+ notify();
962
+ onSubmitted?.(void 0, result.data);
963
+ }
964
+ } catch (err) {
965
+ status = "idle";
966
+ notify();
967
+ throw err;
968
+ }
969
+ }
970
+ return {
971
+ getState: () => snapshot,
972
+ subscribe(listener) {
973
+ listeners.add(listener);
974
+ return () => {
975
+ listeners.delete(listener);
976
+ };
977
+ },
978
+ setValue,
979
+ setUploading,
980
+ next,
981
+ back,
982
+ submit,
983
+ setContext(ctx) {
984
+ if ("formId" in ctx) formId = ctx.formId;
985
+ if ("client" in ctx) client = ctx.client;
986
+ if (ctx.form && ctx.form !== form) {
987
+ form = ctx.form;
988
+ notify();
989
+ }
990
+ },
991
+ destroy() {
992
+ listeners.clear();
993
+ }
994
+ };
995
+ }
996
+
832
997
  // src/define.ts
833
998
  function defineForm(def) {
834
999
  return {
@@ -958,6 +1123,7 @@ export {
958
1123
  createBlock,
959
1124
  createClient,
960
1125
  createEmptyForm,
1126
+ createFormController,
961
1127
  createId,
962
1128
  defineForm,
963
1129
  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.0",
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
  }