-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathcontext.ts
More file actions
245 lines (235 loc) · 7.17 KB
/
Copy pathcontext.ts
File metadata and controls
245 lines (235 loc) · 7.17 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
import fs, { type Stats } from 'node:fs'
import fsPromises from 'node:fs/promises'
import path from 'node:path'
import { Readable, Writable } from 'node:stream'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { Reader } from 'styled-map-package-api/reader'
import type { SetRequired } from 'type-fest'
import type { ServerOptions } from './index.js'
import { CUSTOM_MAP_ID, FALLBACK_MAP_ID } from './lib/constants.js'
import { errors } from './lib/errors.js'
import {
getErrorCode,
getStyleBbox,
getStyleMaxZoom,
getStyleMinZoom,
noop,
} from './lib/utils.js'
type ContextOptions = SetRequired<ServerOptions, 'keyPair'> & {
getRemotePort: () => Promise<number>
}
let tmpCounter = 0
export class Context {
#defaultOnlineStyleUrl: URL
#mapFileUrls: Map<string, URL>
#mapReaders: Map<string, Promise<Reader>> = new Map()
#keyPair: { publicKey: Uint8Array; secretKey: Uint8Array }
getRemotePort: () => Promise<number>
constructor({
defaultOnlineStyleUrl,
customMapPath,
fallbackMapPath,
keyPair,
getRemotePort,
}: ContextOptions) {
this.#defaultOnlineStyleUrl = new URL(defaultOnlineStyleUrl)
this.#mapFileUrls = new Map([
[
CUSTOM_MAP_ID,
typeof customMapPath === 'string'
? pathToFileURL(customMapPath)
: customMapPath,
],
[
FALLBACK_MAP_ID,
typeof fallbackMapPath === 'string'
? pathToFileURL(fallbackMapPath)
: fallbackMapPath,
],
])
this.#keyPair = keyPair
this.getRemotePort = getRemotePort
}
getDefaultOnlineStyleUrl() {
return this.#defaultOnlineStyleUrl
}
getKeyPair() {
return this.#keyPair
}
async getMapInfo(mapId: string) {
const mapFileUrl = this.#mapFileUrls.get(mapId)
if (!mapFileUrl) {
throw new errors.MAP_NOT_FOUND(`Map not found: ${mapId}`)
}
let stats: Stats
try {
stats = await fsPromises.stat(mapFileUrl)
} catch (err) {
if (getErrorCode(err) === 'ENOENT') {
throw new errors.MAP_NOT_FOUND(`Map not found: ${mapId}`)
}
throw err
}
const reader = await this.getReader(mapId)
const style = await reader.getStyle()
const mapName = style.name || path.basename(fileURLToPath(mapFileUrl))
return {
mapId,
mapName,
bounds: getStyleBbox(style),
maxzoom: getStyleMaxZoom(style),
minzoom: getStyleMinZoom(style),
estimatedSizeBytes: stats.size,
mapCreatedAt: stats.ctimeMs,
}
}
getReader(mapId: string) {
const readerPromise = this.#mapReaders.get(mapId)
if (readerPromise) {
return readerPromise
}
const mapFileUrl = this.#mapFileUrls.get(mapId)
if (!mapFileUrl) {
throw new errors.MAP_NOT_FOUND(`Map ID not found: ${mapId}`)
}
const reader = new Reader(fileURLToPath(mapFileUrl))
this.#mapReaders.set(mapId, Promise.resolve(reader))
return Promise.resolve(reader)
}
createMapReadableStream(mapId: string) {
const mapFileUrl = this.#mapFileUrls.get(mapId)
if (!mapFileUrl) {
throw new errors.MAP_NOT_FOUND(`Map ID not found: ${mapId}`)
}
return Readable.toWeb(
fs.createReadStream(mapFileUrl),
) as ReadableStream<Uint8Array> // small discrepancy in types
}
/**
* Creates a writable stream to write map data to the specified map ID.
* The data is first written to a temporary file, and once the stream is closed,
* the temporary file replaces the existing map file. This ensures that the map
* file is only updated when the write operation is fully complete.
*
* @param mapId - The ID of the map to write data to.
* @returns A writable stream to write map data.
*/
createMapWritableStream(mapId: string) {
const mapFileUrl = this.#mapFileUrls.get(mapId)
if (!mapFileUrl) {
throw new errors.MAP_NOT_FOUND(`Map ID not found: ${mapId}`)
}
const tempPath = `${fileURLToPath(mapFileUrl)}.download-${tmpCounter++}`
const nodeWriteStream = fs.createWriteStream(tempPath)
const writable = Writable.toWeb(nodeWriteStream)
const writer = writable.getWriter()
// Ensure the underlying file descriptor is fully released before deleting
// the temp file. On Windows, files cannot be deleted while a handle is
// open, and Writable.toWeb()'s abort() may not wait for the fd to close.
const closeAndUnlink = async () => {
if (!nodeWriteStream.closed) {
await new Promise<void>((resolve) => {
nodeWriteStream.once('close', resolve)
if (!nodeWriteStream.destroyed) nodeWriteStream.destroy()
})
}
await fsPromises.unlink(tempPath).catch(noop)
}
return new WritableStream({
async write(chunk) {
try {
await writer.write(chunk)
} catch (err) {
await closeAndUnlink()
throw new errors.MAP_WRITE_ERROR({
message: err instanceof Error ? err.message : undefined,
cause: err,
})
}
},
close: async () => {
// Finish writing to the temp file
try {
await writer.close()
} catch (err) {
await closeAndUnlink()
throw new errors.MAP_WRITE_ERROR({
message: err instanceof Error ? err.message : undefined,
cause: err,
})
}
// Validate the uploaded map file BEFORE replacing the existing one
const tempReader = new Reader(tempPath)
try {
await tempReader.opened()
} catch {
// Clean up temp file on validation failure
await fsPromises.unlink(tempPath).catch(noop)
throw new errors.INVALID_MAP_FILE()
} finally {
await tempReader.close().catch(noop)
}
// Graceful replacement of SMP Reader when map file is updated
const readerPromise = (async () => {
const existingReaderPromise = this.#mapReaders.get(mapId)
if (existingReaderPromise) {
const existingReader = await existingReaderPromise
await existingReader.opened().catch(noop) // Ensure reader is fully opened before closing
await existingReader.close().catch(noop)
}
await fsPromises.rename(tempPath, mapFileUrl)
return new Reader(fileURLToPath(mapFileUrl))
})()
this.#mapReaders.set(mapId, readerPromise)
// Wait for the file rename to complete before closing the stream
await readerPromise
},
async abort(err) {
try {
await writer.abort(err)
} finally {
await closeAndUnlink()
}
},
})
}
async close() {
const closingPromises: Promise<void>[] = []
for (const [, readerPromise] of this.#mapReaders) {
closingPromises.push(
readerPromise.then((reader) => reader.close()).catch(noop),
)
}
this.#mapReaders.clear()
await Promise.all(closingPromises)
}
/**
* Deletes the map file for the specified map ID.
* Closes any existing reader and removes it from the cache.
*
* @param mapId - The ID of the map to delete.
*/
async deleteMap(mapId: string) {
const mapFileUrl = this.#mapFileUrls.get(mapId)
if (!mapFileUrl) {
throw new errors.MAP_NOT_FOUND(`Map ID not found: ${mapId}`)
}
// Close and remove the reader if it exists
const existingReaderPromise = this.#mapReaders.get(mapId)
if (existingReaderPromise) {
const existingReader = await existingReaderPromise
await existingReader.close().catch(noop)
this.#mapReaders.delete(mapId)
}
// Delete the map file
const mapFilePath = fileURLToPath(mapFileUrl)
try {
await fsPromises.unlink(mapFilePath)
} catch (err) {
if (getErrorCode(err) === 'ENOENT') {
throw new errors.MAP_NOT_FOUND(`Map not found: ${mapId}`)
}
throw err
}
}
}