@entrinsik/vite-plugin-informer 2.7.0-beta.0 → 2.10.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.
@@ -0,0 +1,191 @@
1
+ import { EventEmitter } from 'node:events';
2
+
3
+ /**
4
+ * App Channels in dev: the `broadcast(channel, event, payload)` verb for
5
+ * in-process handlers, and the `channels:` manifest relay that mirrors
6
+ * `emitAppEvent` on the server.
7
+ *
8
+ * A broadcast validates exactly like the server's `broadcastAppMessage`
9
+ * (app-channel-broadcast.js) — same regexes, limits and error messages — and
10
+ * publishes one frame on a plugin-local emitter. The plugin forwards every
11
+ * frame to the page over Vite's own dev websocket (see index.js); nothing
12
+ * here touches the network. Rate limiting and the `enabled` switch are
13
+ * server-cluster concerns and have no dev counterpart.
14
+ */
15
+
16
+ // Mirrors of app-channel-broadcast.js: `orders`, `orders/east`, `@user/brad`.
17
+ // Everything after `@user/` is one username, verbatim — the server compares the
18
+ // whole remainder to the socket's own.
19
+ export const CHANNEL_NAME = /^(@user\/\S+|[\w.-]+(\/[\w.-]+)*)$/;
20
+ export const CHANNEL_NAME_MAX_LENGTH = 128;
21
+ // `created`, `order_created`
22
+ export const EVENT_NAME = /^[\w.-]+$/;
23
+ export const EVENT_NAME_MAX_LENGTH = 64;
24
+ // config-factory.js app.channels.maxFrameBytes default
25
+ export const MAX_FRAME_BYTES = 65536;
26
+ // deploy.js CHANNELS_SCHEMA description cap
27
+ const DESCRIPTION_MAX_LENGTH = 500;
28
+
29
+ // The Vite custom event a frame rides to the page (`hot.on(DEV_CHANNEL_EVENT, ...)`).
30
+ export const DEV_CHANNEL_EVENT = 'informer:channel';
31
+ // The plugin has no tenant identity; frames carry this until it does.
32
+ export const DEV_TENANT = 'dev';
33
+
34
+ /** A short, single-line preview of a payload for the dev console (frames may be 64 KiB). */
35
+ export function previewPayload(value, max = 200) {
36
+ let text;
37
+ try { text = JSON.stringify(value); } catch { text = String(value); }
38
+ if (text === undefined) text = 'undefined';
39
+ return text.length > max ? `${text.slice(0, max)}… (${text.length} chars)` : text;
40
+ }
41
+
42
+ /** The emitter event a published frame is raised on. */
43
+ export const BROADCAST_EVENT = 'broadcast';
44
+
45
+ export function isChannelName(name) {
46
+ return typeof name === 'string' && name.length <= CHANNEL_NAME_MAX_LENGTH && CHANNEL_NAME.test(name);
47
+ }
48
+
49
+ export function isEventName(name) {
50
+ return typeof name === 'string' && name.length <= EVENT_NAME_MAX_LENGTH && EVENT_NAME.test(name);
51
+ }
52
+
53
+ // Shaped like the boom error the server throws: message === code, plus the
54
+ // HTTP status it would carry, so a handler can branch on either in dev.
55
+ function channelError(code, statusCode) {
56
+ const err = new Error(code);
57
+ err.code = code;
58
+ err.statusCode = statusCode;
59
+ return err;
60
+ }
61
+
62
+ /**
63
+ * Validate the shape of a parsed `channels:` block. Returns human-readable
64
+ * error strings (empty when valid). Mirrors deploy.js CHANNELS_SCHEMA so an
65
+ * author sees at boot what the deploy would 400 on.
66
+ *
67
+ * @param {*} block - The raw `channels:` value
68
+ * @returns {string[]} Error messages, one per problem
69
+ */
70
+ export function validateChannels(block) {
71
+ const errors = [];
72
+ if (block === undefined || block === null) return errors;
73
+ if (typeof block !== 'object' || Array.isArray(block)) {
74
+ return ['channels: must be a map of channel name → { description?, on? }'];
75
+ }
76
+ for (const [name, def] of Object.entries(block)) {
77
+ if (!isChannelName(name)) {
78
+ errors.push(`channels: invalid channel name "${name}" (use segments of letters, digits, _ . -, joined by /, max ${CHANNEL_NAME_MAX_LENGTH} chars)`);
79
+ continue;
80
+ }
81
+ if (def === null || def === undefined) continue;
82
+ if (typeof def !== 'object' || Array.isArray(def)) {
83
+ errors.push(`channels.${name}: must be a map with optional "description" and "on"`);
84
+ continue;
85
+ }
86
+ for (const key of Object.keys(def)) {
87
+ if (key !== 'description' && key !== 'on') errors.push(`channels.${name}: unknown key "${key}"`);
88
+ }
89
+ if (def.description !== undefined && (typeof def.description !== 'string' || def.description.length > DESCRIPTION_MAX_LENGTH)) {
90
+ errors.push(`channels.${name}.description: must be a string of at most ${DESCRIPTION_MAX_LENGTH} chars`);
91
+ }
92
+ if (def.on !== undefined) {
93
+ const events = Array.isArray(def.on) ? def.on : [def.on];
94
+ for (const event of events) {
95
+ if (!isEventName(event)) {
96
+ errors.push(`channels.${name}.on: invalid event name ${JSON.stringify(event)} (letters, digits, _ . -, max ${EVENT_NAME_MAX_LENGTH} chars)`);
97
+ }
98
+ }
99
+ }
100
+ }
101
+ return errors;
102
+ }
103
+
104
+ /**
105
+ * Create the dev channels hub shared by every dev handler bag.
106
+ *
107
+ * @param {Object} [opts]
108
+ * @param {string} [opts.tenant] - frame tenant (the plugin knows none; defaults to 'dev')
109
+ * @param {string} [opts.appId] - the dev app id (the mocked `report.id`)
110
+ * @param {string} [opts.logPrefix] - console prefix
111
+ * @returns {{ emitter: EventEmitter, broadcast: Function, relay: Function, tenant: string, appId: string }}
112
+ */
113
+ export function createDevChannels({ tenant = DEV_TENANT, appId = 'dev-local', logPrefix = '[app-channel]' } = {}) {
114
+ const emitter = new EventEmitter();
115
+
116
+ // Validate and publish one §1.1 frame. Synchronous so the manifest relay
117
+ // can run inside a synchronous emit(); throws the server's error codes.
118
+ function publish(channel, event, payload) {
119
+ if (!isChannelName(channel)) throw channelError('app_channel_invalid_name', 400);
120
+ if (!isEventName(event)) throw channelError('app_channel_invalid_event', 400);
121
+
122
+ const message = payload === undefined ? null : payload;
123
+ let serialized;
124
+ try {
125
+ serialized = JSON.stringify(message);
126
+ } catch {
127
+ throw channelError('app_channel_invalid_payload', 400);
128
+ }
129
+ // A function/symbol serializes to nothing at all; the sandbox membrane
130
+ // would have refused it before the server ever saw it.
131
+ if (serialized === undefined) throw channelError('app_channel_invalid_payload', 400);
132
+ if (Buffer.byteLength(serialized) > MAX_FRAME_BYTES) throw channelError('app_channel_frame_too_large', 413);
133
+
134
+ const frame = { tenant, appId, channel, event, payload: message, at: Date.now() };
135
+ emitter.emit(BROADCAST_EVENT, frame);
136
+ return frame;
137
+ }
138
+
139
+ // The bag member. Async like the sandbox's, so a bad name/event/payload
140
+ // rejects rather than throws, exactly as it does in production.
141
+ async function broadcast(channel, event, payload) {
142
+ const frame = publish(channel, event, payload);
143
+ console.log(`${logPrefix} broadcast("${channel}", "${event}", ${previewPayload(frame.payload)})`);
144
+ return { ok: true };
145
+ }
146
+
147
+ /**
148
+ * The `channels:` relay (mirror of emitAppEvent): an emit() of an event
149
+ * listed in a channel's `on` is also broadcast to that channel, same event
150
+ * name and payload. A dropped relay warns and never fails the emit.
151
+ *
152
+ * @param {Object} manifestChannels - the parsed `channels:` block
153
+ * @param {string} event
154
+ * @param {*} payload
155
+ */
156
+ function relay(manifestChannels, event, payload) {
157
+ if (!manifestChannels || typeof manifestChannels !== 'object') return;
158
+ for (const [channel, def] of Object.entries(manifestChannels)) {
159
+ if (![].concat((def && def.on) || []).includes(event)) continue;
160
+ try {
161
+ publish(channel, event, payload);
162
+ console.log(`${logPrefix} relayed emit("${event}") → channel "${channel}"`);
163
+ } catch (err) {
164
+ console.warn(`${logPrefix} relay dropped: channel "${channel}" event "${event}": ${err.message}`);
165
+ }
166
+ }
167
+ }
168
+
169
+ return { emitter, broadcast, relay, tenant, appId };
170
+ }
171
+
172
+ /**
173
+ * Build the dev `emit(event, payload)` bag member: a console-logged no-op
174
+ * (no app_event row in dev) that still runs the manifest relay, so a page
175
+ * subscribed to a relayed channel sees the frame locally.
176
+ *
177
+ * @param {Object} opts
178
+ * @param {ReturnType<typeof createDevChannels>} opts.channels
179
+ * @param {Object} opts.manifestChannels - the parsed `channels:` block
180
+ * @param {string} [opts.logPrefix]
181
+ * @returns {(event: string, payload?: *) => { ok: true }}
182
+ */
183
+ export function createDevEmit({ channels, manifestChannels, logPrefix = '[app-event]' }) {
184
+ return (event, payload) => {
185
+ // The sandbox bootstrap sends `payload || {}` across the membrane.
186
+ const body = payload || {};
187
+ console.log(`${logPrefix} emit("${event}", ${previewPayload(body)})`);
188
+ channels.relay(manifestChannels, event, body);
189
+ return { ok: true };
190
+ };
191
+ }
@@ -173,14 +173,16 @@ const METHOD_SURFACE = {
173
173
  const REQUEST_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
174
174
 
175
175
  /**
176
- * Load the `dependencies:` map from informer.yaml. Returns `{}` if the file
177
- * is missing or the section is absent — both are valid (an app may have no
178
- * declared dependencies).
176
+ * Read and parse informer.yaml once. Returns `{}` when the file is missing or
177
+ * its top level is not a map, so callers can select blocks without guarding.
178
+ * Per-request callers (server routes, agent tools) load it fresh on every
179
+ * request so edits take effect without a dev-server restart, and select every
180
+ * block they need from this one parse via manifestBlock().
179
181
  *
180
182
  * @param {string} projectRoot
181
- * @returns {Promise<Object>} The raw dependencies object as authored
183
+ * @returns {Promise<Object>} The parsed manifest document
182
184
  */
183
- export async function loadDependencies(projectRoot) {
185
+ export async function loadManifest(projectRoot) {
184
186
  const yamlPath = join(projectRoot, 'informer.yaml');
185
187
  try {
186
188
  await access(yamlPath);
@@ -189,8 +191,42 @@ export async function loadDependencies(projectRoot) {
189
191
  }
190
192
  const content = await readFile(yamlPath, 'utf8');
191
193
  const parsed = parseYaml(content);
192
- if (!parsed || typeof parsed !== 'object') return {};
193
- return (parsed.dependencies && typeof parsed.dependencies === 'object') ? parsed.dependencies : {};
194
+ return (parsed && typeof parsed === 'object') ? parsed : {};
195
+ }
196
+
197
+ /**
198
+ * One top-level map block of a parsed manifest, or {} when absent or not a map.
199
+ *
200
+ * @param {Object} manifest - A document from loadManifest()
201
+ * @param {string} key - Top-level block name (`dependencies`, `channels`, `env`, ...)
202
+ * @returns {Object}
203
+ */
204
+ export function manifestBlock(manifest, key) {
205
+ const block = manifest && manifest[key];
206
+ return (block && typeof block === 'object' && !Array.isArray(block)) ? block : {};
207
+ }
208
+
209
+ /**
210
+ * Load the `dependencies:` map from informer.yaml. Returns `{}` if the file
211
+ * is missing or the section is absent — both are valid (an app may have no
212
+ * declared dependencies).
213
+ *
214
+ * @param {string} projectRoot
215
+ * @returns {Promise<Object>} The raw dependencies object as authored
216
+ */
217
+ export async function loadDependencies(projectRoot) {
218
+ return manifestBlock(await loadManifest(projectRoot), 'dependencies');
219
+ }
220
+
221
+ /**
222
+ * Read the `channels:` relay block from informer.yaml: channel name →
223
+ * { description?, on? }. Returns {} when there is no manifest or no block.
224
+ *
225
+ * @param {string} projectRoot
226
+ * @returns {Promise<Object>}
227
+ */
228
+ export async function loadChannels(projectRoot) {
229
+ return manifestBlock(await loadManifest(projectRoot), 'channels');
194
230
  }
195
231
 
196
232
  /**
@@ -203,16 +239,7 @@ export async function loadDependencies(projectRoot) {
203
239
  * @returns {Promise<Object>} The raw env object as authored
204
240
  */
205
241
  export async function loadAppEnv(projectRoot) {
206
- const yamlPath = join(projectRoot, 'informer.yaml');
207
- try {
208
- await access(yamlPath);
209
- } catch {
210
- return {};
211
- }
212
- const content = await readFile(yamlPath, 'utf8');
213
- const parsed = parseYaml(content);
214
- if (!parsed || typeof parsed !== 'object') return {};
215
- return (parsed.env && typeof parsed.env === 'object') ? parsed.env : {};
242
+ return manifestBlock(await loadManifest(projectRoot), 'env');
216
243
  }
217
244
 
218
245
  /**
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The `platform` descriptor the dev mirror hands to app code — the same shape
3
+ * the server injects on `window.__INFORMER__.platform` and on the server
4
+ * handler / tool bag: the Informer build version and the capability flags.
5
+ *
6
+ * Every capability the dev server mirrors is on. `embeddings` is off: the
7
+ * pump and query-time `embed()` need a real Informer, so an app that
8
+ * feature-detects on it sees locally exactly what it sees on an install
9
+ * without the feature. `version` is `'dev'` (not semver) so a floor check
10
+ * treats the dev mirror as "unknown" rather than as any particular release.
11
+ *
12
+ * Override any of it per project with `informer({ mock: { platform: {…} } })`.
13
+ */
14
+ export const DEV_CAPABILITIES = Object.freeze({
15
+ serverRoutes: true,
16
+ webhooks: true,
17
+ tools: true,
18
+ mcp: true,
19
+ agents: true,
20
+ automations: true,
21
+ messages: true,
22
+ storage: true,
23
+ snapshots: true,
24
+ customApis: true,
25
+ integrationDependencies: true,
26
+ datasourceDependencies: true,
27
+ aiCompletions: true,
28
+ // live channels: broadcast() in the sandbox, the `channels:` relay block,
29
+ // and channels/ join/leave handlers — the dev server mirrors all three.
30
+ channels: true,
31
+ embeddings: false
32
+ });
33
+
34
+ export function devPlatform(overrides = {}) {
35
+ const { capabilities = {}, ...rest } = overrides || {};
36
+ return {
37
+ version: 'dev',
38
+ ...rest,
39
+ capabilities: { ...DEV_CAPABILITIES, ...capabilities }
40
+ };
41
+ }