@hot-updater/server 0.36.7 → 1.0.0-rc.1
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/dist/adapters/databaseConstraintErrors.cjs +23 -0
- package/dist/adapters/databaseConstraintErrors.mjs +21 -0
- package/dist/adapters/databasePluginUtils.cjs +117 -0
- package/dist/adapters/databasePluginUtils.mjs +106 -0
- package/dist/adapters/drizzle.cjs +53 -176
- package/dist/adapters/drizzle.d.cts +6 -4
- package/dist/adapters/drizzle.d.mts +6 -4
- package/dist/adapters/drizzle.mjs +54 -177
- package/dist/adapters/drizzleCrud.cjs +250 -0
- package/dist/adapters/drizzleCrud.mjs +249 -0
- package/dist/adapters/drizzleLazyDB.cjs +84 -36
- package/dist/adapters/drizzleLazyDB.mjs +84 -36
- package/dist/adapters/drizzleQuery.cjs +68 -0
- package/dist/adapters/drizzleQuery.mjs +67 -0
- package/dist/adapters/kysely.cjs +18 -138
- package/dist/adapters/kysely.d.cts +3 -12
- package/dist/adapters/kysely.d.mts +3 -12
- package/dist/adapters/kysely.mjs +19 -139
- package/dist/adapters/kyselyCrud.cjs +254 -0
- package/dist/adapters/kyselyCrud.mjs +253 -0
- package/dist/adapters/kyselyQuery.cjs +56 -0
- package/dist/adapters/kyselyQuery.mjs +55 -0
- package/dist/adapters/mongodb.cjs +21 -174
- package/dist/adapters/mongodb.d.cts +3 -2
- package/dist/adapters/mongodb.d.mts +3 -2
- package/dist/adapters/mongodb.mjs +21 -174
- package/dist/adapters/mongodbCollections.cjs +36 -0
- package/dist/adapters/mongodbCollections.mjs +30 -0
- package/dist/adapters/mongodbQuery.cjs +77 -0
- package/dist/adapters/mongodbQuery.mjs +70 -0
- package/dist/adapters/mongodbReads.cjs +148 -0
- package/dist/adapters/mongodbReads.mjs +147 -0
- package/dist/adapters/mongodbWrites.cjs +134 -0
- package/dist/adapters/mongodbWrites.mjs +133 -0
- package/dist/adapters/prisma.cjs +205 -180
- package/dist/adapters/prisma.d.cts +3 -3
- package/dist/adapters/prisma.d.mts +3 -3
- package/dist/adapters/prisma.mjs +206 -181
- package/dist/adapters/prismaQuery.cjs +58 -0
- package/dist/adapters/prismaQuery.mjs +56 -0
- package/dist/adapters/prismaRows.cjs +198 -0
- package/dist/adapters/prismaRows.mjs +188 -0
- package/dist/apiKeys.cjs +139 -0
- package/dist/apiKeys.d.cts +50 -0
- package/dist/apiKeys.d.mts +50 -0
- package/dist/apiKeys.mjs +133 -0
- package/dist/createHotUpdaterCore.cjs +48 -26
- package/dist/createHotUpdaterCore.d.cts +49 -17
- package/dist/createHotUpdaterCore.d.mts +49 -17
- package/dist/createHotUpdaterCore.mjs +51 -29
- package/dist/db/createBundleDiff.cjs +45 -68
- package/dist/db/createBundleDiff.d.cts +3 -3
- package/dist/db/createBundleDiff.d.mts +3 -3
- package/dist/db/createBundleDiff.mjs +47 -68
- package/dist/db/databasePluginCore.cjs +138 -0
- package/dist/db/databasePluginCore.mjs +137 -0
- package/dist/db/fixedMigrator.cjs +36 -93
- package/dist/db/fixedMigrator.mjs +36 -93
- package/dist/db/fixedMigratorMongo.cjs +319 -0
- package/dist/db/fixedMigratorMongo.mjs +319 -0
- package/dist/db/fixedMigratorShared.cjs +23 -0
- package/dist/db/fixedMigratorShared.mjs +18 -0
- package/dist/db/mongoMigrationExecution.cjs +11 -0
- package/dist/db/mongoMigrationExecution.mjs +11 -0
- package/dist/db/releaseCatalog.cjs +66 -0
- package/dist/db/releaseCatalog.d.cts +17 -0
- package/dist/db/releaseCatalog.d.mts +17 -0
- package/dist/db/releaseCatalog.mjs +64 -0
- package/dist/db/schema/mongodb.cjs +14 -6
- package/dist/db/schema/mongodb.mjs +14 -6
- package/dist/db/schema/registry.cjs +0 -2
- package/dist/db/schema/registry.mjs +1 -2
- package/dist/db/schema/sql.cjs +31 -14
- package/dist/db/schema/sql.mjs +32 -13
- package/dist/db/schema/sqlOperations.cjs +4 -4
- package/dist/db/schema/sqlOperations.mjs +5 -5
- package/dist/db/schemaGenerators.cjs +52 -18
- package/dist/db/schemaGenerators.mjs +52 -18
- package/dist/db/schemaReadiness.cjs +4 -3
- package/dist/db/schemaReadiness.mjs +4 -3
- package/dist/db/sqlMigrationExecution.cjs +67 -0
- package/dist/db/sqlMigrationExecution.mjs +66 -0
- package/dist/db/types.cjs +2 -6
- package/dist/db/types.d.cts +60 -16
- package/dist/db/types.d.mts +60 -16
- package/dist/db/types.mjs +3 -6
- package/dist/db/updateArtifacts.cjs +114 -60
- package/dist/db/updateArtifacts.mjs +117 -62
- package/dist/handler.cjs +108 -282
- package/dist/handler.d.cts +3 -46
- package/dist/handler.d.mts +3 -46
- package/dist/handler.mjs +107 -281
- package/dist/handlerBundleRoutes.cjs +155 -0
- package/dist/handlerBundleRoutes.mjs +155 -0
- package/dist/handlerErrors.cjs +9 -0
- package/dist/handlerErrors.mjs +9 -0
- package/dist/handlerParameters.cjs +39 -0
- package/dist/handlerParameters.mjs +34 -0
- package/dist/handlerReleaseCatalogRoutes.cjs +111 -0
- package/dist/handlerReleaseCatalogRoutes.mjs +110 -0
- package/dist/handlerReleaseManagementRoutes.cjs +125 -0
- package/dist/handlerReleaseManagementRoutes.mjs +124 -0
- package/dist/handlerTypes.d.cts +63 -0
- package/dist/handlerTypes.d.mts +63 -0
- package/dist/handlerVersionRoutes.cjs +10 -0
- package/dist/handlerVersionRoutes.d.cts +4 -0
- package/dist/handlerVersionRoutes.d.mts +4 -0
- package/dist/handlerVersionRoutes.mjs +9 -0
- package/dist/index.cjs +10 -1
- package/dist/index.d.cts +9 -3
- package/dist/index.d.mts +9 -3
- package/dist/index.mjs +5 -2
- package/dist/insights/bounded/activeOverview.cjs +87 -0
- package/dist/insights/bounded/activeOverview.mjs +85 -0
- package/dist/insights/bounded/installationSearch.cjs +42 -0
- package/dist/insights/bounded/installationSearch.mjs +42 -0
- package/dist/insights/bounded/persistence.cjs +39 -0
- package/dist/insights/bounded/persistence.mjs +38 -0
- package/dist/insights/bounded/provider.cjs +170 -0
- package/dist/insights/bounded/provider.d.cts +7 -0
- package/dist/insights/bounded/provider.d.mts +7 -0
- package/dist/insights/bounded/provider.mjs +170 -0
- package/dist/insights/bounded/scan.cjs +144 -0
- package/dist/insights/bounded/scan.mjs +138 -0
- package/dist/insights/domain.d.cts +121 -0
- package/dist/insights/domain.d.mts +121 -0
- package/dist/insights/errors.cjs +22 -0
- package/dist/insights/errors.mjs +20 -0
- package/dist/insights/eventInput.cjs +111 -0
- package/dist/insights/eventInput.mjs +111 -0
- package/dist/insights/persistence.d.cts +6 -0
- package/dist/insights/persistence.d.mts +6 -0
- package/dist/insights/queryInput.cjs +58 -0
- package/dist/insights/queryInput.mjs +55 -0
- package/dist/insights/routes.cjs +63 -0
- package/dist/insights/routes.mjs +61 -0
- package/dist/insights/types.d.cts +17 -0
- package/dist/insights/types.d.mts +17 -0
- package/dist/node.cjs +19 -12
- package/dist/node.d.cts +15 -10
- package/dist/node.d.mts +15 -10
- package/dist/node.mjs +19 -13
- package/dist/package.cjs +1 -1
- package/dist/package.mjs +1 -1
- package/dist/schema/dsl.cjs +19 -2
- package/dist/schema/dsl.mjs +17 -3
- package/dist/schema/index.cjs +1 -8
- package/dist/schema/index.mjs +2 -8
- package/dist/schema/types.cjs +3 -1
- package/dist/schema/types.mjs +3 -2
- package/dist/schema/v1_0_0.cjs +364 -0
- package/dist/schema/v1_0_0.mjs +357 -0
- package/dist/storageAccess.cjs +69 -25
- package/dist/storageAccess.mjs +68 -25
- package/dist/types/index.d.cts +4 -3
- package/dist/types/index.d.mts +5 -4
- package/package.json +8 -7
- package/src/adapters/databaseConstraintErrors.ts +25 -0
- package/src/adapters/databasePluginTestDatabase.ts +106 -0
- package/src/adapters/databasePluginUtils.spec.ts +29 -0
- package/src/adapters/databasePluginUtils.ts +214 -0
- package/src/adapters/drizzle.spec.ts +208 -47
- package/src/adapters/drizzle.ts +106 -293
- package/src/adapters/drizzleCrud.ts +419 -0
- package/src/adapters/drizzleLazyDB.ts +206 -100
- package/src/adapters/drizzleQuery.ts +164 -0
- package/src/adapters/kysely.spec.ts +126 -88
- package/src/adapters/kysely.ts +76 -334
- package/src/adapters/kyselyCrud.ts +588 -0
- package/src/adapters/kyselyQuery.ts +128 -0
- package/src/adapters/mongodb.spec.ts +141 -0
- package/src/adapters/mongodb.ts +60 -288
- package/src/adapters/mongodbCollections.ts +62 -0
- package/src/adapters/mongodbQuery.ts +226 -0
- package/src/adapters/mongodbReads.ts +311 -0
- package/src/adapters/mongodbRows.spec.ts +64 -0
- package/src/adapters/mongodbRows.ts +118 -0
- package/src/adapters/mongodbTestClient.ts +342 -0
- package/src/adapters/mongodbTestFilter.ts +166 -0
- package/src/adapters/mongodbWrites.ts +260 -0
- package/src/adapters/prisma.spec.ts +196 -0
- package/src/adapters/prisma.ts +417 -292
- package/src/adapters/prismaQuery.ts +129 -0
- package/src/adapters/prismaRows.spec.ts +98 -0
- package/src/adapters/prismaRows.ts +301 -0
- package/src/adapters/prismaTestClient.ts +396 -0
- package/src/apiKeys.integration.spec.ts +192 -0
- package/src/apiKeys.spec.ts +162 -0
- package/src/apiKeys.ts +220 -0
- package/src/createHotUpdaterCore.ts +164 -100
- package/src/db/bundleRows.spec.ts +164 -0
- package/src/db/bundleRows.ts +10 -140
- package/src/db/createBundleDiff.spec.ts +172 -127
- package/src/db/createBundleDiff.ts +91 -110
- package/src/db/databasePluginCore.spec.ts +62 -0
- package/src/db/databasePluginCore.testFixtures.ts +76 -0
- package/src/db/databasePluginCore.ts +185 -0
- package/src/db/databasePluginUpdateCheck.bench.ts +107 -0
- package/src/db/fixedMigrator.spec.ts +184 -42
- package/src/db/fixedMigrator.ts +64 -205
- package/src/db/fixedMigratorMongo.spec.ts +192 -0
- package/src/db/fixedMigratorMongo.ts +425 -0
- package/src/db/fixedMigratorShared.ts +36 -0
- package/src/db/hotUpdaterSchema.ts +0 -1
- package/src/db/index.spec.ts +333 -1025
- package/src/db/mongoMigrationExecution.ts +23 -0
- package/src/db/node-sqlite.d.ts +25 -0
- package/src/db/releaseCatalog.ts +148 -0
- package/src/db/schema/mongodb.ts +20 -6
- package/src/db/schema/sql.ts +52 -11
- package/src/db/schema/sqlOperations.ts +5 -4
- package/src/db/schemaGenerators.ts +107 -23
- package/src/db/schemaReadiness.ts +2 -1
- package/src/db/sqlMigrationExecution.spec.ts +65 -0
- package/src/db/sqlMigrationExecution.ts +108 -0
- package/src/db/types.spec.ts +55 -0
- package/src/db/types.ts +129 -65
- package/src/db/updateArtifacts.spec.ts +359 -0
- package/src/db/updateArtifacts.ts +290 -126
- package/src/handler-standalone.integration.spec.ts +401 -364
- package/src/handler.spec.ts +137 -582
- package/src/handler.testFixtures.ts +30 -0
- package/src/handler.ts +218 -616
- package/src/handlerAuth.integration.spec.ts +33 -0
- package/src/handlerBundleRoutes.ts +259 -0
- package/src/handlerErrors.ts +7 -0
- package/src/handlerManagement.spec.ts +262 -0
- package/src/handlerParameters.ts +105 -0
- package/src/handlerReleaseCatalogRoutes.ts +154 -0
- package/src/handlerReleaseManagementRoutes.ts +213 -0
- package/src/handlerTypes.ts +96 -0
- package/src/handlerVersionRoutes.ts +12 -0
- package/src/index.ts +22 -2
- package/src/insights/bounded/activeOverview.spec.ts +189 -0
- package/src/insights/bounded/activeOverview.ts +148 -0
- package/src/insights/bounded/installationSearch.spec.ts +130 -0
- package/src/insights/bounded/installationSearch.ts +69 -0
- package/src/insights/bounded/persistence.ts +43 -0
- package/src/insights/bounded/provider.spec.ts +219 -0
- package/src/insights/bounded/provider.ts +229 -0
- package/src/insights/bounded/scan.ts +235 -0
- package/src/insights/domain.ts +139 -0
- package/src/insights/errors.ts +18 -0
- package/src/insights/eventInput.ts +168 -0
- package/src/insights/insights.integration.spec.ts +144 -0
- package/src/insights/persistence.ts +10 -0
- package/src/insights/queryInput.ts +102 -0
- package/src/insights/routes.ts +123 -0
- package/src/insights/types.ts +44 -0
- package/src/node.spec.ts +79 -10
- package/src/node.ts +20 -17
- package/src/releaseCatalogRoutes.integration.spec.ts +238 -0
- package/src/runtime.spec.ts +126 -959
- package/src/runtime.testFixtures.ts +71 -0
- package/src/schema/dsl-all-versions.spec.ts +26 -17
- package/src/schema/dsl.ts +41 -2
- package/src/schema/index.ts +3 -9
- package/src/schema/types.ts +9 -3
- package/src/schema/v1_0_0.spec.ts +146 -0
- package/src/schema/v1_0_0.ts +465 -0
- package/src/storageAccess.spec.ts +127 -32
- package/src/storageAccess.ts +106 -55
- package/src/types/index.ts +2 -1
- package/dist/db/bundleRows.cjs +0 -82
- package/dist/db/bundleRows.d.cts +0 -33
- package/dist/db/bundleRows.d.mts +0 -33
- package/dist/db/bundleRows.mjs +0 -79
- package/dist/db/pluginCore.cjs +0 -242
- package/dist/db/pluginCore.mjs +0 -241
- package/dist/db/schema/sqlMigrations.cjs +0 -74
- package/dist/db/schema/sqlMigrations.mjs +0 -73
- package/dist/db/schemaEnhancements.cjs +0 -20
- package/dist/db/schemaEnhancements.mjs +0 -19
- package/dist/route.cjs +0 -7
- package/dist/route.mjs +0 -7
- package/dist/schema/v0_21_0.cjs +0 -38
- package/dist/schema/v0_21_0.mjs +0 -37
- package/dist/schema/v0_29_0.cjs +0 -45
- package/dist/schema/v0_29_0.mjs +0 -44
- package/dist/schema/v0_31_0.cjs +0 -82
- package/dist/schema/v0_31_0.mjs +0 -80
- package/src/db/pluginCore.spec.ts +0 -1095
- package/src/db/pluginCore.ts +0 -475
- package/src/db/pluginUpdateCheck.bench.ts +0 -254
- package/src/db/schema/sqlMigrations.ts +0 -219
- package/src/db/schemaEnhancements.ts +0 -55
- package/src/route.ts +0 -7
- package/src/schema/v0_21_0.ts +0 -52
- package/src/schema/v0_29_0.ts +0 -62
- package/src/schema/v0_31_0.spec.ts +0 -73
- package/src/schema/v0_31_0.ts +0 -120
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CreateBundleEventRequest,
|
|
3
|
+
CreateBundleEventRequestBase,
|
|
4
|
+
} from "./domain";
|
|
5
|
+
import {
|
|
6
|
+
InsightsBadRequestError,
|
|
7
|
+
InsightsPayloadTooLargeError,
|
|
8
|
+
} from "./errors";
|
|
9
|
+
|
|
10
|
+
const MAX_EVENT_STRING_LENGTH = 1_024;
|
|
11
|
+
export const EVENT_BODY_MAX_BYTES = 16 * 1_024;
|
|
12
|
+
|
|
13
|
+
const eventKeys = new Set([
|
|
14
|
+
"type",
|
|
15
|
+
"installId",
|
|
16
|
+
"toBundleId",
|
|
17
|
+
"userId",
|
|
18
|
+
"username",
|
|
19
|
+
"platform",
|
|
20
|
+
"appVersion",
|
|
21
|
+
"channel",
|
|
22
|
+
"cohort",
|
|
23
|
+
"fingerprintHash",
|
|
24
|
+
"fromBundleId",
|
|
25
|
+
"fromReleaseId",
|
|
26
|
+
"toReleaseId",
|
|
27
|
+
"updateStrategy",
|
|
28
|
+
"sdkVersion",
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
function isRecord(value: unknown): value is Readonly<Record<string, unknown>> {
|
|
32
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function requireStringField(
|
|
36
|
+
payload: Readonly<Record<string, unknown>>,
|
|
37
|
+
key: string,
|
|
38
|
+
): string {
|
|
39
|
+
const value = payload[key];
|
|
40
|
+
if (
|
|
41
|
+
typeof value !== "string" ||
|
|
42
|
+
value.length === 0 ||
|
|
43
|
+
value.length > MAX_EVENT_STRING_LENGTH
|
|
44
|
+
) {
|
|
45
|
+
throw new InsightsBadRequestError(`Invalid event field: ${key}`);
|
|
46
|
+
}
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function requireNullableStringField(
|
|
51
|
+
payload: Readonly<Record<string, unknown>>,
|
|
52
|
+
key: string,
|
|
53
|
+
): string | null {
|
|
54
|
+
if (payload[key] === null) return null;
|
|
55
|
+
return requireStringField(payload, key);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function readBoundedText(request: Request): Promise<string> {
|
|
59
|
+
const contentLength = request.headers.get("content-length");
|
|
60
|
+
const declaredByteLength = Number(contentLength);
|
|
61
|
+
if (
|
|
62
|
+
contentLength !== null &&
|
|
63
|
+
Number.isSafeInteger(declaredByteLength) &&
|
|
64
|
+
declaredByteLength > EVENT_BODY_MAX_BYTES
|
|
65
|
+
) {
|
|
66
|
+
throw new InsightsPayloadTooLargeError(EVENT_BODY_MAX_BYTES);
|
|
67
|
+
}
|
|
68
|
+
if (request.body === null) return "";
|
|
69
|
+
const reader = request.body.getReader();
|
|
70
|
+
const decoder = new TextDecoder();
|
|
71
|
+
let byteLength = 0;
|
|
72
|
+
let text = "";
|
|
73
|
+
while (true) {
|
|
74
|
+
const result = await reader.read();
|
|
75
|
+
if (result.done) break;
|
|
76
|
+
byteLength += result.value.byteLength;
|
|
77
|
+
if (byteLength > EVENT_BODY_MAX_BYTES) {
|
|
78
|
+
await reader.cancel();
|
|
79
|
+
throw new InsightsPayloadTooLargeError(EVENT_BODY_MAX_BYTES);
|
|
80
|
+
}
|
|
81
|
+
text += decoder.decode(result.value, { stream: true });
|
|
82
|
+
}
|
|
83
|
+
return text + decoder.decode();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function parseJson(request: Request): Promise<unknown> {
|
|
87
|
+
const text = await readBoundedText(request);
|
|
88
|
+
try {
|
|
89
|
+
return JSON.parse(text);
|
|
90
|
+
} catch (error) {
|
|
91
|
+
if (error instanceof SyntaxError) {
|
|
92
|
+
throw new InsightsBadRequestError("Invalid event payload");
|
|
93
|
+
}
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function requireEvent(payload: unknown): CreateBundleEventRequest {
|
|
99
|
+
if (
|
|
100
|
+
!isRecord(payload) ||
|
|
101
|
+
Object.keys(payload).some((key) => !eventKeys.has(key))
|
|
102
|
+
) {
|
|
103
|
+
throw new InsightsBadRequestError("Invalid event payload");
|
|
104
|
+
}
|
|
105
|
+
const platform = requireStringField(payload, "platform");
|
|
106
|
+
if (platform !== "ios" && platform !== "android") {
|
|
107
|
+
throw new InsightsBadRequestError("Invalid event field: platform");
|
|
108
|
+
}
|
|
109
|
+
const base: CreateBundleEventRequestBase = {
|
|
110
|
+
installId: requireStringField(payload, "installId"),
|
|
111
|
+
toBundleId: requireStringField(payload, "toBundleId"),
|
|
112
|
+
...(payload.userId === undefined
|
|
113
|
+
? {}
|
|
114
|
+
: { userId: requireStringField(payload, "userId") }),
|
|
115
|
+
...(payload.username === undefined
|
|
116
|
+
? {}
|
|
117
|
+
: { username: requireStringField(payload, "username") }),
|
|
118
|
+
platform,
|
|
119
|
+
appVersion: requireStringField(payload, "appVersion"),
|
|
120
|
+
channel: requireStringField(payload, "channel"),
|
|
121
|
+
cohort: requireStringField(payload, "cohort"),
|
|
122
|
+
fingerprintHash: requireNullableStringField(payload, "fingerprintHash"),
|
|
123
|
+
sdkVersion:
|
|
124
|
+
payload.sdkVersion === undefined
|
|
125
|
+
? null
|
|
126
|
+
: requireNullableStringField(payload, "sdkVersion"),
|
|
127
|
+
fromReleaseId: requireNullableStringField(payload, "fromReleaseId"),
|
|
128
|
+
toReleaseId: requireNullableStringField(payload, "toReleaseId"),
|
|
129
|
+
};
|
|
130
|
+
const type = requireStringField(payload, "type");
|
|
131
|
+
switch (type) {
|
|
132
|
+
case "UPDATE_APPLIED":
|
|
133
|
+
case "RECOVERED":
|
|
134
|
+
case "RELEASE_ADOPTED": {
|
|
135
|
+
const updateStrategy = requireStringField(payload, "updateStrategy");
|
|
136
|
+
if (updateStrategy !== "fingerprint" && updateStrategy !== "appVersion") {
|
|
137
|
+
throw new InsightsBadRequestError(
|
|
138
|
+
"Invalid event field: updateStrategy",
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
...base,
|
|
143
|
+
type,
|
|
144
|
+
fromBundleId: requireStringField(payload, "fromBundleId"),
|
|
145
|
+
updateStrategy,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
case "UNCHANGED":
|
|
149
|
+
if (payload.fromBundleId !== null || payload.updateStrategy !== null) {
|
|
150
|
+
throw new InsightsBadRequestError("Invalid unchanged event shape");
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
...base,
|
|
154
|
+
type,
|
|
155
|
+
fromBundleId: null,
|
|
156
|
+
updateStrategy: null,
|
|
157
|
+
};
|
|
158
|
+
default:
|
|
159
|
+
throw new InsightsBadRequestError("Invalid event field: type");
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function parseBundleEventRequest(
|
|
164
|
+
request: Request,
|
|
165
|
+
): Promise<CreateBundleEventRequest> {
|
|
166
|
+
const payload = await parseJson(request);
|
|
167
|
+
return requireEvent(payload);
|
|
168
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { createInMemoryDatabasePlugin } from "../../../test-utils/test/inMemoryDatabasePlugin";
|
|
4
|
+
import { createHotUpdater } from "../index";
|
|
5
|
+
import { createSchemaManagedDatabase } from "../runtime.testFixtures";
|
|
6
|
+
import { HOT_UPDATER_SCHEMA_VERSION } from "../schema/types";
|
|
7
|
+
|
|
8
|
+
const event = {
|
|
9
|
+
appVersion: "1.0.0",
|
|
10
|
+
channel: "production",
|
|
11
|
+
cohort: "default",
|
|
12
|
+
fingerprintHash: null,
|
|
13
|
+
fromBundleId: null,
|
|
14
|
+
fromReleaseId: null,
|
|
15
|
+
installId: "install-1",
|
|
16
|
+
platform: "ios",
|
|
17
|
+
toBundleId: "bundle-1",
|
|
18
|
+
toReleaseId: null,
|
|
19
|
+
type: "UNCHANGED",
|
|
20
|
+
updateStrategy: null,
|
|
21
|
+
sdkVersion: "2.0.0",
|
|
22
|
+
} as const;
|
|
23
|
+
|
|
24
|
+
const eventRequest = (body: unknown = event) =>
|
|
25
|
+
new Request("https://example.com/events", {
|
|
26
|
+
method: "POST",
|
|
27
|
+
headers: { "content-type": "application/json" },
|
|
28
|
+
body: JSON.stringify(body),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
vi.useRealTimers();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("createHotUpdater Insights", () => {
|
|
36
|
+
it("always persists events and serves queries without a server Insights flag", async () => {
|
|
37
|
+
vi.useFakeTimers();
|
|
38
|
+
vi.setSystemTime(new Date("2026-08-12T00:00:00.000Z"));
|
|
39
|
+
const database = createInMemoryDatabasePlugin();
|
|
40
|
+
const append = vi.spyOn(database.models.insights, "append");
|
|
41
|
+
const hotUpdater = createHotUpdater({
|
|
42
|
+
database,
|
|
43
|
+
clientAccess: { type: "public" },
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const ingestion = await hotUpdater.handlers.client(eventRequest());
|
|
47
|
+
vi.advanceTimersByTime(1);
|
|
48
|
+
const overview = await hotUpdater.handlers.admin(
|
|
49
|
+
new Request("https://example.com/installations/overview"),
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
expect(ingestion.status).toBe(204);
|
|
53
|
+
expect(append).toHaveBeenCalledWith(
|
|
54
|
+
expect.objectContaining({
|
|
55
|
+
install_id: "install-1",
|
|
56
|
+
sdk_version: "2.0.0",
|
|
57
|
+
to_bundle_id: "bundle-1",
|
|
58
|
+
type: "UNCHANGED",
|
|
59
|
+
}),
|
|
60
|
+
);
|
|
61
|
+
expect(hotUpdater.insights).toMatchObject({
|
|
62
|
+
mode: "bounded",
|
|
63
|
+
maxMatchingRows: 50_000,
|
|
64
|
+
});
|
|
65
|
+
expect(overview.status).toBe(200);
|
|
66
|
+
await expect(overview.json()).resolves.toEqual({
|
|
67
|
+
bundles: [{ bundleId: "bundle-1", installations: 1 }],
|
|
68
|
+
trackedInstallations: 1,
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("checks the official schema before Insights reads and writes", async () => {
|
|
73
|
+
const database = createSchemaManagedDatabase(
|
|
74
|
+
"kysely",
|
|
75
|
+
HOT_UPDATER_SCHEMA_VERSION,
|
|
76
|
+
);
|
|
77
|
+
const createMigrator = vi.spyOn(database, "createMigrator");
|
|
78
|
+
const hotUpdater = createHotUpdater({
|
|
79
|
+
database,
|
|
80
|
+
clientAccess: { type: "public" },
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
expect((await hotUpdater.handlers.client(eventRequest())).status).toBe(204);
|
|
84
|
+
await hotUpdater.insights?.getBundleEventOverview();
|
|
85
|
+
|
|
86
|
+
expect(createMigrator).toHaveBeenCalledOnce();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("keeps ingestion and queries on separate handler surfaces", async () => {
|
|
90
|
+
const hotUpdater = createHotUpdater({
|
|
91
|
+
database: createInMemoryDatabasePlugin(),
|
|
92
|
+
clientAccess: { type: "public" },
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
expect((await hotUpdater.handlers.client(eventRequest())).status).toBe(204);
|
|
96
|
+
const clientQuery = await hotUpdater.handlers.client(
|
|
97
|
+
new Request("https://example.com/installations/overview"),
|
|
98
|
+
);
|
|
99
|
+
const adminIngestion = await hotUpdater.handlers.admin(eventRequest());
|
|
100
|
+
const adminQuery = await hotUpdater.handlers.admin(
|
|
101
|
+
new Request("https://example.com/installations/overview"),
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
expect(clientQuery.status).toBe(404);
|
|
105
|
+
expect(adminIngestion.status).toBe(404);
|
|
106
|
+
expect(adminQuery.status).toBe(200);
|
|
107
|
+
expect(adminQuery.headers.get("cache-control")).toBe("private, no-store");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("returns a stable client error for malformed event payloads", async () => {
|
|
111
|
+
const hotUpdater = createHotUpdater({
|
|
112
|
+
database: createInMemoryDatabasePlugin(),
|
|
113
|
+
clientAccess: { type: "public" },
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const response = await hotUpdater.handlers.client(
|
|
117
|
+
eventRequest({ ...event, platform: "web" }),
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
expect(response.status).toBe(400);
|
|
121
|
+
await expect(response.json()).resolves.toEqual({
|
|
122
|
+
error: "Invalid event field: platform",
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it.each(["fromReleaseId", "toReleaseId"] as const)(
|
|
127
|
+
"rejects an omitted %s instead of treating it as null",
|
|
128
|
+
async (field) => {
|
|
129
|
+
const hotUpdater = createHotUpdater({
|
|
130
|
+
database: createInMemoryDatabasePlugin(),
|
|
131
|
+
clientAccess: { type: "public" },
|
|
132
|
+
});
|
|
133
|
+
const payload: Record<string, unknown> = { ...event };
|
|
134
|
+
delete payload[field];
|
|
135
|
+
|
|
136
|
+
const response = await hotUpdater.handlers.client(eventRequest(payload));
|
|
137
|
+
|
|
138
|
+
expect(response.status).toBe(400);
|
|
139
|
+
await expect(response.json()).resolves.toEqual({
|
|
140
|
+
error: `Invalid event field: ${field}`,
|
|
141
|
+
});
|
|
142
|
+
},
|
|
143
|
+
);
|
|
144
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
InsightsScanCursor,
|
|
3
|
+
InsightsScanInput,
|
|
4
|
+
InsightsModel,
|
|
5
|
+
BundleEventRow,
|
|
6
|
+
} from "@hot-updater/plugin-core";
|
|
7
|
+
|
|
8
|
+
export type BundleEventPersistenceRow = BundleEventRow;
|
|
9
|
+
export type InsightsPersistence = InsightsModel;
|
|
10
|
+
export type { InsightsScanCursor, InsightsScanInput };
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ActiveInstallationInput,
|
|
3
|
+
BundleEventInsightsWindow,
|
|
4
|
+
} from "./domain";
|
|
5
|
+
import { InsightsBadRequestError } from "./errors";
|
|
6
|
+
|
|
7
|
+
type IntegerBounds = {
|
|
8
|
+
readonly defaultValue: number;
|
|
9
|
+
readonly minimum: number;
|
|
10
|
+
readonly maximum?: number;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const EVENT_LIST_BOUNDS = {
|
|
14
|
+
defaultValue: 50,
|
|
15
|
+
maximum: 100,
|
|
16
|
+
minimum: 1,
|
|
17
|
+
} as const satisfies IntegerBounds;
|
|
18
|
+
const EVENT_LIST_OFFSET_BOUNDS = {
|
|
19
|
+
defaultValue: 0,
|
|
20
|
+
minimum: 0,
|
|
21
|
+
} as const satisfies IntegerBounds;
|
|
22
|
+
const MAX_USER_ID_LENGTH = 1_024;
|
|
23
|
+
|
|
24
|
+
export type PaginationInput = {
|
|
25
|
+
readonly limit: number;
|
|
26
|
+
readonly offset: number;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type InsightsQueryInput = PaginationInput & {
|
|
30
|
+
readonly window: BundleEventInsightsWindow;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function parseInteger(url: URL, key: string, bounds: IntegerBounds): number {
|
|
34
|
+
const value = url.searchParams.get(key);
|
|
35
|
+
if (value === null) return bounds.defaultValue;
|
|
36
|
+
const parsed = Number(value);
|
|
37
|
+
if (
|
|
38
|
+
!Number.isSafeInteger(parsed) ||
|
|
39
|
+
parsed < bounds.minimum ||
|
|
40
|
+
(bounds.maximum !== undefined && parsed > bounds.maximum)
|
|
41
|
+
) {
|
|
42
|
+
throw new InsightsBadRequestError(`Invalid '${key}' query parameter.`);
|
|
43
|
+
}
|
|
44
|
+
return parsed;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const parsePagination = (request: Request): PaginationInput => {
|
|
48
|
+
const url = new URL(request.url);
|
|
49
|
+
return {
|
|
50
|
+
limit: parseInteger(url, "limit", EVENT_LIST_BOUNDS),
|
|
51
|
+
offset: parseInteger(url, "offset", EVENT_LIST_OFFSET_BOUNDS),
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export const parseInsightsQuery = (request: Request): InsightsQueryInput => {
|
|
56
|
+
const url = new URL(request.url);
|
|
57
|
+
const window = url.searchParams.get("window") ?? "24h";
|
|
58
|
+
if (
|
|
59
|
+
window !== "24h" &&
|
|
60
|
+
window !== "7d" &&
|
|
61
|
+
window !== "30d" &&
|
|
62
|
+
window !== "all"
|
|
63
|
+
) {
|
|
64
|
+
throw new InsightsBadRequestError("Invalid 'window' query parameter.");
|
|
65
|
+
}
|
|
66
|
+
return { ...parsePagination(request), window };
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export const parseActiveInstallationInput = (
|
|
70
|
+
request: Request,
|
|
71
|
+
): ActiveInstallationInput => {
|
|
72
|
+
const url = new URL(request.url);
|
|
73
|
+
const windows = url.searchParams.getAll("window");
|
|
74
|
+
if (windows.length > 1) {
|
|
75
|
+
throw new InsightsBadRequestError("Duplicate 'window' query parameter.");
|
|
76
|
+
}
|
|
77
|
+
const window = windows[0] ?? "30d";
|
|
78
|
+
if (window !== "24h" && window !== "7d" && window !== "30d") {
|
|
79
|
+
throw new InsightsBadRequestError("Invalid 'window' query parameter.");
|
|
80
|
+
}
|
|
81
|
+
const userIds = url.searchParams.getAll("userId");
|
|
82
|
+
if (userIds.length > 1) {
|
|
83
|
+
throw new InsightsBadRequestError("Duplicate 'userId' query parameter.");
|
|
84
|
+
}
|
|
85
|
+
const userId = userIds[0];
|
|
86
|
+
if (
|
|
87
|
+
userId !== undefined &&
|
|
88
|
+
(userId.length === 0 || userId.length > MAX_USER_ID_LENGTH)
|
|
89
|
+
) {
|
|
90
|
+
throw new InsightsBadRequestError("Invalid 'userId' query parameter.");
|
|
91
|
+
}
|
|
92
|
+
return userId === undefined ? { window } : { window, userId };
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export const parseSearchInput = (
|
|
96
|
+
request: Request,
|
|
97
|
+
): PaginationInput & { readonly query: string } => ({
|
|
98
|
+
...parsePagination(request),
|
|
99
|
+
query: new URL(request.url).searchParams.get("query")?.trim() ?? "",
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
export const parseEmptyInput = (): undefined => undefined;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import type { RouteHandler } from "../handlerTypes";
|
|
2
|
+
import {
|
|
3
|
+
InsightsBadRequestError,
|
|
4
|
+
InsightsPayloadTooLargeError,
|
|
5
|
+
InsightsScanLimitExceededError,
|
|
6
|
+
} from "./errors";
|
|
7
|
+
import { parseBundleEventRequest } from "./eventInput";
|
|
8
|
+
import {
|
|
9
|
+
parseActiveInstallationInput,
|
|
10
|
+
parseInsightsQuery,
|
|
11
|
+
parsePagination,
|
|
12
|
+
parseSearchInput,
|
|
13
|
+
} from "./queryInput";
|
|
14
|
+
import type { InsightsProvider } from "./types";
|
|
15
|
+
|
|
16
|
+
const json = (body: unknown, status: number): Response =>
|
|
17
|
+
Response.json(body, {
|
|
18
|
+
headers: { "cache-control": "private, no-store" },
|
|
19
|
+
status,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const requireParam = (
|
|
23
|
+
params: Readonly<Record<string, string>>,
|
|
24
|
+
key: string,
|
|
25
|
+
): string => {
|
|
26
|
+
const value = params[key];
|
|
27
|
+
if (value === undefined || value.length === 0) {
|
|
28
|
+
throw new InsightsBadRequestError(`Missing route parameter: ${key}`);
|
|
29
|
+
}
|
|
30
|
+
return value;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const run = async (operation: () => Promise<Response>): Promise<Response> => {
|
|
34
|
+
try {
|
|
35
|
+
return await operation();
|
|
36
|
+
} catch (error) {
|
|
37
|
+
if (error instanceof InsightsBadRequestError) {
|
|
38
|
+
return json({ error: error.message }, 400);
|
|
39
|
+
}
|
|
40
|
+
if (error instanceof InsightsPayloadTooLargeError) {
|
|
41
|
+
return json({ error: error.message }, 413);
|
|
42
|
+
}
|
|
43
|
+
if (error instanceof InsightsScanLimitExceededError) {
|
|
44
|
+
return json(
|
|
45
|
+
{
|
|
46
|
+
error: {
|
|
47
|
+
code: "INSIGHTS_SCAN_LIMIT_EXCEEDED",
|
|
48
|
+
limit: error.limit,
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
503,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const query = (operation: () => Promise<unknown>): Promise<Response> =>
|
|
59
|
+
run(async () => json(await operation(), 200));
|
|
60
|
+
|
|
61
|
+
export const createInsightsRouteHandlers = (
|
|
62
|
+
provider: InsightsProvider,
|
|
63
|
+
): Record<string, RouteHandler> => ({
|
|
64
|
+
appendBundleEvent: async (_params, request) =>
|
|
65
|
+
run(async () => {
|
|
66
|
+
await provider.appendBundleEvent(await parseBundleEventRequest(request));
|
|
67
|
+
return new Response(null, { status: 204 });
|
|
68
|
+
}),
|
|
69
|
+
getBundleEventSummary: (params) =>
|
|
70
|
+
query(() => provider.getBundleEventSummary(requireParam(params, "id"))),
|
|
71
|
+
getBundleEventInsights: (params, request) =>
|
|
72
|
+
query(() => {
|
|
73
|
+
const input = parseInsightsQuery(request);
|
|
74
|
+
return provider.getBundleEventInsights(
|
|
75
|
+
requireParam(params, "id"),
|
|
76
|
+
input.window,
|
|
77
|
+
input.limit,
|
|
78
|
+
input.offset,
|
|
79
|
+
);
|
|
80
|
+
}),
|
|
81
|
+
getBundleEventOverview: () => query(() => provider.getBundleEventOverview()),
|
|
82
|
+
getActiveInstallationOverview: (_params, request) =>
|
|
83
|
+
query(() =>
|
|
84
|
+
provider.getActiveInstallationOverview(
|
|
85
|
+
parseActiveInstallationInput(request),
|
|
86
|
+
),
|
|
87
|
+
),
|
|
88
|
+
searchInstallations: (_params, request) =>
|
|
89
|
+
query(() => {
|
|
90
|
+
const input = parseSearchInput(request);
|
|
91
|
+
return provider.searchInstallations(
|
|
92
|
+
input.query,
|
|
93
|
+
input.limit,
|
|
94
|
+
input.offset,
|
|
95
|
+
);
|
|
96
|
+
}),
|
|
97
|
+
getInstallationHistory: (params, request) =>
|
|
98
|
+
query(() => {
|
|
99
|
+
const input = parsePagination(request);
|
|
100
|
+
return provider.getInstallationHistory(
|
|
101
|
+
requireParam(params, "installId"),
|
|
102
|
+
input.limit,
|
|
103
|
+
input.offset,
|
|
104
|
+
);
|
|
105
|
+
}),
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
export const registerInsightsClientRoutes = (
|
|
109
|
+
add: (method: string, path: string, handler: string) => void,
|
|
110
|
+
): void => {
|
|
111
|
+
add("POST", "/events", "appendBundleEvent");
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
export const registerInsightsAdminRoutes = (
|
|
115
|
+
add: (method: string, path: string, handler: string) => void,
|
|
116
|
+
): void => {
|
|
117
|
+
add("GET", "/bundles/:id/events/summary", "getBundleEventSummary");
|
|
118
|
+
add("GET", "/bundles/:id/events/insights", "getBundleEventInsights");
|
|
119
|
+
add("GET", "/installations/overview", "getBundleEventOverview");
|
|
120
|
+
add("GET", "/installations/active", "getActiveInstallationOverview");
|
|
121
|
+
add("GET", "/installations", "searchInstallations");
|
|
122
|
+
add("GET", "/installations/:installId/events", "getInstallationHistory");
|
|
123
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ActiveInstallationInput,
|
|
3
|
+
ActiveInstallationOverview,
|
|
4
|
+
BundleEventInsightsResult,
|
|
5
|
+
BundleEventInsightsWindow,
|
|
6
|
+
BundleEventOverview,
|
|
7
|
+
BundleEventSummary,
|
|
8
|
+
BundleEventSummaryByBundle,
|
|
9
|
+
CreateBundleEventRequest,
|
|
10
|
+
InstallationHistoryRow,
|
|
11
|
+
InstallationSearchRow,
|
|
12
|
+
OffsetPaginationResult,
|
|
13
|
+
} from "./domain";
|
|
14
|
+
|
|
15
|
+
export type InsightsProvider = {
|
|
16
|
+
readonly mode: "bounded";
|
|
17
|
+
readonly maxMatchingRows: number;
|
|
18
|
+
appendBundleEvent(input: CreateBundleEventRequest): Promise<void>;
|
|
19
|
+
getBundleEventSummary(bundleId: string): Promise<BundleEventSummary>;
|
|
20
|
+
getBundleEventSummaries(
|
|
21
|
+
bundleIds: readonly string[],
|
|
22
|
+
window: BundleEventInsightsWindow,
|
|
23
|
+
): Promise<readonly BundleEventSummaryByBundle[]>;
|
|
24
|
+
getBundleEventInsights(
|
|
25
|
+
bundleId: string,
|
|
26
|
+
window: BundleEventInsightsWindow,
|
|
27
|
+
limit: number,
|
|
28
|
+
offset: number,
|
|
29
|
+
): Promise<BundleEventInsightsResult>;
|
|
30
|
+
getBundleEventOverview(): Promise<BundleEventOverview>;
|
|
31
|
+
getActiveInstallationOverview(
|
|
32
|
+
input: ActiveInstallationInput,
|
|
33
|
+
): Promise<ActiveInstallationOverview>;
|
|
34
|
+
searchInstallations(
|
|
35
|
+
query: string,
|
|
36
|
+
limit: number,
|
|
37
|
+
offset: number,
|
|
38
|
+
): Promise<OffsetPaginationResult<InstallationSearchRow>>;
|
|
39
|
+
getInstallationHistory(
|
|
40
|
+
installId: string,
|
|
41
|
+
limit: number,
|
|
42
|
+
offset: number,
|
|
43
|
+
): Promise<OffsetPaginationResult<InstallationHistoryRow>>;
|
|
44
|
+
};
|
package/src/node.spec.ts
CHANGED
|
@@ -4,14 +4,12 @@ import { toNodeHandler } from "./node";
|
|
|
4
4
|
|
|
5
5
|
describe("server node entry", () => {
|
|
6
6
|
it("converts a Web Request handler to Node middleware", async () => {
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
};
|
|
14
|
-
const middleware = toNodeHandler(hotUpdater);
|
|
7
|
+
const middleware = toNodeHandler(async (request: Request) =>
|
|
8
|
+
Response.json({
|
|
9
|
+
method: request.method,
|
|
10
|
+
pathname: new URL(request.url).pathname,
|
|
11
|
+
}),
|
|
12
|
+
);
|
|
15
13
|
const headers = new Map<string, string | string[]>();
|
|
16
14
|
const response = {
|
|
17
15
|
body: "",
|
|
@@ -23,8 +21,8 @@ describe("server node entry", () => {
|
|
|
23
21
|
setHeader(name: string, value: string | string[]) {
|
|
24
22
|
headers.set(name, value);
|
|
25
23
|
},
|
|
26
|
-
send(body:
|
|
27
|
-
this.body = body;
|
|
24
|
+
send(body: Uint8Array) {
|
|
25
|
+
this.body = new TextDecoder().decode(body);
|
|
28
26
|
},
|
|
29
27
|
end() {},
|
|
30
28
|
};
|
|
@@ -47,4 +45,75 @@ describe("server node entry", () => {
|
|
|
47
45
|
pathname: "/api/check",
|
|
48
46
|
});
|
|
49
47
|
});
|
|
48
|
+
|
|
49
|
+
it("uses the mount-relative URL supplied by Express", async () => {
|
|
50
|
+
const middleware = toNodeHandler(async (request: Request) =>
|
|
51
|
+
Response.json({ pathname: new URL(request.url).pathname }),
|
|
52
|
+
);
|
|
53
|
+
const response = {
|
|
54
|
+
body: "",
|
|
55
|
+
statusCode: 0,
|
|
56
|
+
status(code: number) {
|
|
57
|
+
this.statusCode = code;
|
|
58
|
+
return this;
|
|
59
|
+
},
|
|
60
|
+
setHeader() {},
|
|
61
|
+
send(body: Uint8Array) {
|
|
62
|
+
this.body = new TextDecoder().decode(body);
|
|
63
|
+
},
|
|
64
|
+
end() {},
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
await middleware(
|
|
68
|
+
{
|
|
69
|
+
method: "GET",
|
|
70
|
+
url: "/channels",
|
|
71
|
+
headers: { host: "example.com" },
|
|
72
|
+
protocol: "https",
|
|
73
|
+
get: (name: string) => (name === "host" ? "example.com" : undefined),
|
|
74
|
+
},
|
|
75
|
+
response,
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
expect(JSON.parse(response.body)).toEqual({
|
|
79
|
+
pathname: "/channels",
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("preserves binary response bytes", async () => {
|
|
84
|
+
const bytes = new Uint8Array([0x50, 0x4b, 0x03, 0x04, 0xff, 0xfe, 0x00]);
|
|
85
|
+
const middleware = toNodeHandler(
|
|
86
|
+
async () =>
|
|
87
|
+
new Response(bytes, {
|
|
88
|
+
headers: { "content-type": "application/zip" },
|
|
89
|
+
}),
|
|
90
|
+
);
|
|
91
|
+
const response = {
|
|
92
|
+
body: new Uint8Array(),
|
|
93
|
+
statusCode: 0,
|
|
94
|
+
status(code: number) {
|
|
95
|
+
this.statusCode = code;
|
|
96
|
+
return this;
|
|
97
|
+
},
|
|
98
|
+
setHeader() {},
|
|
99
|
+
send(body: Uint8Array) {
|
|
100
|
+
this.body = new Uint8Array(body);
|
|
101
|
+
},
|
|
102
|
+
end() {},
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
await middleware(
|
|
106
|
+
{
|
|
107
|
+
method: "GET",
|
|
108
|
+
url: "/hot-updater/storage/token/signature",
|
|
109
|
+
headers: { host: "example.com" },
|
|
110
|
+
protocol: "https",
|
|
111
|
+
get: (name: string) => (name === "host" ? "example.com" : undefined),
|
|
112
|
+
},
|
|
113
|
+
response,
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
expect(response.statusCode).toBe(200);
|
|
117
|
+
expect([...response.body]).toEqual([...bytes]);
|
|
118
|
+
});
|
|
50
119
|
});
|