💾 Archived View for hackersphere.space › ~willowf › gemlog › 2023-09-25.gmi captured on 2023-11-04 at 11:25:21. Gemini links have been rewritten to link to archived content

View Raw

More Information

⬅️ Previous capture (2023-09-28)

-=-=-=-=-=-=-

Willowf's gemlog

Willowf's Home Page

Gemlog index

Previous post

Next post

Today begins my fourth week working at my new software job! I've been learning the framework that is used to make all of the company's internal business software. The framework is Odoo 16 and it's in Python. I'm very lucky to be working with an ERP system that's under LGPLv3 because I can't imagine what a pain it would be if it *wasn't* free.

I recently added a new feature to the Hackersphere Capsule Engine's routing system which I call "dynamic procedures". In contrast to a normal routing system where you have a bunch of "controller" or "handler" classes whose methods implement the behavior of particular routes, dynamic procedures are plain functions which are loaded at runtime. To create a dynamic procedure, you create a Javascript or Typescript file in the `procedures` directory, the name of which starts with an `@` sign, for example `procedures/@foo.ts`. This file should have a single `export default function proc(uriResource: string)`. Given that `@foo.ts` has been compiled (since it is Typescript after all), whenever the URI `hackersphere.space/@foo` is loaded via HTTPS or Gemini, the function `proc` will be dynamically loaded using the ES6 `import` function; then, the URI will be passed to that function, and the function will return the content to be sent back to the client. This unique approach to route handling allows greater flexibility while minimizing shared statefulness and coupling between route handlers and the rest of the system.

In principle, dynamic procedure loading also allows more precise and efficient incremental compilation, because you don't have to recompile the whole HCE to add a new procedure, you only need to compile the procedure itself. In practice, I intend to add a new script to the HCE's `package.json` to make this easy to do, but I haven't added any such script yet, so the few dynamic procedures I've written so far have been compiled by recompiling the whole HCE.

I have been working on building a git frontend for the Hackersphere so that users hoping to jump ship from Github can host their repos here instead. I've set up Gitolite, facilitating the remote creation and management of git repositories by those whose public keys are added to it, and of course anyone with ssh access can already use plain old `git`. I can clone the main repo for the HCE by doing `git clone willowf@hackersphere.space:~/hackersphere.git`; the ultimate goal of getting Gitolite working is so that anyone else can clone the repo as well, and the ultimate goal of building a git frontend is to allow anyone to browse the source code in their Gemini or Web browser. In the spirit of free software, you can email `sysadmin@hackersphere.space` with your email address to request a current copy of the repo, and I will be happy to oblige.

Try out some of the dynamic procedures now:

https://hackersphere.space/@run/user:willowf/src:scripts/example.js

gemini://hackersphere.space/@git/user:willowf

gemini://hackersphere.space/@hello/name:PutYourNameHere

Here's the source code of a dynamic procedure, `@run.ts`, which is active at time of writing, to serve as an example:

import * as fs from "fs";
import * as fsasync from "fs/promises";
import { ProcedureCallResult, getParameters } from "../Procedure";
import { loadServerConfiguration } from "../ServerCfg";

/**
 * Usage:
 * hackersphere.space/@run/user:someone/src:path/to/their/script.js
 */
export default async function proc(uriResource: string): Promise<ProcedureCallResult> {
  const cfg = await loadServerConfiguration("cfg/server.json");
  const params = getParameters(uriResource);
  const homeDir =
    params.namedParameters.user === undefined ?
      cfg.staticFilesDirectory
    : `${cfg.staticFilesDirectory}/~${params.namedParameters.user}`;
  if (params.namedParameters.src === undefined) {
    return {
      error: 1,
      reason: "Expected `script` parameter but got `undefined`"
    };
  }
  const scriptPath = [
    params.namedParameters.src,
    ...params.positionalParameters.filter(v => v !== "@run")
  ].join('/');
  if (scriptPath.includes("..") || scriptPath.includes("./")) {
    return {
      error: 1,
      reason: "Directory walking not allowed"
    };
  } else if (scriptPath.endsWith(".js")) {
    const fullPath = `${homeDir}/${scriptPath}`;
    if (!fs.existsSync(fullPath)) {
      return {
        error: 1,
        reason: scriptPath + ": js file not found"
      };
    } else {
      const jsFileContents = await fsasync.readFile(fullPath);
      return {
        error: 0,
        gemtextResult: `<!DOCTYPE html>
        <html lang="en">
          <head>
            <meta charset="utf-8">
            <title>title</title>
            <link rel="stylesheet" href="style.css">
          </head>
          <body><script type="text/javascript">
${jsFileContents}</script></body>
        </html>`
      };
    }
  } else {
    return {
      error: 1,
      reason: "Only supported language is Javascript"
    };
  }
}

Here's the code of Procedure.ts, which implements dynamic procedures:

import * as fs from "fs";

export type ProcedureRoute = `@${string}`;

interface ProcedureSuccess {
  error: 0,
  gemtextResult: string,
  /** Should the `gemtextResult` be converted to HTML when responding to HTTPS requests? */
  convHtml?: boolean
};

interface ProcedureError {
  error: 1,
  reason: string
};

export type ProcedureCallResult = ProcedureSuccess | ProcedureError;

export interface ProcedureArguments {
  namedParameters: {
    [param: string]: string
  },
  positionalParameters: string[]
};

/**
 * Convenience function to parse a route slug into a parameter list.
 * May not be suitable for all procedures.
 */
export function getParameters(uriResource: string): ProcedureArguments {
  let result: ProcedureArguments = {
    namedParameters: {},
    positionalParameters: []
  };
  const uriParts = uriResource.split('/');
  for (const part of uriParts) {
    if (part.includes(':')) {
      const subparts = part.split(':');
      if (subparts.length === 2) {
        result.namedParameters[subparts[0]] = subparts[1];
      } else {
        const value = subparts.slice(1).join(':');
        result.namedParameters[subparts[0]] = value;
      }
    } else {
      result.positionalParameters.push(part);
    }
  }
  return result;
}

/**
 * A `Procedure` is a route handler that gets called when a route's
 * slug begins with an `@`. This can be thought of as a little like
 * CGI. I've designed it to be able to work with both HTTPS and Gemini.
 * 
 * A call to a `Procedure` might look like:
 * gemini://hackersphere.space/@git/user:willowf/repo:beebo.git
 * 
 * The source code of the procedure should be a file that declares
 * and exports a default const `Procedure` named `proc`. */
export async function callProcedure(
  uriResource: ProcedureRoute
): Promise<ProcedureCallResult> {
  const uriParts = uriResource.split('/').slice(1);
  const route: ProcedureRoute = uriParts[0] as ProcedureRoute;
  /** TODO: configurize fragile relative path. */
  const procedurePath = `./procedures/${route}`;
  try {
    // TODO memoize/cache procedures that have already been loaded before?
    const procedureModule = await import(procedurePath);
    const procedure =
      procedureModule.default as (args: string)=> Promise<ProcedureCallResult>;
    console.log(`${uriResource}: succeeded`);
    return procedure(uriResource);
  } catch (error) {
    console.log(`${uriResource}: failed`);
    return {
      error: 1,
      reason: (error as Error).message
    };
  }
}