The packer images the web template bundles, every optional bundle, and an installation manifest instead of a dumped composition, and the Worker boots that profile through app-boot like dsh web. Package-local lazy client chunks move into content-addressed archives that the VFS fetches on first asynchronous read; client-modules reads chunks asynchronously. The Worker replaces node-addon-require-builtin with Node v24.18.0 internal-module interfaces and reports Node 24, so plugin metadata, the Cordis Loader, and HMR reach one cascaded loader backed by the Worker loader. Resolution failures carry Node error codes, the empty load cache lets HMR apply profile changes, and every optional bundle switches on and off live. Platform stubs throw ERR_FEATURE_UNAVAILABLE_ON_PLATFORM.
199 lines
9.5 KiB
TypeScript
199 lines
9.5 KiB
TypeScript
/**
|
|
* The Node-compatibility layer's refusals and its small answering faces.
|
|
*
|
|
* Two contracts live here. Every replaced symbol must be PRESENT — a missing
|
|
* CommonJS export degrades to `undefined` and fails at call time somewhere
|
|
* unrelated — and every symbol the worker cannot honour must refuse while naming
|
|
* itself, because these errors are routinely swallowed far from their cause and
|
|
* the name is what places them in a worker session's console.
|
|
*
|
|
* The member lists are the tables the modules are checked against: adding a
|
|
* refusing symbol without listing it here leaves it unproven, and listing one
|
|
* that starts answering fails.
|
|
*/
|
|
import { describe, expect, it, vi } from 'vitest'
|
|
import { notAvailableError, notImplementedFail } from '../../src/node/notImplementedFail.ts'
|
|
import * as childProcess from '../../src/node/builtin_modules/implemented/child_process.ts'
|
|
import * as dnsPromises from '../../src/node/builtin_modules/mock/dns/promises.ts'
|
|
import * as https from '../../src/node/builtin_modules/mock/https.ts'
|
|
import * as net from '../../src/node/builtin_modules/mock/net.ts'
|
|
import * as sqlite from '../../src/node/builtin_modules/mock/sqlite.ts'
|
|
import * as stream from '../../src/node/builtin_modules/implemented/stream.ts'
|
|
import * as vm from '../../src/node/builtin_modules/mock/vm.ts'
|
|
import * as workerThreads from '../../src/node/builtin_modules/mock/worker_threads.ts'
|
|
import * as nodePty from '../../src/node/external_packages/node-pty.ts'
|
|
import * as libreofficeKit from '../../src/node/external_packages/libreoffice-kit.ts'
|
|
import * as execa from '../../src/node/external_packages/execa.ts'
|
|
import * as got from '../../src/node/external_packages/got.ts'
|
|
import * as piAi from '../../src/node/external_packages/pi-ai.ts'
|
|
import * as ripgrep from '../../src/node/external_packages/ripgrep.ts'
|
|
import * as ws from '../../src/node/external_packages/ws.ts'
|
|
import { REPLACED_EXTERNAL_PACKAGES } from '../../src/node/external_packages/replaced-externals.ts'
|
|
import * as os from '../../src/node/builtin_modules/implemented/os.ts'
|
|
import * as perfHooks from '../../src/node/builtin_modules/implemented/perf_hooks.ts'
|
|
import { DSH_HOME, DSH_TMP } from '../../src/storage/paths.ts'
|
|
|
|
/** Node's standard code for a feature the running platform cannot provide. */
|
|
const PLATFORM_UNAVAILABLE = 'ERR_FEATURE_UNAVAILABLE_ON_PLATFORM'
|
|
|
|
/** Every refusal writes its message to the console before throwing; keep the run quiet. */
|
|
const quiet = (): void => { vi.spyOn(console, 'error').mockImplementation(() => {}) }
|
|
|
|
/** Symbols that refuse when called. */
|
|
const CALLED: [string, Record<string, unknown>, readonly string[]][] = [
|
|
['node:dns/promises', dnsPromises, ['lookup']],
|
|
['node:https', https, ['createServer']],
|
|
['node:net', net, ['createServer', 'connect']],
|
|
['node:sqlite', sqlite, ['backup']],
|
|
['node:vm', vm, ['createContext', 'runInContext', 'runInNewContext', 'runInThisContext', 'isContext']],
|
|
['node:worker_threads', workerThreads, ['MessageChannel', 'MessagePort', 'markAsUntransferable', 'receiveMessageOnPort']],
|
|
// The rest of `node:child_process` runs commands (see child-process.spec.ts);
|
|
// these three need a real process, so they stay refusals.
|
|
['node:child_process', childProcess, ['execFileSync', 'execSync', 'fork']],
|
|
['node-pty', nodePty, ['spawn', 'open']],
|
|
['execa', execa, ['execa']],
|
|
['got', got.default, ['post']],
|
|
['@deepseek-ai/pi-ai', piAi, [
|
|
'createProvider', 'createModels', 'openAICompletionsApi', 'openAIResponsesApi', 'anthropicMessagesApi',
|
|
'isContextOverflow', 'getSupportedThinkingLevels', 'builtinModels',
|
|
]],
|
|
]
|
|
|
|
/** Classes that refuse when constructed. */
|
|
const CONSTRUCTED: [string, Record<string, unknown>, readonly string[]][] = [
|
|
['node:sqlite', sqlite, ['DatabaseSync', 'StatementSync']],
|
|
['node:vm', vm, ['Script']],
|
|
['node:worker_threads', workerThreads, ['Worker']],
|
|
['node:perf_hooks', perfHooks, ['PerformanceObserver']],
|
|
['ws', ws, ['WebSocket']],
|
|
]
|
|
|
|
describe('not-implemented stubs', () => {
|
|
it('names the module and the symbol, carries the Node platform code, and reports before throwing', () => {
|
|
const reported = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
const error = notAvailableError('node:zlib', 'gzipSync')
|
|
expect(error.message).toBe('web-preview: node:zlib.gzipSync is not available in the worker host')
|
|
expect(error.code).toBe(PLATFORM_UNAVAILABLE)
|
|
expect(reported).toHaveBeenCalledWith(error.message)
|
|
|
|
const stub = notImplementedFail('node:zlib', 'gzipSync')
|
|
expect(() => stub()).toThrow(expect.objectContaining({ message: error.message, code: PLATFORM_UNAVAILABLE }))
|
|
})
|
|
|
|
for (const [module, namespace, members] of CALLED) {
|
|
it(`${module} refuses ${String(members.length)} called symbol(s)`, () => {
|
|
quiet()
|
|
for (const member of members) {
|
|
const value = namespace[member]
|
|
expect(typeof value, member).toBe('function')
|
|
expect(() => (value as () => unknown)(), member).toThrow(new RegExp(`${member}\\b.*not available in the worker host`))
|
|
}
|
|
})
|
|
}
|
|
|
|
for (const [module, namespace, members] of CONSTRUCTED) {
|
|
it(`${module} refuses ${String(members.length)} constructed symbol(s)`, () => {
|
|
quiet()
|
|
for (const member of members) {
|
|
const value = namespace[member]
|
|
expect(typeof value, member).toBe('function')
|
|
expect(() => new (value as new () => unknown)(), member).toThrow(/not available in the worker host/)
|
|
}
|
|
})
|
|
}
|
|
|
|
it('refuses node:worker_threads construction with the Node platform code', () => {
|
|
quiet()
|
|
expect(() => new workerThreads.MessageChannel()).toThrow(expect.objectContaining({ code: PLATFORM_UNAVAILABLE }))
|
|
expect(() => new workerThreads.Worker('')).toThrow(expect.objectContaining({ code: PLATFORM_UNAVAILABLE }))
|
|
})
|
|
|
|
it('keeps the CommonJS interop marker and a default export on every replaced module', () => {
|
|
for (const namespace of [dnsPromises, net, sqlite, vm, workerThreads, childProcess, stream, ws, nodePty, piAi, os, perfHooks, got]) {
|
|
const holder = namespace as { __esModule?: unknown; default?: unknown }
|
|
expect(holder.__esModule).toBe(true)
|
|
expect(holder.default).toBeDefined()
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('constructible-but-inert fakes', () => {
|
|
it('a ws server constructs, accepts listeners, and refuses to carry an upgrade', () => {
|
|
quiet()
|
|
expect(ws.Server).toBe(ws.WebSocketServer)
|
|
const server = new ws.WebSocketServer()
|
|
expect(server.clients.size).toBe(0)
|
|
expect(server.on()).toBe(server)
|
|
expect(() => server.handleUpgrade()).toThrow(/WebSocketServer.handleUpgrade is not available/)
|
|
expect(() => server.emit()).toThrow(/WebSocketServer.emit is not available/)
|
|
let closed = false
|
|
server.close(() => { closed = true })
|
|
expect(closed).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('replaced external packages', () => {
|
|
it('reports Office conversion as unavailable without creating a Node worker', async () => {
|
|
quiet()
|
|
await expect(libreofficeKit.createConverter()).rejects.toMatchObject({
|
|
code: 'unavailable',
|
|
message: 'web-preview: @deepseek-ai/libreoffice-kit.createConverter is not available in the worker host',
|
|
})
|
|
})
|
|
|
|
it('lists the packages the loader serves from the bundle', () => {
|
|
expect(REPLACED_EXTERNAL_PACKAGES).not.toContain('chokidar')
|
|
expect(REPLACED_EXTERNAL_PACKAGES).not.toContain('@deepseek-ai/node-addon-system')
|
|
expect(REPLACED_EXTERNAL_PACKAGES).not.toContain('@deepseek-ai/node-addon-system/landlock-run')
|
|
expect(REPLACED_EXTERNAL_PACKAGES).toContain('ws')
|
|
})
|
|
|
|
it('answers the values callers read without invoking anything', () => {
|
|
// The ripgrep binary path is read as data by its consumer.
|
|
expect(typeof ripgrep.rgPath).toBe('string')
|
|
})
|
|
})
|
|
|
|
describe('node:net address predicates', () => {
|
|
it('classifies IPv4, IPv6, and neither', () => {
|
|
expect([net.isIPv4('127.0.0.1'), net.isIPv4('255.255.255.255')]).toEqual([true, true])
|
|
expect([net.isIPv4('256.0.0.1'), net.isIPv4('::1'), net.isIPv4('nope')]).toEqual([false, false, false])
|
|
expect([net.isIPv6('::1'), net.isIPv6('fe80::1'), net.isIPv6('127.0.0.1')]).toEqual([true, true, false])
|
|
expect([net.isIP('127.0.0.1'), net.isIP('::1'), net.isIP('nope')]).toEqual([4, 6, 0])
|
|
})
|
|
|
|
it('constructs a Socket but refuses to move bytes through it', () => {
|
|
const socket = new net.Socket()
|
|
expect(() => socket.write()).toThrow(/Socket.write is not available/)
|
|
expect(() => socket.end()).toThrow(/Socket.end is not available/)
|
|
// Disposal paths run against sockets that were never connected.
|
|
expect(() => { socket.destroy() }).not.toThrow()
|
|
})
|
|
})
|
|
|
|
describe('node:os', () => {
|
|
it('reports the virtual platform identity and the VFS directories', () => {
|
|
expect([os.EOL, os.tmpdir(), os.homedir()]).toEqual(['\n', DSH_TMP, DSH_HOME])
|
|
expect([os.platform(), os.type(), os.arch()]).toEqual(['linux', 'Linux', 'x64'])
|
|
expect([os.release(), os.hostname()]).toEqual(['0.0.0-dsh-worker', 'dsh-worker'])
|
|
})
|
|
|
|
it('reports no per-core facts and no network interfaces', () => {
|
|
expect(os.cpus()).toEqual([])
|
|
expect(os.networkInterfaces()).toEqual({})
|
|
expect(os.availableParallelism()).toBeGreaterThanOrEqual(1)
|
|
})
|
|
|
|
it('maps the terminal signal names its consumer reads', () => {
|
|
expect(os.constants.signals.SIGTERM).toBe(15)
|
|
expect(os.constants.signals.SIGKILL).toBe(9)
|
|
})
|
|
})
|
|
|
|
describe('node:perf_hooks', () => {
|
|
it("hands over the worker's own clock", () => {
|
|
expect(perfHooks.performance).toBe(globalThis.performance)
|
|
expect(perfHooks.performance.now()).toBeGreaterThan(0)
|
|
})
|
|
})
|