@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
|
@@ -29,6 +29,11 @@ import { runCommandHandlers } from '../async/runCommandHandlers.js';
|
|
|
29
29
|
import { runCronHandlers } from '../async/runCronHandlers.js';
|
|
30
30
|
import { SocketControllerMetadataStore } from '../socket-controller/metadata/SocketControllerMetadataStore.js';
|
|
31
31
|
import { runSocketControllers } from '../socket-controller/runSocketControllers.js';
|
|
32
|
+
import { ExpressProvider } from '../express/ExpressProvider.js';
|
|
33
|
+
import { UiControllerMetadataStore } from '../ui-controller/metadata/UiControllerMetadataStore.js';
|
|
34
|
+
import { UiRendererRegistry } from '../ui-controller/renderer/UiRendererRegistry.js';
|
|
35
|
+
import { IslandRegistry } from '../ui-controller/island/IslandRegistry.js';
|
|
36
|
+
import { runUiControllers } from '../ui-controller/runUiControllers.js';
|
|
32
37
|
import { MemoryRepositoryAdapter } from '../repository/MemoryRepositoryAdapter.js';
|
|
33
38
|
import { RepositoryMetadataStore } from '../repository/RepositoryMetadataStore.js';
|
|
34
39
|
import { RepositoryAdapterRegistry } from '../repository/RepositoryAdapterRegistry.js';
|
|
@@ -42,13 +47,11 @@ const DEFAULT_ADAPTER_LOADERS = {
|
|
|
42
47
|
},
|
|
43
48
|
openrouter: {
|
|
44
49
|
apiKeyEnv: 'OPENROUTER_API_KEY',
|
|
45
|
-
load: async () => (await import('../../addon/chat-bot/openrouter/OpenRouterChatAdapter.js'))
|
|
46
|
-
.OpenRouterChatAdapter,
|
|
50
|
+
load: async () => (await import('../../addon/chat-bot/openrouter/OpenRouterChatAdapter.js')).OpenRouterChatAdapter,
|
|
47
51
|
},
|
|
48
52
|
anthropic: {
|
|
49
53
|
apiKeyEnv: 'ANTHROPIC_API_KEY',
|
|
50
|
-
load: async () => (await import('../../addon/chat-bot/anthropic/AnthropicChatAdapter.js'))
|
|
51
|
-
.AnthropicChatAdapter,
|
|
54
|
+
load: async () => (await import('../../addon/chat-bot/anthropic/AnthropicChatAdapter.js')).AnthropicChatAdapter,
|
|
52
55
|
},
|
|
53
56
|
google: {
|
|
54
57
|
apiKeyEnv: 'GOOGLE_API_KEY',
|
|
@@ -62,6 +65,7 @@ class ProjectRunner {
|
|
|
62
65
|
connectionString;
|
|
63
66
|
isPg;
|
|
64
67
|
preloaded;
|
|
68
|
+
ui;
|
|
65
69
|
pool = null;
|
|
66
70
|
constructor(config = {}) {
|
|
67
71
|
this.directories = config.directories ?? ['src'];
|
|
@@ -70,8 +74,10 @@ class ProjectRunner {
|
|
|
70
74
|
this.connectionString = this.resolveConnectionString(config.connectionString);
|
|
71
75
|
this.isPg = this.connectionString != null && isPostgresUrl(this.connectionString);
|
|
72
76
|
this.preloaded = config.preloaded === true;
|
|
77
|
+
this.ui = config.ui ?? {};
|
|
73
78
|
}
|
|
74
79
|
async run() {
|
|
80
|
+
let scannedFiles = [];
|
|
75
81
|
if (this.preloaded) {
|
|
76
82
|
if (this.isPg)
|
|
77
83
|
await this.initPool();
|
|
@@ -81,11 +87,12 @@ class ProjectRunner {
|
|
|
81
87
|
this.isPg ? this.initPool() : Promise.resolve(),
|
|
82
88
|
scanProjectFiles({ directories: this.directories, exclude: this.exclude }),
|
|
83
89
|
]);
|
|
84
|
-
|
|
90
|
+
scannedFiles = files;
|
|
91
|
+
await this.importFiles(scannedFiles);
|
|
85
92
|
}
|
|
86
93
|
const components = this.discoverComponents();
|
|
87
94
|
await this.registerAdapters(components);
|
|
88
|
-
await this.startComponents(components);
|
|
95
|
+
await this.startComponents(components, scannedFiles);
|
|
89
96
|
}
|
|
90
97
|
resolveConnectionString(configValue) {
|
|
91
98
|
const cs = configValue ?? process.env.DATABASE_URL ?? null;
|
|
@@ -151,6 +158,7 @@ class ProjectRunner {
|
|
|
151
158
|
socketControllers: container
|
|
152
159
|
.resolve(SocketControllerMetadataStore)
|
|
153
160
|
.getAllSocketControllerConstructors(),
|
|
161
|
+
uiControllers: container.resolve(UiControllerMetadataStore).getAllUiControllerConstructors(),
|
|
154
162
|
};
|
|
155
163
|
}
|
|
156
164
|
registerAdapters(components) {
|
|
@@ -199,9 +207,7 @@ class ProjectRunner {
|
|
|
199
207
|
hasCommandHandlers || hasCronHandlers
|
|
200
208
|
? import('../../addon/async/pg/PgJobRepository.js')
|
|
201
209
|
: Promise.resolve(null),
|
|
202
|
-
hasCronHandlers
|
|
203
|
-
? import('../../addon/async/pg/PgCronJobRepository.js')
|
|
204
|
-
: Promise.resolve(null),
|
|
210
|
+
hasCronHandlers ? import('../../addon/async/pg/PgCronJobRepository.js') : Promise.resolve(null),
|
|
205
211
|
]);
|
|
206
212
|
container.register(ChatRepository, { useToken: chatBotMod.PgChatRepository });
|
|
207
213
|
container.register(Locker, { useToken: lockerMod.PgLocker });
|
|
@@ -218,7 +224,7 @@ class ProjectRunner {
|
|
|
218
224
|
}
|
|
219
225
|
logger.info('Configured with PostgreSQL adapters');
|
|
220
226
|
}
|
|
221
|
-
async startComponents(components) {
|
|
227
|
+
async startComponents(components, files = []) {
|
|
222
228
|
const chatAdapters = this.chatAdapters ?? (await this.resolveDefaultChatAdapters());
|
|
223
229
|
if (chatAdapters.length > 0) {
|
|
224
230
|
runChatAdapters(chatAdapters);
|
|
@@ -231,6 +237,9 @@ class ProjectRunner {
|
|
|
231
237
|
logger.info(`Starting ${components.restControllers.length} REST controller(s)`);
|
|
232
238
|
runRestControllers(components.restControllers);
|
|
233
239
|
}
|
|
240
|
+
if (components.uiControllers.length > 0) {
|
|
241
|
+
await this.startUiControllers(components.uiControllers, files);
|
|
242
|
+
}
|
|
234
243
|
if (components.commandHandlers.length > 0) {
|
|
235
244
|
logger.info(`Starting ${components.commandHandlers.length} command handler(s)`);
|
|
236
245
|
runCommandHandlers(components.commandHandlers);
|
|
@@ -244,6 +253,49 @@ class ProjectRunner {
|
|
|
244
253
|
runSocketControllers(components.socketControllers);
|
|
245
254
|
}
|
|
246
255
|
}
|
|
256
|
+
async startUiControllers(uiControllers, files) {
|
|
257
|
+
const rendererRegistry = container.resolve(UiRendererRegistry);
|
|
258
|
+
if (!rendererRegistry.hasDefault()) {
|
|
259
|
+
const { PreactRenderer } = await import('../../addon/ui/preact/PreactRenderer.js');
|
|
260
|
+
rendererRegistry.setDefault(new PreactRenderer());
|
|
261
|
+
}
|
|
262
|
+
const client = rendererRegistry.get().client;
|
|
263
|
+
let pageAssets;
|
|
264
|
+
if (client) {
|
|
265
|
+
// The bundler pulls in esbuild, so only load it when UI islands are in play.
|
|
266
|
+
const bundlerMod = await import('../ui-controller/bundler/index.js');
|
|
267
|
+
pageAssets = this.preloaded
|
|
268
|
+
? await this.setupProdUiAssets(bundlerMod)
|
|
269
|
+
: await this.setupDevUiAssets(bundlerMod, client, files);
|
|
270
|
+
}
|
|
271
|
+
logger.info(`Starting ${uiControllers.length} UI controller(s)`);
|
|
272
|
+
runUiControllers(uiControllers, { pageAssets });
|
|
273
|
+
}
|
|
274
|
+
async setupDevUiAssets(bundlerMod, client, files) {
|
|
275
|
+
const islands = await container.resolve(IslandRegistry).discover(files);
|
|
276
|
+
if (islands.length === 0)
|
|
277
|
+
return undefined;
|
|
278
|
+
const bundler = new bundlerMod.UiBundler({ islands, client, alias: this.ui.bundlerAlias });
|
|
279
|
+
await bundler.startDev();
|
|
280
|
+
bundlerMod.mountUiDevAssets(container.resolve(ExpressProvider).getExpress(), bundler);
|
|
281
|
+
return (used) => bundlerMod.pageAssetsFromManifest(bundler.getManifest(), used, {
|
|
282
|
+
liveReloadPath: '/_wabot/livereload',
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
async setupProdUiAssets(bundlerMod) {
|
|
286
|
+
const fs = await import('node:fs');
|
|
287
|
+
const nodePath = await import('node:path');
|
|
288
|
+
const distUi = nodePath.resolve(process.cwd(), 'dist/ui');
|
|
289
|
+
const manifestPath = nodePath.join(distUi, 'manifest.json');
|
|
290
|
+
if (!fs.existsSync(manifestPath)) {
|
|
291
|
+
logger.warn(`UI manifest not found at ${manifestPath}; islands will not hydrate`);
|
|
292
|
+
return undefined;
|
|
293
|
+
}
|
|
294
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
|
295
|
+
const { default: express } = await import('express');
|
|
296
|
+
container.resolve(ExpressProvider).getExpress().use('/_wabot', express.static(distUi));
|
|
297
|
+
return (used) => bundlerMod.pageAssetsFromManifest(manifest, used);
|
|
298
|
+
}
|
|
247
299
|
async resolveDefaultChatAdapters() {
|
|
248
300
|
const keys = Object.keys(DEFAULT_ADAPTER_LOADERS);
|
|
249
301
|
const emptyKeys = [];
|
|
@@ -13,8 +13,7 @@ function inheritsFrom(ctor, base) {
|
|
|
13
13
|
}
|
|
14
14
|
function memExtension(repositoryClass) {
|
|
15
15
|
if (typeof repositoryClass !== 'function') {
|
|
16
|
-
throw new Error(`@memoryExtension: repository argument must be a class, ` +
|
|
17
|
-
`got ${typeof repositoryClass}`);
|
|
16
|
+
throw new Error(`@memoryExtension: repository argument must be a class, ` + `got ${typeof repositoryClass}`);
|
|
18
17
|
}
|
|
19
18
|
return function (target) {
|
|
20
19
|
if (!inheritsFrom(target, MemoryRepositoryExtension)) {
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Client-safe helpers for calling @action endpoints. No Node imports so these
|
|
2
|
+
// can be bundled into islands.
|
|
3
|
+
function joinUrl(...parts) {
|
|
4
|
+
const joined = parts
|
|
5
|
+
.map((p) => p.replace(/^\/+|\/+$/g, ''))
|
|
6
|
+
.filter(Boolean)
|
|
7
|
+
.join('/');
|
|
8
|
+
return '/' + joined;
|
|
9
|
+
}
|
|
10
|
+
/** Build the URL of an @action: `<controllerPath>/_action/<actionName>`. */
|
|
11
|
+
function actionUrl(controllerPath, actionName) {
|
|
12
|
+
return joinUrl(controllerPath, '_action', actionName);
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* POST JSON to an @action and return its JSON result. Throws on non-2xx with
|
|
16
|
+
* the server-provided error message. Intended for use from islands; plain
|
|
17
|
+
* <form action=…> posts also work for progressive enhancement.
|
|
18
|
+
*/
|
|
19
|
+
async function callAction(url, data, options = {}) {
|
|
20
|
+
const response = await fetch(url, {
|
|
21
|
+
method: 'POST',
|
|
22
|
+
headers: { 'Content-Type': 'application/json', ...options.headers },
|
|
23
|
+
body: data === undefined ? undefined : JSON.stringify(data),
|
|
24
|
+
signal: options.signal,
|
|
25
|
+
});
|
|
26
|
+
const text = await response.text();
|
|
27
|
+
const parsed = text ? JSON.parse(text) : null;
|
|
28
|
+
if (!response.ok) {
|
|
29
|
+
const message = parsed?.error?.message ?? `Action failed with status ${response.status}`;
|
|
30
|
+
throw new Error(message);
|
|
31
|
+
}
|
|
32
|
+
return parsed;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export { actionUrl, callAction };
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import path__default from 'node:path';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import * as esbuild from 'esbuild';
|
|
4
|
+
import { Logger } from '../../../core/logger/Logger.js';
|
|
5
|
+
import { emptyManifest, manifestFromMetafile, NAV_ENTRY_ID, ISLAND_ENTRY_NAMESPACE } from './manifest.js';
|
|
6
|
+
|
|
7
|
+
/** Absolute path to the generic boosted-navigation client core, bundled as an
|
|
8
|
+
* extra entry so it shares the island runtime chunk. */
|
|
9
|
+
const NAV_RUNTIME_MODULE = fileURLToPath(new URL('./navRuntime', import.meta.url));
|
|
10
|
+
/**
|
|
11
|
+
* Source of the nav entry. Imports the island hydrate/unmount hooks from the
|
|
12
|
+
* renderer's runtime module (the same module island entries import, so they
|
|
13
|
+
* share one registry instance) and wires them into the generic nav core.
|
|
14
|
+
*/
|
|
15
|
+
function navEntrySource(runtimeModule) {
|
|
16
|
+
return (`import { hydrateAll, unmountRemoved } from ${JSON.stringify(runtimeModule)}\n` +
|
|
17
|
+
`import { startNavigation } from ${JSON.stringify(NAV_RUNTIME_MODULE)}\n` +
|
|
18
|
+
`startNavigation({ hydrateAll, unmountRemoved })\n`);
|
|
19
|
+
}
|
|
20
|
+
function mimeFor(servePath) {
|
|
21
|
+
if (servePath.endsWith('.js'))
|
|
22
|
+
return 'text/javascript; charset=utf-8';
|
|
23
|
+
if (servePath.endsWith('.css'))
|
|
24
|
+
return 'text/css; charset=utf-8';
|
|
25
|
+
if (servePath.endsWith('.map'))
|
|
26
|
+
return 'application/json; charset=utf-8';
|
|
27
|
+
return 'application/octet-stream';
|
|
28
|
+
}
|
|
29
|
+
function entrySources(islands, client) {
|
|
30
|
+
const sources = new Map();
|
|
31
|
+
for (const island of islands) {
|
|
32
|
+
// keyed by bare id; esbuild records the entry as `${namespace}:${path}`.
|
|
33
|
+
sources.set(island.id, client.islandEntrySource({ id: island.id, importPath: island.importPath }));
|
|
34
|
+
}
|
|
35
|
+
sources.set(NAV_ENTRY_ID, navEntrySource(client.runtimeModule));
|
|
36
|
+
return sources;
|
|
37
|
+
}
|
|
38
|
+
function islandEntryPlugin(sources, cwd) {
|
|
39
|
+
const prefix = `${ISLAND_ENTRY_NAMESPACE}:`;
|
|
40
|
+
const filter = new RegExp(`^${ISLAND_ENTRY_NAMESPACE}:`);
|
|
41
|
+
return {
|
|
42
|
+
name: 'wabot-island-entries',
|
|
43
|
+
setup(build) {
|
|
44
|
+
build.onResolve({ filter }, (args) => ({
|
|
45
|
+
path: args.path.slice(prefix.length),
|
|
46
|
+
namespace: ISLAND_ENTRY_NAMESPACE,
|
|
47
|
+
}));
|
|
48
|
+
build.onLoad({ filter: /.*/, namespace: ISLAND_ENTRY_NAMESPACE }, (args) => ({
|
|
49
|
+
contents: sources.get(args.path) ?? '',
|
|
50
|
+
loader: 'js',
|
|
51
|
+
resolveDir: cwd,
|
|
52
|
+
}));
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
function baseBuildOptions(islands, client, opts) {
|
|
57
|
+
const jsxMode = client.esbuildJsx?.jsx ?? 'automatic';
|
|
58
|
+
return {
|
|
59
|
+
entryPoints: [
|
|
60
|
+
...islands.map((i) => ({ in: `${ISLAND_ENTRY_NAMESPACE}:${i.id}`, out: i.id })),
|
|
61
|
+
// Boosted-navigation runtime, shared across the app's views.
|
|
62
|
+
{ in: `${ISLAND_ENTRY_NAMESPACE}:${NAV_ENTRY_ID}`, out: '_nav' },
|
|
63
|
+
],
|
|
64
|
+
bundle: true,
|
|
65
|
+
splitting: true,
|
|
66
|
+
format: 'esm',
|
|
67
|
+
platform: 'browser',
|
|
68
|
+
outdir: opts.outdir,
|
|
69
|
+
absWorkingDir: opts.cwd,
|
|
70
|
+
metafile: true,
|
|
71
|
+
alias: opts.alias,
|
|
72
|
+
sourcemap: opts.dev ? 'linked' : false,
|
|
73
|
+
minify: !opts.dev,
|
|
74
|
+
entryNames: '[name]',
|
|
75
|
+
chunkNames: 'chunks/[name]-[hash]',
|
|
76
|
+
assetNames: 'assets/[name]-[hash]',
|
|
77
|
+
jsx: jsxMode,
|
|
78
|
+
...(jsxMode === 'automatic'
|
|
79
|
+
? { jsxImportSource: client.esbuildJsx?.jsxImportSource }
|
|
80
|
+
: {
|
|
81
|
+
jsxFactory: client.esbuildJsx?.jsxFactory,
|
|
82
|
+
jsxFragment: client.esbuildJsx?.jsxFragmentFactory,
|
|
83
|
+
}),
|
|
84
|
+
loader: { '.module.css': 'local-css' },
|
|
85
|
+
logLevel: 'silent',
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Bundles island client code with esbuild. In dev it watches and serves from
|
|
90
|
+
* memory; in prod it writes hashed assets to disk and returns a manifest.
|
|
91
|
+
*/
|
|
92
|
+
class UiBundler {
|
|
93
|
+
islands;
|
|
94
|
+
client;
|
|
95
|
+
base;
|
|
96
|
+
cwd;
|
|
97
|
+
alias;
|
|
98
|
+
logger = new Logger('wabot:ui:bundler');
|
|
99
|
+
ctx = null;
|
|
100
|
+
served = new Map();
|
|
101
|
+
manifest;
|
|
102
|
+
rebuildListeners = new Set();
|
|
103
|
+
constructor(options) {
|
|
104
|
+
this.islands = options.islands;
|
|
105
|
+
this.client = options.client;
|
|
106
|
+
this.base = options.base ?? '/_wabot/';
|
|
107
|
+
this.cwd = options.cwd ?? process.cwd();
|
|
108
|
+
this.alias = options.alias;
|
|
109
|
+
this.manifest = emptyManifest(this.base);
|
|
110
|
+
}
|
|
111
|
+
/** Start a watching dev build; assets are kept in memory and served via getFile(). */
|
|
112
|
+
async startDev() {
|
|
113
|
+
const outdir = path__default.resolve(this.cwd, '.wabot-ui-dev');
|
|
114
|
+
const sources = entrySources(this.islands, this.client);
|
|
115
|
+
const onEnd = {
|
|
116
|
+
name: 'wabot-dev-refresh',
|
|
117
|
+
setup: (build) => {
|
|
118
|
+
build.onEnd((result) => {
|
|
119
|
+
this.ingest(result, outdir);
|
|
120
|
+
for (const listener of this.rebuildListeners)
|
|
121
|
+
listener();
|
|
122
|
+
});
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
this.ctx = await esbuild.context({
|
|
126
|
+
...baseBuildOptions(this.islands, this.client, {
|
|
127
|
+
outdir,
|
|
128
|
+
dev: true,
|
|
129
|
+
cwd: this.cwd,
|
|
130
|
+
alias: this.alias,
|
|
131
|
+
}),
|
|
132
|
+
write: false,
|
|
133
|
+
plugins: [islandEntryPlugin(sources, this.cwd), onEnd],
|
|
134
|
+
});
|
|
135
|
+
await this.ctx.rebuild();
|
|
136
|
+
await this.ctx.watch();
|
|
137
|
+
this.logger.info(`watching ${this.islands.length} island(s)`);
|
|
138
|
+
}
|
|
139
|
+
/** Build once and write hashed assets to outDir; returns the manifest. */
|
|
140
|
+
async buildProd(outDir) {
|
|
141
|
+
const outdir = path__default.resolve(this.cwd, outDir);
|
|
142
|
+
const sources = entrySources(this.islands, this.client);
|
|
143
|
+
const result = await esbuild.build({
|
|
144
|
+
...baseBuildOptions(this.islands, this.client, {
|
|
145
|
+
outdir,
|
|
146
|
+
dev: false,
|
|
147
|
+
cwd: this.cwd,
|
|
148
|
+
alias: this.alias,
|
|
149
|
+
}),
|
|
150
|
+
write: true,
|
|
151
|
+
plugins: [islandEntryPlugin(sources, this.cwd)],
|
|
152
|
+
});
|
|
153
|
+
this.manifest = manifestFromMetafile(result.metafile, {
|
|
154
|
+
base: this.base,
|
|
155
|
+
outdir,
|
|
156
|
+
cwd: this.cwd,
|
|
157
|
+
});
|
|
158
|
+
this.logger.info(`built ${this.islands.length} island(s) -> ${outDir}`);
|
|
159
|
+
return this.manifest;
|
|
160
|
+
}
|
|
161
|
+
ingest(result, outdir) {
|
|
162
|
+
this.served.clear();
|
|
163
|
+
for (const file of result.outputFiles ?? []) {
|
|
164
|
+
const rel = path__default.relative(outdir, file.path).split(path__default.sep).join('/');
|
|
165
|
+
const servePath = this.base + rel;
|
|
166
|
+
this.served.set(servePath, { contents: file.contents, type: mimeFor(servePath) });
|
|
167
|
+
}
|
|
168
|
+
if (result.metafile) {
|
|
169
|
+
this.manifest = manifestFromMetafile(result.metafile, {
|
|
170
|
+
base: this.base,
|
|
171
|
+
outdir,
|
|
172
|
+
cwd: this.cwd,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
getFile(servePath) {
|
|
177
|
+
return this.served.get(servePath);
|
|
178
|
+
}
|
|
179
|
+
getManifest() {
|
|
180
|
+
return this.manifest;
|
|
181
|
+
}
|
|
182
|
+
onRebuild(listener) {
|
|
183
|
+
this.rebuildListeners.add(listener);
|
|
184
|
+
}
|
|
185
|
+
async dispose() {
|
|
186
|
+
await this.ctx?.dispose();
|
|
187
|
+
this.ctx = null;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export { UiBundler };
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { Logger } from '../../../core/logger/Logger.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Mounts the dev island assets and a live-reload SSE endpoint on the shared
|
|
5
|
+
* Express app. Each successful rebuild pushes a "reload" event to connected
|
|
6
|
+
* pages (the snippet from {@link liveReloadSnippet} listens for it).
|
|
7
|
+
*/
|
|
8
|
+
function mountUiDevAssets(app, bundler, options = {}) {
|
|
9
|
+
const logger = new Logger('wabot:ui:dev');
|
|
10
|
+
const base = options.base ?? '/_wabot/';
|
|
11
|
+
const mountPath = base.replace(/\/$/, '');
|
|
12
|
+
const liveReloadPath = options.liveReloadPath ?? '/_wabot/livereload';
|
|
13
|
+
const clients = new Set();
|
|
14
|
+
bundler.onRebuild(() => {
|
|
15
|
+
for (const res of clients)
|
|
16
|
+
res.write('data: reload\n\n');
|
|
17
|
+
});
|
|
18
|
+
app.get(liveReloadPath, (req, res) => {
|
|
19
|
+
res.set({
|
|
20
|
+
'Content-Type': 'text/event-stream',
|
|
21
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
22
|
+
Connection: 'keep-alive',
|
|
23
|
+
});
|
|
24
|
+
res.flushHeaders?.();
|
|
25
|
+
res.write('data: connected\n\n');
|
|
26
|
+
clients.add(res);
|
|
27
|
+
req.on('close', () => clients.delete(res));
|
|
28
|
+
});
|
|
29
|
+
app.use(mountPath, (req, res, next) => {
|
|
30
|
+
const servePath = mountPath + req.path;
|
|
31
|
+
const file = bundler.getFile(servePath);
|
|
32
|
+
if (!file)
|
|
33
|
+
return next();
|
|
34
|
+
res.set('Content-Type', file.type);
|
|
35
|
+
res.set('Cache-Control', 'no-cache');
|
|
36
|
+
res.send(Buffer.from(file.contents));
|
|
37
|
+
});
|
|
38
|
+
logger.info(`serving island assets at ${base} (live reload via ${liveReloadPath})`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export { mountUiDevAssets };
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { ISLAND_ENTRY_NAMESPACE, NAV_ENTRY_ID, emptyManifest, manifestFromMetafile } from './manifest.js';
|
|
2
|
+
export { UiBundler } from './UiBundler.js';
|
|
3
|
+
export { liveReloadSnippet, pageAssetsFromManifest } from './pageAssets.js';
|
|
4
|
+
export { mountUiDevAssets } from './devMiddleware.js';
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import path__default from 'node:path';
|
|
2
|
+
|
|
3
|
+
const ISLAND_ENTRY_NAMESPACE = 'wabot-island';
|
|
4
|
+
/** Reserved island-entry id for the boosted-navigation runtime bundle. */
|
|
5
|
+
const NAV_ENTRY_ID = '__wabot_nav';
|
|
6
|
+
function toUrl(base, outdir, cwd, outPath) {
|
|
7
|
+
const abs = path__default.resolve(cwd, outPath);
|
|
8
|
+
const rel = path__default.relative(outdir, abs).split(path__default.sep).join('/');
|
|
9
|
+
return base + rel;
|
|
10
|
+
}
|
|
11
|
+
/** Build the island asset manifest from an esbuild metafile. */
|
|
12
|
+
function manifestFromMetafile(metafile, opts) {
|
|
13
|
+
const { base, outdir, cwd } = opts;
|
|
14
|
+
const islands = {};
|
|
15
|
+
let nav;
|
|
16
|
+
for (const [outPath, output] of Object.entries(metafile.outputs)) {
|
|
17
|
+
const entryPoint = output.entryPoint;
|
|
18
|
+
if (!entryPoint || !entryPoint.startsWith(`${ISLAND_ENTRY_NAMESPACE}:`))
|
|
19
|
+
continue;
|
|
20
|
+
const id = entryPoint.slice(ISLAND_ENTRY_NAMESPACE.length + 1);
|
|
21
|
+
if (id === NAV_ENTRY_ID) {
|
|
22
|
+
nav = toUrl(base, outdir, cwd, outPath);
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
const css = output.cssBundle ? [toUrl(base, outdir, cwd, output.cssBundle)] : [];
|
|
26
|
+
islands[id] = { js: toUrl(base, outdir, cwd, outPath), css };
|
|
27
|
+
}
|
|
28
|
+
return { base, islands, nav };
|
|
29
|
+
}
|
|
30
|
+
function emptyManifest(base = '/_wabot/') {
|
|
31
|
+
return { base, islands: {} };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export { ISLAND_ENTRY_NAMESPACE, NAV_ENTRY_ID, emptyManifest, manifestFromMetafile };
|