acrylblends 0.7.7 → 0.7.8
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/dist/index.d.ts +1 -1
- package/dist/index.js +59 -19
- package/dist/index.js.map +1 -1
- package/docs/framework/README.md +1 -1
- package/docs/framework/UPSTREAM.md +1 -1
- package/docs/framework/agents/index.md +18 -3
- package/docs/framework/getting-started/desktop.md +2 -2
- package/docs/framework/guides/client-modules.md +4 -3
- package/docs/framework/guides/data.md +6 -6
- package/docs/framework/helpers/streaming.md +12 -27
- package/docs/framework/upstream.json +2 -2
- package/lib/browser/read-sse.js +84 -0
- package/lib/db/sqlite.d.ts +1 -1
- package/lib/db.d.ts +1 -84
- package/lib/index-BLTYsyc1.d.ts +93 -0
- package/lib/index-D4FAsBZu.d.ts +85 -0
- package/lib/kv/db.d.ts +2 -2
- package/lib/kv/json.d.ts +1 -1
- package/lib/kv/json.js +87 -24
- package/lib/kv.d.ts +1 -92
- package/lib/node-server.d.ts +3 -3
- package/lib/node-server.js +13 -8
- package/lib/read-sse.d.ts +33 -0
- package/lib/read-sse.js +84 -0
- package/package.json +5 -1
- package/plugins/acryl-db/plugin.json +1 -1
- package/plugins/acryl-db/upstream.json +2 -2
- package/plugins/acryl-db-sqlite/upstream.json +1 -1
- package/plugins/acryl-inspect/upstream.json +1 -1
- package/plugins/acryl-kv/plugin.json +1 -1
- package/plugins/acryl-kv/upstream.json +1 -1
- package/plugins/acryl-kv-db/upstream.json +1 -1
- package/plugins/acryl-kv-json/src/open.ts +86 -22
- package/plugins/acryl-kv-json/upstream.json +2 -2
- package/plugins/acryl-node-server/src/client-modules.ts +10 -9
- package/plugins/acryl-node-server/upstream.json +2 -2
- package/plugins/acryl-router/src/browser/read-sse.ts +3 -0
- package/plugins/acryl-router/src/helpers/read-sse.ts +111 -0
- package/plugins/acryl-router/upstream.json +2 -2
- package/plugins/acryl-router-http/upstream.json +1 -1
- package/plugins/acryl-state/upstream.json +1 -1
- package/plugins/builder/index.ts +2 -2
- package/plugins/builder/plugin.json +1 -1
- package/plugins/desktop/main.ts +3 -3
- package/plugins/desktop/plugin.json +2 -2
- package/plugins/desktop/upstream.json +1 -1
- package/plugins/desktop-web/main.ts +2 -2
- package/plugins/desktop-web/plugin.json +2 -2
- package/plugins/desktop-web/upstream.json +1 -1
- package/plugins/extensions/index.ts +19 -1
- package/plugins/extensions/plugin.json +1 -1
- package/plugins/extensions/resolve.ts +1 -1
- package/plugins/manifest/lock.ts +7 -3
- package/plugins/manifest/upgrade-cli.ts +1 -1
- package/plugins/save/cli.ts +1 -1
- package/plugins/save/plugin.json +1 -1
- package/shared/AGENTS.core.md +10 -10
- package/shared/DEFAULTS.md +40 -0
- package/shared/SKILL.md +14 -0
- package/templates/agent/AGENTS.md +1 -1
- package/templates/agent/README.md +6 -6
- package/templates/agent/gitignore +1 -0
- package/templates/agent/package.json +8 -8
- package/templates/agent/src/app.ts +28 -15
- package/templates/agent/src/main.ts +2 -2
- package/templates/agent/src/routes.ts +5 -5
- package/templates/agent/test/agent.test.ts +1 -1
- package/templates/agent/test/app.test.ts +1 -1
- package/templates/agent/test/builder.test.ts +1 -1
- package/templates/agent/test/model.test.ts +1 -1
- package/templates/agent/tsconfig.json +48 -50
- package/templates/blank/AGENTS.md +1 -1
- package/templates/blank/README.md +2 -2
- package/templates/blank/gitignore +1 -0
- package/templates/blank/package.json +8 -8
- package/templates/blank/src/about.tsx +2 -2
- package/templates/blank/src/app.ts +4 -4
- package/templates/blank/src/main.ts +1 -1
- package/templates/blank/src/routes.ts +1 -1
- package/templates/blank/test/app.test.ts +1 -1
- package/templates/blank/tsconfig.json +1 -1
- package/templates/todo/README.md +1 -1
- package/templates/todo/gitignore +1 -0
- package/templates/todo/package.json +8 -8
- package/templates/todo/src/app.ts +5 -5
- package/templates/todo/src/main.ts +1 -1
- package/templates/todo/src/todo.ts +4 -4
- package/templates/todo/test/todo.test.ts +1 -1
- package/templates/todo/tsconfig.json +1 -1
- package/templates/todo/upstream.json +2 -2
- package/lib/database-D_UN6We8.d.ts +0 -44
- package/lib/kv-DWDm0Lja.d.ts +0 -39
|
@@ -3,9 +3,12 @@
|
|
|
3
3
|
* memory and resolves the `kv` port with the function that closes it. The Cordis plugin is a thin wrapper over this, so both run one code
|
|
4
4
|
* path. This entry imports nothing from Cordis.
|
|
5
5
|
*
|
|
6
|
-
* Every write
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* Every write persists the whole map: serialize to a temporary file in the same folder, flush it, rename it over the file, and flush the
|
|
7
|
+
* folder where the platform allows. The call resolves after the rename, so a crash leaves the old file or the new one, never half of one.
|
|
8
|
+
* Writes are grouped: the calls made in one turn of the event loop, and those that queue while a file write is in flight, are applied in
|
|
9
|
+
* call order and persisted together by one write, so a burst of calls costs one flush and one rename, not one each (on Windows each costs
|
|
10
|
+
* tens of milliseconds). One process owns the
|
|
11
|
+
* file: two writers would overwrite each other.
|
|
9
12
|
*/
|
|
10
13
|
|
|
11
14
|
import { mkdir, open, readdir, readFile, rename, rm } from 'node:fs/promises'
|
|
@@ -95,6 +98,20 @@ async function flushFolder(folder: string): Promise<void> {
|
|
|
95
98
|
}
|
|
96
99
|
}
|
|
97
100
|
|
|
101
|
+
/** What one call does to the map: the key's new JSON text (`undefined` deletes it), and what the call resolves with. */
|
|
102
|
+
interface Change<T = unknown> {
|
|
103
|
+
readonly key: string
|
|
104
|
+
readonly text: string | undefined
|
|
105
|
+
readonly result: T
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** A call waiting for the file write that will contain it. Its change reads the state as the calls before it in the round left it. */
|
|
109
|
+
interface Pending {
|
|
110
|
+
readonly change: (read: (key: string) => string | undefined) => Change
|
|
111
|
+
readonly resolve: (result: unknown) => void
|
|
112
|
+
readonly reject: (error: unknown) => void
|
|
113
|
+
}
|
|
114
|
+
|
|
98
115
|
/** Open (and create, when missing) a JSON-file store. @throws when the file exists and is not a readable kv file. */
|
|
99
116
|
export async function openKv(options: OpenKvOptions): Promise<KvOpened> {
|
|
100
117
|
const file = resolve(options.path)
|
|
@@ -103,7 +120,12 @@ export async function openKv(options: OpenKvOptions): Promise<KvOpened> {
|
|
|
103
120
|
let state = await load(file)
|
|
104
121
|
let closed = false
|
|
105
122
|
let sequence = 0
|
|
106
|
-
|
|
123
|
+
/** Calls waiting for the next file write, in call order. */
|
|
124
|
+
let queue: Pending[] = []
|
|
125
|
+
/** Whether the loop that writes the queue runs; cleared by the loop itself, so a call queued after it ends starts a new one. */
|
|
126
|
+
let draining = false
|
|
127
|
+
/** The latest loop, for `close` to wait on. */
|
|
128
|
+
let drained: Promise<void> = Promise.resolve()
|
|
107
129
|
|
|
108
130
|
const guard = (): void => {
|
|
109
131
|
if (closed) throw new Error(`acryl-kv-json: ${file} is closed`)
|
|
@@ -129,18 +151,62 @@ export async function openKv(options: OpenKvOptions): Promise<KvOpened> {
|
|
|
129
151
|
await flushFolder(dirname(file))
|
|
130
152
|
}
|
|
131
153
|
|
|
132
|
-
/**
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
154
|
+
/**
|
|
155
|
+
* Write the queue until it is empty. Each round takes every call queued so far and applies them in order to one copy of the state. A call
|
|
156
|
+
* whose change throws rejects with its error and changes nothing, and the others in the round still apply. The copy becomes the state
|
|
157
|
+
* only once it is on disk. A failed file write rejects every call in its round and leaves the state as it was.
|
|
158
|
+
*/
|
|
159
|
+
const drain = async (): Promise<void> => {
|
|
160
|
+
try {
|
|
161
|
+
// Let the calls made in this turn join the first round.
|
|
162
|
+
await Promise.resolve()
|
|
163
|
+
await rounds()
|
|
164
|
+
} finally {
|
|
165
|
+
draining = false
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const rounds = async (): Promise<void> => {
|
|
170
|
+
while (queue.length > 0) {
|
|
171
|
+
const round = queue
|
|
172
|
+
queue = []
|
|
136
173
|
const draft = new Map(state)
|
|
137
|
-
const result =
|
|
138
|
-
|
|
174
|
+
const applied: { readonly call: Pending; readonly result: unknown }[] = []
|
|
175
|
+
for (const call of round) {
|
|
176
|
+
let change: Change
|
|
177
|
+
try {
|
|
178
|
+
change = call.change(key => draft.get(key))
|
|
179
|
+
} catch (error) {
|
|
180
|
+
call.reject(error)
|
|
181
|
+
continue
|
|
182
|
+
}
|
|
183
|
+
if (change.text === undefined) draft.delete(change.key)
|
|
184
|
+
else draft.set(change.key, change.text)
|
|
185
|
+
applied.push({ call, result: change.result })
|
|
186
|
+
}
|
|
187
|
+
if (applied.length === 0) continue
|
|
188
|
+
try {
|
|
189
|
+
await persist(draft)
|
|
190
|
+
} catch (error) {
|
|
191
|
+
for (const { call } of applied) call.reject(error)
|
|
192
|
+
continue
|
|
193
|
+
}
|
|
139
194
|
state = draft
|
|
140
|
-
|
|
195
|
+
for (const { call, result } of applied) call.resolve(result)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Queue one change behind every earlier call; it resolves once a file write that contains it is on disk. */
|
|
200
|
+
const write = <T>(change: (read: (key: string) => string | undefined) => Change<T>): Promise<T> => {
|
|
201
|
+
guard()
|
|
202
|
+
const done = new Promise<T>((resolveCall, rejectCall) => {
|
|
203
|
+
queue.push({ change, resolve: resolveCall as (result: unknown) => void, reject: rejectCall })
|
|
141
204
|
})
|
|
142
|
-
|
|
143
|
-
|
|
205
|
+
if (!draining) {
|
|
206
|
+
draining = true
|
|
207
|
+
drained = drain()
|
|
208
|
+
}
|
|
209
|
+
return done
|
|
144
210
|
}
|
|
145
211
|
|
|
146
212
|
const kv: Kv = {
|
|
@@ -153,21 +219,19 @@ export async function openKv(options: OpenKvOptions): Promise<KvOpened> {
|
|
|
153
219
|
async set(key, value) {
|
|
154
220
|
checkKey(key)
|
|
155
221
|
const text = encodeValue(value)
|
|
156
|
-
await write(
|
|
222
|
+
await write(() => ({ key, text, result: undefined }))
|
|
157
223
|
},
|
|
158
224
|
async update<T>(key: string, change: (current: T | undefined) => T) {
|
|
159
225
|
checkKey(key)
|
|
160
|
-
return write(
|
|
161
|
-
const text =
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
draft.set(key, encoded)
|
|
165
|
-
return decodeValue<T>(encoded)
|
|
226
|
+
return write(read => {
|
|
227
|
+
const text = read(key)
|
|
228
|
+
const encoded = encodeValue(runChange(change, text === undefined ? undefined : decodeValue<T>(text)))
|
|
229
|
+
return { key, text: encoded, result: decodeValue<T>(encoded) }
|
|
166
230
|
})
|
|
167
231
|
},
|
|
168
232
|
async delete(key) {
|
|
169
233
|
checkKey(key)
|
|
170
|
-
await write(
|
|
234
|
+
await write(() => ({ key, text: undefined, result: undefined }))
|
|
171
235
|
},
|
|
172
236
|
async list(prefix = '') {
|
|
173
237
|
guard()
|
|
@@ -179,7 +243,7 @@ export async function openKv(options: OpenKvOptions): Promise<KvOpened> {
|
|
|
179
243
|
kv,
|
|
180
244
|
close: async () => {
|
|
181
245
|
closed = true
|
|
182
|
-
await
|
|
246
|
+
await drained
|
|
183
247
|
},
|
|
184
248
|
}
|
|
185
249
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"source": {
|
|
3
3
|
"repo": "acryldev/acryl",
|
|
4
4
|
"path": "plugins/acryl-kv-json",
|
|
5
|
-
"commit": "
|
|
5
|
+
"commit": "f132283c43337f5e1d58dfbbbc15786efb5e756c"
|
|
6
6
|
},
|
|
7
7
|
"vendored": [
|
|
8
8
|
"src",
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"LICENSE"
|
|
11
11
|
],
|
|
12
12
|
"rewrittenSpecifiers": 6,
|
|
13
|
-
"digest": "sha256:
|
|
13
|
+
"digest": "sha256:10613b53bdcf15cce86d6dce5e606d0cd93367cc2569b7603cbf157ebc557548",
|
|
14
14
|
"testsNotRun": {
|
|
15
15
|
"tests/loader.spec.ts": "loads the plugin through the real Loader (@deepseek-ai/cordis-plugin-loader), which generated apps do not install; the plain opener it wraps is tested here by open.spec.ts and conformance.spec.ts",
|
|
16
16
|
"tests/crash.spec.ts": "kills a child process that imports the package build (lib/), which a copy vendored into an app does not have; it runs in ACRYL",
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Client components without a bundler. `clientModules({ root: 'src/client' })` is an app that serves the TypeScript and JSX modules of a
|
|
3
3
|
* folder to the browser as ES modules: a `.tsx`/`.ts`/`.jsx` file compiled with the app's own `typescript` (JSX for `acrylblends/jsx/dom`),
|
|
4
|
-
* a `.js`/`.mjs` file as it is. The framework's own browser imports (`acrylblends/jsx/dom` with its runtimes and `client`,
|
|
5
|
-
* `acrylblends/client`) are rewritten to
|
|
4
|
+
* a `.js`/`.mjs` file as it is. The framework's own browser imports (`acrylblends/jsx/dom` with its runtimes and `client`,
|
|
5
|
+
* `acrylblends/client` and `acrylblends/read-sse`) are rewritten to three self-contained files the same app serves, at a URL relative to each module, so a page needs
|
|
6
6
|
* one `<script type="module" src="/client/counter.tsx">` and no import map, under whatever path the app is routed at.
|
|
7
7
|
*
|
|
8
8
|
* Production runs without `typescript` (a deploy that leaves out devDependencies): `buildClientModules` writes the same compiled modules
|
|
9
|
-
* and the
|
|
9
|
+
* and the three browser files into a folder, and with `NODE_ENV=production` and `compiled` set, only that folder is served.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { copyFileSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, writeFileSync } from 'node:fs'
|
|
@@ -16,7 +16,7 @@ import { Acryl } from '../../acryl-router/src/index.ts'
|
|
|
16
16
|
import { basePath } from '../../acryl-router/src/helpers/index.ts'
|
|
17
17
|
import { MissingTypeScriptError, compileModule } from './transpile.ts'
|
|
18
18
|
|
|
19
|
-
/** The folder the
|
|
19
|
+
/** The folder the three browser files are served from, below the app's mount. A source folder of this name is not served. */
|
|
20
20
|
const RUNTIME_DIR = '@acrylblends'
|
|
21
21
|
/** The framework's browser imports and the browser file that provides each. */
|
|
22
22
|
const BROWSER_IMPORTS: Readonly<Record<string, string>> = {
|
|
@@ -25,8 +25,9 @@ const BROWSER_IMPORTS: Readonly<Record<string, string>> = {
|
|
|
25
25
|
'acrylblends/jsx/dom/jsx-dev-runtime': 'jsx-dom.js',
|
|
26
26
|
'acrylblends/jsx/dom/client': 'jsx-dom.js',
|
|
27
27
|
'acrylblends/client': 'client.js',
|
|
28
|
+
'acrylblends/read-sse': 'read-sse.js',
|
|
28
29
|
}
|
|
29
|
-
const RUNTIME_FILES = ['jsx-dom.js', 'client.js'] as const
|
|
30
|
+
const RUNTIME_FILES = ['jsx-dom.js', 'client.js', 'read-sse.js'] as const
|
|
30
31
|
const COMPILED = new Set(['.ts', '.tsx', '.jsx', '.mts'])
|
|
31
32
|
const SERVED = new Set([...COMPILED, '.js', '.mjs'])
|
|
32
33
|
const JS_TYPE = 'text/javascript; charset=utf-8'
|
|
@@ -41,7 +42,7 @@ export interface ClientModulesOptions {
|
|
|
41
42
|
readonly compiled?: string | undefined
|
|
42
43
|
/** At most this many bytes of compiled modules are kept in memory (least recently served first out); 16 MiB when not given. */
|
|
43
44
|
readonly cacheBytes?: number | undefined
|
|
44
|
-
/** The folder holding `jsx-dom.js` and `
|
|
45
|
+
/** The folder holding `jsx-dom.js`, `client.js` and `read-sse.js`; the framework's own when not given. */
|
|
45
46
|
readonly runtimeDir?: string | undefined
|
|
46
47
|
}
|
|
47
48
|
|
|
@@ -51,11 +52,11 @@ export interface BuildClientModulesOptions {
|
|
|
51
52
|
readonly root: string
|
|
52
53
|
/** Where the compiled modules go, at the same relative paths and file names; it must not lie inside `root`. */
|
|
53
54
|
readonly outDir: string
|
|
54
|
-
/** The folder holding `jsx-dom.js` and `
|
|
55
|
+
/** The folder holding `jsx-dom.js`, `client.js` and `read-sse.js`; the framework's own when not given. */
|
|
55
56
|
readonly runtimeDir?: string | undefined
|
|
56
57
|
}
|
|
57
58
|
|
|
58
|
-
/** The framework's
|
|
59
|
+
/** The framework's three browser files, next to the built module that contains this code. */
|
|
59
60
|
const defaultRuntimeDir = (): string => fileURLToPath(new URL('./browser/', import.meta.url))
|
|
60
61
|
|
|
61
62
|
/** A module path below the root (`widgets/counter.tsx`) with the URL prefix that reaches the browser files from it. */
|
|
@@ -264,7 +265,7 @@ function sourceFiles(root: string, dir = root): string[] {
|
|
|
264
265
|
|
|
265
266
|
/**
|
|
266
267
|
* Compile a folder of client modules once, for a production that has no `typescript`: each module is written to `outDir` at the same
|
|
267
|
-
* relative path and file name (so the page's URLs stay the same), compiled exactly as {@link clientModules} compiles it, and the
|
|
268
|
+
* relative path and file name (so the page's URLs stay the same), compiled exactly as {@link clientModules} compiles it, and the three
|
|
268
269
|
* browser files are copied to `outDir/@acrylblends/`. Files already in `outDir` that no source produces are left there.
|
|
269
270
|
* @param options - the source folder and the output folder.
|
|
270
271
|
* @returns the paths written, relative to `outDir`.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"source": {
|
|
3
3
|
"repo": "acryldev/acryl",
|
|
4
4
|
"path": "plugins/acryl-node-server",
|
|
5
|
-
"commit": "
|
|
5
|
+
"commit": "f132283c43337f5e1d58dfbbbc15786efb5e756c"
|
|
6
6
|
},
|
|
7
7
|
"vendored": [
|
|
8
8
|
"src",
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"LICENSE"
|
|
11
11
|
],
|
|
12
12
|
"rewrittenSpecifiers": 31,
|
|
13
|
-
"digest": "sha256:
|
|
13
|
+
"digest": "sha256:e92c0621600e414cffd3e063c117a887410de53113ac4f5966231b419ed92da1",
|
|
14
14
|
"testsNotRun": {
|
|
15
15
|
"tests/client-modules.spec.ts": "links typescript from the plugin's own node_modules (ACRYL's workspace layout); clientModules and build-client are exercised here by the templates' tests and verify:scaffolder",
|
|
16
16
|
"tests/register.spec.ts": "links typescript from the plugin's own node_modules (ACRYL's workspace layout); the hook is exercised here by the templates' own .tsx tests and verify:scaffolder"
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `readSSE(response)`: the events of a `text/event-stream` answer from `fetch`, for the browser. `EventSource` reads GET streams only; a chat
|
|
3
|
+
* or any stream that needs a POST body, a header or an `AbortSignal` is read with `fetch`, and this is the loop every such page otherwise writes by
|
|
4
|
+
* hand. It follows the parsing rules of the HTML standard (lines end in LF, CRLF or CR; the `data` lines of one event join with LF; a line that
|
|
5
|
+
* starts with a colon is a comment; an event ends at a blank line; an event with no `data` line is not dispatched; the last event is dispatched
|
|
6
|
+
* only if its blank line arrived), and it is one module with no imports, so it also runs in a service worker and on a fetch runtime.
|
|
7
|
+
*
|
|
8
|
+
* ACRYL's own addition, not part of the upstream port.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** One event of the stream. */
|
|
12
|
+
export interface SSEEvent {
|
|
13
|
+
/** The `event:` name; `message` when the event has none. */
|
|
14
|
+
readonly event: string
|
|
15
|
+
/** The `data:` lines of the event, joined with a line feed. */
|
|
16
|
+
readonly data: string
|
|
17
|
+
/** The `id:` of the event, when it had one. */
|
|
18
|
+
readonly id?: string
|
|
19
|
+
/** The `retry:` of the event in milliseconds, when it had one. */
|
|
20
|
+
readonly retry?: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ReadSSEOptions {
|
|
24
|
+
/** Stops the loop and cancels the response body when it aborts. (Abort the `fetch` itself with the signal you pass to it.) */
|
|
25
|
+
readonly signal?: AbortSignal
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Read the events of a `text/event-stream` response as they arrive. Leaving the loop early (`break`, a thrown error) cancels the body, so the
|
|
30
|
+
* request to the server ends.
|
|
31
|
+
* @throws when the response is not `ok` (the status is in the message; the body is cancelled, not parsed), has no body, or fails while it is read.
|
|
32
|
+
*/
|
|
33
|
+
export async function* readSSE(response: Response, options: ReadSSEOptions = {}): AsyncGenerator<SSEEvent, void, undefined> {
|
|
34
|
+
if (!response.ok) {
|
|
35
|
+
await response.body?.cancel().catch(() => undefined)
|
|
36
|
+
throw new Error(`readSSE: the request failed with ${String(response.status)}${response.statusText === '' ? '' : ` ${response.statusText}`}`)
|
|
37
|
+
}
|
|
38
|
+
if (response.body === null) throw new Error('readSSE: the response has no body to read')
|
|
39
|
+
const { signal } = options
|
|
40
|
+
if (signal?.aborted === true) {
|
|
41
|
+
await response.body.cancel().catch(() => undefined)
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const reader = response.body.getReader()
|
|
46
|
+
const onAbort = (): void => {
|
|
47
|
+
void reader.cancel().catch(() => undefined)
|
|
48
|
+
}
|
|
49
|
+
signal?.addEventListener('abort', onAbort, { once: true })
|
|
50
|
+
const decoder = new TextDecoder('utf-8')
|
|
51
|
+
|
|
52
|
+
let buffer = ''
|
|
53
|
+
let skipLineFeed = false
|
|
54
|
+
let name = ''
|
|
55
|
+
let data: string[] = []
|
|
56
|
+
let id: string | undefined
|
|
57
|
+
let retry: number | undefined
|
|
58
|
+
|
|
59
|
+
/** Apply one line; returns the event when this line completes one. */
|
|
60
|
+
const apply = (line: string): SSEEvent | undefined => {
|
|
61
|
+
if (line === '') {
|
|
62
|
+
const event = data.length === 0 ? undefined : { event: name === '' ? 'message' : name, data: data.join('\n'), ...(id === undefined ? {} : { id }), ...(retry === undefined ? {} : { retry }) }
|
|
63
|
+
name = ''
|
|
64
|
+
data = []
|
|
65
|
+
id = undefined
|
|
66
|
+
retry = undefined
|
|
67
|
+
return event
|
|
68
|
+
}
|
|
69
|
+
if (line.startsWith(':')) return undefined
|
|
70
|
+
const colon = line.indexOf(':')
|
|
71
|
+
const field = colon === -1 ? line : line.slice(0, colon)
|
|
72
|
+
let value = colon === -1 ? '' : line.slice(colon + 1)
|
|
73
|
+
if (value.startsWith(' ')) value = value.slice(1)
|
|
74
|
+
if (field === 'event') name = value
|
|
75
|
+
else if (field === 'data') data.push(value)
|
|
76
|
+
else if (field === 'id') {
|
|
77
|
+
if (!value.includes('\u0000')) id = value
|
|
78
|
+
} else if (field === 'retry' && /^\d+$/u.test(value)) retry = Number(value)
|
|
79
|
+
return undefined
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
for (;;) {
|
|
84
|
+
const { done, value } = await reader.read()
|
|
85
|
+
if (done) break
|
|
86
|
+
let text = decoder.decode(value, { stream: true })
|
|
87
|
+
// A CR at the end of the last chunk already ended its line; the LF that completes the CRLF may open this one.
|
|
88
|
+
if (skipLineFeed && text.startsWith('\n')) text = text.slice(1)
|
|
89
|
+
if (text !== '') skipLineFeed = false
|
|
90
|
+
buffer += text
|
|
91
|
+
let start = 0
|
|
92
|
+
for (let index = 0; index < buffer.length; index += 1) {
|
|
93
|
+
const code = buffer.charCodeAt(index)
|
|
94
|
+
if (code !== 10 && code !== 13) continue
|
|
95
|
+
const line = buffer.slice(start, index)
|
|
96
|
+
if (code === 13) {
|
|
97
|
+
if (index + 1 < buffer.length) {
|
|
98
|
+
if (buffer.charCodeAt(index + 1) === 10) index += 1
|
|
99
|
+
} else skipLineFeed = true
|
|
100
|
+
}
|
|
101
|
+
start = index + 1
|
|
102
|
+
const event = apply(line)
|
|
103
|
+
if (event !== undefined) yield event
|
|
104
|
+
}
|
|
105
|
+
buffer = buffer.slice(start)
|
|
106
|
+
}
|
|
107
|
+
} finally {
|
|
108
|
+
signal?.removeEventListener('abort', onAbort)
|
|
109
|
+
await reader.cancel().catch(() => undefined)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"source": {
|
|
3
3
|
"repo": "acryldev/acryl",
|
|
4
4
|
"path": "plugins/acryl-router",
|
|
5
|
-
"commit": "
|
|
5
|
+
"commit": "f132283c43337f5e1d58dfbbbc15786efb5e756c"
|
|
6
6
|
},
|
|
7
7
|
"vendored": [
|
|
8
8
|
"src",
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"LICENSE"
|
|
11
11
|
],
|
|
12
12
|
"rewrittenSpecifiers": 6,
|
|
13
|
-
"digest": "sha256:
|
|
13
|
+
"digest": "sha256:5f07e873992fa2f0a178e029a1fe3d8c99cb4ce3ae8c07445d75431f95125417",
|
|
14
14
|
"testsNotRun": {
|
|
15
15
|
"tests/loader.spec.ts": "loads the built lib/ entry through the real Loader; the library ships source and has no build"
|
|
16
16
|
},
|
package/plugins/builder/index.ts
CHANGED
|
@@ -14,7 +14,7 @@ import type {} from '../instance/index.ts'
|
|
|
14
14
|
import { builderRules } from './script.ts'
|
|
15
15
|
|
|
16
16
|
export interface BuilderConfig {
|
|
17
|
-
/** Where the agent writes extensions. Defaults to `<project>/
|
|
17
|
+
/** Where the agent writes extensions. Defaults to `<project>/plugins`. Must be one of the extensions sources. */
|
|
18
18
|
dir?: string
|
|
19
19
|
/** Where `package_plugin` writes standalone packages. Defaults to `<project>/plugin-packages`. */
|
|
20
20
|
packagesDir?: string
|
|
@@ -64,7 +64,7 @@ export const builder = {
|
|
|
64
64
|
name: 'builder',
|
|
65
65
|
inject: ['extensions', 'tools', 'appInstance'],
|
|
66
66
|
apply(ctx: Context, config: BuilderConfig) {
|
|
67
|
-
const dir = resolve(config?.dir ?? join(ctx.appInstance.root, '
|
|
67
|
+
const dir = resolve(config?.dir ?? join(ctx.appInstance.root, 'plugins'))
|
|
68
68
|
if (!ctx.extensions.sources.some((source) => resolve(source.dir) === dir)) {
|
|
69
69
|
throw new BuilderError(
|
|
70
70
|
`the builder writes into ${dir}, which is not an extensions source; add it to the extensions plugin's sources`,
|
|
@@ -7,5 +7,5 @@
|
|
|
7
7
|
"dependencies": {
|
|
8
8
|
"@deepseek-ai/cordis": "4.0.4"
|
|
9
9
|
},
|
|
10
|
-
"mount": "mount it after agent and extensions: root.plugin(builder, { dir: join(root, '
|
|
10
|
+
"mount": "mount it after agent and extensions: root.plugin(builder, { dir: join(root, 'plugins') }); dir must be an extensions source"
|
|
11
11
|
}
|
package/plugins/desktop/main.ts
CHANGED
|
@@ -10,14 +10,14 @@ import { mkdirSync, readFileSync } from 'node:fs'
|
|
|
10
10
|
import { join } from 'node:path'
|
|
11
11
|
import { fileURLToPath } from 'node:url'
|
|
12
12
|
import { app, BrowserWindow, shell } from 'electron'
|
|
13
|
-
import type { App } from '../../app.ts'
|
|
13
|
+
import type { App } from '../../src/app.ts'
|
|
14
14
|
import '../acryl-node-server/src/register.ts'
|
|
15
15
|
import { selectInstance } from '../instance/index.ts'
|
|
16
16
|
import { createDesktopShutdown, installShutdownRequests } from './shutdown.ts'
|
|
17
17
|
import { resolveDesktopUserDataOverride } from './user-data.ts'
|
|
18
18
|
import { windowOptions } from './window.ts'
|
|
19
19
|
|
|
20
|
-
const projectRoot = fileURLToPath(new URL('
|
|
20
|
+
const projectRoot = fileURLToPath(new URL('../..', import.meta.url))
|
|
21
21
|
const chosen = selectInstance({ projectRoot, env: process.env })
|
|
22
22
|
|
|
23
23
|
/** Where the window opens: `acrylblends.desktopPath` in package.json (the todo template sets `/todo`), else the root. */
|
|
@@ -65,7 +65,7 @@ if (!app.requestSingleInstanceLock()) {
|
|
|
65
65
|
|
|
66
66
|
void app.whenReady().then(async () => {
|
|
67
67
|
// The hook above is registered before the app's modules are loaded, so the app may have .tsx pages: they are loaded here, not as a static import.
|
|
68
|
-
const { createApp } = await import('../../app.ts')
|
|
68
|
+
const { createApp } = await import('../../src/app.ts')
|
|
69
69
|
running = await createApp(chosen)
|
|
70
70
|
const origin = new URL(running.url).origin
|
|
71
71
|
window = new BrowserWindow(windowOptions({ title: chosen.name }))
|
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
"electron": "44.9.3"
|
|
10
10
|
},
|
|
11
11
|
"scripts": {
|
|
12
|
-
"desktop": "electron
|
|
12
|
+
"desktop": "electron plugins/desktop/main.ts"
|
|
13
13
|
},
|
|
14
14
|
"omit": ["**/*.test.*"],
|
|
15
|
-
"mount": "nothing to mount: run `npm install`, then `npm run desktop` (the window opens on the app's own server; every electron call is in
|
|
15
|
+
"mount": "nothing to mount: run `npm install`, then `npm run desktop` (the window opens on the app's own server; every electron call is in plugins/desktop)"
|
|
16
16
|
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"apps/acryl-desktop/src/shell/desktop-user-data.ts": "user-data.ts",
|
|
6
6
|
"apps/acryl-desktop/src/shell/shutdown.ts": "shutdown.ts"
|
|
7
7
|
},
|
|
8
|
-
"commit": "
|
|
8
|
+
"commit": "f132283c43337f5e1d58dfbbbc15786efb5e756c"
|
|
9
9
|
},
|
|
10
10
|
"rewrittenSpecifiers": 0,
|
|
11
11
|
"digest": "sha256:7dfe960a8bbb476c35540d2202a7aaa62b33492c2ac3e34314e2ecfca45f32f2"
|
|
@@ -17,7 +17,7 @@ import { createDesktopShutdown, installShutdownRequests } from './shutdown.ts'
|
|
|
17
17
|
import { resolveDesktopUserDataOverride } from './user-data.ts'
|
|
18
18
|
import { windowOptions } from './window.ts'
|
|
19
19
|
|
|
20
|
-
const projectRoot = fileURLToPath(new URL('
|
|
20
|
+
const projectRoot = fileURLToPath(new URL('../..', import.meta.url))
|
|
21
21
|
const manifest = JSON.parse(readFileSync(join(projectRoot, 'package.json'), 'utf8')) as {
|
|
22
22
|
name?: string
|
|
23
23
|
acrylblends?: { desktopPath?: string }
|
|
@@ -73,7 +73,7 @@ if (!app.requestSingleInstanceLock()) {
|
|
|
73
73
|
|
|
74
74
|
void app.whenReady().then(async () => {
|
|
75
75
|
// The hook above is registered before the app's modules are loaded, so the app may have .tsx pages: they are loaded here, not as a static import.
|
|
76
|
-
const { default: acrylApp, close } = (await import('../../app.ts')) as {
|
|
76
|
+
const { default: acrylApp, close } = (await import('../../src/app.ts')) as {
|
|
77
77
|
default: { fetch: Parameters<typeof serve>[0]['fetch'] }
|
|
78
78
|
close?: () => Promise<void>
|
|
79
79
|
}
|
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
"electron": "44.9.3"
|
|
10
10
|
},
|
|
11
11
|
"scripts": {
|
|
12
|
-
"desktop": "electron
|
|
12
|
+
"desktop": "electron plugins/desktop-web/main.ts"
|
|
13
13
|
},
|
|
14
14
|
"gitignore": [".acrylblends/"],
|
|
15
15
|
"omit": ["**/*.test.*"],
|
|
16
|
-
"mount": "nothing to mount: run `npm install`, then `npm run desktop` (the window opens on the app's own server; every electron call is in
|
|
16
|
+
"mount": "nothing to mount: run `npm install`, then `npm run desktop` (the window opens on the app's own server; every electron call is in plugins/desktop-web)"
|
|
17
17
|
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"apps/acryl-desktop/src/shell/desktop-user-data.ts": "user-data.ts",
|
|
6
6
|
"apps/acryl-desktop/src/shell/shutdown.ts": "shutdown.ts"
|
|
7
7
|
},
|
|
8
|
-
"commit": "
|
|
8
|
+
"commit": "f132283c43337f5e1d58dfbbbc15786efb5e756c"
|
|
9
9
|
},
|
|
10
10
|
"rewrittenSpecifiers": 0,
|
|
11
11
|
"digest": "sha256:7dfe960a8bbb476c35540d2202a7aaa62b33492c2ac3e34314e2ecfca45f32f2"
|
|
@@ -55,8 +55,24 @@ export interface ExtensionSource {
|
|
|
55
55
|
readonly trust: Trust
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
/** The library copies a plugins folder holds (`library.json`, written by `acrylblends add`): the app mounts them itself, so they are not extensions. */
|
|
59
|
+
function libraryCopies(dir: string): ReadonlySet<string> {
|
|
60
|
+
try {
|
|
61
|
+
const listed = (
|
|
62
|
+
JSON.parse(readFileSync(join(dir, 'library.json'), 'utf8')) as { plugins?: unknown }
|
|
63
|
+
).plugins
|
|
64
|
+
return new Set(
|
|
65
|
+
Array.isArray(listed)
|
|
66
|
+
? listed.filter((name): name is string => typeof name === 'string')
|
|
67
|
+
: [],
|
|
68
|
+
)
|
|
69
|
+
} catch {
|
|
70
|
+
return new Set()
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
58
74
|
export interface ExtensionsConfig {
|
|
59
|
-
/** Where extension folders may come from. Defaults to `<app home>/extensions`, trusted to install. */
|
|
75
|
+
/** Where extension folders may come from. An app passes its `plugins/` folder (library copies listed in its `library.json` are skipped). Defaults to `<app home>/extensions`, trusted to install, for a service used without an app folder. */
|
|
60
76
|
sources?: readonly ExtensionSource[]
|
|
61
77
|
/** When true, every source that would install by itself asks for approval instead (`ask`). */
|
|
62
78
|
approval?: boolean
|
|
@@ -373,11 +389,13 @@ export class Extensions extends Service {
|
|
|
373
389
|
const found: Array<{ name: string; dir: string; trust: Trust }> = []
|
|
374
390
|
for (const source of this.sources) {
|
|
375
391
|
if (!existsSync(source.dir)) continue
|
|
392
|
+
const library = libraryCopies(source.dir)
|
|
376
393
|
for (const entry of readdirSync(source.dir, { withFileTypes: true })) {
|
|
377
394
|
const dir = join(source.dir, entry.name)
|
|
378
395
|
if (
|
|
379
396
|
entry.isDirectory() &&
|
|
380
397
|
NAME.test(entry.name) &&
|
|
398
|
+
!library.has(entry.name) &&
|
|
381
399
|
ENTRIES.some((file) => existsSync(join(dir, file)))
|
|
382
400
|
) {
|
|
383
401
|
if (!found.some((other) => other.name === entry.name))
|
|
@@ -8,5 +8,5 @@
|
|
|
8
8
|
"@deepseek-ai/cordis": "4.0.4",
|
|
9
9
|
"@webboxes/blends-core": "0.4.0"
|
|
10
10
|
},
|
|
11
|
-
"mount": "mount it after instance: root.plugin(extensions, { sources: [{ dir: join(root, '
|
|
11
|
+
"mount": "mount it after instance: root.plugin(extensions, { sources: [{ dir: join(root, 'plugins'), trust: 'install' }] })"
|
|
12
12
|
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* module inside the staging folder, resolves the same name from the app's project folder. Uses Node's public `module.registerHooks`
|
|
5
5
|
* (ACRYL needed the same fallback on Deno); the returned function removes the hook.
|
|
6
6
|
*
|
|
7
|
-
* The framework plugins are not packages in an app (they are source under `
|
|
7
|
+
* The framework plugins are not packages in an app (they are source under `plugins/`), so `aliases` names the files a framework specifier such as
|
|
8
8
|
* `acryl-router` stands for. A staged plugin then imports the same module the app itself runs, so `App` is the very class the router mounts.
|
|
9
9
|
*/
|
|
10
10
|
import { existsSync, mkdirSync, realpathSync } from 'node:fs'
|
package/plugins/manifest/lock.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `npm run lock`: write `blend.lock.json` (formatVersion 3, the lock ACRYL writes too) next to the manifest, recording what the app is
|
|
3
3
|
* built from: the manifest's identity and digest, the Blueprint it was resolved over, its resolved parameters and rows, and a content
|
|
4
|
-
* digest of every plugin folder in `
|
|
4
|
+
* digest of every plugin folder in `plugins/`. Commit it; a diff of the lock is a review of
|
|
5
5
|
* what changed.
|
|
6
6
|
*/
|
|
7
7
|
import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
@@ -69,7 +69,11 @@ export function writeLock(appRoot: string): LockV3 {
|
|
|
69
69
|
version: document.metadata.version,
|
|
70
70
|
digest: manifestDigest(text),
|
|
71
71
|
},
|
|
72
|
-
modules: [
|
|
72
|
+
modules: [
|
|
73
|
+
...localModules(appRoot, 'plugins'),
|
|
74
|
+
...localModules(appRoot, 'src/plugins'),
|
|
75
|
+
...localModules(appRoot, 'extensions'),
|
|
76
|
+
],
|
|
73
77
|
...(lineage
|
|
74
78
|
? {
|
|
75
79
|
lineage: {
|
|
@@ -86,7 +90,7 @@ export function writeLock(appRoot: string): LockV3 {
|
|
|
86
90
|
}
|
|
87
91
|
|
|
88
92
|
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
89
|
-
const appRoot = resolve(fileURLToPath(new URL('
|
|
93
|
+
const appRoot = resolve(fileURLToPath(new URL('../..', import.meta.url)))
|
|
90
94
|
const locked = writeLock(appRoot)
|
|
91
95
|
console.log(
|
|
92
96
|
`wrote ${LOCK_FILE}: ${locked.rows.length} rows, ${locked.modules.length} local plugins`,
|
|
@@ -65,6 +65,6 @@ const parse = (argv: readonly string[]) =>
|
|
|
65
65
|
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
66
66
|
process.exitCode = runUpgrade(
|
|
67
67
|
process.argv.slice(2),
|
|
68
|
-
resolve(fileURLToPath(new URL('
|
|
68
|
+
resolve(fileURLToPath(new URL('../..', import.meta.url))),
|
|
69
69
|
)
|
|
70
70
|
}
|