@hot-updater/server 0.21.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/LICENSE +21 -0
- package/dist/_virtual/rolldown_runtime.cjs +25 -0
- package/dist/adapters/drizzle.cjs +9 -0
- package/dist/adapters/drizzle.d.cts +1 -0
- package/dist/adapters/drizzle.d.ts +1 -0
- package/dist/adapters/drizzle.js +3 -0
- package/dist/adapters/kysely.cjs +9 -0
- package/dist/adapters/kysely.d.cts +1 -0
- package/dist/adapters/kysely.d.ts +1 -0
- package/dist/adapters/kysely.js +3 -0
- package/dist/adapters/mongodb.cjs +9 -0
- package/dist/adapters/mongodb.d.cts +1 -0
- package/dist/adapters/mongodb.d.ts +1 -0
- package/dist/adapters/mongodb.js +3 -0
- package/dist/adapters/prisma.cjs +9 -0
- package/dist/adapters/prisma.d.cts +1 -0
- package/dist/adapters/prisma.d.ts +1 -0
- package/dist/adapters/prisma.js +3 -0
- package/dist/adapters/typeorm.cjs +9 -0
- package/dist/adapters/typeorm.d.cts +1 -0
- package/dist/adapters/typeorm.d.ts +1 -0
- package/dist/adapters/typeorm.js +3 -0
- package/dist/calculatePagination.cjs +27 -0
- package/dist/calculatePagination.js +26 -0
- package/dist/db/index.cjs +289 -0
- package/dist/db/index.d.cts +62 -0
- package/dist/db/index.d.ts +62 -0
- package/dist/db/index.js +284 -0
- package/dist/handler.cjs +141 -0
- package/dist/handler.d.cts +37 -0
- package/dist/handler.d.ts +37 -0
- package/dist/handler.js +140 -0
- package/dist/index.cjs +6 -0
- package/dist/index.d.cts +4 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/schema/v1.cjs +26 -0
- package/dist/schema/v1.js +24 -0
- package/dist/types/index.d.cts +20 -0
- package/dist/types/index.d.ts +20 -0
- package/package.json +73 -0
- package/src/adapters/drizzle.ts +1 -0
- package/src/adapters/kysely.ts +1 -0
- package/src/adapters/mongodb.ts +1 -0
- package/src/adapters/prisma.ts +1 -0
- package/src/adapters/typeorm.ts +1 -0
- package/src/calculatePagination.ts +34 -0
- package/src/db/index.spec.ts +231 -0
- package/src/db/index.ts +490 -0
- package/src/handler-standalone-integration.spec.ts +341 -0
- package/src/handler.ts +231 -0
- package/src/index.ts +3 -0
- package/src/schema/v1.ts +22 -0
- package/src/types/index.ts +21 -0
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import { PGlite } from "@electric-sql/pglite";
|
|
2
|
+
import type { Bundle } from "@hot-updater/core";
|
|
3
|
+
import { NIL_UUID } from "@hot-updater/core";
|
|
4
|
+
import { kyselyAdapter } from "@hot-updater/server/adapters/kysely";
|
|
5
|
+
import { standaloneRepository } from "@hot-updater/standalone";
|
|
6
|
+
import { Kysely } from "kysely";
|
|
7
|
+
import { PGliteDialect } from "kysely-pglite-dialect";
|
|
8
|
+
import { HttpResponse, http } from "msw";
|
|
9
|
+
import { setupServer } from "msw/node";
|
|
10
|
+
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
|
11
|
+
import { hotUpdater } from "./db";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Integration tests between @hot-updater/server handler and @hot-updater/standalone repository
|
|
15
|
+
*
|
|
16
|
+
* This test suite verifies real-world compatibility by:
|
|
17
|
+
* 1. Using actual standaloneRepository (not mocks)
|
|
18
|
+
* 2. Using actual handler (not mocks)
|
|
19
|
+
* 3. Simulating HTTP communication via MSW
|
|
20
|
+
* 4. Testing end-to-end flows: standalone → HTTP → handler → database
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
// Create in-memory database for testing
|
|
24
|
+
const db = new PGlite();
|
|
25
|
+
const kysely = new Kysely({ dialect: new PGliteDialect(db) });
|
|
26
|
+
|
|
27
|
+
// Create handler API with in-memory DB
|
|
28
|
+
const api = hotUpdater({
|
|
29
|
+
database: kyselyAdapter({
|
|
30
|
+
db: kysely,
|
|
31
|
+
provider: "postgresql",
|
|
32
|
+
}),
|
|
33
|
+
basePath: "/hot-updater",
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// Setup MSW server to intercept HTTP requests
|
|
37
|
+
const baseUrl = "http://localhost:3000";
|
|
38
|
+
const server = setupServer();
|
|
39
|
+
|
|
40
|
+
beforeAll(async () => {
|
|
41
|
+
// Initialize database
|
|
42
|
+
const migrator = api.createMigrator();
|
|
43
|
+
const result = await migrator.migrateToLatest({
|
|
44
|
+
mode: "from-schema",
|
|
45
|
+
updateSettings: true,
|
|
46
|
+
});
|
|
47
|
+
await result.execute();
|
|
48
|
+
|
|
49
|
+
// Start MSW server
|
|
50
|
+
server.listen({ onUnhandledRequest: "error" });
|
|
51
|
+
|
|
52
|
+
// Route all requests to our handler
|
|
53
|
+
const handleRequest = async (request: Request) => {
|
|
54
|
+
const response = await api.handler(request);
|
|
55
|
+
const data = (await response.json()) as Record<string, unknown>;
|
|
56
|
+
return HttpResponse.json(data, {
|
|
57
|
+
status: response.status,
|
|
58
|
+
headers: response.headers,
|
|
59
|
+
});
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
server.use(
|
|
63
|
+
// Specific routes
|
|
64
|
+
http.get(`${baseUrl}/hot-updater/bundles`, ({ request }) =>
|
|
65
|
+
handleRequest(request),
|
|
66
|
+
),
|
|
67
|
+
http.get(`${baseUrl}/hot-updater/bundles/:id`, ({ request }) =>
|
|
68
|
+
handleRequest(request),
|
|
69
|
+
),
|
|
70
|
+
http.post(`${baseUrl}/hot-updater/bundles`, ({ request }) =>
|
|
71
|
+
handleRequest(request),
|
|
72
|
+
),
|
|
73
|
+
http.delete(`${baseUrl}/hot-updater/bundles/:id`, ({ request }) =>
|
|
74
|
+
handleRequest(request),
|
|
75
|
+
),
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
afterEach(async () => {
|
|
80
|
+
// Clean up database after each test
|
|
81
|
+
await db.exec("DELETE FROM bundles");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
afterAll(async () => {
|
|
85
|
+
server.close();
|
|
86
|
+
await kysely.destroy();
|
|
87
|
+
await db.close();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const createTestBundle = (overrides?: Partial<Bundle>): Bundle => ({
|
|
91
|
+
id: NIL_UUID,
|
|
92
|
+
platform: "ios",
|
|
93
|
+
channel: "production",
|
|
94
|
+
enabled: true,
|
|
95
|
+
shouldForceUpdate: false,
|
|
96
|
+
fileHash: "test-hash",
|
|
97
|
+
gitCommitHash: null,
|
|
98
|
+
message: null,
|
|
99
|
+
targetAppVersion: "*",
|
|
100
|
+
storageUri: "test://storage",
|
|
101
|
+
fingerprintHash: null,
|
|
102
|
+
...overrides,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("Handler <-> Standalone Repository Integration", () => {
|
|
106
|
+
it("Real integration: appendBundle + commitBundle → handler POST /bundles", async () => {
|
|
107
|
+
// Create standalone repository pointing to our test server
|
|
108
|
+
const repo = standaloneRepository({
|
|
109
|
+
baseUrl: `${baseUrl}/hot-updater`,
|
|
110
|
+
})({ cwd: process.cwd() });
|
|
111
|
+
|
|
112
|
+
const bundle = createTestBundle({
|
|
113
|
+
id: "integration-test-1",
|
|
114
|
+
fileHash: "integration-hash-1",
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// Standalone repository operations
|
|
118
|
+
await repo.appendBundle(bundle);
|
|
119
|
+
await repo.commitBundle(); // Triggers actual commit
|
|
120
|
+
|
|
121
|
+
// Verify via handler that bundle was created
|
|
122
|
+
const request = new Request(
|
|
123
|
+
`${baseUrl}/hot-updater/bundles/integration-test-1`,
|
|
124
|
+
{
|
|
125
|
+
method: "GET",
|
|
126
|
+
},
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
const response = await api.handler(request);
|
|
130
|
+
expect(response.status).toBe(200);
|
|
131
|
+
|
|
132
|
+
const retrieved = (await response.json()) as Bundle;
|
|
133
|
+
expect(retrieved.id).toBe("integration-test-1");
|
|
134
|
+
expect(retrieved.fileHash).toBe("integration-hash-1");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("Real integration: getBundleById → handler GET /bundles/:id", async () => {
|
|
138
|
+
// First, create a bundle directly via handler
|
|
139
|
+
const bundle = createTestBundle({
|
|
140
|
+
id: "get-test-1",
|
|
141
|
+
fileHash: "get-hash-1",
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
await api.insertBundle(bundle);
|
|
145
|
+
|
|
146
|
+
// Create standalone repository
|
|
147
|
+
const repo = standaloneRepository({
|
|
148
|
+
baseUrl: `${baseUrl}/hot-updater`,
|
|
149
|
+
})({ cwd: process.cwd() });
|
|
150
|
+
|
|
151
|
+
// Use standalone repository to retrieve
|
|
152
|
+
const retrieved = await repo.getBundleById("get-test-1");
|
|
153
|
+
|
|
154
|
+
expect(retrieved).toBeTruthy();
|
|
155
|
+
expect(retrieved?.id).toBe("get-test-1");
|
|
156
|
+
expect(retrieved?.fileHash).toBe("get-hash-1");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("Real integration: deleteBundle + commitBundle → handler DELETE /bundles/:id", async () => {
|
|
160
|
+
// Create a bundle via handler
|
|
161
|
+
const bundle = createTestBundle({
|
|
162
|
+
id: "delete-test-1",
|
|
163
|
+
fileHash: "delete-hash-1",
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
await api.insertBundle(bundle);
|
|
167
|
+
|
|
168
|
+
// Verify it exists
|
|
169
|
+
const beforeDelete = await api.getBundleById("delete-test-1");
|
|
170
|
+
expect(beforeDelete).toBeTruthy();
|
|
171
|
+
|
|
172
|
+
// Create standalone repository
|
|
173
|
+
const repo = standaloneRepository({
|
|
174
|
+
baseUrl: `${baseUrl}/hot-updater`,
|
|
175
|
+
})({ cwd: process.cwd() });
|
|
176
|
+
|
|
177
|
+
// Delete via standalone repository
|
|
178
|
+
await repo.deleteBundle(bundle);
|
|
179
|
+
await repo.commitBundle();
|
|
180
|
+
|
|
181
|
+
// Verify it was deleted
|
|
182
|
+
const afterDelete = await api.getBundleById("delete-test-1");
|
|
183
|
+
expect(afterDelete).toBeNull();
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("Real integration: getBundles → handler GET /bundles", async () => {
|
|
187
|
+
// Create multiple bundles
|
|
188
|
+
await api.insertBundle(
|
|
189
|
+
createTestBundle({ id: "list-1", channel: "production" }),
|
|
190
|
+
);
|
|
191
|
+
await api.insertBundle(
|
|
192
|
+
createTestBundle({ id: "list-2", channel: "production" }),
|
|
193
|
+
);
|
|
194
|
+
await api.insertBundle(
|
|
195
|
+
createTestBundle({ id: "list-3", channel: "staging" }),
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
// Create standalone repository
|
|
199
|
+
const repo = standaloneRepository({
|
|
200
|
+
baseUrl: `${baseUrl}/hot-updater`,
|
|
201
|
+
})({ cwd: process.cwd() });
|
|
202
|
+
|
|
203
|
+
// Get all bundles
|
|
204
|
+
const result = await repo.getBundles({ limit: 50, offset: 0 });
|
|
205
|
+
|
|
206
|
+
expect(result.data).toHaveLength(3);
|
|
207
|
+
expect(result.pagination.total).toBe(3);
|
|
208
|
+
|
|
209
|
+
// Filter by channel
|
|
210
|
+
const prodResult = await repo.getBundles({
|
|
211
|
+
where: { channel: "production" },
|
|
212
|
+
limit: 50,
|
|
213
|
+
offset: 0,
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
expect(prodResult.data).toHaveLength(2);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("Full E2E: create → retrieve → update → delete via standalone", async () => {
|
|
220
|
+
const repo = standaloneRepository({
|
|
221
|
+
baseUrl: `${baseUrl}/hot-updater`,
|
|
222
|
+
})({ cwd: process.cwd() });
|
|
223
|
+
|
|
224
|
+
// Step 1: Create bundle via standalone
|
|
225
|
+
const bundle = createTestBundle({
|
|
226
|
+
id: "e2e-bundle",
|
|
227
|
+
fileHash: "e2e-hash",
|
|
228
|
+
enabled: true,
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
await repo.appendBundle(bundle);
|
|
232
|
+
await repo.commitBundle();
|
|
233
|
+
|
|
234
|
+
// Step 2: Retrieve via standalone
|
|
235
|
+
const retrieved = await repo.getBundleById("e2e-bundle");
|
|
236
|
+
expect(retrieved).toBeTruthy();
|
|
237
|
+
expect(retrieved?.enabled).toBe(true);
|
|
238
|
+
|
|
239
|
+
// Step 3: Update via standalone
|
|
240
|
+
await repo.updateBundle("e2e-bundle", { enabled: false });
|
|
241
|
+
await repo.commitBundle();
|
|
242
|
+
|
|
243
|
+
// Verify update
|
|
244
|
+
const updated = await repo.getBundleById("e2e-bundle");
|
|
245
|
+
expect(updated?.enabled).toBe(false);
|
|
246
|
+
|
|
247
|
+
// Step 4: Delete via standalone
|
|
248
|
+
await repo.deleteBundle(bundle);
|
|
249
|
+
await repo.commitBundle();
|
|
250
|
+
|
|
251
|
+
// Verify deletion
|
|
252
|
+
const deleted = await repo.getBundleById("e2e-bundle");
|
|
253
|
+
expect(deleted).toBeNull();
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("Multiple bundles in single commit (standalone sends array)", async () => {
|
|
257
|
+
const repo = standaloneRepository({
|
|
258
|
+
baseUrl: `${baseUrl}/hot-updater`,
|
|
259
|
+
})({ cwd: process.cwd() });
|
|
260
|
+
|
|
261
|
+
// Append multiple bundles
|
|
262
|
+
await repo.appendBundle(createTestBundle({ id: "batch-1" }));
|
|
263
|
+
await repo.appendBundle(createTestBundle({ id: "batch-2" }));
|
|
264
|
+
await repo.appendBundle(createTestBundle({ id: "batch-3" }));
|
|
265
|
+
|
|
266
|
+
// Commit all at once (standalone sends array in POST)
|
|
267
|
+
await repo.commitBundle();
|
|
268
|
+
|
|
269
|
+
// Verify all were created
|
|
270
|
+
const bundle1 = await api.getBundleById("batch-1");
|
|
271
|
+
const bundle2 = await api.getBundleById("batch-2");
|
|
272
|
+
const bundle3 = await api.getBundleById("batch-3");
|
|
273
|
+
|
|
274
|
+
expect(bundle1).toBeTruthy();
|
|
275
|
+
expect(bundle2).toBeTruthy();
|
|
276
|
+
expect(bundle3).toBeTruthy();
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("Works with custom basePath configuration", async () => {
|
|
280
|
+
// Create handler with custom basePath
|
|
281
|
+
const customApi = hotUpdater({
|
|
282
|
+
database: kyselyAdapter({
|
|
283
|
+
db: kysely,
|
|
284
|
+
provider: "postgresql",
|
|
285
|
+
}),
|
|
286
|
+
basePath: "/api/v2",
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
// Setup MSW for custom basePath
|
|
290
|
+
server.use(
|
|
291
|
+
http.get(`${baseUrl}/api/v2/*`, async ({ request }) => {
|
|
292
|
+
const response = await customApi.handler(request);
|
|
293
|
+
return HttpResponse.json(
|
|
294
|
+
(await response.json()) as Record<string, unknown>,
|
|
295
|
+
{
|
|
296
|
+
status: response.status,
|
|
297
|
+
},
|
|
298
|
+
);
|
|
299
|
+
}),
|
|
300
|
+
http.post(`${baseUrl}/api/v2/*`, async ({ request }) => {
|
|
301
|
+
const response = await customApi.handler(request);
|
|
302
|
+
return HttpResponse.json(
|
|
303
|
+
(await response.json()) as Record<string, unknown>,
|
|
304
|
+
{
|
|
305
|
+
status: response.status,
|
|
306
|
+
},
|
|
307
|
+
);
|
|
308
|
+
}),
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
// Create standalone repository with matching basePath
|
|
312
|
+
const repo = standaloneRepository({
|
|
313
|
+
baseUrl: `${baseUrl}/api/v2`,
|
|
314
|
+
})({ cwd: process.cwd() });
|
|
315
|
+
|
|
316
|
+
// Test create and retrieve
|
|
317
|
+
const bundle = createTestBundle({
|
|
318
|
+
id: "custom-path-test",
|
|
319
|
+
fileHash: "custom-hash",
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
await repo.appendBundle(bundle);
|
|
323
|
+
await repo.commitBundle();
|
|
324
|
+
|
|
325
|
+
const retrieved = await repo.getBundleById("custom-path-test");
|
|
326
|
+
expect(retrieved).toBeTruthy();
|
|
327
|
+
expect(retrieved?.fileHash).toBe("custom-hash");
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("Handler returns 404 when bundle not found (standalone handles gracefully)", async () => {
|
|
331
|
+
const repo = standaloneRepository({
|
|
332
|
+
baseUrl: `${baseUrl}/hot-updater`,
|
|
333
|
+
})({ cwd: process.cwd() });
|
|
334
|
+
|
|
335
|
+
// Try to get non-existent bundle
|
|
336
|
+
const result = await repo.getBundleById("non-existent-bundle");
|
|
337
|
+
|
|
338
|
+
// Standalone should return null gracefully
|
|
339
|
+
expect(result).toBeNull();
|
|
340
|
+
});
|
|
341
|
+
});
|
package/src/handler.ts
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AppUpdateInfo,
|
|
3
|
+
AppVersionGetBundlesArgs,
|
|
4
|
+
Bundle,
|
|
5
|
+
FingerprintGetBundlesArgs,
|
|
6
|
+
} from "@hot-updater/core";
|
|
7
|
+
import type { PaginationInfo } from "./types";
|
|
8
|
+
|
|
9
|
+
// Narrow API surface needed by the handler to avoid circular types
|
|
10
|
+
export interface HandlerAPI {
|
|
11
|
+
getAppUpdateInfo: (
|
|
12
|
+
args: AppVersionGetBundlesArgs | FingerprintGetBundlesArgs,
|
|
13
|
+
) => Promise<AppUpdateInfo | null>;
|
|
14
|
+
getBundleById: (id: string) => Promise<Bundle | null>;
|
|
15
|
+
getBundles: (options: {
|
|
16
|
+
where?: { channel?: string; platform?: string };
|
|
17
|
+
limit: number;
|
|
18
|
+
offset: number;
|
|
19
|
+
}) => Promise<{ data: Bundle[]; pagination: PaginationInfo }>;
|
|
20
|
+
insertBundle: (bundle: Bundle) => Promise<void>;
|
|
21
|
+
deleteBundleById: (bundleId: string) => Promise<void>;
|
|
22
|
+
getChannels: () => Promise<string[]>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface HandlerOptions {
|
|
26
|
+
/**
|
|
27
|
+
* Base path for all routes
|
|
28
|
+
* @default "/api"
|
|
29
|
+
*/
|
|
30
|
+
basePath?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Creates a Web Standard Request handler for Hot Updater API
|
|
35
|
+
* This handler is framework-agnostic and works with any framework
|
|
36
|
+
* that supports Web Standard Request/Response (Hono, Elysia, etc.)
|
|
37
|
+
*/
|
|
38
|
+
export function createHandler(
|
|
39
|
+
api: HandlerAPI,
|
|
40
|
+
options: HandlerOptions = {},
|
|
41
|
+
): (request: Request) => Promise<Response> {
|
|
42
|
+
const basePath = options.basePath ?? "/api";
|
|
43
|
+
|
|
44
|
+
return async (request: Request): Promise<Response> => {
|
|
45
|
+
try {
|
|
46
|
+
const url = new URL(request.url);
|
|
47
|
+
const path = url.pathname;
|
|
48
|
+
const method = request.method;
|
|
49
|
+
|
|
50
|
+
// Remove base path from pathname
|
|
51
|
+
const routePath = path.startsWith(basePath)
|
|
52
|
+
? path.slice(basePath.length)
|
|
53
|
+
: path;
|
|
54
|
+
|
|
55
|
+
// POST /api/update - Client checks for updates
|
|
56
|
+
if (routePath === "/update" && method === "POST") {
|
|
57
|
+
const body = (await request.json()) as
|
|
58
|
+
| AppVersionGetBundlesArgs
|
|
59
|
+
| FingerprintGetBundlesArgs;
|
|
60
|
+
const updateInfo = await api.getAppUpdateInfo(body);
|
|
61
|
+
|
|
62
|
+
if (!updateInfo) {
|
|
63
|
+
return new Response(JSON.stringify(null), {
|
|
64
|
+
status: 200,
|
|
65
|
+
headers: { "Content-Type": "application/json" },
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return new Response(JSON.stringify(updateInfo), {
|
|
70
|
+
status: 200,
|
|
71
|
+
headers: { "Content-Type": "application/json" },
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// GET /api/fingerprint/:platform/:fingerprintHash/:channel/:minBundleId/:bundleId
|
|
76
|
+
const fingerprintMatch = routePath.match(
|
|
77
|
+
/^\/fingerprint\/([^/]+)\/([^/]+)\/([^/]+)\/([^/]+)\/([^/]+)$/,
|
|
78
|
+
);
|
|
79
|
+
if (fingerprintMatch && method === "GET") {
|
|
80
|
+
const [, platform, fingerprintHash, channel, minBundleId, bundleId] =
|
|
81
|
+
fingerprintMatch;
|
|
82
|
+
|
|
83
|
+
const updateInfo = await api.getAppUpdateInfo({
|
|
84
|
+
_updateStrategy: "fingerprint",
|
|
85
|
+
platform: platform as "ios" | "android",
|
|
86
|
+
fingerprintHash,
|
|
87
|
+
channel,
|
|
88
|
+
minBundleId,
|
|
89
|
+
bundleId,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
if (!updateInfo) {
|
|
93
|
+
return new Response(JSON.stringify(null), {
|
|
94
|
+
status: 200,
|
|
95
|
+
headers: { "Content-Type": "application/json" },
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return new Response(JSON.stringify(updateInfo), {
|
|
100
|
+
status: 200,
|
|
101
|
+
headers: { "Content-Type": "application/json" },
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// GET /api/app-version/:platform/:appVersion/:channel/:minBundleId/:bundleId
|
|
106
|
+
const appVersionMatch = routePath.match(
|
|
107
|
+
/^\/app-version\/([^/]+)\/([^/]+)\/([^/]+)\/([^/]+)\/([^/]+)$/,
|
|
108
|
+
);
|
|
109
|
+
if (appVersionMatch && method === "GET") {
|
|
110
|
+
const [, platform, appVersion, channel, minBundleId, bundleId] =
|
|
111
|
+
appVersionMatch;
|
|
112
|
+
|
|
113
|
+
const updateInfo = await api.getAppUpdateInfo({
|
|
114
|
+
_updateStrategy: "appVersion",
|
|
115
|
+
platform: platform as "ios" | "android",
|
|
116
|
+
appVersion,
|
|
117
|
+
channel,
|
|
118
|
+
minBundleId,
|
|
119
|
+
bundleId,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
if (!updateInfo) {
|
|
123
|
+
return new Response(JSON.stringify(null), {
|
|
124
|
+
status: 200,
|
|
125
|
+
headers: { "Content-Type": "application/json" },
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return new Response(JSON.stringify(updateInfo), {
|
|
130
|
+
status: 200,
|
|
131
|
+
headers: { "Content-Type": "application/json" },
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// GET /api/bundles/:id - Get single bundle
|
|
136
|
+
const getBundleMatch = routePath.match(/^\/bundles\/([^/]+)$/);
|
|
137
|
+
if (getBundleMatch && method === "GET") {
|
|
138
|
+
const id = getBundleMatch[1];
|
|
139
|
+
const bundle = await api.getBundleById(id);
|
|
140
|
+
|
|
141
|
+
if (!bundle) {
|
|
142
|
+
return new Response(JSON.stringify({ error: "Bundle not found" }), {
|
|
143
|
+
status: 404,
|
|
144
|
+
headers: { "Content-Type": "application/json" },
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return new Response(JSON.stringify(bundle), {
|
|
149
|
+
status: 200,
|
|
150
|
+
headers: { "Content-Type": "application/json" },
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// GET /api/bundles - List bundles
|
|
155
|
+
if (routePath === "/bundles" && method === "GET") {
|
|
156
|
+
const channel = url.searchParams.get("channel") ?? undefined;
|
|
157
|
+
const platform = url.searchParams.get("platform") ?? undefined;
|
|
158
|
+
const limit = Number(url.searchParams.get("limit")) || 50;
|
|
159
|
+
const offset = Number(url.searchParams.get("offset")) || 0;
|
|
160
|
+
|
|
161
|
+
const result = await api.getBundles({
|
|
162
|
+
where: {
|
|
163
|
+
...(channel && { channel }),
|
|
164
|
+
...(platform && { platform }),
|
|
165
|
+
},
|
|
166
|
+
limit,
|
|
167
|
+
offset,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
return new Response(JSON.stringify(result.data), {
|
|
171
|
+
status: 200,
|
|
172
|
+
headers: { "Content-Type": "application/json" },
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// POST /api/bundles - Create new bundle(s)
|
|
177
|
+
if (routePath === "/bundles" && method === "POST") {
|
|
178
|
+
const body = await request.json();
|
|
179
|
+
const bundles = Array.isArray(body) ? body : [body];
|
|
180
|
+
|
|
181
|
+
for (const bundle of bundles) {
|
|
182
|
+
await api.insertBundle(bundle as Bundle);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return new Response(JSON.stringify({ success: true }), {
|
|
186
|
+
status: 201,
|
|
187
|
+
headers: { "Content-Type": "application/json" },
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// DELETE /api/bundles/:id - Delete bundle
|
|
192
|
+
if (routePath.startsWith("/bundles/") && method === "DELETE") {
|
|
193
|
+
const id = routePath.slice("/bundles/".length);
|
|
194
|
+
await api.deleteBundleById(id);
|
|
195
|
+
|
|
196
|
+
return new Response(JSON.stringify({ success: true }), {
|
|
197
|
+
status: 200,
|
|
198
|
+
headers: { "Content-Type": "application/json" },
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// GET /api/channels - List all channels
|
|
203
|
+
if (routePath === "/channels" && method === "GET") {
|
|
204
|
+
const channels = await api.getChannels();
|
|
205
|
+
|
|
206
|
+
return new Response(JSON.stringify({ channels }), {
|
|
207
|
+
status: 200,
|
|
208
|
+
headers: { "Content-Type": "application/json" },
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// 404 Not Found
|
|
213
|
+
return new Response(JSON.stringify({ error: "Not found" }), {
|
|
214
|
+
status: 404,
|
|
215
|
+
headers: { "Content-Type": "application/json" },
|
|
216
|
+
});
|
|
217
|
+
} catch (error) {
|
|
218
|
+
console.error("Hot Updater handler error:", error);
|
|
219
|
+
return new Response(
|
|
220
|
+
JSON.stringify({
|
|
221
|
+
error: "Internal server error",
|
|
222
|
+
message: error instanceof Error ? error.message : "Unknown error",
|
|
223
|
+
}),
|
|
224
|
+
{
|
|
225
|
+
status: 500,
|
|
226
|
+
headers: { "Content-Type": "application/json" },
|
|
227
|
+
},
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
}
|
package/src/index.ts
ADDED
package/src/schema/v1.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { column, idColumn, schema, table } from "fumadb/schema";
|
|
2
|
+
|
|
3
|
+
export const v1 = schema({
|
|
4
|
+
version: "1.0.0",
|
|
5
|
+
tables: {
|
|
6
|
+
bundles: table("bundles", {
|
|
7
|
+
id: idColumn("id", "varchar(255)").defaultTo$("auto"),
|
|
8
|
+
platform: column("platform", "string"),
|
|
9
|
+
should_force_update: column("should_force_update", "bool"),
|
|
10
|
+
enabled: column("enabled", "bool"),
|
|
11
|
+
file_hash: column("file_hash", "string"),
|
|
12
|
+
git_commit_hash: column("git_commit_hash", "string").nullable(),
|
|
13
|
+
message: column("message", "string").nullable(),
|
|
14
|
+
channel: column("channel", "string"),
|
|
15
|
+
storage_uri: column("storage_uri", "string"),
|
|
16
|
+
target_app_version: column("target_app_version", "string").nullable(),
|
|
17
|
+
fingerprint_hash: column("fingerprint_hash", "string").nullable(),
|
|
18
|
+
metadata: column("metadata", "json"),
|
|
19
|
+
}),
|
|
20
|
+
},
|
|
21
|
+
relations: {},
|
|
22
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Bundle } from "@hot-updater/core";
|
|
2
|
+
|
|
3
|
+
export type { Bundle } from "@hot-updater/core";
|
|
4
|
+
|
|
5
|
+
export interface PaginationInfo {
|
|
6
|
+
total: number;
|
|
7
|
+
hasNextPage: boolean;
|
|
8
|
+
hasPreviousPage: boolean;
|
|
9
|
+
currentPage: number;
|
|
10
|
+
totalPages: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface PaginationOptions {
|
|
14
|
+
limit: number;
|
|
15
|
+
offset: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface PaginatedResult {
|
|
19
|
+
data: Bundle[];
|
|
20
|
+
pagination: PaginationInfo;
|
|
21
|
+
}
|