Fix Bare dockerode failure: polyfill Node url.resolve
Release rolling / release (push) Has been cancelled

docker-modem calls url.resolve/parse/format, which bare-url does not
implement. Map bare url imports to build/shims/node-url.cjs so the
standalone peardock-server can talk to the Docker socket again.
This commit is contained in:
Raven Scott
2026-07-11 15:34:12 -04:00
parent ff8a96a48a
commit 2e51c49526
4 changed files with 314 additions and 2 deletions
+260
View File
@@ -0,0 +1,260 @@
/**
* Node.js-compatible `url` for Bare.
*
* bare-url is WHATWG-only (no url.resolve / url.parse / url.format).
* docker-modem (via dockerode) requires the legacy Node API:
* url.resolve, url.parse, url.format, url.URL
*
* Mapped from package.json imports under the "bare" condition.
*/
'use strict'
let URLImpl
let URLSearchParamsImpl
try {
const bare = require('bare-url')
URLImpl = bare.URL || bare
URLSearchParamsImpl =
bare.URLSearchParams ||
(typeof globalThis !== 'undefined' && globalThis.URLSearchParams) ||
null
} catch {
URLImpl = globalThis.URL
URLSearchParamsImpl = globalThis.URLSearchParams
}
const URL = URLImpl
function isAbsoluteUrl(s) {
return /^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(s)
}
/**
* Minimal Node url.parse compatible object.
* @param {string} urlStr
* @param {boolean} [parseQueryString]
*/
function parse(urlStr, parseQueryString) {
const input = String(urlStr ?? '')
try {
let u
if (isAbsoluteUrl(input)) {
u = new URL(input)
} else if (input.startsWith('//')) {
u = new URL('http:' + input)
} else {
// Path-only / relative — give a dummy base then re-map
u = new URL(input, 'http://peardock.invalid')
}
const dummy = u.hostname === 'peardock.invalid' && !isAbsoluteUrl(input)
const auth =
u.username || u.password
? decodeURIComponent(u.username) +
(u.password ? ':' + decodeURIComponent(u.password) : '')
: null
const search = u.search || null
let query = null
if (search) {
if (parseQueryString && URLSearchParamsImpl) {
query = Object.fromEntries(new URLSearchParamsImpl(u.searchParams || search))
} else {
query = search.startsWith('?') ? search.slice(1) : search
}
}
const pathname = u.pathname || null
const path = (pathname || '') + (search || '') || null
return {
protocol: dummy ? null : u.protocol || null,
slashes: dummy ? null : true,
auth: dummy ? null : auth,
host: dummy ? null : u.host || null,
port: dummy ? null : u.port || null,
hostname: dummy ? null : u.hostname || null,
hash: u.hash || null,
search,
query,
pathname,
path,
href: dummy ? input : u.href,
}
} catch {
return {
protocol: null,
slashes: null,
auth: null,
host: null,
port: null,
hostname: null,
hash: null,
search: null,
query: null,
pathname: null,
path: null,
href: input,
}
}
}
/**
* Minimal Node url.format.
* @param {string|object} urlObj
*/
function format(urlObj) {
if (urlObj == null) return ''
if (typeof urlObj === 'string') return urlObj
if (typeof URLImpl === 'function' && urlObj instanceof URLImpl) return urlObj.href
let protocol = urlObj.protocol || ''
if (protocol && protocol[protocol.length - 1] !== ':') protocol += ':'
let auth = ''
if (urlObj.auth) {
auth = String(urlObj.auth).replace(/@/g, '') + '@'
}
let host = ''
if (urlObj.host != null && urlObj.host !== '') {
host = String(urlObj.host)
} else if (urlObj.hostname != null && urlObj.hostname !== '') {
host = String(urlObj.hostname)
if (urlObj.port != null && urlObj.port !== '') host += ':' + urlObj.port
}
let pathname = urlObj.pathname != null ? String(urlObj.pathname) : ''
if (pathname && pathname[0] !== '/' && host) pathname = '/' + pathname
let search = ''
if (urlObj.search != null && urlObj.search !== '') {
search = String(urlObj.search)
if (search[0] !== '?') search = '?' + search
} else if (urlObj.query != null && urlObj.query !== '') {
if (typeof urlObj.query === 'string') {
search = urlObj.query[0] === '?' ? urlObj.query : '?' + urlObj.query
} else if (typeof urlObj.query === 'object' && URLSearchParamsImpl) {
const q = new URLSearchParamsImpl(urlObj.query).toString()
if (q) search = '?' + q
}
}
let hash = urlObj.hash != null ? String(urlObj.hash) : ''
if (hash && hash[0] !== '#') hash = '#' + hash
// Match Node quirks roughly: with host → scheme://host/path; without → scheme:/path
if (host) {
const slashes = protocol ? '//' : ''
return protocol + slashes + auth + host + pathname + search + hash
}
if (protocol) {
// Node: url.format({ protocol: 'http:', pathname: '/x' }) => 'http:/x'
return protocol + (pathname.startsWith('/') ? pathname : '/' + pathname) + search + hash
}
return pathname + search + hash
}
/**
* Node url.resolve(from, to)
* @param {string} from
* @param {string} to
*/
function resolve(from, to) {
const base = String(from ?? '')
const rel = String(to ?? '')
if (!rel) {
try {
return isAbsoluteUrl(base) ? new URL(base).href : format(parse(base))
} catch {
return base
}
}
if (isAbsoluteUrl(rel)) {
try {
return new URL(rel).href
} catch {
return rel
}
}
try {
if (isAbsoluteUrl(base)) {
return new URL(rel, base).href
}
// Path-only base (e.g. "/v1.41/" + "containers/json")
const origin = 'http://peardock.invalid'
const b = base.startsWith('/') ? base : '/' + base
const resolved = new URL(rel, origin + b)
return resolved.pathname + resolved.search + resolved.hash
} catch {
if (rel.startsWith('/')) return rel
const slash = base.endsWith('/') ? '' : '/'
return base + slash + rel
}
}
function pathToFileURL(filepath) {
if (typeof URLImpl !== 'function') throw new Error('URL not available')
const path = require('path')
let resolved = path.resolve(String(filepath))
// Windows drive letters
if (/^[a-zA-Z]:[\\/]/.test(resolved)) {
resolved = '/' + resolved.replace(/\\/g, '/')
} else if (!resolved.startsWith('/')) {
resolved = '/' + resolved
}
return new URLImpl('file://' + encodeURI(resolved).replace(/#/g, '%23'))
}
function fileURLToPath(url) {
const u = typeof url === 'string' ? new URLImpl(url) : url
if (u.protocol !== 'file:') {
throw new TypeError('Must be a file URL')
}
let p = decodeURIComponent(u.pathname)
// /C:/... → C:\... on windows-like
if (/^\/[a-zA-Z]:\//.test(p)) {
p = p.slice(1).replace(/\//g, require('path').sep)
}
return p
}
function domainToASCII(domain) {
return String(domain ?? '')
}
function domainToUnicode(domain) {
return String(domain ?? '')
}
function urlToHttpOptions(url) {
const u = typeof url === 'string' ? new URLImpl(url) : url
return {
protocol: u.protocol,
hostname: u.hostname,
hash: u.hash,
search: u.search,
pathname: u.pathname,
path: u.pathname + u.search,
href: u.href,
port: u.port,
auth: u.username ? u.username + (u.password ? ':' + u.password : '') : '',
}
}
module.exports = {
URL,
Url: URL,
URLSearchParams: URLSearchParamsImpl,
parse,
format,
resolve,
pathToFileURL,
fileURLToPath,
domainToASCII,
domainToUnicode,
urlToHttpOptions,
}
+2 -2
View File
@@ -408,11 +408,11 @@
"default": "tty"
},
"url": {
"bare": "bare-url",
"bare": "./build/shims/node-url.cjs",
"default": "url"
},
"node:url": {
"bare": "bare-url",
"bare": "./build/shims/node-url.cjs",
"default": "url"
},
"util": {
+12
View File
@@ -82,6 +82,18 @@ function buildImportsMap() {
default: typeof map[spec] === 'object' && map[spec]?.default ? map[spec].default : spec,
}
}
// bare-pack resolves relative "bare" import targets poorly from nested
// node_modules — absolutize repo-local shims (e.g. ./build/shims/node-url.cjs).
for (const [spec, target] of Object.entries(map)) {
if (!target || typeof target !== 'object') continue
if (typeof target.bare === 'string' && target.bare.startsWith('./')) {
map[spec] = {
...target,
bare: fileURL(target.bare.replace(/^\.\//, '')),
}
}
}
return map
}
+40
View File
@@ -0,0 +1,40 @@
/**
* Ensure Bare Node url shim matches Node for docker-modem call patterns.
*/
import test from 'brittle'
import { createRequire } from 'module'
const require = createRequire(import.meta.url)
const nodeUrl = require('url')
const shim = require('../build/shims/node-url.cjs')
test('url.resolve matches Node for docker-modem patterns', (t) => {
const cases = [
['http://localhost:2375/v1.41/', 'containers/json'],
['http://localhost:2375/v1.41', 'containers/json'],
['http://localhost/v1.41/', '/v1.41/containers/json'],
['/v1.41/', 'containers/json'],
]
for (const [a, b] of cases) {
t.is(shim.resolve(a, b), nodeUrl.resolve(a, b), `${a} + ${b}`)
}
})
test('url.parse provides host/path/protocol', (t) => {
const p = shim.parse('http://localhost:2375/v1.41/containers/json')
t.is(p.protocol, 'http:')
t.is(p.host, 'localhost:2375')
t.is(p.path, '/v1.41/containers/json')
})
test('url.format builds request URLs', (t) => {
t.is(
shim.format({
protocol: 'http:',
hostname: 'localhost',
port: '2375',
pathname: '/v1.41/x',
}),
'http://localhost:2375/v1.41/x'
)
t.ok(typeof shim.URL === 'function')
})