Skip to content

Commit

Permalink
feat(portal): integrate resource routing to portal (#235)
Browse files Browse the repository at this point in the history
Update the portal code so that it aligns with the changes made in #220

Changelog:

- [x] Create a `routing.ts` file containing the logic of parsing the
Routes dynamic field.
- [x] Add a function that finds if a path matches a route specified in
the Routes DF.

---------

Signed-off-by: giac-mysten <[email protected]>
Co-authored-by: giac-mysten <[email protected]>
  • Loading branch information
Tzal3x and giac-mysten authored Oct 11, 2024
1 parent be54071 commit ff4d6e0
Show file tree
Hide file tree
Showing 9 changed files with 197 additions and 5 deletions.
2 changes: 1 addition & 1 deletion examples/snake/ws-resources.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,6 @@
}
},
"routes": {
"/path/*": "/index.html"
"/path/*": "/file.svg"
}
}
4 changes: 4 additions & 0 deletions portal/common/lib/bcs_data_parsing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,7 @@ export function DynamicFieldStruct<K, V>(K: BcsType<K>, V: BcsType<V>) {
value: V,
});
}

export const RoutesStruct = bcs.struct("Routes", {
routes_list: bcs.map(bcs.string(), bcs.string())
})
2 changes: 1 addition & 1 deletion portal/common/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

export const NETWORK = "testnet";
export const AGGREGATOR = "https://aggregator-devnet.walrus.space:443";
export const SITE_PACKAGE = "0xa9076d22049380d96607e3cc851ed591136c49aa0d9f3ddeac02d3841b3f27f7";
export const SITE_PACKAGE = "0xee40a3dddc38e3c9f594f74f85ae3bda75b0ed05c6f2359554126409cf7e8e9d";
export const MAX_REDIRECT_DEPTH = 3;
export const SITE_NAMES: { [key: string]: string } = {
// Any hardcoded (non suins) name -> object_id mappings go here
Expand Down
30 changes: 29 additions & 1 deletion portal/common/lib/page_fetching.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import {
import { aggregatorEndpoint } from "./aggregator";
import { toB64 } from "@mysten/bcs";
import { sha256 } from "./crypto";
import { getRoutes, matchPathToRoute } from "./routing";
import { HttpStatusCodes } from "./http/http_status_codes";

/**
* Resolves the subdomain to an object ID, and gets the corresponding resources.
Expand All @@ -28,7 +30,33 @@ export async function resolveAndFetchPage(parsedUrl: DomainDetails): Promise<Res
if (isObjectId) {
console.log("Object ID: ", resolveObjectResult);
console.log("Base36 version of the object ID: ", HEXtoBase36(resolveObjectResult));
return fetchPage(client, resolveObjectResult, parsedUrl.path);
// Rerouting based on the contents of the routes object,
// constructed using the ws-resource.json.

// Initiate a fetch request to get the Routes object in case the request
// to the initial unfiltered path fails.
const routesPromise = getRoutes(client, resolveObjectResult);

// Fetch the page using the initial path.
const fetchPromise = await fetchPage(client, resolveObjectResult, parsedUrl.path)

// If the fetch fails, check if the path can be matched using
// the Routes DF and fetch the redirected path.
if (fetchPromise.status == HttpStatusCodes.NOT_FOUND) {
const routes = await routesPromise;
if (!routes) {
console.warn("No routes found for the object ID");
return siteNotFound();
}
let matchingRoute: string | undefined;
matchingRoute = matchPathToRoute(parsedUrl.path, routes)
if (!matchingRoute) {
console.warn(`No matching route found for ${parsedUrl.path}`);
return siteNotFound();
}
return fetchPage(client, resolveObjectResult, matchingRoute);
}
return fetchPromise;
}
return resolveObjectResult;
}
Expand Down
42 changes: 42 additions & 0 deletions portal/common/lib/routing.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// Copyright (c) Mysten Labs, Inc.
// SPDX-License-Identifier: Apache-2.0

import { getRoutes, matchPathToRoute } from "./routing";
import { test, expect } from 'vitest';
import { SuiClient, getFullnodeUrl } from "@mysten/sui/client";
import { NETWORK } from "./constants";

const snakeSiteObjectId = '0x3e01b1b8bf0e54f7843596345faff146f1047e304410ed2eb85d5f67ad404206';
test.skip('getRoutes', async () => {
// TODO: when you make sure get_routes fetches
// the Routes dynamic field, mock the request.
const rpcUrl = getFullnodeUrl(NETWORK);
const client = new SuiClient({ url: rpcUrl });
const routes = await getRoutes(client, snakeSiteObjectId);
console.log(routes)
});

const routesExample = {
routes_list: new Map<string, string>([
['/*', '/default.html'],
['/somewhere/else', '/else.jpeg'],
['/somewhere/else/*', '/star-else.gif'],
['/path/to/*', '/somewhere.html'],
])
};

const testCases = [
["/path/to/somewhere/", "/somewhere.html"],
["/somewhere/else", '/else.jpeg'],
["/", "/default.html"],
["/somewhere", "/default.html"],
["/somewhere/else/star", "/star-else.gif"],
["/somewhere/else/", '/star-else.gif'],
]

testCases.forEach(([requestPath, expected]) => {
test(`matchPathToRoute: "${requestPath}" -> "${expected}"`, () => {
const match = matchPathToRoute(requestPath, routesExample)
expect(match).toEqual(expected)
})
});
99 changes: 99 additions & 0 deletions portal/common/lib/routing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// Copyright (c) Mysten Labs, Inc.
// SPDX-License-Identifier: Apache-2.0

import { getFullnodeUrl, SuiClient, SuiObjectResponse } from "@mysten/sui/client";
import { Routes, } from "./types";
import {
DynamicFieldStruct,
RoutesStruct,
} from "./bcs_data_parsing";
import { bcs, fromB64 } from "@mysten/bcs";


/**
* Gets the Routes dynamic field of the site object.
* Returns the extracted routes_list map to use for future requests,
* and redirects the paths matched accordingly.
*
* @param siteObjectId - The ID of the site object.
* @returns The routes list.
*/
export async function getRoutes(
client: SuiClient, siteObjectId: string
): Promise<Routes | undefined> {
const routesDF = await fetchRoutesDynamicField(client, siteObjectId);
if (!routesDF.data) {
console.warn("No routes dynamic field found for site object.");
return;
}
const routesObj = await fetchRoutesObject(client, routesDF.data.objectId);
const objectData = routesObj.data;
if (objectData && objectData.bcs && objectData.bcs.dataType === "moveObject") {
return parseRoutesData(objectData.bcs.bcsBytes);
}
throw new Error("Routes object data could not be fetched.");
}

/**
* Fetches the dynamic field object for routes.
*
* @param client - The SuiClient instance.
* @param siteObjectId - The ID of the site object.
* @returns The dynamic field object for routes.
*/
async function fetchRoutesDynamicField(
client: SuiClient, siteObjectId: string
): Promise<SuiObjectResponse> {
return await client.getDynamicFieldObject({
parentId: siteObjectId,
name: { type: "vector<u8>", value: "routes" },
});
}

/**
* Fetches the routes object using the dynamic field object ID.
*
* @param client - The SuiClient instance.
* @param objectId - The ID of the dynamic field object.
* @returns The routes object.
*/
async function fetchRoutesObject(client: SuiClient, objectId: string): Promise<SuiObjectResponse> {
return await client.getObject({
id: objectId,
options: { showBcs: true }
});
}

/**
* Parses the routes data from the BCS bytes.
*
* @param bcsBytes - The BCS bytes of the routes object.
* @returns The parsed routes data.
*/
function parseRoutesData(bcsBytes: string): Routes {
const df = DynamicFieldStruct(
// BCS declaration of the ROUTES_FIELD in site.move.
bcs.vector(bcs.u8()),
// The value of the df, i.e. the Routes Struct.
RoutesStruct
).parse(fromB64(bcsBytes));

return df.value as any as Routes;
}

/**
* Matches the path to the appropriate route.
* Path patterns in the routes list are sorted by length in descending order.
* Then the first match is returned.
*
* @param path - The path to match.
* @param routes - The routes to match against.
*/
export function matchPathToRoute(path: string, routes: Routes) {
// TODO: improve this using radix trees.
const res = Array
.from(routes.routes_list.entries())
.filter(([pattern, _]) => new RegExp(`^${pattern.replace('*', '.*')}$`).test(path))
.reduce((a, b) => a[0].length >= b[0].length ? a : b)
return res ? res[1] : undefined;
}
18 changes: 18 additions & 0 deletions portal/common/lib/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,21 @@ export function isVersionedResource(resource: any): resource is VersionedResourc
&& 'version' in resource
&& 'objectId' in resource;
}

/**
* Routes is an opitonal dynamic field object belonging to each site.
*/
export type Routes = {
routes_list: Map<string, string>;
}

/**
* Type guard for the Routes type.
*/
export function isRoutes(obj: any): obj is Routes {
return (
obj &&
typeof obj.routes_list === 'object' &&
obj.routes_list instanceof Map
);
}
3 changes: 2 additions & 1 deletion portal/server/next-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@
/// <reference types="next/image-types/global" />

// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript
// for more information.
2 changes: 1 addition & 1 deletion site-builder/assets/builder-example.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# module: site
# portal: walrus.site
package: 0xa9076d22049380d96607e3cc851ed591136c49aa0d9f3ddeac02d3841b3f27f7
package: 0xee40a3dddc38e3c9f594f74f85ae3bda75b0ed05c6f2359554126409cf7e8e9d
# general:
# rpc_url: https://fullnode.testnet.sui.io:443
# wallet: /path/to/.sui/sui_config/client.yaml
Expand Down

0 comments on commit ff4d6e0

Please sign in to comment.