tangled
alpha
login
or
join now
nekomimi.pet
/
wisp.place-monorepo
89
fork
atom
Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol.
wisp.place
89
fork
atom
overview
issues
10
pulls
pipelines
final pass
nekomimi.pet
3 weeks ago
e3e37624
173f951f
+344
-1
3 changed files
expand all
collapse all
unified
split
apps
hosting-service
src
lib
cache-manager.test.ts
file-serving.test.ts
file-serving.ts
+254
apps/hosting-service/src/lib/cache-manager.test.ts
···
1
1
+
import { describe, test, expect } from 'bun:test'
2
2
+
import { CacheManager } from './cache-manager'
3
3
+
4
4
+
type TestNS = 'ttl' | 'lru' | 'sized' | 'combo'
5
5
+
6
6
+
function createTestCache() {
7
7
+
return new CacheManager<TestNS>({
8
8
+
ttl: { ttl: 100, maxEntries: 100 },
9
9
+
lru: { maxEntries: 3 },
10
10
+
sized: { maxEntries: 100, maxSize: 300, estimateSize: (v) => (v as string).length },
11
11
+
combo: { ttl: 100, maxEntries: 3, maxSize: 500, estimateSize: (v) => (v as string).length },
12
12
+
})
13
13
+
}
14
14
+
15
15
+
describe('CacheManager', () => {
16
16
+
describe('get / set basics', () => {
17
17
+
test('returns undefined for missing key', () => {
18
18
+
const c = createTestCache()
19
19
+
expect(c.get('ttl', 'missing')).toBeUndefined()
20
20
+
})
21
21
+
22
22
+
test('stores and retrieves a value', () => {
23
23
+
const c = createTestCache()
24
24
+
c.set('ttl', 'k', 42)
25
25
+
expect(c.get('ttl', 'k')).toBe(42)
26
26
+
})
27
27
+
28
28
+
test('namespaces are isolated', () => {
29
29
+
const c = createTestCache()
30
30
+
c.set('ttl', 'k', 'from-ttl')
31
31
+
c.set('lru', 'k', 'from-lru')
32
32
+
expect(c.get('ttl', 'k')).toBe('from-ttl')
33
33
+
expect(c.get('lru', 'k')).toBe('from-lru')
34
34
+
})
35
35
+
36
36
+
test('overwrites existing key', () => {
37
37
+
const c = createTestCache()
38
38
+
c.set('lru', 'k', 'v1')
39
39
+
c.set('lru', 'k', 'v2')
40
40
+
expect(c.get('lru', 'k')).toBe('v2')
41
41
+
})
42
42
+
})
43
43
+
44
44
+
describe('TTL expiry', () => {
45
45
+
test('returns value within TTL', () => {
46
46
+
const c = createTestCache()
47
47
+
c.set('ttl', 'k', 'fresh')
48
48
+
expect(c.get('ttl', 'k')).toBe('fresh')
49
49
+
})
50
50
+
51
51
+
test('expires value after TTL', async () => {
52
52
+
const c = createTestCache()
53
53
+
c.set('ttl', 'k', 'stale')
54
54
+
await Bun.sleep(150)
55
55
+
expect(c.get('ttl', 'k')).toBeUndefined()
56
56
+
})
57
57
+
})
58
58
+
59
59
+
describe('LRU eviction by maxEntries', () => {
60
60
+
test('evicts oldest entry when maxEntries exceeded', () => {
61
61
+
const c = createTestCache()
62
62
+
c.set('lru', 'a', 1)
63
63
+
c.set('lru', 'b', 2)
64
64
+
c.set('lru', 'c', 3)
65
65
+
// At capacity (3). Adding a 4th should evict 'a' (oldest).
66
66
+
c.set('lru', 'd', 4)
67
67
+
expect(c.get('lru', 'a')).toBeUndefined()
68
68
+
expect(c.get('lru', 'b')).toBe(2)
69
69
+
expect(c.get('lru', 'd')).toBe(4)
70
70
+
})
71
71
+
72
72
+
test('accessing a key refreshes its LRU position', () => {
73
73
+
const c = createTestCache()
74
74
+
c.set('lru', 'a', 1)
75
75
+
c.set('lru', 'b', 2)
76
76
+
c.set('lru', 'c', 3)
77
77
+
// Touch 'a' so it's no longer the oldest
78
78
+
c.get('lru', 'a')
79
79
+
// Now 'b' is the oldest
80
80
+
c.set('lru', 'd', 4)
81
81
+
expect(c.get('lru', 'b')).toBeUndefined()
82
82
+
expect(c.get('lru', 'a')).toBe(1)
83
83
+
})
84
84
+
})
85
85
+
86
86
+
describe('LRU eviction by maxSize', () => {
87
87
+
test('evicts entries when maxSize exceeded', () => {
88
88
+
const c = createTestCache()
89
89
+
// sized ns: maxSize=300, estimateSize = string length
90
90
+
c.set('sized', 'a', 'x'.repeat(100))
91
91
+
c.set('sized', 'b', 'x'.repeat(100))
92
92
+
c.set('sized', 'c', 'x'.repeat(100))
93
93
+
// At 300 bytes. Adding 150 more should evict until it fits.
94
94
+
c.set('sized', 'd', 'x'.repeat(150))
95
95
+
expect(c.get('sized', 'a')).toBeUndefined()
96
96
+
expect(c.get('sized', 'd')).toBeDefined()
97
97
+
})
98
98
+
})
99
99
+
100
100
+
describe('delete', () => {
101
101
+
test('removes a key', () => {
102
102
+
const c = createTestCache()
103
103
+
c.set('lru', 'k', 'val')
104
104
+
c.delete('lru', 'k')
105
105
+
expect(c.get('lru', 'k')).toBeUndefined()
106
106
+
})
107
107
+
108
108
+
test('delete on missing key is a no-op', () => {
109
109
+
const c = createTestCache()
110
110
+
c.delete('lru', 'nonexistent') // should not throw
111
111
+
})
112
112
+
})
113
113
+
114
114
+
describe('clear', () => {
115
115
+
test('removes all entries in a namespace', () => {
116
116
+
const c = createTestCache()
117
117
+
c.set('lru', 'a', 1)
118
118
+
c.set('lru', 'b', 2)
119
119
+
c.set('ttl', 'x', 3)
120
120
+
c.clear('lru')
121
121
+
expect(c.get('lru', 'a')).toBeUndefined()
122
122
+
expect(c.get('lru', 'b')).toBeUndefined()
123
123
+
// Other namespace untouched
124
124
+
expect(c.get('ttl', 'x')).toBe(3)
125
125
+
})
126
126
+
})
127
127
+
128
128
+
describe('getOrFetch', () => {
129
129
+
test('calls fetcher on cache miss', async () => {
130
130
+
const c = createTestCache()
131
131
+
let called = 0
132
132
+
const val = await c.getOrFetch('lru', 'k', () => { called++; return 'fetched' })
133
133
+
expect(val).toBe('fetched')
134
134
+
expect(called).toBe(1)
135
135
+
})
136
136
+
137
137
+
test('returns cached value without calling fetcher', async () => {
138
138
+
const c = createTestCache()
139
139
+
c.set('lru', 'k', 'cached')
140
140
+
let called = 0
141
141
+
const val = await c.getOrFetch('lru', 'k', () => { called++; return 'fetched' })
142
142
+
expect(val).toBe('cached')
143
143
+
expect(called).toBe(0)
144
144
+
})
145
145
+
146
146
+
test('works with async fetcher', async () => {
147
147
+
const c = createTestCache()
148
148
+
const val = await c.getOrFetch('lru', 'k', async () => {
149
149
+
await Bun.sleep(5)
150
150
+
return 'async-result'
151
151
+
})
152
152
+
expect(val).toBe('async-result')
153
153
+
// Second call should be from cache
154
154
+
expect(c.get('lru', 'k')).toBe('async-result')
155
155
+
})
156
156
+
157
157
+
test('cacheIf: false skips caching', async () => {
158
158
+
const c = createTestCache()
159
159
+
const val = await c.getOrFetch('lru', 'k', () => null, {
160
160
+
cacheIf: (v) => v !== null,
161
161
+
})
162
162
+
expect(val).toBeNull()
163
163
+
// Should NOT be cached
164
164
+
expect(c.get('lru', 'k')).toBeUndefined()
165
165
+
})
166
166
+
167
167
+
test('cacheIf: true caches normally', async () => {
168
168
+
const c = createTestCache()
169
169
+
const val = await c.getOrFetch('lru', 'k', () => 'good', {
170
170
+
cacheIf: (v) => v !== null,
171
171
+
})
172
172
+
expect(val).toBe('good')
173
173
+
expect(c.get('lru', 'k')).toBe('good')
174
174
+
})
175
175
+
})
176
176
+
177
177
+
describe('stats', () => {
178
178
+
test('tracks hits and misses', () => {
179
179
+
const c = createTestCache()
180
180
+
c.get('lru', 'miss1')
181
181
+
c.get('lru', 'miss2')
182
182
+
c.set('lru', 'k', 'v')
183
183
+
c.get('lru', 'k')
184
184
+
const stats = c.getStats()
185
185
+
expect(stats.lru.misses).toBe(2)
186
186
+
expect(stats.lru.hits).toBe(1)
187
187
+
})
188
188
+
189
189
+
test('tracks evictions', () => {
190
190
+
const c = createTestCache()
191
191
+
c.set('lru', 'a', 1)
192
192
+
c.set('lru', 'b', 2)
193
193
+
c.set('lru', 'c', 3)
194
194
+
c.set('lru', 'd', 4) // evicts 'a'
195
195
+
const stats = c.getStats()
196
196
+
expect(stats.lru.evictions).toBe(1)
197
197
+
})
198
198
+
199
199
+
test('tracks entries count', () => {
200
200
+
const c = createTestCache()
201
201
+
c.set('lru', 'a', 1)
202
202
+
c.set('lru', 'b', 2)
203
203
+
expect(c.getStats().lru.entries).toBe(2)
204
204
+
c.delete('lru', 'a')
205
205
+
expect(c.getStats().lru.entries).toBe(1)
206
206
+
})
207
207
+
208
208
+
test('tracks sizeBytes with estimateSize', () => {
209
209
+
const c = createTestCache()
210
210
+
c.set('sized', 'a', 'hello') // 5 bytes
211
211
+
c.set('sized', 'b', 'world!') // 6 bytes
212
212
+
expect(c.getStats().sized.sizeBytes).toBe(11)
213
213
+
c.delete('sized', 'a')
214
214
+
expect(c.getStats().sized.sizeBytes).toBe(6)
215
215
+
})
216
216
+
217
217
+
test('returns independent stats per namespace', () => {
218
218
+
const c = createTestCache()
219
219
+
c.set('ttl', 'a', 1)
220
220
+
c.set('lru', 'b', 2)
221
221
+
const stats = c.getStats()
222
222
+
expect(stats.ttl.entries).toBe(1)
223
223
+
expect(stats.lru.entries).toBe(1)
224
224
+
expect(stats.sized.entries).toBe(0)
225
225
+
})
226
226
+
})
227
227
+
228
228
+
describe('cleanup', () => {
229
229
+
test('startCleanup / stopCleanup do not throw', () => {
230
230
+
const c = createTestCache()
231
231
+
c.startCleanup(50)
232
232
+
c.stopCleanup()
233
233
+
})
234
234
+
235
235
+
test('cleanup sweeps expired TTL entries', async () => {
236
236
+
const c = createTestCache()
237
237
+
c.set('ttl', 'k', 'val')
238
238
+
c.startCleanup(50)
239
239
+
// Wait for TTL (100ms) + cleanup interval (50ms) + buffer
240
240
+
await Bun.sleep(200)
241
241
+
c.stopCleanup()
242
242
+
expect(c.get('ttl', 'k')).toBeUndefined()
243
243
+
})
244
244
+
245
245
+
test('cleanup does not touch non-TTL namespaces', async () => {
246
246
+
const c = createTestCache()
247
247
+
c.set('lru', 'k', 'val')
248
248
+
c.startCleanup(50)
249
249
+
await Bun.sleep(200)
250
250
+
c.stopCleanup()
251
251
+
expect(c.get('lru', 'k')).toBe('val')
252
252
+
})
253
253
+
})
254
254
+
})
+89
apps/hosting-service/src/lib/file-serving.test.ts
···
1
1
+
import { describe, test, expect } from 'bun:test'
2
2
+
import { hasFileExtension } from './file-serving'
3
3
+
4
4
+
describe('hasFileExtension', () => {
5
5
+
describe('paths with extensions', () => {
6
6
+
test('simple file extension', () => {
7
7
+
expect(hasFileExtension('style.css')).toBe(true)
8
8
+
})
9
9
+
10
10
+
test('html extension', () => {
11
11
+
expect(hasFileExtension('index.html')).toBe(true)
12
12
+
})
13
13
+
14
14
+
test('nested path with extension', () => {
15
15
+
expect(hasFileExtension('assets/js/app.js')).toBe(true)
16
16
+
})
17
17
+
18
18
+
test('double extension', () => {
19
19
+
expect(hasFileExtension('archive.tar.gz')).toBe(true)
20
20
+
})
21
21
+
22
22
+
test('dotfile with extension', () => {
23
23
+
expect(hasFileExtension('.htaccess')).toBe(true)
24
24
+
})
25
25
+
26
26
+
test('minified file', () => {
27
27
+
expect(hasFileExtension('bundle.min.js')).toBe(true)
28
28
+
})
29
29
+
30
30
+
test('image file', () => {
31
31
+
expect(hasFileExtension('photo.png')).toBe(true)
32
32
+
})
33
33
+
34
34
+
test('sourcemap', () => {
35
35
+
expect(hasFileExtension('app.js.map')).toBe(true)
36
36
+
})
37
37
+
})
38
38
+
39
39
+
describe('paths without extensions (extensionless files)', () => {
40
40
+
test('simple name', () => {
41
41
+
expect(hasFileExtension('about')).toBe(false)
42
42
+
})
43
43
+
44
44
+
test('binary-style name with dashes', () => {
45
45
+
expect(hasFileExtension('wisp-cli-x86_64-linux')).toBe(false)
46
46
+
})
47
47
+
48
48
+
test('binary-style name with underscores', () => {
49
49
+
expect(hasFileExtension('my_binary_v2')).toBe(false)
50
50
+
})
51
51
+
52
52
+
test('empty string', () => {
53
53
+
expect(hasFileExtension('')).toBe(false)
54
54
+
})
55
55
+
56
56
+
test('single word', () => {
57
57
+
expect(hasFileExtension('README')).toBe(false)
58
58
+
})
59
59
+
60
60
+
test('path with trailing slash', () => {
61
61
+
expect(hasFileExtension('somedir/')).toBe(false)
62
62
+
})
63
63
+
})
64
64
+
65
65
+
describe('directory-with-dot edge cases', () => {
66
66
+
test('dot in directory name, extensionless file', () => {
67
67
+
expect(hasFileExtension('my.folder/file')).toBe(false)
68
68
+
})
69
69
+
70
70
+
test('dot in directory name, file with extension', () => {
71
71
+
expect(hasFileExtension('my.folder/index.html')).toBe(true)
72
72
+
})
73
73
+
74
74
+
test('multiple dotted directories, extensionless file', () => {
75
75
+
expect(hasFileExtension('v1.0/api.v2/handler')).toBe(false)
76
76
+
})
77
77
+
78
78
+
test('multiple dotted directories, file with extension', () => {
79
79
+
expect(hasFileExtension('v1.0/api.v2/handler.js')).toBe(true)
80
80
+
})
81
81
+
})
82
82
+
83
83
+
describe('trailing-dot edge case', () => {
84
84
+
test('trailing dot is not a file extension', () => {
85
85
+
// "file." has a dot but no alphanumeric chars after it
86
86
+
expect(hasFileExtension('file.')).toBe(false)
87
87
+
})
88
88
+
})
89
89
+
})
+1
-1
apps/hosting-service/src/lib/file-serving.ts
···
32
32
* e.g. "style.css" → true, "about" → false, "wisp-cli-x86_64-linux" → false,
33
33
* "dir.name/file" → false, "dir/file.tar.gz" → true
34
34
*/
35
35
-
function hasFileExtension(path: string): boolean {
35
35
+
export function hasFileExtension(path: string): boolean {
36
36
const basename = path.split('/').pop() || '';
37
37
return /\.[a-zA-Z0-9]+$/.test(basename);
38
38
}