Page MenuHomeSealhub

stateful-page.ts
No OneTemporary

stateful-page.ts

/* eslint-disable @typescript-eslint/no-explicit-any */
import Router from "@koa/router";
import { predicates, hasShape, is } from "@sealcode/ts-predicates";
import deepmerge, { ArrayMergeOptions } from "deepmerge";
import { BaseContext, Context } from "koa";
import { Templatable, tempstream } from "tempstream";
import { from_base64, to_base64 } from "../utils/base64.js";
import { ExtractTail } from "../utils/utils.js";
import { Mountable } from "./mountable.js";
import { isPlainObject } from "is-what";
export type StatefulPageActionDescription<ActionName> =
| {
action: ActionName;
label?: string;
content?: string;
disabled?: boolean;
}
| ActionName;
export type StateAndMetadata<State, Actions> =
| {
state: State;
inputs: Record<string, string>;
action: keyof Actions;
action_args: string;
}
| {
state: State;
inputs: Record<string, string>;
action: null;
action_args: null;
$: Record<string, unknown>;
};
export abstract class StatefulPage<
State extends Record<string, unknown>,
Actions extends Record<
string,
(
ctx: BaseContext,
state: State,
inputs: Record<string, string>,
...args: unknown[]
) => State | Promise<State>
>
> extends Mountable {
abstract actions: Actions;
abstract getInitialState(ctx: BaseContext): State | Promise<State>;
abstract render(
ctx: BaseContext,
state: State,
inputs: Record<string, string>
): Templatable | Promise<Templatable>;
async canAccess() {
return <const>{ canAccess: true, message: "" };
}
constructor() {
super();
const original_render = this.render.bind(this) as typeof this.render;
this.render = async (
ctx: BaseContext,
state: State,
inputs: Record<string, string>
) => {
return this.wrapInLayout(
ctx,
await this.wrapInForm(
ctx as Context,
state,
await original_render(ctx, state, inputs)
),
state
);
};
}
abstract wrapInLayout(
ctx: BaseContext,
content: Templatable,
state: State
): Templatable;
async wrapInForm(
context: Context,
state: State,
content: Templatable
): Promise<Templatable> {
return tempstream/* HTML */ `<form
action="./"
method="POST"
enctype="multipart/form-data"
>
<input
name="state"
type="hidden"
value="${to_base64(await this.serializeState(context, state))}"
/>
${content}
</form>`;
}
extractActionAndLabel<ActionName extends keyof Actions>(
action_description: StatefulPageActionDescription<ActionName>
): { action: string; label: string; content: string; disabled: boolean } {
let label, action, content: string;
let disabled: boolean;
if (is(action_description, predicates.object)) {
action = action_description.action.toString();
label = action_description.label || action;
content = action_description.content || label;
disabled = action_description.disabled || false;
} else {
action = action_description.toString();
label = action;
content = label;
disabled = false;
}
return { action, label, content, disabled };
}
makeActionURL<ActionName extends keyof Actions>(
action_description: StatefulPageActionDescription<ActionName>,
...args: ExtractTail<
ExtractTail<ExtractTail<Parameters<Actions[ActionName]>>>
>
) {
const { action } = this.extractActionAndLabel(action_description);
return `./?action=${action}&action_args=${encodeURIComponent(
// encoding as URI Component because sometimes it can contain a "+" which is treated as a space
to_base64(JSON.stringify(args))
)}`;
}
makeActionButton<ActionName extends keyof Actions>(
_state: State,
action_description: StatefulPageActionDescription<ActionName>,
...args: ExtractTail<
ExtractTail<ExtractTail<Parameters<Actions[ActionName]>>>
>
) {
const { label, content, disabled } =
this.extractActionAndLabel(action_description);
return /* HTML */ `
<button
type="submit"
formaction="${this.makeActionURL(action_description, ...args)}"
title="${label}"
${disabled ? "disabled" : ""}
>
${content}
</button>
`;
}
makeActionCallback<ActionName extends keyof Actions>(
action_description:
| {
action: ActionName;
label?: string;
}
| ActionName,
...args: ExtractTail<
ExtractTail<ExtractTail<Parameters<Actions[ActionName]>>>
>
) {
return `(()=>{const form = this.closest('form'); form.action='${this.makeActionURL(
action_description,
...args
)}'; form.requestSubmit()})()`;
}
rerender() {
return "this.closest('form').requestSubmit()";
}
async preprocessState(values: State): Promise<State> {
return values;
}
async preprocessOverrides(
_context: BaseContext,
_state: State,
values: Record<string, unknown>
): Promise<Record<string, unknown>> {
return values;
}
async serializeState(_context: BaseContext, state: State): Promise<string> {
return JSON.stringify(state);
}
async deserializeState(_context: BaseContext, s: string): Promise<State> {
const deserialized = JSON.parse(s) as unknown;
return deserialized as State;
}
async extractState(
ctx: BaseContext
): Promise<StateAndMetadata<State, Actions>> {
if (
!hasShape(
{
action: predicates.maybe(predicates.string),
state: predicates.string,
action_args: predicates.maybe(predicates.string),
$: predicates.maybe(predicates.object),
},
ctx.$body
)
) {
console.error("Wrong data: ", ctx.$body);
throw new Error("wrong formdata shape");
}
const inputs = Object.fromEntries(
Object.entries(ctx.$body).filter(
([key]) => !["action", "state", "args", "$"].includes(key)
)
) as Record<string, string>;
// the "$" key is parsed as dot notation and overrides the state
const original_state_string = ctx.$body.state;
const original_state = await this.deserializeState(
ctx,
typeof original_state_string == "string"
? from_base64(original_state_string)
: "{}"
);
const $body = ctx.$body;
let state_overrides = $body.$ || {};
state_overrides = await this.preprocessOverrides(
ctx,
original_state,
state_overrides
);
let modified_state = deepmerge(original_state, state_overrides, {
isMergeableObject: (v) => isPlainObject(v) || Array.isArray(v),
arrayMerge: (
target: any[],
source: any[],
options: ArrayMergeOptions
) => {
// https://github.com/TehShrike/deepmerge?tab=readme-ov-file#arraymerge-example-combine-arrays
const destination = target.slice();
/* eslint-disable @typescript-eslint/no-unsafe-argument */
source.forEach((item, index) => {
if (typeof destination[index] === "undefined") {
destination[index] =
options.cloneUnlessOtherwiseSpecified(
item,
options
);
} else if (options.isMergeableObject(item)) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
destination[index] = deepmerge(
target[index],
item,
options
);
} else if (target.indexOf(item) === -1) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
destination[index] = item;
}
});
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return destination;
},
}) as State;
// giving extending classes a change to modify the state before furhter processing
modified_state = await this.preprocessState(modified_state);
if (ctx.$body.action && ctx.$body.action_args) {
return {
state: modified_state,
action: ctx.$body.action,
inputs,
action_args: ctx.$body.action_args,
};
} else {
return {
state: modified_state,
action: null,
inputs,
action_args: null,
$: ctx.$body.$ || {},
};
}
}
mount(router: Router, path: string) {
router.get(path, async (ctx) => {
ctx.body = this.render(ctx, await this.getInitialState(ctx), {});
});
router.post(path, async (ctx) => {
const { action, state, inputs, action_args } =
await this.extractState(ctx);
if (action) {
const new_state = await this.actions[action](
ctx,
state,
inputs,
...(JSON.parse(
from_base64(action_args as string)
) as unknown[])
);
ctx.body = this.render(ctx, new_state, inputs);
} else {
ctx.body = this.render(ctx, state, inputs);
}
ctx.status = 433;
});
}
}

File Metadata

Mime Type
text/x-java
Expires
Wed, May 7, 19:51 (17 h, 27 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
644083
Default Alt Text
stateful-page.ts (8 KB)

Event Timeline