@strav/http 0.1.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.
Files changed (46) hide show
  1. package/package.json +50 -0
  2. package/src/auth/access_token.ts +122 -0
  3. package/src/auth/auth.ts +87 -0
  4. package/src/auth/index.ts +7 -0
  5. package/src/auth/middleware/authenticate.ts +64 -0
  6. package/src/auth/middleware/csrf.ts +62 -0
  7. package/src/auth/middleware/guest.ts +46 -0
  8. package/src/http/context.ts +220 -0
  9. package/src/http/cookie.ts +59 -0
  10. package/src/http/cors.ts +163 -0
  11. package/src/http/index.ts +18 -0
  12. package/src/http/middleware.ts +39 -0
  13. package/src/http/rate_limit.ts +173 -0
  14. package/src/http/resource.ts +102 -0
  15. package/src/http/router.ts +556 -0
  16. package/src/http/server.ts +159 -0
  17. package/src/index.ts +7 -0
  18. package/src/middleware/http_cache.ts +106 -0
  19. package/src/middleware/i18n.ts +84 -0
  20. package/src/middleware/request_logger.ts +19 -0
  21. package/src/policy/authorize.ts +44 -0
  22. package/src/policy/index.ts +3 -0
  23. package/src/policy/policy_result.ts +13 -0
  24. package/src/providers/auth_provider.ts +35 -0
  25. package/src/providers/http_provider.ts +27 -0
  26. package/src/providers/index.ts +7 -0
  27. package/src/providers/session_provider.ts +29 -0
  28. package/src/providers/view_provider.ts +18 -0
  29. package/src/session/index.ts +4 -0
  30. package/src/session/middleware.ts +46 -0
  31. package/src/session/session.ts +308 -0
  32. package/src/session/session_manager.ts +83 -0
  33. package/src/validation/index.ts +18 -0
  34. package/src/validation/rules.ts +170 -0
  35. package/src/validation/validate.ts +41 -0
  36. package/src/view/cache.ts +47 -0
  37. package/src/view/client/islands.ts +84 -0
  38. package/src/view/compiler.ts +199 -0
  39. package/src/view/engine.ts +139 -0
  40. package/src/view/escape.ts +14 -0
  41. package/src/view/index.ts +13 -0
  42. package/src/view/islands/island_builder.ts +338 -0
  43. package/src/view/islands/vue_plugin.ts +136 -0
  44. package/src/view/middleware/static.ts +69 -0
  45. package/src/view/tokenizer.ts +182 -0
  46. package/tsconfig.json +5 -0
@@ -0,0 +1,308 @@
1
+ import type Context from '../http/context.ts'
2
+ import { clearCookie } from '../http/cookie.ts'
3
+ import { randomHex } from '@stravigor/kernel/helpers/crypto'
4
+ import { extractUserId } from '@stravigor/database/helpers/identity'
5
+ import SessionManager from './session_manager.ts'
6
+
7
+ const FLASH_KEY = '_flash'
8
+ const FLASH_OLD_KEY = '_flash_old'
9
+
10
+ /**
11
+ * Unified server-side session backed by a database row and an HTTP-only cookie.
12
+ *
13
+ * Serves both anonymous visitors and authenticated users. Stores arbitrary
14
+ * key-value data in a JSONB column and supports flash data (available only
15
+ * on the next request).
16
+ *
17
+ * @example
18
+ * // Read / write data (anonymous or authenticated)
19
+ * const session = ctx.get<Session>('session')
20
+ * session.set('cart', [item])
21
+ * session.flash('success', 'Item added!')
22
+ *
23
+ * // Login
24
+ * session.authenticate(user)
25
+ * await session.regenerate()
26
+ *
27
+ * // Logout
28
+ * return Session.destroy(ctx, ctx.redirect('/login'))
29
+ */
30
+ export default class Session {
31
+ private _id: string
32
+ private _userId: string | null
33
+ private _csrfToken: string
34
+ private _data: Record<string, unknown>
35
+ private _dirty = false
36
+
37
+ constructor(
38
+ id: string,
39
+ userId: string | null,
40
+ csrfToken: string,
41
+ data: Record<string, unknown>,
42
+ readonly ipAddress: string | null,
43
+ readonly userAgent: string | null,
44
+ readonly lastActivity: Date,
45
+ readonly createdAt: Date
46
+ ) {
47
+ this._id = id
48
+ this._userId = userId
49
+ this._csrfToken = csrfToken
50
+ this._data = data
51
+ }
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Getters
55
+ // ---------------------------------------------------------------------------
56
+
57
+ get id(): string {
58
+ return this._id
59
+ }
60
+
61
+ get userId(): string | null {
62
+ return this._userId
63
+ }
64
+
65
+ get csrfToken(): string {
66
+ return this._csrfToken
67
+ }
68
+
69
+ get isAuthenticated(): boolean {
70
+ return this._userId !== null
71
+ }
72
+
73
+ get isDirty(): boolean {
74
+ return this._dirty
75
+ }
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // Data bag
79
+ // ---------------------------------------------------------------------------
80
+
81
+ /** Get a value from the session data. */
82
+ get<T = unknown>(key: string, defaultValue?: T): T {
83
+ return (this._data[key] as T) ?? (defaultValue as T)
84
+ }
85
+
86
+ /** Set a persistent session value. */
87
+ set(key: string, value: unknown): void {
88
+ this._data[key] = value
89
+ this._dirty = true
90
+ }
91
+
92
+ /** Check whether a key exists in the session data. */
93
+ has(key: string): boolean {
94
+ return key in this._data && !key.startsWith('_flash')
95
+ }
96
+
97
+ /** Remove a key from the session data. */
98
+ forget(key: string): void {
99
+ delete this._data[key]
100
+ this._dirty = true
101
+ }
102
+
103
+ /** Remove all session data (keeps the session row alive). */
104
+ flush(): void {
105
+ this._data = {}
106
+ this._dirty = true
107
+ }
108
+
109
+ /** Return all user-facing session data (excludes flash internals). */
110
+ all(): Record<string, unknown> {
111
+ const result: Record<string, unknown> = {}
112
+ for (const [key, value] of Object.entries(this._data)) {
113
+ if (!key.startsWith('_flash')) {
114
+ result[key] = value
115
+ }
116
+ }
117
+ return result
118
+ }
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // Flash data
122
+ // ---------------------------------------------------------------------------
123
+
124
+ /** Set flash data that will be available only on the next request. */
125
+ flash(key: string, value: unknown): void {
126
+ const bag = (this._data[FLASH_KEY] ?? {}) as Record<string, unknown>
127
+ bag[key] = value
128
+ this._data[FLASH_KEY] = bag
129
+ this._dirty = true
130
+ }
131
+
132
+ /** Get flash data set by the previous request. */
133
+ getFlash<T = unknown>(key: string): T | undefined {
134
+ const old = this._data[FLASH_OLD_KEY] as Record<string, unknown> | undefined
135
+ return old?.[key] as T | undefined
136
+ }
137
+
138
+ /** Check if there is flash data for the given key (from previous request). */
139
+ hasFlash(key: string): boolean {
140
+ const old = this._data[FLASH_OLD_KEY] as Record<string, unknown> | undefined
141
+ return old !== undefined && key in old
142
+ }
143
+
144
+ /**
145
+ * Rotate flash data for the next request cycle. Called once per request
146
+ * by the session middleware before the handler runs.
147
+ *
148
+ * Moves `_flash` → `_flash_old` (readable this request), then clears `_flash`.
149
+ * Only marks dirty if there was actually flash data to rotate.
150
+ */
151
+ ageFlash(): void {
152
+ const current = this._data[FLASH_KEY]
153
+ const old = this._data[FLASH_OLD_KEY]
154
+
155
+ if (current !== undefined || old !== undefined) {
156
+ this._data[FLASH_OLD_KEY] = current ?? {}
157
+ delete this._data[FLASH_KEY]
158
+ this._dirty = true
159
+ }
160
+ }
161
+
162
+ // ---------------------------------------------------------------------------
163
+ // Authentication
164
+ // ---------------------------------------------------------------------------
165
+
166
+ /** Associate this session with a user (login). */
167
+ authenticate(user: unknown): void {
168
+ this._userId = extractUserId(user)
169
+ this._dirty = true
170
+ }
171
+
172
+ /** Disassociate the user from this session. */
173
+ clearUser(): void {
174
+ this._userId = null
175
+ this._dirty = true
176
+ }
177
+
178
+ // ---------------------------------------------------------------------------
179
+ // Lifecycle
180
+ // ---------------------------------------------------------------------------
181
+
182
+ /** Whether this session has exceeded its configured lifetime. */
183
+ isExpired(): boolean {
184
+ const lifetimeMs = SessionManager.config.lifetime * 60_000
185
+ return Date.now() - this.lastActivity.getTime() > lifetimeMs
186
+ }
187
+
188
+ /** Update the last_activity timestamp to keep the session alive. */
189
+ async touch(): Promise<void> {
190
+ await SessionManager.db.sql`
191
+ UPDATE "_strav_sessions"
192
+ SET "last_activity" = NOW()
193
+ WHERE "id" = ${this._id}
194
+ `
195
+ }
196
+
197
+ /**
198
+ * Regenerate the session ID and CSRF token. Use after login to
199
+ * prevent session fixation attacks.
200
+ */
201
+ async regenerate(): Promise<void> {
202
+ const oldId = this._id
203
+ this._id = crypto.randomUUID()
204
+ this._csrfToken = randomHex(32)
205
+ this._dirty = true
206
+
207
+ await this.save()
208
+ await SessionManager.db.sql`
209
+ DELETE FROM "_strav_sessions" WHERE "id" = ${oldId}
210
+ `
211
+ }
212
+
213
+ /**
214
+ * Persist session data to the database. Uses an upsert so both new
215
+ * and existing sessions go through the same code path.
216
+ * No-op if the session has not been modified.
217
+ */
218
+ async save(): Promise<void> {
219
+ if (!this._dirty) return
220
+
221
+ const dataToSave = { ...this._data }
222
+ delete dataToSave[FLASH_OLD_KEY]
223
+
224
+ await SessionManager.db.sql`
225
+ INSERT INTO "_strav_sessions"
226
+ ("id", "user_id", "csrf_token", "data", "ip_address", "user_agent", "last_activity")
227
+ VALUES
228
+ (${this._id}, ${this._userId}, ${this._csrfToken},
229
+ ${JSON.stringify(dataToSave)}::jsonb, ${this.ipAddress}, ${this.userAgent}, NOW())
230
+ ON CONFLICT ("id") DO UPDATE SET
231
+ "user_id" = EXCLUDED."user_id",
232
+ "csrf_token" = EXCLUDED."csrf_token",
233
+ "data" = EXCLUDED."data",
234
+ "last_activity" = NOW()
235
+ `
236
+ this._dirty = false
237
+ }
238
+
239
+ // ---------------------------------------------------------------------------
240
+ // Static API
241
+ // ---------------------------------------------------------------------------
242
+
243
+ /** Create a new anonymous session (not yet persisted — call save() or let the middleware handle it). */
244
+ static create(ctx: Context): Session {
245
+ const id = crypto.randomUUID()
246
+ const csrfToken = randomHex(32)
247
+ const ipAddress = ctx.header('x-forwarded-for') ?? null
248
+ const userAgent = ctx.header('user-agent') ?? null
249
+ const now = new Date()
250
+
251
+ const session = new Session(id, null, csrfToken, {}, ipAddress, userAgent, now, now)
252
+ session._dirty = true
253
+ return session
254
+ }
255
+
256
+ /** Look up a session by ID. Returns null if not found. */
257
+ static async find(id: string): Promise<Session | null> {
258
+ const rows = await SessionManager.db.sql`
259
+ SELECT * FROM "_strav_sessions" WHERE "id" = ${id} LIMIT 1
260
+ `
261
+ if (rows.length === 0) return null
262
+ return Session.hydrate(rows[0] as Record<string, unknown>)
263
+ }
264
+
265
+ /** Read the session cookie from the request and look up the session. */
266
+ static async fromRequest(ctx: Context): Promise<Session | null> {
267
+ const sessionId = ctx.cookie(SessionManager.config.cookie)
268
+ if (!sessionId) return null
269
+ return Session.find(sessionId)
270
+ }
271
+
272
+ /** Delete the session from the database and clear the cookie on the response. */
273
+ static async destroy(ctx: Context, response: Response): Promise<Response> {
274
+ const cfg = SessionManager.config
275
+ const sessionId = ctx.cookie(cfg.cookie)
276
+
277
+ if (sessionId) {
278
+ await SessionManager.db.sql`
279
+ DELETE FROM "_strav_sessions" WHERE "id" = ${sessionId}
280
+ `
281
+ }
282
+
283
+ return clearCookie(response, cfg.cookie, { path: '/' })
284
+ }
285
+
286
+ // ---------------------------------------------------------------------------
287
+ // Internal
288
+ // ---------------------------------------------------------------------------
289
+
290
+ private static hydrate(row: Record<string, unknown>): Session {
291
+ const rawData = row.data
292
+ const data: Record<string, unknown> =
293
+ typeof rawData === 'string'
294
+ ? JSON.parse(rawData)
295
+ : ((rawData as Record<string, unknown>) ?? {})
296
+
297
+ return new Session(
298
+ row.id as string,
299
+ (row.user_id as string) ?? null,
300
+ row.csrf_token as string,
301
+ data,
302
+ (row.ip_address as string) ?? null,
303
+ (row.user_agent as string) ?? null,
304
+ row.last_activity as Date,
305
+ row.created_at as Date
306
+ )
307
+ }
308
+ }
@@ -0,0 +1,83 @@
1
+ import { inject } from '@stravigor/kernel/core/inject'
2
+ import { ConfigurationError } from '@stravigor/kernel/exceptions/errors'
3
+ import Configuration from '@stravigor/kernel/config/configuration'
4
+ import Database from '@stravigor/database/database/database'
5
+
6
+ export interface SessionConfig {
7
+ cookie: string
8
+ lifetime: number
9
+ httpOnly: boolean
10
+ secure: boolean
11
+ sameSite: 'strict' | 'lax' | 'none'
12
+ }
13
+
14
+ /**
15
+ * Central session configuration hub.
16
+ *
17
+ * Resolved once via the DI container — stores the database reference
18
+ * and parsed config for Session and the session middleware.
19
+ *
20
+ * @example
21
+ * app.singleton(SessionManager)
22
+ * app.resolve(SessionManager)
23
+ * await SessionManager.ensureTable()
24
+ */
25
+ @inject
26
+ export default class SessionManager {
27
+ private static _db: Database
28
+ private static _config: SessionConfig
29
+
30
+ constructor(db: Database, config: Configuration) {
31
+ SessionManager._db = db
32
+ SessionManager._config = {
33
+ cookie: 'strav_session',
34
+ lifetime: 120,
35
+ httpOnly: true,
36
+ secure: true,
37
+ sameSite: 'lax',
38
+ ...(config.get('session', {}) as object),
39
+ }
40
+ }
41
+
42
+ static get db(): Database {
43
+ if (!SessionManager._db) {
44
+ throw new ConfigurationError(
45
+ 'SessionManager not configured. Resolve it through the container first.'
46
+ )
47
+ }
48
+ return SessionManager._db
49
+ }
50
+
51
+ static get config(): SessionConfig {
52
+ return SessionManager._config
53
+ }
54
+
55
+ /** Create the sessions table if it does not exist. */
56
+ static async ensureTable(): Promise<void> {
57
+ await SessionManager.db.sql`
58
+ CREATE TABLE IF NOT EXISTS "_strav_sessions" (
59
+ "id" UUID PRIMARY KEY,
60
+ "user_id" VARCHAR(255),
61
+ "csrf_token" VARCHAR(64) NOT NULL,
62
+ "data" JSONB NOT NULL DEFAULT '{}',
63
+ "ip_address" VARCHAR(45),
64
+ "user_agent" TEXT,
65
+ "last_activity" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
66
+ "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW()
67
+ )
68
+ `
69
+ }
70
+
71
+ /** Delete all expired sessions. Call periodically for housekeeping. */
72
+ static async gc(): Promise<number> {
73
+ const lifetimeMs = SessionManager.config.lifetime * 60_000
74
+ const cutoff = new Date(Date.now() - lifetimeMs)
75
+
76
+ const rows = await SessionManager.db.sql`
77
+ DELETE FROM "_strav_sessions"
78
+ WHERE "last_activity" < ${cutoff}
79
+ RETURNING "id"
80
+ `
81
+ return rows.length
82
+ }
83
+ }
@@ -0,0 +1,18 @@
1
+ export { validate } from './validate.ts'
2
+ export {
3
+ required,
4
+ string,
5
+ integer,
6
+ number,
7
+ boolean,
8
+ min,
9
+ max,
10
+ email,
11
+ url,
12
+ regex,
13
+ enumOf,
14
+ oneOf,
15
+ array,
16
+ } from './rules.ts'
17
+ export type { Rule } from './rules.ts'
18
+ export type { RuleSet, ValidationResult } from './validate.ts'
@@ -0,0 +1,170 @@
1
+ import { t } from '@stravigor/kernel/i18n/helpers'
2
+
3
+ export interface Rule {
4
+ name: string
5
+ validate(value: unknown): string | null
6
+ }
7
+
8
+ export function required(): Rule {
9
+ return {
10
+ name: 'required',
11
+ validate(value) {
12
+ if (value === undefined || value === null || value === '') {
13
+ return t('validation.required')
14
+ }
15
+ return null
16
+ },
17
+ }
18
+ }
19
+
20
+ export function string(): Rule {
21
+ return {
22
+ name: 'string',
23
+ validate(value) {
24
+ if (value === undefined || value === null) return null
25
+ if (typeof value !== 'string') return t('validation.string')
26
+ return null
27
+ },
28
+ }
29
+ }
30
+
31
+ export function integer(): Rule {
32
+ return {
33
+ name: 'integer',
34
+ validate(value) {
35
+ if (value === undefined || value === null) return null
36
+ if (typeof value !== 'number' || !Number.isInteger(value)) return t('validation.integer')
37
+ return null
38
+ },
39
+ }
40
+ }
41
+
42
+ export function number(): Rule {
43
+ return {
44
+ name: 'number',
45
+ validate(value) {
46
+ if (value === undefined || value === null) return null
47
+ if (typeof value !== 'number' || isNaN(value)) return t('validation.number')
48
+ return null
49
+ },
50
+ }
51
+ }
52
+
53
+ export function boolean(): Rule {
54
+ return {
55
+ name: 'boolean',
56
+ validate(value) {
57
+ if (value === undefined || value === null) return null
58
+ if (typeof value !== 'boolean') return t('validation.boolean')
59
+ return null
60
+ },
61
+ }
62
+ }
63
+
64
+ export function min(n: number): Rule {
65
+ return {
66
+ name: 'min',
67
+ validate(value) {
68
+ if (value === undefined || value === null) return null
69
+ if (typeof value === 'number') {
70
+ if (value < n) return t('validation.min.number', { min: n })
71
+ } else if (typeof value === 'string') {
72
+ if (value.length < n) return t('validation.min.string', { min: n })
73
+ }
74
+ return null
75
+ },
76
+ }
77
+ }
78
+
79
+ export function max(n: number): Rule {
80
+ return {
81
+ name: 'max',
82
+ validate(value) {
83
+ if (value === undefined || value === null) return null
84
+ if (typeof value === 'number') {
85
+ if (value > n) return t('validation.max.number', { max: n })
86
+ } else if (typeof value === 'string') {
87
+ if (value.length > n) return t('validation.max.string', { max: n })
88
+ }
89
+ return null
90
+ },
91
+ }
92
+ }
93
+
94
+ export function email(): Rule {
95
+ return {
96
+ name: 'email',
97
+ validate(value) {
98
+ if (value === undefined || value === null) return null
99
+ if (typeof value !== 'string') return t('validation.string')
100
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return t('validation.email')
101
+ return null
102
+ },
103
+ }
104
+ }
105
+
106
+ export function url(): Rule {
107
+ return {
108
+ name: 'url',
109
+ validate(value) {
110
+ if (value === undefined || value === null) return null
111
+ if (typeof value !== 'string') return t('validation.string')
112
+ try {
113
+ new URL(value)
114
+ return null
115
+ } catch {
116
+ return t('validation.url')
117
+ }
118
+ },
119
+ }
120
+ }
121
+
122
+ export function regex(pattern: RegExp): Rule {
123
+ return {
124
+ name: 'regex',
125
+ validate(value) {
126
+ if (value === undefined || value === null) return null
127
+ if (typeof value !== 'string') return t('validation.string')
128
+ if (!pattern.test(value)) return t('validation.regex')
129
+ return null
130
+ },
131
+ }
132
+ }
133
+
134
+ export function enumOf(enumObj: Record<string, string | number>): Rule {
135
+ const values = Object.values(enumObj)
136
+ return {
137
+ name: 'enumOf',
138
+ validate(value) {
139
+ if (value === undefined || value === null) return null
140
+ if (!values.includes(value as any)) {
141
+ return t('validation.enum', { values: values.join(', ') })
142
+ }
143
+ return null
144
+ },
145
+ }
146
+ }
147
+
148
+ export function oneOf(values: readonly (string | number | boolean)[]): Rule {
149
+ return {
150
+ name: 'oneOf',
151
+ validate(value) {
152
+ if (value === undefined || value === null) return null
153
+ if (!values.includes(value as any)) {
154
+ return t('validation.enum', { values: values.join(', ') })
155
+ }
156
+ return null
157
+ },
158
+ }
159
+ }
160
+
161
+ export function array(): Rule {
162
+ return {
163
+ name: 'array',
164
+ validate(value) {
165
+ if (value === undefined || value === null) return null
166
+ if (!Array.isArray(value)) return t('validation.array')
167
+ return null
168
+ },
169
+ }
170
+ }
@@ -0,0 +1,41 @@
1
+ import type { Rule } from './rules.ts'
2
+
3
+ export type RuleSet = Record<string, Rule[]>
4
+
5
+ export interface ValidationResult<T = Record<string, unknown>> {
6
+ data: T
7
+ errors: Record<string, string[]> | null
8
+ }
9
+
10
+ export function validate<T = Record<string, unknown>>(
11
+ input: unknown,
12
+ rules: RuleSet
13
+ ): ValidationResult<T> {
14
+ const record = (typeof input === 'object' && input !== null ? input : {}) as Record<
15
+ string,
16
+ unknown
17
+ >
18
+ const data: Record<string, unknown> = {}
19
+ const errors: Record<string, string[]> = {}
20
+ let hasErrors = false
21
+
22
+ for (const [field, fieldRules] of Object.entries(rules)) {
23
+ const value = record[field]
24
+ if (value !== undefined) data[field] = value
25
+
26
+ for (const rule of fieldRules) {
27
+ const error = rule.validate(value)
28
+ if (error) {
29
+ if (!errors[field]) errors[field] = []
30
+ errors[field]!.push(error)
31
+ hasErrors = true
32
+ break // stop at first error per field
33
+ }
34
+ }
35
+ }
36
+
37
+ return {
38
+ data: data as T,
39
+ errors: hasErrors ? errors : null,
40
+ }
41
+ }
@@ -0,0 +1,47 @@
1
+ export interface RenderResult {
2
+ output: string
3
+ blocks: Record<string, string>
4
+ }
5
+
6
+ export type RenderFunction = (
7
+ data: Record<string, unknown>,
8
+ includeFn: IncludeFn
9
+ ) => Promise<RenderResult>
10
+
11
+ export type IncludeFn = (name: string, data: Record<string, unknown>) => Promise<string>
12
+
13
+ export interface CacheEntry {
14
+ fn: RenderFunction
15
+ layout?: string
16
+ mtime: number
17
+ filePath: string
18
+ }
19
+
20
+ export default class TemplateCache {
21
+ private entries = new Map<string, CacheEntry>()
22
+
23
+ get(name: string): CacheEntry | undefined {
24
+ return this.entries.get(name)
25
+ }
26
+
27
+ set(name: string, entry: CacheEntry): void {
28
+ this.entries.set(name, entry)
29
+ }
30
+
31
+ async isStale(name: string): Promise<boolean> {
32
+ const entry = this.entries.get(name)
33
+ if (!entry) return true
34
+ const file = Bun.file(entry.filePath)
35
+ const exists = await file.exists()
36
+ if (!exists) return true
37
+ return file.lastModified > entry.mtime
38
+ }
39
+
40
+ delete(name: string): void {
41
+ this.entries.delete(name)
42
+ }
43
+
44
+ clear(): void {
45
+ this.entries.clear()
46
+ }
47
+ }