Page MenuHomeSealhub

stateful-page.ts
No OneTemporary

stateful-page.ts

import Router from "@koa/router";
import { predicates, hasShape, is, hasField } from "@sealcode/ts-predicates";
import deepmerge, { ArrayMergeOptions } from "deepmerge";
import { BaseContext } 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";
export type StatefulPageActionDescription<ActionName> =
| {
action: ActionName;
label?: string;
content?: string;
}
| 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,
(
state: State,
inputs: Record<string, string>,
...args: unknown[]
) => State | Promise<State>
>
> extends Mountable {
abstract actions: Actions;
abstract getInitialState(): 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);
this.render = async (
ctx: BaseContext,
state: State,
inputs: Record<string, string>
) => {
return this.wrapInLayout(
ctx,
this.wrapInForm(
state,
await original_render(ctx, state, inputs)
),
state
);
};
}
abstract wrapInLayout(
ctx: BaseContext,
content: Templatable,
state: State
): Templatable;
wrapInForm(state: State, content: Templatable): Templatable {
return tempstream/* HTML */ `<form action="./" method="POST">
<input
name="state"
type="hidden"
value="${to_base64(JSON.stringify(state))}"
/>
${content}
</form>`;
}
extractActionAndLabel<ActionName extends keyof Actions>(
action_description: StatefulPageActionDescription<ActionName>
): { action: string; label: string; content: string } {
let label, action, content: string;
if (is(action_description, predicates.object)) {
action = action_description.action.toString();
label = action_description.label || action;
content = action_description.content || label;
} else {
action = action_description.toString();
label = action;
content = label;
}
return { action, label, content };
}
makeActionURL<ActionName extends keyof Actions>(
action_description: StatefulPageActionDescription<ActionName>,
...args: ExtractTail<ExtractTail<Parameters<Actions[ActionName]>>>
) {
const { action } = this.extractActionAndLabel(action_description);
return `./?action=${action}&action_args=${to_base64(
JSON.stringify(args)
)}`;
}
makeActionButton<ActionName extends keyof Actions>(
_state: State,
action_description: StatefulPageActionDescription<ActionName>,
...args: ExtractTail<ExtractTail<Parameters<Actions[ActionName]>>>
) {
let { label, content } = this.extractActionAndLabel(action_description);
return /* HTML */ `
<button
type="submit"
formaction="${this.makeActionURL(action_description, ...args)}"
title="${label}"
>
${content}
</button>
`;
}
makeActionCallback<ActionName extends keyof Actions>(
action_description:
| {
action: ActionName;
label?: string;
}
| ActionName,
...args: 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(
state: State,
values: Record<string, unknown>
): Promise<Record<string, unknown>> {
return values;
}
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 = JSON.parse(
typeof original_state_string == "string"
? from_base64(original_state_string)
: "{}"
);
const $body = ctx.$body;
let state_overrides = $body.$ || {};
state_overrides = await this.preprocessOverrides(
original_state,
state_overrides
);
let modified_state = deepmerge(original_state, state_overrides, {
arrayMerge: (
target: any[],
source: any[],
options: ArrayMergeOptions
) => {
// https://github.com/TehShrike/deepmerge?tab=readme-ov-file#arraymerge-example-combine-arrays
const destination = target.slice();
source.forEach((item, index) => {
if (typeof destination[index] === "undefined") {
destination[index] =
options.cloneUnlessOtherwiseSpecified(
item,
options
);
} else if (options.isMergeableObject(item)) {
destination[index] = deepmerge(
target[index],
item,
options
);
} else if (target.indexOf(item) === -1) {
destination[index] = item;
}
});
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 as string,
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(), {});
});
router.post(path, async (ctx) => {
const { action, state, inputs, action_args } =
await this.extractState(ctx);
if (action) {
ctx.body = this.render(
ctx,
await this.actions[action](
state,
inputs,
...(JSON.parse(
from_base64(action_args as string)
) as unknown[])
),
inputs
);
} else {
ctx.body = this.render(ctx, state, inputs);
}
ctx.status = 433;
});
}
}

File Metadata

Mime Type
text/x-java
Expires
Wed, May 7, 19:43 (1 d, 6 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
642649
Default Alt Text
stateful-page.ts (6 KB)

Event Timeline