import { Datex } from "datex-core-legacy";
import { resolveEntrypointRoute } from "../routing/rendering.ts";
import { Context } from "../routing/context.ts";
import { convertANSIToHTML } from "../utils/ansi-to-html.ts";
import { Path } from "datex-core-legacy/utils/path.ts";
import { getCallerFile } from "datex-core-legacy/utils/caller_metadata.ts";
import { setCookie, Cookie } from "../lib/cookie/cookie.ts";
import { ALLOWED_ENTRYPOINT_FILE_NAMES, app } from "../app/app.ts";
import { Entrypoint, RouteHandler, html_generator } from "./entrypoints.ts";
import { client_type } from "datex-core-legacy/utils/constants.ts";
import { HTTPStatus } from "../html/http-status.ts";
import { createErrorHTML } from "../html/errors.tsx";
import { HTTPError } from "../html/http-error.ts";
import { convertToWebPath } from "../app/convert-to-web-path.ts";
import { getJSONCompatibleSerializedValue } from "../utils/serialize-js.ts";
const fileServer = client_type === "deno" ? (await import("https://deno.land/std@0.164.0/http/file_server.ts")) : null;
type mime_type = `${'text'|'image'|'application'|'video'|'audio'}/${string}`;
type json_mime_type = `application/${string}+json`;
export function lazy<T extends html_generator>(generator: T): T {
let result:Awaited<ReturnType<T>>|undefined;
let loaded = false;
return (async function(ctx: Context) {
if (loaded) return result;
else {
result = await generator(ctx, ctx.params) as any;
loaded = true;
return result;
}
}) as any
}
// TODO: remove, deprecated
/**
* @deprecated, use lazy instead
*/
export const once = lazy
/**
* serve a value as raw content (DX, DXB, JSON format)
* @param value any JS value (must be JSON compatible if JSON is used as the content type)
* @param options optional options:
* type: Datex.FILE_TYPE (DX, DXB, JSON)
* formatted: boolean if true, the DX/JSON is formatted with newlines/spaces
* mimeType: custom mime type if type is JSON
* @returns blob containing DATEX/JSON encoded value
*/
export async function provideValue(value:unknown, options?:{type?:Datex.DATEX_FILE_TYPE, formatted?:boolean, mockPointers?:boolean, mimeType?: json_mime_type}) {
if (options?.type == Datex.FILE_TYPE.DATEX_BINARY) {
return provideContent(await Datex.Compiler.compile("?", [value]) as ArrayBuffer, options.type[0])
}
else if (options?.type == Datex.FILE_TYPE.JSON) {
if (options?.mockPointers) value = getJSONCompatibleSerializedValue(value);
return provideContent(JSON.stringify(value??null, null, options?.formatted ? ' ' : undefined), options.mimeType ?? options.type[0])
}
else {
return provideContent(Datex.Runtime.valueToDatexStringExperimental(value, options?.formatted), (options?.type ?? Datex.FILE_TYPE.DATEX_SCRIPT)[0])
}
}
/**
* serve a value as JSON
* @param value any JSON compatible value
* @param options optional options:
* formatted: boolean if true, the DX/JSON is formatted with newlines/spaces
* mimeType: custom mime type (e.g. "application/geo+json")
* @returns blob containing DATEX/JSON encoded value
*/
export function provideJSON(value:unknown, options?:{formatted?:boolean, mimeType?: json_mime_type}) {
return provideValue(value, {formatted: options?.formatted, type: Datex.FILE_TYPE.JSON, mimeType:options?.mimeType})
}
/**
* Show an interactive value view in the browser, including syntax highlighting
* @param value
*/
export function provideValueDebugView(value: unknown) {
const dxString = Datex.Runtime.valueToDatexStringExperimental(value, true, true, true, true);
const html = `<html style="color: white;background: #111111;padding: 10px;line-height: 1.2rem;">
<head>
<meta charset="UTF-8">
<style>
body span {
line-height: 1.2rem!important;
}
</style>
</head>
${convertANSIToHTML(dxString)}
</html>`
return provideResponse(html, "text/html;charset=utf-8");
}
// export function provideValueDebugView(value: unknown) {
// const dxString = Datex.Runtime.valueToDatexStringExperimental(value);
// const page = `
// <!DOCTYPE html>
// <html lang="en">
// <head>
// <meta charset="UTF-8">
// <meta name="viewport" content="viewport-fit=cover, width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"/>
// <title>UXI Value Debug View</title>
// <link rel="icon" href="https://dev.cdn.unyt.org/unyt_core/assets/square_dark.png">
// <script type="importmap">
// {
// "imports": {
// "unyt_core": "https://dev.cdn.unyt.org/unyt_core/datex.ts",
// "uix": "https://dev.cdn.unyt.org/uix/uix.ts",
// "unyt_core/": "https://dev.cdn.unyt.org/unyt_core/",
// "uix/": "https://dev.cdn.unyt.org/uix/",
// "uix_std/": "https://dev.cdn.unyt.org/uix/uix_std/",
// "unyt_tests/": "https://dev.cdn.unyt.org/unyt_tests/",
// "unyt_web/": "https://dev.cdn.unyt.org/unyt_web/",
// "unyt_node/": "https://dev.cdn.unyt.org/unyt_node/",
// "unyt_cli/": "https://dev.cdn.unyt.org/unyt_cli/",
// "supranet/": "https://portal.unyt.org/ts_module_resolver/",
// "uix/jsx-runtime": "https://dev.cdn.unyt.org/uix/jsx-runtime/jsx.ts",
// "backend/": "/@uix/src/backend/",
// "common/": "/@uix/src/common/",
// "frontend/": "/@uix/src/frontend/"
// }
// }
// </script>
// <script type="module">
// import { datex, Datex } from "datex-core-legacy";
// import { DatexValueTreeView } from "uix_std/datex/value_tree_view.ts"
// import { dx_value_manager } from "uix_std/datex/resource_manager.ts";
// await Datex.Supranet.connect();
// const value = await datex \`${dxString}\`;
// console.log("${dxString}", value);
// const tree_view = new DatexValueTreeView({
// root_resource_path:(await dx_value_manager.getResourceForValue(value)).path,
// header:false,
// enable_drop:false,
// display_root: true
// }, {dynamic_size:false});
// document.body.append(tree_view)
// </script>
// </head>
// </html>
// `
// return provideResponse(page, "text/html");
// }
export function provideResponse(content:ReadableStream | XMLHttpRequestBodyInit, type:mime_type, status = 200, cookies?:Cookie[], headers:Record<string, string> = {}, cors = false) {
if (cors) Object.assign(headers, {"Access-Control-Allow-Origin": "*", "Access-Control-Allow-Headers": "*"});
Object.assign(headers, {"Content-Type": type});
const res = new Response(content, {headers, status});
if (cookies) {
for (const cookie of cookies) setCookie!(res.headers, cookie);
}
return res;
}
/**
* serve a string/ArrayBuffer with a specific mime type
* @param content 'file' content
* @param type mime type
* @returns content blob
*/
export async function provideContent(content:string|ArrayBuffer, type:mime_type = "text/plain;charset=utf-8", status?:number) {
const blob = new Blob([content], {type});
await Datex.Runtime.cacheValue(blob);
return provideResponse(blob, type, status);
}
export class FileHandle {
constructor(public path: Path) {}
}
/**
* serve a file
* @param path local file path
* @returns content FSFile
*/
export function provideFile(path: string | URL) {
const resolvedPath = new Path(path, getCallerFile());
return new FileHandle(resolvedPath);
}
/**
* Just returns a URL, which is interpreted as redirect by the entrypoint resolver
* @param path local file path or URL
* @returns resolved URL
*/
export function provideRedirect(path:string|URL) {
if (path instanceof URL) return path;
else if (path.startsWith("/")) return Path.Route(path);
else return new Path(path, getCallerFile());
}
/**
* Similar to provideRedirect/returning a URL, but no redirect on the client - the
* content is just served for the current URL
* uses the internal UIX server to resolve a url to a response
* @param path local file path or URL
* @returns redirect response
*/
export function provideVirtualRedirect(path:string|URL) {
const resolvedPath = path instanceof URL ? path : new Path(path, getCallerFile());
const webPath = convertToWebPath(resolvedPath);
return (ctx: Context) => {
// a URL is required, the domain is not really relevant, but copied from request origin
const origin = new URL(ctx.request?.url??'https://_virtual_redirect.unyt.org').origin
// request headers also copied from request
const request = new Request(new URL(origin + webPath), {headers:ctx.request?.headers})
return app.defaultServer!.getResponse(request)
}
}
const matchURL = /\b((https?|file):\/\/[^\s]+(\:\d+)?(\:\d+)?\b)/g;
/**
* Creates an Error View
* @param message error title
* @param status http status code or error
* @returns
*/
export function provideError(title: string, error?: Error|number|HTTPStatus<number,string>|string) {
const [statusCode, html] = createErrorHTML(title, error);
return new HTTPStatus(statusCode, html)
}
// /**
// * @deprecated return/throw a new HTTPError or an Error instead
// * serve an errror with a status code and message
// * @param message error message
// * @param status http status code
// * @returns content blob
// */
// export function provideError(message: string, status:number|HTTPStatus = 500) {
// status = typeof status == "number" ? status : status.code;
// const content = indent `
// <html>
// <head>
// <meta charset="UTF-8">
// <meta name="viewport" content="viewport-fit=cover, width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"/>
// </head>
// <body>
// <div style="
// width: 100%;
// height: 100%;
// display: flex;
// justify-content: center;
// align-items: center;
// font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
// font-size: 1.5em;
// color: var(--text_highlight);">
// <div style="text-align:center; word-break: break-word;">
// <h2 style="margin-bottom:0; background: #ea2b51; -webkit-background-clip: text; -webkit-text-fill-color: transparent;">Error ${status}</h2>
// <div>${message}</div>
// </div>
// </div>
// </body>
// </html>
// `;
// return provideContent(content, "text/html", status);
// }
/**
* Provide static files, backend only
*/
export class FileProvider implements RouteHandler {
#path: Path
get path() {return this.#path}
constructor(path:Path.representation, public resolveIndexHTML = true, public allowHTMLWithoutExtension = true) {
this.#path = new Path(path, getCallerFile());
if (this.#path.fs_is_dir) this.#path = this.#path.asDir()
}
getRoute(route:Path.route_representation|string, context: Context) {
if (!context.request) return provideError("Cannot serve file");
let path = this.#path.getChildPath(route);
// dir path -> index.html
if (path.fs_is_dir && this.resolveIndexHTML) {
path = path.getChildPath("index.html");
}
// file not found
if (!path.fs_exists) {
// .html?
if (this.allowHTMLWithoutExtension && path.getWithFileExtension("html").fs_exists) {
path = path.getWithFileExtension("html");
}
else return new HTTPError(HTTPStatus.NOT_FOUND)
}
return fileServer!.serveFile(context.request, path.normal_pathname);
}
}
export const KEEP_CONTENT = Symbol("KEEP_CONTENT")
export class PageProvider implements RouteHandler {
path!: Path
useDirective?: string
/**
*
* @param path
* @param useDirective optional use directive required for a entrypoint to be loaded (e.g. "use backend")
* If a use directive is present in an entrypoint file, but not useDirective value is set, the entrypoint is not loaded.
*/
constructor(path:Path.representation, useDirective?: string) {
this.useDirective = useDirective;
this.path = new Path(path, getCallerFile());
if (this.path.fs_is_dir) this.path = this.path.asDir()
}
getRoute(route: Path.route_representation, context: Context): Entrypoint|Promise<Entrypoint> {
if (!this.path) return KEEP_CONTENT // loaded a PageProvider from backend, path not known, cannot resolve (TODO)
return this.#findValidEntrypoint(
this.path.getChildPath(route).asDir(),
ALLOWED_ENTRYPOINT_FILE_NAMES
)
}
async #findValidEntrypoint(parentDir: Path, names: string[], redirectRoute:string[] = []): Promise<Entrypoint|null> {
for (const name of names) {
try {
const url = parentDir.getChildPath(name);
// make sure use directive matches
// browser: fetch file only if useDirective is present in file, otherwise not found is returned
if (client_type == "browser") url.searchParams.append("useDirective", this.useDirective??"")
// deno: read file and check if use directive matches, otherwise return null
else {
if (await url.fsExists() && !await PageProvider.useDirectiveMatchesForFile(url, this.useDirective)) {
// console.log("use directive '" + (this.useDirective??'') + "' does not match for " + url)
return null;
}
}
// make sure another directive
const entrypoint = (await datex.get<any>(url))!.default as Entrypoint;
// resolve route for entrypoint
if (redirectRoute.length) {
// TODO: #14
const { content } = await resolveEntrypointRoute({entrypoint, route: Path.Route(redirectRoute)});
return content as Entrypoint;
}
// return entrypoint directly
return entrypoint;
}
catch (e){
// Error 406, because the use directive of the file does not match
// -> don't go up further in the file tree, just stop here and return null content
// TODO: better way?
if (e.message.endsWith("(406)")) return null;
// Any other unexpected error besides 404/not found, throw error
if (!e.message.endsWith("(404)") && !e.message.includes("No such file or directory")) throw e;
}
}
// no entrypoint in directory, find entrypoint in parent directory
if (parentDir.parent_dir.toString() !== parentDir.toString()) {
redirectRoute.unshift(parentDir.name);
return this.#findValidEntrypoint(parentDir.parent_dir, names, redirectRoute)
}
}
static async useDirectiveMatchesForFile(path: Path, directive?:string) {
const file = (await Deno.readTextFile(path.normal_pathname)).trimStart();
if (directive) {
// does not have the required use directive
if (!file.startsWith(`"use ${directive}"`)) return false;
}
else {
// has a use directive, although know use directive should be present
if (file.match(/^"use [\w-]+"/)) return false;
}
return true;
}
}