@mokup/runtime 0.0.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/dist/index.mjs ADDED
@@ -0,0 +1,583 @@
1
+ const paramNamePattern = /^[\w-]+$/;
2
+ const paramPattern = /^\[([^\]/]+)\]$/;
3
+ const catchallPattern = /^\[\.\.\.([^\]/]+)\]$/;
4
+ const optionalCatchallPattern = /^\[\[\.\.\.([^\]/]+)\]\]$/;
5
+ const groupPattern = /^\([^)]+\)$/;
6
+ function decodeSegment(segment) {
7
+ try {
8
+ return decodeURIComponent(segment);
9
+ } catch {
10
+ return segment;
11
+ }
12
+ }
13
+ function scoreToken(token) {
14
+ switch (token.type) {
15
+ case "static":
16
+ return 4;
17
+ case "param":
18
+ return 3;
19
+ case "catchall":
20
+ return 2;
21
+ case "optional-catchall":
22
+ return 1;
23
+ default:
24
+ return 0;
25
+ }
26
+ }
27
+ function scoreRouteTokens(tokens) {
28
+ return tokens.map(scoreToken);
29
+ }
30
+ function compareRouteScore(a, b) {
31
+ const min = Math.min(a.length, b.length);
32
+ for (let i = 0; i < min; i += 1) {
33
+ const aValue = a[i] ?? 0;
34
+ const bValue = b[i] ?? 0;
35
+ if (aValue !== bValue) {
36
+ return bValue - aValue;
37
+ }
38
+ }
39
+ if (a.length !== b.length) {
40
+ return b.length - a.length;
41
+ }
42
+ return 0;
43
+ }
44
+ function normalizePathname(value) {
45
+ const withoutQuery = value.split("?")[0] ?? "";
46
+ const withoutHash = withoutQuery.split("#")[0] ?? "";
47
+ let normalized = withoutHash.startsWith("/") ? withoutHash : `/${withoutHash}`;
48
+ if (normalized.length > 1 && normalized.endsWith("/")) {
49
+ normalized = normalized.slice(0, -1);
50
+ }
51
+ return normalized;
52
+ }
53
+ function splitPath(value) {
54
+ return normalizePathname(value).split("/").filter(Boolean);
55
+ }
56
+ function parseRouteTemplate(template) {
57
+ const errors = [];
58
+ const warnings = [];
59
+ const normalized = normalizePathname(template);
60
+ const segments = splitPath(normalized);
61
+ const tokens = [];
62
+ const seenParams = /* @__PURE__ */ new Set();
63
+ for (let index = 0; index < segments.length; index += 1) {
64
+ const segment = segments[index];
65
+ if (!segment) {
66
+ continue;
67
+ }
68
+ if (groupPattern.test(segment)) {
69
+ errors.push(`Route groups are not supported: ${segment}`);
70
+ continue;
71
+ }
72
+ const optionalCatchallMatch = segment.match(optionalCatchallPattern);
73
+ if (optionalCatchallMatch) {
74
+ const name = optionalCatchallMatch[1];
75
+ if (!name) {
76
+ errors.push(`Invalid optional catch-all param name "${segment}"`);
77
+ continue;
78
+ }
79
+ if (!paramNamePattern.test(name)) {
80
+ errors.push(`Invalid optional catch-all param name "${name}"`);
81
+ continue;
82
+ }
83
+ if (index !== segments.length - 1) {
84
+ errors.push(`Optional catch-all "${segment}" must be the last segment`);
85
+ continue;
86
+ }
87
+ if (seenParams.has(name)) {
88
+ warnings.push(`Duplicate param name "${name}"`);
89
+ }
90
+ seenParams.add(name);
91
+ tokens.push({ type: "optional-catchall", name });
92
+ continue;
93
+ }
94
+ const catchallMatch = segment.match(catchallPattern);
95
+ if (catchallMatch) {
96
+ const name = catchallMatch[1];
97
+ if (!name) {
98
+ errors.push(`Invalid catch-all param name "${segment}"`);
99
+ continue;
100
+ }
101
+ if (!paramNamePattern.test(name)) {
102
+ errors.push(`Invalid catch-all param name "${name}"`);
103
+ continue;
104
+ }
105
+ if (index !== segments.length - 1) {
106
+ errors.push(`Catch-all "${segment}" must be the last segment`);
107
+ continue;
108
+ }
109
+ if (seenParams.has(name)) {
110
+ warnings.push(`Duplicate param name "${name}"`);
111
+ }
112
+ seenParams.add(name);
113
+ tokens.push({ type: "catchall", name });
114
+ continue;
115
+ }
116
+ const paramMatch = segment.match(paramPattern);
117
+ if (paramMatch) {
118
+ const name = paramMatch[1];
119
+ if (!name) {
120
+ errors.push(`Invalid param name "${segment}"`);
121
+ continue;
122
+ }
123
+ if (!paramNamePattern.test(name)) {
124
+ errors.push(`Invalid param name "${name}"`);
125
+ continue;
126
+ }
127
+ if (seenParams.has(name)) {
128
+ warnings.push(`Duplicate param name "${name}"`);
129
+ }
130
+ seenParams.add(name);
131
+ tokens.push({ type: "param", name });
132
+ continue;
133
+ }
134
+ if (segment.includes("[") || segment.includes("]") || segment.includes("(") || segment.includes(")")) {
135
+ errors.push(`Invalid route segment "${segment}"`);
136
+ continue;
137
+ }
138
+ tokens.push({ type: "static", value: segment });
139
+ }
140
+ return {
141
+ template: normalized,
142
+ tokens,
143
+ score: scoreRouteTokens(tokens),
144
+ errors,
145
+ warnings
146
+ };
147
+ }
148
+ function matchRouteTokens(tokens, pathname) {
149
+ const segments = splitPath(pathname);
150
+ const params = {};
151
+ let index = 0;
152
+ for (const token of tokens) {
153
+ if (token.type === "static") {
154
+ const segment = segments[index];
155
+ if (segment !== token.value) {
156
+ return null;
157
+ }
158
+ index += 1;
159
+ continue;
160
+ }
161
+ if (token.type === "param") {
162
+ const segment = segments[index];
163
+ if (!segment) {
164
+ return null;
165
+ }
166
+ params[token.name] = decodeSegment(segment);
167
+ index += 1;
168
+ continue;
169
+ }
170
+ if (token.type === "catchall") {
171
+ if (index >= segments.length) {
172
+ return null;
173
+ }
174
+ params[token.name] = segments.slice(index).map(decodeSegment);
175
+ index = segments.length;
176
+ continue;
177
+ }
178
+ if (token.type === "optional-catchall") {
179
+ params[token.name] = segments.slice(index).map(decodeSegment);
180
+ index = segments.length;
181
+ continue;
182
+ }
183
+ }
184
+ if (index !== segments.length) {
185
+ return null;
186
+ }
187
+ return { params };
188
+ }
189
+
190
+ function resolveModuleUrl(modulePath, moduleBase) {
191
+ if (/^(?:data|http|https|file):/.test(modulePath)) {
192
+ return modulePath;
193
+ }
194
+ if (!moduleBase) {
195
+ throw new Error("moduleBase is required for relative module paths.");
196
+ }
197
+ const base = typeof moduleBase === "string" ? moduleBase : moduleBase.href;
198
+ if (/^(?:data|http|https|file):/.test(base)) {
199
+ return new URL(modulePath, base).href;
200
+ }
201
+ const normalizedBase = base.endsWith("/") ? base : `${base}/`;
202
+ const normalizedModule = modulePath.startsWith("./") ? modulePath.slice(2) : modulePath.startsWith("/") ? modulePath.slice(1) : modulePath;
203
+ return `${normalizedBase}${normalizedModule}`;
204
+ }
205
+ function normalizeRules(value) {
206
+ if (!value) {
207
+ return [];
208
+ }
209
+ if (Array.isArray(value)) {
210
+ return value;
211
+ }
212
+ if (typeof value === "function") {
213
+ return [
214
+ {
215
+ response: value
216
+ }
217
+ ];
218
+ }
219
+ if (typeof value === "object") {
220
+ return [value];
221
+ }
222
+ return [
223
+ {
224
+ response: value
225
+ }
226
+ ];
227
+ }
228
+ async function executeRule(rule, req, responder, ctx) {
229
+ if (!rule) {
230
+ return void 0;
231
+ }
232
+ const value = rule.response;
233
+ if (typeof value === "function") {
234
+ const handler = value;
235
+ return handler(req, responder, ctx);
236
+ }
237
+ return value;
238
+ }
239
+ function extractMiddlewareSource(value) {
240
+ if (value && typeof value === "object" && "middleware" in value) {
241
+ return value.middleware;
242
+ }
243
+ return value;
244
+ }
245
+ function normalizeMiddleware(value) {
246
+ const resolved = extractMiddlewareSource(value);
247
+ if (!resolved) {
248
+ return [];
249
+ }
250
+ if (Array.isArray(resolved)) {
251
+ return resolved.filter((entry) => typeof entry === "function");
252
+ }
253
+ if (typeof resolved === "function") {
254
+ return [resolved];
255
+ }
256
+ return [];
257
+ }
258
+ async function loadModuleExport(modulePath, exportName, moduleBase, moduleMap) {
259
+ const directMapValue = moduleMap?.[modulePath];
260
+ const resolvedUrl = directMapValue ? void 0 : resolveModuleUrl(modulePath, moduleBase);
261
+ const resolvedMapValue = resolvedUrl ? moduleMap?.[resolvedUrl] : void 0;
262
+ const moduleValue = directMapValue ?? resolvedMapValue;
263
+ const module = moduleValue ?? await import(resolvedUrl ?? modulePath);
264
+ return module[exportName] ?? module.default ?? module;
265
+ }
266
+ function resolveModuleCacheKey(modulePath, exportName, moduleBase, moduleMap) {
267
+ if (moduleMap?.[modulePath]) {
268
+ return `${modulePath}::${exportName}`;
269
+ }
270
+ const resolvedUrl = resolveModuleUrl(modulePath, moduleBase);
271
+ return `${resolvedUrl}::${exportName}`;
272
+ }
273
+ async function loadModuleRule(response, moduleCache, moduleBase, moduleMap) {
274
+ const exportName = response.exportName ?? "default";
275
+ const cacheKey = resolveModuleCacheKey(
276
+ response.module,
277
+ exportName,
278
+ moduleBase,
279
+ moduleMap
280
+ );
281
+ let rules = moduleCache.get(cacheKey);
282
+ if (!rules) {
283
+ const exported = await loadModuleExport(
284
+ response.module,
285
+ exportName,
286
+ moduleBase,
287
+ moduleMap
288
+ );
289
+ rules = normalizeRules(exported);
290
+ moduleCache.set(cacheKey, rules);
291
+ }
292
+ if (typeof response.ruleIndex === "number") {
293
+ return rules[response.ruleIndex];
294
+ }
295
+ return rules[0];
296
+ }
297
+ async function loadModuleMiddleware(middleware, middlewareCache, moduleBase, moduleMap) {
298
+ const exportName = middleware.exportName ?? "default";
299
+ const cacheKey = resolveModuleCacheKey(
300
+ middleware.module,
301
+ exportName,
302
+ moduleBase,
303
+ moduleMap
304
+ );
305
+ let handlers = middlewareCache.get(cacheKey);
306
+ if (!handlers) {
307
+ const exported = await loadModuleExport(
308
+ middleware.module,
309
+ exportName,
310
+ moduleBase,
311
+ moduleMap
312
+ );
313
+ handlers = normalizeMiddleware(exported);
314
+ middlewareCache.set(cacheKey, handlers);
315
+ }
316
+ if (typeof middleware.ruleIndex === "number") {
317
+ return handlers[middleware.ruleIndex];
318
+ }
319
+ return handlers[0];
320
+ }
321
+
322
+ const methodSet = /* @__PURE__ */ new Set([
323
+ "GET",
324
+ "POST",
325
+ "PUT",
326
+ "PATCH",
327
+ "DELETE",
328
+ "OPTIONS",
329
+ "HEAD"
330
+ ]);
331
+ function normalizeMethod(method) {
332
+ if (!method) {
333
+ return void 0;
334
+ }
335
+ const normalized = method.toUpperCase();
336
+ if (methodSet.has(normalized)) {
337
+ return normalized;
338
+ }
339
+ return void 0;
340
+ }
341
+ function mergeHeaders(base, override) {
342
+ if (!override) {
343
+ return base;
344
+ }
345
+ return {
346
+ ...base,
347
+ ...override
348
+ };
349
+ }
350
+ function delay(ms) {
351
+ return new Promise((resolve) => setTimeout(resolve, ms));
352
+ }
353
+
354
+ class ResponseController {
355
+ statusCode = 200;
356
+ headers = /* @__PURE__ */ new Map();
357
+ setHeader(key, value) {
358
+ this.headers.set(key.toLowerCase(), value);
359
+ }
360
+ getHeader(key) {
361
+ return this.headers.get(key.toLowerCase());
362
+ }
363
+ removeHeader(key) {
364
+ this.headers.delete(key.toLowerCase());
365
+ }
366
+ toRecord() {
367
+ const record = {};
368
+ for (const [key, value] of this.headers.entries()) {
369
+ record[key] = value;
370
+ }
371
+ return record;
372
+ }
373
+ }
374
+ function normalizeBody(body, contentType) {
375
+ if (typeof body === "undefined") {
376
+ return { body: null };
377
+ }
378
+ if (typeof body === "string") {
379
+ return {
380
+ body,
381
+ contentType: contentType ?? "text/plain; charset=utf-8"
382
+ };
383
+ }
384
+ if (body instanceof Uint8Array) {
385
+ return {
386
+ body,
387
+ contentType: contentType ?? "application/octet-stream"
388
+ };
389
+ }
390
+ if (body instanceof ArrayBuffer) {
391
+ return {
392
+ body: new Uint8Array(body),
393
+ contentType: contentType ?? "application/octet-stream"
394
+ };
395
+ }
396
+ return {
397
+ body: JSON.stringify(body),
398
+ contentType: contentType ?? "application/json; charset=utf-8"
399
+ };
400
+ }
401
+ function decodeBase64(value) {
402
+ if (typeof atob === "function") {
403
+ const binary = atob(value);
404
+ const bytes = new Uint8Array(binary.length);
405
+ for (let i = 0; i < binary.length; i += 1) {
406
+ bytes[i] = binary.charCodeAt(i);
407
+ }
408
+ return bytes;
409
+ }
410
+ throw new Error("Base64 decoding is not supported in this runtime.");
411
+ }
412
+ function finalizeStatus(status, body) {
413
+ if (status === 200 && body === null) {
414
+ return 204;
415
+ }
416
+ return status;
417
+ }
418
+
419
+ async function executeRoute(route, req, moduleCache, middlewareCache, moduleBase, moduleMap) {
420
+ const responder = new ResponseController();
421
+ const ctx = {
422
+ delay,
423
+ json: (data) => data
424
+ };
425
+ const runHandler = async () => {
426
+ if (route.response.type === "json") {
427
+ return {
428
+ value: route.response.body,
429
+ contentType: "application/json; charset=utf-8"
430
+ };
431
+ }
432
+ if (route.response.type === "text") {
433
+ return {
434
+ value: route.response.body,
435
+ contentType: "text/plain; charset=utf-8"
436
+ };
437
+ }
438
+ if (route.response.type === "binary") {
439
+ return {
440
+ value: decodeBase64(route.response.body),
441
+ contentType: "application/octet-stream"
442
+ };
443
+ }
444
+ const rule = await loadModuleRule(
445
+ route.response,
446
+ moduleCache,
447
+ moduleBase,
448
+ moduleMap
449
+ );
450
+ const value = await executeRule(rule, req, responder, ctx);
451
+ return { value };
452
+ };
453
+ const runMiddlewares = async (middlewares) => {
454
+ let lastIndex = -1;
455
+ const dispatch = async (index) => {
456
+ if (index <= lastIndex) {
457
+ throw new Error("Middleware next() called multiple times.");
458
+ }
459
+ lastIndex = index;
460
+ const handler = middlewares[index];
461
+ if (!handler) {
462
+ return runHandler();
463
+ }
464
+ let nextResult;
465
+ const next = async () => {
466
+ nextResult = await dispatch(index + 1);
467
+ return nextResult.value;
468
+ };
469
+ const value = await handler(req, responder, ctx, next);
470
+ if (typeof value !== "undefined") {
471
+ return { value };
472
+ }
473
+ if (nextResult) {
474
+ return nextResult;
475
+ }
476
+ return { value: void 0 };
477
+ };
478
+ return dispatch(0);
479
+ };
480
+ const middlewareHandlers = [];
481
+ for (const entry of route.middleware ?? []) {
482
+ const handler = await loadModuleMiddleware(
483
+ entry,
484
+ middlewareCache,
485
+ moduleBase,
486
+ moduleMap
487
+ );
488
+ if (handler) {
489
+ middlewareHandlers.push(handler);
490
+ }
491
+ }
492
+ const result = middlewareHandlers.length > 0 ? await runMiddlewares(middlewareHandlers) : await runHandler();
493
+ const responseValue = result.value;
494
+ const contentType = result.contentType;
495
+ if (route.delay && route.delay > 0) {
496
+ await delay(route.delay);
497
+ }
498
+ const headers = mergeHeaders(responder.toRecord(), route.headers);
499
+ if (contentType && !headers["content-type"]) {
500
+ headers["content-type"] = contentType;
501
+ }
502
+ const normalized = normalizeBody(responseValue, headers["content-type"]);
503
+ const status = finalizeStatus(
504
+ route.status ?? responder.statusCode,
505
+ normalized.body
506
+ );
507
+ if (normalized.contentType && !headers["content-type"]) {
508
+ headers["content-type"] = normalized.contentType;
509
+ }
510
+ return {
511
+ status,
512
+ headers,
513
+ body: normalized.body
514
+ };
515
+ }
516
+ function createRuntime(options) {
517
+ let manifestCache = null;
518
+ let compiledRoutes = null;
519
+ const moduleCache = /* @__PURE__ */ new Map();
520
+ const middlewareCache = /* @__PURE__ */ new Map();
521
+ const getManifest = async () => {
522
+ if (!manifestCache) {
523
+ manifestCache = typeof options.manifest === "function" ? await options.manifest() : options.manifest;
524
+ }
525
+ return manifestCache;
526
+ };
527
+ const getRouteList = async () => {
528
+ if (compiledRoutes) {
529
+ return compiledRoutes;
530
+ }
531
+ const manifest = await getManifest();
532
+ const map = /* @__PURE__ */ new Map();
533
+ for (const route of manifest.routes) {
534
+ const method = normalizeMethod(route.method) ?? "GET";
535
+ const parsed = route.tokens ? { tokens: route.tokens, score: route.score ?? scoreRouteTokens(route.tokens), errors: [] } : parseRouteTemplate(route.url);
536
+ if (parsed.errors.length > 0) {
537
+ continue;
538
+ }
539
+ const tokens = route.tokens ?? parsed.tokens;
540
+ const score = route.score ?? parsed.score;
541
+ const list = map.get(method) ?? [];
542
+ list.push({ route, tokens, score });
543
+ map.set(method, list);
544
+ }
545
+ for (const list of map.values()) {
546
+ list.sort((a, b) => compareRouteScore(a.score, b.score));
547
+ }
548
+ compiledRoutes = map;
549
+ return compiledRoutes;
550
+ };
551
+ const handle = async (req) => {
552
+ const method = normalizeMethod(req.method) ?? "GET";
553
+ const map = await getRouteList();
554
+ const list = map.get(method);
555
+ if (!list || list.length === 0) {
556
+ return null;
557
+ }
558
+ for (const entry of list) {
559
+ const matched = matchRouteTokens(entry.tokens, req.path);
560
+ if (!matched) {
561
+ continue;
562
+ }
563
+ const requestWithParams = {
564
+ ...req,
565
+ params: matched.params
566
+ };
567
+ return executeRoute(
568
+ entry.route,
569
+ requestWithParams,
570
+ moduleCache,
571
+ middlewareCache,
572
+ options.moduleBase,
573
+ options.moduleMap
574
+ );
575
+ }
576
+ return null;
577
+ };
578
+ return {
579
+ handle
580
+ };
581
+ }
582
+
583
+ export { compareRouteScore, createRuntime, matchRouteTokens, normalizePathname, parseRouteTemplate, scoreRouteTokens };
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@mokup/runtime",
3
+ "type": "module",
4
+ "version": "0.0.0",
5
+ "description": "Cross-runtime mock matching and response handling for mokup.",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/sonofmagic/mokup.git",
10
+ "directory": "packages/runtime"
11
+ },
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "import": "./dist/index.mjs",
16
+ "require": "./dist/index.cjs"
17
+ }
18
+ },
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
22
+ "main": "./dist/index.cjs",
23
+ "module": "./dist/index.mjs",
24
+ "types": "./dist/index.d.ts",
25
+ "files": [
26
+ "dist"
27
+ ],
28
+ "dependencies": {},
29
+ "devDependencies": {
30
+ "typescript": "^5.9.3",
31
+ "unbuild": "^3.6.1"
32
+ },
33
+ "scripts": {
34
+ "build": "unbuild",
35
+ "dev": "unbuild --stub",
36
+ "lint": "eslint .",
37
+ "typecheck": "tsc -p tsconfig.json --noEmit"
38
+ }
39
+ }