Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 4 additions & 9 deletions examples/plugins/ui-next-plugin/index.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,18 @@
import { Context, Handler } from 'hydrooj';

// Server side of the example plugin. It registers a couple of routes whose
// handlers set a `template`; with @hydrooj/ui-next installed the `next` renderer
// (priority 100) wins for every template, ignores the template file, and injects
// the route/template name into the SPA shell. The client then renders the page
// registered under `page:<template without .html>` (falling back to `page:<route name>`).

class ExampleHomeHandler extends Handler {
useUiNext = true;

async get() {
// Stem (`example_home`) must match the slot registered in ui/index.tsx.
this.response.template = 'example_home.html';
// response.body is forwarded to the page as `args` (see usePageData).
this.response.body = { message: 'Hello from the ui-next example plugin server side!' };
}
}

class ExampleCssHandler extends Handler {
useUiNext = true;

async get() {
this.response.template = 'example_css.html';
this.response.body = {};
}
}
Expand Down
2 changes: 1 addition & 1 deletion examples/plugins/ui-next-plugin/ui/index.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { PluginAPI } from '@hydrooj/ui-next';

export function setup(api: PluginAPI) {
// (2) Page registration — slot names match the server route names / template stems
// (2) Page registration — slot names match the server route names
// registered in ../index.ts. Pages are lazy-loaded on demand.
api.registerPage('example_home', () => import('./pages/example_home'));
api.registerPage('example_css', () => import('./pages/example_css'));
Expand Down
8 changes: 4 additions & 4 deletions framework/framework/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,9 @@ export default (logger, xff, xhost) => async (ctx: KoaContext, next: Next) => {
response.body ||= {};
response.body.url = response.redirect;
}
if (!response.type) {
if (response.pjax && args.pjax) {
// Attachments already have their body and content type on the Koa response.
if (!response.type && (!handler.useUiNext || response.body !== null || ctx.body == null)) {
if (!handler.useUiNext && response.pjax && args.pjax) {
const pjax = typeof response.pjax === 'string' ? [[response.pjax, {}]] : response.pjax;
response.body = {
fragments: (await Promise.all(
Expand All @@ -76,15 +77,14 @@ export default (logger, xff, xhost) => async (ctx: KoaContext, next: Next) => {
response.type = 'application/json';
} else if (
request.json || response.redirect
|| request.query.noTemplate || !response.template // no template, send raw data
|| request.query.noTemplate || handler.useUiNext || !response.template // no template, send raw data
) {
// Send raw data
try {
if (request.headers['x-hydro-inject']) {
const inject = request.headers['x-hydro-inject'].toString().toLowerCase().split(',').map((i) => i.trim());
if (inject.includes('pagename')) {
ctx.set('x-hydro-page', ctx._matchedRouteName || '');
ctx.set('x-hydro-template', response.template || '');
}
if (response.body !== null && typeof response.body === 'object') {
if (inject.includes('uicontext')) response.body.UiContext = UiContext;
Expand Down
5 changes: 4 additions & 1 deletion framework/framework/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,9 @@ export class HandlerCommon {
export class Handler extends HandlerCommon {
static [kHandler] = 'Handler';

/** Render this handler with ui-next, using its route name instead of a template. */
declare useUiNext?: boolean;

loginMethods: any;
notUsage = false;
allowCors = false;
Expand Down Expand Up @@ -390,7 +393,7 @@ export class WebService extends Service<never> {
ctx.mixin('server', ['Route', 'Connection', 'withHandlerClass']);
this.server.keys = this.config.keys;
this.server.proxy = this.config.proxy;
const corsAllowHeaders = 'x-requested-with, accept, origin, content-type, upgrade-insecure-requests';
const corsAllowHeaders = 'x-requested-with, x-hydro-inject, accept, origin, content-type, upgrade-insecure-requests';
this.server.use(Compress());
this.server.use(async (c, next) => {
if ((c.request.headers.origin || c.request.headers.referer) && this.config.cors) {
Expand Down
4 changes: 4 additions & 0 deletions packages/hydrooj/src/service/layers/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ export interface UiContextBase {
cdn_dynamic: boolean;
url_prefix: string;
ws_prefix: string;
domainId?: string;
domainHost?: string[];
}
export const UiContextBase: UiContextBase = {
cdn_prefix: '/',
Expand All @@ -34,7 +36,9 @@ export default async (ctx: KoaContext, next: Next) => {
if (UiContext.ws_prefix.includes(',')) UiContext.ws_prefix = randomPick(UiContext.ws_prefix.split(','));
UiContext.cdn_dynamic = system.get('server.cdn_dynamic');
}
const domainHost = Array.isArray(domainInfo.host) ? domainInfo.host : [domainInfo.host];
UiContext.domainId = domainId;
UiContext.domainHost = domainHost.filter(Boolean);
UiContext.domainVersion = domain.getVersion(domainInfo);
ctx.HydroContext.UiContext = UiContext;
ctx.HydroContext.domain = domainInfo;
Expand Down
194 changes: 103 additions & 91 deletions packages/ui-next/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import fs from 'fs';
import path from 'path';
import esbuild from 'esbuild';
import type { Next } from 'koa';
import c2k from 'koa2-connect/ts';
import { createServer, type Plugin } from 'vite';
import { HandlerCommon, serializer } from '@hydrooj/framework';
import { HandlerCommon, type KoaContext, serializer } from '@hydrooj/framework';
import {
Context, Handler, Logger,
NotFoundError, param, SettingModel, sha1, size, Types,
Expand Down Expand Up @@ -169,6 +170,27 @@ class UiNextConstantHandler extends Handler {
}
}

function getPluginBuildOptions(entries: Record<string, string>): esbuild.BuildOptions {
return {
stdin: {
contents: [
...Object.entries(entries).map(([_, e], i) => `import * as plugin${i} from '${e}';`),
`window.__hydroPlugins = [${Object.entries(entries).map(([n], i) => `{ name: '${n}', ...plugin${i} }`).join(', ')}];`,
].join('\n'),
sourcefile: 'plugins.ts',
resolveDir: process.cwd(),
loader: 'ts',
},
bundle: true,
format: 'esm',
write: false,
target: ['chrome90'],
plugins: [federationPlugin],
jsx: 'automatic',
jsxImportSource: 'react',
};
}

export async function buildPlugins() {
const start = Date.now();
let totalSize = 0;
Expand All @@ -188,38 +210,23 @@ export async function buildPlugins() {
}
};

if (!Object.keys(entries).length) {
emit('plugins.js', 'window.__hydroPlugins = [];');
purge();
logger.info('No plugins to build');
return;
}

try {
if (!Object.keys(entries).length) {
emit('plugins.js', 'window.__hydroPlugins = [];');
purge();
logger.info('No plugins to build');
return;
}

const result = await esbuild.build({
stdin: {
contents: [
...Object.entries(entries).map(([_, e], i) => `import * as plugin${i} from '${e}';`),
`window.__hydroPlugins = [${Object.entries(entries).map(([n], i) => `{ name: '${n}', ...plugin${i} }`).join(', ')}];`,
].join('\n'),
sourcefile: 'plugins.ts',
resolveDir: process.cwd(),
loader: 'ts',
},
bundle: true,
format: 'esm',
...getPluginBuildOptions(entries),
splitting: true,
outdir: 'plugins-dist',
entryNames: 'plugins',
chunkNames: 'chunk-[hash]',
assetNames: 'asset-[hash]',
metafile: true,
write: false,
target: ['chrome90'],
plugins: [federationPlugin],
minify: true,
jsx: 'automatic',
jsxImportSource: 'react',
});
if (result.errors.length) logger.error('Plugin build errors: %o', result.errors);

Expand Down Expand Up @@ -274,17 +281,70 @@ const injectedScripts = (resolve: (name: string) => string, viewLang: string) =>
'versions.js',
].map((name) => `<script src="${resolve(name)}"></script>`);

function injectPage(ctx: Context, handler: Handler, html: string, assetUrl: (name: string) => string, pluginsUrl?: string) {
const serialized = JSON.stringify({
HYDRO_INJECTED: true,
name: handler.context._matchedRouteName,
args: {
UserContext: handler.user,
UiContext: handler.UiContext,
...handler.response.body,
},
url: handler.context.req.url!,
route_map: ctx.server.routeMap,
endpoint: ctx.setting.get('server.url') || undefined,
plugins_url: pluginsUrl,
}, serializer(false, handler)).replaceAll('<', '\\u003c');
const injectHtml = [
buildInject(serialized),
...injectedScripts(assetUrl, getViewLang(handler)),
].join('\n');
return html.replace(INJECT_MARKER, injectHtml);
}

const uiNextLayer = (render: (handler: Handler) => string | Promise<string>) => async (ctx: KoaContext, next: Next) => {
await next();
const handler: Handler = ctx.handler;
if (!handler?.useUiNext || handler.request.websocket) return;

const { request, response } = handler;
response.addHeader('x-hydro-ui-next', 'true');
if (ctx.cors) ctx.append('Access-Control-Expose-Headers', 'x-hydro-page, x-hydro-ui-next');
if (request.json || request.query.noTemplate || response.type || response.redirect || response.body === null) return;

response.body = await render(handler);
response.type = 'text/html';
};

const SupportedHandlers = [
'HomeHandler',
'ProblemMainHandler',
];

export async function apply(ctx: Context) {
if (process.env.HYDRO_CLI) return;

for (const name of SupportedHandlers) {
ctx.withHandlerClass(name, (HandlerClass) => {
ctx.effect(() => {
const original = Object.getOwnPropertyDescriptor(HandlerClass.prototype, 'useUiNext');
HandlerClass.prototype.useUiNext = true;
return () => {
if (original) Object.defineProperty(HandlerClass.prototype, 'useUiNext', original);
else delete HandlerClass.prototype.useUiNext;
};
});
});
}
ctx.Route('ui_next_constants', '/plugins/:version/:name', UiNextConstantHandler);

if (process.env.DEV) {
ctx.on('app/started', async () => {
const buildDev = async () => {
await buildI18n();
await buildCodeLangs();
await buildVersions();
});
};
ctx.on('app/started', buildDev);
ctx.on('app/i18n/update', buildI18n);
ctx.on('system/setting-loaded', buildCodeLangs);
ctx.on('system/setting', buildCodeLangs);
Expand All @@ -311,36 +371,12 @@ export async function apply(ctx: Context) {
ctx.server.addCaptureRoute(route, middleware);
}
const html = fs.readFileSync(path.join(__dirname, 'index.html'), 'utf-8');
ctx.server.registerRenderer('next', {
name: 'next',
accept: [],
output: 'html',
asFallback: true,
priority: 100,
async render(_name, args, context) {
const serialized = JSON.stringify({
HYDRO_INJECTED: true,
name: context.handler.context._matchedRouteName,
template: context.handler.response.template || '',
args: {
UserContext: context.UserContext,
UiContext: context.handler.UiContext,
...args,
},
url: context.handler.context.req.url!,
route_map: ctx.server.routeMap,
endpoint: ctx.setting.get('server.url') || undefined,
}, serializer(false, context.handler));
const ts = Date.now();
const devAssetUrl = (name: string) => `/plugins/0/${name}?_=${ts}`;
const injectHtml = [
buildInject(serialized),
...injectedScripts(devAssetUrl, getViewLang(context.handler)),
].join('\n');
const htmlToRender = html.replace(INJECT_MARKER, injectHtml);
return await vite.transformIndexHtml(context.handler.context.req.url!, htmlToRender);
},
});
ctx.server.addHandlerLayer('ui-next', uiNextLayer(async (handler) => {
const ts = Date.now();
const devAssetUrl = (name: string) => `/plugins/0/${name}?_=${ts}`;
const htmlToRender = injectPage(ctx, handler, html, devAssetUrl);
return vite.transformIndexHtml(handler.context.req.url!, htmlToRender);
}));

// eslint-disable-next-line consistent-return
return async () => {
Expand All @@ -353,45 +389,21 @@ export async function apply(ctx: Context) {
await buildCodeLangs();
await buildVersions();
};
ctx.on('app/started', build);

ctx.server.registerRenderer('next', {
name: 'next',
accept: [],
output: 'html',
asFallback: true,
priority: 100,
async render(_name, args, context) {
const indexHtml = path.join(__dirname, 'public', 'index.html');
if (!fs.existsSync(indexHtml)) return PENDING_HTML;
const html = fs.readFileSync(indexHtml, 'utf-8');
const serialized = JSON.stringify({
HYDRO_INJECTED: true,
name: context.handler.context._matchedRouteName,
template: context.handler.response.template || '',
args: {
UserContext: context.UserContext,
UiContext: context.handler.UiContext,
...args,
},
url: context.handler.context.req.url!,
route_map: ctx.server.routeMap,
endpoint: ctx.setting.get('server.url') || undefined,
plugins_url: `/plugins/${hashes['plugins.js'] || HASH_FALLBACK}/plugins.js`,
}, serializer(false, context.handler));
const prodAssetUrl = (name: string) => `/plugins/${hashes[name] || HASH_FALLBACK}/${name}`;
const injectHtml = [
buildInject(serialized),
...injectedScripts(prodAssetUrl, getViewLang(context.handler)),
].join('\n');
return html.replace(INJECT_MARKER, injectHtml);
},
});
const debouncedBuild = ctx.debounce(build, 2000);
const triggerHotUpdate = (filePath?: string) => {
if (filePath && !filePath.includes('/ui/')) return;
if (filePath && !filePath.includes('/ui/') && !filePath.includes('/ui-next/')) return;
debouncedBuild();
};

ctx.on('app/started', build);

ctx.server.addHandlerLayer('ui-next', uiNextLayer((handler) => {
const indexHtml = path.join(__dirname, 'public', 'index.html');
if (!fs.existsSync(indexHtml)) return PENDING_HTML;
const html = fs.readFileSync(indexHtml, 'utf-8');
const prodAssetUrl = (name: string) => `/plugins/${hashes[name] || HASH_FALLBACK}/${name}`;
return injectPage(ctx, handler, html, prodAssetUrl, prodAssetUrl('plugins.js'));
}));
ctx.on('app/watch/change', triggerHotUpdate);
ctx.on('app/watch/unlink', triggerHotUpdate);
ctx.on('system/setting-loaded', buildCodeLangs);
Expand Down
Loading
Loading