JavaScript

This page covers script resources and JavaScript runtime features in Doors.

If generic resource syntax is new, start with Resources. This page focuses on what is specific to <script> and modules.

It also covers the Go to JavaScript bridge: doors.AData, doors.AHook[...], doors.ARawHook, $data(...), $hook(...), $fetch(...), and $on(...).

Start

Most pages start with one of these:

  • page-local code written directly in the template: plain inline <script>...</script>
  • page-local code kept in a file or bytes: <script src=(doors.ResourceLocalFS("web/app.ts")) inline></script>
  • shorthand bytes: <script src=(appJS)></script>
  • already-hosted URL: <script src="/assets/app.js"></script>

Example:

//go:embed web/picker.ts
var pickerTS []byte
<>
	~>(doors.AHook[string]{
		Name: "save",
		On: func(ctx context.Context, r doors.RequestHook[string]) (any, bool) {
			_ = r.Data()
			return nil, true
		},
	}) <script
		data:userId=(userID)
		src=(pickerTS)
		type="text/typescript"
		inline></script>
</>

Inside web/picker.ts, that script can use $data("userId") and await $hook("save", value).

Scripts

Mental Model

There are three common script shapes in Doors:

  • browser script: the browser just loads and runs it
  • managed script: Doors wraps it and gives it runtime helpers like $data, $hook, $on, and $sys
  • module script: the browser treats it as an ES module

Use a managed script when the code is page-local and should participate in the Doors runtime.

Use a module when the code should be imported, exported, or wired through the import map.

Use a raw or plain browser script when you want normal browser behavior with no Doors runtime wrapper.

Types

Use:

  • no type for regular JavaScript
  • type="module" for ES modules
  • type="typescript" for TypeScript
  • type="module/typescript" for module TypeScript

Output

Script output is controlled with boolean attrs:

  • omitted: normal built or prepared script resource
  • inline: treat a buildable src script like a managed script
  • bundle: bundle dependencies into one output
  • raw: skip script transformation and serve the source as-is

In practice:

  • omitted is the normal choice for linked scripts
  • inline is for code that should behave like page-local managed script code
  • bundle is for modules with dependencies
  • raw is for exact browser behavior

For debugging built scripts, disable minification in app configuration:

app := doors.NewApp(page,
	doors.ESProfile{Minify: false},
)

That keeps Doors transformations active while making the generated JavaScript easier to read in browser devtools.

Managed Scripts

A script is managed when it is:

  • a plain inline JavaScript <script>...</script> block with no src, no raw, and no non-JavaScript type
  • or a buildable src script with inline

Managed scripts get:

  • helper variables like $data, $hook, $fetch, $on, and $sys
  • top-level await
  • subtree cleanup with $sys.clean(...)

If you want the browser to handle the script normally, use raw or a direct browser-usable URL.

Inline

Use inline script when the code belongs only to this page:

<script>
	await $sys.ready()
	console.log("managed inline script")
</script>

By default this is a managed script resource. Doors builds the body, turns it into a src-backed resource, and runs it through the runtime wrapper.

Use inline when the code lives in a file or bytes but should behave the same way:

<script
	src=(doors.ResourceLocalFS("web/picker.ts"))
	type="text/typescript"
	inline></script>

Inline rules:

  • plain inline JavaScript is managed by default
  • actual inline TypeScript bodies are not supported
  • inline module bodies are not supported
  • inline scripts cannot be bundled
  • raw leaves the original tag alone

Linked src

Use a regular linked script when the code should not be loaded as a module:

<script
	src=(doors.ResourceLocalFS("web/app.ts"))
	type="text/typescript"></script>

Common src shapes are:

  • buildable app content: ResourceLocalFS, ResourceFS, ResourceBytes, ResourceString
  • already-hosted local URL: plain string such as "/assets/app.js"
  • external URL: doors.ResourceExternal("https://cdn.example.com/app.js") for a direct browser URL that also participates in CSP source collection
  • handler-backed resource: doors.ResourceHook(...), doors.ResourceHandler(...)
  • proxy-backed resource: doors.ResourceProxy(...)

Buildable src scripts go through the JS pipeline unless raw is used. Plain strings are just direct URLs.

Raw TypeScript is not supported. If the source is TypeScript, let Doors build it.

For managed TypeScript, editor tooling is nicer if you add ambient declarations for helpers like $data, $hook, $fetch, $on, and $sys. TSserver may still warn about top-level await; that is expected for managed inline script bodies and inline scripts.

Modules

Use modules when you want import, export, or import-map based loading.

<script
	src=(doors.ResourceLocalFS("web/app.ts"))
	type="module"
	bundle></script>

Use bundle when the module has dependencies that should be bundled into one output.

Use specifier when the module should be registered in the page import map:

<div id="app"></div>

<script
	src=(doors.ResourceLocalFS("web/react/index.tsx"))
	type="module"
	bundle
	specifier="app"></script>

<script>
	const { mount } = await import("app")
	mount(document.getElementById("app"))
</script>

For module scripts, specifier is usually the main way to wire modules together. If a module is not fully standalone, register it with a specifier and import it by that name.

On a regular <script> tag, specifier does not replace module typing. Use type="module" together with specifier.

Render modules with a specifier as part of the page, for example in the head: the import map is sent once, with the page response, so a module first rendered by a later update does not register its specifier. Scripts rendered later can still import what the page registered.

Import Without Execution

If a module should be available in the import map but should not be executed by a <script> tag, use rel="modulepreload" with specifier:

<link
	rel="modulepreload"
	href=(doors.ResourceLocalFS("web/app.ts"))
	specifier="app">

That registers "app" in the import map and preloads the module, but it does not execute it as a page script.

Later, load it explicitly:

<script>
	const app = await import("app")
	app.mount()
</script>

Attrs

These attrs control script resource behavior:

  • output attrs: inline, bundle, raw
  • specifier: register a module in the import map
  • name: readable output file name
  • profile: named esbuild profile
  • private: host through an instance-scoped hook URL while still using the resource pipeline
  • nocache: host through an instance-scoped hook URL without shared resource caching
  • portable: give the hook URL access of its own so it loads without the session cookie, as private unless nocache is set; not combinable with cache, see Resources

Example:

<script
	src=(doors.ResourceLocalFS("web/react/index.tsx"))
	type="module"
	bundle
	name="react_app.js"
	profile="react"
	private
	specifier="react_app"></script>

Plain string URLs are passed through as-is. doors.ResourceExternal(...) keeps the browser URL direct and adds that URL to the page CSP when the script is rendered with the page; see CSP. Handler and proxy sources already produce hook-backed URLs.

Use private when the script should not be publicly reachable.

Use nocache for dynamically generated script output that should not use shared resource caching.

Build configuration itself is covered in Configuration.

Go Bridge

Data Binding

Use doors.AData or data:name=(...) when the script needs values from Go at render time:

<script
	data:userId=(userID)
	data:theme=(theme)>
	const userId = $data("userId")
	const theme = $data("theme")
	console.log(userId, theme)
</script>

$data(...) returns the decoded value directly for string and JSON-backed values. For []byte, it returns a promise that resolves to an ArrayBuffer, so binary data still needs await.

GoX shorthand such as data:userId=(userID) is equivalent to attaching doors.AData{Name: "userId", Value: userID}.

Attach several AData attrs or several data:name=(...) entries when the script needs more than one server-provided value.

If the value is already known at render time, prefer AData over an extra hook call.

Hooks

Use hooks when JavaScript is already in control and needs to call back into Go. For normal clicks, inputs, and forms, stay with Events.

There are two independent choices:

  • on the Go side: AHook[T] or ARawHook
  • on the JavaScript side: $hook(...) or $fetch(...)

Use:

  • AHook[T] for typed JSON input and output
  • ARawHook for raw request or response control
  • $hook(...) when the script wants the helper-style result
  • $fetch(...) when the script wants the raw Response

These are not a 1:1 pair. For example, $hook(...) can call ARawHook.

Both hook attrs also take optional Scope, Indicator, RequestTimeout, and Race fields. RequestTimeout overrides the RequestTimeout from Configuration for calls to that hook.

For AHook[T], the handler receives doors.RequestHook[T], so it can:

  • read r.Data()
  • read r.Context() for the underlying HTTP request context
  • read or set cookies
  • schedule r.After(...) actions

Return false to keep the hook active.

Return true to remove it after the call.

By default, calls to the same hook run one at a time on the backend, in order of arrival. Race changes that; see Race.

<script
	(doors.AHook[string]{
		Name: "visibility",
		On: func(ctx context.Context, r doors.RequestHook[string]) (any, bool) {
			println(r.Data())
			return nil, false
		},
	})>
	document.addEventListener("visibilitychange", async () => {
		await $hook("visibility", document.visibilityState)
	})
</script>

The handler's ctx parameter is the Doors runtime context. Use it with Doors APIs. r.Context() is the underlying HTTP request context.

A handler can keep the call open past its own return with doors.HoldSettle: the client keeps the $hook(...) promise, the scope, and the indication pending until the hold is released. See Door.

When code outside managed scripts needs a URL, such as a React island API, EventSource, or WebSocket, see Custom Endpoints.

Raw Variant

Use doors.ARawHook when the Go side needs lower-level transport control.

That is the right fit for:

  • raw request body access
  • multipart handling
  • uploads
  • custom decoding
  • custom response bodies or headers

The handler receives doors.RequestRawHook, which adds:

  • r.Body() for raw request bodies
  • r.Reader() or r.ParseForm(...) for multipart forms
  • r.ResponseWriter() when you want to write the response yourself

Race

AHook[T] and ARawHook take the same Race field as event attrs; see Events for the modes.

Set Race: doors.RaceParallel when calls to the same hook must overlap instead of waiting for each other.

  • calls run concurrently, so the handler must synchronize any shared state itself
  • returning true stops new calls; the hook is removed once the calls already running finish
  • returning false keeps the hook active, as usual

That is the right fit for streaming responses held open for a long time, and for independent requests fired in parallel from one script.

<script
	(doors.ARawHook{
		Name: "stream",
		Race: doors.RaceParallel,
		On: func(ctx context.Context, r doors.RequestRawHook) bool {
			w := r.ResponseWriter()
			w.Header().Set("Content-Type", "text/event-stream")
			// write until ctx is done
			return false
		},
	})>
	const a = $fetch("stream")
	const b = $fetch("stream")
</script>

With Race: doors.RaceStrict, a call that arrives out of order is not run, and its $hook(...) or $fetch(...) rejects with a canceled HookErr.

When $hook(...) or $fetch(...) sends data, the client chooses the request body shape automatically:

  • undefined: no body
  • FormData: multipart/form-data
  • URLSearchParams: application/x-www-form-urlencoded
  • Blob: raw blob body
  • File: raw file body
  • ReadableStream: application/octet-stream
  • any other value: JSON

Client API

The main client-side helpers are:

  • $data<T = any>(name: string): T | Promise<ArrayBuffer>
  • $hook(name: string, arg?: any): Promise<any>
  • $fetch(name: string, arg?: any): Promise<Response>
  • $on(name: string, handler: (arg: any, err?: HookErr) => any): void
  • $sys.ready(): Promise<void>
  • $sys.clean(fn: () => void | Promise<void>): void
  • $sys.activateLinks(): void
  • $sys.emit(target: EventTarget, event: Event): Promise<number>
  • HookErr

Manual $hook(...) and $fetch(...) calls throw HookErr. Catch it when failure is part of the normal flow.

Emit and cleanup

Use $on(name, handler) when Go should call JavaScript through doors.ActionEmit:

<script>
	$on("alert", (message, err) => {
		if (err) {
			console.log(err.kind)
			return
		}
		window.alert(message)
	})
</script>

The nearest matching handler wins in the Door tree. $on(...) handlers may return a Promise.

A managed script is loaded from its own URL and runs once the browser has fetched it, which is not ordered relative to Doors calls. An ActionEmit issued by the same render that inserts the script can arrive before the script has registered its $on handler, and then fails with handler … not found. When an emit must reach a handler from freshly rendered script, call a $hook from the script after registering its handlers, and issue the emit from that hook's handler.

Use $sys.clean(...) for timers, global listeners, and embedded widgets that need teardown:

<script>
	const onResize = () => {
		console.log(window.innerWidth)
	}

	window.addEventListener("resize", onResize)

	$sys.clean(() => {
		window.removeEventListener("resize", onResize)
	})
</script>

Dispatch events and wait for hooks

$sys.emit(target, event) is the client-side counterpart of doors.Emitter: it dispatches a DOM event and resolves only after every hook request the event triggered has finished — including HoldSettle and the hook's After actions. The resolved value is the number of hook requests triggered: an element with no matching event attr adds nothing, a bubbling event adds one per event attr it reaches.

<button id="save" (doors.AClick{On: onSave})>Save</button>

<script>
	const save = document.getElementById("save")
	const n = await $sys.emit(save, new PointerEvent("click", { bubbles: true }))
	console.log("handlers ran:", n)
</script>

You build the event, so any constructor and init works — PointerEvent, KeyboardEvent, InputEvent, CustomEvent. Set bubbles: true when ancestor event attrs should run. Any failed hook request rejects the promise with HookErr; a hook canceled by a scope or by RaceStrict, or filtered out by Key, counts as a failure as well. It is safe to call at the top level of a script — the runtime waits for readiness before dispatching.