one 2.6.3 → 2.6.5

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/src/cli.ts CHANGED
@@ -498,7 +498,7 @@ const subCommands = {
498
498
  // workaround for having sub-commands but also positional arg for naming in the create flow
499
499
  const subMain = defineCommand({
500
500
  meta: {
501
- name: 'main',
501
+ name: 'one',
502
502
  version: version,
503
503
  description: 'Welcome to One',
504
504
  },
@@ -507,7 +507,7 @@ const subMain = defineCommand({
507
507
 
508
508
  const main = defineCommand({
509
509
  meta: {
510
- name: 'main',
510
+ name: 'one',
511
511
  version: version,
512
512
  description: 'Welcome to One',
513
513
  },
@@ -4,12 +4,13 @@ import { withRange } from './assetRange'
4
4
  const bytes = Uint8Array.from({ length: 100 }, (_, i) => i)
5
5
 
6
6
  // the assets binding streams in chunks; split so ranges cross chunk edges
7
- function asset(headers: Record<string, string> = {}) {
7
+ function asset(headers: Record<string, string> = {}, pulled = { chunks: 0 }) {
8
8
  const chunks = [bytes.slice(0, 30), bytes.slice(30, 70), bytes.slice(70)]
9
9
  const body = new ReadableStream<Uint8Array>({
10
- start(controller) {
11
- for (const chunk of chunks) controller.enqueue(chunk)
12
- controller.close()
10
+ pull(controller) {
11
+ const chunk = chunks[pulled.chunks++]
12
+ if (chunk) controller.enqueue(chunk)
13
+ else controller.close()
13
14
  },
14
15
  })
15
16
  return new Response(body, {
@@ -19,8 +20,8 @@ function asset(headers: Record<string, string> = {}) {
19
20
  })
20
21
  }
21
22
 
22
- function get(range?: string) {
23
- return new Request('https://x.test/a.webm', range ? { headers: { range } } : {})
23
+ function get(range?: string, headers: Record<string, string> = {}) {
24
+ return new Request('https://x.test/a.webm', { headers: range ? { range, ...headers } : headers })
24
25
  }
25
26
 
26
27
  async function body(response: Response) {
@@ -57,10 +58,49 @@ describe('withRange', () => {
57
58
  expect(await body(clamped)).toEqual([98, 99])
58
59
  })
59
60
 
60
- it('answers 416 past the end', async () => {
61
- const res = await withRange(get('bytes=100-'), asset())
62
- expect(res.status).toBe(416)
63
- expect(res.headers.get('content-range')).toBe('bytes */100')
61
+ it('answers 416 past the end without the full length', async () => {
62
+ for (const headers of [{}, { 'content-length': '100' }] as Record<string, string>[]) {
63
+ const res = await withRange(get('bytes=100-'), asset(headers))
64
+ expect(res.status).toBe(416)
65
+ expect(res.headers.get('content-range')).toBe('bytes */100')
66
+ expect(res.headers.get('content-length')).toBeNull()
67
+ expect(await body(res)).toEqual([])
68
+ }
69
+ })
70
+
71
+ it('streams only the requested bytes when the length is known', async () => {
72
+ const pulled = { chunks: 0 }
73
+ const res = await withRange(get('bytes=0-1'), asset({ 'content-length': '100' }, pulled))
74
+ expect(res.status).toBe(206)
75
+ expect(res.headers.get('content-range')).toBe('bytes 0-1/100')
76
+ expect(res.headers.get('content-length')).toBe('2')
77
+ expect(await body(res)).toEqual([0, 1])
78
+ expect(pulled.chunks).toBeLessThan(3)
79
+
80
+ const cross = await withRange(get('bytes=25-74'), asset({ 'content-length': '100' }))
81
+ expect(await body(cross)).toEqual(Array.from(bytes.slice(25, 75)))
82
+
83
+ const suffix = await withRange(get('bytes=-5'), asset({ 'content-length': '100' }))
84
+ expect(suffix.headers.get('content-range')).toBe('bytes 95-99/100')
85
+ expect(await body(suffix)).toEqual([95, 96, 97, 98, 99])
86
+ })
87
+
88
+ it('honors If-Range only for a matching strong validator', async () => {
89
+ const tagged = { etag: '"new"', 'last-modified': 'Mon, 12 Oct 2026 00:00:00 GMT' }
90
+ const fresh = await withRange(get('bytes=0-1', { 'if-range': '"new"' }), asset(tagged))
91
+ expect(fresh.status).toBe(206)
92
+ const date = await withRange(
93
+ get('bytes=0-1', { 'if-range': 'Mon, 12 Oct 2026 00:00:00 GMT' }),
94
+ asset(tagged)
95
+ )
96
+ expect(date.status).toBe(206)
97
+
98
+ for (const ifRange of ['"old"', 'W/"new"', 'Sun, 11 Oct 2026 00:00:00 GMT']) {
99
+ const response = asset(tagged)
100
+ expect(await withRange(get('bytes=0-1', { 'if-range': ifRange }), response)).toBe(response)
101
+ }
102
+ const weak = asset({ etag: 'W/"new"' })
103
+ expect(await withRange(get('bytes=0-1', { 'if-range': 'W/"new"' }), weak)).toBe(weak)
64
104
  })
65
105
 
66
106
  it('keeps the full response without a usable single range', async () => {
@@ -1,15 +1,17 @@
1
1
  // cloudflare's assets binding ignores Range and always answers 200 with the
2
- // whole file and no content-length, which safari refuses for <video>. this
3
- // reads that full response and answers the 206 (or 416) the browser asked
4
- // for. multi-range requests keep the full 200, which http allows.
2
+ // whole file, usually with no content-length, which safari refuses for
3
+ // <video>. this answers the 206 (or 416) the browser asked for. multi-range
4
+ // requests and stale If-Range validators keep the full 200, which http allows.
5
5
  export async function withRange(request: Request, response: Response): Promise<Response> {
6
6
  const range = request.headers.get('range')
7
7
  if (
8
8
  !range ||
9
9
  request.method !== 'GET' ||
10
10
  response.status !== 200 ||
11
+ !response.body ||
11
12
  response.headers.has('content-range') ||
12
- response.headers.has('content-encoding')
13
+ response.headers.has('content-encoding') ||
14
+ !ifRangeMatches(request.headers.get('if-range'), response.headers)
13
15
  ) {
14
16
  return response
15
17
  }
@@ -17,9 +19,43 @@ export async function withRange(request: Request, response: Response): Promise<R
17
19
  const match = /^bytes=(\d*)-(\d*)$/.exec(range.trim())
18
20
  if (!match || (!match[1] && !match[2])) return response
19
21
 
20
- const bytes = new Uint8Array(await response.arrayBuffer())
21
- const size = bytes.byteLength
22
+ const length = response.headers.get('content-length')
23
+ const known = length && /^\d+$/.test(length) ? Number(length) : undefined
22
24
 
25
+ const headers = new Headers(response.headers)
26
+ headers.set('accept-ranges', 'bytes')
27
+ headers.delete('content-length')
28
+
29
+ // only the requested bytes are kept in memory. with a known size reading
30
+ // stops after them; without one the size only comes from reading to the end
31
+ const { size, kept, keptStart } = await readRange(response.body, match, known)
32
+ const span = resolve(match, size)
33
+ if (!span) {
34
+ headers.set('content-range', `bytes */${size}`)
35
+ return new Response(null, { status: 416, headers })
36
+ }
37
+ headers.set('content-range', `bytes ${span.start}-${span.end}/${size}`)
38
+ headers.set('content-length', String(span.end - span.start + 1))
39
+ return new Response(kept.subarray(span.start - keptStart, span.end - keptStart + 1), {
40
+ status: 206,
41
+ headers,
42
+ })
43
+ }
44
+
45
+ // rfc 9110 13.1.5: honor the range only when the validator still matches,
46
+ // using a strong etag comparison or an exact last-modified date
47
+ function ifRangeMatches(ifRange: string | null, headers: Headers) {
48
+ if (!ifRange) return true
49
+ const value = ifRange.trim()
50
+ if (value.startsWith('"')) {
51
+ const etag = headers.get('etag')
52
+ return !!etag && !etag.startsWith('W/') && etag === value
53
+ }
54
+ if (value.startsWith('W/')) return false
55
+ return headers.get('last-modified') === value
56
+ }
57
+
58
+ function resolve(match: RegExpExecArray, size: number) {
23
59
  let start: number
24
60
  let end: number
25
61
  if (!match[1]) {
@@ -30,16 +66,56 @@ export async function withRange(request: Request, response: Response): Promise<R
30
66
  start = Number(match[1])
31
67
  end = match[2] ? Math.min(Number(match[2]), size - 1) : size - 1
32
68
  }
69
+ if (start >= size || start > end) return
70
+ return { start, end }
71
+ }
33
72
 
34
- const headers = new Headers(response.headers)
35
- headers.set('accept-ranges', 'bytes')
73
+ async function readRange(
74
+ body: ReadableStream<Uint8Array>,
75
+ match: RegExpExecArray,
76
+ known: number | undefined
77
+ ) {
78
+ const span = known === undefined ? undefined : (resolve(match, known) ?? { start: known, end: -1 })
79
+ // an unknown-size suffix range keeps a rolling tail of the requested length
80
+ const suffix = !span && !match[1] ? Number(match[2]) : 0
81
+ const from = span ? span.start : match[1] ? Number(match[1]) : 0
82
+ const to = span ? span.end : match[2] && match[1] ? Number(match[2]) : Number.POSITIVE_INFINITY
36
83
 
37
- if (start >= size || start > end) {
38
- headers.set('content-range', `bytes */${size}`)
39
- return new Response(null, { status: 416, headers })
84
+ const chunks: Uint8Array[] = []
85
+ let keptBytes = 0
86
+ let size = 0
87
+ const reader = body.getReader()
88
+ while (known === undefined || size <= to) {
89
+ const { done, value } = await reader.read()
90
+ if (done) break
91
+ const chunkStart = size
92
+ size += value.byteLength
93
+ if (suffix) {
94
+ chunks.push(value)
95
+ keptBytes += value.byteLength
96
+ // drop whole chunks that fall before the rolling tail
97
+ while (chunks.length > 1 && keptBytes - chunks[0].byteLength >= suffix) {
98
+ keptBytes -= chunks.shift()!.byteLength
99
+ }
100
+ continue
101
+ }
102
+ const lo = Math.max(from, chunkStart)
103
+ const hi = Math.min(to + 1, size)
104
+ if (hi > lo) {
105
+ chunks.push(value.subarray(lo - chunkStart, hi - chunkStart))
106
+ keptBytes += hi - lo
107
+ }
108
+ }
109
+ if (known !== undefined) {
110
+ await reader.cancel()
111
+ size = known
40
112
  }
41
113
 
42
- headers.set('content-range', `bytes ${start}-${end}/${size}`)
43
- headers.set('content-length', String(end - start + 1))
44
- return new Response(bytes.subarray(start, end + 1), { status: 206, headers })
114
+ const kept = new Uint8Array(keptBytes)
115
+ let at = 0
116
+ for (const chunk of chunks) {
117
+ kept.set(chunk, at)
118
+ at += chunk.byteLength
119
+ }
120
+ return { size, kept, keptStart: suffix ? size - keptBytes : from }
45
121
  }
@@ -1 +1 @@
1
- {"version":3,"file":"assetRange.d.ts","sourceRoot":"","sources":["../../src/server/assetRange.ts"],"names":[],"mappings":"AAIA,wBAAsB,SAAS,CAAC,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,CAwCvF"}
1
+ {"version":3,"file":"assetRange.d.ts","sourceRoot":"","sources":["../../src/server/assetRange.ts"],"names":[],"mappings":"AAIA,wBAAsB,SAAS,CAAC,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,CAsCvF"}