spiceflow 1.17.11 → 1.18.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.
Files changed (57) hide show
  1. package/README.md +168 -3
  2. package/dist/client/errors.d.ts +2 -1
  3. package/dist/client/errors.d.ts.map +1 -1
  4. package/dist/client/errors.js +3 -1
  5. package/dist/client/errors.js.map +1 -1
  6. package/dist/client/fetch.d.ts +86 -0
  7. package/dist/client/fetch.d.ts.map +1 -0
  8. package/dist/client/fetch.js +143 -0
  9. package/dist/client/fetch.js.map +1 -0
  10. package/dist/client/index.d.ts +4 -9
  11. package/dist/client/index.d.ts.map +1 -1
  12. package/dist/client/index.js +39 -151
  13. package/dist/client/index.js.map +1 -1
  14. package/dist/client/shared.d.ts +47 -0
  15. package/dist/client/shared.d.ts.map +1 -0
  16. package/dist/client/shared.js +314 -0
  17. package/dist/client/shared.js.map +1 -0
  18. package/dist/client/types.d.ts +3 -1
  19. package/dist/client/types.d.ts.map +1 -1
  20. package/dist/client.test.js +43 -0
  21. package/dist/client.test.js.map +1 -1
  22. package/dist/fetch-client.test.d.ts +2 -0
  23. package/dist/fetch-client.test.d.ts.map +1 -0
  24. package/dist/fetch-client.test.js +362 -0
  25. package/dist/fetch-client.test.js.map +1 -0
  26. package/dist/index.d.ts +1 -1
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +1 -1
  29. package/dist/index.js.map +1 -1
  30. package/dist/mcp-client-transport.d.ts.map +1 -1
  31. package/dist/mcp-client-transport.js +5 -2
  32. package/dist/mcp-client-transport.js.map +1 -1
  33. package/dist/mcp.d.ts +1 -1
  34. package/dist/mcp.d.ts.map +1 -1
  35. package/dist/openapi.d.ts +1 -1
  36. package/dist/openapi.d.ts.map +1 -1
  37. package/dist/spiceflow.d.ts +36 -14
  38. package/dist/spiceflow.d.ts.map +1 -1
  39. package/dist/spiceflow.js +49 -16
  40. package/dist/spiceflow.js.map +1 -1
  41. package/dist/spiceflow.test.js +205 -1
  42. package/dist/spiceflow.test.js.map +1 -1
  43. package/dist/stream.test.js +1 -1
  44. package/dist/stream.test.js.map +1 -1
  45. package/package.json +3 -3
  46. package/src/client/errors.ts +3 -0
  47. package/src/client/fetch.ts +447 -0
  48. package/src/client/index.ts +73 -192
  49. package/src/client/shared.ts +406 -0
  50. package/src/client/types.ts +3 -1
  51. package/src/client.test.ts +52 -0
  52. package/src/fetch-client.test.ts +411 -0
  53. package/src/index.ts +1 -1
  54. package/src/mcp-client-transport.ts +5 -2
  55. package/src/spiceflow.test.ts +315 -1
  56. package/src/spiceflow.ts +106 -32
  57. package/src/stream.test.ts +1 -1
@@ -1,6 +1,6 @@
1
1
  import { test, describe, expect } from 'vitest'
2
2
 
3
- import { bfs, cloneDeep, Spiceflow } from './spiceflow.ts'
3
+ import { bfs, cloneDeep, createSafePath, Spiceflow } from './spiceflow.ts'
4
4
  import { z } from 'zod'
5
5
  import { createSpiceflowClient } from './client/index.ts'
6
6
 
@@ -1257,6 +1257,320 @@ describe('safePath', () => {
1257
1257
  expect(res.status).toBe(200)
1258
1258
  expect(await res.json()).toBe('/target')
1259
1259
  })
1260
+
1261
+ test('safePath appends query params', () => {
1262
+ const app = new Spiceflow()
1263
+ .get('/search', () => 'results', {
1264
+ query: z.object({ q: z.string(), page: z.coerce.number() }),
1265
+ })
1266
+ .get('/users/:id', ({ params }) => params.id, {
1267
+ query: z.object({ fields: z.string() }),
1268
+ })
1269
+
1270
+ expect(app.safePath('/search', { q: 'hello', page: 1 })).toBe(
1271
+ '/search?q=hello&page=1',
1272
+ )
1273
+ expect(
1274
+ app.safePath('/users/:id', { id: '42', fields: 'name' }),
1275
+ ).toBe('/users/42?fields=name')
1276
+
1277
+ // @ts-expect-error - invalid query key 'invalid' not in schema
1278
+ app.safePath('/search', { invalid: 'x' })
1279
+
1280
+ // @ts-expect-error - invalid query key 'nonexistent' not in schema
1281
+ app.safePath('/users/:id', { id: '1', nonexistent: 'x' })
1282
+ })
1283
+
1284
+ test('safePath with query params and no path params', () => {
1285
+ const app = new Spiceflow().get('/items', () => 'items', {
1286
+ query: z.object({ sort: z.string(), limit: z.coerce.number() }),
1287
+ })
1288
+
1289
+ expect(
1290
+ app.safePath('/items', { sort: 'date', limit: 10 }),
1291
+ ).toBe('/items?sort=date&limit=10')
1292
+
1293
+ // @ts-expect-error - wrong query key
1294
+ app.safePath('/items', { order: 'asc' })
1295
+ })
1296
+
1297
+ test('safePath without query still works', () => {
1298
+ const app = new Spiceflow()
1299
+ .get('/simple', () => 'simple')
1300
+ .get('/with-query', () => 'q', {
1301
+ query: z.object({ x: z.string() }),
1302
+ })
1303
+
1304
+ expect(app.safePath('/simple')).toBe('/simple')
1305
+ expect(app.safePath('/with-query')).toBe('/with-query')
1306
+ })
1307
+
1308
+ test('safePath with .route and query', () => {
1309
+ const app = new Spiceflow().route({
1310
+ method: 'GET',
1311
+ path: '/api/search',
1312
+ query: z.object({ term: z.string() }),
1313
+ handler: () => 'search',
1314
+ })
1315
+
1316
+ expect(
1317
+ app.safePath('/api/search', { term: 'test' }),
1318
+ ).toBe('/api/search?term=test')
1319
+
1320
+ // @ts-expect-error - invalid query key for .route-based route
1321
+ app.safePath('/api/search', { wrong: 'x' })
1322
+ })
1323
+
1324
+ test('safePath skips undefined/null query values', () => {
1325
+ const app = new Spiceflow().get('/filter', () => 'filter', {
1326
+ query: z.object({ a: z.string(), b: z.string().optional() }),
1327
+ })
1328
+
1329
+ expect(
1330
+ app.safePath('/filter', { a: 'yes', b: undefined }),
1331
+ ).toBe('/filter?a=yes')
1332
+ })
1333
+
1334
+ test('safePath query with basePath', () => {
1335
+ const app = new Spiceflow({ basePath: '/api' }).get(
1336
+ '/search',
1337
+ () => 'search',
1338
+ {
1339
+ query: z.object({ q: z.string() }),
1340
+ },
1341
+ )
1342
+
1343
+ expect(
1344
+ app.safePath('/api/search', { q: 'hello' }),
1345
+ ).toBe('/api/search?q=hello')
1346
+
1347
+ // @ts-expect-error - invalid query key with basePath
1348
+ app.safePath('/api/search', { wrong: 'x' })
1349
+ })
1350
+
1351
+ test('safePath query with all HTTP method shorthands', () => {
1352
+ const app = new Spiceflow()
1353
+ .put('/put-q', () => 'put', {
1354
+ query: z.object({ x: z.string() }),
1355
+ })
1356
+ .patch('/patch-q', () => 'patch', {
1357
+ query: z.object({ y: z.coerce.number() }),
1358
+ })
1359
+ .delete('/del-q', () => 'del', {
1360
+ query: z.object({ confirm: z.boolean() }),
1361
+ })
1362
+
1363
+ expect(app.safePath('/put-q', { x: 'val' })).toBe(
1364
+ '/put-q?x=val',
1365
+ )
1366
+ expect(app.safePath('/patch-q', { y: 5 })).toBe(
1367
+ '/patch-q?y=5',
1368
+ )
1369
+ expect(app.safePath('/del-q', { confirm: true })).toBe(
1370
+ '/del-q?confirm=true',
1371
+ )
1372
+
1373
+ // @ts-expect-error - wrong query key on put
1374
+ app.safePath('/put-q', { wrong: 'x' })
1375
+
1376
+ // @ts-expect-error - wrong query key on patch
1377
+ app.safePath('/patch-q', { wrong: 1 })
1378
+
1379
+ // @ts-expect-error - wrong query key on delete
1380
+ app.safePath('/del-q', { wrong: true })
1381
+ })
1382
+
1383
+ test('safePath routes without query schema accept arbitrary query at runtime', () => {
1384
+ const app = new Spiceflow().get('/no-schema', () => 'ok')
1385
+
1386
+ expect(
1387
+ app.safePath('/no-schema', { anything: 'works' }),
1388
+ ).toBe('/no-schema?anything=works')
1389
+ })
1390
+ })
1391
+
1392
+ describe('createSafePath', () => {
1393
+ test('works with simple paths', () => {
1394
+ const app = new Spiceflow()
1395
+ .get('/users', () => 'users')
1396
+ .get('/posts', () => 'posts')
1397
+
1398
+ const safePath = createSafePath(app)
1399
+ expect(safePath('/users')).toBe('/users')
1400
+ expect(safePath('/posts')).toBe('/posts')
1401
+ // @ts-expect-error - invalid path
1402
+ safePath('/nonexistent')
1403
+ })
1404
+
1405
+ test('works with path params', () => {
1406
+ const app = new Spiceflow()
1407
+ .get('/users/:id', ({ params }) => params.id)
1408
+ .get('/posts/:postId/comments/:commentId', ({ params }) => params)
1409
+
1410
+ const safePath = createSafePath(app)
1411
+ expect(safePath('/users/:id', { id: '123' })).toBe('/users/123')
1412
+ expect(
1413
+ safePath('/posts/:postId/comments/:commentId', {
1414
+ postId: 'abc',
1415
+ commentId: '456',
1416
+ }),
1417
+ ).toBe('/posts/abc/comments/456')
1418
+ // @ts-expect-error - wrong path
1419
+ safePath('/wrong/:id', { id: '1' })
1420
+ })
1421
+
1422
+ test('works with query params and rejects invalid keys', () => {
1423
+ const app = new Spiceflow()
1424
+ .get('/search', () => 'results', {
1425
+ query: z.object({ q: z.string(), page: z.coerce.number() }),
1426
+ })
1427
+
1428
+ const safePath = createSafePath(app)
1429
+ expect(safePath('/search', { q: 'hello', page: 1 })).toBe(
1430
+ '/search?q=hello&page=1',
1431
+ )
1432
+
1433
+ // @ts-expect-error - invalid query key
1434
+ safePath('/search', { invalid: 'x' })
1435
+ })
1436
+
1437
+ test('works with both path and query params', () => {
1438
+ const app = new Spiceflow()
1439
+ .get('/users/:id', ({ params }) => params.id, {
1440
+ query: z.object({ fields: z.string() }),
1441
+ })
1442
+
1443
+ const safePath = createSafePath(app)
1444
+ expect(
1445
+ safePath('/users/:id', { id: '42', fields: 'name' }),
1446
+ ).toBe('/users/42?fields=name')
1447
+
1448
+ // @ts-expect-error - invalid query key with path params
1449
+ safePath('/users/:id', { id: '1', wrong: 'x' })
1450
+ })
1451
+
1452
+ test('works with wildcard paths', () => {
1453
+ const app = new Spiceflow().get('/files/*', () => 'files')
1454
+
1455
+ const safePath = createSafePath(app)
1456
+ expect(safePath('/files/*', { '*': 'a/b.txt' })).toBe('/files/a/b.txt')
1457
+ })
1458
+
1459
+ test('rejects invalid query keys across multiple routes', () => {
1460
+ const app = new Spiceflow()
1461
+ .get('/items', () => 'items', {
1462
+ query: z.object({ sort: z.string(), limit: z.coerce.number() }),
1463
+ })
1464
+ .post('/create', () => 'created', {
1465
+ query: z.object({ dryRun: z.boolean() }),
1466
+ })
1467
+
1468
+ const safePath = createSafePath(app)
1469
+
1470
+ expect(safePath('/items', { sort: 'name', limit: 10 })).toBe(
1471
+ '/items?sort=name&limit=10',
1472
+ )
1473
+ expect(safePath('/create', { dryRun: true })).toBe(
1474
+ '/create?dryRun=true',
1475
+ )
1476
+
1477
+ // @ts-expect-error - 'order' not in /items query schema
1478
+ safePath('/items', { order: 'asc' })
1479
+
1480
+ // @ts-expect-error - 'verbose' not in /create query schema
1481
+ safePath('/create', { verbose: true })
1482
+ })
1483
+
1484
+ test('works with .route and rejects invalid query keys', () => {
1485
+ const app = new Spiceflow().route({
1486
+ method: 'GET',
1487
+ path: '/api/data',
1488
+ query: z.object({ format: z.string() }),
1489
+ handler: () => 'data',
1490
+ })
1491
+
1492
+ const safePath = createSafePath(app)
1493
+ expect(safePath('/api/data', { format: 'json' })).toBe(
1494
+ '/api/data?format=json',
1495
+ )
1496
+
1497
+ // @ts-expect-error - invalid query key on .route
1498
+ safePath('/api/data', { type: 'csv' })
1499
+
1500
+ // @ts-expect-error - invalid path
1501
+ safePath('/api/other')
1502
+ })
1503
+
1504
+ test('works with basePath and rejects invalid query keys', () => {
1505
+ const app = new Spiceflow({ basePath: '/v2' }).get(
1506
+ '/users',
1507
+ () => 'users',
1508
+ {
1509
+ query: z.object({ active: z.boolean() }),
1510
+ },
1511
+ )
1512
+
1513
+ const safePath = createSafePath(app)
1514
+ expect(safePath('/v2/users', { active: true })).toBe(
1515
+ '/v2/users?active=true',
1516
+ )
1517
+
1518
+ // @ts-expect-error - invalid query key
1519
+ safePath('/v2/users', { status: 'active' })
1520
+
1521
+ // @ts-expect-error - path without basePath prefix
1522
+ safePath('/users')
1523
+ })
1524
+
1525
+ test('without query schema allows arbitrary query', () => {
1526
+ const app = new Spiceflow().get('/free', () => 'ok')
1527
+
1528
+ const safePath = createSafePath(app)
1529
+ expect(
1530
+ safePath('/free', { any: 'value', works: 'here' }),
1531
+ ).toBe('/free?any=value&works=here')
1532
+ })
1533
+
1534
+ test('partial query params are accepted', () => {
1535
+ const app = new Spiceflow().get('/filter', () => 'filter', {
1536
+ query: z.object({ a: z.string(), b: z.string(), c: z.string() }),
1537
+ })
1538
+
1539
+ const safePath = createSafePath(app)
1540
+ expect(safePath('/filter', { a: 'only-a' })).toBe(
1541
+ '/filter?a=only-a',
1542
+ )
1543
+ expect(safePath('/filter', { a: '1', c: '3' })).toBe(
1544
+ '/filter?a=1&c=3',
1545
+ )
1546
+ })
1547
+
1548
+ test('mixed routes with and without query schemas', () => {
1549
+ const app = new Spiceflow()
1550
+ .get('/typed', () => 'typed', {
1551
+ query: z.object({ x: z.string() }),
1552
+ })
1553
+ .get('/untyped', () => 'untyped')
1554
+ .get('/also-typed/:id', ({ params }) => params.id, {
1555
+ query: z.object({ verbose: z.boolean() }),
1556
+ })
1557
+
1558
+ const safePath = createSafePath(app)
1559
+
1560
+ expect(safePath('/typed', { x: 'val' })).toBe('/typed?x=val')
1561
+ expect(safePath('/untyped', { anything: 'goes' })).toBe(
1562
+ '/untyped?anything=goes',
1563
+ )
1564
+ expect(
1565
+ safePath('/also-typed/:id', { id: '1', verbose: true }),
1566
+ ).toBe('/also-typed/1?verbose=true')
1567
+
1568
+ // @ts-expect-error - wrong key on typed route
1569
+ safePath('/typed', { wrong: 'x' })
1570
+
1571
+ // @ts-expect-error - wrong key on also-typed route
1572
+ safePath('/also-typed/:id', { id: '1', wrong: true })
1573
+ })
1260
1574
  })
1261
1575
 
1262
1576
  test('composition with .use() works with state and onError - child app gets same state, errors caught by root', async () => {
package/src/spiceflow.ts CHANGED
@@ -101,6 +101,7 @@ export class Spiceflow<
101
101
  },
102
102
  const out ClientRoutes extends RouteBase = {},
103
103
  const out RoutePaths extends string = '',
104
+ const in out RouteQuerySchemas extends Record<string, unknown> = {},
104
105
  > {
105
106
  private id: number = globalIndex++
106
107
  private router: MedleyRouter = new OriginalRouter()
@@ -116,6 +117,7 @@ export class Spiceflow<
116
117
  Prefix: '' as BasePath,
117
118
  ClientRoutes: {} as ClientRoutes,
118
119
  RoutePaths: '' as RoutePaths,
120
+ RouteQuerySchemas: {} as RouteQuerySchemas,
119
121
  Scoped: false as Scoped,
120
122
  Singleton: {} as Singleton,
121
123
  Definitions: {} as Definitions,
@@ -274,7 +276,8 @@ export class Spiceflow<
274
276
  Definitions,
275
277
  Metadata,
276
278
  ClientRoutes,
277
- RoutePaths
279
+ RoutePaths,
280
+ RouteQuerySchemas
278
281
  > {
279
282
  this.defaultState[name] = value
280
283
  return this as any
@@ -350,7 +353,8 @@ export class Spiceflow<
350
353
  }
351
354
  }
352
355
  >,
353
- RoutePaths | JoinPath<BasePath, Path>
356
+ RoutePaths | JoinPath<BasePath, Path>,
357
+ RouteQuerySchemas & Record<JoinPath<BasePath, Path>, Schema['query']>
354
358
  > {
355
359
  this.add({ method: 'POST', path, handler: handler, hooks: hook })
356
360
 
@@ -400,7 +404,8 @@ export class Spiceflow<
400
404
  }
401
405
  }
402
406
  >,
403
- RoutePaths | JoinPath<BasePath, Path>
407
+ RoutePaths | JoinPath<BasePath, Path>,
408
+ RouteQuerySchemas & Record<JoinPath<BasePath, Path>, Schema['query']>
404
409
  > {
405
410
  this.add({ method: 'GET', path, handler: handler, hooks: hook })
406
411
  return this as any
@@ -448,7 +453,8 @@ export class Spiceflow<
448
453
  }
449
454
  }
450
455
  >,
451
- RoutePaths | JoinPath<BasePath, Path>
456
+ RoutePaths | JoinPath<BasePath, Path>,
457
+ RouteQuerySchemas & Record<JoinPath<BasePath, Path>, Schema['query']>
452
458
  > {
453
459
  this.add({ method: 'PUT', path, handler: handler, hooks: hook })
454
460
 
@@ -501,7 +507,8 @@ export class Spiceflow<
501
507
  }
502
508
  }
503
509
  >,
504
- RoutePaths | JoinPath<BasePath, Path>
510
+ RoutePaths | JoinPath<BasePath, Path>,
511
+ RouteQuerySchemas & Record<JoinPath<BasePath, Path>, Schema['query']>
505
512
  > {
506
513
  // If options.request is defined, disallow for GET and HEAD (methods that don't support a body)
507
514
  const methodsWithNoBody = ['GET', 'HEAD']
@@ -611,7 +618,8 @@ export class Spiceflow<
611
618
  }
612
619
  }
613
620
  >,
614
- RoutePaths | JoinPath<BasePath, Path>
621
+ RoutePaths | JoinPath<BasePath, Path>,
622
+ RouteQuerySchemas & Record<JoinPath<BasePath, Path>, Schema['query']>
615
623
  > {
616
624
  this.add({ method: 'PATCH', path, handler: handler, hooks: hook })
617
625
 
@@ -660,7 +668,8 @@ export class Spiceflow<
660
668
  }
661
669
  }
662
670
  >,
663
- RoutePaths | JoinPath<BasePath, Path>
671
+ RoutePaths | JoinPath<BasePath, Path>,
672
+ RouteQuerySchemas & Record<JoinPath<BasePath, Path>, Schema['query']>
664
673
  > {
665
674
  this.add({ method: 'DELETE', path, handler: handler, hooks: hook })
666
675
 
@@ -709,7 +718,8 @@ export class Spiceflow<
709
718
  }
710
719
  }
711
720
  >,
712
- RoutePaths | JoinPath<BasePath, Path>
721
+ RoutePaths | JoinPath<BasePath, Path>,
722
+ RouteQuerySchemas & Record<JoinPath<BasePath, Path>, Schema['query']>
713
723
  > {
714
724
  this.add({ method: 'OPTIONS', path, handler: handler, hooks: hook })
715
725
 
@@ -758,7 +768,8 @@ export class Spiceflow<
758
768
  }
759
769
  }
760
770
  >,
761
- RoutePaths | JoinPath<BasePath, Path>
771
+ RoutePaths | JoinPath<BasePath, Path>,
772
+ RouteQuerySchemas & Record<JoinPath<BasePath, Path>, Schema['query']>
762
773
  > {
763
774
  for (const method of METHODS) {
764
775
  this.add({ method, path, handler: handler, hooks: hook })
@@ -809,7 +820,8 @@ export class Spiceflow<
809
820
  }
810
821
  }
811
822
  >,
812
- RoutePaths | JoinPath<BasePath, Path>
823
+ RoutePaths | JoinPath<BasePath, Path>,
824
+ RouteQuerySchemas & Record<JoinPath<BasePath, Path>, Schema['query']>
813
825
  > {
814
826
  this.add({ method: 'HEAD', path, handler: handler, hooks: hook })
815
827
 
@@ -832,7 +844,8 @@ export class Spiceflow<
832
844
  ? ClientRoutes & NewSpiceflow['_types']['ClientRoutes']
833
845
  : ClientRoutes &
834
846
  CreateClient<BasePath, NewSpiceflow['_types']['ClientRoutes']>,
835
- RoutePaths | NewSpiceflow['_types']['RoutePaths']
847
+ RoutePaths | NewSpiceflow['_types']['RoutePaths'],
848
+ RouteQuerySchemas & NewSpiceflow['_types']['RouteQuerySchemas']
836
849
  >
837
850
  use<const Schema extends RouteSchema>(
838
851
  handler: MiddlewareHandler<Schema, Singleton>,
@@ -1399,31 +1412,92 @@ export class Spiceflow<
1399
1412
  }
1400
1413
  safePath<
1401
1414
  const Path extends RoutePaths,
1402
- const Params extends ExtractParamsFromPath<Path>,
1403
1415
  >(
1404
1416
  path: Path,
1405
- ...rest: [Params] extends [undefined]
1406
- ? [] | [params?: Params]
1407
- : [params: Params]
1417
+ ...rest: [ExtractParamsFromPath<Path>] extends [undefined]
1418
+ ? Path extends keyof RouteQuerySchemas
1419
+ ? unknown extends RouteQuerySchemas[Path]
1420
+ ? [] | [allParams?: Record<string, string | number | boolean>]
1421
+ : [] | [allParams?: Partial<RouteQuerySchemas[Path]>]
1422
+ : [] | [allParams?: Record<string, string | number | boolean>]
1423
+ : Path extends keyof RouteQuerySchemas
1424
+ ? unknown extends RouteQuerySchemas[Path]
1425
+ ? [allParams: ExtractParamsFromPath<Path> & Record<string, string | number | boolean>]
1426
+ : [allParams: MergeParamsAndQuery<ExtractParamsFromPath<Path>, RouteQuerySchemas[Path]>]
1427
+ : [allParams: ExtractParamsFromPath<Path>] | [allParams: ExtractParamsFromPath<Path> & Record<string, string | number | boolean>]
1408
1428
  ): string {
1409
- let params = (rest.length > 0 ? rest[0] : undefined) as Params | undefined
1410
- let result = path as string
1411
-
1412
- // Handle all provided parameters
1413
- if (params && typeof params === 'object') {
1414
- Object.entries(params).forEach(([key, value]) => {
1415
- if (key === '*') {
1416
- // Replace wildcard
1417
- result = result.replace(/\*/, String(value))
1418
- } else {
1419
- // Replace named parameters as before
1420
- const regex = new RegExp(`:${key}`, 'g')
1421
- result = result.replace(regex, String(value))
1422
- }
1423
- })
1429
+ return buildSafePath(path, rest[0] as Record<string, any> | undefined)
1430
+ }
1431
+ }
1432
+
1433
+ type MergeParamsAndQuery<P, Q> = P extends Record<string, any>
1434
+ ? { [K in keyof (P & Omit<Partial<Q>, keyof P>)]: (P & Omit<Partial<Q>, keyof P>)[K] }
1435
+ : Partial<Q>
1436
+
1437
+ function buildSafePath(path: string, allParams: Record<string, any> | undefined): string {
1438
+ let result = path
1439
+ if (!allParams || typeof allParams !== 'object') return result
1440
+
1441
+ const pathParamNames = new Set<string>()
1442
+ const paramMatches = path.matchAll(/:(\w+)/g)
1443
+ for (const m of paramMatches) {
1444
+ pathParamNames.add(m[1])
1445
+ }
1446
+ const hasWildcard = path.includes('*')
1447
+ if (hasWildcard) pathParamNames.add('*')
1448
+
1449
+ const searchParams = new URLSearchParams()
1450
+ for (const [key, value] of Object.entries(allParams)) {
1451
+ if (value === undefined || value === null) continue
1452
+ if (key === '*' && hasWildcard) {
1453
+ result = result.replace(/\*/, String(value))
1454
+ } else if (pathParamNames.has(key)) {
1455
+ result = result.replace(new RegExp(`:${key}`, 'g'), String(value))
1456
+ } else {
1457
+ searchParams.set(key, String(value))
1424
1458
  }
1459
+ }
1425
1460
 
1426
- return result
1461
+ const qs = searchParams.toString()
1462
+ if (qs) result += '?' + qs
1463
+ return result
1464
+ }
1465
+
1466
+ /**
1467
+ * Create a standalone type-safe path builder. Pass your app instance for automatic
1468
+ * type inference, or call with explicit type params. The app value is not used at runtime.
1469
+ *
1470
+ * ```ts
1471
+ * const app = new Spiceflow()
1472
+ * .get('/users/:id', handler, { query: z.object({ page: z.number() }) })
1473
+ *
1474
+ * const safePath = createSafePath(app)
1475
+ * safePath('/users/:id', { id: '123', page: 1 })
1476
+ * ```
1477
+ */
1478
+ export function createSafePath<
1479
+ const Paths extends string,
1480
+ const QS extends Record<string, unknown>,
1481
+ >(
1482
+ _app?: { _types: { RoutePaths: Paths; RouteQuerySchemas: QS } },
1483
+ ) {
1484
+ return <
1485
+ const Path extends Paths,
1486
+ >(
1487
+ path: Path,
1488
+ ...rest: [ExtractParamsFromPath<Path>] extends [undefined]
1489
+ ? Path extends keyof QS
1490
+ ? unknown extends QS[Path]
1491
+ ? [] | [allParams?: Record<string, string | number | boolean>]
1492
+ : [] | [allParams?: Partial<QS[Path]>]
1493
+ : [] | [allParams?: Record<string, string | number | boolean>]
1494
+ : Path extends keyof QS
1495
+ ? unknown extends QS[Path]
1496
+ ? [allParams: ExtractParamsFromPath<Path> & Record<string, string | number | boolean>]
1497
+ : [allParams: MergeParamsAndQuery<ExtractParamsFromPath<Path>, QS[Path]>]
1498
+ : [allParams: ExtractParamsFromPath<Path>] | [allParams: ExtractParamsFromPath<Path> & Record<string, string | number | boolean>]
1499
+ ): string => {
1500
+ return buildSafePath(path, rest[0] as Record<string, any> | undefined)
1427
1501
  }
1428
1502
  }
1429
1503
 
@@ -1489,7 +1563,7 @@ export function bfs(tree: AnySpiceflow) {
1489
1563
  }
1490
1564
 
1491
1565
 
1492
- export type AnySpiceflow = Spiceflow<any, any, any, any, any, any, any>
1566
+ export type AnySpiceflow = Spiceflow<any, any, any, any, any, any, any, any>
1493
1567
 
1494
1568
  export function isZodSchema(value: unknown): value is ZodType {
1495
1569
  return (
@@ -468,7 +468,7 @@ describe('Stream', () => {
468
468
  let streamError: Error | undefined
469
469
 
470
470
  try {
471
- const stream = streamSSEResponse(response, (x) => x.data)
471
+ const stream = streamSSEResponse({ response, map: (x) => x.data })
472
472
  for await (const value of stream) {
473
473
  values.push(value)
474
474
  }