@ryuzaki13/react-foundation-lib 2.15.0 → 2.17.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/README.md CHANGED
@@ -89,6 +89,7 @@ npm run pack:dry-run
89
89
  | Tree table преобразования | [`tree-table`](./src/tree-table/index.ts), документация в общем справочнике [`table/README.mdx`](./src/table/README.mdx), тесты в [`tree-table`](./src/tree-table) |
90
90
  | Excel export | [`excel`](./src/excel/index.ts), подробный README: [`excel/README.mdx`](./src/excel/README.mdx), тесты: [`excel.test.ts`](./src/excel/excel.test.ts), [`tableExport.test.ts`](./src/excel/tableExport.test.ts) |
91
91
  | FileReader, изображения, base64 Blob | [`file`](./src/file/index.ts), [`binary`](./src/binary/index.ts) |
92
+ | Проверка серверного пути внутри root, ошибки отсутствующего файла | [`file-system`](./src/file-system/index.ts), Node.js API: [`file-system/README.mdx`](./src/file-system/README.mdx) |
92
93
  | DOM overlay, portal, focus, download | [`dom`](./src/dom/index.ts), тест: [`dom.test.tsx`](./src/dom/dom.test.tsx) |
93
94
  | Debounce/throttle, DnD sensors, search params, listbox | [`hooks`](./src/hooks/index.ts), подробный README: [`hooks/README.mdx`](./src/hooks/README.mdx), [`utils/keyboard.ts`](./src/utils/keyboard.ts) |
94
95
  | Breakpoints, media queries и responsive props | [`media`](./src/media/index.ts), подробный README: [`media/README.mdx`](./src/media/README.mdx), тест: [`media.test.ts`](./src/media/media.test.ts) |
@@ -118,6 +119,7 @@ npm run pack:dry-run
118
119
  | `/error-report` | [`error-report/README.mdx`](./src/error-report/README.mdx) |
119
120
  | `/excel` | [`excel/README.mdx`](./src/excel/README.mdx) |
120
121
  | `/file` | [`file/README.mdx`](./src/file/README.mdx) |
122
+ | `/file-system` | [Node.js: пути внутри хранилища](./src/file-system/README.mdx) |
121
123
  | `/fingerprint` | [`fingerprint/README.mdx`](./src/fingerprint/README.mdx) |
122
124
  | `/form` | [`form/README.mdx`](./src/form/README.mdx) |
123
125
  | `/formatters` | [`formatters/README.mdx`](./src/formatters/README.mdx) и его предметные README |
@@ -528,10 +530,11 @@ Pipeline — runtime форматирования ячейки. Он прини
528
530
  | API | Поведение |
529
531
  | ---------------- | ------------------------------------------------------------------------------ |
530
532
  | `binaryToBlob` | Base64/data URL/raw binary string -> `Blob`; валидирует пустые и битые данные. |
533
+ | `getBase64DecodedSize` | Проверяет стандартный Base64 и считает размер без декодирования; неверная форма -> `null`, пустая строка -> `0`. |
531
534
  | `detectMimeType` | MIME по filename, data URL, byte signature, fallback `application/pdf`. |
532
535
  | `useBinaryFile` | Hook: base64 string -> `{ blob, mime }` или error; object URL не создает. |
533
536
 
534
- Тест: [`binaryToBlob.test.ts`](./src/binary/binaryToBlob.test.ts).
537
+ Тесты: [`binaryToBlob.test.ts`](./src/binary/binaryToBlob.test.ts), [`getBase64DecodedSize.test.ts`](./src/binary/getBase64DecodedSize.test.ts).
535
538
 
536
539
  ### `xml`
537
540
 
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Проверяет стандартную Base64-форму и считает байты без декодирования или создания Buffer/Blob.
3
+ * Невалидный payload возвращает null, пустая строка — 0; допустимый размер определяет вызывающий контракт.
4
+ * Data URL, whitespace, URL-safe алфавит и отсутствующий padding здесь не нормализуются.
5
+ */
6
+ export declare function getBase64DecodedSize(value: string): number | null;
7
+ //# sourceMappingURL=getBase64DecodedSize.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"getBase64DecodedSize.d.ts","sourceRoot":"","sources":["../../src/binary/getBase64DecodedSize.ts"],"names":[],"mappings":"AAEA;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAMjE"}
@@ -1,3 +1,4 @@
1
1
  export * from "./binaryToBlob";
2
+ export { getBase64DecodedSize } from "./getBase64DecodedSize";
2
3
  export * from "./useBinaryFile";
3
4
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/binary/index.ts"],"names":[],"mappings":"AAAA,cAAc,gBAAgB,CAAC;AAC/B,cAAc,iBAAiB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/binary/index.ts"],"names":[],"mappings":"AAAA,cAAc,gBAAgB,CAAC;AAC/B,OAAO,EAAE,oBAAoB,EAAE,MAAM,wBAAwB,CAAC;AAC9D,cAAc,iBAAiB,CAAC"}
@@ -99,10 +99,15 @@ var i = "application/octet-stream", a = "application/pdf", o = /^data:([^,]*);ba
99
99
  if (n) return n;
100
100
  }
101
101
  return a;
102
- };
102
+ }, y = /[^A-Za-z0-9+/=]/;
103
+ function b(e) {
104
+ if (e.length % 4 != 0 || y.test(e)) return null;
105
+ let t = e.endsWith("==") ? 2 : +!!e.endsWith("="), n = e.indexOf("=");
106
+ return n !== -1 && n !== e.length - t ? null : Math.floor(e.length * 3 / 4) - t;
107
+ }
103
108
  //#endregion
104
109
  //#region src/binary/useBinaryFile.ts
105
- function y(i) {
110
+ function x(i) {
106
111
  let [a, o] = r(null), [s, c] = r(null), l = n((e) => o(e)), u = n((e) => c(e));
107
112
  return t(() => {
108
113
  if (i) try {
@@ -121,6 +126,6 @@ function y(i) {
121
126
  };
122
127
  }
123
128
  //#endregion
124
- export { _ as binaryToBlob, v as detectMimeType, y as useBinaryFile };
129
+ export { _ as binaryToBlob, v as detectMimeType, b as getBase64DecodedSize, x as useBinaryFile };
125
130
 
126
131
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":[],"sources":["../../src/binary/binaryToBlob.ts","../../src/binary/useBinaryFile.ts"],"sourcesContent":["const DEFAULT_BLOB_MIME_TYPE = \"application/octet-stream\";\nconst DEFAULT_DOCUMENT_MIME_TYPE = \"application/pdf\";\nconst BASE64_DATA_URL_PATTERN = /^data:([^,]*);base64,([\\s\\S]*)$/i;\n\nconst MIME_TYPE_BY_EXTENSION: Record<string, string> = {\n\tbmp: \"image/bmp\",\n\tcsv: \"text/csv\",\n\tdoc: \"application/msword\",\n\tdocx: \"application/vnd.openxmlformats-officedocument.wordprocessingml.document\",\n\tgif: \"image/gif\",\n\tjpeg: \"image/jpeg\",\n\tjpg: \"image/jpeg\",\n\tjson: \"application/json\",\n\tpdf: \"application/pdf\",\n\tpng: \"image/png\",\n\tppt: \"application/vnd.ms-powerpoint\",\n\tpptx: \"application/vnd.openxmlformats-officedocument.presentationml.presentation\",\n\tsvg: \"image/svg+xml\",\n\ttif: \"image/tiff\",\n\ttiff: \"image/tiff\",\n\ttxt: \"text/plain\",\n\twebp: \"image/webp\",\n\txls: \"application/vnd.ms-excel\",\n\txlsx: \"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet\",\n\txml: \"application/xml\",\n\tzip: \"application/zip\"\n};\n\nconst normalizeBase64 = (value: string): string => {\n\tconst normalized = value.replace(/\\s/g, \"\").replace(/-/g, \"+\").replace(/_/g, \"/\");\n\tconst paddingRest = normalized.length % 4;\n\n\tif (paddingRest === 1 || !/^[A-Za-z0-9+/]*={0,2}$/.test(normalized)) {\n\t\tthrow new Error(\"Некорректный формат base64\");\n\t}\n\n\treturn paddingRest === 0 ? normalized : `${normalized}${\"=\".repeat(4 - paddingRest)}`;\n};\n\nconst getBase64RawPayload = (binaryData: string): { data: string; mimeType: string | null } => {\n\tconst dataUrlMatch = binaryData.match(BASE64_DATA_URL_PATTERN);\n\n\tif (!dataUrlMatch) {\n\t\treturn { data: binaryData, mimeType: null };\n\t}\n\n\tconst [, rawMimeType, rawData] = dataUrlMatch;\n\tconst mimeType = rawMimeType.split(\";\")[0].trim();\n\n\treturn {\n\t\tdata: rawData,\n\t\tmimeType: mimeType || null\n\t};\n};\n\nconst getBase64Payload = (binaryData: string): { data: string; mimeType: string | null } => {\n\tconst payload = getBase64RawPayload(binaryData);\n\n\treturn {\n\t\tdata: normalizeBase64(payload.data),\n\t\tmimeType: payload.mimeType\n\t};\n};\n\nconst getDataUrlMimeType = (binaryData: string): string | null => {\n\tconst dataUrlMatch = binaryData.match(BASE64_DATA_URL_PATTERN);\n\n\tif (!dataUrlMatch) {\n\t\treturn null;\n\t}\n\n\tconst mimeType = dataUrlMatch[1].split(\";\")[0].trim();\n\n\treturn mimeType || null;\n};\n\nconst decodeBase64ToBytes = (binaryData: string): Uint8Array => {\n\tconst { data } = getBase64Payload(binaryData);\n\tconst binaryString = atob(data);\n\tconst byteArray = new Uint8Array(binaryString.length);\n\n\tfor (let index = 0; index < binaryString.length; index++) {\n\t\tbyteArray[index] = binaryString.charCodeAt(index);\n\t}\n\n\treturn byteArray;\n};\n\nconst binaryStringToBytes = (binaryData: string): Uint8Array => {\n\tconst byteArray = new Uint8Array(binaryData.length);\n\n\tfor (let index = 0; index < binaryData.length; index++) {\n\t\tbyteArray[index] = binaryData.charCodeAt(index);\n\t}\n\n\treturn byteArray;\n};\n\nconst copyBytesToArrayBuffer = (byteArray: Uint8Array): ArrayBuffer => {\n\tconst buffer = new ArrayBuffer(byteArray.byteLength);\n\tconst view = new Uint8Array(buffer);\n\n\tview.set(byteArray);\n\n\treturn buffer;\n};\n\nconst decodeHeaderFromBase64 = (binaryData: string): string | null => {\n\ttry {\n\t\tconst { data } = getBase64RawPayload(binaryData);\n\t\tconst headerData = data.slice(0, 128);\n\t\tconst binaryString = atob(normalizeBase64(headerData));\n\n\t\treturn binaryString;\n\t} catch {\n\t\treturn null;\n\t}\n};\n\nconst detectMimeTypeBySignature = (binaryData: string): string | null => {\n\tconst header = decodeHeaderFromBase64(binaryData);\n\n\tif (!header) {\n\t\treturn null;\n\t}\n\n\tconst bytes = new Uint8Array(header.length);\n\n\tfor (let index = 0; index < header.length; index++) {\n\t\tbytes[index] = header.charCodeAt(index);\n\t}\n\n\tif (bytes[0] === 0x25 && bytes[1] === 0x50 && bytes[2] === 0x44 && bytes[3] === 0x46) {\n\t\treturn \"application/pdf\";\n\t}\n\n\tif (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {\n\t\treturn \"image/jpeg\";\n\t}\n\n\tif (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) {\n\t\treturn \"image/png\";\n\t}\n\n\tif (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46) {\n\t\treturn \"image/gif\";\n\t}\n\n\tif (bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46) {\n\t\treturn \"image/webp\";\n\t}\n\n\tif (bytes[0] === 0x50 && bytes[1] === 0x4b) {\n\t\treturn \"application/zip\";\n\t}\n\n\tconst textHeader = header\n\t\t.replace(/^\\uFEFF/, \"\")\n\t\t.trimStart()\n\t\t.slice(0, 16);\n\n\tif (textHeader.startsWith(\"<?xml\") || textHeader.startsWith(\"<\")) {\n\t\treturn \"application/xml\";\n\t}\n\n\treturn null;\n};\n\n/**\n * Преобразует base64 или бинарную строку в Blob.\n *\n * Data URL с префиксом `data:*;base64,` поддерживается, сам URL здесь не создаётся.\n */\nexport const binaryToBlob = (binaryData: string, mimeType: string = DEFAULT_BLOB_MIME_TYPE, isBase64 = true): Blob => {\n\tif (!binaryData) {\n\t\tthrow new Error(\"Не переданы бинарные данные\");\n\t}\n\n\ttry {\n\t\tconst byteArray = isBase64 ? decodeBase64ToBytes(binaryData) : binaryStringToBytes(binaryData);\n\n\t\tif (byteArray.length === 0) {\n\t\t\tthrow new Error(\"После преобразования не осталось данных\");\n\t\t}\n\n\t\treturn new Blob([copyBytesToArrayBuffer(byteArray)], { type: mimeType.trim() || DEFAULT_BLOB_MIME_TYPE });\n\t} catch (error) {\n\t\tif (error instanceof Error) {\n\t\t\tthrow error;\n\t\t}\n\t\tthrow new Error(\"Некорректный формат бинарных данных\");\n\t}\n};\n\n/**\n * Определяет MIME-тип по имени файла, data URL или сигнатуре base64-данных.\n *\n * Если определить тип не удалось, используется PDF: большинство документов в этом контуре приходят именно так.\n */\nexport const detectMimeType = (fileName?: string, binaryData?: string): string => {\n\tif (fileName) {\n\t\tconst extension = fileName.toLowerCase().split(\".\").pop();\n\n\t\tif (extension && MIME_TYPE_BY_EXTENSION[extension]) {\n\t\t\treturn MIME_TYPE_BY_EXTENSION[extension];\n\t\t}\n\t}\n\n\tif (binaryData) {\n\t\tconst dataUrlMimeType = getDataUrlMimeType(binaryData);\n\n\t\tif (dataUrlMimeType) {\n\t\t\treturn dataUrlMimeType;\n\t\t}\n\n\t\tconst signatureMimeType = detectMimeTypeBySignature(binaryData);\n\n\t\tif (signatureMimeType) {\n\t\t\treturn signatureMimeType;\n\t\t}\n\t}\n\n\treturn DEFAULT_DOCUMENT_MIME_TYPE;\n};\n","import { useEffect, useEffectEvent, useState } from \"react\";\n\nimport { logError } from \"../utils\";\n\nimport { binaryToBlob, detectMimeType } from \"./binaryToBlob\";\n\nexport function useBinaryFile(bin: string | undefined) {\n\tconst [entry, setEntry] = useState<{ blob: Blob; mime: string } | null>(null);\n\tconst [error, setError] = useState<string | null>(null);\n\n\tconst updateEntry = useEffectEvent((data: { blob: Blob; mime: string } | null) => setEntry(data));\n\tconst updateError = useEffectEvent((error: string | null) => setError(error));\n\n\tuseEffect(() => {\n\t\tif (bin) {\n\t\t\ttry {\n\t\t\t\tconst mime = detectMimeType(undefined, bin);\n\t\t\t\tconst blob = binaryToBlob(bin, mime, true);\n\n\t\t\t\tupdateEntry({ blob, mime });\n\t\t\t\tupdateError(null);\n\t\t\t} catch (e) {\n\t\t\t\tlogError(e);\n\n\t\t\t\tupdateError(String(e));\n\t\t\t\tupdateEntry(null);\n\t\t\t}\n\t\t} else {\n\t\t\tupdateEntry(null);\n\t\t\tupdateError(null);\n\t\t}\n\t}, [bin]);\n\n\treturn { data: entry, error };\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.js","names":[],"sources":["../../src/binary/binaryToBlob.ts","../../src/binary/getBase64DecodedSize.ts","../../src/binary/useBinaryFile.ts"],"sourcesContent":["const DEFAULT_BLOB_MIME_TYPE = \"application/octet-stream\";\nconst DEFAULT_DOCUMENT_MIME_TYPE = \"application/pdf\";\nconst BASE64_DATA_URL_PATTERN = /^data:([^,]*);base64,([\\s\\S]*)$/i;\n\nconst MIME_TYPE_BY_EXTENSION: Record<string, string> = {\n\tbmp: \"image/bmp\",\n\tcsv: \"text/csv\",\n\tdoc: \"application/msword\",\n\tdocx: \"application/vnd.openxmlformats-officedocument.wordprocessingml.document\",\n\tgif: \"image/gif\",\n\tjpeg: \"image/jpeg\",\n\tjpg: \"image/jpeg\",\n\tjson: \"application/json\",\n\tpdf: \"application/pdf\",\n\tpng: \"image/png\",\n\tppt: \"application/vnd.ms-powerpoint\",\n\tpptx: \"application/vnd.openxmlformats-officedocument.presentationml.presentation\",\n\tsvg: \"image/svg+xml\",\n\ttif: \"image/tiff\",\n\ttiff: \"image/tiff\",\n\ttxt: \"text/plain\",\n\twebp: \"image/webp\",\n\txls: \"application/vnd.ms-excel\",\n\txlsx: \"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet\",\n\txml: \"application/xml\",\n\tzip: \"application/zip\"\n};\n\nconst normalizeBase64 = (value: string): string => {\n\tconst normalized = value.replace(/\\s/g, \"\").replace(/-/g, \"+\").replace(/_/g, \"/\");\n\tconst paddingRest = normalized.length % 4;\n\n\tif (paddingRest === 1 || !/^[A-Za-z0-9+/]*={0,2}$/.test(normalized)) {\n\t\tthrow new Error(\"Некорректный формат base64\");\n\t}\n\n\treturn paddingRest === 0 ? normalized : `${normalized}${\"=\".repeat(4 - paddingRest)}`;\n};\n\nconst getBase64RawPayload = (binaryData: string): { data: string; mimeType: string | null } => {\n\tconst dataUrlMatch = binaryData.match(BASE64_DATA_URL_PATTERN);\n\n\tif (!dataUrlMatch) {\n\t\treturn { data: binaryData, mimeType: null };\n\t}\n\n\tconst [, rawMimeType, rawData] = dataUrlMatch;\n\tconst mimeType = rawMimeType.split(\";\")[0].trim();\n\n\treturn {\n\t\tdata: rawData,\n\t\tmimeType: mimeType || null\n\t};\n};\n\nconst getBase64Payload = (binaryData: string): { data: string; mimeType: string | null } => {\n\tconst payload = getBase64RawPayload(binaryData);\n\n\treturn {\n\t\tdata: normalizeBase64(payload.data),\n\t\tmimeType: payload.mimeType\n\t};\n};\n\nconst getDataUrlMimeType = (binaryData: string): string | null => {\n\tconst dataUrlMatch = binaryData.match(BASE64_DATA_URL_PATTERN);\n\n\tif (!dataUrlMatch) {\n\t\treturn null;\n\t}\n\n\tconst mimeType = dataUrlMatch[1].split(\";\")[0].trim();\n\n\treturn mimeType || null;\n};\n\nconst decodeBase64ToBytes = (binaryData: string): Uint8Array => {\n\tconst { data } = getBase64Payload(binaryData);\n\tconst binaryString = atob(data);\n\tconst byteArray = new Uint8Array(binaryString.length);\n\n\tfor (let index = 0; index < binaryString.length; index++) {\n\t\tbyteArray[index] = binaryString.charCodeAt(index);\n\t}\n\n\treturn byteArray;\n};\n\nconst binaryStringToBytes = (binaryData: string): Uint8Array => {\n\tconst byteArray = new Uint8Array(binaryData.length);\n\n\tfor (let index = 0; index < binaryData.length; index++) {\n\t\tbyteArray[index] = binaryData.charCodeAt(index);\n\t}\n\n\treturn byteArray;\n};\n\nconst copyBytesToArrayBuffer = (byteArray: Uint8Array): ArrayBuffer => {\n\tconst buffer = new ArrayBuffer(byteArray.byteLength);\n\tconst view = new Uint8Array(buffer);\n\n\tview.set(byteArray);\n\n\treturn buffer;\n};\n\nconst decodeHeaderFromBase64 = (binaryData: string): string | null => {\n\ttry {\n\t\tconst { data } = getBase64RawPayload(binaryData);\n\t\tconst headerData = data.slice(0, 128);\n\t\tconst binaryString = atob(normalizeBase64(headerData));\n\n\t\treturn binaryString;\n\t} catch {\n\t\treturn null;\n\t}\n};\n\nconst detectMimeTypeBySignature = (binaryData: string): string | null => {\n\tconst header = decodeHeaderFromBase64(binaryData);\n\n\tif (!header) {\n\t\treturn null;\n\t}\n\n\tconst bytes = new Uint8Array(header.length);\n\n\tfor (let index = 0; index < header.length; index++) {\n\t\tbytes[index] = header.charCodeAt(index);\n\t}\n\n\tif (bytes[0] === 0x25 && bytes[1] === 0x50 && bytes[2] === 0x44 && bytes[3] === 0x46) {\n\t\treturn \"application/pdf\";\n\t}\n\n\tif (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {\n\t\treturn \"image/jpeg\";\n\t}\n\n\tif (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) {\n\t\treturn \"image/png\";\n\t}\n\n\tif (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46) {\n\t\treturn \"image/gif\";\n\t}\n\n\tif (bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46) {\n\t\treturn \"image/webp\";\n\t}\n\n\tif (bytes[0] === 0x50 && bytes[1] === 0x4b) {\n\t\treturn \"application/zip\";\n\t}\n\n\tconst textHeader = header\n\t\t.replace(/^\\uFEFF/, \"\")\n\t\t.trimStart()\n\t\t.slice(0, 16);\n\n\tif (textHeader.startsWith(\"<?xml\") || textHeader.startsWith(\"<\")) {\n\t\treturn \"application/xml\";\n\t}\n\n\treturn null;\n};\n\n/**\n * Преобразует base64 или бинарную строку в Blob.\n *\n * Data URL с префиксом `data:*;base64,` поддерживается, сам URL здесь не создаётся.\n */\nexport const binaryToBlob = (binaryData: string, mimeType: string = DEFAULT_BLOB_MIME_TYPE, isBase64 = true): Blob => {\n\tif (!binaryData) {\n\t\tthrow new Error(\"Не переданы бинарные данные\");\n\t}\n\n\ttry {\n\t\tconst byteArray = isBase64 ? decodeBase64ToBytes(binaryData) : binaryStringToBytes(binaryData);\n\n\t\tif (byteArray.length === 0) {\n\t\t\tthrow new Error(\"После преобразования не осталось данных\");\n\t\t}\n\n\t\treturn new Blob([copyBytesToArrayBuffer(byteArray)], { type: mimeType.trim() || DEFAULT_BLOB_MIME_TYPE });\n\t} catch (error) {\n\t\tif (error instanceof Error) {\n\t\t\tthrow error;\n\t\t}\n\t\tthrow new Error(\"Некорректный формат бинарных данных\");\n\t}\n};\n\n/**\n * Определяет MIME-тип по имени файла, data URL или сигнатуре base64-данных.\n *\n * Если определить тип не удалось, используется PDF: большинство документов в этом контуре приходят именно так.\n */\nexport const detectMimeType = (fileName?: string, binaryData?: string): string => {\n\tif (fileName) {\n\t\tconst extension = fileName.toLowerCase().split(\".\").pop();\n\n\t\tif (extension && MIME_TYPE_BY_EXTENSION[extension]) {\n\t\t\treturn MIME_TYPE_BY_EXTENSION[extension];\n\t\t}\n\t}\n\n\tif (binaryData) {\n\t\tconst dataUrlMimeType = getDataUrlMimeType(binaryData);\n\n\t\tif (dataUrlMimeType) {\n\t\t\treturn dataUrlMimeType;\n\t\t}\n\n\t\tconst signatureMimeType = detectMimeTypeBySignature(binaryData);\n\n\t\tif (signatureMimeType) {\n\t\t\treturn signatureMimeType;\n\t\t}\n\t}\n\n\treturn DEFAULT_DOCUMENT_MIME_TYPE;\n};\n","const INVALID_BASE64_CHARACTER_PATTERN = /[^A-Za-z0-9+/=]/;\n\n/**\n * Проверяет стандартную Base64-форму и считает байты без декодирования или создания Buffer/Blob.\n * Невалидный payload возвращает null, пустая строка — 0; допустимый размер определяет вызывающий контракт.\n * Data URL, whitespace, URL-safe алфавит и отсутствующий padding здесь не нормализуются.\n */\nexport function getBase64DecodedSize(value: string): number | null {\n\tif (value.length % 4 !== 0 || INVALID_BASE64_CHARACTER_PATTERN.test(value)) return null;\n\tconst paddingLength = value.endsWith(\"==\") ? 2 : value.endsWith(\"=\") ? 1 : 0;\n\tconst firstPaddingIndex = value.indexOf(\"=\");\n\tif (firstPaddingIndex !== -1 && firstPaddingIndex !== value.length - paddingLength) return null;\n\treturn Math.floor((value.length * 3) / 4) - paddingLength;\n}\n","import { useEffect, useEffectEvent, useState } from \"react\";\n\nimport { logError } from \"../utils\";\n\nimport { binaryToBlob, detectMimeType } from \"./binaryToBlob\";\n\nexport function useBinaryFile(bin: string | undefined) {\n\tconst [entry, setEntry] = useState<{ blob: Blob; mime: string } | null>(null);\n\tconst [error, setError] = useState<string | null>(null);\n\n\tconst updateEntry = useEffectEvent((data: { blob: Blob; mime: string } | null) => setEntry(data));\n\tconst updateError = useEffectEvent((error: string | null) => setError(error));\n\n\tuseEffect(() => {\n\t\tif (bin) {\n\t\t\ttry {\n\t\t\t\tconst mime = detectMimeType(undefined, bin);\n\t\t\t\tconst blob = binaryToBlob(bin, mime, true);\n\n\t\t\t\tupdateEntry({ blob, mime });\n\t\t\t\tupdateError(null);\n\t\t\t} catch (e) {\n\t\t\t\tlogError(e);\n\n\t\t\t\tupdateError(String(e));\n\t\t\t\tupdateEntry(null);\n\t\t\t}\n\t\t} else {\n\t\t\tupdateEntry(null);\n\t\t\tupdateError(null);\n\t\t}\n\t}, [bin]);\n\n\treturn { data: entry, error };\n}\n"],"mappings":"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"}
@@ -0,0 +1,10 @@
1
+ /** Определяет, какую существующую часть пути дополнительно проверить через Node.js realpath. */
2
+ export type PathRealpathMode = "none" | "parent" | "target";
3
+ /** Root принадлежит серверной конфигурации; сегменты обозначают только путь внутри него. */
4
+ export type ResolvePathInsideRootOptions = Readonly<{
5
+ rootPath: string;
6
+ pathSegments: readonly string[];
7
+ /** По умолчанию проверяется существующий target, включая символические ссылки. */
8
+ realpathMode?: PathRealpathMode;
9
+ }>;
10
+ //# sourceMappingURL=fileSystemTypes.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fileSystemTypes.d.ts","sourceRoot":"","sources":["../../src/file-system/fileSystemTypes.ts"],"names":[],"mappings":"AAAA,gGAAgG;AAChG,MAAM,MAAM,gBAAgB,GAAG,MAAM,GAAG,QAAQ,GAAG,QAAQ,CAAC;AAE5D,4FAA4F;AAC5F,MAAM,MAAM,4BAA4B,GAAG,QAAQ,CAAC;IACnD,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,SAAS,MAAM,EAAE,CAAC;IAChC,kFAAkF;IAClF,YAAY,CAAC,EAAE,gBAAgB,CAAC;CAChC,CAAC,CAAC"}
@@ -0,0 +1,4 @@
1
+ export { type PathRealpathMode, type ResolvePathInsideRootOptions } from "./fileSystemTypes";
2
+ export { isFileSystemNotFoundError } from "./isFileSystemNotFoundError";
3
+ export { resolvePathInsideRoot } from "./resolvePathInsideRoot";
4
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/file-system/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,gBAAgB,EAAE,KAAK,4BAA4B,EAAE,MAAM,mBAAmB,CAAC;AAC7F,OAAO,EAAE,yBAAyB,EAAE,MAAM,6BAA6B,CAAC;AACxE,OAAO,EAAE,qBAAqB,EAAE,MAAM,yBAAyB,CAAC"}
@@ -0,0 +1,37 @@
1
+ import { i as e } from "../chunks/isRecord-DXDTW-jA.js";
2
+ import { realpath as t } from "node:fs/promises";
3
+ import { dirname as n, isAbsolute as r, relative as i, resolve as a, sep as o } from "node:path";
4
+ //#region src/file-system/isFileSystemNotFoundError.ts
5
+ function s(t) {
6
+ return e(t) ? t.code === "ENOENT" || t.code === "ENOTDIR" : !1;
7
+ }
8
+ //#endregion
9
+ //#region src/file-system/resolvePathInsideRoot.ts
10
+ function c(e) {
11
+ let t = e.trim();
12
+ if (!t) throw Error("Корневая папка хранилища не задана.");
13
+ return a(t);
14
+ }
15
+ function l(e, t) {
16
+ let n = i(e, t);
17
+ return n === "" || n !== ".." && !n.startsWith(`..${o}`) && !r(n);
18
+ }
19
+ async function u(e, r, i) {
20
+ let a;
21
+ try {
22
+ a = await t(e);
23
+ } catch (e) {
24
+ throw Error("Корневая папка хранилища недоступна.", { cause: e });
25
+ }
26
+ let o = i === "parent" ? n(r) : r, s = await t(o);
27
+ if (!l(a, s)) throw Error("Путь выходит за пределы корневой папки хранилища.");
28
+ }
29
+ async function d({ rootPath: e, pathSegments: t, realpathMode: n = "target" }) {
30
+ let r = c(e), i = a(r, ...t);
31
+ if (!l(r, i)) throw Error("Путь выходит за пределы корневой папки хранилища.");
32
+ return n !== "none" && await u(r, i, n), i;
33
+ }
34
+ //#endregion
35
+ export { s as isFileSystemNotFoundError, d as resolvePathInsideRoot };
36
+
37
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","names":[],"sources":["../../src/file-system/isFileSystemNotFoundError.ts","../../src/file-system/resolvePathInsideRoot.ts"],"sourcesContent":["import { isRecord } from \"../validators\";\n\n/** Отличает отсутствующий путь от ошибок доступа и I/O, которые вызывающий код обязан обработать отдельно. */\nexport function isFileSystemNotFoundError(error: unknown): boolean {\n\tif (!isRecord(error)) return false;\n\treturn error.code === \"ENOENT\" || error.code === \"ENOTDIR\";\n}\n","import { type PathRealpathMode, type ResolvePathInsideRootOptions } from \"./fileSystemTypes\";\n\nimport { realpath } from \"node:fs/promises\";\nimport { dirname, isAbsolute, relative, resolve, sep } from \"node:path\";\n\nfunction resolveStorageRoot(rootPath: string): string {\n\tconst normalizedRootPath = rootPath.trim();\n\tif (!normalizedRootPath) {\n\t\tthrow new Error(\"Корневая папка хранилища не задана.\");\n\t}\n\treturn resolve(normalizedRootPath);\n}\n\nfunction isResolvedPathInsideRoot(rootPath: string, candidatePath: string): boolean {\n\tconst relativePath = relative(rootPath, candidatePath);\n\t// Проверяется сегмент родителя, а не префикс имени: '..metadata' является допустимым файлом внутри root.\n\treturn relativePath === \"\" || (relativePath !== \"..\" && !relativePath.startsWith(`..${sep}`) && !isAbsolute(relativePath));\n}\n\nasync function assertRealPathInsideRoot(\n\trootPath: string,\n\tcandidatePath: string,\n\trealpathMode: Exclude<PathRealpathMode, \"none\">\n): Promise<void> {\n\tlet realRootPath: string;\n\ttry {\n\t\trealRootPath = await realpath(rootPath);\n\t} catch (error) {\n\t\tthrow new Error(\"Корневая папка хранилища недоступна.\", { cause: error });\n\t}\n\tconst pathForRealCheck = realpathMode === \"parent\" ? dirname(candidatePath) : candidatePath;\n\tconst realCandidatePath = await realpath(pathForRealCheck);\n\tif (!isResolvedPathInsideRoot(realRootPath, realCandidatePath)) {\n\t\tthrow new Error(\"Путь выходит за пределы корневой папки хранилища.\");\n\t}\n}\n\n/**\n * Нормализует абсолютный путь и запрещает выход за доверенный root.\n * 'target' проверяет существующий файл, 'parent' — каталог перед созданием файла,\n * 'none' выполняет только лексическую проверку и не защищает от символических ссылок.\n * Проверка не открывает файл: защиту от смены пути между проверкой и I/O обеспечивает вызывающий код.\n */\nexport async function resolvePathInsideRoot({\n\trootPath,\n\tpathSegments,\n\trealpathMode = \"target\"\n}: ResolvePathInsideRootOptions): Promise<string> {\n\tconst resolvedRootPath = resolveStorageRoot(rootPath);\n\tconst resolvedCandidatePath = resolve(resolvedRootPath, ...pathSegments);\n\tif (!isResolvedPathInsideRoot(resolvedRootPath, resolvedCandidatePath)) {\n\t\tthrow new Error(\"Путь выходит за пределы корневой папки хранилища.\");\n\t}\n\tif (realpathMode !== \"none\") {\n\t\tawait assertRealPathInsideRoot(resolvedRootPath, resolvedCandidatePath, realpathMode);\n\t}\n\treturn resolvedCandidatePath;\n}\n"],"mappings":";;;;AAGA,SAAgB,EAA0B,GAAyB;CAElE,OADK,EAAS,CAAK,IACZ,EAAM,SAAS,YAAY,EAAM,SAAS,YADpB;AAE9B;;;ACDA,SAAS,EAAmB,GAA0B;CACrD,IAAM,IAAqB,EAAS,KAAK;CACzC,IAAI,CAAC,GACJ,MAAU,MAAM,qCAAqC;CAEtD,OAAO,EAAQ,CAAkB;AAClC;AAEA,SAAS,EAAyB,GAAkB,GAAgC;CACnF,IAAM,IAAe,EAAS,GAAU,CAAa;CAErD,OAAO,MAAiB,MAAO,MAAiB,QAAQ,CAAC,EAAa,WAAW,KAAK,GAAK,KAAK,CAAC,EAAW,CAAY;AACzH;AAEA,eAAe,EACd,GACA,GACA,GACgB;CAChB,IAAI;CACJ,IAAI;EACH,IAAe,MAAM,EAAS,CAAQ;CACvC,SAAS,GAAO;EACf,MAAU,MAAM,wCAAwC,EAAE,OAAO,EAAM,CAAC;CACzE;CACA,IAAM,IAAmB,MAAiB,WAAW,EAAQ,CAAa,IAAI,GACxE,IAAoB,MAAM,EAAS,CAAgB;CACzD,IAAI,CAAC,EAAyB,GAAc,CAAiB,GAC5D,MAAU,MAAM,mDAAmD;AAErE;AAQA,eAAsB,EAAsB,EAC3C,aACA,iBACA,kBAAe,YACkC;CACjD,IAAM,IAAmB,EAAmB,CAAQ,GAC9C,IAAwB,EAAQ,GAAkB,GAAG,CAAY;CACvE,IAAI,CAAC,EAAyB,GAAkB,CAAqB,GACpE,MAAU,MAAM,mDAAmD;CAKpE,OAHI,MAAiB,UACpB,MAAM,EAAyB,GAAkB,GAAuB,CAAY,GAE9E;AACR"}
@@ -0,0 +1,3 @@
1
+ /** Отличает отсутствующий путь от ошибок доступа и I/O, которые вызывающий код обязан обработать отдельно. */
2
+ export declare function isFileSystemNotFoundError(error: unknown): boolean;
3
+ //# sourceMappingURL=isFileSystemNotFoundError.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"isFileSystemNotFoundError.d.ts","sourceRoot":"","sources":["../../src/file-system/isFileSystemNotFoundError.ts"],"names":[],"mappings":"AAEA,8GAA8G;AAC9G,wBAAgB,yBAAyB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAGjE"}
@@ -0,0 +1,9 @@
1
+ import { type ResolvePathInsideRootOptions } from "./fileSystemTypes";
2
+ /**
3
+ * Нормализует абсолютный путь и запрещает выход за доверенный root.
4
+ * 'target' проверяет существующий файл, 'parent' — каталог перед созданием файла,
5
+ * 'none' выполняет только лексическую проверку и не защищает от символических ссылок.
6
+ * Проверка не открывает файл: защиту от смены пути между проверкой и I/O обеспечивает вызывающий код.
7
+ */
8
+ export declare function resolvePathInsideRoot({ rootPath, pathSegments, realpathMode }: ResolvePathInsideRootOptions): Promise<string>;
9
+ //# sourceMappingURL=resolvePathInsideRoot.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"resolvePathInsideRoot.d.ts","sourceRoot":"","sources":["../../src/file-system/resolvePathInsideRoot.ts"],"names":[],"mappings":"AAAA,OAAO,EAAyB,KAAK,4BAA4B,EAAE,MAAM,mBAAmB,CAAC;AAqC7F;;;;;GAKG;AACH,wBAAsB,qBAAqB,CAAC,EAC3C,QAAQ,EACR,YAAY,EACZ,YAAuB,EACvB,EAAE,4BAA4B,GAAG,OAAO,CAAC,MAAM,CAAC,CAUhD"}
@@ -1,2 +1,3 @@
1
- export * from "./useFetchNextPageEffect";
1
+ export { useFetchNextPageEffect } from "./useFetchNextPageEffect";
2
+ export { type UseFetchNextPageEffectOptions } from "./useFetchNextPageEffectTypes";
2
3
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/virtualizer/index.ts"],"names":[],"mappings":"AAAA,cAAc,0BAA0B,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/virtualizer/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,sBAAsB,EAAE,MAAM,0BAA0B,CAAC;AAClE,OAAO,EAAE,KAAK,6BAA6B,EAAE,MAAM,+BAA+B,CAAC"}
@@ -1,16 +1,28 @@
1
1
  import { useEffect as e, useRef as t } from "react";
2
2
  //#region src/virtualizer/useFetchNextPageEffect.ts
3
- function n({ virtualItems: n, currentItemsCount: r, fetchNextPage: i, hasNextPage: a }) {
4
- let o = t(!1);
5
- e(() => {
6
- !n.length || !r || n[n.length - 1].index >= r - 1 && a && !o.current && (o.current = !0, i?.().finally(() => {
7
- o.current = !1;
3
+ function n({ virtualItems: n, currentItemsCount: r, fetchNextPage: i, hasNextPage: a, onError: o }) {
4
+ let s = t(!1), c = t(null), l = t(!1);
5
+ e(() => (l.current = !0, () => {
6
+ l.current = !1;
7
+ }), []), e(() => {
8
+ (!a || c.current !== r) && (c.current = null), !(!n.length || !r || !a || s.current || c.current === r) && n[n.length - 1].index >= r - 1 && (s.current = !0, Promise.resolve().then(i).then(() => {
9
+ s.current = !1;
10
+ }, async (e) => {
11
+ c.current = r;
12
+ try {
13
+ l.current && o ? await o(e) : console.error("Не удалось загрузить следующую страницу виртуализированного списка.", e);
14
+ } catch (t) {
15
+ console.error("Не удалось обработать ошибку дозагрузки виртуализированного списка.", e, t);
16
+ } finally {
17
+ s.current = !1;
18
+ }
8
19
  }));
9
20
  }, [
10
21
  n,
11
22
  r,
12
23
  a,
13
- i
24
+ i,
25
+ o
14
26
  ]);
15
27
  }
16
28
  //#endregion
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":[],"sources":["../../src/virtualizer/useFetchNextPageEffect.ts"],"sourcesContent":["import { useEffect, useRef } from \"react\";\n\nimport { VirtualItem } from \"@tanstack/react-virtual\";\n\nexport function useFetchNextPageEffect({\n\tvirtualItems,\n\tcurrentItemsCount,\n\tfetchNextPage,\n\thasNextPage\n}: {\n\tvirtualItems: VirtualItem[];\n\tcurrentItemsCount: number;\n\thasNextPage: boolean;\n\tfetchNextPage: () => Promise<unknown>;\n}) {\n\tconst fetchingRef = useRef(false);\n\n\tuseEffect(() => {\n\t\tif (!virtualItems.length || !currentItemsCount) return;\n\t\tconst lastVirtual = virtualItems[virtualItems.length - 1];\n\n\t\tif (lastVirtual.index >= currentItemsCount - 1 && hasNextPage && !fetchingRef.current) {\n\t\t\tfetchingRef.current = true;\n\t\t\tfetchNextPage?.().finally(() => {\n\t\t\t\tfetchingRef.current = false;\n\t\t\t});\n\t\t}\n\t}, [virtualItems, currentItemsCount, hasNextPage, fetchNextPage]);\n}\n"],"mappings":";;AAIA,SAAgB,EAAuB,EACtC,iBACA,sBACA,kBACA,kBAME;CACF,IAAM,IAAc,EAAO,EAAK;CAEhC,QAAgB;EACX,CAAC,EAAa,UAAU,CAAC,KACT,EAAa,EAAa,SAAS,EAEnD,CAAY,SAAS,IAAoB,KAAK,KAAe,CAAC,EAAY,YAC7E,EAAY,UAAU,IACtB,IAAgB,CAAC,CAAC,cAAc;GAC/B,EAAY,UAAU;EACvB,CAAC;CAEH,GAAG;EAAC;EAAc;EAAmB;EAAa;CAAa,CAAC;AACjE"}
1
+ {"version":3,"file":"index.js","names":[],"sources":["../../src/virtualizer/useFetchNextPageEffect.ts"],"sourcesContent":["import { useEffect, useRef } from \"react\";\n\nimport { type UseFetchNextPageEffectOptions } from \"./useFetchNextPageEffectTypes\";\n\n/**\n * Достигнутый край запускает одну дозагрузку. Отказ не превращается в detached\n * rejection и не повторяется из-за новых virtual items при каждом scroll/render.\n */\nexport function useFetchNextPageEffect({\n\tvirtualItems,\n\tcurrentItemsCount,\n\tfetchNextPage,\n\thasNextPage,\n\tonError\n}: UseFetchNextPageEffectOptions): void {\n\tconst fetchingRef = useRef(false);\n\tconst failedItemsCountRef = useRef<number | null>(null);\n\tconst mountedRef = useRef(false);\n\n\tuseEffect(() => {\n\t\tmountedRef.current = true;\n\t\treturn () => {\n\t\t\t// Transport принадлежит caller и не отменяется; поздний отказ остаётся\n\t\t\t// диагностикой, но уже не вызывает callback размонтированного UI.\n\t\t\tmountedRef.current = false;\n\t\t};\n\t}, []);\n\n\tuseEffect(() => {\n\t\t// Новые данные либо явное выключение pagination разрешают следующую\n\t\t// попытку. Identity callback и видимого диапазона не являются retry.\n\t\tif (!hasNextPage || failedItemsCountRef.current !== currentItemsCount) failedItemsCountRef.current = null;\n\t\tif (\n\t\t\t!virtualItems.length ||\n\t\t\t!currentItemsCount ||\n\t\t\t!hasNextPage ||\n\t\t\tfetchingRef.current ||\n\t\t\tfailedItemsCountRef.current === currentItemsCount\n\t\t)\n\t\t\treturn;\n\t\tconst lastVirtual = virtualItems[virtualItems.length - 1];\n\n\t\tif (lastVirtual.index >= currentItemsCount - 1) {\n\t\t\tfetchingRef.current = true;\n\t\t\t// Начало через Promise охватывает и синхронный throw transport callback.\n\t\t\t// Обе ветки завершения обработаны: finally не создаёт второй rejected Promise.\n\t\t\tvoid Promise.resolve()\n\t\t\t\t.then(fetchNextPage)\n\t\t\t\t.then(\n\t\t\t\t\t() => {\n\t\t\t\t\t\tfetchingRef.current = false;\n\t\t\t\t\t},\n\t\t\t\t\tasync (error: unknown) => {\n\t\t\t\t\t\tfailedItemsCountRef.current = currentItemsCount;\n\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\tif (mountedRef.current && onError) await onError(error);\n\t\t\t\t\t\t\telse console.error(\"Не удалось загрузить следующую страницу виртуализированного списка.\", error);\n\t\t\t\t\t\t} catch (reporterError: unknown) {\n\t\t\t\t\t\t\tconsole.error(\"Не удалось обработать ошибку дозагрузки виртуализированного списка.\", error, reporterError);\n\t\t\t\t\t\t} finally {\n\t\t\t\t\t\t\tfetchingRef.current = false;\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t);\n\t\t}\n\t}, [virtualItems, currentItemsCount, hasNextPage, fetchNextPage, onError]);\n}\n"],"mappings":";;AAQA,SAAgB,EAAuB,EACtC,iBACA,sBACA,kBACA,gBACA,cACuC;CACvC,IAAM,IAAc,EAAO,EAAK,GAC1B,IAAsB,EAAsB,IAAI,GAChD,IAAa,EAAO,EAAK;CAW/B,AATA,SACC,EAAW,UAAU,UACR;EAGZ,EAAW,UAAU;CACtB,IACE,CAAC,CAAC,GAEL,QAAgB;EAGf,CAAI,CAAC,KAAe,EAAoB,YAAY,OAAmB,EAAoB,UAAU,OAEpG,GAAC,EAAa,UACd,CAAC,KACD,CAAC,KACD,EAAY,WACZ,EAAoB,YAAY,MAGb,EAAa,EAAa,SAAS,EAEnD,CAAY,SAAS,IAAoB,MAC5C,EAAY,UAAU,IAGtB,QAAa,QAAQ,CAAC,CACpB,KAAK,CAAa,CAAC,CACnB,WACM;GACL,EAAY,UAAU;EACvB,GACA,OAAO,MAAmB;GACzB,EAAoB,UAAU;GAC9B,IAAI;IACH,AAAI,EAAW,WAAW,IAAS,MAAM,EAAQ,CAAK,IACjD,QAAQ,MAAM,uEAAuE,CAAK;GAChG,SAAS,GAAwB;IAChC,QAAQ,MAAM,uEAAuE,GAAO,CAAa;GAC1G,UAAU;IACT,EAAY,UAAU;GACvB;EACD,CACD;CAEH,GAAG;EAAC;EAAc;EAAmB;EAAa;EAAe;CAAO,CAAC;AAC1E"}
@@ -1,8 +1,7 @@
1
- import { VirtualItem } from "@tanstack/react-virtual";
2
- export declare function useFetchNextPageEffect({ virtualItems, currentItemsCount, fetchNextPage, hasNextPage }: {
3
- virtualItems: VirtualItem[];
4
- currentItemsCount: number;
5
- hasNextPage: boolean;
6
- fetchNextPage: () => Promise<unknown>;
7
- }): void;
1
+ import { type UseFetchNextPageEffectOptions } from "./useFetchNextPageEffectTypes";
2
+ /**
3
+ * Достигнутый край запускает одну дозагрузку. Отказ не превращается в detached
4
+ * rejection и не повторяется из-за новых virtual items при каждом scroll/render.
5
+ */
6
+ export declare function useFetchNextPageEffect({ virtualItems, currentItemsCount, fetchNextPage, hasNextPage, onError }: UseFetchNextPageEffectOptions): void;
8
7
  //# sourceMappingURL=useFetchNextPageEffect.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"useFetchNextPageEffect.d.ts","sourceRoot":"","sources":["../../src/virtualizer/useFetchNextPageEffect.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAEtD,wBAAgB,sBAAsB,CAAC,EACtC,YAAY,EACZ,iBAAiB,EACjB,aAAa,EACb,WAAW,EACX,EAAE;IACF,YAAY,EAAE,WAAW,EAAE,CAAC;IAC5B,iBAAiB,EAAE,MAAM,CAAC;IAC1B,WAAW,EAAE,OAAO,CAAC;IACrB,aAAa,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,CAAC;CACtC,QAcA"}
1
+ {"version":3,"file":"useFetchNextPageEffect.d.ts","sourceRoot":"","sources":["../../src/virtualizer/useFetchNextPageEffect.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,KAAK,6BAA6B,EAAE,MAAM,+BAA+B,CAAC;AAEnF;;;GAGG;AACH,wBAAgB,sBAAsB,CAAC,EACtC,YAAY,EACZ,iBAAiB,EACjB,aAAa,EACb,WAAW,EACX,OAAO,EACP,EAAE,6BAA6B,GAAG,IAAI,CAoDtC"}
@@ -0,0 +1,11 @@
1
+ import { type VirtualItem } from "@tanstack/react-virtual";
2
+ /** Дозагрузка не владеет данными и retry: caller передаёт состояние своей пагинации. */
3
+ export type UseFetchNextPageEffectOptions = Readonly<{
4
+ virtualItems: readonly VirtualItem[];
5
+ currentItemsCount: number;
6
+ hasNextPage: boolean;
7
+ fetchNextPage: () => Promise<unknown>;
8
+ /** Ошибка передаётся один раз; без callback остаётся явная console-диагностика. */
9
+ onError?: (error: unknown) => void | Promise<void>;
10
+ }>;
11
+ //# sourceMappingURL=useFetchNextPageEffectTypes.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useFetchNextPageEffectTypes.d.ts","sourceRoot":"","sources":["../../src/virtualizer/useFetchNextPageEffectTypes.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAE3D,wFAAwF;AACxF,MAAM,MAAM,6BAA6B,GAAG,QAAQ,CAAC;IACpD,YAAY,EAAE,SAAS,WAAW,EAAE,CAAC;IACrC,iBAAiB,EAAE,MAAM,CAAC;IAC1B,WAAW,EAAE,OAAO,CAAC;IACrB,aAAa,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,CAAC;IACtC,mFAAmF;IACnF,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACnD,CAAC,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ryuzaki13/react-foundation-lib",
3
- "version": "2.15.0",
3
+ "version": "2.17.0",
4
4
  "description": "Reusable React and TypeScript foundation utilities, hooks, and primitives.",
5
5
  "license": "MIT",
6
6
  "author": "Ryuzaki13",
@@ -97,6 +97,10 @@
97
97
  "types": "./dist/file/index.d.ts",
98
98
  "import": "./dist/file/index.js"
99
99
  },
100
+ "./file-system": {
101
+ "types": "./dist/file-system/index.d.ts",
102
+ "import": "./dist/file-system/index.js"
103
+ },
100
104
  "./fingerprint": {
101
105
  "types": "./dist/fingerprint/index.d.ts",
102
106
  "import": "./dist/fingerprint/index.js"
@@ -4,7 +4,7 @@ import { Meta } from "@storybook/addon-docs/blocks";
4
4
 
5
5
  # Работа с бинарными данными
6
6
 
7
- Модуль `binary` преобразует строковое бинарное содержимое в браузерный `Blob`, определяет предполагаемый MIME-тип документа и предоставляет React hook для типового сценария «base64 → `{ blob, mime }`».
7
+ Модуль `binary` преобразует строковое бинарное содержимое в браузерный `Blob`, определяет предполагаемый MIME-тип документа и предоставляет React hook для типового сценария «base64 → `{ blob, mime }`». Отдельная pure function `getBase64DecodedSize` проверяет стандартный Base64 payload и считает размер без декодирования в browser или server runtime.
8
8
 
9
9
  Основной сценарий:
10
10
 
@@ -20,6 +20,7 @@ import { Meta } from "@storybook/addon-docs/blocks";
20
20
 
21
21
  - [Назначение и границы модуля](#назначение-и-границы-модуля)
22
22
  - [Импорт](#импорт)
23
+ - [`getBase64DecodedSize`](#getbase64decodedsize)
23
24
  - [Минимальные понятия](#минимальные-понятия)
24
25
  - [Как выбрать API](#как-выбрать-api)
25
26
  - [Быстрый старт](#быстрый-старт)
@@ -44,7 +45,8 @@ import { Meta } from "@storybook/addon-docs/blocks";
44
45
  - нормализует пробелы, URL-safe алфавит и отсутствующий base64 padding;
45
46
  - создаёт `Blob`;
46
47
  - предполагает MIME по имени файла, объявлению data URL или небольшой сигнатуре;
47
- - предоставляет React hook для base64-данных.
48
+ - предоставляет React hook для base64-данных;
49
+ - проверяет стандартную Base64-форму и считает размер без выделения decoded buffer через `getBase64DecodedSize`.
48
50
 
49
51
  ### Чего модуль не делает
50
52
 
@@ -75,7 +77,7 @@ import { Meta } from "@storybook/addon-docs/blocks";
75
77
  Импортируйте API только из опубликованного subpath `/binary`:
76
78
 
77
79
  ```ts
78
- import { binaryToBlob, detectMimeType, useBinaryFile } from "@ryuzaki13/react-foundation-lib/binary";
80
+ import { binaryToBlob, detectMimeType, getBase64DecodedSize, useBinaryFile } from "@ryuzaki13/react-foundation-lib/binary";
79
81
  ```
80
82
 
81
83
  Корневой импорт пакета не поддерживается:
@@ -112,6 +114,27 @@ npm install react react-dom
112
114
 
113
115
  В тестовой или серверной среде эти Web APIs могут отсутствовать либо вести себя иначе. Наличие похожих globals в конкретной версии Node.js не превращает browser contract в гарантированный server API.
114
116
 
117
+ `getBase64DecodedSize` использует только строки и числа. Эта функция подходит для client-safe или server request validation и не вызывает browser API, React hooks, `Buffer` или Node.js imports. Существующий barrel `/binary` по-прежнему экспортирует React hook, поэтому его peer-контракт сохраняется.
118
+
119
+ ## `getBase64DecodedSize`
120
+
121
+ ```ts
122
+ function getBase64DecodedSize(value: string): number | null;
123
+
124
+ getBase64DecodedSize("AA=="); // 1 байт
125
+ getBase64DecodedSize("AAE="); // 2 байта
126
+ getBase64DecodedSize("QUJD"); // 3 байта
127
+ getBase64DecodedSize(""); // 0
128
+ getBase64DecodedSize("AA"); // null: отсутствует padding
129
+ getBase64DecodedSize("AAA\n"); // null: перенос строки недопустим
130
+ ```
131
+
132
+ Принимается стандартный алфавит `A–Z`, `a–z`, `0–9`, `+`, `/`, длина должна быть кратна четырём. Один или два символа `=` допустимы только в конце; неверный алфавит, положение или количество padding возвращают `null`. Пустая строка является корректной формой с размером `0`; domain contract отдельно решает, разрешён ли пустой файл.
133
+
134
+ Функция не нормализует whitespace, Base64URL, data URL или отсутствующий padding. Она проверяет форму и вычисляет количество decoded bytes, не декодируя содержимое и не проверяя canonical pad bits, MIME или формат файла. В отличие от browser `binaryToBlob`, это узкий API для проверки строгого transport payload до выделения decoded buffer. Ограничение длины входного HTTP body и лимит размера файла остаются у вызывающей boundary.
135
+
136
+ Время работы — `O(n)` по длине строки, дополнительная память — `O(1)`. Тесты: [`getBase64DecodedSize.test.ts`](./getBase64DecodedSize.test.ts).
137
+
115
138
  ## Минимальные понятия
116
139
 
117
140
  ### Байт
@@ -1307,11 +1330,12 @@ npm run test -- src/binary/binaryToBlob.test.ts
1307
1330
  import { binaryToBlob, detectMimeType, useBinaryFile } from "@ryuzaki13/react-foundation-lib/binary";
1308
1331
  ```
1309
1332
 
1310
- | API | Вход | Результат | Ошибка |
1311
- | ---------------- | ------------------------------- | ----------------- | ---------------------------------------- |
1312
- | `binaryToBlob` | Строка, MIME, base64/raw флаг | Непустой `Blob` | Выбрасывает `Error`/browser decode error |
1313
- | `detectMimeType` | Filename и/или base64/data URL | MIME string | Не выбрасывает; fallback PDF |
1314
- | `useBinaryFile` | Base64/data URL или `undefined` | `{ data, error }` | Логирует и возвращает строку ошибки |
1333
+ | API | Вход | Результат | Ошибка |
1334
+ | ---------------------- | ------------------------------- | ----------------- | ------------------------------------------------ |
1335
+ | `binaryToBlob` | Строка, MIME, base64/raw флаг | Непустой `Blob` | Выбрасывает `Error`/browser decode error |
1336
+ | `getBase64DecodedSize` | Стандартный Base64 payload | Количество байтов | `null` для неверной формы; `0` для пустой строки |
1337
+ | `detectMimeType` | Filename и/или base64/data URL | MIME string | Не выбрасывает; fallback PDF |
1338
+ | `useBinaryFile` | Base64/data URL или `undefined` | `{ data, error }` | Логирует и возвращает строку ошибки |
1315
1339
 
1316
1340
  ### Defaults
1317
1341
 
@@ -0,0 +1,64 @@
1
+ import { Meta } from "@storybook/addon-docs/blocks";
2
+
3
+ <Meta title="Foundation Lib/API/File System" />
4
+
5
+ # Пути внутри серверного файлового хранилища
6
+
7
+ `@ryuzaki13/react-foundation-lib/file-system` — отдельный Node.js entrypoint для проверки путей перед серверным I/O. Он использует `node:fs/promises` и `node:path`; его нельзя импортировать в browser graph или изоморфный route loader.
8
+
9
+ Модуль выделен из проверок хранилища современного приложения. Он не знает доменную сущность, пользователя, БД, environment variables или способ публикации файла. Browser API чтения выбранного `File` остаётся в отдельном `/file`.
10
+
11
+ ## Public API
12
+
13
+ ```ts
14
+ import {
15
+ isFileSystemNotFoundError,
16
+ resolvePathInsideRoot,
17
+ type PathRealpathMode,
18
+ type ResolvePathInsideRootOptions
19
+ } from "@ryuzaki13/react-foundation-lib/file-system";
20
+
21
+ const options = {
22
+ rootPath: "/srv/private-files",
23
+ pathSegments: ["documents", "file.pdf"],
24
+ realpathMode: "parent"
25
+ } satisfies ResolvePathInsideRootOptions;
26
+
27
+ const filePath = await resolvePathInsideRoot(options);
28
+ ```
29
+
30
+ `resolvePathInsideRoot(options)` возвращает `Promise<string>` с нормализованным абсолютным путём. Относительный `rootPath` разрешается от `process.cwd()`, внешние пробелы удаляются, пустая конфигурация отвергается. `pathSegments` принимает readonly-массив. Сам root считается допустимым target, а выход через `..`, абсолютный путь вне root или соседний каталог с похожим именем отвергается.
31
+
32
+ | `PathRealpathMode` | Назначение и условия |
33
+ | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
34
+ | `target` — по умолчанию | Root и target должны существовать. `realpath` проверяет, что фактический target после разрешения символических ссылок остаётся внутри фактического root. |
35
+ | `parent` | Root и непосредственный родитель target должны существовать. Режим нужен перед созданием нового файла; сам target может отсутствовать. Проверяется фактический путь родителя. |
36
+ | `none` | Только лексическая нормализация и проверка границ. Root и target могут отсутствовать. Символические ссылки не проверяются. |
37
+
38
+ Функция возвращает нормализованный исходный путь, а не заменяет его результатом `realpath`. Символическая ссылка внутри root разрешена, если проверяемый фактический путь остаётся внутри root; доверенный root тоже может быть символической ссылкой.
39
+
40
+ ## Ошибки и граница I/O
41
+
42
+ Пустой root, недоступный root и выход за границы приводят к rejected promise. Ошибка доступа к root сохраняет исходную ошибку в `Error.cause`; ошибки `realpath` target или parent передаются вызывающему коду без замены.
43
+
44
+ `isFileSystemNotFoundError(error: unknown): boolean` распознаёт `ENOENT` и `ENOTDIR`. Ошибки доступа, диска или произвольный текст с названием кода не считаются отсутствующим файлом:
45
+
46
+ ```ts
47
+ try {
48
+ await resolvePathInsideRoot({ rootPath: "/srv/private-files", pathSegments: ["documents", "file.pdf"] });
49
+ } catch (error) {
50
+ if (isFileSystemNotFoundError(error)) {
51
+ // Здесь вызывающий сценарий выбирает состояние отсутствующего файла.
52
+ } else {
53
+ throw error;
54
+ }
55
+ }
56
+ ```
57
+
58
+ Проверка пути не открывает файл, не создаёт каталог и не устанавливает filesystem lock. Между проверкой и I/O путь может измениться. Режим `parent` также не проверяет существующий target symlink. Вызывающий storage adapter выбирает безопасные open flags, контролирует владельца каталогов и выполняет фактическое чтение или запись.
59
+
60
+ Загрузка байтов, checksum, MIME validation, staging, публикация, удаление, транзакции и компенсация принадлежат соответствующим владельцам. Этот модуль предоставляет только проверку границ пути и распознавание отсутствующего пути.
61
+
62
+ ## Проверки
63
+
64
+ Тесты используют реальные временные каталоги и символические ссылки: относительный root, dot-сегменты, абсолютные пути, соседние каталоги, parent/target, недоступный root и системные ошибки. Type-test проверяет публичные сигнатуры и readonly-массив сегментов.
@@ -36,16 +36,18 @@ useFetchNextPageEffect({
36
36
  virtualItems,
37
37
  currentItemsCount,
38
38
  fetchNextPage,
39
- hasNextPage
39
+ hasNextPage,
40
+ onError
40
41
  });
41
42
  ```
42
43
 
43
- | Поле | Значение |
44
- | ------------------- | ------------------------------------ |
45
- | `virtualItems` | Текущий массив `VirtualItem[]` |
46
- | `currentItemsCount` | Число реально загруженных data items |
47
- | `hasNextPage` | Есть ли следующая server page |
48
- | `fetchNextPage` | Stable функция, возвращающая Promise |
44
+ | Поле | Значение |
45
+ | ------------------- | ------------------------------------------------------------------- |
46
+ | `virtualItems` | Текущий массив `readonly VirtualItem[]` |
47
+ | `currentItemsCount` | Число реально загруженных data items |
48
+ | `hasNextPage` | Есть ли следующая server page |
49
+ | `fetchNextPage` | Stable функция, возвращающая Promise |
50
+ | `onError` | Необязательный callback `(error: unknown) => void \| Promise<void>` |
49
51
 
50
52
  Hook ничего не возвращает.
51
53
 
@@ -81,25 +83,41 @@ Trigger происходит строго на последнем загруже
81
83
 
82
84
  ## Защита от параллельного fetch
83
85
 
84
- Внутренний `useRef(false)` устанавливается до вызова и сбрасывается в `.finally()`:
86
+ Внутренний single-flight ref устанавливается до вызова и сбрасывается после
87
+ успеха либо завершения обработки ошибки:
85
88
 
86
89
  ```text
87
90
  idle ── trigger ──► fetchingRef=true
88
91
  │
89
92
  Promise pending
90
93
  │
91
- resolve или reject
94
+ resolve или завершение onError
92
95
  ▼
93
96
  fetchingRef=false
94
97
  ```
95
98
 
96
99
  Защита действует только внутри одного mounted hook instance. TanStack Query также должен владеть дедупликацией/network state.
97
100
 
98
- Если `fetchNextPage` отклоняет Promise, hook не добавляет `.catch`; rejection может стать unhandled. Передавайте функцию с принятой error policy или wrapper, который корректно обрабатывает ожидаемую ошибку.
101
+ ## Ошибки и повторная попытка
102
+
103
+ Promise rejection и синхронный throw `fetchNextPage` передаются в `onError`
104
+ один раз. Hook ожидает async callback; его собственный отказ также обрабатывается.
105
+ Без `onError`, при отказе самого callback либо после unmount исходная ошибка
106
+ остаётся в явной `console.error`-диагностике с контекстом дозагрузки. Hook не
107
+ создаёт дополнительный unhandled Promise rejection и не решает, как показывать
108
+ ошибку пользователю: это остаётся у владельца Query и UI.
109
+
110
+ После отказа hook не пытается загрузить ту же страницу при каждом scroll/render.
111
+ Неудачная попытка привязана к `currentItemsCount`: новое количество загруженных
112
+ строк либо явное выключение `hasNextPage` и последующее включение разрешают новую
113
+ попытку. Смена identity `fetchNextPage`, `onError` или `virtualItems` не является
114
+ retry. Изменение данных без изменения количества само по себе latch не снимает;
115
+ явная ручная retry-команда остаётся у владельца Query. Интервалов и собственной
116
+ retry-policy в hook нет.
99
117
 
100
118
  ## Referential stability
101
119
 
102
- Effect зависит от `virtualItems`, `currentItemsCount`, `hasNextPage`, `fetchNextPage`.
120
+ Effect зависит от `virtualItems`, `currentItemsCount`, `hasNextPage`, `fetchNextPage`, `onError`.
103
121
 
104
122
  - Используйте значение virtualizer как предусмотрено его API.
105
123
  - Не создавайте лишний async callback с новой identity без причины.
@@ -110,7 +128,9 @@ Effect зависит от `virtualItems`, `currentItemsCount`, `hasNextPage`, `
110
128
 
111
129
  Сам hook не читает `window`, но является React effect и не выполняет fetch во время server render. В browser effect запускается после commit.
112
130
 
113
- При unmount Promise не отменяется. Abort/cancel должен быть реализован query/transport owner. `fetchingRef` больше не влияет после удаления hook instance.
131
+ При unmount Promise не отменяется. Abort/cancel должен быть реализован query/transport owner.
132
+ Поздний отказ не вызывает `onError` размонтированного UI, но остаётся в явной
133
+ console-диагностике. Уже начатый async `onError` не отменяется, его отказ не теряется.
114
134
 
115
135
  ## Типичные ошибки
116
136
 
@@ -118,17 +138,20 @@ Effect зависит от `virtualItems`, `currentItemsCount`, `hasNextPage`, `
118
138
  - Включить client-side sorting/filtering одной страницы при server-side модели.
119
139
  - Добавить фиктивный loader row в `currentItemsCount`.
120
140
  - Ожидать preload заранее — trigger находится на самом конце.
121
- - Передать rejecting Promise без error handling.
141
+ - Ожидать, что hook сам покажет Query-ошибку или автоматически повторит failed page.
122
142
  - Выполнять fetch прямо в row render вместо effect.
123
143
  - Использовать изменяемый index как domain identity строки.
124
144
 
125
145
  ## API
126
146
 
127
147
  ```ts
128
- function useFetchNextPageEffect(args: {
129
- virtualItems: VirtualItem[];
148
+ type UseFetchNextPageEffectOptions = Readonly<{
149
+ virtualItems: readonly VirtualItem[];
130
150
  currentItemsCount: number;
131
151
  hasNextPage: boolean;
132
152
  fetchNextPage: () => Promise<unknown>;
133
- }): void;
153
+ onError?: (error: unknown) => void | Promise<void>;
154
+ }>;
155
+
156
+ function useFetchNextPageEffect(options: UseFetchNextPageEffectOptions): void;
134
157
  ```