Skip to content

Commit fd7091b

Browse files
authored
Merge pull request #286 from gosuda/feature/serve-file-share-links
feat(tunnel): serve local static sites and detect share links
2 parents f495e8d + 6df8f33 commit fd7091b

15 files changed

Lines changed: 963 additions & 187 deletions

File tree

cmd/portal-tunnel/README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,16 @@ Default HTTPS stream for most local web apps:
3838
portal expose 3000 --name myapp
3939
```
4040

41+
Static site when you want to publish a local folder or a single HTML file
42+
without running a server. Pass a directory (served with `index.html`) or an HTML
43+
file (its folder is served with that file as the SPA/CSR entry). Unknown paths
44+
fall back to the entry file, and paths escaping the folder are refused:
45+
46+
```text
47+
portal expose --serve ./site --name my-app
48+
portal expose --serve ./site/main.html --name my-app
49+
```
50+
4151
Routed HTTP when one public URL should mount multiple local HTTP upstreams:
4252

4353
```text
@@ -100,6 +110,7 @@ Common `portal expose` flags:
100110
--thumbnail Service thumbnail URL metadata
101111
--owner Service owner metadata
102112
--hide Hide service from relay listing screens
113+
--serve Serve a local static site: a directory (served with index.html) or an HTML file (folder served with that file as SPA/CSR entry)
103114
--http-route HTTP route mapping in PATH=UPSTREAM [METHOD[,METHOD...]:USDC_AMOUNT] form
104115
--x402-pay-to Sui USDC payment recipient address for this tunnel
105116
--x402-testnet Use Sui testnet for tunnel x402 payments
@@ -130,6 +141,10 @@ the Settings pane. Edit `http_routes`, `x402_pay_to`, `x402_testnet`, or
130141
## Constraints
131142

132143
- A positional `<target>` cannot be combined with `--http-route`.
144+
- `--serve` cannot be combined with a positional `<target>`, `--http-route`,
145+
`--udp`, or `--tcp`. The `--serve` entry file must exist. Path traversal
146+
(`..`) is refused, but a symlink inside the folder that points outside it is
147+
still followed, so only serve folders you trust.
133148
- `--http-route` cannot be combined with `--udp`.
134149
- Route payment amounts are USDC values such as `0.01`, are part of
135150
`--http-route`, and require `--x402-pay-to`; add `--x402-testnet` for Sui

cmd/portal-tunnel/main.go

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ type exposeFlags struct {
6363
x402Testnet bool
6464
targetAddr string
6565
httpRoutes []string
66+
serve string
6667
udp bool
6768
udpAddr string
6869
tcp bool
@@ -92,6 +93,7 @@ func runExposeCommand(args []string) error {
9293
utils.StringFlag(fs, &flags.x402PayTo, "x402-pay-to", "", "Sui USDC payment recipient address for this tunnel")
9394
utils.BoolFlag(fs, &flags.x402Testnet, "x402-testnet", false, "Use Sui testnet for tunnel x402 payments; default is Sui mainnet")
9495
utils.RepeatedStringFlag(fs, &flags.httpRoutes, "http-route", "HTTP route mapping in PATH=UPSTREAM [METHOD[,METHOD...]:USDC_AMOUNT] form; repeat to aggregate multiple local HTTP services behind one public URL")
96+
utils.StringFlag(fs, &flags.serve, "serve", "", "Serve a local static site: pass a directory (served with index.html) or an HTML file (its folder is served with that file as the SPA/CSR entry). Unknown paths fall back to the entry file")
9597
utils.BoolFlagEnv(fs, &flags.udp, "udp", false, "Enable public UDP relay in addition to the default TCP relay", "UDP_ENABLED")
9698
utils.StringFlagEnv(fs, &flags.udpAddr, "udp-addr", "", "Local UDP target address for relayed datagrams (host:port or port only); defaults to the target when --udp is enabled", "UDP_ADDR")
9799
utils.BoolFlagEnv(fs, &flags.tcp, "tcp", false, "Request a dedicated TCP port on the relay for raw TCP services (no TLS; e.g., Minecraft, game servers)", "TCP_ENABLED")
@@ -113,10 +115,23 @@ func runExposeCommand(args []string) error {
113115
return err
114116
}
115117
httpRouteInputs := append([]string(nil), flags.httpRoutes...)
118+
serve := strings.TrimSpace(flags.serve)
116119
switch {
117-
case flags.targetAddr == "" && len(httpRouteInputs) == 0:
120+
case serve != "" && flags.targetAddr != "":
118121
printExposeUsage(os.Stderr)
119-
return errors.New("target or at least one --http-route is required")
122+
return errors.New("target cannot be combined with --serve")
123+
case serve != "" && len(httpRouteInputs) > 0:
124+
printExposeUsage(os.Stderr)
125+
return errors.New("--serve cannot be combined with --http-route")
126+
case serve != "" && flags.udp:
127+
printExposeUsage(os.Stderr)
128+
return errors.New("--serve cannot be combined with --udp")
129+
case serve != "" && flags.tcp:
130+
printExposeUsage(os.Stderr)
131+
return errors.New("--serve cannot be combined with --tcp")
132+
case serve == "" && flags.targetAddr == "" && len(httpRouteInputs) == 0:
133+
printExposeUsage(os.Stderr)
134+
return errors.New("target, --serve, or at least one --http-route is required")
120135
case flags.targetAddr != "" && len(flags.httpRoutes) > 0:
121136
printExposeUsage(os.Stderr)
122137
return errors.New("target cannot be combined with --http-route")
@@ -125,7 +140,19 @@ func runExposeCommand(args []string) error {
125140
return errors.New("--udp cannot be combined with --http-route")
126141
}
127142

128-
httpRoutes := make([]sdk.HTTPRouteConfig, 0, len(httpRouteInputs))
143+
httpRoutes := make([]sdk.HTTPRouteConfig, 0, len(httpRouteInputs)+1)
144+
if serve != "" {
145+
root, index, err := utils.ResolveStaticSite(serve)
146+
if err != nil {
147+
printExposeUsage(os.Stderr)
148+
return fmt.Errorf("--serve %q: %w", serve, err)
149+
}
150+
httpRoutes = append(httpRoutes, sdk.HTTPRouteConfig{
151+
Prefix: "/",
152+
StaticRoot: root,
153+
StaticIndex: index,
154+
})
155+
}
129156
for _, raw := range httpRouteInputs {
130157
fields := strings.Fields(raw)
131158
if len(fields) == 0 || len(fields) > 2 {
@@ -210,7 +237,7 @@ func runExposeCommand(args []string) error {
210237
if err != nil {
211238
return fmt.Errorf("failed to start relays: %w", err)
212239
}
213-
if len(httpRouteInputs) > 0 {
240+
if len(httpRoutes) > 0 {
214241
defer exposure.Close()
215242
return exposure.RunHTTPRoutes(ctx, httpRoutes, "")
216243
}
@@ -376,11 +403,14 @@ func printExposeUsage(w io.Writer) {
376403
utils.WriteCommandUsage(w,
377404
[]string{
378405
"portal expose [flags] <target>",
406+
"portal expose [flags] --serve <dir|file.html>",
379407
"portal expose [flags] --http-route \"PATH=UPSTREAM [METHOD[,METHOD...]:USDC_AMOUNT]\" [...]",
380408
},
381409
[]string{
382410
"portal expose 3000",
383411
"portal expose localhost:8080 --name my-app",
412+
"portal expose --serve ./site --name my-app",
413+
"portal expose --serve ./site/main.html --name my-app",
384414
"portal expose --http-route /api=http://127.0.0.1:3001 --http-route /=http://127.0.0.1:5173 --name my-app",
385415
"portal expose --http-route \"/paid=http://127.0.0.1:3001 GET:0.01\" --http-route /=http://127.0.0.1:5173 --x402-pay-to 0x...",
386416
"portal expose 3000 --udp --udp-addr 127.0.0.1:5353",

docs/src/lib/components/landing/TunnelCommandForm.svelte

Lines changed: 56 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,15 @@
66
RELAY_ORIGIN,
77
type TunnelCommandOS
88
} from '$lib/tunnel-command';
9-
import { buildDefaultExposeName, resolveExposeName } from '$lib/expose-name';
9+
import { buildDefaultExposeName } from '$lib/expose-name';
10+
import { classifyShareInput, type ShareKind } from '$lib/share-link';
1011
11-
const DEFAULT_HOST = '3000';
12+
const SHARE_KIND_LABEL: Record<ShareKind, string> = { url: 'URL', file: 'File', port: 'Port' };
13+
const SHARE_PLACEHOLDER = 'file:///Users/me/site/index.html or 3000';
1214
13-
let target = $state('3000');
15+
// Empty by default so the field reads as "paste a link here" instead of
16+
// pre-committing the user to a local port.
17+
let target = $state('');
1418
let os: TunnelCommandOS = $state('unix');
1519
let name = $state('');
1620
let nameSeed = $state('');
@@ -20,49 +24,35 @@
2024
nameSeed = crypto.randomUUID();
2125
});
2226
23-
const generatedName = $derived(buildDefaultExposeName(target, nameSeed));
27+
const share = $derived(classifyShareInput(target));
28+
const generatedName = $derived(buildDefaultExposeName(share.seedTarget, nameSeed));
2429
const effectiveName = $derived(name.trim() || generatedName);
2530
26-
const installBlock = $derived.by(() => {
27-
const cmd = buildTunnelDisplayCommand({
31+
const commandLines = $derived.by(() =>
32+
buildTunnelDisplayCommand({
2833
currentOrigin: RELAY_ORIGIN,
29-
target,
34+
target: share.target,
3035
name: effectiveName,
3136
nameSeed,
3237
relayUrls: [RELAY_ORIGIN],
3338
discovery: true,
3439
thumbnailURL: '',
35-
os
36-
});
37-
const lines = cmd.split('\n');
38-
// Install is first line(s), expose is the rest
39-
if (os === 'windows') {
40-
// Windows: first two lines are install
41-
return lines.slice(0, 2).join('\n');
42-
}
43-
return lines[0] ?? '';
44-
});
40+
os,
41+
shareKind: share.kind,
42+
servePath: share.path
43+
}).split('\n')
44+
);
4545
46-
const runBlock = $derived.by(() => {
47-
const cmd = buildTunnelDisplayCommand({
48-
currentOrigin: RELAY_ORIGIN,
49-
target,
50-
name: effectiveName,
51-
nameSeed,
52-
relayUrls: [RELAY_ORIGIN],
53-
discovery: true,
54-
thumbnailURL: '',
55-
os
56-
});
57-
const lines = cmd.split('\n');
58-
if (os === 'windows') {
59-
return lines.slice(2).join('\n');
60-
}
61-
return lines.slice(1).join('\n');
62-
});
46+
// Install is the first line(s); the expose command is the rest.
47+
const installBlock = $derived(
48+
os === 'windows' ? commandLines.slice(0, 2).join('\n') : (commandLines[0] ?? '')
49+
);
50+
const runBlock = $derived(
51+
os === 'windows' ? commandLines.slice(2).join('\n') : commandLines.slice(1).join('\n')
52+
);
6353
6454
const previewURL = $derived(
65-
buildTunnelPreviewURL(RELAY_ORIGIN, effectiveName, target, nameSeed)
55+
buildTunnelPreviewURL(RELAY_ORIGIN, effectiveName, share.seedTarget, nameSeed)
6656
);
6757
6858
function handleCopy() {
@@ -123,16 +113,23 @@
123113
</div>
124114

125115
<div class="space-y-5">
126-
<!-- 1. Start your local app -->
116+
<!-- 1. Paste what you want to share -->
127117
<div class="space-y-2">
128118
<div class="space-y-1.5">
129119
<p class="text-[13px] font-semibold tracking-[0.04em] text-slate-100 sm:text-sm">
130-
1. Start your local app
131-
<span class="ml-1 normal-case tracking-normal text-slate-400">
132-
(e.g.
133-
<span class="mx-1 font-mono text-slate-200">localhost:3000</span>
134-
)
135-
</span>
120+
1. Paste what you want to share
121+
</p>
122+
<p class="text-[12px] leading-5 text-slate-400">
123+
A local port such as
124+
<span class="mx-1 font-mono text-slate-200">3000</span>, a running URL
125+
such as
126+
<span class="mx-1 font-mono text-slate-200">http://localhost:3000</span>,
127+
or a file path such as
128+
<span class="mx-1 font-mono text-slate-200"
129+
>file:///Users/me/site/index.html</span
130+
>
131+
— file paths are served as a static site straight from that folder, so
132+
nothing needs to be running locally.
136133
</p>
137134
</div>
138135
</div>
@@ -174,23 +171,32 @@
174171
</div>
175172
</div>
176173

177-
<!-- Port + Name controls -->
178-
<div class="flex flex-wrap items-center gap-x-4 gap-y-2 sm:flex-nowrap">
179-
<div class="flex shrink-0 items-center gap-2">
174+
<!-- Share + Name controls -->
175+
<div class="space-y-2">
176+
<div class="flex min-w-0 items-center gap-2">
180177
<span
181178
class="shrink-0 text-[9px] font-semibold uppercase tracking-[0.16em] text-slate-500"
182179
>
183-
Port
180+
Share
184181
</span>
185182
<input
186183
type="text"
187184
bind:value={target}
188-
placeholder={DEFAULT_HOST}
189-
aria-label="Local port or address"
190-
class="h-auto w-[76px] border-0 bg-transparent px-0 py-0 font-mono text-[13px] text-slate-200 shadow-none outline-none placeholder:text-slate-600"
185+
placeholder={SHARE_PLACEHOLDER}
186+
aria-label="Link, file path, or local port to share"
187+
class="h-auto min-w-0 flex-1 border-0 bg-transparent px-0 py-0 font-mono text-[13px] text-slate-200 shadow-none outline-none placeholder:text-slate-600"
191188
/>
189+
{#if target.trim() !== ''}
190+
<span
191+
class="shrink-0 rounded px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-[0.12em] text-slate-400"
192+
style="background: rgba(255,255,255,0.06);"
193+
title="Detected share type"
194+
>
195+
{SHARE_KIND_LABEL[share.kind]}
196+
</span>
197+
{/if}
192198
</div>
193-
<div class="ml-auto flex min-w-0 items-center justify-end gap-2 sm:w-88">
199+
<div class="flex min-w-0 items-center gap-2">
194200
<span
195201
class="shrink-0 text-[9px] font-semibold uppercase tracking-[0.16em] text-slate-500"
196202
>

docs/src/lib/share-link.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/**
2+
* Classifies what a user pasted into the quick-start form so the generated
3+
* command matches their intent:
4+
*
5+
* - `url` — an `http(s)://` link: expose the host/port it points at.
6+
* - `file` — a `file://` URL, a Windows path, or an absolute POSIX path: serve
7+
* the local static site with `portal expose --serve <path>`.
8+
* - `port` — a bare port, `host:port`, or anything else: current behavior.
9+
*/
10+
export type ShareKind = 'url' | 'file' | 'port';
11+
12+
export interface ShareInput {
13+
kind: ShareKind;
14+
/** Positional `portal expose <target>` value for `url` / `port` kinds. */
15+
target: string;
16+
/** Local filesystem path passed to `--serve` for the `file` kind. */
17+
path: string;
18+
/** Stable string used to seed auto-generated names. */
19+
seedTarget: string;
20+
}
21+
22+
const WINDOWS_PATH = /^[A-Za-z]:[\\/]/;
23+
const UNC_PATH = /^\\\\/;
24+
25+
export function classifyShareInput(raw: string): ShareInput {
26+
const trimmed = raw.trim();
27+
if (trimmed === '') {
28+
return { kind: 'port', target: '', path: '', seedTarget: '' };
29+
}
30+
31+
if (/^https?:\/\//i.test(trimmed)) {
32+
const target = urlToExposeTarget(trimmed);
33+
return { kind: 'url', target, path: '', seedTarget: target || trimmed };
34+
}
35+
36+
if (/^file:\/\//i.test(trimmed)) {
37+
const path = fileURLToPath(trimmed);
38+
return { kind: 'file', target: '', path, seedTarget: path || trimmed };
39+
}
40+
41+
if (WINDOWS_PATH.test(trimmed) || UNC_PATH.test(trimmed) || trimmed.startsWith('/')) {
42+
return { kind: 'file', target: '', path: trimmed, seedTarget: trimmed };
43+
}
44+
45+
return { kind: 'port', target: trimmed, path: '', seedTarget: trimmed };
46+
}
47+
48+
function urlToExposeTarget(raw: string): string {
49+
try {
50+
const parsed = new URL(raw);
51+
if (parsed.hostname === '') return '';
52+
return parsed.port ? `${parsed.hostname}:${parsed.port}` : parsed.hostname;
53+
} catch {
54+
return '';
55+
}
56+
}
57+
58+
function fileURLToPath(raw: string): string {
59+
try {
60+
const parsed = new URL(raw);
61+
let path = decodeURIComponent(parsed.pathname);
62+
// file:///C:/dir/main.html → /C:/dir/main.html → C:/dir/main.html
63+
if (/^\/[A-Za-z]:[\\/]/.test(path)) {
64+
path = path.slice(1);
65+
}
66+
return path;
67+
} catch {
68+
return raw.replace(/^file:\/\//i, '');
69+
}
70+
}

0 commit comments

Comments
 (0)