@pedit/serve 0.8.2 → 0.8.4
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 +187 -45
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,16 +2,29 @@
|
|
|
2
2
|
|
|
3
3
|
Run [`pedit serve`](https://github.com/piconic-ai/pedit) for a local app. pedit holds one file's Yjs
|
|
4
4
|
document and writes the file; this package starts it, checks every request, and bridges a browser's
|
|
5
|
-
WebSocket to a stream on pedit's standard input and output. Your page edits the file live, with several
|
|
6
|
-
there is no room, relay or account.
|
|
5
|
+
WebSocket to a stream on pedit's standard input and output. Your page edits the file live, with several
|
|
6
|
+
pages at once, and there is no room, relay or account until the app shares the file.
|
|
7
7
|
|
|
8
8
|
Experimental (0.x): the API may change. The package is published with every pedit release and
|
|
9
|
-
carries the same version number
|
|
9
|
+
carries the same version number. The pedit it needs depends on what you use:
|
|
10
|
+
|
|
11
|
+
| To | pedit |
|
|
12
|
+
| --- | --- |
|
|
13
|
+
| Edit a file from your pages | 0.5.0 or later (`MIN_PEDIT_VERSION`) |
|
|
14
|
+
| Share a file with people outside, through a room | 0.6.0 or later |
|
|
15
|
+
| Read or change a room's keep (`status`, `keep`) | 0.8.1 or later |
|
|
16
|
+
| Comment as the host, and get the threads | 0.8.2 or later |
|
|
17
|
+
|
|
18
|
+
A pedit older than 0.6.0 (serve surface 1) rejects every room command with `sharing needs pedit
|
|
19
|
+
serve 2; this pedit is older`; one that lacks a later command rejects it with `pedit is too old for
|
|
20
|
+
status; update it` (or `keep`, `comment`, `reply`, `resolve`). The child goes on.
|
|
10
21
|
|
|
11
22
|
## Requirements
|
|
12
23
|
|
|
13
|
-
- pedit
|
|
14
|
-
|
|
24
|
+
- pedit on the PATH (or pass `bin`). Older ones are refused with a message on how to update. Or let
|
|
25
|
+
your app [download it](#installing-pedit-for-the-user) once, if you opt in.
|
|
26
|
+
- Node 22 or later, on macOS, Linux or Windows. On Windows, pedit stops a child by closing its input
|
|
27
|
+
and never kills it: a child that does not exit in time is reported as an error event.
|
|
15
28
|
|
|
16
29
|
## Install
|
|
17
30
|
|
|
@@ -23,7 +36,9 @@ npm install hono @hono/node-server @hono/node-ws # for the Hono routes
|
|
|
23
36
|
`@pedit/serve/client` needs `yjs`, `y-protocols` and `y-websocket`; `@pedit/serve/hono` needs `hono`.
|
|
24
37
|
They are peers, so your app keeps one copy of Yjs.
|
|
25
38
|
|
|
26
|
-
##
|
|
39
|
+
## Quick start
|
|
40
|
+
|
|
41
|
+
The server mounts the routes under a path and says which ids exist:
|
|
27
42
|
|
|
28
43
|
```js
|
|
29
44
|
import { serve } from '@hono/node-server'
|
|
@@ -46,7 +61,7 @@ injectWebSocket(server)
|
|
|
46
61
|
process.on('SIGINT', () => routes.close().finally(() => process.exit(0)))
|
|
47
62
|
```
|
|
48
63
|
|
|
49
|
-
|
|
64
|
+
The page connects to an id and binds the text to its editor:
|
|
50
65
|
|
|
51
66
|
```js
|
|
52
67
|
import { connect } from '@pedit/serve/client'
|
|
@@ -59,41 +74,145 @@ conn.status.subscribe((status, error) => {
|
|
|
59
74
|
const extensions = [yCollab(conn.text, conn.awareness)] // plus basicSetup, EditorState.readOnly for 'read'
|
|
60
75
|
```
|
|
61
76
|
|
|
62
|
-
##
|
|
77
|
+
## `@pedit/serve/hono`
|
|
78
|
+
|
|
79
|
+
### `peditRoutes(options)`
|
|
80
|
+
|
|
81
|
+
Returns a Hono app with `POST /:id/session` and the WebSocket `GET /:id/doc`, plus the methods
|
|
82
|
+
below. Mount it with `app.route(path, routes)`.
|
|
83
|
+
|
|
84
|
+
| Option | |
|
|
85
|
+
| --- | --- |
|
|
86
|
+
| `upgradeWebSocket` | From the adapter, created on the parent app (`createNodeWebSocket({ app })`). |
|
|
87
|
+
| `root` | The directory whose files may be served; realpath'd at startup, must exist. |
|
|
88
|
+
| `origins` | The exact origins of your pages. Also used for the `Host` check. |
|
|
89
|
+
| `app` | A label another process sees if it asks pedit for a file this app holds. |
|
|
90
|
+
| `resolve(id, c)` | Maps an id to `{ path, role: 'edit' \| 'read' }` under `root`, or `null` for 404. Throw an `HTTPException` for anything else. |
|
|
91
|
+
| `bin` | Absolute path to pedit. Default: `pedit` on the PATH. |
|
|
92
|
+
| `idleMs` | Stop the child this long after its last page left. Default 60 s. A shared file is never stopped for idleness. |
|
|
93
|
+
| `graceMs` | How long a child may take to exit after its input closed, before SIGTERM. Default 5 s. On Windows it is then given up on, never killed. |
|
|
94
|
+
| `allowRemote` | Accept peers that are not on loopback. Default `false`. |
|
|
95
|
+
| `onEvent(event)` | See [events](#events). |
|
|
96
|
+
|
|
97
|
+
Types: `PeditOptions`, `PeditRoutes`, `PeditEvent`, `Role`. The helpers `isLoopback(address)` and
|
|
98
|
+
`inside(root, path)` are exported too.
|
|
99
|
+
|
|
100
|
+
### Methods on the routes
|
|
101
|
+
|
|
102
|
+
Every method takes an absolute `path` to a file under `root`, checked as the routes check it: a
|
|
103
|
+
path outside `root`, a link out of it or a missing file rejects with `file not found` and starts no
|
|
104
|
+
child.
|
|
105
|
+
|
|
106
|
+
| Method | |
|
|
107
|
+
| --- | --- |
|
|
108
|
+
| `share(path, opts?)` | Open a room for the file; see [sharing](#sharing). |
|
|
109
|
+
| `unshare(path)` | Leave the room: a kept room stays kept, a live one ends. |
|
|
110
|
+
| `close(path?)` | With a path, end that file's kept room for good (a live room is only left). Without, stop every child. |
|
|
111
|
+
| `status(path)` | The file's room, shared now or kept without pedit; `null` when it has none. |
|
|
112
|
+
| `keep(path, keep)` | Change how long the relay keeps the room after pedit leaves. |
|
|
113
|
+
| `comment(path, range)`, `reply(path, thread, body)`, `resolve(path, thread, resolved)` | Take part in the file's comments as the host; see [comments](#comments). |
|
|
114
|
+
|
|
115
|
+
### Events
|
|
63
116
|
|
|
64
|
-
|
|
117
|
+
`onEvent` is called with:
|
|
65
118
|
|
|
66
|
-
|
|
|
119
|
+
| Event | |
|
|
67
120
|
| --- | --- |
|
|
68
|
-
| `
|
|
69
|
-
| `
|
|
70
|
-
| `
|
|
71
|
-
| `
|
|
121
|
+
| `started` | `{ id, path, version?, serve? }`: a child a page opened through an id is up. `version` is pedit's version and `serve` its surface version (2 from pedit 0.6.0), when pedit reports them. A child that `share` or `status` started before any page connected is not reported here; the `ServedFiles` layer reports every child. |
|
|
122
|
+
| `stopped` | `{ id, path, reason: 'idle' \| 'exited' \| 'shutdown' }`, for the same children as `started`. |
|
|
123
|
+
| `error` | `{ id, message }`; `id` is `null` when no id is involved. |
|
|
124
|
+
| `update_available` | `{ version, url, security }`, once per version, when pedit finds a newer release. |
|
|
125
|
+
| `shared` | The result of a `share`, with `path` and `id`. |
|
|
126
|
+
| `peers` | `{ path, id, peers: [{ name, access }], more? }`: who is in the room; `more` counts people left out. |
|
|
127
|
+
| `comments` | `{ path, id, threads, more? }`: the file's threads; see [comments](#comments). |
|
|
128
|
+
| `room_ended` | `{ path, id, reason: 'closed' \| 'expired' \| 'maintenance' }`, also when the relay ended the room unasked. |
|
|
129
|
+
| `unshared` | `{ path, id }` |
|
|
130
|
+
|
|
131
|
+
For the room events, `id` is the one the routes last served that path under, or `null`.
|
|
132
|
+
|
|
133
|
+
## `@pedit/serve/client`
|
|
72
134
|
|
|
73
|
-
|
|
135
|
+
### `connect(base, id, options?)`
|
|
74
136
|
|
|
75
|
-
|
|
137
|
+
Opens `${base}/${id}`, where `base` is the same-origin path the routes are mounted at (`'/edit'`, not
|
|
138
|
+
a full URL): issues a session, connects, and resolves after the first sync with a `Connection`. It reconnects with a fresh token until the server says the
|
|
139
|
+
document is gone. `options` is `{ name?, color? }` for awareness.
|
|
140
|
+
|
|
141
|
+
### `Connection`
|
|
142
|
+
|
|
143
|
+
| Field | |
|
|
76
144
|
| --- | --- |
|
|
77
|
-
| `
|
|
78
|
-
| `
|
|
79
|
-
| `
|
|
145
|
+
| `doc`, `text` | The `Y.Doc` and `doc.getText('content')`, the file's text. |
|
|
146
|
+
| `awareness` | Names and cursors. Unauthenticated: anyone who can connect can claim any name. |
|
|
147
|
+
| `role` | `'edit'` or `'read'`. For `read`, no update is ever sent; also make the editor read-only. |
|
|
148
|
+
| `status` | `get()` and `subscribe(cb)`; states `connecting`, `connected`, `reconnecting`, `closed`. The callback gets a `PeditError` when a state has a cause. |
|
|
149
|
+
| `synced` | Resolves after the first sync. |
|
|
150
|
+
| `destroy()` | Closes the connection. |
|
|
80
151
|
|
|
81
|
-
|
|
152
|
+
### `PeditError`
|
|
82
153
|
|
|
83
|
-
|
|
154
|
+
`error.code` (a `PeditErrorCode`) is one of:
|
|
155
|
+
|
|
156
|
+
| Code | |
|
|
84
157
|
| --- | --- |
|
|
85
|
-
| `
|
|
86
|
-
| `
|
|
87
|
-
| `
|
|
88
|
-
| `
|
|
158
|
+
| `not-found` | `resolve` returned `null` for the id. |
|
|
159
|
+
| `held` | Another process holds the file, and the page's role is `edit`. |
|
|
160
|
+
| `busy` | pedit could not serve the file just now: it is missing or not usable, the file or client limit is reached, the child failed to start, or another process holds a file the page would only read. The server reports the cause as an `error` event. |
|
|
161
|
+
| `forbidden` | The Host or Origin did not match, or the peer is not on loopback. |
|
|
162
|
+
| `gone` | pedit's saved state was discarded, so this page's copy cannot merge into the new document. The client stops. |
|
|
163
|
+
| `unavailable` | The session request failed, or its answer could not be read. |
|
|
164
|
+
|
|
165
|
+
Types: `Connection`, `Status`, `PeditErrorCode`, `Role`.
|
|
166
|
+
|
|
167
|
+
## `@pedit/serve`
|
|
168
|
+
|
|
169
|
+
The layer below the routes, for an app that is not on Hono or wants its own transport.
|
|
170
|
+
|
|
171
|
+
### `ServedFiles`
|
|
172
|
+
|
|
173
|
+
`new ServedFiles({ bin, app, idleMs?, graceMs?, onEvent? })` (`ServedFilesOptions`; `bin` must be an
|
|
174
|
+
absolute path). `acquire(path)` starts a child for the file or reuses the one running, and resolves a
|
|
175
|
+
`ServedFile`; `close()` ends every child. Its `onEvent` gets a `ServeEvent`, the same events as the
|
|
176
|
+
routes but by `path` only: this layer knows paths, not ids, and reports every child.
|
|
177
|
+
|
|
178
|
+
### `ServedFile`
|
|
179
|
+
|
|
180
|
+
| Member | |
|
|
181
|
+
| --- | --- |
|
|
182
|
+
| `path`, `generation` | `generation` names the document: the same after a restart that restored it, new when it started afresh. |
|
|
183
|
+
| `dial(role)` | One stream to the child, as a `Bridge` (`send(frame)`, `onframe`, `onclose(reason, code?)`, `close()`); at most 8 per file (`LimitError`). |
|
|
184
|
+
| `share(opts?)`, `unshare()`, `close()`, `status()`, `keep(keep)` | As the routes' methods, for this file; see [sharing](#sharing). `close()` ends a kept room for good; a live one is only left. |
|
|
185
|
+
| `comment(range)`, `reply(thread, body)`, `resolve(thread, resolved)` | See [comments](#comments). |
|
|
186
|
+
|
|
187
|
+
### `resolvePedit(options?)`, `MIN_PEDIT_VERSION`, `peditVersionOk(output)`
|
|
188
|
+
|
|
189
|
+
`resolvePedit({ bin?, install? })` finds pedit, checks the binary and its version, and resolves
|
|
190
|
+
`{ bin, version, installed? }`. With `install: true` it downloads pedit when none is usable; see
|
|
191
|
+
[installing pedit for the user](#installing-pedit-for-the-user). `peditVersionOk(output)` tells
|
|
192
|
+
whether a `pedit --version` output is at least `MIN_PEDIT_VERSION` (development builds pass and are
|
|
193
|
+
left to the feature checks).
|
|
194
|
+
|
|
195
|
+
Types: `ServedFilesOptions`, `ServedFile`, `ServeEvent`, `Bridge`, `Installed`, `Verified`, and the
|
|
196
|
+
share and comment types `ShareOptions`, `ShareResult` (`Shared`, `ShareChoice`, `ShareClosed`),
|
|
197
|
+
`ShareStatus`, `Peer`, `CommentRange`, `CommentThread`, `CommentMessage`, `StartedThread`, `Role`.
|
|
198
|
+
|
|
199
|
+
### Errors
|
|
200
|
+
|
|
201
|
+
| Class | |
|
|
202
|
+
| --- | --- |
|
|
203
|
+
| `HeldError` | Another process serves the file. |
|
|
204
|
+
| `LimitError` | 32 files, or 8 clients on one file. |
|
|
205
|
+
| `NoPeditError` | pedit is missing or not trustworthy (not an absolute path, not a regular file, owned by another user, writable by group or others, or `--version` failed), too old, or could not be installed. |
|
|
89
206
|
|
|
90
207
|
## Sharing
|
|
91
208
|
|
|
92
|
-
`
|
|
209
|
+
`share(path, opts)` opens a room for the file on the relay, so people outside the app can edit,
|
|
210
|
+
comment on or read it from a page of yours ([your own review page](https://github.com/piconic-ai/pedit/blob/main/docs/review-page.md)).
|
|
211
|
+
While the file is shared, its child is not stopped for idleness.
|
|
93
212
|
|
|
94
213
|
```ts
|
|
95
214
|
const result = await routes.share('/home/me/notes/a.md', {
|
|
96
|
-
origins: ['https://cms.example.com'], // pages that may open the room
|
|
215
|
+
origins: ['https://cms.example.com'], // pages that may open the room (at most 4)
|
|
97
216
|
keep: '1d', // how long the relay keeps it after you leave; '0' is a live room
|
|
98
217
|
})
|
|
99
218
|
if (result.type === 'shared') {
|
|
@@ -101,27 +220,36 @@ if (result.type === 'shared') {
|
|
|
101
220
|
}
|
|
102
221
|
```
|
|
103
222
|
|
|
104
|
-
`
|
|
223
|
+
`opts` also takes `relay`, the relay's URL (default: the project's, or `https://edit.piconic.ai`),
|
|
224
|
+
and `choice`, the user's answer to a `choice` result. `share` resolves one of:
|
|
105
225
|
|
|
106
226
|
| Result | Meaning |
|
|
107
227
|
| --- | --- |
|
|
108
|
-
| `{ type: 'shared', room, links: { edit, comment?, read }, keptUntil, resumed }` | The room is open. A link is `<room>.<fragment>`: the page link is `<page URL>#<link>`. |
|
|
228
|
+
| `{ type: 'shared', room, links: { edit, comment?, read }, keptUntil, resumed }` | The room is open. A link is `<room>.<fragment>`: the page link is `<page URL>#<link>`. `keptUntil` is Unix ms, 0 when the relay keeps nothing. |
|
|
109
229
|
| `{ type: 'choice', room, choices }` | The file has a kept room of another history. Ask the user which to keep (`room`, `app` or `later`; `text` is the characters each side holds), then call `share` again with the same options and `choice`. |
|
|
110
230
|
| `{ type: 'closed' }` | `choice: 'app'` ended the kept room, so nothing is shared. Call `share` again to open a new one. |
|
|
111
231
|
|
|
112
|
-
`unshare` leaves the room: a kept room stays until its deadline, a live one ends. `close` ends a kept
|
|
113
|
-
|
|
114
|
-
`status(path)` tells what room the file has, without changing anything, so an app that restarted can show a share that is still open. It resolves `{ room, links: { edit, comment?, read }, keep, host, ends, lifeEnd }`, or `null` when the file has no room (or the relay says it has ended). `host` is `'here'` while pedit shares the room now and `'away'` while it is kept without pedit; `keep` is in seconds, `ends` is when the room ends and `lifeEnd` the end of its life (both Unix ms). It starts the child if none runs, as pedit reads the file's record there, and a record of another history is still reported: `share` then gives the `choice`.
|
|
115
|
-
|
|
116
|
-
`keep(path, keep)` changes how long the relay keeps the room after pedit leaves (`'3d'`, as in `share`; not `'0'`), while the file is shared with a keep. It resolves the new status. It rejects when the file is not shared, the room is live, or the relay refuses; a room kept without pedit gets its new keep from the next `share`.
|
|
232
|
+
`unshare` leaves the room: a kept room stays until its deadline, a live one ends. `close` ends a kept
|
|
233
|
+
room for good. A failure from pedit rejects with its message.
|
|
117
234
|
|
|
118
|
-
|
|
235
|
+
`status(path)` tells what room the file has, without changing anything, so an app that restarted can
|
|
236
|
+
show a share that is still open. It resolves `{ room, links: { edit, comment?, read }, keep, host, ends, lifeEnd }`,
|
|
237
|
+
or `null` when the file has no room (or the relay says it has ended). `host` is `'here'` while pedit
|
|
238
|
+
shares the room now and `'away'` while it is kept without pedit; `keep` is in seconds, `ends` is when
|
|
239
|
+
the room ends and `lifeEnd` the end of its life (both Unix ms). It starts the child if none runs, as
|
|
240
|
+
pedit reads the file's record there, and a record of another history is still reported: `share` then
|
|
241
|
+
gives the `choice`.
|
|
119
242
|
|
|
120
|
-
`
|
|
243
|
+
`keep(path, keep)` changes how long the relay keeps the room after pedit leaves (`'3d'`, as in
|
|
244
|
+
`share`; not `'0'`), while the file is shared with a keep. It resolves the new status. It rejects when
|
|
245
|
+
the file is not shared, the room is live, or the relay refuses; a room kept without pedit gets its new
|
|
246
|
+
keep from the next `share`.
|
|
121
247
|
|
|
122
248
|
## Comments
|
|
123
249
|
|
|
124
|
-
While the app shares a file, it can take part in the file's comments as the host, and gets the
|
|
250
|
+
While the app shares a file, it can take part in the file's comments as the host, and gets the
|
|
251
|
+
threads live. Comments follow `docs/design/comments.md`: the same limits (a body of 1 to 4,000
|
|
252
|
+
characters, 500 threads, 200 comments a thread), written under the host's name as the room shows it.
|
|
125
253
|
|
|
126
254
|
```ts
|
|
127
255
|
const { thread } = await routes.comment('/home/me/notes/a.md', { from: 6, to: 10, body: 'Why wide?' })
|
|
@@ -129,16 +257,27 @@ await routes.reply('/home/me/notes/a.md', thread, 'Because it is.')
|
|
|
129
257
|
await routes.resolve('/home/me/notes/a.md', thread, true) // false opens it again
|
|
130
258
|
```
|
|
131
259
|
|
|
132
|
-
- `comment(path, { from, to, body })` starts a thread on `[from, to)` of the file's text, in UTF-16
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
-
|
|
260
|
+
- `comment(path, { from, to, body })` starts a thread on `[from, to)` of the file's text, in UTF-16
|
|
261
|
+
code units (what `String.prototype.slice` and Yjs count). It resolves `{ thread, message }`. Only
|
|
262
|
+
text and Markdown files take it; an empty range, one outside the text or one over 1,000 characters
|
|
263
|
+
rejects. A file with CRLF line endings counts a line break as one unit, as your page's `Y.Text` does.
|
|
264
|
+
- `reply(path, thread, body)` resolves `{ message }`. `resolve(path, thread, resolved)` resolves
|
|
265
|
+
nothing; setting the state a thread has already is not an error.
|
|
266
|
+
- They work only while the file is shared and pedit is connected to the room. Not shared, a room kept
|
|
267
|
+
with `host: 'away'`, or a connection that is down rejects with pedit's reason: a comment written
|
|
268
|
+
while nobody can see it would look delivered. What is saved stays readable in `.pedit/meta/`.
|
|
269
|
+
- The `comments` event carries the file's threads as they are, once when sharing starts (and on a
|
|
270
|
+
resume) and again whenever the comments, or the text they point into, change. A thread is
|
|
271
|
+
`{ id, quote, resolved, from, to, cut?, messages: [{ id, author, time, body }] }`. `from` and `to`
|
|
272
|
+
are where its anchor is in the text now, or `null` when it cannot be placed (its text was deleted,
|
|
273
|
+
or it is on cells or a node). The event fits 64 KiB: open threads come first, `more` counts threads
|
|
274
|
+
left out, and `cut` counts a thread's oldest comments left out. The event that shows a write comes
|
|
275
|
+
before the call resolves.
|
|
137
276
|
|
|
138
277
|
## Installing pedit for the user
|
|
139
278
|
|
|
140
279
|
Nothing is ever downloaded unless your app asks for it. Without the opt-in, a missing or old pedit
|
|
141
|
-
is a `NoPeditError
|
|
280
|
+
is a `NoPeditError`. To let the app install pedit when needed:
|
|
142
281
|
|
|
143
282
|
```js
|
|
144
283
|
const { bin, version, installed } = await resolvePedit({ install: true })
|
|
@@ -168,12 +307,14 @@ never touched.
|
|
|
168
307
|
## Security model
|
|
169
308
|
|
|
170
309
|
- One hook, `resolve`, decides which ids exist and their role. Every other request is refused by default.
|
|
171
|
-
- Files must be under `root` (realpath checked); ids are `[A-Za-z0-9_-]` only.
|
|
310
|
+
- Files must be under `root` (realpath checked); ids are `[A-Za-z0-9_-]` only, up to 128 characters.
|
|
172
311
|
- Host and Origin must match `origins`, and peers must be on loopback unless `allowRemote` is set.
|
|
173
312
|
- A session token is single use and expires in 60 s; it travels in the WebSocket URL, so log no `token`.
|
|
174
313
|
- A `read` role is enforced by pedit itself, not just by the page: updates from a reader close the socket.
|
|
175
314
|
- The browser never sees a path, a pid or a socket name.
|
|
176
|
-
- On Windows, `resolvePedit` cannot check the owner or write access of the pedit binary it finds on the
|
|
315
|
+
- On Windows, `resolvePedit` cannot check the owner or write access of the pedit binary it finds on the
|
|
316
|
+
PATH, so a writable PATH directory (such as `C:\tools`) could hold a planted `pedit.exe`. Pass an
|
|
317
|
+
absolute `bin` that you trust.
|
|
177
318
|
|
|
178
319
|
## Known limits
|
|
179
320
|
|
|
@@ -184,5 +325,6 @@ never touched.
|
|
|
184
325
|
- Don't write the file yourself while a page is connected.
|
|
185
326
|
|
|
186
327
|
Design: [docs/design/serve.md](https://github.com/piconic-ai/pedit/blob/main/docs/design/serve.md).
|
|
328
|
+
Guide: [embedding pedit](https://github.com/piconic-ai/pedit/blob/main/docs/embed.md).
|
|
187
329
|
Sample: [examples/serve-app](https://github.com/piconic-ai/pedit/tree/main/examples/serve-app).
|
|
188
330
|
MIT license.
|
package/package.json
CHANGED