Skip to content

Commit b2685f3

Browse files
Add relative path prefix support to routeFor utilities (#3292)
* Add relative path prefix support to routeFor utilities Introduce a routePrefix store that allows consuming projects to inject a path prefix (e.g., /projects/{projectId}) into all UI route functions. The prefix is inserted between the SvelteKit base path and the route path via a withPrefix helper that wraps the 12 root resolve() calls. Leaf functions inherit the prefix automatically through the existing string concatenation chain. Auth routes are excluded to avoid breaking SSO flows. * Move getRoutePrefix to coreProvider * Wrap worker routes added after initial PR with withPrefix --------- Co-authored-by: Ross Nelson <ross.nelson@temporal.io>
1 parent 2e7b88d commit b2685f3

5 files changed

Lines changed: 342 additions & 49 deletions

File tree

src/hooks.client.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ consumeAuthCookies();
2424
initCoreProvider({
2525
getAccessToken: async () => getAuthUser().accessToken ?? '',
2626
getIdToken: async () => getAuthUser().idToken,
27+
getRoutePrefix: () => '',
2728
api: {
2829
preRequest: ossPreRequest,
2930
postResponse: ossPostResponse,

src/lib/utilities/core-provider.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export type PostResponseHook = (
1717
export type CoreProvider = {
1818
getAccessToken: () => Promise<string>;
1919
getIdToken: () => Promise<string | undefined>;
20+
getRoutePrefix: () => string;
2021
api: {
2122
preRequest: PreRequestHook;
2223
postResponse: PostResponseHook;
@@ -28,6 +29,7 @@ export type CoreProvider = {
2829
export type InitOptions = {
2930
getAccessToken: () => Promise<string>;
3031
getIdToken?: () => Promise<string | undefined>;
32+
getRoutePrefix?: () => string;
3133
api?: {
3234
preRequest?: PreRequestHook;
3335
postResponse?: PostResponseHook;
@@ -45,6 +47,7 @@ export function initCoreProvider(options: InitOptions): void {
4547
provider = {
4648
getAccessToken: options.getAccessToken,
4749
getIdToken: options.getIdToken ?? (async () => undefined),
50+
getRoutePrefix: options.getRoutePrefix ?? (() => ''),
4851
api: {
4952
preRequest: options.api?.preRequest ?? passthrough,
5053
postResponse: options.api?.postResponse ?? passthroughResponse,
@@ -64,6 +67,11 @@ export async function getIdToken(): Promise<string | undefined> {
6467
return provider.getIdToken();
6568
}
6669

70+
export function getRoutePrefix(): string {
71+
if (!BROWSER || !provider) return '';
72+
return provider.getRoutePrefix();
73+
}
74+
6775
export async function getDataEncoderEndpoint(
6876
namespace: string,
6977
): Promise<string> {

src/lib/utilities/route-for-base-path.test.ts

Lines changed: 181 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import { describe, expect, it } from 'vitest';
1+
import { afterEach, describe, expect, it } from 'vitest';
22

33
import { base } from '$app/paths';
44

5+
import { initCoreProvider } from './core-provider';
56
import * as routeForModule from './route-for';
67
import {
78
routeForArchivalEventHistory,
@@ -257,3 +258,182 @@ describe('routeFor functions should resolve the base path exactly once', () => {
257258
}
258259
});
259260
});
261+
262+
describe('routeFor functions with prefix should resolve base + prefix correctly', () => {
263+
const prefix = '/projects/my-project';
264+
265+
const namespaceParams = { namespace: 'default' };
266+
const workflowParams = {
267+
namespace: 'default',
268+
workflow: 'wf-id',
269+
run: 'run-id',
270+
};
271+
const scheduleParams = { namespace: 'default', scheduleId: 'sched-1' };
272+
const activityParams = {
273+
namespace: 'default',
274+
activityId: 'act-1',
275+
runId: 'run-1',
276+
};
277+
278+
afterEach(() => {
279+
initCoreProvider({
280+
getAccessToken: async () => '',
281+
getRoutePrefix: () => '',
282+
});
283+
});
284+
285+
const prefixedCases: [string, () => string | undefined][] = [
286+
['routeForNamespaces', () => routeForNamespaces()],
287+
['routeForNexus', () => routeForNexus()],
288+
['routeForNexusEndpoint', () => routeForNexusEndpoint('ep-1')],
289+
['routeForNexusEndpointEdit', () => routeForNexusEndpointEdit('ep-1')],
290+
['routeForNexusEndpointCreate', () => routeForNexusEndpointCreate()],
291+
['routeForNamespace', () => routeForNamespace(namespaceParams)],
292+
['routeForNamespaceSelector', () => routeForNamespaceSelector()],
293+
['routeForWorkflows', () => routeForWorkflows(namespaceParams)],
294+
[
295+
'routeForArchivalWorkflows',
296+
() => routeForArchivalWorkflows(namespaceParams),
297+
],
298+
['routeForWorkflow', () => routeForWorkflow(workflowParams)],
299+
['routeForSchedules', () => routeForSchedules(namespaceParams)],
300+
['routeForScheduleCreate', () => routeForScheduleCreate(namespaceParams)],
301+
['routeForSchedule', () => routeForSchedule(scheduleParams)],
302+
['routeForScheduleEdit', () => routeForScheduleEdit(scheduleParams)],
303+
[
304+
'routeForArchivalEventHistory',
305+
() => routeForArchivalEventHistory(workflowParams),
306+
],
307+
[
308+
'routeForEventHistoryEvent',
309+
() => routeForEventHistoryEvent({ ...workflowParams, eventId: '1' }),
310+
],
311+
['routeForEventHistory', () => routeForEventHistory(workflowParams)],
312+
['routeForTimeline', () => routeForTimeline(workflowParams)],
313+
['routeForWorkers', () => routeForWorkers(workflowParams)],
314+
[
315+
'routeForWorkerDeployments',
316+
() => routeForWorkerDeployments(namespaceParams),
317+
],
318+
[
319+
'routeForWorkerDeployment',
320+
() =>
321+
routeForWorkerDeployment({
322+
namespace: 'default',
323+
deployment: 'dep-1',
324+
}),
325+
],
326+
[
327+
'routeForWorkerDeploymentVersion',
328+
() =>
329+
routeForWorkerDeploymentVersion({
330+
namespace: 'default',
331+
deployment: 'dep-1',
332+
version: 'v1',
333+
}),
334+
],
335+
['routeForRelationships', () => routeForRelationships(workflowParams)],
336+
[
337+
'routeForTaskQueue',
338+
() => routeForTaskQueue({ namespace: 'default', queue: 'q-1' }),
339+
],
340+
['routeForCallStack', () => routeForCallStack(workflowParams)],
341+
['routeForWorkflowQuery', () => routeForWorkflowQuery(workflowParams)],
342+
['routeForUserMetadata', () => routeForUserMetadata(workflowParams)],
343+
[
344+
'routeForWorkflowSearchAttributes',
345+
() => routeForWorkflowSearchAttributes(workflowParams),
346+
],
347+
['routeForWorkflowMemo', () => routeForWorkflowMemo(workflowParams)],
348+
['routeForWorkflowUpdate', () => routeForWorkflowUpdate(workflowParams)],
349+
[
350+
'routeForPendingActivities',
351+
() => routeForPendingActivities(workflowParams),
352+
],
353+
['routeForNexusLinks', () => routeForNexusLinks(workflowParams)],
354+
['routeForEventHistoryImport', () => routeForEventHistoryImport()],
355+
['routeForBatchOperations', () => routeForBatchOperations(namespaceParams)],
356+
[
357+
'routeForBatchOperation',
358+
() => routeForBatchOperation({ namespace: 'default', jobId: 'job-1' }),
359+
],
360+
[
361+
'routeForStandaloneActivities',
362+
() => routeForStandaloneActivities(namespaceParams),
363+
],
364+
[
365+
'routeForStandaloneActivitiesWithQuery',
366+
() =>
367+
routeForStandaloneActivitiesWithQuery(namespaceParams, 'test-query'),
368+
],
369+
[
370+
'routeForStartStandaloneActivity',
371+
() => routeForStartStandaloneActivity(namespaceParams),
372+
],
373+
[
374+
'routeForStandaloneActivityDetails',
375+
() => routeForStandaloneActivityDetails(activityParams),
376+
],
377+
[
378+
'routeForStandaloneActivityWorkers',
379+
() => routeForStandaloneActivityWorkers(activityParams),
380+
],
381+
[
382+
'routeForStandaloneActivitySearchAttributes',
383+
() => routeForStandaloneActivitySearchAttributes(activityParams),
384+
],
385+
[
386+
'routeForStandaloneActivityMetadata',
387+
() => routeForStandaloneActivityMetadata(activityParams),
388+
],
389+
[
390+
'routeForWorkflowStart',
391+
() => routeForWorkflowStart({ namespace: 'default' }),
392+
],
393+
[
394+
'routeForWorkflowsWithQuery',
395+
() => routeForWorkflowsWithQuery({ namespace: 'default', query: 'test' }),
396+
],
397+
];
398+
399+
const authCases: [string, () => string | undefined][] = [
400+
[
401+
'routeForAuthentication',
402+
() =>
403+
routeForAuthentication({
404+
settings: { auth: {}, baseUrl: 'https://example.com' },
405+
searchParams: new URLSearchParams(),
406+
}),
407+
],
408+
['routeForLoginPage', () => routeForLoginPage('', false)],
409+
];
410+
411+
it.each(prefixedCases)(
412+
'%s should include base + prefix when prefix is set',
413+
(_name, fn) => {
414+
initCoreProvider({
415+
getAccessToken: async () => '',
416+
getRoutePrefix: () => prefix,
417+
});
418+
const result = fn();
419+
expect(typeof result).toBe('string');
420+
expect(result).toMatch(new RegExp(`^${base}${prefix}`));
421+
expect(result).not.toMatch(
422+
new RegExp(`${base}${prefix}${base}${prefix}`),
423+
);
424+
},
425+
);
426+
427+
it.each(authCases)(
428+
'%s should NOT include prefix (auth routes excluded)',
429+
(_name, fn) => {
430+
initCoreProvider({
431+
getAccessToken: async () => '',
432+
getRoutePrefix: () => prefix,
433+
});
434+
const result = fn();
435+
expect(typeof result).toBe('string');
436+
expect(result).not.toContain(prefix);
437+
},
438+
);
439+
});

src/lib/utilities/route-for.test.ts

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { writable } from 'svelte/store';
22

3-
import { afterEach, describe, expect, it, vi } from 'vitest';
3+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
44

55
import { base } from '$app/paths';
66

@@ -9,6 +9,7 @@ import type {
99
WorkflowViewPreference,
1010
} from '$lib/stores/event-view';
1111

12+
import { initCoreProvider } from './core-provider';
1213
import {
1314
baseRouteForWorkflow,
1415
hasParameters,
@@ -24,6 +25,7 @@ import {
2425
routeForLoginPage,
2526
routeForNamespace,
2627
routeForNamespaces,
28+
routeForNexus,
2729
routeForPendingActivities,
2830
routeForSchedule,
2931
routeForScheduleCreate,
@@ -561,3 +563,79 @@ describe('routeFor worker deployment version and serverless routes', () => {
561563
expect(path).toBe(`${base}/namespaces/default/workers/deployments/create`);
562564
});
563565
});
566+
567+
describe('routeFor with prefix', () => {
568+
const prefix = '/projects/my-project';
569+
570+
beforeEach(() => {
571+
initCoreProvider({
572+
getAccessToken: async () => '',
573+
getRoutePrefix: () => prefix,
574+
});
575+
});
576+
577+
it('should prepend prefix to root route', () => {
578+
expect(routeForNamespaces()).toBe(`${base}${prefix}/namespaces`);
579+
});
580+
581+
it('should prepend prefix to namespace route', () => {
582+
expect(routeForNamespace({ namespace: 'default' })).toBe(
583+
`${base}${prefix}/namespaces/default`,
584+
);
585+
});
586+
587+
it('should propagate prefix through leaf functions', () => {
588+
expect(routeForWorkflows({ namespace: 'default' })).toBe(
589+
`${base}${prefix}/namespaces/default/workflows`,
590+
);
591+
});
592+
593+
it('should propagate prefix through deep leaf functions', () => {
594+
expect(
595+
routeForCallStack({
596+
namespace: 'default',
597+
workflow: 'abc',
598+
run: 'def',
599+
}),
600+
).toBe(`${base}${prefix}/namespaces/default/workflows/abc/def/call-stack`);
601+
});
602+
603+
it('should propagate prefix to nexus routes', () => {
604+
expect(routeForNexus()).toBe(`${base}${prefix}/nexus`);
605+
});
606+
607+
it('should propagate prefix to schedule routes', () => {
608+
expect(routeForSchedules({ namespace: 'default' })).toBe(
609+
`${base}${prefix}/namespaces/default/schedules`,
610+
);
611+
});
612+
613+
it('should not apply prefix when store is empty', () => {
614+
initCoreProvider({
615+
getAccessToken: async () => '',
616+
getRoutePrefix: () => '',
617+
});
618+
expect(routeForNamespaces()).toBe(`${base}/namespaces`);
619+
});
620+
621+
it('should not apply prefix to auth routes', () => {
622+
const settings = { auth: {}, baseUrl: 'https://localhost' };
623+
const searchParams = new URLSearchParams();
624+
const sso = routeForAuthentication({ settings, searchParams });
625+
expect(sso).not.toContain(prefix);
626+
});
627+
628+
it('should not apply prefix to login page', () => {
629+
const login = routeForLoginPage('', false);
630+
expect(login).not.toContain(prefix);
631+
});
632+
633+
it('should revert to default behavior when prefix is cleared', () => {
634+
expect(routeForNamespaces()).toBe(`${base}${prefix}/namespaces`);
635+
initCoreProvider({
636+
getAccessToken: async () => '',
637+
getRoutePrefix: () => '',
638+
});
639+
expect(routeForNamespaces()).toBe(`${base}/namespaces`);
640+
});
641+
});

0 commit comments

Comments
 (0)