mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 09:13:24 +00:00
FIX - stabilize CEF media capture and uploads
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { gameViewGeometry } from '@/utils/gameView'
|
||||
import { createGameView, gameViewGeometry } from '@/utils/gameView'
|
||||
|
||||
describe('gameViewGeometry', () => {
|
||||
it('center-crops a widescreen game view for 3:4 portrait output', () => {
|
||||
@@ -60,3 +60,92 @@ describe('gameViewGeometry', () => {
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('createGameView', () => {
|
||||
it('recreates graphics resources and resumes after context restoration', () => {
|
||||
const gl = {
|
||||
ARRAY_BUFFER: 1,
|
||||
CLAMP_TO_EDGE: 2,
|
||||
COLOR_BUFFER_BIT: 4,
|
||||
COMPILE_STATUS: 5,
|
||||
DYNAMIC_DRAW: 6,
|
||||
FLOAT: 7,
|
||||
FRAGMENT_SHADER: 8,
|
||||
LINK_STATUS: 9,
|
||||
MIRRORED_REPEAT: 10,
|
||||
NEAREST: 11,
|
||||
REPEAT: 12,
|
||||
RGBA: 13,
|
||||
STATIC_DRAW: 14,
|
||||
TEXTURE_2D: 15,
|
||||
TEXTURE_MAG_FILTER: 16,
|
||||
TEXTURE_MIN_FILTER: 17,
|
||||
TEXTURE_WRAP_S: 18,
|
||||
TEXTURE_WRAP_T: 19,
|
||||
TRIANGLE_STRIP: 20,
|
||||
UNSIGNED_BYTE: 21,
|
||||
VERTEX_SHADER: 22,
|
||||
attachShader: vi.fn(),
|
||||
bindBuffer: vi.fn(),
|
||||
bindTexture: vi.fn(),
|
||||
bufferData: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
clearColor: vi.fn(),
|
||||
compileShader: vi.fn(),
|
||||
createBuffer: vi.fn(() => ({})),
|
||||
createProgram: vi.fn(() => ({})),
|
||||
createShader: vi.fn(() => ({})),
|
||||
createTexture: vi.fn(() => ({})),
|
||||
deleteBuffer: vi.fn(),
|
||||
deleteProgram: vi.fn(),
|
||||
deleteShader: vi.fn(),
|
||||
deleteTexture: vi.fn(),
|
||||
drawArrays: vi.fn(),
|
||||
enableVertexAttribArray: vi.fn(),
|
||||
finish: vi.fn(),
|
||||
getAttribLocation: vi.fn((_program, name: string) =>
|
||||
name === 'a_position' ? 0 : 1,
|
||||
),
|
||||
getExtension: vi.fn(() => ({ loseContext: vi.fn() })),
|
||||
getProgramInfoLog: vi.fn(() => ''),
|
||||
getProgramParameter: vi.fn(() => true),
|
||||
getShaderInfoLog: vi.fn(() => ''),
|
||||
getShaderParameter: vi.fn(() => true),
|
||||
getUniformLocation: vi.fn(() => ({})),
|
||||
linkProgram: vi.fn(),
|
||||
shaderSource: vi.fn(),
|
||||
texImage2D: vi.fn(),
|
||||
texParameterf: vi.fn(),
|
||||
uniform1i: vi.fn(),
|
||||
useProgram: vi.fn(),
|
||||
vertexAttribPointer: vi.fn(),
|
||||
viewport: vi.fn(),
|
||||
}
|
||||
const canvas = Object.assign(new EventTarget(), {
|
||||
getContext: () => gl,
|
||||
height: 0,
|
||||
width: 0,
|
||||
}) as unknown as HTMLCanvasElement
|
||||
const restored = vi.fn()
|
||||
vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
vi.spyOn(console, 'info').mockImplementation(() => undefined)
|
||||
const view = createGameView(canvas, { onContextRestored: restored })
|
||||
view.resize(540, 720, 1920, 1080, 2)
|
||||
|
||||
const lost = new Event('webglcontextlost', { cancelable: true })
|
||||
canvas.dispatchEvent(lost)
|
||||
expect(lost.defaultPrevented).toBe(true)
|
||||
expect(view.isLost()).toBe(true)
|
||||
|
||||
canvas.dispatchEvent(new Event('webglcontextrestored'))
|
||||
expect(view.isLost()).toBe(false)
|
||||
expect(restored).toHaveBeenCalledOnce()
|
||||
expect(gl.createProgram).toHaveBeenCalledTimes(2)
|
||||
expect(canvas.width).toBe(540)
|
||||
expect(canvas.height).toBe(720)
|
||||
|
||||
view.render()
|
||||
expect(gl.drawArrays).toHaveBeenCalledOnce()
|
||||
view.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
+177
-78
@@ -31,6 +31,8 @@ export interface GameView {
|
||||
}
|
||||
|
||||
export interface GameViewOptions {
|
||||
onContextLost?: () => void
|
||||
onContextRestored?: () => void
|
||||
preserveDrawingBuffer?: boolean
|
||||
}
|
||||
|
||||
@@ -113,80 +115,180 @@ export function createGameView(
|
||||
|
||||
let lost = false
|
||||
let disposed = false
|
||||
let program: WebGLProgram | null = null
|
||||
let positionBuffer: WebGLBuffer | null = null
|
||||
let texcoordBuffer: WebGLBuffer | null = null
|
||||
let texture: WebGLTexture | null = null
|
||||
let lastSize: {
|
||||
height: number
|
||||
sourceHeight: number
|
||||
sourceWidth: number
|
||||
width: number
|
||||
zoom: number
|
||||
} | null = null
|
||||
|
||||
const releaseResources = (): void => {
|
||||
if (positionBuffer) gl.deleteBuffer(positionBuffer)
|
||||
if (texcoordBuffer) gl.deleteBuffer(texcoordBuffer)
|
||||
if (texture) gl.deleteTexture(texture)
|
||||
if (program) gl.deleteProgram(program)
|
||||
positionBuffer = null
|
||||
texcoordBuffer = null
|
||||
texture = null
|
||||
program = null
|
||||
}
|
||||
|
||||
const initializeResources = (): void => {
|
||||
releaseResources()
|
||||
const nextProgram = gl.createProgram()
|
||||
if (!nextProgram) throw new Error('game_view_program_unavailable')
|
||||
const vertexShader = compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER)
|
||||
const fragmentShader = compileShader(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER)
|
||||
gl.attachShader(nextProgram, vertexShader)
|
||||
gl.attachShader(nextProgram, fragmentShader)
|
||||
gl.linkProgram(nextProgram)
|
||||
gl.deleteShader(vertexShader)
|
||||
gl.deleteShader(fragmentShader)
|
||||
if (!gl.getProgramParameter(nextProgram, gl.LINK_STATUS)) {
|
||||
const error = gl.getProgramInfoLog(nextProgram)
|
||||
gl.deleteProgram(nextProgram)
|
||||
throw new Error(error || 'game_view_program_failed')
|
||||
}
|
||||
gl.useProgram(nextProgram)
|
||||
|
||||
const positionLocation = gl.getAttribLocation(nextProgram, 'a_position')
|
||||
const texcoordLocation = gl.getAttribLocation(nextProgram, 'a_texcoord')
|
||||
if (positionLocation < 0 || texcoordLocation < 0) {
|
||||
gl.deleteProgram(nextProgram)
|
||||
throw new Error('game_view_attributes_unavailable')
|
||||
}
|
||||
|
||||
const nextPositionBuffer = gl.createBuffer()
|
||||
const nextTexcoordBuffer = gl.createBuffer()
|
||||
const nextTexture = gl.createTexture()
|
||||
if (!nextPositionBuffer || !nextTexcoordBuffer || !nextTexture) {
|
||||
if (nextPositionBuffer) gl.deleteBuffer(nextPositionBuffer)
|
||||
if (nextTexcoordBuffer) gl.deleteBuffer(nextTexcoordBuffer)
|
||||
if (nextTexture) gl.deleteTexture(nextTexture)
|
||||
gl.deleteProgram(nextProgram)
|
||||
throw new Error('game_view_resources_unavailable')
|
||||
}
|
||||
|
||||
program = nextProgram
|
||||
positionBuffer = nextPositionBuffer
|
||||
texcoordBuffer = nextTexcoordBuffer
|
||||
texture = nextTexture
|
||||
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer)
|
||||
gl.bufferData(
|
||||
gl.ARRAY_BUFFER,
|
||||
new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]),
|
||||
gl.DYNAMIC_DRAW,
|
||||
)
|
||||
gl.enableVertexAttribArray(positionLocation)
|
||||
gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0)
|
||||
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, texcoordBuffer)
|
||||
gl.bufferData(
|
||||
gl.ARRAY_BUFFER,
|
||||
new Float32Array([0, 0, 1, 0, 0, 1, 1, 1]),
|
||||
gl.STATIC_DRAW,
|
||||
)
|
||||
gl.enableVertexAttribArray(texcoordLocation)
|
||||
gl.vertexAttribPointer(texcoordLocation, 2, gl.FLOAT, false, 0, 0)
|
||||
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture)
|
||||
gl.texImage2D(
|
||||
gl.TEXTURE_2D,
|
||||
0,
|
||||
gl.RGBA,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
gl.RGBA,
|
||||
gl.UNSIGNED_BYTE,
|
||||
new Uint8Array([0, 0, 0, 255]),
|
||||
)
|
||||
gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST)
|
||||
gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST)
|
||||
gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
|
||||
// CitizenFX watches this exact wrap-mode sequence and replaces the seeded pixel with the live
|
||||
// game backbuffer. These calls are intentionally not redundant.
|
||||
gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
|
||||
gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.MIRRORED_REPEAT)
|
||||
gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT)
|
||||
gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
|
||||
gl.uniform1i(gl.getUniformLocation(program, 'u_texture'), 0)
|
||||
gl.clearColor(0, 0, 0, 1)
|
||||
}
|
||||
|
||||
const applySize = (): void => {
|
||||
if (!lastSize || !positionBuffer || !texcoordBuffer) return
|
||||
const geometry = gameViewGeometry(
|
||||
lastSize.sourceWidth,
|
||||
lastSize.sourceHeight,
|
||||
lastSize.width,
|
||||
lastSize.height,
|
||||
lastSize.zoom,
|
||||
)
|
||||
canvas.width = lastSize.width
|
||||
canvas.height = lastSize.height
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer)
|
||||
gl.bufferData(gl.ARRAY_BUFFER, geometry.positions, gl.DYNAMIC_DRAW)
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, texcoordBuffer)
|
||||
gl.bufferData(
|
||||
gl.ARRAY_BUFFER,
|
||||
geometry.textureCoordinates,
|
||||
gl.DYNAMIC_DRAW,
|
||||
)
|
||||
gl.viewport(0, 0, lastSize.width, lastSize.height)
|
||||
}
|
||||
|
||||
const onContextLost = (event: Event) => {
|
||||
event.preventDefault()
|
||||
lost = true
|
||||
console.error('[Camera] Game-view WebGL context lost.')
|
||||
options.onContextLost?.()
|
||||
}
|
||||
const onContextRestored = () => {
|
||||
if (disposed) return
|
||||
try {
|
||||
initializeResources()
|
||||
lost = false
|
||||
applySize()
|
||||
console.info('[Camera] Game-view WebGL context restored.')
|
||||
options.onContextRestored?.()
|
||||
} catch (error) {
|
||||
lost = true
|
||||
console.error('[Camera] Could not restore the game-view WebGL context.', error)
|
||||
}
|
||||
}
|
||||
canvas.addEventListener(
|
||||
'webglcontextlost',
|
||||
onContextLost as EventListener,
|
||||
false,
|
||||
)
|
||||
|
||||
const program = gl.createProgram()
|
||||
if (!program) throw new Error('game_view_program_unavailable')
|
||||
gl.attachShader(program, compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER))
|
||||
gl.attachShader(
|
||||
program,
|
||||
compileShader(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER),
|
||||
canvas.addEventListener(
|
||||
'webglcontextrestored',
|
||||
onContextRestored as EventListener,
|
||||
false,
|
||||
)
|
||||
gl.linkProgram(program)
|
||||
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
||||
throw new Error(gl.getProgramInfoLog(program) || 'game_view_program_failed')
|
||||
try {
|
||||
initializeResources()
|
||||
} catch (error) {
|
||||
canvas.removeEventListener(
|
||||
'webglcontextlost',
|
||||
onContextLost as EventListener,
|
||||
false,
|
||||
)
|
||||
canvas.removeEventListener(
|
||||
'webglcontextrestored',
|
||||
onContextRestored as EventListener,
|
||||
false,
|
||||
)
|
||||
releaseResources()
|
||||
throw error
|
||||
}
|
||||
gl.useProgram(program)
|
||||
|
||||
const positionLocation = gl.getAttribLocation(program, 'a_position')
|
||||
const texcoordLocation = gl.getAttribLocation(program, 'a_texcoord')
|
||||
if (positionLocation < 0 || texcoordLocation < 0) {
|
||||
throw new Error('game_view_attributes_unavailable')
|
||||
}
|
||||
|
||||
const positionBuffer = gl.createBuffer()
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer)
|
||||
gl.bufferData(
|
||||
gl.ARRAY_BUFFER,
|
||||
new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]),
|
||||
gl.DYNAMIC_DRAW,
|
||||
)
|
||||
gl.enableVertexAttribArray(positionLocation)
|
||||
gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0)
|
||||
|
||||
const texcoordBuffer = gl.createBuffer()
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, texcoordBuffer)
|
||||
gl.bufferData(
|
||||
gl.ARRAY_BUFFER,
|
||||
new Float32Array([0, 0, 1, 0, 0, 1, 1, 1]),
|
||||
gl.STATIC_DRAW,
|
||||
)
|
||||
gl.enableVertexAttribArray(texcoordLocation)
|
||||
gl.vertexAttribPointer(texcoordLocation, 2, gl.FLOAT, false, 0, 0)
|
||||
|
||||
const texture = gl.createTexture()
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture)
|
||||
gl.texImage2D(
|
||||
gl.TEXTURE_2D,
|
||||
0,
|
||||
gl.RGBA,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
gl.RGBA,
|
||||
gl.UNSIGNED_BYTE,
|
||||
new Uint8Array([0, 0, 0, 255]),
|
||||
)
|
||||
gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST)
|
||||
gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST)
|
||||
gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
|
||||
// CitizenFX watches this exact wrap-mode sequence and replaces the seeded pixel with the live
|
||||
// game backbuffer. These calls are intentionally not redundant.
|
||||
gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
|
||||
gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.MIRRORED_REPEAT)
|
||||
gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT)
|
||||
gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
|
||||
gl.uniform1i(gl.getUniformLocation(program, 'u_texture'), 0)
|
||||
gl.clearColor(0, 0, 0, 1)
|
||||
|
||||
return {
|
||||
canvas,
|
||||
@@ -198,11 +300,18 @@ export function createGameView(
|
||||
onContextLost as EventListener,
|
||||
false,
|
||||
)
|
||||
canvas.removeEventListener(
|
||||
'webglcontextrestored',
|
||||
onContextRestored as EventListener,
|
||||
false,
|
||||
)
|
||||
if (!lost) releaseResources()
|
||||
gl.getExtension('WEBGL_lose_context')?.loseContext()
|
||||
},
|
||||
isLost: () => lost,
|
||||
render() {
|
||||
if (disposed || lost) return
|
||||
if (disposed || lost || !program) return
|
||||
gl.useProgram(program)
|
||||
gl.clear(gl.COLOR_BUFFER_BIT)
|
||||
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4)
|
||||
gl.finish()
|
||||
@@ -214,25 +323,15 @@ export function createGameView(
|
||||
sourceHeight = window.innerHeight,
|
||||
zoom = 1,
|
||||
) {
|
||||
if (disposed || lost) return
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
const geometry = gameViewGeometry(
|
||||
lastSize = {
|
||||
height,
|
||||
sourceWidth,
|
||||
sourceHeight,
|
||||
width,
|
||||
height,
|
||||
zoom,
|
||||
)
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer)
|
||||
gl.bufferData(gl.ARRAY_BUFFER, geometry.positions, gl.DYNAMIC_DRAW)
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, texcoordBuffer)
|
||||
gl.bufferData(
|
||||
gl.ARRAY_BUFFER,
|
||||
geometry.textureCoordinates,
|
||||
gl.DYNAMIC_DRAW,
|
||||
)
|
||||
gl.viewport(0, 0, width, height)
|
||||
}
|
||||
if (disposed || lost) return
|
||||
applySize()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
bindMediaRecorderError,
|
||||
setBoundedMapEntry,
|
||||
stopMediaRecorder,
|
||||
} from '@/utils/mediaRecorder'
|
||||
|
||||
class FakeRecorder extends EventTarget {
|
||||
state: RecordingState = 'recording'
|
||||
stop = vi.fn(() => {
|
||||
this.state = 'inactive'
|
||||
this.dispatchEvent(new Event('stop'))
|
||||
})
|
||||
}
|
||||
|
||||
describe('media recorder lifecycle', () => {
|
||||
it('resolves from the recorder stop event', async () => {
|
||||
const recorder = new FakeRecorder()
|
||||
|
||||
await stopMediaRecorder(recorder as unknown as MediaRecorder)
|
||||
|
||||
expect(recorder.stop).toHaveBeenCalledOnce()
|
||||
expect(recorder.state).toBe('inactive')
|
||||
})
|
||||
|
||||
it('runs recorder error cleanup only for the current generation', () => {
|
||||
const staleRecorder = new FakeRecorder()
|
||||
const currentRecorder = new FakeRecorder()
|
||||
const cleanup = vi.fn()
|
||||
let generation = 1
|
||||
const unbindStale = bindMediaRecorderError(
|
||||
staleRecorder as unknown as MediaRecorder,
|
||||
() => generation === 1,
|
||||
cleanup,
|
||||
)
|
||||
|
||||
generation = 2
|
||||
staleRecorder.dispatchEvent(new Event('error'))
|
||||
expect(cleanup).not.toHaveBeenCalled()
|
||||
unbindStale()
|
||||
|
||||
bindMediaRecorderError(
|
||||
currentRecorder as unknown as MediaRecorder,
|
||||
() => generation === 2,
|
||||
cleanup,
|
||||
)
|
||||
currentRecorder.dispatchEvent(new Event('error'))
|
||||
currentRecorder.dispatchEvent(new Event('error'))
|
||||
|
||||
expect(cleanup).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps pending recording buffers bounded and evicts the oldest', () => {
|
||||
const pending = new Map<string, number>()
|
||||
|
||||
setBoundedMapEntry(pending, 'first', 1, 2)
|
||||
setBoundedMapEntry(pending, 'second', 2, 2)
|
||||
setBoundedMapEntry(pending, 'third', 3, 2)
|
||||
|
||||
expect([...pending.entries()]).toEqual([
|
||||
['second', 2],
|
||||
['third', 3],
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
export function bindMediaRecorderError(
|
||||
recorder: MediaRecorder,
|
||||
isCurrent: () => boolean,
|
||||
onError: (event: Event) => void,
|
||||
): () => void {
|
||||
let bound = true
|
||||
const handleError = (event: Event): void => {
|
||||
if (!bound || !isCurrent()) return
|
||||
bound = false
|
||||
recorder.removeEventListener('error', handleError)
|
||||
onError(event)
|
||||
}
|
||||
recorder.addEventListener('error', handleError)
|
||||
return () => {
|
||||
if (!bound) return
|
||||
bound = false
|
||||
recorder.removeEventListener('error', handleError)
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopMediaRecorder(recorder: MediaRecorder): Promise<void> {
|
||||
if (recorder.state === 'inactive') return
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const cleanup = (): void => {
|
||||
recorder.removeEventListener('stop', onStop)
|
||||
recorder.removeEventListener('error', onError)
|
||||
}
|
||||
const onStop = (): void => {
|
||||
cleanup()
|
||||
resolve()
|
||||
}
|
||||
const onError = (): void => {
|
||||
cleanup()
|
||||
reject(new Error('media_recorder_stop_failed'))
|
||||
}
|
||||
|
||||
recorder.addEventListener('stop', onStop, { once: true })
|
||||
recorder.addEventListener('error', onError, { once: true })
|
||||
try {
|
||||
recorder.stop()
|
||||
} catch (error) {
|
||||
cleanup()
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function setBoundedMapEntry<Key, Value>(
|
||||
entries: Map<Key, Value>,
|
||||
key: Key,
|
||||
value: Value,
|
||||
maximumSize: number,
|
||||
): void {
|
||||
entries.delete(key)
|
||||
entries.set(key, value)
|
||||
while (entries.size > Math.max(0, maximumSize)) {
|
||||
const oldest = entries.keys().next()
|
||||
if (oldest.done) break
|
||||
entries.delete(oldest.value)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user