@pedit/serve 0.0.0-stage → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 kobaken
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.
package/README.md CHANGED
@@ -1,3 +1,108 @@
1
- # Temporary Holding Version
1
+ # @pedit/serve
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Run [`pedit serve`](https://github.com/piconic-ai/pedit) for a local app. pedit holds one file's Yjs
4
+ document and writes the file; this package starts it, checks every request, and bridges a browser's
5
+ WebSocket to pedit's Unix socket. Your page edits the file live, with several pages at once, and
6
+ there is no room, relay or account.
7
+
8
+ Experimental (0.x): the API may change. The package is published with every pedit release and
9
+ carries the same version number; the pedit it needs is listed below.
10
+
11
+ ## Requirements
12
+
13
+ - pedit 0.4.0 or later on the PATH (or pass `bin`). Older ones are refused with a message on how to update.
14
+ - Node 22 or later, on macOS or Linux (Unix sockets; not Windows).
15
+
16
+ ## Install
17
+
18
+ ```sh
19
+ npm install @pedit/serve yjs y-protocols y-websocket # the browser client's peers
20
+ npm install hono @hono/node-server @hono/node-ws # for the Hono routes
21
+ ```
22
+
23
+ `@pedit/serve/client` needs `yjs`, `y-protocols` and `y-websocket`; `@pedit/serve/hono` needs `hono`.
24
+ They are peers, so your app keeps one copy of Yjs.
25
+
26
+ ## Server
27
+
28
+ ```js
29
+ import { serve } from '@hono/node-server'
30
+ import { createNodeWebSocket } from '@hono/node-ws'
31
+ import { peditRoutes } from '@pedit/serve/hono'
32
+ import { Hono } from 'hono'
33
+
34
+ const app = new Hono()
35
+ const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app })
36
+ const server = serve({ fetch: app.fetch, port: 3000, hostname: '127.0.0.1' })
37
+ const routes = peditRoutes({
38
+ upgradeWebSocket,
39
+ root: '/home/me/notes', // only files under it are served
40
+ origins: ['http://127.0.0.1:3000'],
41
+ app: 'my-app',
42
+ resolve: async (id) => (id === 'todo' ? { path: '/home/me/notes/todo.md', role: 'edit' } : null),
43
+ })
44
+ app.route('/edit', routes)
45
+ injectWebSocket(server)
46
+ process.on('SIGINT', () => routes.close().finally(() => process.exit(0)))
47
+ ```
48
+
49
+ ## Browser
50
+
51
+ ```js
52
+ import { connect } from '@pedit/serve/client'
53
+ import { yCollab } from 'y-codemirror.next'
54
+
55
+ const conn = await connect('/edit', 'todo', { name: 'Ann' })
56
+ conn.status.subscribe((status, error) => {
57
+ if (error?.code === 'gone') showReload() // your UI
58
+ })
59
+ const extensions = [yCollab(conn.text, conn.awareness)] // plus basicSetup, EditorState.readOnly for 'read'
60
+ ```
61
+
62
+ ## API
63
+
64
+ `@pedit/serve/hono`
65
+
66
+ | Export | |
67
+ | --- | --- |
68
+ | `peditRoutes(options)` | A Hono app: `POST /:id/session` and the WebSocket `GET /:id/doc`. Has `close()`. |
69
+ | `options.resolve(id, c)` | Maps an id to `{ path, role: 'edit' \| 'read' }`, or `null` for 404. |
70
+ | `options.root`, `origins`, `app` | The served directory, the exact page origins, and a label shown if another process holds the file. |
71
+ | `options.bin`, `idleMs`, `allowRemote`, `onEvent` | Absolute path to pedit; idle stop (default 60 s); accept non-loopback peers (default false); `started`/`stopped`/`error` events. |
72
+
73
+ `@pedit/serve/client`
74
+
75
+ | Export | |
76
+ | --- | --- |
77
+ | `connect(base, id, { name, color })` | Resolves after the first sync with `{ doc, text, awareness, role, status, synced, destroy }`. |
78
+ | `conn.status` | `get()` and `subscribe(cb)`; states `connecting`, `connected`, `reconnecting`, `closed`. |
79
+ | `PeditError.code` | `not-found`, `held`, `busy`, `forbidden`, `gone`, `unavailable`. |
80
+
81
+ `@pedit/serve`
82
+
83
+ | Export | |
84
+ | --- | --- |
85
+ | `ServedFiles` | The layer below the routes: `acquire(path)` starts or reuses a child, `close()` ends all. |
86
+ | `resolvePedit({ bin })`, `MIN_PEDIT_VERSION` | Finds pedit, checks the binary and its version. |
87
+ | `HeldError`, `LimitError`, `NoPeditError` | Another process serves the file; 32 files or 8 clients; pedit missing or too old. |
88
+
89
+ ## Security model
90
+
91
+ - One hook, `resolve`, decides which ids exist and their role. Every other request is refused by default.
92
+ - Files must be under `root` (realpath checked); ids are `[A-Za-z0-9_-]` only.
93
+ - Host and Origin must match `origins`, and peers must be on loopback unless `allowRemote` is set.
94
+ - A session token is single use and expires in 60 s; it travels in the WebSocket URL, so log no `token`.
95
+ - A `read` role is enforced by pedit itself, not just by the page: updates from a reader close the socket.
96
+ - The browser never sees a path, a pid or a socket name.
97
+
98
+ ## Known limits
99
+
100
+ - Unix only; one id is one file; at most 32 files and 8 clients per file.
101
+ - Awareness (names, cursors) is unauthenticated: any connected page can claim any name.
102
+ - `gone`: pedit's saved state was discarded, so the page's copy cannot merge into the new document.
103
+ The client stops; the app decides whether to offer the text or reload.
104
+ - Don't write the file yourself while a page is connected.
105
+
106
+ Design: [docs/design/serve.md](https://github.com/piconic-ai/pedit/blob/main/docs/design/serve.md).
107
+ Sample: [examples/serve-app](https://github.com/piconic-ai/pedit/tree/main/examples/serve-app).
108
+ MIT license.
@@ -0,0 +1,13 @@
1
+ import type { Role } from './types.ts';
2
+ export declare const MAX_FRAME: number;
3
+ export interface Bridge {
4
+ /** page -> pedit. A frame over 16 MiB closes the bridge. */
5
+ send(frame: Uint8Array): void;
6
+ /** pedit -> page (single handler). Frames that arrive before it is set are held. */
7
+ onframe: ((frame: Uint8Array) => void) | null;
8
+ /** `code` is pedit's close code, passed through (1005/1006 when it gave none). */
9
+ onclose: ((reason: string, code?: number) => void) | null;
10
+ close(): void;
11
+ }
12
+ /** Dial the child's socket. `ws+unix://` URLs break on paths with spaces, so hand ws the connection. */
13
+ export declare function dialSocket(socketPath: string, role: Role, onGone: () => void): Promise<Bridge>;
package/dist/bridge.js ADDED
@@ -0,0 +1,87 @@
1
+ import { connect } from 'node:net';
2
+ import { WebSocket } from 'ws';
3
+ export const MAX_FRAME = 16 * 1024 * 1024;
4
+ /** Dial the child's socket. `ws+unix://` URLs break on paths with spaces, so hand ws the connection. */
5
+ export async function dialSocket(socketPath, role, onGone) {
6
+ const ws = new WebSocket('ws://pedit/', {
7
+ createConnection: () => connect(socketPath),
8
+ headers: role === 'read' ? { 'X-Pedit-Role': 'read' } : {},
9
+ perMessageDeflate: false,
10
+ maxPayload: 4 * MAX_FRAME,
11
+ handshakeTimeout: 5000,
12
+ });
13
+ let handler = null;
14
+ const held = [];
15
+ let heldBytes = 0;
16
+ let closeHandler = null;
17
+ let ended;
18
+ let opened = false;
19
+ let closing = false;
20
+ const bridge = {
21
+ send(frame) {
22
+ if (ws.readyState !== WebSocket.OPEN)
23
+ return;
24
+ if (frame.byteLength > MAX_FRAME)
25
+ return ws.close(1009, 'frame too large');
26
+ ws.send(frame, { binary: true });
27
+ if (ws.bufferedAmount > MAX_FRAME)
28
+ ws.close(1013, 'too slow');
29
+ },
30
+ get onframe() {
31
+ return handler;
32
+ },
33
+ set onframe(fn) {
34
+ handler = fn;
35
+ heldBytes = 0;
36
+ if (fn)
37
+ for (const f of held.splice(0))
38
+ fn(f);
39
+ },
40
+ get onclose() {
41
+ return closeHandler;
42
+ },
43
+ set onclose(fn) {
44
+ closeHandler = fn;
45
+ if (fn && ended)
46
+ fn(ended.reason, ended.code);
47
+ },
48
+ close() {
49
+ if (closing)
50
+ return;
51
+ closing = true;
52
+ ws.close(1000);
53
+ // Do not wait for a stuck peer to answer: the slot must come back.
54
+ setTimeout(() => ws.terminate(), 2000).unref();
55
+ },
56
+ };
57
+ ws.on('message', (data) => {
58
+ const frame = Array.isArray(data) ? Buffer.concat(data) : new Uint8Array(data);
59
+ if (handler)
60
+ return handler(frame);
61
+ held.push(frame);
62
+ heldBytes += frame.byteLength;
63
+ if (heldBytes > MAX_FRAME)
64
+ ws.close(1013, 'no reader');
65
+ });
66
+ ws.on('close', (code, reason) => {
67
+ if (!opened || ended)
68
+ return;
69
+ ended = { reason: reason.toString(), code };
70
+ onGone();
71
+ closeHandler?.(ended.reason, ended.code);
72
+ });
73
+ // Listen before waiting for the open: pedit's first frame can arrive in the same tick.
74
+ await new Promise((resolve, reject) => {
75
+ ws.once('open', resolve);
76
+ ws.once('error', reject);
77
+ ws.once('unexpected-response', () => reject(new Error('pedit refused the connection')));
78
+ });
79
+ opened = true;
80
+ ws.removeAllListeners('error');
81
+ ws.on('error', () => ws.terminate());
82
+ // A close in the same tick as the open came before `opened` was set.
83
+ if (ws.readyState === WebSocket.CLOSED)
84
+ ws.emit('close', 1006, Buffer.alloc(0));
85
+ return bridge;
86
+ }
87
+ //# sourceMappingURL=bridge.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bridge.js","sourceRoot":"","sources":["../src/bridge.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,33 @@
1
+ import { Awareness } from 'y-protocols/awareness';
2
+ import * as Y from 'yjs';
3
+ export type Role = 'edit' | 'read';
4
+ export type Status = 'connecting' | 'connected' | 'reconnecting' | 'closed';
5
+ export type PeditErrorCode = 'not-found' | 'held' | 'busy' | 'forbidden' | 'gone' | 'unavailable';
6
+ export declare class PeditError extends Error {
7
+ code: PeditErrorCode;
8
+ constructor(code: PeditErrorCode, message?: string);
9
+ }
10
+ export interface Connection {
11
+ doc: Y.Doc;
12
+ /** `doc.getText('content')`: the file's text. */
13
+ text: Y.Text;
14
+ /** Unauthenticated: anyone who can connect can claim any name. */
15
+ awareness: Awareness;
16
+ /** For `read`, no update is ever sent; also make the editor read-only (`EditorState.readOnly.of(true)`). */
17
+ role: Role;
18
+ status: {
19
+ get(): Status;
20
+ subscribe(cb: (s: Status, error?: PeditError) => void): () => void;
21
+ };
22
+ /** Resolves after the first sync. */
23
+ synced: Promise<void>;
24
+ destroy(): void;
25
+ }
26
+ /**
27
+ * Open `${base}/${id}` (where `peditRoutes` is mounted): issue a session, connect, and resolve
28
+ * after the first sync. Reconnects with a fresh token until the server says it is gone.
29
+ */
30
+ export declare function connect(base: string, id: string, options?: {
31
+ name?: string;
32
+ color?: string;
33
+ }): Promise<Connection>;
package/dist/client.js ADDED
@@ -0,0 +1,176 @@
1
+ import { Awareness } from 'y-protocols/awareness';
2
+ import { WebsocketProvider } from 'y-websocket';
3
+ import * as Y from 'yjs';
4
+ export class PeditError extends Error {
5
+ code;
6
+ constructor(code, message = code) {
7
+ super(message);
8
+ this.name = 'PeditError';
9
+ this.code = code;
10
+ }
11
+ }
12
+ const REASONS = {
13
+ 403: 'forbidden',
14
+ 404: 'not-found',
15
+ 409: 'held',
16
+ 503: 'busy',
17
+ };
18
+ const MAX_DELAY_MS = 30_000;
19
+ async function session(url) {
20
+ let res;
21
+ try {
22
+ res = await fetch(url, { method: 'POST', credentials: 'same-origin' });
23
+ }
24
+ catch (e) {
25
+ throw new PeditError('unavailable', String(e?.message));
26
+ }
27
+ if (!res.ok)
28
+ throw new PeditError(REASONS[res.status] ?? 'unavailable', `session: ${res.status}`);
29
+ const body = (await res.json());
30
+ if (typeof body.token !== 'string' ||
31
+ (body.role !== 'edit' && body.role !== 'read') ||
32
+ typeof body.generation !== 'string') {
33
+ throw new PeditError('unavailable', 'bad session response');
34
+ }
35
+ return { token: body.token, role: body.role, generation: body.generation };
36
+ }
37
+ /**
38
+ * Open `${base}/${id}` (where `peditRoutes` is mounted): issue a session, connect, and resolve
39
+ * after the first sync. Reconnects with a fresh token until the server says it is gone.
40
+ */
41
+ export async function connect(base, id, options = {}) {
42
+ const root = `${base.replace(/\/$/, '')}/${encodeURIComponent(id)}`;
43
+ const sessionUrl = `${location.origin}${root}/session`;
44
+ const first = await session(sessionUrl);
45
+ const role = first.role;
46
+ const generation = first.generation;
47
+ const doc = new Y.Doc();
48
+ const text = doc.getText('content');
49
+ const awareness = new Awareness(doc);
50
+ const provider = new WebsocketProvider(`${location.origin.replace(/^http/, 'ws')}${root}`, 'doc', doc, { connect: false, disableBc: true, params: { token: first.token }, awareness });
51
+ // A reader never sends an update; pedit also refuses one, with close code 4003.
52
+ if (role === 'read') {
53
+ doc.off('update', provider._updateHandler);
54
+ }
55
+ if (options.name || options.color) {
56
+ const color = options.color ?? '#3b82f6';
57
+ awareness.setLocalStateField('user', {
58
+ name: options.name ?? 'guest',
59
+ color,
60
+ colorLight: `${color}33`,
61
+ });
62
+ }
63
+ let status = 'connecting';
64
+ const subs = new Set();
65
+ const setStatus = (s, error) => {
66
+ if (status === 'closed')
67
+ return;
68
+ status = s;
69
+ for (const cb of [...subs])
70
+ cb(s, error);
71
+ };
72
+ const isClosed = () => status === 'closed';
73
+ let halting = false;
74
+ // disconnect() emits connection-close itself; do not handle that one.
75
+ const halt = () => {
76
+ halting = true;
77
+ try {
78
+ provider.disconnect();
79
+ }
80
+ finally {
81
+ halting = false;
82
+ }
83
+ };
84
+ let destroyed = false;
85
+ let attempts = 0;
86
+ let timer;
87
+ const stop = (error) => {
88
+ clearTimeout(timer);
89
+ halt();
90
+ settle?.reject(error);
91
+ setStatus('closed', error);
92
+ };
93
+ async function retry() {
94
+ if (destroyed || isClosed())
95
+ return;
96
+ try {
97
+ const next = await session(sessionUrl);
98
+ if (destroyed || isClosed())
99
+ return;
100
+ // Live role changes are not supported: the update handler and the editor were set up for one role.
101
+ if (next.role !== role)
102
+ return stop(new PeditError('forbidden', 'role changed; connect again'));
103
+ // The document started afresh: merging this page's copy would duplicate its text.
104
+ if (next.generation !== generation)
105
+ return stop(new PeditError('gone', 'the document was restarted; reload'));
106
+ provider.params = { token: next.token };
107
+ provider.connect();
108
+ }
109
+ catch (e) {
110
+ const err = e instanceof PeditError ? e : new PeditError('unavailable');
111
+ if (err.code === 'forbidden' || err.code === 'not-found')
112
+ return stop(err);
113
+ schedule();
114
+ }
115
+ }
116
+ function schedule() {
117
+ clearTimeout(timer);
118
+ const delay = Math.min(MAX_DELAY_MS, 1000 * 2 ** attempts++);
119
+ timer = setTimeout(retry, delay);
120
+ }
121
+ let settle;
122
+ const synced = new Promise((resolve, reject) => {
123
+ settle = { resolve, reject };
124
+ });
125
+ synced.catch(() => { });
126
+ provider.on('sync', (isSynced) => {
127
+ if (!isSynced)
128
+ return;
129
+ attempts = 0;
130
+ setStatus('connected');
131
+ settle?.resolve();
132
+ });
133
+ provider.on('connection-close', (event) => {
134
+ if (destroyed || isClosed() || halting)
135
+ return;
136
+ halt();
137
+ // 4003: refused for good. 4001 (the child exited) reconnects: retry() re-issues a session
138
+ // and stops only if the generation changed.
139
+ if (event?.code === 4003)
140
+ return stop(new PeditError('forbidden'));
141
+ setStatus(status === 'connecting' ? 'connecting' : 'reconnecting');
142
+ schedule();
143
+ });
144
+ const connection = {
145
+ doc,
146
+ text,
147
+ awareness,
148
+ role,
149
+ status: {
150
+ get: () => status,
151
+ subscribe(cb) {
152
+ subs.add(cb);
153
+ return () => subs.delete(cb);
154
+ },
155
+ },
156
+ synced,
157
+ destroy() {
158
+ destroyed = true;
159
+ clearTimeout(timer);
160
+ halt();
161
+ provider.destroy();
162
+ awareness.destroy();
163
+ subs.clear();
164
+ },
165
+ };
166
+ provider.connect();
167
+ try {
168
+ await synced;
169
+ }
170
+ catch (e) {
171
+ connection.destroy();
172
+ throw e;
173
+ }
174
+ return connection;
175
+ }
176
+ //# sourceMappingURL=client.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client.js","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,19 @@
1
+ /** Another process (maybe another app) serves the file. The labels come from a lock file: never signal the pid. */
2
+ export declare class HeldError extends Error {
3
+ holder: {
4
+ pid: number;
5
+ app?: string;
6
+ };
7
+ constructor(holder: {
8
+ pid: number;
9
+ app?: string;
10
+ });
11
+ }
12
+ /** A hard limit was reached: 32 files, or 8 clients per file. */
13
+ export declare class LimitError extends Error {
14
+ constructor(message: string);
15
+ }
16
+ /** The pedit binary is missing or not trustworthy. */
17
+ export declare class NoPeditError extends Error {
18
+ constructor(message: string);
19
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,24 @@
1
+ /** Another process (maybe another app) serves the file. The labels come from a lock file: never signal the pid. */
2
+ export class HeldError extends Error {
3
+ holder;
4
+ constructor(holder) {
5
+ super('the file is served by another process');
6
+ this.name = 'HeldError';
7
+ this.holder = holder;
8
+ }
9
+ }
10
+ /** A hard limit was reached: 32 files, or 8 clients per file. */
11
+ export class LimitError extends Error {
12
+ constructor(message) {
13
+ super(message);
14
+ this.name = 'LimitError';
15
+ }
16
+ }
17
+ /** The pedit binary is missing or not trustworthy. */
18
+ export class NoPeditError extends Error {
19
+ constructor(message) {
20
+ super(message);
21
+ this.name = 'NoPeditError';
22
+ }
23
+ }
24
+ //# sourceMappingURL=errors.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errors.js","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,mHAAmH;AACnH,MAAM,OAAO,SAAU,SAAQ,KAAK;IAClC,MAAM,CAA+B;IACrC,YAAY,MAAqC;QAC/C,KAAK,CAAC,uCAAuC,CAAC,CAAA;QAC9C,IAAI,CAAC,IAAI,GAAG,WAAW,CAAA;QACvB,IAAI,CAAC,MAAM,GAAG,MAAM,CAAA;IACtB,CAAC;CACF;AAED,iEAAiE;AACjE,MAAM,OAAO,UAAW,SAAQ,KAAK;IACnC,YAAY,OAAe;QACzB,KAAK,CAAC,OAAO,CAAC,CAAA;QACd,IAAI,CAAC,IAAI,GAAG,YAAY,CAAA;IAC1B,CAAC;CACF;AAED,sDAAsD;AACtD,MAAM,OAAO,YAAa,SAAQ,KAAK;IACrC,YAAY,OAAe;QACzB,KAAK,CAAC,OAAO,CAAC,CAAA;QACd,IAAI,CAAC,IAAI,GAAG,cAAc,CAAA;IAC5B,CAAC;CACF"}
package/dist/hono.d.ts ADDED
@@ -0,0 +1,43 @@
1
+ import { type Context, type Env, Hono } from 'hono';
2
+ import type { UpgradeWebSocket } from 'hono/ws';
3
+ import type { Role } from './types.ts';
4
+ export type { Role };
5
+ export interface PeditOptions<E extends Env = Env> {
6
+ /** From the adapter, created on the PARENT app (`createNodeWebSocket({ app })`). */
7
+ upgradeWebSocket: UpgradeWebSocket;
8
+ /** realpath'd at startup; must exist. */
9
+ root: string;
10
+ /** Exact origins of the integrator's pages; also used for the Host check. */
11
+ origins: string[];
12
+ app: string;
13
+ /** Map an id to a file under `root`. null is 404; throw an HTTPException for anything else. */
14
+ resolve: (id: string, c: Context<E>) => Promise<{
15
+ path: string;
16
+ role: Role;
17
+ } | null>;
18
+ /** Absolute path to pedit; default: `pedit` on the PATH. Config only. */
19
+ bin?: string;
20
+ idleMs?: number;
21
+ /** Default false: refuse peers that are not on loopback. */
22
+ allowRemote?: boolean;
23
+ onEvent?: (event: PeditEvent) => void;
24
+ }
25
+ export type PeditEvent = {
26
+ type: 'started';
27
+ id: string;
28
+ path: string;
29
+ } | {
30
+ type: 'stopped';
31
+ id: string;
32
+ path: string;
33
+ reason: 'idle' | 'exited' | 'shutdown';
34
+ } | {
35
+ type: 'error';
36
+ id: string | null;
37
+ message: string;
38
+ };
39
+ /** True for 127.0.0.0/8, ::1 and their IPv4-mapped forms. */
40
+ export declare function isLoopback(address: string | undefined): boolean;
41
+ export declare function peditRoutes<E extends Env = Env>(options: PeditOptions<E>): Hono<E> & {
42
+ close(): Promise<void>;
43
+ };