Page MenuHomeSealhub

stateful-page.ts
No OneTemporary

stateful-page.ts

import Router from "@koa/router";
import { predicates, hasShape, is } 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 abstract class StatefulPage<
State extends Record<string, unknown>,
Actions extends Record<
string,
(
state: State,
inputs: Record<string, string>,
...args: unknown[]
) => State
>
> extends Mountable {
abstract actions: Actions;
abstract getInitialState(): 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)
)
);
};
}
abstract wrapInLayout(ctx: BaseContext, content: Templatable): 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>`;
}
makeActionButton<ActionName extends keyof Actions>(
state: State,
action_description:
| {
action: ActionName;
label?: string;
}
| ActionName,
...args: ExtractTail<ExtractTail<Parameters<Actions[ActionName]>>>
) {
let label, action: string;
if (is(action_description, predicates.object)) {
action = action_description.action.toString();
label = action_description.label || action;
} else {
action = action_description.toString();
label = action;
}
return /* HTML */ `
<input
type="submit"
value=${label}
formaction="./?action=${action}&args=${to_base64(
JSON.stringify(args)
)}"
/>
`;
}
extractState(ctx: BaseContext):
| {
state: State;
inputs: Record<string, string>;
action: keyof Actions;
action_args: string;
}
| {
state: State;
inputs: Record<string, string>;
action: null;
action_args: null;
} {
if (
!hasShape(
{
action: predicates.maybe(predicates.string),
state: predicates.string,
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 = JSON.parse(from_base64(ctx.$body.state));
const modified_state = deepmerge(original_state, ctx.$body.$ || {}, {
arrayMerge: (
_target: any[],
source: any[],
_options: ArrayMergeOptions
) => {
// https://github.com/TehShrike/deepmerge#user-content-arraymerge-example-combine-arrays
return source;
},
}) as State;
if (ctx.$body.action && ctx.$body.args) {
return {
state: modified_state,
action: ctx.$body.action as string,
inputs,
action_args: ctx.$body.args,
};
} else {
return {
state: modified_state,
action: null,
action_args: null,
inputs,
};
}
}
mount(router: Router, path: string) {
router.get(path, (ctx) => {
ctx.body = this.render(ctx, this.getInitialState(), {});
});
router.post(path, (ctx) => {
const { action, state, inputs, action_args } =
this.extractState(ctx);
if (action) {
ctx.body = this.render(
ctx,
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:37 (1 d, 18 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
663660
Default Alt Text
stateful-page.ts (4 KB)

Event Timeline