@entrinsik/vite-plugin-informer 2.7.0 → 2.11.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 +131 -0
- package/bin/ci.js +0 -0
- package/bin/workspace.js +15 -7
- 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-bag.js +185 -0
- package/src/dev-channel-handlers.js +325 -0
- package/src/dev-channel-shim.js +361 -0
- package/src/dev-channels.js +283 -0
- package/src/dev-dependencies.js +44 -17
- package/src/dev-platform.js +44 -0
- package/src/dev-streams.js +660 -0
- package/src/env.js +17 -1
- package/src/index.js +156 -16
- package/src/server-routes.js +121 -196
- package/src/streams-client.js +200 -0
package/README.md
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# @entrinsik/vite-plugin-informer
|
|
2
|
+
|
|
3
|
+
Vite plugin and deploy tool for Informer App development: a local dev server wired to a live Informer, and a deploy pipeline that assembles your project and ships it to an App's library.
|
|
4
|
+
|
|
5
|
+
## Quick start
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm create vite@latest my-app -- --template react
|
|
9
|
+
cd my-app
|
|
10
|
+
npm install -D @entrinsik/vite-plugin-informer
|
|
11
|
+
npx informer-init # wires vite config, informer.yaml, .env, deploy scripts
|
|
12
|
+
npm run dev # local dev against the Informer in .env
|
|
13
|
+
npm run deploy # assemble + upload + deploy
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## What a deploy ships
|
|
17
|
+
|
|
18
|
+
The assembler uploads your built frontend plus these source trees, matching how an App's library is structured in Informer:
|
|
19
|
+
|
|
20
|
+
| Folder | Becomes |
|
|
21
|
+
|---|---|
|
|
22
|
+
| `server/` | API routes, dispatched under the App's `/api` surface |
|
|
23
|
+
| `webhooks/` | Publicly callable webhook routes |
|
|
24
|
+
| `tools/` | AI tools, private to the App's agents |
|
|
25
|
+
| `mcp/` | Tools exposed over the App's MCP endpoint |
|
|
26
|
+
| `channels/` | Live channel handlers: `join` / `joined` / `leave` and event-named handlers for `channel.send()`; `npm run dev` runs them locally, and the `channels:` block in `informer.yaml` declares relays only (each entry needs `on`) |
|
|
27
|
+
| `migrations/` | Workspace database migrations, run in order on deploy |
|
|
28
|
+
| `embeddings/` | Declarative embedding use cases (see below) |
|
|
29
|
+
| `lib/`, `shared/` | Modules importable by the trees above |
|
|
30
|
+
|
|
31
|
+
`informer.yaml` declares dependencies, raw API access, roles, and (optionally) a minimum Informer version.
|
|
32
|
+
|
|
33
|
+
## Server compatibility
|
|
34
|
+
|
|
35
|
+
A deploy begins by asking the target server what it is (`GET /api/about`, one unauthenticated request) and adapts to the answer, so one version of this plugin can drive a fleet on mixed releases.
|
|
36
|
+
|
|
37
|
+
Everything below needs **Informer 2026.1.3**: App Channels (`channels/`, the `channels:` block, `broadcast()`), the embedding pump (`embeddings/`, `embed()`), staged uploads/downloads (`uploads`/`downloads` on the handler bag, `__INFORMER__.upload()`), and the `platform` descriptor itself.
|
|
38
|
+
|
|
39
|
+
Against an older server the CLI:
|
|
40
|
+
|
|
41
|
+
- **Leaves `channels/` and `embeddings/` on your disk.** Those releases don't recognise the folders as server-side, so anything uploaded there is served to any App viewer — your workspace SQL and channel authorization logic included. The rest of the App deploys normally; the warning names what was skipped and why.
|
|
42
|
+
- **Names every feature that would be inert**, so a green deploy can't be mistaken for a working one.
|
|
43
|
+
- **Enforces `requires: { informer: … }` itself.** Releases before 2026.1.3 ignore the block, so without this an App that declared a floor would deploy clean onto a server that can't run it. The deploy is refused before the App's library is touched.
|
|
44
|
+
|
|
45
|
+
A server whose version isn't comparable (a `dev` build, or an `/about` behind a proxy) is treated as current and warned about — gating it would break deploys to builds made from source.
|
|
46
|
+
|
|
47
|
+
App Channels phase 2 (plugin 2.11.0: `channel.send()`, the `joined` export and event-named exports in `channels/` files, wildcard subscriptions, frame `seq` and replay, `connected`) needs a 2026.1.3 server that carries I5-13027. The version probe cannot tell such a server from an earlier 2026.1.3 build, so the deploy is not gated: an earlier build refuses a `channels/` file that exports `joined` or an event name, and accepts a `channels:` entry without `on` that a current server rejects. The dev server runs the phase 2 contract regardless.
|
|
48
|
+
|
|
49
|
+
Feature-detect at runtime with optional chaining, since `platform` is absent entirely before 2026.1.3:
|
|
50
|
+
|
|
51
|
+
```javascript
|
|
52
|
+
if (platform?.capabilities?.channels) broadcast('tickets', { … });
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
A marketplace publish is unaffected: the archive always carries every source tree, because the server that installs it isn't known at publish time.
|
|
56
|
+
|
|
57
|
+
## Embeddings
|
|
58
|
+
|
|
59
|
+
Apps can maintain vector embeddings over their own workspace data declaratively. You ship an `embeddings/` folder with **one file per use case**; the Informer platform acts as an *embedding pump* that asks your App what is pending, chunks and embeds the content in billed batches, and hands the vectors back for your App to store in its own workspace tables. You never call an embedding provider yourself, and the platform never keeps a copy of your corpus — your own tables are the ledger.
|
|
60
|
+
|
|
61
|
+
Each use case file exports `GET` and `POST` (a missing handler fails the deploy) and, optionally, a `config` literal; omitted keys take the defaults below, and a use case with no triggers in its config only runs on deploy and on a manual run:
|
|
62
|
+
|
|
63
|
+
```javascript
|
|
64
|
+
// embeddings/tickets.js
|
|
65
|
+
export const config = { chunking: 'prose', on: ['ticket.created'], cron: '0 * * * *', revision: 1 };
|
|
66
|
+
|
|
67
|
+
// GET: the rows that still need embedding. The query IS the watermark:
|
|
68
|
+
// never embedded, stale, or written under an older revision.
|
|
69
|
+
export async function GET({ query, batch }) {
|
|
70
|
+
return await query(`
|
|
71
|
+
SELECT t.id, t.subject || ' ' || t.body AS content
|
|
72
|
+
FROM tickets t
|
|
73
|
+
LEFT JOIN ticket_embeddings e ON e.ticket_id = t.id AND e.seq = 0
|
|
74
|
+
WHERE e.ticket_id IS NULL OR e.embedded_at < t.updated_at OR e.revision <> $2
|
|
75
|
+
ORDER BY t.id
|
|
76
|
+
LIMIT $1
|
|
77
|
+
`, [batch.limit, batch.revision]);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// POST: store the finished vectors wherever your migrations put them.
|
|
81
|
+
export async function POST({ query, batch }) {
|
|
82
|
+
for (const doc of batch.docs) {
|
|
83
|
+
await query('DELETE FROM ticket_embeddings WHERE ticket_id = $1', [doc.id]);
|
|
84
|
+
for (const c of doc.chunks) {
|
|
85
|
+
await query(
|
|
86
|
+
'INSERT INTO ticket_embeddings (ticket_id, seq, embedding, content, revision) VALUES ($1, $2, $3::vector, $4, $5)',
|
|
87
|
+
[doc.id, c.seq, JSON.stringify(c.embedding), c.content, batch.revision]
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return { stored: batch.docs.length };
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
How it works, briefly:
|
|
96
|
+
|
|
97
|
+
- **Your storage is the ledger.** Because `GET` anti-joins your own embedding table, the pump is idempotent and crash-tolerant: a run that dies mid-way finds the same rows pending next time.
|
|
98
|
+
- **Triggers converge.** Deploy pokes each use case (initial backfill), `emit()` events matching `on:` poke, `cron` desugars into a scheduled poke, and the platform sweep runs whatever is poked. A manual run claims the same single-flight lease directly, so nothing double-embeds.
|
|
99
|
+
- **Failures are bounded.** A run that fails for a reason the next attempt might not hit (a provider blip, a handler timeout) retries with a doubling delay and is parked after five consecutive failures; a run that cannot succeed as-is (a rejected key, an exhausted budget, a contract violation) is parked at once. Every park keeps the error on the use case's status row; the next trigger gives it one more attempt, and a successful run resets the count.
|
|
100
|
+
- **`revision` is the invalidation.** The string the pump hands you couples your config (chunking profile, budgets, `config.revision`) to the resolved platform embedding model. Store it beside each vector and compare it in `GET`: bumping the config, changing chunking, or an admin repointing the embedding model all surface as pending re-embed work automatically.
|
|
101
|
+
- **Unembeddable documents are tombstoned** platform-side by content hash and re-reported with `skipped: true` on every run — stamp them in your own table and exclude them from `GET`.
|
|
102
|
+
- **Workspace pgvector.** Apps with the embeddings capability get the `vector` extension provisioned where the server may create extensions (otherwise the deploy log names the cause and a migration using the `vector` type fails), so migrations can declare real `vector(...)` columns; `ON DELETE CASCADE` foreign keys are the cleanup story.
|
|
103
|
+
- **Query time.** Every handler bag (`server/`, `webhooks/`, `tools/`, `mcp/`) gets `embed(name, text)`, which returns `{ embedding, revision }`. `name` must be a declared use case. Filter on the revision as well as ordering by distance — an admin repointing the embedding model puts your query vectors in a new space while the stored rows are still in the old one, and an unfiltered `ORDER BY` then returns near-random neighbours with no error:
|
|
104
|
+
|
|
105
|
+
```javascript
|
|
106
|
+
const { embedding, revision } = await embed('tickets', q);
|
|
107
|
+
const hits = await query(
|
|
108
|
+
`SELECT ticket_id, content
|
|
109
|
+
FROM ticket_embeddings
|
|
110
|
+
WHERE revision = $2
|
|
111
|
+
ORDER BY embedding <=> $1::vector
|
|
112
|
+
LIMIT 10`,
|
|
113
|
+
[JSON.stringify(embedding), revision]
|
|
114
|
+
);
|
|
115
|
+
```
|
|
116
|
+
- **Billing.** Embedding calls bill to the App, one usage entry per batch of up to 128 chunks regardless of how many provider requests run underneath; pump compute meters against the App's compute budget.
|
|
117
|
+
- **`config` is read as a literal.** Deploy lifts `export const config = { … }` out of the source and evaluates it with none of the file's imports in scope, so it must be self-contained: no imported constants, no spreads of imported objects, no `export { config }` re-export. Other modules may import the literal from the use-case file; the reverse fails the deploy. The same self-contained-literal rule applies to `export const config`, `schema`, and `description` in `server/`, `webhooks/`, `tools/`, and `mcp/`; there a `config` violation fails the deploy while a `schema` or `description` violation is a deploy warning (nothing is published for that export).
|
|
118
|
+
|
|
119
|
+
### Dev loop
|
|
120
|
+
|
|
121
|
+
`npm run deploy` uploads `embeddings/` like any other source tree — there is no manifest block to add (plugin ≥ 2.10.0; earlier versions never upload the folder, so the use case silently does not exist on the server). `npm run dev` never runs the pump, and its `embed()` throws an explanation rather than embedding (the dev mirror reports `platform.capabilities.embeddings: false`), so a search route is exercised against a deployed App. Feature-detect with `platform?.capabilities?.embeddings` — `typeof embed === 'function'` is true on both, since the binding exists either way. Then, in the App admin panel, the **Embeddings** tab lists each use case with its pump status (queued, running, up to date, last run, last error, skipped docs) and a **Run now** action; the same surface exists as API routes (`GET /apps/{id}/embeddings`, `POST /apps/{id}/embeddings/{name}/_run`). Deploy, run, read the error, fix, repeat.
|
|
122
|
+
|
|
123
|
+
### Upgrading an App that already has an `embeddings/` folder
|
|
124
|
+
|
|
125
|
+
`embeddings/` is a server-side folder from this release on, like `server/`: its files are uploaded with the library, never served to browsers, and scanned as pump handlers. An App that kept anything else there (assets, data files) loses those files in the browser after redeploying with plugin 2.10.0 or later. A `.js` file there that exports `GET` or `POST` but not both fails the deploy; one that exports neither is never scanned as a use case at all and lands as a warning on an otherwise successful deploy. Move such content elsewhere before upgrading; the deploy names every stray entry as a warning.
|
|
126
|
+
|
|
127
|
+
### Older Informer releases
|
|
128
|
+
|
|
129
|
+
The `embeddings/` folder, `embed()`, and the pump routes exist only on Informer releases that ship the feature. An App that must also run on older servers feature-detects with optional chaining (`platform?.capabilities?.embeddings` on the handler bag, `window.__INFORMER__.platform?.capabilities?.embeddings` in the browser) — releases before 2026.1.3 inject no `platform` descriptor at all, so a bare `platform.capabilities.embeddings` throws there instead of reporting `false`. It gates its vector DDL with a `-- requires: embeddings` header so the migration is skipped where pgvector is absent, and states a floor in `informer.yaml` (`requires: { informer: '>=…' }`) only if it cannot work without the feature.
|
|
130
|
+
|
|
131
|
+
Full reference: the **Embeddings** article under Developer → API → App in the Informer docs.
|
package/bin/ci.js
CHANGED
|
File without changes
|
package/bin/workspace.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import { readFile } from 'node:fs/promises';
|
|
4
|
-
import { resolve } from 'node:path';
|
|
4
|
+
import { resolve, basename } from 'node:path';
|
|
5
5
|
import { createClient } from '../src/client.js';
|
|
6
|
-
import { loadEnv, envWritePath, parseModeArg } from '../src/env.js';
|
|
6
|
+
import { loadEnv, envWritePath, localEnvValue, parseModeArg } from '../src/env.js';
|
|
7
7
|
import { init, migrate, reset } from '../src/workspace.js';
|
|
8
8
|
|
|
9
9
|
const mode = parseModeArg(process.argv);
|
|
@@ -41,12 +41,22 @@ const api = createClient({ baseUrl, apiKey, user, pass });
|
|
|
41
41
|
const migrationsDir = resolve('migrations');
|
|
42
42
|
const envPath = envWritePath({ mode });
|
|
43
43
|
|
|
44
|
+
// loadEnv walks up to a parent .env (monorepo config), so process.env may
|
|
45
|
+
// carry ANOTHER app's workspace id. Only this app's own env file decides
|
|
46
|
+
// whether it is initialized; migrate/reset fall back to the inherited value.
|
|
47
|
+
const localWorkspaceId = localEnvValue('INFORMER_DEV_WORKSPACE', { mode });
|
|
48
|
+
const inheritedWorkspaceId = process.env.INFORMER_DEV_WORKSPACE || null;
|
|
49
|
+
if (!localWorkspaceId && inheritedWorkspaceId && command === 'init') {
|
|
50
|
+
console.log(`Ignoring INFORMER_DEV_WORKSPACE=${inheritedWorkspaceId} inherited from the environment: ${basename(envPath)} does not define it.`);
|
|
51
|
+
}
|
|
52
|
+
const workspaceId = localWorkspaceId || inheritedWorkspaceId;
|
|
53
|
+
|
|
44
54
|
try {
|
|
45
55
|
if (command === 'init') {
|
|
46
56
|
// Check if already initialized
|
|
47
|
-
if (
|
|
48
|
-
console.error(`Workspace already initialized: ${
|
|
49
|
-
console.error(
|
|
57
|
+
if (localWorkspaceId) {
|
|
58
|
+
console.error(`Workspace already initialized: ${localWorkspaceId}`);
|
|
59
|
+
console.error(`Run workspace:reset to start fresh, or remove INFORMER_DEV_WORKSPACE from ${basename(envPath)} to re-initialize.`);
|
|
50
60
|
process.exit(1);
|
|
51
61
|
}
|
|
52
62
|
|
|
@@ -72,7 +82,6 @@ try {
|
|
|
72
82
|
await init({ api, slug, migrationsDir, envPath });
|
|
73
83
|
|
|
74
84
|
} else if (command === 'migrate') {
|
|
75
|
-
const workspaceId = process.env.INFORMER_DEV_WORKSPACE;
|
|
76
85
|
if (!workspaceId) {
|
|
77
86
|
console.error('No dev workspace found. Run workspace:init first.');
|
|
78
87
|
process.exit(1);
|
|
@@ -80,7 +89,6 @@ try {
|
|
|
80
89
|
await migrate({ api, workspaceId, migrationsDir });
|
|
81
90
|
|
|
82
91
|
} else if (command === 'reset') {
|
|
83
|
-
const workspaceId = process.env.INFORMER_DEV_WORKSPACE;
|
|
84
92
|
if (!workspaceId) {
|
|
85
93
|
console.error('No dev workspace found. Run workspace:init first.');
|
|
86
94
|
process.exit(1);
|
package/package.json
CHANGED
package/src/agent-dev.js
CHANGED
|
@@ -2,7 +2,18 @@ import { readFile, readdir, stat, access } from 'node:fs/promises';
|
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { parse as parseUrl } from 'node:url';
|
|
4
4
|
import yaml from 'yaml';
|
|
5
|
-
import {
|
|
5
|
+
import { manifestBlock, buildDevContext, buildDevCrypto, buildDevMessaging, normalizeFetchPath } from './dev-dependencies.js';
|
|
6
|
+
import { createDevChannels, createDevEmit } from './dev-channels.js';
|
|
7
|
+
import { devPlatform } from './dev-platform.js';
|
|
8
|
+
|
|
9
|
+
// embed() is present on a real install even without the embeddings
|
|
10
|
+
// capability, where it throws a written explanation. Mirroring that here
|
|
11
|
+
// keeps the dev failure the same lesson as the deployed one, instead of a
|
|
12
|
+
// bare "embed is not a function" that reads like a missing binding.
|
|
13
|
+
const embed = async () => {
|
|
14
|
+
throw new Error('embed() is not available in the dev mirror: the embeddings capability needs a real Informer (platform.capabilities.embeddings is false)');
|
|
15
|
+
};
|
|
16
|
+
|
|
6
17
|
const parseYaml = yaml.parse;
|
|
7
18
|
|
|
8
19
|
const MAX_STEPS = 20;
|
|
@@ -149,9 +160,11 @@ async function readSSE(response) {
|
|
|
149
160
|
*
|
|
150
161
|
* @param {Object} viteServer - Vite dev server instance
|
|
151
162
|
* @param {Object} opts - Configuration
|
|
163
|
+
* @param {ReturnType<typeof createDevChannels>} [opts.channels] - the dev channels hub
|
|
164
|
+
* behind `broadcast()` and the `channels:` relay (a private one when omitted)
|
|
152
165
|
* @returns {Function} Connect middleware
|
|
153
166
|
*/
|
|
154
|
-
export function createAgentMiddleware(viteServer, { serverOrigin, authHeader, devWorkspaceId, projectRoot, devBindings, appToken }) {
|
|
167
|
+
export function createAgentMiddleware(viteServer, { serverOrigin, authHeader, devWorkspaceId, projectRoot, devBindings, appToken, channels = createDevChannels() }) {
|
|
155
168
|
|
|
156
169
|
// query() — proxies to the workspace _sql endpoint (same as server-routes.js)
|
|
157
170
|
async function query(sql, params) {
|
|
@@ -226,12 +239,6 @@ export function createAgentMiddleware(viteServer, { serverOrigin, authHeader, de
|
|
|
226
239
|
? async (path, opts = {}) => await fetchAs(appAuth, path, { ...opts, headers: { ...(opts.headers || {}), 'x-informer-app-depth': '1' } })
|
|
227
240
|
: null;
|
|
228
241
|
|
|
229
|
-
// emit() — no-op in dev mode (logs to console)
|
|
230
|
-
function emit(event, payload) {
|
|
231
|
-
console.log(`[agent-dev] emit("${event}",`, JSON.stringify(payload), ')');
|
|
232
|
-
return { ok: true };
|
|
233
|
-
}
|
|
234
|
-
|
|
235
242
|
// notify/email — delivery is a console-logged no-op in dev, but required-field
|
|
236
243
|
// validation mirrors prod so a tool that passes here won't 500 in production.
|
|
237
244
|
const { notify, email } = buildDevMessaging('[agent-dev]');
|
|
@@ -319,10 +326,17 @@ export function createAgentMiddleware(viteServer, { serverOrigin, authHeader, de
|
|
|
319
326
|
// Build the typed dependency context + env the same way the
|
|
320
327
|
// server-routes dev middleware does, so tools using
|
|
321
328
|
// `context.<slot>.<method>(...)` and `env` work locally and match
|
|
322
|
-
// the prod sandbox bag.
|
|
323
|
-
const deps =
|
|
329
|
+
// the prod sandbox bag. The manifest is already in hand.
|
|
330
|
+
const deps = manifestBlock(yaml, 'dependencies');
|
|
324
331
|
const context = buildDevContext({ deps, apiFetch, devBindings, appFetch });
|
|
325
|
-
const env =
|
|
332
|
+
const env = manifestBlock(yaml, 'env');
|
|
333
|
+
|
|
334
|
+
// emit() writes no app_event row in dev, but still relays a
|
|
335
|
+
// listed event to its `channels:` channel; broadcast() publishes
|
|
336
|
+
// a live frame to the page (see dev-channels.js).
|
|
337
|
+
const manifestChannels = manifestBlock(yaml, 'channels');
|
|
338
|
+
const emit = createDevEmit({ channels, manifestChannels, logPrefix: '[agent-dev]' });
|
|
339
|
+
const { broadcast } = channels;
|
|
326
340
|
|
|
327
341
|
// Load tool handlers via ssrLoadModule
|
|
328
342
|
const localTools = await scanLocalTools(projectRoot);
|
|
@@ -441,7 +455,7 @@ export function createAgentMiddleware(viteServer, { serverOrigin, authHeader, de
|
|
|
441
455
|
|
|
442
456
|
if (tool) {
|
|
443
457
|
try {
|
|
444
|
-
result = await tool.handler({ args: tc.input, run: { agentName, trigger: triggerEvent }, context, query, fetch: apiFetch, emit, notify, email, crypto: cryptoHelper, markdown, log, env });
|
|
458
|
+
result = await tool.handler({ args: tc.input, run: { agentName, trigger: triggerEvent }, context, query, fetch: apiFetch, emit, broadcast, notify, email, embed, crypto: cryptoHelper, markdown, log, env, platform: devPlatform() });
|
|
445
459
|
} catch (err) {
|
|
446
460
|
console.error(`[agent-dev] Tool "${tc.name}" failed:`, err.message);
|
|
447
461
|
result = { error: err.message };
|
package/src/assemble.js
CHANGED
|
@@ -22,7 +22,11 @@ import { join, relative, posix } from 'node:path';
|
|
|
22
22
|
// the Vite build lands it at the library root). Without README.md here, a
|
|
23
23
|
// CLI-deployed app could never light those surfaces up via its README.
|
|
24
24
|
const ROOT_CONFIG_FILES = ['informer.yaml', 'data-access.yaml', 'API.md', 'README.md'];
|
|
25
|
-
|
|
25
|
+
// channels/ holds live-channel join/leave handlers (App Channels); the server
|
|
26
|
+
// scans and bundles it like server/ and webhooks/. `embeddings` holds the pump
|
|
27
|
+
// use-case files: server-side like server/, so they ship with the library and
|
|
28
|
+
// the platform can scan them.
|
|
29
|
+
const SOURCE_DIRS = ['migrations', 'tools', 'mcp', 'server', 'webhooks', 'channels', 'embeddings', 'lib', 'shared'];
|
|
26
30
|
|
|
27
31
|
// Entries never worth shipping in an app's server-side library: OS/editor
|
|
28
32
|
// dotfiles (.DS_Store, .env), nested dependency trees, and test files. Applied
|
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
|
+
}
|