@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.
- package/README.md +129 -0
- package/bin/ci.js +52 -51
- package/bin/publish.js +11 -4
- package/package.json +1 -1
- package/src/agent-dev.js +26 -12
- package/src/assemble.js +5 -1
- package/src/compat.js +252 -0
- package/src/deploy.js +116 -43
- package/src/dev-channel-shim.js +150 -0
- package/src/dev-channels.js +191 -0
- package/src/dev-dependencies.js +44 -17
- package/src/dev-platform.js +41 -0
- package/src/dev-streams.js +660 -0
- package/src/fan-out.js +129 -0
- package/src/index.js +131 -16
- package/src/marketplaces.js +28 -2
- package/src/publish-payload.js +55 -7
- package/src/publish.js +25 -1
- package/src/server-routes.js +79 -21
- package/src/streams-client.js +200 -0
package/src/compat.js
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the CLI needs to know about the Informer it is deploying to.
|
|
3
|
+
*
|
|
4
|
+
* The server exposes no route that reports its capability flags —
|
|
5
|
+
* describePlatform() rides on the app page and on the handler bag, never on the
|
|
6
|
+
* API — so compatibility is decided from the build version that GET /api/about
|
|
7
|
+
* reports. That route is `open: true` and has been unchanged across every
|
|
8
|
+
* release this plugin supports, which makes it the one probe that cannot itself
|
|
9
|
+
* be a compatibility problem.
|
|
10
|
+
*
|
|
11
|
+
* Floors, not probes: each feature names the release that introduced it
|
|
12
|
+
* server-side. Comparison mirrors the server's own versionSatisfies()
|
|
13
|
+
* (app/lib/platform-descriptor.js) — hotfix and RC builds carry a prerelease tag
|
|
14
|
+
* (`2026.1.3-hot2026…`) that plain semver ranks BELOW its base, which would
|
|
15
|
+
* refuse a feature on the very release that ships it, so only the numeric base
|
|
16
|
+
* is ever compared.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** The release that introduced each app feature server-side. */
|
|
20
|
+
export const FEATURE_FLOORS = Object.freeze({
|
|
21
|
+
// App Channels: the `channels:` relay block, channels/ handlers, broadcast().
|
|
22
|
+
channels: '2026.1.3',
|
|
23
|
+
// The embedding pump: embeddings/ use cases and embed() on the handler bag.
|
|
24
|
+
embeddings: '2026.1.3',
|
|
25
|
+
// Staged uploads/downloads: the uploads/downloads services, /view/_uploads
|
|
26
|
+
// and /view/_downloads, and __INFORMER__.upload() in the page.
|
|
27
|
+
streams: '2026.1.3',
|
|
28
|
+
// The platform descriptor itself (window.__INFORMER__.platform and the
|
|
29
|
+
// handler bag's `platform`). Absent entirely below this floor, which is why
|
|
30
|
+
// feature detection has to be optional-chained.
|
|
31
|
+
platform: '2026.1.3',
|
|
32
|
+
// Server-side enforcement of the manifest's `requires:` block. Below this
|
|
33
|
+
// floor the CLI is the only thing in the loop that can honour a declared
|
|
34
|
+
// Informer floor, so it enforces it itself.
|
|
35
|
+
requiresGate: '2026.1.3'
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Source trees whose contents must never reach a browser, paired with the
|
|
40
|
+
* release whose FOLDER_GATES list first covered them (app/routes/deploy.js).
|
|
41
|
+
*
|
|
42
|
+
* A server below the floor does not recognise the directory as server-side, so
|
|
43
|
+
* isServerSidePath() returns false for it and view-assets.js serves the file to
|
|
44
|
+
* anyone who can open the app. Uploading these to such a server would publish
|
|
45
|
+
* the App's workspace SQL and channel authorization logic, so the deploy leaves
|
|
46
|
+
* them behind instead.
|
|
47
|
+
*
|
|
48
|
+
* server/, webhooks/, tools/, mcp/ and migrations/ were gated long before any
|
|
49
|
+
* release this plugin supports and so need no floor here.
|
|
50
|
+
*/
|
|
51
|
+
export const GATED_SOURCE_DIRS = Object.freeze([
|
|
52
|
+
Object.freeze({ dir: 'channels', feature: 'channels', holds: 'channel join/leave handlers' }),
|
|
53
|
+
Object.freeze({ dir: 'embeddings', feature: 'embeddings', holds: 'embedding pump handlers (and the workspace SQL in them)' })
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The leading numeric triple of a build version, as semver.coerce() would read
|
|
58
|
+
* it: `2026.1.3-hot20260904` -> `2026.1.3`. Returns null when the string is not
|
|
59
|
+
* a version at all (`dev`), which callers treat as "unknown".
|
|
60
|
+
*
|
|
61
|
+
* @param {string} version
|
|
62
|
+
* @returns {{ major: number, minor: number, patch: number, version: string }|null}
|
|
63
|
+
*/
|
|
64
|
+
export function baseVersion(version) {
|
|
65
|
+
const match = /^\s*v?(\d+)(?:\.(\d+))?(?:\.(\d+))?/.exec(String(version == null ? '' : version));
|
|
66
|
+
if (!match) return null;
|
|
67
|
+
const [major, minor, patch] = [match[1], match[2], match[3]].map(part => Number(part || 0));
|
|
68
|
+
return { major, minor, patch, version: `${major}.${minor}.${patch}` };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** -1, 0 or 1, comparing two parsed bases field by field. */
|
|
72
|
+
function compareBase(a, b) {
|
|
73
|
+
for (const field of ['major', 'minor', 'patch']) {
|
|
74
|
+
if (a[field] !== b[field]) return a[field] < b[field] ? -1 : 1;
|
|
75
|
+
}
|
|
76
|
+
return 0;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Is `version` at or above `floor`? Unknown versions answer `true` — a build
|
|
81
|
+
* whose version is not semver is a developer build, and gating those would
|
|
82
|
+
* break the very people who build Informer from source.
|
|
83
|
+
*
|
|
84
|
+
* @param {string} version - a build version, as /about reports it
|
|
85
|
+
* @param {string} floor - a plain `X.Y.Z`
|
|
86
|
+
* @returns {boolean}
|
|
87
|
+
*/
|
|
88
|
+
export function atLeast(version, floor) {
|
|
89
|
+
const actual = baseVersion(version);
|
|
90
|
+
if (!actual) return true;
|
|
91
|
+
return compareBase(actual, baseVersion(floor)) >= 0;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// The comparator subset a manifest floor realistically uses. Full semver range
|
|
95
|
+
// syntax would mean taking on the `semver` dependency, which this package
|
|
96
|
+
// deliberately does without; anything outside this subset is reported as
|
|
97
|
+
// unevaluated rather than guessed at.
|
|
98
|
+
const RANGE = /^\s*(>=|<=|>|<|\^|~|=)?\s*v?(\d+(?:\.\d+){0,2})\s*$/;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Evaluate a manifest `requires.informer` range against a build version, with
|
|
102
|
+
* the same coerced-base semantics the server uses.
|
|
103
|
+
*
|
|
104
|
+
* @param {string} version - the running build version
|
|
105
|
+
* @param {string} range - a range from informer.yaml
|
|
106
|
+
* @returns {{ ok: boolean, unknown: boolean, reason?: string }} `unknown` marks
|
|
107
|
+
* a verdict the caller must not act on: an unparseable range, or a build
|
|
108
|
+
* version that is not semver.
|
|
109
|
+
*/
|
|
110
|
+
export function satisfiesRange(version, range) {
|
|
111
|
+
const actual = baseVersion(version);
|
|
112
|
+
if (!actual) return { ok: true, unknown: true, reason: 'the server did not report a comparable version' };
|
|
113
|
+
|
|
114
|
+
const match = RANGE.exec(String(range == null ? '' : range));
|
|
115
|
+
if (!match) return { ok: true, unknown: true, reason: `"${range}" is not a range this CLI can evaluate` };
|
|
116
|
+
|
|
117
|
+
const [, operator = '=', literal] = match;
|
|
118
|
+
const wanted = baseVersion(literal);
|
|
119
|
+
const cmp = compareBase(actual, wanted);
|
|
120
|
+
|
|
121
|
+
switch (operator) {
|
|
122
|
+
case '>=': return { ok: cmp >= 0, unknown: false };
|
|
123
|
+
case '>': return { ok: cmp > 0, unknown: false };
|
|
124
|
+
case '<=': return { ok: cmp <= 0, unknown: false };
|
|
125
|
+
case '<': return { ok: cmp < 0, unknown: false };
|
|
126
|
+
// ^ allows the rest of the major, ~ the rest of the minor. Both still
|
|
127
|
+
// need the floor met, so a too-old server fails either way.
|
|
128
|
+
case '^': return { ok: cmp >= 0 && actual.major === wanted.major, unknown: false };
|
|
129
|
+
case '~': return { ok: cmp >= 0 && actual.major === wanted.major && actual.minor === wanted.minor, unknown: false };
|
|
130
|
+
default: return { ok: cmp === 0, unknown: false };
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Ask a server what it is. Never throws: a probe that fails leaves the version
|
|
136
|
+
* unknown, and every gate built on it fails open with a warning rather than
|
|
137
|
+
* turning an unreachable /about into a failed deploy.
|
|
138
|
+
*
|
|
139
|
+
* @param {{ get: (path: string) => Promise<unknown> }} api
|
|
140
|
+
* @returns {Promise<{ version: string|null, base: string|null, unknown: boolean, supports: (feature: string) => boolean }>}
|
|
141
|
+
*/
|
|
142
|
+
export async function describeServer(api) {
|
|
143
|
+
let about = null;
|
|
144
|
+
try {
|
|
145
|
+
about = await api.get('about');
|
|
146
|
+
} catch {
|
|
147
|
+
// A proxy, a WAF, or an auth strategy in front of /about — none of which
|
|
148
|
+
// say anything about the server's age.
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const version = (about && typeof about === 'object' && about.version) || null;
|
|
152
|
+
const base = baseVersion(version);
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
version,
|
|
156
|
+
base: base ? base.version : null,
|
|
157
|
+
unknown: !base,
|
|
158
|
+
supports: (feature) => atLeast(version, FEATURE_FLOORS[feature] || '0.0.0')
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The top-level library directory a collected file belongs to. */
|
|
163
|
+
function topSegment(rel) {
|
|
164
|
+
return String(rel || '').replace(/^\/+/, '').split('/')[0].toLowerCase();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Decide what a deploy to this server may upload and what the developer has to
|
|
169
|
+
* be told. Pure, so the whole compatibility matrix is testable without a server.
|
|
170
|
+
*
|
|
171
|
+
* @param {Object} opts
|
|
172
|
+
* @param {{ version: string|null, base: string|null, unknown: boolean, supports: Function }} opts.server
|
|
173
|
+
* @param {Array<{ abs: string, rel: string }>} opts.files - from collectAppFiles()
|
|
174
|
+
* @param {Object} opts.manifest - the parsed informer.yaml, or {}
|
|
175
|
+
* @returns {{ files: Array, warnings: string[], refusal: string|null }}
|
|
176
|
+
*/
|
|
177
|
+
export function planDeploy({ server, files, manifest }) {
|
|
178
|
+
const warnings = [];
|
|
179
|
+
const where = server.version ? `Informer ${server.version}` : 'this Informer';
|
|
180
|
+
|
|
181
|
+
if (server.unknown) {
|
|
182
|
+
warnings.push(
|
|
183
|
+
`Could not read a comparable version from ${server.version ? `"${server.version}"` : 'GET /api/about'}. `
|
|
184
|
+
+ 'Deploying everything on the assumption the server is current — if it is older than 2026.1.3, '
|
|
185
|
+
+ `${GATED_SOURCE_DIRS.map(d => `${d.dir}/`).join(' and ')} will be readable from the App's view URL.`
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// 1. Leave behind source trees this server would serve to browsers.
|
|
190
|
+
let kept = files;
|
|
191
|
+
for (const { dir, feature, holds } of GATED_SOURCE_DIRS) {
|
|
192
|
+
if (server.supports(feature)) continue;
|
|
193
|
+
const dropped = kept.filter(file => topSegment(file.rel) === dir);
|
|
194
|
+
if (dropped.length === 0) continue;
|
|
195
|
+
kept = kept.filter(file => topSegment(file.rel) !== dir);
|
|
196
|
+
warnings.push(
|
|
197
|
+
`Skipped ${dropped.length} file(s) in ${dir}/ — ${where} predates ${FEATURE_FLOORS[feature]} and does not treat `
|
|
198
|
+
+ `${dir}/ as server-side, so uploading your ${holds} would leave them readable from the App's view URL. `
|
|
199
|
+
+ `The App deploys without them; upgrade the server to ${FEATURE_FLOORS[feature]} to use this feature.`
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// 2. Name every feature the App uses that is simply inert here, so a
|
|
204
|
+
// successful deploy can't be mistaken for a working one.
|
|
205
|
+
const uploaded = new Set(kept.map(file => topSegment(file.rel)));
|
|
206
|
+
if (!server.supports('channels') && isMap(manifest.channels)) {
|
|
207
|
+
warnings.push(
|
|
208
|
+
`informer.yaml declares a channels: block, which ${where} ignores (App Channels needs ${FEATURE_FLOORS.channels}). `
|
|
209
|
+
+ 'Nothing is relayed and broadcast() does not reach the page.'
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
if (!server.supports('streams') && uploaded.has('server')) {
|
|
213
|
+
warnings.push(
|
|
214
|
+
`${where} predates ${FEATURE_FLOORS.streams}: if a handler destructures uploads/downloads from its bag, or the page `
|
|
215
|
+
+ 'calls __INFORMER__.upload() / downloadUrl(), those are undefined here and the staged stream routes answer 404.'
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
if (!server.supports('platform')) {
|
|
219
|
+
warnings.push(
|
|
220
|
+
`${where} injects no platform descriptor. Feature-detect with optional chaining `
|
|
221
|
+
+ '(`platform?.capabilities?.x`, `window.__INFORMER__.platform?.capabilities?.x`) — a bare '
|
|
222
|
+
+ '`platform.capabilities.x` throws here rather than reporting false.'
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return { files: kept, warnings, refusal: refuseOnRequires({ server, manifest, where }) };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** True for a YAML mapping (not a list, not a scalar). */
|
|
230
|
+
function isMap(value) {
|
|
231
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* The manifest's `requires.informer` floor, enforced by the CLI on servers that
|
|
236
|
+
* do not enforce it themselves. Below FEATURE_FLOORS.requiresGate the server
|
|
237
|
+
* ignores the block entirely, so without this an App that declared a floor
|
|
238
|
+
* deploys clean onto a server that cannot run it and fails at runtime instead.
|
|
239
|
+
*
|
|
240
|
+
* @returns {string|null} the refusal message, or null to proceed
|
|
241
|
+
*/
|
|
242
|
+
function refuseOnRequires({ server, manifest, where }) {
|
|
243
|
+
const range = isMap(manifest.requires) && manifest.requires.informer;
|
|
244
|
+
if (!range || server.supports('requiresGate')) return null;
|
|
245
|
+
|
|
246
|
+
const verdict = satisfiesRange(server.version, range);
|
|
247
|
+
if (verdict.unknown || verdict.ok) return null;
|
|
248
|
+
|
|
249
|
+
// No "Deploy refused:" lead-in — bin/deploy.js already prints "Deploy failed:".
|
|
250
|
+
return `this app requires Informer ${range}, but this server is ${server.version}. `
|
|
251
|
+
+ `Update Informer, or remove the requires.informer floor from informer.yaml if the app can run on ${where}.`;
|
|
252
|
+
}
|
package/src/deploy.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { createClient } from './client.js';
|
|
2
2
|
import { collectAppFiles } from './assemble.js';
|
|
3
|
+
import { describeServer, planDeploy } from './compat.js';
|
|
4
|
+
import { loadManifest } from './dev-dependencies.js';
|
|
3
5
|
import { readFile } from 'node:fs/promises';
|
|
4
6
|
import { basename, dirname } from 'node:path';
|
|
5
7
|
|
|
@@ -22,6 +24,20 @@ const CHUNK_THRESHOLD = 512 * 1024; // 512KB
|
|
|
22
24
|
*/
|
|
23
25
|
export async function deploy({ baseUrl, apiKey, user, pass, distDir, name, description, icon, id }) {
|
|
24
26
|
const api = createClient({ baseUrl, apiKey, user, pass });
|
|
27
|
+
const projectRoot = dirname(distDir);
|
|
28
|
+
|
|
29
|
+
// 0. Assemble and version-check before touching the app. Both steps below
|
|
30
|
+
// are destructive — the snapshot rotates and _clear empties the library —
|
|
31
|
+
// so a missing dist/ or a refused version floor has to surface here,
|
|
32
|
+
// while the deployed app is still whole.
|
|
33
|
+
const collected = await collectAppFiles({ distDir, projectRoot });
|
|
34
|
+
const server = await describeServer(api);
|
|
35
|
+
const plan = planDeploy({ server, files: collected, manifest: await loadManifest(projectRoot) });
|
|
36
|
+
|
|
37
|
+
for (const warning of plan.warnings) {
|
|
38
|
+
console.warn(` ! ${warning}`);
|
|
39
|
+
}
|
|
40
|
+
if (plan.refusal) throw new Error(plan.refusal);
|
|
25
41
|
|
|
26
42
|
let entity = null;
|
|
27
43
|
let naturalId = null;
|
|
@@ -104,12 +120,14 @@ export async function deploy({ baseUrl, apiKey, user, pass, distDir, name, descr
|
|
|
104
120
|
console.log('Clearing existing files...');
|
|
105
121
|
await api.post(`${entityPath}/files/_clear`);
|
|
106
122
|
|
|
107
|
-
// 6. Upload the app-library file set: dist output at the
|
|
108
|
-
// informer.yaml / data-access.yaml and the
|
|
109
|
-
// source trees
|
|
110
|
-
//
|
|
123
|
+
// 6. Upload the app-library file set assembled in step 0: dist output at the
|
|
124
|
+
// library root, plus informer.yaml / data-access.yaml and the
|
|
125
|
+
// server/tools/mcp/migrations/webhooks/embeddings source trees, less any
|
|
126
|
+
// tree this server is too old to keep out of browsers (see compat.js).
|
|
127
|
+
// Sourced from the shared collectAppFiles() so a deploy and a marketplace
|
|
128
|
+
// publish package byte-identical contents.
|
|
111
129
|
console.log('Uploading files...');
|
|
112
|
-
const files =
|
|
130
|
+
const files = plan.files;
|
|
113
131
|
|
|
114
132
|
for (const { abs, rel } of files) {
|
|
115
133
|
const content = await readFile(abs);
|
|
@@ -136,45 +154,9 @@ export async function deploy({ baseUrl, apiKey, user, pass, distDir, name, descr
|
|
|
136
154
|
}
|
|
137
155
|
}
|
|
138
156
|
|
|
139
|
-
// 12. Deploy: run migrations + scan/bundle server routes + webhooks + tools + agents
|
|
157
|
+
// 12. Deploy: run migrations + scan/bundle server routes + webhooks + embeddings + tools + agents
|
|
140
158
|
if (apiPrefix === 'apps') {
|
|
141
|
-
|
|
142
|
-
console.log('Deploying...');
|
|
143
|
-
const result = await api.post(`${entityPath}/_deploy`);
|
|
144
|
-
if (result) {
|
|
145
|
-
if (result.migrated && result.migrated.length > 0) {
|
|
146
|
-
console.log(` Ran ${result.migrated.length} migration(s): ${result.migrated.join(', ')}`);
|
|
147
|
-
}
|
|
148
|
-
if (result.routes && result.routes.length > 0) {
|
|
149
|
-
console.log(` Registered ${result.routes.length} server route(s):`);
|
|
150
|
-
for (const r of result.routes) {
|
|
151
|
-
console.log(` ${r}`);
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
if (result.webhooks && result.webhooks.length > 0) {
|
|
155
|
-
console.log(` Registered ${result.webhooks.length} webhook(s):`);
|
|
156
|
-
for (const r of result.webhooks) {
|
|
157
|
-
console.log(` ${r}`);
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
if (result.tools && result.tools.length > 0) {
|
|
161
|
-
console.log(` Registered ${result.tools.length} tool(s): ${result.tools.join(', ')}`);
|
|
162
|
-
}
|
|
163
|
-
if (result.mcpTools && result.mcpTools.length > 0) {
|
|
164
|
-
console.log(` Registered ${result.mcpTools.length} MCP tool(s): ${result.mcpTools.join(', ')}`);
|
|
165
|
-
}
|
|
166
|
-
if (result.agents && result.agents.length > 0) {
|
|
167
|
-
console.log(` Deployed ${result.agents.length} agent(s): ${result.agents.join(', ')}`);
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
} catch (err) {
|
|
171
|
-
if (err.status === 404) {
|
|
172
|
-
// Server may not support _deploy yet — ignore
|
|
173
|
-
} else {
|
|
174
|
-
const detail = err.body || err.message;
|
|
175
|
-
console.error(` Deploy failed: ${detail}`);
|
|
176
|
-
}
|
|
177
|
-
}
|
|
159
|
+
await runServerDeploy(api, entityPath);
|
|
178
160
|
}
|
|
179
161
|
|
|
180
162
|
// 13. Print URL and return UUID for saving
|
|
@@ -188,6 +170,97 @@ export async function deploy({ baseUrl, apiKey, user, pass, distDir, name, descr
|
|
|
188
170
|
return { id: entity.id, url: entityUrl };
|
|
189
171
|
}
|
|
190
172
|
|
|
173
|
+
/**
|
|
174
|
+
* POST `_deploy`: run migrations, scan/bundle server routes, webhooks, tools and
|
|
175
|
+
* agents, and claim the app as platform-managed.
|
|
176
|
+
*
|
|
177
|
+
* Exported for its own tests. This is the step whose failure modes are silent —
|
|
178
|
+
* everything it does is invisible in the CLI output when it doesn't happen.
|
|
179
|
+
*
|
|
180
|
+
* @param {{post: (path: string, body?: unknown) => Promise<unknown>}} api
|
|
181
|
+
* @param {string} entityPath e.g. `apps/my-app`
|
|
182
|
+
*/
|
|
183
|
+
export async function runServerDeploy(api, entityPath) {
|
|
184
|
+
try {
|
|
185
|
+
console.log('Deploying...');
|
|
186
|
+
// `managed: true` claims the app as platform-managed (origin
|
|
187
|
+
// 'deployed'), which locks builder editing so a UI edit can't drift
|
|
188
|
+
// from this project. Opt-in: the server leaves an app editable when the
|
|
189
|
+
// flag is absent, so the GO admin panel's Redeploy and the builder's
|
|
190
|
+
// Save can share this route without converting user-built apps. See
|
|
191
|
+
// app/routes/deploy.js.
|
|
192
|
+
const result = await api.post(`${entityPath}/_deploy`, { managed: true });
|
|
193
|
+
if (result === null) {
|
|
194
|
+
// api.post turns a 404 into null instead of throwing, so this is the
|
|
195
|
+
// only place it can be caught. Files uploaded, but migrations, server
|
|
196
|
+
// routes, webhooks and the managed claim did NOT run — and a missing
|
|
197
|
+
// route is far more often a wrong app id or a proxy path rewrite than
|
|
198
|
+
// a server too old to have _deploy. Falling through here prints
|
|
199
|
+
// "Published ..." and exits 0 on a half-finished deploy.
|
|
200
|
+
throw new Error(
|
|
201
|
+
`Deploy endpoint not found at ${entityPath}/_deploy. The app may not exist on this server, `
|
|
202
|
+
+ `the path may be rewritten by a proxy, or the server may predate _deploy. `
|
|
203
|
+
+ `Files were uploaded, but migrations, server routes and the managed claim did not run.`
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
if (result.migrated && result.migrated.length > 0) {
|
|
207
|
+
console.log(` Ran ${result.migrated.length} migration(s): ${result.migrated.join(', ')}`);
|
|
208
|
+
}
|
|
209
|
+
if (result.routes && result.routes.length > 0) {
|
|
210
|
+
console.log(` Registered ${result.routes.length} server route(s):`);
|
|
211
|
+
for (const r of result.routes) {
|
|
212
|
+
console.log(` ${r}`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (result.webhooks && result.webhooks.length > 0) {
|
|
216
|
+
console.log(` Registered ${result.webhooks.length} webhook(s):`);
|
|
217
|
+
for (const r of result.webhooks) {
|
|
218
|
+
console.log(` ${r}`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (result.channelHandlers && result.channelHandlers.length > 0) {
|
|
222
|
+
console.log(` Registered ${result.channelHandlers.length} channel handler(s):`);
|
|
223
|
+
for (const c of result.channelHandlers) {
|
|
224
|
+
console.log(` ${c}`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
if (result.channels && result.channels.length > 0) {
|
|
228
|
+
console.log(` Relaying events to ${result.channels.length} channel(s): ${result.channels.join(', ')}`);
|
|
229
|
+
}
|
|
230
|
+
if (result.tools && result.tools.length > 0) {
|
|
231
|
+
console.log(` Registered ${result.tools.length} tool(s): ${result.tools.join(', ')}`);
|
|
232
|
+
}
|
|
233
|
+
if (result.mcpTools && result.mcpTools.length > 0) {
|
|
234
|
+
console.log(` Registered ${result.mcpTools.length} MCP tool(s): ${result.mcpTools.join(', ')}`);
|
|
235
|
+
}
|
|
236
|
+
if (result.agents && result.agents.length > 0) {
|
|
237
|
+
console.log(` Deployed ${result.agents.length} agent(s): ${result.agents.join(', ')}`);
|
|
238
|
+
}
|
|
239
|
+
// Advisory findings the server accumulated. The deploy still
|
|
240
|
+
// succeeded — but silently dropping these leaves authors debugging
|
|
241
|
+
// mystery responses the server already diagnosed for them.
|
|
242
|
+
if (result.routeShadowWarnings && result.routeShadowWarnings.length > 0) {
|
|
243
|
+
console.warn(` ⚠ ${result.routeShadowWarnings.length} route shadow warning(s):`);
|
|
244
|
+
for (const w of result.routeShadowWarnings) {
|
|
245
|
+
console.warn(` ${w}`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (result.partialFailures && result.partialFailures.length > 0) {
|
|
249
|
+
console.warn(` ⚠ ${result.partialFailures.length} non-fatal deploy phase failure(s):`);
|
|
250
|
+
for (const f of result.partialFailures) {
|
|
251
|
+
console.warn(` [${f.phase}] ${f.error}`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return result;
|
|
255
|
+
} catch (err) {
|
|
256
|
+
// Rethrow: a failed deploy is a failed publish. Logging and falling
|
|
257
|
+
// through prints "Published ... files" and exits 0, so CI goes green on
|
|
258
|
+
// an app whose migrations never ran. bin/deploy.js turns this into exit 1.
|
|
259
|
+
const detail = err.body || err.message;
|
|
260
|
+
throw new Error(`Deploy failed: ${detail}`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
191
264
|
function formatSize(bytes) {
|
|
192
265
|
if (bytes < 1024) return `${bytes} B`;
|
|
193
266
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { CHANNEL_NAME, CHANNEL_NAME_MAX_LENGTH, DEV_CHANNEL_EVENT } from './dev-channels.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The dev `__INFORMER__.channel(name)` mock: the App Channels client surface
|
|
5
|
+
* (App API v2 §1.9) with the same synchronous name check, `on(event, fn)` →
|
|
6
|
+
* `fn(payload, frame)` dispatch by `frame.event`, unsubscribe fn, `error`
|
|
7
|
+
* codes (`join_refused` | `disconnected` | `not_supported`; `rate_limited`
|
|
8
|
+
* never happens locally), phase-1 `send()` rejection and idempotent
|
|
9
|
+
* `close()` as the origin-mode shim in html-utils.js generateChannelShim.
|
|
10
|
+
*
|
|
11
|
+
* Transport: production subscribes each channel over a nes socket minted
|
|
12
|
+
* from `/_socket`. Dev has no such socket, and opening one would mean a
|
|
13
|
+
* second websocket, a `ws: true` proxy and a credential the dev server
|
|
14
|
+
* cannot mint. Frames instead ride Vite's own dev websocket as the custom
|
|
15
|
+
* event DEV_CHANNEL_EVENT (`server.ws.send({ type: 'custom', ... })` on the
|
|
16
|
+
* node side, `import.meta.hot.on(...)` here) and are filtered by channel
|
|
17
|
+
* name on the page. Vite owns reconnection; a drop surfaces to every open
|
|
18
|
+
* channel as `disconnected`, the way a lost nes socket does.
|
|
19
|
+
*
|
|
20
|
+
* Returned as source (an `installDevChannels(hot, informer)` function
|
|
21
|
+
* declaration) so it can be evaluated in a test `vm` against a fake hot
|
|
22
|
+
* context, the same way the server spec drives the production shim.
|
|
23
|
+
*/
|
|
24
|
+
export function generateDevChannelShim() {
|
|
25
|
+
return `
|
|
26
|
+
// --- App Channels (dev): frames arrive on Vite's dev websocket ---
|
|
27
|
+
function installDevChannels(hot, informer, unavailableReason) {
|
|
28
|
+
var CHANNEL_NAME = /${CHANNEL_NAME.source}/;
|
|
29
|
+
var CHANNEL_NAME_MAX_LENGTH = ${CHANNEL_NAME_MAX_LENGTH};
|
|
30
|
+
var FRAME_EVENT = '${DEV_CHANNEL_EVENT}';
|
|
31
|
+
var open = []; // channels holding at least one handler
|
|
32
|
+
|
|
33
|
+
function channelError(code, message) {
|
|
34
|
+
var e = new Error(message);
|
|
35
|
+
e.code = code;
|
|
36
|
+
return e;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function deliver(frame) {
|
|
40
|
+
if (!frame || typeof frame.channel !== 'string') return;
|
|
41
|
+
open.slice().forEach(function (ch) {
|
|
42
|
+
if (ch.name === frame.channel) ch._deliver(frame);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function dropAll() {
|
|
47
|
+
open.slice().forEach(function (ch) {
|
|
48
|
+
ch._emitError(channelError('disconnected', 'The dev server connection was lost'));
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (hot) {
|
|
53
|
+
hot.on(FRAME_EVENT, deliver);
|
|
54
|
+
hot.on('vite:ws:disconnect', dropAll);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function Channel(name) {
|
|
58
|
+
this.name = name;
|
|
59
|
+
this._handlers = {};
|
|
60
|
+
this._closed = false;
|
|
61
|
+
this._unavailable = false;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
Channel.prototype._deliver = function (frame) {
|
|
65
|
+
if (this._closed) return;
|
|
66
|
+
var list = this._handlers[frame.event];
|
|
67
|
+
if (!list) return;
|
|
68
|
+
list.slice().forEach(function (fn) {
|
|
69
|
+
try { fn(frame.payload, frame); }
|
|
70
|
+
catch (e) { console.error('[Informer] channel handler failed:', e); }
|
|
71
|
+
});
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
Channel.prototype._emitError = function (err) {
|
|
75
|
+
if (this._closed) return;
|
|
76
|
+
var list = this._handlers.error;
|
|
77
|
+
if (!list || !list.length) {
|
|
78
|
+
console.warn('[Informer] channel "' + this.name + '" ' + err.code + ': ' + err.message);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
list.slice().forEach(function (fn) {
|
|
82
|
+
try { fn(err); }
|
|
83
|
+
catch (e) { console.error('[Informer] channel error handler failed:', e); }
|
|
84
|
+
});
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
Channel.prototype.on = function (event, fn) {
|
|
88
|
+
if (this._closed) throw channelError('disconnected', 'The channel is closed');
|
|
89
|
+
if (typeof event !== 'string' || !event) throw new TypeError('channel.on: event name required');
|
|
90
|
+
if (typeof fn !== 'function') throw new TypeError('channel.on: handler must be a function');
|
|
91
|
+
var list = this._handlers[event] || (this._handlers[event] = []);
|
|
92
|
+
list.push(fn);
|
|
93
|
+
if (open.indexOf(this) === -1) open.push(this);
|
|
94
|
+
if (!hot && !this._unavailable) {
|
|
95
|
+
// Not served by the Vite dev server: no frames can ever arrive.
|
|
96
|
+
// Reported once, asynchronously, like a failed connection.
|
|
97
|
+
this._unavailable = true;
|
|
98
|
+
var self = this;
|
|
99
|
+
Promise.resolve().then(function () {
|
|
100
|
+
self._emitError(channelError('disconnected', unavailableReason || 'The Vite dev websocket is not available'));
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
return function () {
|
|
104
|
+
var i = list.indexOf(fn);
|
|
105
|
+
if (i !== -1) list.splice(i, 1);
|
|
106
|
+
};
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
Channel.prototype.send = function () {
|
|
110
|
+
return Promise.reject(channelError('not_supported', 'Sending on a channel is not supported yet'));
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
Channel.prototype.close = function () {
|
|
114
|
+
if (this._closed) return;
|
|
115
|
+
this._closed = true;
|
|
116
|
+
var i = open.indexOf(this);
|
|
117
|
+
if (i !== -1) open.splice(i, 1);
|
|
118
|
+
this._handlers = {};
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
informer.channel = function (name) {
|
|
122
|
+
if (typeof name !== 'string' || name.length > CHANNEL_NAME_MAX_LENGTH || !CHANNEL_NAME.test(name)) {
|
|
123
|
+
throw channelError('join_refused', 'Invalid channel name: ' + name);
|
|
124
|
+
}
|
|
125
|
+
return new Channel(name);
|
|
126
|
+
};
|
|
127
|
+
}`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The `<script type="module">` tag that installs the mock on the dev page.
|
|
132
|
+
* Vite turns an inline module script into an `?html-proxy` module, which is
|
|
133
|
+
* what gives it a live `import.meta.hot`. Module scripts run in document
|
|
134
|
+
* order after parsing, so placed at the top of <head> this installs
|
|
135
|
+
* `channel()` before the app's own module executes.
|
|
136
|
+
*/
|
|
137
|
+
export function renderDevChannelScript({ hub = true } = {}) {
|
|
138
|
+
// Without INFORMER_URL, configureServer returns before building the channel
|
|
139
|
+
// hub, so nothing can ever push a frame — but import.meta.hot is still live,
|
|
140
|
+
// which would leave the shim silent and the author staring at a channel that
|
|
141
|
+
// simply never delivers. Passing no `hot` routes it through the same
|
|
142
|
+
// report-once diagnostic, with the reason that actually applies.
|
|
143
|
+
const install = hub
|
|
144
|
+
? 'installDevChannels(import.meta.hot, window.__INFORMER__);'
|
|
145
|
+
: `installDevChannels(null, window.__INFORMER__, 'Dev channels need INFORMER_URL set for the dev server to relay frames');`;
|
|
146
|
+
return `<script type="module">
|
|
147
|
+
${generateDevChannelShim()}
|
|
148
|
+
${install}
|
|
149
|
+
</script>`;
|
|
150
|
+
}
|