lakutata 3.1.3 → 3.1.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 CHANGED
@@ -144,9 +144,9 @@ on. Each package's README lists its exceptions.
144
144
  `Entrypoint.md`, `Middlewares.md`, `HTTP.md`, `CLI.md`, `ServiceCalls.md`, `GRPC.md`, `NATS.md`, `Schedules.md`,
145
145
  `Queue.md`, `Database.md`, `DatabaseDrivers.md`, `MongoEmbedded.md`, `Entities.md`, `Migrations.md`, `Cache.md`,
146
146
  `Mutex.md`, `I18n.md`, `DockerIntegration.md`, `ContainerdIntegration.md`, `Monitor.md`, `Security.md`, `DBus.md`,
147
- `MCP.md`, `WebRTC.md`, `FFI.md`, `Math.md`, `Command.md`, `Workspaces.md`, `NativeAddons.md`, `Packaging.md`,
148
- `Installers.md`; and the examples, in `doc/en/examples` and `doc/zh/examples`: whole applications built from the
149
- templates.
147
+ `MCP.md`, `WebRTC.md`, `FFI.md`, `Math.md`, `Netif.md`, `Command.md`, `Workspaces.md`, `NativeAddons.md`,
148
+ `Packaging.md`, `Installers.md`; and the examples, in `doc/en/examples` and `doc/zh/examples`: whole applications
149
+ built from the templates.
150
150
  - `@lakutata/cli`: creating, building and packaging applications.
151
151
  - The official Agent Skill for AI coding assistants that work on lakutata projects:
152
152
  [lakutata.com/skill](https://lakutata.com/skill/).
package/README.zh-CN.md CHANGED
@@ -121,7 +121,7 @@ Application
121
121
  ## 另见
122
122
 
123
123
  - 网站 <https://lakutata.com/zh/>:指南、示例和 API 参考,中英文都有。
124
- - 指南在这个包的 `doc/zh`(中文)和 `doc/en`(英文)中:`Overview.md`、`GettingStarted.md`、`ProjectStructure.md`、`UpgradingFrom2.md`、`UpgradingFrom1.md`、`Application.md`、`Objects.md`、`Injection.md`、`DTOValidation.md`、`Exceptions.md`、`Logger.md`、`Decorator.md`、`Time.md`、`HelperFunctions.md`、`Controllers.md`、`Entrypoint.md`、`Middlewares.md`、`HTTP.md`、`CLI.md`、`ServiceCalls.md`、`GRPC.md`、`NATS.md`、`Schedules.md`、`Queue.md`、`Database.md`、`DatabaseDrivers.md`、`MongoEmbedded.md`、`Entities.md`、`Migrations.md`、`Cache.md`、`Mutex.md`、`I18n.md`、`DockerIntegration.md`、`ContainerdIntegration.md`、`Monitor.md`、`Security.md`、`DBus.md`、`MCP.md`、`WebRTC.md`、`FFI.md`、`Math.md`、`Command.md`、`Workspaces.md`、`NativeAddons.md`、`Packaging.md`、`Installers.md`;示例在 `doc/zh/examples` 和 `doc/en/examples` 中:从模板构建的完整应用。
124
+ - 指南在这个包的 `doc/zh`(中文)和 `doc/en`(英文)中:`Overview.md`、`GettingStarted.md`、`ProjectStructure.md`、`UpgradingFrom2.md`、`UpgradingFrom1.md`、`Application.md`、`Objects.md`、`Injection.md`、`DTOValidation.md`、`Exceptions.md`、`Logger.md`、`Decorator.md`、`Time.md`、`HelperFunctions.md`、`Controllers.md`、`Entrypoint.md`、`Middlewares.md`、`HTTP.md`、`CLI.md`、`ServiceCalls.md`、`GRPC.md`、`NATS.md`、`Schedules.md`、`Queue.md`、`Database.md`、`DatabaseDrivers.md`、`MongoEmbedded.md`、`Entities.md`、`Migrations.md`、`Cache.md`、`Mutex.md`、`I18n.md`、`DockerIntegration.md`、`ContainerdIntegration.md`、`Monitor.md`、`Security.md`、`DBus.md`、`MCP.md`、`WebRTC.md`、`FFI.md`、`Math.md`、`Netif.md`、`Command.md`、`Workspaces.md`、`NativeAddons.md`、`Packaging.md`、`Installers.md`;示例在 `doc/zh/examples` 和 `doc/en/examples` 中:从模板构建的完整应用。
125
125
  - `@lakutata/cli`:创建、构建和打包应用。
126
126
  - 官方 Agent Skill(智能体技能),供 AI 编程助手在 lakutata 项目中使用:<https://lakutata.com/zh/skill/>。
127
127
 
package/doc/en/GRPC.md CHANGED
@@ -56,10 +56,15 @@ The options of `grpcServiceEntrypoint`:
56
56
  | `tls` | None (plain HTTP/2) | `{cert, key}` for TLS; with `ca` too, the clients must present a certificate signed by it (mutual TLS) |
57
57
  | `maxMessageSize` | 4 MiB | The largest request, before and after decompression; a larger one fails with `RESOURCE_EXHAUSTED` |
58
58
  | `compression` | `'identity'` | `'gzip'` or `'deflate'` to compress the responses for the clients that accept it. Compressed requests are always accepted |
59
- | `listening` | | `(address) => void`, called once the server listens; the start waits for it |
59
+ | `listening` | | `(address, listeners) => void`, called once the server listens; the start waits for it |
60
+ | `interface`, `family`, `strict`, `onError` | | Listen on a network interface instead of an address, as the HTTP adapters do (see [HTTP](HTTP.md), section 2); not with `host` or `socketPath` |
60
61
  | `middlewares` | | The middlewares of the actions called over gRPC, run after the ones of the entrypoint |
61
62
  | `name` | | The name of the adapter, to tell several apart |
62
63
 
64
+ With `interface`, the package `@lakutata/netif` has to be installed. The `address` given to `listening` is then the
65
+ first one listened on, or `null` while the interface does not exist or has no address yet, and `listeners` are the
66
+ listeners of the interface.
67
+
63
68
  The server has no reflection service and no health service.
64
69
 
65
70
  ## 2. Calling them from another application
@@ -248,4 +253,5 @@ seconds by default) to complete. Then the event streams end and the server close
248
253
 
249
254
  The package also has the layers the service is built on, usable without lakutata: `GRPCConnection`, a client for any
250
255
  gRPC server (unary, server streaming and bidirectional calls, metadata, TLS); `GRPCServer`; and `encode`/`decode`, a
251
- protobuf codec driven by schemas. The README of `@lakutata/grpc` describes them.
256
+ protobuf codec driven by schemas. The README of `@lakutata/grpc` describes them. `GRPCServer.listenWith()` listens
257
+ through the listeners of network interfaces (`listenOnAll()` of `@lakutata/netif`), as the adapter does.
package/doc/en/HTTP.md CHANGED
@@ -123,6 +123,8 @@ The options of `expressHTTPEntrypoint()`:
123
123
  | `fallback` | | Answers the requests outside the paths of the actions, such as the pages of a web UI (section 7) |
124
124
  | `stop` | | Called when the adapter stops, once its server is closed |
125
125
  | `heartbeat` | `15000` | How often a comment keeps the event streams alive, in milliseconds (section 5) |
126
+ | `listening` | | `(server, listeners) => …`: called once the server listens, or once the listeners of the interface are made (see below) |
127
+ | `interface` | | The network interface to listen on instead of an address, with `family`, `strict` and `onError` (see below) |
126
128
 
127
129
  `fastifyHTTPEntrypoint()` takes the same options, except `server` and `app`, replaced by `fastify`: a Fastify instance,
128
130
  or the options of the instance to create (`bodyLimit`, 1 MiB by default; `trustProxy`; `connectionTimeout`,
@@ -146,6 +148,38 @@ the event streams; Fastify also answers 503 to the requests that still arrive on
146
148
  progress get the `shutdownTimeout` of the entrypoint (after it, they are answered with 503), then up to one second to
147
149
  send their responses, before the remaining connections are closed, the WebSockets included.
148
150
 
151
+ ### On a network interface
152
+
153
+ A device with several network interfaces often serves its pages on one of them only, and the address of that interface
154
+ comes from DHCP and changes. With `interface`, the adapter listens on the interface instead of an address, whatever its
155
+ addresses, and goes on listening when they change and when the interface is unplugged and plugged back. By default it
156
+ uses the package `@lakutata/netif`, installed apart, which compiles a native addon (see [Netif](Netif.md)).
157
+
158
+ ```typescript
159
+ import {expressHTTPEntrypoint} from 'lakutata/adapter/express'
160
+
161
+ //The pages on eth1 only, whatever its address
162
+ export const http = expressHTTPEntrypoint({
163
+ port: 8080,
164
+ interface: 'eth1',
165
+ listening: (_server, listeners): void => console.log(listeners?.map(listener => listener.addresses))
166
+ })
167
+ ```
168
+
169
+ | Option | Default | Meaning |
170
+ |---|---|---|
171
+ | `interface` | | The name of the interface (`'eth1'`; on Windows the name of the network connection), `{mac: '52:54:00:12:34:56'}` or `{index: 3}`; several in an array, and then `port` cannot be `0`. It cannot be given with `host` |
172
+ | `family` | `'both'` | `'IPv4'`, `'IPv6'` or both |
173
+ | `strict` | `false` | Fails the start where the system cannot refuse the connections that reach the addresses of the interface through another interface, and stops listening while it cannot (see [Netif](Netif.md), section 2) |
174
+ | `onError` | the log of the application | Receives the errors of listening again while the application runs, such as a port taken by another program; the adapter retries by itself |
175
+ | `interfaceListening` | `@lakutata/netif` | Another implementation of the listening on interfaces: an `IInterfaceListening`, the contract of lakutata that `@lakutata/netif` implements |
176
+
177
+ - The interface may not exist yet when the application starts (a USB network adapter not plugged in): the start
178
+ succeeds, and the adapter listens once the interface appears. Until then the `addresses` of the listeners are empty.
179
+ - The server itself does not listen, and its `address()` is `null`: the listeners give the port and the addresses.
180
+ - `family`, `strict`, `onError` and `interfaceListening` without `interface` fail the creation of the adapter, as do
181
+ `interface` and `host` together.
182
+
149
183
  ## 3. Routes
150
184
 
151
185
  | Decorator | Methods |
package/doc/en/MCP.md CHANGED
@@ -248,7 +248,8 @@ Application.run(() => ({
248
248
  |---|---|---|
249
249
  | `port`, `host`, `path` | `3000`, `'127.0.0.1'`, `'/mcp'` | Where it listens |
250
250
  | `authenticate` | | `(request) => MCPAuthInfo \| undefined`: the authentication of the client; throwing answers 401, returning `undefined` serves the request without one |
251
- | `allowedHosts`, `allowedOrigins` | the local names, on a local address | The `Host` and `Origin` headers accepted (403 otherwise); `false` checks none |
251
+ | `allowedHosts`, `allowedOrigins` | the local names, on a local address or the loopback interface | The `Host` and `Origin` headers accepted (403 otherwise); `false` checks none |
252
+ | `interface`, `family`, `strict`, `onError` | | Listen on a network interface instead of `host`, with the package `@lakutata/netif` (see [HTTP](HTTP.md), section 2) |
252
253
  | `legacy` | `'stateless'` | The clients of the revisions of 2025: served without sessions, or refused with `'reject'` |
253
254
  | `maxRequestBodySize` | 4 MiB | A larger body is answered with 413 |
254
255
  | `bus` | in memory | Where the notifications go, to share them between the instances of the application |
@@ -258,7 +259,8 @@ Application.run(() => ({
258
259
  - It serves the protocol over Streamable HTTP: the revision 2026-07-28, and the revisions of 2025 without sessions.
259
260
  - On a local address, the `Host` and `Origin` headers are checked against the local names, which protects the server
260
261
  from DNS rebinding: behind a proxy, list the names the clients use. On another address, nothing is checked unless you
261
- list names.
262
+ list names. The same goes for `interface`: the loopback interface is checked against the local names, and on another
263
+ interface the clients of the network reach the server, so set `allowedHosts` and `authenticate`.
262
264
  - When the application stops, the calls in progress are answered, then the port closes.
263
265
 
264
266
  **Sharing the port of the HTTP adapter.** With `listen: false`, mount the `handler` of the adapter on the Express
@@ -0,0 +1,261 @@
1
+ # Lakutata Netif
2
+
3
+ The package `@lakutata/netif` lets a server of Node.js listen on a network interface instead of an address. A device
4
+ with several interfaces often serves its management pages on one of them only: the address of that port comes from DHCP
5
+ and changes, or is not there yet when the device starts, and listening on `0.0.0.0` would expose the pages on every
6
+ interface. With the package the server listens on "eth1", whatever its addresses, and keeps listening through their
7
+ changes and through the interface unplugged and plugged back. It is not part of `lakutata`, and it needs no
8
+ application. What it is like:
9
+
10
+ - **Any server**: `http`, `https`, `http2`, `tls`, Express and Fastify take the connections of the interface as their
11
+ own; UDP sockets too, for the services that work on one interface (DHCP, mDNS, SSDP).
12
+ - **The entrypoints of lakutata** take an `interface` option, which serves the controllers of an application on one
13
+ interface (section 5).
14
+ - **The system refuses the other interfaces where it can**: on Linux the sockets are bound to the interface, and a
15
+ connection that reaches its address through another interface is refused. On Windows and macOS the addresses of the
16
+ interface are listened on, and `enforced` tells whether the system refuses those connections.
17
+ - **The interfaces and their changes**: every interface of the machine with its state, and events when its addresses,
18
+ its link or its presence change, from the notifications of the system.
19
+ - **A native addon**, compiled when the package is installed, for Linux, macOS and Windows; it also runs in Bun and in
20
+ the main process of Electron.
21
+
22
+ The README of `@lakutata/netif` describes every function and its options; this guide goes through what the package is
23
+ for, with an example of each use.
24
+
25
+ ```bash
26
+ npm install @lakutata/netif
27
+ ```
28
+
29
+ The addon is compiled when the package is installed, so the machine needs Python 3 and a C++17 compiler (on Alpine Linux
30
+ the headers of the kernel too: `apk add python3 make g++ linux-headers`). The package manager has to run the install
31
+ script of the package: npm runs it, and may warn that the script is "not yet covered by allowScripts" (`npm
32
+ install-scripts approve @lakutata/netif` records that you allow it); Bun and pnpm run it only for the packages they are
33
+ told to trust (`trustedDependencies` in the package.json for Bun, `pnpm approve-builds` for pnpm). Its types refer to
34
+ the ones of `@lakutata/core`, a peer dependency that the package managers install with it; it is not loaded when the
35
+ package runs.
36
+
37
+ ## 1. Listening on an interface
38
+
39
+ `listenOn()` listens on an interface for a server. The interface is given by its name, its hardware address
40
+ (`{mac: '52:54:00:12:34:56'}`, the same whatever the system names it) or its index; on Windows the name is the one of
41
+ the network connection (`'以太网 2'`, `'Ethernet 2'`).
42
+
43
+ ```typescript
44
+ import {createServer} from 'node:http'
45
+ import express from 'express'
46
+ import {type InterfaceListener, listenOn} from '@lakutata/netif'
47
+
48
+ const app = express()
49
+ app.get('/', (request, response) => response.send(`served on ${request.socket.localAddress}`))
50
+
51
+ const listener: InterfaceListener = await listenOn('eth1', createServer(app), {port: 8080})
52
+ listener.on('error', error => console.error(error))
53
+ console.log(listener.port, listener.addresses)
54
+ ```
55
+
56
+ - The listener accepts the connections and gives them to the server, which handles them as its own. The server itself
57
+ does not listen: its `address()` is `null`, and `server.close()` does not stop the listener; use `listener.port`,
58
+ `listener.addresses` and `listener.close()`.
59
+ - The interface may not exist yet or have no address yet: `listenOn()` returns at once, and the interface is listened
60
+ on when it can be. When it is unplugged and plugged back, or when its addresses change, it is listened on again by
61
+ itself; the connections established are left to the server.
62
+ - The first listening throws its errors (the port taken); the later ones are `error` events, retried a little later.
63
+ Handle the `error` event of the listener, as for a `net.Server`.
64
+ - `family` chooses `'IPv4'`, `'IPv6'` or `'both'` (by default); `port: 0` lets the system choose a port, kept when the
65
+ interface is listened on again.
66
+
67
+ ## 2. Which connections are refused
68
+
69
+ A client of another network can send packets for the address of the management port through another interface of the
70
+ device, with a route of its own through it. Whether the system refuses such a connection is what `enforced` tells:
71
+
72
+ | | `enforced` |
73
+ |---|---|
74
+ | Linux | `true`: the sockets are bound to the interface (`SO_BINDTODEVICE`); before Linux 5.7 this needs the capability `CAP_NET_RAW`, without which the addresses are listened on and `enforced` is `false` |
75
+ | Windows | `true` while every other interface receives with the strong host model and does not forward, which is the default |
76
+ | macOS | `false` unless the system is set to refuse those packets (`net.inet.ip.check_interface` set to 2, and `net.inet6.ip6.check_interface` for IPv6) and does not forward |
77
+
78
+ By default the interface is listened on in the best way the system allows, never less than `listen(port, host)` on its
79
+ addresses; `reason` tells why there is no guarantee and how to get it. Where the management port is a boundary of
80
+ security, `strict: true` refuses to listen without the guarantee, and stops the listening while it is lost:
81
+
82
+ ```typescript
83
+ import {createServer} from 'node:http'
84
+ import {type InterfaceListener, NetIfNotEnforcedException, NetworkInterface} from '@lakutata/netif'
85
+
86
+ const management: NetworkInterface = new NetworkInterface({mac: '52:54:00:12:34:56'})
87
+ try {
88
+ const listener: InterfaceListener = await management.listen(createServer(), {port: 443, strict: true})
89
+ listener.on('error', error => console.error(error))
90
+ listener.on('enforced', enforced => console.log(enforced ? 'guaranteed again' : 'stopped: no longer guaranteed'))
91
+ } catch (error) {
92
+ if (error instanceof NetIfNotEnforcedException) console.error(error.message)
93
+ throw error
94
+ }
95
+ ```
96
+
97
+ ## 3. Following an interface
98
+
99
+ A `NetworkInterface` gives the state of an interface now (`present`, `running`, `addresses`…) and an event at each
100
+ change. The events come from the notifications of the system, not from polling.
101
+
102
+ ```typescript
103
+ import {type InterfaceAddress, listInterfaces, NetworkInterface} from '@lakutata/netif'
104
+
105
+ console.log(listInterfaces().map(entry => `${entry.name}: ${entry.running ? 'connected' : 'not connected'}`))
106
+
107
+ const wan: NetworkInterface = new NetworkInterface('eth0')
108
+ wan.on('change', ({added, removed}) => {
109
+ for (const address of added) console.log(`new address ${address.cidr}`)
110
+ for (const address of removed) console.log(`address gone ${address.cidr}`)
111
+ })
112
+ wan.on('down', () => console.log('the cable of eth0 is unplugged'))
113
+ wan.on('up', () => console.log('the cable of eth0 is plugged in'))
114
+ const address: InterfaceAddress = await wan.waitForAddress({family: 'IPv4', timeout: 60000})
115
+ console.log(`reachable at ${address.address}`)
116
+ ```
117
+
118
+ - `listInterfaces()` lists the interfaces without an address too, with their state; `os.networkInterfaces()` lists
119
+ only the ones with an address.
120
+ - An IPv6 address counts once it is usable: about a second after it is added, when its duplicate address detection is
121
+ over.
122
+ - `close()` stops following the interface (and closes its listeners).
123
+
124
+ ## 4. UDP on an interface
125
+
126
+ `createUDPSocket()` gives a UDP socket of the interface, with the methods and the events of a `dgram` socket. A device
127
+ that answers the discovery broadcasts of its management network only:
128
+
129
+ ```typescript
130
+ import {type InterfaceUDPSocket, NetworkInterface} from '@lakutata/netif'
131
+
132
+ const management: NetworkInterface = new NetworkInterface('eth1')
133
+ const socket: InterfaceUDPSocket = await management.createUDPSocket({port: 30303, family: 'IPv4'})
134
+ socket.setBroadcast(true)
135
+ socket.on('message', (message, remote) => {
136
+ const answer: string = `device at ${management.addresses[0]?.address}`
137
+ if (message.toString() === 'who is there') socket.send(answer, remote.port, remote.address)
138
+ })
139
+ socket.on('error', error => console.error(error))
140
+ ```
141
+
142
+ On Linux the socket receives what comes through the interface, its broadcasts and the multicast of the groups joined
143
+ with `addMembership()` included, and nothing of the other interfaces. On Windows and macOS a socket is bound to each
144
+ address of the interface; on macOS such sockets receive no broadcast and no multicast.
145
+
146
+ ## 5. In an application
147
+
148
+ The entrypoints of lakutata that listen on a port take an `interface` option: `expressHTTPEntrypoint()`,
149
+ `fastifyHTTPEntrypoint()`, `httpServiceEntrypoint()`, `socketIOServiceEntrypoint()`, `jsonRPCServiceEntrypoint()`,
150
+ `grpcServiceEntrypoint()` of `@lakutata/grpc` and `httpMCPEntrypoint()` of `@lakutata/mcp`. The actions of the
151
+ controllers are then served on that interface, whatever its addresses:
152
+
153
+ ```typescript
154
+ import {Application} from 'lakutata'
155
+ import {buildEntrypoints, Controller} from 'lakutata/com/entrypoint'
156
+ import {GET} from 'lakutata/decorator/ctrl'
157
+ import {expressHTTPEntrypoint} from 'lakutata/adapter/express'
158
+
159
+ class ManagementController extends Controller {
160
+ @GET('/status')
161
+ public async status(): Promise<{ ok: boolean }> {
162
+ return {ok: true}
163
+ }
164
+ }
165
+
166
+ Application.run(() => ({
167
+ id: 'device.app',
168
+ name: 'Device',
169
+ components: {
170
+ entrypoint: buildEntrypoints({
171
+ controllers: [ManagementController],
172
+ http: expressHTTPEntrypoint({port: 8080, interface: 'eth1'})
173
+ })
174
+ },
175
+ bootstrap: ['entrypoint']
176
+ }))
177
+ ```
178
+
179
+ - `family`, `strict` and `onError` go with `interface`. Without `onError`, the errors of listening again are written to
180
+ the log of the application. The `listening` callback of the adapter gets the listeners. [HTTP](HTTP.md), section 2,
181
+ describes these options.
182
+ - The application starts while the interface does not exist yet, and its actions can be reached once it appears.
183
+ - The entrypoints listen through `IInterfaceListening`, a contract of `@lakutata/core` that this package implements
184
+ and that they load by default. Another implementation is given in their `interfaceListening` option; the
185
+ `InterfaceListener` of the package is an `IInterfaceListener`.
186
+
187
+ A server that is not an entrypoint belongs to a component: it listens in `startup()` and stops in `destroy()`. The
188
+ interface and the port are the options of the component.
189
+
190
+ ```typescript
191
+ import {createServer} from 'node:http'
192
+ import express from 'express'
193
+ import {Application, Component, DTO, type ILogger} from 'lakutata'
194
+ import {Configurable, Inject} from 'lakutata/decorator/di'
195
+ import {type InterfaceListener, listenOn} from '@lakutata/netif'
196
+
197
+ class ManagementPages extends Component {
198
+ @Inject('log')
199
+ protected readonly log: ILogger
200
+
201
+ @Configurable(DTO.String().required())
202
+ protected readonly interfaceName: string
203
+
204
+ @Configurable(DTO.Number().port().default(8080))
205
+ protected readonly port: number
206
+
207
+ protected listener: InterfaceListener
208
+
209
+ protected async startup(): Promise<void> {
210
+ const app = express()
211
+ app.get('/', (request, response) => response.send('management'))
212
+ this.listener = await listenOn(this.interfaceName, createServer(app), {port: this.port})
213
+ this.listener.on('error', (error: Error): void => this.log.error(error.message))
214
+ this.listener.on('listening', (): void => this.log.info('listening on %o', this.listener.addresses))
215
+ this.log.info('enforced: %s', this.listener.enforced)
216
+ }
217
+
218
+ protected async destroy(): Promise<void> {
219
+ await this.listener?.close()
220
+ }
221
+ }
222
+
223
+ Application.run(() => ({
224
+ id: 'device.app',
225
+ name: 'Device',
226
+ components: {
227
+ management: {class: ManagementPages, interfaceName: 'eth1', port: 8080}
228
+ },
229
+ bootstrap: ['management']
230
+ }))
231
+ ```
232
+
233
+ ## 6. Systems, runtimes and packaging
234
+
235
+ - **Linux** binds the sockets to the interface: the system refuses the other interfaces, an address added is served at
236
+ once, and UDP gets the broadcasts and the multicast of the interface. Before Linux 5.7 give the process `CAP_NET_RAW`
237
+ (`setcap cap_net_raw+ep`, or `AmbientCapabilities=CAP_NET_RAW` in its systemd unit).
238
+ - **Windows** listens on the addresses of the interface; its strong host model refuses the other interfaces unless an
239
+ interface is set to the weak host model (`netsh interface ipv4 set interface "<name>" weakhostreceive=enabled`, which
240
+ some load balancers do) or forwards. `reason` names that interface.
241
+ - **macOS** listens on the addresses of the interface, without the guarantee unless the system is set to it. macOS 27
242
+ hides the hardware addresses from the processes (it gives `02:00:00:00:00:00`): give an interface by its name there.
243
+ - **Bun**: its `https` server answers the connections given to it without TLS, so `listen()` refuses it in Bun (a
244
+ Fastify instance created with `https` too); use `http2.createSecureServer({key, cert, allowHTTP1: true})` or
245
+ `tls.createServer()` there. Its servers do not close the idle keep-alive connections given to them, which the
246
+ entrypoints close themselves. A connection given to a server cannot be upgraded to a WebSocket (seen with Bun 1.4), so
247
+ the entrypoints refuse WebSocket on an interface in Bun.
248
+ - **Electron**: in the main process.
249
+ - The bundles, the executables and the installers made by `lakutata package` carry the compiled addon
250
+ (`build/Release/netif.node`): build them on each target platform, after installing the package there (see
251
+ [Packaging](Packaging.md) and [NativeAddons](NativeAddons.md)).
252
+
253
+ ## 7. Exceptions
254
+
255
+ | Exception | When |
256
+ |---|---|
257
+ | `NetIfNotFoundException` | An index no interface has, a hardware address several interfaces have |
258
+ | `NetIfNotEnforcedException` | `strict` where the system cannot refuse the other interfaces, or the guarantee lost (an `error` event) |
259
+ | `NetIfNotSupportedException` | What the system or the runtime cannot do: `bindSocket()` outside Linux, multicast on a socket bound to an address on macOS, an `https` server in Bun |
260
+ | `NetIfTimeoutException` | `waitForAddress()` past its `timeout` |
261
+ | `NetIfException` | The base of all of them; a refusal of the system has its code as `errno` (`'EADDRINUSE'`, `'EACCES'`…) |
@@ -48,8 +48,9 @@ Not part of this package: `@lakutata/cli` (the `lakutata` command for creating p
48
48
  applications, see [Command](Command.md)), `@lakutata/grpc` (a gRPC client, server and service transport),
49
49
  `@lakutata/nats` (a NATS component and service transport), `@lakutata/dbus` (D-Bus), `@lakutata/mcp` (the Model
50
50
  Context Protocol), `@lakutata/webrtc` (WebRTC: the signaling, a TURN server, a media server, peer connections),
51
- `@lakutata/ffi` (the C functions of native libraries) and `@lakutata/math` (symbolic and numeric mathematics, as
52
- functions that need no lakutata application).
51
+ `@lakutata/ffi` (the C functions of native libraries), `@lakutata/math` (symbolic and numeric mathematics, as functions
52
+ that need no lakutata application) and `@lakutata/netif` (servers and UDP sockets listening on a network interface
53
+ whatever its addresses, the interfaces of the machine and their changes).
53
54
 
54
55
  ## The guides
55
56
 
@@ -95,6 +96,7 @@ functions that need no lakutata application).
95
96
  | [WebRTC](WebRTC.md) | Peer-to-peer connections and a media server (`@lakutata/webrtc`): the signaling, the TURN server, browsers and applications, the actions of an application called through data channels, WHIP and WHEP |
96
97
  | [FFI](FFI.md) | The C functions of native libraries (`@lakutata/ffi`): declarations copied from headers, structs, callbacks, memory, packaging |
97
98
  | [Math](Math.md) | Mathematics (`@lakutata/math`): formulas and exact results, equations, decimals of any precision, matrices, statistics, numeric solutions, units |
99
+ | [Netif](Netif.md) | Network interfaces (`@lakutata/netif`): a server listening on an interface whatever its addresses, the connections of the other interfaces refused, the interfaces and their changes, UDP on one interface |
98
100
  | [Command](Command.md) | The `lakutata` command: creating projects, migrations, translations, environment variables |
99
101
  | [Workspaces](Workspaces.md) | Projects of several packages: running their scripts, giving them a version, adding packages |
100
102
  | [NativeAddons](NativeAddons.md) | Native addons in C++ or Rust: creating them, linking libraries, shipping them |
@@ -197,7 +197,7 @@ pages of a web UI during its development. The Electron templates add it in devel
197
197
  | `host` | all the interfaces | The address to listen on |
198
198
  | `path` | `'/'` | The path of the requests; the events are under it, at `<path>/events` |
199
199
  | `limit` | `10485760` (10 MiB) | The largest body, in bytes |
200
- | `listening` | | `(server) => …`, called once the server listens |
200
+ | `listening` | | `(server, listeners) => …`, called once the server listens (`listeners`, with `interface`: see below) |
201
201
 
202
202
  - A request is a `POST` to `path`, its body the message as JSON. It is answered with 200 and the envelope, whatever the
203
203
  result of the action. A body that is not JSON is answered with 400, a body over `limit` with 413, another method or
@@ -284,7 +284,7 @@ connected.
284
284
  | `options` | | The options of the Socket.IO server: `cors`, `path`, `maxHttpBufferSize`… |
285
285
  | `server` | | An HTTP server to share, or a function returning it (below) |
286
286
  | `io` | | An existing Socket.IO server, used as it is |
287
- | `listening` | | `(io, server) => …`, called once the adapter is ready |
287
+ | `listening` | | `(io, server, listeners) => …`, called once the adapter is ready |
288
288
 
289
289
  ```typescript
290
290
  import {io, type Socket} from 'socket.io-client'
@@ -340,7 +340,7 @@ the tools that speak the protocol, and the parent processes over the standard in
340
340
  | `input`, `output` | `process.stdin`, `process.stdout` | The streams of stdio |
341
341
  | `end` | `'application'` | When the input of stdio ends: `'application'` exits the application once the requests in progress are answered, `'none'` does nothing, or a function |
342
342
  | `heartbeat` | `30000` | The interval of the pings of WebSocket, in milliseconds; `0` sends none |
343
- | `listening` | | `(server) => …`, called once the server of HTTP or WebSocket listens |
343
+ | `listening` | | `(server, listeners) => …`, called once the server of HTTP or WebSocket listens |
344
344
 
345
345
  The method and the params of a request make the message: `{"method": "order.get", "params": {"id": 1}}` is the message
346
346
  `{method: 'order.get', id: 1}`, so the action declares the method in its pattern:
@@ -417,6 +417,25 @@ $ echo '{"jsonrpc": "2.0", "id": 1, "method": "sum", "params": {"values": [1, 2,
417
417
 
418
418
  The application exits once its input ends and its requests are answered (`end`).
419
419
 
420
+ ### On a network interface
421
+
422
+ `httpServiceEntrypoint()`, `socketIOServiceEntrypoint()` with its own server, and `jsonRPCServiceEntrypoint()` over HTTP
423
+ or WebSocket can listen on a network interface instead of an address, whatever its addresses, with the package
424
+ `@lakutata/netif` installed apart. They take the options `interface`, `family`, `strict`, `onError` and
425
+ `interfaceListening` of the HTTP adapters (see [HTTP](HTTP.md), section 2), and their `listening` callback gets the
426
+ listeners of the interface as its last argument:
427
+
428
+ ```typescript
429
+ import {httpServiceEntrypoint} from 'lakutata/com/entrypoint'
430
+
431
+ //The service on eth1 only, whatever its address
432
+ export const service = httpServiceEntrypoint({port: 3000, interface: 'eth1'})
433
+ ```
434
+
435
+ In Bun, a connection given to a server cannot be upgraded to a WebSocket (seen with Bun 1.4). There the start fails for
436
+ JSON-RPC over WebSocket, and for Socket.IO unless it uses polling only (`options: {transports: ['polling']}`, and the
437
+ same on the clients).
438
+
420
439
  ### Electron
421
440
 
422
441
  `electronIPCServiceEntrypoint()` serves the actions to the windows of an Electron application, in its main process.
package/doc/zh/GRPC.md CHANGED
@@ -52,10 +52,13 @@ Application.run(() => ({
52
52
  | `tls` | 无(明文 HTTP/2) | `{cert, key}` 启用 TLS;再加上 `ca` 时,客户端必须出示由它签发的证书(双向 TLS) |
53
53
  | `maxMessageSize` | 4 MiB | 请求的最大大小(解压前后都算);超过时以 `RESOURCE_EXHAUSTED` 失败 |
54
54
  | `compression` | `'identity'` | 设为 `'gzip'` 或 `'deflate'` 时,对支持的客户端压缩响应。压缩过的请求总是可以接收 |
55
- | `listening` | | `(address) => void`,服务开始监听后调用;启动过程会等它完成 |
55
+ | `listening` | | `(address, listeners) => void`,服务开始监听后调用;启动过程会等它完成 |
56
+ | `interface`、`family`、`strict`、`onError` | | 按网口监听,而不是按地址监听,用法和 HTTP 适配器一样(见 [HTTP](HTTP.md) 第 2 节);不能和 `host` 或 `socketPath` 一起设置 |
56
57
  | `middlewares` | | 通过 gRPC 调用的动作专用的中间件,在入口的中间件之后执行 |
57
58
  | `name` | | 适配器的名字,用来区分多个适配器 |
58
59
 
60
+ 设置 `interface` 时需要安装 `@lakutata/netif` 包。这时传给 `listening` 的 `address` 是第一个监听的地址;网口还不存在或还没有地址时是 `null`;`listeners` 是网口的监听器。
61
+
59
62
  服务端不提供反射服务,也不提供健康检查服务。
60
63
 
61
64
  ## 2. 从另一个应用调用
@@ -223,4 +226,4 @@ print(json.loads(response.payload) if response.success else json.loads(response.
223
226
 
224
227
  ## 8. 底层组件
225
228
 
226
- 这个包也提供服务所依赖的底层组件,脱离 lakutata 也能使用:`GRPCConnection` 是可以调用任何 gRPC 服务端的客户端(一元调用、服务端流、双向流、元数据、TLS);`GRPCServer` 是服务端;`encode`/`decode` 是基于 schema 的 protobuf 编解码器。详见 `@lakutata/grpc` 的 README。
229
+ 这个包也提供服务所依赖的底层组件,脱离 lakutata 也能使用:`GRPCConnection` 是可以调用任何 gRPC 服务端的客户端(一元调用、服务端流、双向流、元数据、TLS);`GRPCServer` 是服务端;`encode`/`decode` 是基于 schema 的 protobuf 编解码器。详见 `@lakutata/grpc` 的 README。`GRPCServer.listenWith()` 也能像适配器一样,通过网口的监听器(`@lakutata/netif` 的 `listenOnAll()`)按网口监听。
package/doc/zh/HTTP.md CHANGED
@@ -119,6 +119,8 @@ HTTP/1.1 400 Bad Request
119
119
  | `fallback` | | 响应动作路径之外的请求,比如 Web 界面的页面(第 7 节) |
120
120
  | `stop` | | 适配器停止、服务器关闭之后调用 |
121
121
  | `heartbeat` | `15000` | 每隔多久发送一条注释,让事件流保持连接,单位毫秒(第 5 节) |
122
+ | `listening` | | `(server, listeners) => …`:服务器开始监听后调用;按网口监听时,在网口的监听器建好后调用(见下文) |
123
+ | `interface` | | 按网口监听,而不是按地址监听,可以再配上 `family`、`strict` 和 `onError`(见下文) |
122
124
 
123
125
  `fastifyHTTPEntrypoint()` 的选项相同,只是没有 `server` 和 `app`,取而代之的是 `fastify`:一个 Fastify 实例,或者要创建的实例的选项(`bodyLimit`,默认 1 MiB;`trustProxy`;`connectionTimeout`、`keepAliveTimeout`、`requestTimeout`;`logger`……)。它的 `body` 选项只有 `text`,大小上限是实例的 `bodyLimit`。
124
126
 
@@ -136,6 +138,33 @@ HTTP/1.1 400 Bad Request
136
138
 
137
139
  应用停止时(见 [Entrypoint](Entrypoint.md) 第 4 节),适配器不再接受新的连接,并结束事件流;Fastify 还会对已打开的连接上仍然到达的请求返回 503。正在处理的请求可以在入口的 `shutdownTimeout` 内完成(超时后以 503 响应),之后最多再有一秒来发送响应,然后剩下的连接被关闭,WebSocket 连接也一样。
138
140
 
141
+ ### 按网口监听
142
+
143
+ 有多个网口的设备,常常只在其中一个网口上提供页面,而这个网口的地址来自 DHCP,会变。设置 `interface` 后,适配器监听的是这个网口,而不是某个地址:不管网口现在是什么地址,地址变化、网口拔掉再插回,都会继续监听。默认用的是 `@lakutata/netif` 包,需要另外安装,它会编译一个原生插件(见 [Netif](Netif.md))。
144
+
145
+ ```typescript
146
+ import {expressHTTPEntrypoint} from 'lakutata/adapter/express'
147
+
148
+ //页面只在 eth1 上提供,不管它是什么地址
149
+ export const http = expressHTTPEntrypoint({
150
+ port: 8080,
151
+ interface: 'eth1',
152
+ listening: (_server, listeners): void => console.log(listeners?.map(listener => listener.addresses))
153
+ })
154
+ ```
155
+
156
+ | 选项 | 默认值 | 含义 |
157
+ |---|---|---|
158
+ | `interface` | | 网口的名字(`'eth1'`;Windows 上是网络连接的名字)、`{mac: '52:54:00:12:34:56'}` 或 `{index: 3}`;多个网口放在数组里,这时 `port` 不能是 `0`。不能和 `host` 一起设置 |
159
+ | `family` | `'both'` | `'IPv4'`、`'IPv6'` 或两者都监听 |
160
+ | `strict` | `false` | 如果系统挡不住从别的网口发往这个网口地址的连接,就让启动失败,并在挡不住的期间停止监听(见 [Netif](Netif.md) 第 2 节) |
161
+ | `onError` | 应用的日志 | 接收应用运行期间重新监听时的错误,比如端口被别的程序占用;适配器会自己重试 |
162
+ | `interfaceListening` | `@lakutata/netif` | 换用另一种按网口监听的实现:一个 `IInterfaceListening`,也就是 lakutata 定义、`@lakutata/netif` 实现的接口 |
163
+
164
+ - 应用启动时网口可能还不存在(比如 USB 网卡还没插上):启动照样成功,网口出现后适配器就开始监听。在那之前,监听器的 `addresses` 是空的。
165
+ - 服务器自己不监听,它的 `address()` 是 `null`:端口和地址从监听器上读取。
166
+ - 没有设置 `interface` 却设置了 `family`、`strict`、`onError` 或 `interfaceListening`,或者同时设置了 `interface` 和 `host`,创建适配器时就会报错。
167
+
139
168
  ## 3. 路由
140
169
 
141
170
  | 装饰器 | 方法 |
package/doc/zh/MCP.md CHANGED
@@ -224,7 +224,8 @@ Application.run(() => ({
224
224
  |---|---|---|
225
225
  | `port`、`host`、`path` | `3000`、`'127.0.0.1'`、`'/mcp'` | 监听的位置 |
226
226
  | `authenticate` | | `(request) => MCPAuthInfo \| undefined`:客户端的身份认证;抛出错误时以 401 响应,返回 `undefined` 时不认证,照常处理请求 |
227
- | `allowedHosts`、`allowedOrigins` | 在本机地址上时为本机的名字 | 接受的 `Host` 和 `Origin` 请求头(否则为 403);`false` 表示不检查 |
227
+ | `allowedHosts`、`allowedOrigins` | 在本机地址或回环网口上时为本机的名字 | 接受的 `Host` 和 `Origin` 请求头(否则为 403);`false` 表示不检查 |
228
+ | `interface`、`family`、`strict`、`onError` | | 按网口监听,代替 `host`,需要安装 `@lakutata/netif` 包(见 [HTTP](HTTP.md) 第 2 节) |
228
229
  | `legacy` | `'stateless'` | 2025 年协议版本的客户端:以无会话的方式服务,或者用 `'reject'` 拒绝 |
229
230
  | `maxRequestBodySize` | 4 MiB | 更大的请求体以 413 响应 |
230
231
  | `bus` | 内存中 | 通知的去向,用于在应用的多个实例之间共享 |
@@ -232,7 +233,7 @@ Application.run(() => ({
232
233
  | `serverName`、`serverVersion`、`instructions`、`server`、`listening`、`name`、`middlewares` | | 与 stdio 相同;`server` 是 `node:http` 服务器的选项 |
233
234
 
234
235
  - 它通过 Streamable HTTP 提供协议:2026-07-28 版本,以及无会话方式的 2025 年版本。
235
- - 在本机地址上,`Host` 和 `Origin` 请求头会与本机的名字比对,防止 DNS 重绑定攻击:在代理之后时,要列出客户端使用的名字。在其他地址上,除非列出名字,否则不做检查。
236
+ - 在本机地址上,`Host` 和 `Origin` 请求头会与本机的名字比对,防止 DNS 重绑定攻击:在代理之后时,要列出客户端使用的名字。在其他地址上,除非列出名字,否则不做检查。设置 `interface` 时也一样:在回环网口上与本机的名字比对;在其他网口上,网络中的客户端都能访问这个服务器,所以要设置 `allowedHosts` 和 `authenticate`。
236
237
  - 应用停止时,正在处理的调用会得到响应,然后端口关闭。
237
238
 
238
239
  **与 HTTP 适配器共用端口。** 设置 `listen: false`,在 Express 应用的 `before` 钩子中挂载适配器的 `handler`:
@@ -0,0 +1,207 @@
1
+ # Lakutata Netif
2
+
3
+ `@lakutata/netif` 包让 Node.js 的服务器按网口监听,而不是按地址监听。有多个网口的设备,常常只在其中一个网口上提供管理页面:这个网口的地址来自 DHCP,会变,设备开机时可能还没有;而监听 `0.0.0.0` 又会让管理页面出现在所有网口上。用这个包,服务器监听的是“eth1 这个网口”,不管它现在是什么地址,地址变化、网口拔掉再插回,都会继续监听。它不属于 `lakutata`,也不需要应用。它的特点是:
4
+
5
+ - **任何服务器都能用**:`http`、`https`、`http2`、`tls`、Express 和 Fastify 都把这个网口的连接当成自己的连接处理;UDP 套接字也可以,适合只在一个网口上工作的服务(DHCP、mDNS、SSDP)。
6
+ - **lakutata 的入口**有 `interface` 选项,可以让应用的控制器只在一个网口上提供服务(第 5 节)。
7
+ - **系统能挡的,就让系统挡住别的网口**:在 Linux 上,套接字绑定在网口上,从别的网口发往这个网口地址的连接会被拒绝。在 Windows 和 macOS 上,监听的是这个网口的地址,`enforced` 告诉你系统会不会挡住那样的连接。
8
+ - **网口和它们的变化**:本机每个网口的状态,以及地址、连通状态、网口出现和消失时的事件,都来自系统的通知。
9
+ - **原生插件**:在安装这个包时编译,支持 Linux、macOS 和 Windows;也能在 Bun 和 Electron 的主进程里运行。
10
+
11
+ `@lakutata/netif` 的 README 介绍了每个函数及其选项;这篇指南按用途逐一说明这个包能做什么,每种用途都给出一个例子。
12
+
13
+ ```bash
14
+ npm install @lakutata/netif
15
+ ```
16
+
17
+ 原生插件在安装这个包时编译,所以机器上要有 Python 3 和支持 C++17 的编译器(Alpine Linux 还要内核的头文件:`apk add python3 make g++ linux-headers`)。包管理器必须运行这个包的安装脚本:npm 会运行它,可能会提示这个脚本“not yet covered by allowScripts”(运行 `npm install-scripts approve @lakutata/netif` 记录你允许它运行);Bun 和 pnpm 只为被告知可以信任的包运行安装脚本(Bun 在 package.json 的 `trustedDependencies` 里设置,pnpm 用 `pnpm approve-builds`)。它的类型引用了 `@lakutata/core` 的类型,这是一个 peer 依赖,包管理器会一起安装;这个包运行时不会加载它。
18
+
19
+ ## 1. 在网口上监听
20
+
21
+ `listenOn()` 让一个服务器在网口上监听。网口可以按名字、MAC 地址(`{mac: '52:54:00:12:34:56'}`,不随系统给网口起的名字变化)或序号指定;Windows 上的名字是网络连接的名字(`'以太网 2'`、`'Ethernet 2'`)。
22
+
23
+ ```typescript
24
+ import {createServer} from 'node:http'
25
+ import express from 'express'
26
+ import {type InterfaceListener, listenOn} from '@lakutata/netif'
27
+
28
+ const app = express()
29
+ app.get('/', (request, response) => response.send(`served on ${request.socket.localAddress}`))
30
+
31
+ const listener: InterfaceListener = await listenOn('eth1', createServer(app), {port: 8080})
32
+ listener.on('error', error => console.error(error))
33
+ console.log(listener.port, listener.addresses)
34
+ ```
35
+
36
+ - 监听器接受连接后交给服务器,服务器像处理自己的连接一样处理它们。服务器自己并没有在监听:它的 `address()` 是 `null`,`server.close()` 也不能让监听器停下;请用 `listener.port`、`listener.addresses` 和 `listener.close()`。
37
+ - 网口可以暂时不存在,也可以还没有地址:`listenOn()` 会立即返回,等网口能监听了就开始监听。网口拔掉再插回、地址变化时,会自动重新监听;已经建立的连接交给服务器处理。
38
+ - 第一次监听的错误(比如端口被占用)会直接抛出;之后的错误是 `error` 事件,过一会儿会重试。请给监听器加上 `error` 的监听函数,就像对 `net.Server` 那样。
39
+ - `family` 选择 `'IPv4'`、`'IPv6'` 或 `'both'`(默认);`port: 0` 让系统选一个端口,重新监听时保持同一个端口。
40
+
41
+ ## 2. 哪些连接会被挡住
42
+
43
+ 另一个网络的客户端,可以自己加一条经过设备的路由,把发往管理网口地址的包从设备的另一个网口送进来。系统会不会拒绝这样的连接,就是 `enforced` 回答的问题:
44
+
45
+ | | `enforced` |
46
+ |---|---|
47
+ | Linux | `true`:套接字绑定在网口上(`SO_BINDTODEVICE`);Linux 5.7 以前这需要 `CAP_NET_RAW` 能力,没有的话就按地址监听,`enforced` 为 `false` |
48
+ | Windows | 其他网口都按强主机模型收包、都没开转发时为 `true`,默认就是这样 |
49
+ | macOS | 默认为 `false`;只有在系统设置为拒绝这样的包(`net.inet.ip.check_interface` 设为 2,IPv6 还有 `net.inet6.ip6.check_interface`)并且没开转发时才为 `true` |
50
+
51
+ 默认按系统允许的最好方式监听,无论如何都不会比用 `listen(port, host)` 按地址监听差;`reason` 说明为什么没有保证,以及怎样才能有。如果管理网口是一道安全边界,用 `strict: true`:没有保证就拒绝监听,运行中保证丢了就停止监听:
52
+
53
+ ```typescript
54
+ import {createServer} from 'node:http'
55
+ import {type InterfaceListener, NetIfNotEnforcedException, NetworkInterface} from '@lakutata/netif'
56
+
57
+ const management: NetworkInterface = new NetworkInterface({mac: '52:54:00:12:34:56'})
58
+ try {
59
+ const listener: InterfaceListener = await management.listen(createServer(), {port: 443, strict: true})
60
+ listener.on('error', error => console.error(error))
61
+ listener.on('enforced', enforced => console.log(enforced ? 'guaranteed again' : 'stopped: no longer guaranteed'))
62
+ } catch (error) {
63
+ if (error instanceof NetIfNotEnforcedException) console.error(error.message)
64
+ throw error
65
+ }
66
+ ```
67
+
68
+ ## 3. 跟踪一个网口
69
+
70
+ `NetworkInterface` 给出一个网口现在的状态(`present`、`running`、`addresses` 等),并在每次变化时发出事件。事件来自系统的通知,不靠定时轮询。
71
+
72
+ ```typescript
73
+ import {type InterfaceAddress, listInterfaces, NetworkInterface} from '@lakutata/netif'
74
+
75
+ console.log(listInterfaces().map(entry => `${entry.name}: ${entry.running ? 'connected' : 'not connected'}`))
76
+
77
+ const wan: NetworkInterface = new NetworkInterface('eth0')
78
+ wan.on('change', ({added, removed}) => {
79
+ for (const address of added) console.log(`new address ${address.cidr}`)
80
+ for (const address of removed) console.log(`address gone ${address.cidr}`)
81
+ })
82
+ wan.on('down', () => console.log('the cable of eth0 is unplugged'))
83
+ wan.on('up', () => console.log('the cable of eth0 is plugged in'))
84
+ const address: InterfaceAddress = await wan.waitForAddress({family: 'IPv4', timeout: 60000})
85
+ console.log(`reachable at ${address.address}`)
86
+ ```
87
+
88
+ - `listInterfaces()` 也列出没有地址的网口,并带上它们的状态;`os.networkInterfaces()` 只列出有地址的网口。
89
+ - IPv6 地址等它可用后才算:加上后大约一秒,重复地址检测结束时。
90
+ - `close()` 停止跟踪这个网口(它的监听器也会关闭)。
91
+
92
+ ## 4. 网口上的 UDP
93
+
94
+ `createUDPSocket()` 返回这个网口的 UDP 套接字,方法和事件和 `dgram` 的套接字一样。下面是一个只回答管理网络里发现广播的设备:
95
+
96
+ ```typescript
97
+ import {type InterfaceUDPSocket, NetworkInterface} from '@lakutata/netif'
98
+
99
+ const management: NetworkInterface = new NetworkInterface('eth1')
100
+ const socket: InterfaceUDPSocket = await management.createUDPSocket({port: 30303, family: 'IPv4'})
101
+ socket.setBroadcast(true)
102
+ socket.on('message', (message, remote) => {
103
+ const answer: string = `device at ${management.addresses[0]?.address}`
104
+ if (message.toString() === 'who is there') socket.send(answer, remote.port, remote.address)
105
+ })
106
+ socket.on('error', error => console.error(error))
107
+ ```
108
+
109
+ 在 Linux 上,这个套接字只接收从这个网口进来的数据,包括这个网口上的广播和用 `addMembership()` 加入的组播组,别的网口来的一概收不到。在 Windows 和 macOS 上,网口的每个地址各绑一个套接字;macOS 上这样的套接字收不到广播和组播。
110
+
111
+ ## 5. 在应用中使用
112
+
113
+ lakutata 里自己监听端口的入口都有 `interface` 选项:`expressHTTPEntrypoint()`、`fastifyHTTPEntrypoint()`、`httpServiceEntrypoint()`、`socketIOServiceEntrypoint()`、`jsonRPCServiceEntrypoint()`、`@lakutata/grpc` 的 `grpcServiceEntrypoint()` 和 `@lakutata/mcp` 的 `httpMCPEntrypoint()`。设置后,控制器的动作就在这个网口上提供,不管它是什么地址:
114
+
115
+ ```typescript
116
+ import {Application} from 'lakutata'
117
+ import {buildEntrypoints, Controller} from 'lakutata/com/entrypoint'
118
+ import {GET} from 'lakutata/decorator/ctrl'
119
+ import {expressHTTPEntrypoint} from 'lakutata/adapter/express'
120
+
121
+ class ManagementController extends Controller {
122
+ @GET('/status')
123
+ public async status(): Promise<{ ok: boolean }> {
124
+ return {ok: true}
125
+ }
126
+ }
127
+
128
+ Application.run(() => ({
129
+ id: 'device.app',
130
+ name: 'Device',
131
+ components: {
132
+ entrypoint: buildEntrypoints({
133
+ controllers: [ManagementController],
134
+ http: expressHTTPEntrypoint({port: 8080, interface: 'eth1'})
135
+ })
136
+ },
137
+ bootstrap: ['entrypoint']
138
+ }))
139
+ ```
140
+
141
+ - `family`、`strict` 和 `onError` 与 `interface` 配合使用。没有设置 `onError` 时,重新监听的错误会写到应用的日志里。适配器的 `listening` 回调会拿到监听器。这些选项的说明见 [HTTP](HTTP.md) 第 2 节。
142
+ - 网口还不存在时应用也能启动,网口出现后就能访问它的动作。
143
+ - 入口通过 `IInterfaceListening` 按网口监听。这是 lakutata 定义的接口(来自 `@lakutata/core`),这个包实现了它,入口默认加载这个包。另一种实现可以用入口的 `interfaceListening` 选项给出;这个包的 `InterfaceListener` 就是一个 `IInterfaceListener`。
144
+
145
+ 不属于入口的服务器由一个组件持有:在 `startup()` 里开始监听,在 `destroy()` 里停止。网口和端口作为组件的选项。
146
+
147
+ ```typescript
148
+ import {createServer} from 'node:http'
149
+ import express from 'express'
150
+ import {Application, Component, DTO, type ILogger} from 'lakutata'
151
+ import {Configurable, Inject} from 'lakutata/decorator/di'
152
+ import {type InterfaceListener, listenOn} from '@lakutata/netif'
153
+
154
+ class ManagementPages extends Component {
155
+ @Inject('log')
156
+ protected readonly log: ILogger
157
+
158
+ @Configurable(DTO.String().required())
159
+ protected readonly interfaceName: string
160
+
161
+ @Configurable(DTO.Number().port().default(8080))
162
+ protected readonly port: number
163
+
164
+ protected listener: InterfaceListener
165
+
166
+ protected async startup(): Promise<void> {
167
+ const app = express()
168
+ app.get('/', (request, response) => response.send('management'))
169
+ this.listener = await listenOn(this.interfaceName, createServer(app), {port: this.port})
170
+ this.listener.on('error', (error: Error): void => this.log.error(error.message))
171
+ this.listener.on('listening', (): void => this.log.info('listening on %o', this.listener.addresses))
172
+ this.log.info('enforced: %s', this.listener.enforced)
173
+ }
174
+
175
+ protected async destroy(): Promise<void> {
176
+ await this.listener?.close()
177
+ }
178
+ }
179
+
180
+ Application.run(() => ({
181
+ id: 'device.app',
182
+ name: 'Device',
183
+ components: {
184
+ management: {class: ManagementPages, interfaceName: 'eth1', port: 8080}
185
+ },
186
+ bootstrap: ['management']
187
+ }))
188
+ ```
189
+
190
+ ## 6. 系统、运行环境和打包
191
+
192
+ - **Linux** 把套接字绑定在网口上:系统会挡住别的网口,新加的地址立刻就能访问,UDP 能收到这个网口的广播和组播。Linux 5.7 以前,请给进程 `CAP_NET_RAW` 能力(`setcap cap_net_raw+ep`,或在 systemd 单元里写 `AmbientCapabilities=CAP_NET_RAW`)。
193
+ - **Windows** 监听网口的地址;它的强主机模型会挡住别的网口,除非有网口被设成了弱主机模型(`netsh interface ipv4 set interface "<名字>" weakhostreceive=enabled`,有些负载均衡软件会这样做)或者开了转发。`reason` 会说出是哪个网口。
194
+ - **macOS** 监听网口的地址,除非系统设置了,否则没有保证。macOS 27 对进程隐藏 MAC 地址(给出的都是 `02:00:00:00:00:00`):在那里请按名字指定网口。
195
+ - **Bun**:它的 `https` 服务器收到交过来的连接时不做 TLS,所以在 Bun 上 `listen()` 会拒绝它(用 `https` 选项创建的 Fastify 实例也一样);在 Bun 上请改用 `http2.createSecureServer({key, cert, allowHTTP1: true})` 或 `tls.createServer()`。Bun 的服务器不会关闭交给它的空闲长连接,入口会自己关闭它们。交给服务器的连接不能升级为 WebSocket(在 Bun 1.4 上观察到),所以在 Bun 上,入口按网口监听时不支持 WebSocket。
196
+ - **Electron**:在主进程里使用。
197
+ - `lakutata package` 生成的打包文件、可执行文件和安装包会带上编译好的原生插件(`build/Release/netif.node`):请在每个目标平台上先安装这个包,再在那里打包(见[打包](Packaging.md)和[原生插件](NativeAddons.md))。
198
+
199
+ ## 7. 异常
200
+
201
+ | 异常 | 什么时候 |
202
+ |---|---|
203
+ | `NetIfNotFoundException` | 指定的序号没有网口,指定的 MAC 有几个网口 |
204
+ | `NetIfNotEnforcedException` | 用了 `strict`,但系统挡不住别的网口;或者运行中保证丢了(`error` 事件) |
205
+ | `NetIfNotSupportedException` | 系统或运行环境做不到的操作:在 Linux 以外调用 `bindSocket()`,在 macOS 上给绑定在地址上的套接字加组播,在 Bun 上监听 `https` 服务器 |
206
+ | `NetIfTimeoutException` | `waitForAddress()` 超过了 `timeout` |
207
+ | `NetIfException` | 以上所有异常的基类;系统拒绝时,`errno` 是系统的错误码(`'EADDRINUSE'`、`'EACCES'`…) |
@@ -35,7 +35,7 @@
35
35
  | `lakutata/security`、`lakutata/provider/passwordHash` | 哈希、加解密、签名、`PasswordHash` 提供者 |
36
36
  | `lakutata/utils` | 辅助函数 |
37
37
 
38
- 不在本包中的:`@lakutata/cli`(`lakutata` 命令:创建项目、执行迁移、打包应用,见 [Command](Command.md))、`@lakutata/grpc`(gRPC 客户端、服务端和服务传输)、`@lakutata/nats`(NATS 组件和服务传输)、`@lakutata/dbus`(D-Bus)、`@lakutata/mcp`(Model Context Protocol)、`@lakutata/webrtc`(WebRTC:信令、TURN 服务器、媒体服务器、对等连接)、`@lakutata/ffi`(调用动态库中的 C 函数)和 `@lakutata/math`(符号计算和数值计算,直接作为函数使用,不需要 lakutata 应用)。
38
+ 不在本包中的:`@lakutata/cli`(`lakutata` 命令:创建项目、执行迁移、打包应用,见 [Command](Command.md))、`@lakutata/grpc`(gRPC 客户端、服务端和服务传输)、`@lakutata/nats`(NATS 组件和服务传输)、`@lakutata/dbus`(D-Bus)、`@lakutata/mcp`(Model Context Protocol)、`@lakutata/webrtc`(WebRTC:信令、TURN 服务器、媒体服务器、对等连接)、`@lakutata/ffi`(调用动态库中的 C 函数)、`@lakutata/math`(符号计算和数值计算,直接作为函数使用,不需要 lakutata 应用)和 `@lakutata/netif`(服务器和 UDP 套接字按网口监听,不管网口的地址怎么变;本机的网口和它们的变化)。
39
39
 
40
40
  ## 指南
41
41
 
@@ -81,6 +81,7 @@
81
81
  | [WebRTC](WebRTC.md) | 点对点连接和媒体服务器(`@lakutata/webrtc`):信令、TURN 服务器、浏览器和应用、通过数据通道调用应用的动作、WHIP 和 WHEP |
82
82
  | [FFI](FFI.md) | 调用动态库中的 C 函数(`@lakutata/ffi`):从头文件复制的声明、结构体、回调、内存、打包 |
83
83
  | [Math](Math.md) | 数学计算(`@lakutata/math`):公式和精确结果、方程、任意精度的十进制数、矩阵、统计、数值解法、单位 |
84
+ | [Netif](Netif.md) | 网口(`@lakutata/netif`):服务器按网口监听,不管网口的地址怎么变;挡住别的网口的连接;网口和它们的变化;单个网口上的 UDP |
84
85
  | [Command](Command.md) | `lakutata` 命令:创建项目、迁移、翻译、环境变量 |
85
86
  | [Workspaces](Workspaces.md) | 由多个包组成的项目:运行各个包的脚本、统一版本号、添加包 |
86
87
  | [NativeAddons](NativeAddons.md) | 用 C++ 或 Rust 编写的原生插件:创建、链接库、发布 |
@@ -181,7 +181,7 @@ export async function call<T>(message: Record<string, unknown>): Promise<T> {
181
181
  | `host` | 所有网络接口 | 监听的地址 |
182
182
  | `path` | `'/'` | 请求的路径;事件在它下面的 `<path>/events` |
183
183
  | `limit` | `10485760`(10 MiB) | 请求体的最大字节数 |
184
- | `listening` | | `(server) => …`,服务器开始监听后调用 |
184
+ | `listening` | | `(server, listeners) => …`,服务器开始监听后调用(`listeners`:按网口监听时才有,见下文) |
185
185
 
186
186
  - 请求是发往 `path` 的 `POST`,请求体是 JSON 格式的消息。无论动作的结果如何,都以 200 和封套响应。请求体不是 JSON 时以 400 响应,超过 `limit` 时以 413 响应,其他方法或路径以 404 响应;这些响应也带有封套。
187
187
  - CORS 响应头允许任何来源,允许的方法是 `GET`、`POST` 和 `OPTIONS`,并允许任何请求头,例如 `Authorization`。
@@ -255,7 +255,7 @@ Application
255
255
  | `options` | | Socket.IO 服务器的选项:`cors`、`path`、`maxHttpBufferSize`…… |
256
256
  | `server` | | 共用的 HTTP 服务器,或者返回它的函数(见下文) |
257
257
  | `io` | | 一个已有的 Socket.IO 服务器,按原样使用 |
258
- | `listening` | | `(io, server) => …`,适配器就绪后调用 |
258
+ | `listening` | | `(io, server, listeners) => …`,适配器就绪后调用 |
259
259
 
260
260
  ```typescript
261
261
  import {io, type Socket} from 'socket.io-client'
@@ -307,7 +307,7 @@ export const entrypoint = buildEntrypoints({
307
307
  | `input`、`output` | `process.stdin`、`process.stdout` | stdio 使用的流 |
308
308
  | `end` | `'application'` | stdio 的输入结束时:`'application'` 在正在处理的请求都得到响应后退出应用,`'none'` 什么都不做,或者一个函数 |
309
309
  | `heartbeat` | `30000` | WebSocket 的 ping 间隔,单位毫秒;`0` 表示不发送 |
310
- | `listening` | | `(server) => …`,HTTP 或 WebSocket 的服务器开始监听后调用 |
310
+ | `listening` | | `(server, listeners) => …`,HTTP 或 WebSocket 的服务器开始监听后调用 |
311
311
 
312
312
  请求的方法名和参数构成消息:`{"method": "order.get", "params": {"id": 1}}` 就是消息 `{method: 'order.get', id: 1}`,所以动作要在模式中声明方法名:
313
313
 
@@ -378,6 +378,19 @@ $ echo '{"jsonrpc": "2.0", "id": 1, "method": "sum", "params": {"values": [1, 2,
378
378
 
379
379
  输入结束、请求都得到响应之后,应用就会退出(`end`)。
380
380
 
381
+ ### 按网口监听
382
+
383
+ `httpServiceEntrypoint()`、使用自己服务器的 `socketIOServiceEntrypoint()`,以及走 HTTP 或 WebSocket 的 `jsonRPCServiceEntrypoint()`,都可以按网口监听,而不是按地址监听,不管网口现在是什么地址。这需要另外安装 `@lakutata/netif` 包。它们接受和 HTTP 适配器一样的 `interface`、`family`、`strict`、`onError` 和 `interfaceListening` 选项(见 [HTTP](HTTP.md) 第 2 节),`listening` 回调的最后一个参数是网口的监听器:
384
+
385
+ ```typescript
386
+ import {httpServiceEntrypoint} from 'lakutata/com/entrypoint'
387
+
388
+ //服务只在 eth1 上提供,不管它是什么地址
389
+ export const service = httpServiceEntrypoint({port: 3000, interface: 'eth1'})
390
+ ```
391
+
392
+ 在 Bun 上,交给服务器的连接不能升级为 WebSocket(在 Bun 1.4 上观察到)。所以在 Bun 上,走 WebSocket 的 JSON-RPC 会启动失败;Socket.IO 也会启动失败,除非只用轮询(`options: {transports: ['polling']}`,客户端也要这样设置)。
393
+
381
394
  ### Electron
382
395
 
383
396
  `electronIPCServiceEntrypoint()` 在 Electron 应用的主进程中,向它的窗口提供动作。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lakutata",
3
- "version": "3.1.3",
3
+ "version": "3.1.4",
4
4
  "description": "An IoC application framework for Node.js and TypeScript: one programming model for web services, CLI tools, desktop (Electron) and embedded applications.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -354,18 +354,18 @@
354
354
  "mcp"
355
355
  ],
356
356
  "dependencies": {
357
- "@lakutata/cache": "3.1.1",
358
- "@lakutata/containerd": "3.1.1",
359
- "@lakutata/core": "3.1.1",
360
- "@lakutata/docker": "3.1.1",
361
- "@lakutata/entrypoint": "3.1.1",
362
- "@lakutata/i18n": "3.1.1",
363
- "@lakutata/logger": "3.1.1",
364
- "@lakutata/monitor": "3.1.1",
357
+ "@lakutata/cache": "3.1.4",
358
+ "@lakutata/containerd": "3.1.4",
359
+ "@lakutata/core": "3.1.4",
360
+ "@lakutata/docker": "3.1.4",
361
+ "@lakutata/entrypoint": "3.1.4",
362
+ "@lakutata/i18n": "3.1.4",
363
+ "@lakutata/logger": "3.1.4",
364
+ "@lakutata/monitor": "3.1.4",
365
365
  "@lakutata/mutex": "3.1.3",
366
- "@lakutata/orm": "3.1.1",
367
- "@lakutata/queue": "3.1.1",
368
- "@lakutata/security": "3.1.1",
366
+ "@lakutata/orm": "3.1.4",
367
+ "@lakutata/queue": "3.1.4",
368
+ "@lakutata/security": "3.1.4",
369
369
  "@lakutata/utils": "3.1.1"
370
370
  },
371
371
  "files": [
@@ -379,6 +379,7 @@
379
379
  ],
380
380
  "peerDependencies": {
381
381
  "@inquirer/prompts": "^7.0.0 || ^8.0.0",
382
+ "@lakutata/netif": "~3.1.4",
382
383
  "commander": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0",
383
384
  "express": "^4.21.0 || ^5.0.0",
384
385
  "fastify": "^5.0.0",
@@ -403,6 +404,9 @@
403
404
  },
404
405
  "ws": {
405
406
  "optional": true
407
+ },
408
+ "@lakutata/netif": {
409
+ "optional": true
406
410
  }
407
411
  }
408
412
  }