@comapeo/map-server 1.0.0-pre.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 (179) hide show
  1. package/README.md +610 -0
  2. package/dist/context.d.ts +46 -0
  3. package/dist/context.d.ts.map +1 -0
  4. package/dist/context.js +181 -0
  5. package/dist/index.d.ts +25 -0
  6. package/dist/index.d.ts.map +1 -0
  7. package/dist/index.js +112 -0
  8. package/dist/lib/constants.d.ts +7 -0
  9. package/dist/lib/constants.d.ts.map +1 -0
  10. package/dist/lib/constants.js +6 -0
  11. package/dist/lib/download-request.d.ts +17 -0
  12. package/dist/lib/download-request.d.ts.map +1 -0
  13. package/dist/lib/download-request.js +113 -0
  14. package/dist/lib/errors.d.ts +88 -0
  15. package/dist/lib/errors.d.ts.map +1 -0
  16. package/dist/lib/errors.js +158 -0
  17. package/dist/lib/event-stream-response.d.ts +17 -0
  18. package/dist/lib/event-stream-response.d.ts.map +1 -0
  19. package/dist/lib/event-stream-response.js +39 -0
  20. package/dist/lib/event-target.d.ts +9 -0
  21. package/dist/lib/event-target.d.ts.map +1 -0
  22. package/dist/lib/event-target.js +4 -0
  23. package/dist/lib/fetch-api.d.ts +3 -0
  24. package/dist/lib/fetch-api.d.ts.map +1 -0
  25. package/dist/lib/fetch-api.js +16 -0
  26. package/dist/lib/map-share.d.ts +52 -0
  27. package/dist/lib/map-share.d.ts.map +1 -0
  28. package/dist/lib/map-share.js +142 -0
  29. package/dist/lib/secret-stream-fetch.d.ts +7 -0
  30. package/dist/lib/secret-stream-fetch.d.ts.map +1 -0
  31. package/dist/lib/secret-stream-fetch.js +34 -0
  32. package/dist/lib/self-evicting-map.d.ts +16 -0
  33. package/dist/lib/self-evicting-map.d.ts.map +1 -0
  34. package/dist/lib/self-evicting-map.js +29 -0
  35. package/dist/lib/state-update-event.d.ts +8 -0
  36. package/dist/lib/state-update-event.d.ts.map +1 -0
  37. package/dist/lib/state-update-event.js +10 -0
  38. package/dist/lib/utils.d.ts +32 -0
  39. package/dist/lib/utils.d.ts.map +1 -0
  40. package/dist/lib/utils.js +96 -0
  41. package/dist/middlewares/localhost-only.d.ts +11 -0
  42. package/dist/middlewares/localhost-only.d.ts.map +1 -0
  43. package/dist/middlewares/localhost-only.js +10 -0
  44. package/dist/middlewares/parse-request.d.ts +11 -0
  45. package/dist/middlewares/parse-request.d.ts.map +1 -0
  46. package/dist/middlewares/parse-request.js +25 -0
  47. package/dist/routes/downloads.d.ts +15 -0
  48. package/dist/routes/downloads.d.ts.map +1 -0
  49. package/dist/routes/downloads.js +60 -0
  50. package/dist/routes/map-shares.d.ts +19 -0
  51. package/dist/routes/map-shares.d.ts.map +1 -0
  52. package/dist/routes/map-shares.js +192 -0
  53. package/dist/routes/maps.d.ts +6 -0
  54. package/dist/routes/maps.d.ts.map +1 -0
  55. package/dist/routes/maps.js +118 -0
  56. package/dist/routes/root.d.ts +6 -0
  57. package/dist/routes/root.d.ts.map +1 -0
  58. package/dist/routes/root.js +29 -0
  59. package/dist/types.d.ts +110 -0
  60. package/dist/types.d.ts.map +1 -0
  61. package/dist/types.js +96 -0
  62. package/node_modules/@envelop/instrumentation/LICENSE +21 -0
  63. package/node_modules/@envelop/instrumentation/README.md +30 -0
  64. package/node_modules/@envelop/instrumentation/cjs/index.js +5 -0
  65. package/node_modules/@envelop/instrumentation/cjs/instrumentation.js +89 -0
  66. package/node_modules/@envelop/instrumentation/cjs/package.json +1 -0
  67. package/node_modules/@envelop/instrumentation/esm/index.js +2 -0
  68. package/node_modules/@envelop/instrumentation/esm/instrumentation.js +82 -0
  69. package/node_modules/@envelop/instrumentation/package.json +57 -0
  70. package/node_modules/@envelop/instrumentation/typings/index.d.cts +1 -0
  71. package/node_modules/@envelop/instrumentation/typings/index.d.ts +1 -0
  72. package/node_modules/@envelop/instrumentation/typings/instrumentation.d.cts +44 -0
  73. package/node_modules/@envelop/instrumentation/typings/instrumentation.d.ts +44 -0
  74. package/node_modules/@whatwg-node/disposablestack/cjs/AsyncDisposableStack.js +73 -0
  75. package/node_modules/@whatwg-node/disposablestack/cjs/DisposableStack.js +62 -0
  76. package/node_modules/@whatwg-node/disposablestack/cjs/SupressedError.js +16 -0
  77. package/node_modules/@whatwg-node/disposablestack/cjs/index.js +11 -0
  78. package/node_modules/@whatwg-node/disposablestack/cjs/package.json +1 -0
  79. package/node_modules/@whatwg-node/disposablestack/cjs/symbols.js +20 -0
  80. package/node_modules/@whatwg-node/disposablestack/cjs/utils.js +11 -0
  81. package/node_modules/@whatwg-node/disposablestack/esm/AsyncDisposableStack.js +69 -0
  82. package/node_modules/@whatwg-node/disposablestack/esm/DisposableStack.js +58 -0
  83. package/node_modules/@whatwg-node/disposablestack/esm/SupressedError.js +12 -0
  84. package/node_modules/@whatwg-node/disposablestack/esm/index.js +7 -0
  85. package/node_modules/@whatwg-node/disposablestack/esm/symbols.js +16 -0
  86. package/node_modules/@whatwg-node/disposablestack/esm/utils.js +7 -0
  87. package/node_modules/@whatwg-node/disposablestack/package.json +44 -0
  88. package/node_modules/@whatwg-node/disposablestack/typings/AsyncDisposableStack.d.cts +15 -0
  89. package/node_modules/@whatwg-node/disposablestack/typings/AsyncDisposableStack.d.ts +15 -0
  90. package/node_modules/@whatwg-node/disposablestack/typings/DisposableStack.d.cts +14 -0
  91. package/node_modules/@whatwg-node/disposablestack/typings/DisposableStack.d.ts +14 -0
  92. package/node_modules/@whatwg-node/disposablestack/typings/SupressedError.d.cts +5 -0
  93. package/node_modules/@whatwg-node/disposablestack/typings/SupressedError.d.ts +5 -0
  94. package/node_modules/@whatwg-node/disposablestack/typings/index.d.cts +4 -0
  95. package/node_modules/@whatwg-node/disposablestack/typings/index.d.ts +4 -0
  96. package/node_modules/@whatwg-node/disposablestack/typings/symbols.d.cts +5 -0
  97. package/node_modules/@whatwg-node/disposablestack/typings/symbols.d.ts +5 -0
  98. package/node_modules/@whatwg-node/disposablestack/typings/utils.d.cts +2 -0
  99. package/node_modules/@whatwg-node/disposablestack/typings/utils.d.ts +2 -0
  100. package/node_modules/@whatwg-node/promise-helpers/cjs/index.js +270 -0
  101. package/node_modules/@whatwg-node/promise-helpers/cjs/package.json +1 -0
  102. package/node_modules/@whatwg-node/promise-helpers/esm/index.js +257 -0
  103. package/node_modules/@whatwg-node/promise-helpers/package.json +43 -0
  104. package/node_modules/@whatwg-node/promise-helpers/typings/index.d.cts +31 -0
  105. package/node_modules/@whatwg-node/promise-helpers/typings/index.d.ts +31 -0
  106. package/node_modules/@whatwg-node/server/README.md +590 -0
  107. package/node_modules/@whatwg-node/server/cjs/createServerAdapter.js +368 -0
  108. package/node_modules/@whatwg-node/server/cjs/index.js +17 -0
  109. package/node_modules/@whatwg-node/server/cjs/package.json +1 -0
  110. package/node_modules/@whatwg-node/server/cjs/plugins/types.js +0 -0
  111. package/node_modules/@whatwg-node/server/cjs/plugins/useContentEncoding.js +73 -0
  112. package/node_modules/@whatwg-node/server/cjs/plugins/useCors.js +124 -0
  113. package/node_modules/@whatwg-node/server/cjs/plugins/useErrorHandling.js +52 -0
  114. package/node_modules/@whatwg-node/server/cjs/types.js +0 -0
  115. package/node_modules/@whatwg-node/server/cjs/utils.js +599 -0
  116. package/node_modules/@whatwg-node/server/cjs/uwebsockets.js +241 -0
  117. package/node_modules/@whatwg-node/server/esm/createServerAdapter.js +365 -0
  118. package/node_modules/@whatwg-node/server/esm/index.js +11 -0
  119. package/node_modules/@whatwg-node/server/esm/plugins/types.js +0 -0
  120. package/node_modules/@whatwg-node/server/esm/plugins/useContentEncoding.js +70 -0
  121. package/node_modules/@whatwg-node/server/esm/plugins/useCors.js +120 -0
  122. package/node_modules/@whatwg-node/server/esm/plugins/useErrorHandling.js +46 -0
  123. package/node_modules/@whatwg-node/server/esm/types.js +0 -0
  124. package/node_modules/@whatwg-node/server/esm/utils.js +588 -0
  125. package/node_modules/@whatwg-node/server/esm/uwebsockets.js +234 -0
  126. package/node_modules/@whatwg-node/server/package.json +46 -0
  127. package/node_modules/@whatwg-node/server/typings/createServerAdapter.d.cts +19 -0
  128. package/node_modules/@whatwg-node/server/typings/createServerAdapter.d.ts +19 -0
  129. package/node_modules/@whatwg-node/server/typings/index.d.cts +11 -0
  130. package/node_modules/@whatwg-node/server/typings/index.d.ts +11 -0
  131. package/node_modules/@whatwg-node/server/typings/plugins/types.d.cts +76 -0
  132. package/node_modules/@whatwg-node/server/typings/plugins/types.d.ts +76 -0
  133. package/node_modules/@whatwg-node/server/typings/plugins/useContentEncoding.d.cts +2 -0
  134. package/node_modules/@whatwg-node/server/typings/plugins/useContentEncoding.d.ts +2 -0
  135. package/node_modules/@whatwg-node/server/typings/plugins/useCors.d.cts +14 -0
  136. package/node_modules/@whatwg-node/server/typings/plugins/useCors.d.ts +14 -0
  137. package/node_modules/@whatwg-node/server/typings/plugins/useErrorHandling.d.cts +13 -0
  138. package/node_modules/@whatwg-node/server/typings/plugins/useErrorHandling.d.ts +13 -0
  139. package/node_modules/@whatwg-node/server/typings/types.d.cts +100 -0
  140. package/node_modules/@whatwg-node/server/typings/types.d.ts +100 -0
  141. package/node_modules/@whatwg-node/server/typings/utils.d.cts +42 -0
  142. package/node_modules/@whatwg-node/server/typings/utils.d.ts +42 -0
  143. package/node_modules/@whatwg-node/server/typings/uwebsockets.d.cts +32 -0
  144. package/node_modules/@whatwg-node/server/typings/uwebsockets.d.ts +32 -0
  145. package/node_modules/tslib/CopyrightNotice.txt +15 -0
  146. package/node_modules/tslib/LICENSE.txt +12 -0
  147. package/node_modules/tslib/README.md +164 -0
  148. package/node_modules/tslib/SECURITY.md +41 -0
  149. package/node_modules/tslib/modules/index.d.ts +38 -0
  150. package/node_modules/tslib/modules/index.js +70 -0
  151. package/node_modules/tslib/modules/package.json +3 -0
  152. package/node_modules/tslib/package.json +47 -0
  153. package/node_modules/tslib/tslib.d.ts +460 -0
  154. package/node_modules/tslib/tslib.es6.html +1 -0
  155. package/node_modules/tslib/tslib.es6.js +402 -0
  156. package/node_modules/tslib/tslib.es6.mjs +401 -0
  157. package/node_modules/tslib/tslib.html +1 -0
  158. package/node_modules/tslib/tslib.js +484 -0
  159. package/package.json +87 -0
  160. package/src/context.ts +203 -0
  161. package/src/index.ts +193 -0
  162. package/src/lib/constants.ts +6 -0
  163. package/src/lib/download-request.ts +142 -0
  164. package/src/lib/errors.ts +187 -0
  165. package/src/lib/event-stream-response.ts +57 -0
  166. package/src/lib/event-target.ts +11 -0
  167. package/src/lib/fetch-api.ts +18 -0
  168. package/src/lib/map-share.ts +185 -0
  169. package/src/lib/secret-stream-fetch.ts +42 -0
  170. package/src/lib/self-evicting-map.ts +35 -0
  171. package/src/lib/state-update-event.ts +14 -0
  172. package/src/lib/utils.ts +110 -0
  173. package/src/middlewares/localhost-only.ts +16 -0
  174. package/src/middlewares/parse-request.ts +34 -0
  175. package/src/routes/downloads.ts +92 -0
  176. package/src/routes/map-shares.ts +246 -0
  177. package/src/routes/maps.ts +146 -0
  178. package/src/routes/root.ts +37 -0
  179. package/src/types.ts +152 -0
@@ -0,0 +1,187 @@
1
+ import { json, type ErrorFormatter } from 'itty-router'
2
+
3
+ interface ErrorDefinition {
4
+ message: string
5
+ status: number
6
+ code: string
7
+ }
8
+
9
+ type StatusErrorObject = {
10
+ message?: string
11
+ [key: string]: any
12
+ }
13
+
14
+ export class StatusError extends Error {
15
+ status: number;
16
+ [key: string]: any
17
+
18
+ constructor(status = 500, body?: StatusErrorObject | string) {
19
+ super(typeof body === 'object' ? body.message : body)
20
+ if (typeof body === 'object') Object.assign(this, body)
21
+ this.status = status
22
+ }
23
+ }
24
+
25
+ const errorsList = [
26
+ // Download errors (receiver-side)
27
+ {
28
+ code: 'DOWNLOAD_NOT_FOUND',
29
+ message: 'Download not found',
30
+ status: 404,
31
+ },
32
+ {
33
+ code: 'DOWNLOAD_ERROR',
34
+ message: 'Download failed',
35
+ status: 500,
36
+ },
37
+ {
38
+ code: 'DOWNLOAD_SHARE_CANCELED',
39
+ message: 'Download canceled by sender',
40
+ status: 409,
41
+ },
42
+ {
43
+ code: 'DOWNLOAD_SHARE_DECLINED',
44
+ message: 'Cannot download: share was declined',
45
+ status: 409,
46
+ },
47
+ {
48
+ code: 'DOWNLOAD_SHARE_NOT_PENDING',
49
+ message: 'Cannot download: share is not pending',
50
+ status: 409,
51
+ },
52
+ {
53
+ code: 'ABORT_NOT_DOWNLOADING',
54
+ message: 'Cannot abort: download is not in progress',
55
+ status: 409,
56
+ },
57
+ {
58
+ code: 'INVALID_SENDER_DEVICE_ID',
59
+ message: 'Invalid sender device ID',
60
+ status: 400,
61
+ },
62
+
63
+ // Map share errors (sender-side)
64
+ {
65
+ code: 'MAP_SHARE_NOT_FOUND',
66
+ message: 'Map share not found',
67
+ status: 404,
68
+ },
69
+ {
70
+ code: 'CANCEL_SHARE_NOT_CANCELABLE',
71
+ message: 'Cannot cancel: share is not pending or downloading',
72
+ status: 409,
73
+ },
74
+ {
75
+ code: 'DECLINE_SHARE_NOT_PENDING',
76
+ message: 'Cannot decline: share is not pending',
77
+ status: 409,
78
+ },
79
+ {
80
+ code: 'DECLINE_CANNOT_CONNECT',
81
+ message: 'Cannot decline: unable to connect to sender',
82
+ status: 502,
83
+ },
84
+
85
+ // Map errors
86
+ {
87
+ code: 'MAP_NOT_FOUND',
88
+ message: 'Map not found',
89
+ status: 404,
90
+ },
91
+ {
92
+ code: 'RESOURCE_NOT_FOUND',
93
+ message: 'Resource not found',
94
+ status: 404,
95
+ },
96
+ {
97
+ code: 'INVALID_MAP_FILE',
98
+ message: 'Invalid map file',
99
+ status: 400,
100
+ },
101
+
102
+ // Generic errors
103
+ {
104
+ code: 'FORBIDDEN',
105
+ message: 'Forbidden',
106
+ status: 403,
107
+ },
108
+ {
109
+ code: 'INVALID_REQUEST',
110
+ message: 'Invalid request',
111
+ status: 400,
112
+ },
113
+ ] as const satisfies Array<ErrorDefinition>
114
+
115
+ export const errors = {} as Record<
116
+ (typeof errorsList)[number]['code'],
117
+ new (body?: { [key: string]: any } | string) => StatusError
118
+ >
119
+ for (const { code, message, status } of errorsList) {
120
+ errors[code] = class extends StatusError {
121
+ constructor(body?: { [key: string]: any } | string) {
122
+ body = typeof body === 'string' ? { message: body } : body
123
+ super(status, { code, message, ...body })
124
+ }
125
+ }
126
+ }
127
+
128
+ export class ExhaustivenessError extends Error {
129
+ constructor(value: never) {
130
+ super(`Exhaustiveness check failed. ${value} should be impossible`)
131
+ this.name = 'ExhaustivenessError'
132
+ }
133
+ }
134
+
135
+ export function jsonError(err: unknown): { message: string; code: string } {
136
+ if (err === null) {
137
+ return { message: 'Unknown error', code: 'UNKNOWN_ERROR' }
138
+ } else if (typeof err !== 'object') {
139
+ return { message: String(err), code: 'UNKNOWN_ERROR' }
140
+ } else {
141
+ return {
142
+ message:
143
+ 'message' in err
144
+ ? String((err as any).message)
145
+ : String((err as any).error),
146
+ code: (err as any).code || 'UNKNOWN_ERROR',
147
+ }
148
+ }
149
+ }
150
+
151
+ const getMessage = (code: number): string =>
152
+ ({
153
+ 400: 'Bad Request',
154
+ 401: 'Unauthorized',
155
+ 403: 'Forbidden',
156
+ 404: 'Not Found',
157
+ 500: 'Internal Server Error',
158
+ })[code] || 'Unknown Error'
159
+
160
+ const getCode = (status: number): string =>
161
+ ({
162
+ 400: 'BAD_REQUEST',
163
+ 401: 'UNAUTHORIZED',
164
+ 403: 'FORBIDDEN',
165
+ 404: 'NOT_FOUND',
166
+ 500: 'INTERNAL_SERVER_ERROR',
167
+ })[status] || 'UNKNOWN_ERROR'
168
+
169
+ export const error: ErrorFormatter = (a = 500, b?) => {
170
+ // handle passing an Error | StatusError directly in
171
+ if (a instanceof Error) {
172
+ const { message, code, ...err } = a
173
+ a = a.status || 500
174
+ b = {
175
+ message: message || getMessage(a),
176
+ code: code || getCode(a),
177
+ ...err,
178
+ }
179
+ }
180
+
181
+ b = {
182
+ status: a,
183
+ ...(typeof b === 'object' ? b : { message: b || getMessage(a) }),
184
+ }
185
+
186
+ return json(b, { status: a })
187
+ }
@@ -0,0 +1,57 @@
1
+ import type { TypedEventTarget } from 'typed-event-target'
2
+
3
+ import { noop } from './utils.js'
4
+
5
+ const encoder = new TextEncoder()
6
+
7
+ type EventTargetStateUpdater = TypedEventTarget<
8
+ Readonly<Event & { type: 'update' }>
9
+ > & {
10
+ state: any
11
+ }
12
+
13
+ /**
14
+ * Create a Server-Sent Events stream for an EventTarget with a `state` property
15
+ * that emits 'update' events with state updates.
16
+ *
17
+ * You must pass an AbortSignal that will cancel the stream if the client disconnects.
18
+ */
19
+ export function createEventStreamResponse(
20
+ eventTarget: EventTargetStateUpdater,
21
+ { signal }: { signal: AbortSignal },
22
+ ): Response {
23
+ let listener: (event: Event & { type: 'update' }) => void | undefined
24
+
25
+ const stream = new ReadableStream({
26
+ start(controller) {
27
+ controller.enqueue(
28
+ encoder.encode(`data: ${JSON.stringify(eventTarget.state)}\n\n`),
29
+ )
30
+ listener = (event) => {
31
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
32
+ const { type, ...update } = event
33
+ controller.enqueue(
34
+ encoder.encode(`data: ${JSON.stringify(update)}\n\n`),
35
+ )
36
+ }
37
+ eventTarget.addEventListener('update', listener)
38
+ },
39
+ cancel() {
40
+ signal.removeEventListener('abort', onAbort)
41
+ if (listener) {
42
+ eventTarget.removeEventListener('update', listener)
43
+ }
44
+ },
45
+ })
46
+ const onAbort = () => {
47
+ stream.cancel().catch(noop)
48
+ }
49
+ signal.addEventListener('abort', onAbort, { once: true })
50
+ return new Response(stream, {
51
+ headers: {
52
+ 'Content-Type': 'text/event-stream',
53
+ 'Cache-Control': 'no-cache',
54
+ Connection: 'keep-alive',
55
+ },
56
+ })
57
+ }
@@ -0,0 +1,11 @@
1
+ import { type TypedEventTarget as TypedEventTargetOrig } from 'typed-event-target'
2
+
3
+ /**
4
+ * A strongly typed EventTarget - no runtime overhead
5
+ */
6
+ export const TypedEventTarget = EventTarget as {
7
+ new <
8
+ PossibleEvents extends Readonly<Event>,
9
+ >(): TypedEventTargetOrig<PossibleEvents>
10
+ prototype: EventTarget
11
+ }
@@ -0,0 +1,18 @@
1
+ import type { ServerAdapterOptions } from '@whatwg-node/server'
2
+
3
+ // @whatwg-node/server ponyfills the fetch API by default, which has bugs in the
4
+ // ReadableStream implementation that was causing issues with stream error
5
+ // propagation. To avoid these issues, we explicitly provide the native fetch
6
+ // API implementation from Node.js.
7
+ export const fetchAPI: ServerAdapterOptions<any>['fetchAPI'] = {
8
+ ReadableStream: globalThis.ReadableStream,
9
+ WritableStream: globalThis.WritableStream,
10
+ TransformStream: globalThis.TransformStream,
11
+ Response: globalThis.Response,
12
+ Request: globalThis.Request,
13
+ Headers: globalThis.Headers,
14
+ FormData: globalThis.FormData,
15
+ File: globalThis.File,
16
+ Blob: globalThis.Blob,
17
+ fetch: globalThis.fetch,
18
+ }
@@ -0,0 +1,185 @@
1
+ import { TypedEventTarget } from '../lib/event-target.js'
2
+ import {
3
+ MapShareState,
4
+ type MapShareStateUpdate,
5
+ type DownloadStateUpdate,
6
+ type MapInfo,
7
+ } from '../types.js'
8
+ import { errors } from './errors.js'
9
+ import { StateUpdateEvent } from './state-update-event.js'
10
+ import { addTrailingSlash, generateId, getErrorCode } from './utils.js'
11
+
12
+ export type MapShareOptions = MapInfo & {
13
+ /**
14
+ * Base URLs to construct the download URLs for the map share. Multiple URLs
15
+ * are supported because the server might have multiple network interfaces
16
+ * with different IP addresses
17
+ */
18
+ baseUrls: string[]
19
+ /** The device ID of the receiver */
20
+ receiverDeviceId: string
21
+ }
22
+
23
+ /**
24
+ * Maintains the state of a map share and handles downloading from the sharer side
25
+ */
26
+ export class MapShare extends TypedEventTarget<
27
+ InstanceType<typeof StateUpdateEvent>
28
+ > {
29
+ #state: MapShareState
30
+ #download: DownloadResponse | undefined
31
+ constructor({ baseUrls, receiverDeviceId, ...mapInfo }: MapShareOptions) {
32
+ super()
33
+ const shareId = generateId()
34
+ this.#state = {
35
+ ...mapInfo,
36
+ shareId,
37
+ mapShareUrls: baseUrls.map(
38
+ (baseUrl) => new URL(`${shareId}`, addTrailingSlash(baseUrl)).href,
39
+ ),
40
+ receiverDeviceId,
41
+ mapShareCreated: Date.now(),
42
+ status: 'pending',
43
+ }
44
+ }
45
+
46
+ get shareId() {
47
+ return this.#state.shareId
48
+ }
49
+
50
+ get state() {
51
+ return this.#state
52
+ }
53
+
54
+ /**
55
+ * Create a download response for the map share
56
+ */
57
+ downloadResponse(readable: ReadableStream): Response {
58
+ if (this.#state.status === 'canceled') {
59
+ throw new errors.DOWNLOAD_SHARE_CANCELED()
60
+ } else if (this.#state.status === 'declined') {
61
+ throw new errors.DOWNLOAD_SHARE_DECLINED()
62
+ } else if (this.#state.status !== 'pending') {
63
+ throw new errors.DOWNLOAD_SHARE_NOT_PENDING(
64
+ `Cannot download: share status is '${this.#state.status}'`,
65
+ )
66
+ }
67
+ this.#download?.removeAllEventListeners()
68
+ this.#download = new DownloadResponse(readable)
69
+ this.#download.addEventListener('update', (event) => {
70
+ this.#updateState(event)
71
+ })
72
+ return this.#download.response
73
+ }
74
+
75
+ /**
76
+ * Decline the map share with a given reason
77
+ */
78
+ decline(
79
+ reason: Extract<MapShareStateUpdate, { status: 'declined' }>['reason'],
80
+ ) {
81
+ if (this.#state.status !== 'pending') {
82
+ throw new errors.DECLINE_SHARE_NOT_PENDING(
83
+ `Cannot decline: share status is '${this.#state.status}'`,
84
+ )
85
+ }
86
+ this.#updateState({ status: 'declined', reason })
87
+ }
88
+
89
+ /**
90
+ * Cancel the map share
91
+ */
92
+ cancel() {
93
+ if (
94
+ this.#state.status !== 'pending' &&
95
+ this.#state.status !== 'downloading'
96
+ ) {
97
+ throw new errors.CANCEL_SHARE_NOT_CANCELABLE(
98
+ `Cannot cancel: share status is '${this.#state.status}'`,
99
+ )
100
+ }
101
+ this.#download?.cancel()
102
+ this.#updateState({ status: 'canceled' })
103
+ }
104
+
105
+ #updateState(update: MapShareStateUpdate) {
106
+ this.#state = { ...this.#state, ...update }
107
+ queueMicrotask(() => this.dispatchEvent(new StateUpdateEvent(update)))
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Handles the download response of a map share and tracks its state.
113
+ *
114
+ * Currently we only support a single download per map share, but I'm keeping
115
+ * this as a separate class in case we want to support multiple downloads per
116
+ * share in the future (multiple downloads per share will make the "state" of a
117
+ * MapShare harder to reason about and define).
118
+ */
119
+ export class DownloadResponse extends TypedEventTarget<
120
+ InstanceType<typeof StateUpdateEvent<DownloadStateUpdate>>
121
+ > {
122
+ #stream: TransformStream
123
+ #bytesDownloaded = 0
124
+ #abortController = new AbortController()
125
+ #state: DownloadStateUpdate = { status: 'downloading', bytesDownloaded: 0 }
126
+ #response: Response
127
+
128
+ constructor(readable: ReadableStream<Uint8Array>) {
129
+ super()
130
+ this.#stream = new TransformStream({
131
+ start: () => {
132
+ this.#updateState({ status: 'downloading', bytesDownloaded: 0 })
133
+ },
134
+ transform: (chunk, controller) => {
135
+ this.#bytesDownloaded += chunk.length
136
+ this.#updateState({
137
+ status: 'downloading',
138
+ bytesDownloaded: this.#bytesDownloaded,
139
+ })
140
+ controller.enqueue(chunk)
141
+ },
142
+ flush: () => {
143
+ this.#updateState({ status: 'completed' })
144
+ },
145
+ })
146
+ readable
147
+ .pipeTo(this.#stream.writable, {
148
+ signal: this.#abortController.signal,
149
+ // preventAbort: true,
150
+ // preventCancel: true,
151
+ })
152
+ .catch((error) => {
153
+ if (error.name === 'AbortError') {
154
+ this.#updateState({ status: 'canceled' })
155
+ } else if (getErrorCode(error) === 'ECONNRESET') {
156
+ this.#updateState({ status: 'aborted' })
157
+ } else {
158
+ this.#updateState({ status: 'error', error })
159
+ }
160
+ })
161
+
162
+ this.#response = new Response(this.#stream.readable, {
163
+ headers: {
164
+ 'Content-Type': 'application/vnd.smp+zip',
165
+ },
166
+ })
167
+ }
168
+
169
+ get response() {
170
+ return this.#response
171
+ }
172
+
173
+ get state() {
174
+ return this.#state
175
+ }
176
+
177
+ cancel() {
178
+ this.#abortController.abort()
179
+ }
180
+
181
+ #updateState(update: DownloadStateUpdate) {
182
+ this.#state = update
183
+ this.dispatchEvent(new StateUpdateEvent(update))
184
+ }
185
+ }
@@ -0,0 +1,42 @@
1
+ import { fetch as secretStreamFetchOrig } from 'secret-stream-http'
2
+
3
+ import { errors } from './errors.js'
4
+
5
+ /**
6
+ * A wrapper around secret-stream-http's fetch that tries multiple URLs until one works.
7
+ * This is useful when the server has multiple IPs for different network interfaces.
8
+ */
9
+ export async function secretStreamFetch(
10
+ urls: string | URL | Array<string | URL>,
11
+ options: Parameters<typeof secretStreamFetchOrig>[1],
12
+ ) {
13
+ if (!Array.isArray(urls)) {
14
+ urls = [urls]
15
+ }
16
+ let response: Response | undefined
17
+ let error: unknown
18
+
19
+ // The server could have multiple IPs for different network interfaces, and
20
+ // not all of them may be on the same network as us, so try each URL until
21
+ // one works
22
+ for (const url of urls) {
23
+ try {
24
+ response = (await secretStreamFetchOrig(
25
+ url,
26
+ options,
27
+ )) as unknown as Response // Subtle difference bewteen Undici fetch Response and whatwg Response
28
+ break // Exit loop on successful fetch
29
+ } catch (err) {
30
+ error = err
31
+ // Ignore errors and try the next URL
32
+ }
33
+ }
34
+ if (!response) {
35
+ throw new errors.DOWNLOAD_ERROR({
36
+ message: 'Could not connect to map share sender',
37
+ urls,
38
+ cause: error,
39
+ })
40
+ }
41
+ return response
42
+ }
@@ -0,0 +1,35 @@
1
+ const DEFAULT_EVICTION_TIMEOUT_MS = 15 * 60 * 1000 // 15 minutes
2
+
3
+ /**
4
+ * "Not a LRU": A Map that automatically evicts entries after a specified
5
+ * timeout. Used for MapShares which we don't want to keep indefinitely. NB: The
6
+ * use of the Typescript `object` type is intentional: this must be used with
7
+ * non-primitive values, otherwise behaviour would be unexpected because
8
+ * removing a value and re-adding it could result in it being evicted with the
9
+ * original timeout. This has limited applications, but works for our needs.
10
+ */
11
+ export class SelfEvictingTimeoutMap<K, V extends object> extends Map<K, V> {
12
+ #evictionTimeoutMs: number
13
+ #timeouts = new Set<NodeJS.Timeout>()
14
+
15
+ constructor(
16
+ iterable?: ConstructorParameters<typeof Map<K, V>>[0],
17
+ { evictionTimeoutMs = DEFAULT_EVICTION_TIMEOUT_MS } = {},
18
+ ) {
19
+ super(iterable)
20
+ this.#evictionTimeoutMs = evictionTimeoutMs
21
+ }
22
+
23
+ override set(key: K, value: V): this {
24
+ super.set(key, value)
25
+ const timeout = setTimeout(() => {
26
+ this.#timeouts.delete(timeout)
27
+ if (this.get(key) === value) {
28
+ this.delete(key)
29
+ }
30
+ }, this.#evictionTimeoutMs)
31
+ timeout.unref()
32
+ this.#timeouts.add(timeout)
33
+ return this
34
+ }
35
+ }
@@ -0,0 +1,14 @@
1
+ import type { MapShareStateUpdate } from '../types.js'
2
+
3
+ /**
4
+ * Event representing a state update in a map share
5
+ */
6
+ export const StateUpdateEvent = class extends Event {
7
+ public static readonly type = 'update'
8
+ constructor(update: MapShareStateUpdate) {
9
+ super('update')
10
+ Object.assign(this, update)
11
+ }
12
+ } as new <TUpdate extends MapShareStateUpdate>(
13
+ update: TUpdate,
14
+ ) => Event & { type: 'update' } & TUpdate
@@ -0,0 +1,110 @@
1
+ import crypto from 'node:crypto'
2
+
3
+ import { randomBytes } from 'crypto'
4
+ import type { SMPStyle } from 'styled-map-package'
5
+ import z32 from 'z32'
6
+
7
+ import type { BBox } from '../types.js'
8
+
9
+ /**
10
+ * If the argument is an `Error` instance, return its `code` property if it is a string.
11
+ * Otherwise, returns `undefined`.
12
+ *
13
+ * @param {unknown} maybeError
14
+ * @returns {undefined | string}
15
+ * @example
16
+ * try {
17
+ * // do something
18
+ * } catch (err) {
19
+ * console.error(getErrorCode(err))
20
+ * }
21
+ */
22
+ export function getErrorCode(maybeError: unknown) {
23
+ if (
24
+ maybeError instanceof Error &&
25
+ 'code' in maybeError &&
26
+ typeof maybeError.code === 'string'
27
+ ) {
28
+ return maybeError.code
29
+ }
30
+ return undefined
31
+ }
32
+
33
+ export function noop() {}
34
+
35
+ export function generateId() {
36
+ return z32.encode(randomBytes(8))
37
+ }
38
+
39
+ export function getOrInsert<K, V>(map: Map<K, V>, key: K, value: V): V {
40
+ if (map.has(key)) {
41
+ return map.get(key)!
42
+ }
43
+ map.set(key, value)
44
+ return value
45
+ }
46
+
47
+ export function timingSafeEqual(a: string, b: string): boolean {
48
+ const aBuf = Buffer.from(a)
49
+ const bBuf = Buffer.from(b)
50
+ if (aBuf.length !== bBuf.length) {
51
+ return false
52
+ }
53
+ return crypto.timingSafeEqual(aBuf, bBuf)
54
+ }
55
+
56
+ /**
57
+ * Returns a bbox that is the smallest bounding box that contains all the input bboxes.
58
+ *
59
+ * @param bboxes
60
+ * @returns Bounding Box [w, s, e, n] of all input bboxes
61
+ */
62
+ export function unionBBox(bboxes: [BBox, ...BBox[]]): BBox {
63
+ let [w, s, e, n] = bboxes[0]
64
+ for (let i = 1; i < bboxes.length; i++) {
65
+ const [w1, s1, e1, n1] = bboxes[i]
66
+ w = Math.min(w, w1)
67
+ s = Math.min(s, s1)
68
+ e = Math.max(e, e1)
69
+ n = Math.max(n, n1)
70
+ }
71
+ return [w, s, e, n]
72
+ }
73
+
74
+ export function getStyleBbox(style: SMPStyle): BBox {
75
+ const sourceBboxes: BBox[] = []
76
+ for (const source of Object.values(style.sources)) {
77
+ if (!('bounds' in source)) continue
78
+ sourceBboxes.push(source.bounds)
79
+ }
80
+ if (!isNonEmptyArray(sourceBboxes)) {
81
+ return [-180, -85.0511, 180, 85.0511]
82
+ }
83
+ return unionBBox(sourceBboxes)
84
+ }
85
+
86
+ export function getStyleMaxZoom(style: SMPStyle): number {
87
+ let maxzoom = -1
88
+ for (const source of Object.values(style.sources)) {
89
+ if (!('maxzoom' in source)) continue
90
+ maxzoom = Math.max(maxzoom, source.maxzoom ?? -1)
91
+ }
92
+ return maxzoom === -1 ? 22 : maxzoom
93
+ }
94
+
95
+ export function getStyleMinZoom(style: SMPStyle): number {
96
+ let minzoom = 99
97
+ for (const source of Object.values(style.sources)) {
98
+ if (!('minzoom' in source)) continue
99
+ minzoom = Math.min(minzoom, source.minzoom ?? 99)
100
+ }
101
+ return minzoom === 99 ? 0 : minzoom
102
+ }
103
+
104
+ function isNonEmptyArray<T>(arr: T[]): arr is [T, ...T[]] {
105
+ return arr.length > 0
106
+ }
107
+
108
+ export function addTrailingSlash(url: string): string {
109
+ return url.endsWith('/') ? url : url + '/'
110
+ }
@@ -0,0 +1,16 @@
1
+ import { type IRequestStrict, type RequestHandler } from 'itty-router'
2
+
3
+ import { errors } from '../lib/errors.js'
4
+
5
+ /**
6
+ * Middleware to restrict access to localhost only. The localhost listener must
7
+ * pass { isLocalhost: true } in the context.
8
+ */
9
+ export const localhostOnly: RequestHandler<
10
+ IRequestStrict,
11
+ [{ isLocalhost: boolean }]
12
+ > = async (_, { isLocalhost }) => {
13
+ if (!isLocalhost) {
14
+ throw new errors.FORBIDDEN()
15
+ }
16
+ }