From 85aa54592ae6618a766c13deb7e089d7171e2743 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Tue, 7 Jul 2026 04:11:10 +0700 Subject: [PATCH] feat: add S3 byte range parser --- src/utils/s3/range.ts | 46 +++++++++++++++++++++++++++++++++++++++++++ test/s3-range.test.ts | 39 ++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 src/utils/s3/range.ts create mode 100644 test/s3-range.test.ts diff --git a/src/utils/s3/range.ts b/src/utils/s3/range.ts new file mode 100644 index 0000000..3a1f274 --- /dev/null +++ b/src/utils/s3/range.ts @@ -0,0 +1,46 @@ +export type RangeParseResult = + | { type: 'none' } + | { type: 'valid'; start: number; end: number } + | { type: 'invalid' }; + +const DECIMAL = /^\d+$/; + +export const parseRangeHeader = (rangeHeader: string | null, size: number): RangeParseResult => { + if (!rangeHeader) return { type: 'none' }; + if (!Number.isSafeInteger(size) || size < 0) return { type: 'invalid' }; + if (!rangeHeader.startsWith('bytes=')) return { type: 'invalid' }; + + const spec = rangeHeader.slice('bytes='.length).trim(); + if (spec.includes(',')) return { type: 'invalid' }; + + const dash = spec.indexOf('-'); + if (dash === -1) return { type: 'invalid' }; + + const startText = spec.slice(0, dash).trim(); + const endText = spec.slice(dash + 1).trim(); + if (!startText && !endText) return { type: 'invalid' }; + if (size === 0) return { type: 'invalid' }; + + if (!startText) { + if (!DECIMAL.test(endText)) return { type: 'invalid' }; + const suffixLength = Number.parseInt(endText, 10); + if (suffixLength <= 0) return { type: 'invalid' }; + return { type: 'valid', start: Math.max(size - suffixLength, 0), end: size - 1 }; + } + + if (!DECIMAL.test(startText)) return { type: 'invalid' }; + const start = Number.parseInt(startText, 10); + if (start >= size) return { type: 'invalid' }; + + if (!endText) return { type: 'valid', start, end: size - 1 }; + if (!DECIMAL.test(endText)) return { type: 'invalid' }; + + const requestedEnd = Number.parseInt(endText, 10); + if (requestedEnd < start) return { type: 'invalid' }; + return { type: 'valid', start, end: Math.min(requestedEnd, size - 1) }; +}; + +export const contentRange = (start: number, end: number, size: number): string => + `bytes ${start}-${end}/${size}`; + +export const unsatisfiedContentRange = (size: number): string => `bytes */${size}`; diff --git a/test/s3-range.test.ts b/test/s3-range.test.ts new file mode 100644 index 0000000..a915293 --- /dev/null +++ b/test/s3-range.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'bun:test'; +import { contentRange, parseRangeHeader, unsatisfiedContentRange } from '../src/utils/s3/range'; + +describe('S3 HTTP range parser', () => { + it('returns none when Range is missing', () => { + expect(parseRangeHeader(null, 10)).toEqual({ type: 'none' }); + }); + + it('parses explicit start/end ranges', () => { + expect(parseRangeHeader('bytes=2-5', 10)).toEqual({ type: 'valid', start: 2, end: 5 }); + }); + + it('clamps open-ended ranges to object size', () => { + expect(parseRangeHeader('bytes=7-', 10)).toEqual({ type: 'valid', start: 7, end: 9 }); + }); + + it('parses suffix ranges', () => { + expect(parseRangeHeader('bytes=-4', 10)).toEqual({ type: 'valid', start: 6, end: 9 }); + }); + + it('clamps oversized suffix ranges to the whole object', () => { + expect(parseRangeHeader('bytes=-50', 10)).toEqual({ type: 'valid', start: 0, end: 9 }); + }); + + it('rejects multiple ranges', () => { + expect(parseRangeHeader('bytes=0-1,3-4', 10)).toEqual({ type: 'invalid' }); + }); + + it('rejects unsatisfiable ranges', () => { + expect(parseRangeHeader('bytes=10-12', 10)).toEqual({ type: 'invalid' }); + expect(parseRangeHeader('bytes=6-3', 10)).toEqual({ type: 'invalid' }); + expect(parseRangeHeader('bytes=-0', 10)).toEqual({ type: 'invalid' }); + }); + + it('formats content-range headers', () => { + expect(contentRange(2, 5, 10)).toBe('bytes 2-5/10'); + expect(unsatisfiedContentRange(10)).toBe('bytes */10'); + }); +});