hightjs 1.0.7 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/client.d.ts +1 -0
- package/dist/client/client.js +4 -1
- package/dist/client/entry.client.js +0 -1
- package/dist/client/rpc.d.ts +8 -0
- package/dist/client/rpc.js +97 -0
- package/dist/index.js +24 -3
- package/dist/rpc/server.d.ts +11 -0
- package/dist/rpc/server.js +167 -0
- package/dist/rpc/types.d.ts +23 -0
- package/dist/rpc/types.js +20 -0
- package/package.json +6 -2
- package/src/client/client.ts +2 -3
- package/src/client/entry.client.tsx +1 -1
- package/src/client/rpc.ts +101 -0
- package/src/index.ts +30 -5
- package/src/rpc/server.ts +191 -0
- package/src/rpc/types.ts +45 -0
package/dist/client/client.d.ts
CHANGED
package/dist/client/client.js
CHANGED
|
@@ -16,9 +16,12 @@
|
|
|
16
16
|
* limitations under the License.
|
|
17
17
|
*/
|
|
18
18
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
19
|
-
exports.router = exports.Link = void 0;
|
|
19
|
+
exports.importServer = exports.router = exports.Link = void 0;
|
|
20
20
|
// Este arquivo exporta apenas código seguro para o cliente (navegador)
|
|
21
21
|
var Link_1 = require("../components/Link");
|
|
22
22
|
Object.defineProperty(exports, "Link", { enumerable: true, get: function () { return Link_1.Link; } });
|
|
23
23
|
var clientRouter_1 = require("./clientRouter");
|
|
24
24
|
Object.defineProperty(exports, "router", { enumerable: true, get: function () { return clientRouter_1.router; } });
|
|
25
|
+
// RPC (client-side)
|
|
26
|
+
var rpc_1 = require("./rpc");
|
|
27
|
+
Object.defineProperty(exports, "importServer", { enumerable: true, get: function () { return rpc_1.importServer; } });
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `importServer("src/backend/index.ts")` returns a Proxy where every property is
|
|
3
|
+
* a function that performs a POST to `/api/rpc`.
|
|
4
|
+
*
|
|
5
|
+
* Security note: the server will still validate allowlisted directories.
|
|
6
|
+
* @param {string} file
|
|
7
|
+
*/
|
|
8
|
+
export declare function importServer<T extends Record<string, any> = Record<string, any>>(file: string): T;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/*
|
|
3
|
+
* This file is part of the HightJS Project.
|
|
4
|
+
* Copyright (c) 2025 itsmuzin
|
|
5
|
+
*
|
|
6
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
|
+
* you may not use this file except in compliance with the License.
|
|
8
|
+
* You may obtain a copy of the License at
|
|
9
|
+
*
|
|
10
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
*
|
|
12
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
+
* See the License for the specific language governing permissions and
|
|
16
|
+
* limitations under the License.
|
|
17
|
+
*/
|
|
18
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
19
|
+
exports.importServer = importServer;
|
|
20
|
+
const types_1 = require("../rpc/types");
|
|
21
|
+
function asErrorMessage(err) {
|
|
22
|
+
if (err instanceof Error)
|
|
23
|
+
return err.message;
|
|
24
|
+
try {
|
|
25
|
+
return String(err);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return 'Unknown error';
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* `importServer("src/backend/index.ts")` returns a Proxy where every property is
|
|
33
|
+
* a function that performs a POST to `/api/rpc`.
|
|
34
|
+
*
|
|
35
|
+
* Security note: the server will still validate allowlisted directories.
|
|
36
|
+
* @param {string} file
|
|
37
|
+
*/
|
|
38
|
+
function importServer(file) {
|
|
39
|
+
if (!file) {
|
|
40
|
+
throw new Error('importServer(file) requires a non-empty string');
|
|
41
|
+
}
|
|
42
|
+
const handler = {
|
|
43
|
+
get(_target, prop) {
|
|
44
|
+
// allow tooling & debugging
|
|
45
|
+
if (prop === '__file')
|
|
46
|
+
return file;
|
|
47
|
+
if (prop === 'then')
|
|
48
|
+
return undefined; // prevent await Proxy issues
|
|
49
|
+
const fnName = String(prop);
|
|
50
|
+
return async (...args) => {
|
|
51
|
+
const payload = {
|
|
52
|
+
file,
|
|
53
|
+
fn: fnName,
|
|
54
|
+
args,
|
|
55
|
+
request: {
|
|
56
|
+
url: typeof window !== 'undefined' ? window.location.href : undefined,
|
|
57
|
+
method: 'RPC',
|
|
58
|
+
headers: {
|
|
59
|
+
// useful for server-side auth/session logic
|
|
60
|
+
...(typeof navigator !== 'undefined' ? { 'user-agent': navigator.userAgent } : {}),
|
|
61
|
+
// forward cookies so server can reconstruct request.cookies if needed
|
|
62
|
+
...(typeof document !== 'undefined' ? { cookie: document.cookie } : {})
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
let res;
|
|
67
|
+
try {
|
|
68
|
+
res = await fetch(types_1.RPC_ENDPOINT, {
|
|
69
|
+
method: 'POST',
|
|
70
|
+
headers: {
|
|
71
|
+
'Content-Type': 'application/json'
|
|
72
|
+
},
|
|
73
|
+
body: JSON.stringify(payload)
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
throw new Error(asErrorMessage(err));
|
|
78
|
+
}
|
|
79
|
+
let data;
|
|
80
|
+
try {
|
|
81
|
+
data = (await res.json());
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
throw new Error('Invalid JSON response from RPC');
|
|
85
|
+
}
|
|
86
|
+
if (!data || typeof data !== 'object' || typeof data.success !== 'boolean') {
|
|
87
|
+
throw new Error('Invalid RPC response shape');
|
|
88
|
+
}
|
|
89
|
+
if (data.success) {
|
|
90
|
+
return data.return;
|
|
91
|
+
}
|
|
92
|
+
throw new Error(data.error || 'RPC Error');
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
return new Proxy({}, handler);
|
|
97
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -66,6 +66,9 @@ Object.defineProperty(exports, "HightJSResponse", { enumerable: true, get: funct
|
|
|
66
66
|
const hotReload_1 = require("./hotReload");
|
|
67
67
|
const factory_1 = require("./adapters/factory");
|
|
68
68
|
const console_1 = __importStar(require("./api/console"));
|
|
69
|
+
// RPC
|
|
70
|
+
const server_1 = require("./rpc/server");
|
|
71
|
+
const types_1 = require("./rpc/types");
|
|
69
72
|
// Exporta os adapters para uso manual se necessário
|
|
70
73
|
var express_2 = require("./adapters/express");
|
|
71
74
|
Object.defineProperty(exports, "ExpressAdapter", { enumerable: true, get: function () { return express_2.ExpressAdapter; } });
|
|
@@ -330,15 +333,33 @@ function hweb(options) {
|
|
|
330
333
|
},
|
|
331
334
|
getRequestHandler: () => {
|
|
332
335
|
return async (req, res) => {
|
|
333
|
-
// Detecta
|
|
336
|
+
// Detecta o framework e cria request/response genéricos
|
|
334
337
|
const adapter = factory_1.FrameworkAdapterFactory.detectFramework(req, res);
|
|
335
338
|
const genericReq = adapter.parseRequest(req);
|
|
336
339
|
const genericRes = adapter.createResponse(res);
|
|
337
340
|
// Adiciona informações do hweb na requisição genérica
|
|
338
341
|
genericReq.hwebDev = dev;
|
|
339
342
|
genericReq.hotReloadManager = hotReloadManager;
|
|
340
|
-
const {
|
|
341
|
-
const method = genericReq.method.toUpperCase();
|
|
343
|
+
const { hostname } = req.headers;
|
|
344
|
+
const method = (genericReq.method || 'GET').toUpperCase();
|
|
345
|
+
const pathname = new URL(genericReq.url, `http://${hostname}:${port}`).pathname;
|
|
346
|
+
// RPC endpoint (antes das rotas de backend)
|
|
347
|
+
if (pathname === types_1.RPC_ENDPOINT && method === 'POST') {
|
|
348
|
+
try {
|
|
349
|
+
const result = await (0, server_1.executeRpc)({
|
|
350
|
+
projectDir: dir,
|
|
351
|
+
request: genericReq
|
|
352
|
+
}, genericReq.body);
|
|
353
|
+
genericRes.header('Content-Type', 'application/json');
|
|
354
|
+
genericRes.status(200).send(JSON.stringify(result));
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
catch (error) {
|
|
358
|
+
genericRes.header('Content-Type', 'application/json');
|
|
359
|
+
genericRes.status(200).send(JSON.stringify({ success: false, error: 'Internal RPC error' }));
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
342
363
|
// 1. Verifica se é WebSocket upgrade para hot reload
|
|
343
364
|
if (pathname === '/hweb-hotreload/' && genericReq.headers.upgrade === 'websocket' && hotReloadManager) {
|
|
344
365
|
// Framework vai chamar o evento 'upgrade' do servidor HTTP
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { RpcResponsePayload } from './types';
|
|
2
|
+
import type { GenericRequest } from '../types/framework';
|
|
3
|
+
export interface RpcExecutionContext {
|
|
4
|
+
/** absolute project root (same as options.dir in start()) */
|
|
5
|
+
projectDir: string;
|
|
6
|
+
/** allow override for tests */
|
|
7
|
+
allowedServerDirs?: string[];
|
|
8
|
+
/** real incoming request (so cookies/headers/session work) */
|
|
9
|
+
request?: GenericRequest;
|
|
10
|
+
}
|
|
11
|
+
export declare function executeRpc(ctx: RpcExecutionContext, payload: any): Promise<RpcResponsePayload>;
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/*
|
|
3
|
+
* This file is part of the HightJS Project.
|
|
4
|
+
* Copyright (c) 2025 itsmuzin
|
|
5
|
+
*
|
|
6
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
|
+
* you may not use this file except in compliance with the License.
|
|
8
|
+
* You may obtain a copy of the License at
|
|
9
|
+
*
|
|
10
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
*
|
|
12
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
+
* See the License for the specific language governing permissions and
|
|
16
|
+
* limitations under the License.
|
|
17
|
+
*/
|
|
18
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
19
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
20
|
+
};
|
|
21
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
22
|
+
exports.executeRpc = executeRpc;
|
|
23
|
+
const fs_1 = __importDefault(require("fs"));
|
|
24
|
+
const path_1 = __importDefault(require("path"));
|
|
25
|
+
const http_1 = require("../api/http");
|
|
26
|
+
const DEFAULT_ALLOWED_SERVER_DIRS = ['src/backend'];
|
|
27
|
+
function normalizeToPosix(p) {
|
|
28
|
+
return p.replace(/\\/g, '/');
|
|
29
|
+
}
|
|
30
|
+
function isDisallowedPathInput(file) {
|
|
31
|
+
if (!file)
|
|
32
|
+
return true;
|
|
33
|
+
if (file.includes('\u0000'))
|
|
34
|
+
return true;
|
|
35
|
+
// disallow protocol-like or absolute paths (Windows drive letters included)
|
|
36
|
+
if (/^[a-zA-Z]+:/.test(file))
|
|
37
|
+
return true;
|
|
38
|
+
if (file.startsWith('/') || file.startsWith('\\'))
|
|
39
|
+
return true;
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
function validatePayload(payload) {
|
|
43
|
+
return (payload &&
|
|
44
|
+
typeof payload === 'object' &&
|
|
45
|
+
typeof payload.file === 'string' &&
|
|
46
|
+
typeof payload.fn === 'string' &&
|
|
47
|
+
Array.isArray(payload.args));
|
|
48
|
+
}
|
|
49
|
+
function tryResolveWithinAllowedDirs(projectDir, allowedDirs, requestedFile) {
|
|
50
|
+
const req = requestedFile.replace(/\\/g, '/').replace(/^\.(?:\/|\\)/, '');
|
|
51
|
+
for (const d of allowedDirs) {
|
|
52
|
+
const baseAbs = path_1.default.resolve(projectDir, d);
|
|
53
|
+
// Interpret client path as relative to src/web (where it's typically authored)
|
|
54
|
+
const fromWebAbs = path_1.default.resolve(projectDir, 'src/web', req);
|
|
55
|
+
// Map: <project>/src/backend/* (coming from ../../backend/* from web code)
|
|
56
|
+
const mappedFromWebAbs = fromWebAbs.replace(path_1.default.resolve(projectDir, 'backend') + path_1.default.sep, path_1.default.resolve(projectDir, 'src', 'backend') + path_1.default.sep);
|
|
57
|
+
// Also accept callers passing a backend-relative path like "./auth" or "auth"
|
|
58
|
+
const fromBackendAbs = path_1.default.resolve(baseAbs, req);
|
|
59
|
+
const candidateAbsList = [mappedFromWebAbs, fromBackendAbs];
|
|
60
|
+
for (const candidateAbs of candidateAbsList) {
|
|
61
|
+
const baseWithSep = baseAbs.endsWith(path_1.default.sep) ? baseAbs : baseAbs + path_1.default.sep;
|
|
62
|
+
if (!candidateAbs.startsWith(baseWithSep) && candidateAbs !== baseAbs)
|
|
63
|
+
continue;
|
|
64
|
+
if (!fs_1.default.existsSync(baseAbs))
|
|
65
|
+
continue;
|
|
66
|
+
return candidateAbs;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
function toCookies(cookieHeader) {
|
|
72
|
+
if (!cookieHeader)
|
|
73
|
+
return {};
|
|
74
|
+
const out = {};
|
|
75
|
+
for (const part of cookieHeader.split(';')) {
|
|
76
|
+
const idx = part.indexOf('=');
|
|
77
|
+
if (idx === -1)
|
|
78
|
+
continue;
|
|
79
|
+
const k = part.slice(0, idx).trim();
|
|
80
|
+
const v = part.slice(idx + 1).trim();
|
|
81
|
+
if (k)
|
|
82
|
+
out[k] = v;
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
// Keep this for fallback when ctx.request isn't provided
|
|
87
|
+
function buildRpcRequestFromPayload(payload) {
|
|
88
|
+
const headers = {};
|
|
89
|
+
const rawHeaders = payload.request?.headers || {};
|
|
90
|
+
for (const [k, v] of Object.entries(rawHeaders)) {
|
|
91
|
+
headers[k.toLowerCase()] = v;
|
|
92
|
+
}
|
|
93
|
+
const cookieHeader = headers['cookie'] ?? undefined;
|
|
94
|
+
const req = {
|
|
95
|
+
method: payload.request?.method || 'RPC',
|
|
96
|
+
// HightJSRequest validates URLs; keep it safe & local.
|
|
97
|
+
url: payload.request?.url || 'http://localhost/_rpc',
|
|
98
|
+
headers,
|
|
99
|
+
body: null,
|
|
100
|
+
query: {},
|
|
101
|
+
params: {},
|
|
102
|
+
cookies: payload.request?.cookies || toCookies(cookieHeader),
|
|
103
|
+
raw: null
|
|
104
|
+
};
|
|
105
|
+
return new http_1.HightJSRequest(req);
|
|
106
|
+
}
|
|
107
|
+
async function executeRpc(ctx, payload) {
|
|
108
|
+
try {
|
|
109
|
+
if (!validatePayload(payload)) {
|
|
110
|
+
return { success: false, error: 'Invalid RPC payload' };
|
|
111
|
+
}
|
|
112
|
+
const fileInput = payload.file;
|
|
113
|
+
if (isDisallowedPathInput(fileInput)) {
|
|
114
|
+
return { success: false, error: 'Invalid file path' };
|
|
115
|
+
}
|
|
116
|
+
const allowedDirs = (ctx.allowedServerDirs || [...DEFAULT_ALLOWED_SERVER_DIRS]).map(normalizeToPosix);
|
|
117
|
+
const absFile = tryResolveWithinAllowedDirs(ctx.projectDir, allowedDirs, fileInput);
|
|
118
|
+
if (!absFile) {
|
|
119
|
+
return { success: false, error: 'File not allowed for RPC' };
|
|
120
|
+
}
|
|
121
|
+
if (!absFile.startsWith(path_1.default.resolve(ctx.projectDir) + path_1.default.sep)) {
|
|
122
|
+
return { success: false, error: 'Invalid file path' };
|
|
123
|
+
}
|
|
124
|
+
// Ensure fresh code in dev
|
|
125
|
+
try {
|
|
126
|
+
const resolved = require.resolve(absFile);
|
|
127
|
+
if (require.cache[resolved])
|
|
128
|
+
delete require.cache[resolved];
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
// ignore
|
|
132
|
+
}
|
|
133
|
+
let mod;
|
|
134
|
+
try {
|
|
135
|
+
mod = require(absFile);
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
// try again letting require do extension resolution from absFile without explicit extension
|
|
139
|
+
try {
|
|
140
|
+
mod = require(absFile);
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return { success: false, error: 'RPC file not found' };
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
// Support multiple TS/CJS interop shapes:
|
|
147
|
+
// - module.exports.fn
|
|
148
|
+
// - exports.fn
|
|
149
|
+
// - module.exports.default (function)
|
|
150
|
+
// - module.exports.default.fn
|
|
151
|
+
const fnName = payload.fn;
|
|
152
|
+
const fnValue = (mod && typeof mod[fnName] === 'function' && mod[fnName]) ||
|
|
153
|
+
(mod?.default && typeof mod.default === 'function' && fnName === 'default' && mod.default) ||
|
|
154
|
+
(mod?.default && typeof mod.default[fnName] === 'function' && mod.default[fnName]) ||
|
|
155
|
+
undefined;
|
|
156
|
+
if (typeof fnValue !== 'function') {
|
|
157
|
+
return { success: false, error: `RPC function not found: ${fnName}` };
|
|
158
|
+
}
|
|
159
|
+
const rpcRequest = ctx.request ? new http_1.HightJSRequest(ctx.request) : buildRpcRequestFromPayload(payload);
|
|
160
|
+
const result = await fnValue(rpcRequest, ...payload.args);
|
|
161
|
+
return { success: true, return: result };
|
|
162
|
+
}
|
|
163
|
+
catch (err) {
|
|
164
|
+
const message = typeof err?.message === 'string' ? err.message : 'Unknown RPC error';
|
|
165
|
+
return { success: false, error: message };
|
|
166
|
+
}
|
|
167
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export declare const RPC_ENDPOINT: "/api/rpc";
|
|
2
|
+
export type RpcArgs = unknown[];
|
|
3
|
+
export interface RpcRequestPayload {
|
|
4
|
+
file: string;
|
|
5
|
+
fn: string;
|
|
6
|
+
args: RpcArgs;
|
|
7
|
+
/** Optional: minimal request data so server can provide a HightJSRequest to the called function */
|
|
8
|
+
request?: {
|
|
9
|
+
url?: string;
|
|
10
|
+
method?: string;
|
|
11
|
+
headers?: Record<string, string>;
|
|
12
|
+
cookies?: Record<string, string>;
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
export type RpcSuccessResponse = {
|
|
16
|
+
success: true;
|
|
17
|
+
return: unknown;
|
|
18
|
+
};
|
|
19
|
+
export type RpcErrorResponse = {
|
|
20
|
+
success: false;
|
|
21
|
+
error: string;
|
|
22
|
+
};
|
|
23
|
+
export type RpcResponsePayload = RpcSuccessResponse | RpcErrorResponse;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/*
|
|
3
|
+
* This file is part of the HightJS Project.
|
|
4
|
+
* Copyright (c) 2025 itsmuzin
|
|
5
|
+
*
|
|
6
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
|
+
* you may not use this file except in compliance with the License.
|
|
8
|
+
* You may obtain a copy of the License at
|
|
9
|
+
*
|
|
10
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
*
|
|
12
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
+
* See the License for the specific language governing permissions and
|
|
16
|
+
* limitations under the License.
|
|
17
|
+
*/
|
|
18
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
19
|
+
exports.RPC_ENDPOINT = void 0;
|
|
20
|
+
exports.RPC_ENDPOINT = '/api/rpc';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hightjs",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "HightJS is a high-level framework for building web applications with ease and speed. It provides a robust set of tools and features to streamline development and enhance productivity.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -72,11 +72,15 @@
|
|
|
72
72
|
"@types/react": "^19.2.0",
|
|
73
73
|
"@types/react-dom": "^19.2.0",
|
|
74
74
|
"@types/ws": "^8.18.1",
|
|
75
|
+
"jest": "^30.1.3",
|
|
76
|
+
"ts-jest": "^29.4.4",
|
|
77
|
+
"@types/jest": "^30.0.0",
|
|
75
78
|
"ts-loader": "^9.5.4",
|
|
76
79
|
"ts-node": "^10.9.2",
|
|
77
80
|
"typescript": "^5.9.3"
|
|
78
81
|
},
|
|
79
82
|
"scripts": {
|
|
80
|
-
"build": "tsc"
|
|
83
|
+
"build": "tsc",
|
|
84
|
+
"test": "jest"
|
|
81
85
|
}
|
|
82
86
|
}
|
package/src/client/client.ts
CHANGED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* This file is part of the HightJS Project.
|
|
3
|
+
* Copyright (c) 2025 itsmuzin
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { RpcRequestPayload, RpcResponsePayload } from '../rpc/types';
|
|
19
|
+
import { RPC_ENDPOINT } from '../rpc/types';
|
|
20
|
+
|
|
21
|
+
function asErrorMessage(err: unknown): string {
|
|
22
|
+
if (err instanceof Error) return err.message;
|
|
23
|
+
try {
|
|
24
|
+
return String(err);
|
|
25
|
+
} catch {
|
|
26
|
+
return 'Unknown error';
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* `importServer("src/backend/index.ts")` returns a Proxy where every property is
|
|
32
|
+
* a function that performs a POST to `/api/rpc`.
|
|
33
|
+
*
|
|
34
|
+
* Security note: the server will still validate allowlisted directories.
|
|
35
|
+
* @param {string} file
|
|
36
|
+
*/
|
|
37
|
+
export function importServer<T extends Record<string, any> = Record<string, any>>(file: string): T {
|
|
38
|
+
if (!file) {
|
|
39
|
+
throw new Error('importServer(file) requires a non-empty string');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const handler: ProxyHandler<any> = {
|
|
43
|
+
get(_target, prop) {
|
|
44
|
+
// allow tooling & debugging
|
|
45
|
+
if (prop === '__file') return file;
|
|
46
|
+
if (prop === 'then') return undefined; // prevent await Proxy issues
|
|
47
|
+
|
|
48
|
+
const fnName = String(prop);
|
|
49
|
+
|
|
50
|
+
return async (...args: any[]) => {
|
|
51
|
+
const payload: RpcRequestPayload = {
|
|
52
|
+
file,
|
|
53
|
+
fn: fnName,
|
|
54
|
+
args,
|
|
55
|
+
request: {
|
|
56
|
+
url: typeof window !== 'undefined' ? window.location.href : undefined,
|
|
57
|
+
method: 'RPC',
|
|
58
|
+
headers: {
|
|
59
|
+
// useful for server-side auth/session logic
|
|
60
|
+
...(typeof navigator !== 'undefined' ? { 'user-agent': navigator.userAgent } : {}),
|
|
61
|
+
// forward cookies so server can reconstruct request.cookies if needed
|
|
62
|
+
...(typeof document !== 'undefined' ? { cookie: document.cookie } : {})
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
let res: Response;
|
|
68
|
+
try {
|
|
69
|
+
res = await fetch(RPC_ENDPOINT, {
|
|
70
|
+
method: 'POST',
|
|
71
|
+
headers: {
|
|
72
|
+
'Content-Type': 'application/json'
|
|
73
|
+
},
|
|
74
|
+
body: JSON.stringify(payload)
|
|
75
|
+
});
|
|
76
|
+
} catch (err) {
|
|
77
|
+
throw new Error(asErrorMessage(err));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
let data: RpcResponsePayload;
|
|
81
|
+
try {
|
|
82
|
+
data = (await res.json()) as RpcResponsePayload;
|
|
83
|
+
} catch {
|
|
84
|
+
throw new Error('Invalid JSON response from RPC');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (!data || typeof data !== 'object' || typeof (data as any).success !== 'boolean') {
|
|
88
|
+
throw new Error('Invalid RPC response shape');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (data.success) {
|
|
92
|
+
return (data as any).return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
throw new Error((data as any).error || 'RPC Error');
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
return new Proxy({}, handler) as T;
|
|
101
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -46,6 +46,10 @@ import {FrameworkAdapterFactory} from './adapters/factory';
|
|
|
46
46
|
import {GenericRequest, GenericResponse} from './types/framework';
|
|
47
47
|
import Console, {Colors} from "./api/console"
|
|
48
48
|
|
|
49
|
+
// RPC
|
|
50
|
+
import { executeRpc } from './rpc/server';
|
|
51
|
+
import { RPC_ENDPOINT } from './rpc/types';
|
|
52
|
+
|
|
49
53
|
// Exporta apenas os tipos e classes para o backend
|
|
50
54
|
export { HightJSRequest, HightJSResponse };
|
|
51
55
|
export type { BackendRouteConfig, BackendHandler };
|
|
@@ -369,17 +373,38 @@ export default function hweb(options: HightJSOptions) {
|
|
|
369
373
|
},
|
|
370
374
|
getRequestHandler: (): RequestHandler => {
|
|
371
375
|
return async (req: any, res: any) => {
|
|
372
|
-
// Detecta
|
|
376
|
+
// Detecta o framework e cria request/response genéricos
|
|
373
377
|
const adapter = FrameworkAdapterFactory.detectFramework(req, res);
|
|
374
|
-
const genericReq = adapter.parseRequest(req);
|
|
375
|
-
const genericRes = adapter.createResponse(res);
|
|
378
|
+
const genericReq: GenericRequest = adapter.parseRequest(req);
|
|
379
|
+
const genericRes: GenericResponse = adapter.createResponse(res);
|
|
376
380
|
|
|
377
381
|
// Adiciona informações do hweb na requisição genérica
|
|
378
382
|
(genericReq as any).hwebDev = dev;
|
|
379
383
|
(genericReq as any).hotReloadManager = hotReloadManager;
|
|
380
384
|
|
|
381
|
-
const {
|
|
382
|
-
const method = genericReq.method.toUpperCase();
|
|
385
|
+
const {hostname} = req.headers;
|
|
386
|
+
const method = (genericReq.method || 'GET').toUpperCase();
|
|
387
|
+
const pathname = new URL(genericReq.url, `http://${hostname}:${port}`).pathname;
|
|
388
|
+
|
|
389
|
+
// RPC endpoint (antes das rotas de backend)
|
|
390
|
+
if (pathname === RPC_ENDPOINT && method === 'POST') {
|
|
391
|
+
try {
|
|
392
|
+
const result = await executeRpc(
|
|
393
|
+
{
|
|
394
|
+
projectDir: dir,
|
|
395
|
+
request: genericReq
|
|
396
|
+
},
|
|
397
|
+
genericReq.body
|
|
398
|
+
);
|
|
399
|
+
genericRes.header('Content-Type', 'application/json');
|
|
400
|
+
genericRes.status(200).send(JSON.stringify(result));
|
|
401
|
+
return;
|
|
402
|
+
} catch (error) {
|
|
403
|
+
genericRes.header('Content-Type', 'application/json');
|
|
404
|
+
genericRes.status(200).send(JSON.stringify({ success: false, error: 'Internal RPC error' }));
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
383
408
|
|
|
384
409
|
// 1. Verifica se é WebSocket upgrade para hot reload
|
|
385
410
|
if (pathname === '/hweb-hotreload/' && genericReq.headers.upgrade === 'websocket' && hotReloadManager) {
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* This file is part of the HightJS Project.
|
|
3
|
+
* Copyright (c) 2025 itsmuzin
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import fs from 'fs';
|
|
19
|
+
import path from 'path';
|
|
20
|
+
import { RpcRequestPayload, RpcResponsePayload } from './types';
|
|
21
|
+
import { HightJSRequest } from '../api/http';
|
|
22
|
+
import type { GenericRequest } from '../types/framework';
|
|
23
|
+
|
|
24
|
+
const DEFAULT_ALLOWED_SERVER_DIRS = ['src/backend'] as const;
|
|
25
|
+
|
|
26
|
+
function normalizeToPosix(p: string): string {
|
|
27
|
+
return p.replace(/\\/g, '/');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isDisallowedPathInput(file: string): boolean {
|
|
31
|
+
if (!file) return true;
|
|
32
|
+
if (file.includes('\u0000')) return true;
|
|
33
|
+
// disallow protocol-like or absolute paths (Windows drive letters included)
|
|
34
|
+
if (/^[a-zA-Z]+:/.test(file)) return true;
|
|
35
|
+
if (file.startsWith('/') || file.startsWith('\\')) return true;
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function validatePayload(payload: any): payload is RpcRequestPayload {
|
|
40
|
+
return (
|
|
41
|
+
payload &&
|
|
42
|
+
typeof payload === 'object' &&
|
|
43
|
+
typeof payload.file === 'string' &&
|
|
44
|
+
typeof payload.fn === 'string' &&
|
|
45
|
+
Array.isArray(payload.args)
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface RpcExecutionContext {
|
|
50
|
+
/** absolute project root (same as options.dir in start()) */
|
|
51
|
+
projectDir: string;
|
|
52
|
+
/** allow override for tests */
|
|
53
|
+
allowedServerDirs?: string[];
|
|
54
|
+
/** real incoming request (so cookies/headers/session work) */
|
|
55
|
+
request?: GenericRequest;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function tryResolveWithinAllowedDirs(projectDir: string, allowedDirs: string[], requestedFile: string): string | null {
|
|
59
|
+
const req = requestedFile.replace(/\\/g, '/').replace(/^\.(?:\/|\\)/, '');
|
|
60
|
+
|
|
61
|
+
for (const d of allowedDirs) {
|
|
62
|
+
const baseAbs = path.resolve(projectDir, d);
|
|
63
|
+
|
|
64
|
+
// Interpret client path as relative to src/web (where it's typically authored)
|
|
65
|
+
const fromWebAbs = path.resolve(projectDir, 'src/web', req);
|
|
66
|
+
|
|
67
|
+
// Map: <project>/src/backend/* (coming from ../../backend/* from web code)
|
|
68
|
+
const mappedFromWebAbs = fromWebAbs.replace(
|
|
69
|
+
path.resolve(projectDir, 'backend') + path.sep,
|
|
70
|
+
path.resolve(projectDir, 'src', 'backend') + path.sep
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
// Also accept callers passing a backend-relative path like "./auth" or "auth"
|
|
74
|
+
const fromBackendAbs = path.resolve(baseAbs, req);
|
|
75
|
+
|
|
76
|
+
const candidateAbsList = [mappedFromWebAbs, fromBackendAbs];
|
|
77
|
+
|
|
78
|
+
for (const candidateAbs of candidateAbsList) {
|
|
79
|
+
const baseWithSep = baseAbs.endsWith(path.sep) ? baseAbs : baseAbs + path.sep;
|
|
80
|
+
if (!candidateAbs.startsWith(baseWithSep) && candidateAbs !== baseAbs) continue;
|
|
81
|
+
if (!fs.existsSync(baseAbs)) continue;
|
|
82
|
+
return candidateAbs;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function toCookies(cookieHeader: string | undefined): Record<string, string> {
|
|
90
|
+
if (!cookieHeader) return {};
|
|
91
|
+
const out: Record<string, string> = {};
|
|
92
|
+
for (const part of cookieHeader.split(';')) {
|
|
93
|
+
const idx = part.indexOf('=');
|
|
94
|
+
if (idx === -1) continue;
|
|
95
|
+
const k = part.slice(0, idx).trim();
|
|
96
|
+
const v = part.slice(idx + 1).trim();
|
|
97
|
+
if (k) out[k] = v;
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Keep this for fallback when ctx.request isn't provided
|
|
103
|
+
function buildRpcRequestFromPayload(payload: RpcRequestPayload): HightJSRequest {
|
|
104
|
+
const headers: Record<string, string | string[]> = {};
|
|
105
|
+
const rawHeaders = payload.request?.headers || {};
|
|
106
|
+
for (const [k, v] of Object.entries(rawHeaders)) {
|
|
107
|
+
headers[k.toLowerCase()] = v;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const cookieHeader = (headers['cookie'] as string | undefined) ?? undefined;
|
|
111
|
+
|
|
112
|
+
const req: GenericRequest = {
|
|
113
|
+
method: payload.request?.method || 'RPC',
|
|
114
|
+
// HightJSRequest validates URLs; keep it safe & local.
|
|
115
|
+
url: payload.request?.url || 'http://localhost/_rpc',
|
|
116
|
+
headers,
|
|
117
|
+
body: null,
|
|
118
|
+
query: {},
|
|
119
|
+
params: {},
|
|
120
|
+
cookies: payload.request?.cookies || toCookies(cookieHeader),
|
|
121
|
+
raw: null
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
return new HightJSRequest(req);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function executeRpc(ctx: RpcExecutionContext, payload: any): Promise<RpcResponsePayload> {
|
|
128
|
+
try {
|
|
129
|
+
if (!validatePayload(payload)) {
|
|
130
|
+
return { success: false, error: 'Invalid RPC payload' };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const fileInput = payload.file;
|
|
134
|
+
if (isDisallowedPathInput(fileInput)) {
|
|
135
|
+
return { success: false, error: 'Invalid file path' };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const allowedDirs = (ctx.allowedServerDirs || [...DEFAULT_ALLOWED_SERVER_DIRS]).map(normalizeToPosix);
|
|
139
|
+
const absFile = tryResolveWithinAllowedDirs(ctx.projectDir, allowedDirs, fileInput);
|
|
140
|
+
if (!absFile) {
|
|
141
|
+
return { success: false, error: 'File not allowed for RPC' };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (!absFile.startsWith(path.resolve(ctx.projectDir) + path.sep)) {
|
|
145
|
+
return { success: false, error: 'Invalid file path' };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Ensure fresh code in dev
|
|
149
|
+
try {
|
|
150
|
+
const resolved = require.resolve(absFile);
|
|
151
|
+
if (require.cache[resolved]) delete require.cache[resolved];
|
|
152
|
+
} catch {
|
|
153
|
+
// ignore
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
let mod: any;
|
|
157
|
+
try {
|
|
158
|
+
mod = require(absFile);
|
|
159
|
+
} catch {
|
|
160
|
+
// try again letting require do extension resolution from absFile without explicit extension
|
|
161
|
+
try {
|
|
162
|
+
mod = require(absFile);
|
|
163
|
+
} catch {
|
|
164
|
+
return { success: false, error: 'RPC file not found' };
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Support multiple TS/CJS interop shapes:
|
|
169
|
+
// - module.exports.fn
|
|
170
|
+
// - exports.fn
|
|
171
|
+
// - module.exports.default (function)
|
|
172
|
+
// - module.exports.default.fn
|
|
173
|
+
const fnName = payload.fn;
|
|
174
|
+
const fnValue =
|
|
175
|
+
(mod && typeof mod[fnName] === 'function' && mod[fnName]) ||
|
|
176
|
+
(mod?.default && typeof mod.default === 'function' && fnName === 'default' && mod.default) ||
|
|
177
|
+
(mod?.default && typeof mod.default[fnName] === 'function' && mod.default[fnName]) ||
|
|
178
|
+
undefined;
|
|
179
|
+
|
|
180
|
+
if (typeof fnValue !== 'function') {
|
|
181
|
+
return { success: false, error: `RPC function not found: ${fnName}` };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const rpcRequest = ctx.request ? new HightJSRequest(ctx.request) : buildRpcRequestFromPayload(payload);
|
|
185
|
+
const result = await fnValue(rpcRequest, ...payload.args);
|
|
186
|
+
return { success: true, return: result };
|
|
187
|
+
} catch (err: any) {
|
|
188
|
+
const message = typeof err?.message === 'string' ? err.message : 'Unknown RPC error';
|
|
189
|
+
return { success: false, error: message };
|
|
190
|
+
}
|
|
191
|
+
}
|
package/src/rpc/types.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* This file is part of the HightJS Project.
|
|
3
|
+
* Copyright (c) 2025 itsmuzin
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export const RPC_ENDPOINT = '/api/rpc' as const;
|
|
19
|
+
|
|
20
|
+
export type RpcArgs = unknown[];
|
|
21
|
+
|
|
22
|
+
export interface RpcRequestPayload {
|
|
23
|
+
file: string;
|
|
24
|
+
fn: string;
|
|
25
|
+
args: RpcArgs;
|
|
26
|
+
/** Optional: minimal request data so server can provide a HightJSRequest to the called function */
|
|
27
|
+
request?: {
|
|
28
|
+
url?: string;
|
|
29
|
+
method?: string;
|
|
30
|
+
headers?: Record<string, string>;
|
|
31
|
+
cookies?: Record<string, string>;
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type RpcSuccessResponse = {
|
|
36
|
+
success: true;
|
|
37
|
+
return: unknown;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type RpcErrorResponse = {
|
|
41
|
+
success: false;
|
|
42
|
+
error: string;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export type RpcResponsePayload = RpcSuccessResponse | RpcErrorResponse;
|