Skip to content

Commit 6586cbd

Browse files
committed
feat: enhance network runtime with internal asset request support and improved Request facade handling
1 parent 6a9a84d commit 6586cbd

11 files changed

Lines changed: 255 additions & 44 deletions

File tree

cmd/wasm-kernel/deliver_test.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import (
1313

1414
"github.com/gosuda/zeroproxy/internal/cookiejar"
1515
"github.com/gosuda/zeroproxy/internal/zphttp"
16+
"golang.org/x/text/encoding/korean"
17+
"golang.org/x/text/transform"
1618
)
1719

1820
func deliverAwait(p js.Value) (js.Value, bool) {
@@ -121,6 +123,15 @@ func deliverResp(status int, hdr map[string]string, setCookie, body string, hasB
121123
return r
122124
}
123125

126+
func eucKRString(t *testing.T, text string) string {
127+
t.Helper()
128+
encoded, _, err := transform.String(korean.EUCKR.NewEncoder(), text)
129+
if err != nil {
130+
t.Fatal(err)
131+
}
132+
return encoded
133+
}
134+
124135
func mustURL(t *testing.T, raw string) *url.URL {
125136
t.Helper()
126137
u, err := url.Parse(raw)
@@ -166,6 +177,12 @@ func TestDeliverResponseOwnershipAndDelivery(t *testing.T) {
166177
t.Fatalf("document body not transformed (membrane injection missing): %q", doc.body)
167178
}
168179

180+
eucKRDoc := eucKRString(t, `<html><head></head><body>뉴스</body></html>`)
181+
decodedDoc := runDeliver(deliverReq(map[string]string{"X-Zp-Document-Request": "1"}, "https://news.naver.com/"), deliverResp(200, map[string]string{"Content-Type": "text/html; charset=euc-kr"}, "", eucKRDoc, true), mustURL(t, "https://news.naver.com/"))
182+
if !strings.Contains(decodedDoc.body, "뉴스") {
183+
t.Fatalf("document transform must decode euc-kr before rewrite, got body %q", decodedDoc.body)
184+
}
185+
169186
// Set-Cookie is captured into the jar; credentials=omit skips capture.
170187
withCookie := runDeliver(deliverReq(nil, plain), deliverResp(200, map[string]string{"Content-Type": "text/plain"}, "sid=abc; Path=/", "c", true), mustURL(t, plain))
171188
if !strings.Contains(withCookie.cookieDoc, "sid=abc") {

cmd/wasm-kernel/main.go

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"encoding/json"
1010
"fmt"
1111
"io"
12+
"mime"
1213
"net/http"
1314
"net/url"
1415
"strings"
@@ -24,6 +25,7 @@ import (
2425
"github.com/gosuda/zeroproxy/internal/wsproto"
2526
"github.com/gosuda/zeroproxy/internal/yamuxconn"
2627
"github.com/gosuda/zeroproxy/internal/zphttp"
28+
"golang.org/x/net/html/charset"
2729
)
2830

2931
type Kernel struct {
@@ -196,9 +198,14 @@ func transformDocumentResponse(req *http.Request, resp *http.Response, tab *zpht
196198
if source == nil {
197199
source = http.NoBody
198200
}
201+
decodedSource, err := charset.NewReader(source, resp.Header.Get("Content-Type"))
202+
if err != nil {
203+
decodedSource = source
204+
}
205+
docCharset := responseCharset(resp.Header.Get("Content-Type"))
199206
pr, pw := io.Pipe()
200207
go func() {
201-
err := htmltx.TransformTo(pw, source, htmltx.Options{
208+
err := htmltx.TransformTo(pw, decodedSource, htmltx.Options{
202209
TabID: tab.TabID,
203210
EntryID: req.Header.Get("X-Zp-Entry-Id"),
204211
TargetURL: finalURL,
@@ -208,6 +215,7 @@ func transformDocumentResponse(req *http.Request, resp *http.Response, tab *zpht
208215
Servers: headerServers(req.Header.Get("X-Zp-Relay-Servers")),
209216
DynamicCompileAllowed: dynamicCompileAllowed,
210217
ReferrerPolicy: referrerPolicy,
218+
DocumentCharset: docCharset,
211219
DocumentRewriter: rewriteHTMLDocumentFromJS,
212220
})
213221
closeErr := source.Close()
@@ -229,6 +237,14 @@ func transformDocumentResponse(req *http.Request, resp *http.Response, tab *zpht
229237
return true, true
230238
}
231239

240+
func responseCharset(contentType string) string {
241+
_, params, err := mime.ParseMediaType(contentType)
242+
if err != nil {
243+
return ""
244+
}
245+
return strings.TrimSpace(params["charset"])
246+
}
247+
232248
// applyResponsePolicy stamps the response-shaping headers after transform: the
233249
// dynamic-compile signal, the ConstructorPolicy strip, and the response-URL /
234250
// redirect markers.

internal/htmltx/transform.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ type Options struct {
2222
Servers []string
2323
DynamicCompileAllowed bool
2424
ReferrerPolicy string
25+
DocumentCharset string
2526
DocumentRewriter func(source, targetURL, controlPrefix, runtimePrelude, tabID, runtimeToken string, servers []string) (string, error)
2627
}
2728

@@ -74,6 +75,7 @@ type bootConfig struct {
7475
Servers []string `json:"servers,omitempty"`
7576
DynamicCompileAllowed bool `json:"dynamicCompileAllowed,omitempty"`
7677
ReferrerPolicy string `json:"referrerPolicy,omitempty"`
78+
DocumentCharset string `json:"documentCharset,omitempty"`
7779
}
7880

7981
func runtimePrelude(opt Options) string {
@@ -87,6 +89,7 @@ func runtimePrelude(opt Options) string {
8789
Servers: opt.Servers,
8890
DynamicCompileAllowed: opt.DynamicCompileAllowed,
8991
ReferrerPolicy: opt.ReferrerPolicy,
92+
DocumentCharset: opt.DocumentCharset,
9093
})
9194
var b strings.Builder
9295
b.Grow(len(bootJSON) + 130)

rewriter-rs/src/js/swc_rewriter.rs

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
use std::collections::HashSet;
22

33
use swc_common::{
4-
sync::Lrc, FileName, Globals, Mark, SourceMap, SyntaxContext, DUMMY_SP, GLOBALS as SWC_GLOBALS,
4+
comments::SingleThreadedComments, sync::Lrc, FileName, Globals, Mark, SourceMap, SyntaxContext,
5+
DUMMY_SP, GLOBALS as SWC_GLOBALS,
56
};
67
use swc_ecma_ast::{
78
op, ArrayLit, AssignOp, AssignTarget, BinaryOp, Callee, EsVersion, Expr, ExprOrSpread, Ident,
@@ -92,6 +93,7 @@ fn rewrite_script_in_globals(
9293
ctx: RewriteContext<'_>,
9394
) -> Result<String, String> {
9495
let cm: Lrc<SourceMap> = Default::default();
96+
let comments = SingleThreadedComments::default();
9597
let fm = cm.new_source_file(
9698
FileName::Custom("zeroproxy-input.js".into()).into(),
9799
source.to_string(),
@@ -104,7 +106,7 @@ fn rewrite_script_in_globals(
104106
}),
105107
EsVersion::latest(),
106108
StringInput::from(&*fm),
107-
None,
109+
Some(&comments),
108110
);
109111
let mut parser = Parser::new_from(lexer);
110112
let mut program = if module {
@@ -134,17 +136,21 @@ fn rewrite_script_in_globals(
134136
window_aliases: HashSet::new(),
135137
document_aliases: HashSet::new(),
136138
});
137-
print_program(cm, &program)
139+
print_program(cm, &program, &comments)
138140
}
139141

140-
fn print_program(cm: Lrc<SourceMap>, program: &Program) -> Result<String, String> {
142+
fn print_program(
143+
cm: Lrc<SourceMap>,
144+
program: &Program,
145+
comments: &SingleThreadedComments,
146+
) -> Result<String, String> {
141147
let mut out = Vec::new();
142148
{
143149
let wr = JsWriter::new(cm.clone(), "\n", &mut out, None);
144150
let mut emitter = Emitter {
145151
cfg: Config::default().with_minify(true),
146152
cm,
147-
comments: None,
153+
comments: Some(comments),
148154
wr,
149155
};
150156
emitter
@@ -858,6 +864,23 @@ mod tests {
858864
assert!(!out.contains("__zp_get(globalThis, \"location\").href"));
859865
}
860866

867+
#[test]
868+
fn preserves_function_body_block_comments_for_to_string_templates() {
869+
let out = rewrite_script(
870+
r#"const html = parseTemplate(function () {
871+
/*!@preserve
872+
<div class="legacy-template">뉴스</div>
873+
*/
874+
return true;
875+
});"#,
876+
false,
877+
ctx(),
878+
)
879+
.expect("swc rewrite should succeed");
880+
assert!(out.contains("/*!@preserve"));
881+
assert!(out.contains("<div class=\"legacy-template\">뉴스</div>"));
882+
}
883+
861884
#[test]
862885
fn reports_parse_failures() {
863886
let err = rewrite_script("if (", false, ctx()).expect_err("parse should fail");

scripts/build.mjs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,7 @@ async function makeRustRewriterClassic() {
243243
`function clearWasmTiming() { try { if (globalThis.performance && typeof globalThis.performance.clearResourceTimings === 'function') globalThis.performance.clearResourceTimings(); } catch {} }`,
244244
`function init() { if (initialized) return Promise.resolve(true); if (!initPromise) initPromise = wasm_bindgen({ module_or_path: wasmSource() }).then(() => { initialized = true; clearWasmTiming(); return true; }).catch(err => { initError = err; initPromise = null; throw err; }); return initPromise; }`,
245245
`function initSync(bytes) { const source = bytes || loadWasmBytesSync(); if (!source) return false; if (!initialized) { wasm_bindgen.initSync({ module: source }); initialized = true; clearWasmTiming(); } return true; }`,
246+
`function bootstrapInit() { try { if (initSync()) return; } catch {} init().catch(() => {}); }`,
246247
`function ensureReady() { if (!initialized) throw initError || new Error('RUST_REWRITER_NOT_READY'); }`,
247248
`function normalizeKind(kind) { kind = String(kind || 'classic').toLowerCase(); if (kind === 'worker') return 'classic'; if (kind === 'event' || kind === 'event-handler') return 'event-handler'; if (kind === 'function') return 'function'; if (kind === 'module') return 'module'; return 'classic'; }`,
248249
`function lowLevel(source, kind, targetUrl, controlPrefix) { return lowLevelWithContext(source, kind, targetUrl, controlPrefix, '', ''); }`,
@@ -285,7 +286,7 @@ async function makeRustRewriterClassic() {
285286
`const rewriterApi = Object.freeze({ VERSION, get ready() { return initialized; }, init, initSync, rewriteScript: rewriteScriptPublic, rewriteScriptURL: rewriteScriptURLPublic, rewriteFetchURL: rewriteFetchURLPublic, rewriteSrcset: rewriteSrcsetPublic, rewriteTargetURL: rewriteTargetURLPublic, classifyLinkRel: classifyLinkRelPublic, classifyBlockedElement: classifyBlockedElementPublic, classifyMetaPolicy: classifyMetaPolicyPublic, classifyAttrPolicy: classifyAttrPolicyPublic, classifyScriptType: classifyScriptTypePublic, classifyEventHandlerAttr: classifyEventHandlerAttrPublic, rewriteCSS: rewriteCSSPublic, rewriteImportMap: rewriteImportMapPublic, rewriteHTMLDocument: rewriteHTMLDocumentPublic, makeShareURL: makeShareURLPublic, rewriteFunctionBody: rewriteFunctionBodyPublic, blockSource() { return BLOCK_CODE; } });`,
286287
`Object.defineProperty(globalThis, 'ZPRustRewriter', { value: rustApi, enumerable: false, configurable: false, writable: false });`,
287288
`Object.defineProperty(globalThis, 'ZPRewriter', { value: rewriterApi, enumerable: false, configurable: false, writable: false });`,
288-
`if (!initSync()) init().catch(() => {});`,
289+
`bootstrapInit();`,
289290
'})();',
290291
'',
291292
].join('\n');

test/js/core.test.js

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ const assert = require('node:assert/strict');
33
const fs = require('node:fs');
44
const vm = require('node:vm');
55
const { webcrypto } = require('node:crypto');
6+
const path = require('node:path');
7+
const { pathToFileURL } = require('node:url');
68

79
function loadCore() {
810
const ctx = {
@@ -54,6 +56,54 @@ test('base64url decoder is raw path-safe only', () => {
5456
assert.throws(() => ZP.base64UrlToBytes('ab+cd'), /INVALID_BASE64URL/);
5557
assert.throws(() => ZP.base64UrlToBytes('a'), /INVALID_BASE64URL/);
5658
});
59+
60+
test('runtime HTTP facade keeps ZeroProxy assets on the proxy origin', async () => {
61+
const { createHTTPFetchFacade } = await import(
62+
pathToFileURL(path.resolve('web/runtime/network/http.mjs')).href
63+
);
64+
const previousZP = globalThis.ZP;
65+
globalThis.ZP = {
66+
canonicalTargetURL: (input, base) => new URL(String(input), base || undefined),
67+
};
68+
let fetched = null;
69+
const Native = {
70+
fetch: (url, init) => {
71+
fetched = { url, init };
72+
return Promise.resolve(new Response('asset'));
73+
},
74+
Request,
75+
Headers,
76+
};
77+
const facade = createHTTPFetchFacade({
78+
root: {},
79+
Native,
80+
boot: { tabId: 't' },
81+
runtimeToken: 'rt',
82+
normalizedError: code => new Error(code),
83+
postMessageToSW: async () => {},
84+
openUploadStream: async () => '',
85+
getActiveEntryId: () => 'e',
86+
getVirtualURL: () => new URL('https://www.naver.com/'),
87+
getBaseURL: () => 'https://www.naver.com/',
88+
getDocumentReferrerPolicy: () => '',
89+
proxyOrigin: 'https://proxy.example',
90+
isInternalRequestURL: raw => new URL(raw).pathname === '/zp/assets/rust-rewriter.wasm',
91+
});
92+
try {
93+
assert.equal(
94+
facade.requestTargetURL('/zp/assets/rust-rewriter.wasm'),
95+
'https://proxy.example/zp/assets/rust-rewriter.wasm',
96+
);
97+
await facade.fetchThroughRuntime('/zp/assets/rust-rewriter.wasm', { cache: 'no-store' });
98+
assert.deepEqual(fetched, {
99+
url: 'https://proxy.example/zp/assets/rust-rewriter.wasm',
100+
init: { cache: 'no-store' },
101+
});
102+
} finally {
103+
if (previousZP === undefined) delete globalThis.ZP;
104+
else globalThis.ZP = previousZP;
105+
}
106+
});
57107
test('relay server fragments normalize, dedupe, and round-trip through share URLs', async () => {
58108
const ZP = loadCore();
59109
const servers = Array.from(

test/js/static-policy.test.js

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ test('runtime installs required escape-vector hooks', () => {
147147
const worker = fs.readFileSync('web/worker-prelude.js', 'utf8');
148148
for (const needle of [
149149
"document.addEventListener('click'",
150+
"root.addEventListener('click'",
150151
"document.addEventListener('submit'",
151152
'HTMLFormElement.prototype',
152153
'popstate',
@@ -227,10 +228,10 @@ test('runtime installs required escape-vector hooks', () => {
227228
"name === 'origin'",
228229
"base === document && prop === 'location'",
229230
'frameOriginForSource(ev.source)',
230-
"!Native.getAttribute.call(frame, 'data-zp-target-url')",
231-
'compatRelativeRequestBase(raw)',
232-
"path === '/api/auth'",
233-
'https://shopsquare.naver.com/',
231+
'shouldContainFrameWindow: isInitialAboutBlankFrame',
232+
'shouldContainFrameWindow && shouldContainFrameWindow(this, childWin)',
233+
'installRequestFacade',
234+
'return new Native.Request(requestLike ? input : requestTargetURL(input), init)',
234235
"Native.setAttribute.call(this, k, '')",
235236
"'WebSocketStream'",
236237
'getUserMedia',
@@ -294,6 +295,42 @@ test('runtime keeps JavaScript rewriting fail-closed and canonicalizes module UR
294295
);
295296
});
296297

298+
test('filtered DOM collections expose numeric indexes to native slice', () => {
299+
const rt = readRuntimeSource();
300+
assert.ok(
301+
rt.includes("has(_target, prop) { return prop === 'length' || (/^(?:0|[1-9]\\d*)$/.test(String(prop)) && Number(prop) < length()); }"),
302+
'filtered collection HasProperty must recognize all numeric indexes',
303+
);
304+
assert.equal(
305+
rt.includes("has(_target, prop) { return prop === 'length' || (/^(?:0|[1-9]\\\\d*)$/.test(String(prop)) && Number(prop) < length()); }"),
306+
false,
307+
'filtered collection HasProperty must not match a literal backslash-d',
308+
);
309+
});
310+
311+
test('classic script rewrite carries document charset for legacy Korean news scripts', () => {
312+
const rt = readRuntimeSource();
313+
const sw = readServiceWorkerSource();
314+
assert.ok(rt.includes("const documentCharset = String(boot.documentCharset || '')"));
315+
assert.ok(rt.includes("params.set('dc', documentCharset)"));
316+
assert.ok(sw.includes("const documentCharset = url.searchParams.get('dc') || ''"));
317+
assert.ok(sw.includes('scriptResponseText(resp, opt.documentCharset ||'));
318+
assert.ok(sw.includes('new TextDecoder(charset).decode(bytes)'));
319+
});
320+
321+
test('runtime HTTP facade resolves relative requests without site-specific host maps', () => {
322+
const http = fs.readFileSync('web/runtime/network/http.mjs', 'utf8');
323+
assert.equal(/naver|pstatic|shopsquare|recoshopping/i.test(http), false);
324+
assert.ok(http.includes('const parsed = new URL(raw, getBaseURL())'));
325+
});
326+
327+
test('Rust rewriter bootstrap falls back to async WASM load if sync bytes fail', () => {
328+
const build = fs.readFileSync('scripts/build.mjs', 'utf8');
329+
assert.ok(build.includes('function bootstrapInit()'));
330+
assert.ok(build.includes('try { if (initSync()) return; } catch {} init().catch(() => {})'));
331+
assert.ok(build.includes('bootstrapInit();'));
332+
});
333+
297334
test('HTML document transform is a thin Go wrapper over Rust lol_html policy', () => {
298335
const htmltx = fs.readFileSync('internal/htmltx/transform.go', 'utf8');
299336
const kernel = fs.readFileSync('cmd/wasm-kernel/main.go', 'utf8');

0 commit comments

Comments
 (0)