feat(web): grow real node halves in connection and hmr

connection binds the web transport: it injects httpServer + apiProxy and
registers toFetchHandler(ctx.apiProxy) under the /api prefix (the node:http
to fetch bridge moves in from the webserver, keeping the res-close disconnect
detection and drain/close backpressure waits). hmr owns dev reload: a
stat-poll watch per graph row driven by clientModuleHost.onGraphChanged,
rebuilt(id) on content change, and the /plugins/events SSE route (GET/HEAD
guarded); frame types are single-sourced in events.ts shared by both halves.
This commit is contained in:
imccyu
2026-07-25 10:23:40 +08:00
parent c12277b4bb
commit 8d4aa73abe
14 changed files with 461 additions and 54 deletions
+2
View File
@@ -43,10 +43,12 @@
"src"
],
"peerDependencies": {
"@deepseek-ai/dsh-host-webserver": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-host-webserver": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
@@ -0,0 +1,8 @@
/**
* The /api URL prefix — single source for both halves of the web transport.
* The node half registers this prefix on the web server; browser-side path
* literals currently live in the apiproxy client layer (out of scope here).
*/
/** Route prefix owning every api request (`/api` and `/api/<anything>`). */
export const API_PATH = '/api'
@@ -0,0 +1,53 @@
/**
* node:http ↔ WHATWG fetch bridge for the /api transport (host side of the
* web carrier; the fetch-shaped handler itself is transport-agnostic).
*/
import type { IncomingMessage, ServerResponse } from 'node:http'
/** Bridge one node:http request to the fetch-shaped handler (client close aborts; SSE bodies stream out chunk by chunk). */
export async function bridge(req: IncomingMessage, res: ServerResponse, apiHandler: { fetch: typeof fetch }): Promise<void> {
const abort = new AbortController()
// Client-disconnect detection MUST hang off the response, not the request:
// since Node 16, IncomingMessage 'close' fires as soon as the request body is
// fully consumed (immediately for a bodyless GET), which would abort every SSE
// stream right after open. ServerResponse 'close' fires on connection teardown;
// writableEnded distinguishes a normal end() from the client going away.
res.on('close', () => {
if (!res.writableEnded) abort.abort()
})
const chunks: Buffer[] = []
for await (const chunk of req) chunks.push(chunk as Buffer)
/* v8 ignore next 3 -- `??` arms: node:http always sets url/method on server
requests; the fields are only optional on the client-side IncomingMessage type */
const request = new Request(new URL(req.url ?? '/', 'http://dsh.internal'), {
method: req.method ?? 'GET',
headers: Object.fromEntries(Object.entries(req.headers).filter(([, v]) => typeof v === 'string') as [string, string][]),
...chunks.length > 0 ? { body: Buffer.concat(chunks) } : {},
signal: abort.signal,
})
const response = await apiHandler.fetch(request)
res.writeHead(response.status, Object.fromEntries(response.headers.entries()))
if (response.body === null) {
res.end()
return
}
for await (const chunk of response.body) {
// Backpressure: a false return means the socket buffer is full — wait for drain
// instead of buffering unboundedly (slow/suspended SSE consumers). 'close' also
// resolves so a mid-wait disconnect can't park this loop forever; the close
// handler above aborts the handler stream, which then ends the iteration.
if (!res.write(chunk)) {
await new Promise<void>((resolve) => {
const done = (): void => {
res.off('drain', done)
res.off('close', done)
resolve()
}
res.once('drain', done)
res.once('close', done)
})
}
}
res.end()
}
+33 -7
View File
@@ -1,10 +1,36 @@
/**
* Connection plugin, node half. The package IS a dshClient plugin: the wire
* consumer layer lives in its client half in full (src/client/ — contract:
* api-contracts v3 section 3, inventory §3.2); consumers import the /client
* subpath. The empty apply exists so the plugin appears in the host Loader
* (lifecycle governance + dshClient discovery).
* Connection plugin, node half: the host end of the web transport. Registers
* the /api prefix route on the web server and bridges node:http requests to
* the transport-agnostic fetch-shaped api handler. The wire consumer layer
* lives in the client half (src/client/ — contract: api-contracts v3
* section 3); consumers import the /client subpath.
*/
import type { Context } from 'cordis'
// Type-only route import; it also carries the httpServer Context merge.
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
import { API_PATH } from './api-path.ts'
import { bridge } from './http-bridge.ts'
/** Host plugin body — no host-side behavior for the connection plugin. */
export function apply(_ctx: unknown): void {}
export { API_PATH } from './api-path.ts'
/** Cordis plugin name. */
export const name = 'client-connection'
/** Required services: the route registry and the api gateway. */
export const inject = ['httpServer', 'apiProxy']
/**
* Mount the /api transport: wrap the api gateway into a fetch handler and
* serve it under the /api prefix.
* @param ctx - host plugin context carrying httpServer and apiProxy.
*/
export function apply(ctx: Context): void {
const apiHandler = toFetchHandler(ctx.apiProxy)
const route: WebRoute = {
kind: 'prefix',
path: API_PATH,
handler: (req, res) => bridge(req, res, apiHandler),
}
ctx.effect(() => ctx.httpServer.register(route), 'client-connection: /api route')
}
+4 -3
View File
@@ -15,10 +15,11 @@ export const name = 'client-connection-invariant'
export const inject = ['invariants']
/**
* No runtime invariant: the pure wire layer emits no cordis events and owns no
* No runtime invariant: the wire layer emits no cordis events and owns no
* mutable cross-plugin relation — stream/reconnect sequencing is exercised
* directly by its behavior specs, and rpcId round-trip discipline is owned by
* the apiproxy contract layer.
* directly by its behavior specs, rpcId round-trip discipline is owned by the
* apiproxy contract layer, and the node half's single route registration's
* register/dispose symmetry is audited by the webserver package's invariant.
*/
const install: InvariantInstaller = () => {}
@@ -1,10 +1,33 @@
/** Node half: the empty host apply (Loader governance + dshClient discovery placeholder). */
/** Node half: registers the /api prefix route bridging to the api gateway. */
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import { apply } from '../src/index.ts'
import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { HttpServerService, WebRoute } from '@deepseek-ai/dsh-host-webserver'
import { API_PATH, apply, inject } from '../src/index.ts'
describe('node half', () => {
it('apply is a no-op host placeholder', () => {
apply(undefined)
expect(true).toBe(true) // reaching here without throw is the contract
describe('connection node half', () => {
it('registers the /api prefix route and removes it with the fiber', async () => {
const ctx = new Context()
const routes: WebRoute[] = []
// Structural fake: the plugin only touches register(); the service class
// carries private state a literal cannot (and need not) reproduce.
const httpServer: Pick<HttpServerService, 'register' | 'tapIndex' | 'port'> = {
register(route) {
routes.push(route)
return () => { routes.splice(routes.indexOf(route), 1) }
},
tapIndex: () => () => {},
port: 0,
}
ctx.provide('httpServer', httpServer as HttpServerService)
ctx.provide('apiProxy', {} as unknown as ApiProxy)
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
expect(routes).toHaveLength(1)
expect(routes[0]).toMatchObject({ kind: 'prefix', path: API_PATH })
await fiber.dispose()
expect(routes).toHaveLength(0)
})
})
+5 -1
View File
@@ -2,7 +2,8 @@
"extends": "../../../tsconfig.base.client.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
"outDir": "lib/types",
"types": ["node"]
},
"include": [
"src"
@@ -20,6 +21,9 @@
{
"path": "../../host/apiproxy"
},
{
"path": "../../host/webserver"
},
{
"path": "../../ui/user-approval"
},
+5
View File
@@ -28,15 +28,20 @@
"immediately": true
},
"license": "BSD-3-Clause",
"dependencies": {
"schemastery": "^3.18.0"
},
"peerDependencies": {
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
"@deepseek-ai/dsh-client-modules": "^0.0.1",
"@deepseek-ai/dsh-host-webserver": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@cordisjs/plugin-loader": "workspace:^",
"@deepseek-ai/dsh-client-modules": "workspace:^",
"@deepseek-ai/dsh-host-webserver": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"cordis": "^4.0.0-rc.7"
},
+4 -13
View File
@@ -64,20 +64,11 @@
*/
import type { Context } from 'cordis'
import type { Entry, Loader } from '@cordisjs/plugin-loader'
import type { WebBootGraph } from '@deepseek-ai/dsh-client-modules'
import type { PluginsEventFrame } from '../events.ts'
import { EVENTS_ENDPOINT } from '../events.ts'
/**
* Frames on the `GET /plugins/events` system SSE channel (owned host-side by
* dsh-host-webserver's PluginEventFrame). Mirrored here because this is a
* wire boundary: frames arrive as JSON text and are validated at the parse
* point, not shared as a same-process typed seam.
*/
export type PluginsEventFrame =
| { type: 'graph'; graph: WebBootGraph }
| { type: 'rebuilt'; id: string; rev: string }
/** System SSE endpoint pushing graph/rebuilt frames (wire protocol constant). */
export const EVENTS_ENDPOINT = '/plugins/events'
export type { PluginsEventFrame } from '../events.ts'
export { EVENTS_ENDPOINT } from '../events.ts'
/** Cordis plugin name. */
export const name = 'client-hmr'
+16
View File
@@ -0,0 +1,16 @@
/**
* Wire protocol of the `/plugins/events` dev SSE channel — single source for
* both halves of this package. Frames still cross a wire boundary: the
* browser half validates them at its JSON parse point; sharing the type keeps
* the two ends from drifting, not from parsing.
*/
import type { WebBootGraph } from '@deepseek-ai/dsh-client-modules'
/** One SSE frame: the full graph on connect, or one rebuilt bundle notice. */
export type PluginsEventFrame =
| { type: 'graph'; graph: WebBootGraph }
| { type: 'rebuilt'; id: string; rev: string }
/** System SSE endpoint pushing graph/rebuilt frames (wire protocol constant). */
export const EVENTS_ENDPOINT = '/plugins/events'
+149 -6
View File
@@ -1,9 +1,152 @@
/**
* HMR plugin, node half. The package IS a dshClient plugin (dev-only row in
* the host graph): the reload driver lives in its client half in full
* (src/client/); the empty apply exists so the plugin appears in the host
* Loader (lifecycle governance + dshClient discovery).
* HMR plugin, node half: the host end of the dev reload chain. Stat-polls
* every graph row's client bundle (fs.watchFile — polling by design: network
* mounts deliver no inotify events), reports content changes through
* `clientModuleHost.rebuilt(id)`, and serves the `/plugins/events` SSE channel
* broadcasting graph/rebuilt frames to the browser half (src/client/).
* Dev-only row: prod compositions never mount this plugin.
*/
import type { Stats } from 'node:fs'
import { unwatchFile, watchFile } from 'node:fs'
import type { ServerResponse } from 'node:http'
import type { Context } from 'cordis'
import z from 'schemastery'
// Empty type imports carry the clientModuleHost/httpServer Context merges.
import type {} from '@deepseek-ai/dsh-client-modules'
import type {} from '@deepseek-ai/dsh-host-webserver'
import type { PluginsEventFrame } from './events.ts'
import { EVENTS_ENDPOINT } from './events.ts'
/** Host plugin body — no host-side behavior for the HMR plugin. */
export function apply(): void {}
export type { PluginsEventFrame } from './events.ts'
export { EVENTS_ENDPOINT } from './events.ts'
/** Cordis plugin name. */
export const name = 'client-hmr'
/** Required services: the web plugin table and the route registry. */
export const inject = ['clientModuleHost', 'httpServer']
/** Plugin config, validated by the same-named schemastery schema. */
export interface Config {
/** Bundle stat-poll interval in milliseconds (default 500, the build-side watcher's polling default). */
pollIntervalMs?: number
}
export const Config: z<Config> = z.object({
pollIntervalMs: z.number().step(1).min(1).default(500),
})
/** Serialize one frame as an SSE data line. */
function sseData(frame: PluginsEventFrame): string {
return `data: ${JSON.stringify(frame)}\n\n`
}
/**
* Mount the dev chain: bundle watches, rebuilt reporting, and the SSE channel.
* @param ctx - host plugin context carrying clientModuleHost and httpServer.
* @param config - validated {@link Config}.
*/
export function apply(ctx: Context, config: Config): void {
// schemastery's .default() guarantees the field is set after validation.
const pollIntervalMs = config.pollIntervalMs as number
// --- bundle watch: one fs.watchFile stat poll per graph row -------------
const watched = new Map<string, { path: string; listener: (curr: Stats, prev: Stats) => void }>()
const watchRow = (id: string, path: string): void => {
const listener = (curr: Stats, prev: Stats): void => {
// fs.watchFile fires on any stat delta (atime included); only content
// signals count. An all-zero curr means the file vanished mid-rebuild
// — the completing write fires the next tick, so skipping is safe.
if (curr.mtimeMs === prev.mtimeMs && curr.size === prev.size) return
if (curr.mtimeMs === 0) return
try {
// rebuilt() re-hashes; an unchanged hash stays silent (clientModuleHost
// fires onRebuilt only on a real rev change). A torn read of a
// half-written bundle self-heals on the next poll tick.
ctx.clientModuleHost.rebuilt(id)
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
if (code === 'ENOENT') return // mid-rename window; the completed write fires the next poll tick
ctx.logger.warn(error)
}
}
watchFile(path, { interval: pollIntervalMs, persistent: false }, listener)
watched.set(id, { path, listener })
}
// Diff the watch set against the current graph: drop watches for removed
// rows (or rows whose bundle path moved), add watches for new rows.
const syncWatches = (): void => {
const rows = new Map<string, string>()
for (const row of ctx.clientModuleHost.graph().entries) {
const path = ctx.clientModuleHost.clientPath(row.id)
if (path !== undefined) rows.set(row.id, path)
}
for (const [id, watch] of watched) {
if (rows.get(id) === watch.path) continue
unwatchFile(watch.path, watch.listener)
watched.delete(id)
}
for (const [id, path] of rows) {
if (!watched.has(id)) watchRow(id, path)
}
}
ctx.effect(() => {
// Initial sync covers rows already in the graph; the subscription covers
// rows arriving later (boot-window activations, including this plugin's
// own row — no self-exemption, a modules/hmr rebuild rides the same chain).
syncWatches()
const unsubscribe = ctx.clientModuleHost.onGraphChanged(syncWatches)
return () => {
unsubscribe()
for (const { path, listener } of watched.values()) unwatchFile(path, listener)
watched.clear()
}
}, 'client-hmr: bundle watches')
// --- /plugins/events SSE channel ----------------------------------------
const connections = new Set<ServerResponse>()
const connect = (res: ServerResponse): void => {
res.writeHead(200, {
'content-type': 'text/event-stream',
'cache-control': 'no-cache',
'connection': 'keep-alive',
})
// Comment line on open so clients/proxies see a live channel even when
// no rebuild ever happens; EventSource frame parsing skips it naturally.
res.write(': connected\n\n')
res.write(sseData({ type: 'graph', graph: ctx.clientModuleHost.graph() }))
connections.add(res)
res.on('close', () => { connections.delete(res) })
}
ctx.effect(() => {
const disposeRoute = ctx.httpServer.register({
kind: 'exact',
path: EVENTS_ENDPOINT,
handler: (req, res) => {
// Named routes match ahead of the carrier's method gate; keep the old
// global 405 semantics for non-GET hits on this endpoint.
if (req.method !== 'GET' && req.method !== 'HEAD') {
res.writeHead(405)
res.end()
return
}
connect(res)
},
})
const unsubscribe = ctx.clientModuleHost.onRebuilt((id, rev) => {
const line = sseData({ type: 'rebuilt', id, rev })
for (const res of connections) res.write(line)
})
return () => {
unsubscribe()
disposeRoute()
for (const res of connections) res.destroy()
connections.clear()
}
}, 'client-hmr: /plugins/events channel')
}
+36 -9
View File
@@ -3,8 +3,7 @@
* @module @deepseek-ai/dsh-client-hmr/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { Context, Fiber } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-client-hmr'
@@ -14,14 +13,43 @@ export const name = 'client-hmr-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/** Live fs.watchFile pollers (this package is the composition's only stat-poll user). */
function statWatchers(): number {
return process.getActiveResourcesInfo().filter(kind => kind === 'StatWatcher').length
}
/**
* No runtime invariant: a dev-only reload driver — it consumes the loader
* entry tree and module cache but owns no events and no cross-plugin mutable
* state; reload correctness (dispose → style removal → re-execute ordering)
* is observable only through the assembled browser runtime, not a host-side
* event relation.
* Owned relation: every bundle stat watcher the node half starts must die
* with its fiber — a surviving poller would keep re-hashing bundles for a
* torn-down dev chain forever. Checked as a baseline delta: the StatWatcher
* count observed at fiber creation must be restored once disposal has drained
* the fiber's effects (`internal/plugin` fires at dispose start; the microtask
* hop lets the disposer queue its unload before `fiber.await()` joins it).
* SSE-connection and listener teardown live inside the same ctx.effect
* disposers, so the watcher count is the relation's observable proxy.
*/
const install: InvariantInstaller = () => {}
const install: InvariantInstaller = (ctx, fail) => {
const baselines = new WeakMap<Fiber, number>()
ctx.on('internal/plugin', (fiber) => {
if (fiber.name !== 'client-hmr') return
if (fiber.uid !== null) {
baselines.set(fiber, statWatchers())
return
}
const baseline = baselines.get(fiber)
if (baseline === undefined) return
// Returned to the emitter: emitPluginDisposed awaits-and-logs async
// listener failures, so a violation surfaces loudly instead of unhandled.
return (async () => {
await Promise.resolve()
await fiber.await()
const remaining = statWatchers()
if (remaining > baseline) {
fail(`client-hmr fiber disposed but ${remaining - baseline} bundle stat watcher(s) survived teardown`)
}
})()
}, { global: true })
}
/**
* Register this package's invariant companion.
@@ -30,4 +58,3 @@ const install: InvariantInstaller = () => {}
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */
+110 -8
View File
@@ -1,14 +1,116 @@
/**
* Node half of the HMR plugin: an empty apply placeholder (the reload driver
* lives in the client half) whose only contract is mounting and disposing
* cleanly in the host Loader.
* Node half of the HMR plugin: bundle watches follow the graph, stat changes
* report through clientModuleHost.rebuilt, and everything dies with the fiber.
*/
import { describe, expect, it } from 'vitest'
import { apply } from '@deepseek-ai/dsh-client-hmr'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from 'cordis'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { WebBootGraph, ClientModuleHostService } from '@deepseek-ai/dsh-client-modules'
import type { WebRoute, HttpServerService } from '@deepseek-ai/dsh-host-webserver'
import { apply, Config, EVENTS_ENDPOINT, inject } from '../src/index.ts'
const POLL_MS = 20
let dir: string
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-')) })
afterEach(() => { rmSync(dir, { recursive: true, force: true }) })
/**
* Controllable clientModuleHost fake over a mutable id → bundle-path table.
* Structural (Pick+cast): the plugin only touches the read/notify surface;
* the service class carries private scan state a literal need not reproduce.
*/
type FakeHost = ClientModuleHostService & { rebuiltCalls: string[]; fireGraphChanged(): void }
function fakeClientModuleHost(rows: Map<string, string>): FakeHost {
const graphListeners = new Set<() => void>()
const rebuiltCalls: string[] = []
const fake: Pick<FakeHost, 'graph' | 'clientPath' | 'rebuilt' | 'onRebuilt' | 'onGraphChanged' | 'rebuiltCalls' | 'fireGraphChanged'> = {
rebuiltCalls,
fireGraphChanged: () => { for (const l of graphListeners) l() },
graph: (): WebBootGraph => ({
rev: 'r',
entries: [...rows.keys()].map(id => ({ id, url: `/plugins/${id}/client.js?rev=r`, rev: 'r' })),
}),
clientPath: id => rows.get(id),
rebuilt: (id) => { rebuiltCalls.push(id); return 'r2' },
onRebuilt: () => () => {},
onGraphChanged: (listener) => {
graphListeners.add(listener)
return () => { graphListeners.delete(listener) }
},
}
return fake as FakeHost
}
// Structural fake: the plugin only touches register(); the service class
// carries private state a literal cannot (and need not) reproduce.
function fakeHttpServer(routes: WebRoute[]): HttpServerService {
const fake: Pick<HttpServerService, 'register' | 'tapIndex' | 'port'> = {
register(route) {
routes.push(route)
return () => { routes.splice(routes.indexOf(route), 1) }
},
tapIndex: () => () => {},
port: 0,
}
return fake as HttpServerService
}
async function mount(clientModuleHost: FakeHost, httpServer: HttpServerService) {
const ctx = new Context()
ctx.provide('clientModuleHost', clientModuleHost)
ctx.provide('httpServer', httpServer)
const fiber = ctx.plugin(
{ inject: [...inject], Config, apply },
{ pollIntervalMs: POLL_MS },
)
await fiber.await()
return fiber
}
describe('hmr node half', () => {
it('apply is a no-op host placeholder', () => {
apply()
expect(true).toBe(true) // reaching here without throw is the contract
it('watches graph bundles, reports stat changes, and unwatches on dispose', async () => {
const bundle = join(dir, 'a.js')
writeFileSync(bundle, 'v1')
const clientModuleHost = fakeClientModuleHost(new Map([['pkg-a', bundle]]))
const routes: WebRoute[] = []
const fiber = await mount(clientModuleHost, fakeHttpServer(routes))
expect(routes).toHaveLength(1)
expect(routes[0]).toMatchObject({ kind: 'exact', path: EVENTS_ENDPOINT })
// Nudge mtime past stat granularity so the poller sees a content signal.
await new Promise(resolve => setTimeout(resolve, POLL_MS * 2))
writeFileSync(bundle, 'v2-longer')
await vi.waitFor(() => { expect(clientModuleHost.rebuiltCalls).toContain('pkg-a') }, { timeout: 3_000 })
await fiber.dispose()
expect(routes).toHaveLength(0)
// Watcher gone: further file changes report nothing.
clientModuleHost.rebuiltCalls.length = 0
writeFileSync(bundle, 'v3-even-longer')
await new Promise(resolve => setTimeout(resolve, POLL_MS * 4))
expect(clientModuleHost.rebuiltCalls).toHaveLength(0)
})
it('follows graph changes: rows added after activation get watched', async () => {
const early = join(dir, 'early.js')
const late = join(dir, 'late.js')
writeFileSync(early, 'v1')
const rows = new Map([['pkg-early', early]])
const clientModuleHost = fakeClientModuleHost(rows)
const fiber = await mount(clientModuleHost, fakeHttpServer([]))
writeFileSync(late, 'v1')
rows.set('pkg-late', late)
clientModuleHost.fireGraphChanged()
await new Promise(resolve => setTimeout(resolve, POLL_MS * 2))
writeFileSync(late, 'v2-longer')
await vi.waitFor(() => { expect(clientModuleHost.rebuiltCalls).toContain('pkg-late') }, { timeout: 3_000 })
await fiber.dispose()
})
})
+7 -1
View File
@@ -8,7 +8,7 @@
"DOM",
"DOM.Iterable"
],
"types": []
"types": ["node"]
},
"include": [
"src"
@@ -23,6 +23,12 @@
{
"path": "../modules"
},
{
"path": "../../host/webserver"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../support/invariants"
}