{"_id":"@antihero/filidate","name":"@antihero/filidate","dist-tags":{"latest":"0.2.0"},"versions":{"0.2.0":{"name":"@antihero/filidate","version":"0.2.0","publishConfig":{"access":"public"},"description":"Lightweight, extensible, type-safe file validation for browsers and Node.js. Validates File, Blob, Buffer, ArrayBuffer, Uint8Array and file paths against declarative schemas.","author":{"name":"Lelianto Pradana"},"homepage":"https://github.com/Lelianto/filidate#readme","repository":{"type":"git","url":"git+https://github.com/Lelianto/filidate.git"},"bugs":{"url":"https://github.com/Lelianto/filidate/issues"},"keywords":["file","upload","validation","file-upload","schema","mime","signature","magic-number","image","pdf","browser","node","typescript"],"license":"MIT","type":"module","sideEffects":false,"engines":{"node":">=18"},"main":"./dist/index.cjs","module":"./dist/index.js","types":"./dist/index.d.ts","exports":{".":{"browser":{"import":{"types":"./dist/browser/index.d.ts","default":"./dist/browser/index.js"},"require":{"types":"./dist/browser/index.d.cts","default":"./dist/browser/index.cjs"}},"import":{"types":"./dist/index.d.ts","default":"./dist/index.js"},"require":{"types":"./dist/index.d.cts","default":"./dist/index.cjs"}},"./node":{"import":{"types":"./dist/index.d.ts","default":"./dist/index.js"},"require":{"types":"./dist/index.d.cts","default":"./dist/index.cjs"}},"./browser":{"import":{"types":"./dist/browser/index.d.ts","default":"./dist/browser/index.js"},"require":{"types":"./dist/browser/index.d.cts","default":"./dist/browser/index.cjs"}},"./presets":{"browser":{"import":{"types":"./dist/browser/presets.d.ts","default":"./dist/browser/presets.js"},"require":{"types":"./dist/browser/presets.d.cts","default":"./dist/browser/presets.cjs"}},"import":{"types":"./dist/presets.d.ts","default":"./dist/presets.js"},"require":{"types":"./dist/presets.d.cts","default":"./dist/presets.cjs"}},"./package.json":"./package.json"},"scripts":{"build":"tsup","dev":"tsup --watch","typecheck":"tsc --noEmit","lint":"eslint .","test":"vitest run","test:watch":"vitest","test:coverage":"vitest run --coverage","bench":"vitest bench","check:exports":"attw --pack . --profile node16 && publint","check:size":"node scripts/check-size.mjs","prepublishOnly":"pnpm lint && pnpm typecheck && pnpm test && pnpm build && pnpm check:exports","release":"changeset publish"},"devDependencies":{"@arethetypeswrong/cli":"^0.18.5","@changesets/cli":"^2.28.1","@eslint/js":"^10.0.1","@types/node":"^22.13.14","@vitest/coverage-v8":"^3.1.2","eslint":"^9.23.0","prettier":"^3.5.3","publint":"^0.3.9","tsup":"^8.4.0","typescript":"^5.8.3","typescript-eslint":"^8.65.0","vitest":"^3.1.2"},"_id":"@antihero/filidate@0.2.0","gitHead":"3f65c7ad5dda71b91fdddf496f233d47e04ff94b","_nodeVersion":"20.19.6","_npmVersion":"10.8.2","dist":{"integrity":"sha512-/7h+DDE8hCJ7MRjAhow1+NsI20jX2bEfJRnqDBqaqvo4UHAP/4dRrjD9pBFX9YT9jq62n6RyiWyOogQlHzosKQ==","shasum":"320c7b83ac68a6c040f29ae42e45cbf5bc52a722","tarball":"https://registry.npmjs.org/@antihero/filidate/-/filidate-0.2.0.tgz","fileCount":31,"unpackedSize":3140812,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEYCIQDp3U89YIrx2sIubMrwsFzy9/KiA1nTlN0hq3N7/DJXmgIhAKCbKMNix8pQ7mtYKzPkSOA5tC6VkrFl8JtIl1I1r/Ep"}]},"_npmUser":{"name":"antihero","email":"lelianto.eko@gmail.com"},"directories":{},"maintainers":[{"name":"antihero","email":"lelianto.eko@gmail.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/filidate_0.2.0_1785564201802_0.49559953116416766"},"_hasShrinkwrap":false}},"time":{"created":"2026-08-01T06:03:21.638Z","0.2.0":"2026-08-01T06:03:21.973Z","modified":"2026-08-01T06:03:22.198Z"},"maintainers":[{"name":"antihero","email":"lelianto.eko@gmail.com"}],"description":"Lightweight, extensible, type-safe file validation for browsers and Node.js. Validates File, Blob, Buffer, ArrayBuffer, Uint8Array and file paths against declarative schemas.","homepage":"https://github.com/Lelianto/filidate#readme","keywords":["file","upload","validation","file-upload","schema","mime","signature","magic-number","image","pdf","browser","node","typescript"],"repository":{"type":"git","url":"git+https://github.com/Lelianto/filidate.git"},"author":{"name":"Lelianto Pradana"},"bugs":{"url":"https://github.com/Lelianto/filidate/issues"},"license":"MIT","readme":"# filidate\n\n[![npm version](https://img.shields.io/npm/v/%40antihero%2Ffilidate.svg)](https://www.npmjs.com/package/@antihero/filidate)\n[![CI](https://github.com/Lelianto/filidate/actions/workflows/ci.yml/badge.svg)](https://github.com/Lelianto/filidate/actions/workflows/ci.yml)\n[![license](https://img.shields.io/npm/l/%40antihero%2Ffilidate.svg)](./LICENSE)\n[![types](https://img.shields.io/npm/types/%40antihero%2Ffilidate.svg)](https://www.npmjs.com/package/@antihero/filidate)\n\nLightweight, extensible, type-safe file validation for browsers and Node.js.\n\nDeclare what a valid file looks like, then validate `File`, `Blob`, `Buffer`, `ArrayBuffer`, `Uint8Array`, or a file path against it. filidate reads only the bytes the active rules actually need — it never loads a whole file into memory to check its dimensions.\n\n```bash\nnpm install @antihero/filidate\n# pnpm add @antihero/filidate\n# yarn add @antihero/filidate\n```\n\n## Contents\n\n- [Why](#why)\n- [Quick start](#quick-start)\n- [Entry points](#entry-points)\n- [Accepted inputs](#accepted-inputs)\n- [Rules](#rules)\n  - [Size](#size) · [MIME type](#mime-type) · [Extension and signature](#extension-and-signature) · [Filename](#filename) · [Image](#image) · [PDF](#pdf) · [Audio](#audio) · [Video](#video) · [Archive](#archive) · [Security](#security)\n- [Presets](#presets)\n- [Validating multiple files](#validating-multiple-files)\n- [Custom rules](#custom-rules)\n- [Inspecting without validating](#inspecting-without-validating)\n- [Shared configuration and i18n](#shared-configuration-and-i18n)\n- [Plugins](#plugins)\n- [Error reference](#error-reference)\n- [Recipes](#recipes)\n- [API reference](#api-reference)\n- [Gotchas](#gotchas)\n- [Development](#development)\n\n## Why\n\nTrusting `file.type` and the filename extension is not validation — both are attacker-controlled. A `.png` upload can be a Windows executable, and `Content-Type: image/png` is just a string the client chose. filidate reads the file's actual leading bytes, identifies the real format, and reports every failure as a structured, machine-readable error.\n\n- **Content-aware** — detects the true format from file signatures (magic numbers), then cross-checks it against the declared MIME type and the extension.\n- **Zero runtime dependencies**, dual ESM/CJS, ~23 KB gzipped, with a separate browser build that references no Node built-ins.\n- **Lazy, ranged byte reads** — image dimensions, PDF page counts, and media duration come from header ranges, not full decodes.\n- **Structured errors** — a stable `code`, a human `message`, plus `path`/`expected`/`received` so you can build your own UI or i18n layer.\n- **Skip-not-fail semantics** — a rule whose metadata is unavailable is skipped, so `image: { minWidth: 1000 }` never rejects a PDF.\n- **Extensible** — custom rules, plugins for deep inspection, and fully overridable messages.\n\n## Quick start\n\n```ts\nimport { file } from \"@antihero/filidate\";\n\nconst avatar = file({\n  maxSize: \"2MB\",\n  mimeTypes: [\"image/png\", \"image/jpeg\"],\n  signatures: [\"png\", \"jpeg\"],\n  image: { minWidth: 200, minHeight: 200, aspectRatio: \"1:1\" },\n});\n\nconst result = await avatar.validate(input);\n\nif (!result.success) {\n  for (const error of result.errors) {\n    console.log(error.code, error.message);\n    // INVALID_ASPECT_RATIO  Image aspect ratio must be 1:1 (received 1.5).\n  }\n} else {\n  console.log(result.metadata.image?.width);\n}\n```\n\n`validate()` never throws for a validation failure — failures come back in `result.errors`. If you prefer exceptions, use `parse()`, which throws a `FileValidationError` carrying the same array:\n\n```ts\nimport { FileValidationError } from \"@antihero/filidate\";\n\ntry {\n  const normalized = await avatar.parse(input);\n} catch (error) {\n  if (error instanceof FileValidationError) console.log(error.errors);\n}\n```\n\nEvery schema method is async, because reading bytes is async in both environments.\n\n## Entry points\n\nThe default import auto-selects the right build through the `browser` export condition, so most projects just use `@antihero/filidate`. Import a specific entry when a bundler guesses wrong:\n\n```ts\nimport { file } from \"@antihero/filidate\";          // auto: Node or browser\nimport { file } from \"@antihero/filidate/node\";     // Node build — string inputs read from disk\nimport { file } from \"@antihero/filidate/browser\";  // browser build — no Node built-ins\nimport { imageFile } from \"@antihero/filidate/presets\";\n```\n\n| | `@antihero/filidate/node` | `@antihero/filidate/browser` |\n| --- | --- | --- |\n| `File`, `Blob`, `Uint8Array`, `ArrayBuffer` | Supported | Supported |\n| `Buffer` | Supported | Not available in browsers |\n| Path `string` | Read from disk | Rejected as `INVALID_INPUT` |\n| References `node:fs` / `node:path` | Yes | No — asserted in CI |\n\nOnly the Node build registers the file-path reader. Passing a `string` to the browser build is a deliberate error rather than a silent pass, so a server-side path never leaks into client code unnoticed.\n\n## Accepted inputs\n\n`File`, `Blob`, `Buffer`, `Uint8Array`, `ArrayBuffer`, `SharedArrayBuffer`, and — in the Node build — a path `string`.\n\nAll inputs are normalized to a `NormalizedFile`:\n\n```ts\ninterface NormalizedFile {\n  name: string;               // \"\" for raw byte inputs — see Gotchas\n  extension?: string;         // lowercased, no leading dot\n  declaredMimeType?: string;  // from File.type; absent for raw bytes\n  detectedMimeType?: string;  // derived from content\n  size: number;\n  source: FileInput;\n  environment: \"browser\" | \"node\";\n  readBytes(start?: number, end?: number): Promise<Uint8Array>;\n}\n```\n\nA path that does not exist fails with `INVALID_INPUT` rather than throwing.\n\nOn Node.js 18 there is no global `File` — import it from `node:buffer` if you need to construct one. filidate detects both that class and the global `File` from Node 20+, so filenames survive either way.\n\n## Rules\n\nEvery rule is optional. A rule that needs metadata the file cannot provide is **skipped, not failed**.\n\n### Size\n\n`ByteSize` accepts a raw byte count or a string with a `B`/`KB`/`MB`/`GB`/`TB` or `KiB`/`MiB`/`GiB`/`TiB` suffix.\n\n```ts\nfile({ minSize: \"1KB\", maxSize: \"5MB\" });\nfile({ size: 1024 });        // exact\nfile({ required: true });     // reject null/undefined input with FILE_REQUIRED\n```\n\nAny rule value can be wrapped as `{ value, message }` to override just that rule's message:\n\n```ts\nfile({ maxSize: { value: \"2MB\", message: \"Keep it under 2 MB.\" } });\n```\n\n### MIME type\n\n```ts\nfile({\n  mimeTypes: [\"image/*\", \"application/pdf\"],   // wildcards allowed\n  mimeCheck: \"declared-and-detected\",\n});\n```\n\n| `mimeCheck` | Behavior |\n| --- | --- |\n| `declared` | Only the input's declared type is checked. |\n| `detected` | Only the type detected from content is checked. |\n| `declared-or-detected` | Either passes. **Default.** |\n| `declared-and-detected` | Both must pass — strictest. |\n\nFor untrusted uploads prefer `detected` or `declared-and-detected`; the default is permissive because raw byte inputs have no declared type at all.\n\n### Extension and signature\n\n```ts\nfile({\n  extensions: [\".png\", \"jpg\"],   // dots optional, case-insensitive\n  requireExtensionMatch: true,   // extension must agree with detected content\n  signatures: [\"png\", \"jpeg\"],   // magic-number allowlist\n  signatureHeaderBytes: 512,     // bytes read for detection (default 512)\n});\n```\n\n`requireExtensionMatch` is what catches `payload.png` that is actually a ZIP — it fails with `EXTENSION_CONTENT_MISMATCH`.\n\nDetected formats: `png` `jpeg` `gif` `webp` `avif` `heic` `bmp` `tiff` `svg` `pdf` `zip` `rar` `7z` `gzip` `tar` `mp3` `wav` `ogg` `mp4` `mov` `webm` `mkv` `exe` `elf` `macho`.\n\n### Filename\n\n```ts\nfile({\n  filename: {\n    minLength: 1,\n    maxLength: 120,\n    pattern: /^[\\w.-]+$/,            // must fully match\n    forbiddenCharacters: [\"/\", \"\\\\\"],\n    rejectReservedNames: true,        // CON, NUL, COM1, ...\n    rejectDoubleExtensions: true,     // photo.jpg.exe\n    rejectPathTraversal: true,        // ../ traversal attempts\n    rejectHiddenFiles: false,         // default false\n    rejectNullBytes: true,            // default true\n    normalizeUnicode: true,           // NFKC before checks, default true\n  },\n});\n```\n\n### Image\n\n```ts\nfile({\n  image: {\n    minWidth: 200,\n    minHeight: 200,\n    maxWidth: 4096,\n    maxHeight: 4096,\n    exactWidth: 800,\n    exactHeight: 600,\n    aspectRatio: \"16:9\",          // or a number like 1.7778\n    aspectRatioTolerance: 0.01,   // default\n    orientation: \"landscape\",     // portrait | landscape | square\n    allowAnimation: false,        // default false — rejects animated GIF/WebP\n    allowTransparency: false,     // default false — rejects an alpha channel\n  },\n});\n```\n\nDimensions come from the image header, so a 50 MP JPEG costs a few kilobytes to check. An invalid ratio literal such as `\"not-a-ratio\"` throws a `TypeError` when the schema is built, not when a file arrives — so typos surface at startup.\n\n### PDF\n\n```ts\nfile({ pdf: { minPages: 1, maxPages: 50, encrypted: false, maxVersion: \"1.7\" } });\n```\n\nPage counts are header heuristics, not a full parse. For exact counts on unusual PDFs, add a plugin.\n\n### Audio\n\n```ts\nfile({\n  audio: {\n    minDuration: 1, maxDuration: 300,      // seconds\n    minBitrate: 64000, maxBitrate: 320000,\n    minSampleRate: 44100, maxSampleRate: 48000,\n    minChannels: 1, maxChannels: 2,\n    allowedCodecs: [\"mp3\"],\n  },\n});\n```\n\n### Video\n\n```ts\nfile({\n  video: {\n    minDuration: 1, maxDuration: 600,\n    minWidth: 1280, minHeight: 720,\n    maxWidth: 3840, maxHeight: 2160,\n    aspectRatio: \"16:9\",\n    aspectRatioTolerance: 0.01,\n    allowedCodecs: [\"h264\"],\n    minBitrate: 100000, maxBitrate: 20000000,\n  },\n});\n```\n\n### Archive\n\n```ts\nfile({\n  archive: {\n    allowedFormats: [\"zip\"],\n    maxEntries: 100,\n    rejectExecutables: true,       // an entry that is an executable\n    rejectNestedArchives: true,    // an archive inside the archive\n  },\n});\n```\n\n### Security\n\nOpt-in hardening for untrusted uploads. All default to `false`.\n\n```ts\nfile({\n  security: {\n    requireSignatureMatch: true,        // content must be a recognized format\n    rejectExecutableFiles: true,        // exe, elf, mach-o, shebang scripts\n    rejectPolyglotFiles: true,          // matches more than one format at once\n    rejectDoubleExtensions: true,\n    rejectNullBytes: true,\n    rejectPathTraversal: true,\n    rejectSuspiciousMimeMismatch: true, // declared type contradicts content\n  },\n});\n```\n\nA reasonable baseline for public uploads:\n\n```ts\nconst publicUpload = file({\n  required: true,\n  maxSize: \"10MB\",\n  mimeTypes: [\"image/png\", \"image/jpeg\", \"application/pdf\"],\n  mimeCheck: \"declared-and-detected\",\n  requireExtensionMatch: true,\n  filename: { maxLength: 200, rejectPathTraversal: true, rejectDoubleExtensions: true },\n  security: {\n    requireSignatureMatch: true,\n    rejectExecutableFiles: true,\n    rejectPolyglotFiles: true,\n    rejectSuspiciousMimeMismatch: true,\n  },\n});\n```\n\n## Presets\n\nPresets are ordinary `file()` calls and stay transparent — the resolved options are always readable on `schema.options` and `schema.effectiveOptions`. Anything you pass overrides the preset default.\n\n```ts\nimport { imageFile, avatarFile, documentFile, pdfFile, audioFile, videoFile } from \"@antihero/filidate/presets\";\n```\n\n| Preset | Defaults |\n| --- | --- |\n| `imageFile(opts?)` | `image/*` |\n| `avatarFile(opts?)` | `image/*`, min 400×400, aspect ratio `1:1` |\n| `documentFile(opts?)` | pdf, doc, docx, txt, rtf — max 10 MB |\n| `pdfFile(opts?)` | `application/pdf` + `.pdf` + pdf signature — max 10 MB |\n| `audioFile(opts?)` | `audio/*` — max 20 MB |\n| `videoFile(opts?)` | `video/*` — max 100 MB |\n\n```ts\navatarFile({ maxSize: \"1MB\" });                    // override just the size\navatarFile({ minWidth: 200, minHeight: 200 });     // relax the dimensions\navatarFile().effectiveOptions.image;               // { minWidth: 400, minHeight: 400, aspectRatio: \"1:1\" }\n```\n\n`imageFile` and `avatarFile` accept image constraints at the top level as a shorthand; the other presets take the full `FileSchemaOptions`.\n\n## Validating multiple files\n\n`files()` accepts an array, a `FileList`, or any iterable.\n\n```ts\nimport { files, imageFile } from \"@antihero/filidate\";\n\nconst gallery = files({\n  minFiles: 1,\n  maxFiles: 10,\n  maxTotalSize: \"25MB\",\n  each: imageFile({ maxSize: \"5MB\" }),   // a schema, or plain options\n});\n\nconst result = await gallery.validate(event.target.files);\n\nresult.success;              // false if the collection or any file failed\nresult.errors;               // flat list; every per-file error carries metadata.index\nresult.files[2].errors;      // per-file, in input order\nresult.files[2].metadata;    // per-file metadata\n```\n\nCollection-level codes are `FILE_COUNT_MIN`, `FILE_COUNT_MAX`, and `TOTAL_SIZE_EXCEEDED`. To show errors next to each input, read `result.files[i]`; to show one summary list, use `result.errors` and group by `metadata.index`.\n\n## Custom rules\n\nReturn `null` (or nothing) to pass. Return a **string** to fail with that message under `CUSTOM_VALIDATION_FAILED`, or a `ValidationError` — or an array of them — for full control over the code.\n\n```ts\nfile({\n  custom: [\n    // 1. bare string = failure message\n    (ctx) => (ctx.file.name.startsWith(\"tmp-\") ? \"Temporary files are not accepted.\" : null),\n\n    // 2. async, with your own code\n    async (ctx) => {\n      const head = await ctx.readBytes(0, 16);   // cached within this cycle\n      if (head[0] === 0x00) {\n        return { code: \"LEADING_NULL\", message: \"File starts with a null byte.\" };\n      }\n      return null;\n    },\n\n    // 3. object form with a name, and multiple errors at once\n    {\n      name: \"checks\",\n      validate: () => [\n        { code: \"E1\", message: \"first problem\" },\n        { code: \"E2\", message: \"second problem\" },\n      ],\n    },\n  ],\n});\n```\n\n`ctx` is a `RuleContext` with `file` (the `NormalizedFile`), `metadata`, and `readBytes(start, end)`. Reads are cached per validation cycle, so overlapping ranges are fetched once.\n\nName a rule for reuse with `defineFileRule`:\n\n```ts\nimport { defineFileRule } from \"@antihero/filidate\";\n\nconst noTempFiles = defineFileRule({\n  name: \"noTempFiles\",\n  validate: (ctx) => (ctx.file.name.startsWith(\"tmp-\") ? \"Temporary files are not accepted.\" : null),\n});\n\nfile({ custom: [noTempFiles] });\n```\n\nA rule that throws is caught and reported as `CUSTOM_VALIDATION_FAILED` — one bad rule cannot crash the request.\n\n## Inspecting without validating\n\n```ts\nimport { inspectFile } from \"@antihero/filidate\";\n\nconst metadata = await inspectFile(input);\n// {\n//   name, extension, size, declaredMimeType, detectedMimeType, signature,\n//   image?: { width, height, aspectRatio, orientation, animated, transparent, format },\n//   pdf?:   { pages, version, encrypted },\n//   audio?: { duration, bitrate, sampleRate, channels, codec, format },\n//   video?: { duration, width, height, aspectRatio, codec, bitrate, format },\n//   archive?: { format, entries, entryNames },\n// }\n```\n\nUseful for showing a preview, storing dimensions, or routing by real type. `schema.inspect(input)` does the same through an existing schema. Unlike `validate`, `inspectFile` throws if the input cannot be normalized at all.\n\n## Shared configuration and i18n\n\n`createFileValidator` binds messages, locale, and plugins once and returns `file`, `files`, and `inspectFile` pre-configured.\n\n```ts\nimport { createFileValidator } from \"@antihero/filidate\";\n\nconst v = createFileValidator({\n  locale: {\n    FILE_TOO_LARGE: ({ expected, received }) => `Berkas maksimal ${expected} bita (dikirim ${received}).`,\n    INVALID_MIME_TYPE: \"Jenis berkas tidak didukung.\",\n  },\n  maxReadBytes: 512 * 1024,   // cap per read, default 1 MiB\n});\n\nconst schema = v.file({ maxSize: \"2MB\" });\n```\n\nA locale entry is either a plain string or a resolver receiving `{ code, message, expected, received }`. Any error code can be overridden; unlisted codes fall back to English. Precedence is: a rule's inline `message` → your locale entry → the built-in default.\n\n## Plugins\n\nCore inspection stays header-only and dependency-free. Plugins add deeper analysis — full PDF parsing, media probing, EXIF, antivirus — without pulling weight into the core bundle.\n\n```ts\nimport type { FileValidatorPlugin } from \"@antihero/filidate\";\n\nconst pdfPagesPlugin: FileValidatorPlugin = {\n  name: \"pdf-pages\",\n  supports: (file) => file.detectedMimeType === \"application/pdf\",\n  async inspect(file, ctx) {\n    const bytes = await ctx.readBytes(0, 64 * 1024);\n    return { pdf: { pages: countPages(bytes), version: \"1.7\" } };\n  },\n};\n\nfile({ plugins: [pdfPagesPlugin] });\n```\n\nTwo behaviors to know:\n\n- Returned keys are **shallow-merged**, so a returned `pdf` object replaces the core-detected one entirely. Spread the existing values if you only mean to add fields.\n- A plugin whose `supports` or `inspect` throws is **skipped silently** — its metadata is simply absent and validation continues. Rules that depended on that metadata are then skipped rather than failed, so a broken plugin loosens validation instead of breaking it. Log inside your plugin if you need visibility.\n\n## Error reference\n\nEvery failure is a `ValidationError`:\n\n```ts\n{\n  code: \"FILE_TOO_LARGE\",       // stable, safe to switch on\n  message: \"File is too large. Expected at most 2 MB, received 5 MB.\",\n  path: \"size\",                 // e.g. \"size\", \"image.width\"\n  expected: 2097152,\n  received: 5242880,\n  metadata: { index: 0 },       // present for collection errors\n}\n```\n\n| Group | Codes |\n| --- | --- |\n| Input | `FILE_REQUIRED`, `INVALID_INPUT` |\n| Size | `FILE_TOO_SMALL`, `FILE_TOO_LARGE`, `FILE_SIZE_MISMATCH` |\n| Type | `INVALID_MIME_TYPE`, `INVALID_EXTENSION`, `SIGNATURE_MISMATCH`, `EXTENSION_CONTENT_MISMATCH` |\n| Filename | `INVALID_FILENAME`, `FILENAME_TOO_SHORT`, `FILENAME_TOO_LONG`, `FILENAME_FORBIDDEN_CHARACTER`, `FILENAME_RESERVED`, `FILENAME_DOUBLE_EXTENSION`, `FILENAME_PATH_TRAVERSAL`, `FILENAME_HIDDEN_FILE`, `FILENAME_NULL_BYTE` |\n| Image | `INVALID_IMAGE_DIMENSIONS`, `INVALID_ASPECT_RATIO`, `IMAGE_ANIMATION_NOT_ALLOWED`, `IMAGE_TRANSPARENCY_NOT_ALLOWED` |\n| PDF | `PDF_PAGE_LIMIT_EXCEEDED`, `PDF_PAGE_MIN_NOT_MET`, `PDF_ENCRYPTED_NOT_ALLOWED`, `PDF_VERSION_EXCEEDED` |\n| Media | `MEDIA_DURATION_EXCEEDED`, `MEDIA_DURATION_MIN_NOT_MET`, `MEDIA_BITRATE_EXCEEDED`, `MEDIA_BITRATE_MIN_NOT_MET`, `MEDIA_SAMPLE_RATE_EXCEEDED`, `MEDIA_SAMPLE_RATE_MIN_NOT_MET`, `MEDIA_CHANNELS_EXCEEDED`, `MEDIA_CHANNELS_MIN_NOT_MET`, `MEDIA_CODEC_NOT_ALLOWED` |\n| Video | `VIDEO_DIMENSIONS_INVALID`, `INVALID_VIDEO_ASPECT_RATIO` |\n| Archive | `ARCHIVE_FORMAT_NOT_ALLOWED`, `ARCHIVE_ENTRY_LIMIT_EXCEEDED`, `ARCHIVE_EXECUTABLE_REJECTED`, `ARCHIVE_NESTED_REJECTED` |\n| Security | `SECURITY_SIGNATURE_MISMATCH`, `SECURITY_EXECUTABLE_REJECTED`, `SECURITY_POLYGLOT_REJECTED`, `SECURITY_NULL_BYTE_REJECTED`, `SECURITY_SUSPICIOUS_MIME_MISMATCH`, `SECURITY_DOUBLE_EXTENSION_REJECTED`, `SECURITY_PATH_TRAVERSAL_REJECTED` |\n| Collection | `FILE_COUNT_MIN`, `FILE_COUNT_MAX`, `TOTAL_SIZE_EXCEEDED` |\n| Custom | `CUSTOM_VALIDATION_FAILED` |\n\n`ErrorCode` is an open union (`… | (string & {})`), so custom rules may introduce their own codes while keeping autocomplete for the built-ins.\n\n## Recipes\n\n### React file input\n\n```tsx\nimport { useState } from \"react\";\nimport { avatarFile } from \"@antihero/filidate/presets\";\n\nconst schema = avatarFile({ maxSize: \"2MB\" });\n\nexport function AvatarUpload() {\n  const [errors, setErrors] = useState<string[]>([]);\n\n  async function onChange(event: React.ChangeEvent<HTMLInputElement>) {\n    const selected = event.target.files?.[0];\n    if (!selected) return;\n\n    const result = await schema.validate(selected);\n    setErrors(result.success ? [] : result.errors.map((e) => e.message));\n    if (result.success) upload(selected);\n  }\n\n  return (\n    <>\n      <input type=\"file\" accept=\"image/*\" onChange={onChange} />\n      {errors.map((message) => <p key={message}>{message}</p>)}\n    </>\n  );\n}\n```\n\nClient-side validation is for fast feedback only. Always re-validate on the server — the same schema runs in both places.\n\n### Next.js route handler\n\n```ts\nimport { file } from \"@antihero/filidate\";\n\nconst schema = file({\n  required: true,\n  maxSize: \"5MB\",\n  mimeTypes: [\"image/png\", \"image/jpeg\"],\n  mimeCheck: \"declared-and-detected\",\n  security: { requireSignatureMatch: true, rejectPolyglotFiles: true },\n});\n\nexport async function POST(request: Request) {\n  const form = await request.formData();\n  const result = await schema.validate(form.get(\"file\"));\n\n  if (!result.success) {\n    return Response.json(\n      { errors: result.errors.map(({ code, message, path }) => ({ code, message, path })) },\n      { status: 422 },\n    );\n  }\n\n  const bytes = await result.file!.readBytes();\n  return Response.json({ ok: true, width: result.metadata.image?.width });\n}\n```\n\n### Express with multer\n\n`multer`'s memory storage hands you a `Buffer`, which carries **no filename**. Wrap it in a `File` so filename and extension rules actually run — see [Gotchas](#gotchas).\n\n```ts\nimport express from \"express\";\nimport multer from \"multer\";\nimport { file } from \"@antihero/filidate\";\n\nconst upload = multer({ storage: multer.memoryStorage() });\nconst schema = file({\n  maxSize: \"5MB\",\n  extensions: [\".png\", \".jpg\", \".jpeg\"],\n  requireExtensionMatch: true,\n  security: { requireSignatureMatch: true, rejectExecutableFiles: true },\n});\n\napp.post(\"/upload\", upload.single(\"file\"), async (req, res) => {\n  // Wrap the buffer so the name is preserved.\n  const candidate = new File([req.file.buffer], req.file.originalname, { type: req.file.mimetype });\n\n  const result = await schema.validate(candidate);\n  if (!result.success) return res.status(422).json({ errors: result.errors });\n\n  res.json({ ok: true });\n});\n```\n\n### Node script over files on disk\n\n```ts\nimport { readdir } from \"node:fs/promises\";\nimport { join } from \"node:path\";\nimport { pdfFile } from \"@antihero/filidate/presets\";\n\nconst schema = pdfFile({ pdf: { maxPages: 100 } });\n\nfor (const name of await readdir(\"./inbox\")) {\n  const result = await schema.validate(join(\"./inbox\", name));  // path string\n  console.log(name, result.success ? \"ok\" : result.errors[0].code);\n}\n```\n\nOnly the bytes needed by the active rules are read, so scanning a directory of large PDFs stays cheap.\n\n## API reference\n\n| Export | Description |\n| --- | --- |\n| `file(options?, config?)` | Build a single-file schema. |\n| `files(options?, config?)` | Build a collection schema. |\n| `inspectFile(input, config?)` | Extract metadata; no rules applied. |\n| `defineFileRule(rule)` | Name a reusable custom rule. |\n| `createFileValidator(config)` | Bind shared messages, locale, and plugins. |\n| `FileSchema` / `FilesSchema` | Schema classes. |\n| `FileValidationError` | Thrown by `parse()`; carries `.errors`. |\n| `registerFilePathReader(reader)` | Supply a custom path reader. |\n| Presets | `imageFile`, `avatarFile`, `documentFile`, `pdfFile`, `audioFile`, `videoFile` |\n\nSchema methods, all async:\n\n| Method | Returns |\n| --- | --- |\n| `validate(input)` | `ValidationResult` — never throws on a validation failure. |\n| `parse(input)` | The `NormalizedFile`; throws `FileValidationError`. |\n| `safeParse(input)` | Alias for `validate`. |\n| `inspect(input)` | Metadata only. |\n| `options` / `effectiveOptions` | The declared and resolved options. |\n\nExported types include `FileInput`, `NormalizedFile`, `Environment`, `FileMetadata` (and the `Image`/`Pdf`/`Audio`/`Video`/`Archive` variants), `ValidationResult`, `ValidationError`, `ErrorCode`, `FileSchemaOptions`, `FilesSchemaOptions`, `ByteSize`, `Ratio`, `MimeCheckMode`, `CustomRule`, `CustomRuleResult`, `RuleContext`, `Locale`, `ValidatorConfig`, and `FileValidatorPlugin`.\n\n## Gotchas\n\n**Raw byte inputs have no filename.** A `Buffer`, `Uint8Array`, or `ArrayBuffer` normalizes to `name: \"\"` with no extension, so `filename` and `extensions` rules are **skipped and pass**. This is the most common way to think you are validating extensions when you are not. If the name matters, wrap the bytes in a `File`:\n\n```ts\nnew File([buffer], originalName, { type: declaredMimeType });\n```\n\n`Blob` has the same limitation — it carries a type but no name.\n\n**Raw byte inputs also have no declared MIME type**, so under the default `mimeCheck: \"declared-or-detected\"` only the detected type is consulted. That is usually what you want, but be explicit with `mimeCheck: \"detected\"` if you rely on it.\n\n**The file-path reader is registered process-globally.** Importing `@antihero/filidate` (the Node entry) anywhere in a process also enables path reads through `@antihero/filidate/browser` in that same process. This never happens in a real browser bundle, but it means a Node test that imports both entries will not observe the browser entry's path rejection.\n\n**Metadata absence loosens validation.** Because unavailable metadata skips a rule, a corrupt or unrecognized file can pass narrow schemas. Pair format rules with `security: { requireSignatureMatch: true }` when you need a positive assertion about the content.\n\n**Page counts and media metadata are header heuristics.** They are fast and allocation-light, not a full parse. Use a plugin when you need exactness.\n\n## Development\n\n```bash\npnpm install\npnpm test           # vitest\npnpm test:coverage\npnpm lint\npnpm typecheck\npnpm build          # tsup — ESM + CJS + browser builds\npnpm check:exports  # attw + publint\npnpm check:size     # gzipped budget per artifact\npnpm bench\n```\n\nRequires Node.js 18 or later; CI verifies against 18, 20, and 22, and asserts the browser bundle contains no Node built-ins.\n\nReleases run through [changesets](https://github.com/changesets/changesets). Add one with your change:\n\n```bash\npnpm changeset\n```\n\nMerging to `main` opens a release PR; merging that PR publishes to npm with provenance via GitHub Actions.\n\n## License\n\nMIT © Lelianto Pradana\n\n---\n\n## 🚀 More TypeScript Projects\n\nIf you find this package useful, you may also like these open-source projects.\n\n| Project | Description |\n|---------|-------------|\n| **💰 Monify** | Lightweight currency formatting library with multi-currency support. |\n| **🤖 AgentifAI** | Vendor-neutral AI agent event model and debugging toolkit. |\n| **⚡ Statelite** | Lightweight reactive state management for TypeScript. |\n| **🗄️ Nano Cache** | Universal cache abstraction for memory, Redis, IndexedDB, and more. |\n| **🔌 PlugnPlay** | Bootstrap cloud backends with minimal configuration. |\n| **🎨 Sagara UI** | Utility-first CSS framework optimized for AI-assisted development. |\n\n### Explore the ecosystem\n\n- 💰 Monify → https://github.com/Lelianto/monify\n- 🤖 AgentifAI → https://github.com/Lelianto/agentifai\n- ⚡ Statelite → https://github.com/Lelianto/statelite\n- 🗄️ Nano Cache → https://github.com/Lelianto/nano-cache\n- 🔌 PlugnPlay → https://github.com/Lelianto/plugnplay\n- 🎨 Sagara UI → https://github.com/Lelianto/sagaraui\n\n⭐ If you enjoy this project, consider giving it a star. It helps others discover the ecosystem.\n","readmeFilename":"README.md","_rev":"1-bda4b9e562bacecaf6c3d82983304563"}