acrylblends 0.7.6 → 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.
Files changed (105) hide show
  1. package/dist/index.d.ts +1 -1
  2. package/dist/index.js +148 -24
  3. package/dist/index.js.map +1 -1
  4. package/docs/framework/README.md +1 -1
  5. package/docs/framework/UPSTREAM.md +1 -1
  6. package/docs/framework/agents/index.md +18 -3
  7. package/docs/framework/getting-started/desktop.md +2 -2
  8. package/docs/framework/guides/client-modules.md +4 -3
  9. package/docs/framework/guides/data.md +72 -8
  10. package/docs/framework/helpers/streaming.md +12 -27
  11. package/docs/framework/upstream.json +2 -2
  12. package/lib/browser/read-sse.js +84 -0
  13. package/lib/index-BLTYsyc1.d.ts +93 -0
  14. package/lib/kv/db.d.ts +12 -0
  15. package/lib/kv/db.js +57 -0
  16. package/lib/kv/json.d.ts +11 -0
  17. package/lib/kv/json.js +224 -0
  18. package/lib/kv.d.ts +2 -0
  19. package/lib/kv.js +2 -0
  20. package/lib/node-server.d.ts +3 -3
  21. package/lib/node-server.js +13 -8
  22. package/lib/read-sse.d.ts +33 -0
  23. package/lib/read-sse.js +84 -0
  24. package/lib/src-DRFVEQQb.js +90 -0
  25. package/package.json +17 -1
  26. package/plugins/acryl-db/plugin.json +1 -1
  27. package/plugins/acryl-db/upstream.json +2 -2
  28. package/plugins/acryl-db-sqlite/upstream.json +1 -1
  29. package/plugins/acryl-inspect/upstream.json +1 -1
  30. package/plugins/acryl-kv/LICENSE +47 -0
  31. package/plugins/acryl-kv/plugin.json +12 -0
  32. package/plugins/acryl-kv/src/check.ts +58 -0
  33. package/plugins/acryl-kv/src/copy.ts +39 -0
  34. package/plugins/acryl-kv/src/cordis.ts +13 -0
  35. package/plugins/acryl-kv/src/index.ts +14 -0
  36. package/plugins/acryl-kv/src/kv.ts +37 -0
  37. package/plugins/acryl-kv/upstream.json +18 -0
  38. package/plugins/acryl-kv-db/LICENSE +47 -0
  39. package/plugins/acryl-kv-db/plugin.json +13 -0
  40. package/plugins/acryl-kv-db/src/index.ts +37 -0
  41. package/plugins/acryl-kv-db/src/open.ts +80 -0
  42. package/plugins/acryl-kv-db/upstream.json +21 -0
  43. package/plugins/acryl-kv-json/LICENSE +47 -0
  44. package/plugins/acryl-kv-json/plugin.json +13 -0
  45. package/plugins/acryl-kv-json/src/index.ts +71 -0
  46. package/plugins/acryl-kv-json/src/open.ts +249 -0
  47. package/plugins/acryl-kv-json/upstream.json +22 -0
  48. package/plugins/acryl-node-server/src/client-modules.ts +10 -9
  49. package/plugins/acryl-node-server/src/index.ts +12 -1
  50. package/plugins/acryl-node-server/upstream.json +2 -2
  51. package/plugins/acryl-router/src/browser/read-sse.ts +3 -0
  52. package/plugins/acryl-router/src/helpers/read-sse.ts +111 -0
  53. package/plugins/acryl-router/upstream.json +2 -2
  54. package/plugins/acryl-router-http/upstream.json +1 -1
  55. package/plugins/acryl-state/upstream.json +1 -1
  56. package/plugins/builder/index.ts +2 -2
  57. package/plugins/builder/plugin.json +1 -1
  58. package/plugins/desktop/main.ts +3 -3
  59. package/plugins/desktop/plugin.json +2 -2
  60. package/plugins/desktop/upstream.json +1 -1
  61. package/plugins/desktop-web/main.ts +2 -2
  62. package/plugins/desktop-web/plugin.json +2 -2
  63. package/plugins/desktop-web/upstream.json +1 -1
  64. package/plugins/extensions/index.ts +19 -1
  65. package/plugins/extensions/plugin.json +1 -1
  66. package/plugins/extensions/resolve.ts +1 -1
  67. package/plugins/manifest/lock.ts +7 -3
  68. package/plugins/manifest/upgrade-cli.ts +1 -1
  69. package/plugins/save/cli.ts +1 -1
  70. package/plugins/save/plugin.json +1 -1
  71. package/plugins/tsconfig.json +2 -0
  72. package/shared/AGENTS.core.md +10 -10
  73. package/shared/DEFAULTS.md +40 -0
  74. package/shared/SKILL.md +14 -0
  75. package/templates/agent/AGENTS.md +1 -1
  76. package/templates/agent/README.md +6 -6
  77. package/templates/agent/gitignore +1 -0
  78. package/templates/agent/package.json +8 -8
  79. package/templates/agent/src/app.ts +28 -15
  80. package/templates/agent/src/main.ts +2 -2
  81. package/templates/agent/src/routes.ts +5 -5
  82. package/templates/agent/test/agent.test.ts +1 -1
  83. package/templates/agent/test/app.test.ts +1 -1
  84. package/templates/agent/test/builder.test.ts +1 -1
  85. package/templates/agent/test/model.test.ts +1 -1
  86. package/templates/agent/tsconfig.json +48 -50
  87. package/templates/blank/AGENTS.md +1 -1
  88. package/templates/blank/README.md +2 -2
  89. package/templates/blank/gitignore +1 -0
  90. package/templates/blank/package.json +8 -8
  91. package/templates/blank/src/about.tsx +2 -2
  92. package/templates/blank/src/app.ts +4 -4
  93. package/templates/blank/src/main.ts +1 -1
  94. package/templates/blank/src/routes.ts +1 -1
  95. package/templates/blank/test/app.test.ts +1 -1
  96. package/templates/blank/tsconfig.json +1 -1
  97. package/templates/todo/README.md +1 -1
  98. package/templates/todo/gitignore +1 -0
  99. package/templates/todo/package.json +8 -8
  100. package/templates/todo/src/app.ts +5 -5
  101. package/templates/todo/src/main.ts +1 -1
  102. package/templates/todo/src/todo.ts +4 -4
  103. package/templates/todo/test/todo.test.ts +1 -1
  104. package/templates/todo/tsconfig.json +1 -1
  105. package/templates/todo/upstream.json +2 -2
@@ -0,0 +1,80 @@
1
+ /**
2
+ * The plain way to use SQL as the `kv` store, for code without Cordis (an acrylblends app): `kvOnDb(db)` gives the `kv` port over a `kv`
3
+ * table of the `db` port (SQLite today, Postgres after spec 049 slice 2), so an app that grows from a JSON file into SQL keeps its data. The
4
+ * Cordis plugin is a thin wrapper over it. This entry imports nothing from Cordis.
5
+ *
6
+ * The table is created on first use with `CREATE TABLE IF NOT EXISTS`, so it needs no migration of the app's. `update` reads and writes in
7
+ * one `db.transaction`. `list` selects with `substr(key, 1, n) = ?`, not `LIKE`, so `%`, `_` and letter case need no escaping, and sorts by
8
+ * code point in the end, whatever collation the database sorts with.
9
+ */
10
+
11
+ import type { Db } from '../../acryl-db/src/index.ts'
12
+ import { checkKey, codePoints, compareKeys, decodeValue, encodeValue, type Kv, runChange } from '../../acryl-kv/src/index.ts'
13
+
14
+ export type { Kv } from '../../acryl-kv/src/index.ts'
15
+
16
+ export interface KvOnDbOptions {
17
+ /** The table that holds the documents. Defaults to `kv`; a plain SQL identifier. */
18
+ readonly table?: string
19
+ }
20
+
21
+ const IDENTIFIER = /^[a-z_][a-z0-9_]*$/u
22
+
23
+ /** The `kv` port over a `db`. It does not own the database: whoever opened it closes it, and calls after that reject. */
24
+ export function kvOnDb(db: Db, options: KvOnDbOptions = {}): Kv {
25
+ const table = options.table ?? 'kv'
26
+ if (!IDENTIFIER.test(table)) throw new TypeError(`acryl-kv-db: the table name must be a plain lower case SQL identifier, got ${JSON.stringify(table)}`)
27
+
28
+ let ready: Promise<void> | undefined
29
+ const ensure = (): Promise<void> => {
30
+ ready ??= db.exec(`CREATE TABLE IF NOT EXISTS ${table} (key TEXT PRIMARY KEY, value TEXT NOT NULL)`).catch((error: unknown) => {
31
+ ready = undefined
32
+ throw error
33
+ })
34
+ return ready
35
+ }
36
+ /**
37
+ * Run a statement after the table exists. The table's statement and this one are both handed to the database at once (its queue keeps
38
+ * their order), so a `close()` called right after a write cannot slip between them and leave the write on a closed database.
39
+ */
40
+ const after = async <T>(work: () => Promise<T>): Promise<T> => {
41
+ const created = ensure()
42
+ const result = work()
43
+ const [, value] = await Promise.all([created, result])
44
+ return value
45
+ }
46
+ const upsert = `INSERT INTO ${table} (key, value) VALUES (?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value`
47
+
48
+ return {
49
+ async get<T>(key: string) {
50
+ checkKey(key)
51
+ const row = await after(() => db.get<{ value: string }>(`SELECT value FROM ${table} WHERE key = ?`, key))
52
+ return row === undefined ? undefined : decodeValue<T>(row.value)
53
+ },
54
+ async set(key, value) {
55
+ checkKey(key)
56
+ const text = encodeValue(value)
57
+ await after(() => db.run(upsert, key, text))
58
+ },
59
+ async update<T>(key: string, change: (current: T | undefined) => T) {
60
+ checkKey(key)
61
+ return after(() => db.transaction(async tx => {
62
+ const row = await tx.get<{ value: string }>(`SELECT value FROM ${table} WHERE key = ?`, key)
63
+ const next = runChange(change, row === undefined ? undefined : decodeValue<T>(row.value))
64
+ const text = encodeValue(next)
65
+ await tx.run(upsert, key, text)
66
+ return decodeValue<T>(text)
67
+ }))
68
+ },
69
+ async delete(key) {
70
+ checkKey(key)
71
+ await after(() => db.run(`DELETE FROM ${table} WHERE key = ?`, key))
72
+ },
73
+ async list(prefix = '') {
74
+ const rows = await after(() => prefix === ''
75
+ ? db.all<{ key: string }>(`SELECT key FROM ${table}`)
76
+ : db.all<{ key: string }>(`SELECT key FROM ${table} WHERE substr(key, 1, ?) = ?`, codePoints(prefix), prefix))
77
+ return rows.map(row => row.key).sort(compareKeys)
78
+ },
79
+ }
80
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "source": {
3
+ "repo": "acryldev/acryl",
4
+ "path": "plugins/acryl-kv-db",
5
+ "commit": "f132283c43337f5e1d58dfbbbc15786efb5e756c"
6
+ },
7
+ "vendored": [
8
+ "src",
9
+ "tests",
10
+ "LICENSE"
11
+ ],
12
+ "rewrittenSpecifiers": 10,
13
+ "digest": "sha256:266a44080a78b3d10fc05e5b83a39599e44a5be998eb97368aa07507ac4e3a48",
14
+ "testsNotRun": {
15
+ "tests/loader.spec.ts": "loads the plugin through the real Loader (@deepseek-ai/cordis-plugin-loader), which generated apps do not install; kvOnDb is tested here by open.spec.ts and conformance.spec.ts",
16
+ "tests/plain-entry-imports.spec.ts": "a copy vendored into an app (with relative imports, no build of its own) has neither `lib/`; the app's own typecheck is what proves the copy names no Cordis type it does not provide."
17
+ },
18
+ "notice": [
19
+ "LICENSE"
20
+ ]
21
+ }
@@ -0,0 +1,47 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 WebBoxes s.r.o.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+ ---
24
+
25
+ The files under src/facade are derived from the SQLite facade of chord in pi (https://github.com/earendil-works/pi, packages/chord, commit d5b3de22d0e24a3e9c38a9410d3329b099717283), used under this license:
26
+
27
+ MIT License
28
+
29
+ Copyright (c) 2025 Mario Zechner
30
+
31
+ Permission is hereby granted, free of charge, to any person obtaining a copy
32
+ of this software and associated documentation files (the "Software"), to deal
33
+ in the Software without restriction, including without limitation the rights
34
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
35
+ copies of the Software, and to permit persons to whom the Software is
36
+ furnished to do so, subject to the following conditions:
37
+
38
+ The above copyright notice and this permission notice shall be included in all
39
+ copies or substantial portions of the Software.
40
+
41
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
42
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
43
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
44
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
45
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
46
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
47
+ SOFTWARE.
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "acryl-kv-json",
3
+ "description": "The JSON-file provider of the kv port: one file in the app home, rewritten atomically (temp file, then rename) on every write. Vendored from ACRYL.",
4
+ "requires": ["acryl-kv", "instance"],
5
+ "inject": [],
6
+ "provides": ["kv"],
7
+ "dependencies": {
8
+ "@deepseek-ai/cordis": "4.0.4",
9
+ "@deepseek-ai/schemastery": "3.18.4"
10
+ },
11
+ "mount": "mount it after instance: root.plugin(kvJson, {}) (the file is kv.json in the app home, or set path); then a plugin that declares inject: ['kv'] calls await ctx.kv.set('theme', 'dark') and ctx.kv.update('visits', (n) => (n ?? 0) + 1)",
12
+ "omit": ["**/*.test.*", "**/*.spec.*", "**/*.case.ts"]
13
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * The JSON-file provider of the `kv` port (spec 049 slice 3): one file, rewritten atomically on every write, opened with the plain
3
+ * `openKv` and provided as `kv` by this Cordis plugin. App plugins declare `inject: ['kv']` and import types from `acryl-kv` only, so
4
+ * replacing this row with `acryl-kv-db` does not touch them.
5
+ *
6
+ * Unloading withdraws `kv` first, so consumers stop calling, and then closes the store: the close waits for the writes in flight.
7
+ */
8
+
9
+ import { dirname, isAbsolute, join, resolve } from 'node:path'
10
+ import { fileURLToPath } from 'node:url'
11
+ import type { Context } from '@deepseek-ai/cordis'
12
+ import z from '@deepseek-ai/schemastery'
13
+ import { KV_SERVICE } from '../../acryl-kv/src/index.ts'
14
+ import { openKv } from './open.ts'
15
+
16
+ export { openKv, type OpenKvOptions } from './open.ts'
17
+
18
+ /** Stable Cordis plugin name, equal to the package name and the Loader row id. */
19
+ export const name = 'acryl-kv-json'
20
+
21
+ /**
22
+ * What this plugin reads of the runtime's optional `appInstance` service: its home folder. The service belongs to the runtime (and to an
23
+ * app's own instance plugin), so this plugin declares no type for it on Cordis' Context; it checks the one field it uses at runtime.
24
+ */
25
+ interface AppHome {
26
+ readonly home: string
27
+ }
28
+
29
+ function isAppHome(value: unknown): value is AppHome {
30
+ return typeof value === 'object' && value !== null && 'home' in value && typeof value.home === 'string'
31
+ }
32
+
33
+ export interface Config {
34
+ path?: string
35
+ }
36
+
37
+ export const Config: z<Config> = z.object({
38
+ path: z.string().description('The JSON file, absolute or relative to the app. Defaults to kv.json in the ACRYL app home; required where there is none.'),
39
+ })
40
+
41
+ /** The file name used in the app home when `path` is not configured. */
42
+ export const DEFAULT_FILE = 'kv.json'
43
+
44
+ /** A configured path as a file path: absolute as given, relative to the Loader's base URL (the app) otherwise. */
45
+ function resolveFromApp(ctx: Context, path: string): string {
46
+ if (path.startsWith('file:')) return fileURLToPath(path)
47
+ if (isAbsolute(path)) return path
48
+ const base = ctx.baseUrl
49
+ if (base === undefined || !base.startsWith('file:')) {
50
+ throw new Error(`acryl-kv-json: path ${JSON.stringify(path)} is relative, but this app has no file base URL to resolve it from; give an absolute path`)
51
+ }
52
+ const appDir = base.endsWith('/') ? fileURLToPath(base) : dirname(fileURLToPath(base))
53
+ return resolve(appDir, path)
54
+ }
55
+
56
+ export async function apply(ctx: Context, rawConfig: Config = {}): Promise<void> {
57
+ const config = Config(rawConfig)
58
+ // The app home comes from the runtime's `appInstance` service when this runs inside an ACRYL app; a plain framework app names its file.
59
+ const appInstance: unknown = ctx.get('appInstance')
60
+ let file: string
61
+ if (config.path !== undefined) file = resolveFromApp(ctx, config.path)
62
+ else if (isAppHome(appInstance)) file = join(appInstance.home, DEFAULT_FILE)
63
+ else throw new Error('acryl-kv-json: no file path; set `path` in this row\'s config (there is no ACRYL app home to default to)')
64
+
65
+ // Disposers run in reverse: the service is withdrawn before the store closes.
66
+ await ctx.effect(async function* () {
67
+ const opened = await openKv({ path: file })
68
+ yield () => opened.close()
69
+ yield ctx.reflect.provide(KV_SERVICE, opened.kv)
70
+ }, `acryl-kv-json: ${file}`)
71
+ }
@@ -0,0 +1,249 @@
1
+ /**
2
+ * The plain way to open the JSON-file provider, for code without Cordis (an acrylblends app): `openKv({ path })` reads one JSON file into
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
+ * path. This entry imports nothing from Cordis.
5
+ *
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.
12
+ */
13
+
14
+ import { mkdir, open, readdir, readFile, rename, rm } from 'node:fs/promises'
15
+ import { basename, dirname, join, resolve } from 'node:path'
16
+ import { checkKey, compareKeys, decodeValue, encodeValue, type Kv, type KvOpened, runChange } from '../../acryl-kv/src/index.ts'
17
+
18
+ export type { Kv, KvOpened } from '../../acryl-kv/src/index.ts'
19
+
20
+ export interface OpenKvOptions {
21
+ /** The JSON file; its folder is created if missing. */
22
+ readonly path: string
23
+ }
24
+
25
+ /** The file's own format version, for a later change of layout. */
26
+ const FORMAT = 1
27
+
28
+ /** Stray temporary files from a crash end in `.tmp` and start with the file's name. */
29
+ const tempPrefix = (file: string): string => `${basename(file)}.`
30
+
31
+ /** The file content for a map of key to JSON text, keys in code point order, so the same data is the same bytes. */
32
+ function serialize(entries: ReadonlyMap<string, string>): string {
33
+ const body = [...entries.keys()].sort(compareKeys).map(key => `${JSON.stringify(key)}:${entries.get(key)}`).join(',')
34
+ return `{"version":${FORMAT},"entries":{${body}}}\n`
35
+ }
36
+
37
+ function unreadable(file: string, reason: string): Error {
38
+ return new Error(`acryl-kv-json: ${file} is not a readable kv file (${reason}); it was not changed`)
39
+ }
40
+
41
+ /** The entries of a file, or none when it does not exist. A file that exists and does not parse is an error: it is never started over. */
42
+ async function load(file: string): Promise<Map<string, string>> {
43
+ let text: string
44
+ try {
45
+ text = await readFile(file, 'utf8')
46
+ } catch (error) {
47
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return new Map()
48
+ throw error
49
+ }
50
+ let parsed: unknown
51
+ try {
52
+ parsed = JSON.parse(text)
53
+ } catch (error) {
54
+ throw unreadable(file, (error as Error).message)
55
+ }
56
+ if (typeof parsed !== 'object' || parsed === null || (parsed as { version?: unknown }).version !== FORMAT) throw unreadable(file, 'not a version 1 kv file')
57
+ const entries = (parsed as { entries?: unknown }).entries
58
+ if (typeof entries !== 'object' || entries === null || Array.isArray(entries)) throw unreadable(file, 'no entries object')
59
+ return new Map(Object.entries(entries).map(([key, value]) => [key, JSON.stringify(value)]))
60
+ }
61
+
62
+ /** Remove the temporary files an earlier crash left beside the file. */
63
+ async function removeStrays(file: string): Promise<void> {
64
+ const prefix = tempPrefix(file)
65
+ for (const name of await readdir(dirname(file))) {
66
+ if (name.startsWith(prefix) && name.endsWith('.tmp')) await rm(join(dirname(file), name), { force: true })
67
+ }
68
+ }
69
+
70
+ const RENAME_RETRIES = 8
71
+
72
+ /** Rename over the target. Windows refuses while a scanner or an indexer holds the target for a moment, so it is retried briefly. */
73
+ async function replace(from: string, to: string): Promise<void> {
74
+ for (let attempt = 0; ; attempt += 1) {
75
+ try {
76
+ await rename(from, to)
77
+ return
78
+ } catch (error) {
79
+ const code = (error as NodeJS.ErrnoException).code
80
+ if (attempt >= RENAME_RETRIES || (code !== 'EPERM' && code !== 'EBUSY' && code !== 'EACCES')) throw error
81
+ await new Promise(resolveWait => setTimeout(resolveWait, 10 * (attempt + 1)))
82
+ }
83
+ }
84
+ }
85
+
86
+ /** Flush the folder entry of a renamed file (POSIX); Windows cannot open a folder, and a filesystem that refuses is no worse than before. */
87
+ async function flushFolder(folder: string): Promise<void> {
88
+ if (process.platform === 'win32') return
89
+ try {
90
+ const handle = await open(folder, 'r')
91
+ try {
92
+ await handle.sync()
93
+ } finally {
94
+ await handle.close()
95
+ }
96
+ } catch {
97
+ // Best effort: the rename itself is already atomic.
98
+ }
99
+ }
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
+
115
+ /** Open (and create, when missing) a JSON-file store. @throws when the file exists and is not a readable kv file. */
116
+ export async function openKv(options: OpenKvOptions): Promise<KvOpened> {
117
+ const file = resolve(options.path)
118
+ await mkdir(dirname(file), { recursive: true })
119
+ await removeStrays(file)
120
+ let state = await load(file)
121
+ let closed = false
122
+ let sequence = 0
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()
129
+
130
+ const guard = (): void => {
131
+ if (closed) throw new Error(`acryl-kv-json: ${file} is closed`)
132
+ }
133
+
134
+ /** Persist a map: the file is the old one or this one, never between. */
135
+ const persist = async (next: ReadonlyMap<string, string>): Promise<void> => {
136
+ sequence += 1
137
+ const temp = join(dirname(file), `${tempPrefix(file)}${process.pid}.${sequence}.tmp`)
138
+ try {
139
+ const handle = await open(temp, 'w')
140
+ try {
141
+ await handle.writeFile(serialize(next), 'utf8')
142
+ await handle.sync()
143
+ } finally {
144
+ await handle.close()
145
+ }
146
+ await replace(temp, file)
147
+ } catch (error) {
148
+ await rm(temp, { force: true })
149
+ throw error
150
+ }
151
+ await flushFolder(dirname(file))
152
+ }
153
+
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 = []
173
+ const draft = new Map(state)
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
+ }
194
+ state = draft
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 })
204
+ })
205
+ if (!draining) {
206
+ draining = true
207
+ drained = drain()
208
+ }
209
+ return done
210
+ }
211
+
212
+ const kv: Kv = {
213
+ async get<T>(key: string) {
214
+ guard()
215
+ checkKey(key)
216
+ const text = state.get(key)
217
+ return text === undefined ? undefined : decodeValue<T>(text)
218
+ },
219
+ async set(key, value) {
220
+ checkKey(key)
221
+ const text = encodeValue(value)
222
+ await write(() => ({ key, text, result: undefined }))
223
+ },
224
+ async update<T>(key: string, change: (current: T | undefined) => T) {
225
+ checkKey(key)
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) }
230
+ })
231
+ },
232
+ async delete(key) {
233
+ checkKey(key)
234
+ await write(() => ({ key, text: undefined, result: undefined }))
235
+ },
236
+ async list(prefix = '') {
237
+ guard()
238
+ return [...state.keys()].filter(key => key.startsWith(prefix)).sort(compareKeys)
239
+ },
240
+ }
241
+
242
+ return {
243
+ kv,
244
+ close: async () => {
245
+ closed = true
246
+ await drained
247
+ },
248
+ }
249
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "source": {
3
+ "repo": "acryldev/acryl",
4
+ "path": "plugins/acryl-kv-json",
5
+ "commit": "f132283c43337f5e1d58dfbbbc15786efb5e756c"
6
+ },
7
+ "vendored": [
8
+ "src",
9
+ "tests",
10
+ "LICENSE"
11
+ ],
12
+ "rewrittenSpecifiers": 6,
13
+ "digest": "sha256:10613b53bdcf15cce86d6dce5e606d0cd93367cc2569b7603cbf157ebc557548",
14
+ "testsNotRun": {
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
+ "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",
17
+ "tests/plain-entry-imports.spec.ts": "a copy vendored into an app (with relative imports, no build of its own) has neither `lib/`; the app's own typecheck is what proves the copy names no Cordis type it does not provide."
18
+ },
19
+ "notice": [
20
+ "LICENSE"
21
+ ]
22
+ }
@@ -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`, and
5
- * `acrylblends/client`) are rewritten to two self-contained files the same app serves, at a URL relative to each module, so a page needs
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 two browser files into a folder, and with `NODE_ENV=production` and `compiled` set, only that folder is served.
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 two browser files are served from, below the app's mount. A source folder of this name is not served. */
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 `client.js`; the framework's own when not given. */
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 `client.js`; the framework's own when not given. */
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 two browser files, next to the built module that contains this code. */
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 two
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`.
@@ -93,13 +93,24 @@ export class NodeServer extends Service {
93
93
  this.bound = (this.server.address() as AddressInfo).port
94
94
  return
95
95
  } catch (error) {
96
- if ((error as NodeJS.ErrnoException).code !== 'EADDRINUSE') throw error
96
+ if (!isPortUnavailable(error)) throw error
97
97
  }
98
98
  }
99
99
  throw new Error(`acryl-node-server: no free port from ${String(port)} to ${String(last)} on ${hostname}`)
100
100
  }
101
101
  }
102
102
 
103
+ /**
104
+ * Whether a failed bind means "this port, try the next one". A port another socket holds answers `EADDRINUSE` everywhere. On Windows a port
105
+ * inside a range the system reserves (Hyper-V, WSL and Docker exclude ranges, `netsh interface ipv4 show excludedportrange`) answers
106
+ * `EACCES`, which is a property of that port too; elsewhere `EACCES` means the process may not bind low ports at all, so moving on would
107
+ * only hide the reason.
108
+ */
109
+ export function isPortUnavailable(error: unknown, platform: NodeJS.Platform = process.platform): boolean {
110
+ const code = (error as NodeJS.ErrnoException | undefined)?.code
111
+ return code === 'EADDRINUSE' || (platform === 'win32' && code === 'EACCES')
112
+ }
113
+
103
114
  /** Loads once the server listens, so a plugin that injects `nodeServer` can rely on `url`. A failure to bind fails the plugin. */
104
115
  export async function apply(ctx: Context, config: Partial<Config> = {}): Promise<void> {
105
116
  const server = new NodeServer(ctx, { hostname: '127.0.0.1', port: 0, scan: 20, ...config })
@@ -2,7 +2,7 @@
2
2
  "source": {
3
3
  "repo": "acryldev/acryl",
4
4
  "path": "plugins/acryl-node-server",
5
- "commit": "8e23e14336647943d4232047e3ea253e434535d3"
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:0a3278e006540d0dbecdfb6ada3ca865f3a9a78b649edd9454dab0c76281eda0",
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,3 @@
1
+ /** `readSSE` (`acrylblends/read-sse`) as one browser module with no imports, served by `clientModules`. */
2
+
3
+ export * from '../helpers/read-sse.ts'