|
1 | 1 | import * as assert from 'node:assert/strict'
|
2 | 2 | import { describe, it, mock } from 'node:test'
|
3 | 3 |
|
4 |
| -import type { RequestContext } from './request-context.ts' |
| 4 | +import { RegExpMatcher, RoutePattern } from '@remix-run/route-pattern' |
| 5 | +import { createStorageKey } from './app-storage.ts' |
| 6 | +import { RequestContext } from './request-context.ts' |
5 | 7 | import { createRoutes } from './route-map.ts'
|
6 | 8 | import { createRouter } from './router.ts'
|
7 | 9 |
|
@@ -683,3 +685,372 @@ describe('per-route middleware', () => {
|
683 | 685 | assert.deepEqual(requestLog, ['m1', 'm2-short-circuit'])
|
684 | 686 | })
|
685 | 687 | })
|
| 688 | + |
| 689 | +describe('404 handling', () => { |
| 690 | + it('returns a 404 response when no route matches', async () => { |
| 691 | + let router = createRouter() |
| 692 | + router.get('/home', () => new Response('Home')) |
| 693 | + |
| 694 | + let response = await router.fetch('https://remix.run/nonexistent') |
| 695 | + |
| 696 | + assert.equal(response.status, 404) |
| 697 | + assert.equal(await response.text(), 'Not Found: /nonexistent') |
| 698 | + }) |
| 699 | + |
| 700 | + it('supports a custom defaultHandler', async () => { |
| 701 | + let router = createRouter({ |
| 702 | + defaultHandler: ({ url }) => { |
| 703 | + return new Response(`Custom 404: ${url.pathname}`, { |
| 704 | + status: 404, |
| 705 | + headers: { 'X-Custom': 'true' }, |
| 706 | + }) |
| 707 | + }, |
| 708 | + }) |
| 709 | + |
| 710 | + router.get('/home', () => new Response('Home')) |
| 711 | + |
| 712 | + let response = await router.fetch('https://remix.run/missing') |
| 713 | + |
| 714 | + assert.equal(response.status, 404) |
| 715 | + assert.equal(await response.text(), 'Custom 404: /missing') |
| 716 | + assert.equal(response.headers.get('X-Custom'), 'true') |
| 717 | + }) |
| 718 | + |
| 719 | + it('calls defaultHandler only when no routes match', async () => { |
| 720 | + let defaultCalls = 0 |
| 721 | + let router = createRouter({ |
| 722 | + defaultHandler: () => { |
| 723 | + defaultCalls++ |
| 724 | + return new Response('Not Found', { status: 404 }) |
| 725 | + }, |
| 726 | + }) |
| 727 | + |
| 728 | + router.get('/', () => new Response('Home')) |
| 729 | + router.get('/about', () => new Response('About')) |
| 730 | + |
| 731 | + await router.fetch('https://remix.run/') |
| 732 | + assert.equal(defaultCalls, 0) |
| 733 | + |
| 734 | + await router.fetch('https://remix.run/about') |
| 735 | + assert.equal(defaultCalls, 0) |
| 736 | + |
| 737 | + await router.fetch('https://remix.run/missing') |
| 738 | + assert.equal(defaultCalls, 1) |
| 739 | + }) |
| 740 | +}) |
| 741 | + |
| 742 | +describe('error handling', () => { |
| 743 | + it('propagates errors thrown in route handlers', async () => { |
| 744 | + let router = createRouter() |
| 745 | + router.get('/', () => { |
| 746 | + throw new Error('Handler error') |
| 747 | + }) |
| 748 | + |
| 749 | + await assert.rejects(async () => { |
| 750 | + await router.fetch('https://remix.run/') |
| 751 | + }, new Error('Handler error')) |
| 752 | + }) |
| 753 | + |
| 754 | + it('propagates async errors thrown in route handlers', async () => { |
| 755 | + let router = createRouter() |
| 756 | + router.get('/', async () => { |
| 757 | + await Promise.resolve() |
| 758 | + throw new Error('Async handler error') |
| 759 | + }) |
| 760 | + |
| 761 | + await assert.rejects(async () => { |
| 762 | + await router.fetch('https://remix.run/') |
| 763 | + }, new Error('Async handler error')) |
| 764 | + }) |
| 765 | + |
| 766 | + it('propagates errors thrown in global middleware', async () => { |
| 767 | + let router = createRouter() |
| 768 | + |
| 769 | + router.use(() => { |
| 770 | + throw new Error('Global middleware error') |
| 771 | + }) |
| 772 | + |
| 773 | + router.get('/', () => new Response('OK')) |
| 774 | + |
| 775 | + await assert.rejects(async () => { |
| 776 | + await router.fetch('https://remix.run/') |
| 777 | + }, new Error('Global middleware error')) |
| 778 | + }) |
| 779 | + |
| 780 | + it('propagates errors thrown in per-route middleware', async () => { |
| 781 | + let router = createRouter() |
| 782 | + |
| 783 | + router.get('/', { |
| 784 | + use: [ |
| 785 | + () => { |
| 786 | + throw new Error('Per-route middleware error') |
| 787 | + }, |
| 788 | + ], |
| 789 | + handler() { |
| 790 | + return new Response('OK') |
| 791 | + }, |
| 792 | + }) |
| 793 | + |
| 794 | + await assert.rejects(async () => { |
| 795 | + await router.fetch('https://remix.run/') |
| 796 | + }, new Error('Per-route middleware error')) |
| 797 | + }) |
| 798 | + |
| 799 | + it('propagates errors thrown in defaultHandler', async () => { |
| 800 | + let router = createRouter({ |
| 801 | + defaultHandler: () => { |
| 802 | + throw new Error('Default handler error') |
| 803 | + }, |
| 804 | + }) |
| 805 | + |
| 806 | + router.get('/home', () => new Response('Home')) |
| 807 | + |
| 808 | + await assert.rejects(async () => { |
| 809 | + await router.fetch('https://remix.run/missing') |
| 810 | + }, new Error('Default handler error')) |
| 811 | + }) |
| 812 | + |
| 813 | + it('allows middleware to catch and handle errors from downstream', async () => { |
| 814 | + let router = createRouter() |
| 815 | + |
| 816 | + router.use(async (_, next) => { |
| 817 | + try { |
| 818 | + return await next() |
| 819 | + } catch (error) { |
| 820 | + return new Response(`Caught: ${(error as Error).message}`, { status: 500 }) |
| 821 | + } |
| 822 | + }) |
| 823 | + |
| 824 | + router.get('/', () => { |
| 825 | + throw new Error('Handler error') |
| 826 | + }) |
| 827 | + |
| 828 | + let response = await router.fetch('https://remix.run/') |
| 829 | + assert.equal(response.status, 500) |
| 830 | + assert.equal(await response.text(), 'Caught: Handler error') |
| 831 | + }) |
| 832 | +}) |
| 833 | + |
| 834 | +describe('router.dispatch()', () => { |
| 835 | + it('returns null when no route matches', async () => { |
| 836 | + let router = createRouter() |
| 837 | + router.get('/home', () => new Response('Home')) |
| 838 | + |
| 839 | + let response = await router.dispatch(new Request('https://remix.run/missing')) |
| 840 | + |
| 841 | + assert.equal(response, null) |
| 842 | + }) |
| 843 | + |
| 844 | + it('returns a response when a route matches', async () => { |
| 845 | + let router = createRouter() |
| 846 | + router.get('/home', () => new Response('Home')) |
| 847 | + |
| 848 | + let response = await router.dispatch(new Request('https://remix.run/home')) |
| 849 | + |
| 850 | + assert.ok(response) |
| 851 | + assert.equal(response.status, 200) |
| 852 | + assert.equal(await response.text(), 'Home') |
| 853 | + }) |
| 854 | + |
| 855 | + it('does not call the defaultHandler', async () => { |
| 856 | + let defaultHandlerCalled = false |
| 857 | + let router = createRouter({ |
| 858 | + defaultHandler: () => { |
| 859 | + defaultHandlerCalled = true |
| 860 | + return new Response('Default', { status: 404 }) |
| 861 | + }, |
| 862 | + }) |
| 863 | + |
| 864 | + router.get('/home', () => new Response('Home')) |
| 865 | + |
| 866 | + let response = await router.dispatch(new Request('https://remix.run/missing')) |
| 867 | + |
| 868 | + assert.equal(response, null) |
| 869 | + assert.equal(defaultHandlerCalled, false) |
| 870 | + }) |
| 871 | + |
| 872 | + it('accepts a RequestContext instead of a Request', async () => { |
| 873 | + let storageKey = createStorageKey<string>() |
| 874 | + let router = createRouter() |
| 875 | + router.get('/:id', ({ params, storage }) => { |
| 876 | + return new Response(`ID: ${params.id}, Storage: ${storage.get(storageKey)}`) |
| 877 | + }) |
| 878 | + |
| 879 | + let request = new Request('https://remix.run/123') |
| 880 | + let context = new RequestContext({ request, params: {} }) |
| 881 | + context.storage.set(storageKey, 'value') |
| 882 | + |
| 883 | + let response = await router.dispatch(context) |
| 884 | + |
| 885 | + assert.ok(response) |
| 886 | + assert.equal(await response.text(), 'ID: 123, Storage: value') |
| 887 | + }) |
| 888 | + |
| 889 | + it('passes upstream middleware to nested routes', async () => { |
| 890 | + let requestLog: string[] = [] |
| 891 | + |
| 892 | + let router = createRouter() |
| 893 | + router.get('/', () => { |
| 894 | + requestLog.push('handler') |
| 895 | + return new Response('OK') |
| 896 | + }) |
| 897 | + |
| 898 | + let request = new Request('https://remix.run/') |
| 899 | + let upstreamMiddleware = [ |
| 900 | + () => { |
| 901 | + requestLog.push('upstream') |
| 902 | + }, |
| 903 | + ] |
| 904 | + |
| 905 | + let response = await router.dispatch(request, upstreamMiddleware) |
| 906 | + |
| 907 | + assert.ok(response) |
| 908 | + assert.deepEqual(requestLog, ['upstream', 'handler']) |
| 909 | + }) |
| 910 | +}) |
| 911 | + |
| 912 | +describe('trailing slash handling', () => { |
| 913 | + it('matches routes with and without trailing slashes for single-path routes', async () => { |
| 914 | + let router = createRouter() |
| 915 | + router.get('/about', () => new Response('About')) |
| 916 | + router.get('/contact/', () => new Response('Contact')) |
| 917 | + |
| 918 | + // Route defined without trailing slash |
| 919 | + let response1 = await router.fetch('https://remix.run/about') |
| 920 | + assert.equal(response1.status, 200) |
| 921 | + assert.equal(await response1.text(), 'About') |
| 922 | + |
| 923 | + let response2 = await router.fetch('https://remix.run/about/') |
| 924 | + assert.equal(response2.status, 404) // Trailing slash doesn't match |
| 925 | + |
| 926 | + // Route defined with trailing slash |
| 927 | + let response3 = await router.fetch('https://remix.run/contact/') |
| 928 | + assert.equal(response3.status, 200) |
| 929 | + assert.equal(await response3.text(), 'Contact') |
| 930 | + |
| 931 | + let response4 = await router.fetch('https://remix.run/contact') |
| 932 | + assert.equal(response4.status, 404) // Without trailing slash doesn't match |
| 933 | + }) |
| 934 | + |
| 935 | + it('matches routes with and without trailing slashes for createRoutes', async () => { |
| 936 | + let routes = createRoutes('api', { |
| 937 | + users: '/users', |
| 938 | + posts: '/posts/', |
| 939 | + }) |
| 940 | + |
| 941 | + let router = createRouter() |
| 942 | + router.get(routes.users, () => new Response('Users')) |
| 943 | + router.get(routes.posts, () => new Response('Posts')) |
| 944 | + |
| 945 | + // Route defined without trailing slash in createRoutes |
| 946 | + let response1 = await router.fetch('https://remix.run/api/users') |
| 947 | + assert.equal(response1.status, 200) |
| 948 | + assert.equal(await response1.text(), 'Users') |
| 949 | + |
| 950 | + let response2 = await router.fetch('https://remix.run/api/users/') |
| 951 | + assert.equal(response2.status, 404) // Trailing slash doesn't match |
| 952 | + |
| 953 | + // Route defined with trailing slash in createRoutes |
| 954 | + let response3 = await router.fetch('https://remix.run/api/posts/') |
| 955 | + assert.equal(response3.status, 200) |
| 956 | + assert.equal(await response3.text(), 'Posts') |
| 957 | + |
| 958 | + let response4 = await router.fetch('https://remix.run/api/posts') |
| 959 | + assert.equal(response4.status, 404) // Without trailing slash doesn't match |
| 960 | + }) |
| 961 | + |
| 962 | + it('handles root path with and without trailing slash', async () => { |
| 963 | + let router = createRouter() |
| 964 | + router.get('/', () => new Response('Home')) |
| 965 | + |
| 966 | + // Root with trailing slash |
| 967 | + let response1 = await router.fetch('https://remix.run/') |
| 968 | + assert.equal(response1.status, 200) |
| 969 | + assert.equal(await response1.text(), 'Home') |
| 970 | + |
| 971 | + // Root without trailing slash (edge case) |
| 972 | + let response2 = await router.fetch('https://remix.run') |
| 973 | + assert.equal(response2.status, 200) |
| 974 | + assert.equal(await response2.text(), 'Home') |
| 975 | + }) |
| 976 | + |
| 977 | + it('handles nested routes with trailing slash combinations', async () => { |
| 978 | + let routes = createRoutes('admin', { |
| 979 | + dashboard: '/', |
| 980 | + users: { |
| 981 | + index: '/users', |
| 982 | + show: '/users/:id', |
| 983 | + }, |
| 984 | + }) |
| 985 | + |
| 986 | + let router = createRouter() |
| 987 | + router.get(routes.dashboard, () => new Response('Admin Dashboard')) |
| 988 | + router.get(routes.users.index, () => new Response('Users List')) |
| 989 | + router.get(routes.users.show, ({ params }) => new Response(`User ${params.id}`)) |
| 990 | + |
| 991 | + // Dashboard (base path - createRoutes('admin', { dashboard: '/' }) produces '/admin') |
| 992 | + let response1 = await router.fetch('https://remix.run/admin') |
| 993 | + assert.equal(response1.status, 200) |
| 994 | + assert.equal(await response1.text(), 'Admin Dashboard') |
| 995 | + |
| 996 | + let response2 = await router.fetch('https://remix.run/admin/') |
| 997 | + assert.equal(response2.status, 404) // Trailing slash doesn't match '/admin' |
| 998 | + |
| 999 | + // Nested users index |
| 1000 | + let response3 = await router.fetch('https://remix.run/admin/users') |
| 1001 | + assert.equal(response3.status, 200) |
| 1002 | + assert.equal(await response3.text(), 'Users List') |
| 1003 | + |
| 1004 | + let response4 = await router.fetch('https://remix.run/admin/users/') |
| 1005 | + assert.equal(response4.status, 404) // Trailing slash doesn't match |
| 1006 | + |
| 1007 | + // Nested users show |
| 1008 | + let response5 = await router.fetch('https://remix.run/admin/users/123') |
| 1009 | + assert.equal(response5.status, 200) |
| 1010 | + assert.equal(await response5.text(), 'User 123') |
| 1011 | + |
| 1012 | + let response6 = await router.fetch('https://remix.run/admin/users/123/') |
| 1013 | + assert.equal(response6.status, 404) // Trailing slash doesn't match |
| 1014 | + }) |
| 1015 | +}) |
| 1016 | + |
| 1017 | +describe('custom matcher', () => { |
| 1018 | + it('uses a custom matcher when provided', async () => { |
| 1019 | + let matchAllCalls = 0 |
| 1020 | + |
| 1021 | + // Create a custom matcher that tracks calls |
| 1022 | + class CustomMatcher extends RegExpMatcher { |
| 1023 | + *matchAll(url: string | URL) { |
| 1024 | + matchAllCalls++ |
| 1025 | + yield* super.matchAll(url) |
| 1026 | + } |
| 1027 | + } |
| 1028 | + |
| 1029 | + let customMatcher = new CustomMatcher() |
| 1030 | + let router = createRouter({ matcher: customMatcher }) |
| 1031 | + router.get('/', () => new Response('Home')) |
| 1032 | + |
| 1033 | + await router.fetch('https://remix.run/') |
| 1034 | + |
| 1035 | + assert.ok(matchAllCalls > 0, 'Custom matcher should be called') |
| 1036 | + }) |
| 1037 | + |
| 1038 | + it('adds routes to the custom matcher', async () => { |
| 1039 | + let addedPatterns: string[] = [] |
| 1040 | + |
| 1041 | + class CustomMatcher extends RegExpMatcher { |
| 1042 | + add<P extends string>(pattern: P | RoutePattern<P>, data: any): void { |
| 1043 | + let routePattern = typeof pattern === 'string' ? new RoutePattern(pattern) : pattern |
| 1044 | + addedPatterns.push(routePattern.source) |
| 1045 | + super.add(pattern, data) |
| 1046 | + } |
| 1047 | + } |
| 1048 | + |
| 1049 | + let customMatcher = new CustomMatcher() |
| 1050 | + let router = createRouter({ matcher: customMatcher }) |
| 1051 | + router.get('/home', () => new Response('Home')) |
| 1052 | + router.get('/about', () => new Response('About')) |
| 1053 | + |
| 1054 | + assert.deepEqual(addedPatterns, ['/home', '/about']) |
| 1055 | + }) |
| 1056 | +}) |
0 commit comments