@hot-updater/server 0.28.0 → 0.29.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.
Files changed (84) hide show
  1. package/dist/adapters/drizzle.cjs +7 -7
  2. package/dist/adapters/drizzle.mjs +2 -0
  3. package/dist/adapters/kysely.cjs +7 -7
  4. package/dist/adapters/kysely.mjs +2 -0
  5. package/dist/adapters/mongodb.cjs +7 -7
  6. package/dist/adapters/mongodb.mjs +2 -0
  7. package/dist/adapters/prisma.cjs +7 -7
  8. package/dist/adapters/prisma.mjs +2 -0
  9. package/dist/calculatePagination.cjs +1 -3
  10. package/dist/{calculatePagination.js → calculatePagination.mjs} +1 -2
  11. package/dist/db/index.cjs +24 -15
  12. package/dist/db/index.d.cts +12 -9
  13. package/dist/db/index.d.mts +30 -0
  14. package/dist/db/index.mjs +45 -0
  15. package/dist/db/ormCore.cjs +247 -138
  16. package/dist/db/ormCore.d.cts +35 -17
  17. package/dist/db/ormCore.d.mts +44 -0
  18. package/dist/db/ormCore.mjs +386 -0
  19. package/dist/db/pluginCore.cjs +145 -40
  20. package/dist/db/pluginCore.mjs +176 -0
  21. package/dist/db/types.cjs +1 -3
  22. package/dist/db/types.d.cts +14 -21
  23. package/dist/db/types.d.mts +24 -0
  24. package/dist/db/{types.js → types.mjs} +1 -2
  25. package/dist/handler.cjs +117 -48
  26. package/dist/handler.d.cts +28 -18
  27. package/dist/handler.d.mts +47 -0
  28. package/dist/handler.mjs +217 -0
  29. package/dist/index.cjs +5 -5
  30. package/dist/index.d.cts +3 -3
  31. package/dist/index.d.mts +5 -0
  32. package/dist/index.mjs +4 -0
  33. package/dist/internalRouter.cjs +54 -0
  34. package/dist/internalRouter.mjs +52 -0
  35. package/dist/node.cjs +2 -3
  36. package/dist/node.d.cts +0 -1
  37. package/dist/{node.d.ts → node.d.mts} +1 -2
  38. package/dist/{node.js → node.mjs} +1 -2
  39. package/dist/route.cjs +7 -0
  40. package/dist/route.mjs +7 -0
  41. package/dist/runtime.cjs +42 -0
  42. package/dist/runtime.d.cts +21 -0
  43. package/dist/runtime.d.mts +21 -0
  44. package/dist/runtime.mjs +40 -0
  45. package/dist/schema/v0_21_0.cjs +1 -5
  46. package/dist/schema/{v0_21_0.js → v0_21_0.mjs} +1 -3
  47. package/dist/schema/v0_29_0.cjs +24 -0
  48. package/dist/schema/v0_29_0.mjs +24 -0
  49. package/dist/types/{index.d.ts → index.d.mts} +1 -1
  50. package/package.json +18 -18
  51. package/src/db/index.spec.ts +64 -29
  52. package/src/db/index.ts +55 -35
  53. package/src/db/ormCore.ts +438 -210
  54. package/src/db/ormUpdateCheck.bench.ts +261 -0
  55. package/src/db/pluginCore.ts +298 -49
  56. package/src/db/pluginUpdateCheck.bench.ts +250 -0
  57. package/src/db/types.ts +52 -27
  58. package/src/{handler-standalone-integration.spec.ts → handler-standalone.integration.spec.ts} +106 -0
  59. package/src/handler.spec.ts +156 -0
  60. package/src/handler.ts +296 -77
  61. package/src/internalRouter.ts +104 -0
  62. package/src/route.ts +7 -0
  63. package/src/runtime.spec.ts +277 -0
  64. package/src/runtime.ts +121 -0
  65. package/src/schema/v0_29_0.ts +26 -0
  66. package/dist/_virtual/rolldown_runtime.cjs +0 -25
  67. package/dist/adapters/drizzle.js +0 -3
  68. package/dist/adapters/kysely.js +0 -3
  69. package/dist/adapters/mongodb.js +0 -3
  70. package/dist/adapters/prisma.js +0 -3
  71. package/dist/db/index.d.ts +0 -27
  72. package/dist/db/index.js +0 -36
  73. package/dist/db/ormCore.d.ts +0 -26
  74. package/dist/db/ormCore.js +0 -273
  75. package/dist/db/pluginCore.js +0 -69
  76. package/dist/db/types.d.ts +0 -31
  77. package/dist/handler.d.ts +0 -37
  78. package/dist/handler.js +0 -146
  79. package/dist/index.d.ts +0 -5
  80. package/dist/index.js +0 -5
  81. /package/dist/adapters/{drizzle.d.ts → drizzle.d.mts} +0 -0
  82. /package/dist/adapters/{kysely.d.ts → kysely.d.mts} +0 -0
  83. /package/dist/adapters/{mongodb.d.ts → mongodb.d.mts} +0 -0
  84. /package/dist/adapters/{prisma.d.ts → prisma.d.mts} +0 -0
package/src/handler.ts CHANGED
@@ -3,26 +3,45 @@ import type {
3
3
  AppVersionGetBundlesArgs,
4
4
  Bundle,
5
5
  FingerprintGetBundlesArgs,
6
+ Platform,
6
7
  } from "@hot-updater/core";
7
- import { addRoute, createRouter, findRoute } from "rou3";
8
+ import type {
9
+ DatabaseBundleQueryOptions,
10
+ HotUpdaterContext,
11
+ } from "@hot-updater/plugin-core";
12
+ import { addRoute, createRouter, findRoute } from "./internalRouter";
8
13
  import type { PaginationInfo } from "./types";
9
14
 
10
15
  declare const __VERSION__: string;
11
16
 
12
17
  // Narrow API surface needed by the handler to avoid circular types
13
- export interface HandlerAPI {
18
+ export interface HandlerAPI<TContext = unknown> {
14
19
  getAppUpdateInfo: (
15
20
  args: AppVersionGetBundlesArgs | FingerprintGetBundlesArgs,
21
+ context?: HotUpdaterContext<TContext>,
16
22
  ) => Promise<AppUpdateInfo | null>;
17
- getBundleById: (id: string) => Promise<Bundle | null>;
18
- getBundles: (options: {
19
- where?: { channel?: string; platform?: string };
20
- limit: number;
21
- offset: number;
22
- }) => Promise<{ data: Bundle[]; pagination: PaginationInfo }>;
23
- insertBundle: (bundle: Bundle) => Promise<void>;
24
- deleteBundleById: (bundleId: string) => Promise<void>;
25
- getChannels: () => Promise<string[]>;
23
+ getBundleById: (
24
+ id: string,
25
+ context?: HotUpdaterContext<TContext>,
26
+ ) => Promise<Bundle | null>;
27
+ getBundles: (
28
+ options: DatabaseBundleQueryOptions,
29
+ context?: HotUpdaterContext<TContext>,
30
+ ) => Promise<{ data: Bundle[]; pagination: PaginationInfo }>;
31
+ insertBundle: (
32
+ bundle: Bundle,
33
+ context?: HotUpdaterContext<TContext>,
34
+ ) => Promise<void>;
35
+ updateBundleById: (
36
+ bundleId: string,
37
+ bundle: Partial<Bundle>,
38
+ context?: HotUpdaterContext<TContext>,
39
+ ) => Promise<void>;
40
+ deleteBundleById: (
41
+ bundleId: string,
42
+ context?: HotUpdaterContext<TContext>,
43
+ ) => Promise<void>;
44
+ getChannels: (context?: HotUpdaterContext<TContext>) => Promise<string[]>;
26
45
  }
27
46
 
28
47
  export interface HandlerOptions {
@@ -31,14 +50,38 @@ export interface HandlerOptions {
31
50
  * @default "/api"
32
51
  */
33
52
  basePath?: string;
53
+ routes?: HandlerRoutes;
54
+ }
55
+
56
+ export interface HandlerRoutes {
57
+ /**
58
+ * Controls whether update-check routes are mounted.
59
+ * @default true
60
+ */
61
+ updateCheck?: boolean;
62
+ /**
63
+ * Controls whether bundle management routes are mounted.
64
+ * This includes `/version` and `/api/bundles*`, which are used by the
65
+ * CLI `standaloneRepository` plugin.
66
+ * @default true
67
+ */
68
+ bundles?: boolean;
34
69
  }
35
70
 
36
- type RouteHandler = (
71
+ type RouteHandler<TContext = unknown> = (
37
72
  params: Record<string, string>,
38
73
  request: Request,
39
- api: HandlerAPI,
74
+ api: HandlerAPI<TContext>,
75
+ context?: HotUpdaterContext<TContext>,
40
76
  ) => Promise<Response>;
41
77
 
78
+ class HandlerBadRequestError extends Error {
79
+ constructor(message: string) {
80
+ super(message);
81
+ this.name = "HandlerBadRequestError";
82
+ }
83
+ }
84
+
42
85
  // Route handlers
43
86
  const handleVersion: RouteHandler = async () => {
44
87
  return new Response(JSON.stringify({ version: __VERSION__ }), {
@@ -47,15 +90,88 @@ const handleVersion: RouteHandler = async () => {
47
90
  });
48
91
  };
49
92
 
50
- const handleFingerprintUpdate: RouteHandler = async (params, _request, api) => {
51
- const updateInfo = await api.getAppUpdateInfo({
52
- _updateStrategy: "fingerprint",
53
- platform: params.platform as "ios" | "android",
54
- fingerprintHash: params.fingerprintHash,
55
- channel: params.channel,
56
- minBundleId: params.minBundleId,
57
- bundleId: params.bundleId,
58
- });
93
+ const decodeMaybe = (value: string | undefined): string | undefined => {
94
+ if (value === undefined) return undefined;
95
+ try {
96
+ return decodeURIComponent(value);
97
+ } catch {
98
+ return value;
99
+ }
100
+ };
101
+
102
+ const isPlatform = (value: string): value is Platform => {
103
+ return value === "ios" || value === "android";
104
+ };
105
+
106
+ const requireRouteParam = (
107
+ params: Record<string, string>,
108
+ key: string,
109
+ ): string => {
110
+ const value = params[key];
111
+ if (!value) {
112
+ throw new HandlerBadRequestError(`Missing route parameter: ${key}`);
113
+ }
114
+
115
+ return value;
116
+ };
117
+
118
+ const requirePlatformParam = (params: Record<string, string>): Platform => {
119
+ const platform = requireRouteParam(params, "platform");
120
+
121
+ if (!isPlatform(platform)) {
122
+ throw new HandlerBadRequestError(
123
+ `Invalid platform: ${platform}. Expected 'ios' or 'android'.`,
124
+ );
125
+ }
126
+
127
+ return platform;
128
+ };
129
+
130
+ type BundlePatchPayload = Partial<Bundle> & {
131
+ id?: string;
132
+ };
133
+
134
+ const requireBundlePatchPayload = (
135
+ payload: unknown,
136
+ bundleId: string,
137
+ ): Partial<Bundle> => {
138
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
139
+ throw new HandlerBadRequestError("Invalid bundle payload");
140
+ }
141
+
142
+ const bundlePatch = payload as BundlePatchPayload;
143
+ if (bundlePatch.id !== undefined && bundlePatch.id !== bundleId) {
144
+ throw new HandlerBadRequestError("Bundle id mismatch");
145
+ }
146
+
147
+ const { id: _ignoredId, ...rest } = bundlePatch;
148
+ return rest;
149
+ };
150
+
151
+ const handleFingerprintUpdateWithCohort: RouteHandler = async (
152
+ params,
153
+ _request,
154
+ api,
155
+ context,
156
+ ) => {
157
+ const platform = requirePlatformParam(params);
158
+ const fingerprintHash = requireRouteParam(params, "fingerprintHash");
159
+ const channel = requireRouteParam(params, "channel");
160
+ const minBundleId = requireRouteParam(params, "minBundleId");
161
+ const bundleId = requireRouteParam(params, "bundleId");
162
+
163
+ const updateInfo = await api.getAppUpdateInfo(
164
+ {
165
+ _updateStrategy: "fingerprint",
166
+ platform,
167
+ fingerprintHash,
168
+ channel,
169
+ minBundleId,
170
+ bundleId,
171
+ cohort: decodeMaybe(params.cohort),
172
+ },
173
+ context,
174
+ );
59
175
 
60
176
  return new Response(JSON.stringify(updateInfo), {
61
177
  status: 200,
@@ -63,15 +179,30 @@ const handleFingerprintUpdate: RouteHandler = async (params, _request, api) => {
63
179
  });
64
180
  };
65
181
 
66
- const handleAppVersionUpdate: RouteHandler = async (params, _request, api) => {
67
- const updateInfo = await api.getAppUpdateInfo({
68
- _updateStrategy: "appVersion",
69
- platform: params.platform as "ios" | "android",
70
- appVersion: params.appVersion,
71
- channel: params.channel,
72
- minBundleId: params.minBundleId,
73
- bundleId: params.bundleId,
74
- });
182
+ const handleAppVersionUpdateWithCohort: RouteHandler = async (
183
+ params,
184
+ _request,
185
+ api,
186
+ context,
187
+ ) => {
188
+ const platform = requirePlatformParam(params);
189
+ const appVersion = requireRouteParam(params, "appVersion");
190
+ const channel = requireRouteParam(params, "channel");
191
+ const minBundleId = requireRouteParam(params, "minBundleId");
192
+ const bundleId = requireRouteParam(params, "bundleId");
193
+
194
+ const updateInfo = await api.getAppUpdateInfo(
195
+ {
196
+ _updateStrategy: "appVersion",
197
+ platform,
198
+ appVersion,
199
+ channel,
200
+ minBundleId,
201
+ bundleId,
202
+ cohort: decodeMaybe(params.cohort),
203
+ },
204
+ context,
205
+ );
75
206
 
76
207
  return new Response(JSON.stringify(updateInfo), {
77
208
  status: 200,
@@ -79,8 +210,14 @@ const handleAppVersionUpdate: RouteHandler = async (params, _request, api) => {
79
210
  });
80
211
  };
81
212
 
82
- const handleGetBundle: RouteHandler = async (params, _request, api) => {
83
- const bundle = await api.getBundleById(params.id);
213
+ const handleGetBundle: RouteHandler = async (
214
+ params,
215
+ _request,
216
+ api,
217
+ context,
218
+ ) => {
219
+ const bundleId = requireRouteParam(params, "id");
220
+ const bundle = await api.getBundleById(bundleId, context);
84
221
 
85
222
  if (!bundle) {
86
223
  return new Response(JSON.stringify({ error: "Bundle not found" }), {
@@ -95,21 +232,35 @@ const handleGetBundle: RouteHandler = async (params, _request, api) => {
95
232
  });
96
233
  };
97
234
 
98
- const handleGetBundles: RouteHandler = async (_params, request, api) => {
235
+ const handleGetBundles: RouteHandler = async (
236
+ _params,
237
+ request,
238
+ api,
239
+ context,
240
+ ) => {
99
241
  const url = new URL(request.url);
100
242
  const channel = url.searchParams.get("channel") ?? undefined;
101
- const platform = url.searchParams.get("platform") ?? undefined;
243
+ const platform = url.searchParams.get("platform");
102
244
  const limit = Number(url.searchParams.get("limit")) || 50;
103
245
  const offset = Number(url.searchParams.get("offset")) || 0;
104
246
 
105
- const result = await api.getBundles({
106
- where: {
107
- ...(channel && { channel }),
108
- ...(platform && { platform }),
247
+ if (platform !== null && !isPlatform(platform)) {
248
+ throw new HandlerBadRequestError(
249
+ `Invalid platform: ${platform}. Expected 'ios' or 'android'.`,
250
+ );
251
+ }
252
+
253
+ const result = await api.getBundles(
254
+ {
255
+ where: {
256
+ ...(channel && { channel }),
257
+ ...(platform && { platform }),
258
+ },
259
+ limit,
260
+ offset,
109
261
  },
110
- limit,
111
- offset,
112
- });
262
+ context,
263
+ );
113
264
 
114
265
  return new Response(JSON.stringify(result.data), {
115
266
  status: 200,
@@ -117,12 +268,17 @@ const handleGetBundles: RouteHandler = async (_params, request, api) => {
117
268
  });
118
269
  };
119
270
 
120
- const handleCreateBundles: RouteHandler = async (_params, request, api) => {
271
+ const handleCreateBundles: RouteHandler = async (
272
+ _params,
273
+ request,
274
+ api,
275
+ context,
276
+ ) => {
121
277
  const body = await request.json();
122
278
  const bundles = Array.isArray(body) ? body : [body];
123
279
 
124
280
  for (const bundle of bundles) {
125
- await api.insertBundle(bundle as Bundle);
281
+ await api.insertBundle(bundle as Bundle, context);
126
282
  }
127
283
 
128
284
  return new Response(JSON.stringify({ success: true }), {
@@ -131,8 +287,32 @@ const handleCreateBundles: RouteHandler = async (_params, request, api) => {
131
287
  });
132
288
  };
133
289
 
134
- const handleDeleteBundle: RouteHandler = async (params, _request, api) => {
135
- await api.deleteBundleById(params.id);
290
+ const handleUpdateBundle: RouteHandler = async (
291
+ params,
292
+ request,
293
+ api,
294
+ context,
295
+ ) => {
296
+ const bundleId = requireRouteParam(params, "id");
297
+ const body = await request.json();
298
+ const payload = Array.isArray(body) ? body[0] : body;
299
+ const bundlePatch = requireBundlePatchPayload(payload, bundleId);
300
+ await api.updateBundleById(bundleId, bundlePatch, context);
301
+
302
+ return new Response(JSON.stringify({ success: true }), {
303
+ status: 200,
304
+ headers: { "Content-Type": "application/json" },
305
+ });
306
+ };
307
+
308
+ const handleDeleteBundle: RouteHandler = async (
309
+ params,
310
+ _request,
311
+ api,
312
+ context,
313
+ ) => {
314
+ const bundleId = requireRouteParam(params, "id");
315
+ await api.deleteBundleById(bundleId, context);
136
316
 
137
317
  return new Response(JSON.stringify({ success: true }), {
138
318
  status: 200,
@@ -140,8 +320,13 @@ const handleDeleteBundle: RouteHandler = async (params, _request, api) => {
140
320
  });
141
321
  };
142
322
 
143
- const handleGetChannels: RouteHandler = async (_params, _request, api) => {
144
- const channels = await api.getChannels();
323
+ const handleGetChannels: RouteHandler = async (
324
+ _params,
325
+ _request,
326
+ api,
327
+ context,
328
+ ) => {
329
+ const channels = await api.getChannels(context);
145
330
 
146
331
  return new Response(JSON.stringify({ channels }), {
147
332
  status: 200,
@@ -150,52 +335,79 @@ const handleGetChannels: RouteHandler = async (_params, _request, api) => {
150
335
  };
151
336
 
152
337
  // Route handlers map
153
- const routes: Record<string, RouteHandler> = {
338
+ const routes: Record<string, RouteHandler<any>> = {
154
339
  version: handleVersion,
155
- fingerprintUpdate: handleFingerprintUpdate,
156
- appVersionUpdate: handleAppVersionUpdate,
340
+ fingerprintUpdateWithCohort: handleFingerprintUpdateWithCohort,
341
+ appVersionUpdateWithCohort: handleAppVersionUpdateWithCohort,
157
342
  getBundle: handleGetBundle,
158
343
  getBundles: handleGetBundles,
159
344
  createBundles: handleCreateBundles,
345
+ updateBundle: handleUpdateBundle,
160
346
  deleteBundle: handleDeleteBundle,
161
347
  getChannels: handleGetChannels,
162
348
  };
163
349
 
164
350
  /**
165
351
  * Creates a Web Standard Request handler for Hot Updater API
166
- * This handler is framework-agnostic and works with any framework
167
- * that supports Web Standard Request/Response (Hono, Elysia, etc.)
352
+ * This handler is framework-agnostic and works with any runtime that
353
+ * supports standard Request/Response objects.
168
354
  */
169
- export function createHandler(
170
- api: HandlerAPI,
355
+ export function createHandler<TContext = unknown>(
356
+ api: HandlerAPI<TContext>,
171
357
  options: HandlerOptions = {},
172
- ): (request: Request) => Promise<Response> {
358
+ ): (
359
+ request: Request,
360
+ context?: HotUpdaterContext<TContext>,
361
+ ) => Promise<Response> {
173
362
  const basePath = options.basePath ?? "/api";
363
+ const updateCheckEnabled = options.routes?.updateCheck ?? true;
364
+ const bundlesEnabled = options.routes?.bundles ?? true;
174
365
 
175
366
  // Create and configure router
176
367
  const router = createRouter();
177
368
 
178
369
  // Register routes
179
- addRoute(router, "GET", "/version", "version");
180
- addRoute(
181
- router,
182
- "GET",
183
- "/fingerprint/:platform/:fingerprintHash/:channel/:minBundleId/:bundleId",
184
- "fingerprintUpdate",
185
- );
186
- addRoute(
187
- router,
188
- "GET",
189
- "/app-version/:platform/:appVersion/:channel/:minBundleId/:bundleId",
190
- "appVersionUpdate",
191
- );
192
- addRoute(router, "GET", "/api/bundles/channels", "getChannels");
193
- addRoute(router, "GET", "/api/bundles/:id", "getBundle");
194
- addRoute(router, "GET", "/api/bundles", "getBundles");
195
- addRoute(router, "POST", "/api/bundles", "createBundles");
196
- addRoute(router, "DELETE", "/api/bundles/:id", "deleteBundle");
370
+ if (updateCheckEnabled) {
371
+ addRoute(
372
+ router,
373
+ "GET",
374
+ "/fingerprint/:platform/:fingerprintHash/:channel/:minBundleId/:bundleId",
375
+ "fingerprintUpdateWithCohort",
376
+ );
377
+ addRoute(
378
+ router,
379
+ "GET",
380
+ "/fingerprint/:platform/:fingerprintHash/:channel/:minBundleId/:bundleId/:cohort",
381
+ "fingerprintUpdateWithCohort",
382
+ );
383
+ addRoute(
384
+ router,
385
+ "GET",
386
+ "/app-version/:platform/:appVersion/:channel/:minBundleId/:bundleId",
387
+ "appVersionUpdateWithCohort",
388
+ );
389
+ addRoute(
390
+ router,
391
+ "GET",
392
+ "/app-version/:platform/:appVersion/:channel/:minBundleId/:bundleId/:cohort",
393
+ "appVersionUpdateWithCohort",
394
+ );
395
+ }
396
+
397
+ if (bundlesEnabled) {
398
+ addRoute(router, "GET", "/version", "version");
399
+ addRoute(router, "GET", "/api/bundles/channels", "getChannels");
400
+ addRoute(router, "GET", "/api/bundles/:id", "getBundle");
401
+ addRoute(router, "GET", "/api/bundles", "getBundles");
402
+ addRoute(router, "POST", "/api/bundles", "createBundles");
403
+ addRoute(router, "PATCH", "/api/bundles/:id", "updateBundle");
404
+ addRoute(router, "DELETE", "/api/bundles/:id", "deleteBundle");
405
+ }
197
406
 
198
- return async (request: Request): Promise<Response> => {
407
+ return async (
408
+ request: Request,
409
+ context?: HotUpdaterContext<TContext>,
410
+ ): Promise<Response> => {
199
411
  try {
200
412
  const url = new URL(request.url);
201
413
  const path = url.pathname;
@@ -217,7 +429,7 @@ export function createHandler(
217
429
  }
218
430
 
219
431
  // Get handler and execute
220
- const handler = routes[match.data as string];
432
+ const handler = routes[match.data as string] as RouteHandler<TContext>;
221
433
  if (!handler) {
222
434
  return new Response(JSON.stringify({ error: "Handler not found" }), {
223
435
  status: 500,
@@ -225,8 +437,15 @@ export function createHandler(
225
437
  });
226
438
  }
227
439
 
228
- return await handler(match.params || {}, request, api);
440
+ return await handler(match.params || {}, request, api, context);
229
441
  } catch (error) {
442
+ if (error instanceof HandlerBadRequestError) {
443
+ return new Response(JSON.stringify({ error: error.message }), {
444
+ status: 400,
445
+ headers: { "Content-Type": "application/json" },
446
+ });
447
+ }
448
+
230
449
  console.error("Hot Updater handler error:", error);
231
450
  return new Response(
232
451
  JSON.stringify({
@@ -0,0 +1,104 @@
1
+ interface RouteRecord<T> {
2
+ data: T;
3
+ method: string;
4
+ paramNames: string[];
5
+ segments: string[];
6
+ }
7
+
8
+ interface RouteMatch<T> {
9
+ data: T;
10
+ params: Record<string, string>;
11
+ }
12
+
13
+ interface Router<T> {
14
+ routes: RouteRecord<T>[];
15
+ }
16
+
17
+ const normalizePath = (path: string) => {
18
+ if (!path) {
19
+ return "/";
20
+ }
21
+
22
+ if (path === "/") {
23
+ return path;
24
+ }
25
+
26
+ const withLeadingSlash = path.startsWith("/") ? path : `/${path}`;
27
+ return withLeadingSlash.endsWith("/")
28
+ ? withLeadingSlash.slice(0, -1)
29
+ : withLeadingSlash;
30
+ };
31
+
32
+ const toSegments = (path: string) => {
33
+ const normalized = normalizePath(path);
34
+ return normalized === "/" ? [] : normalized.slice(1).split("/");
35
+ };
36
+
37
+ export function createRouter<T>(): Router<T> {
38
+ return { routes: [] };
39
+ }
40
+
41
+ export function addRoute<T>(
42
+ router: Router<T>,
43
+ method: string,
44
+ path: string,
45
+ data: T,
46
+ ) {
47
+ const segments = toSegments(path);
48
+ const paramNames = segments
49
+ .filter((segment) => segment.startsWith(":"))
50
+ .map((segment) => segment.slice(1));
51
+
52
+ router.routes.push({
53
+ data,
54
+ method: method.toUpperCase(),
55
+ paramNames,
56
+ segments,
57
+ });
58
+ }
59
+
60
+ export function findRoute<T>(
61
+ router: Router<T>,
62
+ method: string,
63
+ path: string,
64
+ ): RouteMatch<T> | undefined {
65
+ const normalizedMethod = method.toUpperCase();
66
+ const pathSegments = toSegments(path);
67
+
68
+ for (const route of router.routes) {
69
+ if (route.method !== normalizedMethod) {
70
+ continue;
71
+ }
72
+
73
+ if (route.segments.length !== pathSegments.length) {
74
+ continue;
75
+ }
76
+
77
+ const params: Record<string, string> = {};
78
+ let matched = true;
79
+
80
+ for (let index = 0; index < route.segments.length; index += 1) {
81
+ const routeSegment = route.segments[index];
82
+ const pathSegment = pathSegments[index];
83
+
84
+ if (routeSegment.startsWith(":")) {
85
+ params[routeSegment.slice(1)] = pathSegment;
86
+ continue;
87
+ }
88
+
89
+ if (routeSegment !== pathSegment) {
90
+ matched = false;
91
+ break;
92
+ }
93
+ }
94
+
95
+ if (matched) {
96
+ return {
97
+ data: route.data,
98
+ params,
99
+ };
100
+ }
101
+ }
102
+
103
+ return undefined;
104
+ }
package/src/route.ts ADDED
@@ -0,0 +1,7 @@
1
+ export const normalizeBasePath = (basePath?: string) => {
2
+ if (!basePath || basePath === "/") {
3
+ return "/";
4
+ }
5
+
6
+ return basePath.endsWith("/") ? basePath.slice(0, -1) : basePath;
7
+ };