@wabot-dev/framework 0.9.80 → 2.0.0-beta.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/bin/skills.mjs +151 -0
- package/bin/wabot-skills.mjs +120 -0
- package/dist/build/build.js +1031 -8
- package/dist/src/addon/chat-bot/in-memory/InMemoryChatMemory.js +1 -3
- package/dist/src/addon/chat-bot/xai/XAIChatAdapter.js +180 -0
- package/dist/src/addon/chat-controller/cmd/cmdChannelSocketPath.js +1 -5
- package/dist/src/addon/chat-controller/hubspot/@hubspot.js +28 -0
- package/dist/src/addon/chat-controller/hubspot/HubSpotChannel.js +81 -0
- package/dist/src/addon/chat-controller/hubspot/HubSpotChannelConfig.js +20 -0
- package/dist/src/addon/chat-controller/hubspot/HubSpotReceiver.js +42 -0
- package/dist/src/addon/chat-controller/hubspot/HubSpotSender.js +118 -0
- package/dist/src/addon/chat-controller/hubspot/HubSpotWebhookController.js +122 -0
- package/dist/src/addon/chat-controller/hubspot/downloadHubSpotAttachments.js +45 -0
- package/dist/src/addon/chat-controller/hubspot/hubspotChannelName.js +3 -0
- package/dist/src/addon/chat-controller/hubspot/verifyHubSpotSignatureV3.js +28 -0
- package/dist/src/addon/chat-controller/{telegram/markdownToTelegramHtml.js → markdown/markdownToChatHtml.js} +5 -8
- package/dist/src/addon/chat-controller/slack/@slack.js +22 -0
- package/dist/src/addon/chat-controller/slack/SlackChannel.js +187 -0
- package/dist/src/addon/chat-controller/slack/SlackChannelConfig.js +12 -0
- package/dist/src/addon/chat-controller/slack/markdownToSlackMrkdwn.js +38 -0
- package/dist/src/addon/chat-controller/slack/slackChannelName.js +3 -0
- package/dist/src/addon/chat-controller/telegram/TelegramChannel.js +2 -2
- package/dist/src/addon/ui/preact/PreactRenderer.js +86 -0
- package/dist/src/addon/ui/preact/outlet.js +22 -0
- package/dist/src/addon/ui/preact/preactClientRuntime.js +67 -0
- package/dist/src/core/repository/CrudRepository.js +7 -7
- package/dist/src/feature/async/computeDedupKey.js +1 -1
- package/dist/src/feature/pg/@pgExtension.js +2 -4
- package/dist/src/feature/project-runner/ProjectRunner.js +62 -10
- package/dist/src/feature/project-runner/scanner.js +1 -1
- package/dist/src/feature/repository/@memExtension.js +1 -2
- package/dist/src/feature/ui-controller/actions.js +35 -0
- package/dist/src/feature/ui-controller/bundler/UiBundler.js +191 -0
- package/dist/src/feature/ui-controller/bundler/devMiddleware.js +41 -0
- package/dist/src/feature/ui-controller/bundler/index.js +4 -0
- package/dist/src/feature/ui-controller/bundler/manifest.js +34 -0
- package/dist/src/feature/ui-controller/bundler/navRuntime.js +236 -0
- package/dist/src/feature/ui-controller/bundler/pageAssets.js +30 -0
- package/dist/src/feature/ui-controller/document/escape.js +17 -0
- package/dist/src/feature/ui-controller/document/helpers.js +13 -0
- package/dist/src/feature/ui-controller/document/renderDocument.js +43 -0
- package/dist/src/feature/ui-controller/island/IslandRegistry.js +68 -0
- package/dist/src/feature/ui-controller/island/island.js +40 -0
- package/dist/src/feature/ui-controller/island/serialize.js +35 -0
- package/dist/src/feature/ui-controller/metadata/@action.js +18 -0
- package/dist/src/feature/ui-controller/metadata/@uiController.js +19 -0
- package/dist/src/feature/ui-controller/metadata/@uiMiddleware.js +20 -0
- package/dist/src/feature/ui-controller/metadata/@view.js +18 -0
- package/dist/src/feature/ui-controller/metadata/UiControllerMetadataStore.js +107 -0
- package/dist/src/feature/ui-controller/renderer/UiRendererRegistry.js +42 -0
- package/dist/src/feature/ui-controller/runUiControllers.js +285 -0
- package/dist/src/index.d.ts +632 -3
- package/dist/src/index.js +30 -1
- package/dist/src/testing/index.d.ts +43 -1
- package/dist/src/testing/index.js +1 -0
- package/dist/src/testing/uiHarness.js +102 -0
- package/dist/src/ui/client.js +6 -0
- package/dist/src/ui/index.d.ts +427 -0
- package/dist/src/ui/index.js +29 -0
- package/dist/src/ui/jsx-dev-runtime.d.ts +1 -0
- package/dist/src/ui/jsx-dev-runtime.js +1 -0
- package/dist/src/ui/jsx-runtime.d.ts +1 -0
- package/dist/src/ui/jsx-runtime.js +1 -0
- package/package.json +33 -13
- package/skills/wabot-async/SKILL.md +143 -0
- package/skills/wabot-auth/SKILL.md +153 -0
- package/skills/wabot-chat/SKILL.md +140 -0
- package/skills/wabot-di-config/SKILL.md +117 -0
- package/skills/wabot-framework/SKILL.md +81 -0
- package/skills/wabot-framework/references/quickstart.md +85 -0
- package/skills/wabot-mindset/SKILL.md +159 -0
- package/skills/wabot-ops/SKILL.md +151 -0
- package/skills/wabot-persistence/SKILL.md +159 -0
- package/skills/wabot-rest-socket/SKILL.md +167 -0
- package/skills/wabot-testing/SKILL.md +214 -0
- package/skills/wabot-ui/SKILL.md +201 -0
- package/skills/wabot-validation/SKILL.md +108 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Signature } from '@hubspot/api-client';
|
|
2
|
+
|
|
3
|
+
function verifyHubSpotSignatureV3(opts) {
|
|
4
|
+
const { secret, method, url, rawBody, timestampHeader, signatureHeader } = opts;
|
|
5
|
+
if (!secret || !signatureHeader || !timestampHeader) {
|
|
6
|
+
return false;
|
|
7
|
+
}
|
|
8
|
+
const timestamp = Number(timestampHeader);
|
|
9
|
+
if (!Number.isFinite(timestamp)) {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
try {
|
|
13
|
+
return Signature.isValid({
|
|
14
|
+
method,
|
|
15
|
+
signatureVersion: 'v3',
|
|
16
|
+
url,
|
|
17
|
+
requestBody: rawBody,
|
|
18
|
+
clientSecret: secret,
|
|
19
|
+
signature: signatureHeader,
|
|
20
|
+
timestamp,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export { verifyHubSpotSignatureV3 };
|
|
@@ -1,12 +1,9 @@
|
|
|
1
|
-
const PH_OPEN = '
|
|
2
|
-
const PH_CLOSE = '
|
|
1
|
+
const PH_OPEN = '\x01';
|
|
2
|
+
const PH_CLOSE = '\x02';
|
|
3
3
|
function escapeHtml(text) {
|
|
4
|
-
return text
|
|
5
|
-
.replace(/&/g, '&')
|
|
6
|
-
.replace(/</g, '<')
|
|
7
|
-
.replace(/>/g, '>');
|
|
4
|
+
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
8
5
|
}
|
|
9
|
-
function
|
|
6
|
+
function markdownToChatHtml(input) {
|
|
10
7
|
if (!input)
|
|
11
8
|
return input;
|
|
12
9
|
const reserved = [];
|
|
@@ -42,4 +39,4 @@ function markdownToTelegramHtml(input) {
|
|
|
42
39
|
return text;
|
|
43
40
|
}
|
|
44
41
|
|
|
45
|
-
export {
|
|
42
|
+
export { markdownToChatHtml };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { container } from '../../../core/injection/index.js';
|
|
2
|
+
import { resolveConfigReferences } from '../../../core/config/resolver.js';
|
|
3
|
+
import { ControllerMetadataStore } from '../../../feature/chat-controller/metadata/ControllerMetadataStore.js';
|
|
4
|
+
import '../../../feature/chat-controller/ChatResolver.js';
|
|
5
|
+
import '../../../feature/chat-controller/runChatControllers.js';
|
|
6
|
+
import { SlackChannel } from './SlackChannel.js';
|
|
7
|
+
import { SlackChannelConfig } from './SlackChannelConfig.js';
|
|
8
|
+
|
|
9
|
+
function slack(config) {
|
|
10
|
+
return function (target, propertyKey) {
|
|
11
|
+
const resolved = resolveConfigReferences(config);
|
|
12
|
+
const store = container.resolve(ControllerMetadataStore);
|
|
13
|
+
store.saveChannelMetadata({
|
|
14
|
+
channelConstructor: SlackChannel,
|
|
15
|
+
functionName: propertyKey.toString(),
|
|
16
|
+
controllerConstructor: target.constructor,
|
|
17
|
+
channelConfig: new SlackChannelConfig(resolved.appToken, resolved.botToken, resolved.signingSecret),
|
|
18
|
+
});
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export { slack };
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { __decorate, __metadata } from 'tslib';
|
|
2
|
+
import Bolt from '@slack/bolt';
|
|
3
|
+
import { injectable } from '../../../core/injection/index.js';
|
|
4
|
+
import { Logger } from '../../../core/logger/Logger.js';
|
|
5
|
+
import { SlackChannelConfig } from './SlackChannelConfig.js';
|
|
6
|
+
import { markdownToSlackMrkdwn } from './markdownToSlackMrkdwn.js';
|
|
7
|
+
import { slackChannelName } from './slackChannelName.js';
|
|
8
|
+
|
|
9
|
+
var SlackChannel_1;
|
|
10
|
+
const { App } = Bolt;
|
|
11
|
+
const GROUP_CHANNEL_TYPES = new Set(['channel', 'group', 'mpim']);
|
|
12
|
+
const PRIVATE_CHANNEL_TYPES = new Set(['im']);
|
|
13
|
+
const MAX_FILE_SIZE_BYTES = 20 * 1024 * 1024;
|
|
14
|
+
const USER_NAME_CACHE_TTL_MS = 60 * 60 * 1000;
|
|
15
|
+
let SlackChannel = class SlackChannel {
|
|
16
|
+
static { SlackChannel_1 = this; }
|
|
17
|
+
config;
|
|
18
|
+
static channelName = slackChannelName;
|
|
19
|
+
app;
|
|
20
|
+
callback = null;
|
|
21
|
+
logger = new Logger('wabot:slack-channel');
|
|
22
|
+
userNameCache = new Map();
|
|
23
|
+
constructor(config) {
|
|
24
|
+
this.config = config;
|
|
25
|
+
this.app = new App({
|
|
26
|
+
token: config.botToken,
|
|
27
|
+
appToken: config.appToken,
|
|
28
|
+
socketMode: true,
|
|
29
|
+
signingSecret: config.signingSecret,
|
|
30
|
+
deferInitialization: true,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
listen(callback) {
|
|
34
|
+
this.callback = callback;
|
|
35
|
+
this.app.message(async (args) => {
|
|
36
|
+
await this.handleMessage(args);
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
async handleMessage(args) {
|
|
40
|
+
if (!this.callback)
|
|
41
|
+
return;
|
|
42
|
+
const { message, say } = args;
|
|
43
|
+
if (message.subtype && message.subtype !== 'file_share') {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const chatConnection = this.buildChatConnection(message);
|
|
47
|
+
const threadTs = message.thread_ts ?? message.ts;
|
|
48
|
+
const { images, documents } = await this.extractMedia(message.files);
|
|
49
|
+
const senderName = await this.resolveUserName(message.user);
|
|
50
|
+
const text = message.text ?? '';
|
|
51
|
+
const reply = this.buildReply(say, threadTs);
|
|
52
|
+
try {
|
|
53
|
+
await this.callback({
|
|
54
|
+
channel: slackChannelName,
|
|
55
|
+
chatConnection,
|
|
56
|
+
message: {
|
|
57
|
+
senderId: message.user,
|
|
58
|
+
senderName,
|
|
59
|
+
text,
|
|
60
|
+
images: images.length > 0 ? images : undefined,
|
|
61
|
+
documents: documents.length > 0 ? documents : undefined,
|
|
62
|
+
metadata: {
|
|
63
|
+
ts: message.ts ?? '',
|
|
64
|
+
thread_ts: message.thread_ts ?? '',
|
|
65
|
+
channel: message.channel,
|
|
66
|
+
channel_type: message.channel_type ?? '',
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
reply,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
this.logger.error('Failed to handle Slack message', err instanceof Error ? { message: err.message } : { err });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
buildChatConnection(message) {
|
|
77
|
+
const channelType = message.channel_type;
|
|
78
|
+
const chatType = GROUP_CHANNEL_TYPES.has(channelType ?? '')
|
|
79
|
+
? 'GROUP'
|
|
80
|
+
: PRIVATE_CHANNEL_TYPES.has(channelType ?? '')
|
|
81
|
+
? 'PRIVATE'
|
|
82
|
+
: 'GROUP';
|
|
83
|
+
return {
|
|
84
|
+
id: message.channel,
|
|
85
|
+
chatType,
|
|
86
|
+
channelName: SlackChannel_1.channelName,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
buildReply(say, threadTs) {
|
|
90
|
+
return async (replyMessage) => {
|
|
91
|
+
if (!replyMessage.text)
|
|
92
|
+
return;
|
|
93
|
+
await say({
|
|
94
|
+
text: markdownToSlackMrkdwn(replyMessage.text),
|
|
95
|
+
mrkdwn: true,
|
|
96
|
+
...(threadTs ? { thread_ts: threadTs } : {}),
|
|
97
|
+
});
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
async extractMedia(files) {
|
|
101
|
+
const images = [];
|
|
102
|
+
const documents = [];
|
|
103
|
+
if (!files || files.length === 0)
|
|
104
|
+
return { images, documents };
|
|
105
|
+
const results = await Promise.all(files.map((file) => this.downloadFile(file)));
|
|
106
|
+
for (const result of results) {
|
|
107
|
+
if (!result)
|
|
108
|
+
continue;
|
|
109
|
+
if (result.file.mimeType.startsWith('image/')) {
|
|
110
|
+
images.push(result.file);
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
documents.push(result.file);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return { images, documents };
|
|
117
|
+
}
|
|
118
|
+
async downloadFile(file) {
|
|
119
|
+
const mimeType = file.mimetype ?? 'application/octet-stream';
|
|
120
|
+
if (!file.url_private) {
|
|
121
|
+
this.logger.warn(`slack file '${file.id}' has no url_private, skipping`);
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
if (file.size !== undefined && file.size > MAX_FILE_SIZE_BYTES) {
|
|
125
|
+
this.logger.warn(`slack file '${file.id}' exceeds 20 MB (${file.size} bytes), skipping`);
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
try {
|
|
129
|
+
const res = await fetch(file.url_private, {
|
|
130
|
+
headers: { Authorization: `Bearer ${this.config.botToken}` },
|
|
131
|
+
});
|
|
132
|
+
if (!res.ok) {
|
|
133
|
+
throw new Error(`${res.status} ${res.statusText}`);
|
|
134
|
+
}
|
|
135
|
+
const base64 = Buffer.from(await res.arrayBuffer()).toString('base64');
|
|
136
|
+
return {
|
|
137
|
+
file: {
|
|
138
|
+
id: file.id,
|
|
139
|
+
name: file.name,
|
|
140
|
+
mimeType,
|
|
141
|
+
base64Url: `data:${mimeType};base64,${base64}`,
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
catch (err) {
|
|
146
|
+
this.logger.warn(`failed to download slack file '${file.id}'`, err instanceof Error ? { message: err.message } : { err });
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
async resolveUserName(userId) {
|
|
151
|
+
if (!userId)
|
|
152
|
+
return undefined;
|
|
153
|
+
const now = Date.now();
|
|
154
|
+
const cached = this.userNameCache.get(userId);
|
|
155
|
+
if (cached && cached.expiresAt > now) {
|
|
156
|
+
return cached.name;
|
|
157
|
+
}
|
|
158
|
+
try {
|
|
159
|
+
const res = await this.app.client.users.info({ user: userId });
|
|
160
|
+
const user = res.user;
|
|
161
|
+
if (user) {
|
|
162
|
+
const name = user.real_name || user.name || userId;
|
|
163
|
+
this.userNameCache.set(userId, { name, expiresAt: now + USER_NAME_CACHE_TTL_MS });
|
|
164
|
+
return name;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
catch (err) {
|
|
168
|
+
this.logger.warn(`failed to resolve slack user '${userId}'`, err instanceof Error ? { message: err.message } : { err });
|
|
169
|
+
}
|
|
170
|
+
return userId;
|
|
171
|
+
}
|
|
172
|
+
connect() {
|
|
173
|
+
void (async () => {
|
|
174
|
+
await this.app.init();
|
|
175
|
+
await this.app.start();
|
|
176
|
+
})();
|
|
177
|
+
}
|
|
178
|
+
disconnect() {
|
|
179
|
+
void this.app.stop();
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
SlackChannel = SlackChannel_1 = __decorate([
|
|
183
|
+
injectable(),
|
|
184
|
+
__metadata("design:paramtypes", [SlackChannelConfig])
|
|
185
|
+
], SlackChannel);
|
|
186
|
+
|
|
187
|
+
export { SlackChannel };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
class SlackChannelConfig {
|
|
2
|
+
appToken;
|
|
3
|
+
botToken;
|
|
4
|
+
signingSecret;
|
|
5
|
+
constructor(appToken, botToken, signingSecret) {
|
|
6
|
+
this.appToken = appToken;
|
|
7
|
+
this.botToken = botToken;
|
|
8
|
+
this.signingSecret = signingSecret;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export { SlackChannelConfig };
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
const PH_OPEN = '';
|
|
2
|
+
const PH_CLOSE = '';
|
|
3
|
+
function escapeMrkdwn(text) {
|
|
4
|
+
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
5
|
+
}
|
|
6
|
+
function markdownToSlackMrkdwn(input) {
|
|
7
|
+
if (!input)
|
|
8
|
+
return input;
|
|
9
|
+
const reserved = [];
|
|
10
|
+
const reserve = (snippet) => {
|
|
11
|
+
const token = `${PH_OPEN}${reserved.length}${PH_CLOSE}`;
|
|
12
|
+
reserved.push(snippet);
|
|
13
|
+
return token;
|
|
14
|
+
};
|
|
15
|
+
let text = input.replace(/\r\n/g, '\n');
|
|
16
|
+
text = text.replace(/```[\s\S]*?```/g, (block) => {
|
|
17
|
+
return reserve(block);
|
|
18
|
+
});
|
|
19
|
+
text = text.replace(/`([^`\n]+)`/g, (_, code) => {
|
|
20
|
+
return reserve('`' + code + '`');
|
|
21
|
+
});
|
|
22
|
+
text = text.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, label, url) => {
|
|
23
|
+
return reserve(`<${url}|${label}>`);
|
|
24
|
+
});
|
|
25
|
+
text = escapeMrkdwn(text);
|
|
26
|
+
text = text.replace(/^(#{1,6})\s+(.+)$/gm, (_, _hashes, content) => `*${content.trim()}*`);
|
|
27
|
+
text = text.replace(/^>\s?(.*)$/gm, '> $1');
|
|
28
|
+
text = text.replace(/^>\s*$\n/gm, '');
|
|
29
|
+
text = text.replace(/\*\*([^\n*]+?)\*\*/g, '*$1*');
|
|
30
|
+
text = text.replace(/(^|[\s(>])__([^\n*][^_\n]*?)__(?=[\s).,!?:;]|$)/g, '$1*$2*');
|
|
31
|
+
text = text.replace(/(^|[\s(>])_([^\s_][^_\n]*?)_(?=[\s).,!?:;]|$)/g, '$1_$2_');
|
|
32
|
+
text = text.replace(/~~([^\n~]+?)~~/g, '~$1~');
|
|
33
|
+
text = text.replace(/^[ \t]*[-*+]\s+(.+)$/gm, '• $1');
|
|
34
|
+
text = text.replace(new RegExp(`${PH_OPEN}(\\d+)${PH_CLOSE}`, 'g'), (_, idx) => reserved[Number(idx)]);
|
|
35
|
+
return text;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export { markdownToSlackMrkdwn };
|
|
@@ -3,7 +3,7 @@ import { Bot } from 'grammy';
|
|
|
3
3
|
import { TelegramChannelConfig } from './TelegramChannelConfig.js';
|
|
4
4
|
import { injectable } from '../../../core/injection/index.js';
|
|
5
5
|
import { Logger } from '../../../core/logger/Logger.js';
|
|
6
|
-
import {
|
|
6
|
+
import { markdownToChatHtml } from '../markdown/markdownToChatHtml.js';
|
|
7
7
|
import { telegramChannelName } from './telegramChannelName.js';
|
|
8
8
|
|
|
9
9
|
var TelegramChannel_1;
|
|
@@ -41,7 +41,7 @@ let TelegramChannel = class TelegramChannel {
|
|
|
41
41
|
reply: async (replyMessage) => {
|
|
42
42
|
if (!replyMessage.text)
|
|
43
43
|
return;
|
|
44
|
-
await ctx.reply(
|
|
44
|
+
await ctx.reply(markdownToChatHtml(replyMessage.text), {
|
|
45
45
|
parse_mode: 'HTML',
|
|
46
46
|
});
|
|
47
47
|
},
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { fileURLToPath } from 'node:url';
|
|
2
|
+
import { options, h } from 'preact';
|
|
3
|
+
import { renderToString } from 'preact-render-to-string';
|
|
4
|
+
import '../../../core/injection/index.js';
|
|
5
|
+
import '../../../feature/ui-controller/metadata/UiControllerMetadataStore.js';
|
|
6
|
+
import '../../../feature/ui-controller/renderer/UiRendererRegistry.js';
|
|
7
|
+
import { getIslandMeta } from '../../../feature/ui-controller/island/island.js';
|
|
8
|
+
import { serializeProps } from '../../../feature/ui-controller/island/serialize.js';
|
|
9
|
+
import '../../../feature/ui-controller/island/IslandRegistry.js';
|
|
10
|
+
import '../../../core/error/setupErrorHandlers.js';
|
|
11
|
+
import 'debug';
|
|
12
|
+
import '../../../core/validation/metadata/ValidationMetadataStore.js';
|
|
13
|
+
import '../../../feature/express/ExpressProvider.js';
|
|
14
|
+
import '../../../feature/rest-controller/metadata/RestControllerMetadataStore.js';
|
|
15
|
+
import 'express';
|
|
16
|
+
import 'node:path';
|
|
17
|
+
import 'node:http';
|
|
18
|
+
import 'node:crypto';
|
|
19
|
+
import { OutletContext } from './outlet.js';
|
|
20
|
+
|
|
21
|
+
/** Absolute path (no extension) to the browser hydration runtime, resolved by esbuild. */
|
|
22
|
+
const PREACT_CLIENT_RUNTIME = fileURLToPath(new URL('./preactClientRuntime', import.meta.url));
|
|
23
|
+
let currentCollector = null;
|
|
24
|
+
const WRAPPED = '__wabotIslandWrapped';
|
|
25
|
+
// Global Preact diff hook (`__b`), invoked per vnode *during* rendering by
|
|
26
|
+
// preact-render-to-string. While an SSR collector is active, replace each
|
|
27
|
+
// island vnode with a <wabot-island> host element that wraps the island's
|
|
28
|
+
// server HTML and carries its serialized props, and record the island so the
|
|
29
|
+
// page can ship its client bundle. Installed once on import (server only).
|
|
30
|
+
const preactOptions = options;
|
|
31
|
+
const previousDiffHook = preactOptions.__b;
|
|
32
|
+
preactOptions.__b = (vnode) => {
|
|
33
|
+
if (currentCollector && vnode && typeof vnode.type === 'function' && !vnode[WRAPPED]) {
|
|
34
|
+
const meta = getIslandMeta(vnode.type);
|
|
35
|
+
if (meta) {
|
|
36
|
+
const props = vnode.props ?? {};
|
|
37
|
+
const id = meta.id ?? meta.name;
|
|
38
|
+
if (!currentCollector.seen.has(id)) {
|
|
39
|
+
currentCollector.seen.add(id);
|
|
40
|
+
currentCollector.islands.push({ id, props });
|
|
41
|
+
}
|
|
42
|
+
vnode.type = 'wabot-island';
|
|
43
|
+
vnode.props = {
|
|
44
|
+
'data-island': id,
|
|
45
|
+
'data-props': serializeProps(props),
|
|
46
|
+
children: h(meta.component, props),
|
|
47
|
+
};
|
|
48
|
+
vnode[WRAPPED] = true;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
previousDiffHook?.(vnode);
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* Default UI renderer backed by Preact + @preact/signals. Renders views to HTML
|
|
55
|
+
* and emits hydration hosts for any component wrapped with `island()`.
|
|
56
|
+
*/
|
|
57
|
+
class PreactRenderer {
|
|
58
|
+
id = 'preact';
|
|
59
|
+
client = {
|
|
60
|
+
runtimeModule: PREACT_CLIENT_RUNTIME,
|
|
61
|
+
esbuildJsx: { jsx: 'automatic', jsxImportSource: 'preact' },
|
|
62
|
+
islandEntrySource: ({ id, importPath }) => `import { registerIsland } from ${JSON.stringify(PREACT_CLIENT_RUNTIME)}\n` +
|
|
63
|
+
`import Island from ${JSON.stringify(importPath)}\n` +
|
|
64
|
+
`registerIsland(${JSON.stringify(id)}, Island)\n`,
|
|
65
|
+
};
|
|
66
|
+
renderToString(node, context) {
|
|
67
|
+
// With a layout, render the view inside the shell where <Outlet/> sits.
|
|
68
|
+
// Without one (or for boosted-nav fragments), render the view directly.
|
|
69
|
+
const Layout = context?.layout;
|
|
70
|
+
const tree = Layout
|
|
71
|
+
? h(OutletContext.Provider, { value: node }, h(Layout, {}))
|
|
72
|
+
: node;
|
|
73
|
+
const collector = { islands: [], seen: new Set() };
|
|
74
|
+
const previous = currentCollector;
|
|
75
|
+
currentCollector = collector;
|
|
76
|
+
try {
|
|
77
|
+
const html = renderToString(tree);
|
|
78
|
+
return { html, islands: collector.islands, styles: [] };
|
|
79
|
+
}
|
|
80
|
+
finally {
|
|
81
|
+
currentCollector = previous;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export { PreactRenderer };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { createContext, h } from 'preact';
|
|
2
|
+
import { useContext } from 'preact/hooks';
|
|
3
|
+
|
|
4
|
+
/** SSR context carrying the current view node so `<Outlet/>` can render it. */
|
|
5
|
+
const OutletContext = createContext(null);
|
|
6
|
+
/**
|
|
7
|
+
* Placeholder a controller `layout` renders where the current view goes. Emits a
|
|
8
|
+
* `<wabot-outlet>` host wrapping the view's server HTML; during boosted
|
|
9
|
+
* navigation only this element's content is swapped, so the surrounding shell
|
|
10
|
+
* (and its islands) keep their state.
|
|
11
|
+
*
|
|
12
|
+
* function Layout() {
|
|
13
|
+
* return <div class="app"><Nav /><main><Outlet /></main></div>
|
|
14
|
+
* }
|
|
15
|
+
* @uiController({ path: '/panel', app: true, layout: Layout })
|
|
16
|
+
*/
|
|
17
|
+
function Outlet() {
|
|
18
|
+
const node = useContext(OutletContext);
|
|
19
|
+
return h('wabot-outlet', { 'data-wabot-outlet': '' }, node);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export { Outlet, OutletContext };
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { render, hydrate, h } from 'preact';
|
|
2
|
+
|
|
3
|
+
// Browser hydration runtime for the Preact adapter. Bundled into the client by
|
|
4
|
+
// UiBundler and shared across islands (and the boosted-nav runtime). Each
|
|
5
|
+
// island's client bundle calls registerIsland(); this finds the matching
|
|
6
|
+
// <wabot-island> hosts and hydrates them with their serialized props.
|
|
7
|
+
//
|
|
8
|
+
// The boosted-navigation runtime imports hydrateAll()/unmountRemoved() from
|
|
9
|
+
// this same module. Because every island entry and the nav entry import this
|
|
10
|
+
// module, esbuild hoists it into a single shared chunk, so they all share the
|
|
11
|
+
// registry and mounted-hosts state below (one module instance per page).
|
|
12
|
+
const registry = new Map();
|
|
13
|
+
// Hosts we have hydrated. Kept as an iterable Set (not a WeakSet) so the nav
|
|
14
|
+
// runtime can walk it to unmount hosts that have left the DOM after a swap.
|
|
15
|
+
const mounted = new Set();
|
|
16
|
+
function registerIsland(id, Component) {
|
|
17
|
+
registry.set(id, Component);
|
|
18
|
+
hydrateIsland(id);
|
|
19
|
+
}
|
|
20
|
+
function hydrateIsland(id) {
|
|
21
|
+
const Component = registry.get(id);
|
|
22
|
+
if (!Component || typeof document === 'undefined')
|
|
23
|
+
return;
|
|
24
|
+
const selector = `wabot-island[data-island="${id.replace(/["\\]/g, '\\$&')}"]`;
|
|
25
|
+
document.querySelectorAll(selector).forEach((el) => {
|
|
26
|
+
if (mounted.has(el))
|
|
27
|
+
return;
|
|
28
|
+
let props = {};
|
|
29
|
+
const raw = el.dataset.props;
|
|
30
|
+
if (raw) {
|
|
31
|
+
try {
|
|
32
|
+
props = JSON.parse(raw);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
props = {};
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
hydrate(h(Component, props), el);
|
|
39
|
+
mounted.add(el);
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Hydrate any not-yet-mounted hosts for every registered island. Called by the
|
|
44
|
+
* nav runtime after swapping in a new view: islands whose bundle already loaded
|
|
45
|
+
* earlier in the session won't re-run their entry, so this picks up their new
|
|
46
|
+
* hosts. Hosts of islands not yet loaded hydrate when their bundle imports.
|
|
47
|
+
*/
|
|
48
|
+
function hydrateAll() {
|
|
49
|
+
for (const id of registry.keys())
|
|
50
|
+
hydrateIsland(id);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Unmount islands whose host is no longer in the document (removed by a view
|
|
54
|
+
* swap), running Preact's teardown so effects/subscriptions don't leak.
|
|
55
|
+
*/
|
|
56
|
+
function unmountRemoved() {
|
|
57
|
+
if (typeof document === 'undefined')
|
|
58
|
+
return;
|
|
59
|
+
for (const el of mounted) {
|
|
60
|
+
if (!document.contains(el)) {
|
|
61
|
+
render(null, el);
|
|
62
|
+
mounted.delete(el);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export { hydrateAll, registerIsland, unmountRemoved };
|
|
@@ -1,24 +1,24 @@
|
|
|
1
1
|
class CrudRepository {
|
|
2
2
|
find(id) {
|
|
3
|
-
throw new Error(
|
|
3
|
+
throw new Error('Method not implemented.');
|
|
4
4
|
}
|
|
5
5
|
findOrThrow(id) {
|
|
6
|
-
throw new Error(
|
|
6
|
+
throw new Error('Method not implemented.');
|
|
7
7
|
}
|
|
8
8
|
findByIds(ids) {
|
|
9
|
-
throw new Error(
|
|
9
|
+
throw new Error('Method not implemented.');
|
|
10
10
|
}
|
|
11
11
|
findAll(id) {
|
|
12
|
-
throw new Error(
|
|
12
|
+
throw new Error('Method not implemented.');
|
|
13
13
|
}
|
|
14
14
|
create(item) {
|
|
15
|
-
throw new Error(
|
|
15
|
+
throw new Error('Method not implemented.');
|
|
16
16
|
}
|
|
17
17
|
update(item) {
|
|
18
|
-
throw new Error(
|
|
18
|
+
throw new Error('Method not implemented.');
|
|
19
19
|
}
|
|
20
20
|
delete(item) {
|
|
21
|
-
throw new Error(
|
|
21
|
+
throw new Error('Method not implemented.');
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
24
|
|
|
@@ -12,7 +12,7 @@ function canonicalize(value) {
|
|
|
12
12
|
const keys = Object.keys(value)
|
|
13
13
|
.filter((k) => value[k] !== undefined)
|
|
14
14
|
.sort();
|
|
15
|
-
return
|
|
15
|
+
return '{' + keys.map((k) => JSON.stringify(k) + ':' + canonicalize(value[k])).join(',') + '}';
|
|
16
16
|
}
|
|
17
17
|
function computeDedupKey(commandData) {
|
|
18
18
|
return createHash('sha256').update(canonicalize(commandData)).digest('hex');
|
|
@@ -16,13 +16,11 @@ function inheritsFrom(ctor, base) {
|
|
|
16
16
|
}
|
|
17
17
|
function pgExtension(repositoryClass) {
|
|
18
18
|
if (typeof repositoryClass !== 'function') {
|
|
19
|
-
throw new Error(`@pgExtension: repository argument must be a class, ` +
|
|
20
|
-
`got ${typeof repositoryClass}`);
|
|
19
|
+
throw new Error(`@pgExtension: repository argument must be a class, ` + `got ${typeof repositoryClass}`);
|
|
21
20
|
}
|
|
22
21
|
return function (target) {
|
|
23
22
|
if (!inheritsFrom(target, PgRepositoryBase)) {
|
|
24
|
-
throw new Error(`@pgExtension on ${target.name}: extension class must extend ` +
|
|
25
|
-
`PgRepositoryExtension.`);
|
|
23
|
+
throw new Error(`@pgExtension on ${target.name}: extension class must extend ` + `PgRepositoryExtension.`);
|
|
26
24
|
}
|
|
27
25
|
const store = container.resolve(RepositoryMetadataStore);
|
|
28
26
|
store.saveExtension(repositoryClass, PG_ADAPTER_ID, target);
|