@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.
- package/README.md +610 -0
- package/dist/context.d.ts +46 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +181 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +112 -0
- package/dist/lib/constants.d.ts +7 -0
- package/dist/lib/constants.d.ts.map +1 -0
- package/dist/lib/constants.js +6 -0
- package/dist/lib/download-request.d.ts +17 -0
- package/dist/lib/download-request.d.ts.map +1 -0
- package/dist/lib/download-request.js +113 -0
- package/dist/lib/errors.d.ts +88 -0
- package/dist/lib/errors.d.ts.map +1 -0
- package/dist/lib/errors.js +158 -0
- package/dist/lib/event-stream-response.d.ts +17 -0
- package/dist/lib/event-stream-response.d.ts.map +1 -0
- package/dist/lib/event-stream-response.js +39 -0
- package/dist/lib/event-target.d.ts +9 -0
- package/dist/lib/event-target.d.ts.map +1 -0
- package/dist/lib/event-target.js +4 -0
- package/dist/lib/fetch-api.d.ts +3 -0
- package/dist/lib/fetch-api.d.ts.map +1 -0
- package/dist/lib/fetch-api.js +16 -0
- package/dist/lib/map-share.d.ts +52 -0
- package/dist/lib/map-share.d.ts.map +1 -0
- package/dist/lib/map-share.js +142 -0
- package/dist/lib/secret-stream-fetch.d.ts +7 -0
- package/dist/lib/secret-stream-fetch.d.ts.map +1 -0
- package/dist/lib/secret-stream-fetch.js +34 -0
- package/dist/lib/self-evicting-map.d.ts +16 -0
- package/dist/lib/self-evicting-map.d.ts.map +1 -0
- package/dist/lib/self-evicting-map.js +29 -0
- package/dist/lib/state-update-event.d.ts +8 -0
- package/dist/lib/state-update-event.d.ts.map +1 -0
- package/dist/lib/state-update-event.js +10 -0
- package/dist/lib/utils.d.ts +32 -0
- package/dist/lib/utils.d.ts.map +1 -0
- package/dist/lib/utils.js +96 -0
- package/dist/middlewares/localhost-only.d.ts +11 -0
- package/dist/middlewares/localhost-only.d.ts.map +1 -0
- package/dist/middlewares/localhost-only.js +10 -0
- package/dist/middlewares/parse-request.d.ts +11 -0
- package/dist/middlewares/parse-request.d.ts.map +1 -0
- package/dist/middlewares/parse-request.js +25 -0
- package/dist/routes/downloads.d.ts +15 -0
- package/dist/routes/downloads.d.ts.map +1 -0
- package/dist/routes/downloads.js +60 -0
- package/dist/routes/map-shares.d.ts +19 -0
- package/dist/routes/map-shares.d.ts.map +1 -0
- package/dist/routes/map-shares.js +192 -0
- package/dist/routes/maps.d.ts +6 -0
- package/dist/routes/maps.d.ts.map +1 -0
- package/dist/routes/maps.js +118 -0
- package/dist/routes/root.d.ts +6 -0
- package/dist/routes/root.d.ts.map +1 -0
- package/dist/routes/root.js +29 -0
- package/dist/types.d.ts +110 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +96 -0
- package/node_modules/@envelop/instrumentation/LICENSE +21 -0
- package/node_modules/@envelop/instrumentation/README.md +30 -0
- package/node_modules/@envelop/instrumentation/cjs/index.js +5 -0
- package/node_modules/@envelop/instrumentation/cjs/instrumentation.js +89 -0
- package/node_modules/@envelop/instrumentation/cjs/package.json +1 -0
- package/node_modules/@envelop/instrumentation/esm/index.js +2 -0
- package/node_modules/@envelop/instrumentation/esm/instrumentation.js +82 -0
- package/node_modules/@envelop/instrumentation/package.json +57 -0
- package/node_modules/@envelop/instrumentation/typings/index.d.cts +1 -0
- package/node_modules/@envelop/instrumentation/typings/index.d.ts +1 -0
- package/node_modules/@envelop/instrumentation/typings/instrumentation.d.cts +44 -0
- package/node_modules/@envelop/instrumentation/typings/instrumentation.d.ts +44 -0
- package/node_modules/@whatwg-node/disposablestack/cjs/AsyncDisposableStack.js +73 -0
- package/node_modules/@whatwg-node/disposablestack/cjs/DisposableStack.js +62 -0
- package/node_modules/@whatwg-node/disposablestack/cjs/SupressedError.js +16 -0
- package/node_modules/@whatwg-node/disposablestack/cjs/index.js +11 -0
- package/node_modules/@whatwg-node/disposablestack/cjs/package.json +1 -0
- package/node_modules/@whatwg-node/disposablestack/cjs/symbols.js +20 -0
- package/node_modules/@whatwg-node/disposablestack/cjs/utils.js +11 -0
- package/node_modules/@whatwg-node/disposablestack/esm/AsyncDisposableStack.js +69 -0
- package/node_modules/@whatwg-node/disposablestack/esm/DisposableStack.js +58 -0
- package/node_modules/@whatwg-node/disposablestack/esm/SupressedError.js +12 -0
- package/node_modules/@whatwg-node/disposablestack/esm/index.js +7 -0
- package/node_modules/@whatwg-node/disposablestack/esm/symbols.js +16 -0
- package/node_modules/@whatwg-node/disposablestack/esm/utils.js +7 -0
- package/node_modules/@whatwg-node/disposablestack/package.json +44 -0
- package/node_modules/@whatwg-node/disposablestack/typings/AsyncDisposableStack.d.cts +15 -0
- package/node_modules/@whatwg-node/disposablestack/typings/AsyncDisposableStack.d.ts +15 -0
- package/node_modules/@whatwg-node/disposablestack/typings/DisposableStack.d.cts +14 -0
- package/node_modules/@whatwg-node/disposablestack/typings/DisposableStack.d.ts +14 -0
- package/node_modules/@whatwg-node/disposablestack/typings/SupressedError.d.cts +5 -0
- package/node_modules/@whatwg-node/disposablestack/typings/SupressedError.d.ts +5 -0
- package/node_modules/@whatwg-node/disposablestack/typings/index.d.cts +4 -0
- package/node_modules/@whatwg-node/disposablestack/typings/index.d.ts +4 -0
- package/node_modules/@whatwg-node/disposablestack/typings/symbols.d.cts +5 -0
- package/node_modules/@whatwg-node/disposablestack/typings/symbols.d.ts +5 -0
- package/node_modules/@whatwg-node/disposablestack/typings/utils.d.cts +2 -0
- package/node_modules/@whatwg-node/disposablestack/typings/utils.d.ts +2 -0
- package/node_modules/@whatwg-node/promise-helpers/cjs/index.js +270 -0
- package/node_modules/@whatwg-node/promise-helpers/cjs/package.json +1 -0
- package/node_modules/@whatwg-node/promise-helpers/esm/index.js +257 -0
- package/node_modules/@whatwg-node/promise-helpers/package.json +43 -0
- package/node_modules/@whatwg-node/promise-helpers/typings/index.d.cts +31 -0
- package/node_modules/@whatwg-node/promise-helpers/typings/index.d.ts +31 -0
- package/node_modules/@whatwg-node/server/README.md +590 -0
- package/node_modules/@whatwg-node/server/cjs/createServerAdapter.js +368 -0
- package/node_modules/@whatwg-node/server/cjs/index.js +17 -0
- package/node_modules/@whatwg-node/server/cjs/package.json +1 -0
- package/node_modules/@whatwg-node/server/cjs/plugins/types.js +0 -0
- package/node_modules/@whatwg-node/server/cjs/plugins/useContentEncoding.js +73 -0
- package/node_modules/@whatwg-node/server/cjs/plugins/useCors.js +124 -0
- package/node_modules/@whatwg-node/server/cjs/plugins/useErrorHandling.js +52 -0
- package/node_modules/@whatwg-node/server/cjs/types.js +0 -0
- package/node_modules/@whatwg-node/server/cjs/utils.js +599 -0
- package/node_modules/@whatwg-node/server/cjs/uwebsockets.js +241 -0
- package/node_modules/@whatwg-node/server/esm/createServerAdapter.js +365 -0
- package/node_modules/@whatwg-node/server/esm/index.js +11 -0
- package/node_modules/@whatwg-node/server/esm/plugins/types.js +0 -0
- package/node_modules/@whatwg-node/server/esm/plugins/useContentEncoding.js +70 -0
- package/node_modules/@whatwg-node/server/esm/plugins/useCors.js +120 -0
- package/node_modules/@whatwg-node/server/esm/plugins/useErrorHandling.js +46 -0
- package/node_modules/@whatwg-node/server/esm/types.js +0 -0
- package/node_modules/@whatwg-node/server/esm/utils.js +588 -0
- package/node_modules/@whatwg-node/server/esm/uwebsockets.js +234 -0
- package/node_modules/@whatwg-node/server/package.json +46 -0
- package/node_modules/@whatwg-node/server/typings/createServerAdapter.d.cts +19 -0
- package/node_modules/@whatwg-node/server/typings/createServerAdapter.d.ts +19 -0
- package/node_modules/@whatwg-node/server/typings/index.d.cts +11 -0
- package/node_modules/@whatwg-node/server/typings/index.d.ts +11 -0
- package/node_modules/@whatwg-node/server/typings/plugins/types.d.cts +76 -0
- package/node_modules/@whatwg-node/server/typings/plugins/types.d.ts +76 -0
- package/node_modules/@whatwg-node/server/typings/plugins/useContentEncoding.d.cts +2 -0
- package/node_modules/@whatwg-node/server/typings/plugins/useContentEncoding.d.ts +2 -0
- package/node_modules/@whatwg-node/server/typings/plugins/useCors.d.cts +14 -0
- package/node_modules/@whatwg-node/server/typings/plugins/useCors.d.ts +14 -0
- package/node_modules/@whatwg-node/server/typings/plugins/useErrorHandling.d.cts +13 -0
- package/node_modules/@whatwg-node/server/typings/plugins/useErrorHandling.d.ts +13 -0
- package/node_modules/@whatwg-node/server/typings/types.d.cts +100 -0
- package/node_modules/@whatwg-node/server/typings/types.d.ts +100 -0
- package/node_modules/@whatwg-node/server/typings/utils.d.cts +42 -0
- package/node_modules/@whatwg-node/server/typings/utils.d.ts +42 -0
- package/node_modules/@whatwg-node/server/typings/uwebsockets.d.cts +32 -0
- package/node_modules/@whatwg-node/server/typings/uwebsockets.d.ts +32 -0
- package/node_modules/tslib/CopyrightNotice.txt +15 -0
- package/node_modules/tslib/LICENSE.txt +12 -0
- package/node_modules/tslib/README.md +164 -0
- package/node_modules/tslib/SECURITY.md +41 -0
- package/node_modules/tslib/modules/index.d.ts +38 -0
- package/node_modules/tslib/modules/index.js +70 -0
- package/node_modules/tslib/modules/package.json +3 -0
- package/node_modules/tslib/package.json +47 -0
- package/node_modules/tslib/tslib.d.ts +460 -0
- package/node_modules/tslib/tslib.es6.html +1 -0
- package/node_modules/tslib/tslib.es6.js +402 -0
- package/node_modules/tslib/tslib.es6.mjs +401 -0
- package/node_modules/tslib/tslib.html +1 -0
- package/node_modules/tslib/tslib.js +484 -0
- package/package.json +87 -0
- package/src/context.ts +203 -0
- package/src/index.ts +193 -0
- package/src/lib/constants.ts +6 -0
- package/src/lib/download-request.ts +142 -0
- package/src/lib/errors.ts +187 -0
- package/src/lib/event-stream-response.ts +57 -0
- package/src/lib/event-target.ts +11 -0
- package/src/lib/fetch-api.ts +18 -0
- package/src/lib/map-share.ts +185 -0
- package/src/lib/secret-stream-fetch.ts +42 -0
- package/src/lib/self-evicting-map.ts +35 -0
- package/src/lib/state-update-event.ts +14 -0
- package/src/lib/utils.ts +110 -0
- package/src/middlewares/localhost-only.ts +16 -0
- package/src/middlewares/parse-request.ts +34 -0
- package/src/routes/downloads.ts +92 -0
- package/src/routes/map-shares.ts +246 -0
- package/src/routes/maps.ts +146 -0
- package/src/routes/root.ts +37 -0
- 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
|
package/src/lib/utils.ts
ADDED
|
@@ -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
|
+
}
|