@use-voltra/server 2.0.0-rc.4 → 2.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.
- package/package.json +2 -1
- package/src/index.ts +240 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@use-voltra/server",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "Shared server rendering foundation for Voltra",
|
|
5
5
|
"main": "build/cjs/index.js",
|
|
6
6
|
"module": "build/esm/index.js",
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
},
|
|
17
17
|
"files": [
|
|
18
18
|
"build",
|
|
19
|
+
"src",
|
|
19
20
|
"README.md"
|
|
20
21
|
],
|
|
21
22
|
"keywords": [
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
|
|
3
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The platform that sent the widget update request.
|
|
7
|
+
* Read from the `platform` query parameter.
|
|
8
|
+
*/
|
|
9
|
+
export type WidgetPlatform = 'ios' | 'android'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The system color scheme reported by the native widget.
|
|
13
|
+
* Read from the `theme` query parameter.
|
|
14
|
+
*/
|
|
15
|
+
export type WidgetTheme = 'light' | 'dark'
|
|
16
|
+
|
|
17
|
+
type NodeLikeRequest = IncomingMessage & {
|
|
18
|
+
url?: string
|
|
19
|
+
method?: string
|
|
20
|
+
headers: Record<string, string | string[] | undefined>
|
|
21
|
+
socket?: {
|
|
22
|
+
encrypted?: boolean
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type NodeLikeResponse = ServerResponse
|
|
27
|
+
|
|
28
|
+
export type WidgetUpdateHandler = (request: Request) => Promise<Response>
|
|
29
|
+
export type WidgetUpdateNodeHandler = (req: NodeLikeRequest, res: NodeLikeResponse) => Promise<void>
|
|
30
|
+
export type WidgetUpdateExpressHandler = WidgetUpdateNodeHandler
|
|
31
|
+
export type WidgetRenderResult = Promise<string | null> | string | null
|
|
32
|
+
export type WidgetRenderer = (request: WidgetRenderRequest) => WidgetRenderResult
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Request context provided to the widget render handler.
|
|
36
|
+
* Contains the widget ID, family, and any auth headers from the request.
|
|
37
|
+
*/
|
|
38
|
+
export interface WidgetRenderRequest {
|
|
39
|
+
/** Parsed request URL, including all query parameters. */
|
|
40
|
+
url: URL
|
|
41
|
+
/** The widget ID requesting an update */
|
|
42
|
+
widgetId: string
|
|
43
|
+
/** The platform the request is coming from */
|
|
44
|
+
platform: WidgetPlatform
|
|
45
|
+
/** The system color scheme (`light` or `dark`). Defaults to `light` when not provided. */
|
|
46
|
+
theme: WidgetTheme
|
|
47
|
+
/** The widget family/size (iOS only: "systemSmall", "systemMedium", etc.) */
|
|
48
|
+
family?: string
|
|
49
|
+
/** The authorization token from the request (if present) */
|
|
50
|
+
token?: string
|
|
51
|
+
/** All request headers */
|
|
52
|
+
headers: Record<string, string | string[] | undefined>
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Options for creating the widget update handler.
|
|
57
|
+
*/
|
|
58
|
+
export interface WidgetUpdateHandlerOptions {
|
|
59
|
+
/**
|
|
60
|
+
* Render function that returns a serialized iOS widget payload for a given request.
|
|
61
|
+
* If not provided, iOS requests will receive a 404 response.
|
|
62
|
+
*/
|
|
63
|
+
renderIos?: WidgetRenderer
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Render function that returns a serialized Android widget payload for a given request.
|
|
67
|
+
* If not provided, Android requests will receive a 404 response.
|
|
68
|
+
*/
|
|
69
|
+
renderAndroid?: WidgetRenderer
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Optional validation function for auth tokens.
|
|
73
|
+
* Return true if the token is valid, false to reject with 401.
|
|
74
|
+
* If not provided, all requests are accepted.
|
|
75
|
+
*/
|
|
76
|
+
validateToken?: (token: string) => Promise<boolean> | boolean
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function isWidgetPlatform(value: string | null): value is WidgetPlatform {
|
|
80
|
+
return value === 'ios' || value === 'android'
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function isWidgetTheme(value: string | null): value is WidgetTheme {
|
|
84
|
+
return value === 'light' || value === 'dark'
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function jsonResponse(status: number, body: Record<string, string>): Response {
|
|
88
|
+
return new Response(JSON.stringify(body), {
|
|
89
|
+
status,
|
|
90
|
+
headers: {
|
|
91
|
+
'Content-Type': 'application/json',
|
|
92
|
+
},
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function normalizeHeaders(headers: Headers): Record<string, string> {
|
|
97
|
+
const result: Record<string, string> = {}
|
|
98
|
+
|
|
99
|
+
headers.forEach((value, key) => {
|
|
100
|
+
result[key] = value
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
return result
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function getNodeRequestUrl(req: NodeLikeRequest): string {
|
|
107
|
+
const protocol = req.socket?.encrypted ? 'https' : 'http'
|
|
108
|
+
const host = req.headers.host || 'localhost'
|
|
109
|
+
return new URL(req.url || '/', `${protocol}://${host}`).toString()
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function createFetchRequestFromNode(req: NodeLikeRequest): Request {
|
|
113
|
+
const headers = new Headers()
|
|
114
|
+
|
|
115
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
116
|
+
if (Array.isArray(value)) {
|
|
117
|
+
for (const item of value) {
|
|
118
|
+
headers.append(key, item)
|
|
119
|
+
}
|
|
120
|
+
continue
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (typeof value === 'string') {
|
|
124
|
+
headers.set(key, value)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return new Request(getNodeRequestUrl(req), {
|
|
129
|
+
method: req.method || 'GET',
|
|
130
|
+
headers,
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function writeFetchResponseToNode(response: Response, res: NodeLikeResponse): Promise<void> {
|
|
135
|
+
const headers: Record<string, string> = {}
|
|
136
|
+
|
|
137
|
+
response.headers.forEach((value, key) => {
|
|
138
|
+
headers[key] = value
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
res.writeHead(response.status, headers)
|
|
142
|
+
res.end(await response.text())
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Creates a Fetch API request handler for serving widget updates.
|
|
147
|
+
*/
|
|
148
|
+
export function createWidgetUpdateHandler(options: WidgetUpdateHandlerOptions): WidgetUpdateHandler {
|
|
149
|
+
const { renderIos, renderAndroid, validateToken } = options
|
|
150
|
+
|
|
151
|
+
return async (request: Request): Promise<Response> => {
|
|
152
|
+
try {
|
|
153
|
+
const url = new URL(request.url)
|
|
154
|
+
const widgetId = url.searchParams.get('widgetId')
|
|
155
|
+
const family = url.searchParams.get('family') || undefined
|
|
156
|
+
const platformParam = url.searchParams.get('platform')
|
|
157
|
+
|
|
158
|
+
if (!widgetId) {
|
|
159
|
+
return jsonResponse(400, { error: 'Missing required query parameter: widgetId' })
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (!isWidgetPlatform(platformParam)) {
|
|
163
|
+
return jsonResponse(400, { error: 'Missing or invalid required query parameter: platform' })
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const platform: WidgetPlatform = platformParam
|
|
167
|
+
const themeParam = url.searchParams.get('theme')
|
|
168
|
+
const theme: WidgetTheme = isWidgetTheme(themeParam) ? themeParam : 'light'
|
|
169
|
+
|
|
170
|
+
const authHeader = request.headers.get('authorization')
|
|
171
|
+
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : undefined
|
|
172
|
+
|
|
173
|
+
if (validateToken) {
|
|
174
|
+
if (!token) {
|
|
175
|
+
return jsonResponse(401, { error: 'Authorization required' })
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const isValid = await validateToken(token)
|
|
179
|
+
if (!isValid) {
|
|
180
|
+
return jsonResponse(401, { error: 'Invalid token' })
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const renderRequest: WidgetRenderRequest = {
|
|
185
|
+
url,
|
|
186
|
+
widgetId,
|
|
187
|
+
platform,
|
|
188
|
+
theme,
|
|
189
|
+
family,
|
|
190
|
+
token,
|
|
191
|
+
headers: normalizeHeaders(request.headers),
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const renderer = platform === 'android' ? renderAndroid : renderIos
|
|
195
|
+
if (!renderer) {
|
|
196
|
+
const platformName = platform === 'android' ? 'Android' : 'iOS'
|
|
197
|
+
return jsonResponse(404, { error: `No ${platformName} render handler configured for widget: ${widgetId}` })
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const jsonPayload = await renderer(renderRequest)
|
|
201
|
+
if (!jsonPayload) {
|
|
202
|
+
const errorMessage =
|
|
203
|
+
platform === 'android' ? `No content for Android widget: ${widgetId}` : `No content for widget: ${widgetId}`
|
|
204
|
+
|
|
205
|
+
return jsonResponse(404, { error: errorMessage })
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return new Response(jsonPayload, {
|
|
209
|
+
status: 200,
|
|
210
|
+
headers: {
|
|
211
|
+
'Content-Type': 'application/json',
|
|
212
|
+
'Cache-Control': 'no-cache',
|
|
213
|
+
},
|
|
214
|
+
})
|
|
215
|
+
} catch (error) {
|
|
216
|
+
console.error('[Voltra] Widget update handler error:', error)
|
|
217
|
+
return jsonResponse(500, { error: 'Internal server error' })
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Creates a Node.js HTTP request handler for serving widget updates.
|
|
224
|
+
*/
|
|
225
|
+
export function createWidgetUpdateNodeHandler(options: WidgetUpdateHandlerOptions): WidgetUpdateNodeHandler {
|
|
226
|
+
const handler = createWidgetUpdateHandler(options)
|
|
227
|
+
|
|
228
|
+
return async (req: NodeLikeRequest, res: NodeLikeResponse): Promise<void> => {
|
|
229
|
+
const request = createFetchRequestFromNode(req)
|
|
230
|
+
const response = await handler(request)
|
|
231
|
+
await writeFetchResponseToNode(response, res)
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Creates an Express-compatible request handler for serving widget updates.
|
|
237
|
+
*/
|
|
238
|
+
export function createWidgetUpdateExpressHandler(options: WidgetUpdateHandlerOptions): WidgetUpdateExpressHandler {
|
|
239
|
+
return createWidgetUpdateNodeHandler(options)
|
|
240
|
+
}
|