Skip to content

Commit

Permalink
refactor(cli): support serving static files
Browse files Browse the repository at this point in the history
  • Loading branch information
charIeszhao committed Jul 31, 2024
1 parent 244dbb3 commit 89d5ac7
Show file tree
Hide file tree
Showing 5 changed files with 121 additions and 26 deletions.
1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
"hpagent": "^1.2.0",
"http-proxy-middleware": "^3.0.0",
"inquirer": "^9.0.0",
"mime": "^4.0.4",
"nanoid": "^5.0.1",
"ora": "^8.0.1",
"p-limit": "^6.0.0",
Expand Down
59 changes: 39 additions & 20 deletions packages/cli/src/commands/proxy/index.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
import * as http from 'node:http';
import http from 'node:http';

import { isValidUrl } from '@logto/core-kit';
import { conditional } from '@silverhand/essentials';
import chalk from 'chalk';
import type { CommandModule } from 'yargs';

import { consoleLog } from '../../utils.js';

import { type ProxyCommandArgs } from './types.js';
import { createLogtoResponseHandler, createProxy, isLogtoRequestPath } from './utils.js';
import {
checkExperienceInput,
createLogtoResponseHandler,
createProxy,
createStaticFileProxy,
isLogtoRequestPath,
} from './utils.js';

const proxy: CommandModule<unknown, ProxyCommandArgs> = {
command: ['proxy'],
Expand All @@ -20,16 +27,15 @@ const proxy: CommandModule<unknown, ProxyCommandArgs> = {
describe: 'The URI of your custom sign-in experience page.',
type: 'string',
},
'tenant-id': {
alias: ['t'],
describe:
'Your Logto Cloud tenant ID. WHen provided, endpoint URI will be set to `https://<tenant-id>.logto.app` by default.',
'experience-path': {
alias: ['xp'],
describe: 'The local folder path of your custom sign-in experience assets.',
type: 'string',
},
endpoint: {
alias: 'ep',
describe:
'Specify the full Logto endpoint URI, which takes precedence over tenant ID when provided.',
'Logto endpoint URI, which can be found in Logto Console. E.g.: https://<tenant-id>.logto.app/',
type: 'string',
},
port: {
Expand All @@ -38,19 +44,21 @@ const proxy: CommandModule<unknown, ProxyCommandArgs> = {
type: 'number',
default: 9000,
},
verbose: {
alias: 'v',
describe: 'Show verbose output.',
type: 'boolean',
default: false,
},
})
.global('e'),
handler: async ({ 'experience-uri': expUri, 'tenant-id': tenantId, endpoint, port }) => {
if (!expUri || !isValidUrl(expUri)) {
consoleLog.fatal(
'A valid sign-in experience URI must be provided. E.g.: http://localhost:4000'
);
}
if (!tenantId && (!endpoint || !isValidUrl(endpoint))) {
consoleLog.fatal('Either tenant ID or a valid Logto endpoint URI must be provided.');
}
handler: async ({ 'experience-uri': url, 'experience-path': path, endpoint, port, verbose }) => {
checkExperienceInput(url, path);

const logtoEndpointUrl = new URL(endpoint ?? `https://${tenantId}.logto.app}`);
if (!endpoint || !isValidUrl(endpoint)) {
consoleLog.fatal('A valid Logto endpoint URI must be provided.');
}
const logtoEndpointUrl = new URL(endpoint);
const proxyUrl = new URL(`http://localhost:${port}`);

const proxyLogtoRequest = createProxy(
Expand All @@ -62,20 +70,31 @@ const proxy: CommandModule<unknown, ProxyCommandArgs> = {
response,
logtoEndpointUrl,
proxyUrl,
verbose,
})
);
const proxySignInExpRequest = createProxy(expUri);
const proxySignInExpRequest = conditional(url && createProxy(url));
const proxyStaticFileRequest = conditional(path && createStaticFileProxy(path));

const server = http.createServer((request, response) => {
consoleLog.info(`Incoming request: ${chalk.blue(request.url)}`);
if (verbose) {
consoleLog.info(`Incoming request: ${chalk.blue(request.url)}`);
}

// Proxy the requests to Logto endpoint
if (isLogtoRequestPath(request.url)) {
void proxyLogtoRequest(request, response);
return;
}

void proxySignInExpRequest(request, response);
if (proxySignInExpRequest) {
void proxySignInExpRequest(request, response);
return;
}

if (proxyStaticFileRequest) {
void proxyStaticFileRequest(request, response);
}
});

server.listen(port, () => {
Expand Down
4 changes: 3 additions & 1 deletion packages/cli/src/commands/proxy/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ import type * as http from 'node:http';

export type ProxyCommandArgs = {
'experience-uri'?: string;
'tenant-id'?: string;
'experience-path'?: string;
endpoint?: string;
port: number;
verbose: boolean;
};

export type ProxyResponseHandler = {
Expand All @@ -13,4 +14,5 @@ export type ProxyResponseHandler = {
response: http.ServerResponse;
logtoEndpointUrl: URL;
proxyUrl: URL;
verbose: boolean;
};
73 changes: 68 additions & 5 deletions packages/cli/src/commands/proxy/utils.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import { existsSync } from 'node:fs';
import fs from 'node:fs/promises';
import type http from 'node:http';
import path from 'node:path';

import { isValidUrl } from '@logto/core-kit';
import { conditional, trySafe } from '@silverhand/essentials';
import chalk from 'chalk';
import { createProxyMiddleware, responseInterceptor } from 'http-proxy-middleware';
import { type OnProxyEvent } from 'http-proxy-middleware/dist/types.js';
import mime from 'mime';

import { consoleLog } from '../../utils.js';

Expand All @@ -23,21 +30,55 @@ export const createProxy = (targetUrl: string, onProxyResponse?: OnProxyEvent['p
});
};

export const createStaticFileProxy = (staticPath: string) => {
const index = 'index.html';
const indexContentType = 'text/html; charset=utf-8';
const noCache = 'no-cache, no-store, must-revalidate';
const maxAgeSevenDays = 'max-age=604_800_000';

return async (request: http.IncomingMessage, response: http.ServerResponse) => {
if (!request.url) {
response.writeHead(400).end();
return;
}

if (request.method === 'HEAD' || request.method === 'GET') {
const loadIndex = !isFileAssetPath(request.url);
const requestPath = path.join(staticPath, loadIndex ? index : request.url);
try {
const content = await fs.readFile(requestPath, 'utf8');
response.setHeader('cache-control', loadIndex ? noCache : maxAgeSevenDays);
response.setHeader('content-type', loadIndex ? indexContentType : getMimeType(request.url));
response.writeHead(200);
response.end(content);
} catch {
response.setHeader('content-type', getMimeType(request.url));
response.writeHead(404);
response.end();
}
}
};
};

/**
* Intercept the response from Logto endpoint and replace Logto endpoint URLs in the response with the
* proxy URL. The string replace happens in the following cases:
* - The response is a redirect response, and the response header contains `location` property.
* - The response contains JSON data that consists of properties such as `**_endpoint` and `redirectTo`.
* - The response is HTML content (logoutsource) that contains a form action URL.
* - The response is a redirect response, and the `location` property in response header may contain Logto
* endpoint URI.
* - The response body is JSON, which consists of properties such as `**_endpoint` and `redirectTo`. These
* properties may contain Logto endpoint URI.
* - The response is HTML content that contains a form. The form action URL may contain Logto endpoint URI.
*
* Note: the `issuer` and `jwks_uri` properties in the `/oidc/.well-known` response should not be replaced,
* even they also contain the Logto endpoint URL.
* even they also contain the Logto endpoint URI.
*/
export const createLogtoResponseHandler = async ({
proxyResponse,
request,
response,
logtoEndpointUrl,
proxyUrl,
verbose,
}: ProxyResponseHandler) => {
const { location } = proxyResponse.headers;
if (location) {
Expand All @@ -47,7 +88,9 @@ export const createLogtoResponseHandler = async ({

void responseInterceptor(async (responseBuffer, proxyResponse) => {
const responseBody = responseBuffer.toString();
consoleLog.info(`Response received: ${chalk.green(responseBody)}`);
if (verbose) {
consoleLog.info(`Response received: ${chalk.green(responseBody)}`);
}

if (proxyResponse.headers['content-type']?.includes('text/html')) {
return responseBody.replace(
Expand All @@ -73,6 +116,23 @@ export const createLogtoResponseHandler = async ({
})(proxyResponse, request, response);
};

export const checkExperienceInput = (url?: string, staticPath?: string) => {
if (staticPath && url) {
consoleLog.fatal('Only one of the experience URI or path can be provided.');
}
if (!staticPath && !url) {
consoleLog.fatal('Either a sign-in experience URI or local path must be provided.');
}
if (url && !isValidUrl(url)) {
consoleLog.fatal(
'A valid sign-in experience URI must be provided. E.g.: http://localhost:4000'
);
}
if (staticPath && !existsSync(path.join(staticPath, 'index.html'))) {
consoleLog.fatal('The provided path does not contain a valid index.html file.');
}
};

/**
* Check if the request path is a Logto request path.
* @example isLogtoRequestPath('/oidc/.well-known/openid-configuration') // true
Expand All @@ -82,3 +142,6 @@ export const createLogtoResponseHandler = async ({
*/
export const isLogtoRequestPath = (requestPath?: string) =>
['/oidc/', '/api/'].some((path) => requestPath?.startsWith(path)) || requestPath === '/consent';

const isFileAssetPath = (url: string) => url.split('/').slice(-1).includes('.');
const getMimeType = (filePath: string) => mime.getType(filePath) ?? 'application/octet-stream';
10 changes: 10 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 89d5ac7

Please sign in to comment.